dtwo Policy Store

Freeze M365 Identity Plane

Freezes directory and membership mutations on the Microsoft 365 MCP surface. The policy denies, by tool-name suffix:

Direction
ingress
Rego package
ms365.ingress.freeze_identity_plane
App
ms365
Bundle
soc2
Published
Minimum gateway
1.0.0b24
Schema version
1.0.0
Checksum
sha256:d64b95081439f286552ad6a368e6d710fbf158099d6301ee3d2491797b5ef62f

ms365freeze-identity-planeingressidentityentragroupsiso27001-nistsoc2

What this policy does

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

{
  "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

{
  "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-batchgraph-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.

Policy source (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)
}

Canonical source: policy.md on GitHub · raw · raw on this site (.md)

Used in these guides