Task — engineering-spec@1

"AUTH 22-role RBAC catalogue — closed enum + permission matrix + role-assignment REST + JWT claims + ADR gate + stub→full migration"

doneTASK-AUTH-101
module auth · class product · priority p0 · created 2026-05-16 · shipped 2026-05-19
depends on TASK-AUTH-005 · blocks TASK-AUTH-108, TASK-AUTH-109, TASK-CRM-001, TASK-DOC-001, TASK-HR-001, TASK-KB-001, TASK-OKR-001, TASK-REW-001, TASK-TIME-001, TASK-INV-005, TASK-AUTH-104, TASK-INV-003, TASK-INV-004

§1 — Description (BCP-14 normative)

The AUTH service MUST ship the closed 22-role RBAC catalogue, the permission matrix, the role-assignment REST API, the JWT role-claim shape, the ADR gate, and the stub→full migration path. Each component:

  1. MUST define exactly 22 roles in a closed Rust enum Role at services/auth/src/rbac/catalogue.rs. The variants are (in catalogue order, matching website/docs/modules/auth.html#rbac-catalogue and DEC-121): RootAdmin, TenantAdmin, TenantMember, ServiceAccount, AgentPersona, Founder, Cfo, Cto, Coo, Chro, Cmo, Cpo, Cso, Cseco, Clo, Cdo, Dpo, Caio, ClientPortalUser, Auditor, Regulator, BillingSystem. The string form is kebab-case (e.g. tenant-admin, client-portal-user). The enum MUST NOT carry a _Other(String) variant — unknown strings parse to RoleParseError::UnknownRole(input) and are rejected at every API boundary.
  1. MUST define exactly 40 resources in a closed enum Resource (one per cross-module surface: subject, tenant, role_assignment, jwt_jwks, audit_row, crm_account, crm_contact, crm_deal, proj_issue, proj_engagement, proj_rate_card, proj_timeline, time_entry, time_expense, inv_invoice, inv_payment, inv_hoa_don, kb_document, kb_runbook, hr_member, hr_contract, hr_leave, hr_cccd_photo, rew_payslip, rew_bp_ledger, esop_grant, esop_valuation, learn_skill, learn_certification, okr_objective, okr_kr, res_allocation, doc_document, doc_signature, email_thread, chat_channel, chat_message, cuo_chain, memory_memory, obs_alert). Adding a 41st resource is an ADR.
  1. MUST define exactly 5 actions in a closed enum Action: Read, Write, Admin, Approve, Sign. Approve is reserved for dual-signoff workflows (CFO+CEO co-sign, etc.); Sign is reserved for e-signature on DOC + hóa đơn emissions. Adding a 6th action is an ADR.
  1. MUST seed the permission matrix in migration 0005_roles_permissions.sql with one row per (role, resource, action) tuple that is allowed. Disallowed tuples are absent — there is no explicit deny row. Total seeded rows: ~280 (varies by audit; the migration's row count is asserted in the migration test). The matrix is loaded into the RoleMatrix snapshot at service start and refreshed every 60 s by a tokio task.
  1. MUST expose POST /v1/admin/subjects/{subject_id}/roles with body {"role": "<kebab-name>"} to grant a single role to a subject. The handler:
  1. MUST expose DELETE /v1/admin/subjects/{subject_id}/roles/{role} with the same caller-permission gate. Deletion:
  1. MUST expose GET /v1/admin/roles returning the catalogue: {"version": <rbac_v>, "roles": [{"name":"<kebab>","display":"<Human>","reserved":<bool>,"requires_webauthn":<bool>,"scope_summary":"<one-line>"}, ...]}. The handler reads from the in-memory RoleMatrix; never hits the DB. RBAC-version-aware caching: ETag is W/"rbac-v<n>".
  1. MUST issue access tokens (TASK-AUTH-004 path) with two new claims:
  1. MUST check role membership via the in-memory RoleMatrix only. The matrix is loaded at boot from subject_roles join role_permissions and refreshed every 60 s. Per-request DB lookups for role/permission are MUST NOT (per DEC-126 — performance budget is < 50 µs per check at p95).
  1. MUST consult the RoleMatrix from RLS via the auth.has_role(role_name) SQL function (created in migration 0005_roles_permissions.sql). The function reads the per-session GUC auth.roles (set by the JWT middleware on every connection). RLS policies for sensitive tables (subjects, audit_row, billing_*, hr_cccd_photo, rew_payslip) MUST include a auth.has_role(<role>) check in addition to the existing tenant-id RLS.
  1. MUST classify the following 5 roles as reserved (Role::is_reserved() == true): root-admin, client-portal-user, auditor, regulator, billing-system. Reserved roles MUST NOT be assignable via the standard POST /v1/admin/subjects/{id}/roles endpoint. Attempts return 403 FORBIDDEN {"error":"reserved_role","role":"<x>","required_endpoint":"<path or 'not yet specified'>"}. The dedicated reserved-role-assignment endpoints land in slice 2 (out of scope for this task; see §9).
  1. MUST classify founder as WebAuthn-required (Role::requires_webauthn() == true). All other roles return false from this method. The classification is intrinsic to the role (closed enum match), not driven by configuration — preventing accidental relaxation.
  1. MUST reject any JWT whose roles claim contains a string that does not parse to a Role variant. The verifier (used by every consuming service per TASK-AUTH-004 §1 #7) treats this as a tampered token: 401 UNAUTHORIZED {"error":"invalid_token","reason":"unknown_role_in_claim"}. The check is at the verifier, not downstream — failing closed.
  1. MUST emit exactly one auth.role_catalogue_changed memory audit row whenever the catalogue version bumps (via the trigger on role_catalogue_version). The row carries {old_version, new_version, changed_at, migration_id, adr_id}. Catalogue version bumps without a recorded adr_id (e.g. ad-hoc INSERT in a manual psql session) fail the audit row's NOT NULL adr_id constraint and roll back the change.
  1. MUST ship migration 0005_roles_permissions.sql that creates:
  1. MUST ship migration 0006_role_catalogue_version.sql that creates:
  1. MUST ship the ADR gate at services/auth/src/rbac/adr.rs as a CI-callable validator. Invoked as cargo test rbac_adr_gate_test, it:
  1. MUST support the 5-role stub→full migration path without invalidating existing access tokens issued under rbac_v = 1. Existing tokens (issued by TASK-AUTH-002's 5-role allow-list, before this task ships) carry no rbac_v claim. The verifier treats the absence of rbac_v as implicit rbac_v = 1 and accepts the token for a 30-day grace window. After grace, missing-claim tokens are rejected (401 UNAUTHORIZED {"error":"rbac_version_required"}). The grace-window enforcer is TASK-AUTH-109.
  1. MUST classify the 5 stub roles (root-admin, tenant-admin, tenant-member, service-account, agent-persona) as a strict prefix of the 22-role catalogue — same names, same string form, additive permissions only. The matrix migration MUST NOT change the permission matrix for any stub role except to add new (resource, action) tuples. A regression test (rbac_stub_compat_test) asserts that every (stub_role, resource, action) tuple present in the prior TASK-AUTH-002 catalogue is still present after migration.
  1. MUST support layered narrowing via the scope_grant table: scope_grants(tenant_id UUID, subject_id UUID, resource TEXT, resource_id UUID, action TEXT, granted_by UUID, granted_at TIMESTAMPTZ, expires_at TIMESTAMPTZ). The grant NARROWS a role's base privilege to specific resource_ids. Example: cfo has inv_invoice + Read matrix-wide; a scope_grants row with resource=inv_invoice, resource_id=<invoice-A>, action=Read, expires_at=2026-12-31 for a tenant-member subject grants read access to invoice-A only. The HasPermission extractor checks both layers: role matrix MUST permit OR scope-grant MUST cover. Grants are not standalone privileges — they only narrow/extend within a tenant's scope.
  1. MUST complete role check (has_role / has_any_role / has_permission) in ≤ 50 µs p99 against an in-memory matrix of 22 roles × 200 permissions × 1000 active subjects. The performance test (rbac_perf_test) asserts this on every CI run.
  1. MUST emit OTel span auth.rbac_check with attributes subject_id_hash16, role, resource, action, outcome (allow | deny | reserved_role | webauthn_required | unknown_role) on every check. Sampling: 1% under steady state, 100% on outcome != allow (deny + error paths fully captured).
  1. MUST emit OTel metrics:
  1. MUST include roles and rbac_v claims in every JWT issued by TASK-AUTH-004 after this task ships. The JWT header typ MUST remain JWT; the claim shape change is additive only (no breaking changes to existing claim names). TASK-AUTH-004's verifier is updated to surface both claims via Claims::roles() and Claims::rbac_v().
  1. MUST ship the ADR file services/auth/adr/ADR-101-rbac-22-role-catalogue.md as part of this task. The ADR documents: business rationale (closed catalogue prevents ABAC slide), scope-creep risk assessment (each new role costs 1 ADR + DPO + CSEC review), deprecation policy (90-day shadow-monitoring window), audit-trail implications (every assignment + revocation chained into memory), and the explicit DPO + CSEC sign-off block.

§2 — Why this design (rationale for humans)

Why a closed 22-role catalogue and not ABAC (§1 #1, DEC-121, DEC-122)? ABAC (attribute-based access control) sounds flexible — "if subject.department == finance AND resource.classification <= confidential, allow" — but in practice it produces a debugging nightmare. Every access decision becomes a logic puzzle; tracing why a request was denied requires reconstructing the attribute graph at decision time. RBAC with a closed catalogue is auditable by construction: every subject's privileges are a finite, enumerable set, and changes are ADR-gated. The 22-role boundary is a deliberate ceiling — when we want a 23rd, we either (a) realise an existing role covers the case, or (b) write an ADR that forces explicit consideration of scope creep. The website docs (§2.6) describe this as "a design assertion."

Why the 5-role stub is a strict prefix (§1 #19, DEC-123)? TASK-AUTH-002 shipped a 2-name allow-list (tenant-admin, tenant-member); the wider stub used in TASK-AUTH-005/006 included root-admin, service-account, agent-persona. Those 5 names are not "temporary placeholder names" — they ARE the production names. This task adds 17 more without renaming any of the 5. The rbac_stub_compat_test regression test makes this guarantee enforceable: prior subjects holding tenant-admin keep exactly the same matrix-permission set + any new (resource, action) tuples that were absent before. This is the property that lets existing tokens (without rbac_v) keep working — the underlying matrix is additive only.

Why a permission matrix layer instead of role checks directly in code (§1 #4, DEC-122)? Role checks scattered across services produce drift: service A might use caller.has_role("chief-financial-officer"), service B might write caller.role == "cfo" || caller.role == "founder". The matrix centralises the truth — caller.has_permission(Resource::InvInvoice, Action::Approve) is the canonical check, and the matrix decides which roles satisfy it. Adding a new role only requires updating the matrix; consuming services don't change. This is the ISO 27001:2022 A.5.18 recommendation (access rights provisioned via documented roles), not via inline string comparisons.

Why role + scope-grant layered (§1 #20, DEC-124)? Pure RBAC over-grants: giving an external auditor the auditor role grants matrix-wide read; we only want them to see the specific audit window they were engaged for. The scope-grant layer narrows: auditor role provides base privilege; the scope_grants row narrows it to resource_id IN (engagement-2026-q3). Without scope-grants, we'd be forced to invent per-audit roles (auditor-2026-q3) — and that's the ABAC slide the closed catalogue is designed to prevent. Scope grants are NOT standalone privileges (they cannot grant what the role does not already permit); they only narrow/restrict. This preserves the matrix as the source of truth for "what is allowed in principle."

Why in-memory RoleMatrix with 60s refresh and not per-request DB lookup (§1 #9, §1 #21, DEC-126)? Role checks happen on every authenticated request — typically 5–20 per request lifetime (auth, RLS context, scope checks, audit-row emission). At 1k RPS with 10 checks each, that's 10k role lookups per second; at 1 ms per DB hop, that's 10 cores burning on RBAC alone. The in-memory matrix collapses this to a single hashmap lookup at < 50 µs. The 60-second refresh tolerates revocation latency in exchange for performance: a revoked role is honoured for up to 60s after revocation; the OTel auth_rbac_matrix_refresh_total{outcome=success} metric tracks freshness. Time-critical revocations (e.g. terminated employee) go via the per-tenant CRL flush endpoint that targets the cache directly (out of scope for this task; see TASK-AUTH-111 placeholder).

Why reserved roles cannot be self-assigned (§1 #11, DEC-127)? root-admin is the cross-tenant superuser — its assignment is a CyberSkill operator action, not a tenant action. client-portal-user belongs to PORTAL's JIT provisioning flow (TASK-PORTAL-003). auditor and regulator are external identities granted through a vetted intake — never by a tenant-admin clicking a UI button. billing-system is the Stripe/VietQR webhook identity — assigned via a setup script, not a REST call. Routing reserved-role assignment through the standard endpoint would invite operator mistakes; refusing at the API boundary makes the elevated path explicit.

Why founder requires WebAuthn (§1 #12, DEC-128)? The founder role grants cross-module privileged read (financial overview, OKR-cascade override, strategic-document signoff). A compromised founder credential is catastrophic — the entire company's executive view is exposed. WebAuthn (passkey) eliminates phishability and provides hardware-bound assurance. The check is intrinsic to the role (Role::Founder.requires_webauthn() == true) rather than configuration-driven because we never want this gate accidentally turned off via a config typo.

Why rbac_v in the JWT (§1 #8, DEC-129)? Without versioning, a long-lived access token issued before a role's permissions were tightened keeps the older, looser permissions until expiry. The rbac_v claim lets verifiers detect stale tokens and challenge them — the threshold (2 versions behind) tolerates normal refresh lag but catches significantly outdated tokens. Verifiers compare claims.rbac_v against RoleMatrix.version; > 2 behind → reject with rbac_version_stale. This is replay-resistance against tokens issued before a role-tightening ADR landed.

Why the ADR gate at CI (§1 #17, §1 #25, DEC-121)? Without the gate, a developer can quietly add a 23rd role via a migration patch and the closed-catalogue design assertion silently dies. The CI test fails the build until ADR-NNN.md exists AND the migration's SQL comment references it. The cost is one ADR file per role change; the benefit is that the catalogue's boundary is enforced by tooling, not by reviewer vigilance.

Why classify exactly 40 resources and 5 actions (§1 #2, §1 #3)? Resources and actions are themselves closed enums for the same reason roles are: drift between services on what counts as a "resource" or an "action" produces unauditable matrices. 40 covers every cross-module surface in the planned BACKLOG; 5 actions (Read/Write/Admin/Approve/Sign) cover every business workflow without ABAC-style verb explosion. Adding the 41st resource or 6th action is an ADR — the same governance discipline as adding a role.

Why does the roles claim hold names only and not the matrix (§1 #8, PDPL Art. 6)? Embedding the full permission matrix in every JWT bloats tokens (typical ~2 KB → ~50 KB) and creates a data-minimisation problem: every service that logs the JWT now logs every permission. Names-only keeps the token small and the verifier-side matrix lookup fast (< 50 µs). The verifier resolves names → permissions via the in-memory RoleMatrix; the token never carries the matrix itself.

Why auth.has_role() as a SQL function for RLS (§1 #10)? RLS policies need to evaluate at the database layer, not the application layer — application-layer checks can be bypassed by a SQL-injection or a misrouted query. The SQL function reads the per-session GUC (set by the JWT middleware at connection acquisition); RLS policies invoke it inline. This makes "tenant-admin or above can read audit rows" express as USING (tenant_id = current_setting('auth.tenant_id')::uuid AND auth.has_role('tenant-admin')) — the role check is in the policy, not behind it.

Why hard-delete role assignments instead of soft-delete (§1 #6)? The auth.role_revoked memory audit row IS the record. Soft-delete would create two records of truth (the row's revoked_at field plus the audit row) and invite "is this revoked?" ambiguity in queries. The audit row is unerasable (per AGENTS.md §6.5); soft-delete adds nothing the audit chain doesn't already provide.

Why a 30-day grace window for stub-era tokens (§1 #18, DEC-125)? Typical access-token lifetime in the TASK-AUTH-004 design is 1 hour; refresh tokens 7 days. A 30-day grace covers every refresh-token cycle plus a safety margin — no production user is surprised by a forced re-auth. The grace-window enforcer (TASK-AUTH-109) flips on 30d after this task ships; until then, missing-rbac_v tokens are accepted. After flip, missing-claim tokens are rejected — and the rejection metric auth_rbac_check_total{outcome=stub_token_rejected} tells operations how many tokens are still missing the claim.


§3 — API contract

3.1 — Closed role enum

// services/auth/src/rbac/catalogue.rs
use std::str::FromStr;
use std::fmt;
use serde::{Deserialize, Serialize};

/// The closed 22-role catalogue. Adding a variant requires ADR-NNN + matching migration.
/// String form is kebab-case via Display + FromStr.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(try_from = "&str", into = "String")]
pub enum Role {
    RootAdmin,
    TenantAdmin,
    TenantMember,
    ServiceAccount,
    AgentPersona,
    Founder,
    Cfo,
    Cto,
    Coo,
    Chro,
    Cmo,
    Cpo,
    Cso,
    Cseco,
    Clo,
    Cdo,
    Dpo,
    Caio,
    ClientPortalUser,
    Auditor,
    Regulator,
    BillingSystem,
}

impl Role {
    /// All 22 variants in catalogue order. Tests assert len() == 22.
    pub const ALL: &'static [Role] = &[
        Role::RootAdmin, Role::TenantAdmin, Role::TenantMember, Role::ServiceAccount,
        Role::AgentPersona, Role::Founder, Role::Cfo, Role::Cto, Role::Coo, Role::Chro,
        Role::Cmo, Role::Cpo, Role::Cso, Role::Cseco, Role::Clo, Role::Cdo, Role::Dpo,
        Role::Caio, Role::ClientPortalUser, Role::Auditor, Role::Regulator, Role::BillingSystem,
    ];

    /// Kebab-case string form (canonical wire/storage representation).
    pub fn as_str(self) -> &'static str {
        match self {
            Role::RootAdmin => "root-admin",
            Role::TenantAdmin => "tenant-admin",
            Role::TenantMember => "tenant-member",
            Role::ServiceAccount => "service-account",
            Role::AgentPersona => "agent-persona",
            Role::Founder => "founder",
            Role::Cfo => "chief-financial-officer",
            Role::Cto => "chief-technology-officer",
            Role::Coo => "chief-operating-officer",
            Role::Chro => "chief-human-resources-officer",
            Role::Cmo => "chief-marketing-officer",
            Role::Cpo => "cpo",
            Role::Cso => "cso",
            Role::Cseco => "cseco",
            Role::Clo => "clo",
            Role::Cdo => "cdo",
            Role::Dpo => "dpo",
            Role::Caio => "chief-ai-officer",
            Role::ClientPortalUser => "client-portal-user",
            Role::Auditor => "auditor",
            Role::Regulator => "regulator",
            Role::BillingSystem => "billing-system",
        }
    }

    /// Reserved roles (cannot be self-assigned via standard REST).
    pub fn is_reserved(self) -> bool {
        matches!(self,
            Role::RootAdmin | Role::ClientPortalUser | Role::Auditor
            | Role::Regulator | Role::BillingSystem
        )
    }

    /// WebAuthn-required roles (founder only at slice 1).
    pub fn requires_webauthn(self) -> bool {
        matches!(self, Role::Founder)
    }
}

#[derive(Debug, thiserror::Error)]
pub enum RoleParseError {
    #[error("unknown_role: {0}")]
    UnknownRole(String),
}

impl FromStr for Role {
    type Err = RoleParseError;
    fn from_str(s: &str) -> Result<Self, Self::Err> {
        for r in Role::ALL { if r.as_str() == s { return Ok(*r); } }
        Err(RoleParseError::UnknownRole(s.to_string()))
    }
}

impl fmt::Display for Role {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { f.write_str(self.as_str()) }
}

impl TryFrom<&str> for Role {
    type Error = RoleParseError;
    fn try_from(s: &str) -> Result<Self, Self::Error> { Role::from_str(s) }
}

impl From<Role> for String {
    fn from(r: Role) -> String { r.as_str().to_string() }
}

3.2 — Closed resource + action enums

// services/auth/src/rbac/permissions.rs
use serde::{Deserialize, Serialize};
use std::str::FromStr;

#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(try_from = "&str", into = "&'static str")]
pub enum Resource {
    Subject, Tenant, RoleAssignment, JwtJwks, AuditRow,
    CrmAccount, CrmContact, CrmDeal,
    ProjIssue, ProjEngagement, ProjRateCard, ProjTimeline,
    TimeEntry, TimeExpense,
    InvInvoice, InvPayment, InvHoaDon,
    KbDocument, KbRunbook,
    HrMember, HrContract, HrLeave, HrCccdPhoto,
    RewPayslip, RewBpLedger,
    EsopGrant, EsopValuation,
    LearnSkill, LearnCertification,
    OkrObjective, OkrKr,
    ResAllocation,
    DocDocument, DocSignature,
    EmailThread, ChatChannel, ChatMessage,
    CuoChain, MemoryMemory, ObsAlert,
}

impl Resource {
    pub const ALL: &'static [Resource] = &[ /* ...40 variants in order... */ ];
    pub fn as_str(self) -> &'static str { /* kebab-snake match */ todo!() }
}

#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(try_from = "&str", into = "&'static str")]
pub enum Action { Read, Write, Admin, Approve, Sign }

impl Action {
    pub const ALL: &'static [Action] = &[Action::Read, Action::Write, Action::Admin, Action::Approve, Action::Sign];
}

3.3 — In-memory RoleMatrix + 60s refresher

// services/auth/src/rbac/matrix.rs
use std::collections::{HashMap, HashSet};
use std::sync::Arc;
use arc_swap::ArcSwap;
use sha2::{Digest, Sha256};

use crate::rbac::catalogue::Role;
use crate::rbac::permissions::{Resource, Action};

#[derive(Debug, Clone)]
pub struct RoleMatrix {
    /// Catalogue version (bumped on every ADR-approved schema change).
    pub version: u32,
    /// (role, resource, action) → allowed.
    pub allowed: HashSet<(Role, Resource, Action)>,
    /// hash of the matrix for change detection during refresh.
    pub hash: [u8; 32],
}

impl RoleMatrix {
    pub fn has_permission(&self, role: Role, res: Resource, act: Action) -> bool {
        self.allowed.contains(&(role, res, act))
    }
}

pub struct RoleMatrixHandle {
    inner: Arc<ArcSwap<RoleMatrix>>,
}

impl RoleMatrixHandle {
    pub async fn load_from_db(pool: &sqlx::PgPool) -> anyhow::Result<Self> {
        let m = load_matrix(pool).await?;
        Ok(Self { inner: Arc::new(ArcSwap::from_pointee(m)) })
    }

    pub fn snapshot(&self) -> Arc<RoleMatrix> { self.inner.load_full() }

    /// Spawn 60s refresher. Reloads matrix; only swaps if hash changed.
    pub fn spawn_refresher(self: Arc<Self>, pool: sqlx::PgPool) {
        tokio::spawn(async move {
            let mut interval = tokio::time::interval(std::time::Duration::from_secs(60));
            loop {
                interval.tick().await;
                match load_matrix(&pool).await {
                    Ok(fresh) => {
                        let current = self.inner.load();
                        if current.hash != fresh.hash {
                            self.inner.store(Arc::new(fresh));
                            metrics::counter!("auth_rbac_matrix_refresh_total", "outcome" => "success").increment(1);
                        } else {
                            metrics::counter!("auth_rbac_matrix_refresh_total", "outcome" => "hash_unchanged").increment(1);
                        }
                    }
                    Err(_) => {
                        metrics::counter!("auth_rbac_matrix_refresh_total", "outcome" => "db_unreachable").increment(1);
                        // Keep serving stale matrix; never panic the service.
                    }
                }
            }
        });
    }
}

async fn load_matrix(pool: &sqlx::PgPool) -> anyhow::Result<RoleMatrix> {
    let version: i32 = sqlx::query_scalar("SELECT version FROM role_catalogue_version WHERE id = 1")
        .fetch_one(pool).await?;
    let rows: Vec<(String, String, String)> = sqlx::query_as(
        "SELECT role, resource, action FROM role_permissions ORDER BY role, resource, action"
    ).fetch_all(pool).await?;
    let mut allowed = HashSet::new();
    let mut hasher = Sha256::new();
    for (r, res, act) in &rows {
        hasher.update(r.as_bytes()); hasher.update(b"\0");
        hasher.update(res.as_bytes()); hasher.update(b"\0");
        hasher.update(act.as_bytes()); hasher.update(b"\n");
        let role = Role::from_str(r)?;
        let resource = Resource::from_str(res)?;
        let action = Action::from_str(act)?;
        allowed.insert((role, resource, action));
    }
    Ok(RoleMatrix { version: version as u32, allowed, hash: hasher.finalize().into() })
}

3.4 — Axum extractors

// services/auth/src/rbac/check.rs
use axum::{async_trait, extract::FromRequestParts, http::request::Parts, http::StatusCode};
use crate::rbac::catalogue::Role;
use crate::rbac::permissions::{Resource, Action};
use crate::jwt::Claims;

pub struct HasRole(pub Role);

#[async_trait]
impl<S: Send + Sync> FromRequestParts<S> for HasRole {
    type Rejection = (StatusCode, &'static str);
    async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result<Self, Self::Rejection> {
        let claims: &Claims = parts.extensions.get()
            .ok_or((StatusCode::UNAUTHORIZED, "no_claims"))?;
        // Read required role from request annotation (set by route handler).
        let required: Role = parts.extensions.get::<Role>()
            .copied().ok_or((StatusCode::INTERNAL_SERVER_ERROR, "no_role_annotation"))?;
        if claims.roles().contains(&required) {
            Ok(HasRole(required))
        } else {
            metrics::counter!("auth_rbac_check_total", "outcome" => "deny", "role" => required.as_str()).increment(1);
            Err((StatusCode::FORBIDDEN, "insufficient_role"))
        }
    }
}

pub struct HasPermission(pub Resource, pub Action);

#[async_trait]
impl<S: Send + Sync> FromRequestParts<S> for HasPermission {
    type Rejection = (StatusCode, &'static str);
    async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result<Self, Self::Rejection> {
        let claims: &Claims = parts.extensions.get().ok_or((StatusCode::UNAUTHORIZED, "no_claims"))?;
        let matrix: &Arc<crate::rbac::matrix::RoleMatrix> = parts.extensions.get().ok_or((StatusCode::INTERNAL_SERVER_ERROR, "no_matrix"))?;
        let (res, act) = parts.extensions.get::<(Resource, Action)>().copied().ok_or((StatusCode::INTERNAL_SERVER_ERROR, "no_perm_annotation"))?;
        let allowed = claims.roles().iter().any(|r| matrix.has_permission(*r, res, act));
        if allowed { Ok(HasPermission(res, act)) }
        else {
            metrics::counter!("auth_rbac_check_total", "outcome" => "deny", "role" => "agg").increment(1);
            Err((StatusCode::FORBIDDEN, "insufficient_permission"))
        }
    }
}

3.5 — Migration 0005

-- services/auth/migrations/0005_roles_permissions.sql
-- ADR: ADR-101

BEGIN;

CREATE TABLE roles (
    name              TEXT PRIMARY KEY,
    display           TEXT NOT NULL,
    reserved          BOOLEAN NOT NULL,
    requires_webauthn BOOLEAN NOT NULL,
    scope_summary     TEXT NOT NULL,
    lands_in_slice    INT NOT NULL
);

INSERT INTO roles (name, display, reserved, requires_webauthn, scope_summary, lands_in_slice) VALUES
  ('root-admin',         'Root Admin',         TRUE,  FALSE, 'Cross-tenant superuser; CyberSkill operators only',    1),
  ('tenant-admin',       'Tenant Admin',       FALSE, FALSE, 'Full admin within one tenant',                          1),
  ('tenant-member',      'Tenant Member',      FALSE, FALSE, 'Regular member; read shareable+, write own scopes',     1),
  ('service-account',    'Service Account',    FALSE, FALSE, 'Non-human identity; module-to-module mTLS',             1),
  ('agent-persona',      'Agent Persona',      FALSE, FALSE, 'Persona-versioned agent (CUO + sub-skills)',            1),
  ('founder',            'Founder',            FALSE, TRUE,  'Founder-CEO equivalent; WebAuthn required',             3),
  ('cfo',                'CFO',                FALSE, FALSE, 'Financial read + disbursement + ESOP signoff',          4),
  ('cto',                'CTO',                FALSE, FALSE, 'Tech-debt + security advisory + OBS digest target',     4),
  ('coo',                'COO',                FALSE, FALSE, 'Cross-module status + blocker triage + process',        4),
  ('chro',               'CHRO',               FALSE, FALSE, 'HR records + onboarding + perf review + PII-elevated', 4),
  ('cmo',                'CMO',                FALSE, FALSE, 'Campaign briefs + content calendars + comms approval',  4),
  ('cpo',                'CPO',                FALSE, FALSE, 'Product brief + roadmap + task-author canonical',         4),
  ('cso',                'CSO (Strategy)',     FALSE, FALSE, 'OKR cascade + scenarios + competitive intel read',      4),
  ('cseco',              'CSO (Security)',     FALSE, FALSE, 'Security review + key rotation + vuln triage',          4),
  ('clo',                'CLO',                FALSE, FALSE, 'Contract redline + DSAR triage + regulatory signoff',   4),
  ('cdo',                'CDO',                FALSE, FALSE, 'Data quality + lineage + residency + memory owner',      4),
  ('dpo',                'DPO',                FALSE, FALSE, 'DSAR fulfilment + breach notification + purge approval',4),
  ('caio',               'CAIO',               FALSE, FALSE, 'AI Gateway budget + synthesis sub-skill review',        5),
  ('client-portal-user', 'Client Portal User', TRUE,  FALSE, 'External tenant user (PORTAL filter only)',             5),
  ('auditor',            'Auditor',            TRUE,  FALSE, 'External auditor; read-only, time-bounded, scope-pinned',5),
  ('regulator',          'Regulator',          TRUE,  FALSE, 'External regulatory authority; DSAR + breach scopes',   5),
  ('billing-system',     'Billing System',     TRUE,  FALSE, 'Stripe/VietQR/Momo webhook identity; write-restricted', 5);

CREATE TABLE resources (
    name   TEXT PRIMARY KEY,
    module TEXT NOT NULL
);
-- 40 rows inserted here, one per Resource enum variant (omitted for brevity in spec).

CREATE TABLE actions (name TEXT PRIMARY KEY);
INSERT INTO actions (name) VALUES ('read'), ('write'), ('admin'), ('approve'), ('sign');

CREATE TABLE role_permissions (
    role     TEXT NOT NULL REFERENCES roles(name),
    resource TEXT NOT NULL REFERENCES resources(name),
    action   TEXT NOT NULL REFERENCES actions(name),
    PRIMARY KEY (role, resource, action)
);
-- ~280 INSERT rows here, generated from the matrix design in ADR-101 §3.

CREATE TABLE subject_roles (
    tenant_id  UUID NOT NULL,
    subject_id UUID NOT NULL,
    role       TEXT NOT NULL REFERENCES roles(name),
    granted_by UUID NOT NULL,
    granted_at TIMESTAMPTZ NOT NULL DEFAULT now(),
    PRIMARY KEY (subject_id, role)
);
ALTER TABLE subject_roles ENABLE ROW LEVEL SECURITY;
CREATE POLICY subject_roles_tenant ON subject_roles
    USING (tenant_id = current_setting('auth.tenant_id')::uuid)
    WITH CHECK (tenant_id = current_setting('auth.tenant_id')::uuid);

REVOKE INSERT, UPDATE, DELETE ON role_permissions FROM cyberos_app;  -- runtime cannot mutate matrix
REVOKE INSERT, UPDATE, DELETE ON roles FROM cyberos_app;             -- runtime cannot add roles
REVOKE INSERT, UPDATE, DELETE ON resources FROM cyberos_app;
REVOKE INSERT, UPDATE, DELETE ON actions FROM cyberos_app;

CREATE TABLE scope_grants (
    tenant_id   UUID NOT NULL,
    subject_id  UUID NOT NULL,
    resource    TEXT NOT NULL REFERENCES resources(name),
    resource_id UUID NOT NULL,
    action      TEXT NOT NULL REFERENCES actions(name),
    granted_by  UUID NOT NULL,
    granted_at  TIMESTAMPTZ NOT NULL DEFAULT now(),
    expires_at  TIMESTAMPTZ,
    PRIMARY KEY (subject_id, resource, resource_id, action)
);
ALTER TABLE scope_grants ENABLE ROW LEVEL SECURITY;
CREATE POLICY scope_grants_tenant ON scope_grants
    USING (tenant_id = current_setting('auth.tenant_id')::uuid)
    WITH CHECK (tenant_id = current_setting('auth.tenant_id')::uuid);

CREATE OR REPLACE FUNCTION auth.has_role(role_name TEXT) RETURNS BOOLEAN AS $$
DECLARE
    roles_csv TEXT;
BEGIN
    roles_csv := current_setting('auth.roles', true);
    IF roles_csv IS NULL THEN RETURN FALSE; END IF;
    RETURN role_name = ANY(string_to_array(roles_csv, ','));
END;
$$ LANGUAGE plpgsql STABLE;

COMMIT;

3.6 — Migration 0006 (catalogue version + trigger)

-- services/auth/migrations/0006_role_catalogue_version.sql
-- ADR: ADR-101

BEGIN;

CREATE TABLE role_catalogue_version (
    id         INT PRIMARY KEY CHECK (id = 1),
    version    INT NOT NULL,
    updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
    adr_id     TEXT NOT NULL
);
INSERT INTO role_catalogue_version (id, version, adr_id) VALUES (1, 2, 'ADR-101');
-- v1 was the implicit pre-TASK-AUTH-101 catalogue (5 roles); v2 ships here.

CREATE OR REPLACE FUNCTION bump_catalogue_version() RETURNS TRIGGER AS $$
BEGIN
    UPDATE role_catalogue_version SET version = version + 1, updated_at = now() WHERE id = 1;
    -- memory audit row emission via memory_writer bridge (TASK-AI-003) is handled at the migration commit hook.
    RETURN NULL;
END;
$$ LANGUAGE plpgsql;

CREATE TRIGGER trg_bump_on_roles AFTER INSERT OR UPDATE OR DELETE ON roles
    EXECUTE FUNCTION bump_catalogue_version();
CREATE TRIGGER trg_bump_on_perms AFTER INSERT OR UPDATE OR DELETE ON role_permissions
    EXECUTE FUNCTION bump_catalogue_version();

REVOKE INSERT, UPDATE, DELETE ON role_catalogue_version FROM cyberos_app;

COMMIT;

3.7 — REST handlers

// services/auth/src/admin/roles_rest.rs
use axum::{Json, extract::{Path, State}, http::StatusCode};
use serde::{Deserialize, Serialize};
use uuid::Uuid;
use crate::rbac::catalogue::Role;
use crate::rbac::matrix::RoleMatrixHandle;
use crate::audit::role_events;

#[derive(Deserialize)]
pub struct AssignRoleRequest { pub role: String }

#[derive(Serialize)]
pub struct AssignRoleResponse {
    pub subject_id: Uuid,
    pub role: String,
    pub granted_by: Uuid,
    pub granted_at: chrono::DateTime<chrono::Utc>,
}

pub async fn assign_role(
    State(state): State<AppState>,
    claims: Claims,                              // injected by TASK-AUTH-004 middleware
    Path(subject_id): Path<Uuid>,
    Json(req): Json<AssignRoleRequest>,
) -> Result<(StatusCode, Json<AssignRoleResponse>), ApiError> {
    // 1. Permission check.
    state.matrix.snapshot().require_permission(&claims.roles(), Resource::RoleAssignment, Action::Admin)?;
    // 2. Parse role string against closed enum.
    let role: Role = req.role.parse().map_err(|_| ApiError::UnknownRole(req.role.clone()))?;
    // 3. Refuse reserved-role assignment via this endpoint.
    if role.is_reserved() { return Err(ApiError::ReservedRole(role)); }
    // 4. Founder requires WebAuthn enrolment.
    if role.requires_webauthn() {
        let has_passkey = state.webauthn.has_factor(claims.tenant_id(), subject_id).await?;
        if !has_passkey { return Err(ApiError::WebAuthnRequired(role)); }
    }
    // 5. Insert + audit-before-action in one transaction.
    let mut tx = state.db.begin().await?;
    sqlx::query("INSERT INTO subject_roles (tenant_id, subject_id, role, granted_by) VALUES ($1, $2, $3, $4)")
        .bind(claims.tenant_id()).bind(subject_id).bind(role.as_str()).bind(claims.subject_id())
        .execute(&mut *tx).await
        .map_err(|e| {
            if let Some(db_err) = e.as_database_error() {
                if db_err.code().as_deref() == Some("23505") { return ApiError::AlreadyGranted; }
            }
            ApiError::Db(e)
        })?;
    role_events::emit_role_assigned(&mut tx, claims.tenant_id(), subject_id, role, claims.subject_id()).await?;
    tx.commit().await?;
    Ok((StatusCode::CREATED, Json(AssignRoleResponse { subject_id, role: role.as_str().to_string(), granted_by: claims.subject_id(), granted_at: chrono::Utc::now() })))
}

pub async fn revoke_role(
    State(state): State<AppState>,
    claims: Claims,
    Path((subject_id, role_str)): Path<(Uuid, String)>,
) -> Result<StatusCode, ApiError> {
    state.matrix.snapshot().require_permission(&claims.roles(), Resource::RoleAssignment, Action::Admin)?;
    let role: Role = role_str.parse().map_err(|_| ApiError::UnknownRole(role_str.clone()))?;
    let mut tx = state.db.begin().await?;
    let rows = sqlx::query("DELETE FROM subject_roles WHERE tenant_id = $1 AND subject_id = $2 AND role = $3")
        .bind(claims.tenant_id()).bind(subject_id).bind(role.as_str())
        .execute(&mut *tx).await?.rows_affected();
    if rows > 0 {
        role_events::emit_role_revoked(&mut tx, claims.tenant_id(), subject_id, role, claims.subject_id()).await?;
    }
    tx.commit().await?;
    Ok(StatusCode::NO_CONTENT)
}

pub async fn list_roles(State(state): State<AppState>) -> Json<RoleCatalogueResponse> {
    let matrix = state.matrix.snapshot();
    let roles: Vec<RoleCatalogueEntry> = Role::ALL.iter().map(|r| RoleCatalogueEntry {
        name: r.as_str().into(),
        display: r.display_name().into(),
        reserved: r.is_reserved(),
        requires_webauthn: r.requires_webauthn(),
        scope_summary: r.scope_summary().into(),
    }).collect();
    Json(RoleCatalogueResponse { version: matrix.version, roles })
}

3.8 — Updated JWT claims

// services/auth/src/jwt.rs (delta)
#[derive(Debug, Serialize, Deserialize)]
pub struct Claims {
    pub sub: Uuid,               // subject_id
    pub tid: Uuid,               // tenant_id
    pub iss: String,
    pub iat: i64,
    pub exp: i64,
    pub nbf: i64,
    pub roles: Vec<String>,      // NEW — kebab-case role names
    pub rbac_v: u32,             // NEW — catalogue version at issuance
}

impl Claims {
    pub fn roles(&self) -> Vec<Role> {
        // Per §1 #13: refuse tokens with unknown role names.
        self.roles.iter().map(|s| s.parse::<Role>()).collect::<Result<_, _>>()
            .expect("verifier upstream already rejected unknown-role tokens")
    }
    pub fn rbac_v(&self) -> u32 { self.rbac_v }
}

3.9 — ADR file

<!-- services/auth/adr/ADR-101-rbac-22-role-catalogue.md -->
# ADR-101 — 22-role closed RBAC catalogue

**Status:** Accepted
**Date:** 2026-05-16
**Deciders:** Stephen Cheng (CTO), DPO sign-off (pending), CSEC sign-off (pending)

## Context
CyberOS needs a role-based access control model that scales from the initial 1-tenant deployment (CyberSkill itself) to multi-tenant agency operation. Two design options: (a) ABAC (attribute-based), (b) RBAC with a closed catalogue.

## Decision
RBAC with exactly 22 closed roles. Permission matrix is `roles × resources × actions`. Roles are not extensible at runtime — schema-defined only. Adding role 23 requires a new ADR; same for resource 41 or action 6.

## Consequences
**Positive:** every access decision is auditable; permission matrix is enumerable; compliance auditors (ISO 27001 A.5.18) accept by construction.
**Negative:** narrowing per-resource access requires the scope-grants layer (DEC-124); pure RBAC over-grants.
**Mitigation:** scope-grants narrow within a tenant's matrix; never grant what the role doesn't already permit.

## Scope-creep risk assessment
Every new role costs: 1 ADR + DPO review + CSEC review + matrix migration + regression test. The cost is deliberate; it forces the question "does an existing role cover this?" before adding a new one.

## DPO sign-off
PDPL Art. 6 (data minimisation) is satisfied: the JWT carries role names only, not the permission matrix. The matrix never leaves the AUTH service.

## CSEC sign-off
Reserved-role assignment (root-admin, auditor, regulator, billing-system, client-portal-user) requires dedicated elevated endpoints, not the standard REST. Founder requires WebAuthn factor presence.

§4 — Acceptance criteria

  1. Closed enum invariantsRole::ALL.len() == 22 AND every variant has unique as_str() AND from_str(as_str()) == Ok(variant) for all 22.
  2. Unknown role rejection"super-admin".parse::<Role>() returns Err(RoleParseError::UnknownRole("super-admin")).
  3. Reserved role classification — exactly 5 roles return true from is_reserved(): root-admin, client-portal-user, auditor, regulator, billing-system.
  4. WebAuthn-required classification — exactly 1 role (founder) returns true from requires_webauthn().
  5. Resource closed enumResource::ALL.len() == 40 AND parse round-trip.
  6. Action closed enumAction::ALL.len() == 5 AND parse round-trip.
  7. Matrix seededSELECT count(*) FROM role_permissions returns the expected row count (matches ADR-101 §3 enumeration; assertion in migration test).
  8. Matrix in-memory load — service start loads matrix; RoleMatrix::has_permission(TenantAdmin, Subject, Admin) returns true.
  9. Matrix refresher hash-only-swap — running the refresher twice on unchanged matrix yields auth_rbac_matrix_refresh_total{outcome=hash_unchanged} increment 1, not a fresh allocation.
  10. POST role happy path — tenant-admin caller, valid target subject, valid role string → 201 with response shape; row appears in subject_roles; auth.role_assigned audit row emitted.
  11. POST role unknown{"role":"super-admin"} → 400 with {"error":"unknown_role"} body.
  12. POST role reserved{"role":"root-admin"} → 403 with {"error":"reserved_role"}.
  13. POST founder without passkey — target subject has no WebAuthn factor; {"role":"founder"} → 409 with {"error":"webauthn_required"}.
  14. POST role idempotent — re-POST same (subject_id, role) → 409 {"error":"already_granted"} (PK enforces).
  15. DELETE role happy — existing grant → 204; row removed; auth.role_revoked audit row emitted.
  16. DELETE role absent — non-existent grant → 204 (idempotent; matches REST DELETE semantics).
  17. GET /v1/admin/roles — returns version + 22 entries with stable order; ETag is W/"rbac-v<n>".
  18. JWT carries roles + rbac_v — newly issued token (after TASK-AUTH-101 ships) has both claims populated; older tokens (pre-TASK-AUTH-101) lack rbac_v and are accepted during grace window.
  19. JWT unknown-role rejection — verifier given a token with roles: ["super-admin"] returns 401 {"error":"invalid_token","reason":"unknown_role_in_claim"}.
  20. RLS consults role — query as a tenant-member subject against audit_row returns 0 rows; same query as tenant-admin returns all tenant-scoped rows. (auth.has_role SQL function in policy.)
  21. Stub compatibility — every (stub_role, resource, action) tuple from the pre-TASK-AUTH-101 matrix is present in the new matrix; no removed tuples (additive only).
  22. ADR gate — migration adding a 23rd role without ADR-NNN comment → cargo test rbac_adr_gate_test fails.
  23. Catalogue-changed audit — INSERT into roles (via test migration with ADR) bumps role_catalogue_version.version; emits exactly one auth.role_catalogue_changed memory audit row with old/new version + adr_id.
  24. Scope-grant narrowingcfo role + scope-grant (inv_invoice, invoice-A, Read, expires=future) → caller may read invoice-A; revoke grant → 403 on next request after 60s cache TTL.
  25. Perf budgetrbac_perf_test: 1k checks against 22-role × 40-resource × 5-action matrix complete in < 50 µs p99 (in-process, no network).
  26. OTel span emission — every check emits auth.rbac_check span with outcome attribute; deny + error outcomes always sampled.
  27. OTel metrics emissionauth_rbac_check_total, auth_rbac_check_latency_us, auth_rbac_matrix_refresh_total, auth_rbac_subject_role_count, auth_rbac_catalogue_version all observable from /metrics endpoint.

§5 — Verification

// services/auth/tests/rbac_catalogue_test.rs
use cyberos_auth::rbac::catalogue::{Role, RoleParseError};
use std::collections::HashSet;

#[test]
fn all_22_roles_present() {
    assert_eq!(Role::ALL.len(), 22);
    let names: HashSet<&str> = Role::ALL.iter().map(|r| r.as_str()).collect();
    assert_eq!(names.len(), 22, "duplicate role names");
}

#[test]
fn roundtrip_for_every_role() {
    for r in Role::ALL {
        let parsed: Role = r.as_str().parse().unwrap();
        assert_eq!(*r, parsed);
    }
}

#[test]
fn unknown_role_rejected() {
    let err = "super-admin".parse::<Role>().unwrap_err();
    assert!(matches!(err, RoleParseError::UnknownRole(s) if s == "super-admin"));
}

#[test]
fn reserved_roles_exact_set() {
    let reserved: HashSet<Role> = Role::ALL.iter().copied().filter(|r| r.is_reserved()).collect();
    let expected: HashSet<Role> = [Role::RootAdmin, Role::ClientPortalUser, Role::Auditor, Role::Regulator, Role::BillingSystem].into_iter().collect();
    assert_eq!(reserved, expected);
}

#[test]
fn webauthn_required_exactly_founder() {
    let required: Vec<Role> = Role::ALL.iter().copied().filter(|r| r.requires_webauthn()).collect();
    assert_eq!(required, vec![Role::Founder]);
}
// services/auth/tests/rbac_adr_gate_test.rs (excerpt)
#[tokio::test]
async fn assign_unknown_role_rejected() {
    let ctx = TestCtx::new_with_seed("tenant-admin").await;
    let resp = ctx.post(&format!("/v1/admin/subjects/{}/roles", target_id), json!({"role":"super-admin"})).await;
    assert_eq!(resp.status(), 400);
    let body: serde_json::Value = resp.json().await.unwrap();
    assert_eq!(body["error"], "unknown_role");
    assert_eq!(body["role"], "super-admin");
    assert!(body["allowed"].as_array().unwrap().len() == 22);
}

#[tokio::test]
async fn assign_reserved_role_rejected() {
    let ctx = TestCtx::new_with_seed("tenant-admin").await;
    let resp = ctx.post(&format!("/v1/admin/subjects/{}/roles", target_id), json!({"role":"root-admin"})).await;
    assert_eq!(resp.status(), 403);
    assert_eq!(resp.json::<serde_json::Value>().await.unwrap()["error"], "reserved_role");
}

#[tokio::test]
async fn assign_founder_without_passkey_rejected() {
    let ctx = TestCtx::new_with_seed("tenant-admin").await;
    let target_id = ctx.create_subject_without_passkey().await;
    let resp = ctx.post(&format!("/v1/admin/subjects/{}/roles", target_id), json!({"role":"founder"})).await;
    assert_eq!(resp.status(), 409);
    assert_eq!(resp.json::<serde_json::Value>().await.unwrap()["error"], "webauthn_required");
}

#[tokio::test]
async fn assign_role_emits_audit_row() {
    let ctx = TestCtx::new_with_seed("tenant-admin").await;
    let target_id = ctx.create_subject().await;
    let resp = ctx.post(&format!("/v1/admin/subjects/{}/roles", target_id), json!({"role":"cfo"})).await;
    assert_eq!(resp.status(), 201);
    let rows = ctx.memory_audit_rows("auth.role_assigned").await;
    assert_eq!(rows.len(), 1);
    assert_eq!(rows[0]["subject_id"], target_id.to_string());
    assert_eq!(rows[0]["role"], "chief-financial-officer");
}
// services/auth/tests/rbac_adr_gate_test.rs
#[test]
fn matrix_check_under_50us_p99() {
    let matrix = synthetic_matrix(22, 40, 5);
    let mut times: Vec<u64> = Vec::with_capacity(10_000);
    let start = std::time::Instant::now();
    for _ in 0..10_000 {
        let t0 = std::time::Instant::now();
        std::hint::black_box(matrix.has_permission(Role::Cfo, Resource::InvInvoice, Action::Read));
        times.push(t0.elapsed().as_nanos() as u64);
    }
    let elapsed = start.elapsed();
    times.sort_unstable();
    let p99_us = (times[(times.len() * 99) / 100] as f64) / 1000.0;
    eprintln!("matrix check p99 = {p99_us:.3} µs (10k iters, total {elapsed:?})");
    assert!(p99_us < 50.0, "p99 = {p99_us}µs exceeds 50µs budget");
}
// services/auth/tests/rbac_adr_gate_test.rs
#[test]
fn every_role_touching_migration_has_adr_reference() {
    let walker = crate::rbac::adr::AdrGate::new("services/auth/migrations", "services/auth/adr");
    let report = walker.scan().expect("scan");
    assert!(report.violations.is_empty(),
        "migrations touch roles/role_permissions without ADR reference: {:#?}", report.violations);
}
// services/auth/tests/rls_isolation_test.rs
#[tokio::test]
async fn stub_token_accepted_during_grace_window() {
    let ctx = TestCtx::new().await;
    let stub_token = ctx.issue_stub_token(/* no rbac_v claim */).await;
    let resp = ctx.get("/v1/admin/roles").header("Authorization", format!("Bearer {stub_token}")).await;
    assert_eq!(resp.status(), 200);
}

#[tokio::test]
async fn stub_token_rejected_after_grace_window() {
    let ctx = TestCtx::new_with_clock_skew_days(31).await;  // simulate post-grace
    let stub_token = ctx.issue_stub_token().await;
    let resp = ctx.get("/v1/admin/roles").header("Authorization", format!("Bearer {stub_token}")).await;
    assert_eq!(resp.status(), 401);
    assert_eq!(resp.json::<serde_json::Value>().await.unwrap()["reason"], "rbac_version_required");
}

#[tokio::test]
async fn stub_role_permissions_strictly_additive() {
    let pre_matrix = load_pre_task_auth_101_matrix();
    let post_matrix = load_matrix_after_migrations();
    for (role, res, act) in &pre_matrix.allowed {
        assert!(post_matrix.allowed.contains(&(*role, *res, *act)),
            "TASK-AUTH-101 removed pre-existing tuple ({role:?}, {res:?}, {act:?})");
    }
}

§6 — Implementation skeleton

(API contract above is the skeleton — Resource enum's full 40-variant body is filled in during implementation; ADR-101 §3 enumerates the matrix rows that the migration seeds.)


§7 — Dependencies

Upstream (this task depends on):

Downstream (this task blocks):

Cross-module (informational):


§8 — Example payloads

8.1 — POST /v1/admin/subjects/{id}/roles request

{
  "role": "cfo"
}

8.2 — 201 CREATED response

{
  "subject_id": "9b1deb4d-3b7d-4bad-9bdd-2b0d7b3dcb6d",
  "role": "chief-financial-officer",
  "granted_by": "8a7c8c80-1234-4567-89ab-cdef01234567",
  "granted_at": "2026-05-16T14:32:11Z"
}

8.3 — auth.role_assigned memory audit row

{
  "kind": "auth.role_assigned",
  "tenant_id": "5e8f1d2a-...",
  "subject_id": "9b1deb4d-3b7d-4bad-9bdd-2b0d7b3dcb6d",
  "subject_id_hash16": "9b1deb4d3b7d4bad",
  "role": "chief-financial-officer",
  "granted_by": "8a7c8c80-1234-4567-89ab-cdef01234567",
  "rbac_v": 2,
  "trace_id": "4bf92f3577b34da6a3ce929d0e0e4736",
  "ts_ns": 1747920731000000000
}

8.4 — GET /v1/admin/roles response (truncated)

{
  "version": 2,
  "roles": [
    {"name":"root-admin","display":"Root Admin","reserved":true,"requires_webauthn":false,"scope_summary":"Cross-tenant superuser; CyberSkill operators only"},
    {"name":"tenant-admin","display":"Tenant Admin","reserved":false,"requires_webauthn":false,"scope_summary":"Full admin within one tenant"},
    {"name":"founder","display":"Founder","reserved":false,"requires_webauthn":true,"scope_summary":"Founder-CEO equivalent; WebAuthn required"}
  ]
}

8.5 — JWT claims (post-TASK-AUTH-101)

{
  "sub": "9b1deb4d-3b7d-4bad-9bdd-2b0d7b3dcb6d",
  "tid": "5e8f1d2a-...",
  "iss": "https://auth.cyberskill.world",
  "iat": 1747920731,
  "exp": 1747924331,
  "nbf": 1747920731,
  "roles": ["tenant-member", "chief-financial-officer"],
  "rbac_v": 2
}

8.6 — auth.role_catalogue_changed memory audit row

{
  "kind": "auth.role_catalogue_changed",
  "old_version": 2,
  "new_version": 3,
  "migration_id": "0007_add_role_caso.sql",
  "adr_id": "ADR-115",
  "changed_at": "2026-08-01T10:00:00Z",
  "ts_ns": 1754049600000000000
}

§9 — Open questions

Deferred:

All other questions resolved.


§10 — Failure modes inventory

FailureDetectionOutcomeRecovery
Role::ALL length drifts from 22 (developer adds variant without updating ALL)rbac_catalogue_test::all_22_roles_presentCI fails before mergeAdd the variant to ALL array
Two roles share the same as_str()rbac_catalogue_test::all_22_roles_present (HashSet len check)CI failsFix duplicate string in as_str() match
Migration 0005 row count driftsrbac_permission_matrix_test (asserts seeded row count == ADR-101 expectation)CI failsEither fix migration OR update ADR + test together
Matrix loader hits malformed role_permissions row (NULL role)load_matrix returns Err; service stays on prior snapshot; auth_rbac_matrix_refresh_total{outcome=db_unreachable} incrementsService serves stale matrix; OBS alarm at 5min sustainedOperator inspects DB; rolls back bad migration
RoleMatrix refresher panicsTokio task supervisor restarts; OBS alarm sev-2 on panic count > 0Brief refresh gap (60s); checks still served from stale snapshotInvestigate panic via tracing; ship patch
JWT contains roles: ["super-admin"] (tampered token)Claims::roles() parse fails at verifier401 unknown_role_in_claim; OBS sev-3 if pattern repeatsToken is invalid; user re-auths
JWT missing rbac_v after grace windowVerifier check against clock401 rbac_version_requiredUser re-auths and gets a fresh token with rbac_v
JWT carries rbac_v: 1 but live version is 4Verifier compares; > 2 stale → reject401 rbac_version_staleRefresh-token flow issues fresh access token
POST role with body {"role":"super-admin"}Handler parse400 unknown_role with allowed array of 22Caller picks valid role
POST role for reserved role (root-admin)Handler is_reserved() check403 reserved_roleCaller routes to dedicated reserved-role endpoint
POST founder for subject without passkeyHandler webauthn::has_factor check409 webauthn_requiredEnrol passkey first (TASK-AUTH-105)
POST role duplicate (race-condition double-click)Postgres PK violation (23505)409 already_granted (idempotent on subject_id+role PK)No action; request is no-op
DELETE absent roleHandler returns 204 anyway204 NO CONTENT (REST idempotency)No action
Audit row emission fails inside grant txPostgres transaction rolls back500 audit_failed; subject_roles INSERT also revertsOperator inspects memory_writer health
Matrix auth.has_role SQL function evaluates wrong (RLS bypass risk)Negative test rls_audit_row_member_zero_rowsTest failsFix function or policy
Two services see different matrix versions brieflyExpected — 60s eventual consistencyrbac_v claim catches stale token verificationRefresher catches up; OTel auth_rbac_catalogue_version gauge shows version per service
Migration adds 23rd role with no ADR filerbac_adr_gate_test CI gateBuild failsEither write ADR or revert migration
ADR file referenced but doesn't existrbac_adr_gate_test checks path existsBuild failsCreate ADR or fix path
Catalogue version bump trigger fails silentlyTest rbac_catalogue_version_bump asserts incrementCI fails before mergeInvestigate trigger function body
Scope-grant points at deleted resource (resource_id no longer exists)Periodic FK-check job (out of scope here)Stale grant is no-op (resource not found at access time)Future cleanup job in slice 2
Subject holds 100+ roles (pathological)Test rbac_subject_role_count_gauge warns at > 10OBS sev-4; not blockingReview with tenant-admin; revoke unnecessary
RLS policy on subjects denies tenant-admin (regression)rbac_rls_integration_testTest failsFix policy USING clause
Bcrypt path changed (TASK-AUTH-114 lands) and role gate breaks for founderTest rbac_founder_webauthn_gate_test after bcrypt swapCI failsCoordinate TASK-AUTH-114 + TASK-AUTH-101 changes
60s refresher leaks task on shutdownService shutdown handler cancels tokenTokio drop drainsNone (defensive)
In-memory matrix grows unbounded (large tenant)Matrix size = roles × resources × actions = 4400 rows maxBounded by closed enumsNone — bound by design
Subject's role-cache out of sync briefly after grantExpected 60s window; OTel auth_rbac_subject_role_count gaugeCaller waits one refresh cycleAcceptable per DEC-126
Concurrent grant + revoke on same (subject_id, role)Postgres serialisationEither grant-then-revoke (clean) OR revoke-on-nothing (204)None — serialisable transactions handle it
Audit row chain head drift across servicesPer-row prev_chain validates at memory_writerBridge rejects out-of-order rowsOperator runs cyberos doctor --repair
Test fixtures call Role::Other(s)Compile error (variant doesn't exist)Build failsFix test to use valid Role
RBAC service started without role-catalogue tableMigration not appliedService refuses to start (config validation)Apply migrations

§11 — Implementation notes


End of TASK-AUTH-101.