Story-1064: support staff app-surface on-duty grant-scoped session (ADR-098)

Story 1064Epic None25 files · +1299/−93

PR #265 · pramaan-io/pramaan-functions · merged 2026-06-07

Why this matters

When support needs to enter a firm — without seeing the firm.

One session. One firm. Zero confidential rows.

Wide key. Same door. Confidential leak.

The fix — On duty, re-verified every request, allowlisted to settings only — the rest is invisible at the database.

How it flows

sequenceDiagram
  autonumber
  participant staff as Platform Staff
  participant router as Front Desk (Router + DB dep)
  participant db as Config Tables (allowlist)
  participant vault as Matters / Clients (no policy)
  staff->>router: POST /go-on-duty {firm_id}
  router->>router: verify active grant exists
  router->>db: on_duty_firm_id = 
  router->>router: load 'support' capability set (settings only)
  staff->>router: request with staff token
  router->>router: re-verify grant EVERY request
  router->>db: SET app.support_firm_id = X
  router-->>vault: no policy → row invisible

support_app.py · migration 0076

Methodin → outwhat it does
_resolve_on_duty_firm_id(session, staff_user_id) → UUIDlooks up which firm the staff is on duty for; NULL means off duty
get_db_with_app_support_session(request, current_user) → AsyncIterator[AsyncSession]re-verifies the active grant on every request and opens a support-scoped session
_create_support_firm_read_only_policy(table) → Noneinstalls the allowlist door lock on a config table
_create_support_firm_id_policy(table) → Noneinstalls the per-firm policy that scopes the support key

What changed, file by file

Migration 0076 that creates per-firm RLS policies on allowlisted config tables and leaves confidential tables with no policy.

new methodsignature
_create_support_firm_policy(table) → None
_create_support_firm_read_only_policy(table) → None
_create_support_firm_id_policy(table) → None
_drop_support_policy(table) → None
upgrade() → None
downgrade() → None
New fields: revision : strdown_revision : str | Sequence[str] | Nonebranch_labels : str | Sequence[str] | Nonedepends_on : str | Sequence[str] | None

New support session dependency that resolves the on-duty firm and re-verifies the active grant on every single request.

new methodsignature
_resolve_on_duty_firm_id(session, staff_user_id) → UUID
get_db_with_app_support_session(request, current_user) → AsyncIterator[AsyncSession]

Business logic for the on/off duty toggle, including the active-grant precondition before a staff is allowed on duty.

new methodsignature
go_on_duty(session, user_id, firm_id) → StaffOnDutyResponse
go_off_duty(session, user_id) → StaffOnDutyResponse

Admin HTTP endpoints that expose go on duty and go off duty to platform staff.

new methodsignature
go_on_duty(req, db, current_user) → StaffOnDutyResponse
go_off_duty(db, current_user) → StaffOnDutyResponse

Request and response shapes for the on/off duty endpoints, including the on_duty_firm_id field.

new classsignature
StaffOnDutyRequest(class)
StaffOnDutyResponse(class)
New fields: firm_id : UUIDuser_id : UUIDon_duty_firm_id : UUID | None

Adds the on_duty_firm_id field on the platform_staff entity, the NULLable FK that drives the duty state.

New fields: on_duty_firm_id : UUID | None

Adds the _is_support_session helper so firm routes can detect a support-staff session and scope writes correctly.

new methodsignature
_is_support_session(session) → bool

Files changed (25)

File±
alembic/versions/54d8a9c0e1f2_0076_support_app_session.py ★+249
docs/walkthroughs/story-1064-walkthrough.html+104
openapi/admin-api.json ★+247
src/app_api/firm_service.py+14 −6
src/app_api/router_matters.py+2
src/app_api/routes/firm.py+3 −1
src/domains/audit_log/service.py+8 −5
src/domains/filing_type/repository.py+10 −10
src/domains/forum/service.py+1 −1
src/domains/matter_type/repository.py+3 −3
src/domains/matter_type/template_service.py+1 −1
src/domains/platform_staff/__init__.py+1 −1
src/domains/platform_staff/dto.py+20
src/domains/platform_staff/entity.py+15 −2
src/domains/platform_staff/service.py+94 −2
src/shared/authz/AGENTS.md+4 −1
src/shared/authz/permissions.py+1 −1
src/shared/db/rls/__init__.py+22 −8
src/shared/db/rls/app.py+29 −19
src/shared/db/rls/support_app.py ★+100
src/shared/routes/staff.py+39
tests/integration/test_app_filing_types.py+9 −8
tests/integration/test_app_firm_routes.py+118
tests/integration/test_rls_isolation.py+178 −10
tests/unit/test_authz_permissions.py+27 −14