Route-inventory guard for ChannelBindingView scope
Part of epic #3059 — Settings → Channels (WhatsApp) redesign. Story-3037 made the data visible; this change makes the invariant that keeps it safe executable. That single sentence is the whole value of what remains.
provider_subject on ChannelBindingView and retain
provider_subject_masked. Story-3037 (pramaan-functions#855)
delivered the exposure first — it added provider_subject,
deleted provider_subject_masked outright ("no
compatibility alias remains"), unmasked both constructors, flipped the old
leak canary, rewrote the register docstring, and regenerated the OpenAPI.
The "retain masked" criterion was therefore withdrawn:
re-adding a field main deliberately removed would resurrect deleted legacy
(AGENTS.md R1). This PR changes no DTO and no OpenAPI.
The invariant, and why it needs a guard
The full E.164 subject is PII. What makes exposing it safe is a single property:
Story-3037 unmasked the data but left nothing that enforces the routes stay inside that boundary. A fifth route added later would inherit full phone numbers silently. This Story is that missing guard.
The four routes that return the view
| Route | Scope | How it holds the invariant |
|---|---|---|
GET /app/channels/bindings |
own | list_my_bindings filters to _current_user_id() |
DELETE /app/channels/bindings/{id} |
own | unbind raises the uniform 404 unless user_id matches |
GET /app/channels/register |
firm admin | require_firm_admin |
POST /app/channels/firm-bindings/{id}/revoke |
firm admin | require_firm_admin — flipped from settings:write in Bug-3060 (#857) |
The guard
tests/unit/test_channel_binding_view_route_inventory.py walks the
live FastAPI route table, finds every route whose response embeds a
ChannelBindingView (directly, in a list, or nested), and asserts
each is classified own-scoped or firm-admin-scoped. A firm-admin route must
actually carry require_firm_admin in its dependency
tree — settings:write is not enough, because
firm_editor and support hold it too (per
require_firm_admin's own docstring). A future route returning the
view without a gate fails the classification test the moment it is mounted.
discovered = every route whose response embeds ChannelBindingView
── GET /app/channels/bindings (own)
── DELETE /app/channels/bindings/{id} (own)
── GET /app/channels/register (require_firm_admin)
── POST /app/channels/firm-bindings/{id}/revoke (require_firm_admin)
assert discovered ⊆ OWN_SCOPED ∪ FIRM_ADMIN # no unclassified route
assert every FIRM_ADMIN route has require_firm_admin in its dependency tree
Why this stacks on Bug-3060
Until Bug-3060 (#857) lands, the revoke route is settings:write,
which firm_editor and support hold — so it returns
the full subject outside the admin boundary and the guard's
test_firm_admin_routes_enforce_require_firm_admin is red on that
route (verified: the tripwire fires on the real broken state — AGENTS.md #3).
This branch is therefore based on issue-3060-revoke-gate-firm-admin,
where route 4 is require_firm_admin and the guard is green. The
PR must merge after #857; retarget the base to main once #857
lands.
Also in this change
-
A boundary comment in
router.py: if a route ever returns a binding outside own-or-firm-admin scope, it needs its own gate and a read audit before it ships. -
test_personal_routes_never_expose_another_members_subject— the behavioural proof the guard defers to for the own-scoped routes: caller A's personal/bindings(and a cross-member unbind) never carry caller B'sprovider_subject. -
No DTO, constructor, or OpenAPI change — those already landed with
Story-3037.
firm_member→ 403 on the register is already proven bytest_register_is_firm_admin_only.