Multi-tenancy Phase 4: the distributed Slack app (CHOO-2626) - #435
Closed
petr-sandbox wants to merge 2 commits into
Closed
petr-sandbox wants to merge 2 commits into
petr-sandbox wants to merge 2 commits into
Conversation
This was referenced Sep 11, 2026
petr-sandbox
force-pushed
the
work/multi-tenancy-phase4
branch
from
September 14, 2026 21:51
671b79d to
f024427
Compare
petr-sandbox
force-pushed
the
work/multi-tenancy-phase4
branch
from
September 15, 2026 13:29
436f01c to
c8af4d8
Compare
This was referenced Sep 15, 2026
Merged
Collaborator
Author
|
Split into three stacked PRs for review — #474 (schema and protocol) → #475 (the install flow) → #476 (ending an install). 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
force-pushed
the
work/multi-tenancy-phase4
branch
4 times, most recently
from
September 17, 2026 20:09
7bf1cc4 to
07bf79b
Compare
`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
force-pushed
the
work/multi-tenancy-phase4
branch
from
September 17, 2026 20:18
07bf79b to
804d14d
Compare
Collaborator
Author
|
Implemented separately. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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_installsand 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_installjoins the closed list ofSECURITY DEFINERlookups, 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.
beginmints a signed, single-use state naming the tenant;completeburns 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_eventin 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 fromGATEWAY_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/messagingin 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
config.messaging_public_urljoined to a computed path, and as the literal placeholderHOSTindocs/old/bridges/SLACK_DISTRIBUTED_APP.md. Substituting it is a documentation change and a Slack app manifest, not a code change./messagingneeds one, and the ingress work is in a separate repo.event_deliverydiscriminator on the Slack connection config; the operator-facing side of that coexistence is not finished.🤖 Generated with Claude Code