Severity: Low · AUD-CHAN-12
What's wrong
The access mode jwt_linked enforces nothing on its own: with require_link=False (the default) it is behaviourally identical to open. An operator who selects "jwt_linked" — reasonably read as "an account is required" — gets no added gating unless they also flip a second, independent switch.
Where
backend/app/schemas/channel_bot.py:19,22 — mode and require_link are independent fields with no validator coupling them
backend/app/services/channels/router.py:773-792 — _check_access enforces only whitelist/group_only; jwt_linked passes through (:792)
backend/app/services/channels/router.py:972 — linking is enforced only when policy.get("require_link", False) is truthy
backend/app/services/channels/router.py:813-815 — _admits_unlinked keys off require_link, not mode
backend/app/services/channels/base.py:32 — "require_link": False in DEFAULT_ACCESS_POLICY
How it fails
mode="jwt_linked", require_link=False admits an unlinked room sender under the binding creator exactly as open does. This is adjacent to a deliberate decision: #639 intentionally made require_link the switch and admitted unlinked room senders under the binding. The residual is that the jwt_linked mode value now decides nothing — a gate that reads as applied but is inert (the #718 class), though not an escalation (unlinked never runs role-less or as another user).
Fix
Either a schema validator forcing require_link=True when mode="jwt_linked", or make _check_access/_admits_unlinked treat the mode itself as requiring a link — or retire the mode value and let require_link be the only knob, updating the dashboard/CLI copy.
Acceptance criteria
Cluster Channels and bots in #168. Nearest: #639 (made require_link the switch), #718 (the inert-gate class). Pre-existing, found in the 2026-09-03 channels audit.
Severity: Low · AUD-CHAN-12
What's wrong
The access mode
jwt_linkedenforces nothing on its own: withrequire_link=False(the default) it is behaviourally identical toopen. An operator who selects "jwt_linked" — reasonably read as "an account is required" — gets no added gating unless they also flip a second, independent switch.Where
backend/app/schemas/channel_bot.py:19,22—modeandrequire_linkare independent fields with no validator coupling thembackend/app/services/channels/router.py:773-792—_check_accessenforces onlywhitelist/group_only;jwt_linkedpasses through (:792)backend/app/services/channels/router.py:972— linking is enforced only whenpolicy.get("require_link", False)is truthybackend/app/services/channels/router.py:813-815—_admits_unlinkedkeys offrequire_link, notmodebackend/app/services/channels/base.py:32—"require_link": FalseinDEFAULT_ACCESS_POLICYHow it fails
mode="jwt_linked", require_link=Falseadmits an unlinked room sender under the binding creator exactly asopendoes. This is adjacent to a deliberate decision: #639 intentionally maderequire_linkthe switch and admitted unlinked room senders under the binding. The residual is that thejwt_linkedmode value now decides nothing — a gate that reads as applied but is inert (the #718 class), though not an escalation (unlinked never runs role-less or as another user).Fix
Either a schema validator forcing
require_link=Truewhenmode="jwt_linked", or make_check_access/_admits_unlinkedtreat the mode itself as requiring a link — or retire the mode value and letrequire_linkbe the only knob, updating the dashboard/CLI copy.Acceptance criteria
jwt_linkedrefuses an unlinked room sender without a second setting, or the mode value is gonetest_jwt_linked_mode_requires_a_link_even_without_require_linkCluster Channels and bots in #168. Nearest: #639 (made
require_linkthe switch), #718 (the inert-gate class). Pre-existing, found in the 2026-09-03 channels audit.