Skip to content

Slack-native ban: button + modal on request posts, restricted to authorized Slack users #152

@jakebromberg

Description

@jakebromberg

Problem

Once the core ban plumbing lands (#150, #151, WXYC/Backend-Service#1261, WXYC/wxyc-ios-64#351), banning a fingerprint still requires an operator to (a) find the offender's fingerprint somewhere, then (b) curl the admin API. That's awkward, undiscoverable, and pushes ops work out of the surface DJs are actually looking at: Slack.

DJs see the abusive request in Slack, where the post is already rendered with the listener's message and metadata. The natural place to act is right there.

This ticket replaces (supersedes) #149, which was drafted against device_id identity from a legacy BS auth system since confirmed dead. The ban-target is now the iOS-generated fingerprint UUID, attached to the Slack post as message metadata.

End state

A full Slack app for request-o-matic that:

  1. Replaces the incoming webhook for posting requests. Posts go through chat.postMessage with a bot token instead of requests.post(SLACK_WEBHOOK_URL, ...). Same visual output as today, but now interactive.
  2. Attaches a "Ban requester" button to each post that originated from an authenticated client (i.e. where request-o-matic has a fingerprint for the requester). Posts without an associated fingerprint get no button. The fingerprint is stored in the message's metadata.event_payload (Slack-native private metadata) — not visible to listeners, only retrievable by the bot on button-click.
  3. On button click: opens a Slack modal asking for a reason. On submit, request-o-matic:
    • Verifies the Slack request signature (v0= HMAC-SHA256 against SLACK_SIGNING_SECRET)
    • Authorizes the acting Slack user (see below)
    • Calls services/ban_service.py.ban(fingerprint, reason, actor=slack_user_id) — the same function Admin HTTP API for managing request-line fingerprint bans #151 uses. Not a parallel codepath.
    • Posts an ephemeral confirmation to the acting user, and edits the original message to show "banned by @ — "
  4. Authorization: only specific Slack users can ban. v1 mechanism: hardcoded allowlist in env (SLACK_BAN_AUTHORIZED_USERS=U01ABC,U02DEF,...). Reject (and log + Sentry-breadcrumb) all other users. Allowlist can graduate to a user-group or channel-membership check later.

Files / changes

  • services/slack.py — switch from incoming-webhook POST to chat.postMessage with SLACK_BOT_TOKEN; attach fingerprint as Slack message metadata; add the "Ban requester" button block when fingerprint is known
  • routers/slack_interactivity.py (new) — handles POST /slack/interactivity (the URL Slack calls when buttons are clicked or modals submitted)
  • services/slack_signature.py (new) — request-signature verification middleware
  • services/slack_authorization.py (new) — Slack user-ID allowlist check
  • services/ban_service.py — already created by Admin HTTP API for managing request-line fingerprint bans #151; this ticket just calls into it
  • config/settings.py — new env vars: SLACK_BOT_TOKEN, SLACK_SIGNING_SECRET, SLACK_BAN_AUTHORIZED_USERS, SLACK_CHANNEL_ID. Deprecate SLACK_WEBHOOK_URL (keep as fallback for one release, then remove).
  • Slack app setup outside the repo: register a new Slack app, request scopes (chat:write, chat:write.public if posting to public channels), install to the workspace, register the request URL for interactivity, capture the bot token.
  • Update CLAUDE.md, README.md with the new Slack-app setup runbook.

Constraints

  • Don't fork the ban codepath. The Slack handler must call services/ban_service.py.ban(...) (created in Admin HTTP API for managing request-line fingerprint bans #151), passing actor=slack_user_id. The HTTP admin API and the Slack action are two frontends on one mechanism. Sketch:
    routers/admin.py                  ─┐
                                       ├──→ services/ban_service.py.ban(...) ──→ BS /internal/banned-fingerprints
    routers/slack_interactivity.py    ─┘
    
    This keeps audit trails consistent (actor field gets logged whether the source is a curl or a Slack click) and ensures the two paths can't drift.
  • Don't bypass signature verification. Every Slack callback must pass HMAC verification before any other processing. This is non-negotiable — an unverified callback is forgeable.
  • Don't post the "Ban requester" button on posts with no fingerprint. Unauthenticated v3.1 callers and degraded-mode posts have nothing to ban. Showing a button that 500s on click is worse than showing no button.
  • Keep chat.postMessage channel ID configurable. With bot-token posting, channel ID is explicit (env var SLACK_CHANNEL_ID). Default to the same channel as the current webhook.
  • Don't break current Slack posting during migration. Ship the Slack-app post path behind a feature flag (SLACK_USE_BOT_TOKEN) and migrate gradually, OR stage as two PRs (migration first, interactivity second — see "Suggested approach").
  • Don't expose the fingerprint as visible text in the message. It's a stable per-user identifier — putting it in user-visible content is a deanon vector. Use Slack's metadata.event_payload (private metadata) or the button value field (visible only to button handlers).

Suggested approach

Land as two PRs:

  1. PR 1 — Slack-app migration. Replace incoming webhook with bot-token chat.postMessage. No UX change. Pure plumbing. Behind a feature flag (SLACK_USE_BOT_TOKEN) for one release so we can roll back if chat.postMessage behaves differently than the webhook.
  2. PR 2 — Interactive ban action. Add the button + modal + interactivity endpoint + signature verification + authorization. Requires PR 1 as a base.

If the implementer prefers one bigger PR, that's fine — but the rollback story is cleaner with the split.

Open questions

  1. Edit-the-message-on-ban vs ephemeral-confirmation-only. Editing the original post to "banned by @dj — reason" gives a public audit trail for everyone in the channel; ephemeral confirmation is private to the DJ. Recommend: both — edit the message (small footer) AND post an ephemeral ack.
  2. Authorization graduation path. Hardcoded allowlist is v1. Future: Slack user-group membership (@wxyc-dj-staff via usergroups.users.list), or channel-membership in a specific ops channel.

Acceptance

  • Slack app registered, bot token + signing secret stored as env vars on staging and prod
  • chat.postMessage replaces incoming webhook (behind feature flag during transition)
  • "Ban requester" button appears on posts with a fingerprint, not on posts without
  • Button click opens a modal asking for reason
  • Submit verifies Slack signature → checks allowlist → calls services/ban_service.py.ban(...) → posts ephemeral ack + edits original message
  • Unauthorized clicker gets an ephemeral "you don't have permission" message and is logged
  • Unit tests for signature verification (positive + negative cases), authorization check, and the full happy-path flow with mocked Slack payloads
  • CLAUDE.md / README.md updated with Slack-app setup runbook
  • No regression: existing tests for services/slack.py still pass under the new posting path

Related

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions