Skip to content

Role / Permissions Matrix (P6-001)

Phase 6 card P6-001 introduces real role enforcement on the governance, mapping, review, and ingest HTTP surfaces. Authorisation is split into two layers:

  1. Authentication — the existing JWT subsystem in pycharter.api.dependencies.auth validates the Bearer token and produces a payload with sub, role, and (P6-001 additions) workspace_id.
  2. Authorisation — pycharter.api.dependencies.role_check.require_role converts the verified payload into a RequesterContext (via get_requester_context) and enforces a declared minimum hierarchy role per route. The dependency raises 403 Forbidden via _http_errors.raise_logged_forbidden when the caller's role rank is below the minimum.

Role hierarchy

Rank Role Scope Visible policy states
0 viewer workspace-scoped approved
1 reviewer workspace-scoped approved, under_review
2 admin workspace-scoped approved, under_review, deprecated, rejected
3 superuser cross-workspace same as admin, plus bypass workspace filter

The numeric rank lives in pycharter.api.dependencies.requester_context.ROLE_ORDER. A request satisfies require_role(min_role) iff ROLE_ORDER[request.role] >= ROLE_ORDER[min_role].

Source of role + workspace

get_requester_context resolves in three steps:

  1. Auth disabled (dev / smoke). When PYCHARTER_AUTH_DISABLED=true every caller is a superuser with no workspace scoping. Mirrors get_current_user's behaviour for the same flag so JWT + role gates stay consistent. Off in production.
  2. Verified JWT (canonical source). When the request carries an Authorization: Bearer <token> header that decodes, the token's role and workspace_id claims are used. Legacy Admin / User role labels (from tokens issued before P6-001) are mapped to admin / viewer so the transition is non-breaking.
  3. Anonymous default. Otherwise the caller is treated as a viewer with workspace_id=None — non-superuser readers with no workspace see nothing by design.

The legacy X-Role / X-Workspace header fallback (gated by PYCHARTER_REQUESTER_CONTEXT_FROM_HEADERS) was removed in the 2026-05-20 refactor. JWT is the only auth source now. Tests use :func:tests._helpers.jwt_headers to mint short-lived tokens instead of sending role headers.

JWT claim mapping for clients issuing new tokens:

{
  "sub": "alice",
  "role": "reviewer",
  "workspace_id": "8f0a51c5-0e62-4f3a-...",
  "exp": 1747676419,
  "iat": 1747672819
}

role is one of viewer | reviewer | admin | superuser; workspace_id is the concept-scheme UUID the user is scoped to (omit for superuser).

Route × minimum-role matrix

Read endpoints (GETs) are not enumerated here — they remain authenticated (any valid Bearer token works) and the response-layer policy filters in pycharter.api.routes.v1.semantic_context._filter_pack continue to narrow visibility per role. Only write endpoints that gained explicit require_role checks in P6-001 are listed:

Method + Path Minimum role Module
POST /api/v1/suggestions/{id}/accept reviewer semantic_suggestions.accept
POST /api/v1/suggestions/{id}/dismiss reviewer semantic_suggestions.dismiss
POST /api/v1/suggestions/{id}/request-changes reviewer semantic_suggestions.request_changes
POST /api/v1/suggestions/accept-batch reviewer semantic_suggestions.accept_batch
POST /api/v1/suggestions/dismiss-batch reviewer semantic_suggestions.dismiss_batch
POST /api/v1/suggestions/request-changes-batch reviewer semantic_suggestions.request_changes_batch
POST /api/v1/contracts/{contract_id}/fields/{field_path}/binding/transitions reviewer semantic_bindings.create_transition
POST /api/v1/contracts/{contract_id}/fields/{field_path}/binding/accept reviewer semantic_bindings_actions.accept_binding
POST /api/v1/contracts/{contract_id}/fields/{field_path}/binding/remap reviewer semantic_bindings_actions.remap_binding
POST /api/v1/contracts/{contract_id}/fields/{field_path}/binding/mark-technical-only reviewer semantic_bindings_actions.mark_technical_only
POST /api/v1/contracts/{contract_id}/fields/{field_path}/binding/annotate reviewer semantic_bindings_actions.annotate_binding
POST /api/v1/proposals/link admin semantic_proposals.link_proposal_to_suggestion
POST /api/v1/semantic/ingestion/import reviewer semantic_ingestion.import_ontology
POST /api/v1/semantic/ingestion/import-all reviewer semantic_ingestion.import_all_ontologies
POST /api/v1/semantic/ingestion/kb-text reviewer semantic_kb_ingest.ingest_text
POST /api/v1/semantic/ingestion/kb-upload reviewer semantic_kb_ingest.ingest_upload

Routes intentionally not gated by P6-001 (open to any authenticated caller until a follow-up card narrows them) include the remaining proposal lifecycle endpoints (create_proposal, update_status, merge_proposal, validate_proposal), the SHACL governance validator (semantic_governance.validate_governance), all semantic_health reads, and the rest of the editor / search / metadata surface. Phase 6 follow-up cards (P6-004 audit-export et al.) will extend the matrix.

Migration notes — from P5-006 headers to P6-001 JWT

The P5-006 release shipped a placeholder resolver that read X-Role and X-Workspace directly. P6-001 added the JWT path alongside it, gated by PYCHARTER_REQUESTER_CONTEXT_FROM_HEADERS=true. The 2026-05-20 refactor removed the header path entirely — JWT is now the only auth source.

Migration outcome (completed)

  1. Token issuance must emit role ∈ {viewer, reviewer, admin, superuser} and a workspace_id claim. Old Admin / User tokens still verify and map to admin / viewer via _LEGACY_ROLE_MAP.
  2. PYCHARTER_REQUESTER_CONTEXT_FROM_HEADERS is no longer read. Removing it from your deployment config is harmless.
  3. _context_from_headers and the header-reader code have been retired from requester_context.py. The RequesterContext shape itself is unchanged, so no downstream code (response filters, P5-006 policy enforcement) had to move.

Client migration plan (UIs, CLIs, scripts)

Before (P5-006) After (P6-001)
X-Role: admin Authorization: Bearer <jwt-with-role-admin>
X-Workspace: <scheme-uuid> JWT workspace_id claim
(No auth header) POST /api/v1/auth/login then attach Bearer token

Test migration plan

tests/unit/test_semantic_context_routes.py and tests/unit/test_semantic_context_policy_filtering.py use header fixtures (_headers(Role.X, workspace)) — these keep passing because the header fallback is on by default in tests. New tests added in P6-001 (tests/unit/test_role_check_dependency.py, tests/unit/test_routes_role_enforcement.py) exercise the JWT path directly so the production behaviour is pinned independently of the fallback.

Bootstrapping the first superuser

Because superuser is a JWT role claim, the bootstrap path is the existing user-config flow plus a role override:

  1. Add a user with role superuser to PYCHARTER_AUTH_USERS:
    [
      {"username": "root", "password": "<dev-only>", "role": "superuser"}
    ]
    
  2. POST /api/v1/auth/login with those credentials → returns a JWT whose role claim is superuser. The token bypasses workspace scoping.

For production deployments a follow-up card (P6-002 — user management) is expected to move the user table into the database and introduce a proper bootstrap CLI; until then operators must seed at least one superuser row via the env var to perform cross-workspace administration.

Audit and logging

require_role's denial path uses raise_logged_forbidden from api/_http_errors.py, which writes a structured logger.exception record with the caller's resolved role, the required role, and the route handler name. The public response body is a static string — never the exception's str() — to satisfy the project-wide information-disclosure policy (CodeQL).

See also

  • docs/reference/semantic-context-roles.md — P5-006 visibility filtering rules. Updated to point at this matrix for the new enforcement points.
  • pycharter.api.dependencies.requester_context — Role enum, ROLE_ORDER, JWT + header resolution.
  • pycharter.api.dependencies.role_check — require_role factory.
  • pycharter.api.dependencies.auth — JWT issuance / verification.