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:
- Authentication — the existing JWT subsystem in
pycharter.api.dependencies.authvalidates the Bearer token and produces a payload withsub,role, and (P6-001 additions)workspace_id. - Authorisation —
pycharter.api.dependencies.role_check.require_roleconverts the verified payload into aRequesterContext(viaget_requester_context) and enforces a declared minimum hierarchy role per route. The dependency raises403 Forbiddenvia_http_errors.raise_logged_forbiddenwhen 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:
- Auth disabled (dev / smoke). When
PYCHARTER_AUTH_DISABLED=trueevery caller is asuperuserwith no workspace scoping. Mirrorsget_current_user's behaviour for the same flag so JWT + role gates stay consistent. Off in production. - Verified JWT (canonical source). When the request carries an
Authorization: Bearer <token>header that decodes, the token'sroleandworkspace_idclaims are used. LegacyAdmin/Userrole labels (from tokens issued before P6-001) are mapped toadmin/viewerso the transition is non-breaking. - Anonymous default. Otherwise the caller is treated as a
viewerwithworkspace_id=None— non-superuser readers with no workspace see nothing by design.
The legacy
X-Role/X-Workspaceheader fallback (gated byPYCHARTER_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_headersto 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)¶
- Token issuance must emit
role∈{viewer, reviewer, admin, superuser}and aworkspace_idclaim. OldAdmin/Usertokens still verify and map toadmin/viewervia_LEGACY_ROLE_MAP. PYCHARTER_REQUESTER_CONTEXT_FROM_HEADERSis no longer read. Removing it from your deployment config is harmless._context_from_headersand the header-reader code have been retired fromrequester_context.py. TheRequesterContextshape 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:
- Add a user with role
superusertoPYCHARTER_AUTH_USERS: POST /api/v1/auth/loginwith those credentials → returns a JWT whoseroleclaim issuperuser. 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—Roleenum,ROLE_ORDER, JWT + header resolution.pycharter.api.dependencies.role_check—require_rolefactory.pycharter.api.dependencies.auth— JWT issuance / verification.