From 45d1c664929fb3b4249d828444314505b91f4640 Mon Sep 17 00:00:00 2001 From: Petr Bauch Date: Fri, 11 Sep 2026 14:07:23 +0200 Subject: [PATCH 01/29] feat(db): messaging_installs and the lookup that routes a workspace to a tenant MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- core/switch_core/db/models.py | 74 +++++++++ core/switch_core/db/tenant_lookup.py | 127 ++++++++++----- .../c8a4e21f6d30_messaging_installs.py | 126 +++++++++++++++ .../db/test_messaging_install_claim.py | 151 ++++++++++++++++++ .../switch_core/db/test_row_level_security.py | 14 +- .../switch_core/db/test_tenant_lookup.py | 114 ++++++++++--- 6 files changed, 547 insertions(+), 59 deletions(-) create mode 100644 core/switch_core/migrations/versions/c8a4e21f6d30_messaging_installs.py create mode 100644 core/tests/switch_core/db/test_messaging_install_claim.py diff --git a/core/switch_core/db/models.py b/core/switch_core/db/models.py index f61bfb12f..2ecd40132 100644 --- a/core/switch_core/db/models.py +++ b/core/switch_core/db/models.py @@ -1131,6 +1131,80 @@ class CollaborationBridge(TenantScoped, Base): ) +# ── Messaging Installs ───────────────────────────────────────────────────────── + + +class MessagingInstall(TenantScoped, Base): + """A tenant's installation of the Switch app into one external workspace. + + The difference from `collaboration_bridges` is who supplied the + credential. A bridge holds a token an operator pasted in, from an app that + operator registered; an install holds a token *we* were granted, for our + app, by whoever clicked Add to Slack. Both end up driving the same adapter, + so this table records only what the install added: which workspace, whose + token, and what it may do. + + **`(platform, external_workspace_id)` is unique across the whole + deployment, not per tenant**, and that is the single most important line + here. Inbound events arrive over one public endpoint carrying a workspace + id and no tenant, so a workspace claimed by two tenants is a message with + two possible destinations and no way to choose — which is the failure this + whole phase exists to make unrepresentable. The database decides it rather + than a read-then-insert in application code, because the check and the + write cannot be made atomic from outside. + + That constraint is also the one place a tenant learns something about + another: claiming a workspace somebody else already claimed fails, and the + failure says so. It is the right answer — the alternative is a silent + second claim — and what it discloses is that *some* tenant holds a + workspace the caller was already able to name. + + `bridge_id` is nullable because the install row is written before anything + is built on it, and because removing a bridge should not force the + credential to be thrown away and re-granted. A null there means the + install is recorded and not yet serving. + + `encrypted_bot_token` uses the same key as every other credential this + schema stores (`crypto.encrypt_token` over the configured secret), 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. + + `scopes` is the platform's own spelling of what was granted, stored + verbatim rather than parsed into a list — a scope string that means + nothing to us is still the thing to show an operator asking why a call was + refused. + """ + + __tablename__ = "messaging_installs" + __table_args__ = ( + UniqueConstraint( + "platform", + "external_workspace_id", + name="uq_messaging_installs_workspace", + ), + UniqueConstraint("id", "tenant_id", name="uq_messaging_installs_id_tenant"), + ForeignKeyConstraint( + ["tenant_id", "bridge_id"], + ["collaboration_bridges.tenant_id", "collaboration_bridges.id"], + name="fk_messaging_installs_bridge", + ), + ) + + id: Mapped[str] = mapped_column(Text, primary_key=True, default=_uuid) + platform: Mapped[str] = mapped_column(Text, nullable=False) + external_workspace_id: Mapped[str] = mapped_column(Text, nullable=False) + encrypted_bot_token: Mapped[str] = mapped_column(Text, nullable=False) + scopes: Mapped[str] = mapped_column(Text, nullable=False) + status: Mapped[str] = mapped_column(Text, nullable=False) + installed_by_user_id: Mapped[str] = mapped_column( + Text, ForeignKey("users.id"), nullable=False + ) + bridge_id: Mapped[str | None] = mapped_column(Text, nullable=True) + installed_at: Mapped[str] = mapped_column( + DateTime(timezone=True), server_default=func.now(), nullable=False + ) + + # ── Server-Side Connectors ──────────────────────────────────────────────────── diff --git a/core/switch_core/db/tenant_lookup.py b/core/switch_core/db/tenant_lookup.py index dcb3fbcfe..adcd78dfd 100644 --- a/core/switch_core/db/tenant_lookup.py +++ b/core/switch_core/db/tenant_lookup.py @@ -1,4 +1,4 @@ -"""The whole exemption from row-level security, written out as eight functions. +"""The whole exemption from row-level security, written out as nine functions. Row-level security is enforced by `require_tenant_id()` (`db/rls_ddl.py`), which raises when no tenant is bound. That is the property everything else @@ -64,8 +64,9 @@ So the property this design actually holds is: **the exemption discloses the shape of the deployment — which tenants exist, and which tenant a given user, -credential, room, bridge, connector or invitation belongs to — and no row of -any tenant-scoped table.** It is a boundary on data, not on metadata. Narrowing +credential, room, bridge, connector, invitation or installed workspace belongs +to — and no row of any tenant-scoped table.** It is a boundary on data, not on +metadata. Narrowing the second is a question about who may hold the runtime role's credentials at all, since everything above is reachable by anyone who has them. @@ -108,6 +109,19 @@ only the tenant id does, which is the property every lookup in this module rests on. +**`tenant_of_messaging_install` is the ninth, and the only one whose caller is +not a person.** An event from the Switch app installed in a customer's Slack +arrives over a public endpoint that no one has authenticated to: what it +carries is a workspace id, and the tenant is precisely what has to be worked out before +anything else may happen. There is no row in hand to read the answer off, so +this is shape 1 with the credential replaced by a workspace — resolve the +tenant, bind it, and read everything after that through the ordinary scoped +store. It takes both the platform and the workspace id because that pair, +not the workspace id alone, is what `messaging_installs` makes unique; a +lookup on the id alone would answer twice the first time two platforms +happened to mint the same string, and refuse a customer's traffic for a +reason in someone else's account. + Why not the obvious alternatives is argued in `docs/old/multi-tenancy-phase1-db.md`, "The bootstrap problem"; the short version is that returning rows instead of tenant ids would put a second copy @@ -160,9 +174,9 @@ class TenantLookupError(RuntimeError): @dataclass(frozen=True) class TenantLookup: - """One exempt function: its name, its single argument, and what it reads. + """One exempt function: its name, its arguments, and what it reads. - `argument` is the parameter name *without* the `p_` prefix the SQL carries. + `arguments` are the parameter names *without* the `p_` prefix the SQL carries. The prefix is not decoration: a `LANGUAGE sql` function whose parameter is spelled like a column of a table in its own query resolves the name to the column, silently, rather than to the parameter. Verified on Postgres 16, @@ -183,25 +197,29 @@ class TenantLookup: """ name: str - argument: str | None + arguments: tuple[str, ...] query: str purpose: str @property - def parameter(self) -> str | None: - return None if self.argument is None else f"p_{self.argument}" + def parameters(self) -> tuple[str, ...]: + return tuple(f"p_{argument}" for argument in self.arguments) + + @property + def parameter_declaration(self) -> str: + return ", ".join(f"{parameter} text" for parameter in self.parameters) @property def signature(self) -> str: - return f"{self.name}({'' if self.argument is None else 'text'})" + return f"{self.name}({', '.join('text' for _ in self.arguments)})" -# The seven of them. Ordered as the three shapes above: enumeration, then +# The nine of them. Ordered as the three shapes above: enumeration, then # credential resolution, then deriving a tenant from an identifier in hand. TENANT_LOOKUPS: tuple[TenantLookup, ...] = ( TenantLookup( name="all_tenant_ids", - argument=None, + arguments=(), query="SELECT id FROM tenants ORDER BY created_at, id", purpose=( "Every tenant in the deployment, oldest first. The one question a " @@ -211,7 +229,7 @@ def signature(self) -> str: ), TenantLookup( name="tenants_of_user", - argument="user_id", + arguments=("user_id",), query=( "SELECT tenant_id FROM tenant_members " "WHERE user_id = p_user_id ORDER BY tenant_id" @@ -224,7 +242,7 @@ def signature(self) -> str: ), TenantLookup( name="tenant_of_api_key", - argument="key_hash", + arguments=("key_hash",), query="SELECT tenant_id FROM api_keys WHERE key_hash = p_key_hash", purpose=( "Which tenant a bearer credential belongs to. `api_keys.key_hash` " @@ -234,7 +252,7 @@ def signature(self) -> str: ), TenantLookup( name="tenant_of_agent_oauth_client", - argument="oauth_client_id", + arguments=("oauth_client_id",), query=( "SELECT tenant_id FROM agents WHERE oauth_client_id = p_oauth_client_id" ), @@ -247,7 +265,7 @@ def signature(self) -> str: ), TenantLookup( name="tenant_of_room", - argument="room_id", + arguments=("room_id",), query="SELECT tenant_id FROM rooms WHERE id = p_room_id", purpose=( "Which tenant a room belongs to, for work reaching a room by its " @@ -256,7 +274,7 @@ def signature(self) -> str: ), TenantLookup( name="tenant_of_collaboration_bridge", - argument="bridge_id", + arguments=("bridge_id",), query="SELECT tenant_id FROM collaboration_bridges WHERE id = p_bridge_id", purpose=( "Which tenant a bridge belongs to. Asked from boot, with nothing " @@ -266,13 +284,13 @@ def signature(self) -> str: ), TenantLookup( name="tenant_of_server_connector", - argument="connector_id", + arguments=("connector_id",), query="SELECT tenant_id FROM server_connectors WHERE id = p_connector_id", purpose="Same as the bridge, for a server-side connector.", ), TenantLookup( name="tenant_of_invitation", - argument="token_hash", + arguments=("token_hash",), query="SELECT tenant_id FROM invitations WHERE token_hash = p_token_hash", purpose=( "Which tenant an invitation belongs to, resolved from the hash of " @@ -282,6 +300,22 @@ def signature(self) -> str: "refuses to a session with nothing bound yet." ), ), + TenantLookup( + name="tenant_of_messaging_install", + arguments=("platform", "external_workspace_id"), + query=( + "SELECT tenant_id FROM messaging_installs " + "WHERE platform = p_platform " + "AND external_workspace_id = p_external_workspace_id" + ), + purpose=( + "Which tenant an inbound event from an installed workspace belongs " + "to. The public webhook is unauthenticated by nature and knows only " + "the platform it was posted to and the workspace the payload names, " + "so this runs before anything else the request does. Unique by " + "constraint on exactly this pair, so it answers at most once." + ), + ), ) TENANT_LOOKUPS_BY_NAME: dict[str, TenantLookup] = { @@ -297,9 +331,8 @@ def signature(self) -> str: def create_lookup_ddl(lookup: TenantLookup) -> str: - parameters = "" if lookup.parameter is None else f"{lookup.parameter} text" return ( - f"CREATE OR REPLACE FUNCTION {lookup.name}({parameters})\n" + f"CREATE OR REPLACE FUNCTION {lookup.name}({lookup.parameter_declaration})\n" f" RETURNS SETOF text\n" f" LANGUAGE sql STABLE SECURITY DEFINER\n" f" SET search_path = {SECURE_SEARCH_PATH}\n" @@ -337,33 +370,40 @@ def attach_tenant_lookups(metadata: MetaData) -> None: # ── Calling them ────────────────────────────────────────────────────────────── # One `text()` per lookup, written out rather than assembled from `lookup.name` -# at call time: the eight names are fixed and known here, so there is nothing -# for a call site to build. The assertion below is what keeps this dict from -# quietly falling behind `TENANT_LOOKUPS` — a ninth lookup with no entry -# here fails at import, not with a `KeyError` on whatever request reaches it -# first. +# at call time: the nine names are fixed and known here, so there is nothing +# for a call site to build. Each bind is named after the lookup's own argument, +# which is what lets `_call` zip them positionally against the dataclass and +# fail loudly on a mismatch rather than binding the workspace to the platform. +# The assertion below is what keeps this dict from quietly falling behind +# `TENANT_LOOKUPS` — a tenth lookup with no entry here fails at import, not +# with a `KeyError` on whatever request reaches it first. _LOOKUP_STATEMENTS: dict[str, TextClause] = { "all_tenant_ids": text("SELECT tenant_id FROM all_tenant_ids() AS tenant_id"), "tenants_of_user": text( - "SELECT tenant_id FROM tenants_of_user(:argument) AS tenant_id" + "SELECT tenant_id FROM tenants_of_user(:user_id) AS tenant_id" ), "tenant_of_api_key": text( - "SELECT tenant_id FROM tenant_of_api_key(:argument) AS tenant_id" + "SELECT tenant_id FROM tenant_of_api_key(:key_hash) AS tenant_id" ), "tenant_of_agent_oauth_client": text( - "SELECT tenant_id FROM tenant_of_agent_oauth_client(:argument) AS tenant_id" + "SELECT tenant_id FROM tenant_of_agent_oauth_client(:oauth_client_id) " + "AS tenant_id" ), "tenant_of_room": text( - "SELECT tenant_id FROM tenant_of_room(:argument) AS tenant_id" + "SELECT tenant_id FROM tenant_of_room(:room_id) AS tenant_id" ), "tenant_of_collaboration_bridge": text( - "SELECT tenant_id FROM tenant_of_collaboration_bridge(:argument) AS tenant_id" + "SELECT tenant_id FROM tenant_of_collaboration_bridge(:bridge_id) AS tenant_id" ), "tenant_of_server_connector": text( - "SELECT tenant_id FROM tenant_of_server_connector(:argument) AS tenant_id" + "SELECT tenant_id FROM tenant_of_server_connector(:connector_id) AS tenant_id" ), "tenant_of_invitation": text( - "SELECT tenant_id FROM tenant_of_invitation(:argument) AS tenant_id" + "SELECT tenant_id FROM tenant_of_invitation(:token_hash) AS tenant_id" + ), + "tenant_of_messaging_install": text( + "SELECT tenant_id FROM " + "tenant_of_messaging_install(:platform, :external_workspace_id) AS tenant_id" ), } @@ -375,7 +415,7 @@ def attach_tenant_lookups(metadata: MetaData) -> None: async def _call( session_factory: async_sessionmaker[AsyncSession], lookup: TenantLookup, - argument: str | None = None, + *arguments: str, ) -> list[str]: """Run one lookup on a session of its own, with nothing bound. @@ -384,10 +424,14 @@ async def _call( — and the answer must not be narrowed to it. Nothing bound is also the honest state: this session may not touch a scoped table, and the database is what enforces that now rather than an allowlist. + + `strict=True` on the zip is the arity check. A lookup called with one + argument too few would otherwise leave a bind unfilled, and a lookup whose + two arguments were passed the wrong way round is a webhook resolving the + wrong tenant — both are worth a `ValueError` here rather than a surprise + further down. """ - parameters: dict[str, object] = {} - if lookup.parameter is not None: - parameters["argument"] = argument + parameters: dict[str, object] = dict(zip(lookup.arguments, arguments, strict=True)) with no_tenant(): async with session_factory() as session: result = await session.execute(_LOOKUP_STATEMENTS[lookup.name], parameters) @@ -460,3 +504,14 @@ async def tenant_of_invitation( ) -> str | None: lookup = TENANT_LOOKUPS_BY_NAME["tenant_of_invitation"] return _at_most_one(lookup, await _call(session_factory, lookup, token_hash)) + + +async def tenant_of_messaging_install( + session_factory: async_sessionmaker[AsyncSession], + platform: str, + external_workspace_id: str, +) -> str | None: + lookup = TENANT_LOOKUPS_BY_NAME["tenant_of_messaging_install"] + return _at_most_one( + lookup, await _call(session_factory, lookup, platform, external_workspace_id) + ) diff --git a/core/switch_core/migrations/versions/c8a4e21f6d30_messaging_installs.py b/core/switch_core/migrations/versions/c8a4e21f6d30_messaging_installs.py new file mode 100644 index 000000000..160a15d79 --- /dev/null +++ b/core/switch_core/migrations/versions/c8a4e21f6d30_messaging_installs.py @@ -0,0 +1,126 @@ +"""messaging_installs, and the lookup that resolves one to a tenant + +The table behind the official Slack app: one row per external workspace a +tenant has installed us into, holding the token that install granted. It is +tenant-scoped like everything else, so it gets the same `tenant_isolation` +policy as the other tables — written out below rather than inherited, because +`265ed188ad6f` installed the policies as they stood then and a table added +afterwards has to bring its own. + +`(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; the constraint makes that unrepresentable, and it has +to be the database that decides because a read-then-insert in application code +cannot be made atomic. It is also globally unique for the same reason +`api_keys.key_hash` is: the value is resolved before a tenant is known, so a +per-tenant index could not answer the question being asked. + +`tenant_of_messaging_install(platform, workspace)` is the lookup that asks it, +and it is an addition to the closed set of `SECURITY DEFINER` functions +`9c41a7b0e5d8` installed as the whole exemption from row-level security. That +set is deliberately short and every entry in it is callable by anyone holding +the runtime role's credentials, so adding one is meant to be argued with. The +argument for this one: the webhook is unauthenticated by nature and holds +nothing but a workspace id, the answer it returns 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 at all. + +It takes two arguments where every other lookup takes one, because the pair is +what the table makes unique. 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. + +The DDL below is a verbatim copy of `switch_core/db/rls_ddl.py` and +`switch_core/db/tenant_lookup.py` as they stood when this migration was +written, copied rather than imported for the same reason every other revision +in this chain copies rather than imports: a migration is a record of a change +that already happened, and importing the live module would let a later edit +silently change what this one means. +`tests/switch_core/db/test_frozen_ddl_matches_create_all.py` is what keeps the +copy from drifting. + +Revision ID: c8a4e21f6d30 +Revises: b1d7c4f0a92e +Create Date: 2026-09-11 00:00:00.000000 + +""" + +from collections.abc import Sequence + +import sqlalchemy as sa +from alembic import op + +revision: str = "c8a4e21f6d30" +down_revision: str | None = "5daaea6b674d" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + +TABLE = "messaging_installs" +POLICY_NAME = "tenant_isolation" + +ENABLE_RLS = f'ALTER TABLE "{TABLE}" ENABLE ROW LEVEL SECURITY' + +CREATE_POLICY = f"""CREATE POLICY {POLICY_NAME} ON "{TABLE}" + FOR ALL + USING ("tenant_id" = (SELECT require_tenant_id())) + WITH CHECK ("tenant_id" = (SELECT require_tenant_id()))""" + +DROP_POLICY = f'DROP POLICY IF EXISTS {POLICY_NAME} ON "{TABLE}"' + +SECURE_SEARCH_PATH = "pg_catalog, public, pg_temp" + +CREATE_TENANT_OF_MESSAGING_INSTALL = f"""CREATE OR REPLACE FUNCTION tenant_of_messaging_install(p_platform text, p_external_workspace_id text) + RETURNS SETOF text + LANGUAGE sql STABLE SECURITY DEFINER + SET search_path = {SECURE_SEARCH_PATH} +AS $$SELECT tenant_id FROM messaging_installs WHERE platform = p_platform AND external_workspace_id = p_external_workspace_id$$""" + +DROP_TENANT_OF_MESSAGING_INSTALL = ( + "DROP FUNCTION IF EXISTS tenant_of_messaging_install(text, text)" +) + + +def upgrade() -> None: + op.create_table( + TABLE, + sa.Column("id", sa.Text(), nullable=False), + sa.Column("tenant_id", sa.Text(), nullable=False), + sa.Column("platform", sa.Text(), nullable=False), + sa.Column("external_workspace_id", sa.Text(), nullable=False), + sa.Column("encrypted_bot_token", sa.Text(), nullable=False), + sa.Column("scopes", sa.Text(), nullable=False), + sa.Column("status", sa.Text(), nullable=False), + sa.Column("installed_by_user_id", sa.Text(), nullable=False), + sa.Column("bridge_id", sa.Text(), nullable=True), + sa.Column( + "installed_at", + sa.DateTime(timezone=True), + server_default=sa.text("now()"), + nullable=False, + ), + sa.PrimaryKeyConstraint("id"), + sa.ForeignKeyConstraint( + ["tenant_id"], ["tenants.id"], name="fk_messaging_installs_tenant" + ), + sa.ForeignKeyConstraint(["installed_by_user_id"], ["users.id"]), + sa.ForeignKeyConstraint( + ["tenant_id", "bridge_id"], + ["collaboration_bridges.tenant_id", "collaboration_bridges.id"], + name="fk_messaging_installs_bridge", + ), + sa.UniqueConstraint( + "platform", "external_workspace_id", name="uq_messaging_installs_workspace" + ), + sa.UniqueConstraint("id", "tenant_id", name="uq_messaging_installs_id_tenant"), + ) + op.execute(ENABLE_RLS) + op.execute(CREATE_POLICY) + op.execute(CREATE_TENANT_OF_MESSAGING_INSTALL) + + +def downgrade() -> None: + op.execute(DROP_TENANT_OF_MESSAGING_INSTALL) + op.execute(DROP_POLICY) + op.drop_table(TABLE) diff --git a/core/tests/switch_core/db/test_messaging_install_claim.py b/core/tests/switch_core/db/test_messaging_install_claim.py new file mode 100644 index 000000000..1319744ae --- /dev/null +++ b/core/tests/switch_core/db/test_messaging_install_claim.py @@ -0,0 +1,151 @@ +"""One external workspace belongs to one tenant, and the database is what says so. + +Inbound events from the Switch messaging app arrive over a public endpoint +carrying a workspace id and nothing else — no tenant, no credential of ours. +`tenant_of_messaging_install` turns that workspace into a tenant, and the whole +of why it can is that `messaging_installs` makes `(platform, +external_workspace_id)` unique across the deployment. Two tenants holding the +same workspace would be an event with two possible destinations and a lookup +that refuses rather than picking; every message from that workspace would stop. + +So the constraint is the routing guarantee, and it is asserted here through the +restricted role rather than through the owner, because the interesting part is +what row-level security does *not* do to it. A unique index is enforced against +rows the policy hides, which is the behaviour this needs and is easy to assume +the other way round: the second tenant cannot read the first tenant's row and +is still refused the insert. + +That refusal discloses one bit — some tenant holds this workspace — to a caller +who could already name it. That is deliberate, and the alternative is worse: a +silent second claim, discovered when a customer's messages start arriving in +somebody else's rooms. +""" + +from __future__ import annotations + +import uuid + +import pytest +from sqlalchemy import select +from sqlalchemy.exc import IntegrityError +from sqlalchemy.ext.asyncio import async_sessionmaker + +from switch_core.db.models import MessagingInstall, Tenant, User +from switch_core.db.session_scope import tenant_session +from switch_core.db.tenant_lookup import tenant_of_messaging_install +from tests.conftest import RLSHarness + +pytestmark = pytest.mark.no_ambient_tenant + + +class _Fixture: + def __init__(self) -> None: + self.tenant_a: str = "" + self.tenant_b: str = "" + self.user_id: str = "" + self.workspace: str = "" + + +async def _two_tenants_and_a_workspace(owner: async_sessionmaker) -> _Fixture: + fixture = _Fixture() + suffix = uuid.uuid4().hex[:8] + fixture.tenant_a = f"tenant-a-{suffix}" + fixture.tenant_b = f"tenant-b-{suffix}" + fixture.workspace = f"T-{suffix}" + + async with owner() as session: + for tenant_id in (fixture.tenant_a, fixture.tenant_b): + session.add(Tenant(id=tenant_id, slug=tenant_id, name=tenant_id)) + user = User(name="installer", email=f"{suffix}@example.test", role="user") + session.add(user) + await session.flush() + fixture.user_id = user.id + await session.commit() + return fixture + + +def _install(fixture: _Fixture, tenant_id: str) -> MessagingInstall: + return MessagingInstall( + tenant_id=tenant_id, + platform="slack", + external_workspace_id=fixture.workspace, + encrypted_bot_token="ciphertext", + scopes="chat:write", + status="active", + installed_by_user_id=fixture.user_id, + ) + + +async def test_a_second_tenant_cannot_claim_a_claimed_workspace( + rls_harness: RLSHarness, +) -> None: + fixture = await _two_tenants_and_a_workspace(rls_harness.owner) + + async with tenant_session(rls_harness.restricted, fixture.tenant_a) as session: + session.add(_install(fixture, fixture.tenant_a)) + await session.commit() + + with pytest.raises(IntegrityError) as raised: + async with tenant_session(rls_harness.restricted, fixture.tenant_b) as session: + session.add(_install(fixture, fixture.tenant_b)) + await session.commit() + assert "uq_messaging_installs_workspace" in str(raised.value) + + +async def test_the_claiming_tenant_still_cannot_see_the_row_it_collided_with( + rls_harness: RLSHarness, +) -> None: + """The measurement that makes the test above mean something. + + If tenant B could read tenant A's install, the constraint would be + redundant with an ordinary application-level check. It cannot, so the + constraint is the only thing standing between two claims on one workspace. + """ + fixture = await _two_tenants_and_a_workspace(rls_harness.owner) + + async with tenant_session(rls_harness.restricted, fixture.tenant_a) as session: + session.add(_install(fixture, fixture.tenant_a)) + await session.commit() + + async with tenant_session(rls_harness.restricted, fixture.tenant_b) as session: + visible = ( + await session.execute( + select(MessagingInstall.id).where( + MessagingInstall.external_workspace_id == fixture.workspace + ) + ) + ).scalars() + assert list(visible) == [] + + +async def test_the_same_workspace_on_another_platform_is_a_separate_claim( + rls_harness: RLSHarness, +) -> None: + """Uniqueness is on the pair. Two platforms minting the same string is a + coincidence, not a conflict, and refusing the second install would take a + customer's Teams workspace away because somebody's Slack workspace shares + an id.""" + fixture = await _two_tenants_and_a_workspace(rls_harness.owner) + + async with tenant_session(rls_harness.restricted, fixture.tenant_a) as session: + session.add(_install(fixture, fixture.tenant_a)) + await session.commit() + + async with tenant_session(rls_harness.restricted, fixture.tenant_b) as session: + install = _install(fixture, fixture.tenant_b) + install.platform = "teams" + session.add(install) + await session.commit() + + assert ( + await tenant_of_messaging_install( + rls_harness.restricted, "slack", fixture.workspace + ) + == fixture.tenant_a + ) + assert ( + await tenant_of_messaging_install( + rls_harness.restricted, "teams", fixture.workspace + ) + == fixture.tenant_b + ) diff --git a/core/tests/switch_core/db/test_row_level_security.py b/core/tests/switch_core/db/test_row_level_security.py index b1446b51c..c6a1a6ca8 100644 --- a/core/tests/switch_core/db/test_row_level_security.py +++ b/core/tests/switch_core/db/test_row_level_security.py @@ -46,6 +46,8 @@ GLOBAL_TABLES, POLICY_NAME, REQUIRE_TENANT_FUNCTION_NAME, + create_policy_ddl, + enable_rls_ddl, scoped_tables, unscoped_tables, ) @@ -328,8 +330,8 @@ def _expected_predicate(tenant_column: str) -> str: ) -def _migration_module() -> ModuleType: - """The `265ed188ad6f` revision module, loaded through Alembic. +def _revision_module(revision: str) -> ModuleType: + """One revision module, loaded through Alembic. Alembic's own loader, rather than an `importlib` call on a path, so this finds the file the same way a deployment would and fails the same way if @@ -338,8 +340,12 @@ def _migration_module() -> ModuleType: core = Path(switch_core.__file__).resolve().parents[1] config = Config(str(core / "alembic.ini")) config.set_main_option("script_location", str(core / "switch_core" / "migrations")) - revision = ScriptDirectory.from_config(config).get_revision(_RLS_REVISION) - return revision.module + return ScriptDirectory.from_config(config).get_revision(revision).module + + +def _migration_module() -> ModuleType: + """The revision that installed the policies.""" + return _revision_module(_RLS_REVISION) class TestCatalogueCoverage: diff --git a/core/tests/switch_core/db/test_tenant_lookup.py b/core/tests/switch_core/db/test_tenant_lookup.py index 70cb344bc..5e39f0bf9 100644 --- a/core/tests/switch_core/db/test_tenant_lookup.py +++ b/core/tests/switch_core/db/test_tenant_lookup.py @@ -52,6 +52,7 @@ Client, CollaborationBridge, Invitation, + MessagingInstall, Room, ServerConnector, Tenant, @@ -69,6 +70,7 @@ tenant_of_api_key, tenant_of_collaboration_bridge, tenant_of_invitation, + tenant_of_messaging_install, tenant_of_room, tenant_of_server_connector, tenants_of_user, @@ -92,11 +94,14 @@ # The same bookkeeping in the other direction: a lookup the live module names # that `9c41a7b0e5d8` never created, and the revision that did create it. The # frozen-copy comparison below has to know about both to stay exact — without -# this entry the only way to keep it green would be to loosen it to a subset +# these entries the only way to keep it green would be to loosen it to a subset # check, and a subset check passes for a lookup that exists in the module and -# in no migration at all, which is a deployment whose invitation acceptance -# cannot resolve a tenant with the whole suite green. -_ADDED_SINCE = {"tenant_of_invitation": "5daaea6b674d"} +# in no migration at all, which is a deployment whose invitation acceptance — +# or whose inbound webhook — cannot resolve a tenant with the whole suite green. +_ADDED_SINCE = { + "tenant_of_invitation": "5daaea6b674d", + "tenant_of_messaging_install": "c8a4e21f6d30", +} def _revision_module(revision: str) -> ModuleType: @@ -128,6 +133,8 @@ def __init__(self) -> None: self.bridge_a: str = "" self.connector_a: str = "" self.key_hash_a: str = "" + self.workspace_a: str = "" + self.workspace_b: str = "" self.oauth_client_a: str = "" self.user_a: str = "" self.user_in_both: str = "" @@ -151,6 +158,8 @@ async def _two_populated_tenants(harness: RLSHarness) -> _Fixture: fixture.tenant_b = f"tenant-b-{suffix}" fixture.oauth_client_a = f"oauth-{suffix}" fixture.key_hash_a = f"hash-a-{suffix}" + fixture.workspace_a = f"T-a-{suffix}" + fixture.workspace_b = f"T-b-{suffix}" async with harness.owner() as session: for tenant_id in (fixture.tenant_a, fixture.tenant_b): @@ -226,6 +235,17 @@ async def _two_populated_tenants(harness: RLSHarness) -> _Fixture: created_by=user_both.id, ) session.add(invitation) + session.add( + MessagingInstall( + tenant_id=tenant_id, + platform="slack", + external_workspace_id=f"T-{tag}-{suffix}", + encrypted_bot_token="x", + scopes="chat:write", + status="active", + installed_by_user_id=user_both.id, + ) + ) session.add( Agent( tenant_id=tenant_id, @@ -453,6 +473,57 @@ async def test_an_unknown_invitation_token_resolves_to_nothing( await tenant_of_invitation(rls_harness.restricted, "no-such-hash") is None ) + async def test_an_installed_workspace_resolves_to_the_tenant_that_installed_it( + self, rls_harness: RLSHarness + ) -> None: + """The whole of what routes an inbound webhook. + + Both tenants have installed the same platform, so a lookup that + ignored its arguments, or matched on the platform alone, answers twice + and is refused rather than passing. + """ + fixture = await _two_populated_tenants(rls_harness) + restricted = rls_harness.restricted + assert ( + await tenant_of_messaging_install(restricted, "slack", fixture.workspace_a) + == fixture.tenant_a + ) + assert ( + await tenant_of_messaging_install(restricted, "slack", fixture.workspace_b) + == fixture.tenant_b + ) + + async def test_a_workspace_on_another_platform_resolves_to_nothing( + self, rls_harness: RLSHarness + ) -> None: + """The pair is the key, not either half of it. + + A workspace id that exists under `slack` must not answer for `teams`; + the two arguments being applied to the columns they name is the + difference between routing an event and delivering it to whoever + happened to mint the same string first. + """ + fixture = await _two_populated_tenants(rls_harness) + assert ( + await tenant_of_messaging_install( + rls_harness.restricted, "teams", fixture.workspace_a + ) + is None + ) + + async def test_an_uninstalled_workspace_resolves_to_nothing_rather_than_raising( + self, rls_harness: RLSHarness + ) -> None: + """A webhook for a workspace we are not installed in is a request to + reject, not a fault. Same shape as an unknown bearer token.""" + await _two_populated_tenants(rls_harness) + assert ( + await tenant_of_messaging_install( + rls_harness.restricted, "slack", "T-never-installed" + ) + is None + ) + async def test_an_ambiguous_answer_is_refused_rather_than_picked( self, rls_harness: RLSHarness ) -> None: @@ -614,7 +685,7 @@ def test_the_frozen_list_matches_the_live_one(self) -> None: live = { ( lookup.name, - "" if lookup.parameter is None else f"{lookup.parameter} text", + lookup.parameter_declaration, lookup.signature, lookup.query, ) @@ -640,24 +711,30 @@ def test_the_added_lookup_is_installed_by_a_revision_and_not_only_here( A lookup added to `db/tenant_lookup.py` is built by `create_all`, so every test in this file exercises it and passes. A deployment's schema is built by Alembic, which knows nothing about it: the function is - absent, and the first call — an invitation acceptance trying to - resolve a tenant — fails at runtime in production and nowhere else. + absent, and the first call — an invitation acceptance or an inbound + webhook trying to resolve a tenant — fails at runtime in production and + nowhere else. Comparing the rendered statement rather than the pieces, for the same reason the test below does: a revision that created the function `SECURITY INVOKER`, or without the `search_path`, would install something that cannot read across tenants at all. + + Driven by `_ADDED_SINCE` rather than naming one lookup, so the next + entry is covered by adding it there and nowhere else. """ - revision = _ADDED_SINCE["tenant_of_invitation"] - lookup = TENANT_LOOKUPS_BY_NAME["tenant_of_invitation"] - module = _revision_module(revision) - assert module.CREATE_TENANT_OF_INVITATION == create_lookup_ddl(lookup), ( - f"revision {revision} would install tenant_of_invitation with " - "different DDL from the one db/tenant_lookup.py builds." - ) - assert module.DROP_TENANT_OF_INVITATION == ( - f"DROP FUNCTION IF EXISTS {lookup.signature}" - ) + for name, revision in _ADDED_SINCE.items(): + lookup = TENANT_LOOKUPS_BY_NAME[name] + module = _revision_module(revision) + assert getattr(module, f"CREATE_{name.upper()}") == create_lookup_ddl( + lookup + ), ( + f"revision {revision} would install {name} with different DDL " + "from the one db/tenant_lookup.py builds." + ) + assert getattr(module, f"DROP_{name.upper()}") == ( + f"DROP FUNCTION IF EXISTS {lookup.signature}" + ) def test_the_dropped_lookup_is_dropped_by_a_revision_and_not_only_here( self, @@ -713,9 +790,8 @@ def test_the_statement_it_would_run_is_the_statement_the_module_builds( for lookup in TENANT_LOOKUPS: if lookup.name in _ADDED_SINCE: continue - parameters = "" if lookup.parameter is None else f"{lookup.parameter} text" assert module.create_lookup_ddl( - lookup.name, parameters, lookup.query + lookup.name, lookup.parameter_declaration, lookup.query ) == create_lookup_ddl(lookup), ( f"migration 9c41a7b0e5d8 would install {lookup.name} with " "different DDL from the one db/tenant_lookup.py builds." From 438ac75db9eef472dd9d65d2b49373d7d5d70a06 Mon Sep 17 00:00:00 2001 From: Petr Bauch Date: Fri, 11 Sep 2026 14:52:51 +0200 Subject: [PATCH 02/29] feat(bridges): install protocol for the distributed Slack app (CHOO-2626) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- core/switch_core/bridges/agent/auth.py | 8 + .../bridges/collaboration/install.py | 220 +++++++++++++ .../bridges/collaboration/slack/adapter.py | 109 ++++++- .../bridges/collaboration/slack/install.py | 153 +++++++++ core/switch_core/config.py | 44 +++ core/switch_core/db/models.py | 53 ++++ .../d3f6b0c95a17_messaging_install_states.py | 87 +++++ .../agent/test_version_is_not_public.py | 6 + .../test_bridge_type_registry.py | 6 +- .../test_slack_distributed_app.py | 122 +++++++ .../collaboration/test_slack_installer.py | 299 ++++++++++++++++++ .../switch_core/db/test_row_level_security.py | 2 - .../switch_core/test_config_slack_app.py | 63 ++++ docs/old/bridges/README.md | 6 + docs/old/bridges/SLACK_DISTRIBUTED_APP.md | 202 ++++++++++++ docs/old/bridges/SLACK_SETUP.md | 5 + 16 files changed, 1365 insertions(+), 20 deletions(-) create mode 100644 core/switch_core/bridges/collaboration/install.py create mode 100644 core/switch_core/bridges/collaboration/slack/install.py create mode 100644 core/switch_core/migrations/versions/d3f6b0c95a17_messaging_install_states.py create mode 100644 core/tests/switch_core/bridges/collaboration/test_slack_distributed_app.py create mode 100644 core/tests/switch_core/bridges/collaboration/test_slack_installer.py create mode 100644 core/tests/switch_core/test_config_slack_app.py create mode 100644 docs/old/bridges/SLACK_DISTRIBUTED_APP.md diff --git a/core/switch_core/bridges/agent/auth.py b/core/switch_core/bridges/agent/auth.py index fac0bb763..5fa9a4f11 100644 --- a/core/switch_core/bridges/agent/auth.py +++ b/core/switch_core/bridges/agent/auth.py @@ -12,6 +12,9 @@ from switch_core.bridges.agent.api_key_cache import ApiKeyCache from switch_core.bridges.agent.registration_bootstrap import REGISTRATION_KEY_TYPES +from switch_core.bridges.collaboration.install import ( + PUBLIC_PATH_PREFIX as MESSAGING_INSTALL_PREFIX, +) from switch_core.db.models import Agent, ApiKey from switch_core.db.session_scope import tenant_session from switch_core.db.stores.agent_store import AgentStore @@ -37,6 +40,11 @@ # Public switchdash:// deeplink HTTP redirect — followed by whoever clicks # the "Open in Switch Console" link in an external channel, so no bearer token. "/deeplink", + # Workspace installs of the distributed messaging apps: the OAuth callback + # and the platforms' event webhooks. Unauthenticated by nature — an inbound + # Slack event carries no credential of ours — so each route proves its own + # origin from the platform's signature before it does anything else. + MESSAGING_INSTALL_PREFIX, ) diff --git a/core/switch_core/bridges/collaboration/install.py b/core/switch_core/bridges/collaboration/install.py new file mode 100644 index 000000000..ff9e4039e --- /dev/null +++ b/core/switch_core/bridges/collaboration/install.py @@ -0,0 +1,220 @@ +"""Installing *our* app into someone else's workspace. + +A `CollaborationAdapter` is what a bridge runs; this is what happens before +there is one. The distinction is not organisational — the two have genuinely +different lifetimes and genuinely different secrets: + +- An adapter is per bridge, built from a `connection_config` that already + holds a working credential, and it exists only while that bridge runs. +- An installer is per deployment, built once from the credentials of the app + *we* registered with the platform, and it exists whether or not any bridge + does. It is what turns a click on "Add to Slack" into a credential, and what + proves an inbound webhook came from the platform rather than from anyone who + found the URL. + +So an installer is not a classmethod on the adapter. Making it one would mean +threading a client secret through every call, and would put "which app did we +register with Slack" on an object whose whole scope is one customer's bridge. + +**The seam between the two is `connection_config`.** An installer's last act +is to render the grant into exactly the dict the platform's adapter already +takes, so nothing downstream of the install knows an install happened: +`CollaborationBridgeLifecycleService.register` validates and starts it the way +it does a bridge an operator typed in by hand. That is deliberate — an +installed bridge and a self-registered one differ in where the token came +from, and in nothing else. + +**Registration is the feature flag.** There is no `installs_enabled` setting. +An installer exists for a platform when that platform's app credentials are +configured, and the endpoints refuse when it does not — a deployment that has +not registered an app cannot half-offer installs. +""" + +from __future__ import annotations + +from abc import ABC, abstractmethod +from collections.abc import Mapping +from dataclasses import dataclass +from typing import ClassVar + +#: The public prefix every install endpoint hangs off. +#: +#: Its own prefix rather than a corner of an existing one, because everything +#: under it is unauthenticated by nature — a Slack event carries no credential +#: of ours, and a callback arrives before there is anything to authenticate +#: against. Grouping them makes that one property of the prefix rather than of +#: each route. +#: +#: Deliberately not under `/gateway`, which is cookie-authenticated and is not +#: routed to this application from outside; and deliberately not under +#: `/oauth`, which already belongs to agents authenticating *to* Switch and +#: would share only the word. +PUBLIC_PATH_PREFIX = "/messaging" + + +def oauth_callback_path(platform: str) -> str: + return f"{PUBLIC_PATH_PREFIX}/{platform}/oauth/callback" + + +def events_path(platform: str) -> str: + return f"{PUBLIC_PATH_PREFIX}/{platform}/events" + + +def interactive_path(platform: str) -> str: + return f"{PUBLIC_PATH_PREFIX}/{platform}/interactive" + + +def commands_path(platform: str) -> str: + return f"{PUBLIC_PATH_PREFIX}/{platform}/commands" + + +def public_url(public_origin: str, path: str) -> str: + """Absolute URL for one install path, given the deployment's public origin. + + The origin is `GATEWAY_PUBLIC_URL`, which is validated at startup as scheme + and host with no path, so this is a join and not a merge. It exists as a + function so the redirect URI sent to the platform and the one registered + with the app are built the same way — the platform compares them exactly, + and a trailing slash on one side is a refused install with a message that + does not say so. + """ + return f"{public_origin.rstrip('/')}{path}" + + +class MessagingInstallError(RuntimeError): + """An install could not be completed, with a reason fit to show an operator. + + Raised rather than returned for the usual reason: every caller of these + methods is a request handler that must not continue on a failure, and a + falsy return is the kind of thing a caller forgets to check. + """ + + +class WebhookAuthenticityError(RuntimeError): + """An inbound webhook did not prove it came from the platform. + + Separate from `MessagingInstallError` because the two are answered + differently: an install failure is shown to the operator who caused it, + while this one is a request from an unknown party and gets a bare 401 with + nothing in it. Never log the body alongside this — it is unauthenticated + input. + """ + + +@dataclass(frozen=True) +class InstallGrant: + """What the platform handed back when a workspace installed us. + + Deliberately three fields and not the platform's whole response. What a + grant *is*, across platforms, is a workspace, a credential, and the + permissions that credential was actually given — everything else in the + response is Slack's shape and belongs behind `connection_config`. + + `scopes` is the platform's own spelling, kept verbatim. A scope string that + means nothing to us is still the thing to show an operator asking why a + call was refused, and parsing it into a list here would be a parser to keep + in step with someone else's vocabulary for no gain. + """ + + external_workspace_id: str + bot_token: str + scopes: str + + +class MessagingAppInstaller(ABC): + """The install half of one platform, holding that platform's app credentials. + + One instance per platform per deployment, built at boot from config and + registered by platform name. Every method is deliberately synchronous + except the code exchange, which is the only one that talks to the network. + """ + + #: The platform this installs, matching the adapter registry's key and the + #: `platform` column on `messaging_installs`. + platform: ClassVar[str] + + @abstractmethod + def authorize_url(self, *, state: str, redirect_uri: str) -> str: + """Where to send the browser to begin an install. + + `state` is opaque here and is not the installer's to interpret: it is + minted, signed and redeemed by the install service, and this method's + only obligation is to hand it back to the platform unchanged. + """ + + @abstractmethod + async def redeem(self, *, code: str, redirect_uri: str) -> InstallGrant: + """Exchange the authorization code the callback carried for a credential. + + `redirect_uri` is passed again because the platform checks it matches + the one the flow started with — it is part of the proof, not a + convenience. + + Raise :class:`MessagingInstallError` on anything short of a usable + grant, including a well-formed response the platform marked as failed. + """ + + @abstractmethod + def verify_webhook(self, *, headers: Mapping[str, str], body: bytes) -> None: + """Prove an inbound event came from the platform, or raise. + + Takes the **raw body**, not a parsed payload, because every platform's + signature covers the bytes as sent: re-serialising a parsed dict + produces different bytes and a signature that never verifies. + + This is the first thing any webhook handler does — before parsing, + before resolving a tenant, before logging the body. Raise + :class:`WebhookAuthenticityError`. + """ + + @abstractmethod + def workspace_of_event(self, payload: Mapping[str, object]) -> str: + """Which workspace an authenticated event came from. + + The answer is what resolves a tenant, so this runs on a request with + nothing bound and must not touch the database. Raise + :class:`WebhookAuthenticityError` for a payload that names no + workspace: an event we cannot route is not an event we may guess at. + """ + + @abstractmethod + def connection_config(self, grant: InstallGrant) -> dict[str, object]: + """Render a grant as the connection config this platform's adapter takes. + + The whole point of the abstraction: after this, an installed bridge is + indistinguishable from one an operator registered by hand, and every + line of lifecycle, validation and start-up code is shared. + """ + + +class MessagingInstallerRegistry: + """The installers this deployment has app credentials for. + + A dict with a loud `__getitem__`, which is the only behaviour worth a class + here: asking for a platform nobody registered an app for is the ordinary + case (most deployments will register none), and it has to produce a + sentence an operator can act on rather than a `KeyError` in a traceback. + """ + + def __init__(self) -> None: + self._installers: dict[str, MessagingAppInstaller] = {} + + def register(self, installer: MessagingAppInstaller) -> None: + if installer.platform in self._installers: + raise MessagingInstallError( + f"an installer for {installer.platform!r} is already registered" + ) + self._installers[installer.platform] = installer + + def get(self, platform: str) -> MessagingAppInstaller: + try: + return self._installers[platform] + except KeyError: + raise MessagingInstallError( + f"no {platform} app is registered with this deployment, so it " + "cannot be installed into a workspace. Configure the app's " + "credentials and restart." + ) from None + + def platforms(self) -> list[str]: + return sorted(self._installers) diff --git a/core/switch_core/bridges/collaboration/slack/adapter.py b/core/switch_core/bridges/collaboration/slack/adapter.py index fb6beebfc..009b69da6 100644 --- a/core/switch_core/bridges/collaboration/slack/adapter.py +++ b/core/switch_core/bridges/collaboration/slack/adapter.py @@ -8,10 +8,11 @@ from collections import OrderedDict from collections.abc import Awaitable, Callable from dataclasses import replace -from typing import Any, ClassVar +from typing import Any, ClassVar, Literal import httpx -from pydantic import BaseModel, Field +from pydantic import BaseModel, Field, model_validator +from pydantic.json_schema import SkipJsonSchema from slack_sdk.errors import SlackApiError from slack_sdk.socket_mode.aiohttp import SocketModeClient from slack_sdk.socket_mode.request import SocketModeRequest @@ -196,7 +197,29 @@ class SlackUser(BaseModel): class SlackConnectionConfig(BridgeConnectionConfig): bot_token: str - app_token: str + #: How inbound events reach this bridge, which is decided by which Slack + #: app the token came from and is not an operator's preference. + #: + #: An app the operator registered themselves uses Socket Mode: Switch dials + #: out and needs no inbound route. The distributed app cannot — Slack does + #: not permit Socket Mode for it — so its events arrive as signed HTTP + #: posts to a public endpoint, and there is no app-level token at all. + #: + #: Hidden from the registration form because it is not a question an + #: operator filling that form can be asked: reaching the form at all means + #: Socket Mode, and the webhook value is written by the install flow. + event_delivery: SkipJsonSchema[Literal["socket_mode", "webhook"]] = "socket_mode" + #: Required under Socket Mode and meaningless without it, which the + #: validator below enforces rather than leaving to be discovered. + app_token: str | None = Field( + default=None, + title="App-level token", + description=( + "The xapp-… token with the connections:write scope, from the app's " + "Basic Information page. Required: it is what opens the connection " + "Slack delivers events down." + ), + ) workspace_id: str # The descriptions are not decoration: both registration forms build # themselves from this schema, so what is written here is the only @@ -221,6 +244,28 @@ class SlackConnectionConfig(BridgeConnectionConfig): ), ) + @model_validator(mode="after") + def _app_token_matches_delivery(self) -> SlackConnectionConfig: + """Refuse the two states that would look configured and receive nothing. + + A Socket Mode bridge with no app token opens no connection, so it + sends fine and never hears a word back — the exact shape of failure + that reads as "Slack is quiet today" for a week. An app token on a + webhook bridge is the opposite mistake: a credential for a mechanism + this bridge does not use, which will be read as evidence that it does. + """ + if self.event_delivery == "socket_mode" and not self.app_token: + raise ValueError( + "app_token is required: without it Switch opens no Socket Mode " + "connection and this bridge would receive no Slack events at all." + ) + if self.event_delivery == "webhook" and self.app_token: + raise ValueError( + "app_token must be empty for a bridge whose events arrive over " + "HTTP; the distributed Slack app has no app-level token." + ) + return self + class SlackAdapter(CollaborationAdapter): # Pin a turn's status to the message being worked on, opening a thread on @@ -362,15 +407,28 @@ async def start( auth.get("team", ""), ) - self._socket_client = SocketModeClient( - app_token=self._config.app_token, - web_client=self._web_client, - ) - self._socket_client.socket_mode_request_listeners.append( - self._handle_socket_event # type: ignore[arg-type] - ) - await self._socket_client.connect() - logger.info("Slack Socket Mode connected") + if self._config.event_delivery == "webhook": + # Nothing to connect: events are posted to the public endpoint, + # which verifies them and calls dispatch_event below. Said out loud + # because a bridge that opens no connection and logs nothing is + # indistinguishable from one that failed to. + logger.info( + "Slack adapter listening over HTTP; events arrive at the " + "messaging install endpoint rather than over Socket Mode" + ) + else: + # Narrowing for the type checker; the config validator is what + # actually guarantees it. + assert self._config.app_token is not None + self._socket_client = SocketModeClient( + app_token=self._config.app_token, + web_client=self._web_client, + ) + self._socket_client.socket_mode_request_listeners.append( + self._handle_socket_event # type: ignore[arg-type] + ) + await self._socket_client.connect() + logger.info("Slack Socket Mode connected") logger.debug( _TRACE + "build carries agent sessions; config agent_sessions=%s", self._config.agent_sessions, @@ -1920,7 +1978,7 @@ def _replace(match: re.Match[str]) -> str: return re.sub(r"@([A-Za-z0-9][A-Za-z0-9._-]*)", _replace, content) - # ── Socket Mode event handling ─────────────────────────────────────────── + # ── Event handling ─────────────────────────────────────────────────────── async def _handle_socket_event( self, client: SocketModeClient, req: SocketModeRequest @@ -1928,15 +1986,32 @@ async def _handle_socket_event( await client.send_socket_mode_response( SocketModeResponse(envelope_id=req.envelope_id) ) + await self.dispatch_event(envelope_type=req.type, payload=req.payload) - if req.type == "slash_commands": - await self._handle_slash_command(req.payload) + async def dispatch_event( + self, *, envelope_type: str, payload: dict[str, Any] + ) -> None: + """Route one Slack event, whichever transport carried it. + + Socket Mode hands over an envelope type and a payload; the public + webhook parses the same two out of the request it has already proved + genuine. Everything after that point is identical, so it is written + once — a dispatch that differed by transport is how the two delivery + modes would drift into behaving differently for the same event. + + Acknowledgement is *not* here, because the two transports acknowledge + incompatibly: Socket Mode replies on the socket before dispatching, + while HTTP acknowledges by returning 200 to the post. Both must do it + promptly and neither can do it for the other. + """ + if envelope_type == "slash_commands": + await self._handle_slash_command(payload) return - if req.type != "events_api": + if envelope_type != "events_api": return - event = req.payload.get("event", {}) + event = payload.get("event", {}) event_type = event.get("type") event_subtype = event.get("subtype") diff --git a/core/switch_core/bridges/collaboration/slack/install.py b/core/switch_core/bridges/collaboration/slack/install.py new file mode 100644 index 000000000..cf63742ff --- /dev/null +++ b/core/switch_core/bridges/collaboration/slack/install.py @@ -0,0 +1,153 @@ +"""Installing the distributed Slack app into a customer's workspace. + +The counterpart to `SLACK_SETUP.md`'s self-registered app, and a different +Slack app from it. See `docs/old/bridges/SLACK_DISTRIBUTED_APP.md` for the +registration walkthrough and the manifest; `BOT_SCOPES` below is the same list +as that manifest's, and a test compares them so the two cannot drift. + +The one structural difference from the self-registered app runs through +everything here: it has no app-level token, because Slack does not permit +Socket Mode for a distributed app and a single socket could not be shared by +replicas anyway. Events arrive over HTTPS and are proved genuine by the +signing secret instead — which is why `verify_webhook` exists on this side of +the boundary and has no equivalent on the adapter. +""" + +from __future__ import annotations + +import logging +from collections.abc import Mapping +from typing import ClassVar +from urllib.parse import urlencode + +from slack_sdk.errors import SlackApiError +from slack_sdk.signature import SignatureVerifier +from slack_sdk.web.async_client import AsyncWebClient + +from switch_core.bridges.collaboration.install import ( + InstallGrant, + MessagingAppInstaller, + MessagingInstallError, + WebhookAuthenticityError, +) + +logger = logging.getLogger(__name__) + +AUTHORIZE_URL = "https://slack.com/oauth/v2/authorize" + +#: The bot scopes the distributed app requests, in the manifest's order. +#: +#: Identical to the self-registered app's: the two apps differ in how they are +#: installed and how events reach them, never in what the bot may do once it is +#: in a channel. +BOT_SCOPES: tuple[str, ...] = ( + "files:read", + "files:write", + "assistant:write", + "channels:history", + "channels:manage", + "channels:read", + "chat:write", + "chat:write.customize", + "commands", + "groups:history", + "groups:read", + "groups:write", + "im:history", + "im:read", + "im:write", + "mpim:history", + "reactions:read", + "reactions:write", + "users:read", + "usergroups:read", + "usergroups:write", +) + + +class SlackAppInstaller(MessagingAppInstaller): + platform: ClassVar[str] = "slack" + + def __init__( + self, *, client_id: str, client_secret: str, signing_secret: str + ) -> None: + self._client_id = client_id + self._client_secret = client_secret + self._verifier = SignatureVerifier(signing_secret) + + def authorize_url(self, *, state: str, redirect_uri: str) -> str: + return f"{AUTHORIZE_URL}?" + urlencode( + { + "client_id": self._client_id, + "scope": ",".join(BOT_SCOPES), + "redirect_uri": redirect_uri, + "state": state, + } + ) + + async def redeem(self, *, code: str, redirect_uri: str) -> InstallGrant: + try: + response = await AsyncWebClient().oauth_v2_access( + client_id=self._client_id, + client_secret=self._client_secret, + code=code, + redirect_uri=redirect_uri, + ) + except SlackApiError as error: + raise MessagingInstallError( + f"Slack refused the install: {error.response.get('error', error)}" + ) from error + + # Slack answers 200 with `ok: false` for most refusals, so the absence + # of an exception above proves nothing on its own. + if not response.get("ok"): + raise MessagingInstallError( + f"Slack refused the install: {response.get('error', 'unknown error')}" + ) + + if response.get("is_enterprise_install"): + raise MessagingInstallError( + "this app was installed org-wide on Slack Enterprise Grid, which " + "Switch cannot yet record: an org-wide install is identified by " + "an enterprise rather than by a single workspace, and an " + "installation is stored against one workspace. Install it into " + "a single workspace instead." + ) + + team = response.get("team") or {} + workspace_id = team.get("id") + access_token = response.get("access_token") + if not workspace_id or not access_token: + raise MessagingInstallError( + "Slack accepted the install but returned no workspace id or no " + "bot token, so there is nothing to record. Nothing was saved." + ) + + return InstallGrant( + external_workspace_id=workspace_id, + bot_token=access_token, + scopes=response.get("scope") or "", + ) + + def verify_webhook(self, *, headers: Mapping[str, str], body: bytes) -> None: + try: + valid = self._verifier.is_valid_request(body, dict(headers)) + except ValueError as error: + # A non-numeric timestamp header reaches int() inside the verifier. + # It is unauthenticated input, so it must answer like any other bad + # signature rather than as a server fault. + raise WebhookAuthenticityError("malformed Slack timestamp") from error + if not valid: + raise WebhookAuthenticityError("bad Slack signature") + + def workspace_of_event(self, payload: Mapping[str, object]) -> str: + workspace_id = payload.get("team_id") + if not isinstance(workspace_id, str) or not workspace_id: + raise WebhookAuthenticityError("Slack event names no workspace") + return workspace_id + + def connection_config(self, grant: InstallGrant) -> dict[str, object]: + return { + "bot_token": grant.bot_token, + "workspace_id": grant.external_workspace_id, + } diff --git a/core/switch_core/config.py b/core/switch_core/config.py index ab01ca1f4..93683bbd9 100644 --- a/core/switch_core/config.py +++ b/core/switch_core/config.py @@ -178,6 +178,22 @@ class SwitchConfig(BaseSettings): # unset, the raw `switchdash://` deeplink is posted as-is. gateway_public_url: str | None = None + # Credentials of the distributed Slack app *we* registered — the one a + # customer installs by clicking a button, as opposed to the app an operator + # registers themselves and pastes tokens for. See + # `docs/old/bridges/SLACK_DISTRIBUTED_APP.md`. + # + # Setting all three is what enables workspace installs at all: there is no + # separate on/off switch, because an app with no credentials is not an app. + # Setting some is a mistake and is refused at startup. + # + # The signing secret is the one that must never be treated as optional in + # spirit: it is the whole of what distinguishes a Slack event from a post by + # anyone who learned the URL. + slack_app_client_id: str | None = None + slack_app_client_secret: str | None = None + slack_app_signing_secret: str | None = None + # Upper bound on a single attachment an agent may post to a room (and that # a collaboration bridge will relay out). Uploads over this raise instead # of being truncated or silently dropped. @@ -421,6 +437,34 @@ def _validate_gateway_oidc(self) -> "SwitchConfig": ) return self + @model_validator(mode="after") + def _validate_slack_app(self) -> "SwitchConfig": + required = ( + self.slack_app_client_id, + self.slack_app_client_secret, + self.slack_app_signing_secret, + ) + set_count = sum(1 for value in required if value) + if 0 < set_count < len(required): + raise ValueError( + "Partial distributed Slack app config: set all of " + "SLACK_APP_CLIENT_ID / SLACK_APP_CLIENT_SECRET / " + "SLACK_APP_SIGNING_SECRET, or none of them." + ) + # The redirect URI and the events URL are both built from the public + # origin, and Slack checks the redirect matches the one registered with + # the app. Without the origin they would be built against nothing, so a + # deployment configured to offer installs and unable to name itself is + # a startup error rather than a broken button. + if set_count and not self.gateway_public_url: + raise ValueError( + "A distributed Slack app is configured but GATEWAY_PUBLIC_URL " + "is not. The install redirect and the events endpoint are built " + "from it, and Slack rejects a redirect that does not match the " + "one registered with the app." + ) + return self + @property def gateway_oidc_enabled(self) -> bool: return bool( diff --git a/core/switch_core/db/models.py b/core/switch_core/db/models.py index 2ecd40132..ddc33ca2d 100644 --- a/core/switch_core/db/models.py +++ b/core/switch_core/db/models.py @@ -1205,6 +1205,59 @@ class MessagingInstall(TenantScoped, Base): ) +class MessagingInstallState(TenantScoped, Base): + """One in-flight install: minted when the flow starts, burnt when it lands. + + An install is two requests with a trip through the platform in between. The + first is an authenticated operator asking to install; the second is a + browser arriving back at a public endpoint from the platform, carrying an + authorization code and a `state` we chose. Nothing else ties the two + together, so `state` has to carry the whole of what the second request may + not be trusted to assert: which tenant, and on whose behalf. + + **The row is not what carries the tenant across.** The `state` parameter is + a signed token naming the tenant, so the callback binds a tenant it can + verify without reading anything first. That is the point of the design: + every other unauthenticated entry point resolves its tenant through a + `SECURITY DEFINER` lookup, and this one does not have to, so it does not — + the closed list in `db/tenant_lookup.py` stays as short as it is. What this + row adds is the one property a signature cannot have: **single use.** A + signed token is valid until it expires and a captured one can be replayed; + the redemption below happens once because `consumed_at` is set in the same + statement that checks it is null. + + Which makes the failure this prevents worth naming. Replaying a captured + state completes an install of the attacker's own workspace against the + victim's tenant — that workspace's messages then arrive in the victim's + rooms, which is message injection, not a leak. Single use and a short + expiry are what close it. + + Redemption is a scoped write like any other, run after the signature has + bound the tenant, so row-level security is a second check on the first: a + token whose signed tenant disagrees with the row's finds no row at all. + + The two timestamps are both needed and mean different things. `expires_at` + is a bound on how long the platform's round trip may take; `consumed_at` + is the fact of redemption, kept rather than deleted so an operator asking + why a link stopped working can see it was used rather than lost. + """ + + __tablename__ = "messaging_install_states" + + id: Mapped[str] = mapped_column(Text, primary_key=True, default=_uuid) + platform: Mapped[str] = mapped_column(Text, nullable=False) + created_by_user_id: Mapped[str] = mapped_column( + Text, ForeignKey("users.id"), nullable=False + ) + created_at: Mapped[str] = mapped_column( + DateTime(timezone=True), server_default=func.now(), nullable=False + ) + expires_at: Mapped[str] = mapped_column(DateTime(timezone=True), nullable=False) + consumed_at: Mapped[str | None] = mapped_column( + DateTime(timezone=True), nullable=True + ) + + # ── Server-Side Connectors ──────────────────────────────────────────────────── diff --git a/core/switch_core/migrations/versions/d3f6b0c95a17_messaging_install_states.py b/core/switch_core/migrations/versions/d3f6b0c95a17_messaging_install_states.py new file mode 100644 index 000000000..b5b8522cd --- /dev/null +++ b/core/switch_core/migrations/versions/d3f6b0c95a17_messaging_install_states.py @@ -0,0 +1,87 @@ +"""messaging_install_states + +The other half of an install: the in-flight record that ties the operator who +started one to the callback that finishes it. `messaging_installs` is the +result; this is the ticket. + +It installs no lookup, and that is the interesting thing about it. The +callback is as unauthenticated as the webhook is, so the obvious shape would +have been a ninth `SECURITY DEFINER` function resolving a state to its tenant +— exactly `tenant_of_api_key`'s shape, an opaque credential presented by a +stranger. It is not needed: the `state` parameter is a token we signed, so the +tenant travels inside it and the callback can bind before it reads. The row +exists for the one thing a signature cannot do, which is to stop being valid +the second time it is presented. + +So redemption is an ordinary scoped write — set `consumed_at` where it is +still null, in one statement, and take the absence of a returned row as the +refusal. Row-level security then checks the signature's claim a second time +for free: a token naming the wrong tenant matches no row. + +The `tenant_isolation` policy is written out below rather than inherited, +because `265ed188ad6f` installed the policies as they stood then and a table +added afterwards has to bring its own. The DDL is a verbatim copy of +`switch_core/db/rls_ddl.py` as it stood when this migration was written, +copied rather than imported for the reason every revision in this chain +copies: a migration records a change that already happened, and importing the +live module would let a later edit silently change what this one means. +`tests/switch_core/db/test_frozen_ddl_matches_create_all.py` is what keeps the +copy from drifting. + +Revision ID: d3f6b0c95a17 +Revises: c8a4e21f6d30 +Create Date: 2026-09-11 00:00:00.000000 + +""" + +from collections.abc import Sequence + +import sqlalchemy as sa +from alembic import op + +revision: str = "d3f6b0c95a17" +down_revision: str | None = "c8a4e21f6d30" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + +TABLE = "messaging_install_states" +POLICY_NAME = "tenant_isolation" + +ENABLE_RLS = f'ALTER TABLE "{TABLE}" ENABLE ROW LEVEL SECURITY' + +CREATE_POLICY = f"""CREATE POLICY {POLICY_NAME} ON "{TABLE}" + FOR ALL + USING ("tenant_id" = (SELECT require_tenant_id())) + WITH CHECK ("tenant_id" = (SELECT require_tenant_id()))""" + +DROP_POLICY = f'DROP POLICY IF EXISTS {POLICY_NAME} ON "{TABLE}"' + + +def upgrade() -> None: + op.create_table( + TABLE, + sa.Column("id", sa.Text(), nullable=False), + sa.Column("tenant_id", sa.Text(), nullable=False), + sa.Column("platform", sa.Text(), nullable=False), + sa.Column("created_by_user_id", sa.Text(), nullable=False), + sa.Column( + "created_at", + sa.DateTime(timezone=True), + server_default=sa.text("now()"), + nullable=False, + ), + sa.Column("expires_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("consumed_at", sa.DateTime(timezone=True), nullable=True), + sa.PrimaryKeyConstraint("id"), + sa.ForeignKeyConstraint( + ["tenant_id"], ["tenants.id"], name="fk_messaging_install_states_tenant" + ), + sa.ForeignKeyConstraint(["created_by_user_id"], ["users.id"]), + ) + op.execute(ENABLE_RLS) + op.execute(CREATE_POLICY) + + +def downgrade() -> None: + op.execute(DROP_POLICY) + op.drop_table(TABLE) diff --git a/core/tests/switch_core/bridges/agent/test_version_is_not_public.py b/core/tests/switch_core/bridges/agent/test_version_is_not_public.py index 8c946faa2..5d009dab3 100644 --- a/core/tests/switch_core/bridges/agent/test_version_is_not_public.py +++ b/core/tests/switch_core/bridges/agent/test_version_is_not_public.py @@ -39,4 +39,10 @@ def test_the_unauthenticated_allowlist_has_not_grown() -> None: "/oauth", "/gateway", "/deeplink", + # Workspace installs of the distributed messaging apps: the OAuth + # callback and the platforms' event webhooks. Public because the + # callers are Slack and a browser mid-redirect, neither of which holds + # a credential of ours. Nothing under it discloses a version, and each + # route checks the platform's signature before it acts. + "/messaging", } diff --git a/core/tests/switch_core/bridges/collaboration/test_bridge_type_registry.py b/core/tests/switch_core/bridges/collaboration/test_bridge_type_registry.py index 34fafc77f..6d0bb7940 100644 --- a/core/tests/switch_core/bridges/collaboration/test_bridge_type_registry.py +++ b/core/tests/switch_core/bridges/collaboration/test_bridge_type_registry.py @@ -100,7 +100,11 @@ def test_get_config_schema_exposes_required_fields() -> None: "agent_usergroups", "agent_sessions", } - assert set(schema["required"]) == {"bot_token", "app_token", "workspace_id"} + # app_token is offered but not required by the schema: a bridge whose + # events arrive over HTTP has none. What enforces it for a Socket Mode + # bridge — which receives nothing at all without one — is the model + # validator, not this form. + assert set(schema["required"]) == {"bot_token", "workspace_id"} def test_get_config_schema_unknown_type_raises() -> None: diff --git a/core/tests/switch_core/bridges/collaboration/test_slack_distributed_app.py b/core/tests/switch_core/bridges/collaboration/test_slack_distributed_app.py new file mode 100644 index 000000000..1396c38ac --- /dev/null +++ b/core/tests/switch_core/bridges/collaboration/test_slack_distributed_app.py @@ -0,0 +1,122 @@ +"""The registration walkthrough and the code have to agree, so this compares them. + +`docs/old/bridges/SLACK_DISTRIBUTED_APP.md` carries a manifest an operator +pastes into Slack. Everything in it is a promise the running system has to +keep: the scopes it requests are the scopes the authorize URL asks for, and +the URLs it registers are the paths this application serves. Neither is +checked by anything at runtime — Slack simply refuses a redirect that does not +match, or grants a scope we never use, and the failure surfaces as a customer's +install not working for a reason nobody can see from here. + +The manifest is parsed out of the markdown rather than kept in a fixture, +because a fixture would be a third copy and the operator pastes the markdown. +""" + +from __future__ import annotations + +import json +import re +from pathlib import Path + +import pytest + +from switch_core.bridges.collaboration.install import ( + commands_path, + events_path, + interactive_path, + oauth_callback_path, + public_url, +) +from switch_core.bridges.collaboration.slack.install import BOT_SCOPES + +_DOC = ( + Path(__file__).resolve().parents[5] + / "docs" + / "old" + / "bridges" + / "SLACK_DISTRIBUTED_APP.md" +) + +_HOST = "HOST" + + +@pytest.fixture(scope="module") +def manifest() -> dict: + text = _DOC.read_text() + blocks = re.findall(r"```json\n(.*?)\n```", text, re.DOTALL) + assert len(blocks) == 1, ( + f"expected exactly one json block in {_DOC.name}, found {len(blocks)}" + ) + return json.loads(blocks[0]) + + +def test_the_manifest_requests_the_scopes_the_authorize_url_asks_for( + manifest: dict, +) -> None: + """Same scopes, same order. + + Order matters less to Slack than to a reader diffing the two, and keeping + it exact costs nothing. + """ + assert tuple(manifest["oauth_config"]["scopes"]["bot"]) == BOT_SCOPES + + +def test_the_manifest_registers_the_redirect_the_callback_is_served_at( + manifest: dict, +) -> None: + """The one mismatch Slack refuses outright. + + A redirect URI is compared byte for byte against the registered list, so a + path that drifted here is every install failing with `bad_redirect_uri` + and nothing in our logs at all — the refusal happens at Slack. + """ + expected = public_url(f"https://{_HOST}", oauth_callback_path("slack")) + assert manifest["oauth_config"]["redirect_urls"] == [expected] + + +def test_the_manifest_points_events_and_interactivity_at_the_served_paths( + manifest: dict, +) -> None: + settings = manifest["settings"] + assert settings["event_subscriptions"]["request_url"] == public_url( + f"https://{_HOST}", events_path("slack") + ) + assert settings["interactivity"]["request_url"] == public_url( + f"https://{_HOST}", interactive_path("slack") + ) + + +def test_every_slash_command_posts_to_the_commands_path(manifest: dict) -> None: + expected = public_url(f"https://{_HOST}", commands_path("slack")) + urls = {command["url"] for command in manifest["features"]["slash_commands"]} + assert urls == {expected} + + +def test_the_manifest_does_not_enable_socket_mode(manifest: dict) -> None: + """The property the whole distributed app exists to have. + + Slack forbids Socket Mode for a Marketplace-listed app, and a single + socket could not be shared across replicas even if it did not. Turning it + on here would produce an app that works in a one-replica test and silently + delivers to one arbitrary replica in production. + """ + assert manifest["settings"]["socket_mode_enabled"] is False + + +def test_the_manifest_does_not_declare_the_app_an_agent(manifest: dict) -> None: + """`agent_view` is irreversible per app and needs re-review to distribute. + + Left out deliberately rather than forgotten — see the doc. If it is added, + that is a decision, and this test is where it gets recorded as one. + """ + assert "agent_view" not in manifest["features"] + + +def test_the_manifest_does_not_enable_org_wide_deploy(manifest: dict) -> None: + """An org-wide install has an enterprise id and no single workspace id. + + `messaging_installs` is unique on `(platform, external_workspace_id)` and + the installer refuses an enterprise install outright, so enabling this + would offer customers a button that always fails. + """ + assert manifest["settings"]["org_deploy_enabled"] is False diff --git a/core/tests/switch_core/bridges/collaboration/test_slack_installer.py b/core/tests/switch_core/bridges/collaboration/test_slack_installer.py new file mode 100644 index 000000000..aa7e6499a --- /dev/null +++ b/core/tests/switch_core/bridges/collaboration/test_slack_installer.py @@ -0,0 +1,299 @@ +"""What the installer does with what Slack sends back, including when it lies. + +The install endpoints are the only ones in the system a stranger can reach +with no credential at all, so the interesting cases here are the hostile and +the malformed ones rather than the happy path. +""" + +from __future__ import annotations + +import hashlib +import hmac +import time + +import pytest +from slack_sdk.web.async_client import AsyncWebClient + +from switch_core.bridges.collaboration.install import ( + InstallGrant, + MessagingInstallerRegistry, + MessagingInstallError, + WebhookAuthenticityError, + events_path, + oauth_callback_path, + public_url, +) +from switch_core.bridges.collaboration.slack.adapter import SlackConnectionConfig +from switch_core.bridges.collaboration.slack.install import ( + BOT_SCOPES, + SlackAppInstaller, +) + +_SIGNING_SECRET = "test-signing-secret" + + +@pytest.fixture +def installer() -> SlackAppInstaller: + return SlackAppInstaller( + client_id="1234.5678", + client_secret="test-client-secret", + signing_secret=_SIGNING_SECRET, + ) + + +def _sign(body: bytes, timestamp: str) -> str: + digest = hmac.new( + _SIGNING_SECRET.encode(), + b"v0:" + timestamp.encode() + b":" + body, + hashlib.sha256, + ).hexdigest() + return f"v0={digest}" + + +def _signed_headers(body: bytes, *, age_seconds: int = 0) -> dict[str, str]: + timestamp = str(int(time.time()) - age_seconds) + return { + "X-Slack-Request-Timestamp": timestamp, + "X-Slack-Signature": _sign(body, timestamp), + } + + +class TestAuthorizeUrl: + def test_it_carries_the_state_and_redirect_unchanged( + self, installer: SlackAppInstaller + ) -> None: + redirect = public_url("https://switch.example", oauth_callback_path("slack")) + url = installer.authorize_url(state="opaque-state", redirect_uri=redirect) + + assert url.startswith("https://slack.com/oauth/v2/authorize?") + assert "state=opaque-state" in url + assert ( + "redirect_uri=https%3A%2F%2Fswitch.example%2Fmessaging%2Fslack%2Foauth%2Fcallback" + in url + ) + + def test_it_asks_for_every_scope_the_bot_needs( + self, installer: SlackAppInstaller + ) -> None: + url = installer.authorize_url(state="s", redirect_uri="https://x.example/cb") + for scope in BOT_SCOPES: + assert scope.replace(":", "%3A") in url + + +class TestWebhookVerification: + def test_a_correctly_signed_body_passes(self, installer: SlackAppInstaller) -> None: + body = b'{"type":"event_callback","team_id":"T1"}' + installer.verify_webhook(headers=_signed_headers(body), body=body) + + def test_a_tampered_body_is_refused(self, installer: SlackAppInstaller) -> None: + body = b'{"type":"event_callback","team_id":"T1"}' + headers = _signed_headers(body) + with pytest.raises(WebhookAuthenticityError): + installer.verify_webhook(headers=headers, body=body + b" ") + + def test_an_old_signature_is_refused(self, installer: SlackAppInstaller) -> None: + """Replay window. A capture stays valid forever without it.""" + body = b'{"team_id":"T1"}' + with pytest.raises(WebhookAuthenticityError): + installer.verify_webhook( + headers=_signed_headers(body, age_seconds=60 * 10), body=body + ) + + def test_missing_headers_are_refused_rather_than_skipped( + self, installer: SlackAppInstaller + ) -> None: + with pytest.raises(WebhookAuthenticityError): + installer.verify_webhook(headers={}, body=b"{}") + + def test_a_nonnumeric_timestamp_is_a_bad_signature_not_a_crash( + self, installer: SlackAppInstaller + ) -> None: + """Reachable by anyone who finds the URL. + + The verifier calls `int()` on the header, so without the guard this + unauthenticated input is a 500 and a traceback per request rather than + a 401. + """ + body = b"{}" + with pytest.raises(WebhookAuthenticityError): + installer.verify_webhook( + headers={ + "X-Slack-Request-Timestamp": "not-a-number", + "X-Slack-Signature": "v0=deadbeef", + }, + body=body, + ) + + def test_header_case_does_not_matter(self, installer: SlackAppInstaller) -> None: + body = b'{"team_id":"T1"}' + headers = {k.lower(): v for k, v in _signed_headers(body).items()} + installer.verify_webhook(headers=headers, body=body) + + +class TestWorkspaceOfEvent: + def test_it_reads_the_team_id(self, installer: SlackAppInstaller) -> None: + assert installer.workspace_of_event({"team_id": "T123"}) == "T123" + + @pytest.mark.parametrize("payload", [{}, {"team_id": ""}, {"team_id": 7}]) + def test_an_event_naming_no_workspace_is_refused( + self, installer: SlackAppInstaller, payload: dict + ) -> None: + """An event we cannot route is not an event to guess at. + + There is no sensible default here: picking any tenant would deliver a + stranger's message into somebody's rooms. + """ + with pytest.raises(WebhookAuthenticityError): + installer.workspace_of_event(payload) + + +class TestRedeem: + async def _redeem_returning( + self, monkeypatch: pytest.MonkeyPatch, installer: SlackAppInstaller, response + ) -> InstallGrant: + async def fake(self, **kwargs): # noqa: ANN001, ANN202 + return response + + monkeypatch.setattr(AsyncWebClient, "oauth_v2_access", fake) + return await installer.redeem(code="c", redirect_uri="https://x.example/cb") + + async def test_a_good_grant_becomes_the_three_things_we_keep( + self, installer: SlackAppInstaller, monkeypatch: pytest.MonkeyPatch + ) -> None: + grant = await self._redeem_returning( + monkeypatch, + installer, + { + "ok": True, + "access_token": "xoxb-granted", + "scope": "chat:write,commands", + "team": {"id": "T123", "name": "Acme"}, + }, + ) + assert grant == InstallGrant( + external_workspace_id="T123", + bot_token="xoxb-granted", + scopes="chat:write,commands", + ) + + async def test_a_two_hundred_saying_not_ok_is_still_a_failure( + self, installer: SlackAppInstaller, monkeypatch: pytest.MonkeyPatch + ) -> None: + """Slack answers 200 with `ok: false` for most refusals.""" + with pytest.raises(MessagingInstallError, match="invalid_code"): + await self._redeem_returning( + monkeypatch, installer, {"ok": False, "error": "invalid_code"} + ) + + async def test_an_org_wide_install_is_refused_with_a_reason( + self, installer: SlackAppInstaller, monkeypatch: pytest.MonkeyPatch + ) -> None: + """It has an enterprise id and no single workspace id to be unique on.""" + with pytest.raises(MessagingInstallError, match="org-wide"): + await self._redeem_returning( + monkeypatch, + installer, + { + "ok": True, + "access_token": "xoxb-granted", + "is_enterprise_install": True, + "enterprise": {"id": "E1"}, + "team": None, + }, + ) + + async def test_a_grant_with_no_workspace_is_refused( + self, installer: SlackAppInstaller, monkeypatch: pytest.MonkeyPatch + ) -> None: + with pytest.raises(MessagingInstallError, match="nothing to record"): + await self._redeem_returning( + monkeypatch, installer, {"ok": True, "access_token": "xoxb-granted"} + ) + + +class TestConnectionConfig: + def test_a_grant_renders_a_config_the_adapter_accepts( + self, installer: SlackAppInstaller + ) -> None: + """The seam: after this an installed bridge is an ordinary bridge.""" + rendered = installer.connection_config( + InstallGrant( + external_workspace_id="T123", + bot_token="xoxb-granted", + scopes="chat:write", + ) + ) + config = SlackConnectionConfig.model_validate( + {**rendered, "event_delivery": "webhook"} + ) + assert config.bot_token == "xoxb-granted" + assert config.workspace_id == "T123" + assert config.app_token is None + + +class TestDeliveryModeValidation: + def test_socket_mode_without_an_app_token_is_refused(self) -> None: + """The failure that otherwise reads as "Slack is quiet today".""" + with pytest.raises(ValueError, match="app_token is required"): + SlackConnectionConfig.model_validate( + {"bot_token": "xoxb-x", "workspace_id": "T1"} + ) + + def test_a_webhook_bridge_with_an_app_token_is_refused(self) -> None: + with pytest.raises(ValueError, match="must be empty"): + SlackConnectionConfig.model_validate( + { + "bot_token": "xoxb-x", + "workspace_id": "T1", + "app_token": "xapp-x", + "event_delivery": "webhook", + } + ) + + def test_the_hand_registered_shape_still_validates(self) -> None: + config = SlackConnectionConfig.model_validate( + {"bot_token": "xoxb-x", "app_token": "xapp-x", "workspace_id": "T1"} + ) + assert config.event_delivery == "socket_mode" + + def test_the_registration_form_never_offers_the_delivery_mode(self) -> None: + """It is not a question an operator filling that form can be asked.""" + assert ( + "event_delivery" + not in SlackConnectionConfig.model_json_schema()["properties"] + ) + + +class TestRegistry: + def test_an_unregistered_platform_says_what_to_do(self) -> None: + registry = MessagingInstallerRegistry() + with pytest.raises(MessagingInstallError, match="no slack app is registered"): + registry.get("slack") + + def test_registering_twice_is_refused(self, installer: SlackAppInstaller) -> None: + registry = MessagingInstallerRegistry() + registry.register(installer) + with pytest.raises(MessagingInstallError, match="already registered"): + registry.register(installer) + + def test_a_registered_installer_comes_back( + self, installer: SlackAppInstaller + ) -> None: + registry = MessagingInstallerRegistry() + registry.register(installer) + assert registry.get("slack") is installer + assert registry.platforms() == ["slack"] + + +class TestPaths: + def test_the_public_prefix_bypasses_bearer_auth(self) -> None: + """Otherwise every Slack event is a 401 nobody sees.""" + from switch_core.bridges.agent.auth import PUBLIC_PATH_PREFIXES + + assert events_path("slack").startswith(PUBLIC_PATH_PREFIXES) + + def test_joining_an_origin_with_a_trailing_slash_does_not_double_it(self) -> None: + """Slack compares the redirect byte for byte.""" + assert public_url( + "https://switch.example/", oauth_callback_path("slack") + ) == public_url("https://switch.example", oauth_callback_path("slack")) diff --git a/core/tests/switch_core/db/test_row_level_security.py b/core/tests/switch_core/db/test_row_level_security.py index c6a1a6ca8..0a3945552 100644 --- a/core/tests/switch_core/db/test_row_level_security.py +++ b/core/tests/switch_core/db/test_row_level_security.py @@ -46,8 +46,6 @@ GLOBAL_TABLES, POLICY_NAME, REQUIRE_TENANT_FUNCTION_NAME, - create_policy_ddl, - enable_rls_ddl, scoped_tables, unscoped_tables, ) diff --git a/core/tests/switch_core/test_config_slack_app.py b/core/tests/switch_core/test_config_slack_app.py new file mode 100644 index 000000000..5c9c18222 --- /dev/null +++ b/core/tests/switch_core/test_config_slack_app.py @@ -0,0 +1,63 @@ +"""Half-configuring the distributed Slack app has to be a startup error. + +Each of the three credentials fails differently and none of them fails +usefully. Without the client id and secret the code exchange is refused by +Slack; without the signing secret there is nothing distinguishing a real event +from a post by anyone who found the URL. A deployment that sets two of three +looks configured, offers the button, and breaks at the worst moment — so it +does not start. +""" + +import pytest + +from switch_core.config import SwitchConfig + +_BASE_KWARGS = dict( + db_host="db", + db_port="5432", + db_user="postgres", + db_password="pw", + db_name="switch", + matrix_server_name="switch.local", + agent_registration_token="token", + jwt_secret_key="jwt", + gateway_admin_email="admin@example.com", + gateway_admin_password="pw", +) + +_APP = dict( + slack_app_client_id="1234.5678", + slack_app_client_secret="secret", + slack_app_signing_secret="signing", +) + + +def _config(**overrides: object) -> SwitchConfig: + return SwitchConfig(**{**_BASE_KWARGS, **overrides}) # type: ignore[arg-type] + + +def test_setting_none_of_them_is_the_ordinary_case() -> None: + assert _config().slack_app_client_id is None + + +def test_setting_all_three_with_a_public_origin_is_accepted() -> None: + config = _config(**_APP, gateway_public_url="https://switch.example") + assert config.slack_app_signing_secret == "signing" + + +@pytest.mark.parametrize("missing", sorted(_APP)) +def test_setting_some_of_them_raises(missing: str) -> None: + partial = {key: value for key, value in _APP.items() if key != missing} + with pytest.raises(ValueError, match="Partial distributed Slack app config"): + _config(**partial, gateway_public_url="https://switch.example") + + +def test_an_app_with_no_public_origin_raises() -> None: + """The redirect and the events URL are both built from it. + + Slack compares the redirect against the one registered with the app, so + building it against nothing is an install that fails at Slack with nothing + in our logs. + """ + with pytest.raises(ValueError, match="GATEWAY_PUBLIC_URL"): + _config(**_APP) diff --git a/docs/old/bridges/README.md b/docs/old/bridges/README.md index fffa6b0bf..38a105e37 100644 --- a/docs/old/bridges/README.md +++ b/docs/old/bridges/README.md @@ -19,6 +19,12 @@ shared onboarding model, then the per-platform guide: | Discord | [`DISCORD_SETUP.md`](DISCORD_SETUP.md) | single bot app | Gateway WebSocket (outbound) | not required | | Telegram | [`TELEGRAM_SETUP.md`](TELEGRAM_SETUP.md) | single bot, agent named in the message body | long polling (outbound) | not required | +Every guide above describes an app the operator registers themselves and +supplies the credentials for. Slack additionally has a **distributed** app — +one we register and a customer installs by clicking a button, receiving events +over HTTPS rather than Socket Mode. It is a separate Slack app with different +requirements: see [`SLACK_DISTRIBUTED_APP.md`](SLACK_DISTRIBUTED_APP.md). + ## The onboarding model (same for every bridge) A bridge is an **unowned, workspace-wide integration** that holds platform diff --git a/docs/old/bridges/SLACK_DISTRIBUTED_APP.md b/docs/old/bridges/SLACK_DISTRIBUTED_APP.md new file mode 100644 index 000000000..3294bde14 --- /dev/null +++ b/docs/old/bridges/SLACK_DISTRIBUTED_APP.md @@ -0,0 +1,202 @@ +# The distributed Slack app + +`SLACK_SETUP.md` describes the app **an operator registers for themselves**: +they create it, install it into their own workspace, and paste its tokens into +Switch. This page describes the other one — the app **we** register and +distribute, which a customer installs by clicking a button and which never +requires them to see a token at all. + +They are two separate Slack apps and they will both exist. Nothing here +replaces the other page. + +## Why it cannot be the same app + +The self-registered app uses **Socket Mode**: Switch dials out to Slack and +events arrive down that connection, so the deployment needs no inbound route +from the internet. That is the right shape for a self-hosted operator and the +wrong shape here, for two independent reasons: + +- Slack does not permit Socket Mode for apps listed on the Marketplace. +- A Socket Mode connection is opened with one app-level token by one process. + Every installing workspace's events would arrive down that single socket, so + the moment Switch runs more than one replica the events land on whichever + replica happens to hold the connection. + +So the distributed app receives events over HTTPS instead, at a public URL. +That is the whole of the difference, and everything below follows from it. + +## The four URLs + +Every URL is the same host with a different path. The host is the public +origin of the deployment — the same value as `GATEWAY_PUBLIC_URL`, which is +already validated as scheme-and-host with no path and already serves the +deeplink redirect from the same application. + +| Slack setting | Path | +| --- | --- | +| **OAuth & Permissions → Redirect URLs** | `/messaging/slack/oauth/callback` | +| **Event Subscriptions → Request URL** | `/messaging/slack/events` | +| **Interactivity & Shortcuts → Request URL** | `/messaging/slack/interactive` | +| **Slash Commands → each command's URL** | `/messaging/slack/commands` | + +`/messaging` is a public prefix in its own right: it is unauthenticated by +nature, because a Slack event arrives with no credential of ours and an OAuth +callback arrives before there is anything to authenticate against. It is +deliberately not `/gateway` — that prefix is cookie-authenticated and is not +routed to this application from the outside — and deliberately not `/oauth`, +which already belongs to agents authenticating *to* Switch and would collide +in name only, confusingly. + +Reaching it from the internet needs the prefix added in two places: the +application's own public-path list, and the deployment's ingress path +allowlist. + +## Registering the app + +**Slack verifies the Request URL the moment you save it**, by posting a +challenge it expects the endpoint to echo back. So the order matters: you can +create the app and collect its credentials before the endpoints exist, but you +cannot fill in the URL fields until they are live and publicly reachable. + +1. → **Create New App** → **From an app + manifest**, in a workspace we control. Paste the manifest below, with the + host substituted. +2. **Basic Information → App Credentials.** Take the **Client ID**, **Client + Secret** and **Signing Secret**. The signing secret is what proves an + inbound webhook came from Slack; the client id and secret are what exchange + an install code for a bot token. There is no app-level token and no bot + token here — a bot token belongs to an installation, not to the app, and + arrives one per customer. +3. **Manage Distribution → Activate Public Distribution.** This is what makes + the app installable outside our own workspace and produces the *Add to + Slack* URL. Slack requires every hardcoded workspace reference to be removed + first. +4. Marketplace listing is a separate, reviewed submission, and is not required + for a customer to install by link. + +## Manifest + +Substitute the host in the four `url` fields. The scopes, slash commands and +bot events are identical to the self-registered app's, because the app does +the same job once installed — the differences are all in `settings` and +`oauth_config`. + +```json +{ + "display_information": { + "name": "Agent Switch" + }, + "features": { + "app_home": { + "home_tab_enabled": true, + "messages_tab_enabled": false, + "messages_tab_read_only_enabled": false + }, + "bot_user": { + "display_name": "Agent Switch", + "always_online": false + }, + "slash_commands": [ + { "command": "/admin", "url": "https://HOST/messaging/slack/commands", "description": "Toggle admin mode on/off for this room", "should_escape": false }, + { "command": "/help", "url": "https://HOST/messaging/slack/commands", "description": "Show the list of available in-room commands", "should_escape": false }, + { "command": "/reset", "url": "https://HOST/messaging/slack/commands", "description": "Reset a targeted agent's session (clears context, then reconnects)", "usage_hint": "@agent-name | @role (required)", "should_escape": true }, + { "command": "/reset-all-agents", "url": "https://HOST/messaging/slack/commands", "description": "Reset EVERY agent's session in this room", "should_escape": false }, + { "command": "/compact", "url": "https://HOST/messaging/slack/commands", "description": "Compact a targeted agent's session context", "usage_hint": "@agent-name | @role (required)", "should_escape": true }, + { "command": "/compact-all-agents", "url": "https://HOST/messaging/slack/commands", "description": "Compact EVERY agent's session context in this room", "should_escape": false }, + { "command": "/interrupt", "url": "https://HOST/messaging/slack/commands", "description": "Interrupt a targeted agent's current turn", "usage_hint": "@agent-name | @role (required)", "should_escape": true }, + { "command": "/interrupt-all-agents", "url": "https://HOST/messaging/slack/commands", "description": "Interrupt EVERY agent's current turn in this room", "should_escape": false }, + { "command": "/agents-status", "url": "https://HOST/messaging/slack/commands", "description": "Show each agent's presence and capabilities in this room", "should_escape": false }, + { "command": "/roles", "url": "https://HOST/messaging/slack/commands", "description": "List this room's roles and who currently holds each", "should_escape": false }, + { "command": "/list-agents", "url": "https://HOST/messaging/slack/commands", "description": "List the agents available in this room", "should_escape": false }, + { "command": "/list-switch-agents", "url": "https://HOST/messaging/slack/commands", "description": "List all agents registered on the Switch", "should_escape": false }, + { "command": "/list-documents", "url": "https://HOST/messaging/slack/commands", "description": "List the room's internal documents", "should_escape": false }, + { "command": "/list-references", "url": "https://HOST/messaging/slack/commands", "description": "List the room's references", "should_escape": false }, + { "command": "/list-aliases", "url": "https://HOST/messaging/slack/commands", "description": "List per-room agent aliases (@alias to agent)", "should_escape": false }, + { "command": "/set-alias", "url": "https://HOST/messaging/slack/commands", "description": "Give an agent a room alias", "usage_hint": "@agent-name @alias", "should_escape": true }, + { "command": "/remove-alias", "url": "https://HOST/messaging/slack/commands", "description": "Remove a room alias", "usage_hint": "@alias (or @agent-name)", "should_escape": true }, + { "command": "/invite-agent", "url": "https://HOST/messaging/slack/commands", "description": "Add an existing agent to this room", "usage_hint": "@agent-name", "should_escape": true }, + { "command": "/run-cmd", "url": "https://HOST/messaging/slack/commands", "description": "Show the terminal command to start a session for an agent", "usage_hint": "@agent-name [@role]", "should_escape": true }, + { "command": "/agents-greet", "url": "https://HOST/messaging/slack/commands", "description": "Have agents in the room introduce themselves", "should_escape": false }, + { "command": "/room-url", "url": "https://HOST/messaging/slack/commands", "description": "Show the frontend URL for this room", "should_escape": false } + ] + }, + "oauth_config": { + "redirect_urls": [ + "https://HOST/messaging/slack/oauth/callback" + ], + "scopes": { + "bot": [ + "files:read", + "files:write", + "assistant:write", + "channels:history", + "channels:manage", + "channels:read", + "chat:write", + "chat:write.customize", + "commands", + "groups:history", + "groups:read", + "groups:write", + "im:history", + "im:read", + "im:write", + "mpim:history", + "reactions:read", + "reactions:write", + "users:read", + "usergroups:read", + "usergroups:write" + ] + }, + "pkce_enabled": false + }, + "settings": { + "event_subscriptions": { + "request_url": "https://HOST/messaging/slack/events", + "bot_events": [ + "message.channels", + "message.groups", + "message.im", + "message.mpim" + ] + }, + "interactivity": { + "is_enabled": true, + "request_url": "https://HOST/messaging/slack/interactive" + }, + "org_deploy_enabled": false, + "socket_mode_enabled": false, + "token_rotation_enabled": false, + "is_mcp_enabled": false + } +} +``` + +## Three things left out on purpose + +**`agent_view`.** The self-registered app declares itself a Slack agent, which +is what turns DMs into threads and puts progress on the message. Declaring it +is irreversible per app, removes guest access from the workspace, and requires +re-review for a distributed app. It should be a deliberate later step with +that review budgeted, not something a customer discovers after installing. + +**`org_deploy_enabled`.** An Enterprise Grid org-wide install identifies itself +by enterprise id rather than by a single workspace id. `messaging_installs` is +unique on `(platform, external_workspace_id)` and an org install does not have +one of those, so enabling this is a schema question and not a checkbox. + +**`token_rotation_enabled`.** Rotating tokens means storing a refresh token, +refreshing before expiry, and handling a refresh that fails while events are +arriving. Worth doing, and not worth doing at the same time as everything +else; a non-rotating bot token is what the self-registered app already uses. + +## What a customer's install produces + +One row in `messaging_installs`: the workspace it was installed into, the bot +token that install granted (encrypted), the scopes Slack actually approved, +and the tenant and user who initiated it. `(platform, external_workspace_id)` +is unique across the whole deployment, because an inbound event carries a +workspace id and no tenant — a workspace claimed by two tenants would be an +event with two possible destinations. A second tenant attempting to claim an +already-claimed workspace is refused by the database. diff --git a/docs/old/bridges/SLACK_SETUP.md b/docs/old/bridges/SLACK_SETUP.md index da8182690..4f69fefc2 100644 --- a/docs/old/bridges/SLACK_SETUP.md +++ b/docs/old/bridges/SLACK_SETUP.md @@ -6,6 +6,11 @@ avatar overrides. Inbound events arrive over **Socket Mode** — an outbound WebSocket the bot opens to Slack — so **no public ingress is required**. Outbound messages go through the Slack Web API. +This page is about the app **you** register and hold the tokens for. There is +also a distributed app that we register and you install by clicking a button; +it is a different Slack app with different requirements, described in +[`SLACK_DISTRIBUTED_APP.md`](SLACK_DISTRIBUTED_APP.md). + ## Prerequisites - A Slack workspace where you can install a custom app (workspace admin approval From 21b661cf4e22946894b0eea9248cb6089f3f8b27 Mon Sep 17 00:00:00 2001 From: Petr Bauch Date: Fri, 11 Sep 2026 15:30:05 +0200 Subject: [PATCH 03/29] feat(bridges): an install that completes, from the button to a running bridge (CHOO-2626) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../bridges/collaboration/install.py | 8 +- .../bridges/collaboration/install_routes.py | 145 +++++++ .../bridges/collaboration/install_service.py | 196 ++++++++++ .../bridges/collaboration/install_state.py | 133 +++++++ .../bridges/collaboration/slack/install.py | 6 + core/switch_core/db/stores/__init__.py | 2 + .../db/stores/messaging_install_store.py | 160 ++++++++ core/switch_core/gateway/app.py | 13 + core/switch_core/gateway/dependencies.py | 15 + .../switch_core/gateway/messaging_installs.py | 86 +++++ core/switch_core/main.py | 47 +++ .../collaboration/test_install_service.py | 354 ++++++++++++++++++ .../collaboration/test_install_state.py | 117 ++++++ .../collaboration/test_slack_installer.py | 32 +- .../db/stores/test_messaging_install_store.py | 271 ++++++++++++++ 15 files changed, 1579 insertions(+), 6 deletions(-) create mode 100644 core/switch_core/bridges/collaboration/install_routes.py create mode 100644 core/switch_core/bridges/collaboration/install_service.py create mode 100644 core/switch_core/bridges/collaboration/install_state.py create mode 100644 core/switch_core/db/stores/messaging_install_store.py create mode 100644 core/switch_core/gateway/messaging_installs.py create mode 100644 core/tests/switch_core/bridges/collaboration/test_install_service.py create mode 100644 core/tests/switch_core/bridges/collaboration/test_install_state.py create mode 100644 core/tests/switch_core/db/stores/test_messaging_install_store.py diff --git a/core/switch_core/bridges/collaboration/install.py b/core/switch_core/bridges/collaboration/install.py index ff9e4039e..bd80a4bda 100644 --- a/core/switch_core/bridges/collaboration/install.py +++ b/core/switch_core/bridges/collaboration/install.py @@ -105,11 +105,16 @@ class WebhookAuthenticityError(RuntimeError): class InstallGrant: """What the platform handed back when a workspace installed us. - Deliberately three fields and not the platform's whole response. What a + Deliberately four fields and not the platform's whole response. What a grant *is*, across platforms, is a workspace, a credential, and the permissions that credential was actually given — everything else in the response is Slack's shape and belongs behind `connection_config`. + `workspace_name` is the exception, and it earns its place by being the only + thing here a person recognises. It names the bridge in the operator's list, + where the alternative is a row of opaque platform ids. It is the customer's + own text and is never matched on. + `scopes` is the platform's own spelling, kept verbatim. A scope string that means nothing to us is still the thing to show an operator asking why a call was refused, and parsing it into a list here would be a parser to keep @@ -117,6 +122,7 @@ class InstallGrant: """ external_workspace_id: str + workspace_name: str bot_token: str scopes: str diff --git a/core/switch_core/bridges/collaboration/install_routes.py b/core/switch_core/bridges/collaboration/install_routes.py new file mode 100644 index 000000000..38a6dd855 --- /dev/null +++ b/core/switch_core/bridges/collaboration/install_routes.py @@ -0,0 +1,145 @@ +"""The public half of an install: the callback the platform redirects back to. + +Mounted on the agent-bridge app rather than inside `/gateway`, because this is +the leg the outside world reaches. `/gateway` is not routed here from the +public load balancer at all, and it would not help if it were — it is +cookie-authenticated and this caller has no cookie of ours. + +**The reply is a page, not a redirect.** Sending the browser on to the gateway +would work today, when the person installing is an operator who can reach it, +and would break the moment the same flow is offered to a customer who cannot: +the gateway is on a private hostname and the callback is not. So the outcome is +rendered here, in one self-contained page, on the origin the browser already +reached. It is deliberately plain; when there is a place to send people, this +becomes a redirect and the page becomes its fallback. +""" + +from __future__ import annotations + +import html +import logging + +from fastapi import APIRouter, Query +from starlette.responses import HTMLResponse + +from switch_core.bridges.collaboration.install import ( + PUBLIC_PATH_PREFIX, + MessagingInstallError, +) +from switch_core.bridges.collaboration.install_service import ( + InstallPlatformMismatch, + MessagingInstallService, +) +from switch_core.bridges.collaboration.install_state import InstallStateError +from switch_core.db.stores.messaging_install_store import ( + MessagingInstallClaimedError, + MessagingInstallStateError, +) + +logger = logging.getLogger(__name__) + +_PAGE = """ + +{title} + +

{title}

{detail}

+""" + + +def _page(*, title: str, detail: str, status: int) -> HTMLResponse: + return HTMLResponse( + _PAGE.format(title=html.escape(title), detail=html.escape(detail)), + status_code=status, + ) + + +def create_messaging_install_router( + service: MessagingInstallService, +) -> APIRouter: + """Build the public install routes over one already-configured service. + + A factory closing over the service rather than a module-level router with + dependencies, because this app has no dependency-injection module of its + own and adding one for a single object would be the larger change. + """ + router = APIRouter(prefix=PUBLIC_PATH_PREFIX) + + @router.get("/{platform}/oauth/callback") + async def oauth_callback( + platform: str, + code: str | None = Query(default=None), + state: str | None = Query(default=None), + error: str | None = Query(default=None), + ) -> HTMLResponse: + # The platform's own refusal, which is usually the customer deciding + # not to install after all. Nothing went wrong here and nothing was + # written; saying so is the whole handling. + if error: + return _page( + title="Install cancelled", + detail=f"{platform} reported: {error}. Nothing was connected.", + status=200, + ) + + if not code or not state: + return _page( + title="Install could not be completed", + detail=( + f"{platform} did not send back everything this needs. Start " + "the install again from Switch." + ), + status=400, + ) + + try: + install = await service.complete( + platform=platform, code=code, state_token=state + ) + except (InstallStateError, InstallPlatformMismatch) as failure: + # Unauthenticated input, so the reply says nothing the caller did + # not already know. The reason goes to the log, at warning: a + # forged state is worth noticing and is not worth an alert. + logger.warning("Refused a %s install callback: %s", platform, failure) + return _page( + title="Install could not be completed", + detail=( + "This install link is not one Switch recognises. Start the " + "install again from Switch." + ), + status=400, + ) + except MessagingInstallStateError as failure: + return _page( + title="Install link already used", + detail=str(failure), + status=400, + ) + except MessagingInstallClaimedError as failure: + return _page( + title="Workspace already connected", + detail=str(failure), + status=409, + ) + except MessagingInstallError as failure: + return _page( + title="Install could not be completed", + detail=str(failure), + status=400, + ) + + return _page( + title="Switch is connected", + detail=( + f"The {platform} workspace {install.external_workspace_id} is " + "connected to Switch. You can close this page and finish setting " + "it up there." + ), + status=200, + ) + + return router diff --git a/core/switch_core/bridges/collaboration/install_service.py b/core/switch_core/bridges/collaboration/install_service.py new file mode 100644 index 000000000..74601a8c8 --- /dev/null +++ b/core/switch_core/bridges/collaboration/install_service.py @@ -0,0 +1,196 @@ +"""The two legs of an install, and the order they have to happen in. + +An install is one operation split across two requests that share no session, +no origin and no credential — only the state token. The first leg is +authenticated and decides the tenant; the second is a stranger arriving from +the platform. Everything here is about that asymmetry. + +The finishing order is load-bearing and not the obvious one: + +1. **Verify the signature, then bind the tenant.** Nothing before this touches + the database, because until the signature verifies there is no tenant to + bind and RLS would refuse every statement anyway. +2. **Burn the state, and commit.** Before talking to the platform, not after. + Redeeming first would leave a window in which a replayed callback is still + redeemable, and burning inside the same transaction as the rest would hold + a row lock across a call to someone else's API. The cost is that a platform + outage burns the link — the customer starts a ten-second flow again, and + the property we keep in exchange is that a captured state is worth nothing. +3. **Exchange the code**, which is the only network call. +4. **Claim the workspace**, which is where a workspace already held by another + tenant fails, in the database rather than in a check above it. +5. **Register the bridge** from the rendered connection config, exactly as if + an operator had typed the token in, and point the install row at it. + +Step 5 last is deliberate too: a bridge that exists with no install row behind +it is an orphan nothing can revoke, whereas an install row with no bridge is a +recorded credential waiting to be used, which is a state the schema already +allows for. +""" + +from __future__ import annotations + +import logging + +from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker + +from switch_core.bridges.collaboration.install import ( + MessagingAppInstaller, + MessagingInstallerRegistry, + oauth_callback_path, + public_url, +) +from switch_core.bridges.collaboration.install_state import ( + InstallState, + mint, + verify, +) +from switch_core.bridges.collaboration.lifecycle_service import ( + CollaborationBridgeLifecycleService, +) +from switch_core.crypto import encrypt_token +from switch_core.db.models import MessagingInstall +from switch_core.db.session_scope import tenant_session +from switch_core.db.stores.messaging_install_store import MessagingInstallStore +from switch_core.tenant_context import tenant_scope + +logger = logging.getLogger(__name__) + + +class InstallPlatformMismatch(RuntimeError): + """A state minted for one platform arrived at another's callback. + + Only reachable with a valid signature, so this is not an attack so much as + a deployment that has crossed its own wires — but it would otherwise redeem + a real state against the wrong installer, and the store's own platform + predicate would then refuse it with a message about expiry that is not + true. + """ + + +class MessagingInstallService: + def __init__( + self, + *, + session_factory: async_sessionmaker[AsyncSession], + store: MessagingInstallStore, + installers: MessagingInstallerRegistry, + lifecycle: CollaborationBridgeLifecycleService, + public_origin: str, + secret: str, + ) -> None: + self._session_factory = session_factory + self._store = store + self._installers = installers + self._lifecycle = lifecycle + self._public_origin = public_origin + self._secret = secret + + def _redirect_uri(self, platform: str) -> str: + """Where the platform sends the browser back to. + + Built from the public origin rather than from the incoming request, + because the two legs arrive on different hostnames and the platform + compares this string byte for byte against the one registered with the + app. Deriving it from `request.url` would produce the gateway's + hostname on the first leg and a redirect the platform refuses. + """ + return public_url(self._public_origin, oauth_callback_path(platform)) + + def installer(self, platform: str) -> MessagingAppInstaller: + return self._installers.get(platform) + + def platforms(self) -> list[str]: + return self._installers.platforms() + + async def begin(self, session: AsyncSession, *, platform: str, user_id: str) -> str: + """Start an install and return where to send the browser. + + Runs on the caller's own scoped session, so the tenant recorded is the + tenant they are authenticated for and there is no parameter through + which they could name another. + """ + installer = self._installers.get(platform) + state = await self._store.start_install( + session, platform=platform, user_id=user_id + ) + token = mint( + InstallState( + tenant_id=state.tenant_id, state_id=state.id, platform=platform + ), + secret=self._secret, + ) + return installer.authorize_url( + state=token, redirect_uri=self._redirect_uri(platform) + ) + + async def complete( + self, *, platform: str, code: str, state_token: str + ) -> MessagingInstall: + """Finish an install begun elsewhere, on behalf of nobody in particular. + + The caller is unauthenticated: everything trusted here comes out of the + signature on `state_token` or out of the platform's own response. + """ + installer = self._installers.get(platform) + state = verify(state_token, secret=self._secret) + if state.platform != platform: + raise InstallPlatformMismatch( + f"an install state for {state.platform} was presented to the " + f"{platform} callback" + ) + + with tenant_scope(state.tenant_id): + async with tenant_session( + self._session_factory, state.tenant_id + ) as session: + burnt = await self._store.redeem_state( + session, state_id=state.state_id, platform=platform + ) + await session.commit() + + grant = await installer.redeem( + code=code, redirect_uri=self._redirect_uri(platform) + ) + + async with tenant_session( + self._session_factory, state.tenant_id + ) as session: + install = await self._store.record_install( + session, + platform=platform, + external_workspace_id=grant.external_workspace_id, + encrypted_bot_token=encrypt_token(grant.bot_token, self._secret), + scopes=grant.scopes, + user_id=burnt.created_by_user_id, + ) + install_id = install.id + await session.commit() + + bridge = await self._lifecycle.register( + bridge_type=platform, + display_name=grant.workspace_name, + connection_config=installer.connection_config(grant), + # Off, though the granted scopes would allow it. Nobody was + # asked: an install has no registration form, and letting an + # app create channels in a customer's workspace is a decision + # someone should make rather than inherit. + channel_creation_enabled=False, + ) + + async with tenant_session( + self._session_factory, state.tenant_id + ) as session: + attached = await self._store.attach_bridge( + session, install_id=install_id, bridge_id=bridge.id + ) + await session.commit() + + logger.info( + "Installed %s workspace %s for tenant %s as bridge %s", + platform, + grant.external_workspace_id, + state.tenant_id, + bridge.id, + ) + return attached diff --git a/core/switch_core/bridges/collaboration/install_state.py b/core/switch_core/bridges/collaboration/install_state.py new file mode 100644 index 000000000..fda5928be --- /dev/null +++ b/core/switch_core/bridges/collaboration/install_state.py @@ -0,0 +1,133 @@ +"""The `state` parameter that carries a tenant across the install round trip. + +An install starts on the gateway, where the caller is authenticated and their +tenant is known, and finishes on a public callback the platform redirects the +browser to. Something has to get the tenant from one to the other, and the two +obvious candidates do not work here: + +- **A cookie** is what the gateway's own OIDC flow uses, and it cannot be used + for this. The gateway is reached on one hostname and the public callback on + another — they are different origins by deployment, not by accident — so a + cookie set at the start leg is simply not sent to the callback. +- **A database lookup** keyed by the state would work, but the callback runs + with no tenant bound and RLS refuses every read until one is. Making it + possible would mean a ninth `SECURITY DEFINER` function, and the shortness of + that list is the property that makes it reviewable. + +So the state is a token *we* sign, naming the tenant. The callback verifies the +signature, binds the tenant it names, and from that point runs as an ordinary +scoped request — every subsequent read and write is checked by RLS in the +normal way. A forged or edited state fails the signature and never reaches the +database at all. + +**A signature is not enough on its own**, which is why the token names a row as +well as a tenant. A signed token stays valid as long as the key does, so a +captured state could be replayed to install an attacker's workspace against the +victim's tenant — message injection into someone else's rooms. Single use is +the missing half and only the database can provide it: the row named here is +burnt with a conditional update, and a second attempt finds nothing to burn. + +The signing key is derived from `JWT_SECRET_KEY` rather than being another +value to deploy, but it is *derived* rather than reused: a token minted here +must never be mistakable for an agent's JWT, or for whatever the next thing to +want a signature turns out to be. +""" + +from __future__ import annotations + +import base64 +import hashlib +import hmac +import json +from dataclasses import dataclass + +#: Distinguishes this key from every other use of `JWT_SECRET_KEY`, and this +#: token format from whatever replaces it. Changing either string invalidates +#: every state in flight, which for a flow measured in seconds is free. +_KEY_INFO = b"switch/messaging-install-state/v1" + +_PREFIX = "v1." + + +class InstallStateError(RuntimeError): + """A state parameter was absent, malformed, or not signed by us. + + All three are the same answer to the caller — the install is refused — + and deliberately the same answer to an observer: nothing distinguishes a + truncated token from a forged one, because the difference is only ever + interesting to someone probing. + """ + + +@dataclass(frozen=True) +class InstallState: + """What a verified state names. + + `tenant_id` is trusted *because* the signature verified, and is bound to + the session before anything else happens. `state_id` names the row to burn. + `platform` is carried so the callback route's own path cannot be used to + redeem a state minted for a different platform. + """ + + tenant_id: str + state_id: str + platform: str + + +def _signing_key(secret: str) -> bytes: + return hmac.new(secret.encode(), _KEY_INFO, hashlib.sha256).digest() + + +def _b64(raw: bytes) -> str: + return base64.urlsafe_b64encode(raw).decode().rstrip("=") + + +def _unb64(encoded: str) -> bytes: + return base64.urlsafe_b64decode(encoded + "=" * (-len(encoded) % 4)) + + +def mint(state: InstallState, *, secret: str) -> str: + """Sign a state for the platform to hand back to us unchanged.""" + payload = _b64( + json.dumps( + {"tid": state.tenant_id, "sid": state.state_id, "plat": state.platform}, + separators=(",", ":"), + sort_keys=True, + ).encode() + ) + signature = hmac.new(_signing_key(secret), payload.encode(), hashlib.sha256) + return f"{_PREFIX}{payload}.{_b64(signature.digest())}" + + +def verify(token: str, *, secret: str) -> InstallState: + """Recover the state from a token, or raise. + + Nothing here touches the database, and nothing here is trusted before the + comparison: the payload is not decoded until its signature has matched, so + a hostile token is bytes we compared and discarded. + """ + if not token.startswith(_PREFIX): + raise InstallStateError("install state is not a state this deployment minted") + + try: + payload, signature = token[len(_PREFIX) :].split(".") + except ValueError: + raise InstallStateError("install state is malformed") from None + + expected = hmac.new(_signing_key(secret), payload.encode(), hashlib.sha256).digest() + try: + matches = hmac.compare_digest(_unb64(signature), expected) + except ValueError: + raise InstallStateError("install state is malformed") from None + if not matches: + raise InstallStateError("install state was not signed by this deployment") + + try: + decoded = json.loads(_unb64(payload)) + return InstallState( + tenant_id=decoded["tid"], + state_id=decoded["sid"], + platform=decoded["plat"], + ) + except (ValueError, KeyError, TypeError): + raise InstallStateError("install state is malformed") from None diff --git a/core/switch_core/bridges/collaboration/slack/install.py b/core/switch_core/bridges/collaboration/slack/install.py index cf63742ff..2a3922994 100644 --- a/core/switch_core/bridges/collaboration/slack/install.py +++ b/core/switch_core/bridges/collaboration/slack/install.py @@ -125,6 +125,7 @@ async def redeem(self, *, code: str, redirect_uri: str) -> InstallGrant: return InstallGrant( external_workspace_id=workspace_id, + workspace_name=team.get("name") or workspace_id, bot_token=access_token, scopes=response.get("scope") or "", ) @@ -150,4 +151,9 @@ def connection_config(self, grant: InstallGrant) -> dict[str, object]: return { "bot_token": grant.bot_token, "workspace_id": grant.external_workspace_id, + # Not a detail of this rendering: it is the difference between the + # two apps. Left out, the config validates as a Socket Mode bridge + # missing its app token, and the install fails at registration + # rather than at anything a reader would look at. + "event_delivery": "webhook", } diff --git a/core/switch_core/db/stores/__init__.py b/core/switch_core/db/stores/__init__.py index dbee2199f..114b00a82 100644 --- a/core/switch_core/db/stores/__init__.py +++ b/core/switch_core/db/stores/__init__.py @@ -7,6 +7,7 @@ from switch_core.db.stores.external_user_store import ExternalUserStore from switch_core.db.stores.invitation_store import InvitationStore from switch_core.db.stores.message_store import MessageStore +from switch_core.db.stores.messaging_install_store import MessagingInstallStore from switch_core.db.stores.reference_store import ReferenceStore from switch_core.db.stores.reference_type_store import ReferenceTypeStore from switch_core.db.stores.room_group_store import RoomGroupStore @@ -28,6 +29,7 @@ "ExternalUserStore", "InvitationStore", "MessageStore", + "MessagingInstallStore", "ReferenceStore", "ReferenceTypeStore", "RoomGroupStore", diff --git a/core/switch_core/db/stores/messaging_install_store.py b/core/switch_core/db/stores/messaging_install_store.py new file mode 100644 index 000000000..269560850 --- /dev/null +++ b/core/switch_core/db/stores/messaging_install_store.py @@ -0,0 +1,160 @@ +from __future__ import annotations + +from datetime import UTC, datetime, timedelta + +from sqlalchemy import select, update +from sqlalchemy.exc import IntegrityError +from sqlalchemy.ext.asyncio import AsyncSession + +from switch_core.db.models import ( + MessagingInstall, + MessagingInstallState, + require_tenant_id, +) + +#: How long a customer has to finish the platform's half of the flow. Long +#: enough for a real person to read a consent screen and pick a workspace, +#: short enough that a state captured from a browser's history or a proxy log +#: is worthless by the time anyone reads it. +STATE_TTL = timedelta(minutes=10) + + +class MessagingInstallClaimedError(RuntimeError): + """Another tenant has already installed the app into this workspace. + + Worth its own type because it is the one install failure that is not a + mistake by the caller and cannot be retried into working. What it discloses + — that *somebody* holds this workspace — is deliberate: the alternative is + accepting a second claim and delivering that workspace's messages to + whichever tenant the router happened to pick. + """ + + +class MessagingInstallStateError(RuntimeError): + """A state could not be redeemed: already used, expired, or not ours.""" + + +class MessagingInstallStore: + async def start_install( + self, session: AsyncSession, *, platform: str, user_id: str + ) -> MessagingInstallState: + """Record an install about to be attempted, and return the row to sign. + + `tenant_id` is left to the model's default, which reads the tenant + bound to this session — the caller does not get to name one. That is + the whole reason the start leg is authenticated and the callback is + not: this is where the tenant is decided. + """ + state = MessagingInstallState( + platform=platform, + created_by_user_id=user_id, + expires_at=datetime.now(UTC) + STATE_TTL, + ) + session.add(state) + await session.flush() + return state + + async def redeem_state( + self, session: AsyncSession, *, state_id: str, platform: str + ) -> MessagingInstallState: + """Burn a state, or raise. Exactly one caller can succeed. + + The check and the write are one statement on purpose. Reading the row, + deciding it is unused and then updating it leaves a window in which two + callbacks both read `consumed_at IS NULL` — and the whole reason this + row exists is to make a replayed state fail. `expires_at` is in the + same predicate for the same reason. + + Scoped to the bound tenant as well, which the signature has already + established: a state signed for one tenant and naming a row belonging + to another matches nothing, so RLS is a second, independent check on + the first. + """ + result = await session.execute( + update(MessagingInstallState) + .where( + MessagingInstallState.id == state_id, + MessagingInstallState.tenant_id == require_tenant_id(), + MessagingInstallState.platform == platform, + MessagingInstallState.consumed_at.is_(None), + MessagingInstallState.expires_at > datetime.now(UTC), + ) + .values(consumed_at=datetime.now(UTC)) + .returning(MessagingInstallState) + ) + state = result.scalars().one_or_none() + if state is None: + raise MessagingInstallStateError( + "this install link has already been used or has expired. Start " + "the install again from Switch." + ) + return state + + async def record_install( + self, + session: AsyncSession, + *, + platform: str, + external_workspace_id: str, + encrypted_bot_token: str, + scopes: str, + user_id: str, + ) -> MessagingInstall: + """Claim a workspace for the bound tenant. + + The claim is the insert: `(platform, external_workspace_id)` is unique + across the deployment, so the database decides who holds a workspace + rather than a read followed by a write that cannot be made atomic with + it. A second tenant's install therefore fails here, loudly, instead of + producing an event with two possible destinations. + """ + install = MessagingInstall( + platform=platform, + external_workspace_id=external_workspace_id, + encrypted_bot_token=encrypted_bot_token, + scopes=scopes, + status="active", + installed_by_user_id=user_id, + ) + session.add(install) + try: + await session.flush() + except IntegrityError as exc: + if "uq_messaging_installs_workspace" not in str(exc.orig): + raise + raise MessagingInstallClaimedError( + f"the {platform} workspace {external_workspace_id} is already " + "connected to Switch. Remove the existing install before " + "connecting it again." + ) from exc + return install + + async def get_for_workspace( + self, session: AsyncSession, *, platform: str, external_workspace_id: str + ) -> MessagingInstall | None: + """The bound tenant's install of one workspace, if it is theirs. + + Deliberately still scoped, even though the unique constraint means at + most one row exists deployment-wide: the caller is an inbound webhook + that resolved a tenant from the workspace a moment ago, and this + re-reading it under RLS is what makes a mistake there a miss rather + than a cross-tenant read. + """ + result = await session.execute( + select(MessagingInstall).where( + MessagingInstall.platform == platform, + MessagingInstall.external_workspace_id == external_workspace_id, + ) + ) + return result.scalars().one_or_none() + + async def attach_bridge( + self, session: AsyncSession, *, install_id: str, bridge_id: str + ) -> MessagingInstall: + """Point an install at the bridge now serving it.""" + install = await session.get(MessagingInstall, install_id) + if install is None: + raise MessagingInstallStateError(f"install not found: {install_id}") + install.bridge_id = bridge_id + await session.flush() + return install diff --git a/core/switch_core/gateway/app.py b/core/switch_core/gateway/app.py index 6f1b46d2f..a97deaf73 100644 --- a/core/switch_core/gateway/app.py +++ b/core/switch_core/gateway/app.py @@ -8,6 +8,9 @@ from switch_core.bridges.agent.server_connectors.lifecycle import ( ServerSideConnectorLifecycleService, ) +from switch_core.bridges.collaboration.install_service import ( + MessagingInstallService, +) from switch_core.bridges.collaboration.lifecycle_service import ( CollaborationBridgeLifecycleService, ) @@ -32,6 +35,9 @@ from switch_core.gateway.dependencies import init_dependencies from switch_core.gateway.documents import router as documents_router from switch_core.gateway.ecosystem import router as ecosystem_router +from switch_core.gateway.messaging_installs import ( + router as messaging_installs_router, +) from switch_core.gateway.oidc_routes import register_oidc_client from switch_core.gateway.oidc_routes import router as oidc_router from switch_core.gateway.packages import router as packages_router @@ -64,6 +70,7 @@ def create_gateway_app( template_store: TemplateStore, resource_service: ResourceService, protocol: ProtocolService, + install_service: MessagingInstallService | None, config: SwitchConfig, ) -> FastAPI: init_dependencies( @@ -85,6 +92,7 @@ def create_gateway_app( template_store=template_store, resource_service=resource_service, protocol=protocol, + install_service=install_service, config=config, ) @@ -120,5 +128,10 @@ def create_gateway_app( app.include_router(packages_router, tags=["packages"]) app.include_router(templates_router, tags=["templates"]) app.include_router(ecosystem_router, prefix="/ecosystem", tags=["ecosystem"]) + app.include_router( + messaging_installs_router, + prefix="/messaging-apps", + tags=["messaging-apps"], + ) return app diff --git a/core/switch_core/gateway/dependencies.py b/core/switch_core/gateway/dependencies.py index 962561960..8f85508d1 100644 --- a/core/switch_core/gateway/dependencies.py +++ b/core/switch_core/gateway/dependencies.py @@ -10,6 +10,9 @@ from switch_core.bridges.agent.server_connectors.lifecycle import ( ServerSideConnectorLifecycleService, ) +from switch_core.bridges.collaboration.install_service import ( + MessagingInstallService, +) from switch_core.bridges.collaboration.lifecycle_service import ( CollaborationBridgeLifecycleService, ) @@ -52,6 +55,7 @@ def init_dependencies( template_store: TemplateStore, resource_service: ResourceService, protocol: ProtocolService, + install_service: MessagingInstallService | None, config: SwitchConfig, ) -> None: _state["agent_store"] = agent_store @@ -72,6 +76,7 @@ def init_dependencies( _state["template_store"] = template_store _state["resource_service"] = resource_service _state["protocol"] = protocol + _state["install_service"] = install_service _state["config"] = config @@ -215,5 +220,15 @@ def get_protocol() -> ProtocolService: return _state["protocol"] # type: ignore[no-any-return] +def get_install_service() -> MessagingInstallService | None: + """None when this deployment registered no messaging app of its own. + + Nullable rather than absent because that is the ordinary case, and the + endpoints have to answer it with a refusal that says so rather than with a + KeyError. + """ + return _state["install_service"] # type: ignore[no-any-return] + + def get_resource_service() -> ResourceService: return _state["resource_service"] # type: ignore[no-any-return] diff --git a/core/switch_core/gateway/messaging_installs.py b/core/switch_core/gateway/messaging_installs.py new file mode 100644 index 000000000..16ee7ffab --- /dev/null +++ b/core/switch_core/gateway/messaging_installs.py @@ -0,0 +1,86 @@ +"""Starting an install: the authenticated leg, and the only one that picks a tenant. + +The endpoint answers with a URL rather than a redirect. The install has to +begin in a top-level browser window on the platform's own domain, and a +redirect from an XHR the operator's dashboard made would be followed by the +XHR, not by the window. Handing the URL back and letting the page navigate is +the shape that works. +""" + +from __future__ import annotations + +import logging +from typing import Annotated + +from fastapi import APIRouter, Depends, HTTPException +from pydantic import BaseModel +from sqlalchemy.ext.asyncio import AsyncSession + +from switch_core.bridges.collaboration.install import MessagingInstallError +from switch_core.bridges.collaboration.install_service import MessagingInstallService +from switch_core.db.models import User +from switch_core.gateway.auth import require_admin +from switch_core.gateway.dependencies import get_install_service, get_session + +logger = logging.getLogger(__name__) + +router = APIRouter() + + +class InstallStart(BaseModel): + authorize_url: str + + +class InstallablePlatforms(BaseModel): + platforms: list[str] + + +def _require_installs( + service: MessagingInstallService | None, +) -> MessagingInstallService: + """Refuse rather than pretend when this deployment registered no app. + + Most deployments will register none — the self-hosted path is an operator + registering their own app and pasting the token in — so this is an ordinary + answer and not an error condition. It is still a refusal: a deployment that + offered the button and then failed at the platform would be worse. + """ + if service is None: + raise HTTPException( + status_code=501, + detail=( + "This Switch deployment has no messaging app of its own to " + "install. Register a bridge with your own app's credentials " + "instead." + ), + ) + return service + + +@router.get("") +async def installable_platforms( + service: Annotated[MessagingInstallService | None, Depends(get_install_service)], + _user: Annotated[User, Depends(require_admin)], +) -> InstallablePlatforms: + return InstallablePlatforms( + platforms=[] if service is None else service.platforms() + ) + + +@router.post("/{platform}/install") +async def begin_install( + platform: str, + session: Annotated[AsyncSession, Depends(get_session)], + service: Annotated[MessagingInstallService | None, Depends(get_install_service)], + user: Annotated[User, Depends(require_admin)], +) -> InstallStart: + try: + authorize_url = await _require_installs(service).begin( + session, platform=platform, user_id=user.id + ) + except MessagingInstallError as failure: + raise HTTPException(status_code=404, detail=str(failure)) from failure + + await session.commit() + logger.info("Started a %s install for user %s", platform, user.id) + return InstallStart(authorize_url=authorize_url) diff --git a/core/switch_core/main.py b/core/switch_core/main.py index 1c0423e2f..f36744ffc 100644 --- a/core/switch_core/main.py +++ b/core/switch_core/main.py @@ -49,6 +49,11 @@ DiscordAdapter, DiscordConnectionConfig, ) +from switch_core.bridges.collaboration.install import MessagingInstallerRegistry +from switch_core.bridges.collaboration.install_routes import ( + create_messaging_install_router, +) +from switch_core.bridges.collaboration.install_service import MessagingInstallService from switch_core.bridges.collaboration.lifecycle_service import ( CollaborationBridgeLifecycleService, ) @@ -60,6 +65,7 @@ SlackAdapter, SlackConnectionConfig, ) +from switch_core.bridges.collaboration.slack.install import SlackAppInstaller from switch_core.bridges.collaboration.teams.adapter import ( TeamsAdapter, TeamsConnectionConfig, @@ -100,6 +106,7 @@ from switch_core.db.stores.invitation_store import InvitationStore from switch_core.db.stores.media_store import MediaStore from switch_core.db.stores.message_store import MessageStore +from switch_core.db.stores.messaging_install_store import MessagingInstallStore from switch_core.db.stores.package_store import PackageStore from switch_core.db.stores.reference_store import ReferenceStore from switch_core.db.stores.reference_type_store import ReferenceTypeStore @@ -475,6 +482,36 @@ async def run(config: SwitchConfig) -> None: "opencode", OpenCodeConnector, OpenCodeConnectionConfig ) + # ── Messaging app installs ────────────────────────────────────────────── + # Registration is the feature flag. An installer exists for a platform when + # this deployment holds that platform's app credentials, and the whole + # install surface refuses when none does — a deployment that registered no + # app cannot half-offer the button. Config validation has already required + # the three Slack values all together and a public origin with them. + installers = MessagingInstallerRegistry() + if config.slack_app_client_id: + assert config.slack_app_client_secret is not None + assert config.slack_app_signing_secret is not None + installers.register( + SlackAppInstaller( + client_id=config.slack_app_client_id, + client_secret=config.slack_app_client_secret, + signing_secret=config.slack_app_signing_secret, + ) + ) + + install_service: MessagingInstallService | None = None + if installers.platforms(): + assert config.gateway_public_url is not None + install_service = MessagingInstallService( + session_factory=session_factory, + store=MessagingInstallStore(), + installers=installers, + lifecycle=collab_lifecycle, + public_origin=config.gateway_public_url, + secret=config.jwt_secret_key, + ) + # ── Gateway app ─────────────────────────────────────────────────────────── gateway_app = create_gateway_app( agent_store=agent_store, @@ -495,6 +532,7 @@ async def run(config: SwitchConfig) -> None: template_store=template_store, resource_service=resource_service, protocol=protocol, + install_service=install_service, config=config, ) @@ -516,6 +554,15 @@ async def run(config: SwitchConfig) -> None: async def health_check() -> JSONResponse: return JSONResponse({"status": "ok"}) + # Mounted on the agent-bridge app, not inside /gateway: this is the leg a + # platform and a customer's browser reach, and /gateway is neither routed + # here from outside nor reachable without a cookie they do not have. + if install_service is not None: + agent_bridge_app.include_router( + create_messaging_install_router(install_service), + tags=["messaging-installs"], + ) + agent_bridge_app.mount("/gateway", gateway_app) # ── Ensure system clients exist ───────────────────────────────────────── diff --git a/core/tests/switch_core/bridges/collaboration/test_install_service.py b/core/tests/switch_core/bridges/collaboration/test_install_service.py new file mode 100644 index 000000000..aec860c6b --- /dev/null +++ b/core/tests/switch_core/bridges/collaboration/test_install_service.py @@ -0,0 +1,354 @@ +"""The install as one operation, across the two requests it is actually made of. + +The individual pieces are covered elsewhere — the signature in +`test_install_state.py`, single use in the store's own tests, Slack's responses +in `test_slack_installer.py`. What is only visible here is the join: that the +tenant the first leg was authenticated for is the tenant the second leg writes +to, that a replay gets no further than the burn, and that a bridge appears at +the end configured the way an operator's own would be. + +Postgres is real because row-level security is the second half of the argument +and a mock has no policies. +""" + +from __future__ import annotations + +import uuid +from collections.abc import Mapping +from typing import ClassVar +from urllib.parse import parse_qs, urlparse + +import pytest +from sqlalchemy.ext.asyncio import async_sessionmaker + +from switch_core.bridges.collaboration.install import ( + InstallGrant, + MessagingAppInstaller, + MessagingInstallerRegistry, +) +from switch_core.bridges.collaboration.install_service import ( + InstallPlatformMismatch, + MessagingInstallService, +) +from switch_core.bridges.collaboration.install_state import ( + InstallState, + InstallStateError, + mint, +) +from switch_core.crypto import decrypt_token +from switch_core.db.models import ( + Client, + CollaborationBridge, + MessagingInstall, + Tenant, + User, +) +from switch_core.db.session_scope import tenant_session +from switch_core.db.stores.messaging_install_store import ( + MessagingInstallClaimedError, + MessagingInstallStateError, + MessagingInstallStore, +) +from tests.conftest import RLSHarness + +pytestmark = pytest.mark.no_ambient_tenant + +_SECRET = "test-secret" +_ORIGIN = "https://switch.example" + + +class _FakeInstaller(MessagingAppInstaller): + """A platform that always says yes, and counts how often it was asked.""" + + platform: ClassVar[str] = "slack" + + def __init__(self, workspace_id: str) -> None: + self.workspace_id = workspace_id + self.redeem_calls: list[str] = [] + + def authorize_url(self, *, state: str, redirect_uri: str) -> str: + return f"https://platform.example/authorize?state={state}" + + async def redeem(self, *, code: str, redirect_uri: str) -> InstallGrant: + self.redeem_calls.append(redirect_uri) + return InstallGrant( + external_workspace_id=self.workspace_id, + workspace_name="Acme", + bot_token="xoxb-granted", + scopes="chat:write", + ) + + def verify_webhook(self, *, headers: Mapping[str, str], body: bytes) -> None: + return None + + def workspace_of_event(self, payload: Mapping[str, object]) -> str: + return self.workspace_id + + def connection_config(self, grant: InstallGrant) -> dict[str, object]: + return {"bot_token": grant.bot_token, "workspace_id": grant.workspace_name} + + +class _FakeLifecycle: + """Stands in for the bridge lifecycle, which starts real network clients. + + It still writes the rows, because the install points at the bridge through + a composite foreign key and a stub returning an id that is not in the table + would prove the last step works when it does not. + """ + + def __init__( + self, factory: async_sessionmaker, tenant_id: str, suffix: str + ) -> None: + self._factory = factory + self._tenant_id = tenant_id + self._suffix = suffix + self.registered: list[dict[str, object]] = [] + + async def register(self, **kwargs: object) -> CollaborationBridge: + self.registered.append(kwargs) + async with tenant_session(self._factory, self._tenant_id) as session: + client = Client( + matrix_user_id=f"@bridge-{len(self.registered)}:{self._suffix}", + display_name=str(kwargs["display_name"]), + type="collaboration_bridge", + ) + session.add(client) + await session.flush() + bridge = CollaborationBridge( + type=str(kwargs["bridge_type"]), + display_name=str(kwargs["display_name"]), + connection_config={}, + client_id=client.id, + status="active", + ) + session.add(bridge) + await session.flush() + bridge_id = bridge.id + await session.commit() + return CollaborationBridge(id=bridge_id) + + +class _Fixture: + def __init__(self) -> None: + self.tenant_a: str = "" + self.tenant_b: str = "" + self.user_id: str = "" + self.workspace: str = "" + self.installer: _FakeInstaller + self.lifecycle: _FakeLifecycle + self.service: MessagingInstallService + + +async def _fixture(harness: RLSHarness) -> _Fixture: + fixture = _Fixture() + suffix = uuid.uuid4().hex[:8] + fixture.tenant_a = f"tenant-a-{suffix}" + fixture.tenant_b = f"tenant-b-{suffix}" + fixture.workspace = f"T-{suffix}" + + async with harness.owner() as session: + for tenant_id in (fixture.tenant_a, fixture.tenant_b): + session.add(Tenant(id=tenant_id, slug=tenant_id, name=tenant_id)) + user = User(name="installer", email=f"{suffix}@example.test", role="user") + session.add(user) + await session.flush() + fixture.user_id = user.id + await session.commit() + + fixture.installer = _FakeInstaller(fixture.workspace) + fixture.lifecycle = _FakeLifecycle(harness.restricted, fixture.tenant_a, suffix) + installers = MessagingInstallerRegistry() + installers.register(fixture.installer) + fixture.service = MessagingInstallService( + session_factory=harness.restricted, + store=MessagingInstallStore(), + installers=installers, + lifecycle=fixture.lifecycle, # type: ignore[arg-type] + public_origin=_ORIGIN, + secret=_SECRET, + ) + return fixture + + +async def _begin(factory: async_sessionmaker, fixture: _Fixture, tenant_id: str) -> str: + """Start an install as `tenant_id` and return the state it minted.""" + async with tenant_session(factory, tenant_id) as session: + url = await fixture.service.begin( + session, platform="slack", user_id=fixture.user_id + ) + await session.commit() + return parse_qs(urlparse(url).query)["state"][0] + + +class TestTheRoundTrip: + async def test_an_install_lands_in_the_tenant_that_started_it( + self, rls_harness: RLSHarness + ) -> None: + fixture = await _fixture(rls_harness) + state = await _begin(rls_harness.restricted, fixture, fixture.tenant_a) + + install = await fixture.service.complete( + platform="slack", code="the-code", state_token=state + ) + + assert install.tenant_id == fixture.tenant_a + assert install.external_workspace_id == fixture.workspace + assert install.installed_by_user_id == fixture.user_id + + async def test_the_bot_token_is_encrypted_at_rest( + self, rls_harness: RLSHarness + ) -> None: + fixture = await _fixture(rls_harness) + state = await _begin(rls_harness.restricted, fixture, fixture.tenant_a) + + install = await fixture.service.complete( + platform="slack", code="the-code", state_token=state + ) + + assert "xoxb-granted" not in install.encrypted_bot_token + assert decrypt_token(install.encrypted_bot_token, _SECRET) == "xoxb-granted" + + async def test_it_ends_with_a_bridge_the_install_points_at( + self, rls_harness: RLSHarness + ) -> None: + """The seam. An install that records a credential and builds nothing is + a customer who clicked Add to Slack and got nothing.""" + fixture = await _fixture(rls_harness) + state = await _begin(rls_harness.restricted, fixture, fixture.tenant_a) + + install = await fixture.service.complete( + platform="slack", code="the-code", state_token=state + ) + + assert len(fixture.lifecycle.registered) == 1 + registered = fixture.lifecycle.registered[0] + assert registered["bridge_type"] == "slack" + assert registered["display_name"] == "Acme" + assert install.bridge_id is not None + + async def test_the_redirect_is_the_public_one_on_both_legs( + self, rls_harness: RLSHarness + ) -> None: + """The platform compares them, so a mismatch is a refused install. + + Built from the configured origin rather than from either request, + which is the only way the two legs — on different hostnames — agree. + """ + fixture = await _fixture(rls_harness) + state = await _begin(rls_harness.restricted, fixture, fixture.tenant_a) + await fixture.service.complete( + platform="slack", code="the-code", state_token=state + ) + + assert fixture.installer.redeem_calls == [ + f"{_ORIGIN}/messaging/slack/oauth/callback" + ] + + +class TestWhatTheCallbackWillNotDo: + async def test_a_replayed_state_never_reaches_the_platform( + self, rls_harness: RLSHarness + ) -> None: + """Burnt before the code is exchanged, not after. + + The count is the assertion: a replay that got as far as redeeming would + have obtained a second credential before anything refused it. + """ + fixture = await _fixture(rls_harness) + state = await _begin(rls_harness.restricted, fixture, fixture.tenant_a) + await fixture.service.complete( + platform="slack", code="the-code", state_token=state + ) + + with pytest.raises(MessagingInstallStateError): + await fixture.service.complete( + platform="slack", code="the-code", state_token=state + ) + assert len(fixture.installer.redeem_calls) == 1 + + async def test_a_state_naming_a_tenant_it_does_not_own_is_refused( + self, rls_harness: RLSHarness + ) -> None: + """What is left if the signing key ever leaks. + + Forging a state for tenant B against tenant A's row gets past the + signature by construction, and row-level security still finds no row + to burn. + """ + fixture = await _fixture(rls_harness) + state = await _begin(rls_harness.restricted, fixture, fixture.tenant_a) + + async with tenant_session(rls_harness.restricted, fixture.tenant_a) as session: + state_id = ( + await MessagingInstallStore().start_install( + session, platform="slack", user_id=fixture.user_id + ) + ).id + await session.commit() + + forged = mint( + InstallState( + tenant_id=fixture.tenant_b, state_id=state_id, platform="slack" + ), + secret=_SECRET, + ) + with pytest.raises(MessagingInstallStateError): + await fixture.service.complete( + platform="slack", code="the-code", state_token=forged + ) + assert fixture.installer.redeem_calls == [] + assert state # the untouched state is still redeemable; nothing was burnt + + async def test_an_unsigned_state_never_touches_the_database( + self, rls_harness: RLSHarness + ) -> None: + fixture = await _fixture(rls_harness) + with pytest.raises(InstallStateError): + await fixture.service.complete( + platform="slack", code="the-code", state_token="v1.aaa.bbb" + ) + + async def test_a_state_for_another_platform_is_refused( + self, rls_harness: RLSHarness + ) -> None: + fixture = await _fixture(rls_harness) + forged = mint( + InstallState( + tenant_id=fixture.tenant_a, state_id="whatever", platform="teams" + ), + secret=_SECRET, + ) + with pytest.raises(InstallPlatformMismatch): + await fixture.service.complete( + platform="slack", code="the-code", state_token=forged + ) + + async def test_a_workspace_another_tenant_holds_is_refused( + self, rls_harness: RLSHarness + ) -> None: + """And no bridge is built for it. + + The whole point of the deployment-wide unique constraint: one + workspace's events have exactly one destination. + """ + fixture = await _fixture(rls_harness) + async with tenant_session(rls_harness.restricted, fixture.tenant_b) as session: + session.add( + MessagingInstall( + tenant_id=fixture.tenant_b, + platform="slack", + external_workspace_id=fixture.workspace, + encrypted_bot_token="ciphertext", + scopes="chat:write", + status="active", + installed_by_user_id=fixture.user_id, + ) + ) + await session.commit() + + state = await _begin(rls_harness.restricted, fixture, fixture.tenant_a) + with pytest.raises(MessagingInstallClaimedError): + await fixture.service.complete( + platform="slack", code="the-code", state_token=state + ) + assert fixture.lifecycle.registered == [] diff --git a/core/tests/switch_core/bridges/collaboration/test_install_state.py b/core/tests/switch_core/bridges/collaboration/test_install_state.py new file mode 100644 index 000000000..6ba915df0 --- /dev/null +++ b/core/tests/switch_core/bridges/collaboration/test_install_state.py @@ -0,0 +1,117 @@ +"""The state token is the only thing binding a tenant across the install. + +It is minted on a page a customer's admin loaded and comes back from the +public internet, so every test here is about what happens when what comes back +is not what went out. The consequence of getting it wrong is not a failed +install: it is an attacker's Slack workspace attached to somebody else's +tenant, delivering messages into their rooms. +""" + +from __future__ import annotations + +import base64 +import hashlib +import hmac +import json + +import pytest + +from switch_core.bridges.collaboration.install_state import ( + InstallState, + InstallStateError, + mint, + verify, +) + +_SECRET = "test-jwt-secret" + +_STATE = InstallState(tenant_id="tenant-a", state_id="state-1", platform="slack") + + +def test_a_minted_state_verifies_back_to_what_went_in() -> None: + assert verify(mint(_STATE, secret=_SECRET), secret=_SECRET) == _STATE + + +def test_the_token_is_safe_in_a_url() -> None: + """It travels as a query parameter through a redirect the platform builds.""" + token = mint(_STATE, secret=_SECRET) + assert all(c.isalnum() or c in "-_." for c in token) + + +class TestForgery: + def test_an_edited_tenant_is_refused(self) -> None: + """The attack the signature exists to stop. + + Swapping the tenant in a captured state is how you would attach your + own workspace to someone else's rooms. + """ + token = mint(_STATE, secret=_SECRET) + payload, signature = token.removeprefix("v1.").split(".") + decoded = json.loads(base64.urlsafe_b64decode(payload + "==")) + decoded["tid"] = "tenant-b" + forged = ( + base64.urlsafe_b64encode(json.dumps(decoded).encode()).decode().rstrip("=") + ) + + with pytest.raises(InstallStateError): + verify(f"v1.{forged}.{signature}", secret=_SECRET) + + def test_a_state_from_another_deployment_is_refused(self) -> None: + token = mint(_STATE, secret="a-different-secret") + with pytest.raises(InstallStateError, match="not signed by this deployment"): + verify(token, secret=_SECRET) + + def test_the_key_is_not_the_jwt_key_itself(self) -> None: + """Domain separation, so one signature can never be read as the other. + + Asserted by construction rather than by outcome: the token is signed + under a derived key, so signing the same payload with the raw secret + produces something this refuses. + """ + token = mint(_STATE, secret=_SECRET) + payload = token.removeprefix("v1.").split(".")[0] + raw = hmac.new(_SECRET.encode(), payload.encode(), hashlib.sha256).digest() + naive = base64.urlsafe_b64encode(raw).decode().rstrip("=") + + with pytest.raises(InstallStateError): + verify(f"v1.{payload}.{naive}", secret=_SECRET) + + +class TestMalformed: + @pytest.mark.parametrize( + "token", + [ + "", + "not-a-token", + "v1.", + "v1.only-one-part", + "v1.a.b.c", + "v1.!!!.!!!", + "v2." + mint(_STATE, secret=_SECRET).removeprefix("v1."), + ], + ) + def test_garbage_is_an_error_and_not_a_crash(self, token: str) -> None: + """Reachable by anyone who finds the callback URL. + + Every one of these has to be a refused install rather than a traceback, + because the caller is unauthenticated by nature. + """ + with pytest.raises(InstallStateError): + verify(token, secret=_SECRET) + + def test_a_well_signed_payload_that_is_not_a_state_is_refused(self) -> None: + """Signed by us, but not this. A key used for two things is a bug.""" + payload = base64.urlsafe_b64encode(b'{"sub":"agent-1"}').decode().rstrip("=") + key = hmac.new( + _SECRET.encode(), b"switch/messaging-install-state/v1", hashlib.sha256 + ).digest() + signature = ( + base64.urlsafe_b64encode( + hmac.new(key, payload.encode(), hashlib.sha256).digest() + ) + .decode() + .rstrip("=") + ) + + with pytest.raises(InstallStateError, match="malformed"): + verify(f"v1.{payload}.{signature}", secret=_SECRET) diff --git a/core/tests/switch_core/bridges/collaboration/test_slack_installer.py b/core/tests/switch_core/bridges/collaboration/test_slack_installer.py index aa7e6499a..dc2f5159f 100644 --- a/core/tests/switch_core/bridges/collaboration/test_slack_installer.py +++ b/core/tests/switch_core/bridges/collaboration/test_slack_installer.py @@ -157,7 +157,7 @@ async def fake(self, **kwargs): # noqa: ANN001, ANN202 monkeypatch.setattr(AsyncWebClient, "oauth_v2_access", fake) return await installer.redeem(code="c", redirect_uri="https://x.example/cb") - async def test_a_good_grant_becomes_the_three_things_we_keep( + async def test_a_good_grant_becomes_the_few_things_we_keep( self, installer: SlackAppInstaller, monkeypatch: pytest.MonkeyPatch ) -> None: grant = await self._redeem_returning( @@ -172,10 +172,27 @@ async def test_a_good_grant_becomes_the_three_things_we_keep( ) assert grant == InstallGrant( external_workspace_id="T123", + workspace_name="Acme", bot_token="xoxb-granted", scopes="chat:write,commands", ) + async def test_a_workspace_with_no_name_falls_back_to_its_id( + self, installer: SlackAppInstaller, monkeypatch: pytest.MonkeyPatch + ) -> None: + """The name is only ever a label, so its absence is not a failure.""" + grant = await self._redeem_returning( + monkeypatch, + installer, + { + "ok": True, + "access_token": "xoxb-granted", + "scope": "chat:write", + "team": {"id": "T123"}, + }, + ) + assert grant.workspace_name == "T123" + async def test_a_two_hundred_saying_not_ok_is_still_a_failure( self, installer: SlackAppInstaller, monkeypatch: pytest.MonkeyPatch ) -> None: @@ -215,19 +232,24 @@ class TestConnectionConfig: def test_a_grant_renders_a_config_the_adapter_accepts( self, installer: SlackAppInstaller ) -> None: - """The seam: after this an installed bridge is an ordinary bridge.""" + """The seam: after this an installed bridge is an ordinary bridge. + + Validated as-is, with nothing added. What the installer renders is what + `register` is handed, so a rendering that only validates once the test + has helped it along is a registration that fails in production. + """ rendered = installer.connection_config( InstallGrant( external_workspace_id="T123", + workspace_name="Acme", bot_token="xoxb-granted", scopes="chat:write", ) ) - config = SlackConnectionConfig.model_validate( - {**rendered, "event_delivery": "webhook"} - ) + config = SlackConnectionConfig.model_validate(rendered) assert config.bot_token == "xoxb-granted" assert config.workspace_id == "T123" + assert config.event_delivery == "webhook" assert config.app_token is None diff --git a/core/tests/switch_core/db/stores/test_messaging_install_store.py b/core/tests/switch_core/db/stores/test_messaging_install_store.py new file mode 100644 index 000000000..5b1304417 --- /dev/null +++ b/core/tests/switch_core/db/stores/test_messaging_install_store.py @@ -0,0 +1,271 @@ +"""Redeeming an install state is a race, and only one caller may win it. + +The state is the only thing tying an authenticated "install this" to an +unauthenticated callback from the platform, and a signature alone cannot make +it single-use. This is where that property lives, so these run against a real +Postgres through the restricted role — a test that redeemed a state in Python +would prove nothing about the statement that actually runs. +""" + +from __future__ import annotations + +import asyncio +import uuid +from datetime import UTC, datetime, timedelta + +import pytest +from sqlalchemy.ext.asyncio import async_sessionmaker + +from switch_core.db.models import Tenant, User +from switch_core.db.session_scope import tenant_session +from switch_core.db.stores.messaging_install_store import ( + MessagingInstallClaimedError, + MessagingInstallStateError, + MessagingInstallStore, +) +from tests.conftest import RLSHarness + +pytestmark = pytest.mark.no_ambient_tenant + + +class _Fixture: + def __init__(self) -> None: + self.tenant_a: str = "" + self.tenant_b: str = "" + self.user_id: str = "" + self.workspace: str = "" + + +async def _two_tenants(owner: async_sessionmaker) -> _Fixture: + fixture = _Fixture() + suffix = uuid.uuid4().hex[:8] + fixture.tenant_a = f"tenant-a-{suffix}" + fixture.tenant_b = f"tenant-b-{suffix}" + fixture.workspace = f"T-{suffix}" + + async with owner() as session: + for tenant_id in (fixture.tenant_a, fixture.tenant_b): + session.add(Tenant(id=tenant_id, slug=tenant_id, name=tenant_id)) + user = User(name="installer", email=f"{suffix}@example.test", role="user") + session.add(user) + await session.flush() + fixture.user_id = user.id + await session.commit() + return fixture + + +class TestStartingAnInstall: + async def test_the_state_takes_its_tenant_from_the_session( + self, rls_harness: RLSHarness + ) -> None: + """The caller never names a tenant; the bound session decides. + + This is the whole reason the start leg is the authenticated one. + """ + fixture = await _two_tenants(rls_harness.owner) + store = MessagingInstallStore() + + async with tenant_session(rls_harness.restricted, fixture.tenant_a) as session: + state = await store.start_install( + session, platform="slack", user_id=fixture.user_id + ) + await session.commit() + assert state.tenant_id == fixture.tenant_a + + +class TestRedeemingAState: + async def test_a_state_redeems_once(self, rls_harness: RLSHarness) -> None: + fixture = await _two_tenants(rls_harness.owner) + store = MessagingInstallStore() + + async with tenant_session(rls_harness.restricted, fixture.tenant_a) as session: + state_id = ( + await store.start_install( + session, platform="slack", user_id=fixture.user_id + ) + ).id + await session.commit() + + async with tenant_session(rls_harness.restricted, fixture.tenant_a) as session: + redeemed = await store.redeem_state( + session, state_id=state_id, platform="slack" + ) + await session.commit() + assert redeemed.consumed_at is not None + + async def test_replaying_it_is_refused(self, rls_harness: RLSHarness) -> None: + """The attack. A captured state must not install a second workspace.""" + fixture = await _two_tenants(rls_harness.owner) + store = MessagingInstallStore() + + async with tenant_session(rls_harness.restricted, fixture.tenant_a) as session: + state_id = ( + await store.start_install( + session, platform="slack", user_id=fixture.user_id + ) + ).id + await session.commit() + + async with tenant_session(rls_harness.restricted, fixture.tenant_a) as session: + await store.redeem_state(session, state_id=state_id, platform="slack") + await session.commit() + + async with tenant_session(rls_harness.restricted, fixture.tenant_a) as session: + with pytest.raises(MessagingInstallStateError, match="already been used"): + await store.redeem_state(session, state_id=state_id, platform="slack") + + async def test_two_simultaneous_redemptions_produce_one_winner( + self, rls_harness: RLSHarness + ) -> None: + """Sequential replay is the easy half; this is the one that needs the + check and the write to be a single statement. + + Two callbacks arriving together would both read `consumed_at IS NULL` + under a read-then-update, and both would proceed. + """ + fixture = await _two_tenants(rls_harness.owner) + store = MessagingInstallStore() + + async with tenant_session(rls_harness.restricted, fixture.tenant_a) as session: + state_id = ( + await store.start_install( + session, platform="slack", user_id=fixture.user_id + ) + ).id + await session.commit() + + async def attempt() -> bool: + async with tenant_session( + rls_harness.restricted, fixture.tenant_a + ) as session: + try: + await store.redeem_state( + session, state_id=state_id, platform="slack" + ) + except MessagingInstallStateError: + return False + await session.commit() + return True + + outcomes = await asyncio.gather(attempt(), attempt()) + assert sum(outcomes) == 1 + + async def test_an_expired_state_is_refused(self, rls_harness: RLSHarness) -> None: + fixture = await _two_tenants(rls_harness.owner) + store = MessagingInstallStore() + + async with tenant_session(rls_harness.restricted, fixture.tenant_a) as session: + state = await store.start_install( + session, platform="slack", user_id=fixture.user_id + ) + state.expires_at = datetime.now(UTC) - timedelta(seconds=1) + await session.commit() + state_id = state.id + + async with tenant_session(rls_harness.restricted, fixture.tenant_a) as session: + with pytest.raises(MessagingInstallStateError, match="expired"): + await store.redeem_state(session, state_id=state_id, platform="slack") + + async def test_another_tenant_cannot_redeem_it( + self, rls_harness: RLSHarness + ) -> None: + """The second check on the signature. + + A state's tenant is established by the signature before this runs, so + reaching here with the wrong one means the signature was forged or the + key leaked. Row-level security still refuses, independently. + """ + fixture = await _two_tenants(rls_harness.owner) + store = MessagingInstallStore() + + async with tenant_session(rls_harness.restricted, fixture.tenant_a) as session: + state_id = ( + await store.start_install( + session, platform="slack", user_id=fixture.user_id + ) + ).id + await session.commit() + + async with tenant_session(rls_harness.restricted, fixture.tenant_b) as session: + with pytest.raises(MessagingInstallStateError): + await store.redeem_state(session, state_id=state_id, platform="slack") + + async def test_a_state_cannot_be_redeemed_on_another_platform( + self, rls_harness: RLSHarness + ) -> None: + """The path a callback is served on does not get to pick the state.""" + fixture = await _two_tenants(rls_harness.owner) + store = MessagingInstallStore() + + async with tenant_session(rls_harness.restricted, fixture.tenant_a) as session: + state_id = ( + await store.start_install( + session, platform="slack", user_id=fixture.user_id + ) + ).id + await session.commit() + + async with tenant_session(rls_harness.restricted, fixture.tenant_a) as session: + with pytest.raises(MessagingInstallStateError): + await store.redeem_state(session, state_id=state_id, platform="teams") + + +class TestRecordingAnInstall: + async def test_a_claimed_workspace_raises_something_showable( + self, rls_harness: RLSHarness + ) -> None: + """An IntegrityError reaching a request handler is a 500 and a + traceback; this failure is ordinary and has to read as one.""" + fixture = await _two_tenants(rls_harness.owner) + store = MessagingInstallStore() + + async with tenant_session(rls_harness.restricted, fixture.tenant_a) as session: + await store.record_install( + session, + platform="slack", + external_workspace_id=fixture.workspace, + encrypted_bot_token="ciphertext", + scopes="chat:write", + user_id=fixture.user_id, + ) + await session.commit() + + async with tenant_session(rls_harness.restricted, fixture.tenant_b) as session: + with pytest.raises(MessagingInstallClaimedError, match="already connected"): + await store.record_install( + session, + platform="slack", + external_workspace_id=fixture.workspace, + encrypted_bot_token="ciphertext", + scopes="chat:write", + user_id=fixture.user_id, + ) + + async def test_only_the_owning_tenant_reads_it_back( + self, rls_harness: RLSHarness + ) -> None: + fixture = await _two_tenants(rls_harness.owner) + store = MessagingInstallStore() + + async with tenant_session(rls_harness.restricted, fixture.tenant_a) as session: + await store.record_install( + session, + platform="slack", + external_workspace_id=fixture.workspace, + encrypted_bot_token="ciphertext", + scopes="chat:write", + user_id=fixture.user_id, + ) + await session.commit() + + async with tenant_session(rls_harness.restricted, fixture.tenant_a) as session: + mine = await store.get_for_workspace( + session, platform="slack", external_workspace_id=fixture.workspace + ) + assert mine is not None + + async with tenant_session(rls_harness.restricted, fixture.tenant_b) as session: + theirs = await store.get_for_workspace( + session, platform="slack", external_workspace_id=fixture.workspace + ) + assert theirs is None From 5e3f78a3b8c04a23f215bd25d55d8c4248a8f2a0 Mon Sep 17 00:00:00 2001 From: Petr Bauch Date: Fri, 11 Sep 2026 16:31:51 +0200 Subject: [PATCH 04/29] feat(collaboration): route inbound platform events to the installing tenant MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../bridges/collaboration/adapter.py | 26 +- .../bridges/collaboration/install.py | 68 ++- .../bridges/collaboration/install_routes.py | 131 ++++- .../bridges/collaboration/install_service.py | 133 ++++- .../bridges/collaboration/models.py | 11 + .../bridges/collaboration/slack/install.py | 95 +++- .../collaboration/test_install_service.py | 7 + .../collaboration/test_install_webhook.py | 537 ++++++++++++++++++ .../collaboration/test_slack_installer.py | 151 ++++- .../db/test_tenant_exemption_allowlist.py | 7 + 10 files changed, 1138 insertions(+), 28 deletions(-) create mode 100644 core/tests/switch_core/bridges/collaboration/test_install_webhook.py diff --git a/core/switch_core/bridges/collaboration/adapter.py b/core/switch_core/bridges/collaboration/adapter.py index e4df7bea6..36e16f7d4 100644 --- a/core/switch_core/bridges/collaboration/adapter.py +++ b/core/switch_core/bridges/collaboration/adapter.py @@ -5,7 +5,7 @@ from abc import ABC, abstractmethod from collections.abc import Awaitable, Callable from dataclasses import dataclass, replace -from typing import ClassVar +from typing import Any, ClassVar from switch_core.agent_display_name import defuse_label_markup from switch_core.agent_icon import default_icon_url @@ -20,6 +20,7 @@ InboundMessage, InboundUserJoin, OutboundAttachment, + WebhookDeliveryUnsupported, ) logger = logging.getLogger(__name__) @@ -248,6 +249,29 @@ async def start( @abstractmethod async def stop(self) -> None: ... + async def dispatch_event( + self, *, envelope_type: str, payload: dict[str, Any] + ) -> None: + """Handle one event that arrived over the public webhook. + + Concrete on the base and raising, rather than abstract, because + receiving events over HTTP is a property of a platform and of which app + a bridge's token came from — most adapters dial out and are handed + their events on a connection they opened, and have nothing to override + here. + + Raising rather than returning quietly matters: the caller is a route + that has already proved the request genuine and resolved which bridge + it belongs to, so reaching an adapter that cannot take it means a + workspace's traffic is being delivered nowhere. Silence there is the + failure that reads as "the platform has gone quiet". + """ + raise WebhookDeliveryUnsupported( + f"{type(self).__name__} does not receive events over HTTP, so the " + "event posted for this bridge cannot be delivered. A bridge reached " + "this way was installed as a distributed app; this one was not." + ) + @abstractmethod async def send_message( self, diff --git a/core/switch_core/bridges/collaboration/install.py b/core/switch_core/bridges/collaboration/install.py index bd80a4bda..62b7012a8 100644 --- a/core/switch_core/bridges/collaboration/install.py +++ b/core/switch_core/bridges/collaboration/install.py @@ -35,7 +35,7 @@ from abc import ABC, abstractmethod from collections.abc import Mapping from dataclasses import dataclass -from typing import ClassVar +from typing import Any, ClassVar, Literal #: The public prefix every install endpoint hangs off. #: @@ -101,6 +101,51 @@ class WebhookAuthenticityError(RuntimeError): """ +class WebhookPayloadError(RuntimeError): + """A webhook proved genuine and then could not be read. + + Distinct from :class:`WebhookAuthenticityError` because it says something + entirely different: the signature checked out, so this really is the + platform, and what it sent is a shape this build does not know. That is a + fault worth seeing in a log — an unrecognised body is how a platform's + change first shows up — where a bad signature is just the internet. + """ + + +#: Which of a platform's inbound endpoints a request arrived on. +#: +#: Three, because that is what the platforms ask for and what the app manifests +#: declare: events, interactivity, and slash commands. They are separate URLs +#: rather than one, because a platform decides that, not us — and they carry +#: genuinely different bodies (Slack posts JSON to the first and a form to the +#: other two), which is why the endpoint is an argument to parsing rather than +#: something a handler could infer. +WebhookEndpoint = Literal["events", "interactive", "commands"] + + +@dataclass(frozen=True) +class InboundWebhook: + """One authenticated inbound event, in the shape a running adapter takes. + + `envelope_type` and `payload` are deliberately the two arguments Socket + Mode's own listener is handed, so an event that arrived over HTTP and the + same event over a socket reach `dispatch_event` indistinguishable from one + another. Anything that made them differ would be two code paths for one + behaviour, drifting apart at the speed of whichever gets used more. + + `handshake` is the exception, and it is not an event at all: a platform + proving the URL it was given is really ours (Slack's `url_verification`) + expects a specific string echoed straight back and nothing dispatched. It + is `None` for every real event, and it arrives before any workspace has + installed anything — so it must be answerable with no tenant, no install + row, and nothing running. + """ + + envelope_type: str + payload: dict[str, Any] + handshake: str | None + + @dataclass(frozen=True) class InstallGrant: """What the platform handed back when a workspace installed us. @@ -173,14 +218,31 @@ def verify_webhook(self, *, headers: Mapping[str, str], body: bytes) -> None: :class:`WebhookAuthenticityError`. """ + @abstractmethod + def parse_webhook( + self, *, endpoint: WebhookEndpoint, body: bytes + ) -> InboundWebhook: + """Read a verified request body into an event a running adapter takes. + + Called only after :meth:`verify_webhook` has passed, and separate from + it for exactly that reason: parsing before verifying is how an + unauthenticated body gets to choose which code runs. + + Takes the raw bytes rather than a parsed payload because only this + method knows the encoding, which is per platform and per endpoint — + Slack posts JSON to one of its three and form data to the other two. + + Raise :class:`WebhookPayloadError` for a body that cannot be read. + """ + @abstractmethod def workspace_of_event(self, payload: Mapping[str, object]) -> str: """Which workspace an authenticated event came from. The answer is what resolves a tenant, so this runs on a request with nothing bound and must not touch the database. Raise - :class:`WebhookAuthenticityError` for a payload that names no - workspace: an event we cannot route is not an event we may guess at. + :class:`WebhookPayloadError` for a payload that names no workspace: an + event we cannot route is not an event we may guess at. """ @abstractmethod diff --git a/core/switch_core/bridges/collaboration/install_routes.py b/core/switch_core/bridges/collaboration/install_routes.py index 38a6dd855..5c7029a7d 100644 --- a/core/switch_core/bridges/collaboration/install_routes.py +++ b/core/switch_core/bridges/collaboration/install_routes.py @@ -1,4 +1,10 @@ -"""The public half of an install: the callback the platform redirects back to. +"""The public half of an install: the OAuth callback, and the events after it. + +Everything here is unauthenticated in the sense that matters — no caller holds +a credential of ours. A browser mid-redirect proves itself with a state we +signed; a platform posting an event proves itself with a signature over the +raw body. Neither is a session, and nothing here may assume a tenant before it +has established one. Mounted on the agent-bridge app rather than inside `/gateway`, because this is the leg the outside world reaches. `/gateway` is not routed here from the @@ -12,6 +18,9 @@ rendered here, in one self-contained page, on the origin the browser already reached. It is deliberately plain; when there is a place to send people, this becomes a redirect and the page becomes its fallback. + +The event routes answer nobody who reads English, so they answer in status +codes and say the rest in the log. """ from __future__ import annotations @@ -19,16 +28,23 @@ import html import logging -from fastapi import APIRouter, Query -from starlette.responses import HTMLResponse +from fastapi import APIRouter, BackgroundTasks, Query, Request, Response +from starlette.responses import HTMLResponse, PlainTextResponse from switch_core.bridges.collaboration.install import ( PUBLIC_PATH_PREFIX, + InboundWebhook, MessagingInstallError, + WebhookAuthenticityError, + WebhookEndpoint, + WebhookPayloadError, ) from switch_core.bridges.collaboration.install_service import ( InstallPlatformMismatch, MessagingInstallService, + WebhookBridgeUnavailable, + WebhookTarget, + WebhookWorkspaceUnknown, ) from switch_core.bridges.collaboration.install_state import InstallStateError from switch_core.db.stores.messaging_install_store import ( @@ -142,4 +158,113 @@ async def oauth_callback( status=200, ) + async def _deliver(target: WebhookTarget, event: InboundWebhook) -> None: + """Handle one event after the platform has been answered. + + Its own wrapper so a failure is logged here rather than raised into the + server's background-task machinery, where it would surface — if at all + — as an unattributed traceback with nothing in it naming the bridge. + """ + try: + await service.deliver(target, event) + except Exception: + logger.exception( + "Failed to handle a %s event for bridge %s (tenant %s)", + event.envelope_type, + target.bridge_id, + target.tenant_id, + ) + + async def _inbound( + platform: str, + endpoint: WebhookEndpoint, + request: Request, + background: BackgroundTasks, + ) -> Response: + """One verified event, from any of a platform's three inbound URLs. + + The status codes are read by the platform, not by a person, and they + are chosen for what it does with them. Slack retries a 5xx and gives up + on a 4xx, and counts failures against the app as a whole — so a + permanent condition must not look transient, and a transient one must + not look permanent. + """ + body = await request.body() + try: + event = service.authenticate( + platform=platform, + endpoint=endpoint, + headers=dict(request.headers), + body=body, + ) + except MessagingInstallError: + # No app registered for this platform, so nothing here could have + # signed anything. Not found rather than an explanation: the caller + # is unauthenticated and learns only that there is nothing here. + return Response(status_code=404) + except WebhookAuthenticityError as failure: + logger.warning("Refused an unverified %s webhook: %s", platform, failure) + return Response(status_code=401) + except WebhookPayloadError as failure: + # Verified, so this really is the platform sending something this + # build cannot read. Worth an error rather than a shrug — it is how + # a platform's change to its own payloads first becomes visible. + logger.error("Could not read a verified %s webhook: %s", platform, failure) + return Response(status_code=400) + + if event.handshake is not None: + logger.info("Answered a %s URL verification", platform) + return PlainTextResponse(event.handshake) + + try: + target = await service.resolve(platform=platform, event=event) + except WebhookPayloadError as failure: + logger.error( + "A verified %s event named no workspace: %s", platform, failure + ) + return Response(status_code=400) + except WebhookWorkspaceUnknown as failure: + logger.warning("Dropped a %s event: %s", platform, failure) + return Response(status_code=404) + except WebhookBridgeUnavailable as failure: + # Deliberately a 503: the platform retrying is the right behaviour + # while a bridge restarts, and a 200 here would drop a real message + # on the floor and report that it had been handled. + logger.error("Could not deliver a %s event: %s", platform, failure) + return Response(status_code=503) + + # Answered first, handled after. The platform's deadline is short and + # what happens next is not bounded by it — a turn can take minutes — + # so acknowledging on the way out is what keeps a slow room from + # becoming a retried, duplicated one. + background.add_task(_deliver, target, event) + return Response(status_code=200) + + @router.post("/{platform}/events") + async def events( + platform: str, request: Request, background: BackgroundTasks + ) -> Response: + return await _inbound(platform, "events", request, background) + + @router.post("/{platform}/interactive") + async def interactive( + platform: str, request: Request, background: BackgroundTasks + ) -> Response: + """Interactions with a message this app posted. + + Routed like any other event and, on Slack today, handled by nothing: + the app declares an interactivity URL because features it does use + require one, and its stop button arrives as an ordinary event instead. + The route exists because the URL is declared — a declared URL that 404s + counts against the app — and because the alternative is deciding here, + rather than in the adapter, what a platform's interactions mean. + """ + return await _inbound(platform, "interactive", request, background) + + @router.post("/{platform}/commands") + async def commands( + platform: str, request: Request, background: BackgroundTasks + ) -> Response: + return await _inbound(platform, "commands", request, background) + return router diff --git a/core/switch_core/bridges/collaboration/install_service.py b/core/switch_core/bridges/collaboration/install_service.py index 74601a8c8..55c37b5bc 100644 --- a/core/switch_core/bridges/collaboration/install_service.py +++ b/core/switch_core/bridges/collaboration/install_service.py @@ -31,12 +31,17 @@ from __future__ import annotations import logging +from collections.abc import Mapping +from dataclasses import dataclass from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker +from switch_core.bridges.collaboration.adapter import CollaborationAdapter from switch_core.bridges.collaboration.install import ( + InboundWebhook, MessagingAppInstaller, MessagingInstallerRegistry, + WebhookEndpoint, oauth_callback_path, public_url, ) @@ -52,11 +57,40 @@ from switch_core.db.models import MessagingInstall from switch_core.db.session_scope import tenant_session from switch_core.db.stores.messaging_install_store import MessagingInstallStore -from switch_core.tenant_context import tenant_scope +from switch_core.db.tenant_lookup import tenant_of_messaging_install +from switch_core.tenant_context import no_tenant, tenant_scope logger = logging.getLogger(__name__) +class WebhookWorkspaceUnknown(RuntimeError): + """An authentic event named a workspace no tenant here has installed. + + Ordinary rather than alarming: an app left in a workspace whose install was + removed goes on posting for as long as someone leaves it there. It is still + an error, because the alternative is answering "fine" to traffic that + reaches nobody. + """ + + +class WebhookBridgeUnavailable(RuntimeError): + """The workspace resolves to a tenant, and nothing is running to take it. + + Transient by nature — a bridge mid-restart, or one that has not been built + for a recorded install yet — so it is worth telling the platform to try + again rather than swallowing the event. + """ + + +@dataclass(frozen=True) +class WebhookTarget: + """Where one verified event goes: a tenant, a bridge, and its live adapter.""" + + tenant_id: str + bridge_id: str + adapter: CollaborationAdapter + + class InstallPlatformMismatch(RuntimeError): """A state minted for one platform arrived at another's callback. @@ -194,3 +228,100 @@ async def complete( bridge.id, ) return attached + + # ── Inbound events ─────────────────────────────────────────────────────── + # + # The other direction, and the one that runs constantly. Three steps, kept + # separate because each answers to something different: + # + # `authenticate` is pure and does no I/O, so a request that cannot prove + # itself is refused without this deployment doing any work on its behalf. + # `resolve` is two indexed reads and decides *whose* event this is. + # `deliver` is the handling, which can take as long as the work takes. + # + # The split is what lets the route acknowledge in time. Slack gives three + # seconds and retries what it does not get an answer to, so a handler that + # posts to Matrix before replying turns one slow room into duplicate + # messages. Everything up to and including `resolve` is fast enough to + # answer inside, and `deliver` runs after the response has gone. + + def authenticate( + self, + *, + platform: str, + endpoint: WebhookEndpoint, + headers: Mapping[str, str], + body: bytes, + ) -> InboundWebhook: + """Prove an inbound request came from the platform, and read it. + + Verification is first and unconditional. Nothing above it inspects the + body, so an unsigned request cannot pick which parser runs, and nothing + is logged from it either — it is a stranger's bytes until this passes. + """ + installer = self._installers.get(platform) + installer.verify_webhook(headers=headers, body=body) + return installer.parse_webhook(endpoint=endpoint, body=body) + + async def resolve(self, *, platform: str, event: InboundWebhook) -> WebhookTarget: + """Turn a workspace id into the one bridge entitled to the event. + + The tenant comes from the exempt lookup (`db/tenant_lookup.py`), which + is the only way to answer it: the caller authenticated to nothing, and + every table that could say is scoped. The install row is then read + *again* under that tenant rather than returned by the lookup — a + deliberate second check, so a wrong answer above is a miss here instead + of a cross-tenant read. + """ + installer = self._installers.get(platform) + workspace_id = installer.workspace_of_event(event.payload) + + tenant_id = await tenant_of_messaging_install( + self._session_factory, platform, workspace_id + ) + if tenant_id is None: + raise WebhookWorkspaceUnknown( + f"no tenant has installed Switch into {platform} workspace " + f"{workspace_id}" + ) + + async with tenant_session(self._session_factory, tenant_id) as session: + install = await self._store.get_for_workspace( + session, platform=platform, external_workspace_id=workspace_id + ) + if install is None: + raise WebhookWorkspaceUnknown( + f"the install of {platform} workspace {workspace_id} resolved to " + f"tenant {tenant_id} and could not then be read as that tenant" + ) + if install.bridge_id is None: + raise WebhookBridgeUnavailable( + f"the install of {platform} workspace {workspace_id} has no " + "bridge yet, so there is nothing to deliver its events to" + ) + + adapter = self._lifecycle.get_adapter(install.bridge_id) + if adapter is None: + raise WebhookBridgeUnavailable( + f"bridge {install.bridge_id}, which serves {platform} workspace " + f"{workspace_id}, is not running" + ) + return WebhookTarget( + tenant_id=tenant_id, bridge_id=install.bridge_id, adapter=adapter + ) + + async def deliver(self, target: WebhookTarget, event: InboundWebhook) -> None: + """Hand a resolved event to the bridge, as its own transport would. + + With **nothing bound**, which is not an oversight. A bridge that + receives over a socket dispatches from a task that binds no tenant, and + every handler below it binds the tenant of the room it is acting on. + Binding here would make the two delivery paths differ in the one + respect that decides who a message reaches, and would hide a handler + that had forgotten to bind for itself — for exactly as long as it took + someone to receive the same event over a socket instead. + """ + with no_tenant(): + await target.adapter.dispatch_event( + envelope_type=event.envelope_type, payload=event.payload + ) diff --git a/core/switch_core/bridges/collaboration/models.py b/core/switch_core/bridges/collaboration/models.py index 054369cde..94eb44f6a 100644 --- a/core/switch_core/bridges/collaboration/models.py +++ b/core/switch_core/bridges/collaboration/models.py @@ -17,6 +17,17 @@ class ChannelCreationUnsupported(ValueError): """ +class WebhookDeliveryUnsupported(RuntimeError): + """A verified inbound event was routed to a bridge that cannot take one. + + Only reachable when a bridge is recorded as serving an installed workspace + and its adapter receives events some other way — so it is a deployment + inconsistency rather than anything the sender did, and it is raised rather + than logged-and-dropped so the route answers with a failure the platform's + own delivery log records. + """ + + class Attachment(BaseModel): """An inbound file attachment of any type, with its raw bytes. diff --git a/core/switch_core/bridges/collaboration/slack/install.py b/core/switch_core/bridges/collaboration/slack/install.py index 2a3922994..492e8891b 100644 --- a/core/switch_core/bridges/collaboration/slack/install.py +++ b/core/switch_core/bridges/collaboration/slack/install.py @@ -15,26 +15,59 @@ from __future__ import annotations +import json import logging from collections.abc import Mapping -from typing import ClassVar -from urllib.parse import urlencode +from typing import Any, ClassVar +from urllib.parse import parse_qsl, urlencode from slack_sdk.errors import SlackApiError from slack_sdk.signature import SignatureVerifier from slack_sdk.web.async_client import AsyncWebClient from switch_core.bridges.collaboration.install import ( + InboundWebhook, InstallGrant, MessagingAppInstaller, MessagingInstallError, WebhookAuthenticityError, + WebhookEndpoint, + WebhookPayloadError, ) logger = logging.getLogger(__name__) AUTHORIZE_URL = "https://slack.com/oauth/v2/authorize" + +def _json_object(raw: bytes) -> dict[str, Any]: + try: + parsed = json.loads(raw) + except ValueError as error: + raise WebhookPayloadError("Slack posted a body that is not JSON") from error + if not isinstance(parsed, dict): + raise WebhookPayloadError("Slack posted JSON that is not an object") + return parsed + + +def _form_fields(raw: bytes) -> dict[str, Any]: + """Slack's form encoding as a flat dict, matching Socket Mode's payload. + + `parse_qsl` and not `parse_qs`: the latter makes every value a list, and + the adapter reads these fields as the strings Socket Mode hands it. A + repeated field would be a Slack change worth noticing rather than a list to + accommodate, and the last value wins the way a form is usually read. + """ + try: + return dict( + parse_qsl(raw.decode(), keep_blank_values=True, strict_parsing=True) + ) + except (UnicodeDecodeError, ValueError) as error: + raise WebhookPayloadError( + "Slack posted a body that is not form data" + ) from error + + #: The bot scopes the distributed app requests, in the manifest's order. #: #: Identical to the self-registered app's: the two apps differ in how they are @@ -141,10 +174,66 @@ def verify_webhook(self, *, headers: Mapping[str, str], body: bytes) -> None: if not valid: raise WebhookAuthenticityError("bad Slack signature") + def parse_webhook( + self, *, endpoint: WebhookEndpoint, body: bytes + ) -> InboundWebhook: + if endpoint == "commands": + # A slash command posts its fields as a form, and Socket Mode + # delivers that same flat dict — so the parsed form *is* the + # payload, with no unwrapping. + return InboundWebhook( + envelope_type="slash_commands", + payload=_form_fields(body), + handshake=None, + ) + + if endpoint == "interactive": + # Nested once: the form carries a single `payload` field whose + # value is JSON. + raw = _form_fields(body).get("payload") + if not raw: + raise WebhookPayloadError( + "Slack posted an interaction with no payload field" + ) + return InboundWebhook( + envelope_type="interactive", + payload=_json_object(raw.encode()), + handshake=None, + ) + + envelope = _json_object(body) + if envelope.get("type") == "url_verification": + # Slack proving the URL is ours, at the moment the Request URL is + # saved. It is signed like any other post, so it reaches here + # having been verified — and it names no workspace, because none + # has installed anything yet. + challenge = envelope.get("challenge") + if not isinstance(challenge, str) or not challenge: + raise WebhookPayloadError( + "Slack sent a URL verification with no challenge to echo" + ) + return InboundWebhook( + envelope_type="url_verification", payload=envelope, handshake=challenge + ) + + return InboundWebhook( + envelope_type="events_api", payload=envelope, handshake=None + ) + def workspace_of_event(self, payload: Mapping[str, object]) -> str: + """Which workspace an event came from, over Slack's two spellings. + + An event callback and a slash command name it flat, as `team_id`; an + interaction nests it under `team`. Both are read here rather than + normalised at parse time, because the payload handed to the adapter has + to stay byte-identical to the one Socket Mode delivers. + """ workspace_id = payload.get("team_id") if not isinstance(workspace_id, str) or not workspace_id: - raise WebhookAuthenticityError("Slack event names no workspace") + team = payload.get("team") + workspace_id = team.get("id") if isinstance(team, dict) else None + if not isinstance(workspace_id, str) or not workspace_id: + raise WebhookPayloadError("Slack event names no workspace") return workspace_id def connection_config(self, grant: InstallGrant) -> dict[str, object]: diff --git a/core/tests/switch_core/bridges/collaboration/test_install_service.py b/core/tests/switch_core/bridges/collaboration/test_install_service.py index aec860c6b..9f8897369 100644 --- a/core/tests/switch_core/bridges/collaboration/test_install_service.py +++ b/core/tests/switch_core/bridges/collaboration/test_install_service.py @@ -22,9 +22,11 @@ from sqlalchemy.ext.asyncio import async_sessionmaker from switch_core.bridges.collaboration.install import ( + InboundWebhook, InstallGrant, MessagingAppInstaller, MessagingInstallerRegistry, + WebhookEndpoint, ) from switch_core.bridges.collaboration.install_service import ( InstallPlatformMismatch, @@ -81,6 +83,11 @@ async def redeem(self, *, code: str, redirect_uri: str) -> InstallGrant: def verify_webhook(self, *, headers: Mapping[str, str], body: bytes) -> None: return None + def parse_webhook( + self, *, endpoint: WebhookEndpoint, body: bytes + ) -> InboundWebhook: + return InboundWebhook(envelope_type=endpoint, payload={}, handshake=None) + def workspace_of_event(self, payload: Mapping[str, object]) -> str: return self.workspace_id diff --git a/core/tests/switch_core/bridges/collaboration/test_install_webhook.py b/core/tests/switch_core/bridges/collaboration/test_install_webhook.py new file mode 100644 index 000000000..f7d346af0 --- /dev/null +++ b/core/tests/switch_core/bridges/collaboration/test_install_webhook.py @@ -0,0 +1,537 @@ +"""An event from an installed workspace, from the wire to the right bridge. + +This is the half of multi-tenancy that has no session to lean on. A Slack post +arrives at one public URL shared by every tenant on the deployment, carrying no +credential of ours, and something has to decide whose it is. Everything that +decides it is here: the signature, the workspace id in the payload, and the +lookup from that workspace to a tenant. + +Two tenants exist in every fixture below, each with the app installed into its +own workspace, and the assertion that matters is the same one each time: an +event for one of them reaches that one's bridge and **no part of it reaches the +other's**. A test with a single tenant would pass against a router that ignored +the payload entirely. + +Real Postgres, the restricted role, and the real Slack installer — including +the real signature check. What is faked is only what would otherwise open a +socket: the bridge lifecycle and the adapter at the end of it. +""" + +from __future__ import annotations + +import hashlib +import hmac +import json +import time +import uuid +from dataclasses import dataclass +from typing import Any +from urllib.parse import urlencode + +import httpx +import pytest +from fastapi import FastAPI +from sqlalchemy.ext.asyncio import async_sessionmaker + +from switch_core.bridges.collaboration.adapter import CollaborationAdapter +from switch_core.bridges.collaboration.install import ( + MessagingInstallerRegistry, + commands_path, + events_path, + interactive_path, +) +from switch_core.bridges.collaboration.install_routes import ( + create_messaging_install_router, +) +from switch_core.bridges.collaboration.install_service import MessagingInstallService +from switch_core.bridges.collaboration.slack.install import SlackAppInstaller +from switch_core.db.models import ( + Client, + CollaborationBridge, + MessagingInstall, + Tenant, + User, +) +from switch_core.db.session_scope import tenant_session +from switch_core.db.stores.messaging_install_store import MessagingInstallStore +from switch_core.tenant_context import current_tenant_id +from tests.conftest import RLSHarness + +pytestmark = pytest.mark.no_ambient_tenant + +_SIGNING_SECRET = "test-signing-secret" +_SECRET = "test-secret" +_ORIGIN = "https://switch.example" + + +def _signed(body: bytes) -> dict[str, str]: + timestamp = str(int(time.time())) + digest = hmac.new( + _SIGNING_SECRET.encode(), + b"v0:" + timestamp.encode() + b":" + body, + hashlib.sha256, + ).hexdigest() + return { + "X-Slack-Request-Timestamp": timestamp, + "X-Slack-Signature": f"v0={digest}", + "Content-Type": "application/json", + } + + +class _SocketOnlyAdapter(CollaborationAdapter): + """A bridge that takes its events some other way. + + Concrete only so one can be built; every platform call is a stub, because + nothing on the webhook path may reach one. It inherits `dispatch_event` + from the base — the refusal — which is the behaviour one test below is + about. + """ + + def __init__(self) -> None: ... + + async def start(self, *a: Any, **k: Any) -> Any: ... + async def stop(self, *a: Any, **k: Any) -> Any: ... + async def send_message(self, *a: Any, **k: Any) -> Any: ... + async def send_typing(self, *a: Any, **k: Any) -> Any: ... + async def update_message(self, *a: Any, **k: Any) -> Any: ... + async def delete_message(self, *a: Any, **k: Any) -> Any: ... + async def create_channel(self, *a: Any, **k: Any) -> Any: ... + async def get_channel_type(self, *a: Any, **k: Any) -> Any: ... + async def get_channel_agent_names(self, *a: Any, **k: Any) -> Any: ... + async def add_agents_to_channel(self, *a: Any, **k: Any) -> Any: ... + async def add_users_to_channel(self, *a: Any, **k: Any) -> Any: ... + async def create_agent_identity(self, *a: Any, **k: Any) -> Any: ... + async def remove_agent_identity(self, *a: Any, **k: Any) -> Any: ... + def translate_inbound(self, *a: Any, **k: Any) -> Any: ... + def translate_outbound(self, *a: Any, **k: Any) -> Any: ... + + +class _RecordingAdapter(_SocketOnlyAdapter): + """An adapter that only remembers what it was handed. + + It also records the tenant bound at the moment of dispatch, which is the + subject of one of the tests below: the answer has to be "none", the same as + it is under Socket Mode. + """ + + def __init__(self) -> None: + self.dispatched: list[tuple[str, dict[str, Any]]] = [] + self.tenants_bound: list[str | None] = [] + + async def dispatch_event( + self, *, envelope_type: str, payload: dict[str, Any] + ) -> None: + self.dispatched.append((envelope_type, payload)) + self.tenants_bound.append(current_tenant_id()) + + +class _FakeLifecycle: + def __init__(self) -> None: + self.adapters: dict[str, CollaborationAdapter] = {} + + def get_adapter(self, bridge_id: str) -> CollaborationAdapter | None: + return self.adapters.get(bridge_id) + + +@dataclass +class _Workspace: + tenant_id: str + workspace_id: str + bridge_id: str + adapter: _RecordingAdapter + + +class _Fixture: + def __init__(self, harness: RLSHarness) -> None: + self.harness = harness + self.lifecycle = _FakeLifecycle() + self.a: _Workspace + self.b: _Workspace + self.client: httpx.AsyncClient + self.service: MessagingInstallService + + +async def _make_bridge(factory: async_sessionmaker, tenant_id: str, suffix: str) -> str: + async with tenant_session(factory, tenant_id) as session: + client = Client( + matrix_user_id=f"@bridge-{tenant_id}:{suffix}", + display_name="bridge", + type="collaboration_bridge", + ) + session.add(client) + await session.flush() + bridge = CollaborationBridge( + type="slack", + display_name="Acme", + connection_config={}, + client_id=client.id, + status="active", + ) + session.add(bridge) + await session.flush() + bridge_id = bridge.id + await session.commit() + return bridge_id + + +async def _fixture(harness: RLSHarness) -> _Fixture: + fixture = _Fixture(harness) + suffix = uuid.uuid4().hex[:8] + + async with harness.owner() as session: + for label in ("a", "b"): + tenant_id = f"tenant-{label}-{suffix}" + session.add(Tenant(id=tenant_id, slug=tenant_id, name=tenant_id)) + user = User(name="installer", email=f"{suffix}@example.test", role="user") + session.add(user) + await session.flush() + user_id = user.id + await session.commit() + + workspaces = [] + for label in ("a", "b"): + tenant_id = f"tenant-{label}-{suffix}" + workspace_id = f"T-{label}-{suffix}" + bridge_id = await _make_bridge(harness.restricted, tenant_id, suffix) + async with tenant_session(harness.restricted, tenant_id) as session: + session.add( + MessagingInstall( + tenant_id=tenant_id, + platform="slack", + external_workspace_id=workspace_id, + encrypted_bot_token="ciphertext", + scopes="chat:write", + status="active", + installed_by_user_id=user_id, + bridge_id=bridge_id, + ) + ) + await session.commit() + adapter = _RecordingAdapter() + fixture.lifecycle.adapters[bridge_id] = adapter + workspaces.append( + _Workspace( + tenant_id=tenant_id, + workspace_id=workspace_id, + bridge_id=bridge_id, + adapter=adapter, + ) + ) + fixture.a, fixture.b = workspaces + + installers = MessagingInstallerRegistry() + installers.register( + SlackAppInstaller( + client_id="1234.5678", + client_secret="test-client-secret", + signing_secret=_SIGNING_SECRET, + ) + ) + fixture.service = MessagingInstallService( + session_factory=harness.restricted, + store=MessagingInstallStore(), + installers=installers, + lifecycle=fixture.lifecycle, # type: ignore[arg-type] + public_origin=_ORIGIN, + secret=_SECRET, + ) + + app = FastAPI() + app.include_router(create_messaging_install_router(fixture.service)) + fixture.client = httpx.AsyncClient( + transport=httpx.ASGITransport(app=app), base_url=_ORIGIN + ) + return fixture + + +def _event(workspace_id: str, text: str) -> bytes: + return json.dumps( + { + "type": "event_callback", + "team_id": workspace_id, + "event": {"type": "message", "text": text, "channel": "C1"}, + } + ).encode() + + +def _texts(adapter: _RecordingAdapter) -> list[str]: + return [payload["event"]["text"] for _, payload in adapter.dispatched] + + +async def _post(fixture: _Fixture, path: str, body: bytes) -> httpx.Response: + return await fixture.client.post(path, content=body, headers=_signed(body)) + + +class TestAnEventReachesOneTenant: + async def test_it_reaches_the_tenant_that_installed_the_workspace( + self, rls_harness: RLSHarness + ) -> None: + fixture = await _fixture(rls_harness) + body = _event(fixture.a.workspace_id, "hello") + + response = await _post(fixture, events_path("slack"), body) + + assert response.status_code == 200 + assert len(fixture.a.adapter.dispatched) == 1 + envelope_type, payload = fixture.a.adapter.dispatched[0] + assert envelope_type == "events_api" + assert payload["event"]["text"] == "hello" + + async def test_it_reaches_no_other_tenant(self, rls_harness: RLSHarness) -> None: + """The whole point. One URL, two customers, and the payload decides. + + Asserted from both directions in one test, because the failure this + guards against is a router that resolves the tenant once and then + fans out to every bridge it knows: that passes any test that only + looks at the intended recipient. + """ + fixture = await _fixture(rls_harness) + + await _post(fixture, events_path("slack"), _event(fixture.a.workspace_id, "a")) + await _post(fixture, events_path("slack"), _event(fixture.b.workspace_id, "b")) + + assert _texts(fixture.a.adapter) == ["a"] + assert _texts(fixture.b.adapter) == ["b"] + + async def test_nothing_is_bound_when_the_adapter_runs( + self, rls_harness: RLSHarness + ) -> None: + """Same as Socket Mode, deliberately. + + A bridge that dials out dispatches from a task with no tenant bound, + and every handler underneath binds the tenant of the room it is acting + on. Binding one here would make the two delivery paths differ in the + one respect that decides who a message reaches, and would hide a + handler that had forgotten to bind for itself. + """ + fixture = await _fixture(rls_harness) + + await _post(fixture, events_path("slack"), _event(fixture.a.workspace_id, "x")) + + assert fixture.a.adapter.tenants_bound == [None] + + async def test_a_slash_command_arrives_as_its_form_fields( + self, rls_harness: RLSHarness + ) -> None: + fixture = await _fixture(rls_harness) + body = urlencode( + { + "command": "/agents-status", + "text": "", + "team_id": fixture.a.workspace_id, + "channel_id": "C1", + } + ).encode() + + response = await _post(fixture, commands_path("slack"), body) + + assert response.status_code == 200 + envelope_type, payload = fixture.a.adapter.dispatched[0] + assert envelope_type == "slash_commands" + assert payload["command"] == "/agents-status" + + async def test_an_interaction_is_routed_by_its_nested_team( + self, rls_harness: RLSHarness + ) -> None: + """Slack names the workspace differently here, and it still routes.""" + fixture = await _fixture(rls_harness) + body = urlencode( + { + "payload": json.dumps( + {"type": "block_actions", "team": {"id": fixture.b.workspace_id}} + ) + } + ).encode() + + response = await _post(fixture, interactive_path("slack"), body) + + assert response.status_code == 200 + assert fixture.b.adapter.dispatched[0][0] == "interactive" + assert fixture.a.adapter.dispatched == [] + + +class TestWhatTheEndpointRefuses: + async def test_an_unsigned_post_is_refused_and_delivers_nothing( + self, rls_harness: RLSHarness + ) -> None: + """The first line, and the only one that runs before anything is read. + + A workspace id in an unsigned body is just a string somebody typed; + acting on it would let anyone who knows a customer's Slack team id post + into their rooms. + """ + fixture = await _fixture(rls_harness) + body = _event(fixture.a.workspace_id, "forged") + + response = await fixture.client.post( + events_path("slack"), + content=body, + headers={"Content-Type": "application/json"}, + ) + + assert response.status_code == 401 + assert fixture.a.adapter.dispatched == [] + + async def test_a_signature_over_a_different_body_is_refused( + self, rls_harness: RLSHarness + ) -> None: + """A captured signature re-used over a payload naming another tenant.""" + fixture = await _fixture(rls_harness) + captured = _signed(_event(fixture.b.workspace_id, "theirs")) + + response = await fixture.client.post( + events_path("slack"), + content=_event(fixture.a.workspace_id, "mine"), + headers=captured, + ) + + assert response.status_code == 401 + assert fixture.a.adapter.dispatched == [] + assert fixture.b.adapter.dispatched == [] + + async def test_an_unknown_workspace_is_not_reported_as_handled( + self, rls_harness: RLSHarness + ) -> None: + """404 rather than a polite 200. + + The app is still installed somewhere we no longer serve, and the + honest answer is that this event reached nobody. A 200 would make it + invisible on both sides — a platform's own delivery log is often the + only place anyone would see it. + """ + fixture = await _fixture(rls_harness) + + response = await _post(fixture, events_path("slack"), _event("T-nobody", "x")) + + assert response.status_code == 404 + assert fixture.a.adapter.dispatched == [] + assert fixture.b.adapter.dispatched == [] + + async def test_an_event_naming_no_workspace_is_refused( + self, rls_harness: RLSHarness + ) -> None: + fixture = await _fixture(rls_harness) + body = json.dumps({"type": "event_callback", "event": {}}).encode() + + response = await _post(fixture, events_path("slack"), body) + + assert response.status_code == 400 + + async def test_a_body_that_cannot_be_read_is_refused( + self, rls_harness: RLSHarness + ) -> None: + fixture = await _fixture(rls_harness) + + response = await _post(fixture, events_path("slack"), b"not json") + + assert response.status_code == 400 + + async def test_a_platform_with_no_app_registered_is_not_found( + self, rls_harness: RLSHarness + ) -> None: + fixture = await _fixture(rls_harness) + + response = await _post(fixture, events_path("teams"), _event("T1", "x")) + + assert response.status_code == 404 + + +class TestWhenTheBridgeIsNotThere: + async def test_a_stopped_bridge_asks_the_platform_to_retry( + self, rls_harness: RLSHarness + ) -> None: + """503, so Slack retries while a bridge restarts. + + The alternative is a 200 that discards a real message and reports it + handled — the exact shape of failure that reads as "Slack lost a + message" and is never found. + """ + fixture = await _fixture(rls_harness) + del fixture.lifecycle.adapters[fixture.a.bridge_id] + + response = await _post( + fixture, events_path("slack"), _event(fixture.a.workspace_id, "x") + ) + + assert response.status_code == 503 + + async def test_an_install_with_no_bridge_yet_says_the_same( + self, rls_harness: RLSHarness + ) -> None: + """A recorded credential nothing has been built on. The schema allows + it, so the router has to answer for it.""" + fixture = await _fixture(rls_harness) + async with tenant_session( + rls_harness.restricted, fixture.a.tenant_id + ) as session: + install = await MessagingInstallStore().get_for_workspace( + session, + platform="slack", + external_workspace_id=fixture.a.workspace_id, + ) + assert install is not None + install.bridge_id = None + await session.commit() + + response = await _post( + fixture, events_path("slack"), _event(fixture.a.workspace_id, "x") + ) + + assert response.status_code == 503 + + +class TestTheHandshake: + async def test_it_is_answered_before_anyone_has_installed_anything( + self, rls_harness: RLSHarness + ) -> None: + """Slack saves a Request URL only if this is echoed back. + + It carries no workspace, so a handshake that had to resolve a tenant + could never be answered — and the app could never be configured at all. + """ + fixture = await _fixture(rls_harness) + body = json.dumps( + {"type": "url_verification", "challenge": "let-me-in"} + ).encode() + + response = await _post(fixture, events_path("slack"), body) + + assert response.status_code == 200 + assert response.text == "let-me-in" + assert fixture.a.adapter.dispatched == [] + + async def test_an_unsigned_handshake_is_still_refused( + self, rls_harness: RLSHarness + ) -> None: + fixture = await _fixture(rls_harness) + + response = await fixture.client.post( + events_path("slack"), + content=b'{"type":"url_verification","challenge":"let-me-in"}', + ) + + assert response.status_code == 401 + + +class TestABridgeThatCannotTakeEvents: + async def test_the_refusal_is_not_swallowed(self, rls_harness: RLSHarness) -> None: + """A webhook install pointed at a socket-mode adapter is a fault. + + It cannot happen through the install flow — the rendered config says + `event_delivery: webhook` — but it is exactly the state a hand-edited + connection config could reach, and delivering nowhere while answering + 200 is how it would stay hidden. + """ + fixture = await _fixture(rls_harness) + adapter = _SocketOnlyAdapter() + fixture.lifecycle.adapters[fixture.a.bridge_id] = adapter + + event = fixture.service.authenticate( + platform="slack", + endpoint="events", + headers=_signed(_event(fixture.a.workspace_id, "x")), + body=_event(fixture.a.workspace_id, "x"), + ) + target = await fixture.service.resolve(platform="slack", event=event) + with pytest.raises(Exception, match="does not receive events over HTTP"): + await fixture.service.deliver(target, event) diff --git a/core/tests/switch_core/bridges/collaboration/test_slack_installer.py b/core/tests/switch_core/bridges/collaboration/test_slack_installer.py index dc2f5159f..5c7cb4c47 100644 --- a/core/tests/switch_core/bridges/collaboration/test_slack_installer.py +++ b/core/tests/switch_core/bridges/collaboration/test_slack_installer.py @@ -9,7 +9,9 @@ import hashlib import hmac +import json import time +from urllib.parse import urlencode import pytest from slack_sdk.web.async_client import AsyncWebClient @@ -19,6 +21,7 @@ MessagingInstallerRegistry, MessagingInstallError, WebhookAuthenticityError, + WebhookPayloadError, events_path, oauth_callback_path, public_url, @@ -130,23 +133,6 @@ def test_header_case_does_not_matter(self, installer: SlackAppInstaller) -> None installer.verify_webhook(headers=headers, body=body) -class TestWorkspaceOfEvent: - def test_it_reads_the_team_id(self, installer: SlackAppInstaller) -> None: - assert installer.workspace_of_event({"team_id": "T123"}) == "T123" - - @pytest.mark.parametrize("payload", [{}, {"team_id": ""}, {"team_id": 7}]) - def test_an_event_naming_no_workspace_is_refused( - self, installer: SlackAppInstaller, payload: dict - ) -> None: - """An event we cannot route is not an event to guess at. - - There is no sensible default here: picking any tenant would deliver a - stranger's message into somebody's rooms. - """ - with pytest.raises(WebhookAuthenticityError): - installer.workspace_of_event(payload) - - class TestRedeem: async def _redeem_returning( self, monkeypatch: pytest.MonkeyPatch, installer: SlackAppInstaller, response @@ -228,6 +214,137 @@ async def test_a_grant_with_no_workspace_is_refused( ) +class TestParsingAWebhook: + """Turning three differently-shaped bodies into what Socket Mode delivers. + + The comparison that matters is not with Slack's documentation but with the + socket transport: `dispatch_event` is shared, so anything parsed into a + different shape here is a bridge that behaves differently depending on + which Slack app its token came from. + """ + + def test_an_event_callback_becomes_the_socket_mode_envelope( + self, installer: SlackAppInstaller + ) -> None: + body = json.dumps( + { + "type": "event_callback", + "team_id": "T1", + "event": {"type": "message", "text": "hello"}, + } + ).encode() + + parsed = installer.parse_webhook(endpoint="events", body=body) + + assert parsed.envelope_type == "events_api" + assert parsed.handshake is None + # The whole envelope, not the inner event: `dispatch_event` reads + # `payload["event"]` out of it, exactly as Socket Mode hands it over. + assert parsed.payload["event"] == {"type": "message", "text": "hello"} + assert parsed.payload["team_id"] == "T1" + + def test_a_url_verification_is_answered_and_not_dispatched( + self, installer: SlackAppInstaller + ) -> None: + """Slack proving the URL at the moment it is saved. + + It arrives before any workspace has installed anything, so a handshake + that had to resolve a tenant could never be answered — which would mean + the Request URL could not be saved at all. + """ + body = json.dumps({"type": "url_verification", "challenge": "abc123"}).encode() + + parsed = installer.parse_webhook(endpoint="events", body=body) + + assert parsed.handshake == "abc123" + + def test_a_url_verification_with_nothing_to_echo_is_refused( + self, installer: SlackAppInstaller + ) -> None: + with pytest.raises(WebhookPayloadError, match="challenge"): + installer.parse_webhook( + endpoint="events", body=b'{"type":"url_verification"}' + ) + + def test_a_slash_command_is_its_form_fields( + self, installer: SlackAppInstaller + ) -> None: + body = urlencode( + {"command": "/agents-status", "text": "", "team_id": "T1", "user_id": "U1"} + ).encode() + + parsed = installer.parse_webhook(endpoint="commands", body=body) + + assert parsed.envelope_type == "slash_commands" + assert parsed.payload["command"] == "/agents-status" + # A string, not a one-element list: the adapter reads these the way + # Socket Mode delivers them. + assert parsed.payload["text"] == "" + + def test_an_interaction_is_unwrapped_from_its_payload_field( + self, installer: SlackAppInstaller + ) -> None: + inner = {"type": "block_actions", "team": {"id": "T1"}} + body = urlencode({"payload": json.dumps(inner)}).encode() + + parsed = installer.parse_webhook(endpoint="interactive", body=body) + + assert parsed.envelope_type == "interactive" + assert parsed.payload == inner + + def test_an_interaction_with_no_payload_field_is_refused( + self, installer: SlackAppInstaller + ) -> None: + with pytest.raises(WebhookPayloadError, match="payload"): + installer.parse_webhook(endpoint="interactive", body=b"other=1") + + @pytest.mark.parametrize( + "body", + [ + pytest.param(b"not json at all", id="not-json"), + pytest.param(b"[1, 2, 3]", id="json-but-not-an-object"), + pytest.param(b"", id="empty"), + ], + ) + def test_a_body_that_cannot_be_read_is_a_payload_error( + self, installer: SlackAppInstaller, body: bytes + ) -> None: + """Verified, so it really is Slack. That makes it worth a loud error. + + A bad signature is the internet; a signed body this build cannot parse + is either a Slack change or a bug of ours, and both want a log line + rather than a shrug. + """ + with pytest.raises(WebhookPayloadError): + installer.parse_webhook(endpoint="events", body=body) + + +class TestWhichWorkspaceSentIt: + """The one question that decides who the event belongs to.""" + + def test_an_event_names_it_flat(self, installer: SlackAppInstaller) -> None: + assert installer.workspace_of_event({"team_id": "T1"}) == "T1" + + def test_an_interaction_names_it_nested(self, installer: SlackAppInstaller) -> None: + assert installer.workspace_of_event({"team": {"id": "T1"}}) == "T1" + + @pytest.mark.parametrize( + "payload", + [ + pytest.param({}, id="absent"), + pytest.param({"team_id": ""}, id="empty"), + pytest.param({"team_id": 7}, id="not-a-string"), + pytest.param({"team": "T1"}, id="team-not-an-object"), + ], + ) + def test_an_event_naming_none_is_refused_rather_than_guessed_at( + self, installer: SlackAppInstaller, payload: dict[str, object] + ) -> None: + """There is no safe default. Any guess picks somebody's tenant.""" + with pytest.raises(WebhookPayloadError): + installer.workspace_of_event(payload) + + class TestConnectionConfig: def test_a_grant_renders_a_config_the_adapter_accepts( self, installer: SlackAppInstaller diff --git a/core/tests/switch_core/db/test_tenant_exemption_allowlist.py b/core/tests/switch_core/db/test_tenant_exemption_allowlist.py index 64c129911..3d57a604d 100644 --- a/core/tests/switch_core/db/test_tenant_exemption_allowlist.py +++ b/core/tests/switch_core/db/test_tenant_exemption_allowlist.py @@ -77,6 +77,13 @@ # `_room_tenant`'s fallback: which tenant is this room in, asked when the # answer is not already cached alongside the channel mapping. "switch_core.bridges.collaboration.bridge_core", + # An inbound webhook from an installed workspace: the platform's signature + # proves the sender and the payload names a workspace, and nothing in + # either names a tenant. It is the one read that must happen before a + # tenant can be bound at all, and the install row is then re-read scoped to + # the tenant it produced, so a wrong answer here is a miss rather than a + # cross-tenant read. + "switch_core.bridges.collaboration.install_service", # `switch_core.transport.postgres` and `switch_core.clients.agent_client` # came off this list with `tenant_of_client`: both were built from a # `clients` row that already named the tenant, so they carry it instead of From 47b9fb283cdbd6805dba07058bb773e4e2162717 Mon Sep 17 00:00:00 2001 From: Petr Bauch Date: Mon, 14 Sep 2026 17:51:44 -0400 Subject: [PATCH 05/29] feat(deploy): route /messaging to switch-core, and say the tenant must 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 --- deploy/remote/helm/switch/values.yaml | 1 + docs/old/bridges/SLACK_DISTRIBUTED_APP.md | 5 +++++ 2 files changed, 6 insertions(+) diff --git a/deploy/remote/helm/switch/values.yaml b/deploy/remote/helm/switch/values.yaml index 75fffe8dc..6aca7c16d 100644 --- a/deploy/remote/helm/switch/values.yaml +++ b/deploy/remote/helm/switch/values.yaml @@ -45,6 +45,7 @@ ingress: - /mcp - /oauth - /.well-known + - /messaging - /health # Path prefixes served by the Teams bridge listener rather than the FastAPI # app, because that listener is a separate HTTP server on its own port. Used diff --git a/docs/old/bridges/SLACK_DISTRIBUTED_APP.md b/docs/old/bridges/SLACK_DISTRIBUTED_APP.md index 3294bde14..04d6e0f1f 100644 --- a/docs/old/bridges/SLACK_DISTRIBUTED_APP.md +++ b/docs/old/bridges/SLACK_DISTRIBUTED_APP.md @@ -9,6 +9,11 @@ requires them to see a token at all. They are two separate Slack apps and they will both exist. Nothing here replaces the other page. +The 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, +so no amount of clicking in Slack brings a tenant into being. + ## Why it cannot be the same app The self-registered app uses **Socket Mode**: Switch dials out to Slack and From 444876e25d4c765b7b9b52b18017544e33a1b66a Mon Sep 17 00:00:00 2001 From: Petr Bauch Date: Tue, 15 Sep 2026 07:24:09 -0400 Subject: [PATCH 06/29] feat(config): MESSAGING_PUBLIC_URL, separate from the gateway's public origin MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .env.example | 14 +++++++ .../bridges/collaboration/install.py | 4 +- core/switch_core/config.py | 40 ++++++++++++++++++- core/switch_core/main.py | 4 +- .../switch_core/test_config_slack_app.py | 37 +++++++++++++++-- deploy/local/standalone-docker-compose.yml | 4 ++ docs/old/bridges/SLACK_DISTRIBUTED_APP.md | 14 +++++-- 7 files changed, 104 insertions(+), 13 deletions(-) diff --git a/.env.example b/.env.example index ed7a9c2f4..545f48d63 100644 --- a/.env.example +++ b/.env.example @@ -121,6 +121,20 @@ FRONTEND_BASE_URL=http://localhost:5173 # deeplink. # GATEWAY_PUBLIC_URL=http://localhost:8000 +# Public origin a messaging platform reaches Switch on: the base of the OAuth +# redirect and the event URLs under /messaging for the distributed Slack app. +# Must be https and must resolve from the internet, so it is usually a +# different host from GATEWAY_PUBLIC_URL. Required to offer workspace installs. +# MESSAGING_PUBLIC_URL=https://switch.example.com + +# Credentials of the distributed Slack app — the one a customer installs by +# clicking a button, not the one an operator registers for themselves (that is +# SLACK_BOT_TOKEN et al). Set all three or none; setting some is refused at +# startup. See docs/old/bridges/SLACK_DISTRIBUTED_APP.md. +# SLACK_APP_CLIENT_ID= +# SLACK_APP_CLIENT_SECRET= +# SLACK_APP_SIGNING_SECRET= + # ── Mattermost (local dev) ─────────────────────────────────────────────────── MATTERMOST_ADMIN_USER=admin MATTERMOST_ADMIN_PASSWORD= diff --git a/core/switch_core/bridges/collaboration/install.py b/core/switch_core/bridges/collaboration/install.py index 62b7012a8..8abdcffee 100644 --- a/core/switch_core/bridges/collaboration/install.py +++ b/core/switch_core/bridges/collaboration/install.py @@ -71,8 +71,8 @@ def commands_path(platform: str) -> str: def public_url(public_origin: str, path: str) -> str: """Absolute URL for one install path, given the deployment's public origin. - The origin is `GATEWAY_PUBLIC_URL`, which is validated at startup as scheme - and host with no path, so this is a join and not a merge. It exists as a + The origin is `MESSAGING_PUBLIC_URL`, which is validated at startup as + scheme and host with no path, so this is a join and not a merge. It exists as a function so the redirect URI sent to the platform and the one registered with the app are built the same way — the platform compares them exactly, and a trailing slash on one side is a refused install with a message that diff --git a/core/switch_core/config.py b/core/switch_core/config.py index 93683bbd9..1881292a4 100644 --- a/core/switch_core/config.py +++ b/core/switch_core/config.py @@ -194,6 +194,20 @@ class SwitchConfig(BaseSettings): slack_app_client_secret: str | None = None slack_app_signing_secret: str | None = None + # Public origin (scheme + host, no path) that a messaging platform reaches + # Switch on: the base of the OAuth redirect and of the three event URLs + # under `/messaging`, and the one registered with the app. + # + # Separate from `gateway_public_url` because the two answer to different + # audiences and need not be the same host. The gateway URL is opened by a + # person following a deeplink and may live on a private network; this one + # is dialled by Slack from the internet and must resolve and present a + # browser-trusted certificate there. A deployment whose gateway is + # reachable only over a VPN can still offer installs, and pointing the + # gateway URL at the internet-facing host to achieve that would silently + # move every deeplink along with it. + messaging_public_url: str | None = None + # Upper bound on a single attachment an agent may post to a room (and that # a collaboration bridge will relay out). Uploads over this raise instead # of being truncated or silently dropped. @@ -410,6 +424,28 @@ def _validate_gateway_public_url(self) -> "SwitchConfig": ) return self + @model_validator(mode="after") + def _validate_messaging_public_url(self) -> "SwitchConfig": + # Slack compares the redirect URI it is sent against the one registered + # with the app, byte for byte, and reports a mismatch as a generic + # refusal. A path here would make every install URL wrong in a way the + # error message does not name, so it is a startup error instead. + if self.messaging_public_url: + parts = urlsplit(self.messaging_public_url) + if not parts.scheme or not parts.netloc or parts.path not in ("", "/"): + raise ValueError( + "MESSAGING_PUBLIC_URL must be a scheme + host only " + "(e.g. https://switch.example), with no path, " + f"got {self.messaging_public_url!r}." + ) + if parts.scheme != "https": + raise ValueError( + "MESSAGING_PUBLIC_URL must be https. Slack refuses to " + "register an http redirect or event URL, so an http origin " + f"cannot work, got {self.messaging_public_url!r}." + ) + return self + @model_validator(mode="after") def _validate_gateway_oidc(self) -> "SwitchConfig": required = ( @@ -456,9 +492,9 @@ def _validate_slack_app(self) -> "SwitchConfig": # the app. Without the origin they would be built against nothing, so a # deployment configured to offer installs and unable to name itself is # a startup error rather than a broken button. - if set_count and not self.gateway_public_url: + if set_count and not self.messaging_public_url: raise ValueError( - "A distributed Slack app is configured but GATEWAY_PUBLIC_URL " + "A distributed Slack app is configured but MESSAGING_PUBLIC_URL " "is not. The install redirect and the events endpoint are built " "from it, and Slack rejects a redirect that does not match the " "one registered with the app." diff --git a/core/switch_core/main.py b/core/switch_core/main.py index f36744ffc..067e995d5 100644 --- a/core/switch_core/main.py +++ b/core/switch_core/main.py @@ -502,13 +502,13 @@ async def run(config: SwitchConfig) -> None: install_service: MessagingInstallService | None = None if installers.platforms(): - assert config.gateway_public_url is not None + assert config.messaging_public_url is not None install_service = MessagingInstallService( session_factory=session_factory, store=MessagingInstallStore(), installers=installers, lifecycle=collab_lifecycle, - public_origin=config.gateway_public_url, + public_origin=config.messaging_public_url, secret=config.jwt_secret_key, ) diff --git a/core/tests/switch_core/test_config_slack_app.py b/core/tests/switch_core/test_config_slack_app.py index 5c9c18222..603bd1f45 100644 --- a/core/tests/switch_core/test_config_slack_app.py +++ b/core/tests/switch_core/test_config_slack_app.py @@ -41,7 +41,7 @@ def test_setting_none_of_them_is_the_ordinary_case() -> None: def test_setting_all_three_with_a_public_origin_is_accepted() -> None: - config = _config(**_APP, gateway_public_url="https://switch.example") + config = _config(**_APP, messaging_public_url="https://switch.example") assert config.slack_app_signing_secret == "signing" @@ -49,7 +49,7 @@ def test_setting_all_three_with_a_public_origin_is_accepted() -> None: def test_setting_some_of_them_raises(missing: str) -> None: partial = {key: value for key, value in _APP.items() if key != missing} with pytest.raises(ValueError, match="Partial distributed Slack app config"): - _config(**partial, gateway_public_url="https://switch.example") + _config(**partial, messaging_public_url="https://switch.example") def test_an_app_with_no_public_origin_raises() -> None: @@ -59,5 +59,36 @@ def test_an_app_with_no_public_origin_raises() -> None: building it against nothing is an install that fails at Slack with nothing in our logs. """ - with pytest.raises(ValueError, match="GATEWAY_PUBLIC_URL"): + with pytest.raises(ValueError, match="MESSAGING_PUBLIC_URL"): _config(**_APP) + + +def test_the_gateway_url_does_not_stand_in_for_it() -> None: + """They name different hosts and only one of them is dialled by Slack. + + A deployment whose gateway is on a private network is the ordinary case, + and accepting that value here would register a redirect URL Slack cannot + reach — an install that fails at Slack with nothing in our logs. + """ + with pytest.raises(ValueError, match="MESSAGING_PUBLIC_URL"): + _config(**_APP, gateway_public_url="https://gateway.example") + + +def test_a_path_on_the_origin_is_refused() -> None: + """Slack compares the redirect byte for byte and says only that it failed.""" + with pytest.raises(ValueError, match=r"scheme \+ host only"): + _config(**_APP, messaging_public_url="https://switch.example/messaging") + + +def test_a_trailing_slash_is_allowed() -> None: + """The one path that is harmless: joining it with a rooted path is the same + string either way, and an operator who pastes a host with one should not be + refused for it.""" + config = _config(**_APP, messaging_public_url="https://switch.example/") + assert config.messaging_public_url == "https://switch.example/" + + +def test_an_http_origin_is_refused() -> None: + """Slack will not register one, so accepting it only defers the failure.""" + with pytest.raises(ValueError, match="must be https"): + _config(**_APP, messaging_public_url="http://switch.example") diff --git a/deploy/local/standalone-docker-compose.yml b/deploy/local/standalone-docker-compose.yml index f9ec593e0..9ff50cde2 100644 --- a/deploy/local/standalone-docker-compose.yml +++ b/deploy/local/standalone-docker-compose.yml @@ -207,6 +207,10 @@ services: GATEWAY_ADMIN_PASSWORD: ${GATEWAY_ADMIN_PASSWORD} FRONTEND_BASE_URL: ${FRONTEND_BASE_URL} GATEWAY_PUBLIC_URL: ${GATEWAY_PUBLIC_URL:-} + MESSAGING_PUBLIC_URL: ${MESSAGING_PUBLIC_URL:-} + SLACK_APP_CLIENT_ID: ${SLACK_APP_CLIENT_ID:-} + SLACK_APP_CLIENT_SECRET: ${SLACK_APP_CLIENT_SECRET:-} + SLACK_APP_SIGNING_SECRET: ${SLACK_APP_SIGNING_SECRET:-} depends_on: postgres: condition: service_healthy diff --git a/docs/old/bridges/SLACK_DISTRIBUTED_APP.md b/docs/old/bridges/SLACK_DISTRIBUTED_APP.md index 04d6e0f1f..d0e4f9da4 100644 --- a/docs/old/bridges/SLACK_DISTRIBUTED_APP.md +++ b/docs/old/bridges/SLACK_DISTRIBUTED_APP.md @@ -32,10 +32,16 @@ That is the whole of the difference, and everything below follows from it. ## The four URLs -Every URL is the same host with a different path. The host is the public -origin of the deployment — the same value as `GATEWAY_PUBLIC_URL`, which is -already validated as scheme-and-host with no path and already serves the -deeplink redirect from the same application. +Every URL is the same host with a different path. The host is +**`MESSAGING_PUBLIC_URL`**: scheme and host, no path, https only, and the +origin Slack itself dials. + +It is deliberately not `GATEWAY_PUBLIC_URL`. That one is the host a *person* +lands on following an "Open in Switch Console" deeplink, and on many +deployments it is reachable only over a private network — which is fine for a +person and useless to Slack. Pointing it at an internet-facing host to satisfy +Slack would move every deeplink to that host as a side effect, so the two are +separate settings and a deployment may set either, both, or neither. | Slack setting | Path | | --- | --- | From c8af4d8ec74dff2ff762aa5e7a31036526fe00e1 Mon Sep 17 00:00:00 2001 From: Petr Bauch Date: Tue, 15 Sep 2026 08:16:10 -0400 Subject: [PATCH 07/29] feat(deploy): wire the distributed Slack app through the chart MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../helm/switch/samples/ingress.example.yaml | 6 ++++ .../remote/helm/switch/templates/_helpers.tpl | 20 +++++++++++ deploy/remote/helm/switch/values.yaml | 35 +++++++++++++++++++ 3 files changed, 61 insertions(+) diff --git a/deploy/remote/helm/switch/samples/ingress.example.yaml b/deploy/remote/helm/switch/samples/ingress.example.yaml index 5fb7effd1..998f9fa6a 100644 --- a/deploy/remote/helm/switch/samples/ingress.example.yaml +++ b/deploy/remote/helm/switch/samples/ingress.example.yaml @@ -46,6 +46,12 @@ spec: - path: /.well-known pathType: Prefix backend: { service: { name: RELEASE-switch-core, port: { number: 8000 } } } + # The distributed Slack app's OAuth callback and event URLs. Needed + # only if you offer workspace installs; Slack dials these from the + # internet, so they must be reachable and on a trusted certificate. + - path: /messaging + pathType: Prefix + backend: { service: { name: RELEASE-switch-core, port: { number: 8000 } } } - path: /health pathType: Prefix backend: { service: { name: RELEASE-switch-core, port: { number: 8000 } } } diff --git a/deploy/remote/helm/switch/templates/_helpers.tpl b/deploy/remote/helm/switch/templates/_helpers.tpl index eac995c80..22f2bf51a 100644 --- a/deploy/remote/helm/switch/templates/_helpers.tpl +++ b/deploy/remote/helm/switch/templates/_helpers.tpl @@ -62,6 +62,10 @@ MATTERMOST_USER_PASSWORD: {{ .Values.secrets.mattermostUserPassword | default .V {{- if .Values.switchCore.oidc.enabled }} GATEWAY_OIDC_CLIENT_SECRET: {{ required "secrets.gatewayOidcClientSecret is required when switchCore.oidc.enabled" .Values.secrets.gatewayOidcClientSecret | b64enc | quote }} {{- end }} +{{- if .Values.switchCore.slackApp.enabled }} +SLACK_APP_CLIENT_SECRET: {{ required "secrets.slackAppClientSecret is required when switchCore.slackApp.enabled" .Values.secrets.slackAppClientSecret | b64enc | quote }} +SLACK_APP_SIGNING_SECRET: {{ required "secrets.slackAppSigningSecret is required when switchCore.slackApp.enabled" .Values.secrets.slackAppSigningSecret | b64enc | quote }} +{{- end }} {{- end }} {{/* @@ -604,6 +608,22 @@ Include with `nindent 12`. - name: GATEWAY_PUBLIC_URL value: {{ .Values.switchCore.gatewayPublicUrl | quote }} {{- end }} +{{- if .Values.switchCore.slackApp.enabled }} +- name: MESSAGING_PUBLIC_URL + value: {{ required "switchCore.slackApp.messagingPublicUrl is required when switchCore.slackApp.enabled" .Values.switchCore.slackApp.messagingPublicUrl | quote }} +- name: SLACK_APP_CLIENT_ID + value: {{ required "switchCore.slackApp.clientId is required when switchCore.slackApp.enabled" .Values.switchCore.slackApp.clientId | quote }} +- name: SLACK_APP_CLIENT_SECRET + valueFrom: + secretKeyRef: + name: {{ include "switch.secretName" . }} + key: SLACK_APP_CLIENT_SECRET +- name: SLACK_APP_SIGNING_SECRET + valueFrom: + secretKeyRef: + name: {{ include "switch.secretName" . }} + key: SLACK_APP_SIGNING_SECRET +{{- end }} {{- end }} {{/* diff --git a/deploy/remote/helm/switch/values.yaml b/deploy/remote/helm/switch/values.yaml index 6aca7c16d..a978886b6 100644 --- a/deploy/remote/helm/switch/values.yaml +++ b/deploy/remote/helm/switch/values.yaml @@ -32,6 +32,18 @@ ingress: # Emit ingress-nginx annotations that keep MCP/SSE streams alive (long read # timeouts, buffering off). Harmless on other controllers but ignored by them # — set false and add your controller's equivalents via annotations. + # + # Your controller almost certainly needs an equivalent, because the defaults + # elsewhere are short enough to cut an idle agent stream. An AWS ALB idles a + # connection out after 60 seconds; a GCE backend service after 30. On an ALB, + # add to annotations above: + # + # alb.ingress.kubernetes.io/load-balancer-attributes: idle_timeout.timeout_seconds=3600 + # + # — a property of the load balancer rather than of this Ingress, so Ingresses + # sharing a group must all agree on the value or the controller reconciles + # none of them. On GCE it is `timeoutSec` on a BackendConfig, which is a + # separate object this chart does not render. streamingAnnotations: true tls: enabled: false @@ -90,6 +102,11 @@ secrets: mattermostUserPassword: "" # Gateway OIDC client secret (required when switchCore.oidc.enabled). gatewayOidcClientSecret: "" + # Distributed Slack app (required when switchCore.slackApp.enabled). The + # signing secret is the whole of what distinguishes a real Slack event from + # a post by anyone who learned the URL. + slackAppClientSecret: "" + slackAppSigningSecret: "" # ── Switch Core (FastAPI) ───────────────────────────────────────────────────── switchCore: @@ -150,6 +167,24 @@ switchCore: requireEmailVerified: true # Set false to disable password login entirely (OIDC-only). passwordLoginEnabled: true + # The distributed Slack app: the one a customer installs into their workspace + # by clicking a button, as opposed to an app an operator registers themselves + # and pastes a bot token for. Off unless you have registered such an app and + # are distributing it — a self-hosted install almost certainly wants the + # per-tenant Socket Mode bridge instead. + # + # Enabling it commits the deployment to being reachable from the internet: + # Slack dials messagingPublicUrl and gives up on anything it cannot resolve + # or cannot verify a certificate for. Add /messaging to ingress.agentApiPaths + # as well, or the URLs exist and nothing routes to them. + slackApp: + enabled: false + # Public origin Slack reaches Switch on — scheme + host, https, no path. + # Not the same thing as gatewayPublicUrl, which is where a person lands + # from a deeplink and may be private; setting this one to that host would + # move every deeplink along with it. + messagingPublicUrl: "" + clientId: "" # Bearer-token resolution runs before every authenticated request and agents # beat continuously, so a successful resolution is memoised in process for # this many seconds. Only successes are cached — an unknown or revoked token From 5131d635c75ba5a0ca9a66df520feb682aacf6d9 Mon Sep 17 00:00:00 2001 From: Petr Bauch Date: Tue, 15 Sep 2026 09:36:49 -0400 Subject: [PATCH 08/29] feat(db): let an install end, and a workspace be installed again (CHOO-2626) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `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 --- core/switch_core/db/models.py | 56 +++++--- .../db/stores/messaging_install_store.py | 51 ++++++-- core/switch_core/db/tenant_lookup.py | 16 ++- ...a7f2c3e9b481_messaging_installs_can_end.py | 108 ++++++++++++++++ .../db/test_messaging_install_claim.py | 121 +++++++++++++++++- .../switch_core/db/test_tenant_lookup.py | 22 +++- 6 files changed, 335 insertions(+), 39 deletions(-) create mode 100644 core/switch_core/migrations/versions/a7f2c3e9b481_messaging_installs_can_end.py diff --git a/core/switch_core/db/models.py b/core/switch_core/db/models.py index ddc33ca2d..2099d6ed6 100644 --- a/core/switch_core/db/models.py +++ b/core/switch_core/db/models.py @@ -1145,19 +1145,33 @@ class MessagingInstall(TenantScoped, Base): token, and what it may do. **`(platform, external_workspace_id)` is unique across the whole - deployment, not per tenant**, and that is the single most important line - here. Inbound events arrive over one public endpoint carrying a workspace - id and no tenant, so a workspace claimed by two tenants is a message with - two possible destinations and no way to choose — which is the failure this - whole phase exists to make unrepresentable. The database decides it rather - than a read-then-insert in application code, because the check and the - write cannot be made atomic from outside. - - That constraint is also the one place a tenant learns something about - another: claiming a workspace somebody else already claimed fails, and the - failure says so. It is the right answer — the alternative is a silent - second claim — and what it discloses is that *some* tenant holds a - workspace the caller was already able to name. + deployment among rows that are still `active`**, and that is the single + most important line here. Inbound events arrive over one public endpoint + carrying a workspace id and no tenant, so a workspace claimed by two + tenants is a message with two possible destinations and no way to choose — + which is the failure this whole phase exists to make unrepresentable. The + database decides it rather than a read-then-insert in application code, + because the check and the write cannot be made atomic from outside. + + It is a *partial* index rather than a plain constraint because an install + has to be able to end. A customer who removes the app in Slack, or an + operator who disconnects it here, leaves a row behind — and a row that + still occupied the workspace would mean nobody could ever install that + workspace again, including the customer who just removed it. Ending an + install therefore frees the workspace, and keeps the record of the one + that ended. + + That index is also the one place a tenant learns something about another: + claiming a workspace somebody else already holds fails, and the failure + says so. It is the right answer — the alternative is a silent second claim + — and what it discloses is that *some* tenant holds a workspace the caller + was already able to name. + + `status` is `active`, `disconnected` (an operator here ended it) or + `revoked` (the platform told us it was over). The two endings are recorded + apart because they call for different things: one is somebody's decision + and the other is news, and an operator looking at a bridge that stopped + working needs to know which. `bridge_id` is nullable because the install row is written before anything is built on it, and because removing a bridge should not force the @@ -1167,7 +1181,10 @@ class MessagingInstall(TenantScoped, Base): `encrypted_bot_token` uses the same key as every other credential this schema stores (`crypto.encrypt_token` over the configured secret), 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. + A per-tenant key is a stronger boundary and a later decision. It is + nullable so that an install which has ended can keep its record without + keeping its secret: the token is worthless by then, and a worthless + credential still reads like a credential to whoever finds the dump. `scopes` is the platform's own spelling of what was granted, stored verbatim rather than parsed into a list — a scope string that means @@ -1177,10 +1194,12 @@ class MessagingInstall(TenantScoped, Base): __tablename__ = "messaging_installs" __table_args__ = ( - UniqueConstraint( + Index( + "uq_messaging_installs_workspace", "platform", "external_workspace_id", - name="uq_messaging_installs_workspace", + unique=True, + postgresql_where=text("status = 'active'"), ), UniqueConstraint("id", "tenant_id", name="uq_messaging_installs_id_tenant"), ForeignKeyConstraint( @@ -1193,7 +1212,7 @@ class MessagingInstall(TenantScoped, Base): id: Mapped[str] = mapped_column(Text, primary_key=True, default=_uuid) platform: Mapped[str] = mapped_column(Text, nullable=False) external_workspace_id: Mapped[str] = mapped_column(Text, nullable=False) - encrypted_bot_token: Mapped[str] = mapped_column(Text, nullable=False) + encrypted_bot_token: Mapped[str | None] = mapped_column(Text, nullable=True) scopes: Mapped[str] = mapped_column(Text, nullable=False) status: Mapped[str] = mapped_column(Text, nullable=False) installed_by_user_id: Mapped[str] = mapped_column( @@ -1203,6 +1222,9 @@ class MessagingInstall(TenantScoped, Base): installed_at: Mapped[str] = mapped_column( DateTime(timezone=True), server_default=func.now(), nullable=False ) + ended_at: Mapped[datetime | None] = mapped_column( + DateTime(timezone=True), nullable=True + ) class MessagingInstallState(TenantScoped, Base): diff --git a/core/switch_core/db/stores/messaging_install_store.py b/core/switch_core/db/stores/messaging_install_store.py index 269560850..b91a6d2a5 100644 --- a/core/switch_core/db/stores/messaging_install_store.py +++ b/core/switch_core/db/stores/messaging_install_store.py @@ -18,6 +18,18 @@ #: is worthless by the time anyone reads it. STATE_TTL = timedelta(minutes=10) +#: An install that is serving. The only status the workspace uniqueness index +#: covers, so it is also the answer to "who holds this workspace". +INSTALL_ACTIVE = "active" + +#: Ended here, by someone who decided to. The bridge is gone with it. +INSTALL_DISCONNECTED = "disconnected" + +#: Ended there: the platform told us the app was removed or its token killed. +#: Distinct from `disconnected` because an operator whose bridge stopped +#: working needs to know whether it was news or a decision. +INSTALL_REVOKED = "revoked" + class MessagingInstallClaimedError(RuntimeError): """Another tenant has already installed the app into this workspace. @@ -103,17 +115,21 @@ async def record_install( """Claim a workspace for the bound tenant. The claim is the insert: `(platform, external_workspace_id)` is unique - across the deployment, so the database decides who holds a workspace - rather than a read followed by a write that cannot be made atomic with - it. A second tenant's install therefore fails here, loudly, instead of - producing an event with two possible destinations. + across the deployment among active rows, so the database decides who + holds a workspace rather than a read followed by a write that cannot be + made atomic with it. A second tenant's install therefore fails here, + loudly, instead of producing an event with two possible destinations. + + Installs that have ended are not in the index, so re-installing a + workspace somebody released is an ordinary insert and needs no check of + its own. """ install = MessagingInstall( platform=platform, external_workspace_id=external_workspace_id, encrypted_bot_token=encrypted_bot_token, scopes=scopes, - status="active", + status=INSTALL_ACTIVE, installed_by_user_id=user_id, ) session.add(install) @@ -124,26 +140,33 @@ async def record_install( raise raise MessagingInstallClaimedError( f"the {platform} workspace {external_workspace_id} is already " - "connected to Switch. Remove the existing install before " - "connecting it again." + "connected to Switch. Disconnect the existing install — from " + "the organisation that holds it, which may not be yours — " + "before connecting it again." ) from exc return install async def get_for_workspace( self, session: AsyncSession, *, platform: str, external_workspace_id: str ) -> MessagingInstall | None: - """The bound tenant's install of one workspace, if it is theirs. - - Deliberately still scoped, even though the unique constraint means at - most one row exists deployment-wide: the caller is an inbound webhook - that resolved a tenant from the workspace a moment ago, and this - re-reading it under RLS is what makes a mistake there a miss rather - than a cross-tenant read. + """The bound tenant's live install of one workspace, if it is theirs. + + Deliberately still scoped, even though the unique index means at most + one active row exists deployment-wide: the caller is an inbound webhook + that resolved a tenant from the workspace a moment ago, and re-reading + it under RLS is what makes a mistake there a miss rather than a + cross-tenant read. + + The status predicate matches the index's and `tenant_of_messaging_ + install`'s. Without it a workspace installed, removed and installed + again returns two rows and this raises — an outage for the live + install, caused by the ended one. """ result = await session.execute( select(MessagingInstall).where( MessagingInstall.platform == platform, MessagingInstall.external_workspace_id == external_workspace_id, + MessagingInstall.status == INSTALL_ACTIVE, ) ) return result.scalars().one_or_none() diff --git a/core/switch_core/db/tenant_lookup.py b/core/switch_core/db/tenant_lookup.py index adcd78dfd..371a457da 100644 --- a/core/switch_core/db/tenant_lookup.py +++ b/core/switch_core/db/tenant_lookup.py @@ -122,6 +122,13 @@ happened to mint the same string, and refuse a customer's traffic for a reason in someone else's account. +It also filters on `status = 'active'`, which is not a refinement but the +other half of the same uniqueness. Only active rows are unique by index, so a +workspace that was installed, removed and installed again has two rows and +this would otherwise answer twice — refusing a customer's live traffic on the +strength of an install they themselves ended. The predicate here and the one +on the index are the same predicate, and have to stay that way. + Why not the obvious alternatives is argued in `docs/old/multi-tenancy-phase1-db.md`, "The bootstrap problem"; the short version is that returning rows instead of tenant ids would put a second copy @@ -306,14 +313,17 @@ def signature(self) -> str: query=( "SELECT tenant_id FROM messaging_installs " "WHERE platform = p_platform " - "AND external_workspace_id = p_external_workspace_id" + "AND external_workspace_id = p_external_workspace_id " + "AND status = 'active'" ), purpose=( "Which tenant an inbound event from an installed workspace belongs " "to. The public webhook is unauthenticated by nature and knows only " "the platform it was posted to and the workspace the payload names, " - "so this runs before anything else the request does. Unique by " - "constraint on exactly this pair, so it answers at most once." + "so this runs before anything else the request does. The status " + "predicate is the index's own: only active rows are unique, and an " + "install that ended must stop answering for its workspace rather " + "than compete with the one that replaced it." ), ), ) diff --git a/core/switch_core/migrations/versions/a7f2c3e9b481_messaging_installs_can_end.py b/core/switch_core/migrations/versions/a7f2c3e9b481_messaging_installs_can_end.py new file mode 100644 index 000000000..98a1c950d --- /dev/null +++ b/core/switch_core/migrations/versions/a7f2c3e9b481_messaging_installs_can_end.py @@ -0,0 +1,108 @@ +"""an install that can end, and a workspace that can be installed again + +`c8a4e21f6d30` gave `messaging_installs` a plain unique constraint on +`(platform, external_workspace_id)`, on the grounds that a workspace claimed +by two tenants is an event with two destinations. That grounds is still right; +the constraint was too strong for it. It made a claim permanent — the row it +protects is never deleted, so a customer who removed the Switch app from their +own Slack could not put it back, and the error they got told them to "remove +the existing install" through a path that did not exist. + +What actually has to be unique is the set of installs that are *serving*. So +the constraint becomes a unique index over `status = 'active'` under the same +name, and ending an install frees the workspace while keeping the record of +what happened to it. + +`tenant_of_messaging_install` is redefined against the same predicate, and +that is not a tidy-up. The function resolves a workspace to a tenant for +traffic nobody has authenticated, and it was written to rely on the old +constraint answering at most once. Left alone, the first workspace to be +installed twice would make it answer twice and the caller refuses an ambiguous +answer — so the customer's live install would stop receiving events because of +one they had themselves ended. + +Two column changes come with it. `ended_at` records when, alongside the +`status` that says which of the two ways. And `encrypted_bot_token` becomes +nullable so an ended install can keep its record without keeping its secret: +the token is dead by then, and a dead credential still reads like a credential +to whoever finds the dump. + +The lookup DDL below is a verbatim copy of `switch_core/db/tenant_lookup.py` +as it stood when this migration was written, copied rather than imported for +the reason every revision in this chain copies: a migration records a change +that already happened, and importing the live module would let a later edit +silently change what this one means. +`tests/switch_core/db/test_frozen_ddl_matches_create_all.py` is what keeps the +copy from drifting. + +Revision ID: a7f2c3e9b481 +Revises: d3f6b0c95a17 +Create Date: 2026-09-15 00:00:00.000000 + +""" + +from collections.abc import Sequence + +import sqlalchemy as sa +from alembic import op + +revision: str = "a7f2c3e9b481" +down_revision: str | None = "d3f6b0c95a17" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + +TABLE = "messaging_installs" +INDEX_NAME = "uq_messaging_installs_workspace" + +SECURE_SEARCH_PATH = "pg_catalog, public, pg_temp" + +CREATE_TENANT_OF_MESSAGING_INSTALL = f"""CREATE OR REPLACE FUNCTION tenant_of_messaging_install(p_platform text, p_external_workspace_id text) + RETURNS SETOF text + LANGUAGE sql STABLE SECURITY DEFINER + SET search_path = {SECURE_SEARCH_PATH} +AS $$SELECT tenant_id FROM messaging_installs WHERE platform = p_platform AND external_workspace_id = p_external_workspace_id AND status = 'active'$$""" + +RESTORE_TENANT_OF_MESSAGING_INSTALL = f"""CREATE OR REPLACE FUNCTION tenant_of_messaging_install(p_platform text, p_external_workspace_id text) + RETURNS SETOF text + LANGUAGE sql STABLE SECURITY DEFINER + SET search_path = {SECURE_SEARCH_PATH} +AS $$SELECT tenant_id FROM messaging_installs WHERE platform = p_platform AND external_workspace_id = p_external_workspace_id$$""" + + +def upgrade() -> None: + # Same name as the constraint it replaces, deliberately: the store reads + # the name out of the integrity error to tell a claimed workspace from any + # other write that failed, and Postgres reports a unique index by the same + # name a unique constraint would have carried. + op.drop_constraint(INDEX_NAME, TABLE, type_="unique") + op.create_index( + INDEX_NAME, + TABLE, + ["platform", "external_workspace_id"], + unique=True, + postgresql_where=sa.text("status = 'active'"), + ) + op.add_column( + TABLE, sa.Column("ended_at", sa.DateTime(timezone=True), nullable=True) + ) + op.alter_column( + TABLE, "encrypted_bot_token", existing_type=sa.Text(), nullable=True + ) + op.execute(CREATE_TENANT_OF_MESSAGING_INSTALL) + + +def downgrade() -> None: + op.execute(RESTORE_TENANT_OF_MESSAGING_INSTALL) + op.alter_column( + TABLE, "encrypted_bot_token", existing_type=sa.Text(), nullable=False + ) + op.drop_column(TABLE, "ended_at") + op.drop_index(INDEX_NAME, table_name=TABLE) + # Both of the statements above and this one fail on data the old schema + # cannot hold: an ended install whose token was discarded, and a workspace + # holding more than one row. That is the honest outcome — going back means + # choosing which of those installs survives, and a migration is not the + # thing that gets to choose. + op.create_unique_constraint( + INDEX_NAME, TABLE, ["platform", "external_workspace_id"] + ) diff --git a/core/tests/switch_core/db/test_messaging_install_claim.py b/core/tests/switch_core/db/test_messaging_install_claim.py index 1319744ae..c1af5cd94 100644 --- a/core/tests/switch_core/db/test_messaging_install_claim.py +++ b/core/tests/switch_core/db/test_messaging_install_claim.py @@ -19,16 +19,26 @@ who could already name it. That is deliberate, and the alternative is worse: a silent second claim, discovered when a customer's messages start arriving in somebody else's rooms. + +The claim covers **active** installs only, and the second half of this file is +about that. A claim that outlived the install would mean a workspace could be +connected once ever — including by the customer who had just disconnected it — +so ending an install releases the workspace. The lookup has to agree with the +index about which rows count, and the tests below measure that agreement from +both directions: a released workspace can be claimed again, and a workspace +with history resolves to whoever holds it now rather than refusing because +two rows name it. """ from __future__ import annotations import uuid +from datetime import UTC, datetime import pytest -from sqlalchemy import select +from sqlalchemy import select, update from sqlalchemy.exc import IntegrityError -from sqlalchemy.ext.asyncio import async_sessionmaker +from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker from switch_core.db.models import MessagingInstall, Tenant, User from switch_core.db.session_scope import tenant_session @@ -76,6 +86,20 @@ def _install(fixture: _Fixture, tenant_id: str) -> MessagingInstall: ) +async def _end(session: AsyncSession, install_id: str, status: str) -> None: + """End an install the way the store will, without depending on it yet. + + A direct write, because what is under test here is the index and the + lookup: the properties have to hold for any row in that state, not only + for rows a particular method happened to produce. + """ + await session.execute( + update(MessagingInstall) + .where(MessagingInstall.id == install_id) + .values(status=status, ended_at=datetime.now(UTC), encrypted_bot_token=None) + ) + + async def test_a_second_tenant_cannot_claim_a_claimed_workspace( rls_harness: RLSHarness, ) -> None: @@ -149,3 +173,96 @@ async def test_the_same_workspace_on_another_platform_is_a_separate_claim( ) == fixture.tenant_b ) + + +async def test_an_install_that_ended_releases_the_workspace( + rls_harness: RLSHarness, +) -> None: + """The claim lasts as long as the install and not a moment longer. + + A permanent claim is not a stricter version of this guarantee, it is a + different and wrong one: a customer who tries Switch, disconnects, and + comes back finds their own workspace held by a row nobody can see and + nobody can release. + """ + fixture = await _two_tenants_and_a_workspace(rls_harness.owner) + + async with tenant_session(rls_harness.restricted, fixture.tenant_a) as session: + install = _install(fixture, fixture.tenant_a) + session.add(install) + await session.flush() + await _end(session, install.id, "disconnected") + await session.commit() + + async with tenant_session(rls_harness.restricted, fixture.tenant_b) as session: + session.add(_install(fixture, fixture.tenant_b)) + await session.commit() + + assert ( + await tenant_of_messaging_install( + rls_harness.restricted, "slack", fixture.workspace + ) + == fixture.tenant_b + ) + + +async def test_the_lookup_does_not_answer_twice_for_a_workspace_with_history( + rls_harness: RLSHarness, +) -> None: + """The failure the status predicate exists to prevent. + + The lookup refuses an ambiguous answer rather than picking one, which is + right — but it makes a second row for the same workspace an outage for the + live install rather than a stale record. So the predicate on the function + has to be the index's predicate, and this measures it with enough history + to catch a lookup that merely takes the first row: two ended installs and + one live one, inserted in that order. + """ + fixture = await _two_tenants_and_a_workspace(rls_harness.owner) + + for _ in range(2): + async with tenant_session(rls_harness.restricted, fixture.tenant_a) as session: + install = _install(fixture, fixture.tenant_a) + session.add(install) + await session.flush() + await _end(session, install.id, "revoked") + await session.commit() + + async with tenant_session(rls_harness.restricted, fixture.tenant_a) as session: + session.add(_install(fixture, fixture.tenant_a)) + await session.commit() + + assert ( + await tenant_of_messaging_install( + rls_harness.restricted, "slack", fixture.workspace + ) + == fixture.tenant_a + ) + + +async def test_a_workspace_nobody_holds_any_longer_resolves_to_nobody( + rls_harness: RLSHarness, +) -> None: + """An ended install must not go on routing the workspace's traffic. + + The app can still be sitting in the customer's Slack after a disconnect + here, posting events at us for as long as someone leaves it there. Those + events belong to no tenant now, and the lookup saying so is what turns + them into a refusal instead of a delivery into rooms the customer has + stopped paying for. + """ + fixture = await _two_tenants_and_a_workspace(rls_harness.owner) + + async with tenant_session(rls_harness.restricted, fixture.tenant_a) as session: + install = _install(fixture, fixture.tenant_a) + session.add(install) + await session.flush() + await _end(session, install.id, "disconnected") + await session.commit() + + assert ( + await tenant_of_messaging_install( + rls_harness.restricted, "slack", fixture.workspace + ) + is None + ) diff --git a/core/tests/switch_core/db/test_tenant_lookup.py b/core/tests/switch_core/db/test_tenant_lookup.py index 5e39f0bf9..a68e939ef 100644 --- a/core/tests/switch_core/db/test_tenant_lookup.py +++ b/core/tests/switch_core/db/test_tenant_lookup.py @@ -103,6 +103,15 @@ "tenant_of_messaging_install": "c8a4e21f6d30", } +# A third kind of change, and the quietest: a lookup whose body a later +# revision replaced. Creation and removal both show up as a function that is +# there or is not; a redefinition leaves a function of the right name and +# signature answering a different question, which nothing about the shape of +# the schema reveals. So the revision that last wrote the body is named here +# and compared against the module, while the creating revision above goes on +# owning the drop. +_REDEFINED_SINCE = {"tenant_of_messaging_install": "a7f2c3e9b481"} + def _revision_module(revision: str) -> ModuleType: """One revision module, loaded through Alembic. @@ -722,17 +731,24 @@ def test_the_added_lookup_is_installed_by_a_revision_and_not_only_here( Driven by `_ADDED_SINCE` rather than naming one lookup, so the next entry is covered by adding it there and nowhere else. + + The body is compared against `_REDEFINED_SINCE` where there is an + entry, because the revision that creates a function is not always the + one that last says what it does. The drop is still the creating + revision's: a redefinition replaces a body and leaves the function it + replaced nothing to undo. """ for name, revision in _ADDED_SINCE.items(): lookup = TENANT_LOOKUPS_BY_NAME[name] - module = _revision_module(revision) + defining = _REDEFINED_SINCE.get(name, revision) + module = _revision_module(defining) assert getattr(module, f"CREATE_{name.upper()}") == create_lookup_ddl( lookup ), ( - f"revision {revision} would install {name} with different DDL " + f"revision {defining} would install {name} with different DDL " "from the one db/tenant_lookup.py builds." ) - assert getattr(module, f"DROP_{name.upper()}") == ( + assert getattr(_revision_module(revision), f"DROP_{name.upper()}") == ( f"DROP FUNCTION IF EXISTS {lookup.signature}" ) From 71bc056d326e7311621618689c3f68a31f0cd65c Mon Sep 17 00:00:00 2001 From: Petr Bauch Date: Tue, 15 Sep 2026 09:47:58 -0400 Subject: [PATCH 09/29] feat(messaging): let an install end (CHOO-2626) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../bridges/collaboration/install.py | 43 +++ .../bridges/collaboration/install_service.py | 151 +++++++++- .../bridges/collaboration/slack/install.py | 55 ++++ .../db/stores/messaging_install_store.py | 81 +++++- .../collaboration/test_install_service.py | 262 ++++++++++++++++++ 5 files changed, 587 insertions(+), 5 deletions(-) diff --git a/core/switch_core/bridges/collaboration/install.py b/core/switch_core/bridges/collaboration/install.py index 8abdcffee..aa1b2d6fa 100644 --- a/core/switch_core/bridges/collaboration/install.py +++ b/core/switch_core/bridges/collaboration/install.py @@ -205,6 +205,31 @@ async def redeem(self, *, code: str, redirect_uri: str) -> InstallGrant: grant, including a well-formed response the platform marked as failed. """ + @abstractmethod + async def revoke(self, *, bot_token: str) -> None: + """Tell the platform the credential it granted us is finished with. + + Called when an operator disconnects an install. Deleting our copy is + not the same act: the token stays valid at the platform, so a dump + taken before the disconnect would still hold a working key into a + customer's workspace. This is the half only the platform can do. + + **A token the platform already considers dead is a success, not a + failure.** The common reason to be disconnecting at all is that the + customer removed the app on their side, and an implementation that + raised on "this token is already invalid" would make exactly that + install impossible to disconnect, forever. + + Raise :class:`MessagingInstallError` for anything else — a refusal we + do not understand leaves a live credential behind and the operator + should hear about it rather than see a disconnect that reports + success. + + Not the same thing as the platform's own uninstall. Revoking a token + does not remove the app from the workspace; it ends this deployment's + access with it. + """ + @abstractmethod def verify_webhook(self, *, headers: Mapping[str, str], body: bytes) -> None: """Prove an inbound event came from the platform, or raise. @@ -245,6 +270,24 @@ def workspace_of_event(self, payload: Mapping[str, object]) -> str: event we cannot route is not an event we may guess at. """ + @abstractmethod + def revocation_of_event(self, payload: Mapping[str, object]) -> str | None: + """Why this event says the install is over, or `None` if it does not. + + Platforms report the end of an install as an ordinary event on the + ordinary endpoint, which makes it easy to treat as one: the app is + removed from a workspace, an event arrives saying so, nothing reads + it, and the deployment goes on holding a dead token, a running bridge + and a claim on a workspace whose owner believes they have left. + + A reason rather than a flag because it is the thing worth logging — an + operator asking why their bridge stopped needs the platform's own + answer, and there is more than one way an install can end. + + Runs on a payload that has been authenticated and not yet routed, so + like :meth:`workspace_of_event` it must not touch the database. + """ + @abstractmethod def connection_config(self, grant: InstallGrant) -> dict[str, object]: """Render a grant as the connection config this platform's adapter takes. diff --git a/core/switch_core/bridges/collaboration/install_service.py b/core/switch_core/bridges/collaboration/install_service.py index 55c37b5bc..ba83e198e 100644 --- a/core/switch_core/bridges/collaboration/install_service.py +++ b/core/switch_core/bridges/collaboration/install_service.py @@ -26,6 +26,27 @@ it is an orphan nothing can revoke, whereas an install row with no bridge is a recorded credential waiting to be used, which is a state the schema already allows for. + +**Ending one runs the same steps backwards, and there are two ways in.** An +operator disconnects here, or the platform tells us the app is gone. They +differ in exactly one step — whether there is a live token to revoke — and in +nothing else, because both have to leave the same state behind: no bridge, no +stored credential, a released workspace and a row saying what happened. A +deployment that handled only the first would go on holding a dead token and a +claim on a workspace whose owner believes they have left. + +The revoking order is: tell the platform first, then destroy things. A +revocation we do not understand then leaves the install exactly as it was and +the operator can try again, where the other order would have removed the +bridge and left a working key into a customer's workspace in whatever dump was +taken next. + +Then the row, and the bridge last — the reverse of the building order and for +the same reason read backwards. The install's pointer at the bridge is a real +foreign key, so the row has to let go before the bridge can be deleted at all; +and if the deletion then fails, what is left is a bridge with no credential +that an operator can see and remove, rather than an install still claiming a +workspace it has already been thrown out of. """ from __future__ import annotations @@ -53,10 +74,15 @@ from switch_core.bridges.collaboration.lifecycle_service import ( CollaborationBridgeLifecycleService, ) -from switch_core.crypto import encrypt_token +from switch_core.crypto import decrypt_token, encrypt_token from switch_core.db.models import MessagingInstall from switch_core.db.session_scope import tenant_session -from switch_core.db.stores.messaging_install_store import MessagingInstallStore +from switch_core.db.stores.messaging_install_store import ( + INSTALL_ACTIVE, + INSTALL_DISCONNECTED, + INSTALL_REVOKED, + MessagingInstallStore, +) from switch_core.db.tenant_lookup import tenant_of_messaging_install from switch_core.tenant_context import no_tenant, tenant_scope @@ -229,6 +255,127 @@ async def complete( ) return attached + # ── Ending an install ──────────────────────────────────────────────────── + + async def disconnect(self, *, tenant_id: str, install_id: str) -> MessagingInstall: + """End an install because somebody here said to. + + Idempotent: disconnecting an install that has already ended returns it + unchanged. An operator can reach this for a row the platform's own + `app_uninstalled` ended a second earlier, and that is not an error to + show them. + + **Removing the bridge detaches every room that used it**, which become + internal-only. That is the honest consequence of disconnecting a + messaging app and it is not softened here — but it is the reason this + is an explicit action with a confirmation in front of it rather than + something inferred. + """ + async with tenant_session(self._session_factory, tenant_id) as session: + install = await self._store.get(session, install_id=install_id) + platform = install.platform + workspace_id = install.external_workspace_id + bridge_id = install.bridge_id + token = install.encrypted_bot_token + already_ended = install.status != INSTALL_ACTIVE + + if already_ended: + logger.info( + "Install %s of %s workspace %s had already ended; nothing to do", + install_id, + platform, + workspace_id, + ) + async with tenant_session(self._session_factory, tenant_id) as session: + return await self._store.get(session, install_id=install_id) + + with tenant_scope(tenant_id): + if token is not None: + await self._installers.get(platform).revoke( + bot_token=decrypt_token(token, self._secret) + ) + + async with tenant_session(self._session_factory, tenant_id) as session: + ended = await self._store.end( + session, install_id=install_id, status=INSTALL_DISCONNECTED + ) + await session.commit() + + if bridge_id is not None: + await self._lifecycle.remove(bridge_id) + + logger.info( + "Disconnected %s workspace %s for tenant %s", + platform, + workspace_id, + tenant_id, + ) + return ended + + async def revoked(self, *, platform: str, workspace_id: str, reason: str) -> None: + """End an install because the platform said it is over. + + Resolves the workspace itself rather than going through `resolve`, + which is built for delivering an event and insists on a running bridge. + Here a missing bridge is beside the point: the news is that the install + is finished, and a deployment that could only record that while the + bridge happened to be up would keep the dead ones it most needs to + clear. + + A workspace that resolves to nobody is the ordinary case, not a fault. + The platform retries these events, so the second delivery arrives after + the first has already ended the install. + + No revocation call: the token this would revoke is the one the platform + has just told us it killed. + """ + tenant_id = await tenant_of_messaging_install( + self._session_factory, platform, workspace_id + ) + if tenant_id is None: + logger.info( + "Ignoring end-of-install for %s workspace %s (%s): no tenant " + "holds it, so it has already ended", + platform, + workspace_id, + reason, + ) + return + + with tenant_scope(tenant_id): + async with tenant_session(self._session_factory, tenant_id) as session: + install = await self._store.get_for_workspace( + session, platform=platform, external_workspace_id=workspace_id + ) + if install is None: + logger.warning( + "End-of-install for %s workspace %s resolved to tenant " + "%s and could not then be read as that tenant", + platform, + workspace_id, + tenant_id, + ) + return + install_id = install.id + bridge_id = install.bridge_id + + async with tenant_session(self._session_factory, tenant_id) as session: + await self._store.end( + session, install_id=install_id, status=INSTALL_REVOKED + ) + await session.commit() + + if bridge_id is not None: + await self._lifecycle.remove(bridge_id) + + logger.warning( + "Ended the install of %s workspace %s for tenant %s: %s", + platform, + workspace_id, + tenant_id, + reason, + ) + # ── Inbound events ─────────────────────────────────────────────────────── # # The other direction, and the one that runs constantly. Three steps, kept diff --git a/core/switch_core/bridges/collaboration/slack/install.py b/core/switch_core/bridges/collaboration/slack/install.py index 492e8891b..848aada72 100644 --- a/core/switch_core/bridges/collaboration/slack/install.py +++ b/core/switch_core/bridges/collaboration/slack/install.py @@ -39,6 +39,21 @@ AUTHORIZE_URL = "https://slack.com/oauth/v2/authorize" +#: Slack's ways of saying the token is finished already. Revoking one of these +#: is the outcome the caller wanted, so it is a success — and it is the usual +#: case, because the reason to be disconnecting is often that the customer +#: removed the app first. +_ALREADY_DEAD = frozenset({"invalid_auth", "token_revoked", "account_inactive"}) + +#: The two events by which Slack says an install is over, and what each means +#: in words an operator can read. `app_uninstalled` is the customer removing +#: the app; `tokens_revoked` is the narrower case of the tokens being killed +#: while the app stays. Both leave us unable to act in the workspace. +_REVOCATION_EVENTS: dict[str, str] = { + "app_uninstalled": "the app was removed from the Slack workspace", + "tokens_revoked": "Slack revoked this workspace's tokens", +} + def _json_object(raw: bytes) -> dict[str, Any]: try: @@ -163,6 +178,31 @@ async def redeem(self, *, code: str, redirect_uri: str) -> InstallGrant: scopes=response.get("scope") or "", ) + async def revoke(self, *, bot_token: str) -> None: + try: + response = await AsyncWebClient(token=bot_token).auth_revoke() + except SlackApiError as error: + reason = error.response.get("error", "") + if reason in _ALREADY_DEAD: + logger.info( + "Slack reports the bot token was already invalid (%s); " + "treating the revocation as done", + reason, + ) + return + raise MessagingInstallError( + f"Slack refused to revoke the bot token: {reason or error}" + ) from error + + # `revoked: false` with `ok: true` is Slack accepting the call and + # telling you it did nothing — which for a disconnect is the whole of + # what was asked for, so it cannot be read off `ok` alone. + if not response.get("revoked"): + raise MessagingInstallError( + "Slack accepted the revocation request and reported the token " + "was not revoked, so it is still valid." + ) + def verify_webhook(self, *, headers: Mapping[str, str], body: bytes) -> None: try: valid = self._verifier.is_valid_request(body, dict(headers)) @@ -236,6 +276,21 @@ def workspace_of_event(self, payload: Mapping[str, object]) -> str: raise WebhookPayloadError("Slack event names no workspace") return workspace_id + def revocation_of_event(self, payload: Mapping[str, object]) -> str | None: + """Read the two end-of-install events out of an Events API envelope. + + Only `event_callback` envelopes carry one. A slash command or an + interaction cannot say the app was uninstalled — there would be nobody + left to press the button — so the inner `event.type` is the only place + worth looking, and reading a `type` from anywhere else would let an + interaction payload with the right-looking field disconnect a + customer's workspace. + """ + event = payload.get("event") + if not isinstance(event, dict): + return None + return _REVOCATION_EVENTS.get(event.get("type", "")) + def connection_config(self, grant: InstallGrant) -> dict[str, object]: return { "bot_token": grant.bot_token, diff --git a/core/switch_core/db/stores/messaging_install_store.py b/core/switch_core/db/stores/messaging_install_store.py index b91a6d2a5..714e9c0c2 100644 --- a/core/switch_core/db/stores/messaging_install_store.py +++ b/core/switch_core/db/stores/messaging_install_store.py @@ -46,6 +46,16 @@ class MessagingInstallStateError(RuntimeError): """A state could not be redeemed: already used, expired, or not ours.""" +class MessagingInstallNotFound(RuntimeError): + """No install of that id is visible to the tenant bound on this session. + + "Not visible" rather than "does not exist", and the two are deliberately + not distinguished: the reads here are scoped, so an id belonging to + another tenant misses exactly as an invented one does. Telling them apart + would be a way to ask whether an id exists elsewhere. + """ + + class MessagingInstallStore: async def start_install( self, session: AsyncSession, *, platform: str, user_id: str @@ -171,13 +181,78 @@ async def get_for_workspace( ) return result.scalars().one_or_none() + async def get(self, session: AsyncSession, *, install_id: str) -> MessagingInstall: + """One of the bound tenant's installs, by id, or raise.""" + install = await session.get(MessagingInstall, install_id) + if install is None: + raise MessagingInstallNotFound( + f"no install {install_id} belongs to this organisation" + ) + return install + + async def list_for_tenant(self, session: AsyncSession) -> list[MessagingInstall]: + """Every install this tenant has ever made, newest first. + + Ended ones included, and that is the point of the method rather than a + side effect. An operator looking at this list is usually looking at it + because something stopped working, and a list of only the live installs + answers "there is nothing here" to the question "what happened to the + one that was here yesterday". + """ + result = await session.execute( + select(MessagingInstall).order_by( + MessagingInstall.installed_at.desc(), MessagingInstall.id + ) + ) + return list(result.scalars()) + + async def end( + self, session: AsyncSession, *, install_id: str, status: str + ) -> MessagingInstall: + """Mark an install finished, and discard the credential and bridge with it. + + `status` says which of the two ways it ended — see + `INSTALL_DISCONNECTED` and `INSTALL_REVOKED`. Anything else is a + programming error rather than a state the column may hold: the + uniqueness index reads `status = 'active'`, so a typo here would leave + a row that is neither serving nor releasing its workspace. + + **Ending an install twice is success.** The platform retries the event + that says an app was uninstalled, and an operator can click disconnect + on a row a retry has already ended; both must reach the same place. + That is also why this is an ordinary read-then-write rather than the + single conditional statement `redeem_state` uses — there is no race to + lose here, because two writers racing to end the same install both want + what the other is doing. + """ + if status not in (INSTALL_DISCONNECTED, INSTALL_REVOKED): + raise ValueError( + f"{status!r} is not a way an install can end; expected " + f"{INSTALL_DISCONNECTED!r} or {INSTALL_REVOKED!r}" + ) + install = await self.get(session, install_id=install_id) + if install.status != INSTALL_ACTIVE: + return install + + install.status = status + install.ended_at = datetime.now(UTC) + # The token is worthless the moment the platform is told so, and a + # worthless credential still reads like a live one to whoever finds + # the dump. Keeping the row is the record; keeping the secret is not + # part of it. + install.encrypted_bot_token = None + # And the pointer goes with it, because the bridge is about to. The + # foreign key has no `ON DELETE`, so a row still naming the bridge is + # what would refuse its deletion. + install.bridge_id = None + await session.flush() + return install + async def attach_bridge( self, session: AsyncSession, *, install_id: str, bridge_id: str ) -> MessagingInstall: """Point an install at the bridge now serving it.""" - install = await session.get(MessagingInstall, install_id) - if install is None: - raise MessagingInstallStateError(f"install not found: {install_id}") + install = await self.get(session, install_id=install_id) install.bridge_id = bridge_id await session.flush() return install diff --git a/core/tests/switch_core/bridges/collaboration/test_install_service.py b/core/tests/switch_core/bridges/collaboration/test_install_service.py index 9f8897369..c7f0252f6 100644 --- a/core/tests/switch_core/bridges/collaboration/test_install_service.py +++ b/core/tests/switch_core/bridges/collaboration/test_install_service.py @@ -26,6 +26,7 @@ InstallGrant, MessagingAppInstaller, MessagingInstallerRegistry, + MessagingInstallError, WebhookEndpoint, ) from switch_core.bridges.collaboration.install_service import ( @@ -47,7 +48,11 @@ ) from switch_core.db.session_scope import tenant_session from switch_core.db.stores.messaging_install_store import ( + INSTALL_ACTIVE, + INSTALL_DISCONNECTED, + INSTALL_REVOKED, MessagingInstallClaimedError, + MessagingInstallNotFound, MessagingInstallStateError, MessagingInstallStore, ) @@ -67,6 +72,8 @@ class _FakeInstaller(MessagingAppInstaller): def __init__(self, workspace_id: str) -> None: self.workspace_id = workspace_id self.redeem_calls: list[str] = [] + self.revoked_tokens: list[str] = [] + self.revoke_error: Exception | None = None def authorize_url(self, *, state: str, redirect_uri: str) -> str: return f"https://platform.example/authorize?state={state}" @@ -80,6 +87,19 @@ async def redeem(self, *, code: str, redirect_uri: str) -> InstallGrant: scopes="chat:write", ) + async def revoke(self, *, bot_token: str) -> None: + if self.revoke_error is not None: + raise self.revoke_error + self.revoked_tokens.append(bot_token) + + def revocation_of_event(self, payload: Mapping[str, object]) -> str | None: + event = payload.get("event") + if not isinstance(event, dict): + return None + if event.get("type") == "app_uninstalled": + return "the app was removed" + return None + def verify_webhook(self, *, headers: Mapping[str, str], body: bytes) -> None: return None @@ -110,6 +130,7 @@ def __init__( self._tenant_id = tenant_id self._suffix = suffix self.registered: list[dict[str, object]] = [] + self.removed: list[str] = [] async def register(self, **kwargs: object) -> CollaborationBridge: self.registered.append(kwargs) @@ -134,6 +155,20 @@ async def register(self, **kwargs: object) -> CollaborationBridge: await session.commit() return CollaborationBridge(id=bridge_id) + async def remove(self, bridge_id: str) -> None: + """Delete the row, not just record the call. + + The install points at the bridge through a foreign key with no + `ON DELETE`, so a stub that only counted removals would let a caller + that forgot to release the pointer pass here and fail in production. + """ + self.removed.append(bridge_id) + async with tenant_session(self._factory, self._tenant_id) as session: + bridge = await session.get(CollaborationBridge, bridge_id) + if bridge is not None: + await session.delete(bridge) + await session.commit() + class _Fixture: def __init__(self) -> None: @@ -187,6 +222,23 @@ async def _begin(factory: async_sessionmaker, fixture: _Fixture, tenant_id: str) return parse_qs(urlparse(url).query)["state"][0] +async def _installed( + factory: async_sessionmaker, fixture: _Fixture, tenant_id: str +) -> MessagingInstall: + """Run both legs and return the install they produced.""" + state = await _begin(factory, fixture, tenant_id) + return await fixture.service.complete( + platform="slack", code="the-code", state_token=state + ) + + +async def _reread( + factory: async_sessionmaker, tenant_id: str, install_id: str +) -> MessagingInstall: + async with tenant_session(factory, tenant_id) as session: + return await MessagingInstallStore().get(session, install_id=install_id) + + class TestTheRoundTrip: async def test_an_install_lands_in_the_tenant_that_started_it( self, rls_harness: RLSHarness @@ -359,3 +411,213 @@ async def test_a_workspace_another_tenant_holds_is_refused( platform="slack", code="the-code", state_token=state ) assert fixture.lifecycle.registered == [] + + +class TestDisconnecting: + """An install an operator ended, and what has to be true afterwards.""" + + async def test_the_platform_is_told_before_anything_is_destroyed( + self, rls_harness: RLSHarness + ) -> None: + """Deleting our copy of a token does not stop it working. + + Revoking is the half only the platform can do, so a disconnect that + skipped it would leave a live key into a customer's workspace in every + backup taken before it. + """ + fixture = await _fixture(rls_harness) + install = await _installed(rls_harness.restricted, fixture, fixture.tenant_a) + + await fixture.service.disconnect( + tenant_id=fixture.tenant_a, install_id=install.id + ) + + assert fixture.installer.revoked_tokens == ["xoxb-granted"] + + async def test_it_leaves_no_credential_and_no_bridge( + self, rls_harness: RLSHarness + ) -> None: + fixture = await _fixture(rls_harness) + install = await _installed(rls_harness.restricted, fixture, fixture.tenant_a) + bridge_id = install.bridge_id + + ended = await fixture.service.disconnect( + tenant_id=fixture.tenant_a, install_id=install.id + ) + + assert ended.status == INSTALL_DISCONNECTED + assert ended.ended_at is not None + assert ended.encrypted_bot_token is None + assert ended.bridge_id is None + assert fixture.lifecycle.removed == [bridge_id] + + async def test_the_workspace_can_be_installed_again( + self, rls_harness: RLSHarness + ) -> None: + """The reason the uniqueness index is partial. + + A customer who disconnects and changes their mind must be able to + click Add to Slack again, and a row that went on occupying the + workspace would mean nobody ever could. + """ + fixture = await _fixture(rls_harness) + first = await _installed(rls_harness.restricted, fixture, fixture.tenant_a) + await fixture.service.disconnect( + tenant_id=fixture.tenant_a, install_id=first.id + ) + + second = await _installed(rls_harness.restricted, fixture, fixture.tenant_a) + + assert second.id != first.id + assert second.status == INSTALL_ACTIVE + assert second.bridge_id is not None + + async def test_disconnecting_twice_is_success( + self, rls_harness: RLSHarness + ) -> None: + """An operator can click it on a row the platform ended a second ago.""" + fixture = await _fixture(rls_harness) + install = await _installed(rls_harness.restricted, fixture, fixture.tenant_a) + await fixture.service.disconnect( + tenant_id=fixture.tenant_a, install_id=install.id + ) + + again = await fixture.service.disconnect( + tenant_id=fixture.tenant_a, install_id=install.id + ) + + assert again.status == INSTALL_DISCONNECTED + assert fixture.installer.revoked_tokens == ["xoxb-granted"] + assert len(fixture.lifecycle.removed) == 1 + + async def test_a_refusal_nobody_understands_leaves_the_install_intact( + self, rls_harness: RLSHarness + ) -> None: + """Which is the whole reason the platform is told first. + + The operator sees the failure and can try again; the alternative is a + bridge already gone and a token still valid. + """ + fixture = await _fixture(rls_harness) + install = await _installed(rls_harness.restricted, fixture, fixture.tenant_a) + fixture.installer.revoke_error = MessagingInstallError("Slack said no") + + with pytest.raises(MessagingInstallError): + await fixture.service.disconnect( + tenant_id=fixture.tenant_a, install_id=install.id + ) + + still = await _reread(rls_harness.restricted, fixture.tenant_a, install.id) + assert still.status == INSTALL_ACTIVE + assert still.encrypted_bot_token is not None + assert still.bridge_id is not None + assert fixture.lifecycle.removed == [] + + async def test_another_tenants_install_is_not_disconnectable( + self, rls_harness: RLSHarness + ) -> None: + """And misses the way an invented id would, telling the caller nothing.""" + fixture = await _fixture(rls_harness) + install = await _installed(rls_harness.restricted, fixture, fixture.tenant_a) + + with pytest.raises(MessagingInstallNotFound): + await fixture.service.disconnect( + tenant_id=fixture.tenant_b, install_id=install.id + ) + + assert fixture.installer.revoked_tokens == [] + + +class TestThePlatformEndingIt: + async def test_an_uninstall_event_ends_the_install( + self, rls_harness: RLSHarness + ) -> None: + fixture = await _fixture(rls_harness) + install = await _installed(rls_harness.restricted, fixture, fixture.tenant_a) + + await fixture.service.revoked( + platform="slack", + workspace_id=fixture.workspace, + reason="the app was removed", + ) + + ended = await _reread(rls_harness.restricted, fixture.tenant_a, install.id) + assert ended.status == INSTALL_REVOKED + assert ended.encrypted_bot_token is None + assert ended.bridge_id is None + assert fixture.lifecycle.removed == [install.bridge_id] + + async def test_nothing_is_revoked_at_a_platform_that_already_did_it( + self, rls_harness: RLSHarness + ) -> None: + fixture = await _fixture(rls_harness) + await _installed(rls_harness.restricted, fixture, fixture.tenant_a) + + await fixture.service.revoked( + platform="slack", + workspace_id=fixture.workspace, + reason="the app was removed", + ) + + assert fixture.installer.revoked_tokens == [] + + async def test_a_retried_event_is_not_an_error( + self, rls_harness: RLSHarness + ) -> None: + """Slack redelivers, so the second one arrives after the workspace is free.""" + fixture = await _fixture(rls_harness) + await _installed(rls_harness.restricted, fixture, fixture.tenant_a) + await fixture.service.revoked( + platform="slack", workspace_id=fixture.workspace, reason="removed" + ) + + await fixture.service.revoked( + platform="slack", workspace_id=fixture.workspace, reason="removed" + ) + + assert len(fixture.lifecycle.removed) == 1 + + async def test_a_workspace_nobody_installed_is_ignored( + self, rls_harness: RLSHarness + ) -> None: + fixture = await _fixture(rls_harness) + + await fixture.service.revoked( + platform="slack", workspace_id="T-nobody", reason="removed" + ) + + assert fixture.lifecycle.removed == [] + + +class TestWhatTheOperatorSees: + async def test_the_list_keeps_installs_that_ended( + self, rls_harness: RLSHarness + ) -> None: + """A list of only the live ones answers "nothing here" to "what happened + to the one that was here yesterday".""" + fixture = await _fixture(rls_harness) + first = await _installed(rls_harness.restricted, fixture, fixture.tenant_a) + await fixture.service.disconnect( + tenant_id=fixture.tenant_a, install_id=first.id + ) + second = await _installed(rls_harness.restricted, fixture, fixture.tenant_a) + + async with tenant_session(rls_harness.restricted, fixture.tenant_a) as session: + listed = await MessagingInstallStore().list_for_tenant(session) + + assert {install.id for install in listed} == {first.id, second.id} + assert {install.status for install in listed} == { + INSTALL_ACTIVE, + INSTALL_DISCONNECTED, + } + + async def test_the_list_is_the_bound_tenants_own( + self, rls_harness: RLSHarness + ) -> None: + fixture = await _fixture(rls_harness) + await _installed(rls_harness.restricted, fixture, fixture.tenant_a) + + async with tenant_session(rls_harness.restricted, fixture.tenant_b) as session: + listed = await MessagingInstallStore().list_for_tenant(session) + + assert listed == [] From 4c4463e16ede7a5d28f3ad38d381868369e17aa3 Mon Sep 17 00:00:00 2001 From: Petr Bauch Date: Tue, 15 Sep 2026 09:54:29 -0400 Subject: [PATCH 10/29] feat(messaging): routes for ending an install (CHOO-2626) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../bridges/collaboration/install_routes.py | 39 +++++- .../bridges/collaboration/install_service.py | 42 ++++++- .../switch_core/gateway/messaging_installs.py | 84 ++++++++++++- .../collaboration/test_install_webhook.py | 118 ++++++++++++++++-- 4 files changed, 269 insertions(+), 14 deletions(-) diff --git a/core/switch_core/bridges/collaboration/install_routes.py b/core/switch_core/bridges/collaboration/install_routes.py index 5c7029a7d..899ec55fe 100644 --- a/core/switch_core/bridges/collaboration/install_routes.py +++ b/core/switch_core/bridges/collaboration/install_routes.py @@ -42,6 +42,7 @@ from switch_core.bridges.collaboration.install_service import ( InstallPlatformMismatch, MessagingInstallService, + Revocation, WebhookBridgeUnavailable, WebhookTarget, WebhookWorkspaceUnknown, @@ -175,6 +176,29 @@ async def _deliver(target: WebhookTarget, event: InboundWebhook) -> None: target.tenant_id, ) + async def _end_install(platform: str, revocation: Revocation) -> None: + """Act on the platform's news after it has been acknowledged. + + Logged and not raised for the same reason as `_deliver`, and with more + at stake: the platform redelivers what it gets no answer to, and a + traceback out of a background task would leave a dead install claiming + a workspace with nothing in the log naming it. + """ + try: + await service.revoked( + platform=platform, + workspace_id=revocation.workspace_id, + reason=revocation.reason, + ) + except Exception: + logger.exception( + "Failed to end the install of %s workspace %s after the platform " + "reported it was over (%s)", + platform, + revocation.workspace_id, + revocation.reason, + ) + async def _inbound( platform: str, endpoint: WebhookEndpoint, @@ -217,6 +241,13 @@ async def _inbound( return PlainTextResponse(event.handshake) try: + # Before resolving, because this is the one event that arrives as + # the bridge it would be resolved to is going away. + revocation = service.revocation(platform=platform, event=event) + if revocation is not None: + background.add_task(_end_install, platform, revocation) + return Response(status_code=200) + target = await service.resolve(platform=platform, event=event) except WebhookPayloadError as failure: logger.error( @@ -224,8 +255,14 @@ async def _inbound( ) return Response(status_code=400) except WebhookWorkspaceUnknown as failure: + # A 200 for an event that reached nobody, which is the one place + # this file answers something other than what happened. The app + # left behind in a workspace whose install ended goes on posting, + # the platform cannot act on a 404, and it counts the refusals + # against the app as a whole — so the honest answer costs every + # other customer's delivery. The log is where it is visible. logger.warning("Dropped a %s event: %s", platform, failure) - return Response(status_code=404) + return Response(status_code=200) except WebhookBridgeUnavailable as failure: # Deliberately a 503: the platform retrying is the right behaviour # while a bridge restarts, and a 200 here would drop a real message diff --git a/core/switch_core/bridges/collaboration/install_service.py b/core/switch_core/bridges/collaboration/install_service.py index ba83e198e..bacbb1fbe 100644 --- a/core/switch_core/bridges/collaboration/install_service.py +++ b/core/switch_core/bridges/collaboration/install_service.py @@ -94,8 +94,10 @@ class WebhookWorkspaceUnknown(RuntimeError): Ordinary rather than alarming: an app left in a workspace whose install was removed goes on posting for as long as someone leaves it there. It is still - an error, because the alternative is answering "fine" to traffic that - reaches nobody. + an error here, because a resolver that returned nothing would make "nobody + holds this" and "here is where it goes" the same shape — but it is one the + route absorbs rather than reports, since the platform cannot fix it and + telling it repeatedly that its posts fail is held against the app itself. """ @@ -108,6 +110,14 @@ class WebhookBridgeUnavailable(RuntimeError): """ +@dataclass(frozen=True) +class Revocation: + """A platform saying, in an ordinary event, that an install is over.""" + + workspace_id: str + reason: str + + @dataclass(frozen=True) class WebhookTarget: """Where one verified event goes: a tenant, a bridge, and its live adapter.""" @@ -255,6 +265,14 @@ async def complete( ) return attached + async def list_installs(self, session: AsyncSession) -> list[MessagingInstall]: + """The bound tenant's installs, for the operator's own list. + + On the caller's session like `begin`, and scoped by it: there is no + tenant argument because there is no tenant to choose. + """ + return await self._store.list_for_tenant(session) + # ── Ending an install ──────────────────────────────────────────────────── async def disconnect(self, *, tenant_id: str, install_id: str) -> MessagingInstall: @@ -410,6 +428,26 @@ def authenticate( installer.verify_webhook(headers=headers, body=body) return installer.parse_webhook(endpoint=endpoint, body=body) + def revocation(self, *, platform: str, event: InboundWebhook) -> Revocation | None: + """Whether this event is the platform ending the install, and for whom. + + Asked before `resolve` and not after, because the two disagree about + what a missing bridge means. `resolve` insists on one, and the event + most worth reading here is precisely the one that arrives when the + bridge is on its way out — so an uninstall routed through the ordinary + path would be dropped exactly when it mattered. + + Pure, like `authenticate`: it reads the payload and nothing else, so + the route can answer the platform before any of the work begins. + """ + installer = self._installers.get(platform) + reason = installer.revocation_of_event(event.payload) + if reason is None: + return None + return Revocation( + workspace_id=installer.workspace_of_event(event.payload), reason=reason + ) + async def resolve(self, *, platform: str, event: InboundWebhook) -> WebhookTarget: """Turn a workspace id into the one bridge entitled to the event. diff --git a/core/switch_core/gateway/messaging_installs.py b/core/switch_core/gateway/messaging_installs.py index 16ee7ffab..ac730188d 100644 --- a/core/switch_core/gateway/messaging_installs.py +++ b/core/switch_core/gateway/messaging_installs.py @@ -10,6 +10,7 @@ from __future__ import annotations import logging +from datetime import datetime from typing import Annotated from fastapi import APIRouter, Depends, HTTPException @@ -18,7 +19,8 @@ from switch_core.bridges.collaboration.install import MessagingInstallError from switch_core.bridges.collaboration.install_service import MessagingInstallService -from switch_core.db.models import User +from switch_core.db.models import User, require_tenant_id +from switch_core.db.stores.messaging_install_store import MessagingInstallNotFound from switch_core.gateway.auth import require_admin from switch_core.gateway.dependencies import get_install_service, get_session @@ -35,6 +37,30 @@ class InstallablePlatforms(BaseModel): platforms: list[str] +class InstalledApp(BaseModel): + """One install as an operator needs to see it. + + No token and nothing derived from one. `status` and `ended_at` are the + point of the shape rather than decoration: an operator looks at this list + because a bridge stopped working, and "disconnected" and "revoked" are the + difference between a decision someone here made and news from the + platform. + """ + + id: str + platform: str + external_workspace_id: str + status: str + scopes: str + bridge_id: str | None + installed_at: datetime + ended_at: datetime | None + + +class InstalledApps(BaseModel): + installs: list[InstalledApp] + + def _require_installs( service: MessagingInstallService | None, ) -> MessagingInstallService: @@ -84,3 +110,59 @@ async def begin_install( await session.commit() logger.info("Started a %s install for user %s", platform, user.id) return InstallStart(authorize_url=authorize_url) + + +@router.get("/installs") +async def list_installs( + session: Annotated[AsyncSession, Depends(get_session)], + service: Annotated[MessagingInstallService | None, Depends(get_install_service)], + _user: Annotated[User, Depends(require_admin)], +) -> InstalledApps: + """This organisation's installs, ended ones included. + + An empty list when the deployment registered no app of its own, rather + than the 501 the install endpoints answer with: a deployment can have + installs recorded from before its credentials were removed, and a page + that cannot even show them is worse than one with nothing on it. + """ + if service is None: + return InstalledApps(installs=[]) + return InstalledApps( + installs=[ + InstalledApp.model_validate(install, from_attributes=True) + for install in await service.list_installs(session) + ] + ) + + +@router.delete("/installs/{install_id}") +async def disconnect_install( + install_id: str, + service: Annotated[MessagingInstallService | None, Depends(get_install_service)], + user: Annotated[User, Depends(require_admin)], +) -> InstalledApp: + """End an install: revoke the credential, remove the bridge, free the workspace. + + Not on the request's session, deliberately. Disconnecting revokes a token + at the platform and tears a bridge down, and holding this request's + transaction open across both would keep a row locked while somebody else's + API is slow. The service opens what it needs, in the order failure can be + recovered from. + + **Rooms that used the bridge become internal-only**, which is why this is a + delete an operator has to ask for rather than anything inferred. + """ + try: + ended = await _require_installs(service).disconnect( + tenant_id=require_tenant_id(), install_id=install_id + ) + except MessagingInstallNotFound as missing: + raise HTTPException(status_code=404, detail=str(missing)) from missing + except MessagingInstallError as failure: + # The platform refused to revoke and nothing was destroyed, so this is + # upstream's answer rather than a fault here — and it is retryable, + # which the operator needs to be told rather than left to guess. + raise HTTPException(status_code=502, detail=str(failure)) from failure + + logger.info("User %s disconnected messaging install %s", user.id, install_id) + return InstalledApp.model_validate(ended, from_attributes=True) diff --git a/core/tests/switch_core/bridges/collaboration/test_install_webhook.py b/core/tests/switch_core/bridges/collaboration/test_install_webhook.py index f7d346af0..fd4244e92 100644 --- a/core/tests/switch_core/bridges/collaboration/test_install_webhook.py +++ b/core/tests/switch_core/bridges/collaboration/test_install_webhook.py @@ -126,12 +126,29 @@ async def dispatch_event( class _FakeLifecycle: - def __init__(self) -> None: + def __init__(self, factory: async_sessionmaker) -> None: + self._factory = factory self.adapters: dict[str, CollaborationAdapter] = {} + self.removed: list[str] = [] def get_adapter(self, bridge_id: str) -> CollaborationAdapter | None: return self.adapters.get(bridge_id) + async def remove(self, bridge_id: str) -> None: + """Delete the row, on an unscoped session like the real one. + + The real lifecycle opens a plain session and lets the tenant bound + around the call decide what it can see, so a fake that took a tenant + argument would not be exercising the same thing. + """ + self.removed.append(bridge_id) + self.adapters.pop(bridge_id, None) + async with self._factory() as session: + bridge = await session.get(CollaborationBridge, bridge_id) + if bridge is not None: + await session.delete(bridge) + await session.commit() + @dataclass class _Workspace: @@ -144,7 +161,7 @@ class _Workspace: class _Fixture: def __init__(self, harness: RLSHarness) -> None: self.harness = harness - self.lifecycle = _FakeLifecycle() + self.lifecycle = _FakeLifecycle(harness.restricted) self.a: _Workspace self.b: _Workspace self.client: httpx.AsyncClient @@ -254,6 +271,16 @@ def _event(workspace_id: str, text: str) -> bytes: ).encode() +def _uninstalled(workspace_id: str) -> bytes: + return json.dumps( + { + "type": "event_callback", + "team_id": workspace_id, + "event": {"type": "app_uninstalled"}, + } + ).encode() + + def _texts(adapter: _RecordingAdapter) -> list[str]: return [payload["event"]["text"] for _, payload in adapter.dispatched] @@ -389,21 +416,24 @@ async def test_a_signature_over_a_different_body_is_refused( assert fixture.a.adapter.dispatched == [] assert fixture.b.adapter.dispatched == [] - async def test_an_unknown_workspace_is_not_reported_as_handled( + async def test_an_unknown_workspace_is_dropped_without_telling_the_platform( self, rls_harness: RLSHarness ) -> None: - """404 rather than a polite 200. - - The app is still installed somewhere we no longer serve, and the - honest answer is that this event reached nobody. A 200 would make it - invisible on both sides — a platform's own delivery log is often the - only place anyone would see it. + """200, and the one place this endpoint answers something other than + what happened. + + The app is still installed in a workspace we no longer serve, so it + posts for as long as someone leaves it there. The platform cannot act + on a refusal — there is nothing for it to fix — and it counts the + refusals against the app as a whole, so the honest 404 would be paid + for by every other customer's delivery. It is dropped, and it is in the + log. """ fixture = await _fixture(rls_harness) response = await _post(fixture, events_path("slack"), _event("T-nobody", "x")) - assert response.status_code == 404 + assert response.status_code == 200 assert fixture.a.adapter.dispatched == [] assert fixture.b.adapter.dispatched == [] @@ -535,3 +565,71 @@ async def test_the_refusal_is_not_swallowed(self, rls_harness: RLSHarness) -> No target = await fixture.service.resolve(platform="slack", event=event) with pytest.raises(Exception, match="does not receive events over HTTP"): await fixture.service.deliver(target, event) + + +class TestThePlatformSayingTheInstallIsOver: + """`app_uninstalled` arrives on the same URL as everything else.""" + + async def test_it_ends_the_install_instead_of_dispatching( + self, rls_harness: RLSHarness + ) -> None: + fixture = await _fixture(rls_harness) + + response = await _post( + fixture, events_path("slack"), _uninstalled(fixture.a.workspace_id) + ) + + assert response.status_code == 200 + assert fixture.a.adapter.dispatched == [] + assert fixture.lifecycle.removed == [fixture.a.bridge_id] + + async with tenant_session( + rls_harness.restricted, fixture.a.tenant_id + ) as session: + installs = await MessagingInstallStore().list_for_tenant(session) + assert [install.status for install in installs] == ["revoked"] + + async def test_it_ends_nobody_elses(self, rls_harness: RLSHarness) -> None: + """The payload decides here as well, and the blast radius is larger. + + A revocation routed by anything but the workspace in the event would + disconnect a customer who did nothing. + """ + fixture = await _fixture(rls_harness) + + await _post(fixture, events_path("slack"), _uninstalled(fixture.a.workspace_id)) + await _post(fixture, events_path("slack"), _event(fixture.b.workspace_id, "b")) + + assert _texts(fixture.b.adapter) == ["b"] + assert fixture.lifecycle.removed == [fixture.a.bridge_id] + + async def test_the_workspace_stops_resolving_to_anyone( + self, rls_harness: RLSHarness + ) -> None: + """Which is what frees it to be installed again. + + An app removed from a workspace can still post on its way out, and an + ended install that went on answering for the workspace would route + those to a bridge that no longer exists. + """ + fixture = await _fixture(rls_harness) + await _post(fixture, events_path("slack"), _uninstalled(fixture.a.workspace_id)) + + trailing = await _post( + fixture, events_path("slack"), _event(fixture.a.workspace_id, "late") + ) + + assert trailing.status_code == 200 + assert fixture.a.adapter.dispatched == [] + + async def test_a_redelivery_is_not_an_error(self, rls_harness: RLSHarness) -> None: + """Slack retries what it is slow to hear back from, and it hears 200 + from the first delivery only after the install has already gone.""" + fixture = await _fixture(rls_harness) + body = _uninstalled(fixture.a.workspace_id) + + first = await _post(fixture, events_path("slack"), body) + second = await _post(fixture, events_path("slack"), body) + + assert (first.status_code, second.status_code) == (200, 200) + assert fixture.lifecycle.removed == [fixture.a.bridge_id] From 5f15bff2ef279ddebfb58765d70e295cc77eccb7 Mon Sep 17 00:00:00 2001 From: Petr Bauch Date: Tue, 15 Sep 2026 09:59:03 -0400 Subject: [PATCH 11/29] fix(console): re-sync the bundled standalone compose 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 --- .../resources/standalone-docker-compose.pinned.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/console/apps/switch-console-desktop/src/main/core/managed-switch-server/resources/standalone-docker-compose.pinned.yml b/console/apps/switch-console-desktop/src/main/core/managed-switch-server/resources/standalone-docker-compose.pinned.yml index 8830b9439..1df73b93a 100644 --- a/console/apps/switch-console-desktop/src/main/core/managed-switch-server/resources/standalone-docker-compose.pinned.yml +++ b/console/apps/switch-console-desktop/src/main/core/managed-switch-server/resources/standalone-docker-compose.pinned.yml @@ -214,6 +214,10 @@ services: GATEWAY_ADMIN_PASSWORD: ${GATEWAY_ADMIN_PASSWORD} FRONTEND_BASE_URL: ${FRONTEND_BASE_URL} GATEWAY_PUBLIC_URL: ${GATEWAY_PUBLIC_URL:-} + MESSAGING_PUBLIC_URL: ${MESSAGING_PUBLIC_URL:-} + SLACK_APP_CLIENT_ID: ${SLACK_APP_CLIENT_ID:-} + SLACK_APP_CLIENT_SECRET: ${SLACK_APP_CLIENT_SECRET:-} + SLACK_APP_SIGNING_SECRET: ${SLACK_APP_SIGNING_SECRET:-} depends_on: postgres: condition: service_healthy From 0eab523efc03dffa12a7c568e045a157d8e97d1c Mon Sep 17 00:00:00 2001 From: Petr Bauch Date: Tue, 15 Sep 2026 10:20:08 -0400 Subject: [PATCH 12/29] fix(console): record why the managed stack sets no messaging-app vars MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../managed-switch-server/env-file.test.ts | 21 ++++++++++++++++--- 1 file changed, 18 insertions(+), 3 deletions(-) diff --git a/console/apps/switch-console-desktop/src/main/core/managed-switch-server/env-file.test.ts b/console/apps/switch-console-desktop/src/main/core/managed-switch-server/env-file.test.ts index c0d3bdc82..63ca8994a 100644 --- a/console/apps/switch-console-desktop/src/main/core/managed-switch-server/env-file.test.ts +++ b/console/apps/switch-console-desktop/src/main/core/managed-switch-server/env-file.test.ts @@ -90,9 +90,24 @@ describe('buildEnvFile', () => { const interpolated = new Set( [...composeBody.matchAll(/\$\{([A-Z_][A-Z0-9_]*)/g)].map((m) => m[1]) ); - // Nothing is exempt today. An entry here must say why the stack is correct - // without it — leaving a var unset is a decision, not a default. - const intentionallyUnset = new Set(); + // An entry here must say why the stack is correct without it — leaving a + // var unset is a decision, not a default. + const intentionallyUnset = new Set([ + // The four below configure switch-core as a distributed messaging app — + // one app we own, installed by a customer into their own workspace, with + // the platform posting events to URLs declared once in the app manifest. + // A managed stack cannot be one of those and is not meant to be: it binds + // to loopback, so no platform can reach its callback or event URLs, and + // the credentials are the app owner's rather than anything this machine + // could hold. switch-core registers no installer without them and the + // operator UI says so rather than offering a button that would fail at + // Slack. Connecting a workspace from here is the other path — an operator + // registering a bridge with their own app's token. + 'MESSAGING_PUBLIC_URL', + 'SLACK_APP_CLIENT_ID', + 'SLACK_APP_CLIENT_SECRET', + 'SLACK_APP_SIGNING_SECRET', + ]); const missing = [...interpolated] .filter((key) => !intentionallyUnset.has(key)) From a6cb478e2b47363dc5bcdfcda875d3b216a2667f Mon Sep 17 00:00:00 2001 From: Petr Bauch Date: Tue, 15 Sep 2026 10:50:53 -0400 Subject: [PATCH 13/29] feat(messaging): handle an inbound platform event once (CHOO-2626) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../bridges/collaboration/install.py | 24 +- .../bridges/collaboration/install_routes.py | 14 + .../bridges/collaboration/install_service.py | 88 ++++++- .../bridges/collaboration/slack/install.py | 55 +++- core/switch_core/db/models.py | 66 +++++ core/switch_core/db/stores/__init__.py | 2 + .../db/stores/messaging_event_store.py | 94 +++++++ core/switch_core/main.py | 2 + .../b2e9d41c7f60_messaging_event_receipts.py | 86 ++++++ .../collaboration/test_install_service.py | 12 +- .../collaboration/test_install_webhook.py | 247 ++++++++++++++++++ .../collaboration/test_slack_installer.py | 114 +++++++- 12 files changed, 778 insertions(+), 26 deletions(-) create mode 100644 core/switch_core/db/stores/messaging_event_store.py create mode 100644 core/switch_core/migrations/versions/b2e9d41c7f60_messaging_event_receipts.py diff --git a/core/switch_core/bridges/collaboration/install.py b/core/switch_core/bridges/collaboration/install.py index aa1b2d6fa..ceb5c16a8 100644 --- a/core/switch_core/bridges/collaboration/install.py +++ b/core/switch_core/bridges/collaboration/install.py @@ -139,11 +139,26 @@ class InboundWebhook: is `None` for every real event, and it arrives before any workspace has installed anything — so it must be answerable with no tenant, no install row, and nothing running. + + `external_event_id` is the platform's own id for this delivery, and the + only thing two copies of one event have in common — the payload is + identical, so nothing else could tell a retry from a second message saying + the same words. `None` means the platform does not number this kind of + envelope, which on Slack means the kind it also does not retry; it never + means "this one was not checked". + + `delivery_attempt` is how many times the platform has given up on us and + sent this again, zero on a first delivery. It changes nothing about how the + event is handled — the receipt decides that — and is carried because it is + the only place the deployment is told its own acknowledgements are arriving + too late. """ envelope_type: str payload: dict[str, Any] handshake: str | None + external_event_id: str | None + delivery_attempt: int @dataclass(frozen=True) @@ -245,9 +260,9 @@ def verify_webhook(self, *, headers: Mapping[str, str], body: bytes) -> None: @abstractmethod def parse_webhook( - self, *, endpoint: WebhookEndpoint, body: bytes + self, *, endpoint: WebhookEndpoint, headers: Mapping[str, str], body: bytes ) -> InboundWebhook: - """Read a verified request body into an event a running adapter takes. + """Read a verified request into an event a running adapter takes. Called only after :meth:`verify_webhook` has passed, and separate from it for exactly that reason: parsing before verifying is how an @@ -257,6 +272,11 @@ def parse_webhook( method knows the encoding, which is per platform and per endpoint — Slack posts JSON to one of its three and form data to the other two. + Takes the headers as well as the body because a delivery is described + in both: the event is in the body, and how many times it has been sent + is in a header. Which header, and whether there is one, is the + platform's business rather than the route's. + Raise :class:`WebhookPayloadError` for a body that cannot be read. """ diff --git a/core/switch_core/bridges/collaboration/install_routes.py b/core/switch_core/bridges/collaboration/install_routes.py index 899ec55fe..fb8818228 100644 --- a/core/switch_core/bridges/collaboration/install_routes.py +++ b/core/switch_core/bridges/collaboration/install_routes.py @@ -240,6 +240,20 @@ async def _inbound( logger.info("Answered a %s URL verification", platform) return PlainTextResponse(event.handshake) + if event.delivery_attempt > 0: + # The only signal this deployment gets that its own acknowledgements + # are arriving too late. The event itself is handled normally — the + # receipt decides whether it is a duplicate — but a run of these is + # the platform saying the three-second answer is being missed, and + # nothing else in the system would say so. + logger.warning( + "%s is re-sending a %s event (attempt %s), which means an earlier " + "delivery was not acknowledged in time", + platform, + event.envelope_type, + event.delivery_attempt, + ) + try: # Before resolving, because this is the one event that arrives as # the bridge it would be resolved to is going away. diff --git a/core/switch_core/bridges/collaboration/install_service.py b/core/switch_core/bridges/collaboration/install_service.py index bacbb1fbe..062d3f678 100644 --- a/core/switch_core/bridges/collaboration/install_service.py +++ b/core/switch_core/bridges/collaboration/install_service.py @@ -77,6 +77,7 @@ from switch_core.crypto import decrypt_token, encrypt_token from switch_core.db.models import MessagingInstall from switch_core.db.session_scope import tenant_session +from switch_core.db.stores.messaging_event_store import MessagingEventReceiptStore from switch_core.db.stores.messaging_install_store import ( INSTALL_ACTIVE, INSTALL_DISCONNECTED, @@ -120,9 +121,15 @@ class Revocation: @dataclass(frozen=True) class WebhookTarget: - """Where one verified event goes: a tenant, a bridge, and its live adapter.""" + """Where one verified event goes: a tenant, a bridge, and its live adapter. + + `platform` is carried rather than passed alongside because it is part of + the answer: the same workspace id could in principle be issued by two + platforms, and every row written about this delivery is keyed by the pair. + """ tenant_id: str + platform: str bridge_id: str adapter: CollaborationAdapter @@ -144,6 +151,7 @@ def __init__( *, session_factory: async_sessionmaker[AsyncSession], store: MessagingInstallStore, + receipts: MessagingEventReceiptStore, installers: MessagingInstallerRegistry, lifecycle: CollaborationBridgeLifecycleService, public_origin: str, @@ -151,6 +159,7 @@ def __init__( ) -> None: self._session_factory = session_factory self._store = store + self._receipts = receipts self._installers = installers self._lifecycle = lifecycle self._public_origin = public_origin @@ -426,7 +435,7 @@ def authenticate( """ installer = self._installers.get(platform) installer.verify_webhook(headers=headers, body=body) - return installer.parse_webhook(endpoint=endpoint, body=body) + return installer.parse_webhook(endpoint=endpoint, headers=headers, body=body) def revocation(self, *, platform: str, event: InboundWebhook) -> Revocation | None: """Whether this event is the platform ending the install, and for whom. @@ -492,20 +501,75 @@ async def resolve(self, *, platform: str, event: InboundWebhook) -> WebhookTarge f"{workspace_id}, is not running" ) return WebhookTarget( - tenant_id=tenant_id, bridge_id=install.bridge_id, adapter=adapter + tenant_id=tenant_id, + platform=platform, + bridge_id=install.bridge_id, + adapter=adapter, ) async def deliver(self, target: WebhookTarget, event: InboundWebhook) -> None: - """Hand a resolved event to the bridge, as its own transport would. - - With **nothing bound**, which is not an oversight. A bridge that - receives over a socket dispatches from a task that binds no tenant, and - every handler below it binds the tenant of the room it is acting on. - Binding here would make the two delivery paths differ in the one - respect that decides who a message reaches, and would hide a handler - that had forgotten to bind for itself — for exactly as long as it took - someone to receive the same event over a socket instead. + """Hand a resolved event to the bridge, at most once. + + Claiming comes first and the dispatch second, so a retry that arrives + while the first delivery is still working finds the event taken and + stops. That ordering is the whole of the deduplication: the platform + sends the same event again whenever it is not answered in time, and + the payload of a retry is identical to the original, so nothing later + in the stack could tell it from someone saying the same thing twice. + + An event the platform does not number is dispatched without a claim. + That is not a weaker guarantee quietly accepted — Slack numbers what it + retries, so an envelope with no id is one that arrives exactly once. + + The dispatch itself runs with **nothing bound**, which is not an + oversight. A bridge that receives over a socket dispatches from a task + that binds no tenant, and every handler below it binds the tenant of + the room it is acting on. Binding here would make the two delivery + paths differ in the one respect that decides who a message reaches, and + would hide a handler that had forgotten to bind for itself — for + exactly as long as it took someone to receive the same event over a + socket instead. """ + if event.external_event_id is None: + await self._dispatch(target, event) + return + + async with tenant_session(self._session_factory, target.tenant_id) as session: + receipt = await self._receipts.claim( + session, + platform=target.platform, + external_event_id=event.external_event_id, + ) + if receipt is None: + logger.info( + "Dropped a repeat delivery of %s event %s to bridge %s " + "(attempt %s); it has already been taken", + target.platform, + event.external_event_id, + target.bridge_id, + event.delivery_attempt, + ) + return + receipt_id = receipt.id + # Before the dispatch, not with it. The index entry this writes is + # what a concurrent retry collides with, and an uncommitted one + # makes that retry wait for the turn instead of losing to it. + await session.commit() + + await self._dispatch(target, event) + + async with tenant_session(self._session_factory, target.tenant_id) as session: + await self._receipts.mark_handled(session, receipt_id=receipt_id) + pruned = await self._receipts.prune(session) + await session.commit() + if pruned: + logger.info( + "Pruned %s expired messaging event receipts for tenant %s", + pruned, + target.tenant_id, + ) + + async def _dispatch(self, target: WebhookTarget, event: InboundWebhook) -> None: with no_tenant(): await target.adapter.dispatch_event( envelope_type=event.envelope_type, payload=event.payload diff --git a/core/switch_core/bridges/collaboration/slack/install.py b/core/switch_core/bridges/collaboration/slack/install.py index 848aada72..09beb5119 100644 --- a/core/switch_core/bridges/collaboration/slack/install.py +++ b/core/switch_core/bridges/collaboration/slack/install.py @@ -65,6 +65,30 @@ def _json_object(raw: bytes) -> dict[str, Any]: return parsed +#: Slack's count of how many times it has re-sent a delivery, absent on the +#: first. Header names arrive from Starlette lower-cased and are compared that +#: way; Slack's own spelling is `X-Slack-Retry-Num`. +_RETRY_NUM_HEADER = "x-slack-retry-num" + + +def _retry_number(headers: Mapping[str, str]) -> int: + """How many times Slack has sent this already, defaulting to none. + + Unauthenticated in the sense that it is not covered by the signature — the + signature is over the body and the timestamp — so it is read as a hint and + never as a decision. A forged value cannot make an event be handled twice + or dropped, because the receipt decides that; the worst it can do is put a + wrong number in a log line. + """ + raw = headers.get(_RETRY_NUM_HEADER) + if raw is None: + return 0 + try: + return int(raw) + except ValueError: + return 0 + + def _form_fields(raw: bytes) -> dict[str, Any]: """Slack's form encoding as a flat dict, matching Socket Mode's payload. @@ -215,8 +239,10 @@ def verify_webhook(self, *, headers: Mapping[str, str], body: bytes) -> None: raise WebhookAuthenticityError("bad Slack signature") def parse_webhook( - self, *, endpoint: WebhookEndpoint, body: bytes + self, *, endpoint: WebhookEndpoint, headers: Mapping[str, str], body: bytes ) -> InboundWebhook: + attempt = _retry_number(headers) + if endpoint == "commands": # A slash command posts its fields as a form, and Socket Mode # delivers that same flat dict — so the parsed form *is* the @@ -225,6 +251,13 @@ def parse_webhook( envelope_type="slash_commands", payload=_form_fields(body), handshake=None, + # Slack retries neither of the two form endpoints. A command + # and an interaction are a person waiting on a dialog, and a + # reply that arrives a minute late is worse than none — so + # Slack sends each exactly once and there is no id on it to + # deduplicate by. + external_event_id=None, + delivery_attempt=attempt, ) if endpoint == "interactive": @@ -239,6 +272,8 @@ def parse_webhook( envelope_type="interactive", payload=_json_object(raw.encode()), handshake=None, + external_event_id=None, + delivery_attempt=attempt, ) envelope = _json_object(body) @@ -253,11 +288,25 @@ def parse_webhook( "Slack sent a URL verification with no challenge to echo" ) return InboundWebhook( - envelope_type="url_verification", payload=envelope, handshake=challenge + envelope_type="url_verification", + payload=envelope, + handshake=challenge, + external_event_id=None, + delivery_attempt=attempt, ) + event_id = envelope.get("event_id") return InboundWebhook( - envelope_type="events_api", payload=envelope, handshake=None + envelope_type="events_api", + payload=envelope, + handshake=None, + # The one envelope Slack numbers, and the one it retries. A + # retried delivery carries the same `event_id` as the original, + # which is the whole basis of handling it once. + external_event_id=event_id + if isinstance(event_id, str) and event_id + else None, + delivery_attempt=attempt, ) def workspace_of_event(self, payload: Mapping[str, object]) -> str: diff --git a/core/switch_core/db/models.py b/core/switch_core/db/models.py index 2099d6ed6..caa5dc649 100644 --- a/core/switch_core/db/models.py +++ b/core/switch_core/db/models.py @@ -1280,6 +1280,72 @@ class MessagingInstallState(TenantScoped, Base): ) +class MessagingEventReceipt(TenantScoped, Base): + """One inbound platform event, claimed once so it is handled once. + + Platforms deliver at least once. Slack gives three seconds to acknowledge + an event and retries what it does not get an answer to — so a deployment + under load, or one restarted mid-request, is told the same thing again. + Without a record of what has been taken, the second telling produces a + second answer in the customer's channel, which is the visible failure: an + agent replying twice to one question. + + **The row is written before the work, not after**, and the unique index is + what arbitrates. Two retries in flight at once both reach the insert and + exactly one survives it; the loser stops there. Recording afterwards would + order the two the wrong way round — both would dispatch, and the duplicate + would be detected once it no longer mattered. + + That ordering chooses at-most-once over at-least-once, which is worth + stating plainly: an event claimed by a process that then dies is not + retried, because the platform has already been told 200 and this table says + the event is taken. It is not a new loss. The route has acknowledged before + handling since it was written — it has to, the deadline is shorter than a + turn — so the event was already unrecoverable at that point. What this adds + is `handled_at`, which makes the loss visible: a claimed row that never + completed is a real event that reached nobody, and it can be found. + + `external_event_id` is the platform's own id for the delivery, and only + some envelopes have one. Slack numbers Events API envelopes and retries + only those; a slash command and an interaction get one shot and no id, so + there is nothing to deduplicate and no row here. A missing id means "the + platform does not retry this", not "this was not checked". + + Uniqueness is `(tenant_id, platform, external_event_id)` and not the + deployment-wide pair, unlike the workspace claim on `messaging_installs`. + The two would be equivalent — an event id is unique in the platform's own + namespace and a workspace belongs to one tenant — so the tenant-local index + is the one to prefer: it keeps one customer's event ids out of another's + namespace entirely, and it means a conflict is always with a row the + inserting tenant can actually see rather than an opaque refusal naming + somebody else's. + """ + + __tablename__ = "messaging_event_receipts" + __table_args__ = ( + UniqueConstraint( + "tenant_id", + "platform", + "external_event_id", + name="uq_messaging_event_receipts_event", + ), + # Pruning reads this and nothing else. Receipts are only useful for as + # long as the platform might still retry, and the table would otherwise + # grow with every message the busiest workspace ever sends. + Index("ix_messaging_event_receipts_received_at", "received_at"), + ) + + id: Mapped[str] = mapped_column(Text, primary_key=True, default=_uuid) + platform: Mapped[str] = mapped_column(Text, nullable=False) + external_event_id: Mapped[str] = mapped_column(Text, nullable=False) + received_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), server_default=func.now(), nullable=False + ) + handled_at: Mapped[datetime | None] = mapped_column( + DateTime(timezone=True), nullable=True + ) + + # ── Server-Side Connectors ──────────────────────────────────────────────────── diff --git a/core/switch_core/db/stores/__init__.py b/core/switch_core/db/stores/__init__.py index 114b00a82..1ba7e80ba 100644 --- a/core/switch_core/db/stores/__init__.py +++ b/core/switch_core/db/stores/__init__.py @@ -7,6 +7,7 @@ from switch_core.db.stores.external_user_store import ExternalUserStore from switch_core.db.stores.invitation_store import InvitationStore from switch_core.db.stores.message_store import MessageStore +from switch_core.db.stores.messaging_event_store import MessagingEventReceiptStore from switch_core.db.stores.messaging_install_store import MessagingInstallStore from switch_core.db.stores.reference_store import ReferenceStore from switch_core.db.stores.reference_type_store import ReferenceTypeStore @@ -29,6 +30,7 @@ "ExternalUserStore", "InvitationStore", "MessageStore", + "MessagingEventReceiptStore", "MessagingInstallStore", "ReferenceStore", "ReferenceTypeStore", diff --git a/core/switch_core/db/stores/messaging_event_store.py b/core/switch_core/db/stores/messaging_event_store.py new file mode 100644 index 000000000..deea6a095 --- /dev/null +++ b/core/switch_core/db/stores/messaging_event_store.py @@ -0,0 +1,94 @@ +from __future__ import annotations + +from datetime import UTC, datetime, timedelta + +from sqlalchemy import delete, update +from sqlalchemy.exc import IntegrityError +from sqlalchemy.ext.asyncio import AsyncSession + +from switch_core.db.models import MessagingEventReceipt + +#: How long a handled event is remembered. +#: +#: Two different needs, and the longer one sets it. Deduplication needs only to +#: outlast the platform's retry schedule, which is tens of minutes — Slack has +#: given up long before an hour. `handled_at` is the reason for days: a claimed +#: receipt that never completed is an event that reached nobody, and an hour is +#: not long enough for anyone to notice and come looking. A week of one row per +#: inbound event is a table Postgres does not notice. +RECEIPT_RETENTION = timedelta(days=7) + + +class MessagingEventReceiptStore: + """Who has already taken an inbound event, and whether they finished it.""" + + async def claim( + self, session: AsyncSession, *, platform: str, external_event_id: str + ) -> MessagingEventReceipt | None: + """Take an event to handle, or return `None` because someone else has. + + The insert *is* the claim. Reading for an existing receipt and then + writing one would leave the window this exists to close: two retries + arriving together both read nothing, both write, and the customer gets + two answers. Here the unique index decides, and the loser learns it did + by failing. + + `None` rather than an exception because a duplicate is the ordinary + case this is built for, not a fault — platforms deliver at least once + by design, and a caller that treats the second one as an error would + fill the log with the system working correctly. + + **Commit before doing the work.** An uncommitted claim still holds the + index entry, so a concurrent retry's insert blocks on it rather than + failing — and would stay blocked for as long as the first delivery + takes, which is the length of an agent's turn. Committing straight away + turns that wait into the immediate refusal it should be. + """ + receipt = MessagingEventReceipt( + platform=platform, external_event_id=external_event_id + ) + session.add(receipt) + try: + await session.flush() + except IntegrityError as exc: + if "uq_messaging_event_receipts_event" not in str(exc.orig): + raise + await session.rollback() + return None + return receipt + + async def mark_handled(self, session: AsyncSession, *, receipt_id: str) -> None: + """Record that the claimed event was seen all the way through. + + Its absence is the useful half. A receipt still unhandled long after it + was claimed is an event the platform was told we had and nobody ever + answered — a process that died mid-turn — and without this column that + is indistinguishable from an event handled perfectly. + """ + await session.execute( + update(MessagingEventReceipt) + .where(MessagingEventReceipt.id == receipt_id) + .values(handled_at=datetime.now(UTC)) + ) + + async def prune(self, session: AsyncSession) -> int: + """Drop the bound tenant's receipts older than the retention window. + + Opportunistic rather than scheduled, the way stale role leases are + cleared when the next agent takes a role. This backend has no janitor + to hang a sweep on, and inventing one for a single table would be a + larger change than the table deserves; running it off the traffic that + creates the rows keeps the work proportional to that traffic and needs + nothing started at boot. + + Called after the event has been dispatched, not before it. A prune in + the claiming transaction would sit between a retry and the refusal it + is waiting for. + """ + result = await session.execute( + delete(MessagingEventReceipt).where( + MessagingEventReceipt.received_at + < datetime.now(UTC) - RECEIPT_RETENTION + ) + ) + return int(result.rowcount or 0) # type: ignore[attr-defined] diff --git a/core/switch_core/main.py b/core/switch_core/main.py index 067e995d5..12ce05586 100644 --- a/core/switch_core/main.py +++ b/core/switch_core/main.py @@ -106,6 +106,7 @@ from switch_core.db.stores.invitation_store import InvitationStore from switch_core.db.stores.media_store import MediaStore from switch_core.db.stores.message_store import MessageStore +from switch_core.db.stores.messaging_event_store import MessagingEventReceiptStore from switch_core.db.stores.messaging_install_store import MessagingInstallStore from switch_core.db.stores.package_store import PackageStore from switch_core.db.stores.reference_store import ReferenceStore @@ -506,6 +507,7 @@ async def run(config: SwitchConfig) -> None: install_service = MessagingInstallService( session_factory=session_factory, store=MessagingInstallStore(), + receipts=MessagingEventReceiptStore(), installers=installers, lifecycle=collab_lifecycle, public_origin=config.messaging_public_url, diff --git a/core/switch_core/migrations/versions/b2e9d41c7f60_messaging_event_receipts.py b/core/switch_core/migrations/versions/b2e9d41c7f60_messaging_event_receipts.py new file mode 100644 index 000000000..89249574e --- /dev/null +++ b/core/switch_core/migrations/versions/b2e9d41c7f60_messaging_event_receipts.py @@ -0,0 +1,86 @@ +"""messaging_event_receipts + +A platform that retries is a platform that will eventually deliver the same +event twice, and the visible failure is an agent answering one question twice +in a customer's channel. This table is what makes the second delivery cheap to +recognise: a row is written before the work starts, the unique index arbitrates +between concurrent retries, and the loser stops. + +`received_at` carries an index of its own because pruning reads that column and +nothing else. Receipts are only interesting for as long as the platform might +still re-send, and without a sweep the table would grow with every message the +busiest workspace ever sends. + +The `tenant_isolation` policy is written out below rather than inherited, +because `265ed188ad6f` installed the policies as they stood then and a table +added afterwards has to bring its own. The DDL is a verbatim copy of +`switch_core/db/rls_ddl.py` as it stood when this migration was written, +copied rather than imported for the reason every revision in this chain +copies: a migration records a change that already happened, and importing the +live module would let a later edit silently change what this one means. +`tests/switch_core/db/test_frozen_ddl_matches_create_all.py` is what keeps the +copy from drifting. + +Revision ID: b2e9d41c7f60 +Revises: a7f2c3e9b481 +Create Date: 2026-09-15 00:00:00.000000 + +""" + +from collections.abc import Sequence + +import sqlalchemy as sa +from alembic import op + +revision: str = "b2e9d41c7f60" +down_revision: str | None = "a7f2c3e9b481" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + +TABLE = "messaging_event_receipts" +POLICY_NAME = "tenant_isolation" + +ENABLE_RLS = f'ALTER TABLE "{TABLE}" ENABLE ROW LEVEL SECURITY' + +CREATE_POLICY = f"""CREATE POLICY {POLICY_NAME} ON "{TABLE}" + FOR ALL + USING ("tenant_id" = (SELECT require_tenant_id())) + WITH CHECK ("tenant_id" = (SELECT require_tenant_id()))""" + +DROP_POLICY = f'DROP POLICY IF EXISTS {POLICY_NAME} ON "{TABLE}"' + + +def upgrade() -> None: + op.create_table( + TABLE, + sa.Column("id", sa.Text(), nullable=False), + sa.Column("tenant_id", sa.Text(), nullable=False), + sa.Column("platform", sa.Text(), nullable=False), + sa.Column("external_event_id", sa.Text(), nullable=False), + sa.Column( + "received_at", + sa.DateTime(timezone=True), + server_default=sa.text("now()"), + nullable=False, + ), + sa.Column("handled_at", sa.DateTime(timezone=True), nullable=True), + sa.PrimaryKeyConstraint("id"), + sa.ForeignKeyConstraint( + ["tenant_id"], ["tenants.id"], name="fk_messaging_event_receipts_tenant" + ), + sa.UniqueConstraint( + "tenant_id", + "platform", + "external_event_id", + name="uq_messaging_event_receipts_event", + ), + ) + op.create_index("ix_messaging_event_receipts_received_at", TABLE, ["received_at"]) + op.execute(ENABLE_RLS) + op.execute(CREATE_POLICY) + + +def downgrade() -> None: + op.execute(DROP_POLICY) + op.drop_index("ix_messaging_event_receipts_received_at", table_name=TABLE) + op.drop_table(TABLE) diff --git a/core/tests/switch_core/bridges/collaboration/test_install_service.py b/core/tests/switch_core/bridges/collaboration/test_install_service.py index c7f0252f6..a6879881d 100644 --- a/core/tests/switch_core/bridges/collaboration/test_install_service.py +++ b/core/tests/switch_core/bridges/collaboration/test_install_service.py @@ -47,6 +47,7 @@ User, ) from switch_core.db.session_scope import tenant_session +from switch_core.db.stores.messaging_event_store import MessagingEventReceiptStore from switch_core.db.stores.messaging_install_store import ( INSTALL_ACTIVE, INSTALL_DISCONNECTED, @@ -104,9 +105,15 @@ def verify_webhook(self, *, headers: Mapping[str, str], body: bytes) -> None: return None def parse_webhook( - self, *, endpoint: WebhookEndpoint, body: bytes + self, *, endpoint: WebhookEndpoint, headers: Mapping[str, str], body: bytes ) -> InboundWebhook: - return InboundWebhook(envelope_type=endpoint, payload={}, handshake=None) + return InboundWebhook( + envelope_type=endpoint, + payload={}, + handshake=None, + external_event_id=None, + delivery_attempt=0, + ) def workspace_of_event(self, payload: Mapping[str, object]) -> str: return self.workspace_id @@ -204,6 +211,7 @@ async def _fixture(harness: RLSHarness) -> _Fixture: fixture.service = MessagingInstallService( session_factory=harness.restricted, store=MessagingInstallStore(), + receipts=MessagingEventReceiptStore(), installers=installers, lifecycle=fixture.lifecycle, # type: ignore[arg-type] public_origin=_ORIGIN, diff --git a/core/tests/switch_core/bridges/collaboration/test_install_webhook.py b/core/tests/switch_core/bridges/collaboration/test_install_webhook.py index fd4244e92..7b72dc33f 100644 --- a/core/tests/switch_core/bridges/collaboration/test_install_webhook.py +++ b/core/tests/switch_core/bridges/collaboration/test_install_webhook.py @@ -19,18 +19,21 @@ from __future__ import annotations +import asyncio import hashlib import hmac import json import time import uuid from dataclasses import dataclass +from datetime import UTC, datetime, timedelta from typing import Any from urllib.parse import urlencode import httpx import pytest from fastapi import FastAPI +from sqlalchemy import select from sqlalchemy.ext.asyncio import async_sessionmaker from switch_core.bridges.collaboration.adapter import CollaborationAdapter @@ -48,11 +51,16 @@ from switch_core.db.models import ( Client, CollaborationBridge, + MessagingEventReceipt, MessagingInstall, Tenant, User, ) from switch_core.db.session_scope import tenant_session +from switch_core.db.stores.messaging_event_store import ( + RECEIPT_RETENTION, + MessagingEventReceiptStore, +) from switch_core.db.stores.messaging_install_store import MessagingInstallStore from switch_core.tenant_context import current_tenant_id from tests.conftest import RLSHarness @@ -125,6 +133,29 @@ async def dispatch_event( self.tenants_bound.append(current_tenant_id()) +class _GatedAdapter(_SocketOnlyAdapter): + """An adapter that holds a dispatch open until it is let go. + + What it buys is determinism. The race worth testing is a retry arriving + while the first delivery is still working — the whole reason the claim is + written before the dispatch rather than after — and an adapter that returns + instantly would let the first delivery finish before the second began, + proving only that a repeat is refused once the first is over. + """ + + def __init__(self) -> None: + self.dispatched: list[tuple[str, dict[str, Any]]] = [] + self.entered = asyncio.Event() + self.release = asyncio.Event() + + async def dispatch_event( + self, *, envelope_type: str, payload: dict[str, Any] + ) -> None: + self.dispatched.append((envelope_type, payload)) + self.entered.set() + await self.release.wait() + + class _FakeLifecycle: def __init__(self, factory: async_sessionmaker) -> None: self._factory = factory @@ -247,6 +278,7 @@ async def _fixture(harness: RLSHarness) -> _Fixture: fixture.service = MessagingInstallService( session_factory=harness.restricted, store=MessagingInstallStore(), + receipts=MessagingEventReceiptStore(), installers=installers, lifecycle=fixture.lifecycle, # type: ignore[arg-type] public_origin=_ORIGIN, @@ -271,6 +303,30 @@ def _event(workspace_id: str, text: str) -> bytes: ).encode() +def _numbered_event(workspace_id: str, text: str, event_id: str) -> bytes: + """An event carrying the id Slack puts on everything it retries. + + Separate from `_event` rather than an argument to it, so the tests above go + on exercising the unnumbered path — which is the one a slash command and an + interaction take, and which must dispatch rather than being refused for + having nothing to deduplicate by. + """ + return json.dumps( + { + "type": "event_callback", + "team_id": workspace_id, + "event_id": event_id, + "event": {"type": "message", "text": text, "channel": "C1"}, + } + ).encode() + + +async def _receipts(factory: async_sessionmaker, tenant_id: str) -> list[Any]: + async with tenant_session(factory, tenant_id) as session: + rows = await session.execute(select(MessagingEventReceipt)) + return list(rows.scalars()) + + def _uninstalled(workspace_id: str) -> bytes: return json.dumps( { @@ -567,6 +623,197 @@ async def test_the_refusal_is_not_swallowed(self, rls_harness: RLSHarness) -> No await fixture.service.deliver(target, event) +class TestAnEventIsHandledOnce: + """The platform delivers at least once; the customer must hear once. + + Slack allows three seconds to acknowledge and re-sends what it does not get + an answer to, so a deployment under load is told the same thing twice. The + payload of a retry is byte-identical to the original — the id is the only + thing that distinguishes it from someone saying the same words again — and + the visible failure is an agent answering one question twice in a + customer's channel. + """ + + async def test_a_retry_of_the_same_event_dispatches_once( + self, rls_harness: RLSHarness + ) -> None: + fixture = await _fixture(rls_harness) + body = _numbered_event(fixture.a.workspace_id, "hello", "Ev1") + + first = await _post(fixture, events_path("slack"), body) + second = await _post(fixture, events_path("slack"), body) + + assert (first.status_code, second.status_code) == (200, 200) + assert _texts(fixture.a.adapter) == ["hello"] + + async def test_a_retry_arriving_mid_turn_loses_to_the_delivery_in_flight( + self, rls_harness: RLSHarness + ) -> None: + """The case the ordering exists for, and the only one that is a race. + + Recording the receipt after the work instead of before would order the + two the wrong way round: both would find nothing claimed, both would + dispatch, and the duplicate would be noticed once it no longer + mattered. + """ + fixture = await _fixture(rls_harness) + gated = _GatedAdapter() + fixture.lifecycle.adapters[fixture.a.bridge_id] = gated + body = _numbered_event(fixture.a.workspace_id, "hello", "Ev1") + event = fixture.service.authenticate( + platform="slack", endpoint="events", headers=_signed(body), body=body + ) + target = await fixture.service.resolve(platform="slack", event=event) + + in_flight = asyncio.create_task(fixture.service.deliver(target, event)) + await gated.entered.wait() + await fixture.service.deliver(target, event) + + assert len(gated.dispatched) == 1 + gated.release.set() + await in_flight + + async def test_an_event_with_no_id_is_dispatched_every_time( + self, rls_harness: RLSHarness + ) -> None: + """Not a weaker guarantee quietly accepted. + + Slack numbers only the envelopes it retries, so one arriving without an + id arrives exactly once. Two of them are two real messages, and + refusing the second for having nothing to deduplicate by would drop a + customer's message on the floor. + """ + fixture = await _fixture(rls_harness) + body = _event(fixture.a.workspace_id, "same words") + + await _post(fixture, events_path("slack"), body) + await _post(fixture, events_path("slack"), body) + + assert _texts(fixture.a.adapter) == ["same words", "same words"] + assert await _receipts(rls_harness.restricted, fixture.a.tenant_id) == [] + + async def test_one_tenants_event_ids_do_not_block_anothers( + self, rls_harness: RLSHarness + ) -> None: + """Uniqueness is per tenant, and this is why that is the shape to want. + + Slack's ids are unique in its own namespace, so a deployment-wide index + would protect exactly as well — but it would let one customer's traffic + refuse another's, and a conflict would name a row the inserting tenant + cannot see. + """ + fixture = await _fixture(rls_harness) + + await _post( + fixture, + events_path("slack"), + _numbered_event(fixture.a.workspace_id, "a", "Ev-shared"), + ) + await _post( + fixture, + events_path("slack"), + _numbered_event(fixture.b.workspace_id, "b", "Ev-shared"), + ) + + assert _texts(fixture.a.adapter) == ["a"] + assert _texts(fixture.b.adapter) == ["b"] + + async def test_a_completed_delivery_is_recorded_as_handled( + self, rls_harness: RLSHarness + ) -> None: + """The absence of this is the useful half. + + Claiming before the work chooses at-most-once: an event taken by a + process that then dies is not retried, because the platform has already + been told 200. `handled_at` is what keeps that loss findable — a + claimed receipt that never completed is a real event that reached + nobody. + """ + fixture = await _fixture(rls_harness) + + await _post( + fixture, + events_path("slack"), + _numbered_event(fixture.a.workspace_id, "hello", "Ev1"), + ) + + rows = await _receipts(rls_harness.restricted, fixture.a.tenant_id) + assert [(row.platform, row.external_event_id) for row in rows] == [ + ("slack", "Ev1") + ] + assert rows[0].handled_at is not None + + async def test_a_receipt_past_its_retention_is_pruned_by_the_next_event( + self, rls_harness: RLSHarness + ) -> None: + """Opportunistic, because this backend has no janitor to hang it on. + + The table would otherwise grow with every message the busiest workspace + ever sends. Running the sweep off the traffic that creates the rows + keeps the work proportional to that traffic and needs nothing started + at boot. + """ + fixture = await _fixture(rls_harness) + async with tenant_session( + rls_harness.restricted, fixture.a.tenant_id + ) as session: + session.add( + MessagingEventReceipt( + tenant_id=fixture.a.tenant_id, + platform="slack", + external_event_id="Ev-ancient", + received_at=datetime.now(UTC) + - RECEIPT_RETENTION + - timedelta(days=1), + ) + ) + await session.commit() + + await _post( + fixture, + events_path("slack"), + _numbered_event(fixture.a.workspace_id, "hello", "Ev-fresh"), + ) + + rows = await _receipts(rls_harness.restricted, fixture.a.tenant_id) + assert [row.external_event_id for row in rows] == ["Ev-fresh"] + + async def test_a_retry_of_an_event_nobody_holds_is_still_dropped_at_resolve( + self, rls_harness: RLSHarness + ) -> None: + """Deduplication sits after resolution, so an unknown workspace writes + no receipt at all — there is no tenant to write it as.""" + fixture = await _fixture(rls_harness) + + response = await _post( + fixture, events_path("slack"), _numbered_event("T-nobody", "x", "Ev1") + ) + + assert response.status_code == 200 + assert await _receipts(rls_harness.restricted, fixture.a.tenant_id) == [] + + async def test_a_bridge_that_was_down_still_takes_the_retry( + self, rls_harness: RLSHarness + ) -> None: + """The 503 and the receipt compose, and this is the case that proves it. + + A bridge mid-restart makes the event fail before any claim is written, + so the retry Slack sends in response finds nothing taken and is handled + normally. Claiming any earlier — before `resolve` — would turn a + transient outage into a permanently lost message. + """ + fixture = await _fixture(rls_harness) + body = _numbered_event(fixture.a.workspace_id, "hello", "Ev1") + del fixture.lifecycle.adapters[fixture.a.bridge_id] + + refused = await _post(fixture, events_path("slack"), body) + fixture.lifecycle.adapters[fixture.a.bridge_id] = fixture.a.adapter + retried = await _post(fixture, events_path("slack"), body) + + assert (refused.status_code, retried.status_code) == (503, 200) + assert _texts(fixture.a.adapter) == ["hello"] + + class TestThePlatformSayingTheInstallIsOver: """`app_uninstalled` arrives on the same URL as everything else.""" diff --git a/core/tests/switch_core/bridges/collaboration/test_slack_installer.py b/core/tests/switch_core/bridges/collaboration/test_slack_installer.py index 5c7cb4c47..ea8695a44 100644 --- a/core/tests/switch_core/bridges/collaboration/test_slack_installer.py +++ b/core/tests/switch_core/bridges/collaboration/test_slack_installer.py @@ -21,6 +21,7 @@ MessagingInstallerRegistry, MessagingInstallError, WebhookAuthenticityError, + WebhookEndpoint, WebhookPayloadError, events_path, oauth_callback_path, @@ -234,7 +235,7 @@ def test_an_event_callback_becomes_the_socket_mode_envelope( } ).encode() - parsed = installer.parse_webhook(endpoint="events", body=body) + parsed = installer.parse_webhook(endpoint="events", headers={}, body=body) assert parsed.envelope_type == "events_api" assert parsed.handshake is None @@ -254,7 +255,7 @@ def test_a_url_verification_is_answered_and_not_dispatched( """ body = json.dumps({"type": "url_verification", "challenge": "abc123"}).encode() - parsed = installer.parse_webhook(endpoint="events", body=body) + parsed = installer.parse_webhook(endpoint="events", headers={}, body=body) assert parsed.handshake == "abc123" @@ -263,7 +264,7 @@ def test_a_url_verification_with_nothing_to_echo_is_refused( ) -> None: with pytest.raises(WebhookPayloadError, match="challenge"): installer.parse_webhook( - endpoint="events", body=b'{"type":"url_verification"}' + endpoint="events", headers={}, body=b'{"type":"url_verification"}' ) def test_a_slash_command_is_its_form_fields( @@ -273,7 +274,7 @@ def test_a_slash_command_is_its_form_fields( {"command": "/agents-status", "text": "", "team_id": "T1", "user_id": "U1"} ).encode() - parsed = installer.parse_webhook(endpoint="commands", body=body) + parsed = installer.parse_webhook(endpoint="commands", headers={}, body=body) assert parsed.envelope_type == "slash_commands" assert parsed.payload["command"] == "/agents-status" @@ -287,7 +288,7 @@ def test_an_interaction_is_unwrapped_from_its_payload_field( inner = {"type": "block_actions", "team": {"id": "T1"}} body = urlencode({"payload": json.dumps(inner)}).encode() - parsed = installer.parse_webhook(endpoint="interactive", body=body) + parsed = installer.parse_webhook(endpoint="interactive", headers={}, body=body) assert parsed.envelope_type == "interactive" assert parsed.payload == inner @@ -296,7 +297,106 @@ def test_an_interaction_with_no_payload_field_is_refused( self, installer: SlackAppInstaller ) -> None: with pytest.raises(WebhookPayloadError, match="payload"): - installer.parse_webhook(endpoint="interactive", body=b"other=1") + installer.parse_webhook(endpoint="interactive", headers={}, body=b"other=1") + + def test_an_event_callback_carries_slacks_own_event_id( + self, installer: SlackAppInstaller + ) -> None: + """The id is the only thing two copies of one event have in common. + + The payload of a retry is byte-identical to the original, so without + this nothing downstream could tell a re-send from somebody saying the + same words twice. + """ + body = json.dumps( + {"type": "event_callback", "team_id": "T1", "event_id": "Ev123"} + ).encode() + + parsed = installer.parse_webhook(endpoint="events", headers={}, body=body) + + assert parsed.external_event_id == "Ev123" + + @pytest.mark.parametrize( + "envelope", + [ + pytest.param({"type": "event_callback", "team_id": "T1"}, id="absent"), + pytest.param( + {"type": "event_callback", "team_id": "T1", "event_id": ""}, + id="empty", + ), + pytest.param( + {"type": "event_callback", "team_id": "T1", "event_id": 7}, + id="not-a-string", + ), + ], + ) + def test_an_event_callback_with_no_usable_id_deduplicates_by_nothing( + self, installer: SlackAppInstaller, envelope: dict[str, object] + ) -> None: + """No id means dispatch it, never refuse it. + + An empty or missing `event_id` is not a reason to drop a real message + from a customer's channel — it only means there is nothing to key a + receipt on, so the event is handled the way an unnumbered one is. + """ + parsed = installer.parse_webhook( + endpoint="events", headers={}, body=json.dumps(envelope).encode() + ) + + assert parsed.external_event_id is None + + @pytest.mark.parametrize( + ("endpoint", "body"), + [ + pytest.param( + "commands", urlencode({"team_id": "T1"}).encode(), id="command" + ), + pytest.param( + "interactive", + urlencode({"payload": json.dumps({"team": {"id": "T1"}})}).encode(), + id="interaction", + ), + ], + ) + def test_the_form_endpoints_carry_no_id_because_slack_sends_them_once( + self, installer: SlackAppInstaller, endpoint: WebhookEndpoint, body: bytes + ) -> None: + """Slack retries neither, so there is nothing to deduplicate. + + A command and an interaction are a person waiting on a dialog. Slack + sends each exactly once and puts no id on it. + """ + parsed = installer.parse_webhook(endpoint=endpoint, headers={}, body=body) + + assert parsed.external_event_id is None + + @pytest.mark.parametrize( + ("headers", "expected"), + [ + pytest.param({}, 0, id="first-delivery"), + pytest.param({"x-slack-retry-num": "2"}, 2, id="second-retry"), + pytest.param({"x-slack-retry-num": "nonsense"}, 0, id="unparseable"), + ], + ) + def test_the_retry_count_is_read_as_a_hint_and_never_as_a_decision( + self, + installer: SlackAppInstaller, + headers: dict[str, str], + expected: int, + ) -> None: + """Unsigned input, so a bad value must not be able to refuse an event. + + The retry header is outside the signature — which covers the body and + the timestamp — so a garbled or forged one degrades to zero rather than + raising. The worst a forgery achieves is a wrong number in a log line. + """ + body = json.dumps( + {"type": "event_callback", "team_id": "T1", "event_id": "Ev1"} + ).encode() + + parsed = installer.parse_webhook(endpoint="events", headers=headers, body=body) + + assert parsed.delivery_attempt == expected @pytest.mark.parametrize( "body", @@ -316,7 +416,7 @@ def test_a_body_that_cannot_be_read_is_a_payload_error( rather than a shrug. """ with pytest.raises(WebhookPayloadError): - installer.parse_webhook(endpoint="events", body=body) + installer.parse_webhook(endpoint="events", headers={}, body=body) class TestWhichWorkspaceSentIt: From 89d262747b5557d12018822e3b91efa9f80a5779 Mon Sep 17 00:00:00 2001 From: Petr Bauch Date: Tue, 15 Sep 2026 11:01:38 -0400 Subject: [PATCH 14/29] fix(gateway): refuse to delete a bridge an install built (CHOO-2626) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `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 --- .../db/stores/messaging_install_store.py | 21 ++ core/switch_core/gateway/collaborations.py | 22 ++ core/switch_core/gateway/dependencies.py | 16 ++ .../gateway/test_bridge_delete_guard.py | 252 ++++++++++++++++++ 4 files changed, 311 insertions(+) create mode 100644 core/tests/switch_core/gateway/test_bridge_delete_guard.py diff --git a/core/switch_core/db/stores/messaging_install_store.py b/core/switch_core/db/stores/messaging_install_store.py index 714e9c0c2..5e6ae18e8 100644 --- a/core/switch_core/db/stores/messaging_install_store.py +++ b/core/switch_core/db/stores/messaging_install_store.py @@ -181,6 +181,27 @@ async def get_for_workspace( ) return result.scalars().one_or_none() + async def get_for_bridge( + self, session: AsyncSession, *, bridge_id: str + ) -> MessagingInstall | None: + """The live install a bridge was built for, if it was built for one. + + Asked from the other direction than the rest of this store, and by + something that does not otherwise know installs exist: the bridge + delete endpoint, which has to refuse rather than tear down a bridge + whose credential is a token nobody here has revoked. + + Live installs only. An ended one has already released its pointer, so a + row matching here is always a bridge that is still somebody's install. + """ + result = await session.execute( + select(MessagingInstall).where( + MessagingInstall.bridge_id == bridge_id, + MessagingInstall.status == INSTALL_ACTIVE, + ) + ) + return result.scalars().one_or_none() + async def get(self, session: AsyncSession, *, install_id: str) -> MessagingInstall: """One of the bound tenant's installs, by id, or raise.""" install = await session.get(MessagingInstall, install_id) diff --git a/core/switch_core/gateway/collaborations.py b/core/switch_core/gateway/collaborations.py index 902a5c974..3490b6216 100644 --- a/core/switch_core/gateway/collaborations.py +++ b/core/switch_core/gateway/collaborations.py @@ -19,6 +19,7 @@ from switch_core.db.models import CollaborationBridge, ExternalUser, User from switch_core.db.stores.collaboration_bridge_store import CollaborationBridgeStore from switch_core.db.stores.external_user_store import ExternalUserStore +from switch_core.db.stores.messaging_install_store import MessagingInstallStore from switch_core.db.stores.room_store import RoomStore from switch_core.db.stores.user_store import UserStore from switch_core.gateway.auth import ( @@ -30,6 +31,7 @@ get_bridge_store, get_collab_lifecycle, get_external_user_store, + get_install_store, get_room_service, get_room_store, get_session, @@ -677,6 +679,7 @@ async def delete_bridge( bridge_store: Annotated[CollaborationBridgeStore, Depends(get_bridge_store)], room_store: Annotated[RoomStore, Depends(get_room_store)], room_service: Annotated[RoomService, Depends(get_room_service)], + install_store: Annotated[MessagingInstallStore, Depends(get_install_store)], collab_lifecycle: Annotated[ CollaborationBridgeLifecycleService, Depends(get_collab_lifecycle) ], @@ -688,6 +691,25 @@ async def delete_bridge( if bridge is None: raise HTTPException(status_code=404, detail="Bridge not found") + # Before a single room is deleted, because the rooms do not come back. A + # bridge built by an install holds a token this deployment did not issue + # and the platform still honours, and the install row's pointer at it is a + # real foreign key — so this delete would destroy every room on the bridge + # and *then* be refused by Postgres, leaving the bridge running, the rooms + # gone and a live credential nobody has revoked. + install = await install_store.get_for_bridge(session, bridge_id=bridge_id) + if install is not None: + raise HTTPException( + status_code=409, + detail=( + f"This connection was created by installing the Switch app into " + f"{install.platform} workspace {install.external_workspace_id}, so " + "it cannot be deleted here — the app would stay installed and its " + "token would stay valid. Disconnect the app instead, which revokes " + "the token at the platform and then removes this connection." + ), + ) + rooms = await room_store.get_by_bridge(session, bridge_id) for room in rooms: await room_service.delete_room(room.id) diff --git a/core/switch_core/gateway/dependencies.py b/core/switch_core/gateway/dependencies.py index 8f85508d1..5358de849 100644 --- a/core/switch_core/gateway/dependencies.py +++ b/core/switch_core/gateway/dependencies.py @@ -24,6 +24,7 @@ from switch_core.db.stores.collaboration_bridge_store import CollaborationBridgeStore from switch_core.db.stores.external_user_store import ExternalUserStore from switch_core.db.stores.invitation_store import InvitationStore +from switch_core.db.stores.messaging_install_store import MessagingInstallStore from switch_core.db.stores.room_group_store import RoomGroupStore from switch_core.db.stores.room_store import RoomStore from switch_core.db.stores.server_connector_store import ServerConnectorStore @@ -220,6 +221,21 @@ def get_protocol() -> ProtocolService: return _state["protocol"] # type: ignore[no-any-return] +def get_install_store() -> MessagingInstallStore: + """Built here rather than threaded through `init_dependencies`. + + It is stateless — no connection, no configuration, nothing for a shared + instance to own — and `get_room_yaml_service` above already constructs + rather than reads. + + Deliberately not reached through `get_install_service`, which is `None` on + a deployment that registered no app of its own. Install rows outlive those + credentials: a bridge built by an install has to stay protected after the + credentials are taken away, which is exactly when the service is gone. + """ + return MessagingInstallStore() + + def get_install_service() -> MessagingInstallService | None: """None when this deployment registered no messaging app of its own. diff --git a/core/tests/switch_core/gateway/test_bridge_delete_guard.py b/core/tests/switch_core/gateway/test_bridge_delete_guard.py new file mode 100644 index 000000000..c0c39ea18 --- /dev/null +++ b/core/tests/switch_core/gateway/test_bridge_delete_guard.py @@ -0,0 +1,252 @@ +"""Deleting a bridge an install built must be refused, not half-done. + +`messaging_installs.bridge_id` is a real foreign key with no `ON DELETE`, and +the delete endpoint tears down every room on the bridge *before* it removes the +bridge itself. Without a guard the ordering plays out as: rooms irreversibly +deleted, Postgres then refuses the bridge deletion, operator gets a 500 — and +the app is still installed with a token nobody revoked. +""" + +from __future__ import annotations + +import uuid + +import pytest +from fastapi import HTTPException +from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker + +from switch_core.db.models import ( + Client, + CollaborationBridge, + MessagingInstall, + Room, + User, +) +from switch_core.db.stores.collaboration_bridge_store import CollaborationBridgeStore +from switch_core.db.stores.messaging_install_store import ( + INSTALL_ACTIVE, + INSTALL_DISCONNECTED, + MessagingInstallStore, +) +from switch_core.db.stores.room_store import RoomStore +from switch_core.gateway.collaborations import delete_bridge + +_BRIDGE_STORE = CollaborationBridgeStore() +_ROOM_STORE = RoomStore() +_INSTALL_STORE = MessagingInstallStore() + + +class _RecordingRoomService: + """Remembers the rooms it was told to delete and deletes nothing. + + A spy rather than the real service because the property under test is that + it is never called at all: an assertion on the list is the assertion that + the refusal came first. + """ + + def __init__(self) -> None: + self.deleted: list[str] = [] + + async def delete_room(self, room_id: str) -> None: + self.deleted.append(room_id) + + +class _RecordingLifecycle: + def __init__(self) -> None: + self.removed: list[str] = [] + + async def remove(self, bridge_id: str) -> None: + self.removed.append(bridge_id) + + +async def _make_admin(session: AsyncSession) -> User: + user = User( + id=f"admin-{uuid.uuid4().hex[:8]}", + name=f"admin-{uuid.uuid4().hex[:8]}", + email=f"admin-{uuid.uuid4().hex[:8]}@example.test", + role="admin", + ) + session.add(user) + await session.flush() + return user + + +async def _make_bridge(session: AsyncSession) -> str: + client = Client( + matrix_user_id=f"@bridge-{uuid.uuid4().hex[:12]}:test", + display_name="bridge client", + type="bridge", + ) + session.add(client) + await session.flush() + bridge = CollaborationBridge( + type="slack", + display_name="Slack", + client_id=client.id, + status="active", + ) + session.add(bridge) + await session.flush() + return bridge.id + + +async def _make_room(session: AsyncSession, *, bridge_id: str) -> str: + room = Room( + matrix_room_id=f"!{uuid.uuid4().hex[:8]}:test", + name="bridged room", + description="mirror of an external channel", + bridge_id=bridge_id, + channel_type="channel_public", + external_channel_id="C123", + ) + session.add(room) + await session.flush() + return room.id + + +async def _make_install( + session: AsyncSession, *, bridge_id: str | None, status: str, user_id: str +) -> str: + install = MessagingInstall( + platform="slack", + external_workspace_id=f"T{uuid.uuid4().hex[:8]}", + encrypted_bot_token="encrypted-placeholder", + scopes="app_mentions:read,chat:write", + status=status, + installed_by_user_id=user_id, + bridge_id=bridge_id, + ) + session.add(install) + await session.flush() + return install.id + + +async def test_a_bridge_an_install_built_cannot_be_deleted_here( + session_factory: async_sessionmaker[AsyncSession], +) -> None: + async with session_factory() as session: + admin = await _make_admin(session) + bridge_id = await _make_bridge(session) + room_id = await _make_room(session, bridge_id=bridge_id) + await _make_install( + session, bridge_id=bridge_id, status=INSTALL_ACTIVE, user_id=admin.id + ) + await session.commit() + + room_service = _RecordingRoomService() + lifecycle = _RecordingLifecycle() + async with session_factory() as session: + admin = await _make_admin(session) + with pytest.raises(HTTPException) as excinfo: + await delete_bridge( + bridge_id, + session, + _BRIDGE_STORE, + _ROOM_STORE, + room_service, # type: ignore[arg-type] + _INSTALL_STORE, + lifecycle, # type: ignore[arg-type] + admin, + ) + + assert excinfo.value.status_code == 409 + # The whole point of the ordering: nothing was destroyed on the way to the + # refusal, so retrying after disconnecting is a clean retry. + assert room_service.deleted == [] + assert lifecycle.removed == [] + + async with session_factory() as session: + assert await _BRIDGE_STORE.get(session, bridge_id) is not None + assert await _ROOM_STORE.get(session, room_id) is not None + + +async def test_the_refusal_names_the_workspace_to_disconnect( + session_factory: async_sessionmaker[AsyncSession], +) -> None: + """An operator who cannot delete here has to be told where to go instead.""" + async with session_factory() as session: + admin = await _make_admin(session) + bridge_id = await _make_bridge(session) + install_id = await _make_install( + session, bridge_id=bridge_id, status=INSTALL_ACTIVE, user_id=admin.id + ) + await session.commit() + + async with session_factory() as session: + install = await _INSTALL_STORE.get(session, install_id=install_id) + workspace = install.external_workspace_id + with pytest.raises(HTTPException) as excinfo: + await delete_bridge( + bridge_id, + session, + _BRIDGE_STORE, + _ROOM_STORE, + _RecordingRoomService(), # type: ignore[arg-type] + _INSTALL_STORE, + _RecordingLifecycle(), # type: ignore[arg-type] + await _make_admin(session), + ) + + detail = str(excinfo.value.detail) + assert workspace in detail + assert "slack" in detail + assert "Disconnect" in detail + + +async def test_a_bridge_registered_by_hand_still_deletes( + session_factory: async_sessionmaker[AsyncSession], +) -> None: + async with session_factory() as session: + bridge_id = await _make_bridge(session) + room_id = await _make_room(session, bridge_id=bridge_id) + await session.commit() + + room_service = _RecordingRoomService() + lifecycle = _RecordingLifecycle() + async with session_factory() as session: + result = await delete_bridge( + bridge_id, + session, + _BRIDGE_STORE, + _ROOM_STORE, + room_service, # type: ignore[arg-type] + _INSTALL_STORE, + lifecycle, # type: ignore[arg-type] + await _make_admin(session), + ) + + assert result == {"ok": True} + assert room_service.deleted == [room_id] + assert lifecycle.removed == [bridge_id] + + +async def test_a_bridge_whose_install_already_ended_still_deletes( + session_factory: async_sessionmaker[AsyncSession], +) -> None: + """Disconnecting releases the pointer, so an ended install guards nothing. + + The row it leaves behind is a record, not a claim — and a bridge that + outlived its install is an ordinary bridge again. + """ + async with session_factory() as session: + admin = await _make_admin(session) + bridge_id = await _make_bridge(session) + await _make_install( + session, bridge_id=None, status=INSTALL_DISCONNECTED, user_id=admin.id + ) + await session.commit() + + lifecycle = _RecordingLifecycle() + async with session_factory() as session: + await delete_bridge( + bridge_id, + session, + _BRIDGE_STORE, + _ROOM_STORE, + _RecordingRoomService(), # type: ignore[arg-type] + _INSTALL_STORE, + lifecycle, # type: ignore[arg-type] + await _make_admin(session), + ) + + assert lifecycle.removed == [bridge_id] From 5cdbe406c92c4dce5c1352084bd2f0aa269ce3ea Mon Sep 17 00:00:00 2001 From: Petr Bauch Date: Tue, 15 Sep 2026 11:05:29 -0400 Subject: [PATCH 15/29] feat(gateway): install and disconnect the Switch app from the dashboard (CHOO-2626) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- gateway/src/data/api.ts | 67 ++++- gateway/src/data/hooks.ts | 14 ++ .../collaborations/CollaborationsPage.tsx | 46 +++- .../collaborations/DisconnectAppDialog.tsx | 96 +++++++ .../collaborations/InstalledAppsSection.tsx | 235 ++++++++++++++++++ 5 files changed, 447 insertions(+), 11 deletions(-) create mode 100644 gateway/src/pages/collaborations/DisconnectAppDialog.tsx create mode 100644 gateway/src/pages/collaborations/InstalledAppsSection.tsx diff --git a/gateway/src/data/api.ts b/gateway/src/data/api.ts index 0bae2bc28..065666d9d 100644 --- a/gateway/src/data/api.ts +++ b/gateway/src/data/api.ts @@ -816,11 +816,12 @@ export async function fetchAllExternalUsers(): Promise< return [...byId.values()]; } -export async function deleteBridge(bridgeId: string): Promise { - const res = await fetchJson<{ ok: boolean }>(`/collaborations/${bridgeId}`, { - method: "DELETE", - }); - return res?.ok ?? false; +// Throws rather than returning false: a connection created by installing the +// Switch app is refused here with a 409 saying to disconnect the app instead, +// and a caller that only sees "it didn't work" would leave the operator +// clicking Delete at a row that will never go. +export async function deleteBridge(bridgeId: string): Promise<{ ok: boolean }> { + return jsonRequest<{ ok: boolean }>(`/collaborations/${bridgeId}`, "DELETE"); } export interface BridgeUpdateInput { @@ -839,6 +840,62 @@ export async function updateBridge( }); } +// ── Installed apps ─────────────────────────────────────────────────────────── +// +// The other way a connection comes into being: instead of an operator +// registering their own app and pasting its credentials, they install this +// deployment's app into their workspace and the platform hands the credential +// back. Most deployments have no app of their own, so `fetchInstallablePlatforms` +// answering with an empty list is the ordinary case and not a failure. + +export interface InstalledApp { + id: string; + platform: string; + external_workspace_id: string; + // "active", "disconnected" (ended here) or "revoked" (ended at the + // platform). The last two are kept apart because an operator whose + // connection stopped working needs to know which of the two it was. + status: string; + // The platform's own spelling of what was granted, shown verbatim. + scopes: string; + bridge_id: string | null; + installed_at: string; + ended_at: string | null; +} + +export async function fetchInstallablePlatforms(): Promise { + const res = await fetchJson<{ platforms: string[] }>("/messaging-apps"); + return res === null ? null : res.platforms; +} + +export async function fetchInstalledApps(): Promise { + const res = await fetchJson<{ installs: InstalledApp[] }>( + "/messaging-apps/installs", + ); + return res === null ? null : res.installs; +} + +// Answers with a URL rather than redirecting, because the install has to begin +// in a top-level window on the platform's own domain — a redirect returned to +// this fetch would be followed by the fetch. +export async function beginAppInstall(platform: string): Promise { + const res = await jsonRequest<{ authorize_url: string }>( + `/messaging-apps/${platform}/install`, + "POST", + ); + return res.authorize_url; +} + +// Throwing, because 502 here means the platform refused to revoke, nothing was +// destroyed, and trying again is the right next move — all of which is in the +// message and none of which survives a boolean. +export async function disconnectApp(installId: string): Promise { + return jsonRequest( + `/messaging-apps/installs/${installId}`, + "DELETE", + ); +} + // ── Auth ──────────────────────────────────────────────────────────────────── export interface UserInfo { diff --git a/gateway/src/data/hooks.ts b/gateway/src/data/hooks.ts index c89af9d67..2f24101a8 100644 --- a/gateway/src/data/hooks.ts +++ b/gateway/src/data/hooks.ts @@ -10,6 +10,7 @@ import { type DocumentDetail, type DocumentSummary, type InboundLinkedRoomDetail, + type InstalledApp, type KnownAgentType, type LinkedRoomDetail, type RoomGraphData, @@ -35,6 +36,8 @@ import { fetchDocumentRooms, fetchDocuments, fetchInboundLinkedRooms, + fetchInstallablePlatforms, + fetchInstalledApps, fetchKnownAgentTypes, fetchLinkedRooms, fetchRoomGraph, @@ -155,6 +158,17 @@ export function useAllExternalUsers(): UseQueryResult { return useQuery(fetchAllExternalUsers); } +// Empty on every deployment that registered no app of its own, which is most +// of them — the page reads that as "there is nothing to install here", not as +// a failure. +export function useInstallablePlatforms(): UseQueryResult { + return useQuery(fetchInstallablePlatforms); +} + +export function useInstalledApps(): UseQueryResult { + return useQuery(fetchInstalledApps); +} + export function useKnownAgentTypes(): UseQueryResult { return useQuery(fetchKnownAgentTypes); } diff --git a/gateway/src/pages/collaborations/CollaborationsPage.tsx b/gateway/src/pages/collaborations/CollaborationsPage.tsx index 2ae99d390..fb2ddb86f 100644 --- a/gateway/src/pages/collaborations/CollaborationsPage.tsx +++ b/gateway/src/pages/collaborations/CollaborationsPage.tsx @@ -2,6 +2,7 @@ import AddLinkOutlined from "@mui/icons-material/AddLinkOutlined"; import AddOutlined from "@mui/icons-material/AddOutlined"; import DeleteOutline from "@mui/icons-material/DeleteOutline"; import { + Alert, Box, Button, Chip, @@ -25,6 +26,7 @@ import { useAuth } from "../../data/AuthContext"; import { useBridges } from "../../data/hooks"; import { formatDate, titleCase } from "../../theme/hootFormat"; import AddToChatDialog from "./AddToChatDialog"; +import InstalledAppsSection from "./InstalledAppsSection"; import RegisterMessagingAppDialog from "./RegisterMessagingAppDialog"; type BridgeRow = BridgeDetail & { id: string }; @@ -41,6 +43,7 @@ export default function CollaborationsPage() { const { data: bridges, loading, refetch } = useBridges(); const [deleteTarget, setDeleteTarget] = useState(null); const [deleting, setDeleting] = useState(false); + const [deleteError, setDeleteError] = useState(null); const [savingId, setSavingId] = useState(null); const [registerOpen, setRegisterOpen] = useState(false); const [installTarget, setInstallTarget] = useState(null); @@ -48,10 +51,19 @@ export default function CollaborationsPage() { const handleDelete = useCallback(async () => { if (!deleteTarget) return; setDeleting(true); - await deleteBridge(deleteTarget.bridge_id); - setDeleteTarget(null); - setDeleting(false); - refetch(); + setDeleteError(null); + try { + await deleteBridge(deleteTarget.bridge_id); + setDeleteTarget(null); + refetch(); + } catch (e) { + // A connection an install created is refused here, and the refusal says + // to disconnect the app instead. Closing the dialog on it would leave + // the operator clicking Delete at a row that will never go. + setDeleteError(e instanceof Error ? e.message : "Failed to delete"); + } finally { + setDeleting(false); + } }, [deleteTarget, refetch]); const handleToggleGreetings = useCallback( @@ -221,6 +233,8 @@ export default function CollaborationsPage() { )} + + setRegisterOpen(false)} @@ -232,7 +246,14 @@ export default function CollaborationsPage() { onClose={() => setInstallTarget(null)} /> - setDeleteTarget(null)}> + { + if (deleting) return; + setDeleteError(null); + setDeleteTarget(null); + }} + > Delete collaboration bridge @@ -241,9 +262,22 @@ export default function CollaborationsPage() { {deleteTarget?.room_count ?? 0} associated room {deleteTarget?.room_count === 1 ? "" : "s"} and external users. + {deleteError && ( + + {deleteError} + + )} - + + + + + ); +} diff --git a/gateway/src/pages/collaborations/InstalledAppsSection.tsx b/gateway/src/pages/collaborations/InstalledAppsSection.tsx new file mode 100644 index 000000000..61cd9ef5d --- /dev/null +++ b/gateway/src/pages/collaborations/InstalledAppsSection.tsx @@ -0,0 +1,235 @@ +import LinkOffOutlined from "@mui/icons-material/LinkOffOutlined"; +import { + Alert, + Box, + Button, + Chip, + CircularProgress, + IconButton, + Stack, + Tooltip, + Typography, +} from "@mui/material"; +import type { GridColDef } from "@mui/x-data-grid"; +import { useCallback, useMemo, useState } from "react"; +import DataTable from "../../components/DataTable"; +import { type InstalledApp, beginAppInstall } from "../../data/api"; +import { useInstallablePlatforms, useInstalledApps } from "../../data/hooks"; +import { EM_DASH, MONO_SX, formatDate, titleCase } from "../../theme/hootFormat"; +import DisconnectAppDialog from "./DisconnectAppDialog"; + +/** + * The other way a connection comes into being. Registering an app means + * creating one on the platform, choosing its scopes and pasting its token in + * here; installing means clicking a button and approving a consent screen, + * with this deployment's own app supplying the credentials. + * + * Both are shown because both exist, and an operator has to be able to tell + * which of their connections is which — a registered app's token is theirs to + * rotate and an installed one's is not, and only the second has to be removed + * through Disconnect. + * + * Renders nothing at all on a deployment that has no app of its own and has + * never had one, which is most of them: an empty section explaining a feature + * nobody here can use is worse than no section. + */ +type InstallRow = InstalledApp & { id: string }; + +const STATUS_COLOR: Record = { + active: "success", + // Ended at the platform rather than here — somebody removed the app, or its + // token was killed. Warned rather than greyed out, because unlike + // "disconnected" it is news. + revoked: "warning", + disconnected: "default", +}; + +interface Props { + isAdmin: boolean; + // Disconnecting removes the connection the install created, so the list of + // connections above this section is stale once it succeeds. + onConnectionsChanged: () => void; +} + +export default function InstalledAppsSection({ + isAdmin, + onConnectionsChanged, +}: Props) { + const { data: platforms } = useInstallablePlatforms(); + const { data: installs, loading, refetch } = useInstalledApps(); + const [starting, setStarting] = useState(null); + const [error, setError] = useState(null); + const [disconnectTarget, setDisconnectTarget] = useState( + null, + ); + + const handleInstall = useCallback(async (platform: string) => { + setStarting(platform); + setError(null); + try { + // A top-level navigation, not a popup and not an iframe: the platform's + // consent screen sets cookies on its own domain and refuses to be + // framed, and a popup opened after an await is what a browser blocks. + // `starting` is deliberately left set — the page is on its way out. + window.location.href = await beginAppInstall(platform); + } catch (e) { + setError(e instanceof Error ? e.message : "Failed to start the install"); + setStarting(null); + } + }, []); + + const handleDisconnected = useCallback(() => { + setDisconnectTarget(null); + refetch(); + onConnectionsChanged(); + }, [refetch, onConnectionsChanged]); + + const columns = useMemo[]>( + () => [ + { + field: "platform", + headerName: "Platform", + width: 130, + renderCell: ({ value }) => ( + + ), + }, + { + field: "external_workspace_id", + headerName: "Workspace", + width: 170, + renderCell: ({ value }) => ( + + {String(value)} + + ), + }, + { + field: "status", + headerName: "Status", + width: 140, + renderCell: ({ value }) => ( + + ), + }, + { + field: "scopes", + headerName: "Scopes", + flex: 1, + minWidth: 180, + // Verbatim, and in full on hover: a scope string that means nothing + // here is still the answer to why the platform refused a call. + renderCell: ({ value }) => ( + + + {String(value)} + + + ), + }, + { + field: "installed_at", + headerName: "Installed", + width: 130, + valueFormatter: (value) => formatDate(value as string), + }, + { + field: "ended_at", + headerName: "Ended", + width: 130, + valueFormatter: (value) => + value ? formatDate(value as string) : EM_DASH, + }, + ...(isAdmin + ? [ + { + field: "actions" as const, + headerName: "", + width: 70, + sortable: false, + filterable: false, + align: "right" as const, + renderCell: ({ row }: { row: InstallRow }) => + row.status === "active" ? ( + + setDisconnectTarget(row)} + > + + + + ) : null, + }, + ] + : []), + ], + [isAdmin], + ); + + const rows = useMemo(() => installs ?? [], [installs]); + + const installable = platforms ?? []; + if (installable.length === 0 && rows.length === 0) return null; + + return ( + + + Installed apps + {isAdmin && ( + + {installable.map((platform) => ( + + ))} + + )} + + + + Installing adds this Switch deployment's own app to your workspace. + The workspace grants it a token that Switch holds and you cannot see or + rotate, and the connection it creates receives messages over HTTPS + rather than the socket a registered app opens. Removing it has to go + through Disconnect, which revokes that token at the platform first. + + + {error && ( + setError(null)}> + {error} + + )} + + {loading ? ( + + ) : rows.length === 0 ? ( + + No workspaces yet. Use the button above to install the app into one. + + ) : ( + + )} + + setDisconnectTarget(null)} + onDisconnected={handleDisconnected} + /> + + ); +} From aa616edbf6401e9cc5eaa496809950a2c4de26a7 Mon Sep 17 00:00:00 2001 From: lbangalosbt Date: Wed, 16 Sep 2026 08:44:55 -0400 Subject: [PATCH 16/29] refactor(discord): extract DiscordConnection (socket lifecycle) from the adapter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Separate the Gateway socket — the discord.Client, the command tree, readiness, slash-command sync, reconnection and the bot's own user id — from the adapter's per-guild message handling, into a new DiscordConnection the adapter composes. The socket is per bot token where the adapter is per guild, so the two have genuinely different lifetimes; this is the first step toward a single shared, multi-tenant connection. Behavior-preserving: the self-registered single-guild bridge connects, syncs guild-scoped commands and handles messages exactly as before. Intents are now built by the adapter and handed to the connection as a constructor argument — the seam a shared connection will use to request a different set. Tests that injected a fake client or bot id now do so on adapter._connection; no behavioral assertion changed. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../bridges/collaboration/discord/adapter.py | 160 ++++----------- .../collaboration/discord/connection.py | 193 ++++++++++++++++++ .../test_bridge_agent_display_names.py | 4 +- .../collaboration/test_discord_adapter.py | 65 +++--- .../collaboration/test_discord_agent_roles.py | 2 +- .../test_discord_provisioning.py | 2 +- .../test_discord_slash_commands.py | 4 +- .../test_discord_working_reaction.py | 4 +- 8 files changed, 271 insertions(+), 163 deletions(-) create mode 100644 core/switch_core/bridges/collaboration/discord/connection.py diff --git a/core/switch_core/bridges/collaboration/discord/adapter.py b/core/switch_core/bridges/collaboration/discord/adapter.py index f29ec2058..57e937224 100644 --- a/core/switch_core/bridges/collaboration/discord/adapter.py +++ b/core/switch_core/bridges/collaboration/discord/adapter.py @@ -1,17 +1,15 @@ from __future__ import annotations -import asyncio import io import logging import re import time from collections import OrderedDict -from collections.abc import Awaitable, Callable, Coroutine +from collections.abc import Awaitable, Callable from dataclasses import replace from typing import Any, ClassVar import discord -from discord import app_commands from pydantic import Field from switch_core.bridges.agent.commands import COMMANDS_BY_NAME @@ -21,6 +19,7 @@ LiveRuntimeIndicator, ) from switch_core.bridges.collaboration.discord.chunking import chunk_message +from switch_core.bridges.collaboration.discord.connection import DiscordConnection from switch_core.bridges.collaboration.discord.slash import ( SlashArgError, build_app_commands, @@ -45,8 +44,6 @@ # via per-message username/avatar overrides. _WEBHOOK_NAME = "Switch Bridge" -_READY_TIMEOUT = 30.0 - # Put on the message an agent is working on for as long as its turn lasts. _WORKING_REACTION = "👀" @@ -159,10 +156,14 @@ def __init__(self, *, config: DiscordConnectionConfig) -> None: super().__init__() self._config = config self._guild_id = int(config.guild_id) - self._client: discord.Client | None = None - self._tree: app_commands.CommandTree[Any] | None = None - self._connect_task: asyncio.Task[None] | None = None - self._bot_user_id: int = 0 + # The Gateway socket lives on the connection, not the adapter: the + # socket is per bot token and the adapter is per guild. Intents are + # built here and handed over, so the socket owner does not decide them. + self._connection = DiscordConnection( + bot_token=config.bot_token, + intents=self._build_intents(), + command_guild_id=self._guild_id, + ) # channel id -> webhook the bridge posts through in that channel. self._webhooks: dict[int, discord.Webhook] = {} # Ids of webhooks the bridge has minted/adopted, for echo dropping. @@ -211,123 +212,33 @@ async def start( self._on_user_joined = on_user_joined self._on_app_joined = on_app_joined - intents = discord.Intents.none() - intents.guilds = True - intents.guild_messages = True - intents.dm_messages = True - intents.message_content = True - intents.members = True - - client = discord.Client(intents=intents) - client.event(self._make_on_message()) - self._tree = app_commands.CommandTree(client) - guild = discord.Object(id=self._guild_id) - for app_command in build_app_commands(self._handle_slash_command): - # Bound to the guild, not global — see _sync_slash_commands. Adding - # them globally here would leave the guild-scoped sync below with an - # empty payload, registering nothing at all. - self._tree.add_command(app_command, guild=guild) - self._client = client - - await client.login(self._config.bot_token) - self._connect_task = asyncio.create_task( - client.connect(), name=f"discord-gateway-{self._config.guild_id}" + await self._connection.connect( + commands=build_app_commands(self._handle_slash_command), + on_message=self._handle_message, ) - ready = asyncio.ensure_future(client.wait_until_ready()) - done, _ = await asyncio.wait( - {ready, self._connect_task}, - timeout=_READY_TIMEOUT, - return_when=asyncio.FIRST_COMPLETED, - ) - if self._connect_task in done: - ready.cancel() - exc = self._connect_task.exception() - raise RuntimeError("Discord gateway connection failed") from exc - if ready not in done: - ready.cancel() - await self.stop() - raise RuntimeError(f"Discord gateway not ready after {_READY_TIMEOUT:.0f}s") - - assert client.user is not None - self._bot_user_id = client.user.id logger.info( "Discord adapter connected as %s (guild %s)", - client.user, - self._config.guild_id, - ) - await self._sync_slash_commands() - - async def _sync_slash_commands(self) -> None: - """Publish the in-room command set as guild-scoped application commands. - - Guild-scoped rather than global, because the adapter is single-guild by - construction (`DiscordConnectionConfig.guild_id` is required and every - lookup is scoped to it). Guild commands also apply immediately, where - global ones propagate for up to an hour, and global registration is - per-application — so on an instance running several Discord bridges it - would leak each bridge's commands into the others' guilds, where they - could only fail. Syncing is a bulk overwrite, so re-running it on every - start reconciles renames and removals rather than accumulating them. - - Any sync failure is logged and left non-fatal — hence the broad catch: - the bridge still works over `!`-commands and messages, and dropping the - whole bridge over a missing `applications.commands` scope is a worse - outcome than running without the slash surface. The degradation is - visible in the logs rather than silent. - """ - if self._tree is None: - return - try: - synced = await self._tree.sync(guild=discord.Object(id=self._guild_id)) - except Exception: - logger.exception( - "Failed to sync Discord slash commands for guild %s — the bridge " - "will run without them (check the bot's applications.commands scope)", - self._config.guild_id, - ) - return - logger.info( - "Synced %d Discord slash commands to guild %s", - len(synced), + self._connection.client.user, self._config.guild_id, ) - def _make_on_message( - self, - ) -> Callable[[discord.Message], Coroutine[Any, Any, None]]: - # client.event registers by function __name__, so hand it a closure - # named exactly like the gateway event. - async def on_message(message: discord.Message) -> None: - try: - await self._handle_message(message) - except Exception: - logger.exception("Failed to handle inbound Discord message") - - return on_message + @staticmethod + def _build_intents() -> discord.Intents: + intents = discord.Intents.none() + intents.guilds = True + intents.guild_messages = True + intents.dm_messages = True + intents.message_content = True + intents.members = True + return intents async def stop(self) -> None: - if self._client: - try: - await self._client.close() - except Exception: - pass - task = self._connect_task - self._connect_task = None - if task: - task.cancel() - try: - await task - except (asyncio.CancelledError, Exception): - pass - self._client = None - self._tree = None + await self._connection.close() self._webhooks.clear() logger.info("Discord adapter stopped") def _require_client(self) -> discord.Client: - if self._client is None: - raise RuntimeError("Discord client not connected") - return self._client + return self._connection.client # ── Messaging ──────────────────────────────────────────────────────────── @@ -780,7 +691,8 @@ async def _mark_being_read(self, message_ref: str, *, working: bool) -> None: and no reaction, rather than a mark that is not there. """ location_id, message_id = self._parse_message_ref(message_ref) - if not message_id or self._client is None: + client = self._connection.client_or_none + if not message_id or client is None: return if working == (message_ref in self._eyes): return @@ -792,7 +704,7 @@ async def _mark_being_read(self, message_ref: str, *, working: bool) -> None: await message.add_reaction(_WORKING_REACTION) self._eyes.add(message_ref) else: - await message.remove_reaction(_WORKING_REACTION, self._client.user) + await message.remove_reaction(_WORKING_REACTION, client.user) self._eyes.discard(message_ref) except discord.NotFound: # The message (or the reaction) is gone; the end state is what was @@ -1129,7 +1041,8 @@ def _disable_agent_roles(self, reason: str) -> None: ) def _guild_from_cache(self) -> Any: - return self._client.get_guild(self._guild_id) if self._client else None + client = self._connection.client_or_none + return client.get_guild(self._guild_id) if client else None def _role_name(self, role_id: int) -> str | None: guild = self._guild_from_cache() @@ -1218,9 +1131,8 @@ def _replace_role(match: re.Match[str]) -> str: return f"@{name}" if name else match.group(0) def _replace_channel(match: re.Match[str]) -> str: - channel = ( - self._client.get_channel(int(match.group(1))) if self._client else None - ) + client = self._connection.client_or_none + channel = client.get_channel(int(match.group(1))) if client else None name = getattr(channel, "name", None) return f"#{name}" if name else match.group(0) @@ -1242,9 +1154,10 @@ async def _handle_message(self, message: Any) -> None: return author = message.author + bot_user_id = self._connection.bot_user_id # Drop only our own posts (loop prevention): the bot itself and the # bridge's webhooks. Third-party bots/webhooks are still bridged. - if author.id == self._bot_user_id: + if author.id == bot_user_id: return webhook_id = getattr(message, "webhook_id", None) if webhook_id and webhook_id in self._webhook_ids: @@ -1310,8 +1223,7 @@ async def _handle_message(self, message: Any) -> None: getattr(message, "attachments", []) or [] ) self_mention = ( - bool(self._bot_user_id) - and re.search(rf"<@!?{self._bot_user_id}>", content) is not None + bool(bot_user_id) and re.search(rf"<@!?{bot_user_id}>", content) is not None ) await self._on_message( InboundMessage( @@ -1325,7 +1237,7 @@ async def _handle_message(self, message: Any) -> None: channel_name=channel_name, attachments=attachments, attachment_failures=attachment_failures, - self_mention_token=str(self._bot_user_id) if self_mention else None, + self_mention_token=str(bot_user_id) if self_mention else None, ) ) diff --git a/core/switch_core/bridges/collaboration/discord/connection.py b/core/switch_core/bridges/collaboration/discord/connection.py new file mode 100644 index 000000000..7d2334b50 --- /dev/null +++ b/core/switch_core/bridges/collaboration/discord/connection.py @@ -0,0 +1,193 @@ +from __future__ import annotations + +import asyncio +import logging +from collections.abc import Awaitable, Callable, Coroutine +from typing import Any + +import discord +from discord import app_commands + +logger = logging.getLogger(__name__) + +_READY_TIMEOUT = 30.0 + + +class DiscordConnection: + """Owns the Discord Gateway socket, separate from per-guild message handling. + + A `DiscordConnection` holds the `discord.Client`, the application command + tree, the connect task and the bot's own user id, and it owns the socket + lifecycle: login, the readiness race, slash-command sync and shutdown. + `DiscordAdapter` composes one and reads its client through here rather than + holding the socket itself. + + Splitting the socket out of the adapter is the first step toward a single + shared, multi-tenant connection: the adapter is per guild, but the socket is + per bot token, so the two have genuinely different lifetimes. Intents are + supplied by the caller — the seam a shared connection uses to request a + different set (e.g. dropping DM intents, gating message content) without the + socket owner deciding them. + + `command_guild_id` scopes slash-command registration. When set, commands are + published guild-scoped (immediate, and confined to that guild); when None, + they are registered globally (per application, across every guild). + """ + + def __init__( + self, + *, + bot_token: str, + intents: discord.Intents, + command_guild_id: int | None, + ) -> None: + self._bot_token = bot_token + self._intents = intents + self._command_guild_id = command_guild_id + self._client: discord.Client | None = None + self._tree: app_commands.CommandTree[Any] | None = None + self._connect_task: asyncio.Task[None] | None = None + self._bot_user_id: int = 0 + + @property + def client(self) -> discord.Client: + if self._client is None: + raise RuntimeError("Discord client not connected") + return self._client + + @property + def client_or_none(self) -> discord.Client | None: + return self._client + + @property + def bot_user_id(self) -> int: + return self._bot_user_id + + async def connect( + self, + *, + commands: list[app_commands.Command[Any, ..., Any]], + on_message: Callable[[discord.Message], Awaitable[None]], + ) -> None: + """Open the Gateway connection and block until it is ready. + + Raises if the connection fails or does not become ready within + `_READY_TIMEOUT`. On timeout the half-open client is torn down; on a + connect-task failure it is left as-is for the caller's `close()`. + """ + client = discord.Client(intents=self._intents) + client.event(self._make_on_message(on_message)) + self._tree = app_commands.CommandTree(client) + guild = ( + discord.Object(id=self._command_guild_id) + if self._command_guild_id is not None + else None + ) + for command in commands: + # Bound to the guild when scoped — see _sync_slash_commands. Adding + # them globally when a guild sync follows would leave that sync with + # an empty payload, registering nothing at all. + if guild is not None: + self._tree.add_command(command, guild=guild) + else: + self._tree.add_command(command) + self._client = client + + await client.login(self._bot_token) + self._connect_task = asyncio.create_task( + client.connect(), name=f"discord-gateway-{self._command_guild_id}" + ) + ready = asyncio.ensure_future(client.wait_until_ready()) + done, _ = await asyncio.wait( + {ready, self._connect_task}, + timeout=_READY_TIMEOUT, + return_when=asyncio.FIRST_COMPLETED, + ) + if self._connect_task in done: + ready.cancel() + exc = self._connect_task.exception() + raise RuntimeError("Discord gateway connection failed") from exc + if ready not in done: + ready.cancel() + await self.close() + raise RuntimeError(f"Discord gateway not ready after {_READY_TIMEOUT:.0f}s") + + assert client.user is not None + self._bot_user_id = client.user.id + logger.info( + "Discord gateway connected as %s (guild %s)", + client.user, + self._command_guild_id, + ) + await self._sync_slash_commands() + + async def _sync_slash_commands(self) -> None: + """Publish the in-room command set as application commands. + + Guild-scoped when `command_guild_id` is set — which the self-registered + adapter always is, since it serves one guild. Guild commands apply + immediately, where global ones propagate for up to an hour, and global + registration is per-application — so on an instance running several + single-guild Discord bridges it would leak each bridge's commands into + the others' guilds, where they could only fail. Syncing is a bulk + overwrite, so re-running it on every start reconciles renames and + removals rather than accumulating them. + + Any sync failure is logged and left non-fatal — hence the broad catch: + the bridge still works over `!`-commands and messages, and dropping the + whole bridge over a missing `applications.commands` scope is a worse + outcome than running without the slash surface. The degradation is + visible in the logs rather than silent. + """ + if self._tree is None: + return + try: + if self._command_guild_id is not None: + synced = await self._tree.sync( + guild=discord.Object(id=self._command_guild_id) + ) + else: + synced = await self._tree.sync() + except Exception: + logger.exception( + "Failed to sync Discord slash commands for guild %s — the bridge " + "will run without them (check the bot's applications.commands scope)", + self._command_guild_id, + ) + return + logger.info( + "Synced %d Discord slash commands to guild %s", + len(synced), + self._command_guild_id, + ) + + def _make_on_message( + self, handler: Callable[[discord.Message], Awaitable[None]] + ) -> Callable[[discord.Message], Coroutine[Any, Any, None]]: + # client.event registers by function __name__, so hand it a closure + # named exactly like the gateway event. + async def on_message(message: discord.Message) -> None: + try: + await handler(message) + except Exception: + logger.exception("Failed to handle inbound Discord message") + + return on_message + + async def close(self) -> None: + if self._client: + try: + await self._client.close() + except Exception: + pass + task = self._connect_task + self._connect_task = None + if task: + task.cancel() + try: + await task + except (asyncio.CancelledError, Exception): + pass + self._client = None + self._tree = None + logger.info("Discord connection closed") diff --git a/core/tests/switch_core/bridges/collaboration/test_bridge_agent_display_names.py b/core/tests/switch_core/bridges/collaboration/test_bridge_agent_display_names.py index 892df5562..2a9a6ac02 100644 --- a/core/tests/switch_core/bridges/collaboration/test_bridge_agent_display_names.py +++ b/core/tests/switch_core/bridges/collaboration/test_bridge_agent_display_names.py @@ -704,10 +704,10 @@ def _discord_adapter( adapter = DiscordAdapter( config=DiscordConnectionConfig(bot_token="token", guild_id=str(GUILD_ID)) ) - adapter._bot_user_id = BOT_USER_ID + adapter._connection._bot_user_id = BOT_USER_ID channel = _FakeChannel() dm = _FakeChannel(DM_CHANNEL_ID, dm=True) - adapter._client = _FakeDiscordClient({CHANNEL_ID: channel, DM_CHANNEL_ID: dm}) # type: ignore[assignment] + adapter._connection._client = _FakeDiscordClient({CHANNEL_ID: channel, DM_CHANNEL_ID: dm}) # type: ignore[assignment] if bridge is not None: adapter.set_agent_presentation_resolver(bridge._agent_presentation) return adapter, channel, dm diff --git a/core/tests/switch_core/bridges/collaboration/test_discord_adapter.py b/core/tests/switch_core/bridges/collaboration/test_discord_adapter.py index 2ff6c4d82..4897e7a24 100644 --- a/core/tests/switch_core/bridges/collaboration/test_discord_adapter.py +++ b/core/tests/switch_core/bridges/collaboration/test_discord_adapter.py @@ -9,6 +9,7 @@ from switch_core.bridges.agent.commands import COMMANDS from switch_core.bridges.collaboration.discord import adapter as adapter_module +from switch_core.bridges.collaboration.discord import connection as connection_module from switch_core.bridges.collaboration.discord.adapter import ( DiscordAdapter, DiscordConnectionConfig, @@ -25,7 +26,7 @@ def _adapter() -> DiscordAdapter: adapter = DiscordAdapter( config=DiscordConnectionConfig(bot_token="token", guild_id=str(GUILD_ID)) ) - adapter._bot_user_id = BOT_USER_ID + adapter._connection._bot_user_id = BOT_USER_ID return adapter @@ -444,7 +445,7 @@ def get_guild(self, guild_id: int) -> Any | None: return _Guild() if guild_id == GUILD_ID else None adapter = _adapter() - adapter._client = _Client() # type: ignore[assignment] + adapter._connection._client = _Client() # type: ignore[assignment] commands = _capture_commands(adapter) _run( @@ -558,7 +559,7 @@ def test_oversize_attachment_reported_as_failure() -> None: def test_send_message_posts_via_webhook_with_agent_identity() -> None: adapter = _adapter() channel = _FakeChannel() - adapter._client = _FakeClient({CHANNEL_ID: channel}) + adapter._connection._client = _FakeClient({CHANNEL_ID: channel}) webhook = _FakeWebhook() adapter._webhooks[CHANNEL_ID] = webhook @@ -578,7 +579,7 @@ def test_send_message_posts_via_webhook_with_agent_identity() -> None: def test_long_message_is_split_across_posts_not_dropped() -> None: adapter = _adapter() channel = _FakeChannel() - adapter._client = _FakeClient({CHANNEL_ID: channel}) + adapter._connection._client = _FakeClient({CHANNEL_ID: channel}) webhook = _FakeWebhook() adapter._webhooks[CHANNEL_ID] = webhook body = "\n".join(f"line {i}" for i in range(1000)) @@ -599,7 +600,7 @@ def test_long_message_is_split_across_posts_not_dropped() -> None: def test_long_admin_message_is_split_across_posts() -> None: adapter = _adapter() channel = _FakeChannel() - adapter._client = _FakeClient({CHANNEL_ID: channel}) + adapter._connection._client = _FakeClient({CHANNEL_ID: channel}) body = "\n".join(f"- `!cmd-{i}` — does a thing" for i in range(200)) ref = _run(adapter.admin_message(str(CHANNEL_ID), body)) @@ -613,7 +614,7 @@ def test_long_admin_message_is_split_across_posts() -> None: def test_failed_part_leaves_a_visible_truncation_notice() -> None: adapter = _adapter() channel = _FakeChannel() - adapter._client = _FakeClient({CHANNEL_ID: channel}) + adapter._connection._client = _FakeClient({CHANNEL_ID: channel}) webhook = _FakeWebhook() adapter._webhooks[CHANNEL_ID] = webhook body = "\n".join(f"line {i}" for i in range(1000)) @@ -642,7 +643,7 @@ def test_send_message_with_thread_root_posts_into_thread() -> None: root = _FakeMessage(channel, message_id=4000) root.content = "the root message" channel.messages[4000] = root - adapter._client = _FakeClient({CHANNEL_ID: channel}) + adapter._connection._client = _FakeClient({CHANNEL_ID: channel}) webhook = _FakeWebhook() adapter._webhooks[CHANNEL_ID] = webhook @@ -662,7 +663,7 @@ def test_send_message_reuses_existing_thread() -> None: adapter = _adapter() channel = _FakeChannel() thread = _FakeThread(parent=channel, thread_id=4000) - adapter._client = _FakeClient({CHANNEL_ID: channel, 4000: thread}) + adapter._connection._client = _FakeClient({CHANNEL_ID: channel, 4000: thread}) webhook = _FakeWebhook() adapter._webhooks[CHANNEL_ID] = webhook @@ -678,7 +679,7 @@ def test_send_message_reuses_existing_thread() -> None: def test_send_message_to_dm_falls_back_to_bot_post() -> None: adapter = _adapter() dm = _FakeDMChannel() - adapter._client = _FakeClient({dm.id: dm}) + adapter._connection._client = _FakeClient({dm.id: dm}) ref = _run(adapter.send_message(str(dm.id), "my-agent", "hi")) @@ -692,7 +693,7 @@ def test_send_message_to_dm_falls_back_to_bot_post() -> None: def test_send_attachment_posts_via_webhook_with_agent_identity() -> None: adapter = _adapter() channel = _FakeChannel() - adapter._client = _FakeClient({CHANNEL_ID: channel}) + adapter._connection._client = _FakeClient({CHANNEL_ID: channel}) webhook = _FakeWebhook() adapter._webhooks[CHANNEL_ID] = webhook @@ -723,7 +724,7 @@ def test_send_attachment_posts_via_webhook_with_agent_identity() -> None: def test_send_attachment_without_caption_sends_empty_content() -> None: adapter = _adapter() channel = _FakeChannel() - adapter._client = _FakeClient({CHANNEL_ID: channel}) + adapter._connection._client = _FakeClient({CHANNEL_ID: channel}) webhook = _FakeWebhook() adapter._webhooks[CHANNEL_ID] = webhook @@ -741,7 +742,7 @@ def test_send_attachment_into_thread() -> None: adapter = _adapter() channel = _FakeChannel() thread = _FakeThread(parent=channel, thread_id=4000) - adapter._client = _FakeClient({CHANNEL_ID: channel, 4000: thread}) + adapter._connection._client = _FakeClient({CHANNEL_ID: channel, 4000: thread}) webhook = _FakeWebhook() adapter._webhooks[CHANNEL_ID] = webhook @@ -763,7 +764,7 @@ def test_send_attachment_into_thread() -> None: def test_send_attachment_to_dm_posts_file_as_bot() -> None: adapter = _adapter() dm = _FakeDMChannel() - adapter._client = _FakeClient({dm.id: dm}) + adapter._connection._client = _FakeClient({dm.id: dm}) ref = _run( adapter.send_attachment( @@ -785,7 +786,7 @@ async def send(self, **kwargs: Any) -> Any: adapter = _adapter() channel = _FakeChannel() - adapter._client = _FakeClient({CHANNEL_ID: channel}) + adapter._connection._client = _FakeClient({CHANNEL_ID: channel}) webhook = _FileRejectingWebhook() adapter._webhooks[CHANNEL_ID] = webhook @@ -811,7 +812,7 @@ async def send(self, **kwargs: Any) -> Any: def test_admin_message_posts_as_bot_not_webhook() -> None: adapter = _adapter() channel = _FakeChannel() - adapter._client = _FakeClient({CHANNEL_ID: channel}) + adapter._connection._client = _FakeClient({CHANNEL_ID: channel}) ref = _run(adapter.admin_message(str(CHANNEL_ID), "system notice")) @@ -823,7 +824,7 @@ def test_admin_message_posts_as_bot_not_webhook() -> None: def test_update_message_edits_via_webhook_with_thread() -> None: adapter = _adapter() channel = _FakeChannel() - adapter._client = _FakeClient({CHANNEL_ID: channel}) + adapter._connection._client = _FakeClient({CHANNEL_ID: channel}) webhook = _FakeWebhook() adapter._webhooks[CHANNEL_ID] = webhook @@ -840,7 +841,7 @@ def test_update_message_falls_back_to_bot_message_edit() -> None: channel = _FakeChannel() bot_msg = _FakeMessage(channel, message_id=502) channel.messages[502] = bot_msg - adapter._client = _FakeClient({CHANNEL_ID: channel}) + adapter._connection._client = _FakeClient({CHANNEL_ID: channel}) webhook = _FakeWebhook() webhook.edit_raises_not_found = True adapter._webhooks[CHANNEL_ID] = webhook @@ -858,7 +859,7 @@ def test_delete_message_goes_through_the_webhook_that_sent_it() -> None: channel = _FakeChannel() webhook = _FakeWebhook() channel.existing_webhooks = [webhook] - adapter._client = _FakeClient({CHANNEL_ID: channel}) + adapter._connection._client = _FakeClient({CHANNEL_ID: channel}) _run(adapter.delete_message(str(CHANNEL_ID), f"{CHANNEL_ID}:901")) @@ -872,7 +873,7 @@ def test_delete_message_in_a_thread_passes_the_thread_through() -> None: webhook = _FakeWebhook() channel.existing_webhooks = [webhook] thread = _FakeThread(parent=channel, thread_id=4000) - adapter._client = _FakeClient({CHANNEL_ID: channel, 4000: thread}) + adapter._connection._client = _FakeClient({CHANNEL_ID: channel, 4000: thread}) _run(adapter.delete_message(str(CHANNEL_ID), "4000:901")) @@ -891,7 +892,7 @@ def test_delete_message_falls_back_to_the_bot_for_its_own_posts() -> None: thread = _FakeThread(parent=channel, thread_id=4000) thread.deleted_ids = [] # type: ignore[attr-defined] thread.get_partial_message = lambda mid: _FakePartialMessage(thread, mid) # type: ignore[attr-defined] - adapter._client = _FakeClient({CHANNEL_ID: channel, 4000: thread}) + adapter._connection._client = _FakeClient({CHANNEL_ID: channel, 4000: thread}) _run(adapter.delete_message(str(CHANNEL_ID), "4000:901")) @@ -902,7 +903,7 @@ def test_delete_message_falls_back_to_the_bot_for_its_own_posts() -> None: def test_send_typing_triggers_once_and_off_is_noop() -> None: adapter = _adapter() channel = _FakeChannel() - adapter._client = _FakeClient({CHANNEL_ID: channel}) + adapter._connection._client = _FakeClient({CHANNEL_ID: channel}) _run(adapter.send_typing(str(CHANNEL_ID), "my-agent", True)) _run(adapter.send_typing(str(CHANNEL_ID), "my-agent", False)) @@ -916,7 +917,7 @@ def test_send_typing_triggers_once_and_off_is_noop() -> None: def _runtime_setup() -> tuple[DiscordAdapter, _FakeChannel, _FakeWebhook]: adapter = _adapter() channel = _FakeChannel() - adapter._client = _FakeClient({CHANNEL_ID: channel}) + adapter._connection._client = _FakeClient({CHANNEL_ID: channel}) webhook = _FakeWebhook() adapter._webhooks[CHANNEL_ID] = webhook return adapter, channel, webhook @@ -1055,7 +1056,7 @@ def test_get_webhook_adopts_existing_bridge_webhook() -> None: channel = _FakeChannel() existing = _FakeWebhook(name="Switch Bridge", webhook_id=555) channel.existing_webhooks = [_FakeWebhook(name="other", webhook_id=1), existing] - adapter._client = _FakeClient({CHANNEL_ID: channel}) + adapter._connection._client = _FakeClient({CHANNEL_ID: channel}) webhook = _run(adapter._get_webhook(CHANNEL_ID)) @@ -1067,7 +1068,7 @@ def test_get_webhook_adopts_existing_bridge_webhook() -> None: def test_get_webhook_creates_when_missing() -> None: adapter = _adapter() channel = _FakeChannel() - adapter._client = _FakeClient({CHANNEL_ID: channel}) + adapter._connection._client = _FakeClient({CHANNEL_ID: channel}) webhook = _run(adapter._get_webhook(CHANNEL_ID)) @@ -1085,7 +1086,9 @@ def test_get_webhook_creates_when_missing() -> None: def test_translate_inbound_converts_user_and_channel_mentions() -> None: adapter = _adapter() adapter._user_names[7] = "louis" - adapter._client = _FakeClient({CHANNEL_ID: _FakeChannel(name="general")}) + adapter._connection._client = _FakeClient( + {CHANNEL_ID: _FakeChannel(name="general")} + ) out = adapter.translate_inbound(f"hi <@7> and <@!7>, see <#{CHANNEL_ID}> or <@99>") @@ -1196,10 +1199,10 @@ def test_start_becomes_ready_then_stop_closes_client() -> None: async def scenario() -> None: with patch.object(adapter_module.discord, "Client", lambda **kw: fake): await adapter.start(*_noop_callbacks()) - assert adapter._client is fake + assert adapter._connection._client is fake assert fake.logged_in assert fake.registered_events # on_message handler registered - assert adapter._bot_user_id == BOT_USER_ID + assert adapter._connection._bot_user_id == BOT_USER_ID # Slash commands are published to the configured GUILD, never # globally: the adapter is single-guild by construction, guild @@ -1215,8 +1218,8 @@ async def scenario() -> None: await adapter.stop() assert fake.closed - assert adapter._client is None - assert adapter._tree is None + assert adapter._connection._client is None + assert adapter._connection._tree is None _run(scenario()) @@ -1249,13 +1252,13 @@ def test_start_times_out_when_never_ready_and_stops() -> None: async def scenario() -> None: with ( patch.object(adapter_module.discord, "Client", lambda **kw: fake), - patch.object(adapter_module, "_READY_TIMEOUT", 0.05), + patch.object(connection_module, "_READY_TIMEOUT", 0.05), ): with pytest.raises(RuntimeError, match="not ready"): await adapter.start(*_noop_callbacks()) # Timeout path tears the half-open client down. assert fake.closed - assert adapter._client is None + assert adapter._connection._client is None _run(scenario()) diff --git a/core/tests/switch_core/bridges/collaboration/test_discord_agent_roles.py b/core/tests/switch_core/bridges/collaboration/test_discord_agent_roles.py index 30f193d3a..7c2cf62ac 100644 --- a/core/tests/switch_core/bridges/collaboration/test_discord_agent_roles.py +++ b/core/tests/switch_core/bridges/collaboration/test_discord_agent_roles.py @@ -102,7 +102,7 @@ def _adapter(guild: _FakeGuild, *, agent_roles: bool = True) -> DiscordAdapter: bot_token="token", guild_id=str(GUILD_ID), agent_roles=agent_roles ) ) - made._client = _FakeClient(guild) # type: ignore[assignment] + made._connection._client = _FakeClient(guild) # type: ignore[assignment] return made diff --git a/core/tests/switch_core/bridges/collaboration/test_discord_provisioning.py b/core/tests/switch_core/bridges/collaboration/test_discord_provisioning.py index 49cdfd039..19f9ba940 100644 --- a/core/tests/switch_core/bridges/collaboration/test_discord_provisioning.py +++ b/core/tests/switch_core/bridges/collaboration/test_discord_provisioning.py @@ -122,7 +122,7 @@ async def set_permissions(self, target: Any, **kwargs: Any) -> None: def _wire( adapter: DiscordAdapter, guild: _FakeGuild, channels: dict[int, Any] | None = None ) -> None: - adapter._client = _FakeClient(guild, channels) # type: ignore[assignment] + adapter._connection._client = _FakeClient(guild, channels) # type: ignore[assignment] # ── create_channel ─────────────────────────────────────────────────────────── diff --git a/core/tests/switch_core/bridges/collaboration/test_discord_slash_commands.py b/core/tests/switch_core/bridges/collaboration/test_discord_slash_commands.py index 15873ff7d..b2572b5b6 100644 --- a/core/tests/switch_core/bridges/collaboration/test_discord_slash_commands.py +++ b/core/tests/switch_core/bridges/collaboration/test_discord_slash_commands.py @@ -36,7 +36,7 @@ def _adapter() -> DiscordAdapter: adapter = DiscordAdapter( config=DiscordConnectionConfig(bot_token="token", guild_id=str(GUILD_ID)) ) - adapter._bot_user_id = BOT_USER_ID + adapter._connection._bot_user_id = BOT_USER_ID return adapter @@ -493,7 +493,7 @@ def _adapter_with_roles( roles: dict[int, str] | None = None, members: dict[int, str] | None = None ) -> DiscordAdapter: adapter = _adapter() - adapter._client = _RoleClient(_RoleGuild(roles or {}, members or {})) # type: ignore[assignment] + adapter._connection._client = _RoleClient(_RoleGuild(roles or {}, members or {})) # type: ignore[assignment] return adapter diff --git a/core/tests/switch_core/bridges/collaboration/test_discord_working_reaction.py b/core/tests/switch_core/bridges/collaboration/test_discord_working_reaction.py index e0893fa05..018efc3ac 100644 --- a/core/tests/switch_core/bridges/collaboration/test_discord_working_reaction.py +++ b/core/tests/switch_core/bridges/collaboration/test_discord_working_reaction.py @@ -89,7 +89,7 @@ def adapter(channel: _FakeChannel) -> DiscordAdapter: made = DiscordAdapter( config=DiscordConnectionConfig(bot_token="token", guild_id=str(GUILD_ID)) ) - made._client = _FakeClient({CHANNEL_ID: channel}) # type: ignore[assignment] + made._connection._client = _FakeClient({CHANNEL_ID: channel}) # type: ignore[assignment] return made @@ -201,7 +201,7 @@ def test_a_message_inside_a_thread_is_marked_in_that_thread( only place the reaction can be added. """ thread = _FakeChannel(channel_id=777) - adapter._client._channels[777] = thread # type: ignore[union-attr] + adapter._connection._client._channels[777] = thread # type: ignore[union-attr] _state(adapter, "working", anchor="777:33") From 088f2376b9c3a01efc32913378b0805bf3f96588 Mon Sep 17 00:00:00 2001 From: lbangalosbt Date: Wed, 16 Sep 2026 08:52:54 -0400 Subject: [PATCH 17/29] refactor(discord): route inbound messages by guild id in DiscordConnection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The connection now dispatches each inbound message to a handler registered for its guild id (via register_message_handler), and a guild-less direct message to an optional DM handler (set_dm_handler) — instead of the adapter filtering every event against its one guild. Handlers are looked up live per message, so a handler registered after connect() still receives events. This is behavior-preserving for the self-registered bridge, which registers one guild handler plus the DM handler so its guild messages and DMs both flow as before; and it is the seam the shared multi-tenant connection uses to register a handler per installed guild (and to leave the DM slot empty, dropping DMs that carry no guild to attribute to a tenant). The former guild filter in _handle_message is removed, and its foreign-guild/DM behavior is now covered by connection-level routing tests. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../bridges/collaboration/discord/adapter.py | 13 +++-- .../collaboration/discord/connection.py | 45 ++++++++++++++-- .../collaboration/test_discord_adapter.py | 52 +++++++++++++++++-- 3 files changed, 99 insertions(+), 11 deletions(-) diff --git a/core/switch_core/bridges/collaboration/discord/adapter.py b/core/switch_core/bridges/collaboration/discord/adapter.py index 57e937224..4b4b098b8 100644 --- a/core/switch_core/bridges/collaboration/discord/adapter.py +++ b/core/switch_core/bridges/collaboration/discord/adapter.py @@ -212,9 +212,13 @@ async def start( self._on_user_joined = on_user_joined self._on_app_joined = on_app_joined + # One guild, one handler; the DM handler is the same adapter so direct + # messages still reach it. The connection routes each message by guild + # id, which for a single-guild bridge is exactly the old filter. + self._connection.register_message_handler(self._guild_id, self._handle_message) + self._connection.set_dm_handler(self._handle_message) await self._connection.connect( commands=build_app_commands(self._handle_slash_command), - on_message=self._handle_message, ) logger.info( "Discord adapter connected as %s (guild %s)", @@ -1149,10 +1153,9 @@ def _replace_channel(match: re.Match[str]) -> str: ) async def _handle_message(self, message: Any) -> None: - guild = getattr(message, "guild", None) - if guild is not None and guild.id != self._guild_id: - return - + # The connection routes each message here by guild id (or as a DM), so + # this handler only ever sees its own guild's messages and DMs — the + # guild filter that used to live here now lives in DiscordConnection. author = message.author bot_user_id = self._connection.bot_user_id # Drop only our own posts (loop prevention): the bot itself and the diff --git a/core/switch_core/bridges/collaboration/discord/connection.py b/core/switch_core/bridges/collaboration/discord/connection.py index 7d2334b50..ae6c36972 100644 --- a/core/switch_core/bridges/collaboration/discord/connection.py +++ b/core/switch_core/bridges/collaboration/discord/connection.py @@ -48,6 +48,30 @@ def __init__( self._tree: app_commands.CommandTree[Any] | None = None self._connect_task: asyncio.Task[None] | None = None self._bot_user_id: int = 0 + # Inbound message routing. A guild's messages go to the handler + # registered for its id; a direct message (no guild) goes to the DM + # handler if one is set, and is dropped otherwise. The self-registered + # adapter registers one guild handler plus the DM handler; a shared + # multi-tenant connection registers a handler per installed guild and + # leaves the DM slot empty, so DMs — which carry no guild to attribute + # them to a tenant — are dropped. + self._message_handlers: dict[ + int, Callable[[discord.Message], Awaitable[None]] + ] = {} + self._dm_handler: Callable[[discord.Message], Awaitable[None]] | None = None + + def register_message_handler( + self, guild_id: int, handler: Callable[[discord.Message], Awaitable[None]] + ) -> None: + self._message_handlers[guild_id] = handler + + def unregister_message_handler(self, guild_id: int) -> None: + self._message_handlers.pop(guild_id, None) + + def set_dm_handler( + self, handler: Callable[[discord.Message], Awaitable[None]] | None + ) -> None: + self._dm_handler = handler @property def client(self) -> discord.Client: @@ -67,16 +91,20 @@ async def connect( self, *, commands: list[app_commands.Command[Any, ..., Any]], - on_message: Callable[[discord.Message], Awaitable[None]], ) -> None: """Open the Gateway connection and block until it is ready. + Message handlers are registered separately (before or after this call) + via `register_message_handler` / `set_dm_handler`, and are looked up + live per message — so a shared connection can register a guild's handler + the moment its install resolves, after the socket is already open. + Raises if the connection fails or does not become ready within `_READY_TIMEOUT`. On timeout the half-open client is torn down; on a connect-task failure it is left as-is for the caller's `close()`. """ client = discord.Client(intents=self._intents) - client.event(self._make_on_message(on_message)) + client.event(self._make_on_message()) self._tree = app_commands.CommandTree(client) guild = ( discord.Object(id=self._command_guild_id) @@ -162,11 +190,22 @@ async def _sync_slash_commands(self) -> None: ) def _make_on_message( - self, handler: Callable[[discord.Message], Awaitable[None]] + self, ) -> Callable[[discord.Message], Coroutine[Any, Any, None]]: # client.event registers by function __name__, so hand it a closure # named exactly like the gateway event. async def on_message(message: discord.Message) -> None: + # Route to the handler registered for this message's guild, or the + # DM handler for a guild-less message. Look up live per message so + # handlers registered after connect() still receive events. An event + # for a guild (or a DM) with no registered handler is dropped. + guild = message.guild + if guild is None: + handler = self._dm_handler + else: + handler = self._message_handlers.get(guild.id) + if handler is None: + return try: await handler(message) except Exception: diff --git a/core/tests/switch_core/bridges/collaboration/test_discord_adapter.py b/core/tests/switch_core/bridges/collaboration/test_discord_adapter.py index 4897e7a24..015c5eaed 100644 --- a/core/tests/switch_core/bridges/collaboration/test_discord_adapter.py +++ b/core/tests/switch_core/bridges/collaboration/test_discord_adapter.py @@ -316,12 +316,10 @@ def test_own_webhook_message_dropped_but_foreign_webhook_bridged() -> None: assert captured[0].message_ref == f"{CHANNEL_ID}:2" -def test_other_guild_and_system_messages_skipped() -> None: +def test_system_messages_skipped() -> None: adapter = _adapter() captured = _capture_messages(adapter) - other_guild_channel = _FakeChannel(guild=_FakeGuild(guild_id=999)) - _run(adapter._handle_message(_gateway_message(channel=other_guild_channel))) _run( adapter._handle_message( _gateway_message( @@ -333,6 +331,54 @@ def test_other_guild_and_system_messages_skipped() -> None: assert captured == [] +def _connection() -> Any: + return connection_module.DiscordConnection( + bot_token="token", + intents=discord.Intents.none(), + command_guild_id=GUILD_ID, + ) + + +def test_connection_routes_messages_to_the_matching_guild_handler() -> None: + # The guild filter that used to live in _handle_message now lives in the + # connection: a message reaches only the handler registered for its guild. + conn = _connection() + delivered: list[Any] = [] + + async def handler(message: Any) -> None: + delivered.append(message) + + conn.register_message_handler(GUILD_ID, handler) + on_message = conn._make_on_message() + + _run(on_message(_gateway_message(channel=_FakeChannel()))) + _run( + on_message( + _gateway_message(channel=_FakeChannel(guild=_FakeGuild(guild_id=999))) + ) + ) + + assert len(delivered) == 1 + + +def test_connection_drops_dms_unless_a_dm_handler_is_set() -> None: + conn = _connection() + delivered: list[Any] = [] + + async def handler(message: Any) -> None: + delivered.append(message) + + conn.register_message_handler(GUILD_ID, handler) + on_message = conn._make_on_message() + + _run(on_message(_gateway_message(channel=_FakeDMChannel()))) + assert delivered == [] + + conn.set_dm_handler(handler) + _run(on_message(_gateway_message(channel=_FakeDMChannel()))) + assert len(delivered) == 1 + + def test_duplicate_message_ids_deduplicated() -> None: adapter = _adapter() captured = _capture_messages(adapter) From dac6f6699afa5a83f3957c66db95b0351b3dd2f1 Mon Sep 17 00:00:00 2001 From: lbangalosbt Date: Wed, 16 Sep 2026 09:42:28 -0400 Subject: [PATCH 18/29] style(discord): reformat test_bridge_agent_display_names A long line left unformatted by the DiscordConnection extraction (aa616edb) that ruff format now wraps; no behaviour change. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../bridges/collaboration/test_bridge_agent_display_names.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/core/tests/switch_core/bridges/collaboration/test_bridge_agent_display_names.py b/core/tests/switch_core/bridges/collaboration/test_bridge_agent_display_names.py index 2a9a6ac02..c9589697b 100644 --- a/core/tests/switch_core/bridges/collaboration/test_bridge_agent_display_names.py +++ b/core/tests/switch_core/bridges/collaboration/test_bridge_agent_display_names.py @@ -707,7 +707,9 @@ def _discord_adapter( adapter._connection._bot_user_id = BOT_USER_ID channel = _FakeChannel() dm = _FakeChannel(DM_CHANNEL_ID, dm=True) - adapter._connection._client = _FakeDiscordClient({CHANNEL_ID: channel, DM_CHANNEL_ID: dm}) # type: ignore[assignment] + adapter._connection._client = _FakeDiscordClient( + {CHANNEL_ID: channel, DM_CHANNEL_ID: dm} + ) # type: ignore[assignment] if bridge is not None: adapter.set_agent_presentation_resolver(bridge._agent_presentation) return adapter, channel, dm From e4efe6242ca1afdf2d11e1a5c576c7508ca36149 Mon Sep 17 00:00:00 2001 From: lbangalosbt Date: Wed, 16 Sep 2026 09:42:40 -0400 Subject: [PATCH 19/29] feat(bridges): let a messaging install carry no per-install token (Discord) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The distributed Discord app authenticates to every guild with one deployment-level application bot token, so a Discord install has no per-install credential to capture or store — its grant is a guild id and a name. This cuts the tokenless-grant seam through the shared messaging- install layer so such an install can be recorded and completed, while a token-based platform (Slack) still carries and requires its token. Three places assumed a per-install token: - `InstallGrant.bot_token` becomes `str | None` — `None` for a platform whose credential is deployment-level. - `MessagingInstallStore.record_install` accepts a nullable `encrypted_bot_token` and inserts it as-is (the column is already nullable from a7f2c3e9b481). - `MessagingInstallService.complete` encrypts only when the grant carries a token, else stores `None`. Slack stays strict: its requirement lives on `SlackConnectionConfig. bot_token`, a required field, so a token-based bridge still fails config validation without one — the column's nullability is not the guard. The disconnect path already skips the platform `revoke` call when the stored token is `None` (added with the "install can end" work), so a tokenless install revokes nothing on disconnect and just ends its local record; a regression test locks that in. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../bridges/collaboration/install.py | 10 +++- .../bridges/collaboration/install_service.py | 9 +++- .../db/stores/messaging_install_store.py | 7 ++- .../collaboration/test_install_service.py | 52 +++++++++++++++++-- .../db/stores/test_messaging_install_store.py | 28 ++++++++++ 5 files changed, 99 insertions(+), 7 deletions(-) diff --git a/core/switch_core/bridges/collaboration/install.py b/core/switch_core/bridges/collaboration/install.py index ceb5c16a8..955416413 100644 --- a/core/switch_core/bridges/collaboration/install.py +++ b/core/switch_core/bridges/collaboration/install.py @@ -175,6 +175,14 @@ class InstallGrant: where the alternative is a row of opaque platform ids. It is the customer's own text and is never matched on. + `bot_token` is `None` for a platform whose credential is not per-install. + A Discord install grants no per-guild token — the bot authenticates to + every guild with the one deployment-level application token — so its grant + is a guild id and a name and nothing to store. A Slack grant always carries + one; the requirement lives on that platform's `connection_config` validator, + not here (`SlackConnectionConfig.bot_token` is required), so an optional + field here does not weaken it. + `scopes` is the platform's own spelling, kept verbatim. A scope string that means nothing to us is still the thing to show an operator asking why a call was refused, and parsing it into a list here would be a parser to keep @@ -183,7 +191,7 @@ class InstallGrant: external_workspace_id: str workspace_name: str - bot_token: str + bot_token: str | None scopes: str diff --git a/core/switch_core/bridges/collaboration/install_service.py b/core/switch_core/bridges/collaboration/install_service.py index 062d3f678..7cee29311 100644 --- a/core/switch_core/bridges/collaboration/install_service.py +++ b/core/switch_core/bridges/collaboration/install_service.py @@ -239,7 +239,14 @@ async def complete( session, platform=platform, external_workspace_id=grant.external_workspace_id, - encrypted_bot_token=encrypt_token(grant.bot_token, self._secret), + # A grant with no token is a platform whose credential is + # deployment-level (Discord), not per-install; there is + # nothing to encrypt and the column is nullable for it. + encrypted_bot_token=( + encrypt_token(grant.bot_token, self._secret) + if grant.bot_token is not None + else None + ), scopes=grant.scopes, user_id=burnt.created_by_user_id, ) diff --git a/core/switch_core/db/stores/messaging_install_store.py b/core/switch_core/db/stores/messaging_install_store.py index 5e6ae18e8..97479cfbf 100644 --- a/core/switch_core/db/stores/messaging_install_store.py +++ b/core/switch_core/db/stores/messaging_install_store.py @@ -118,7 +118,7 @@ async def record_install( *, platform: str, external_workspace_id: str, - encrypted_bot_token: str, + encrypted_bot_token: str | None, scopes: str, user_id: str, ) -> MessagingInstall: @@ -133,6 +133,11 @@ async def record_install( Installs that have ended are not in the index, so re-installing a workspace somebody released is an ordinary insert and needs no check of its own. + + `encrypted_bot_token` is `None` for a platform whose credential is not + per-install (Discord's is deployment-level). The column is nullable for + exactly that; a token-based platform's requirement is enforced by its + connection-config validator, not here. """ install = MessagingInstall( platform=platform, diff --git a/core/tests/switch_core/bridges/collaboration/test_install_service.py b/core/tests/switch_core/bridges/collaboration/test_install_service.py index a6879881d..2db724626 100644 --- a/core/tests/switch_core/bridges/collaboration/test_install_service.py +++ b/core/tests/switch_core/bridges/collaboration/test_install_service.py @@ -70,8 +70,9 @@ class _FakeInstaller(MessagingAppInstaller): platform: ClassVar[str] = "slack" - def __init__(self, workspace_id: str) -> None: + def __init__(self, workspace_id: str, *, tokenless: bool = False) -> None: self.workspace_id = workspace_id + self.tokenless = tokenless self.redeem_calls: list[str] = [] self.revoked_tokens: list[str] = [] self.revoke_error: Exception | None = None @@ -84,7 +85,10 @@ async def redeem(self, *, code: str, redirect_uri: str) -> InstallGrant: return InstallGrant( external_workspace_id=self.workspace_id, workspace_name="Acme", - bot_token="xoxb-granted", + # `None` stands in for a platform whose credential is + # deployment-level, not per-install (Discord). The service must + # store no token for it and revoke nothing on disconnect. + bot_token=None if self.tokenless else "xoxb-granted", scopes="chat:write", ) @@ -188,7 +192,7 @@ def __init__(self) -> None: self.service: MessagingInstallService -async def _fixture(harness: RLSHarness) -> _Fixture: +async def _fixture(harness: RLSHarness, *, tokenless: bool = False) -> _Fixture: fixture = _Fixture() suffix = uuid.uuid4().hex[:8] fixture.tenant_a = f"tenant-a-{suffix}" @@ -204,7 +208,7 @@ async def _fixture(harness: RLSHarness) -> _Fixture: fixture.user_id = user.id await session.commit() - fixture.installer = _FakeInstaller(fixture.workspace) + fixture.installer = _FakeInstaller(fixture.workspace, tokenless=tokenless) fixture.lifecycle = _FakeLifecycle(harness.restricted, fixture.tenant_a, suffix) installers = MessagingInstallerRegistry() installers.register(fixture.installer) @@ -629,3 +633,43 @@ async def test_the_list_is_the_bound_tenants_own( listed = await MessagingInstallStore().list_for_tenant(session) assert listed == [] + + +class TestATokenlessGrant: + """A platform whose credential is deployment-level, not per-install (Discord). + + The grant carries no token, so there is nothing to encrypt at record time + and nothing to revoke at the platform on disconnect — the app-level bot + token is not this install's to end. The rest of the flow is unchanged. + """ + + async def test_complete_stores_no_token_and_still_builds_a_bridge( + self, rls_harness: RLSHarness + ) -> None: + fixture = await _fixture(rls_harness, tokenless=True) + state = await _begin(rls_harness.restricted, fixture, fixture.tenant_a) + + install = await fixture.service.complete( + platform="slack", code="the-code", state_token=state + ) + + assert install.encrypted_bot_token is None + assert install.bridge_id is not None + assert len(fixture.lifecycle.registered) == 1 + + async def test_disconnect_revokes_nothing_and_ends_cleanly( + self, rls_harness: RLSHarness + ) -> None: + fixture = await _fixture(rls_harness, tokenless=True) + install = await _installed(rls_harness.restricted, fixture, fixture.tenant_a) + bridge_id = install.bridge_id + + ended = await fixture.service.disconnect( + tenant_id=fixture.tenant_a, install_id=install.id + ) + + assert fixture.installer.revoked_tokens == [] + assert ended.status == INSTALL_DISCONNECTED + assert ended.ended_at is not None + assert ended.bridge_id is None + assert fixture.lifecycle.removed == [bridge_id] diff --git a/core/tests/switch_core/db/stores/test_messaging_install_store.py b/core/tests/switch_core/db/stores/test_messaging_install_store.py index 5b1304417..1d37a378b 100644 --- a/core/tests/switch_core/db/stores/test_messaging_install_store.py +++ b/core/tests/switch_core/db/stores/test_messaging_install_store.py @@ -269,3 +269,31 @@ async def test_only_the_owning_tenant_reads_it_back( session, platform="slack", external_workspace_id=fixture.workspace ) assert theirs is None + + async def test_a_tokenless_install_records_and_reads_back( + self, rls_harness: RLSHarness + ) -> None: + """A platform whose credential is deployment-level (Discord) stores no + token: the row goes in with `encrypted_bot_token = NULL` and reads back + through the scoped path unchanged.""" + fixture = await _two_tenants(rls_harness.owner) + store = MessagingInstallStore() + + async with tenant_session(rls_harness.restricted, fixture.tenant_a) as session: + recorded = await store.record_install( + session, + platform="discord", + external_workspace_id=fixture.workspace, + encrypted_bot_token=None, + scopes="bot applications.commands", + user_id=fixture.user_id, + ) + assert recorded.encrypted_bot_token is None + await session.commit() + + async with tenant_session(rls_harness.restricted, fixture.tenant_a) as session: + mine = await store.get_for_workspace( + session, platform="discord", external_workspace_id=fixture.workspace + ) + assert mine is not None + assert mine.encrypted_bot_token is None From b604a8e5e48c4e8367d971cda52677b89f37ab49 Mon Sep 17 00:00:00 2001 From: lbangalosbt Date: Wed, 16 Sep 2026 10:25:01 -0400 Subject: [PATCH 20/29] feat(bridges): configuration and installer for the distributed Discord app MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Stage 2 of the distributed Discord app: the config the app exists for and the installer that turns an Add-to-Server click into a tokenless install. The completing flow, the inert bridge and the shared Gateway connection are later stages; nothing here starts a bridge. - config: DISCORD_APP_CLIENT_ID / DISCORD_APP_CLIENT_SECRET / DISCORD_APP_BOT_TOKEN / DISCORD_APP_APPLICATION_ID, with an all-or-nothing _validate_discord_app mirroring the Slack one and the same requirement that MESSAGING_PUBLIC_URL be set with them. The bot token lives here, in deployment config, not in a per-install row — the one shape difference from Slack that the rest follows from. - DiscordAppInstaller: authorize_url with the pinned `bot applications.commands` scopes and a least-privilege permission integer (275683314768, summed from named bits the adapter actually uses); redeem exchanges the code and reads the guild id and name back, returning a tokenless InstallGrant; connection_config renders {guild_id, event_delivery: "shared"} and no token. Discord has no webhook, so the inbound half of the ABC (verify_webhook / parse_webhook / workspace_of_event / revocation_of_event) and revoke are stubbed to raise — never reached, and split from the ABC later. - DiscordConnectionConfig: a hidden event_delivery discriminator (own_connection | shared) mirroring Slack's, bot_token now optional, and a validator refusing the half-states (own_connection without a token; shared with one). A shared bridge is not startable yet, so the adapter fails loud on a tokenless config rather than constructing one that receives nothing. - main.py registers DiscordAppInstaller when its credentials are configured, beside the Slack one; the operator dashboard lists it automatically. - docs/old/bridges/DISCORD_DISTRIBUTED_APP.md: the operator walkthrough, carrying a pinned contract block (scopes, permission integer, redirect) that a doc-vs-code test parses and compares to the code, so the two cannot drift. Slack code untouched. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../bridges/collaboration/discord/adapter.py | 59 ++++- .../bridges/collaboration/discord/install.py | 206 ++++++++++++++++++ core/switch_core/config.py | 45 ++++ core/switch_core/main.py | 11 + .../test_bridge_type_registry.py | 8 +- .../test_discord_distributed_app.py | 61 ++++++ .../collaboration/test_discord_installer.py | 182 ++++++++++++++++ .../switch_core/test_config_discord_app.py | 65 ++++++ docs/old/bridges/DISCORD_DISTRIBUTED_APP.md | 181 +++++++++++++++ 9 files changed, 814 insertions(+), 4 deletions(-) create mode 100644 core/switch_core/bridges/collaboration/discord/install.py create mode 100644 core/tests/switch_core/bridges/collaboration/test_discord_distributed_app.py create mode 100644 core/tests/switch_core/bridges/collaboration/test_discord_installer.py create mode 100644 core/tests/switch_core/test_config_discord_app.py create mode 100644 docs/old/bridges/DISCORD_DISTRIBUTED_APP.md diff --git a/core/switch_core/bridges/collaboration/discord/adapter.py b/core/switch_core/bridges/collaboration/discord/adapter.py index 4b4b098b8..353c87fba 100644 --- a/core/switch_core/bridges/collaboration/discord/adapter.py +++ b/core/switch_core/bridges/collaboration/discord/adapter.py @@ -7,10 +7,11 @@ from collections import OrderedDict from collections.abc import Awaitable, Callable from dataclasses import replace -from typing import Any, ClassVar +from typing import Any, ClassVar, Literal import discord -from pydantic import Field +from pydantic import Field, model_validator +from pydantic.json_schema import SkipJsonSchema from switch_core.bridges.agent.commands import COMMANDS_BY_NAME from switch_core.bridges.agent.commands import Command as InRoomCommand @@ -117,8 +118,26 @@ async def send_rebuilding( class DiscordConnectionConfig(BridgeConnectionConfig): - bot_token: str + #: Optional because it depends on `event_delivery`, which the validator + #: below enforces: the self-registered bridge opens its own connection and + #: needs a token; the distributed bridge routes through the one shared + #: connection and carries none — its token is deployment config. + bot_token: str | None = None guild_id: str + #: How this bridge's events reach it, and which is decided by which Discord + #: app the install came from rather than by an operator's preference. + #: + #: `own_connection` is the self-registered app: Switch opens a Gateway + #: connection scoped to this guild with the token above. `shared` is the + #: distributed app: the bridge opens nothing and registers its guild with + #: the one deployment-level connection instead, so it holds no token. + #: + #: Hidden from the registration form because it is not a question the + #: operator filling that form can be asked: reaching the form means the + #: self-registered app, and the shared value is written by the install flow. + event_delivery: SkipJsonSchema[Literal["own_connection", "shared"]] = ( + "own_connection" + ) # Both registration forms build themselves from this schema, so what is # written here is the only explanation an operator gets next to the # checkbox. @@ -131,6 +150,29 @@ class DiscordConnectionConfig(BridgeConnectionConfig): ), ) + @model_validator(mode="after") + def _token_matches_delivery(self) -> DiscordConnectionConfig: + """Refuse the two half-states that look configured and cannot work. + + An own-connection bridge with no bot token opens no Gateway connection, + so it would receive nothing — the silent failure the token is there to + prevent. A shared bridge carrying a token is the opposite mistake: a + credential for a connection this bridge does not own, read as evidence + that it does. + """ + if self.event_delivery == "own_connection" and not self.bot_token: + raise ValueError( + "bot_token is required: without it Switch opens no Gateway " + "connection and this bridge would receive no Discord events." + ) + if self.event_delivery == "shared" and self.bot_token: + raise ValueError( + "bot_token must be empty for a shared-connection bridge; the " + "distributed Discord app's token is deployment config, not " + "this install's." + ) + return self + class DiscordAdapter(CollaborationAdapter): """Discord collaboration bridge adapter. @@ -156,6 +198,17 @@ def __init__(self, *, config: DiscordConnectionConfig) -> None: super().__init__() self._config = config self._guild_id = int(config.guild_id) + if config.bot_token is None: + # A shared-delivery bridge carries no token and does not open its + # own Gateway connection; it registers its guild with the one shared + # connection instead. That connection is not built yet — the inert + # bridge and the shared client land in later stages — so a shared + # bridge is not startable here, and this fails loud rather than + # constructing an adapter that would silently receive nothing. + raise NotImplementedError( + "a shared-delivery Discord bridge does not open its own Gateway " + "connection; the shared connection is not built yet" + ) # The Gateway socket lives on the connection, not the adapter: the # socket is per bot token and the adapter is per guild. Intents are # built here and handed over, so the socket owner does not decide them. diff --git a/core/switch_core/bridges/collaboration/discord/install.py b/core/switch_core/bridges/collaboration/discord/install.py new file mode 100644 index 000000000..6268c44bc --- /dev/null +++ b/core/switch_core/bridges/collaboration/discord/install.py @@ -0,0 +1,206 @@ +"""Installing the distributed Discord app into a customer's server (guild). + +The counterpart to `DISCORD_SETUP.md`'s self-registered app, and a different +Discord application from it. See `docs/old/bridges/DISCORD_DISTRIBUTED_APP.md` +for the registration walkthrough; `SCOPES` and `PERMISSIONS` below are pinned in +that document too, and a test compares them so the two cannot drift. + +Two structural differences from Slack's installer run through everything here, +and both come from one fact — Discord grants no per-install credential: + +- `redeem` returns a **tokenless** grant. The code exchange tells us the guild + the bot was added to (its id and name) and hands back a user token we do not + need; the bot authenticates to every guild with the one deployment-level + application token, which is config and never rides inside an install. +- There is **no webhook**. Discord delivers messages and interactions over the + Gateway, so the inbound half of the `MessagingAppInstaller` ABC — + `verify_webhook`, `parse_webhook`, `workspace_of_event`, `revocation_of_event` + — has nothing to implement. Those are stubbed to raise: nothing routes + `/messaging/discord/events` traffic, so they are never reached, and splitting + the ABC into an install half and a webhook half is deferred until a second + non-webhook platform makes it pay off. `revoke` is stubbed for the same + reason — a tokenless install has nothing to revoke and `disconnect` never + calls it. +""" + +from __future__ import annotations + +import logging +from collections.abc import Mapping +from typing import Any, ClassVar +from urllib.parse import urlencode + +import httpx + +from switch_core.bridges.collaboration.install import ( + InboundWebhook, + InstallGrant, + MessagingAppInstaller, + MessagingInstallError, + WebhookEndpoint, +) + +logger = logging.getLogger(__name__) + +AUTHORIZE_URL = "https://discord.com/oauth2/authorize" +TOKEN_URL = "https://discord.com/api/oauth2/token" + +#: The OAuth scopes the *Add to Server* URL asks for. `bot` adds the bot to the +#: guild; `applications.commands` is what lets Switch register its in-room +#: commands as native slash commands. Pinned in DISCORD_DISTRIBUTED_APP.md and +#: compared by a test. +SCOPES: tuple[str, ...] = ("bot", "applications.commands") + +#: The least-privilege guild permissions the authorize URL requests, as named +#: bits summed into the bitfield Discord takes. Each is a capability the adapter +#: actually exercises — nothing here is speculative, and adding one is a +#: decision this table records. The permission table in DISCORD_DISTRIBUTED_APP.md +#: lists the same bits, and a test pins the resulting integer. +_PERMISSION_BITS: dict[str, int] = { + "view_channel": 1 << 10, + "send_messages": 1 << 11, + "send_messages_in_threads": 1 << 38, + "manage_webhooks": 1 << 29, # mint the per-channel webhook agents post under + "manage_channels": 1 << 4, # provision channel access / private rooms + "manage_roles": 1 << 28, # per-agent mentionable role for @-autocomplete + "read_message_history": 1 << 16, + "attach_files": 1 << 15, + "add_reactions": 1 << 6, # mark the message an agent is working on +} + +#: The decimal bitfield the authorize URL carries in `permissions`. +PERMISSIONS: int = sum(_PERMISSION_BITS.values()) + + +class DiscordAppInstaller(MessagingAppInstaller): + platform: ClassVar[str] = "discord" + + def __init__( + self, + *, + client_id: str, + client_secret: str, + application_id: str, + ) -> None: + self._client_id = client_id + self._client_secret = client_secret + # Held so a caller can reason about which app the connection speaks for; + # the bot token itself is deployment config injected into the shared + # connection, not the installer's to hold. + self._application_id = application_id + + def authorize_url(self, *, state: str, redirect_uri: str) -> str: + return f"{AUTHORIZE_URL}?" + urlencode( + { + "client_id": self._client_id, + "scope": " ".join(SCOPES), + "permissions": str(PERMISSIONS), + "redirect_uri": redirect_uri, + "response_type": "code", + "state": state, + } + ) + + async def redeem(self, *, code: str, redirect_uri: str) -> InstallGrant: + """Exchange the code for the guild the bot was added to. + + Returns a tokenless grant: the response carries a user access token we + do not keep, and — because the `bot` scope was authorized — a `guild` + object naming the server the bot now belongs to, which is the only thing + the install records. + """ + async with httpx.AsyncClient() as http: + response = await http.post( + TOKEN_URL, + data={ + "client_id": self._client_id, + "client_secret": self._client_secret, + "grant_type": "authorization_code", + "code": code, + "redirect_uri": redirect_uri, + }, + headers={"Content-Type": "application/x-www-form-urlencoded"}, + ) + if response.status_code != httpx.codes.OK: + raise MessagingInstallError( + f"Discord refused the install ({response.status_code}): " + f"{response.text[:200]}" + ) + try: + payload: dict[str, Any] = response.json() + except ValueError as error: + raise MessagingInstallError( + "Discord accepted the install but returned a body that is not " + "JSON, so there is nothing to record. Nothing was saved." + ) from error + + guild = payload.get("guild") + if not isinstance(guild, dict): + raise MessagingInstallError( + "Discord accepted the install but returned no guild, which means " + "the bot was not added to a server (the `bot` scope was dropped, " + "or the user cancelled). Nothing was saved." + ) + guild_id = guild.get("id") + if not isinstance(guild_id, str) or not guild_id: + raise MessagingInstallError( + "Discord returned a guild with no id, so the install cannot be " + "attributed to a server. Nothing was saved." + ) + name = guild.get("name") + return InstallGrant( + external_workspace_id=guild_id, + workspace_name=name if isinstance(name, str) and name else guild_id, + # The one that makes this a Discord grant: no per-install token. + bot_token=None, + scopes=payload.get("scope") or "", + ) + + def connection_config(self, grant: InstallGrant) -> dict[str, object]: + return { + "guild_id": grant.external_workspace_id, + # The difference between the two Discord apps, mirroring Slack's + # `event_delivery`. Under `shared` the bridge opens no connection of + # its own and carries no token; leaving it out would validate as a + # self-registered bridge missing its bot token and fail at + # registration rather than at anything a reader would look at. + "event_delivery": "shared", + } + + # ── The webhook half the ABC declares and Discord does not have ─────────── + # + # Discord delivers over the Gateway (DISCORD_DISTRIBUTED_APP.md, "the one + # public URL"), so none of these is reached: no `/messaging/discord/events` + # traffic is routed, and `disconnect` skips `revoke` for a tokenless install. + # They raise rather than return so a future caller that wired one up by + # mistake fails loudly instead of silently doing nothing. + + async def revoke(self, *, bot_token: str) -> None: + raise NotImplementedError( + "a Discord install has no per-install token to revoke; the bot token " + "is deployment config shared by every install" + ) + + def verify_webhook(self, *, headers: Mapping[str, str], body: bytes) -> None: + raise NotImplementedError( + "the distributed Discord app has no webhook; events arrive over the Gateway" + ) + + def parse_webhook( + self, *, endpoint: WebhookEndpoint, headers: Mapping[str, str], body: bytes + ) -> InboundWebhook: + raise NotImplementedError( + "the distributed Discord app has no webhook; events arrive over the Gateway" + ) + + def workspace_of_event(self, payload: Mapping[str, object]) -> str: + raise NotImplementedError( + "the distributed Discord app has no webhook; a guild id is read off " + "the Gateway event, not an HTTP payload" + ) + + def revocation_of_event(self, payload: Mapping[str, object]) -> str | None: + raise NotImplementedError( + "the distributed Discord app has no webhook; a removal is a Gateway " + "event, not an HTTP payload" + ) diff --git a/core/switch_core/config.py b/core/switch_core/config.py index 1881292a4..b949cd412 100644 --- a/core/switch_core/config.py +++ b/core/switch_core/config.py @@ -194,6 +194,21 @@ class SwitchConfig(BaseSettings): slack_app_client_secret: str | None = None slack_app_signing_secret: str | None = None + # The distributed Discord app (`DISCORD_DISTRIBUTED_APP.md`): the one app + # *we* register and a customer adds to their server, distinct from the + # self-registered app whose token an operator pastes in. + # + # Four values and not three, and the shape difference from Slack is the last + # one: Discord grants no per-install token, so the bot token is deployment + # config that lives *here* and is injected into the one shared Gateway + # connection — not captured per install the way a Slack workspace token is. + # As with Slack, setting all of them is what enables installs (registration + # is the feature flag) and setting some is a startup error. + discord_app_client_id: str | None = None + discord_app_client_secret: str | None = None + discord_app_bot_token: str | None = None + discord_app_application_id: str | None = None + # Public origin (scheme + host, no path) that a messaging platform reaches # Switch on: the base of the OAuth redirect and of the three event URLs # under `/messaging`, and the one registered with the app. @@ -501,6 +516,36 @@ def _validate_slack_app(self) -> "SwitchConfig": ) return self + @model_validator(mode="after") + def _validate_discord_app(self) -> "SwitchConfig": + required = ( + self.discord_app_client_id, + self.discord_app_client_secret, + self.discord_app_bot_token, + self.discord_app_application_id, + ) + set_count = sum(1 for value in required if value) + if 0 < set_count < len(required): + raise ValueError( + "Partial distributed Discord app config: set all of " + "DISCORD_APP_CLIENT_ID / DISCORD_APP_CLIENT_SECRET / " + "DISCORD_APP_BOT_TOKEN / DISCORD_APP_APPLICATION_ID, or none " + "of them." + ) + # The OAuth redirect is built from the public origin, and Discord checks + # it matches the one registered with the app byte for byte. Without the + # origin a deployment offering the install button would build the + # redirect against nothing, so it is a startup error rather than an + # install that fails at Discord with nothing in our logs. + if set_count and not self.messaging_public_url: + raise ValueError( + "A distributed Discord app is configured but MESSAGING_PUBLIC_URL " + "is not. The install redirect is built from it, and Discord " + "rejects a redirect that does not match the one registered with " + "the app." + ) + return self + @property def gateway_oidc_enabled(self) -> bool: return bool( diff --git a/core/switch_core/main.py b/core/switch_core/main.py index 12ce05586..d79fe70e6 100644 --- a/core/switch_core/main.py +++ b/core/switch_core/main.py @@ -49,6 +49,7 @@ DiscordAdapter, DiscordConnectionConfig, ) +from switch_core.bridges.collaboration.discord.install import DiscordAppInstaller from switch_core.bridges.collaboration.install import MessagingInstallerRegistry from switch_core.bridges.collaboration.install_routes import ( create_messaging_install_router, @@ -500,6 +501,16 @@ async def run(config: SwitchConfig) -> None: signing_secret=config.slack_app_signing_secret, ) ) + if config.discord_app_client_id: + assert config.discord_app_client_secret is not None + assert config.discord_app_application_id is not None + installers.register( + DiscordAppInstaller( + client_id=config.discord_app_client_id, + client_secret=config.discord_app_client_secret, + application_id=config.discord_app_application_id, + ) + ) install_service: MessagingInstallService | None = None if installers.platforms(): diff --git a/core/tests/switch_core/bridges/collaboration/test_bridge_type_registry.py b/core/tests/switch_core/bridges/collaboration/test_bridge_type_registry.py index 6d0bb7940..c50802f4f 100644 --- a/core/tests/switch_core/bridges/collaboration/test_bridge_type_registry.py +++ b/core/tests/switch_core/bridges/collaboration/test_bridge_type_registry.py @@ -69,8 +69,14 @@ def test_discord_adapter_registers_with_expected_required_fields() -> None: schema = service.get_config_schema("discord") # agent_roles is offered but not required: it needs Manage Roles and room # under Discord's 250-role cap, so a connection stays valid without it. + # event_delivery is hidden (SkipJsonSchema): it is written by the install + # flow, not chosen on the form. assert set(schema["properties"]) == {"bot_token", "guild_id", "agent_roles"} - assert set(schema["required"]) == {"bot_token", "guild_id"} + # bot_token is offered but not required by the schema: a shared-connection + # bridge (the distributed app) has none. What enforces it for a self- + # registered bridge — which opens its own connection and receives nothing + # without one — is the model validator, not this form. + assert set(schema["required"]) == {"guild_id"} def test_telegram_adapter_registers_with_expected_required_fields() -> None: diff --git a/core/tests/switch_core/bridges/collaboration/test_discord_distributed_app.py b/core/tests/switch_core/bridges/collaboration/test_discord_distributed_app.py new file mode 100644 index 000000000..0410130ed --- /dev/null +++ b/core/tests/switch_core/bridges/collaboration/test_discord_distributed_app.py @@ -0,0 +1,61 @@ +"""The registration walkthrough and the code have to agree, so this compares them. + +`docs/old/bridges/DISCORD_DISTRIBUTED_APP.md` carries a pinned contract block: +the OAuth scopes the *Add to Server* URL asks for, the least-privilege +permission integer it requests, and the redirect it registers. Each is a +promise the running system keeps — Discord refuses a redirect that does not +match, and a permission the code needs but the URL never requested surfaces as a +customer's bot silently unable to do its job, with nothing in our logs. + +The contract is parsed out of the markdown rather than kept in a fixture, +because a fixture would be a third copy of a value that already lives in the +code and the doc. +""" + +from __future__ import annotations + +import json +import re +from pathlib import Path + +from switch_core.bridges.collaboration.discord.install import PERMISSIONS, SCOPES +from switch_core.bridges.collaboration.install import ( + oauth_callback_path, + public_url, +) + +_DOC = ( + Path(__file__).resolve().parents[5] + / "docs" + / "old" + / "bridges" + / "DISCORD_DISTRIBUTED_APP.md" +) + +_HOST = "HOST" + + +def _contract() -> dict: + text = _DOC.read_text() + blocks = re.findall(r"```json\n(.*?)\n```", text, re.DOTALL) + assert len(blocks) == 1, ( + f"expected exactly one json block in {_DOC.name}, found {len(blocks)}" + ) + return json.loads(blocks[0]) + + +def test_the_doc_requests_the_scopes_the_code_asks_for() -> None: + """Same scopes, same order — the order the authorize URL builds them in.""" + assert tuple(_contract()["scopes"]) == SCOPES + + +def test_the_doc_pins_the_permission_integer_the_code_pins() -> None: + """A decimal bitfield, exact. A drift here is a permission granted-and-unused + or needed-and-missing, and only the second is visible, late.""" + assert int(_contract()["permissions"]) == PERMISSIONS + + +def test_the_doc_registers_the_redirect_the_callback_is_served_at() -> None: + """The one mismatch Discord refuses outright, byte for byte.""" + expected = public_url(f"https://{_HOST}", oauth_callback_path("discord")) + assert _contract()["redirect_uri"] == expected diff --git a/core/tests/switch_core/bridges/collaboration/test_discord_installer.py b/core/tests/switch_core/bridges/collaboration/test_discord_installer.py new file mode 100644 index 000000000..abc24098d --- /dev/null +++ b/core/tests/switch_core/bridges/collaboration/test_discord_installer.py @@ -0,0 +1,182 @@ +"""The distributed Discord installer: the authorize URL, the tokenless grant, +and the two half-states its connection config refuses. + +Discord has no webhook, so the inbound half of the ABC is stubbed to raise; +those stubs are tested only to prove they fail loud rather than silently doing +nothing, because nothing reaches them in normal operation. +""" + +from __future__ import annotations + +from urllib.parse import parse_qs, urlparse + +import httpx +import pytest + +from switch_core.bridges.collaboration.discord.adapter import DiscordConnectionConfig +from switch_core.bridges.collaboration.discord.install import ( + PERMISSIONS, + SCOPES, + DiscordAppInstaller, +) +from switch_core.bridges.collaboration.install import MessagingInstallError + + +@pytest.fixture +def installer() -> DiscordAppInstaller: + return DiscordAppInstaller( + client_id="123456789012345678", + client_secret="secret", + application_id="123456789012345678", + ) + + +class TestTheAuthorizeUrl: + def test_it_carries_the_state_and_redirect_unchanged( + self, installer: DiscordAppInstaller + ) -> None: + redirect = "https://switch.example/messaging/discord/oauth/callback" + url = installer.authorize_url(state="opaque-state", redirect_uri=redirect) + query = parse_qs(urlparse(url).query) + assert query["state"] == ["opaque-state"] + assert query["redirect_uri"] == [redirect] + assert query["response_type"] == ["code"] + + def test_it_asks_for_the_pinned_scopes_and_permissions( + self, installer: DiscordAppInstaller + ) -> None: + """Decision #11: the scopes and permission integer are fixed in code, + and this is where the authorize URL is held to them.""" + url = installer.authorize_url(state="s", redirect_uri="https://x.example/cb") + query = parse_qs(urlparse(url).query) + assert query["scope"] == [" ".join(SCOPES)] + assert query["permissions"] == [str(PERMISSIONS)] + + +class TestRedeem: + async def _grant( + self, + installer: DiscordAppInstaller, + monkeypatch: pytest.MonkeyPatch, + response: httpx.Response, + ): + async def fake_post(self, url, **kwargs): # type: ignore[no-untyped-def] + return response + + monkeypatch.setattr(httpx.AsyncClient, "post", fake_post) + return await installer.redeem(code="the-code", redirect_uri="https://x/cb") + + async def test_a_good_exchange_becomes_a_tokenless_grant( + self, installer: DiscordAppInstaller, monkeypatch: pytest.MonkeyPatch + ) -> None: + grant = await self._grant( + installer, + monkeypatch, + httpx.Response( + 200, + json={ + "access_token": "user-token-we-drop", + "scope": "bot applications.commands", + "guild": {"id": "42", "name": "Acme"}, + }, + ), + ) + assert grant.external_workspace_id == "42" + assert grant.workspace_name == "Acme" + assert grant.bot_token is None + assert grant.scopes == "bot applications.commands" + + async def test_a_guild_with_no_name_falls_back_to_its_id( + self, installer: DiscordAppInstaller, monkeypatch: pytest.MonkeyPatch + ) -> None: + grant = await self._grant( + installer, + monkeypatch, + httpx.Response(200, json={"guild": {"id": "42"}}), + ) + assert grant.workspace_name == "42" + + async def test_a_non_2xx_is_refused( + self, installer: DiscordAppInstaller, monkeypatch: pytest.MonkeyPatch + ) -> None: + with pytest.raises(MessagingInstallError, match="Discord refused"): + await self._grant( + installer, monkeypatch, httpx.Response(400, text="invalid_grant") + ) + + async def test_no_guild_means_the_bot_was_not_added( + self, installer: DiscordAppInstaller, monkeypatch: pytest.MonkeyPatch + ) -> None: + """No `guild` in the response is the bot scope dropped or a cancel; there + is nothing to attribute the install to.""" + with pytest.raises(MessagingInstallError, match="no guild"): + await self._grant( + installer, + monkeypatch, + httpx.Response(200, json={"access_token": "t"}), + ) + + +class TestConnectionConfig: + def test_it_renders_a_shared_tokenless_config( + self, installer: DiscordAppInstaller + ) -> None: + from switch_core.bridges.collaboration.install import InstallGrant + + config = installer.connection_config( + InstallGrant( + external_workspace_id="42", + workspace_name="Acme", + bot_token=None, + scopes="bot applications.commands", + ) + ) + assert config == {"guild_id": "42", "event_delivery": "shared"} + + +class TestTheWebhookHalfIsStubbed: + """Discord delivers over the Gateway; these raise so a mistaken caller sees + it rather than silently getting nothing.""" + + async def test_revoke_raises(self, installer: DiscordAppInstaller) -> None: + with pytest.raises(NotImplementedError): + await installer.revoke(bot_token="whatever") + + def test_verify_webhook_raises(self, installer: DiscordAppInstaller) -> None: + with pytest.raises(NotImplementedError): + installer.verify_webhook(headers={}, body=b"") + + def test_parse_webhook_raises(self, installer: DiscordAppInstaller) -> None: + with pytest.raises(NotImplementedError): + installer.parse_webhook(endpoint="events", headers={}, body=b"") + + def test_workspace_of_event_raises(self, installer: DiscordAppInstaller) -> None: + with pytest.raises(NotImplementedError): + installer.workspace_of_event({}) + + def test_revocation_of_event_raises(self, installer: DiscordAppInstaller) -> None: + with pytest.raises(NotImplementedError): + installer.revocation_of_event({}) + + +class TestConnectionConfigHalfStates: + """The two shapes that look configured and cannot work.""" + + def test_own_connection_needs_a_token(self) -> None: + with pytest.raises(ValueError, match="bot_token is required"): + DiscordConnectionConfig(guild_id="42", event_delivery="own_connection") + + def test_shared_must_not_carry_a_token(self) -> None: + with pytest.raises(ValueError, match="bot_token must be empty"): + DiscordConnectionConfig( + guild_id="42", event_delivery="shared", bot_token="a-token" + ) + + def test_a_self_registered_config_still_validates(self) -> None: + """The default path is unchanged: own_connection with a token.""" + config = DiscordConnectionConfig(guild_id="42", bot_token="a-token") + assert config.event_delivery == "own_connection" + + def test_a_shared_config_validates_without_a_token(self) -> None: + config = DiscordConnectionConfig(guild_id="42", event_delivery="shared") + assert config.bot_token is None diff --git a/core/tests/switch_core/test_config_discord_app.py b/core/tests/switch_core/test_config_discord_app.py new file mode 100644 index 000000000..c3b4d77a1 --- /dev/null +++ b/core/tests/switch_core/test_config_discord_app.py @@ -0,0 +1,65 @@ +"""Half-configuring the distributed Discord app has to be a startup error. + +The four credentials are useless apart: without the client id and secret the +code exchange is refused, without the application id there is no app to speak +for, and without the bot token the shared Gateway connection has nothing to open +with. A deployment that sets three of four looks configured, offers the button, +and breaks — so it does not start. And an app with no public origin builds its +redirect against nothing, which Discord refuses with nothing in our logs. +""" + +import pytest + +from switch_core.config import SwitchConfig + +_BASE_KWARGS = dict( + db_host="db", + db_port="5432", + db_user="postgres", + db_password="pw", + db_name="switch", + matrix_server_name="switch.local", + agent_registration_token="token", + jwt_secret_key="jwt", + gateway_admin_email="admin@example.com", + gateway_admin_password="pw", +) + +_APP = dict( + discord_app_client_id="123456789012345678", + discord_app_client_secret="secret", + discord_app_bot_token="bot-token", + discord_app_application_id="123456789012345678", +) + + +def _config(**overrides: object) -> SwitchConfig: + return SwitchConfig(**{**_BASE_KWARGS, **overrides}) # type: ignore[arg-type] + + +def test_setting_none_of_them_is_the_ordinary_case() -> None: + assert _config().discord_app_client_id is None + + +def test_setting_all_four_with_a_public_origin_is_accepted() -> None: + config = _config(**_APP, messaging_public_url="https://switch.example") + assert config.discord_app_bot_token == "bot-token" + + +@pytest.mark.parametrize("missing", sorted(_APP)) +def test_setting_some_of_them_raises(missing: str) -> None: + partial = {key: value for key, value in _APP.items() if key != missing} + with pytest.raises(ValueError, match="Partial distributed Discord app config"): + _config(**partial, messaging_public_url="https://switch.example") + + +def test_an_app_with_no_public_origin_raises() -> None: + """The redirect is built from it, and Discord compares it byte for byte.""" + with pytest.raises(ValueError, match="MESSAGING_PUBLIC_URL"): + _config(**_APP) + + +def test_the_gateway_url_does_not_stand_in_for_it() -> None: + """They name different hosts and only one is a valid OAuth redirect origin.""" + with pytest.raises(ValueError, match="MESSAGING_PUBLIC_URL"): + _config(**_APP, gateway_public_url="https://gateway.example") diff --git a/docs/old/bridges/DISCORD_DISTRIBUTED_APP.md b/docs/old/bridges/DISCORD_DISTRIBUTED_APP.md new file mode 100644 index 000000000..52874c187 --- /dev/null +++ b/docs/old/bridges/DISCORD_DISTRIBUTED_APP.md @@ -0,0 +1,181 @@ +# The distributed Discord app + +`DISCORD_SETUP.md` describes the app **an operator registers for themselves**: +they create it, add its bot to their own server with a token they hold, and +paste that token into Switch. This page describes the other one — the app **we** +register and distribute, which a customer installs by clicking **Add to Server** +and which never requires them to see a token at all. + +They are two separate Discord applications and they will both exist. Nothing +here replaces the other page. + +The install connects a Discord server (**guild**) to a tenant that **already +exists**. Creating a tenant is Switch Console's job and is not reachable from +Discord: the flow begins with an authenticated admin inside the tenant they are +installing into, so no amount of clicking in Discord brings a tenant into being. + +## Why it cannot be the same app + +The self-registered app opens its **own** Gateway connection, scoped to one +guild, using a bot token the operator holds. That is the right shape for a +self-hosted operator and the wrong shape here, because a distributed app has no +per-customer token to open a per-customer connection with: + +- **Discord grants one bot token per application, not one per install.** Adding + the bot to a guild returns the *guild* (so we learn its id) and nothing we + need to keep — there is no per-guild credential to capture or store. The bot + authenticates to every guild with the single application bot token. +- **One bot token means one connection.** A connection opened with that token + receives every installing guild's events; it cannot be subscribed to one + tenant's guilds. So a single connection **multiplexes every tenant's + traffic**, and each inbound event is routed to a tenant by the `guild_id` it + carries. That is not a choice — with one bot token it is the only shape. + +So the delivery mechanism is the *same* as the self-registered app — an outbound +Gateway WebSocket, no inbound webhook — but the distributed app runs **one +shared connection for all tenants** instead of one per guild, and asks the +customer for nothing. Everything below follows from that. + +## The one public URL + +Discord delivers both messages and interactions over the Gateway, so unlike the +distributed Slack app there is **no events endpoint, no interactions endpoint, +and no request signature to verify**. The only route that must be reachable from +the internet is the **OAuth callback**: + +| Discord Developer Portal setting | Path | +| --- | --- | +| **OAuth2 → Redirects** | `/messaging/discord/oauth/callback` | + +The host is **`MESSAGING_PUBLIC_URL`**: scheme and host, no path, https only, +and the origin Discord itself sends the browser back to. + +It is deliberately not `GATEWAY_PUBLIC_URL`. That one is the host a *person* +lands on following an "Open in Switch Console" deeplink, and on many deployments +it is reachable only over a private network — which is fine for a person and +useless as an OAuth redirect. Pointing it at an internet-facing host to satisfy +Discord would move every deeplink to that host as a side effect, so the two are +separate settings and a deployment may set either, both, or neither. + +`/messaging` is a public prefix in its own right: it is unauthenticated by +nature, because an OAuth callback arrives before there is anything to +authenticate against. It is deliberately not `/gateway` — that prefix is +cookie-authenticated and is not routed to this application from the outside — and +deliberately not `/oauth`, which already belongs to agents authenticating *to* +Switch and would collide in name only, confusingly. + +Reaching it from the internet needs the prefix added in two places: the +application's own public-path list, and the deployment's ingress path allowlist. + +## Registering the app + +Unlike Slack, Discord verifies no Request URL on save (there is none), so the +app can be created and its credentials collected before the callback endpoint is +live. The callback only has to be reachable by the time a customer installs. + +1. [Discord Developer Portal](https://discord.com/developers/applications) → + **New Application**, in an account we control. Name it (e.g. "Agent Switch"). +2. **General Information → Application ID.** This is `DISCORD_APP_APPLICATION_ID`. +3. **OAuth2 → Client ID and Client Secret.** These are + `DISCORD_APP_CLIENT_ID` and `DISCORD_APP_CLIENT_SECRET`, and are what exchange + an install code for the guild the app was added to. Add the redirect URL from + the table above under **OAuth2 → Redirects** — Discord compares it byte for + byte against the one the install flow sends, so a trailing slash on one side + is a refused install with a message that does not say so. +4. **Bot → Token.** This is `DISCORD_APP_BOT_TOKEN`. There is exactly one, it + belongs to the application rather than to any install, and it is deployment + configuration — it is never stored per install and never revoked when a + customer disconnects. +5. **Installation → Activate public distribution.** This is what makes the app + installable outside our own account and produces the *Add to Server* URL. For + a private test guild you can skip it and install with the URL directly. + +There is no slash-command step in the portal: the distributed app registers its +commands **globally**, once for the application, from code at boot (see below). + +## The contract this app requests + +The *Add to Server* URL asks for two OAuth scopes and a single least-privilege +permission integer, and sends the browser back to the callback. These three +values are the promise the running system has to keep: the scopes it requests +are the scopes the code asks for, the permission integer is the one the code +pins, and the redirect is the path this application serves. Nothing checks them +at runtime — Discord simply refuses a redirect that does not match, and a +permission the code needs but the URL never asked for surfaces as a customer's +bot silently unable to do its job. + +So they are pinned in code and here, and a test compares the two. Substitute the +host in `redirect_uri`; `permissions` is a decimal bitfield and is exact. + +```json +{ + "scopes": ["bot", "applications.commands"], + "permissions": "275683314768", + "redirect_uri": "https://HOST/messaging/discord/oauth/callback" +} +``` + +`applications.commands` is what lets Switch register its in-room commands as +native Discord slash commands. The permission integer is the sum of exactly the +bits the adapter uses, and nothing more: + +| Permission | Bit | Why the adapter needs it | +| --- | --- | --- | +| View Channels | `1 << 10` | See the channels in the guild. | +| Send Messages | `1 << 11` | Post agent replies. | +| Send Messages in Threads | `1 << 38` | Reply inside a thread. | +| Manage Webhooks | `1 << 29` | Mint the per-channel webhook agents post under — without it agents cannot appear under their own names. | +| Manage Channels | `1 << 4` | Provision access to a channel (`set_permissions`, private-room creation). | +| Manage Roles | `1 << 28` | Give each agent a mentionable role so its name completes when you type `@`. | +| Read Message History | `1 << 16` | Reply in context within a thread. | +| Attach Files | `1 << 15` | Relay agent attachments. | +| Add Reactions | `1 << 6` | Mark the message an agent is working on. | + +Slash commands need no permission bit — they come from the `applications.commands` +scope. Editing and deleting the bot's own webhook messages needs no Manage +Messages. If you build the URL by hand in **OAuth2 → URL Generator**, selecting +these boxes produces the same integer. + +## Three things left out on purpose + +**The Message Content intent.** Reading arbitrary message text is a *privileged* +gateway intent, and requesting it while unapproved does not degrade gracefully — +Discord closes the whole connection with code 4014. So the intent is requested +conditionally, controlled by `DISCORD_APP_MESSAGE_CONTENT` and **defaulting +off**. Off, the connection opens fine and content still arrives for the cases the +intent is not needed for — messages that mention the bot and the bot's own +messages — which is exactly mention-only operation. On (once approved), agents +see all message content. Verification is *per application*, not per tenant, and +its ~100-guild threshold counts the total guilds across **every** tenant, so in +the distributed model the app crosses it quickly and verification is worth +starting early rather than deferring. + +**Direct messages.** A Discord DM carries no guild id, so it cannot be +attributed to a tenant. DM events are dropped explicitly — never silently — and +the shared connection does not request DM message intents. Attributing a DM (for +example by the sender's single shared installed guild) is deferred, and would +still have to refuse the ambiguous case of a user present in two tenants' guilds. + +**Per-guild slash commands.** The self-registered app registers its commands +per guild, because each of its bridges is scoped to one guild. The distributed +app registers them **globally**, once for the application, and routes each +invocation by the guild id it carries — there is no per-install registration +step and no registration traffic that grows with the number of guilds. The cost +is that a command-set change takes minutes to propagate rather than applying +immediately. + +## What a customer's install produces + +One row in `messaging_installs`: the guild it was installed into (as +`external_workspace_id`), a **null** `encrypted_bot_token` — a Discord install +stores no credential, because the bot token is deployment configuration and not +this install's to hold — the scopes Discord approved, and the tenant and user +who initiated it. `(platform, external_workspace_id)` is unique across the whole +deployment, because an inbound event carries a guild id and no tenant — a guild +claimed by two tenants would be an event with two possible destinations. A +second tenant attempting to claim an already-installed guild is refused by the +database. + +Disconnecting ends that row and detaches its rooms, but **revokes no token**: +the credential is the application's, shared by every install, and is never ended +on one customer's disconnect. From 4094224061dd56bf152e23d7246b35a8e81b93a5 Mon Sep 17 00:00:00 2001 From: lbangalosbt Date: Wed, 16 Sep 2026 11:15:13 -0400 Subject: [PATCH 21/29] feat(discord): a shared-delivery bridge is constructible and inert MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Stage 3 (first half of the split): make the distributed app's per-guild bridge exist without a connection of its own, so completing a Discord install produces a tokenless install pointed at a real — if inert — bridge instead of a 500. Attaching it to the shared Gateway connection, and the removal / out-of-band-join / DM handling that ride Gateway events, land with that connection in the next stage. Under `event_delivery == "shared"` the adapter builds no DiscordConnection (`_connection` is None), `start()` returns without dialling Discord (it keeps its callbacks for when the shared connection attaches), and `stop()` has nothing to close. Every outbound path goes through a new `_require_connection()` that raises rather than no-ops, so an inert bridge fails loud if something drives it before it is attached — never fakes a send. The self-registered (`own_connection`) path is unchanged: it still builds and owns its connection, and all existing Discord adapter tests pass untouched. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../bridges/collaboration/discord/adapter.py | 81 +++++++++++++------ .../test_discord_shared_bridge.py | 81 +++++++++++++++++++ 2 files changed, 136 insertions(+), 26 deletions(-) create mode 100644 core/tests/switch_core/bridges/collaboration/test_discord_shared_bridge.py diff --git a/core/switch_core/bridges/collaboration/discord/adapter.py b/core/switch_core/bridges/collaboration/discord/adapter.py index 353c87fba..529cfd25e 100644 --- a/core/switch_core/bridges/collaboration/discord/adapter.py +++ b/core/switch_core/bridges/collaboration/discord/adapter.py @@ -198,25 +198,23 @@ def __init__(self, *, config: DiscordConnectionConfig) -> None: super().__init__() self._config = config self._guild_id = int(config.guild_id) - if config.bot_token is None: - # A shared-delivery bridge carries no token and does not open its - # own Gateway connection; it registers its guild with the one shared - # connection instead. That connection is not built yet — the inert - # bridge and the shared client land in later stages — so a shared - # bridge is not startable here, and this fails loud rather than - # constructing an adapter that would silently receive nothing. - raise NotImplementedError( - "a shared-delivery Discord bridge does not open its own Gateway " - "connection; the shared connection is not built yet" - ) # The Gateway socket lives on the connection, not the adapter: the # socket is per bot token and the adapter is per guild. Intents are # built here and handed over, so the socket owner does not decide them. - self._connection = DiscordConnection( - bot_token=config.bot_token, - intents=self._build_intents(), - command_guild_id=self._guild_id, - ) + # + # A self-registered bridge owns its connection, built now from its + # token. A distributed (shared-delivery) bridge owns none: it is inert + # until it is attached to the one deployment-level connection, which is + # not built yet — so `_connection` stays None and every outbound path + # fails loud through `_require_connection` rather than pretending. + self._connection: DiscordConnection | None = None + if config.event_delivery == "own_connection": + assert config.bot_token is not None # guaranteed by the validator + self._connection = DiscordConnection( + bot_token=config.bot_token, + intents=self._build_intents(), + command_guild_id=self._guild_id, + ) # channel id -> webhook the bridge posts through in that channel. self._webhooks: dict[int, discord.Webhook] = {} # Ids of webhooks the bridge has minted/adopted, for echo dropping. @@ -265,17 +263,31 @@ async def start( self._on_user_joined = on_user_joined self._on_app_joined = on_app_joined + if self._config.event_delivery == "shared": + # Inert: a shared-delivery bridge opens no connection of its own and + # is not yet attached to the shared one. It exists as a bridge — its + # rooms, the operator's list, moderation — but neither sends nor + # receives until the shared Gateway connection is built and hands it + # its guild. The callbacks are kept for that moment. + logger.info( + "Discord bridge for guild %s registered inert (shared delivery); " + "awaiting the shared Gateway connection", + self._config.guild_id, + ) + return + # One guild, one handler; the DM handler is the same adapter so direct # messages still reach it. The connection routes each message by guild # id, which for a single-guild bridge is exactly the old filter. - self._connection.register_message_handler(self._guild_id, self._handle_message) - self._connection.set_dm_handler(self._handle_message) - await self._connection.connect( + conn = self._require_connection() + conn.register_message_handler(self._guild_id, self._handle_message) + conn.set_dm_handler(self._handle_message) + await conn.connect( commands=build_app_commands(self._handle_slash_command), ) logger.info( "Discord adapter connected as %s (guild %s)", - self._connection.client.user, + conn.client.user, self._config.guild_id, ) @@ -290,12 +302,29 @@ def _build_intents() -> discord.Intents: return intents async def stop(self) -> None: - await self._connection.close() + # A shared-delivery bridge has no connection of its own to close; + # stopping it is just dropping its per-guild state. + if self._connection is not None: + await self._connection.close() self._webhooks.clear() logger.info("Discord adapter stopped") + def _require_connection(self) -> DiscordConnection: + """The bridge's Gateway connection, or a loud error if it has none. + + A shared-delivery bridge is inert until it is attached to the shared + connection; reaching an outbound path before that is a bug, and this + surfaces it rather than letting the call no-op or crash obscurely. + """ + if self._connection is None: + raise RuntimeError( + "this Discord bridge uses shared delivery and is not attached to " + "the shared Gateway connection yet" + ) + return self._connection + def _require_client(self) -> discord.Client: - return self._connection.client + return self._require_connection().client # ── Messaging ──────────────────────────────────────────────────────────── @@ -748,7 +777,7 @@ async def _mark_being_read(self, message_ref: str, *, working: bool) -> None: and no reaction, rather than a mark that is not there. """ location_id, message_id = self._parse_message_ref(message_ref) - client = self._connection.client_or_none + client = self._require_connection().client_or_none if not message_id or client is None: return if working == (message_ref in self._eyes): @@ -1098,7 +1127,7 @@ def _disable_agent_roles(self, reason: str) -> None: ) def _guild_from_cache(self) -> Any: - client = self._connection.client_or_none + client = self._require_connection().client_or_none return client.get_guild(self._guild_id) if client else None def _role_name(self, role_id: int) -> str | None: @@ -1188,7 +1217,7 @@ def _replace_role(match: re.Match[str]) -> str: return f"@{name}" if name else match.group(0) def _replace_channel(match: re.Match[str]) -> str: - client = self._connection.client_or_none + client = self._require_connection().client_or_none channel = client.get_channel(int(match.group(1))) if client else None name = getattr(channel, "name", None) return f"#{name}" if name else match.group(0) @@ -1210,7 +1239,7 @@ async def _handle_message(self, message: Any) -> None: # this handler only ever sees its own guild's messages and DMs — the # guild filter that used to live here now lives in DiscordConnection. author = message.author - bot_user_id = self._connection.bot_user_id + bot_user_id = self._require_connection().bot_user_id # Drop only our own posts (loop prevention): the bot itself and the # bridge's webhooks. Third-party bots/webhooks are still bridged. if author.id == bot_user_id: diff --git a/core/tests/switch_core/bridges/collaboration/test_discord_shared_bridge.py b/core/tests/switch_core/bridges/collaboration/test_discord_shared_bridge.py new file mode 100644 index 000000000..9ffcf1132 --- /dev/null +++ b/core/tests/switch_core/bridges/collaboration/test_discord_shared_bridge.py @@ -0,0 +1,81 @@ +"""A shared-delivery Discord bridge is constructible and inert. + +The distributed app's per-guild bridge (decision #3) opens no Gateway connection +of its own — it will register its guild with the one shared connection once that +is built. Until then it exists as a bridge but neither sends nor receives, and +any outbound path fails loud rather than pretending. This pins that state so a +completing install can produce such a bridge without it crashing on start and +without it silently swallowing sends. +""" + +from __future__ import annotations + +import pytest + +from switch_core.bridges.collaboration.discord.adapter import ( + DiscordAdapter, + DiscordConnectionConfig, +) +from switch_core.bridges.collaboration.models import ( + InboundAgentJoin, + InboundAppJoin, + InboundCommand, + InboundMessage, + InboundUserJoin, +) + +GUILD_ID = "900" + + +def _shared_adapter() -> DiscordAdapter: + return DiscordAdapter( + config=DiscordConnectionConfig(guild_id=GUILD_ID, event_delivery="shared") + ) + + +async def _noop_message(_: InboundMessage) -> None: ... +async def _noop_command(_: InboundCommand) -> None: ... +async def _noop_agent(_: InboundAgentJoin) -> None: ... +async def _noop_user(_: InboundUserJoin) -> None: ... +async def _noop_app(_: InboundAppJoin) -> None: ... + + +async def _start(adapter: DiscordAdapter) -> None: + await adapter.start( + _noop_message, _noop_command, _noop_agent, _noop_user, _noop_app + ) + + +def test_a_shared_config_builds_an_adapter_with_no_connection() -> None: + adapter = _shared_adapter() + assert adapter._connection is None + + +def test_an_own_connection_config_still_builds_its_connection() -> None: + adapter = DiscordAdapter( + config=DiscordConnectionConfig(guild_id=GUILD_ID, bot_token="token") + ) + assert adapter._connection is not None + + +async def test_inert_start_opens_no_connection() -> None: + """start() returns without dialling Discord and stores the callbacks for the + moment the shared connection attaches; nothing is opened.""" + adapter = _shared_adapter() + await _start(adapter) + assert adapter._connection is None + assert adapter._on_message is _noop_message + + +async def test_stopping_an_inert_bridge_is_safe() -> None: + adapter = _shared_adapter() + await _start(adapter) + await adapter.stop() # no connection to close + + +async def test_an_outbound_path_fails_loud_while_inert() -> None: + """It does not silently no-op: reaching the client before the shared + connection is attached is a bug, and it surfaces as one.""" + adapter = _shared_adapter() + with pytest.raises(RuntimeError, match="shared delivery"): + adapter._require_client() From 99a5db7ebddf686a11fd6f0f61605b6ca92aeeb3 Mon Sep 17 00:00:00 2001 From: lbangalosbt Date: Wed, 16 Sep 2026 11:28:08 -0400 Subject: [PATCH 22/29] feat(discord): the shared deployment-level Gateway connection (Stage 4a) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit First reviewable piece of Stage 4: the one Gateway connection the distributed Discord app multiplexes every tenant over. DiscordGatewayClient owns a single DiscordConnection built with the application bot token, binds no tenant, registers commands globally (command_guild_id=None, decision #7), and is started at boot before the bridges and stopped on shutdown. It lives in the one switch-core pod (a forced singleton), so there is never a second owner and no leader election. Its intents are the least a shared, multi-tenant connection needs: no dm_messages (a DM carries no guild to attribute to a tenant, and the DM handler is left unset — guard G4 begins here) and no members (a privileged intent that would close the connection past Discord's ~100-guild verification threshold; member lookups fall back to API fetches). message_content is privileged and gated behind DISCORD_APP_MESSAGE_CONTENT, default off (mention-only) — decision #5. A configured-but-unreachable Discord app must not take down a pod serving every other platform, so a failed start is logged and Discord installs stay inert until it reconnects, rather than fatal. Attaching each guild's inert bridge to this connection, slash routing, removal handling and the remaining guards land in the following commits. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../bridges/collaboration/discord/gateway.py | 79 +++++++++++++++++++ core/switch_core/config.py | 8 ++ core/switch_core/main.py | 24 ++++++ .../collaboration/test_discord_gateway.py | 44 +++++++++++ 4 files changed, 155 insertions(+) create mode 100644 core/switch_core/bridges/collaboration/discord/gateway.py create mode 100644 core/tests/switch_core/bridges/collaboration/test_discord_gateway.py diff --git a/core/switch_core/bridges/collaboration/discord/gateway.py b/core/switch_core/bridges/collaboration/discord/gateway.py new file mode 100644 index 000000000..e7c2fb93d --- /dev/null +++ b/core/switch_core/bridges/collaboration/discord/gateway.py @@ -0,0 +1,79 @@ +"""The one deployment-level Discord Gateway connection. + +The distributed Discord app authenticates to every tenant's guild with a single +application bot token, and one token means one Gateway connection (see +`DISCORD_DISTRIBUTED_APP.md`). This client owns that connection: it holds the +bot token, opens the socket, and **binds no tenant** — each event is routed to a +guild's inert bridge, and every tenant binding happens below, per room. + +It is not a bridge and does not go through the collaboration lifecycle. It is a +single deployment-level object, started at boot alongside the bridges and living +in the one switch-core pod (a forced singleton), so there is never a second +owner of the socket and no leader election to arrange. + +This module is the foundation: it opens the shared socket with the right intents +and registers no commands yet. Attaching each guild's inert bridge to it, slash +routing, removal handling and the isolation guards land in the stages after. +""" + +from __future__ import annotations + +import logging + +import discord + +from switch_core.bridges.collaboration.discord.connection import DiscordConnection + +logger = logging.getLogger(__name__) + + +class DiscordGatewayClient: + def __init__(self, *, bot_token: str, message_content: bool) -> None: + self._message_content = message_content + # command_guild_id=None → commands register globally, once for the + # application across every guild (decision #7); guild-scoped registration + # is the self-registered adapter's, which serves one guild. + self._connection = DiscordConnection( + bot_token=bot_token, + intents=self._build_intents(message_content), + command_guild_id=None, + ) + + @staticmethod + def _build_intents(message_content: bool) -> discord.Intents: + """The least intents a multi-tenant shared connection needs. + + No `dm_messages`: a DM carries no guild, so it cannot be attributed to a + tenant, and the connection leaves its DM handler unset so any that + arrive are dropped (guard G4). No `members` either: it is a *privileged* + intent like message content, so requesting it unapproved would close the + connection past Discord's ~100-guild verification threshold — which the + distributed app crosses quickly — and member lookups fall back to API + fetches without it. + + `message_content` is privileged and defaults off (mention-only): the + connection still opens, and the bot still sees messages that mention it + and its own — turning it on (once verified) is what gives agents full + message text. + """ + intents = discord.Intents.none() + intents.guilds = True + intents.guild_messages = True + intents.message_content = message_content + return intents + + @property + def connection(self) -> DiscordConnection: + return self._connection + + async def start(self) -> None: + # No commands yet — global slash routing lands in a later stage. The DM + # handler is deliberately left unset (guard G4). + await self._connection.connect(commands=[]) + logger.info( + "Discord shared Gateway connection started (message_content=%s)", + self._message_content, + ) + + async def stop(self) -> None: + await self._connection.close() diff --git a/core/switch_core/config.py b/core/switch_core/config.py index b949cd412..7d1643f07 100644 --- a/core/switch_core/config.py +++ b/core/switch_core/config.py @@ -209,6 +209,14 @@ class SwitchConfig(BaseSettings): discord_app_bot_token: str | None = None discord_app_application_id: str | None = None + # Whether the shared Gateway connection requests the privileged message- + # content intent. Off by default (mention-only): the connection opens + # unapproved and agents still see mentions of the bot and its own messages. + # Requesting it while unapproved closes the connection past Discord's + # ~100-guild verification threshold, so it is a deliberate flag flipped once + # the app is verified — not something inferred (decision #5). + discord_app_message_content: bool = False + # Public origin (scheme + host, no path) that a messaging platform reaches # Switch on: the base of the OAuth redirect and of the three event URLs # under `/messaging`, and the one registered with the app. diff --git a/core/switch_core/main.py b/core/switch_core/main.py index d79fe70e6..8e6187eac 100644 --- a/core/switch_core/main.py +++ b/core/switch_core/main.py @@ -49,6 +49,7 @@ DiscordAdapter, DiscordConnectionConfig, ) +from switch_core.bridges.collaboration.discord.gateway import DiscordGatewayClient from switch_core.bridges.collaboration.discord.install import DiscordAppInstaller from switch_core.bridges.collaboration.install import MessagingInstallerRegistry from switch_core.bridges.collaboration.install_routes import ( @@ -604,6 +605,25 @@ async def lifespan(app: object) -> AsyncIterator[None]: # ── Start runtime ──────────────────────────────────────────────────────── await client_lifecycle.start_all() + + # The one shared Discord Gateway connection, up before its inert bridges + # would attach to it. A configured-but-unreachable Discord app must not take + # down a pod that serves every other platform, so a failure here is logged + # and Discord installs stay inert until it recovers, rather than fatal. + discord_gateway: DiscordGatewayClient | None = None + if config.discord_app_bot_token: + discord_gateway = DiscordGatewayClient( + bot_token=config.discord_app_bot_token, + message_content=config.discord_app_message_content, + ) + try: + await discord_gateway.start() + except Exception: + logger.exception( + "The shared Discord Gateway connection failed to start; Discord " + "installs will be inert until it is reconnected" + ) + await collab_lifecycle.start_all() # Backfill room membership: system clients (e.g. the admin client) added @@ -635,6 +655,7 @@ async def lifespan(app: object) -> AsyncIterator[None]: collab_lifecycle, connector_lifecycle, matrix_admin, + discord_gateway, ) ), ) @@ -1082,11 +1103,14 @@ async def _shutdown( collab_lifecycle: CollaborationBridgeLifecycleService, connector_lifecycle: ServerSideConnectorLifecycleService, matrix_admin: Provisioning, + discord_gateway: DiscordGatewayClient | None, ) -> None: logger.info("Shutting down...") server.should_exit = True await connector_lifecycle.stop_all() await collab_lifecycle.stop_all() + if discord_gateway is not None: + await discord_gateway.stop() await client_lifecycle.stop_all() await matrix_admin.close() diff --git a/core/tests/switch_core/bridges/collaboration/test_discord_gateway.py b/core/tests/switch_core/bridges/collaboration/test_discord_gateway.py new file mode 100644 index 000000000..7b1dfcc0a --- /dev/null +++ b/core/tests/switch_core/bridges/collaboration/test_discord_gateway.py @@ -0,0 +1,44 @@ +"""The shared, deployment-level Discord Gateway connection. + +Stage 4a: the connection exists with the right intents and command scope, binds +no tenant, and never opens the door to a direct message (guard G4 begins here — +no DM intent and no DM handler). Attaching bridges, routing and removal come +later; this pins the foundation. +""" + +from __future__ import annotations + +from switch_core.bridges.collaboration.discord.gateway import DiscordGatewayClient + + +def _gateway(*, message_content: bool = False) -> DiscordGatewayClient: + return DiscordGatewayClient(bot_token="bot-token", message_content=message_content) + + +def test_it_requests_no_dm_or_members_intent() -> None: + """G4 starts here (no DM intent), and members is privileged, so a shared + multi-tenant connection does not request it either.""" + intents = _gateway().connection._intents + assert intents.guilds is True + assert intents.guild_messages is True + assert intents.dm_messages is False + assert intents.members is False + + +def test_message_content_is_off_by_default() -> None: + assert _gateway().connection._intents.message_content is False + + +def test_message_content_can_be_turned_on() -> None: + assert _gateway(message_content=True).connection._intents.message_content is True + + +def test_commands_register_globally_not_per_guild() -> None: + """Decision #7: one application-wide command set, not one per guild.""" + assert _gateway().connection._command_guild_id is None + + +def test_no_dm_handler_is_wired() -> None: + """A DM carries no guild, so it cannot be attributed to a tenant; the slot + is left empty so any that arrive are dropped (G4).""" + assert _gateway().connection._dm_handler is None From 268219fb998c77e59e3a6a5237413ea800bab5b8 Mon Sep 17 00:00:00 2001 From: lbangalosbt Date: Wed, 16 Sep 2026 11:30:43 -0400 Subject: [PATCH 23/29] feat(discord): gate the members intent behind DISCORD_APP_MEMBERS The server-members intent is privileged the same way message content is: requesting it unapproved closes the shared connection past Discord's ~100-guild threshold. So the shared Gateway connection requests it only when DISCORD_APP_MEMBERS is set (default off), on its own flag rather than riding message content's, because the two are approved independently. Off, member lookups fall back to API fetches; on (once verified), the bot fills its member cache. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../bridges/collaboration/discord/gateway.py | 26 +++++++-------- core/switch_core/config.py | 8 +++++ core/switch_core/main.py | 1 + .../collaboration/test_discord_gateway.py | 32 ++++++++++++------- 4 files changed, 43 insertions(+), 24 deletions(-) diff --git a/core/switch_core/bridges/collaboration/discord/gateway.py b/core/switch_core/bridges/collaboration/discord/gateway.py index e7c2fb93d..4eaaad042 100644 --- a/core/switch_core/bridges/collaboration/discord/gateway.py +++ b/core/switch_core/bridges/collaboration/discord/gateway.py @@ -28,38 +28,38 @@ class DiscordGatewayClient: - def __init__(self, *, bot_token: str, message_content: bool) -> None: + def __init__(self, *, bot_token: str, message_content: bool, members: bool) -> None: self._message_content = message_content + self._members = members # command_guild_id=None → commands register globally, once for the # application across every guild (decision #7); guild-scoped registration # is the self-registered adapter's, which serves one guild. self._connection = DiscordConnection( bot_token=bot_token, - intents=self._build_intents(message_content), + intents=self._build_intents(message_content, members), command_guild_id=None, ) @staticmethod - def _build_intents(message_content: bool) -> discord.Intents: + def _build_intents(message_content: bool, members: bool) -> discord.Intents: """The least intents a multi-tenant shared connection needs. No `dm_messages`: a DM carries no guild, so it cannot be attributed to a tenant, and the connection leaves its DM handler unset so any that - arrive are dropped (guard G4). No `members` either: it is a *privileged* - intent like message content, so requesting it unapproved would close the - connection past Discord's ~100-guild verification threshold — which the - distributed app crosses quickly — and member lookups fall back to API - fetches without it. - - `message_content` is privileged and defaults off (mention-only): the - connection still opens, and the bot still sees messages that mention it - and its own — turning it on (once verified) is what gives agents full - message text. + arrive are dropped (guard G4). + + `message_content` and `members` are both *privileged* and default off: + requesting either unapproved closes the connection past Discord's + ~100-guild verification threshold, which the distributed app crosses + quickly. Off, the connection still opens — the bot sees messages that + mention it and its own, and member lookups fall back to API fetches; + each is turned on independently once the app is verified for it. """ intents = discord.Intents.none() intents.guilds = True intents.guild_messages = True intents.message_content = message_content + intents.members = members return intents @property diff --git a/core/switch_core/config.py b/core/switch_core/config.py index 7d1643f07..9ea25e765 100644 --- a/core/switch_core/config.py +++ b/core/switch_core/config.py @@ -217,6 +217,14 @@ class SwitchConfig(BaseSettings): # the app is verified — not something inferred (decision #5). discord_app_message_content: bool = False + # Whether the shared Gateway connection requests the privileged server- + # members intent. Off by default, and privileged the same way message + # content is: requesting it unapproved closes the connection past the + # ~100-guild threshold. Off, member lookups fall back to API fetches; on + # (once verified), the bot fills its member cache. Its own flag rather than + # riding message content's, because the two are approved independently. + discord_app_members: bool = False + # Public origin (scheme + host, no path) that a messaging platform reaches # Switch on: the base of the OAuth redirect and of the three event URLs # under `/messaging`, and the one registered with the app. diff --git a/core/switch_core/main.py b/core/switch_core/main.py index 8e6187eac..842574b26 100644 --- a/core/switch_core/main.py +++ b/core/switch_core/main.py @@ -615,6 +615,7 @@ async def lifespan(app: object) -> AsyncIterator[None]: discord_gateway = DiscordGatewayClient( bot_token=config.discord_app_bot_token, message_content=config.discord_app_message_content, + members=config.discord_app_members, ) try: await discord_gateway.start() diff --git a/core/tests/switch_core/bridges/collaboration/test_discord_gateway.py b/core/tests/switch_core/bridges/collaboration/test_discord_gateway.py index 7b1dfcc0a..b2dec7539 100644 --- a/core/tests/switch_core/bridges/collaboration/test_discord_gateway.py +++ b/core/tests/switch_core/bridges/collaboration/test_discord_gateway.py @@ -11,28 +11,38 @@ from switch_core.bridges.collaboration.discord.gateway import DiscordGatewayClient -def _gateway(*, message_content: bool = False) -> DiscordGatewayClient: - return DiscordGatewayClient(bot_token="bot-token", message_content=message_content) - - -def test_it_requests_no_dm_or_members_intent() -> None: - """G4 starts here (no DM intent), and members is privileged, so a shared - multi-tenant connection does not request it either.""" +def _gateway( + *, message_content: bool = False, members: bool = False +) -> DiscordGatewayClient: + return DiscordGatewayClient( + bot_token="bot-token", + message_content=message_content, + members=members, + ) + + +def test_it_requests_no_dm_intent_and_privileged_intents_default_off() -> None: + """G4 starts here (no DM intent). message_content and members are both + privileged and off by default so the connection opens unapproved.""" intents = _gateway().connection._intents assert intents.guilds is True assert intents.guild_messages is True assert intents.dm_messages is False + assert intents.message_content is False assert intents.members is False -def test_message_content_is_off_by_default() -> None: - assert _gateway().connection._intents.message_content is False - - def test_message_content_can_be_turned_on() -> None: assert _gateway(message_content=True).connection._intents.message_content is True +def test_members_can_be_turned_on_independently() -> None: + """Its own flag, approved separately from message content.""" + intents = _gateway(members=True).connection._intents + assert intents.members is True + assert intents.message_content is False + + def test_commands_register_globally_not_per_guild() -> None: """Decision #7: one application-wide command set, not one per guild.""" assert _gateway().connection._command_guild_id is None From 3888f42648129c88a6617e85eb1434c3569401e2 Mon Sep 17 00:00:00 2001 From: lbangalosbt Date: Wed, 16 Sep 2026 12:59:40 -0400 Subject: [PATCH 24/29] feat(discord): route shared-connection messages to their guild's bridge (Stage 4b) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The shared connection now delivers. It carries one catch-all guild handler (not a per-guild registry that would cache which tenant a guild belongs to on the connection), and every message is resolved fresh: - read the guild id off the Gateway event and resolve it through the new MessagingInstallService.resolve_by_workspace — the same tenant lookup and RLS-scoped install re-read the webhook path uses, kept inside the module already allowlisted for the exemption, just reached without a webhook; - a guild with no active install resolves to nothing and is dropped, never routed to a default or first tenant (G3); - the resolved inert bridge is handed the shared connection the first time it is used (lazily, since rooms — and therefore any outbound — only exist after a first inbound message), and the event is dispatched with no tenant bound, each handler binding the tenant of the room it acts on (G1). resolve_by_workspace is split out of resolve() (behaviour-preserving); the connection gains a set_guild_message_handler slot that takes precedence over the per-guild registry (a connection is only ever one shape); the adapter gains ensure_shared_connection (idempotent inject) and a thin dispatch_inbound entry so the shared client does not reach into it. main.py hands the gateway the install service. Guards G1 and G3 land here with tests. Global slash routing, removal/DM handling and G2/G4 tests follow. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../bridges/collaboration/discord/adapter.py | 21 +++ .../collaboration/discord/connection.py | 16 ++ .../bridges/collaboration/discord/gateway.py | 68 +++++++- .../bridges/collaboration/install_service.py | 21 ++- core/switch_core/main.py | 5 + .../collaboration/test_discord_gateway.py | 6 + .../test_discord_gateway_routing.py | 161 ++++++++++++++++++ 7 files changed, 291 insertions(+), 7 deletions(-) create mode 100644 core/tests/switch_core/bridges/collaboration/test_discord_gateway_routing.py diff --git a/core/switch_core/bridges/collaboration/discord/adapter.py b/core/switch_core/bridges/collaboration/discord/adapter.py index 529cfd25e..2cb958d78 100644 --- a/core/switch_core/bridges/collaboration/discord/adapter.py +++ b/core/switch_core/bridges/collaboration/discord/adapter.py @@ -309,6 +309,27 @@ async def stop(self) -> None: self._webhooks.clear() logger.info("Discord adapter stopped") + def ensure_shared_connection(self, connection: DiscordConnection) -> None: + """Attach the shared Gateway connection the first time this bridge is used. + + A shared-delivery bridge is built inert (no connection of its own); the + deployment-level Gateway client injects its connection here so the + adapter's inbound handling and its outbound posting both run against it. + Idempotent and set-once: an own-connection bridge already has one and is + left alone, and repeated calls after the first are no-ops. + """ + if self._connection is None: + self._connection = connection + + async def dispatch_inbound(self, message: discord.Message) -> None: + """Handle one inbound Gateway message the shared client routed here. + + The shared connection resolves a guild to this bridge and calls this; + the self-registered connection calls the same handler directly. Kept a + thin public entry so the shared client does not reach into the adapter. + """ + await self._handle_message(message) + def _require_connection(self) -> DiscordConnection: """The bridge's Gateway connection, or a loud error if it has none. diff --git a/core/switch_core/bridges/collaboration/discord/connection.py b/core/switch_core/bridges/collaboration/discord/connection.py index ae6c36972..d0801bc2a 100644 --- a/core/switch_core/bridges/collaboration/discord/connection.py +++ b/core/switch_core/bridges/collaboration/discord/connection.py @@ -58,6 +58,15 @@ def __init__( self._message_handlers: dict[ int, Callable[[discord.Message], Awaitable[None]] ] = {} + # A single handler for *every* guild's messages, used by the shared + # multi-tenant connection: it routes each event by resolving the guild + # to its tenant fresh, rather than keeping a per-guild registry that + # would cache which tenant a guild belongs to on the connection. When + # set it takes precedence over the per-guild registry (a connection is + # only ever one shape or the other). + self._guild_message_handler: ( + Callable[[discord.Message], Awaitable[None]] | None + ) = None self._dm_handler: Callable[[discord.Message], Awaitable[None]] | None = None def register_message_handler( @@ -68,6 +77,11 @@ def register_message_handler( def unregister_message_handler(self, guild_id: int) -> None: self._message_handlers.pop(guild_id, None) + def set_guild_message_handler( + self, handler: Callable[[discord.Message], Awaitable[None]] | None + ) -> None: + self._guild_message_handler = handler + def set_dm_handler( self, handler: Callable[[discord.Message], Awaitable[None]] | None ) -> None: @@ -202,6 +216,8 @@ async def on_message(message: discord.Message) -> None: guild = message.guild if guild is None: handler = self._dm_handler + elif self._guild_message_handler is not None: + handler = self._guild_message_handler else: handler = self._message_handlers.get(guild.id) if handler is None: diff --git a/core/switch_core/bridges/collaboration/discord/gateway.py b/core/switch_core/bridges/collaboration/discord/gateway.py index 4eaaad042..132607243 100644 --- a/core/switch_core/bridges/collaboration/discord/gateway.py +++ b/core/switch_core/bridges/collaboration/discord/gateway.py @@ -22,15 +22,32 @@ import discord +from switch_core.bridges.collaboration.discord.adapter import DiscordAdapter from switch_core.bridges.collaboration.discord.connection import DiscordConnection +from switch_core.bridges.collaboration.install_service import ( + MessagingInstallService, + WebhookBridgeUnavailable, + WebhookWorkspaceUnknown, +) +from switch_core.tenant_context import no_tenant logger = logging.getLogger(__name__) +_PLATFORM = "discord" + class DiscordGatewayClient: - def __init__(self, *, bot_token: str, message_content: bool, members: bool) -> None: + def __init__( + self, + *, + bot_token: str, + message_content: bool, + members: bool, + install_service: MessagingInstallService, + ) -> None: self._message_content = message_content self._members = members + self._install_service = install_service # command_guild_id=None → commands register globally, once for the # application across every guild (decision #7); guild-scoped registration # is the self-registered adapter's, which serves one guild. @@ -67,8 +84,12 @@ def connection(self) -> DiscordConnection: return self._connection async def start(self) -> None: - # No commands yet — global slash routing lands in a later stage. The DM - # handler is deliberately left unset (guard G4). + # One handler for every guild's messages: each event resolves its guild + # to a tenant fresh, so nothing about which tenant a guild belongs to is + # cached on the connection (guard G1). The DM handler is deliberately + # left unset (guard G4). No commands yet — global slash routing lands in + # a later stage. + self._connection.set_guild_message_handler(self._on_guild_message) await self._connection.connect(commands=[]) logger.info( "Discord shared Gateway connection started (message_content=%s)", @@ -77,3 +98,44 @@ async def start(self) -> None: async def stop(self) -> None: await self._connection.close() + + async def _on_guild_message(self, message: discord.Message) -> None: + """Route one guild message to the bridge its guild resolves to. + + Runs with **no tenant bound** and resolves the guild fresh on every + event (G1): the shared connection is multi-tenant and long-lived, so a + tenant is never cached on it and each event is scoped from scratch. + A guild with no active install resolves to nothing and is dropped, never + routed to a default or first tenant (G3) — the system fails closed. + Each handler below binds the tenant of the room it acts on, matching the + socket and webhook delivery paths. + """ + guild = message.guild + if guild is None: + # The connection only routes guild messages here, so this is + # defensive; a DM would have gone to the (unset) DM handler. + return + with no_tenant(): + try: + target = await self._install_service.resolve_by_workspace( + platform=_PLATFORM, workspace_id=str(guild.id) + ) + except (WebhookWorkspaceUnknown, WebhookBridgeUnavailable) as exc: + logger.info("Dropping Discord message for guild %s: %s", guild.id, exc) + return + + adapter = target.adapter + if not isinstance(adapter, DiscordAdapter): + logger.error( + "Bridge %s for Discord guild %s is not a Discord adapter (%s); " + "dropping the message", + target.bridge_id, + guild.id, + type(adapter).__name__, + ) + return + + # Inert until now: hand it the shared connection so its inbound + # handling and outbound posting run against the one socket. + adapter.ensure_shared_connection(self._connection) + await adapter.dispatch_inbound(message) diff --git a/core/switch_core/bridges/collaboration/install_service.py b/core/switch_core/bridges/collaboration/install_service.py index 7cee29311..1b176a389 100644 --- a/core/switch_core/bridges/collaboration/install_service.py +++ b/core/switch_core/bridges/collaboration/install_service.py @@ -465,7 +465,17 @@ def revocation(self, *, platform: str, event: InboundWebhook) -> Revocation | No ) async def resolve(self, *, platform: str, event: InboundWebhook) -> WebhookTarget: - """Turn a workspace id into the one bridge entitled to the event. + """Turn a webhook event's workspace into the bridge entitled to it.""" + installer = self._installers.get(platform) + workspace_id = installer.workspace_of_event(event.payload) + return await self.resolve_by_workspace( + platform=platform, workspace_id=workspace_id + ) + + async def resolve_by_workspace( + self, *, platform: str, workspace_id: str + ) -> WebhookTarget: + """Turn a workspace id into the one bridge entitled to its events. The tenant comes from the exempt lookup (`db/tenant_lookup.py`), which is the only way to answer it: the caller authenticated to nothing, and @@ -473,10 +483,13 @@ async def resolve(self, *, platform: str, event: InboundWebhook) -> WebhookTarge *again* under that tenant rather than returned by the lookup — a deliberate second check, so a wrong answer above is a miss here instead of a cross-tenant read. - """ - installer = self._installers.get(platform) - workspace_id = installer.workspace_of_event(event.payload) + Split from `resolve` so a caller that already holds the workspace id + reaches it without a webhook: the Discord shared connection reads the + guild id straight off the Gateway event, and calling this keeps the + exempt-lookup caller inside this already-allowlisted module and inherits + the scoped re-read for free. + """ tenant_id = await tenant_of_messaging_install( self._session_factory, platform, workspace_id ) diff --git a/core/switch_core/main.py b/core/switch_core/main.py index 842574b26..bf5705e59 100644 --- a/core/switch_core/main.py +++ b/core/switch_core/main.py @@ -612,10 +612,15 @@ async def lifespan(app: object) -> AsyncIterator[None]: # and Discord installs stay inert until it recovers, rather than fatal. discord_gateway: DiscordGatewayClient | None = None if config.discord_app_bot_token: + # install_service is present whenever an installer is registered, and the + # Discord bot token being set means the Discord installer is — so this is + # not None here. Asserted rather than branched to say that out loud. + assert install_service is not None discord_gateway = DiscordGatewayClient( bot_token=config.discord_app_bot_token, message_content=config.discord_app_message_content, members=config.discord_app_members, + install_service=install_service, ) try: await discord_gateway.start() diff --git a/core/tests/switch_core/bridges/collaboration/test_discord_gateway.py b/core/tests/switch_core/bridges/collaboration/test_discord_gateway.py index b2dec7539..46887dc56 100644 --- a/core/tests/switch_core/bridges/collaboration/test_discord_gateway.py +++ b/core/tests/switch_core/bridges/collaboration/test_discord_gateway.py @@ -8,16 +8,22 @@ from __future__ import annotations +from typing import Any + from switch_core.bridges.collaboration.discord.gateway import DiscordGatewayClient def _gateway( *, message_content: bool = False, members: bool = False ) -> DiscordGatewayClient: + # These tests only inspect the connection the client builds, so a bare + # stand-in for the install service (never called here) is enough. + install_service: Any = object() return DiscordGatewayClient( bot_token="bot-token", message_content=message_content, members=members, + install_service=install_service, ) diff --git a/core/tests/switch_core/bridges/collaboration/test_discord_gateway_routing.py b/core/tests/switch_core/bridges/collaboration/test_discord_gateway_routing.py new file mode 100644 index 000000000..836247260 --- /dev/null +++ b/core/tests/switch_core/bridges/collaboration/test_discord_gateway_routing.py @@ -0,0 +1,161 @@ +"""The shared connection routes each guild message by resolving it fresh. + +This is where the isolation guards on the multi-tenant socket are pinned: +- G1: the dispatch runs with no tenant bound, and the tenant is resolved fresh + per event (nothing about a guild's tenant is cached on the connection). +- G3: a guild with no active install resolves to nothing and is dropped, never + routed to a default or first tenant. +""" + +from __future__ import annotations + +from typing import Any + +import discord + +from switch_core.bridges.collaboration.discord.adapter import ( + DiscordAdapter, + DiscordConnectionConfig, +) +from switch_core.bridges.collaboration.discord.gateway import DiscordGatewayClient +from switch_core.bridges.collaboration.install_service import ( + WebhookBridgeUnavailable, + WebhookTarget, + WebhookWorkspaceUnknown, +) +from switch_core.tenant_context import current_tenant_id, tenant_scope + +GUILD_ID = 42 + + +class _FakeInstallService: + def __init__( + self, *, target: WebhookTarget | None = None, error: Exception | None = None + ) -> None: + self._target = target + self._error = error + self.calls: list[tuple[str, str]] = [] + + async def resolve_by_workspace( + self, *, platform: str, workspace_id: str + ) -> WebhookTarget: + self.calls.append((platform, workspace_id)) + if self._error is not None: + raise self._error + assert self._target is not None + return self._target + + +def _gateway(install_service: Any) -> DiscordGatewayClient: + return DiscordGatewayClient( + bot_token="bot-token", + message_content=False, + members=False, + install_service=install_service, + ) + + +def _shared_adapter() -> DiscordAdapter: + return DiscordAdapter( + config=DiscordConnectionConfig(guild_id=str(GUILD_ID), event_delivery="shared") + ) + + +def _message(guild_id: int | None) -> discord.Message: + guild = None if guild_id is None else type("_G", (), {"id": guild_id})() + return type("_M", (), {"guild": guild})() # type: ignore[return-value] + + +def _target(adapter: Any) -> WebhookTarget: + return WebhookTarget( + tenant_id="tenant-a", platform="discord", bridge_id="bridge-1", adapter=adapter + ) + + +async def test_a_resolved_message_is_dispatched_to_its_bridge() -> None: + adapter = _shared_adapter() + seen: dict[str, Any] = {} + + async def fake_dispatch(message: discord.Message) -> None: + seen["message"] = message + + adapter.dispatch_inbound = fake_dispatch # type: ignore[method-assign] + service = _FakeInstallService(target=_target(adapter)) + gateway = _gateway(service) + + message = _message(GUILD_ID) + await gateway._on_guild_message(message) + + assert service.calls == [("discord", str(GUILD_ID))] + assert seen["message"] is message + # The inert bridge was handed the shared connection on first use. + assert adapter._connection is gateway.connection + + +async def test_dispatch_runs_with_no_tenant_bound() -> None: + """G1: even if the caller had a tenant bound, the dispatch does not — each + handler below binds the tenant of the room it acts on.""" + adapter = _shared_adapter() + seen: dict[str, Any] = {} + + async def fake_dispatch(message: discord.Message) -> None: + seen["tenant_during"] = current_tenant_id() + + adapter.dispatch_inbound = fake_dispatch # type: ignore[method-assign] + gateway = _gateway(_FakeInstallService(target=_target(adapter))) + + with tenant_scope("tenant-somebody-else"): + await gateway._on_guild_message(_message(GUILD_ID)) + # The binding is restored for the caller after the event (G1). + assert current_tenant_id() == "tenant-somebody-else" + + assert seen["tenant_during"] is None + + +async def test_a_guild_with_no_active_install_is_dropped() -> None: + """G3: fails closed — no default or first tenant.""" + adapter = _shared_adapter() + dispatched = False + + async def fake_dispatch(message: discord.Message) -> None: + nonlocal dispatched + dispatched = True + + adapter.dispatch_inbound = fake_dispatch # type: ignore[method-assign] + gateway = _gateway( + _FakeInstallService(error=WebhookWorkspaceUnknown("no tenant holds it")) + ) + + await gateway._on_guild_message(_message(GUILD_ID)) + + assert dispatched is False + + +async def test_a_bridge_not_yet_running_is_dropped() -> None: + gateway = _gateway( + _FakeInstallService(error=WebhookBridgeUnavailable("no bridge yet")) + ) + # No adapter to dispatch to; the point is it does not raise. + await gateway._on_guild_message(_message(GUILD_ID)) + + +async def test_a_dm_shaped_event_is_ignored() -> None: + """Defensive: the connection routes DMs to the (unset) DM handler, but a + guild-less event reaching here is dropped before any resolution (G4).""" + service = _FakeInstallService() + gateway = _gateway(service) + + await gateway._on_guild_message(_message(None)) + + assert service.calls == [] + + +async def test_a_non_discord_bridge_is_dropped_not_dispatched() -> None: + """A guild resolving to a non-Discord bridge is a wiring fault, not a + message to force through.""" + + class _NotDiscord: + pass + + gateway = _gateway(_FakeInstallService(target=_target(_NotDiscord()))) + await gateway._on_guild_message(_message(GUILD_ID)) # logs and returns From d9dd0c249c65440d6e6b2f21a78197b05429f224 Mon Sep 17 00:00:00 2001 From: lbangalosbt Date: Wed, 16 Sep 2026 14:16:04 -0400 Subject: [PATCH 25/29] feat(discord): global slash-command routing by guild id (Stage 4c) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The shared connection now registers the in-room command set globally, once for the application (decision #7), and routes each invocation to the bridge its interaction.guild_id resolves to — the same fresh, no-tenant-bound resolution the message path uses. Global commands appear in every guild the bot is in, so an invocation from a guild with no active install is answered with an ephemeral refusal rather than dropped: an unacknowledged interaction shows the user "interaction failed", where an install-less message can just be dropped. The adapter gains a thin dispatch_slash entry mirroring dispatch_inbound. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../bridges/collaboration/discord/adapter.py | 14 ++++ .../bridges/collaboration/discord/gateway.py | 72 +++++++++++++++++-- .../test_discord_gateway_routing.py | 61 ++++++++++++++++ 3 files changed, 143 insertions(+), 4 deletions(-) diff --git a/core/switch_core/bridges/collaboration/discord/adapter.py b/core/switch_core/bridges/collaboration/discord/adapter.py index 2cb958d78..7a9bc8ec6 100644 --- a/core/switch_core/bridges/collaboration/discord/adapter.py +++ b/core/switch_core/bridges/collaboration/discord/adapter.py @@ -330,6 +330,20 @@ async def dispatch_inbound(self, message: discord.Message) -> None: """ await self._handle_message(message) + async def dispatch_slash( + self, + interaction: discord.Interaction, + command: InRoomCommand, + values: dict[str, Any], + ) -> None: + """Handle one slash invocation the shared client routed here by guild. + + The self-registered connection reaches the same handler through the + command tree it owns; the shared connection registers commands globally + and routes each invocation to the bridge its guild resolves to. + """ + await self._handle_slash_command(interaction, command, values) + def _require_connection(self) -> DiscordConnection: """The bridge's Gateway connection, or a loud error if it has none. diff --git a/core/switch_core/bridges/collaboration/discord/gateway.py b/core/switch_core/bridges/collaboration/discord/gateway.py index 132607243..b39088812 100644 --- a/core/switch_core/bridges/collaboration/discord/gateway.py +++ b/core/switch_core/bridges/collaboration/discord/gateway.py @@ -11,19 +11,23 @@ in the one switch-core pod (a forced singleton), so there is never a second owner of the socket and no leader election to arrange. -This module is the foundation: it opens the shared socket with the right intents -and registers no commands yet. Attaching each guild's inert bridge to it, slash -routing, removal handling and the isolation guards land in the stages after. +It opens the shared socket with the right intents, routes each guild message +and each global slash invocation to the bridge its guild resolves to (fresh per +event, no tenant cached — guards G1/G3), and registers the command set globally +once for the application. Removal / out-of-band-join handling lands after. """ from __future__ import annotations import logging +from typing import Any import discord +from switch_core.bridges.agent.commands import Command as InRoomCommand from switch_core.bridges.collaboration.discord.adapter import DiscordAdapter from switch_core.bridges.collaboration.discord.connection import DiscordConnection +from switch_core.bridges.collaboration.discord.slash import build_app_commands from switch_core.bridges.collaboration.install_service import ( MessagingInstallService, WebhookBridgeUnavailable, @@ -90,7 +94,9 @@ async def start(self) -> None: # left unset (guard G4). No commands yet — global slash routing lands in # a later stage. self._connection.set_guild_message_handler(self._on_guild_message) - await self._connection.connect(commands=[]) + # Commands register globally, once for the application, and each + # invocation is routed to a bridge by the guild it carries (decision #7). + await self._connection.connect(commands=build_app_commands(self._on_slash)) logger.info( "Discord shared Gateway connection started (message_content=%s)", self._message_content, @@ -139,3 +145,61 @@ async def _on_guild_message(self, message: discord.Message) -> None: # handling and outbound posting run against the one socket. adapter.ensure_shared_connection(self._connection) await adapter.dispatch_inbound(message) + + async def _on_slash( + self, + interaction: discord.Interaction, + command: InRoomCommand, + values: dict[str, Any], + ) -> None: + """Route one global slash invocation to the bridge its guild resolves to. + + Global commands appear in every guild the bot is in, including ones with + no Switch install, so an invocation from an unmapped guild is answered + with an ephemeral refusal rather than dropped — Discord shows + "interaction failed" for one left unacknowledged. Resolved fresh per + event and dispatched with no tenant bound, like the message path (G1). + """ + guild_id = interaction.guild_id + if guild_id is None: + await self._refuse_slash( + interaction, "This command only works inside a server." + ) + return + with no_tenant(): + try: + target = await self._install_service.resolve_by_workspace( + platform=_PLATFORM, workspace_id=str(guild_id) + ) + except (WebhookWorkspaceUnknown, WebhookBridgeUnavailable) as exc: + logger.info( + "Refusing Discord slash command for guild %s: %s", guild_id, exc + ) + await self._refuse_slash( + interaction, "Switch is not connected to this server." + ) + return + + adapter = target.adapter + if not isinstance(adapter, DiscordAdapter): + logger.error( + "Bridge %s for Discord guild %s is not a Discord adapter (%s); " + "refusing the slash command", + target.bridge_id, + guild_id, + type(adapter).__name__, + ) + await self._refuse_slash( + interaction, "Switch is not connected to this server." + ) + return + + adapter.ensure_shared_connection(self._connection) + await adapter.dispatch_slash(interaction, command, values) + + @staticmethod + async def _refuse_slash(interaction: discord.Interaction, message: str) -> None: + try: + await interaction.response.send_message(message, ephemeral=True) + except discord.HTTPException: + logger.exception("Failed to refuse a Discord slash interaction") diff --git a/core/tests/switch_core/bridges/collaboration/test_discord_gateway_routing.py b/core/tests/switch_core/bridges/collaboration/test_discord_gateway_routing.py index 836247260..36d292fe4 100644 --- a/core/tests/switch_core/bridges/collaboration/test_discord_gateway_routing.py +++ b/core/tests/switch_core/bridges/collaboration/test_discord_gateway_routing.py @@ -159,3 +159,64 @@ class _NotDiscord: gateway = _gateway(_FakeInstallService(target=_target(_NotDiscord()))) await gateway._on_guild_message(_message(GUILD_ID)) # logs and returns + + +class _FakeResponse: + def __init__(self) -> None: + self.refusals: list[tuple[str, bool]] = [] + + async def send_message(self, content: str, *, ephemeral: bool = False) -> None: + self.refusals.append((content, ephemeral)) + + +def _interaction(guild_id: int | None) -> Any: + return type("_I", (), {"guild_id": guild_id, "response": _FakeResponse()})() + + +def _command() -> Any: + return type("_C", (), {"name": "help"})() + + +async def test_a_slash_command_is_routed_to_its_guilds_bridge() -> None: + adapter = _shared_adapter() + seen: dict[str, Any] = {} + + async def fake_slash(interaction: Any, command: Any, values: Any) -> None: + seen["tenant_during"] = current_tenant_id() + seen["command"] = command + + adapter.dispatch_slash = fake_slash # type: ignore[method-assign] + gateway = _gateway(_FakeInstallService(target=_target(adapter))) + + interaction = _interaction(GUILD_ID) + command = _command() + with tenant_scope("tenant-somebody-else"): + await gateway._on_slash(interaction, command, {}) + + assert seen["command"] is command + assert seen["tenant_during"] is None # G1 + assert interaction.response.refusals == [] + assert adapter._connection is gateway.connection + + +async def test_a_slash_from_an_uninstalled_guild_is_refused_ephemerally() -> None: + """Global commands appear everywhere; an unmapped guild gets an ephemeral + refusal rather than an unacknowledged 'interaction failed' (and G3).""" + gateway = _gateway( + _FakeInstallService(error=WebhookWorkspaceUnknown("no tenant holds it")) + ) + interaction = _interaction(GUILD_ID) + + await gateway._on_slash(interaction, _command(), {}) + + assert len(interaction.response.refusals) == 1 + assert interaction.response.refusals[0][1] is True # ephemeral + + +async def test_a_slash_with_no_guild_is_refused() -> None: + gateway = _gateway(_FakeInstallService()) + interaction = _interaction(None) + + await gateway._on_slash(interaction, _command(), {}) + + assert len(interaction.response.refusals) == 1 From ee54fb7e05a559c7177a41b3cfed7e8bf3e32a8f Mon Sep 17 00:00:00 2001 From: lbangalosbt Date: Wed, 16 Sep 2026 14:18:25 -0400 Subject: [PATCH 26/29] feat(discord): end an install when the bot is removed from its guild (Stage 4d) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The shared connection now handles the two guild-lifecycle Gateway events. Removal (the bot kicked from a guild, or the guild deleted) routes through the platform-initiated end path — the same one a Slack app_uninstalled takes — which marks the install inactive and detaches its bridge but revokes no token (decision #8; a Discord install has none). A removal for a guild no tenant installed resolves to nobody and is a harmless no-op. A guild join provisions nothing: only a recorded install (via the OAuth flow) makes a guild's events route anywhere, and a guild with none is ignored — its messages resolve to nobody and are dropped (G3). The join is logged so an out-of-band add is visible rather than silent. DiscordConnection gains guild-remove/join handler slots the shared connection wires and the self-registered adapter leaves unset. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../collaboration/discord/connection.py | 57 +++++++++++++++++++ .../bridges/collaboration/discord/gateway.py | 35 ++++++++++++ .../test_discord_gateway_routing.py | 32 +++++++++++ 3 files changed, 124 insertions(+) diff --git a/core/switch_core/bridges/collaboration/discord/connection.py b/core/switch_core/bridges/collaboration/discord/connection.py index d0801bc2a..847f5ad5e 100644 --- a/core/switch_core/bridges/collaboration/discord/connection.py +++ b/core/switch_core/bridges/collaboration/discord/connection.py @@ -68,6 +68,15 @@ def __init__( Callable[[discord.Message], Awaitable[None]] | None ) = None self._dm_handler: Callable[[discord.Message], Awaitable[None]] | None = None + # The bot being removed from / added to a guild. Only the shared + # connection wires these; the self-registered adapter serves the one + # guild it was configured with. + self._guild_remove_handler: ( + Callable[[discord.Guild], Awaitable[None]] | None + ) = None + self._guild_join_handler: Callable[[discord.Guild], Awaitable[None]] | None = ( + None + ) def register_message_handler( self, guild_id: int, handler: Callable[[discord.Message], Awaitable[None]] @@ -82,6 +91,22 @@ def set_guild_message_handler( ) -> None: self._guild_message_handler = handler + def set_guild_lifecycle_handlers( + self, + *, + on_remove: Callable[[discord.Guild], Awaitable[None]], + on_join: Callable[[discord.Guild], Awaitable[None]], + ) -> None: + """Handlers for the bot being removed from / added to a guild. + + Used by the shared connection to end an install when its guild removes + the bot, and to note an out-of-band join. Set before `connect`; the + self-registered adapter, which serves one guild it was configured with, + leaves them unset. + """ + self._guild_remove_handler = on_remove + self._guild_join_handler = on_join + def set_dm_handler( self, handler: Callable[[discord.Message], Awaitable[None]] | None ) -> None: @@ -119,6 +144,10 @@ async def connect( """ client = discord.Client(intents=self._intents) client.event(self._make_on_message()) + if self._guild_remove_handler is not None: + client.event(self._make_on_guild_remove()) + if self._guild_join_handler is not None: + client.event(self._make_on_guild_join()) self._tree = app_commands.CommandTree(client) guild = ( discord.Object(id=self._command_guild_id) @@ -229,6 +258,34 @@ async def on_message(message: discord.Message) -> None: return on_message + def _make_on_guild_remove( + self, + ) -> Callable[[discord.Guild], Coroutine[Any, Any, None]]: + async def on_guild_remove(guild: discord.Guild) -> None: + handler = self._guild_remove_handler + if handler is None: + return + try: + await handler(guild) + except Exception: + logger.exception("Failed to handle Discord guild removal") + + return on_guild_remove + + def _make_on_guild_join( + self, + ) -> Callable[[discord.Guild], Coroutine[Any, Any, None]]: + async def on_guild_join(guild: discord.Guild) -> None: + handler = self._guild_join_handler + if handler is None: + return + try: + await handler(guild) + except Exception: + logger.exception("Failed to handle Discord guild join") + + return on_guild_join + async def close(self) -> None: if self._client: try: diff --git a/core/switch_core/bridges/collaboration/discord/gateway.py b/core/switch_core/bridges/collaboration/discord/gateway.py index b39088812..8f42fc7f9 100644 --- a/core/switch_core/bridges/collaboration/discord/gateway.py +++ b/core/switch_core/bridges/collaboration/discord/gateway.py @@ -94,6 +94,10 @@ async def start(self) -> None: # left unset (guard G4). No commands yet — global slash routing lands in # a later stage. self._connection.set_guild_message_handler(self._on_guild_message) + self._connection.set_guild_lifecycle_handlers( + on_remove=self._on_guild_remove, + on_join=self._on_guild_join, + ) # Commands register globally, once for the application, and each # invocation is routed to a bridge by the guild it carries (decision #7). await self._connection.connect(commands=build_app_commands(self._on_slash)) @@ -203,3 +207,34 @@ async def _refuse_slash(interaction: discord.Interaction, message: str) -> None: await interaction.response.send_message(message, ephemeral=True) except discord.HTTPException: logger.exception("Failed to refuse a Discord slash interaction") + + async def _on_guild_remove(self, guild: discord.Guild) -> None: + """The bot was removed from a guild: end that guild's install. + + Routed through the platform-initiated end path — the same one a Slack + `app_uninstalled` takes — which marks the install inactive and detaches + its bridge but revokes nothing (decision #8; there is no per-install + token to revoke). A guild no tenant has installed resolves to nobody and + is a no-op there, so a removal we were never serving is harmless. + """ + await self._install_service.revoked( + platform=_PLATFORM, + workspace_id=str(guild.id), + reason="the bot was removed from the Discord server", + ) + + async def _on_guild_join(self, guild: discord.Guild) -> None: + """The bot was added to a guild. + + Nothing is provisioned here: only a recorded install (via the OAuth + flow) makes a guild's events route anywhere, and a guild with none is + ignored — its messages resolve to nobody and are dropped (G3). A guild + added outside the install flow therefore does nothing but this line, + which is what makes an out-of-band join visible rather than silent. + """ + logger.info( + "The Discord bot was added to guild %s (%s); it serves Switch only " + "once an install has been recorded for it", + guild.id, + getattr(guild, "name", "?"), + ) diff --git a/core/tests/switch_core/bridges/collaboration/test_discord_gateway_routing.py b/core/tests/switch_core/bridges/collaboration/test_discord_gateway_routing.py index 36d292fe4..e809a6907 100644 --- a/core/tests/switch_core/bridges/collaboration/test_discord_gateway_routing.py +++ b/core/tests/switch_core/bridges/collaboration/test_discord_gateway_routing.py @@ -35,6 +35,7 @@ def __init__( self._target = target self._error = error self.calls: list[tuple[str, str]] = [] + self.revoked_calls: list[tuple[str, str, str]] = [] async def resolve_by_workspace( self, *, platform: str, workspace_id: str @@ -45,6 +46,9 @@ async def resolve_by_workspace( assert self._target is not None return self._target + async def revoked(self, *, platform: str, workspace_id: str, reason: str) -> None: + self.revoked_calls.append((platform, workspace_id, reason)) + def _gateway(install_service: Any) -> DiscordGatewayClient: return DiscordGatewayClient( @@ -220,3 +224,31 @@ async def test_a_slash_with_no_guild_is_refused() -> None: await gateway._on_slash(interaction, _command(), {}) assert len(interaction.response.refusals) == 1 + + +def _guild(guild_id: int) -> Any: + return type("_G", (), {"id": guild_id, "name": "Acme"})() + + +async def test_being_removed_from_a_guild_ends_its_install() -> None: + """Decision #8: the platform-initiated end path, which revokes no token.""" + service = _FakeInstallService() + gateway = _gateway(service) + + await gateway._on_guild_remove(_guild(GUILD_ID)) + + assert service.revoked_calls == [ + ("discord", str(GUILD_ID), "the bot was removed from the Discord server") + ] + + +async def test_joining_a_guild_provisions_nothing() -> None: + """An out-of-band join is logged and ignored — nothing is resolved or ended; + only a recorded install makes a guild route anywhere (G3).""" + service = _FakeInstallService() + gateway = _gateway(service) + + await gateway._on_guild_join(_guild(GUILD_ID)) + + assert service.calls == [] + assert service.revoked_calls == [] From 7379898d8fbf3bb3d1c1eb38ce72e22db16ac985 Mon Sep 17 00:00:00 2001 From: lbangalosbt Date: Wed, 16 Sep 2026 14:23:53 -0400 Subject: [PATCH 27/29] test(discord): consolidate the shared-connection isolation guards (Stage 4e) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pins the two guard faces not already covered where their feature landed: - G4 (no guild-less routing): a DM reaches no handler when the DM slot is empty — how the shared connection is wired — while a guild message still routes. Its intent half (no dm_messages requested) is in test_discord_gateway. - G2 (tenant-scoped identity): the same Discord user in two tenants' guilds gets two independent records, because each guild is served by its own adapter (one install = one tenant, D2) with its own id-keyed caches; the database side is scoped the same way through row-level security. G1 (per-event scoping, resolved fresh, no tenant cached) and G3 (no default tenant, unmapped guild dropped) are pinned in test_discord_gateway_routing. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../test_discord_isolation_guards.py | 79 +++++++++++++++++++ 1 file changed, 79 insertions(+) create mode 100644 core/tests/switch_core/bridges/collaboration/test_discord_isolation_guards.py diff --git a/core/tests/switch_core/bridges/collaboration/test_discord_isolation_guards.py b/core/tests/switch_core/bridges/collaboration/test_discord_isolation_guards.py new file mode 100644 index 000000000..47755d7be --- /dev/null +++ b/core/tests/switch_core/bridges/collaboration/test_discord_isolation_guards.py @@ -0,0 +1,79 @@ +"""The four isolation guards on the shared, multi-tenant Discord connection. + +Each guard has a test that fails without it. G1 (per-event scoping, no tenant +cached, resolved fresh) and G3 (no default tenant, unmapped guild dropped) live +in `test_discord_gateway_routing.py`, where the routing they guard is. G4's +intent half is in `test_discord_gateway.py`. This file pins the two remaining +faces: G4's drop-before-routing and G2's tenant-scoped identity. +""" + +from __future__ import annotations + +from typing import Any + +import discord + +from switch_core.bridges.collaboration.discord.adapter import ( + DiscordAdapter, + DiscordConnectionConfig, +) +from switch_core.bridges.collaboration.discord.connection import DiscordConnection + + +def _connection() -> DiscordConnection: + return DiscordConnection( + bot_token="bot-token", + intents=discord.Intents.none(), + command_guild_id=None, + ) + + +def _message(guild_id: int | None) -> Any: + guild = None if guild_id is None else type("_G", (), {"id": guild_id})() + return type("_M", (), {"guild": guild})() + + +# ── G4 — no guild-less routing ─────────────────────────────────────────────── + + +async def test_a_dm_is_dropped_before_routing() -> None: + """A guild-less message reaches no handler when the DM slot is empty, which + is how the shared connection is wired — a DM carries no guild to attribute + to a tenant.""" + conn = _connection() + routed: list[Any] = [] + + async def catch_all(message: Any) -> None: + routed.append(message) + + conn.set_guild_message_handler(catch_all) + # No DM handler set (the shared connection leaves it unset). + on_message = conn._make_on_message() + + await on_message(_message(None)) # a DM + assert routed == [] + + await on_message(_message(42)) # a guild message still routes + assert len(routed) == 1 + + +# ── G2 — tenant-scoped identity ────────────────────────────────────────────── + + +async def test_each_guilds_bridge_has_its_own_identity_caches() -> None: + """The same Discord user in two tenants' guilds yields two independent + records: each guild is served by its own adapter (one install = one tenant, + decision D2), so a user-id-keyed cache on one is not shared with the other. + The database side is scoped the same way — puppet and external-user rows are + written under each bridge's tenant through row-level security.""" + tenant_a = DiscordAdapter( + config=DiscordConnectionConfig(guild_id="1", event_delivery="shared") + ) + tenant_b = DiscordAdapter( + config=DiscordConnectionConfig(guild_id="2", event_delivery="shared") + ) + + tenant_a._user_names[999] = "alice-in-a" + + assert tenant_a._user_names is not tenant_b._user_names + assert 999 not in tenant_b._user_names From 662245dfeedaeb7f6d1493c101841a3045c1624f Mon Sep 17 00:00:00 2001 From: lbangalosbt Date: Wed, 16 Sep 2026 14:36:54 -0400 Subject: [PATCH 28/29] docs(config): document DISCORD_APP_* env vars in .env.example MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The distributed Discord app's credentials mirror the Slack block: all four or none, MESSAGING_PUBLIC_URL required with them, plus the two privileged- intent flags. Config docs only — no behaviour change. Co-Authored-By: Claude Opus 4.8 (1M context) --- .env.example | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/.env.example b/.env.example index 545f48d63..25138f15b 100644 --- a/.env.example +++ b/.env.example @@ -135,6 +135,21 @@ FRONTEND_BASE_URL=http://localhost:5173 # SLACK_APP_CLIENT_SECRET= # SLACK_APP_SIGNING_SECRET= +# Credentials of the distributed Discord app — the one a customer adds to their +# server by clicking "Add to Server", not the self-registered app whose token an +# operator pastes in (DISCORD_SETUP.md). Set all four or none; setting some is +# refused at startup, and MESSAGING_PUBLIC_URL must be set with them. The bot +# token lives here, in deployment config, because a Discord install carries no +# per-install token. See docs/old/bridges/DISCORD_DISTRIBUTED_APP.md. +# DISCORD_APP_CLIENT_ID= +# DISCORD_APP_CLIENT_SECRET= +# DISCORD_APP_BOT_TOKEN= +# DISCORD_APP_APPLICATION_ID= +# Privileged intents on the shared connection, each off by default (mention-only +# / API member fetches) and requiring Discord verification past ~100 guilds. +# DISCORD_APP_MESSAGE_CONTENT=false +# DISCORD_APP_MEMBERS=false + # ── Mattermost (local dev) ─────────────────────────────────────────────────── MATTERMOST_ADMIN_USER=admin MATTERMOST_ADMIN_PASSWORD= From ba66d95a92bcddb3f17a415c4050525d7d7a30ac Mon Sep 17 00:00:00 2001 From: lbangalosbt Date: Wed, 16 Sep 2026 16:59:11 -0400 Subject: [PATCH 29/29] chore(gitleaks): allowlist the public Discord id dummy in a config test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The default discord-client-id rule flags the 17-19 digit placeholder in test_config_discord_app.py. A Discord client/application id is a public identifier, not a secret, and the value is an obvious dummy — allowlisted following the existing false-positive convention in this file. Co-Authored-By: Claude Opus 4.8 (1M context) --- .gitleaks.toml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.gitleaks.toml b/.gitleaks.toml index 3c56aee28..e9fec6c15 100644 --- a/.gitleaks.toml +++ b/.gitleaks.toml @@ -44,6 +44,10 @@ regexes = [ # Collaboration-adapter type registration; the mattermost-access-token # rule mistakes the identifier sequence for a token. No secret here. '''"mattermost", MattermostAdapter, MattermostConnectionConfig''', + # A Discord client/application id is a *public* identifier, not a secret, and + # these are obvious dummies in a config-validator test — but the default + # `discord-client-id` rule flags any 17-19 digit number next to the keyword. + '''discord_app_(?:client|application)_id="\d{17,19}"''', ] paths = [ # Log-redaction tests exercise deliberately fake vendor tokens. Both spellings