dtwo Policy Store

Read-Only Baseline: Group-Gated Microsoft 365 Writes

The least-privilege baseline for Microsoft 365 through the gateway: every tool call is allowed only if it is a read , or the caller's IdP token carries the…

Direction
ingress
Rego package
ms365.ingress.role_gate_writes
App
ms365
Bundles
soc2gdpr-ccpasox
Published
Minimum gateway
1.0.0b24
Schema version
1.0.0
Checksum
sha256:020cb78240c9e17de27002cd9d2d1147f9822645550709d9ff2bd494a2f67c78

ms365role-gate-writesingressleast-privilegesoc2gdpr-ccpasox

What this policy does

Direction: ingress (tool_pre_invoke) Default: deny — reads pass for everyone, writes only for the writer group Package: ms365.ingress.role_gate_writes

What it does

The least-privilege baseline for Microsoft 365 through the gateway: every tool call is allowed only if it is a read, or the caller's IdP token carries the write-authorized group (placeholder: m365-writers). Everything that is not recognizably a read — sends, creates, updates, deletes, uploads, shares, reactions, calendar responses, graph-batch, and any verb the policy has never seen — is treated as a write and denied for callers outside the group. When identity claims are absent the policy fails closed: no groups, no writes.

The softeria ms-365-mcp-server (the primary policy target) also ships a server-side --read-only flag. This policy is the gateway-side equivalent, with two advantages: it is enforced even if the server flag is dropped or the server is redeployed without it, and it supports per-user gating — trusted users in m365-writers keep write access while everyone else gets a read-only tenant view over the same connector.

Compliance alignment

  • SOC 2 CC6.1 / CC6.3 — supports logical access security and role-based least privilege over the M365 estate on the agent channel: write capability is granted by role, not by connector possession. CC6.2 — the grant rides on live IdP claims, so deprovisioning a user in the IdP revokes agent write access with no gateway change. PI1.2 — only authorized principals can submit state-changing inputs.
  • HIPAA §164.502(b)/§164.514(d) (minimum necessary) and §164.308(a)(4) (information access management) — mailbox, drive, and Teams write surfaces are limited to a defined workforce role; §164.312(a)(1) — per-call, identity-bound access control on the MCP path; §164.308(a)(3) — supports termination effect via IdP-claim liveness.
  • GDPR Art. 25 — data protection by default on the agent channel (the default posture is read-only); Art. 29 / 32(4) — supports processing only on the controller's instructions by stopping unauthorized principals from acting on personal data; Art. 5(1)(b) purpose limitation (partial). CCPA §1798.100(e) — reasonable security procedures (partial).
  • SOX ITGC (access to programs and data) — supports least-privilege access to financially relevant systems (Excel workbooks, SharePoint lists) reached through M365; COSO P10 SoD (partial) — read-everyone/write-few is the coarsest separation-of-duties cut.

Tool name matching

The softeria server names tools verb-noun (kebab-case), and the DTwo gateway prepends the configured MCP server name (observed live as ms365-, e.g. ms365-list-mail-messages). The policy therefore classifies by the verb segment, matched case-insensitively on lower(input.resource.name) as a whole hyphen-delimited segment — either at the start of the name (unprefixed deployments) or immediately after a - (prefixed deployments). Write verbs additionally match as the final segment of the name, so a trailing write verb can never hide behind a leading read verb:

  • Read verbs: list-, get-, search-, download-, extract-, find-, parse- (e.g. ms365-get-drive-item, ms365-search-query, ms365-download-bytes).
  • Write verbs: create-, send-, update-, delete-, add-, remove-, upload-, move-, copy-, share-, reply-, forward-, set-, clear-, cancel-, format-, sort-, merge-, insert-, pin-, accept-, decline-, tentatively-, dismiss-, snooze-, unmerge-, unpin-, unset-, reauthorize-.

A name is a read only when a read verb matches and no write verb matches anywhere in the name — so ms365-create-sharepoint-list-item (which contains -list- as a noun fragment) resolves to the stricter write class. Names matching neither list (e.g. ms365-graph-batch) are writes. Segment matching also keeps noun fragments from triggering write verbs: -pinned-, -settings, -shared-, and -sharepoint- do not match pin-, set-, or share-.

The ms365- prefix is a deployment choice, not a standard — verify the exact names your gateway sends with the dump-input debug technique before relying on this in production.

Argument shape

This policy inspects only the tool name and the caller's identity claims — it never reads input.payload.args. The writer grant checks for the literal string "m365-writers" in object.get(input.subject, "claims", {}).groups, which must be an array of strings (the common IdP shape) — the Rego enforces is_array explicitly, so an object- or string-shaped groups claim never grants. The match is exact and case-sensitive ("M365-Writers" does not grant). A missing subject, missing claims, missing groups, or a non-array groups value all resolve to "not a writer" — the policy fails closed for grants.

Examples

Allowed — read, no identity needed

{
  "input": {
    "action": "tool_pre_invoke",
    "resource": { "name": "ms365-list-mail-messages", "type": "tool" },
    "payload": { "name": "ms365-list-mail-messages", "args": {} }
  }
}

allow = true, no reason.

Allowed — write by a group member

{
  "input": {
    "action": "tool_pre_invoke",
    "resource": { "name": "ms365-send-mail", "type": "tool" },
    "subject": {
      "sub": "google-apps|pat@example.com",
      "claims": { "groups": ["engineering", "m365-writers"] }
    },
    "payload": { "name": "ms365-send-mail", "args": { /* ... */ } }
  }
}

allow = true.

Denied — write without the group

{
  "input": {
    "action": "tool_pre_invoke",
    "resource": { "name": "ms365-send-mail", "type": "tool" },
    "subject": {
      "sub": "google-apps|sam@example.com",
      "claims": { "groups": ["engineering"] }
    },
    "payload": { "name": "ms365-send-mail", "args": { /* ... */ } }
  }
}

allow = false, reason = "This Microsoft 365 tool call is a write, which requires membership in the m365-writers IdP group (...)".

Composition

This is the app's baseline; membership in m365-writers is necessary for any write but not sufficient for the highest-risk ones. Destructive operations (delete-*, remove-*, cancel-*), directory and group mutations, mailbox persistence (create-mail-rule, create-subscription, mailbox settings), and external email/Teams sends remain subject to the stricter companion policies (freeze-destructive-ops, freeze-identity-plane, guard-mailbox-persistence, guard-external-send) even for group members — policies compose with AND, so the strictest attached policy wins. Pair it also with a graph-batch / raw-passthrough deny (deny-escape-hatches family): this policy classifies graph-batch as a write, but writer-group members could otherwise reach arbitrary Graph endpoints through it.

Known limitations

  • Group names are placeholders — replace m365-writers with your IdP's group name at import time, and confirm your IdP actually emits a groups claim (Entra ID requires the groups claim to be configured on the app registration; Auth0 needs an Action or RBAC setup). The membership check is exact and case-sensitive — a claim of "M365-Writers" denies (fails closed), so copy the group name from your IdP verbatim.
  • Verb-list drift. New tools with unknown verbs deny by default for non-writers (fail closed). The residual risk is a future write tool whose leading verb is not in the write list but whose name contains a read verb as a later segment (no such tool exists in the verified softeria inventory today) — it would classify as a read. Trailing write verbs are covered (write verbs also match as the final segment), so only an unknown-leading-verb + embedded-read-verb name can slip. Re-check the lists when the upstream server adds tools.
  • Verb false positives are conservative. A few tools are writes by verb but read-like in effect — e.g. create-drive-item-preview (renders a preview, persists nothing). They deny for non-writers. Misclassification here only ever over-blocks; add a narrow exact-name allow rule if a specific one matters to your users.
  • All ingress hook kinds are gated. The policy does not filter on input.action, so prompt_pre_fetch and resource_pre_fetch events on the same pipeline are classified by the same verb rules and fail closed — non-writers are denied prompt/resource fetches whose names don't start with a read verb. The softeria server is tool-only, so this is theoretical there; on mixed servers, add an explicit allow for those hook kinds if read-equivalent.
  • Softeria vocabulary only. Single-tool passthrough servers (merill/lokka's Lokka-Microsoft) and the Anthropic-hosted M365 connector (tool names unpublished, not gateway-routable) defeat name-based classification — for Lokka, gate on the method argument instead; the Anthropic connector is a coverage gap to flag, not a policy target.
  • Reads are not harmless. download-bytes, search-query, transcript reads, and fetchAllPages Excel reads bulk-export data yet pass this policy for everyone. Pair with the egress redaction and bulk-export-cap companions.
  • Non-array groups claims fail closed — an IdP that emits groups as a single string will deny all writes until the claim is mapped to an array (or the policy is adapted).

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.role_gate_writes

# Read-only baseline: reads pass for everyone, writes require the writer group.
default allow := false

# Verb segments that identify read-only Microsoft 365 tools (softeria
# ms-365-mcp-server vocabulary, verified from a live gateway deployment).
read_verbs := {
    "list",
    "get",
    "search",
    "download",
    "extract",
    "find",
    "parse",
}

# Verb segments that identify writes. A name matching any of these is a write
# even if it also contains a read verb deeper in the name (e.g.
# create-sharepoint-list-item contains "-list-" as a noun fragment) —
# ambiguity resolves to the stricter class.
write_verbs := {
    "create",
    "send",
    "update",
    "delete",
    "add",
    "remove",
    "upload",
    "move",
    "copy",
    "share",
    "reply",
    "forward",
    "set",
    "clear",
    "cancel",
    "format",
    "sort",
    "merge",
    "insert",
    "pin",
    "accept",
    "decline",
    "tentatively",
    "dismiss",
    "snooze",
    "unmerge",
    "unpin",
    "unset",
    "reauthorize",
}

# Case-insensitive tool name; missing fields resolve to "" (fail closed).
tool_name := lower(object.get(object.get(input, "resource", {}), "name", ""))

# A verb matches only as a whole hyphen-delimited segment: at the start of the
# name (unprefixed deployment) or right after a "-" (gateway server-name
# prefix, e.g. "ms365-list-mail-messages"). This keeps noun fragments like
# "-pinned-", "-settings", or "-shared-" from matching "pin-"/"set-"/"share-".
verb_in_name(verb) if {
    startswith(tool_name, sprintf("%s-", [verb]))
}

verb_in_name(verb) if {
    contains(tool_name, sprintf("-%s-", [verb]))
}

matches_read_verb if {
    some verb in read_verbs
    verb_in_name(verb)
}

matches_write_verb if {
    some verb in write_verbs
    verb_in_name(verb)
}

# Write verbs additionally match as the final segment (no trailing hyphen), so
# a name with a leading read verb and a trailing write verb (hypothetical
# "get-chat-message-unpin") cannot classify as a read. Read verbs deliberately
# do NOT get final-segment matching: a trailing read noun (the "list" in
# "delete-todo-task-list") must not soften a write, and an unknown-verb name
# stays a write.
matches_write_verb if {
    some verb in write_verbs
    endswith(tool_name, sprintf("-%s", [verb]))
}

# A read is a read verb with no write verb anywhere in the name. Everything
# else — write verbs, both-class names, unknown verbs, missing names — is
# treated as a write.
is_read_tool if {
    matches_read_verb
    not matches_write_verb
}

# Writer grant: the IdP token must carry the m365-writers group. Placeholder —
# replace with your IdP's group name at import time. Missing subject, claims,
# or groups resolve to an empty list: no group claim, no grant.
caller_groups := object.get(
    object.get(object.get(input, "subject", {}), "claims", {}),
    "groups",
    []
)

is_writer if {
    is_array(caller_groups)
    some group in caller_groups
    group == "m365-writers"
}

# Reads pass for everyone.
allow if {
    is_read_tool
}

# Writers may call anything.
allow if {
    is_writer
}

reason := "This Microsoft 365 tool call is a write, which requires membership in the m365-writers IdP group; your identity token does not carry it. Read tools (list, get, search, download, extract, find, parse) remain available. Contact your InfoSec team for write access, or if this looks like a misclassified read." if not allow

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

Used in these guides