ServiceNow: Role-Gated Writes (Read-Only Default)
else fails closed
- Direction
- ingress
- Rego package
servicenow.ingress.role_gate_writes- App
- servicenow
- Bundles
- soc2gdpr-ccpa
- Published
- Minimum gateway
- 1.0.0b24
- Schema version
- 1.0.0
- Checksum
sha256:ac9628e829a7ad715c2727faa87da59d15a8be121c2f3d400495a512fa4bca08
servicenowrole-gate-writesaccess-controlleast-privilegeingresssoc2gdpr-ccpa
What this policy does
Direction: ingress (tool_pre_invoke)
Default: deny — reads pass for everyone, verified writes require the writer group, everything
else fails closed
Package: servicenow.ingress.role_gate_writes
What it does
Makes ServiceNow read-only by default on the MCP path. Read tools pass for every caller. Every
verified write-class tool — incident, change, agile, catalog, knowledge, platform-code, and
changeset mutations — is denied unless the caller's IdP groups claim contains the placeholder
group servicenow-writers. Any tool that is neither a recognized read nor a verified write (an
unknown or renamed upstream tool, or a call with no tool name at all) is denied by default, so
an unrecognized write cannot slip through.
One caveat to that guarantee: reads are recognized by a list_/get_ verb heuristic, not an
enumerated read allowlist. A write whose tool name is admin-renamed to start with a read verb
(possible on the instance-defined official server — e.g. a subflow named get_and_close_incident)
is classified as a read and allowed. This residual is documented under Known limitations; run the
companion default-deny-unknown-tools (PF-28) policy alongside this one so such drift is caught
and audited rather than silently allowed.
Read tools that pass (matched on the echelon-ai-labs / michaelbuckner verb_noun vocabulary):
- any name whose tool segment starts with
list_(e.g.list_incidents,list_users,list_change_requests,list_articles,list_workflows,list_changesets) orget_(e.g.get_user,get_record,get_change_request_details,get_workflow,get_script_include,get_article,get_catalog_item) - the exact read tools
search_records,perform_query, andnatural_language_search
The check runs at ingress, before the call reaches the ServiceNow MCP server, so a denied write never executes and has no side effects on the instance.
Compliance alignment
- SOC 2 CC6.1 — supports logical access security over protected assets: ServiceNow records (incidents, changes, KB, catalog, platform code) cannot be mutated over the agent channel without an explicit role grant. CC6.3 — supports role-based access, least privilege, and separation of duties: write capability is tied to a live IdP group, and removing the group in the IdP removes agent write access on the next call.
- HIPAA §164.308(a)(4) — supports information access management for ServiceNow tenants whose incident/HRSD/CSM records hold ePHI: write authorization is role-scoped. §164.312(a)(1) — supports technical access control with per-call identity taken from the caller's JWT. §164.502(b) / §164.514(d) — supports minimum-necessary and role-based limits by keeping the default posture read-only.
- PCI DSS 7.2.1 / 7.2.2 — supports a least-privilege access model: agent write access to ServiceNow records adjacent to cardholder data is limited to the roles that need it. 7.2.5 — supports application/system-account least privilege by narrowing what the agent's OAuth grant can actually change.
- GDPR Art. 25 — supports data protection by design/default on the agent channel: the default posture is read-only. Art. 29 / Art. 32(4) — supports processing only on the controller's documented instructions: unauthorized principals cannot alter personal data in ServiceNow through the agent. Art. 5(1)(b) — supports purpose limitation by gating writes to a controlled role.
- SOX ITGC (access to programs and data) — supports least-privilege access where ServiceNow holds financially relevant records or change tickets: mutations require membership in a controlled group. SoD (COSO Principle 10) — supports separation of the initiate and approve roles by keeping ordinary agent writes behind a role grant (approval-class tools are gated separately — see Composition).
Tool name matching
The DTwo gateway prefixes tool names with the configured MCP server name (e.g.
servicenow-mcp-create_incident), and the prefix is not standardized — so matching is
case-insensitive and portable:
- Reads are matched on the
verb_nounsnake_case convention shared by the two big community servers (echelon-ai-labs and michaelbuckner). Alist_/get_verb is recognized only at the start of the name or immediately after a-server-prefix delimiter (the delimiter the gateway actually uses, e.g.servicenow-mcp-list_incidents), plus the three exact read toolssearch_records,perform_query, andnatural_language_search— which are matched at the same boundary (the bare name, or right after a-delimiter), not by a bare suffix. That anchoring matters: a bare suffix match would treat any instance-defined tool whose name merely ends with one of the three (e.g. an admin-named write subflowincident_close_and_search_records) as a read and allow it for everyone — a red-team finding, now closed. A_-delimited match (_get_/_list_inside a name) is intentionally not treated as a read verb: in the verb_noun vocabulary an underscore is part of the tool name, so an underscore-infix match cannot be told apart from a write subflow such asincident_get_and_resolve— matching those as reads let writes slip through (red-team finding, now closed). One consequence, which now holds uniformly for both thelist_/get_verbs and the three exact read tools: if your gateway joins the server-name prefix with_instead of-, read tools will not be recognized and will fail closed (denied) — configure a-prefix, or add the reads explicitly. - Writes are matched by exact suffix (
endswith) so any gateway server-name prefix still matches. The verified write set is:- Incident / ticket:
create_incident,update_incident,add_comment,add_work_notes,resolve_incident - Change:
create_change_request,update_change_request,add_change_task - Agile:
create_story,update_story,create_epic,update_epic,create_scrum_task,update_scrum_task,create_project,update_project - Catalog:
create_catalog_category,update_catalog_category,move_catalog_items,update_catalog_item,create_catalog_item_variable,update_catalog_item_variable,create_category - Knowledge:
create_knowledge_base,create_article,update_article,publish_article - Free-text write / code:
natural_language_update,update_script - Platform code:
create_workflow,update_workflow,delete_workflow,create_script_include,update_script_include,delete_script_include,create_ui_policy,create_ui_policy_action - Changeset / update-set:
create_changeset,update_changeset,commit_changeset,publish_changeset,add_file_to_changeset
- Incident / ticket:
Two entries in the write set deserve a call-out because they perform writes the policy cannot inspect field-by-field:
natural_language_update(michaelbuckner server) performs a write from free-text instructions with no structured, inspectable field diff — the agent describes a change in prose and the server executes it.update_scriptedits server-side script that then runs inside the instance.
Both stay inside the gated write set (a writer may call them). Stricter tenants should remove
natural_language_update and update_script from even the writer grant — carve them out into a
separate, more tightly gated policy, or move them to a human-only deny.
Verify the exact names your gateway sends with the dump-input debug technique before relying on this in production. If your ServiceNow server exposes a write tool whose suffix is not in the list above, it will be denied for everyone (fail closed) until you add it — see Known limitations.
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 the policy cannot be
bypassed by unusual argument keys, nesting, batched payloads, or encodings — and it works
identically whether or not a tool's argument schema is documented.
Identity
Group membership is read fail-closed via
object.get(object.get(object.get(input, "subject", {}), "claims", {}), "groups", []) with an
is_array guard: a missing subject, missing claims, a missing groups claim, or a groups
claim that is not an array all mean "not a writer", and every write is denied. Reads are
unaffected by identity.
Examples
Allowed — read tool, no identity required
{
"input": {
"action": "tool_pre_invoke",
"resource": { "name": "servicenow-mcp-list_incidents", "type": "tool" },
"payload": { "name": "servicenow-mcp-list_incidents", "args": { "limit": 20 } }
}
}
allow = true, no reason.
Allowed — write tool, caller in the writer group
{
"input": {
"action": "tool_pre_invoke",
"resource": { "name": "servicenow-mcp-create_incident", "type": "tool" },
"subject": { "sub": "auth0|alice", "claims": { "groups": ["servicenow-writers"] } },
"payload": {
"name": "servicenow-mcp-create_incident",
"args": { "short_description": "Printer down on 3rd floor" }
}
}
}
allow = true, no reason.
Denied — write tool, caller not in the writer group
{
"input": {
"action": "tool_pre_invoke",
"resource": { "name": "servicenow-mcp-create_incident", "type": "tool" },
"subject": { "sub": "auth0|bob", "claims": { "groups": ["engineering"] } },
"payload": {
"name": "servicenow-mcp-create_incident",
"args": { "short_description": "Printer down on 3rd floor" }
}
}
}
allow = false, reason = "ServiceNow write tools are restricted to members of the 'servicenow-writers' group ...".
Composition
This policy is the ServiceNow least-privilege baseline; it gates who may write, not what they may write, and does not cover the higher-blast-radius surfaces. Pair it with:
apps/servicenow/default-deny-unknown-tools(PF-28) — because ServiceNow's official server publishes instance-defined tool names, run the allowlist/drift policy alongside this one so new or renamed upstream tools are caught and audited, not silently denied without an operator seeing the drift.apps/servicenow/require-human-approval-changes(PF-15) —approve_change,reject_change, andsubmit_change_for_approvalare deliberately not in this policy's write set; they are unconditionally human-only and are gated by that companion policy.- A freeze-identity-plane policy (PF-13) for
create_user,update_user,create_group,update_group,add_group_members,remove_group_members— ServiceNow group membership drives ACLs, so these privilege-escalation primitives are also intentionally out of this policy's write set and belong behind a stricter identity-admin gate. - An egress PII/PHI redaction policy on
list_incidents/get_record/search_records/list_usersresponses, since this policy leaves the read path open.
Known limitations
- Group names are placeholders — replace
servicenow-writerswith your IdP's real group name at import time. The policy expectsgroupsto be an array claim in the caller's JWT; if your IdP emits roles under a different or namespaced claim (e.g.https://acme.com/groups), updatewriter_groupandcaller_groupsinpolicy.md. - Fail-closed on unknown tools (by design). Anything that is not a recognized read or a
verified-suffix write is denied for everyone, including writers. That is the intended posture —
it prevents an unclassified write from slipping through — but it also means legitimate write
tools outside the verified set are blocked until an operator adds them. Known examples not in
the write set:
create_story_dependency/delete_story_dependency(agile dependency writes), and the identity/approval tools covered by the companion policies above. Add any tool your tenant legitimately uses towrite_suffixes(for the writer grant) or the read helpers, and run the companiondefault-deny-unknown-toolspolicy so the drift is surfaced rather than silent. - A write whose name starts with a read verb is allowed (residual). Reads are recognized by a
list_/get_verb heuristic, not an enumerated allowlist, so a tool whose name starts withlist_/get_(at the start of the name, or right after the-server prefix) is treated as a read for everyone. On the community servers no write is named this way, but the official server's tool names are admin-defined, so a write subflow could be published asget_and_close_incident,list_and_purge_records, etc. and would pass the read path. This cannot be closed from inside a single least-privilege policy without a full write vocabulary; the mitigation is the companiondefault-deny-unknown-tools(PF-28) policy, which allowlists audited tool names and alerts on drift. (Two related but distinct misclassifications are closed: the_get_/_list_infix case — e.g.incident_get_and_resolve— because underscore-infix matches are not treated as read verbs; and the ends-with-a-read-suffix case — e.g.incident_close_and_search_records— because the three exact reads are matched only at the start of the name or after a-delimiter, not by a bare suffix.) - Official-server tool names are instance-defined. ServiceNow's native MCP Server derives tool
names from the skills/subflows/APIs an admin publishes, with no canonical vocabulary; this
policy's read/write matching is built for the echelon-ai-labs and michaelbuckner
verb_nouncommunity vocabulary. For the official server you must supply a per-tenant tool inventory and map each name into the read helpers orwrite_suffixes. natural_language_updateandupdate_scriptare inside the writer grant. They perform an uninspectable free-text write and a server-side code edit respectively; a writer may call them. Stricter tenants should remove them from even the writer grant (see Tool name matching).- Reads are open to everyone. Read tools reach any table the instance credential can read
(
sys_user, HRSD case tables,cmdb_ci, custom PII tables viaperform_query/search_records/get_record). This policy does not scope reads by table or redact responses — pair it with a read-fence and/or egress redaction if your instance holds regulated data. - A server named with a write suffix (e.g. an MCP server configured so its name ends in
...create_incident) is a pathological case that would make matching over-broad; keep the configured server name distinct from the tool suffixes, or verify with the dump-input technique.
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 servicenow.ingress.role_gate_writes
# Deny-by-default: reads are explicitly allowed below, verified writes require
# the writer group, and anything unrecognized (unknown/renamed tool, missing
# name) fails closed so a write can never slip through unclassified.
default allow := false
# Placeholder IdP group permitted to perform ServiceNow writes.
# Replace "servicenow-writers" with your IdP's group name at import time.
writer_group := "servicenow-writers"
# Lowercased tool name. The gateway prefixes tool names with the configured
# MCP server name (e.g. `servicenow-mcp-create_incident`), so all matching
# below is case-insensitive and suffix/segment based to stay portable.
tool_name := lower(object.get(object.get(input, "resource", {}), "name", ""))
# --- Identity (fail closed) ---
# Missing subject, missing claims, a missing groups claim, or a groups claim
# that is not an array all yield "not a writer" — writes then deny.
caller_groups := object.get(object.get(object.get(input, "subject", {}), "claims", {}), "groups", [])
caller_is_writer if {
is_array(caller_groups)
some group in caller_groups
group == writer_group
}
# --- Read-tool detection (echelon-ai-labs / michaelbuckner verb_noun vocab) ---
# `list_` / `get_` recognized as a read verb: at the start of the name
# (unprefixed) or right after the gateway's `-` server-name prefix delimiter
# (e.g. `servicenow-mcp-list_incidents`).
#
# We deliberately do NOT treat a `_`-delimited match (`_get_` / `_list_`) as a
# read verb. In the verb_noun snake_case vocabulary an underscore is *within* a
# tool name, so an underscore-infix match cannot be distinguished from a genuine
# read verb — e.g. an instance-defined write subflow named
# `incident_get_and_resolve` or `case_list_and_close` embeds `_get_`/`_list_`
# yet is a write. Matching those as reads let writes slip through unclassified
# (red-team finding). Only the hyphen boundary — the real prefix delimiter the
# gateway uses — and the start of the name are trusted. See Known limitations
# for the residual (a write whose name *starts* with a read verb) and the
# `_`-delimited-prefix caveat.
read_prefixes := ["list_", "get_"]
has_read_prefix if {
some prefix in read_prefixes
startswith(tool_name, prefix)
}
has_read_prefix if {
some prefix in read_prefixes
contains(tool_name, concat("", ["-", prefix]))
}
# Exact read tools that carry no list_/get_ prefix.
read_suffixes := ["search_records", "perform_query", "natural_language_search"]
# Recognized at a name boundary only — the bare name (unprefixed) or right after
# the gateway's `-` server-name prefix delimiter — mirroring has_read_prefix. We
# deliberately do NOT use a bare `endswith` here: an unanchored `endswith` would
# treat any instance-defined tool whose name merely *ends* with one of these
# (e.g. a write subflow `incident_close_and_search_records`, or `search_records`
# reached under a `_`-joined server prefix) as a read and allow it for everyone
# (red-team finding, now closed). Anchoring to the start or the `-` boundary
# keeps the three reads recognized under the real `-` prefix while failing such
# underscore-infix look-alikes closed — consistent with the `_`-prefix caveat
# documented for the list_/get_ verbs.
has_read_suffix if {
some suffix in read_suffixes
tool_name == suffix
}
has_read_suffix if {
some suffix in read_suffixes
endswith(tool_name, concat("", ["-", suffix]))
}
is_read_tool if has_read_prefix
is_read_tool if has_read_suffix
# --- Write-tool detection (verified suffixes) ---
# Matched by suffix so any gateway server-name prefix still matches. Grouped by
# ServiceNow surface; see the description for the per-suffix rationale.
write_suffixes := [
# Incident / ticket
"create_incident",
"update_incident",
"add_comment",
"add_work_notes",
"resolve_incident",
# Change
"create_change_request",
"update_change_request",
"add_change_task",
# Agile
"create_story",
"update_story",
"create_epic",
"update_epic",
"create_scrum_task",
"update_scrum_task",
"create_project",
"update_project",
# Catalog
"create_catalog_category",
"update_catalog_category",
"move_catalog_items",
"update_catalog_item",
"create_catalog_item_variable",
"update_catalog_item_variable",
"create_category",
# Knowledge
"create_knowledge_base",
"create_article",
"update_article",
"publish_article",
# Free-text write / server-side code
"natural_language_update",
"update_script",
# Platform code
"create_workflow",
"update_workflow",
"delete_workflow",
"create_script_include",
"update_script_include",
"delete_script_include",
"create_ui_policy",
"create_ui_policy_action",
# Changeset / update-set
"create_changeset",
"update_changeset",
"commit_changeset",
"publish_changeset",
"add_file_to_changeset",
]
is_write_tool if {
some suffix in write_suffixes
endswith(tool_name, suffix)
}
# --- Decision ---
# Reads pass for everyone. The `not is_write_tool` guard keeps the stricter
# class winning if a name were ever both (none in the current sets).
allow if {
is_read_tool
not is_write_tool
}
# Verified writes pass only for members of the writer group.
allow if {
is_write_tool
caller_is_writer
}
# Denied write by a non-writer: name the required group and point to the gateway
# admin who maps IdP groups.
reasons contains msg if {
is_write_tool
not caller_is_writer
msg := sprintf("ServiceNow write tools are restricted to members of the '%s' group — this account has read-only ServiceNow access through the gateway. Ask your gateway admin to map your IdP group to '%s', or hand this write to a teammate with ServiceNow write access. If this tool is actually read-only, contact your gateway admin to update the policy.", [writer_group, writer_group])
}
# Fail-closed deny: the tool is neither a recognized read nor a verified write
# (unknown/renamed upstream tool, or a call with no tool name). Denied for
# everyone, including writers, until an operator classifies it.
reasons contains msg if {
not is_read_tool
not is_write_tool
msg := sprintf("This ServiceNow tool (%q) is not on the gateway's read allowlist or verified write list, so it is denied by default (fail closed). ServiceNow tool names can be instance-defined; ask your gateway admin to classify this tool — add it to the read set, or to the gated write set for the 'servicenow-writers' group — in the policy.", [tool_name])
}
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)
Related policies
Airtable: Redact PII in Record Reads
Scans the responses of the Airtable record-read tools — the calls that return row fields values — and rewrites high-confidence PII shapes to a fixed…
Asana: Redact PII in Task & Comment Reads
On the Asana MCP read path, this transform scans the free-text business fields that ride back in task, comment/story, and status-update responses — notes,…
BigQuery: Redact PII in Query Results
Scans the content returned by BigQuery's result-returning tools and rewrites high-confidence PII shapes to fixed, non-recoverable redaction tokens before the…
Block Agent Email to External Recipients
Blocks agent-initiated Microsoft 365 email sends when any recipient address falls outside a corporate-domain allowlist.
Block BigQuery Exfiltration and Cross-Project Writes
Inspects the raw GoogleSQL string carried by BigQuery SQL tools and denies any statement that moves data out of the tenant's own project — even when the call…
bigqueryguard-warehouse-exportingresssqlexfiltrationsoc2pci-dssgdpr-ccpa
Block Bulk Export & External Staging (Snowflake)
Blocks Snowflake SQL-execution tool calls whose query text moves whole tables off the Snowflake perimeter — bulk export to cloud storage or a stage, and…
snowflakeguard-warehouse-sqlexportexfiltrationingresssoc2pci-dssgdpr-ccpa