Updates Microsoft Purview Compliance Manager improvement actions from Paramify.
Compliance Manager has no write API for improvement actions. The only supported way to change them in bulk is to export the workbook, edit it, and upload it back. This tool does the edit. A person does the export and the upload.
Note
The tool never connects to Microsoft 365, Entra or Purview. It reads Paramify only. The workbook is the one thing that moves between the two systems, and you move it by hand.
Status. Used in production against real CMMC Level 2 programs, and maintained. It is deliberately semi-automated: the export and the re-upload are manual because Compliance Manager offers no API for either. Issues and pull requests are welcome.
- What this tool does
- What you need before you start
- One-time setup
- Where to keep the workbooks
- Running it
- Options you may want to change
- If something goes wrong
- What this tool will never do
Reference: Notes on the Paramify API · Development · Contributing · Licence
| Step | Who does it |
|---|---|
| Export the Action Update workbook from Purview | You |
| Read Paramify and fill in three columns | The tool |
| Review what changed | You |
| Upload the workbook back into Purview | You |
It fills in exactly three columns, and leaves everything else in the workbook untouched:
| Purview column | Where the value comes from |
|---|---|
Implementation Status |
Paramify audit log — the last status change |
Implementation Date |
Paramify audit log — that same change's timestamp |
Implementation Notes |
The Solution Capability narrative, as plain text |
Each improvement action is matched to the Solution Capability with exactly the same name. Nothing else is used — not row order, not the Action Id, not the count.
The two systems do not need to hold the same number of entries. A Paramify program covers the improvement actions it covers; the rest of the assessment simply has no capability, and those rows are left exactly as they were. Every row without a match, and every capability without a row, is counted in the run report so you can see the coverage.
- A Paramify API token for the workspace holding this program.
- Read-only:
View Solution Capabilities,View Evidences,View Audit Logs - To also write the workbook to a Paramify Evidence Set: add
Write Evidences
- Read-only:
- Permission to export from Compliance Manager and to use Update actions to upload.
- Python 3.10 or newer and git on the machine you will run this from.
- Outbound HTTPS access to Paramify from that machine.
Note
The token is read from the environment, or from a .env file in the working
directory. It is never written to disk, never logged, and never appears in the
run report. .env is gitignored here — keep it that way, and prefer a
short-lived token scoped to a single workspace.
You only do this once. Afterwards, skip to section 5 each time.
Put it somewhere permanent.
git clone https://github.com/paramify/purview-action-update.git
cd purview-action-updateKeeps everything self-contained and off your system Python.
python3 -m venv .venv
source .venv/bin/activate # Windows: .venv\Scripts\activate
pip install -e .That installs the purview-action-update command used below.
This lasts for the current terminal window only. Repeat it in a new window, or see the tip at the end of section 4.
export PARAMIFY_API_TOKEN='your-token-here'Only if you are not on the production instance, also set the base URL. A
valid token against the wrong instance fails as a 401 that looks exactly like
a bad token:
export PARAMIFY_API_BASE_URL='https://your-instance/api/v0'Do this first every session. It makes one small read and changes nothing.
purview-action-update --check-authTip
What you should see: "authenticated": true, the base URL you expected,
and a solution_capabilities_visible count.
That count does not need to equal the number of improvement actions. Matching is done by name, not by number — see section 1. Only a count of 0, or one obviously wrong for this program, suggests the token is pointing at a different Paramify workspace.
Set this up once. The folder can live anywhere you like — your home folder, Desktop, a shared drive. Tell the tool where it is and it handles the rest, so the commands in section 5 need no file paths at all.
export PURVIEW_WRITEBACK_DIR=~/purview-writebackChange that path to wherever you want it. The tool creates the two folders inside it on first run:
purview-writeback/
inbox/
ExportActions.xlsx <- the newest export, always here
runs/
run-2026-10-02T14-02-11Z/
ExportActions.updated.xlsx <- upload this one to Purview
run_report.json
run-2026-10-05T09-44-03Z/
...
| Folder | What goes in it |
|---|---|
inbox/ |
The newest export from Purview, and nothing else. Each time you export, save it here as ExportActions.xlsx, replacing the previous one. You only ever keep one file here |
runs/ |
The tool's output. It creates a new dated folder every run, so nothing is overwritten and you can always see what was uploaded and when. Leave these alone |
Caution
The one rule. Files move in one direction only: from Purview into
inbox/, and from runs/ back into Purview. Never copy a file from runs/
into inbox/ — that is a snapshot of Purview from an earlier day, and
re-using it would undo anything changed since. The tool stamps its own output
and refuses to accept it as input.
Tip
Make it permanent. To avoid retyping the token and this path in every new
terminal window, add both export lines to the end of your shell profile
(~/.zshrc on a Mac), then open a new window.
Repeat these steps whenever you want to refresh Purview.
Go to Compliance Manager, then the Assessment of your choice, and export. It must be the Assessments page — the Improvement actions page produces a shorter file that is missing the columns this tool writes. If you use the wrong one, the tool stops and tells you.
Save the downloaded file into the inbox/ folder from section 4, named
ExportActions.xlsx, replacing whatever is already there.
Important
Always export a new workbook. Never re-use the file a previous run produced. That file is a snapshot of Purview from that moment, so anything changed since is missing from it, and uploading it would undo those changes.
This reads Paramify and shows what it would do. It writes no file.
purview-action-update --dry-run --print-changes-briefThe same command without --dry-run. Your original export is never modified; a
copy is written to a new dated folder.
purview-action-update --print-changes-briefTip
What you should see: the last line reads verification PASS — the workbook satisfies Purview's rules, and the line above it tells you where the file was
written. If it says FAIL, do not upload it — open an issue with the run
report attached.
Every cell the tool chose not to write is listed with the reason why. This is the part worth reading.
open "$PURVIEW_WRITEBACK_DIR"/runs/run-*/run_report.jsonCompliance Manager, then Assessments, then Update actions. Choose the
ExportActions.updated.xlsx file from the newest folder in runs/ — step 3
printed its exact path.
Attaches the workbook and its run report to an Evidence Set, created once and
reused on later runs. Needs Write Evidences on the token.
purview-action-update --uploadTo confirm afterwards that an upload landed:
purview-action-update --list-artifactsThe defaults are the cautious ones: fill in blanks, change nothing that already has a value, and refuse anything unclear. Decide these once for your program, then keep using the same flags.
| Flag | What it does |
|---|---|
--mode fill-empty (default) |
Writes only into empty cells. Nothing already there is touched |
--mode sync |
Also replaces values that disagree with Paramify |
| Flag | What it does |
|---|---|
--notes-policy follow-mode (default) |
Notes follow --mode |
--notes-policy append |
Keeps the existing note and adds the Paramify narrative underneath. Safe to re-run — it will not stack duplicates |
--notes-policy replace |
The Paramify narrative replaces the note entirely |
Purview requires Test Date to be on or after Implementation Date.
| Flag | What it does |
|---|---|
--on-date-conflict skip (default) |
Leaves the row alone and reports it |
--on-date-conflict advance-test |
Moves Test Date to match. The recorded result stands, but its date no longer reflects when the test ran |
--on-date-conflict clear-test |
Clears Test Date and Test Status — truthful, since the control needs retesting, but it discards a recorded pass |
Only the calendar date has to be right, so dates render in the operator's own
timezone, not UTC. An audit event at 2026-10-02T02:00Z is still 1 October
everywhere in the US; rendered in UTC it would be written as the 2nd.
Resolution order: --timezone → TZ → the system zone from /etc/localtime →
the OS's current UTC offset → UTC. The run report records which was used.
Full flag reference — paths, diagnostics, upload and cleanup
| Flag | Effect |
|---|---|
--writeback-dir PATH |
The working area. Also PURVIEW_WRITEBACK_DIR |
--workbook / --out |
Override either path individually |
--no-run-dir |
Write into --out directly instead of a dated subfolder |
--dry-run |
Plan and report, write nothing |
--print-changes / --print-changes-brief |
List written cells; brief omits the long narratives |
--check-auth |
Validate the token; reports the auth scheme and base URL |
--base-url |
Override PARAMIFY_API_BASE_URL |
--probe-audit / --probe-values |
Dump the audit-log shape. Keys and counts; values only with the second flag, and only short ones |
--audit-start YYYY-MM-DD |
Lower bound on the audit scan |
--activity-types |
Audit activity types to scan (default HISTORY) |
--status-fallback-solcap |
Where no audit activity exists, use the capability's current status (no date derivable) |
--strict-test-status |
Refuse rows with a blank Test Status where the status does not permit None |
--upload |
Attach the workbook and run report to an Evidence Set |
--evidence-reference-id / --evidence-name / --evidence-id |
Target a different Evidence Set |
--list-artifacts [EVIDENCE] |
Show what is attached, to confirm an upload landed |
--allow-no-changes |
Upload even when nothing changed |
--min-match-rate |
Refuse to upload below this name-match fraction. Off by default |
--accept-near-matches |
Join names matching only after case/whitespace normalization |
--allow-chained-input |
Accept a workbook this tool produced (refused by default) |
--prune-no-op-artifacts / --delete-evidence-set |
Cleanup; both need --confirm |
| Line | What it tells you |
|---|---|
exact name matches |
How many rows found their capability |
cells written |
What actually changed, by column |
cells held back |
Proposed but declined — reasons in the report |
audit activity found |
Which capabilities had a status change the tool could read |
evidence set |
Whether anything reached Paramify |
verification |
PASS means the file satisfies Purview's rules. FAIL means do not upload it |
Lines beginning ! are advisories: things that were written but deserve a
look. They are not errors.
| Message | What it means |
|---|---|
| Rejected the token under both auth schemes | Wrong or expired token, or the right token for a different Paramify instance |
| Missing required column | The workbook came from the Improvement actions page. Export again from Assessments |
| Refusing to run: produced by this tool | You passed a previous run's output. Export a fresh one from Purview |
| NOTHING matched by name | No action name matched any capability name. The token is almost certainly pointing at a different workspace |
| Refusing to upload: the run changed no cells | Nothing needed changing. Everything already matches |
| Refusing to upload: only n rows matched | Only appears if you set --min-match-rate yourself. A partial match is normal and does not stop anything by default |
verification FAIL |
Do not upload the file |
Note
run_report.json, saved next to the workbook, records every decision and
every reason. It contains no passwords or tokens, and it is the single most
useful thing to attach to an issue.
These are built into the code, not left to care. Every refusal is recorded with its reason.
- Connect to your Microsoft tenant. It holds no Microsoft credentials.
- Change your original exported file. It always writes a copy.
- Write to any column other than the three in section 1 — five, if you opt into a date-conflict resolution.
- Empty a cell because Paramify had nothing to say.
- Write a status for
PARTIALLY_IMPLEMENTEDorNOT_SET— neither has an honest Purview equivalent. - Write a status that invalidates the row's Test Status, or a date later than it, unless you ask for a resolution.
- Produce a workbook Purview would reject — it re-reads the finished file
and checks it against Purview's own rules, and
--uploadrefuses a failing one. - Accept its own output as input, so a stale snapshot cannot be re-uploaded by accident.
- Guess. An unclear status, an unreadable date or a name that only nearly matches is reported, never resolved quietly.
Two things are not in the published OpenAPI document, both established by probe:
GET /audit-logsis served but undocumented. A nonexistent path returns 404 even unauthenticated while this returns 401, so routing precedes auth and the route exists. A 404 is still handled as degraded rather than fatal.- Its change entries carry only
old/new, with no field name. A status change is therefore identified by both sides being members of Paramify's status vocabulary — requiring the pair, so a field edited to the literal textIMPLEMENTEDis not misread as a transition.
A freshly copied workspace has no modification history at all — copying
recreates every record, leaving the status history in the source.
--probe-audit diagnoses that case by name.
There are also two auth schemes (Authorization: Bearer, and a legacy header
literally named Bearer). The client tries the current one, falls back, and pins
whichever authenticates.
The rules tab constrains Test Status by Implementation Status and does not list
None among the values permitted alongside Implemented. It is silent on
whether a blank cell is allowed, and no export seen so far settles it.
This tool takes the permissive reading — a blank cell is an absence, not a value
— and counts every affected row under advisories.blank_test_status. The first
re-upload confirms it; --strict-test-status excludes them.
pip install -e '.[dev]'
pytest -q150 tests, fully offline against synthetic fixtures. No tenant data in the
repo, and none should ever be added. The end-to-end tests need a real Purview
export and skip without one; point PURVIEW_EXPORT at a workbook to run them.
| File | Role |
|---|---|
config.py |
Purview field rules from the workbook's own rules tab; base URL and timezone resolution |
mapping.py |
Pure transforms — status map, date format, notes rendering, conflict checks |
matching.py |
Action Title ↔ SolCap name join, and match quality |
audit.py |
Audit log → implementation status and date, plus the shape probe |
planner.py |
Per-row decisions: what to write, what to refuse, what to flag |
workbook.py |
Read the tab, apply writes, save a copy, stamp provenance |
verify.py |
Re-read the produced file and check it against Purview's rules |
report.py |
run_report.json, the terminal summary, the changes table |
paramify_api.py |
Read-only client plus evidence upload; dual auth scheme |
upload.py |
Evidence Set, artifacts, and gated cleanup |
run.py |
CLI |
The design principle throughout: decide, then apply. The planner produces proposed changes with a written/declined flag and a reason; only then does anything touch the workbook. That is why every refusal can explain itself.
Issues and pull requests welcome. Two things make a change easy to accept:
- A test that fails before the change and passes after. The suite is the specification, particularly for the refusals — most of them exist because a real run got something wrong.
- No tenant data. Fixtures are synthetic. If a bug needs real data to reproduce, describe the shape rather than attaching the file.
GPL-3.0-only. See LICENSE.