Skip to main content

Enforcement gaps

Core enforces authorization per route rather than globally. Its three mechanisms — require_role/require_admin, verify_org_membership and authorize_resource — all work correctly, but nothing obliges a route to call one, and no framework-level default applies when none is called.

The routers below authenticate the caller and then stop. They accept any valid Keycloak token, regardless of system role or organization membership. None of this is visible from the console, which only decides whether to draw a button.

This page is a working list, not a permanent reference. It should shrink.

#AreaRouterStatus
1Platform bot-pool configsystem_channel_teams.pyOpen
2Platform bot-pool configsystem_channel_slack.pyOpen
3Cross-org integration CRUDwhatsapp_management.pyOpen
4Cross-org integration CRUDsms_management.pyOpen
5Cross-org integration CRUDtelegram_management.pyOpen
6Cross-org integration CRUDslack_management.pyOpen
7Message accessmessages.pyOpen

1–2. Platform bot-pool configuration​

system_channel_teams.py (9 endpoints) and system_channel_slack.py manage the Brain Station 23-owned Teams and Slack credentials that every organization draws on — see System Configuration. Both routers take get_current_user and never call require_admin().

Any authenticated user — including an org_guest of a single trial organization — can create, update and delete entries in the platform bot pool, and reassign which organizations are bound to which bot.

These are the clearest cases, and also the cheapest to fix: the resource is platform-level, the required role is sys_admin, and the check is a router-level dependencies=[Depends(require_admin())] rather than a per-handler edit.

3–6. Cross-organization integration CRUD​

whatsapp_management.py, sms_management.py, telegram_management.py and slack_management.py each expose the same five-endpoint shape — create, list, get, update, delete — and each takes organization_id directly from the client: a query parameter on list, a body field on create, and implicitly via the stored record on get/update/delete. It is never checked against the caller's memberships.

GET /api/v1/whatsapp/integrations?organizationId=<any-org-uuid>

Supplying an organization UUID the caller has no membership in returns that organization's integrations. The same applies to creating an integration inside another org, and to reading, updating or deleting one by its integration ID.

Integration records hold channel credentials, so this is the highest-value item on the page.

The fix is verify_org_membership(current_user, organization_id, require_role=…) on the endpoints that receive an org ID, and authorize_resource(record, current_user, require_role=…) on the ones that load a record by its own ID — the pattern knowledge_bases.py and credentials.py already follow. Choosing the minimum role is a product decision, not a mechanical one: org_editor matches how channels are gated in the console, org_admin is stricter.

7. Message access​

messages.py requires authentication on all five endpoints and performs no ownership or organization check. message_id and conversation_id are taken directly from the path or query.

Any authenticated user can read, update or delete any message by ID, and list every message in any conversation by ID. Conversation contents are end-user chat transcripts, so the exposure is not limited to platform staff or to a single tenant.

Messages reach an organization through their conversation, so the fix runs one level up: load the conversation and call authorize_resource on it — the shape conversation.py already uses.

Not a gap​

feedback.py looks identical at a glance — get_current_user, no verify_org_membership — but every query is scoped by current_user.id rather than by a client-supplied identifier, so there is no ID to tamper with. It is correct as written.

The *_channel.py routers and twilio_whatsapp_webhook.py take no user authentication at all. That is by design: they are inbound provider webhooks authenticated by signature, not by JWT, and are outside this model entirely.

Two structural notes​

Both of these outlive any individual fix above.

The pattern is invisible at review time. A FastAPI handler with current_user: Annotated[TokenUser, Depends(get_current_user)] looks authorized. The difference between a safe route and an unsafe one is a call buried in the body, exactly as it is on identity's OrganizationController. Nothing fails, warns or lints when it is absent. Every gap on this page has that shape.

The console cannot compensate. hasPermission/hasAuthority decide what is drawn, and the routes above are reachable with a bare token and a UUID. Adding an action constant or an AUTHORITY_RULES entry changes nothing about what these endpoints accept.