This release is a pre-release and may not be stable for production use.
Microsoft 365 Governance as Code
Each badge reports something measured. The Python versions are the ones CI runs the suite against, and the CI badge is the state of
main. None of them is a claim about the governance conclusions this engine produces; those declare their own basis, one rule at a time.
Every governance conclusion must declare what kind of truth it is.
Automation may verify a claim. It may never strengthen it.
Governance checks that show their work. PowerShell collects facts. Python evaluates declarative rules. Every finding says what kind of claim it is, which evidence it came from, and what it does not establish.
Nothing here changes anything in a tenant. There is no write path, no
remediation command, and no --fix-all.
Scope. This project evaluates the Microsoft 365 tenant that exists today. It does not infer the characteristics of an estate it has not observed. There are two modes, and the engine never knows which one produced the evidence it is reading: Live observes Microsoft 365 directly, Assessment evaluates evidence exported by something else and says so on the first line of every report. See docs/SCOPE.md.
Why it exists. We kept writing the same governance findings into documents, and kept watching them lose the one thing that made them useful: whether the finding was a rule Microsoft enforces, a limit it imposes, advice it gives, or our own opinion. This project is that distinction, made executable and made impossible to skip.
Status: 1.0.0b5. 20 rules, a thirteen-mode collector validated
against a live tenant, 9 profiles, 14 commands and 823 tests at 90 per
cent coverage. Beta because the model has stopped moving: the outcomes, the
resolution order, the basis types and the evidence schema are frozen, and
docs/MILESTONE-A.md records what closing SharePoint
end to end actually cost. The rule set is still small, and that is the next
milestone rather than a caveat on this one.
The problem
Most governance tooling produces a green box. A green box has no grammar for the difference between these four sentences:
- the product enforces this;
- the product imposes this boundary, and here is the number;
- Microsoft recommends this, and permits the alternative;
- we think this is a good idea.
Those four produce different conversations, different budgets and different arguments with an auditor. A tool that renders them identically has removed the only information the reader needed.
And a green box has no grammar for the most common outcome of all: we could not read this. Missing evidence is a fact about collection, not a fact about the resource, and the moment it renders as a pass the whole report becomes unreliable in a way nobody can see.
The trust model
Every rule declares a basis. There are five, and the distinction between
them is the point of the project:
| basis | Meaning | Requires |
|---|---|---|
requirement |
The product enforces this | a source |
documented-limit |
A boundary the product imposes, with a number | a source, and the limit stated next to the observed value |
documented-guidance |
Microsoft recommends it; the alternative is permitted | a source |
convention |
Widely held practice, documented by nobody | a rationale |
opinion |
Our position, stated as ours | a rationale |
A source never decides the type. An opinion may cite documentation. A convention may cite documentation. The source explains the claim; it does not change what kind of claim it is.
The engine never classifies. basis is authored by a person and carried
into the report unchanged. The schema verifies that it is present and
justified; it never infers what it should be. That boundary is the whole
difference between automation that verifies and automation that strengthens.
Six outcomes, and unknown is never a pass:
pass · fail · unknown · not-applicable · invalid-evidence · error
error is the only one a rule may not author a message for: it describes the
engine, not the resource.
Full model in docs/TRUST-MODEL.md.
Architecture
collectors ─→ evidence ─→ engine ─→ results ─→ reports
▲ ▲
schemas rules + profile
Evidence flows one way. Nothing downstream writes back.
Validation happens in six layers, ordered by scope, and every constraint has exactly one owner — a constraint enforced in two places will diverge the first time one of them is corrected:
| Layer | Scope | Example |
|---|---|---|
| 1 | the file is a document | duplicate YAML keys |
| 2 | one field | basis is one of five |
| 3 | one file, references followed | required equals what the rule consumes |
| 4 | every file | duplicate ids |
| 5 | the outside world | the source URL resolves |
| 6 | a person | the source still says what the rule claims |
Layer 6 is numbered like the others on purpose. No validator can prove that a rule represents a document correctly, and pretending otherwise would be the exact failure the trust model exists to prevent.
Details in docs/ARCHITECTURE.md and docs/JSON-SCHEMA-PLAN.md.
Install
brew install pipx # macOS. Ubuntu 23.04+/Debian 12+: sudo apt install pipx
pipx ensurepath # then open a new shell
pipx install m365-governance-as-code==1.0.0b5
pipx ensurepath is not optional, and skipping it looks like a broken install:
pipx gives each application a directory of its own, and that directory is not on
your PATH until this puts it there. Without it you install successfully and then
get command not found: m365-governance.
pipx and not pip, and it is not a preference either. This is a command-line
application rather than a library you import, and a modern Python refuses to
install one into the system environment:
error: externally-managed-environment
That is PEP 668, enforced by Homebrew's
Python, Debian's and Ubuntu's — including for installing pipx itself, which is
why the distribution package is the way in rather than pip install --user.
If you would rather manage the environment yourself:
python3 -m venv .venv
./.venv/bin/pip install m365-governance-as-code==1.0.0b5
The == is not optional yet. 1.0.0b5 is a pre-release under PEP 440, and pip
skips pre-releases unless a version is pinned or --pre is given, so plain
pipx install m365-governance-as-code resolves to nothing until there is a
stable release.
The rules, profiles, schemas and the collector ship inside the package, so an installed copy works from any directory. Nothing resolves against a checkout.
Requires Python 3.11 or later. The collector additionally requires PowerShell 7 and PnP.PowerShell, and is not needed to run anything below.
To work on the project instead of using it:
python -m venv .venv && source .venv/bin/activate
pip install -e . && pip install -r requirements-dev.txt
Your own rules
The packaged set is what runs when you supply nothing. A path replaces it entirely:
m365-governance evaluate --rules ./my-rules --evidence ./evidence
It replaces; it never merges. Either the set that shipped with this version, complete, or the set you supplied, complete. A rule set assembled from both would exist only in the memory of whoever typed the command, and two runs of the same version would stop meaning the same thing. Every report says which of the two it used.
Quick start
Five commands. All of them run offline, against fixtures. No tenant required.
pipx install m365-governance-as-code==1.0.0b5 # 1. install (see Install)
m365-governance doctor # 2. is anything broken here
m365-governance list-rules # 3. what ships with this version
m365-governance show-rule SPO-LIST-001
m365-governance evaluate --evidence <an-evidence-file.json>
Example evidence ships with the package. doctor confirms it is there, in the
packaged content line, but does not print the path. This does:
python -c "import m365_governance, pathlib; print(pathlib.Path(m365_governance.__file__).parent / 'data' / 'fixtures')"
explain unknown is the one to run second. The project rests on six words
being different from each other, and the difference between "we could not read
this" and "this is fine" is the whole of it.
show-rule is the one to run first. It prints the whole claim: the basis, the
rationale behind the severity, the evidence the rule cannot decide without,
the source, and how the rule can pass while the problem survives.
The commands
explain OUTCOME |
What pass, fail, unknown, not-applicable, invalid-evidence and error mean, and what each is not. explain all for the six |
collect SLICE |
Run a collector against a tenant and write evidence. Evaluates nothing. Reports progress as it arrives, and writes a manifest saying whether the collection was completed, partial, failed or cancelled |
doctor |
Python, dependencies, schemas, rules, profiles, and whether PowerShell is around. Says what it found, not only whether it liked it |
list-rules |
Every rule with the kind of claim it makes, strongest claim first |
show-rule ID |
One rule in full, including what it does not establish |
stats EVIDENCE |
What a collector managed to see, before anything is evaluated |
validate |
Every rule against the schemas and the invariants |
evaluate |
Rules against evidence: one document or a directory of them. Markdown, JSON or self-contained HTML |
report RUN.json |
Re-render a stored run in another format, without evaluating again |
diff BEFORE AFTER |
What moved between two assessments, and what that does not establish |
diff is the one a periodic audit needs:
## SPO-SITE-001
**pass → fail**
Evidence that moved:
| Path | Before | After |
|---|---|---|
| `owners.count` | 2 | 1 |
It reports the rule version alongside the outcome, because a result that moved
because somebody edited the rule is not a result that moved because somebody
removed an owner. --fail-on-regression exits non-zero when any rule left
pass, including for unknown: losing the answer is a regression too.
A rule
schema_version: "1.0"
id: SPO-SITE-001
version: "1.0"
title: A site should have at least two owners
basis:
type: convention
sources: []
rationale: >
Microsoft does not require a minimum number of owners, does not warn about
a single owner, and does not prevent a site from having one. This is our
recommendation, and an organisation may reasonably decline it.
severity:
default: medium
rationale: >
The consequence is delay, not exposure. Access decisions queue behind a
person who is not there.
configurable: true
evidence_requirements:
- path: owners.count
type: integer
required: true
condition:
operator: less-than
evidence: owners.count
value: 2
limitations:
passes_without_resolving: >
Two owners who are dormant, who have left, or who do not know they own the
site satisfy this rule completely. The count is two and nobody is able to
act.
passes_without_resolving is mandatory. It answers one question — how can
this rule pass while the problem survives? — and it is a field of its own
rather than one entry in a list because it is the one that gets omitted. An
author who cannot answer it has not yet understood what the rule measures.
Rules execute no code. The condition grammar has twelve operators and no escape hatch, because a rule that can execute stops being reviewable by anyone who does not read the language it executes in.
Evidence
{
"facts": {
"owners": {
"state": "observed",
"direct": [{"principal_id": "user-1", "principal_type": "user"}],
"groups": [{"principal_id": "group-1", "principal_type": "group",
"expansion": {"status": "not-attempted"}}],
"expansion_complete": false,
"minimum_count": 1
}
}
}
A group owner is one principal and may be forty people. When expansion is incomplete the collector emits a bound, never a guess, and the engine reasons from the bound:
- three direct owners and one unexpanded group proves
at least 3, soowners >= 2is a pass, with certainty; - one direct owner and one unexpanded group proves nothing about
owners < 2, so the answer is unknown.
Without an upper bound the engine can prove pass and can never prove fail.
That table lives in docs/ARCHITECTURE.md, not in each
rule: a rule author declares the condition, and the engine decides what can be
concluded from what was collected.
[] means observed, and there are none. A fact that was not collected is
never an empty list.
Output
### SPO-LIST-001 v2.0
The list holds 148000 items, above Microsoft's documented limit of 100,000,
and still inherits its permissions. Inheritance can no longer be broken on
this list, so it cannot be given unique permissions later.
- Basis: **documented-limit** — a boundary the product imposes
- Severity: medium
- Evidence: `items.count` = 148000, `permissions.inheritance_broken` = false
- Source: SharePoint limits, Items in lists and libraries — checked 2026-08-05
And when the answer is that there is no answer:
1 rule could not be decided. That is not compliance: missing evidence is a
fact about collection, not about the resource.
One example per outcome is in examples/, every one of them
produced by a real run and checked by CI. There is no example of error: a
rule cannot author one, so an example of it would be an example of a bug.
Limitations
Stated here rather than discovered:
- Two rules. This is a model with a working engine, not a coverage tool.
- The collector runs delegated, never as an application. It has been run
read-only against a real tenant and validated against PnP.PowerShell 3.3.0,
but a delegated run sees what one person sees. Every report says so on its
first line. A tenant-wide inventory needs an application identity with
Sites.Read.Alland admin consent, and that is not implemented. - No group expansion. The collector emits
minimum_countand declares the expansionnot-attempted. That is honest and the engine handles it; it is not the same as counting. - One profile. A second one is created when a concrete rule needs to differ, not before.
- Source liveness is not checked, and whether a source still supports the claim can only be checked by a person.
- The model has not been reviewed by anyone outside its authors. Two independent reviews are pending; see docs/review/RULE-REVIEW.md. Changes already decided but deliberately not yet applied are in docs/POST-REVIEW-CHANGES.md.
Permissions
The collector is read-only and asks for the least that works:
Verified by running it, delegated, against a live tenant on 2026-08-06:
| Fact | Cmdlet | Identity used |
|---|---|---|
| Site owners | Get-PnPSiteCollectionAdmin |
site collection administrator |
| List item count | Get-PnPList |
read on the site |
| Permission inheritance | Get-PnPList -Includes HasUniqueRoleAssignments |
read on the site |
-ClientId is required by this collector. PnP.PowerShell removed its own
multi-tenant application in 2.12.0,
so a connection with no client id anywhere fails before it reaches the network.
Register an Entra ID app, or use one your tenant already has.
The module accepts a
default client id
instead — Set-PnPManagedAppId, or ENTRAID_APP_ID / ENTRAID_CLIENT_ID /
AZURE_CLIENT_ID. This collector still asks for it explicitly, because
evidence has to say which identity observed it and an ambient environment
variable is one nobody can name afterwards.
An interactive sign-in produces identity_kind: delegated, and every report
built from it says so: that run saw what one person sees, and nothing in it
may be read as a tenant-wide statement.
A fact the identity may not read comes back as permission-denied, never as
absent and never as compliant.
Roadmap
1.0.0-beta.1 is the baseline. Milestone A closed SharePoint end to end and
does not reopen; see docs/MILESTONE-A.md.
Epic B is open: everything one identity cannot see. Application
authentication, group expansion, importers, HTML reporting, and SARIF once the
representation of unknown is decided. The model is frozen for the whole of
it. See docs/EPIC-B.md.
The table below is the direction, ordered by what would make the tool useful to somebody else soonest rather than by what is most interesting to build.
| More SharePoint rules | Retention, site lifecycle, and the classification rules a tenant that uses labels would need. The engine is done; the work is authoring claims honestly. |
| Coverage across a run | Six of 53 sites refused the collector, and a report over the other 47 says 47 without saying "of 53". The envelope records coverage per document; nothing records it per run. |
| Group expansion | A group owner is one principal and may be forty people. The collector declares the expansion not-attempted and emits a lower bound; expanding it turns bounds into counts. |
| Application authentication | Delegated runs see what one person sees, and every count in this repository carries that clause. A tenant-wide inventory needs an app identity with Sites.Read.All and admin consent. |
| Exchange, Teams and Entra collectors | The evidence schema is service-agnostic. Each collector is new code and no new model. |
| HTML reporting | Markdown and JSON exist. HTML is for the reader who is not in a terminal. |
| SARIF output | So findings appear where code findings already appear, in a pipeline's own UI. Blocked until unknown has an agreed representation: SARIF has six kind values and this project has six outcomes, and they are not the same six. |
Open issues are the current list. This table is the direction.
Not on the roadmap, ever: automatic remediation, rules that execute code,
inferring basis from a source, or treating missing evidence as compliance.
Contributing
Rules are the interesting contribution. A pull request that adds one is reviewed against docs/review/RULE-REVIEW.md, and the criterion is not agreement — it is disagreeing in the right place.
Read CONTRIBUTING.md first; it is short. Before opening a pull request:
m365-governance validate
pytest
python tools/examples.py --check
Also: CODE_OF_CONDUCT.md · SECURITY.md · CHANGELOG.md
The build fails, deliberately, when a rule is missing basis, when a
documented type has no source, when a convention has no rationale, when
passes_without_resolving is missing, when a condition reads evidence nobody
declared, when evidence is declared and never consumed, when a failure message
interpolates a value that is missing precisely when that message prints, when
a YAML key is duplicated, when two rules share an id, or when any unknown
field appears anywhere.
That last one is additionalProperties: false everywhere, and it is not
pedantry: severty: high must stop the build, not disappear.
When you change a rule, read docs/CHANGE-POLICY.md. The test is one sentence: if a report produced yesterday would be interpreted differently today, the rule version must change.
License
MIT. See LICENSE.
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file m365_governance_as_code-1.0.0b5.tar.gz.
File metadata
- Download URL: m365_governance_as_code-1.0.0b5.tar.gz
- Upload date:
- Size: 302.7 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via:
twine/7.0.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
b659a10367c6be76ebe3a302d81198b4ea7f4917c7edf931dd9d2b8312ba145b
|
|
| MD5 |
afd17b57ea789ff8f7c1bc67005b99ba
|
|
| BLAKE2b-256 |
6ca3f3cf180fa6ecd7d59a29c19dae4f8bf475e1ad9cbdc6bc7a34b807ddaa76
|
Provenance
The following attestation bundles were made for m365_governance_as_code-1.0.0b5.tar.gz:
Publisher:
publish.yml on ph7x-Systems/m365-governance-as-code
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
m365_governance_as_code-1.0.0b5.tar.gz -
Subject digest:
b659a10367c6be76ebe3a302d81198b4ea7f4917c7edf931dd9d2b8312ba145b - Sigstore transparency entry: 2491318421
- Sigstore integration time:
-
Permalink:
ph7x-Systems/m365-governance-as-code@6be20e67579d515ed6a048fc16636b758a7b8745 -
Branch / Tag:
refs/tags/v1.0.0b5 - Owner: https://github.com/ph7x-Systems
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
publish.yml@6be20e67579d515ed6a048fc16636b758a7b8745 -
Trigger Event:
release
-
Statement type:
File details
Details for the file m365_governance_as_code-1.0.0b5-py3-none-any.whl.
File metadata
- Download URL: m365_governance_as_code-1.0.0b5-py3-none-any.whl
- Upload date:
- Size: 333.0 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via:
twine/7.0.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
b9ce1a5aef0a202294177d92b1aa370c66bc64b2676bff755898c115659b8a56
|
|
| MD5 |
440c5b201869b24ba9c549bf36d76115
|
|
| BLAKE2b-256 |
3e2dbc5a4197a7bc058addf9efc98f12ff9c54ce877ff5627aec16df2d0a067a
|
Provenance
The following attestation bundles were made for m365_governance_as_code-1.0.0b5-py3-none-any.whl:
Publisher:
publish.yml on ph7x-Systems/m365-governance-as-code
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
m365_governance_as_code-1.0.0b5-py3-none-any.whl -
Subject digest:
b9ce1a5aef0a202294177d92b1aa370c66bc64b2676bff755898c115659b8a56 - Sigstore transparency entry: 2491319203
- Sigstore integration time:
-
Permalink:
ph7x-Systems/m365-governance-as-code@6be20e67579d515ed6a048fc16636b758a7b8745 -
Branch / Tag:
refs/tags/v1.0.0b5 - Owner: https://github.com/ph7x-Systems
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
publish.yml@6be20e67579d515ed6a048fc16636b758a7b8745 -
Trigger Event:
release
-
Statement type: