Slack Role-Gate Writes
Gates every Slack write-class tool behind an IdP group: callers whose JWT groups claim contains slack-writers may send and schedule messages, add or remove…
- Direction
- ingress
- Rego package
slack.ingress.role_gate_writes- App
- slack
- Bundles
- slackim-messagingsoc2gdpr-ccpa
- Published
- Minimum gateway
- 1.0.0b24
- Schema version
- 1.0.0
- Checksum
sha256:bb893589a6e31ab1f70160613c91bd3563e3f9d340abf3342ff94d559fa7332c
slackrole-gate-writesaccess-controlleast-privilegeingresssoc2gdpr-ccpa
What this policy does
Direction: ingress (tool_pre_invoke)
Default: deny on match, allow otherwise
Package: slack.ingress.role_gate_writes
What it does
Gates every Slack write-class tool behind an IdP group: callers whose JWT
groups claim contains slack-writers may send and schedule messages, add
or remove reactions, create and update canvases, and manage saved items and
user groups; everyone else gets a read-only Slack posture through the agent
channel. All read tools (search, history, channel/user lookups, profile and
canvas reads) pass for every caller.
The group check is fail-closed: if the caller has no subject.claims,
no groups claim, or a groups claim that is not a list of strings, write
tools are denied. A missing claim never grants write access.
Even the official server's low-risk slack_send_message_draft (creates an
unsent draft) is treated as a write — drafts are staged sends, and gating
them keeps the read-only posture unambiguous.
Compliance alignment
- SOC 2 CC6.1; CC6.3 — logical access security and role-based least privilege: Slack mutations through the agent channel require an explicit IdP group membership; the default posture is read-only.
- HIPAA §164.308(a)(4); §164.312(a)(1) — information access management and access control on the MCP path: for workspaces where channel and DM content can carry health-related disclosures, writes are authorized per caller identity, keyed to live IdP claims.
- PCI DSS 7.2.1; 7.2.2; 7.2.5 — least-privilege access model: agent-channel users get the minimum access (read) unless their role requires write, and the broad OAuth grant the Slack MCP server holds is narrowed per caller.
- GDPR Art. 25; Art. 29 — data protection by default on the agent channel, and processing of personal data only by persons acting under the controller's authorization.
- SOX ITGC — access to programs and data — least-privilege write access through the agent channel to a communication system whose messages can move market-relevant and financial information, keyed to live IdP group membership.
Why ingress
Slack writes are externally visible the instant they land — a sent message reaches humans (including external orgs via Slack Connect shared channels) and is effectively irreversible, a scheduled message time-shifts the send past any live session review, and user-group mutations change org paging and escalation structure. Denying at ingress means an unauthorized write never reaches Slack.
Tool name matching
The policy keys on the tool name only. It reads the tool name from
both the PARC input.resource.name and the legacy input.payload.name
— both are populated on tool hooks and carry the same value, and
payload.name is the actual invocation target — lowercases and normalizes
each (hyphens → underscores; a missing or non-string name coerces to the
empty string), and treats the call as a write if either name matches the
write vocabulary by suffix. Checking both fields means a request cannot
disable the gate by carrying the write name only under payload.name, by
leaving resource.name empty, or by planting a non-string value (an
object/number/array) in one field to poison the other — each field is
normalized independently, so a garbage value in one never suppresses
detection of a real write name in the other. The write vocabulary
covers all three server generations in real use (the DTwo gateway prefixes
tool names with the configured server name, so suffix matching stays
portable; normalization covers deployments that observed kebab-case naming):
- Official Slack MCP server (
mcp.slack.com, the Claude connector's server):slack_send_message,slack_schedule_message,slack_send_message_draft,slack_create_canvas,slack_update_canvas. - korotovsky community server:
conversations_add_message,reactions_add,reactions_remove,conversations_mark,saved_update,saved_clear_completed,usergroups_create,usergroups_update,usergroups_users_update. - Archived reference server (legacy, still widely forked):
slack_post_message,slack_reply_to_thread,slack_add_reaction.
Anything not on the write list — including every read tool of all three
server generations — is allowed for all callers. Verify the exact tool
names your gateway sends with the dump-input debug technique before
relying on this in production, and extend write_suffixes if your server
exposes additional mutating tools.
Argument shape
None assumed. The policy decides on the tool name alone and never inspects
input.payload.args.
The identity check reads input.subject.claims.groups via object.get
chains and expects an array of strings (the common IdP shape for a groups
claim). Group comparison is exact (case-sensitive).
Examples
Allowed (read tool, any caller)
{
"input": {
"action": "tool_pre_invoke",
"resource": { "name": "slack-mcp-slack_read_channel", "type": "tool" },
"subject": { "sub": "auth0|reader", "claims": { "groups": ["support"] } },
"payload": { "name": "slack-mcp-slack_read_channel", "args": { "channel_id": "C0123456789" } }
}
}
allow = true, no reason.
Allowed (write tool, group member)
{
"input": {
"action": "tool_pre_invoke",
"resource": { "name": "slack-mcp-slack_send_message", "type": "tool" },
"subject": { "sub": "auth0|writer", "claims": { "groups": ["slack-writers"] } },
"payload": { "name": "slack-mcp-slack_send_message", "args": { "channel_id": "C0123456789", "message": "shipping at 3" } }
}
}
allow = true, no reason.
Denied (write tool, non-member)
{
"input": {
"action": "tool_pre_invoke",
"resource": { "name": "slack-mcp-slack_send_message", "type": "tool" },
"subject": { "sub": "auth0|reader", "claims": { "groups": ["support"] } },
"payload": { "name": "slack-mcp-slack_send_message", "args": { "channel_id": "C0123456789", "message": "shipping at 3" } }
}
}
allow = false, reason = "Slack write tools are limited to members of the 'slack-writers' group — your Slack access through the agent channel is read-only. If you believe this is a false positive, ask your administrator to add you to the writers group.".
Composition
This policy is the baseline least-privilege layer for Slack; it composes with (does not replace) the targeted deny policies:
slack/deny-channel-creationandslack/deny-direct-messagesstill apply to members of the writers group — aslack-writersmember can post, but still cannot create channels or write into DMs while those policies are attached.slack/block-secrets— DLP on the message body for the sends this policy permits.slack/guard-dm-privacy— the read-side counterpart, gating DM/private-channel search and history.slack/redact-sensitive-info— egress masking on what comes back.
See the bundles/slack and
bundles/im-messaging bundles
for the curated sets.
Known limitations
- Group name is a placeholder. Replace
slack-writers(thewriters_groupconstant in the Rego) with your IdP's real group name at import time. Group comparison is exact and case-sensitive. - Groups claim must be an array of strings. The Rego guards on
is_array(caller_groups), so every other shape fails closed and denies all writes: a single string (e.g."slack-writers"), an object/map (a map's values are not treated as memberships — this guard is why), a number, ornull. If your IdP emitsgroupsas a string, a map, or a namespaced custom claim (e.g.https://acme.com/groups), pointcaller_groupsat the real array location — until then, all writes are denied for every caller (fail-closed). - Official tool names are observed-current, not contractual. Slack
publishes exact names only at runtime and says to treat
tools/listas the source of truth; names can change. The five official write names here are corroborated across catalogs and integration guides as of mid-2026 — re-verify after server updates. - Write list is a blocklist. New mutating tools added by a server
upgrade are allowed until added to
write_suffixes. Slack's docs also describe reaction, file, and channel/DM-creation capabilities on the official server whose tool names were not verifiable — they are deliberately not matched here (do not police guessed names). For a fail-closed posture on unknown tools, compose with a default-deny allowlist policy instead. - Suffix over-match. Generic suffixes like
reactions_addorsaved_updatecould match a non-Slack tool with the same ending on a shared pipeline. Scope the pipeline to the Slack server, or narrow the suffixes, if that is a concern. - Nameless requests pass. If a request carries no tool name under
either
resource.nameorpayload.name— or carries a non-string value (an object/number/array) in both fields, which each coerce to the empty string — no write suffix can match and the call is allowed; there is no tool name for this policy to gate. (A non-string in only one field does not open the gate: the other field is still checked, so a real write name there is still caught — see the poison-proof note under Tool name matching.) A normaltool_pre_invokealways names its tool, so this affects only malformed or mis-routed hooks; compose with a default-deny allowlist policy if you need unknown-shape requests denied outright. - Name-only decision. The policy cannot distinguish destinations or content — a writers-group member can post anywhere the token reaches. Compose with the targeted policies above to constrain what writers can do.
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 slack.ingress.role_gate_writes
# Deny-by-default: only the explicit allow rules below permit the request.
default allow := false
# Placeholder IdP group allowed to call Slack write tools.
# Replace with your IdP's real group name at import time.
writers_group := "slack-writers"
# Write-tool suffixes across the three Slack MCP server generations in real
# use. Matched against the lowercased, hyphen-normalized resource name (the
# gateway prefixes tool names with the configured server name, so we match
# by suffix to stay portable). Verify exact names with the dump-input debug
# technique before deploying.
write_suffixes := [
# --- Official Slack MCP server (mcp.slack.com; names observed via
# tools/list — Slack says to treat tools/list as source of truth) ---
"slack_send_message",
"slack_schedule_message",
"slack_send_message_draft",
"slack_create_canvas",
"slack_update_canvas",
# --- korotovsky community server (Slack-Web-API-style object_verb) ---
"conversations_add_message",
"reactions_add",
"reactions_remove",
"conversations_mark",
"saved_update",
"saved_clear_completed",
"usergroups_create",
"usergroups_update",
"usergroups_users_update",
# --- Archived reference server (legacy, still widely forked) ---
"slack_post_message",
"slack_reply_to_thread",
"slack_add_reaction",
]
# The tool identity can arrive under the PARC `resource.name` or the legacy
# `payload.name`. Both are populated on tool hooks and documented to carry the
# same value, and `payload.name` is the actual invocation target — so we check
# BOTH. Keying on `resource.name` alone lets a request disable the write gate
# by carrying the write name only under `payload.name` (or by leaving
# `resource.name` empty/absent), which would let every write through as a
# non-writer. `object.get(..., "")` makes a missing name normalize to the empty
# string, which matches no suffix, so a missing field never opens the gate.
#
# Hyphens are normalized to underscores so both kebab-case and snake_case
# deployments match the same suffix list (e.g. `slack-mcp-slack-send-message`
# and `slack-mcp-slack_send_message` both normalize to `..._slack_send_message`).
#
# A missing OR non-string name coerces to "" (matches no suffix). This is
# fail-closed for detection AND poison-proof: `lower()` errors on a non-string,
# which without the `is_string` guard would leave the rule undefined and make
# the `[resource_tool_name, payload_tool_name]` array undefined — silently
# disabling the write gate whenever EITHER field carried a non-string value
# (an object/number/array), even while the other field carried a real write
# name. Coercing per field keeps a non-string in one field from disabling
# detection of the write name in the other.
normalized_name(obj) := replace(lower(name), "-", "_") if {
name := object.get(obj, "name", "")
is_string(name)
}
normalized_name(obj) := "" if {
not is_string(object.get(obj, "name", ""))
}
resource_tool_name := normalized_name(object.get(input, "resource", {}))
payload_tool_name := normalized_name(object.get(input, "payload", {}))
is_slack_write_tool if {
some candidate in [resource_tool_name, payload_tool_name]
some suffix in write_suffixes
endswith(candidate, suffix)
}
# Fail-closed groups lookup: missing subject, missing claims, or a missing
# groups claim all resolve to [] and grant nothing.
caller_groups := object.get(
object.get(object.get(input, "subject", {}), "claims", {}),
"groups",
[],
)
# Exact, case-sensitive group match. The `is_array` guard makes every
# non-array shape fail closed: a string groups claim (e.g. "slack-writers")
# yields no bindings anyway, but an OBJECT/map claim would otherwise have
# `some group in caller_groups` iterate its VALUES — so a map whose value
# happened to equal the writers group would wrongly grant. Requiring an
# array first honors the documented "must be a list of strings" contract:
# string, object, number, and null groups claims all deny.
caller_is_writer if {
is_array(caller_groups)
some group in caller_groups
group == writers_group
}
# Read tools (anything not on the write list) pass for every caller.
allow if {
not is_slack_write_tool
}
# Write tools pass only for members of the writers group.
allow if {
is_slack_write_tool
caller_is_writer
}
reasons contains "Slack write tools are limited to members of the 'slack-writers' group — your Slack access through the agent channel is read-only. If you believe this is a false positive, ask your administrator to add you to the writers group." if {
is_slack_write_tool
not caller_is_writer
}
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