[Story-1059] Resolve firm permissions from Postgres

Story 1059Epic None25 files · +858/−121

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

How it flows

sequenceDiagram
  autonumber
  participant user as Firm user
  participant app as App layer
  participant db as Postgres
  user->>app: Clerk JWT + request
  app->>app: _resolve_membership_role
  app->>db: query firm_memberships
  db->>app: role string
  app->>app: resolve_permissions
  app->>db: query role_permissions
  db->>app: permission set
  app->>app: check + union old claim
  app->>user: 200 OK
  app->>app: authz_denied log
  app->>app: authz_matrix_gap event

permissions.py · +88 — new methods

Methodin → outwhat it does
_load_permission_cache() → dict[str, frozenset[str]]
resolve_permissions(role) → set[str]
clear_permission_cache() → None

What changed, file by file

Cache plus resolve helper that turn a firm role into its permission set, pulled live from role_permissions.

new methodsignature
_load_permission_cache() → dict[str, frozenset[str]]
resolve_permissions(role) → set[str]
clear_permission_cache() → None

Resolves the active firm role from firm_memberships and attaches role plus permissions to the auth identity.

new methodsignature
_resolve_membership_role(session, user_id, clerk_org_id) → str | None
New fields: role : str | Nonepermissions : Permissions granted by the current firm

Migration seven-four creating the role_permissions bridge table and seeding the final V-one matrix.

new methodsignature
upgrade() → None
downgrade() → None
New fields: branch_labels : str | Sequence[str] | Nonedepends_on : str | Sequence[str] | None

Declares the new RolePermission bridge entity that links role strings to permission strings in Postgres.

new classsignature
RolePermission(type)
New fields: role : strpermission : str

Adds a dependency that injects the resolved firm role and permissions into request scope for downstream checks.

new methodsignature
_dep(request, current_user) → None

Guarantees and the honest caveats

Matrix governs firm permissions
Union with old claim, with gap event
Structured authz_denied on every 403
/app/me now exposes additive permissions
Local DB blocked by pre-existing migration
Integration RBAC slices blocked by missing grants
Repo-wide ruff and mypy fail on pre-existing issues

Files changed (25)

File±
alembic/env.py+1
alembic/versions/9a1b2c3d4e5f_0074_role_permissions.py ★+79
docs/contracts/clerk-jwt-contract.md+11 −10
docs/walkthroughs/story-1059-walkthrough.html+82
openapi/app-api.json+8
src/app_api/dtos.py+5 −1
src/app_api/me_service.py+3
src/domains/firm_membership/__init__.py+3 −2
src/domains/firm_membership/entity.py+6 −6
src/domains/firm_membership/service.py+1 −1
src/domains/platform_staff/entity.py+4 −3
src/shared/auth/clerk.py ★+84 −34
src/shared/auth/permissions.py+25 −12
src/shared/authz/AGENTS.md+71
src/shared/authz/CLAUDE.md+1
src/shared/authz/__init__.py+13
src/shared/authz/entity.py+34
src/shared/authz/permissions.py ★+88
src/shared/db/entities.py+2
tests/integration/test_adr054_auth.py+114 −33
tests/integration/test_app_clients.py+5 −8
tests/integration/test_app_filing_types.py+61 −8
tests/integration/test_app_forums.py+2 −2
tests/integration/test_app_me.py+32 −1
tests/unit/test_authz_permissions.py+123