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.
| # | Area | Router | Status |
|---|---|---|---|
| 1 | Platform bot-pool config | system_channel_teams.py | Open |
| 2 | Platform bot-pool config | system_channel_slack.py | Open |
| 3 | Cross-org integration CRUD | whatsapp_management.py | Open |
| 4 | Cross-org integration CRUD | sms_management.py | Open |
| 5 | Cross-org integration CRUD | telegram_management.py | Open |
| 6 | Cross-org integration CRUD | slack_management.py | Open |
| 7 | Message access | messages.py | Open |
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.