Skip to content

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

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

petr-sandbox wants to merge 15 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 and others added 7 commits September 15, 2026 09:29
…o a tenant

The table behind the official messaging app: one row per external workspace a
tenant has installed us into, holding the token that install granted. Slack is
the first platform to use it; the shape is deliberately platform-agnostic
because Teams, Discord and Telegram each need the same row.

(platform, external_workspace_id) is unique across the deployment rather than
per tenant. Inbound events arrive over one public endpoint carrying a
workspace id and no tenant, so a workspace claimed twice is an event with two
possible destinations and no way to choose. The database decides it, because a
read-then-insert in application code cannot be made atomic. The cost is that a
tenant claiming a claimed workspace learns it is claimed; the alternative is a
silent second claim, discovered when a customer's messages arrive in somebody
else's rooms.

tenant_of_messaging_install is an addition to the closed set of SECURITY
DEFINER functions that are exempt from row-level security, and is meant to be
argued with rather than waved through. The argument: the webhook is
unauthenticated by nature and holds nothing but a workspace id, what comes back
is a tenant id and never a row, and without it there is no way to bind a tenant
before touching the payload — which is the only order in which the payload may
be touched.

It is the first lookup to take two arguments, so TenantLookup carries a tuple
of them and each bind is named after the argument it fills. A lookup on the
workspace id alone would answer twice the first time two platforms minted the
same string, and the caller refuses an ambiguous answer rather than picking —
so one customer's traffic would start failing for a reason in another
platform's namespace.

bridge_id is nullable: the install row is written before anything is built on
it, and removing a bridge should not force the credential to be re-granted.
Without the column the webhook would have to find its bridge by string-matching
a workspace id inside JSON.

encrypted_bot_token uses the same key as every other credential this schema
stores, so it is protected against a stolen dump and not against a compromised
process. A per-tenant key is a stronger boundary and a later decision.

Two frozen-copy comparisons needed the other direction added. 9c41a7b0e5d8
froze the lookups and 265ed188ad6f froze the scoped-table list, and both tests
compared their copy against the live module exactly; a lookup or a table added
afterwards is legitimately absent from them. _ADDED_SINCE and _POLICIED_SINCE
name what was added and the revision that installs it, and each is paired with
a test that the named revision really renders the same DDL — loosening either
comparison to a subset check would pass just as happily for a function or a
policy that exists in create_all and in no migration at all.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…626)

Everything an app install needs that does not depend on the public hostname,
which is still undecided. The host appears only as `GATEWAY_PUBLIC_URL` joined
to a path computed at runtime, and as the literal placeholder `HOST` in the
walkthrough.

The install layer is generic and the Slack implementation is the only one:
`MessagingAppInstaller` is per deployment and holds *our* app's credentials,
where an adapter is per bridge and holds a customer's. The seam between them
is `connection_config` — an installer's last act renders the grant into the
dict the adapter already takes, so the lifecycle service registers, validates
and starts an installed bridge exactly as it does one an operator typed in.
Registration is the feature flag: an installer exists when its credentials are
configured, and the endpoints refuse when it does not.

`messaging_install_states` records one in-flight install. The row is not what
carries the tenant across — the signed state is — so the flow needs no ninth
`SECURITY DEFINER` lookup and the closed list stays as short as it is. What the
row adds is single use, which a signature cannot give: without it, replaying a
captured state installs an attacker's workspace against the victim's tenant and
injects messages into their rooms.

Slack takes events two incompatible ways, so `SlackConnectionConfig` gains a
hidden `event_delivery` discriminator and a validator refusing both half-states.
A Socket Mode bridge with no app token would send fine and receive nothing —
the shape of failure that reads as "Slack is quiet today" for a week.

Two guards were tripped on purpose and updated deliberately:

- `/messaging` joins the unauthenticated allowlist. Its callers are Slack and a
  browser mid-redirect, neither of which holds a credential of ours; nothing
  under it discloses a version, and every route proves the platform's signature
  before it acts.
- `app_token` is no longer required by the Slack config schema, because a
  webhook bridge has none. The model validator, not the form, is what enforces
  it under Socket Mode.

The walkthrough is checked against the code rather than trusted: a test parses
the manifest out of the markdown and compares scopes, redirect, request URLs
and every slash command against what the installer actually asks for and the
paths this application actually serves.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…g bridge (CHOO-2626)

Both legs of the OAuth flow and everything between them. Still host-agnostic:
the redirect is `GATEWAY_PUBLIC_URL` joined to a path, never derived from the
incoming request, which is also the only way the two legs agree on a string
the platform compares byte for byte.

The state token is what makes the round trip work at all. The gateway and the
public callback are different origins by deployment, so a cookie is not sent to
the second leg; and the callback runs with no tenant bound, so it cannot read
its way to one without a ninth SECURITY DEFINER lookup. Instead the state is
signed under a key derived from JWT_SECRET_KEY — derived, not reused, so an
install state can never be mistaken for an agent's JWT — and names the tenant.
The callback verifies it, binds that tenant, and from there is an ordinary
scoped request that RLS checks like any other.

The finishing order is the part worth reviewing:

  verify the signature, bind the tenant, burn the state and commit, exchange
  the code, claim the workspace, register the bridge, point the install at it.

Burning before the network call, in its own transaction, costs a restarted
ten-second flow when the platform is down and buys a captured state being worth
nothing. Burning inside the rest would hold a row lock across a call to
someone else's API. The claim is the insert, so a workspace another tenant
already holds fails in the database rather than in a check racing above it —
and no bridge is built for it.

Two seams were wrong and are fixed here:

- The rendered connection config omitted the webhook delivery mode, so it
  validated as a Socket Mode bridge with no app token and registration would
  have failed. The test had been helping it along by adding the key; it now
  validates exactly what `register` is handed.
- A grant carried no workspace name, leaving the bridge to be named after an
  opaque platform id in the operator's list.

Registration stays the feature flag: the installer exists when the app
credentials are configured, the gateway endpoint answers 501 when it does not,
and the public routes are not mounted at all.

The callback answers with a page rather than a redirect. Sending the browser
to the gateway works only while the person installing is an operator who can
reach a private hostname, and this flow is meant to outgrow that.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…tenant

A distributed Slack app cannot use Socket Mode, so events arrive as signed
HTTP posts at one public URL shared by every tenant on the deployment. The
request carries no credential of ours and no tenant: what identifies the
customer is the workspace id inside a body the platform signed.

The path is three steps, deliberately separate. Verify the signature over the
raw bytes, before anything reads the body — parsing first is how an
unauthenticated body chooses which code runs. Parse it into the two arguments
Socket Mode's own listener takes, so an event over HTTP and the same event
over a socket reach `dispatch_event` indistinguishable from one another,
including with no tenant bound. Then resolve the workspace to a tenant through
the existing `tenant_of_messaging_install`, and re-read the install row scoped
to that tenant — so a wrong answer above is a miss rather than a cross-tenant
read, rather than adding a ninth exemption from row-level security.

Status codes are chosen for what the platform does with them, not for a
reader: 401 unverified, 400 unreadable, 404 for a workspace nobody here has
installed, and 503 when the bridge is not running, because a retry while a
bridge restarts is better than a 200 that discards a real message and reports
it handled. The event is acknowledged before it is handled, since a turn can
take minutes and the platform's deadline is seconds.

`url_verification` is answered inline: it names no workspace, because it
arrives before anyone has installed anything, so a handshake that had to
resolve a tenant could never be answered and the app could never be
configured.

Tests drive the real router over the real installer and real Postgres, with
two tenants installed into two workspaces, asserting each event reaches one
bridge and no part of it reaches the other's.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…t exist

The install and webhook endpoints live on the agent-bridge app, so a managed
ingress has to send their prefix there rather than to the gateway SPA.

Also states in the distributed-app page what the flow already enforces: an
install connects a workspace to a tenant that exists already, and creating one
is not reachable from Slack.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…c origin

The install redirect and the three Slack event URLs were built from
GATEWAY_PUBLIC_URL, which is the host a person lands on following an "Open in
Switch Console" deeplink. On a deployment whose gateway sits on a private
network that host is exactly the wrong one: Slack has to dial it from the
internet over TLS. Repointing the gateway URL to satisfy Slack would have
moved every deeplink with it.

So the origin Slack sees is its own setting, required whenever the distributed
app is configured, validated as scheme-and-host with no path and refused if it
is not https — Slack will not register an http URL, and accepting one here
only defers the failure to a place with no logs.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
An operator could configure the app in the application and had no way to say
so through the chart. Adds switchCore.slackApp — off by default, since a
self-hosted install wants the per-tenant Socket Mode bridge instead — with the
two credentials going through the Secret like the OIDC one and the client id
and public origin as plain env. Enabling it without either is a render-time
failure rather than a pod that starts and cannot complete an install.

Also names, in the values comments and the sample manifest, the timeout that
cuts an idle MCP stream on a cloud load balancer: the chart's streaming
annotations are ingress-nginx's, and an ALB idles a connection out after 60
seconds regardless of them.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@petr-sandbox
petr-sandbox force-pushed the work/multi-tenancy-phase4 branch from 436f01c to c8af4d8 Compare September 15, 2026 13:29
petr-sandbox and others added 4 commits September 15, 2026 09:36
…O-2626)

`messaging_installs` made `(platform, external_workspace_id)` unique across
the deployment so that an inbound event from a workspace has exactly one
tenant to go to. That is still the guarantee. The constraint enforcing it was
too strong: nothing deletes an install row, so the claim outlived the install
and a workspace could be connected once, ever. The error a customer got told
them to remove the existing install, through a path that did not exist.

What has to be unique is the set of installs that are serving. The constraint
becomes a unique index over `status = 'active'`, under the same name — the
store reads that name out of the integrity error to tell a claimed workspace
from any other failed write, and Postgres reports a unique index by the name a
constraint would have carried.

`tenant_of_messaging_install` is redefined against the same predicate, and
that part is load-bearing rather than tidy. It resolves a workspace to a
tenant for traffic nobody has authenticated, and it was written relying on the
old constraint to answer at most once; the caller refuses an ambiguous answer.
Left alone, the first workspace to be installed, released and installed again
would make it answer twice, and the customer's live install would stop
receiving events because of one they had themselves ended. `get_for_workspace`
takes the same predicate for the same reason.

`ended_at` records when, alongside the `status` that says which of the two
ways it ended — a decision here, or news from the platform. An operator
looking at a bridge that stopped working needs to know which.
`encrypted_bot_token` becomes nullable so an install that has ended can keep
its record without keeping its secret.

Nothing yet writes any of this: no store method ends an install and no route
calls one. This is the schema that makes those possible.

`_REDEFINED_SINCE` is new bookkeeping in the lookup test. Creating and
dropping a function both show up as a function that is there or is not; a
redefinition leaves one of the right name and signature answering a different
question, which nothing about the shape of the schema reveals.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
An install could be made and never unmade. Disconnecting one now revokes
the credential at the platform, removes the bridge, and marks the row
ended so the workspace is free to be installed again — and the two events
by which a platform says an install is over are read the same way.

The order is deliberate. The platform is told first, so a refusal nobody
understands leaves the install exactly as it was and the operator can try
again rather than finding the bridge gone and the token still live. The
row is ended next, which also releases its pointer at the bridge — the
foreign key has no ON DELETE, so nothing could delete the bridge while the
install still named it. The bridge goes last: if that fails, what is left
is a credential-less bridge an operator can see and remove, not an install
still claiming a workspace it has been thrown out of.

Ending twice is success on both paths. Slack redelivers its uninstall
event, and an operator can click disconnect on a row a redelivery ended a
second earlier; neither is a fault to report.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The operator's two: list this organisation's installs, ended ones
included, and disconnect one. The list keeps ended rows deliberately —
somebody looking at it is usually looking because something stopped
working, and "nothing here" is the wrong answer to "what happened to the
bridge that was here yesterday". Neither carries a token or anything
derived from one.

On the webhook side, an uninstall is now read before the event is routed
rather than after. It has to be: resolving insists on a running bridge,
and this is the one event that arrives as the bridge goes away, so the
ordinary path would drop it exactly when it mattered.

An event for a workspace nobody holds now answers 200 instead of 404.
That is the single place this endpoint says something other than what
happened, and it is deliberate: an app left in a workspace whose install
ended posts for as long as someone leaves it there, the platform cannot
act on the refusal, and it counts refusals against the app as a whole —
so the honest answer would be paid for by every other customer's
delivery. The drop is in the log.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The desktop app ships a pinned copy of deploy/local/standalone-docker-compose.yml
and a check fails when the two drift. Adding the distributed Slack app's four
variables to the source left the copy behind.

All four default to empty, so a managed server that configures none of them
starts exactly as before.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@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

petr-sandbox and others added 4 commits September 15, 2026 10:20
The bundled compose now interpolates MESSAGING_PUBLIC_URL and the three
SLACK_APP_* secrets, and env-file.test.ts requires every interpolated
variable to be set in the generated .env — a `${VAR:-}` default included,
deliberately, since that is how GATEWAY_PUBLIC_URL came to be silently
omitted and the deeplink redirect disabled on every managed stack.

These four are genuinely unset rather than forgotten. A managed stack
binds to loopback, so no platform can reach its callback or event URLs,
and the credentials belong to whoever registered the app rather than to
the machine running the console. switch-core registers no installer
without them and the operator UI says as much, so the omission is
visible rather than a button that fails at Slack.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Slack allows three seconds to acknowledge an event and re-sends whatever it
does not get an answer to. The payload of a retry is byte-identical to the
original, so nothing below the route could tell it from a second person saying
the same words — and the visible failure is an agent replying twice to one
question in a customer's channel. A distributed app cannot ship without this:
it is the ordinary consequence of one slow room, not an edge case.

`messaging_event_receipts` is the record of what has been taken. A row is
written *before* the work and the unique index arbitrates: two retries in
flight together both reach the insert and exactly one survives. Recording
afterwards would order the two the wrong way round — both would dispatch and
the duplicate would be noticed once it no longer mattered. The claim is
committed before the dispatch, because an uncommitted index entry makes a
concurrent retry block for the length of an agent's turn rather than lose
immediately.

That ordering chooses at-most-once, which is worth stating plainly: an event
claimed by a process that then dies is not retried. It is not a new loss — the
route has acknowledged before handling since it was written, because the
deadline is shorter than a turn — but `handled_at` is what makes it visible, so
a claimed receipt that never completed can be found.

Uniqueness is `(tenant_id, platform, external_event_id)` rather than
deployment-wide. The two protect equally, since a Slack event id is unique in
its own namespace and a workspace belongs to one tenant; the tenant-local index
keeps one customer's ids out of another's namespace and makes every conflict a
row the inserting tenant can see.

Deduplication sits after `resolve` and not before it, which keeps the table
ordinary RLS-scoped and adds no `SECURITY DEFINER` exemption. It also composes
with the 503 a restarting bridge already answers: that path writes no receipt,
so the retry it asks for is handled rather than dropped.

Only numbered envelopes are claimed. Slack numbers Events API deliveries and
retries only those; a slash command and an interaction arrive once with no id,
so they dispatch unclaimed. A missing id means "the platform does not retry
this", never "this was not checked".

`X-Slack-Retry-Num` is read as a hint — it is outside the signature, so a
forged value can do no more than put a wrong number in a log line. Its one use
is a warning: a run of retries is the only signal this deployment gets that its
own acknowledgements are arriving too late.

Pruning is opportunistic, on the traffic that creates the rows, following the
`role_leases` precedent. There is no row-deleting janitor anywhere in this
backend and inventing one for a single table would be the larger change.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`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>
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