Skip to content

Multi-tenancy Phase 4: the distributed Slack app (CHOO-2626) - #435

Closed
petr-sandbox wants to merge 2 commits into
mainfrom
work/multi-tenancy-phase4
Closed

petr-sandbox wants to merge 2 commits into
mainfrom
work/multi-tenancy-phase4

Conversation

@petr-sandbox

@petr-sandbox petr-sandbox commented Sep 11, 2026

Copy link
Copy Markdown
Collaborator

Draft. The mechanism is built and tested; the Slack app itself does not exist yet and the public hostname is not stood up. Nothing here can be turned on until both land — see What is missing below.

What this is

Today a Slack bridge is an operator pasting a bot token from an app they registered themselves. That does not scale past us: a customer cannot register our app, and a distributed app on the Slack Marketplace cannot use Socket Mode. So this adds the other way in — a customer clicks Add to Slack, and a bridge appears that is indistinguishable from one an operator typed in by hand.

The seam that makes that true is connection_config: an installer's last act is to render the grant into exactly the dict the platform's adapter already takes, so every line of lifecycle, validation and start-up code is shared. An installed bridge and a self-registered one differ in where the token came from, and in nothing else.

An install connects a workspace to a tenant that already exists. Creating a tenant is Switch Console's job and is not reachable from Slack: the flow begins with an authenticated admin inside the tenant they are installing into.

The commits

messaging_installs and the lookup. One row per installed workspace, tenant-scoped like everything else, with a deployment-wide unique constraint on (platform, external_workspace_id) — one workspace's events have exactly one destination. The bot token is encrypted at rest with the existing bridge-config key. tenant_of_messaging_install joins the closed list of SECURITY DEFINER lookups, because a workspace id is the only thing an inbound event carries and it has to become a tenant before anything can be scoped.

The install protocol. MessagingAppInstaller — per deployment, holding the credentials of the app we registered, as against an adapter which is per bridge and per customer. Registration is the feature flag: an installer exists when a platform's app credentials are configured, and the endpoints refuse when they are not, so a deployment cannot half-offer installs.

The round trip. begin mints a signed, single-use state naming the tenant; complete burns it before redeeming the code, exchanges the grant, writes the install and registers the bridge. The state is signed rather than a cookie because the gateway and the public callback are different origins — a cookie set on the first leg is not sent on the second. Admin-initiated: the tenant comes from the caller's session.

The inbound webhook. Verify the signature over the raw bytes before anything reads the body; parse per endpoint; resolve the workspace to a tenant; re-read the install row scoped to that tenant, so a wrong answer above is a miss rather than a cross-tenant read. Events over HTTP reach dispatch_event in exactly the shape Socket Mode delivers, with no tenant bound, so there is one path from there down rather than two that drift.

MESSAGING_PUBLIC_URL. The origin Slack dials, separate from GATEWAY_PUBLIC_URL. The latter is the host a person lands on following an "Open in Switch Console" deeplink and is routinely private; reusing it would have meant repointing every deeplink in order to satisfy Slack. Required whenever the app is configured, refused if it carries a path or is not https.

Chart wiring. switchCore.slackApp, off by default, plus /messaging in the routed path list and in the sample Ingress. Enabling it without the credentials or the origin fails at render rather than producing a pod that starts and cannot complete an install.

Two decisions worth arguing with

Status codes are for Slack, not for a reader. Slack retries a 5xx, gives up on a 4xx, and counts failures against the app as a whole. So: 401 unverified, 400 unreadable, 404 for a workspace nobody here has installed, 503 when the bridge is not running. That last one is the interesting one — a 200 there would discard a real message and report it handled, which is the failure that reads as "Slack lost a message" and is never found.

The callback replies with a page, not a redirect. Sending the browser to the gateway works today, when the installer is an operator who can reach a private hostname, and breaks the moment a customer does it. When there is somewhere to send people this becomes a redirect and the page becomes its fallback.

Tests

Real Postgres under the restricted role throughout, because row-level security is half the argument and a mock has no policies. The webhook tests drive the real router over the real Slack installer — including the real signature check — with two tenants installed into two workspaces, asserting each event reaches one bridge and no part of it reaches the other's. A single-tenant test would pass against a router that ignored the payload entirely.

What is missing

  • No Slack app is registered. Nothing here is reachable until one is.
  • The public hostname does not exist yet. It appears only as config.messaging_public_url joined to a computed path, and as the literal placeholder HOST in docs/old/bridges/SLACK_DISTRIBUTED_APP.md. Substituting it is a documentation change and a Slack app manifest, not a code change.
  • The deployment has no HTTPS front door. Serving /messaging needs one, and the ingress work is in a separate repo.
  • Socket Mode and installed workspaces coexist through the event_delivery discriminator on the Slack connection config; the operator-facing side of that coexistence is not finished.

🤖 Generated with Claude Code

@petr-sandbox

Copy link
Copy Markdown
Collaborator Author

Split into three stacked PRs for review — #474 (schema and protocol) → #475 (the install flow) → #476 (ending an install). git diff work/multi-tenancy-phase4 work/messaging-install-lifecycle is empty, so the three reproduce this branch exactly.

Leaving this one open as the umbrella until the stack lands; close it whenever you prefer.

🤖 Generated with Claude Code

lbangalosbt added a commit that referenced this pull request Sep 16, 2026
…en decisions

Break the design spike into reviewable, commit-sized implementation stages
mirroring the distributed Slack PR (#435: schema -> install protocol ->
completing flow -> inbound routing), each naming the real seams it touches and
its own tests, and record the decisions taken while reviewing it:

- connection ownership: the single Gateway connection is owned by the
  forced-singleton switch-core pod (chart hard-fails on replicaCount != 1); no
  leader election needed until switch-core is ever made horizontally scalable.
  Cite the *current* reason for the singleton (in-process event buffer, invite
  bus, presence bus, message-listener registry), not the deleted Matrix code.
- tokenless grant: keep encrypted_bot_token nullable, but note the token
  invariant for token-based platforms is enforced by the connection-config
  validator, not the DB column; and that InstallGrant/record_install/complete
  all change, not just the column.
- message content: a runtime flag defaulting to mention-only, because
  requesting an unapproved privileged intent closes the connection (4014)
  rather than degrading; verification is per-app and its threshold counts guilds
  across all tenants.
- ABC mismatch: stub the webhook methods on the Discord installer now, split the
  ABC later.
- dispatch with no tenant bound (handlers bind per-room), matching the Slack
  path; route tenant resolution through the allowlisted install_service.resolve.

Also fix stale "Matrix sync sessions" wording in the switch-core replicaCount
guard and singleton comment (Matrix was deleted; the message bus is Postgres
LISTEN/NOTIFY) so the guard names the real in-process state.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@petr-sandbox
petr-sandbox force-pushed the work/multi-tenancy-phase4 branch 4 times, most recently from 7bf1cc4 to 07bf79b Compare September 17, 2026 20:09
petr-sandbox and others added 2 commits September 17, 2026 16:18
`DELETE /gateway/collaborations/{id}` deletes every room on the bridge and
then removes the bridge. `messaging_installs.bridge_id` is a real foreign key
with no `ON DELETE`, so on an install-created bridge that ordering played out
as: rooms irreversibly deleted, Postgres refuses the bridge deletion, operator
gets a 500 — and the Slack app is still installed with a token nobody revoked.

Refuse with a 409 before any room is touched, naming the workspace and
pointing at Disconnect, which revokes the token at the platform first.

`MessagingInstallStore.get_for_bridge` asks the question from the bridge's
side. It is reached through a new `get_install_store` dependency rather than
`get_install_service`, which is None on a deployment that registered no app of
its own — install rows outlive those credentials, and a bridge built by an
install has to stay protected after they are taken away.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…rd (CHOO-2626)

The install flow had no operator surface: the endpoints existed and nothing
called them. Adds an "Installed apps" section to the Messaging Apps page —
one button per platform this deployment can install, the organisation's
installs with their status and scopes, and Disconnect.

The section is absent entirely when the deployment registered no app of its
own and has no installs on record, which is most of them.

Disconnect carries the warning that has nowhere else to live: rooms on the
connection survive but become internal-only, and installing again creates a
new connection rather than reattaching them. Its 502 — the platform refused
to revoke, nothing was destroyed — is shown in the dialog so the operator can
retry, rather than closing on it.

`deleteBridge` now throws instead of returning false. It could only report
"it didn't work", and a connection an install created is refused with a 409
saying to disconnect the app instead — advice the operator needs to see
rather than click Delete again.

Webhook-versus-socket delivery is explained in the section's copy rather than
offered as a field. It is not the operator's to choose: the install sets it,
and the bridge-registration form hides it for that reason.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@petr-sandbox
petr-sandbox force-pushed the work/multi-tenancy-phase4 branch from 07bf79b to 804d14d Compare September 17, 2026 20:18
@petr-sandbox

Copy link
Copy Markdown
Collaborator Author

Implemented separately.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant