---
name: Slack Role-Gate Writes
tags:
  - slack
  - role-gate-writes
  - access-control
  - least-privilege
  - ingress
  - soc2
  - gdpr-ccpa
publishedAt: 2026-07-12
description: |
  # slack / role-gate-writes

  **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)

  ```jsonc
  {
    "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)

  ```jsonc
  {
    "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)

  ```jsonc
  {
    "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-creation`](../deny-channel-creation/policy.md) and
    [`slack/deny-direct-messages`](../deny-direct-messages/policy.md) still
    apply to members of the writers group — a `slack-writers` member can
    post, but still cannot create channels or write into DMs while those
    policies are attached.
  - [`slack/block-secrets`](../block-secrets/policy.md) — DLP on the message
    body for the sends this policy permits.
  - [`slack/guard-dm-privacy`](../guard-dm-privacy/policy.md) — the read-side
    counterpart, gating DM/private-channel search and history.
  - [`slack/redact-sensitive-info`](../redact-sensitive-info/policy.md) —
    egress masking on what comes back.

  See the [`bundles/slack`](../../../bundles/slack/README.md) and
  [`bundles/im-messaging`](../../../bundles/im-messaging/README.md) bundles
  for the curated sets.

  ## Known limitations

  - **Group name is a placeholder.** Replace `slack-writers` (the
    `writers_group` constant 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, or `null`. If your IdP emits `groups` as a string, a map, or a
    namespaced custom claim (e.g. `https://acme.com/groups`), point
    `caller_groups` at 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/list` as
    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_add` or
    `saved_update` could 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.name` or `payload.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 normal `tool_pre_invoke` always 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.
direction: ingress
apps:
  - slack
industries: []
bundles:
  - slack
  - im-messaging
  - soc2
  - gdpr-ccpa
experimental: true
schemaVersion: 1.0.0
minimumGatewayVersion: 1.0.0b24
---

```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)
}
```
