--- name: Freeze M365 Identity Plane tags: - ms365 - freeze-identity-plane - ingress - identity - entra - groups - iso27001-nist - soc2 publishedAt: 2026-07-12 description: | # ms365 / freeze-identity-plane **Direction:** ingress (`tool_pre_invoke`) **Default:** deny group/team membership mutations, allow everything else **Package:** `ms365.ingress.freeze_identity_plane` ## What it does Freezes directory and membership mutations on the Microsoft 365 MCP surface. The policy denies, by tool-name suffix: - `*-create-group` / `*-update-group` — group creation and property changes - `*-add-group-member` / `*-remove-group-member` — group membership changes - `*-add-group-owner` / `*-remove-group-owner` — group ownership changes - `*-add-team-member` / `*-remove-team-member` — Teams membership changes All other tools pass through unchanged. The denied tools are exempt **only** for callers whose IdP `groups` claim contains the placeholder group `iam-admins`, read fail-closed from `input.subject.claims.groups` — a missing subject, missing claims, or missing/empty `groups` claim means no exemption and the mutation is denied. The escalation risk is concrete in M365. Microsoft 365 groups are the access-control primitive behind Teams, SharePoint sites, and shared mailboxes: `add-group-owner` hands control of every group-bound resource (the Team, its SharePoint site, its shared mailbox) to the added principal, and `add-group-member` silently widens access to group-shared files and channels. An injected agent that can touch these tools can grant itself — or an outside account — persistent access that survives the session. Freezing the identity plane at ingress means the mutation never reaches Microsoft Graph. Reads (`*-list-groups`, `*-list-group-members`, `*-list-group-owners`, `*-get-group`, `*-list-team-members`, `*-list-my-memberships`, …) stay open so agents can operate recon-free without triggering denials. `delete-group` is intentionally **not** matched here — destructive deletion belongs to the companion `freeze-destructive-ops` policy (one policy, one job). ## Compliance alignment - **ISO 27001 A.8.2 / NIST 800-53 AC-6(9), AC-6(10)** — supports privileged access restriction: group membership and ownership changes are privileged directory operations, and this policy prevents non-privileged callers (and injected agents acting as them) from executing privileged functions on the agent channel. - **FedRAMP AC-6** — supports least-privilege alignment for deployments mapped through the NIST 800-53 baseline: identity-plane mutations require an explicit IdP-asserted admin group. - **SOC 2 CC6.1 / CC6.3** — supports logical access security and role-based least privilege: group membership and ownership changes are privileged operations, gated to a named admin group so a non-privileged caller (or an injected agent acting as one) cannot widen its own access. - **HIPAA §164.308(a)(4)** — supports information access management on a PHI-capable suite: M365 groups are the access-control primitive behind Teams, SharePoint sites, and shared mailboxes that hold ePHI, so freezing membership and ownership mutations on the agent channel keeps access grants under human control. ## Tool name matching The policy matches by suffix on the lowercased `input.resource.name`: `-create-group`, `-update-group`, `-add-group-member`, `-add-group-owner`, `-remove-group-member`, `-remove-group-owner`, `-add-team-member`, `-remove-team-member` Tool names are verified against the `softeria/ms-365-mcp-server` implementation as observed live through a gateway deployment (gateway prefix `ms365-`, e.g. `ms365-add-group-owner`). The DTwo gateway prefixes tool names with the configured MCP server name, and that prefix is not standardized — suffix matching keeps the policy portable across server names. Bare, unprefixed tool names (`create-group` rather than `ms365-create-group`) carry no leading hyphen and would not end with any listed suffix, so the policy also matches each bare name exactly — a gateway that forwards the server's own tool names without a prefix cannot slip past the suffix match. Verify the exact names your gateway sends with the dump-input debug technique before relying on this in production. ## Argument shape The decision uses only the tool name (`input.resource.name`) and the caller's identity (`input.subject.claims.groups`). Tool arguments are not inspected, so no argument-shape drift can bypass the deny. Identity is read with `object.get` chains: a missing `subject`, missing `claims`, or missing `groups` claim yields an empty group list, which fails closed — the caller is not exempt and the mutation is denied. ## Examples ### Allowed — read tool, no identity required ```jsonc { "input": { "action": "tool_pre_invoke", "resource": { "name": "ms365-list-group-members", "type": "tool" }, "payload": { "name": "ms365-list-group-members", "args": { "groupId": "g-123" } } } } ``` `allow = true`, no reason. ### Denied — membership mutation by a non-admin caller ```jsonc { "input": { "action": "tool_pre_invoke", "resource": { "name": "ms365-add-group-owner", "type": "tool" }, "subject": { "sub": "auth0|agent-user", "claims": { "groups": ["engineering"] } }, "payload": { "name": "ms365-add-group-owner", "args": { "groupId": "g-123", "userId": "u-456" } } } } ``` `allow = false`, `reason = "M365 group and team membership changes are frozen on the agent path (...)"`. ### Allowed — same mutation by an `iam-admins` member The same call with `"groups": ["iam-admins"]` in `input.subject.claims` returns `allow = true`. ## Composition This policy is single-purpose. Useful companions: - **`freeze-destructive-ops`** (PF-06) — owns `*-delete-group` and the rest of the delete-class surface. This policy deliberately leaves deletion to it. - **A PF-22 escape-hatch deny on `*-graph-batch`** — `graph-batch` can issue arbitrary Graph requests, including `POST /groups/{id}/members/$ref`, and bypasses every per-tool rule here. Without it, this policy's guarantee holds only for the named tools. - **`role-gate-writes`** (PF-12) — the baseline write gate for everything else on the M365 surface. ## Known limitations - **`graph-batch` and raw-Graph passthroughs bypass this policy.** The softeria server's `graph-batch` tool and Lokka's single `Lokka-Microsoft` tool can reach the same Graph membership endpoints without matching any suffix here. Deploy a PF-22 escape-hatch policy alongside this one; for Lokka, name-based matching is useless and the deny must inspect `method`/`path` arguments. - **Group names are placeholders** — replace `iam-admins` with your IdP's group name at import time. The match is an exact, case-sensitive string comparison against entries of the `groups` claim; `IAM-Admins` does not match `iam-admins`. - **The `groups` claim must be an array of strings.** If your IdP emits a single string or a namespaced custom claim (e.g. `https://acme.com/groups`), adjust `caller_groups` in the Rego. The exemption is guarded by `is_array`, so every non-array shape fails closed (deny) — including an object-shaped claim such as `{"role": "iam-admins"}`, whose *values* would otherwise have been iterated by `some group in caller_groups` and spoofed the admin exemption. Without the guard that shape failed **open**; with it, only a JSON array whose elements include the exact string `iam-admins` grants the exemption. - **Suffix matching assumes the gateway joins the server-name prefix with a hyphen.** DTwo's gateway does (`ms365-add-group-owner`, verified live), and the policy also matches bare unprefixed names exactly. But a non-standard gateway that joined the prefix with `_` or `.` (`ms365_add-group-owner`, `ms365.add-group-owner`) would not end with any hyphen-led suffix and would slip through. Verify the exact separator your gateway sends with the dump-input debug technique; if it is not a hyphen, extend the match. - **Only softeria tool names are verified.** The Anthropic-hosted Microsoft 365 connector does not publish MCP-level tool names (and does not traverse a customer gateway); the official Microsoft enterprise server is read-only and has no mutation tools to match. If you route a different Graph-backed server through the gateway, verify its tool names and extend the suffix list. - **Reads stay open by design.** `list-groups`, `list-group-members`, and other directory reads are not gated here. If directory recon itself is a concern in your environment, add a separate read-gating policy rather than widening this one. - **A request with no resolvable tool name passes through.** This is a blocklist keyed on `input.resource.name`: a missing or empty name matches no suffix and is allowed. The gateway reliably populates `resource.name` on `tool_pre_invoke`, so this is inherent blocklist semantics rather than an observed gateway behavior; if you need fail-closed-on-unknown, deploy a PF-28 default-deny allowlist policy instead of (or alongside) this one. > **Compliance note.** This policy supports alignment with the cited framework controls **on the MCP path only**. No policy or bundle makes an organization compliant with any framework; web-UI, native-API, and in-app access are outside the gateway's reach by design. Validate against your own compliance program before relying on it. direction: ingress apps: - ms365 industries: [] bundles: - soc2 experimental: true schemaVersion: 1.0.0 minimumGatewayVersion: 1.0.0b24 --- ```rego package ms365.ingress.freeze_identity_plane # Deny-by-default: only the explicit allow rules below permit the request. default allow := false # Placeholder IdP group permitted to perform identity-plane mutations. # Replace "iam-admins" with your IdP's group name at import time. iam_admin_group := "iam-admins" # Lowercased, whitespace-trimmed tool name. The gateway prefixes tool names # with the configured MCP server name (observed live as `ms365-`), so matching # is case-insensitive and suffix-based to stay portable across server names. # Red-team fix: trim_space so a trailing space/newline in the tool name cannot # defeat the endswith suffix match (`"...-add-group-owner\n"` would otherwise # slip through). tool_name := trim_space(lower(object.get(object.get(input, "resource", {}), "name", ""))) # Directory and membership mutations on the softeria ms-365-mcp-server, # verified from a live gateway deployment. `delete-group` is intentionally # absent — it belongs to the companion freeze-destructive-ops policy. identity_mutation_suffixes := [ "-create-group", "-update-group", "-add-group-member", "-add-group-owner", "-remove-group-member", "-remove-group-owner", "-add-team-member", "-remove-team-member", ] is_identity_mutation if { some suffix in identity_mutation_suffixes endswith(tool_name, suffix) } # Red-team fix: every suffix starts with "-", so a bare, unprefixed tool name # (e.g. `create-group` from a gateway configured without a server-name prefix) # would not end with any suffix and slip through. Match the bare names exactly. is_identity_mutation if { some suffix in identity_mutation_suffixes tool_name == trim_prefix(suffix, "-") } # --- Identity (fail closed) --- # Missing subject, missing claims, a missing groups claim, or a groups claim # that is not an array all yield "not an IAM admin" — mutations then deny. caller_groups := object.get(object.get(object.get(input, "subject", {}), "claims", {}), "groups", []) # Red-team fix: guard on is_array. Without it, `some group in caller_groups` # iterates the *values* of an object-shaped groups claim, so a claim like # {"role": "iam-admins"} would spoof the exemption and fail OPEN. Requiring an # array makes every non-array shape (string, object, number) fail closed — # matching the documented "must be an array of strings" contract. caller_is_iam_admin if { is_array(caller_groups) some group in caller_groups group == iam_admin_group } # Allow any tool that is not an identity-plane mutation (reads such as # list-groups and list-group-members stay open for recon-free operation). allow if { not is_identity_mutation } # Allow identity-plane mutations only for members of the IAM admin group. allow if { is_identity_mutation caller_is_iam_admin } reasons contains msg if { is_identity_mutation not caller_is_iam_admin msg := sprintf("M365 group and team membership changes are frozen on the agent path — group membership and ownership changes are made in the Microsoft Entra admin center by an identity administrator. If your role requires making these changes through the gateway, ask your identity admin to add you to the '%s' IdP group, or contact your InfoSec team if this looks like a false positive.", [iam_admin_group]) } reason := joined if { count(reasons) > 0 reason_list := sort([r | some r in reasons]) joined := concat("; ", reason_list) } ```