From d836f2fe467fa6fe5f195962c8d0efd5e6ec7b28 Mon Sep 17 00:00:00 2001 From: Abiorh001 Date: Tue, 14 Jul 2026 17:33:20 +0100 Subject: [PATCH 1/6] docs(auth): approve idempotency plan --- .agent-loop/LOOP_STATE.md | 20 +- .agent-loop/REVIEW_LOG.md | 21 ++ .agent-loop/WORK_QUEUE.md | 14 +- .../CHUNK_MAP.md | 7 +- .../STATUS.md | 22 +- ...S-AUTH-001-05B-idempotency-invalidation.md | 199 +++++++++++++++--- 6 files changed, 226 insertions(+), 57 deletions(-) diff --git a/.agent-loop/LOOP_STATE.md b/.agent-loop/LOOP_STATE.md index de988016..c963e094 100644 --- a/.agent-loop/LOOP_STATE.md +++ b/.agent-loop/LOOP_STATE.md @@ -4,23 +4,21 @@ - Active initiative: `WS-AUTH-001` - Workstream Authorization Service - Active planning chunk: none -- Active implementation chunk: none -- Branch: `codex/ws-auth-001-cat-post-merge-memory` +- Active implementation chunk: `WS-AUTH-001-05B` - Authority Idempotency And + Invalidation Foundation +- Branch: `codex/ws-auth-001-05b-idempotency-invalidation` - Worktree: `/home/abiorh/flow/workstream-authorization-service` -- Status: `WS-AUTH-001-CAT` passed required internal reviews, Backend, Agent - Gates, CodeRabbit, and explicit human approval, then merged through PR #117 as - `4c5d4fc` on 2026-07-14. Post-merge memory is the only active work. +- Status: CAT post-merge memory merged through PR #118 as `eba7e2b` on + 2026-07-14. `WS-AUTH-001-05B` is active; its repaired L1 plan passed senior + engineering, QA/test, and security/auth/privacy review. - Prior `WS-AUTH-001-01` reviewed implementation SHA: `be0b836` - Prior `WS-AUTH-001-01` final merged branch head: `b5217e1` -- Latest integrated `main` merge commit: `4c5d4fc` -- Current gate: CAT post-merge memory and stop; no runtime implementation chunk - is active. +- Latest integrated `main` merge commit: `eba7e2b` +- Current gate: bounded AUTH-05B implementation and deterministic evidence. - Scope checkpoint: the 52 approved identifiers and `/api/v1` namespace remain unchanged. AUTH-05A's 49-identifier audit base remains runtime truth until the three planned recovery identifiers receive typed/SQL parity in AUTH-13/14. -- Next chunk: the user explicitly directed AUTH-05B to continue after this - reconciliation. Its start signal is recorded and it may activate after this - post-merge memory update merges, without another start signal. +- Next chunk: `WS-AUTH-001-06` remains inactive. Do not start it automatically. - Focused evidence: the audit/delegation suite passed 11 tests at 94.55 percent audit-subsystem coverage. Final GitHub Backend passed 949 tests at 82.77 percent global coverage and 91.07 percent artifact-foundation coverage. diff --git a/.agent-loop/REVIEW_LOG.md b/.agent-loop/REVIEW_LOG.md index cb6a6986..380311b1 100644 --- a/.agent-loop/REVIEW_LOG.md +++ b/.agent-loop/REVIEW_LOG.md @@ -1,5 +1,26 @@ # Review Log +## 2026-07-14 - WS-AUTH-001-05B Repaired Plan Passed + +The repaired L1 plan passes senior engineering, QA/test, and +security/auth/privacy review. The approved contract closes the ten-operation +registry, strict canonical request variants, actor-bound audit linkage, +single-use claim state machine, immutable database enforcement, clean mismatch +transaction, exact event counts, migration compatibility, and hostile-input +privacy evidence. Bounded runtime implementation may begin; AUTH-06 remains +inactive. + +## 2026-07-14 - WS-AUTH-001-05B L1 Plan Review Repair + +PR #118 merged CAT post-merge memory as `eba7e2b`, satisfying the recorded +AUTH-05B activation gate. The first 05B L1 preimplementation review rejected +runtime edits until the contract closed the exact operation registry, strict +canonical request/response/result types, opaque claim ownership, committed-row +immutability, clean mismatch-denial transaction, narrow invalidation ownership, +and deterministic downgrade guard. The repair also replaces the numeric line +ceiling with the user-directed semantic chunk boundary. Runtime code remains +unmodified pending repaired plan approval. + ## 2026-07-14 - WS-AUTH-001-CAT Merged PR #117 merged through explicit human approval as `4c5d4fc`. Final branch head diff --git a/.agent-loop/WORK_QUEUE.md b/.agent-loop/WORK_QUEUE.md index 1a39b70a..4413eb0c 100644 --- a/.agent-loop/WORK_QUEUE.md +++ b/.agent-loop/WORK_QUEUE.md @@ -4,13 +4,13 @@ | Chunk | Title | Risk | Status | |---|---|---:|---| -| `WS-AUTH-001-CAT-MEMORY` | Catalogue Post-Merge Memory | L1 | PR #117 merge confirmed; memory update active | +| `WS-AUTH-001-05B` | Authority Idempotency And Invalidation Foundation | L1 | Active; repaired L1 plan passed, implementation in progress | ## Planned Next | Chunk | Title | Risk | Status | |---|---|---:|---| -| `WS-AUTH-001-05B` | Authority Idempotency And Invalidation Foundation | L1 | Start signal received; activate after CAT post-merge memory merges | +| `WS-AUTH-001-06` | Canonical Actor Profile And Identity Link | L1 | Inactive until AUTH-05B merge/memory and a separate explicit user start | | `WS-QUAL-001-01B2` | Baseline Evidence And CI Ratchet | L1 | Paused for AUTH priority; no valid replacement baseline yet | | `WS-QUAL-001-02` | Project Service Coverage | L1 | Inactive until 01B2 merge/memory plus explicit user start | | `WS-POL-002-04` | Locked Runtime Execution And Routing Hardening | L1 | Inactive pending relevant authorization proof and a separate explicit user start | @@ -57,15 +57,13 @@ | `WS-AUTH-001-04B` | PostgreSQL Rate Controls | L1 | Merged through PR #113 as `05a63c8` on 2026-07-14 | | `WS-AUTH-001-05A` | Shared Audit Ownership And Append-Only Authority Evidence | L1 | Merged through PR #115 as `8e1cde6` on 2026-07-14 | | `WS-AUTH-001-CAT` | Action And Resource Catalogue Reconciliation | L1 | Merged through PR #117 as `4c5d4fc` on 2026-07-14 | +| `WS-AUTH-001-CAT-MEMORY` | Catalogue Post-Merge Memory | L1 | Merged through PR #118 as `eba7e2b` on 2026-07-14 | ## Proposed Next -AUTH-05A merged through PR #115 as `8e1cde6` after required internal reviews, -Backend, Agent Gates, CodeRabbit, and explicit human approval passed. The user -has given the AUTH-05B start signal. AUTH-05B remains inactive only until -the CAT post-merge memory update merges; no second start signal is required. Do -not start AUTH-05B before that final memory gate, or start AUTH-06 or POL-002-04 -automatically. +AUTH-05A merged through PR #115 as `8e1cde6`, and CAT plus its post-merge memory +merged through PRs #117 and #118. AUTH-05B's repaired L1 plan passed and bounded +implementation is active. Do not start AUTH-06 or POL-002-04 automatically. Coverage R10 merged through PR #108. Do not start 01B2, chunk 02, or another coverage implementation chunk from this worktree. diff --git a/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/CHUNK_MAP.md b/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/CHUNK_MAP.md index be59ae76..375538f8 100644 --- a/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/CHUNK_MAP.md +++ b/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/CHUNK_MAP.md @@ -21,7 +21,7 @@ stopped. | `WS-AUTH-001-05` | Authority Evidence And Idempotency Foundation | L1 | Split before implementation into 05A and 05B | | `WS-AUTH-001-05A` | Shared Audit Ownership And Append-Only Authority Evidence | L1 | Merged through PR #115 as `8e1cde6` | | `WS-AUTH-001-CAT` | Action And Resource Catalogue Reconciliation | L1 | Merged through PR #117 as `4c5d4fc` | -| `WS-AUTH-001-05B` | Authority Idempotency And Invalidation Foundation | L1 | Start signal received; activate after CAT post-merge memory merges | +| `WS-AUTH-001-05B` | Authority Idempotency And Invalidation Foundation | L1 | Active; repaired L1 plan passed, implementation in progress | | `WS-AUTH-001-06` | Canonical Actor Profile And Identity Link | L1 | Proposed | | `WS-AUTH-001-07` | Authorization Kernel And Permission Registry | L1 | Proposed | | `WS-AUTH-001-08` | Bootstrap And Administrative Role Grants | L1 | Proposed | @@ -109,5 +109,6 @@ the semantic AUTH-05A boundary. Required reviews and checks passed, and explicit human approval merged PR #115 as `8e1cde6` on 2026-07-14, followed by merged post-merge memory. `WS-AUTH-001-CAT` then merged through PR #117 as `4c5d4fc` after Backend, Agent Gates, CodeRabbit, and explicit human approval passed. The -AUTH-05B start signal is recorded; 05B may activate after CAT post-merge memory -merges, without another start signal. Do not start AUTH-06 or POL-002-04. +CAT post-merge memory merged through PR #118 as `eba7e2b`; AUTH-05B's repaired +L1 plan passed and bounded implementation is active. Do not start AUTH-06 or +POL-002-04. diff --git a/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/STATUS.md b/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/STATUS.md index 61a229cc..3a25eba2 100644 --- a/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/STATUS.md +++ b/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/STATUS.md @@ -59,19 +59,22 @@ CodeRabbit passed, and explicit human approval merged PR #115 as `8e1cde6` on The user requested action/resource catalogue reconciliation before AUTH-05B. That docs-only work passed required internal reviews, Backend, Agent Gates, and CodeRabbit; explicit human approval merged PR #117 as `4c5d4fc` on 2026-07-14. +Its post-merge memory merged through PR #118 as `eba7e2b`. AUTH-05B is active; +its repaired L1 plan passed senior engineering, QA/test, and +security/auth/privacy review before runtime edits. ## Active planning chunk -None. CAT post-merge memory is active; no planning chunk is active. +None. AUTH-05B implementation is active under its approved repaired contract. ## Active implementation chunk -None. AUTH-05B's start signal is recorded and it remains inactive only until CAT -post-merge memory merges. +`WS-AUTH-001-05B` is active. Its repaired L1 plan passed and bounded runtime +implementation is in progress. ## Current implementation branch -`codex/ws-auth-001-cat-post-merge-memory` in +`codex/ws-auth-001-05b-idempotency-invalidation` in `/home/abiorh/flow/workstream-authorization-service`. ## Chunk status @@ -88,7 +91,7 @@ post-merge memory merges. | `WS-AUTH-001-05` | Split | `codex/ws-auth-001-05-authority-evidence` | - | Parent split before implementation into 05A and 05B. | | `WS-AUTH-001-05A` | Merged | `codex/ws-auth-001-05-authority-evidence` | #115 | Merged as `8e1cde6`; reviewed code `ea16fd8`; final branch head `d023952`. | | `WS-AUTH-001-CAT` | Merged | `codex/ws-auth-001-action-catalogue-reconciliation` | #117 | Merged as `4c5d4fc`; final branch head `5b4ec96`. | -| `WS-AUTH-001-05B` | Inactive | - | - | Start signal received; activate after CAT post-merge memory merges. | +| `WS-AUTH-001-05B` | Active | `codex/ws-auth-001-05b-idempotency-invalidation` | - | Repaired L1 plan passed; bounded implementation active. | | `WS-AUTH-001-06` | Proposed | - | - | Canonical actor profile and identity link. | | `WS-AUTH-001-07` | Proposed | - | - | Authorization kernel and permissions. | | `WS-AUTH-001-08` | Proposed | - | - | Bootstrap and administrative grants. | @@ -103,10 +106,11 @@ post-merge memory merges. ## Blockers -AUTH-05A has no remaining blocker and is merged. The combined AUTH-05 contract +AUTH-05A and CAT post-merge memory have no remaining blocker and are merged. +The combined AUTH-05 contract was rejected before runtime changes because shared audit evidence and idempotency/invalidation were not reviewable as one L1 change. AUTH-05A owns -migration `0018`; inactive AUTH-05B later owns migration `0019`. Non-test +migration `0018`; active AUTH-05B owns migration `0019`. Non-test operators must later supply explicit classification evidence rather than inferred kinds before the owning canonical actor migration. @@ -118,8 +122,8 @@ permission identifiers remain approved, including `operations.checker.retry`; the three recovery identifiers receive persisted parity only in their owning later chunks. `WS-AUTH-001-CAT` retains only safe registry/conformance rules. This is a scope decision, not an AUTH-05B runtime -blocker. PR #117 is merged; AUTH-05B begins after this post-merge memory update -merges, without another start signal. +blocker. PR #118 is merged and AUTH-05B is active. Its current gate is repaired +L1 plan approval, not an external dependency or another user signal. AUTH-04B review evidence and its PR trust bundle are recorded at `reviews/WS-AUTH-001-04B-internal-review-evidence.md` and diff --git a/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/chunks/WS-AUTH-001-05B-idempotency-invalidation.md b/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/chunks/WS-AUTH-001-05B-idempotency-invalidation.md index 7e399afc..254da237 100644 --- a/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/chunks/WS-AUTH-001-05B-idempotency-invalidation.md +++ b/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/chunks/WS-AUTH-001-05B-idempotency-invalidation.md @@ -2,8 +2,10 @@ ## Status -Inactive until AUTH-05A merges, its post-merge memory is current, and the user -gives a separate explicit start signal. +Active on `codex/ws-auth-001-05b-idempotency-invalidation`. AUTH-05A, catalogue +reconciliation, and their post-merge memory checkpoints are merged. The user's +AUTH-05B start signal is recorded. The repaired L1 preimplementation plan passed +senior engineering, QA/test, and security/auth/privacy review. ## Parent initiative @@ -17,9 +19,10 @@ denial, and invalidation orchestration on the shared AUTH-05A audit envelope. ## Risk and circuit breaker - Risk: L1 / SLA P1. -- Inspect scope at 350 changed non-comment production lines. -- Hard stop at 500 changed non-comment production lines, counting - `backend/app/**` plus migration code. +- The circuit breaker is this contract's semantic boundary, typed/SQL parity, + acceptance evidence, and reviewability rather than a production-line count. +- Stop and replan if the boundary expands; do not compress or split an atomic + database/runtime invariant to satisfy a numeric ceiling. ## Allowed files @@ -79,11 +82,77 @@ The canonical request object is a typed, bounded JSON-compatible dictionary. It uses the shared canonical hash helper; no authorization-local encoder is introduced. +The closed idempotency operations are exactly: + +```text +service_actor.create +admin_role_grant.issue +admin_role_grant.revoke +project_role_grant.issue +project_role_grant.revoke +actor_profile.suspend +actor_profile.reactivate +actor_profile.deactivate +actor_identity_link.revoke +actor_identity_link.reactivate +``` + +These are idempotency-operation tokens, not AUTH-07 `ActionId` values. Project +grant replacement is a `project_role_grant.issue` request whose strict +canonical payload contains the replaced grant reference. Bootstrap, +first-access provisioning, qualification capture, and identity-link creation +are not in this registry. Each operation has the exact `extra="forbid"` request +variant below. Unknown, extra, mistyped, overlong, non-canonical, or nested +free-form values fail before hashing. Actor kind/reference rules match AUTH-05A. + +Every variant is frozen and scalar-only, revalidated into one immutable +snapshot, and serialized with `model_dump(mode="json", exclude_none=True)` +before hashing. Its canonical encoded object is limited to 2,048 bytes. Raw +issuer/subject, display data, human reason, request body, token, idempotency key, +request ID, and correlation ID are never persisted. Validation errors are +generic and retain or echo none of the rejected value. + +`reason_digest`, `identity_reference_digest`, and `profile_payload_digest` are +internal server-derived strings matching exact `sha256:[0-9a-f]{64}` syntax. +They are produced with `canonical_json_hash` from frozen, strict, bounded +snapshots of the applicable human reason, verified normalized issuer/opaque +subject, and service profile payload. HTTP/request callers cannot supply these +digests. The identity snapshot comes only from verified issuer output; different +issuer/subject pairs cannot select one stored fingerprint through a request +field. + +| Operation | Canonical request variant and exact fields | Permission | Audit reason | Response | Success event | Invalidation target | +|---|---|---|---|---|---|---| +| `service_actor.create` | `ServiceActorCreateRequest(operation, identity_reference_digest, profile_payload_digest)` | `actor.service.provision` | `manual_service_provisioning` | `actor_profile`, `201` | `ServiceActorProvisioned` | `actor_profile` | +| `admin_role_grant.issue` | `AdminRoleGrantIssueRequest(operation, target_actor_id, role, scope_type, scope_project_id?, reason_digest)` | `admin_role.grant` | `authority_assignment` | `admin_role_grant`, `201` | `AdminRoleGrantIssued` | `admin_role_grant` | +| `admin_role_grant.revoke` | `AdminRoleGrantRevokeRequest(operation, grant_id, reason_digest)` | `admin_role.revoke` | `authority_revocation` | `admin_role_grant`, `200` | `AdminRoleGrantRevoked` | `admin_role_grant` | +| `project_role_grant.issue` | `ProjectRoleGrantIssueRequest(operation, project_id, target_actor_id, role, replaced_grant_id?, reason_digest)` | `project.role_grant.manage` | `authority_assignment` without replacement; `authority_replacement` with required replacement ID | `project_role_grant`, `201` | `ProjectRoleGrantIssued` without replacement; `ProjectRoleGrantReplaced` with required replacement ID | `project_role_grant` | +| `project_role_grant.revoke` | `ProjectRoleGrantRevokeRequest(operation, project_id, grant_id, reason_digest)` | `project.role_grant.manage` | `authority_revocation` | `project_role_grant`, `200` | `ProjectRoleGrantRevoked` | `project_role_grant` | +| `actor_profile.suspend` | `ActorProfileSuspendRequest(operation, actor_profile_id, reason_digest)` | `actor.profile.suspend` | `security_response` or `administrative_correction` | `actor_profile`, `200` | `ActorProfileSuspended` | `actor_profile` | +| `actor_profile.reactivate` | `ActorProfileReactivateRequest(operation, actor_profile_id, reason_digest)` | `actor.profile.reactivate` | `administrative_correction` | `actor_profile`, `200` | `ActorProfileReactivated` | `actor_profile` | +| `actor_profile.deactivate` | `ActorProfileDeactivateRequest(operation, actor_profile_id, reason_digest)` | `actor.profile.deactivate` | `security_response` or `administrative_correction` | `actor_profile`, `200` | `ActorProfileDeactivated` | `actor_profile` | +| `actor_identity_link.revoke` | `ActorIdentityLinkRevokeRequest(operation, identity_link_id, reason_digest)` | `actor.identity_link.revoke` | `identity_lifecycle_change` | `actor_identity_link`, `200` | `ActorIdentityLinkRevoked` | `actor_identity_link` | +| `actor_identity_link.reactivate` | `ActorIdentityLinkReactivateRequest(operation, identity_link_id, reason_digest)` | `actor.identity_link.reactivate` | `identity_lifecycle_change` | `actor_identity_link`, `200` | `ActorIdentityLinkReactivated` | `actor_identity_link` | + +Admin issue roles are exactly `access_administrator`, `operator`, +`project_manager`, `finance_authority`, or `audit_authority`. +`access_administrator` and `operator` require `system` scope with no project; +the other three admit `system` with no project or `project` with exactly one +project UUID. Project issue roles are exactly `submitter`, `reviewer`, or +`both`. Create/issue variants use deterministic request facts or existing +parents in the digest; a newly generated response resource UUID is never part +of their request digest. + ## State machine and transaction ownership - Reservation runs before any business-state flush. -- `INSERT ... ON CONFLICT` plus a locked read serializes concurrent users of - one replay namespace. +- Under PostgreSQL `READ COMMITTED`, reservation executes `INSERT ... ON + CONFLICT DO NOTHING RETURNING id`. On conflict it performs a full-namespace + `SELECT ... FOR UPDATE`; the unique-index wait resolves the winning + transaction before the locked read. A missing row after conflict, `40001`, or + deadlock fails closed and is not translated to replay/mismatch; the caller + rolls back and may retry the whole transaction at its bounded application + boundary. Repository/service code does not perform retries. - A new reservation returns `claimed` with a `pending` row in the caller's transaction. - Same namespace and digest after commit returns `replay` with the existing @@ -101,21 +170,62 @@ introduced. - Repository/service methods may insert, lock, flush, and return typed outcomes only. The injected caller session alone commits or rolls back; no parallel unit-of-work abstraction or session is introduced. +- Reservation returns a discriminated `claimed`, `replay`, or `mismatch` + result. A claim is an opaque typed handle containing record ID, namespace, + operation, and digest. Completion conditionally updates exactly one matching + pending record; forged, stale, or wrong-namespace handles fail closed. + Same-session re-entry while the caller's claim remains pending raises a + stable internal pending error and is never replayed. +- Database enforcement owns both timestamps, permits only the + `pending -> committed` transition, makes identity/namespace/digest immutable, + makes committed response references immutable, and rejects update after + commit, delete, or truncate. The deferred pending guard re-reads current row + state so insert-pending/update-committed succeeds while durable pending state + cannot commit. +- A replay reference is internal orchestration data. A later route-owning chunk + must reload the canonical resource and reauthorize the current request before + disclosing any resource identifier or representation. ## Invalidation and denial evidence Invalidation is an `AuthorityInvalidationRequested` authority-domain -`AuditEvent`, not a second event table. Authorization owns typed orchestration; -AuditRepository remains the persistence owner. The event links its causing -authority event, exact target kind/reference, request/correlation IDs, actor, -reason, and idempotency record when applicable. AUTH-05B introduces persistence -and orchestration only: no cache, worker, adapter, or authority behavior consumes -the event. - -Allowed, denied, and invalidation events use stable tokens. Denial/mismatch -orchestration returns a typed result rather than committing or raising inside -the repository; the caller commits the denial evidence before translating the -result to an API error. Denials are not stored as successful replay results. +`AuditEvent`, not a second event table. Authorization constructs the narrow +typed invalidation input; `AuditService` and `AuditRepository` remain the sole +validation and persistence path. The event links its causing concrete domain +success event, exact operation-compatible target kind/reference, +request/correlation IDs, actor, reason, and claim record. AUTH-05B introduces +persistence and orchestration only: no cache, worker, adapter, or authority +behavior consumes the event. + +Future owning domain services construct their concrete success event with the +merged AUTH-05A type. AUTH-05B validates it against the claim's actor, +request/correlation context, idempotency record, operation-to-permission, +operation-to-event, resource, and target before the shared writer persists it. +AUTH-05B does not create generic allowed decisions; AUTH-07 owns those. + +`idempotency_reference` means the internal idempotency-record primary key, never +the client key. Migration `0019` adds a `NOT VALID` composite foreign key from +new audit references plus actor kind/reference to the matching idempotency +record's unique `(id, actor_ref_kind, actor_ref)` key, preserving any pre-0019 +forward references while enforcing every new write. The 0019 audit trigger +requires every new mapped success and every new invalidation event to carry a +non-null idempotency reference. It enforces record operation to success-event, +permission, resource type, resource ID, and invalidation-target mapping. A new +invalidation must reference a cause event with that same record and actor. The +successful mutation event flushes first, its required invalidation flushes +second, and only then may completion mark the claim committed. Completion also +requires exactly that mapped event pair and response resource. A failure at any +step leaves caller rollback to remove the entire transaction. + +Mismatch orchestration constructs only a privacy-bounded +`SensitiveAuthorizationDenied` / `idempotency_mismatch` input from strict +context and the operation-to-permission mapping. The mismatch result contains +no existing record, digest, response, or foreign namespace data. Reservation +must be the caller's first write. On mismatch the caller rolls back that unit of +work, opens a clean transaction on the same injected session, asks AUTH-05B to +append denial evidence, commits it, and only then translates the stable error. +Repository and service code never commit, roll back, or create a session. +Denials are not stored as successful replay results. ## Migration custody @@ -124,15 +234,23 @@ result to an API error. Denials are not stored as successful replay results. - Tests assert exact table columns, constraints, indexes, deferred trigger, defaults, invalid rows, and database time. - Downgrade takes writer-blocking locks and refuses without mutation when any - idempotency row or audit row referencing idempotency/invalidation evidence - exists. Legacy and AUTH-05A audit rows remain intact. + idempotency row exists. Under the new-write composite reference and + committed-row deletion guard, that predicate also covers every 05B-linked + audit/invalidation event. Legacy rows, ordinary AUTH-05A rows, pre-0019 + unmatched forward references, and pre-0019 invalidation rows remain intact + and do not incorrectly block an otherwise compatible downgrade. +- Downgrade locks `authority_idempotency_records` and then `audit_events` in + deterministic `ACCESS EXCLUSIVE` order, performs every refusal check before + DDL, and remains transactional. A concurrent writer cannot enter between the + emptiness check and schema removal. - Privileged fixture cleanup permits `0019 -> 0018 -> 0019`; re-upgrade restores the pending-commit guard. Destructive tests restore `head` in `finally`. ## Acceptance criteria -- Independent-session exact concurrent retries produce one committed result - and one success/invalidation event set. +- Independent-session exact concurrent retries produce one committed result, + exactly one concrete success event, and exactly one invalidation event with + correct cause, actor, claim, resource, and insertion order. - Concurrent mismatched requests produce one winner and one privacy-safe mismatch outcome; different actors/kinds remain isolated. - Injected failure after reservation, synthetic business flush, audit insert, @@ -140,7 +258,32 @@ result to an API error. Denials are not stored as successful replay results. transaction or nothing. Repositories never commit/rollback. - Retry after rollback claims normally; commit of unfinished pending state fails closed and leaves no durable pending row after caller rollback. -- Exact replay never duplicates business, success, or invalidation evidence. +- Exact replay adds zero business, success, invalidation, or denial rows. +- Mismatch adds exactly one denial and zero success/invalidation rows after the + reservation transaction is rolled back. +- Committed records cannot be changed or removed; wrong, stale, or forged claim + handles and same-session pending re-entry fail closed. +- Strict canonical request variants, response references, result variants, and + PostgreSQL registries have exact parity. Response references admit only the + operation-compatible resource type, canonical UUID, optional positive + version, and the exact `200` or `201` status in the operation table. +- Mismatch denial evidence is created only after the reservation transaction is + rolled back and contains no request body, digest, existing response, or + conflicting namespace information. +- Concurrency tests use explicit process/task barriers and observed database + lock state rather than timing sleeps. Commit-attempt rollback is proved with + the real deferred database constraint, not a mocked session method. +- Direct SQL proves initial committed insertion fails, only pending-to-committed + succeeds, mapped events reject null/nonexistent/cross-actor/mismatched-cause + references, `convalidated` remains false, and an orphan seeded at 0018 + survives upgrade. The existing audit writer test creates a real 0019 record + for new linked evidence rather than relying on an unmatched future UUID. +- Authorization tests submit hostile and state-changing `Mapping` inputs, + mutate source values after validation, exceed the canonical-object limit, and + use noncanonical UUID/digest forms. Secret-bearing rejected values must be + absent from Pydantic `errors()`, `json()`, string, exception args, cause, + context, and public state; the immutable snapshot and resulting digest remain + unchanged after source mutation. - No route, permission, grant, actor, product authority behavior, or invalidation consumer exists. @@ -197,6 +340,7 @@ remain unchanged. Review replay namespace, canonical hashing, pending-commit prevention, concurrency/rollback behavior, typed result privacy, invalidation causation, +committed-row immutability, clean mismatch evidence, claim-handle ownership, and caller-owned transaction boundaries. ## Stop conditions @@ -204,6 +348,9 @@ and caller-owned transaction boundaries. Stop if exact concurrency cannot serialize without a committed pending row, denial evidence requires committing unrelated work, a route/grant/permission must be introduced, or tests/CI must be weakened. AUTH-05B consumes the merged -05A audit envelope and sole-writer contract without modifying its schema, -triggers, constraints, repository, or writer. A missing audit primitive requires -stop and replan rather than reopening 05A custody. +05A audit envelope and sole-writer contract without reopening its registries, +typed writer, append-only rules, or migration. A missing audit primitive +requires stop and replan rather than reopening 05A custody. The narrow +0019-owned composite audit-reference constraint and linked-invalidation trigger +are the only allowed additions to the merged audit table; 0018 and the shared +audit schema/service/repository remain unchanged. From 6e41b4de47bad424d9072437299e8a6a090645a1 Mon Sep 17 00:00:00 2001 From: Abiorh001 Date: Tue, 14 Jul 2026 18:21:11 +0100 Subject: [PATCH 2/6] feat(auth): add idempotent authority mutation foundation --- .../versions/0019_authority_idempotency.py | 287 ++++++++++ backend/app/db/models.py | 1 + backend/app/modules/authorization/__init__.py | 1 + backend/app/modules/authorization/models.py | 93 ++++ .../app/modules/authorization/repository.py | 131 +++++ backend/app/modules/authorization/schemas.py | 336 ++++++++++++ backend/app/modules/authorization/service.py | 236 +++++++++ backend/tests/test_alembic.py | 377 +++++++++++++ backend/tests/test_audit.py | 91 +++- backend/tests/test_authorization.py | 495 ++++++++++++++++++ docs/architecture_data_model.md | 19 +- docs/operations_authorization_service.md | 22 + 12 files changed, 2061 insertions(+), 28 deletions(-) create mode 100644 backend/alembic/versions/0019_authority_idempotency.py create mode 100644 backend/app/modules/authorization/__init__.py create mode 100644 backend/app/modules/authorization/models.py create mode 100644 backend/app/modules/authorization/repository.py create mode 100644 backend/app/modules/authorization/schemas.py create mode 100644 backend/app/modules/authorization/service.py create mode 100644 backend/tests/test_authorization.py diff --git a/backend/alembic/versions/0019_authority_idempotency.py b/backend/alembic/versions/0019_authority_idempotency.py new file mode 100644 index 00000000..c4de4a73 --- /dev/null +++ b/backend/alembic/versions/0019_authority_idempotency.py @@ -0,0 +1,287 @@ +"""add authority idempotency and linked invalidation enforcement + +Revision ID: 0019_authority_idempotency +Revises: 0018_authority_audit_evidence +Create Date: 2026-07-14 +""" + +from __future__ import annotations + +from alembic import op +import sqlalchemy as sa + +revision = "0019_authority_idempotency" +down_revision = "0018_authority_audit_evidence" +branch_labels = None +depends_on = None + +OPERATIONS = ( + "service_actor.create", "admin_role_grant.issue", "admin_role_grant.revoke", + "project_role_grant.issue", "project_role_grant.revoke", "actor_profile.suspend", + "actor_profile.reactivate", "actor_profile.deactivate", "actor_identity_link.revoke", + "actor_identity_link.reactivate", +) + + +def _tokens(values: tuple[str, ...]) -> str: + return ", ".join(f"'{value}'" for value in values) + + +def upgrade() -> None: + """Create immutable reservations and enforce new audit-reference integrity.""" + op.create_table( + "authority_idempotency_records", + sa.Column("id", sa.Uuid(), nullable=False), + sa.Column("idempotency_key", sa.Uuid(), nullable=False), + sa.Column("actor_ref_kind", sa.String(32), nullable=False), + sa.Column("actor_ref", sa.String(100), nullable=False), + sa.Column("operation", sa.String(48), nullable=False), + sa.Column("request_digest", sa.String(71), nullable=False), + sa.Column("status", sa.String(16), nullable=False), + sa.Column("response_resource_type", sa.String(32)), + sa.Column("response_resource_id", sa.Uuid()), + sa.Column("response_resource_version", sa.BigInteger()), + sa.Column("response_http_status", sa.SmallInteger()), + sa.Column( + "created_at", sa.DateTime(timezone=True), nullable=False, + server_default=sa.text("statement_timestamp()"), + ), + sa.Column("committed_at", sa.DateTime(timezone=True)), + sa.PrimaryKeyConstraint("id", name="pk_authority_idempotency_records"), + sa.UniqueConstraint( + "actor_ref_kind", "actor_ref", "operation", "idempotency_key", + name="uq_authority_idempotency_records_replay_namespace", + ), + sa.UniqueConstraint( + "id", "actor_ref_kind", "actor_ref", + name="uq_authority_idempotency_records_actor_reference", + ), + sa.CheckConstraint( + "actor_ref_kind in ('legacy_actor', 'actor_profile', 'system_principal')", + name="actor_kind", + ), + sa.CheckConstraint( + "((actor_ref_kind = 'system_principal' and actor_ref = " + "'workstream:system:bootstrap') or (actor_ref_kind <> 'system_principal' " + "and actor_ref ~ '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$'))", + name="actor_reference", + ), + sa.CheckConstraint( + f"operation in ({_tokens(OPERATIONS)})", + name="operation", + ), + sa.CheckConstraint( + "request_digest ~ '^sha256:[0-9a-f]{64}$'", + name="request_digest", + ), + sa.CheckConstraint( + "status in ('pending', 'committed')", + name="status", + ), + sa.CheckConstraint( + "response_resource_version is null or response_resource_version > 0", + name="response_version", + ), + sa.CheckConstraint( + "(status = 'pending' and response_resource_type is null and " + "response_resource_id is null and response_resource_version is null and " + "response_http_status is null and committed_at is null) or " + "(status = 'committed' and response_resource_type is not null and " + "response_resource_id is not null and response_http_status is not null and " + "committed_at is not null)", + name="state_shape", + ), + sa.CheckConstraint( + "(operation = 'service_actor.create' and (response_resource_type is null or " + "response_resource_type = 'actor_profile')) or " + "(operation like 'admin_role_grant.%' and (response_resource_type is null or " + "response_resource_type = 'admin_role_grant')) or " + "(operation like 'project_role_grant.%' and (response_resource_type is null or " + "response_resource_type = 'project_role_grant')) or " + "(operation like 'actor_profile.%' and (response_resource_type is null or " + "response_resource_type = 'actor_profile')) or " + "(operation like 'actor_identity_link.%' and (response_resource_type is null or " + "response_resource_type = 'actor_identity_link'))", + name="response_type", + ), + sa.CheckConstraint( + "response_http_status is null or ((operation in ('service_actor.create', " + "'admin_role_grant.issue', 'project_role_grant.issue') and " + "response_http_status = 201) or (operation not in ('service_actor.create', " + "'admin_role_grant.issue', 'project_role_grant.issue') and " + "response_http_status = 200))", + name="response_status", + ), + ) + op.execute( + "alter table audit_events add constraint fk_audit_events_authority_idempotency " + "foreign key (idempotency_reference, actor_ref_kind, actor_id) references " + "authority_idempotency_records (id, actor_ref_kind, actor_ref) not valid" + ) + _create_functions_and_triggers() + + +def _create_functions_and_triggers() -> None: + op.execute( + """ + create function guard_authority_idempotency_record() returns trigger + language plpgsql as $$ + declare success_count integer; invalidation_count integer; success_id text; + success_row audit_events%rowtype; + begin + if tg_op = 'INSERT' then + if new.status <> 'pending' then raise exception 'idempotency must begin pending' using errcode='23514'; end if; + new.created_at := statement_timestamp(); new.committed_at := null; return new; + elsif tg_op = 'DELETE' then + raise exception 'authority idempotency records are immutable' using errcode='55000'; + end if; + if old.status <> 'pending' or new.status <> 'committed' + or (new.id, new.idempotency_key, new.actor_ref_kind, new.actor_ref, + new.operation, new.request_digest, new.created_at) + is distinct from + (old.id, old.idempotency_key, old.actor_ref_kind, old.actor_ref, + old.operation, old.request_digest, old.created_at) then + raise exception 'invalid authority idempotency transition' using errcode='23514'; + end if; + select count(*), min(id) into success_count, success_id + from audit_events where event_domain='authority' and idempotency_reference=new.id + and event_type <> 'AuthorityInvalidationRequested'; + select count(*) into invalidation_count from audit_events + where event_domain='authority' and idempotency_reference=new.id + and event_type='AuthorityInvalidationRequested'; + if success_count <> 1 or invalidation_count <> 1 then + raise exception 'authority evidence pair required' using errcode='23514'; + end if; + select * into success_row from audit_events where id=success_id; + if success_row.resource_type <> new.response_resource_type + or success_row.resource_id <> new.response_resource_id::text then + raise exception 'authority response does not match evidence' using errcode='23514'; + end if; + new.committed_at := statement_timestamp(); return new; + end $$ + """ + ) + op.execute( + """ + create function reject_pending_authority_idempotency() returns trigger + language plpgsql as $$ begin + if exists(select 1 from authority_idempotency_records where id=new.id and status='pending') then + raise exception 'pending authority idempotency cannot commit' using errcode='23514'; + end if; return null; + end $$ + """ + ) + op.execute( + """ + create function reject_authority_idempotency_truncate() returns trigger + language plpgsql as $$ begin + raise exception 'authority idempotency records are immutable' using errcode='55000'; + end $$ + """ + ) + op.execute( + """ + create function validate_linked_authority_event() returns trigger + language plpgsql as $$ + declare record_row authority_idempotency_records%rowtype; + cause_row audit_events%rowtype; expected_permission text; + expected_resource text; valid_success boolean; + begin + if new.event_domain <> 'authority' then return new; end if; + valid_success := new.event_type in ( + 'ServiceActorProvisioned','AdminRoleGrantIssued','AdminRoleGrantRevoked', + 'ProjectRoleGrantIssued','ProjectRoleGrantReplaced','ProjectRoleGrantRevoked', + 'ActorProfileSuspended','ActorProfileReactivated','ActorProfileDeactivated', + 'ActorIdentityLinkRevoked','ActorIdentityLinkReactivated'); + if not valid_success and new.event_type <> 'AuthorityInvalidationRequested' then + if new.idempotency_reference is not null then + raise exception 'invalid authority idempotency event' using errcode='23514'; + end if; return new; + end if; + if new.idempotency_reference is null then + raise exception 'authority event requires idempotency reference' using errcode='23514'; + end if; + select * into record_row from authority_idempotency_records + where id=new.idempotency_reference and actor_ref_kind=new.actor_ref_kind and actor_ref=new.actor_id; + if not found then raise exception 'invalid authority idempotency reference' using errcode='23503'; end if; + expected_permission := case record_row.operation + when 'service_actor.create' then 'actor.service.provision' + when 'admin_role_grant.issue' then 'admin_role.grant' + when 'admin_role_grant.revoke' then 'admin_role.revoke' + when 'project_role_grant.issue' then 'project.role_grant.manage' + when 'project_role_grant.revoke' then 'project.role_grant.manage' + when 'actor_profile.suspend' then 'actor.profile.suspend' + when 'actor_profile.reactivate' then 'actor.profile.reactivate' + when 'actor_profile.deactivate' then 'actor.profile.deactivate' + when 'actor_identity_link.revoke' then 'actor.identity_link.revoke' + when 'actor_identity_link.reactivate' then 'actor.identity_link.reactivate' end; + expected_resource := case + when record_row.operation='service_actor.create' or record_row.operation like 'actor_profile.%' then 'actor_profile' + when record_row.operation like 'admin_role_grant.%' then 'admin_role_grant' + when record_row.operation like 'project_role_grant.%' then 'project_role_grant' + else 'actor_identity_link' end; + if new.permission_id <> expected_permission or new.resource_type <> expected_resource + or new.resource_id is null then + raise exception 'authority event does not match operation' using errcode='23514'; + end if; + if new.event_type='AuthorityInvalidationRequested' then + select * into cause_row from audit_events where id=new.invalidation_cause_event_id; + if not found or cause_row.idempotency_reference is distinct from record_row.id + or cause_row.actor_ref_kind is distinct from new.actor_ref_kind + or cause_row.actor_id is distinct from new.actor_id + or cause_row.resource_type is distinct from new.invalidation_target_kind + or cause_row.resource_id is distinct from new.invalidation_target_ref then + raise exception 'invalid linked authority cause' using errcode='23514'; + end if; + elsif not ( + (record_row.operation='service_actor.create' and new.event_type='ServiceActorProvisioned') or + (record_row.operation='admin_role_grant.issue' and new.event_type='AdminRoleGrantIssued') or + (record_row.operation='admin_role_grant.revoke' and new.event_type='AdminRoleGrantRevoked') or + (record_row.operation='project_role_grant.issue' and new.event_type in ('ProjectRoleGrantIssued','ProjectRoleGrantReplaced')) or + (record_row.operation='project_role_grant.revoke' and new.event_type='ProjectRoleGrantRevoked') or + (record_row.operation='actor_profile.suspend' and new.event_type='ActorProfileSuspended') or + (record_row.operation='actor_profile.reactivate' and new.event_type='ActorProfileReactivated') or + (record_row.operation='actor_profile.deactivate' and new.event_type='ActorProfileDeactivated') or + (record_row.operation='actor_identity_link.revoke' and new.event_type='ActorIdentityLinkRevoked') or + (record_row.operation='actor_identity_link.reactivate' and new.event_type='ActorIdentityLinkReactivated')) then + raise exception 'authority success event does not match operation' using errcode='23514'; + end if; return new; + end $$ + """ + ) + op.execute( + "create trigger authority_idempotency_guard before insert or update or delete " + "on authority_idempotency_records for each row execute function guard_authority_idempotency_record()" + ) + op.execute( + "create constraint trigger authority_idempotency_pending_guard after insert or update " + "on authority_idempotency_records deferrable initially deferred for each row " + "execute function reject_pending_authority_idempotency()" + ) + op.execute( + "create trigger authority_idempotency_reject_truncate before truncate " + "on authority_idempotency_records execute function reject_authority_idempotency_truncate()" + ) + op.execute( + "create trigger audit_events_validate_idempotency before insert on audit_events " + "for each row execute function validate_linked_authority_event()" + ) + + +def downgrade() -> None: + """Drop 0019 only when no durable idempotency/evidence pair exists.""" + bind = op.get_bind() + bind.execute(sa.text("lock table authority_idempotency_records in access exclusive mode")) + bind.execute(sa.text("lock table audit_events in access exclusive mode")) + if bind.execute(sa.text("select exists(select 1 from authority_idempotency_records)")).scalar_one(): + raise RuntimeError("cannot downgrade non-empty authority idempotency") + op.execute("drop trigger audit_events_validate_idempotency on audit_events") + op.execute("drop function validate_linked_authority_event()") + op.drop_constraint("fk_audit_events_authority_idempotency", "audit_events", type_="foreignkey") + op.execute("drop trigger authority_idempotency_pending_guard on authority_idempotency_records") + op.execute("drop trigger authority_idempotency_reject_truncate on authority_idempotency_records") + op.execute("drop trigger authority_idempotency_guard on authority_idempotency_records") + op.execute("drop function reject_pending_authority_idempotency()") + op.execute("drop function reject_authority_idempotency_truncate()") + op.execute("drop function guard_authority_idempotency_record()") + op.drop_table("authority_idempotency_records") diff --git a/backend/app/db/models.py b/backend/app/db/models.py index 67e2d1df..827d171a 100644 --- a/backend/app/db/models.py +++ b/backend/app/db/models.py @@ -10,6 +10,7 @@ ArtifactUploadItem, ArtifactUploadSession, ) +from app.modules.authorization.models import AuthorityIdempotencyRecord # noqa: F401 from app.modules.checkers.models import CheckerResult, CheckerRun # noqa: F401 from app.modules.projects.models import ( # noqa: F401 EffectiveProjectSubmissionArtifactPolicy, diff --git a/backend/app/modules/authorization/__init__.py b/backend/app/modules/authorization/__init__.py new file mode 100644 index 00000000..b9b09941 --- /dev/null +++ b/backend/app/modules/authorization/__init__.py @@ -0,0 +1 @@ +"""Authority mutation idempotency and evidence orchestration.""" diff --git a/backend/app/modules/authorization/models.py b/backend/app/modules/authorization/models.py new file mode 100644 index 00000000..9032c6ab --- /dev/null +++ b/backend/app/modules/authorization/models.py @@ -0,0 +1,93 @@ +"""SQLAlchemy models for authority mutation idempotency.""" + +from __future__ import annotations + +from datetime import datetime +from uuid import UUID + +from sqlalchemy import BigInteger, CheckConstraint, DateTime, SmallInteger, String, UniqueConstraint, Uuid +from sqlalchemy.orm import Mapped, mapped_column +from sqlalchemy.sql import func + +from app.db.base import Base + + +class AuthorityIdempotencyRecord(Base): + """Internal reservation and typed replay reference for one authority mutation.""" + + __tablename__ = "authority_idempotency_records" + __table_args__ = ( + UniqueConstraint( + "actor_ref_kind", "actor_ref", "operation", "idempotency_key", + name="replay_namespace", + ), + UniqueConstraint("id", "actor_ref_kind", "actor_ref", name="actor_reference"), + CheckConstraint( + "actor_ref_kind in ('legacy_actor', 'actor_profile', 'system_principal')", + name="actor_kind", + ), + CheckConstraint( + "((actor_ref_kind = 'system_principal' and actor_ref = " + "'workstream:system:bootstrap') or (actor_ref_kind <> 'system_principal' and " + "actor_ref ~ '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$'))", + name="actor_reference", + ), + CheckConstraint( + "operation in ('service_actor.create', 'admin_role_grant.issue', " + "'admin_role_grant.revoke', 'project_role_grant.issue', " + "'project_role_grant.revoke', 'actor_profile.suspend', " + "'actor_profile.reactivate', 'actor_profile.deactivate', " + "'actor_identity_link.revoke', 'actor_identity_link.reactivate')", + name="operation", + ), + CheckConstraint("request_digest ~ '^sha256:[0-9a-f]{64}$'", name="request_digest"), + CheckConstraint("status in ('pending', 'committed')", name="status"), + CheckConstraint( + "response_resource_version is null or response_resource_version > 0", + name="response_version", + ), + CheckConstraint( + "(status = 'pending' and response_resource_type is null and " + "response_resource_id is null and response_resource_version is null and " + "response_http_status is null and committed_at is null) or " + "(status = 'committed' and response_resource_type is not null and " + "response_resource_id is not null and response_http_status is not null and " + "committed_at is not null)", + name="state_shape", + ), + CheckConstraint( + "response_http_status is null or ((operation in ('service_actor.create', " + "'admin_role_grant.issue', 'project_role_grant.issue') and " + "response_http_status = 201) or (operation not in ('service_actor.create', " + "'admin_role_grant.issue', 'project_role_grant.issue') and " + "response_http_status = 200))", + name="response_status", + ), + CheckConstraint( + "(operation = 'service_actor.create' and (response_resource_type is null or " + "response_resource_type = 'actor_profile')) or " + "(operation like 'admin_role_grant.%' and (response_resource_type is null or " + "response_resource_type = 'admin_role_grant')) or " + "(operation like 'project_role_grant.%' and (response_resource_type is null or " + "response_resource_type = 'project_role_grant')) or " + "(operation like 'actor_profile.%' and (response_resource_type is null or " + "response_resource_type = 'actor_profile')) or " + "(operation like 'actor_identity_link.%' and (response_resource_type is null or " + "response_resource_type = 'actor_identity_link'))", + name="response_type", + ), + ) + + id: Mapped[UUID] = mapped_column(Uuid(), primary_key=True) + idempotency_key: Mapped[UUID] = mapped_column(Uuid(), nullable=False) + actor_ref_kind: Mapped[str] = mapped_column(String(32), nullable=False) + actor_ref: Mapped[str] = mapped_column(String(100), nullable=False) + operation: Mapped[str] = mapped_column(String(48), nullable=False) + request_digest: Mapped[str] = mapped_column(String(71), nullable=False) + status: Mapped[str] = mapped_column(String(16), nullable=False) + response_resource_type: Mapped[str | None] = mapped_column(String(32)) + response_resource_id: Mapped[UUID | None] = mapped_column(Uuid()) + response_resource_version: Mapped[int | None] = mapped_column(BigInteger) + response_http_status: Mapped[int | None] = mapped_column(SmallInteger) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) + committed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) diff --git a/backend/app/modules/authorization/repository.py b/backend/app/modules/authorization/repository.py new file mode 100644 index 00000000..bb2da0cf --- /dev/null +++ b/backend/app/modules/authorization/repository.py @@ -0,0 +1,131 @@ +"""Caller-transaction persistence for authority mutation idempotency.""" + +from __future__ import annotations + +from uuid import UUID, uuid4 + +from sqlalchemy import select, update +from sqlalchemy.dialects.postgresql import insert +from sqlalchemy.ext.asyncio import AsyncSession + +from app.modules.authorization.models import AuthorityIdempotencyRecord +from app.modules.authorization.schemas import ( + AuthorityClaimHandle, + AuthorityOperation, + AuthorityReservationResult, + AuthorityResourceType, + AuthorityResponseReference, + ClaimedReservation, + InvalidAuthorityClaimError, + MismatchedReservation, + PendingAuthorityReservationError, + ReplayedReservation, +) +from app.modules.audit.schemas import ActorReferenceKind + + +class AuthorityIdempotencyRepository: + """Reserve, lock, and complete records without transaction ownership.""" + + def __init__(self, session: AsyncSession) -> None: + """Bind persistence operations to the caller-owned session.""" + + self._session = session + + async def reserve( + self, + *, + idempotency_key: UUID, + actor_ref_kind: ActorReferenceKind, + actor_ref: str, + operation: AuthorityOperation, + request_digest: str, + ) -> AuthorityReservationResult: + """Claim a new namespace or return its committed replay disposition.""" + values = { + "id": uuid4(), + "idempotency_key": idempotency_key, + "actor_ref_kind": actor_ref_kind.value, + "actor_ref": actor_ref, + "operation": operation.value, + "request_digest": request_digest, + "status": "pending", + } + record_id = await self._session.scalar( + insert(AuthorityIdempotencyRecord) + .values(**values) + .on_conflict_do_nothing( + index_elements=[ + AuthorityIdempotencyRecord.actor_ref_kind, + AuthorityIdempotencyRecord.actor_ref, + AuthorityIdempotencyRecord.operation, + AuthorityIdempotencyRecord.idempotency_key, + ] + ) + .returning(AuthorityIdempotencyRecord.id) + ) + if record_id is not None: + return ClaimedReservation( + claim=AuthorityClaimHandle( + record_id=record_id, + idempotency_key=idempotency_key, + actor_ref_kind=actor_ref_kind, + actor_ref=actor_ref, + operation=operation, + request_digest=request_digest, + ) + ) + + record = await self._session.scalar( + select(AuthorityIdempotencyRecord) + .where( + AuthorityIdempotencyRecord.actor_ref_kind == actor_ref_kind.value, + AuthorityIdempotencyRecord.actor_ref == actor_ref, + AuthorityIdempotencyRecord.operation == operation.value, + AuthorityIdempotencyRecord.idempotency_key == idempotency_key, + ) + .with_for_update() + ) + if record is None: + raise RuntimeError("authority reservation conflict was not visible") + if record.request_digest != request_digest: + return MismatchedReservation() + if record.status == "pending": + raise PendingAuthorityReservationError("authority reservation is pending") + return ReplayedReservation( + response=AuthorityResponseReference( + resource_type=AuthorityResourceType(record.response_resource_type), + resource_id=record.response_resource_id, + version=record.response_resource_version, + http_status=record.response_http_status, + ) + ) + + async def complete( + self, + claim: AuthorityClaimHandle, + response: AuthorityResponseReference, + ) -> None: + """Atomically complete exactly the pending row owned by a claim handle.""" + result = await self._session.execute( + update(AuthorityIdempotencyRecord) + .where( + AuthorityIdempotencyRecord.id == claim.record_id, + AuthorityIdempotencyRecord.idempotency_key == claim.idempotency_key, + AuthorityIdempotencyRecord.actor_ref_kind == claim.actor_ref_kind.value, + AuthorityIdempotencyRecord.actor_ref == claim.actor_ref, + AuthorityIdempotencyRecord.operation == claim.operation.value, + AuthorityIdempotencyRecord.request_digest == claim.request_digest, + AuthorityIdempotencyRecord.status == "pending", + ) + .values( + status="committed", + response_resource_type=response.resource_type.value, + response_resource_id=response.resource_id, + response_resource_version=response.version, + response_http_status=response.http_status, + ) + .returning(AuthorityIdempotencyRecord.id) + ) + if result.scalar_one_or_none() is None: + raise InvalidAuthorityClaimError("invalid authority claim") diff --git a/backend/app/modules/authorization/schemas.py b/backend/app/modules/authorization/schemas.py new file mode 100644 index 00000000..8199b29a --- /dev/null +++ b/backend/app/modules/authorization/schemas.py @@ -0,0 +1,336 @@ +"""Strict privacy-bounded types for authority mutation idempotency.""" + +from __future__ import annotations + +from collections.abc import Mapping +from enum import StrEnum +import json +import re +from typing import Annotated, Literal +from uuid import UUID + +from pydantic import BaseModel, ConfigDict, Field, TypeAdapter, model_validator + +from app.core.hashing import canonical_json_hash +from app.modules.audit.schemas import ActorReferenceKind + +_DIGEST = re.compile(r"^sha256:[0-9a-f]{64}$") +_MODEL_CONFIG = ConfigDict(extra="forbid", frozen=True, strict=True, hide_input_in_errors=True) + + +class AuthorityOperation(StrEnum): + """Closed authority mutations that require a client idempotency key.""" + + SERVICE_ACTOR_CREATE = "service_actor.create" + ADMIN_ROLE_GRANT_ISSUE = "admin_role_grant.issue" + ADMIN_ROLE_GRANT_REVOKE = "admin_role_grant.revoke" + PROJECT_ROLE_GRANT_ISSUE = "project_role_grant.issue" + PROJECT_ROLE_GRANT_REVOKE = "project_role_grant.revoke" + ACTOR_PROFILE_SUSPEND = "actor_profile.suspend" + ACTOR_PROFILE_REACTIVATE = "actor_profile.reactivate" + ACTOR_PROFILE_DEACTIVATE = "actor_profile.deactivate" + ACTOR_IDENTITY_LINK_REVOKE = "actor_identity_link.revoke" + ACTOR_IDENTITY_LINK_REACTIVATE = "actor_identity_link.reactivate" + + +class AuthorityResourceType(StrEnum): + """Resource types that an authority mutation can return and invalidate.""" + + ACTOR_PROFILE = "actor_profile" + ACTOR_IDENTITY_LINK = "actor_identity_link" + ADMIN_ROLE_GRANT = "admin_role_grant" + PROJECT_ROLE_GRANT = "project_role_grant" + + +class AdminRole(StrEnum): + """Closed administrative role tokens.""" + + ACCESS_ADMINISTRATOR = "access_administrator" + OPERATOR = "operator" + PROJECT_MANAGER = "project_manager" + FINANCE_AUTHORITY = "finance_authority" + AUDIT_AUTHORITY = "audit_authority" + + +class AdminScope(StrEnum): + """Administrative grant scope tokens.""" + + SYSTEM = "system" + PROJECT = "project" + + +class ProjectRole(StrEnum): + """Exact-project contributor role tokens.""" + + SUBMITTER = "submitter" + REVIEWER = "reviewer" + BOTH = "both" + + +Digest = Annotated[str, Field(pattern=r"^sha256:[0-9a-f]{64}$")] + + +class CanonicalAuthorityRequest(BaseModel): + """Frozen scalar-only base for one canonical mutation request.""" + + model_config = _MODEL_CONFIG + + operation: AuthorityOperation + + +class ServiceActorCreateRequest(CanonicalAuthorityRequest): + """Canonical server-derived facts for manual service-actor creation.""" + + operation: Literal[AuthorityOperation.SERVICE_ACTOR_CREATE] + identity_reference_digest: Digest + profile_payload_digest: Digest + + +class AdminRoleGrantIssueRequest(CanonicalAuthorityRequest): + """Canonical request facts for issuing one administrative role grant.""" + + operation: Literal[AuthorityOperation.ADMIN_ROLE_GRANT_ISSUE] + target_actor_id: UUID + role: AdminRole + scope_type: AdminScope + scope_project_id: UUID | None = None + reason_digest: Digest + + @model_validator(mode="after") + def validate_scope(self): + """Require role-compatible system or exact-project scope.""" + + system_only = self.role in {AdminRole.ACCESS_ADMINISTRATOR, AdminRole.OPERATOR} + if system_only and self.scope_type != AdminScope.SYSTEM: + raise ValueError("invalid authority mutation request") + if (self.scope_type == AdminScope.PROJECT) != (self.scope_project_id is not None): + raise ValueError("invalid authority mutation request") + return self + + +class AdminRoleGrantRevokeRequest(CanonicalAuthorityRequest): + """Canonical request facts for revoking one administrative role grant.""" + + operation: Literal[AuthorityOperation.ADMIN_ROLE_GRANT_REVOKE] + grant_id: UUID + reason_digest: Digest + + +class ProjectRoleGrantIssueRequest(CanonicalAuthorityRequest): + """Canonical request facts for issuing or replacing one project role grant.""" + + operation: Literal[AuthorityOperation.PROJECT_ROLE_GRANT_ISSUE] + project_id: UUID + target_actor_id: UUID + role: ProjectRole + replaced_grant_id: UUID | None = None + reason_digest: Digest + + +class ProjectRoleGrantRevokeRequest(CanonicalAuthorityRequest): + """Canonical request facts for revoking one project role grant.""" + + operation: Literal[AuthorityOperation.PROJECT_ROLE_GRANT_REVOKE] + project_id: UUID + grant_id: UUID + reason_digest: Digest + + +class ActorProfileSuspendRequest(CanonicalAuthorityRequest): + """Canonical request facts for suspending one actor profile.""" + + operation: Literal[AuthorityOperation.ACTOR_PROFILE_SUSPEND] + actor_profile_id: UUID + reason_digest: Digest + + +class ActorProfileReactivateRequest(CanonicalAuthorityRequest): + """Canonical request facts for reactivating one actor profile.""" + + operation: Literal[AuthorityOperation.ACTOR_PROFILE_REACTIVATE] + actor_profile_id: UUID + reason_digest: Digest + + +class ActorProfileDeactivateRequest(CanonicalAuthorityRequest): + """Canonical request facts for permanently deactivating one actor profile.""" + + operation: Literal[AuthorityOperation.ACTOR_PROFILE_DEACTIVATE] + actor_profile_id: UUID + reason_digest: Digest + + +class ActorIdentityLinkRevokeRequest(CanonicalAuthorityRequest): + """Canonical request facts for revoking one actor identity link.""" + + operation: Literal[AuthorityOperation.ACTOR_IDENTITY_LINK_REVOKE] + identity_link_id: UUID + reason_digest: Digest + + +class ActorIdentityLinkReactivateRequest(CanonicalAuthorityRequest): + """Canonical request facts for reactivating one actor identity link.""" + + operation: Literal[AuthorityOperation.ACTOR_IDENTITY_LINK_REACTIVATE] + identity_link_id: UUID + reason_digest: Digest + + +AuthorityMutationRequest = Annotated[ + ServiceActorCreateRequest + | AdminRoleGrantIssueRequest + | AdminRoleGrantRevokeRequest + | ProjectRoleGrantIssueRequest + | ProjectRoleGrantRevokeRequest + | ActorProfileSuspendRequest + | ActorProfileReactivateRequest + | ActorProfileDeactivateRequest + | ActorIdentityLinkRevokeRequest + | ActorIdentityLinkReactivateRequest, + Field(discriminator="operation"), +] +_REQUEST_ADAPTER = TypeAdapter(AuthorityMutationRequest) + + +def parse_authority_request(value: object) -> AuthorityMutationRequest: + """Readmit an untrusted request without retaining rejected input.""" + admitted = None + try: + candidate = dict(value) if isinstance(value, Mapping) else None + admitted = _REQUEST_ADAPTER.validate_python(candidate, strict=True) + encoded = json.dumps( + admitted.model_dump(mode="json", exclude_none=True), + sort_keys=True, + separators=(",", ":"), + ).encode() + if len(encoded) > 2048: + admitted = None + except Exception: # noqa: BLE001 - Mapping and rejected values are untrusted + admitted = None + if admitted is None: + raise TypeError("invalid authority mutation request") + return admitted + + +def derive_reason_digest(reason: object) -> str: + """Derive an internal digest from one bounded human mutation reason.""" + valid = isinstance(reason, str) and 1 <= len(reason.encode("utf-8")) <= 500 + if not valid: + raise TypeError("invalid authority reason") + return canonical_json_hash({"reason": reason}) + + +def derive_service_identity_digest(issuer: object, subject: object) -> str: + """Derive an internal identity reference from verified normalized facts.""" + valid = ( + isinstance(issuer, str) + and issuer.startswith("https://") + and 1 <= len(issuer.encode("utf-8")) <= 500 + and isinstance(subject, str) + and 1 <= len(subject.encode("utf-8")) <= 200 + ) + if not valid: + raise TypeError("invalid verified service identity") + return canonical_json_hash({"issuer": issuer, "subject": subject}) + + +def derive_service_profile_digest(display_name: object, grant_reason: object) -> str: + """Derive an internal digest from bounded service profile request facts.""" + valid = ( + isinstance(display_name, str) + and 1 <= len(display_name.encode("utf-8")) <= 200 + and isinstance(grant_reason, str) + and 1 <= len(grant_reason.encode("utf-8")) <= 500 + ) + if not valid: + raise TypeError("invalid service profile request") + return canonical_json_hash({"display_name": display_name, "grant_reason": grant_reason}) + + +class AuthorityResponseReference(BaseModel): + """Internal typed reference returned for a committed mutation.""" + + model_config = _MODEL_CONFIG + + resource_type: AuthorityResourceType + resource_id: UUID + version: Annotated[int, Field(gt=0)] | None = None + http_status: Literal[200, 201] + + +class AuthorityClaimHandle(BaseModel): + """Opaque single-use proof of a reservation claimed by this transaction.""" + + model_config = _MODEL_CONFIG + + record_id: UUID + idempotency_key: UUID + actor_ref_kind: ActorReferenceKind + actor_ref: Annotated[str, Field(max_length=100)] + operation: AuthorityOperation + request_digest: Digest + + +class ClaimedReservation(BaseModel): + """A new reservation owned by the caller's current transaction.""" + + model_config = _MODEL_CONFIG + outcome: Literal["claimed"] = "claimed" + claim: AuthorityClaimHandle + + +class ReplayedReservation(BaseModel): + """The typed response reference from an exact committed retry.""" + + model_config = _MODEL_CONFIG + outcome: Literal["replay"] = "replay" + response: AuthorityResponseReference + + +class MismatchedReservation(BaseModel): + """A privacy-neutral signal that an existing request digest differs.""" + + model_config = _MODEL_CONFIG + outcome: Literal["mismatch"] = "mismatch" + + +AuthorityReservationResult = ClaimedReservation | ReplayedReservation | MismatchedReservation + + +class AuthorityInvalidationContext(BaseModel): + """Caller-owned identifiers for one server-constructed invalidation event.""" + + model_config = _MODEL_CONFIG + + event_id: UUID + request_id: UUID + correlation_id: UUID + + +class AuthorityMismatchContext(BaseModel): + """Privacy-bounded context for mismatch denial evidence.""" + + model_config = _MODEL_CONFIG + + event_id: UUID + request_id: UUID + correlation_id: UUID + project_id: UUID | None = None + + +class AuthorityCompletionResult(BaseModel): + """Internal result after success, invalidation, and completion flush.""" + + model_config = _MODEL_CONFIG + + response: AuthorityResponseReference + success_event_id: UUID + invalidation_event_id: UUID + + +class PendingAuthorityReservationError(RuntimeError): + """The caller attempted to replay its own uncommitted reservation.""" + + +class InvalidAuthorityClaimError(RuntimeError): + """A stale, forged, completed, or wrong-namespace claim was supplied.""" diff --git a/backend/app/modules/authorization/service.py b/backend/app/modules/authorization/service.py new file mode 100644 index 00000000..4dc74067 --- /dev/null +++ b/backend/app/modules/authorization/service.py @@ -0,0 +1,236 @@ +"""Typed orchestration for authority mutation replay and invalidation evidence.""" + +from __future__ import annotations + +from dataclasses import dataclass +from uuid import UUID + +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.hashing import canonical_json_hash +from app.modules.audit.schemas import ( + ActorReferenceKind, + AuthorityAuditEventInput, + AuthorityEventType, +) +from app.modules.audit.service import AuditService +from app.modules.authorization.repository import AuthorityIdempotencyRepository +from app.modules.authorization.schemas import ( + AdminRoleGrantIssueRequest, + AuthorityClaimHandle, + AuthorityCompletionResult, + AuthorityInvalidationContext, + AuthorityMismatchContext, + AuthorityMutationRequest, + AuthorityOperation, + AuthorityReservationResult, + AuthorityResourceType, + AuthorityResponseReference, + ProjectRoleGrantIssueRequest, + parse_authority_request, +) + + +@dataclass(frozen=True) +class _OperationEvidence: + """Closed success and invalidation evidence mapping for one operation.""" + + permission: str + resource_type: AuthorityResourceType + http_status: int + events: tuple[AuthorityEventType, ...] + + +_EVIDENCE = { + AuthorityOperation.SERVICE_ACTOR_CREATE: _OperationEvidence( + "actor.service.provision", AuthorityResourceType.ACTOR_PROFILE, 201, + (AuthorityEventType.SERVICE_ACTOR_PROVISIONED,), + ), + AuthorityOperation.ADMIN_ROLE_GRANT_ISSUE: _OperationEvidence( + "admin_role.grant", AuthorityResourceType.ADMIN_ROLE_GRANT, 201, + (AuthorityEventType.ADMIN_ROLE_GRANT_ISSUED,), + ), + AuthorityOperation.ADMIN_ROLE_GRANT_REVOKE: _OperationEvidence( + "admin_role.revoke", AuthorityResourceType.ADMIN_ROLE_GRANT, 200, + (AuthorityEventType.ADMIN_ROLE_GRANT_REVOKED,), + ), + AuthorityOperation.PROJECT_ROLE_GRANT_ISSUE: _OperationEvidence( + "project.role_grant.manage", AuthorityResourceType.PROJECT_ROLE_GRANT, 201, + (AuthorityEventType.PROJECT_ROLE_GRANT_ISSUED, AuthorityEventType.PROJECT_ROLE_GRANT_REPLACED), + ), + AuthorityOperation.PROJECT_ROLE_GRANT_REVOKE: _OperationEvidence( + "project.role_grant.manage", AuthorityResourceType.PROJECT_ROLE_GRANT, 200, + (AuthorityEventType.PROJECT_ROLE_GRANT_REVOKED,), + ), + AuthorityOperation.ACTOR_PROFILE_SUSPEND: _OperationEvidence( + "actor.profile.suspend", AuthorityResourceType.ACTOR_PROFILE, 200, + (AuthorityEventType.ACTOR_PROFILE_SUSPENDED,), + ), + AuthorityOperation.ACTOR_PROFILE_REACTIVATE: _OperationEvidence( + "actor.profile.reactivate", AuthorityResourceType.ACTOR_PROFILE, 200, + (AuthorityEventType.ACTOR_PROFILE_REACTIVATED,), + ), + AuthorityOperation.ACTOR_PROFILE_DEACTIVATE: _OperationEvidence( + "actor.profile.deactivate", AuthorityResourceType.ACTOR_PROFILE, 200, + (AuthorityEventType.ACTOR_PROFILE_DEACTIVATED,), + ), + AuthorityOperation.ACTOR_IDENTITY_LINK_REVOKE: _OperationEvidence( + "actor.identity_link.revoke", AuthorityResourceType.ACTOR_IDENTITY_LINK, 200, + (AuthorityEventType.ACTOR_IDENTITY_LINK_REVOKED,), + ), + AuthorityOperation.ACTOR_IDENTITY_LINK_REACTIVATE: _OperationEvidence( + "actor.identity_link.reactivate", AuthorityResourceType.ACTOR_IDENTITY_LINK, 200, + (AuthorityEventType.ACTOR_IDENTITY_LINK_REACTIVATED,), + ), +} + + +def _canonical_actor(kind: ActorReferenceKind, reference: str) -> bool: + """Accept only the shared canonical actor-reference forms.""" + + if kind == ActorReferenceKind.SYSTEM_PRINCIPAL: + return reference == "workstream:system:bootstrap" + try: + return str(UUID(reference)) == reference + except (ValueError, AttributeError): + return False + + +class AuthorityMutationService: + """Coordinate idempotency and shared audit writes in one caller transaction.""" + + def __init__(self, session: AsyncSession) -> None: + """Bind idempotency and audit writers to one caller transaction.""" + + self._repository = AuthorityIdempotencyRepository(session) + self._audit = AuditService(session) + + async def reserve( + self, + *, + idempotency_key: UUID, + actor_ref_kind: ActorReferenceKind, + actor_ref: str, + request: object, + ) -> AuthorityReservationResult: + """Validate, hash, and reserve before any caller business-state flush.""" + mutation = parse_authority_request(request) + if not _canonical_actor(actor_ref_kind, actor_ref): + raise TypeError("invalid authority reservation input") + digest = canonical_json_hash(mutation.model_dump(mode="json", exclude_none=True)) + return await self._repository.reserve( + idempotency_key=idempotency_key, + actor_ref_kind=actor_ref_kind, + actor_ref=actor_ref, + operation=mutation.operation, + request_digest=digest, + ) + + async def complete( + self, + *, + claim: AuthorityClaimHandle, + request: object, + response: AuthorityResponseReference, + success: AuthorityAuditEventInput, + invalidation: AuthorityInvalidationContext, + ) -> AuthorityCompletionResult: + """Flush one mapped success/invalidation pair, then complete its claim.""" + mutation = parse_authority_request(request) + spec = _EVIDENCE[claim.operation] + expected_digest = canonical_json_hash(mutation.model_dump(mode="json", exclude_none=True)) + allowed_events = spec.events + if isinstance(mutation, ProjectRoleGrantIssueRequest): + allowed_events = ( + AuthorityEventType.PROJECT_ROLE_GRANT_REPLACED + if mutation.replaced_grant_id else AuthorityEventType.PROJECT_ROLE_GRANT_ISSUED, + ) + valid = ( + mutation.operation == claim.operation + and expected_digest == claim.request_digest + and success.event_type in allowed_events + and success.actor_ref_kind == claim.actor_ref_kind + and success.actor_ref == claim.actor_ref + and success.idempotency_reference == claim.record_id + and success.permission_id == spec.permission + and success.resource_type == spec.resource_type.value + and success.resource_id == str(response.resource_id) + and response.resource_type == spec.resource_type + and response.http_status == spec.http_status + and success.request_id == invalidation.request_id + and success.correlation_id == invalidation.correlation_id + ) + if not valid: + raise TypeError("invalid authority completion input") + stored_success = await self._audit.add_authority_event(success) + invalidation_input = AuthorityAuditEventInput( + event_id=invalidation.event_id, + event_type=AuthorityEventType.AUTHORITY_INVALIDATION_REQUESTED, + entity_type="authority_invalidation", + entity_id=str(invalidation.event_id), + actor_ref_kind=claim.actor_ref_kind, + actor_ref=claim.actor_ref, + request_id=invalidation.request_id, + correlation_id=invalidation.correlation_id, + permission_id=spec.permission, + project_id=success.project_id, + resource_type=success.resource_type, + resource_id=success.resource_id, + reason="authority_state_changed", + idempotency_reference=claim.record_id, + invalidation_cause_event_id=UUID(stored_success.id), + invalidation_target_kind=spec.resource_type.value, + invalidation_target_ref=success.resource_id, + before_facts={"effective": True}, + after_facts={"effective": False}, + ) + await self._audit.add_authority_event(invalidation_input) + await self._repository.complete(claim, response) + return AuthorityCompletionResult( + response=response, + success_event_id=success.event_id, + invalidation_event_id=invalidation.event_id, + ) + + async def record_mismatch_denial( + self, + *, + actor_ref_kind: ActorReferenceKind, + actor_ref: str, + request: object, + context: AuthorityMismatchContext, + ) -> UUID: + """Append privacy-safe mismatch evidence in a caller-started clean transaction.""" + mutation: AuthorityMutationRequest = parse_authority_request(request) + spec = _EVIDENCE[mutation.operation] + resource_id = _request_resource_id(mutation) + event = AuthorityAuditEventInput( + event_id=context.event_id, + event_type=AuthorityEventType.SENSITIVE_AUTHORIZATION_DENIED, + entity_type="authorization_decision", + entity_id=str(context.event_id), + actor_ref_kind=actor_ref_kind, + actor_ref=actor_ref, + request_id=context.request_id, + correlation_id=context.correlation_id, + permission_id=spec.permission, + project_id=str(context.project_id) if context.project_id else None, + resource_type=spec.resource_type.value if resource_id else None, + resource_id=str(resource_id) if resource_id else None, + reason="authorization_evaluation", + denial_code="idempotency_mismatch", + after_facts={"allowed": False}, + ) + await self._audit.add_authority_event(event) + return context.event_id + + +def _request_resource_id(request: AuthorityMutationRequest) -> UUID | None: + """Return only an existing mutation target; creates intentionally return none.""" + for field in ("grant_id", "actor_profile_id", "identity_link_id"): + value = getattr(request, field, None) + if value is not None: + return value + if isinstance(request, AdminRoleGrantIssueRequest): + return request.target_actor_id + return getattr(request, "project_id", None) diff --git a/backend/tests/test_alembic.py b/backend/tests/test_alembic.py index ffbac20b..5d977ecc 100644 --- a/backend/tests/test_alembic.py +++ b/backend/tests/test_alembic.py @@ -508,6 +508,90 @@ def test_authority_audit_schema_preserves_legacy_and_guards_downgrade( } +def test_authority_idempotency_schema_preserves_audit_and_guards_downgrade( + isolated_database_env: str, + migration_lock, +) -> None: + """Prove 0019 state, linkage, forward compatibility, and downgrade custody.""" + project_root = Path(__file__).resolve().parents[1] + config = Config(str(project_root / "alembic.ini")) + config.set_main_option("script_location", str(project_root / "alembic")) + orphan_event, orphan_ref = str(uuid4()), str(uuid4()) + record_id, actor_id, target_id = str(uuid4()), str(uuid4()), str(uuid4()) + + with migration_lock(): + try: + command.downgrade(config, "0018_authority_audit_evidence") + asyncio.run( + _insert_pre_0019_forward_reference( + isolated_database_env, orphan_event, orphan_ref + ) + ) + command.upgrade(config, "0019_authority_idempotency") + schema = asyncio.run(_authority_idempotency_schema(isolated_database_env)) + invalid = asyncio.run(_authority_idempotency_invalid_writes(isolated_database_env)) + asyncio.run( + _insert_committed_authority_idempotency( + isolated_database_env, record_id, actor_id, target_id + ) + ) + with pytest.raises(RuntimeError, match="non-empty authority idempotency"): + command.downgrade(config, "0018_authority_audit_evidence") + refused = asyncio.run(_authority_idempotency_state(isolated_database_env, orphan_event)) + asyncio.run( + _remove_authority_idempotency_fixture( + isolated_database_env, record_id, orphan_event=None + ) + ) + command.downgrade(config, "0018_authority_audit_evidence") + preserved = asyncio.run(_authority_idempotency_state(isolated_database_env, orphan_event)) + command.upgrade(config, "0019_authority_idempotency") + restored = asyncio.run(_authority_idempotency_schema(isolated_database_env)) + finally: + command.upgrade(config, "head") + asyncio.run( + _remove_authority_idempotency_fixture( + isolated_database_env, record_id, orphan_event=orphan_event + ) + ) + + assert schema == restored + assert schema == { + "columns": { + "actor_ref:varchar:NO", "actor_ref_kind:varchar:NO", "committed_at:timestamptz:YES", + "created_at:timestamptz:NO", "id:uuid:NO", "idempotency_key:uuid:NO", + "operation:varchar:NO", "request_digest:varchar:NO", "response_http_status:int2:YES", + "response_resource_id:uuid:YES", "response_resource_type:varchar:YES", + "response_resource_version:int8:YES", "status:varchar:NO", + }, + "constraints": { + "authority_idempotency_pending_guard", + "ck_authority_idempotency_records_actor_kind", + "ck_authority_idempotency_records_actor_reference", + "ck_authority_idempotency_records_operation", + "ck_authority_idempotency_records_request_digest", + "ck_authority_idempotency_records_response_status", + "ck_authority_idempotency_records_response_type", + "ck_authority_idempotency_records_response_version", + "ck_authority_idempotency_records_state_shape", + "ck_authority_idempotency_records_status", + "pk_authority_idempotency_records", + "uq_authority_idempotency_records_actor_reference", + "uq_authority_idempotency_records_replay_namespace", + }, + "triggers": { + "authority_idempotency_guard", + "authority_idempotency_pending_guard", + "authority_idempotency_reject_truncate", + }, + "audit_fk_validated": False, + "audit_trigger": True, + } + assert invalid == {"initial_committed": True, "pending_commit": True, "new_orphan": True} + assert refused == {"revision": "0019_authority_idempotency", "records": 1, "orphan": 1} + assert preserved == {"revision": "0018_authority_audit_evidence", "records": None, "orphan": 1} + + async def _fetch_columns(database_url: str) -> set[str]: """Return current public table columns as table.column names.""" engine = create_async_engine(database_url) @@ -2348,3 +2432,296 @@ async def _remove_authority_audit_fixture(database_url: str, event_id: str) -> N ) finally: await engine.dispose() + + +async def _insert_pre_0019_forward_reference( + database_url: str, event_id: str, reference: str +) -> None: + """Seed the forward reference that 0019's NOT VALID FK must preserve.""" + engine = create_async_engine(database_url) + try: + async with engine.begin() as connection: + await connection.execute( + text( + "insert into audit_events (id, entity_type, entity_id, event_type, actor_id, " + "actor_roles, claim_snapshot, auth_source, is_dev_auth, event_payload, " + "event_domain, event_version, actor_ref_kind, request_id, correlation_id, " + "permission_id, reason, idempotency_reference, after_facts) values " + "(:id, 'authorization_decision', :id, 'SensitiveAuthorizationAllowed', " + ":actor, '[]'::json, '{}'::json, 'local_authority', false, '{}'::json, " + "'authority', 1, 'actor_profile', :request, :correlation, " + "'actor.profile.read_any', 'authorization_evaluation', :reference, " + "'{\"allowed\": true}'::json)" + ), + { + "id": event_id, + "actor": str(uuid4()), + "request": str(uuid4()), + "correlation": str(uuid4()), + "reference": reference, + }, + ) + finally: + await engine.dispose() + + +async def _authority_idempotency_schema(database_url: str) -> dict[str, object]: + """Return the exact 0019 schema and audit-link surface.""" + engine = create_async_engine(database_url) + try: + async with engine.begin() as connection: + columns = set( + ( + await connection.execute( + text( + "select column_name || ':' || udt_name || ':' || is_nullable " + "from information_schema.columns where table_schema='public' " + "and table_name='authority_idempotency_records'" + ) + ) + ).scalars() + ) + constraints = set( + ( + await connection.execute( + text( + "select conname from pg_constraint where " + "conrelid='authority_idempotency_records'::regclass" + ) + ) + ).scalars() + ) + triggers = set( + ( + await connection.execute( + text( + "select tgname from pg_trigger where " + "tgrelid='authority_idempotency_records'::regclass and not tgisinternal" + ) + ) + ).scalars() + ) + return { + "columns": columns, + "constraints": constraints, + "triggers": triggers, + "audit_fk_validated": await connection.scalar( + text( + "select convalidated from pg_constraint where " + "conname='fk_audit_events_authority_idempotency'" + ) + ), + "audit_trigger": bool( + await connection.scalar( + text( + "select exists(select 1 from pg_trigger where " + "tgname='audit_events_validate_idempotency' and not tgisinternal)" + ) + ) + ), + } + finally: + await engine.dispose() + + +async def _authority_idempotency_invalid_writes(database_url: str) -> dict[str, bool]: + """Prove invalid initial state, durable pending, and new orphan fail closed.""" + engine = create_async_engine(database_url) + results: dict[str, bool] = {} + try: + for name, statement, values in ( + ( + "initial_committed", + "insert into authority_idempotency_records (id,idempotency_key,actor_ref_kind," + "actor_ref,operation,request_digest,status,response_resource_type," + "response_resource_id,response_http_status,committed_at) values " + "(:id,:key,'actor_profile',:actor,'actor_profile.suspend',:digest,'committed'," + "'actor_profile',:resource,200,statement_timestamp())", + {"id": str(uuid4()), "key": str(uuid4()), "actor": str(uuid4()), + "resource": str(uuid4()), "digest": "sha256:" + "a" * 64}, + ), + ( + "pending_commit", + "insert into authority_idempotency_records (id,idempotency_key,actor_ref_kind," + "actor_ref,operation,request_digest,status) values " + "(:id,:key,'actor_profile',:actor,'actor_profile.suspend',:digest,'pending')", + {"id": str(uuid4()), "key": str(uuid4()), "actor": str(uuid4()), + "digest": "sha256:" + "a" * 64}, + ), + ( + "new_orphan", + "insert into audit_events (id,entity_type,entity_id,event_type,actor_id," + "actor_roles,claim_snapshot,auth_source,is_dev_auth,event_payload,event_domain," + "event_version,actor_ref_kind,request_id,correlation_id,permission_id,reason," + "idempotency_reference,after_facts) values (:id,'authorization_decision',:id," + "'SensitiveAuthorizationAllowed',:actor,'[]','{}','local_authority',false,'{}'," + "'authority',1,'actor_profile',:request,:correlation,'actor.profile.read_any'," + "'authorization_evaluation',:reference,cast(:facts as json))", + {"id": str(uuid4()), "actor": str(uuid4()), "request": str(uuid4()), + "correlation": str(uuid4()), "reference": str(uuid4()), + "facts": json.dumps({"allowed": True})}, + ), + ): + try: + async with engine.begin() as connection: + await connection.execute(text(statement), values) + except DBAPIError: + results[name] = True + else: + results[name] = False + return results + finally: + await engine.dispose() + + +async def _insert_committed_authority_idempotency( + database_url: str, record_id: str, actor_id: str, target_id: str +) -> None: + """Insert one complete actor-suspension reservation and evidence pair.""" + engine = create_async_engine(database_url) + success_id, invalidation_id = str(uuid4()), str(uuid4()) + request_id, correlation_id = str(uuid4()), str(uuid4()) + try: + async with engine.begin() as connection: + await connection.execute( + text( + "insert into authority_idempotency_records (id,idempotency_key,actor_ref_kind," + "actor_ref,operation,request_digest,status) values " + "(:id,:key,'actor_profile',:actor,'actor_profile.suspend',:digest,'pending')" + ), + {"id": record_id, "key": str(uuid4()), "actor": actor_id, + "digest": "sha256:" + "a" * 64}, + ) + common = ( + "actor_roles,claim_snapshot,auth_source,is_dev_auth,event_payload,event_domain," + "event_version,actor_ref_kind,request_id,correlation_id,permission_id," + "resource_type,resource_id,reason,idempotency_reference" + ) + await connection.execute( + text( + f"insert into audit_events (id,entity_type,entity_id,event_type,actor_id,{common}," + "before_facts,after_facts) values (:id,'actor_profile',:target," + "'ActorProfileSuspended',:actor,'[]','{}','local_authority',false,'{}'," + "'authority',1,'actor_profile',:request,:correlation,'actor.profile.suspend'," + "'actor_profile',:target,'security_response',:record," + "cast(:before_facts as json),cast(:after_facts as json))" + ), + { + "id": success_id, + "target": target_id, + "actor": actor_id, + "request": request_id, + "correlation": correlation_id, + "record": record_id, + "before_facts": json.dumps({"status": "active"}), + "after_facts": json.dumps({"status": "suspended"}), + }, + ) + await connection.execute( + text( + f"insert into audit_events (id,entity_type,entity_id,event_type,actor_id,{common}," + "invalidation_cause_event_id,invalidation_target_kind,invalidation_target_ref," + "before_facts,after_facts) values (:id,'authority_invalidation',:id," + "'AuthorityInvalidationRequested',:actor,'[]','{}','local_authority',false,'{}'," + "'authority',1,'actor_profile',:request,:correlation,'actor.profile.suspend'," + "'actor_profile',:target,'authority_state_changed',:record,:cause,'actor_profile'," + ":target,cast(:before_facts as json),cast(:after_facts as json))" + ), + { + "id": invalidation_id, + "target": target_id, + "actor": actor_id, + "request": request_id, + "correlation": correlation_id, + "record": record_id, + "cause": success_id, + "before_facts": json.dumps({"effective": True}), + "after_facts": json.dumps({"effective": False}), + }, + ) + await connection.execute( + text( + "update authority_idempotency_records set status='committed'," + "response_resource_type='actor_profile',response_resource_id=:target," + "response_resource_version=1,response_http_status=200 where id=:id" + ), + {"id": record_id, "target": target_id}, + ) + finally: + await engine.dispose() + + +async def _authority_idempotency_state(database_url: str, orphan_event: str) -> dict[str, object]: + """Return revision, optional record count, and preserved orphan count.""" + engine = create_async_engine(database_url) + try: + async with engine.begin() as connection: + exists = await connection.scalar( + text( + "select exists(select 1 from information_schema.tables where " + "table_name='authority_idempotency_records')" + ) + ) + return { + "revision": await connection.scalar(text("select version_num from alembic_version")), + "records": await connection.scalar( + text("select count(*) from authority_idempotency_records") + ) if exists else None, + "orphan": await connection.scalar( + text("select count(*) from audit_events where id=:id"), {"id": orphan_event} + ), + } + finally: + await engine.dispose() + + +async def _remove_authority_idempotency_fixture( + database_url: str, record_id: str, *, orphan_event: str | None +) -> None: + """Owner-only cleanup for immutable 0019 fixtures.""" + engine = create_async_engine(database_url) + try: + async with engine.begin() as connection: + exists = await connection.scalar( + text( + "select exists(select 1 from information_schema.tables where " + "table_name='authority_idempotency_records')" + ) + ) + await connection.execute(text("lock table audit_events in access exclusive mode")) + await connection.execute( + text("alter table audit_events disable trigger audit_events_reject_update_delete") + ) + await connection.execute( + text("delete from audit_events where idempotency_reference=:record"), + {"record": record_id}, + ) + if orphan_event: + await connection.execute( + text("delete from audit_events where id=:id"), {"id": orphan_event} + ) + await connection.execute( + text("alter table audit_events enable trigger audit_events_reject_update_delete") + ) + if exists: + await connection.execute( + text("lock table authority_idempotency_records in access exclusive mode") + ) + await connection.execute( + text( + "alter table authority_idempotency_records disable trigger " + "authority_idempotency_guard" + ) + ) + await connection.execute( + text("delete from authority_idempotency_records where id=:id"), + {"id": record_id}, + ) + await connection.execute( + text( + "alter table authority_idempotency_records enable trigger " + "authority_idempotency_guard" + ) + ) + finally: + await engine.dispose() diff --git a/backend/tests/test_audit.py b/backend/tests/test_audit.py index d8e57d94..caa738c2 100644 --- a/backend/tests/test_audit.py +++ b/backend/tests/test_audit.py @@ -651,8 +651,10 @@ def test_authority_input_enforces_grant_scope_matrix() -> None: ) -async def test_authority_event_matrix_has_typed_direct_sql_parity(audit_factory) -> None: - """Every registered event accepts and rejects the same fact shape at both boundaries.""" +async def test_authority_event_matrix_preserves_shapes_and_requires_idempotency_links( + audit_factory, +) -> None: + """Preserve 05A shapes while 05B-linked mutations fail closed without a claim.""" insert = text( "insert into audit_events " "(id, entity_type, entity_id, event_type, actor_id, actor_roles, claim_snapshot, " @@ -671,6 +673,20 @@ async def test_authority_event_matrix_has_typed_direct_sql_parity(audit_factory) ) cases = _authority_event_matrix() assert {case["event_type"] for case in cases} == set(AuthorityEventType) + linked = { + AuthorityEventType.SERVICE_ACTOR_PROVISIONED, + AuthorityEventType.ADMIN_ROLE_GRANT_ISSUED, + AuthorityEventType.ADMIN_ROLE_GRANT_REVOKED, + AuthorityEventType.PROJECT_ROLE_GRANT_ISSUED, + AuthorityEventType.PROJECT_ROLE_GRANT_REPLACED, + AuthorityEventType.PROJECT_ROLE_GRANT_REVOKED, + AuthorityEventType.ACTOR_PROFILE_SUSPENDED, + AuthorityEventType.ACTOR_PROFILE_REACTIVATED, + AuthorityEventType.ACTOR_PROFILE_DEACTIVATED, + AuthorityEventType.ACTOR_IDENTITY_LINK_REVOKED, + AuthorityEventType.ACTOR_IDENTITY_LINK_REACTIVATED, + AuthorityEventType.AUTHORITY_INVALIDATION_REQUESTED, + } async with audit_factory() as session: for case in cases: @@ -678,8 +694,13 @@ async def test_authority_event_matrix_has_typed_direct_sql_parity(audit_factory) assert ( AuthorityAuditEventInput.model_validate(candidate).event_type == case["event_type"] ) - await session.execute(insert, _authority_sql_values(candidate)) - await session.commit() + if case["event_type"] in linked: + with pytest.raises(IntegrityError, match="idempotency reference"): + await session.execute(insert, _authority_sql_values(candidate)) + await session.rollback() + else: + await session.execute(insert, _authority_sql_values(candidate)) + await session.commit() for case in cases: event_id = uuid4() @@ -782,29 +803,46 @@ async def test_authority_writer_persists_typed_privacy_neutral_events(audit_fact async with audit_factory() as session: service = AuditService(session) - stored = [ - await service.add_authority_event(value) for value in (allowed, denied, invalidation) - ] + stored = [await service.add_authority_event(value) for value in (allowed, denied)] await session.commit() + stored_values = [ + { + "event_type": event.event_type, + "occurred_at": event.occurred_at, + "event_domain": event.event_domain, + "event_version": event.event_version, + "external_subject": event.external_subject, + "external_issuer": event.external_issuer, + "actor_roles": event.actor_roles, + "claim_snapshot": event.claim_snapshot, + "event_payload": event.event_payload, + "auth_source": event.auth_source, + "is_dev_auth": event.is_dev_auth, + "idempotency_reference": event.idempotency_reference, + } + for event in stored + ] + with pytest.raises(IntegrityError, match="idempotency reference"): + await service.add_authority_event(invalidation) + await session.rollback() - assert [event.event_type for event in stored] == [ + assert [event["event_type"] for event in stored_values] == [ "SensitiveAuthorizationAllowed", "SensitiveAuthorizationDenied", - "AuthorityInvalidationRequested", ] - assert all(event.occurred_at is not None for event in stored) - for event in stored: - assert event.event_domain == "authority" - assert event.event_version == 1 - assert event.external_subject is None - assert event.external_issuer is None - assert event.actor_roles == [] - assert event.claim_snapshot == {} - assert event.event_payload == {} - assert event.auth_source == "local_authority" - assert event.is_dev_auth is False - assert stored[0].idempotency_reference is None - assert UUID(stored[2].idempotency_reference) == future_idempotency + assert all(event["occurred_at"] is not None for event in stored_values) + for event in stored_values: + assert event["event_domain"] == "authority" + assert event["event_version"] == 1 + assert event["external_subject"] is None + assert event["external_issuer"] is None + assert event["actor_roles"] == [] + assert event["claim_snapshot"] == {} + assert event["event_payload"] == {} + assert event["auth_source"] == "local_authority" + assert event["is_dev_auth"] is False + assert stored_values[0]["idempotency_reference"] is None + assert future_idempotency is not None async def test_legacy_writer_and_reader_remain_compatible(audit_factory) -> None: @@ -1037,7 +1075,9 @@ def grant_values(facts: str, *, scope_project_id: str | None = project_id) -> di "effective": True, } ) - await session.execute(grant_insert, grant_values(valid_facts)) + with pytest.raises(IntegrityError, match="idempotency reference"): + await session.execute(grant_insert, grant_values(valid_facts)) + await session.rollback() system_facts = json.dumps( { "status": "active", @@ -1046,8 +1086,9 @@ def grant_values(facts: str, *, scope_project_id: str | None = project_id) -> di "effective": True, } ) - await session.execute(grant_insert, grant_values(system_facts, scope_project_id=None)) - await session.commit() + with pytest.raises(IntegrityError, match="idempotency reference"): + await session.execute(grant_insert, grant_values(system_facts, scope_project_id=None)) + await session.rollback() for values in ( grant_values(valid_facts.replace(project_id, str(uuid4()))), grant_values(valid_facts) | {"resource_id": str(uuid4())}, diff --git a/backend/tests/test_authorization.py b/backend/tests/test_authorization.py new file mode 100644 index 00000000..d73edcf5 --- /dev/null +++ b/backend/tests/test_authorization.py @@ -0,0 +1,495 @@ +from __future__ import annotations + +import asyncio +from collections import UserDict +from collections.abc import Mapping +from pathlib import Path +from uuid import UUID, uuid4 + +from alembic import command +from alembic.config import Config +import pytest +from sqlalchemy import text +from sqlalchemy.exc import DBAPIError +from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine + +from app.modules.audit.schemas import ActorReferenceKind, AuthorityAuditEventInput, AuthorityEventType +from app.modules.authorization.schemas import ( + ActorIdentityLinkReactivateRequest, + ActorIdentityLinkRevokeRequest, + ActorProfileDeactivateRequest, + ActorProfileReactivateRequest, + ActorProfileSuspendRequest, + AdminRole, + AdminRoleGrantIssueRequest, + AdminRoleGrantRevokeRequest, + AdminScope, + AuthorityClaimHandle, + AuthorityInvalidationContext, + AuthorityMismatchContext, + AuthorityOperation, + AuthorityResourceType, + AuthorityResponseReference, + ClaimedReservation, + InvalidAuthorityClaimError, + MismatchedReservation, + PendingAuthorityReservationError, + ProjectRole, + ProjectRoleGrantIssueRequest, + ProjectRoleGrantRevokeRequest, + ReplayedReservation, + ServiceActorCreateRequest, + derive_reason_digest, + derive_service_identity_digest, + derive_service_profile_digest, + parse_authority_request, +) +from app.modules.authorization.service import AuthorityMutationService + +DIGEST = "sha256:" + "a" * 64 + + +@pytest.fixture +def authorization_database_env(postgres_database_url: str, migration_lock) -> str: + """Ensure authorization tests run at the current isolated schema head.""" + project_root = Path(__file__).resolve().parents[1] + config = Config(str(project_root / "alembic.ini")) + config.set_main_option("script_location", str(project_root / "alembic")) + with migration_lock(): + command.upgrade(config, "head") + return postgres_database_url + + +@pytest.fixture +async def authorization_factory(authorization_database_env: str): + """Provide sessions and remove only rows created by this test module.""" + engine = create_async_engine(authorization_database_env) + try: + yield async_sessionmaker(engine, expire_on_commit=False) + finally: + async with engine.begin() as connection: + await connection.execute(text("lock table authority_idempotency_records in access exclusive mode")) + await connection.execute(text("lock table audit_events in access exclusive mode")) + await connection.execute(text("alter table audit_events disable trigger audit_events_reject_update_delete")) + await connection.execute(text("alter table authority_idempotency_records disable trigger authority_idempotency_guard")) + await connection.execute(text("delete from audit_events where idempotency_reference is not null or denial_code='idempotency_mismatch'")) + await connection.execute(text("delete from authority_idempotency_records")) + await connection.execute(text("alter table authority_idempotency_records enable trigger authority_idempotency_guard")) + await connection.execute(text("alter table audit_events enable trigger audit_events_reject_update_delete")) + await engine.dispose() + + +def _request(target: UUID | None = None) -> ActorProfileSuspendRequest: + return ActorProfileSuspendRequest( + operation=AuthorityOperation.ACTOR_PROFILE_SUSPEND, + actor_profile_id=target or uuid4(), + reason_digest=DIGEST, + ) + + +def _success( + claim: AuthorityClaimHandle, + request: ActorProfileSuspendRequest, + *, + request_id: UUID | None = None, + correlation_id: UUID | None = None, +) -> AuthorityAuditEventInput: + event_id = uuid4() + return AuthorityAuditEventInput( + event_id=event_id, + event_type=AuthorityEventType.ACTOR_PROFILE_SUSPENDED, + entity_type="actor_profile", + entity_id=str(request.actor_profile_id), + actor_ref_kind=claim.actor_ref_kind, + actor_ref=claim.actor_ref, + request_id=request_id or uuid4(), + correlation_id=correlation_id or uuid4(), + permission_id="actor.profile.suspend", + resource_type="actor_profile", + resource_id=str(request.actor_profile_id), + target_ref_kind="actor_profile", + target_ref_id=str(request.actor_profile_id), + reason="security_response", + idempotency_reference=claim.record_id, + before_facts={"status": "active"}, + after_facts={"status": "suspended"}, + ) + + +async def _claim(service: AuthorityMutationService, actor: UUID, key: UUID, request): + result = await service.reserve( + idempotency_key=key, + actor_ref_kind=ActorReferenceKind.ACTOR_PROFILE, + actor_ref=str(actor), + request=request.model_dump(), + ) + assert isinstance(result, ClaimedReservation) + return result.claim + + +async def _complete(service, claim, request): + success = _success(claim, request) + response = AuthorityResponseReference( + resource_type=AuthorityResourceType.ACTOR_PROFILE, + resource_id=request.actor_profile_id, + version=1, + http_status=200, + ) + result = await service.complete( + claim=claim, + request=request.model_dump(), + response=response, + success=success, + invalidation=AuthorityInvalidationContext( + event_id=uuid4(), + request_id=success.request_id, + correlation_id=success.correlation_id, + ), + ) + return result + + +@pytest.mark.asyncio +async def test_claim_completion_and_exact_replay_have_one_evidence_pair( + authorization_factory, +) -> None: + actor, key, request = uuid4(), uuid4(), _request() + async with authorization_factory() as session: + service = AuthorityMutationService(session) + claim = await _claim(service, actor, key, request) + completed = await _complete(service, claim, request) + await session.commit() + + async with authorization_factory() as session: + replay = await AuthorityMutationService(session).reserve( + idempotency_key=key, + actor_ref_kind=ActorReferenceKind.ACTOR_PROFILE, + actor_ref=str(actor), + request=request.model_dump(), + ) + assert isinstance(replay, ReplayedReservation) + assert replay.response == completed.response + await session.commit() + counts = ( + await session.execute( + text( + "select event_type, count(*) from audit_events " + "where idempotency_reference=:record group by event_type order by event_type" + ), + {"record": claim.record_id}, + ) + ).all() + assert counts == [("ActorProfileSuspended", 1), ("AuthorityInvalidationRequested", 1)] + + +@pytest.mark.asyncio +async def test_pending_commit_fails_and_rollback_allows_retry(authorization_factory) -> None: + actor, key, request = uuid4(), uuid4(), _request() + async with authorization_factory() as session: + await _claim(AuthorityMutationService(session), actor, key, request) + with pytest.raises(DBAPIError, match="pending authority idempotency"): + await session.commit() + await session.rollback() + async with authorization_factory() as session: + result = await _claim(AuthorityMutationService(session), actor, key, request) + assert result.request_digest.startswith("sha256:") + await session.rollback() + + +@pytest.mark.asyncio +async def test_same_session_pending_and_forged_claim_fail_closed(authorization_factory) -> None: + actor, key, request = uuid4(), uuid4(), _request() + async with authorization_factory() as session: + service = AuthorityMutationService(session) + claim = await _claim(service, actor, key, request) + with pytest.raises(PendingAuthorityReservationError): + await service.reserve( + idempotency_key=key, + actor_ref_kind=ActorReferenceKind.ACTOR_PROFILE, + actor_ref=str(actor), + request=request.model_dump(), + ) + forged = claim.model_copy(update={"request_digest": "sha256:" + "b" * 64}) + with pytest.raises(InvalidAuthorityClaimError): + await service._repository.complete( + forged, + AuthorityResponseReference( + resource_type=AuthorityResourceType.ACTOR_PROFILE, + resource_id=request.actor_profile_id, + http_status=200, + ), + ) + await session.rollback() + + +@pytest.mark.asyncio +async def test_mismatch_is_private_and_denial_uses_clean_transaction(authorization_factory) -> None: + actor, key, request = uuid4(), uuid4(), _request() + async with authorization_factory() as session: + service = AuthorityMutationService(session) + await _complete(service, await _claim(service, actor, key, request), request) + await session.commit() + different = _request() + async with authorization_factory() as session: + result = await AuthorityMutationService(session).reserve( + idempotency_key=key, + actor_ref_kind=ActorReferenceKind.ACTOR_PROFILE, + actor_ref=str(actor), + request=different.model_dump(), + ) + assert isinstance(result, MismatchedReservation) + assert result.model_dump() == {"outcome": "mismatch"} + await session.rollback() + async with authorization_factory() as session: + service = AuthorityMutationService(session) + denial_id = await service.record_mismatch_denial( + actor_ref_kind=ActorReferenceKind.ACTOR_PROFILE, + actor_ref=str(actor), + request=different.model_dump(), + context=AuthorityMismatchContext( + event_id=uuid4(), request_id=uuid4(), correlation_id=uuid4() + ), + ) + await session.commit() + denial = await session.scalar( + text("select denial_code from audit_events where id=:id"), {"id": str(denial_id)} + ) + assert denial == "idempotency_mismatch" + + +def test_request_admission_is_frozen_bounded_and_nonretaining() -> None: + secret = "SECRET_AUTHORITY_INPUT_9f4b" + source = UserDict(_request().model_dump()) + admitted = parse_authority_request(source) + source["reason_digest"] = "sha256:" + "b" * 64 + assert admitted.reason_digest == DIGEST + + rejected = [ + {**_request().model_dump(), "reason_digest": secret}, + {**_request().model_dump(), "actor_profile_id": str(uuid4()).upper()}, + {**_request().model_dump(), "extra": secret}, + ] + for value in rejected: + with pytest.raises(TypeError, match="invalid authority mutation request") as caught: + parse_authority_request(value) + assert secret not in str(caught.value) + assert secret not in repr(caught.value.args) + assert caught.value.__cause__ is None + assert caught.value.__context__ is None + + +class _ChangingMapping(Mapping): + def __init__(self, first: dict, second: dict) -> None: + self.first, self.second, self.calls = first, second, 0 + + def __iter__(self): + return iter(self.first) + + def __len__(self): + return len(self.first) + + def __getitem__(self, key): + self.calls += 1 + return (self.first if self.calls == 1 else self.second)[key] + + +def test_state_changing_mapping_cannot_change_validated_snapshot() -> None: + first = _request().model_dump() + hostile = _ChangingMapping(first, {**first, "reason_digest": "not-a-digest"}) + with pytest.raises(TypeError, match="invalid authority mutation request"): + parse_authority_request(hostile) + + +def test_every_operation_has_one_strict_canonical_request_variant() -> None: + project, actor, resource = uuid4(), uuid4(), uuid4() + requests = [ + ServiceActorCreateRequest( + operation=AuthorityOperation.SERVICE_ACTOR_CREATE, + identity_reference_digest=derive_service_identity_digest( + "https://identity.flowresearch.tech", "opaque-service-subject" + ), + profile_payload_digest=derive_service_profile_digest("Adapter", "Approved"), + ), + AdminRoleGrantIssueRequest( + operation=AuthorityOperation.ADMIN_ROLE_GRANT_ISSUE, + target_actor_id=actor, + role=AdminRole.PROJECT_MANAGER, + scope_type=AdminScope.PROJECT, + scope_project_id=project, + reason_digest=derive_reason_digest("Assigned"), + ), + AdminRoleGrantRevokeRequest( + operation=AuthorityOperation.ADMIN_ROLE_GRANT_REVOKE, + grant_id=resource, + reason_digest=DIGEST, + ), + ProjectRoleGrantIssueRequest( + operation=AuthorityOperation.PROJECT_ROLE_GRANT_ISSUE, + project_id=project, + target_actor_id=actor, + role=ProjectRole.BOTH, + replaced_grant_id=resource, + reason_digest=DIGEST, + ), + ProjectRoleGrantRevokeRequest( + operation=AuthorityOperation.PROJECT_ROLE_GRANT_REVOKE, + project_id=project, + grant_id=resource, + reason_digest=DIGEST, + ), + ActorProfileSuspendRequest( + operation=AuthorityOperation.ACTOR_PROFILE_SUSPEND, + actor_profile_id=resource, + reason_digest=DIGEST, + ), + ActorProfileReactivateRequest( + operation=AuthorityOperation.ACTOR_PROFILE_REACTIVATE, + actor_profile_id=resource, + reason_digest=DIGEST, + ), + ActorProfileDeactivateRequest( + operation=AuthorityOperation.ACTOR_PROFILE_DEACTIVATE, + actor_profile_id=resource, + reason_digest=DIGEST, + ), + ActorIdentityLinkRevokeRequest( + operation=AuthorityOperation.ACTOR_IDENTITY_LINK_REVOKE, + identity_link_id=resource, + reason_digest=DIGEST, + ), + ActorIdentityLinkReactivateRequest( + operation=AuthorityOperation.ACTOR_IDENTITY_LINK_REACTIVATE, + identity_link_id=resource, + reason_digest=DIGEST, + ), + ] + assert {parse_authority_request(item.model_dump()).operation for item in requests} == set( + AuthorityOperation + ) + + +@pytest.mark.asyncio +async def test_failure_after_evidence_flush_rolls_back_claim_and_events( + authorization_factory, monkeypatch +) -> None: + actor, key, request = uuid4(), uuid4(), _request() + async with authorization_factory() as session: + service = AuthorityMutationService(session) + claim = await _claim(service, actor, key, request) + + async def fail_completion(*_args, **_kwargs): + raise RuntimeError("injected completion failure") + + monkeypatch.setattr(service._repository, "complete", fail_completion) + with pytest.raises(RuntimeError, match="injected completion failure"): + await _complete(service, claim, request) + await session.rollback() + async with authorization_factory() as session: + assert await session.scalar( + text("select count(*) from authority_idempotency_records where id=:id"), + {"id": claim.record_id}, + ) == 0 + assert await session.scalar( + text("select count(*) from audit_events where idempotency_reference=:id"), + {"id": claim.record_id}, + ) == 0 + + +async def _wait_for_database_lock(database_url: str, application_name: str) -> None: + """Observe the loser waiting on PostgreSQL rather than using a timing sleep.""" + engine = create_async_engine(database_url) + try: + async with engine.connect() as connection: + for _ in range(5000): + waiting = await connection.scalar( + text( + "select exists(select 1 from pg_stat_activity where " + "application_name=:name and wait_event_type='Lock')" + ), + {"name": application_name}, + ) + if waiting: + return + await asyncio.sleep(0) + finally: + await engine.dispose() + raise AssertionError("concurrent reservation never reached the database lock") + + +@pytest.mark.asyncio +async def test_concurrent_exact_and_mismatched_retries_serialize_at_unique_namespace( + authorization_database_env: str, + authorization_factory, +) -> None: + del authorization_factory # fixture owns immutable-row cleanup + actor, key, request = uuid4(), uuid4(), _request() + winner_engine = create_async_engine( + authorization_database_env, + connect_args={"server_settings": {"application_name": "auth05b-winner"}}, + ) + loser_engine = create_async_engine( + authorization_database_env, + connect_args={"server_settings": {"application_name": "auth05b-loser"}}, + ) + winner_factory = async_sessionmaker(winner_engine, expire_on_commit=False) + loser_factory = async_sessionmaker(loser_engine, expire_on_commit=False) + + async def lose(candidate): + async with loser_factory() as session: + return await AuthorityMutationService(session).reserve( + idempotency_key=key, + actor_ref_kind=ActorReferenceKind.ACTOR_PROFILE, + actor_ref=str(actor), + request=candidate.model_dump(), + ) + + try: + async with winner_factory() as winner: + service = AuthorityMutationService(winner) + claim = await _claim(service, actor, key, request) + loser = asyncio.create_task(lose(request)) + await asyncio.wait_for( + _wait_for_database_lock(authorization_database_env, "auth05b-loser"), + timeout=5, + ) + await _complete(service, claim, request) + await winner.commit() + assert isinstance(await asyncio.wait_for(loser, timeout=5), ReplayedReservation) + + key = uuid4() + async with winner_factory() as winner: + service = AuthorityMutationService(winner) + claim = await _claim(service, actor, key, request) + loser = asyncio.create_task(lose(_request())) + await asyncio.wait_for( + _wait_for_database_lock(authorization_database_env, "auth05b-loser"), + timeout=5, + ) + await _complete(service, claim, request) + await winner.commit() + assert isinstance(await asyncio.wait_for(loser, timeout=5), MismatchedReservation) + finally: + await winner_engine.dispose() + await loser_engine.dispose() + + +@pytest.mark.asyncio +async def test_same_key_is_isolated_by_actor_reference_kind(authorization_factory) -> None: + key, request = uuid4(), _request() + async with authorization_factory() as first, authorization_factory() as second: + one = await AuthorityMutationService(first).reserve( + idempotency_key=key, + actor_ref_kind=ActorReferenceKind.ACTOR_PROFILE, + actor_ref=str(uuid4()), + request=request.model_dump(), + ) + two = await AuthorityMutationService(second).reserve( + idempotency_key=key, + actor_ref_kind=ActorReferenceKind.LEGACY_ACTOR, + actor_ref=str(uuid4()), + request=request.model_dump(), + ) + assert isinstance(one, ClaimedReservation) + assert isinstance(two, ClaimedReservation) + await first.rollback() + await second.rollback() diff --git a/docs/architecture_data_model.md b/docs/architecture_data_model.md index 1223479f..dd3735ce 100644 --- a/docs/architecture_data_model.md +++ b/docs/architecture_data_model.md @@ -107,9 +107,22 @@ every operation that could remove the final effective Access Administrator. ### Authority Idempotency And Invalidation -Canonical idempotency records bind operation, actor, request hash, committed -result, and replay status. Authority invalidation events are written atomically -with grant/profile/link changes and drive bounded reconciliation. +`authority_idempotency_records` binds one client key to the exact actor-kind, +opaque actor reference, closed mutation operation, canonical request digest, +and typed committed resource reference. The unique actor-scoped namespace +serializes concurrent retries. New rows begin `pending`; a deferred PostgreSQL +guard prevents that state from committing, and immutable database triggers +permit only one evidence-backed transition to `committed`. + +The raw request and response body are never stored. Replay returns only an +internal resource type/UUID, optional positive version, and exact successful +status; route-owning code must reload and reauthorize that resource before +external disclosure. New linked audit rows use an actor-bound composite foreign +key while the `NOT VALID` migration preserves pre-foundation forward references. +Every committed authority mutation has exactly one concrete success event and +one causally linked `AuthorityInvalidationRequested` event in `audit_events`. +This foundation persists the request only; no cache, queue, background job processor, or consumer +acts on invalidation yet. ### API Rate Control Counter diff --git a/docs/operations_authorization_service.md b/docs/operations_authorization_service.md index 60dfd9e0..9a131be1 100644 --- a/docs/operations_authorization_service.md +++ b/docs/operations_authorization_service.md @@ -440,6 +440,28 @@ metrics/configuration evidence for the normal security incident period. Never use token, subject, email, `jti`, raw URL, key material, or unbounded resource IDs as metric labels. +## Authority Mutation Idempotency + +Service-actor creation, administrative/project grant issue or revocation, +actor suspension/reactivation/deactivation, and identity-link +revocation/reactivation require a canonical UUID idempotency key. The namespace +is actor-kind, opaque actor reference, closed operation, and key. Reusing that +namespace with a different canonical request produces `idempotency_mismatch` +without exposing the stored digest, record, or response. + +Reservation must be the first database write. Business state, one concrete +success event, one linked invalidation event, and the typed replay reference +commit in the same caller-owned transaction. The authorization repository and +service never commit, roll back, open a second session, expire a pending row, or +steal a claim. On mismatch the caller rolls back the reservation transaction, +starts a clean transaction on the same injected session, commits exactly one +privacy-bounded denial event, and then translates the conflict. + +A replay reference is internal only. Before responding, the future route owner +must load the canonical resource and evaluate current authority again. There is +currently no invalidation consumer or backlog processor; the durable event is +foundation evidence for the owning later chunk. + ## Authority Audit Custody Authority decisions and invalidation requests use the shared `audit_events` From e0838908dfa19227fab2f73e32c9b0c94dd39bb4 Mon Sep 17 00:00:00 2001 From: Abiorh001 Date: Tue, 14 Jul 2026 18:45:57 +0100 Subject: [PATCH 3/6] fix(auth): close idempotency evidence invariants --- .../versions/0019_authority_idempotency.py | 58 +- backend/app/modules/authorization/schemas.py | 94 ++- backend/app/modules/authorization/service.py | 80 ++- backend/tests/test_alembic.py | 119 +++- backend/tests/test_authorization.py | 605 +++++++++++++++++- docs/architecture_data_model.md | 3 +- 6 files changed, 900 insertions(+), 59 deletions(-) diff --git a/backend/alembic/versions/0019_authority_idempotency.py b/backend/alembic/versions/0019_authority_idempotency.py index c4de4a73..f4f2e118 100644 --- a/backend/alembic/versions/0019_authority_idempotency.py +++ b/backend/alembic/versions/0019_authority_idempotency.py @@ -204,6 +204,9 @@ def _create_functions_and_triggers() -> None: select * into record_row from authority_idempotency_records where id=new.idempotency_reference and actor_ref_kind=new.actor_ref_kind and actor_ref=new.actor_id; if not found then raise exception 'invalid authority idempotency reference' using errcode='23503'; end if; + if record_row.status <> 'pending' then + raise exception 'committed authority idempotency is closed' using errcode='23514'; + end if; expected_permission := case record_row.operation when 'service_actor.create' then 'actor.service.provision' when 'admin_role_grant.issue' then 'admin_role.grant' @@ -229,22 +232,51 @@ def _create_functions_and_triggers() -> None: if not found or cause_row.idempotency_reference is distinct from record_row.id or cause_row.actor_ref_kind is distinct from new.actor_ref_kind or cause_row.actor_id is distinct from new.actor_id + or cause_row.permission_id is distinct from new.permission_id or cause_row.resource_type is distinct from new.invalidation_target_kind - or cause_row.resource_id is distinct from new.invalidation_target_ref then + or cause_row.resource_id is distinct from new.invalidation_target_ref + or cause_row.resource_type is distinct from new.resource_type + or cause_row.resource_id is distinct from new.resource_id + or cause_row.target_ref_kind is distinct from cause_row.resource_type + or cause_row.target_ref_id is distinct from cause_row.resource_id + or cause_row.request_id is distinct from new.request_id + or cause_row.correlation_id is distinct from new.correlation_id + or cause_row.project_id is distinct from new.project_id + or new.entity_type <> 'authority_invalidation' + or new.entity_id <> new.id + or not ( + (record_row.operation='service_actor.create' and cause_row.event_type='ServiceActorProvisioned') or + (record_row.operation='admin_role_grant.issue' and cause_row.event_type='AdminRoleGrantIssued') or + (record_row.operation='admin_role_grant.revoke' and cause_row.event_type='AdminRoleGrantRevoked') or + (record_row.operation='project_role_grant.issue' and cause_row.event_type in ('ProjectRoleGrantIssued','ProjectRoleGrantReplaced')) or + (record_row.operation='project_role_grant.revoke' and cause_row.event_type='ProjectRoleGrantRevoked') or + (record_row.operation='actor_profile.suspend' and cause_row.event_type='ActorProfileSuspended') or + (record_row.operation='actor_profile.reactivate' and cause_row.event_type='ActorProfileReactivated') or + (record_row.operation='actor_profile.deactivate' and cause_row.event_type='ActorProfileDeactivated') or + (record_row.operation='actor_identity_link.revoke' and cause_row.event_type='ActorIdentityLinkRevoked') or + (record_row.operation='actor_identity_link.reactivate' and cause_row.event_type='ActorIdentityLinkReactivated')) then raise exception 'invalid linked authority cause' using errcode='23514'; end if; - elsif not ( - (record_row.operation='service_actor.create' and new.event_type='ServiceActorProvisioned') or - (record_row.operation='admin_role_grant.issue' and new.event_type='AdminRoleGrantIssued') or - (record_row.operation='admin_role_grant.revoke' and new.event_type='AdminRoleGrantRevoked') or - (record_row.operation='project_role_grant.issue' and new.event_type in ('ProjectRoleGrantIssued','ProjectRoleGrantReplaced')) or - (record_row.operation='project_role_grant.revoke' and new.event_type='ProjectRoleGrantRevoked') or - (record_row.operation='actor_profile.suspend' and new.event_type='ActorProfileSuspended') or - (record_row.operation='actor_profile.reactivate' and new.event_type='ActorProfileReactivated') or - (record_row.operation='actor_profile.deactivate' and new.event_type='ActorProfileDeactivated') or - (record_row.operation='actor_identity_link.revoke' and new.event_type='ActorIdentityLinkRevoked') or - (record_row.operation='actor_identity_link.reactivate' and new.event_type='ActorIdentityLinkReactivated')) then - raise exception 'authority success event does not match operation' using errcode='23514'; + else + if new.entity_type <> expected_resource or new.entity_id <> new.resource_id + or new.target_ref_kind is distinct from expected_resource + or new.target_ref_id is distinct from new.resource_id + or new.invalidation_cause_event_id is not null + or new.invalidation_target_kind is not null + or new.invalidation_target_ref is not null + or not ( + (record_row.operation='service_actor.create' and new.event_type='ServiceActorProvisioned') or + (record_row.operation='admin_role_grant.issue' and new.event_type='AdminRoleGrantIssued') or + (record_row.operation='admin_role_grant.revoke' and new.event_type='AdminRoleGrantRevoked') or + (record_row.operation='project_role_grant.issue' and new.event_type in ('ProjectRoleGrantIssued','ProjectRoleGrantReplaced')) or + (record_row.operation='project_role_grant.revoke' and new.event_type='ProjectRoleGrantRevoked') or + (record_row.operation='actor_profile.suspend' and new.event_type='ActorProfileSuspended') or + (record_row.operation='actor_profile.reactivate' and new.event_type='ActorProfileReactivated') or + (record_row.operation='actor_profile.deactivate' and new.event_type='ActorProfileDeactivated') or + (record_row.operation='actor_identity_link.revoke' and new.event_type='ActorIdentityLinkRevoked') or + (record_row.operation='actor_identity_link.reactivate' and new.event_type='ActorIdentityLinkReactivated')) then + raise exception 'authority success event does not match operation' using errcode='23514'; + end if; end if; return new; end $$ """ diff --git a/backend/app/modules/authorization/schemas.py b/backend/app/modules/authorization/schemas.py index 8199b29a..fab028c1 100644 --- a/backend/app/modules/authorization/schemas.py +++ b/backend/app/modules/authorization/schemas.py @@ -6,7 +6,7 @@ from enum import StrEnum import json import re -from typing import Annotated, Literal +from typing import Annotated, Any, Literal, Self from uuid import UUID from pydantic import BaseModel, ConfigDict, Field, TypeAdapter, model_validator @@ -77,6 +77,40 @@ class CanonicalAuthorityRequest(BaseModel): operation: AuthorityOperation + def __init__(self, **data: object) -> None: + """Construct only from values that pass without exposing diagnostics.""" + accepted = True + try: + super().__init__(**data) + except Exception: # noqa: BLE001 - rejected values must not escape diagnostics + accepted = False + if not accepted: + raise TypeError("invalid authority mutation request") + + @classmethod + def model_validate(cls, obj: object, **kwargs: Any) -> Self: + """Validate an object while replacing detailed diagnostics with one error.""" + admitted = None + try: + admitted = super().model_validate(obj, **kwargs) + except Exception: # noqa: BLE001 - rejected values must not escape diagnostics + admitted = None + if admitted is None: + raise TypeError("invalid authority mutation request") + return admitted + + @classmethod + def model_validate_json(cls, json_data: str | bytes | bytearray, **kwargs: Any) -> Self: + """Validate JSON while replacing detailed diagnostics with one error.""" + admitted = None + try: + admitted = super().model_validate_json(json_data, **kwargs) + except Exception: # noqa: BLE001 - rejected values must not escape diagnostics + admitted = None + if admitted is None: + raise TypeError("invalid authority mutation request") + return admitted + class ServiceActorCreateRequest(CanonicalAuthorityRequest): """Canonical server-derived facts for manual service-actor creation.""" @@ -214,37 +248,54 @@ def parse_authority_request(value: object) -> AuthorityMutationRequest: def derive_reason_digest(reason: object) -> str: """Derive an internal digest from one bounded human mutation reason.""" - valid = isinstance(reason, str) and 1 <= len(reason.encode("utf-8")) <= 500 - if not valid: + digest = None + try: + if type(reason) is str and 1 <= len(reason.encode("utf-8")) <= 500: + digest = canonical_json_hash({"reason": reason}) + except Exception: # noqa: BLE001 - raw human text must not escape diagnostics + digest = None + if digest is None: raise TypeError("invalid authority reason") - return canonical_json_hash({"reason": reason}) + return digest def derive_service_identity_digest(issuer: object, subject: object) -> str: """Derive an internal identity reference from verified normalized facts.""" - valid = ( - isinstance(issuer, str) - and issuer.startswith("https://") - and 1 <= len(issuer.encode("utf-8")) <= 500 - and isinstance(subject, str) - and 1 <= len(subject.encode("utf-8")) <= 200 - ) - if not valid: + digest = None + try: + if ( + type(issuer) is str + and issuer.startswith("https://") + and 1 <= len(issuer.encode("utf-8")) <= 500 + and type(subject) is str + and 1 <= len(subject.encode("utf-8")) <= 200 + ): + digest = canonical_json_hash({"issuer": issuer, "subject": subject}) + except Exception: # noqa: BLE001 - verified identity text must not escape diagnostics + digest = None + if digest is None: raise TypeError("invalid verified service identity") - return canonical_json_hash({"issuer": issuer, "subject": subject}) + return digest def derive_service_profile_digest(display_name: object, grant_reason: object) -> str: """Derive an internal digest from bounded service profile request facts.""" - valid = ( - isinstance(display_name, str) - and 1 <= len(display_name.encode("utf-8")) <= 200 - and isinstance(grant_reason, str) - and 1 <= len(grant_reason.encode("utf-8")) <= 500 - ) - if not valid: + digest = None + try: + if ( + type(display_name) is str + and 1 <= len(display_name.encode("utf-8")) <= 200 + and type(grant_reason) is str + and 1 <= len(grant_reason.encode("utf-8")) <= 500 + ): + digest = canonical_json_hash( + {"display_name": display_name, "grant_reason": grant_reason} + ) + except Exception: # noqa: BLE001 - raw profile text must not escape diagnostics + digest = None + if digest is None: raise TypeError("invalid service profile request") - return canonical_json_hash({"display_name": display_name, "grant_reason": grant_reason}) + return digest class AuthorityResponseReference(BaseModel): @@ -315,7 +366,6 @@ class AuthorityMismatchContext(BaseModel): event_id: UUID request_id: UUID correlation_id: UUID - project_id: UUID | None = None class AuthorityCompletionResult(BaseModel): diff --git a/backend/app/modules/authorization/service.py b/backend/app/modules/authorization/service.py index 4dc74067..af8d15af 100644 --- a/backend/app/modules/authorization/service.py +++ b/backend/app/modules/authorization/service.py @@ -16,7 +16,13 @@ from app.modules.audit.service import AuditService from app.modules.authorization.repository import AuthorityIdempotencyRepository from app.modules.authorization.schemas import ( + ActorIdentityLinkReactivateRequest, + ActorIdentityLinkRevokeRequest, + ActorProfileDeactivateRequest, + ActorProfileReactivateRequest, + ActorProfileSuspendRequest, AdminRoleGrantIssueRequest, + AdminRoleGrantRevokeRequest, AuthorityClaimHandle, AuthorityCompletionResult, AuthorityInvalidationContext, @@ -27,6 +33,8 @@ AuthorityResourceType, AuthorityResponseReference, ProjectRoleGrantIssueRequest, + ProjectRoleGrantRevokeRequest, + ServiceActorCreateRequest, parse_authority_request, ) @@ -153,10 +161,15 @@ async def complete( and success.actor_ref == claim.actor_ref and success.idempotency_reference == claim.record_id and success.permission_id == spec.permission + and success.entity_type == spec.resource_type.value + and success.entity_id == str(response.resource_id) and success.resource_type == spec.resource_type.value and success.resource_id == str(response.resource_id) + and success.target_ref_kind == spec.resource_type.value + and success.target_ref_id == str(response.resource_id) and response.resource_type == spec.resource_type and response.http_status == spec.http_status + and _request_matches_success(mutation, response, success) and success.request_id == invalidation.request_id and success.correlation_id == invalidation.correlation_id ) @@ -203,7 +216,7 @@ async def record_mismatch_denial( """Append privacy-safe mismatch evidence in a caller-started clean transaction.""" mutation: AuthorityMutationRequest = parse_authority_request(request) spec = _EVIDENCE[mutation.operation] - resource_id = _request_resource_id(mutation) + resource_id = _existing_request_resource_id(mutation) event = AuthorityAuditEventInput( event_id=context.event_id, event_type=AuthorityEventType.SENSITIVE_AUTHORIZATION_DENIED, @@ -214,7 +227,7 @@ async def record_mismatch_denial( request_id=context.request_id, correlation_id=context.correlation_id, permission_id=spec.permission, - project_id=str(context.project_id) if context.project_id else None, + project_id=_request_project_id(mutation), resource_type=spec.resource_type.value if resource_id else None, resource_id=str(resource_id) if resource_id else None, reason="authorization_evaluation", @@ -225,12 +238,67 @@ async def record_mismatch_denial( return context.event_id -def _request_resource_id(request: AuthorityMutationRequest) -> UUID | None: - """Return only an existing mutation target; creates intentionally return none.""" +def _existing_request_resource_id(request: AuthorityMutationRequest) -> UUID | None: + """Return an existing mutated resource; issue/create operations return none.""" for field in ("grant_id", "actor_profile_id", "identity_link_id"): value = getattr(request, field, None) if value is not None: return value + return None + + +def _request_project_id(request: AuthorityMutationRequest) -> str | None: + """Derive project audit scope only from the canonical request.""" if isinstance(request, AdminRoleGrantIssueRequest): - return request.target_actor_id - return getattr(request, "project_id", None) + project_id = request.scope_project_id + else: + project_id = getattr(request, "project_id", None) + return str(project_id) if project_id is not None else None + + +def _request_matches_success( + request: AuthorityMutationRequest, + response: AuthorityResponseReference, + success: AuthorityAuditEventInput, +) -> bool: + """Bind the canonical request target and scope to its concrete success evidence.""" + resource_id = response.resource_id + if isinstance(request, ServiceActorCreateRequest): + return success.project_id is None and success.target_actor_ref is None + if isinstance(request, AdminRoleGrantIssueRequest): + facts = success.after_facts or {} + return ( + success.target_actor_ref_kind == ActorReferenceKind.ACTOR_PROFILE + and success.target_actor_ref == str(request.target_actor_id) + and success.project_id + == (str(request.scope_project_id) if request.scope_project_id else None) + and success.matched_grant_id is None + and facts.get("role") == request.role.value + and facts.get("scope_type") == request.scope_type.value + and facts.get("scope_id") + == (str(request.scope_project_id) if request.scope_project_id else None) + ) + if isinstance(request, AdminRoleGrantRevokeRequest): + return resource_id == request.grant_id + if isinstance(request, ProjectRoleGrantIssueRequest): + facts = success.after_facts or {} + return ( + success.target_actor_ref_kind == ActorReferenceKind.ACTOR_PROFILE + and success.target_actor_ref == str(request.target_actor_id) + and success.project_id == str(request.project_id) + and success.matched_grant_id + == (str(request.replaced_grant_id) if request.replaced_grant_id else None) + and facts.get("role") == request.role.value + and facts.get("scope_type") == "project" + and facts.get("scope_id") == str(request.project_id) + ) + if isinstance(request, ProjectRoleGrantRevokeRequest): + return resource_id == request.grant_id and success.project_id == str(request.project_id) + if isinstance( + request, + (ActorProfileSuspendRequest, ActorProfileReactivateRequest, ActorProfileDeactivateRequest), + ): + return resource_id == request.actor_profile_id and success.project_id is None + if isinstance(request, (ActorIdentityLinkRevokeRequest, ActorIdentityLinkReactivateRequest)): + return resource_id == request.identity_link_id and success.project_id is None + return False diff --git a/backend/tests/test_alembic.py b/backend/tests/test_alembic.py index 5d977ecc..3f0ef6d8 100644 --- a/backend/tests/test_alembic.py +++ b/backend/tests/test_alembic.py @@ -535,6 +535,9 @@ def test_authority_idempotency_schema_preserves_audit_and_guards_downgrade( isolated_database_env, record_id, actor_id, target_id ) ) + immutable = asyncio.run( + _authority_idempotency_immutable_writes(isolated_database_env, record_id) + ) with pytest.raises(RuntimeError, match="non-empty authority idempotency"): command.downgrade(config, "0018_authority_audit_evidence") refused = asyncio.run(_authority_idempotency_state(isolated_database_env, orphan_event)) @@ -543,7 +546,9 @@ def test_authority_idempotency_schema_preserves_audit_and_guards_downgrade( isolated_database_env, record_id, orphan_event=None ) ) - command.downgrade(config, "0018_authority_audit_evidence") + downgrade_lock_observed = _authority_downgrade_waits_for_writer( + config, isolated_database_env + ) preserved = asyncio.run(_authority_idempotency_state(isolated_database_env, orphan_event)) command.upgrade(config, "0019_authority_idempotency") restored = asyncio.run(_authority_idempotency_schema(isolated_database_env)) @@ -588,6 +593,13 @@ def test_authority_idempotency_schema_preserves_audit_and_guards_downgrade( "audit_trigger": True, } assert invalid == {"initial_committed": True, "pending_commit": True, "new_orphan": True} + assert immutable == { + "update": True, + "delete": True, + "truncate": True, + "database_timestamps": True, + } + assert downgrade_lock_observed is True assert refused == {"revision": "0019_authority_idempotency", "records": 1, "orphan": 1} assert preserved == {"revision": "0018_authority_audit_evidence", "records": None, "orphan": 1} @@ -2600,11 +2612,12 @@ async def _insert_committed_authority_idempotency( await connection.execute( text( f"insert into audit_events (id,entity_type,entity_id,event_type,actor_id,{common}," - "before_facts,after_facts) values (:id,'actor_profile',:target," + "target_ref_kind,target_ref_id,before_facts,after_facts) values " + "(:id,'actor_profile',:target," "'ActorProfileSuspended',:actor,'[]','{}','local_authority',false,'{}'," "'authority',1,'actor_profile',:request,:correlation,'actor.profile.suspend'," "'actor_profile',:target,'security_response',:record," - "cast(:before_facts as json),cast(:after_facts as json))" + "'actor_profile',:target,cast(:before_facts as json),cast(:after_facts as json))" ), { "id": success_id, @@ -2675,6 +2688,99 @@ async def _authority_idempotency_state(database_url: str, orphan_event: str) -> await engine.dispose() +async def _authority_idempotency_immutable_writes( + database_url: str, record_id: str +) -> dict[str, bool]: + """Prove committed rows are immutable and carry database-owned timestamps.""" + engine = create_async_engine(database_url) + results: dict[str, bool] = {} + try: + statements = { + "update": "update authority_idempotency_records set response_http_status=200 where id=:id", + "delete": "delete from authority_idempotency_records where id=:id", + "truncate": "truncate authority_idempotency_records", + } + for name, statement in statements.items(): + try: + async with engine.begin() as connection: + await connection.execute(text(statement), {"id": record_id}) + except DBAPIError: + results[name] = True + else: + results[name] = False + async with engine.connect() as connection: + results["database_timestamps"] = bool( + await connection.scalar( + text( + "select created_at is not null and committed_at is not null " + "and committed_at >= created_at from authority_idempotency_records " + "where id=:id" + ), + {"id": record_id}, + ) + ) + return results + finally: + await engine.dispose() + + +def _authority_downgrade_waits_for_writer(config: Config, database_url: str) -> bool: + """Observe downgrade waiting for the deterministic writer-blocking table lock.""" + writer_ready = threading.Event() + release_writer = threading.Event() + + async def hold_writer_lock() -> None: + engine = create_async_engine(database_url) + try: + async with engine.connect() as connection: + transaction = await connection.begin() + await connection.execute( + text("lock table authority_idempotency_records in row exclusive mode") + ) + writer_ready.set() + await asyncio.to_thread(release_writer.wait) + await transaction.rollback() + finally: + await engine.dispose() + + async def observe_downgrade_lock() -> bool: + engine = create_async_engine(database_url) + try: + async with engine.connect() as connection: + for _ in range(5000): + waiting = await connection.scalar( + text( + "select exists(select 1 from pg_locks locks " + "join pg_class relation on relation.oid=locks.relation " + "where relation.relname='authority_idempotency_records' " + "and locks.mode='AccessExclusiveLock' and not locks.granted)" + ) + ) + if waiting: + return True + await asyncio.sleep(0) + return False + finally: + await engine.dispose() + + with ThreadPoolExecutor(max_workers=2) as executor: + writer = executor.submit(asyncio.run, hold_writer_lock()) + if not writer_ready.wait(timeout=5): + release_writer.set() + writer.result(timeout=5) + return False + downgrade = executor.submit( + command.downgrade, config, "0018_authority_audit_evidence" + ) + try: + observed = asyncio.run(observe_downgrade_lock()) + finally: + release_writer.set() + writer.result(timeout=10) + downgrade.result(timeout=10) + return observed + + async def _remove_authority_idempotency_fixture( database_url: str, record_id: str, *, orphan_event: str | None ) -> None: @@ -2688,6 +2794,10 @@ async def _remove_authority_idempotency_fixture( "table_name='authority_idempotency_records')" ) ) + if exists: + await connection.execute( + text("lock table authority_idempotency_records in access exclusive mode") + ) await connection.execute(text("lock table audit_events in access exclusive mode")) await connection.execute( text("alter table audit_events disable trigger audit_events_reject_update_delete") @@ -2704,9 +2814,6 @@ async def _remove_authority_idempotency_fixture( text("alter table audit_events enable trigger audit_events_reject_update_delete") ) if exists: - await connection.execute( - text("lock table authority_idempotency_records in access exclusive mode") - ) await connection.execute( text( "alter table authority_idempotency_records disable trigger " diff --git a/backend/tests/test_authorization.py b/backend/tests/test_authorization.py index d73edcf5..d162948e 100644 --- a/backend/tests/test_authorization.py +++ b/backend/tests/test_authorization.py @@ -10,10 +10,11 @@ from alembic.config import Config import pytest from sqlalchemy import text -from sqlalchemy.exc import DBAPIError +from sqlalchemy.exc import DBAPIError, IntegrityError from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine from app.modules.audit.schemas import ActorReferenceKind, AuthorityAuditEventInput, AuthorityEventType +from app.modules.audit.service import AuditService from app.modules.authorization.schemas import ( ActorIdentityLinkReactivateRequest, ActorIdentityLinkRevokeRequest, @@ -116,6 +117,158 @@ def _success( ) +def _operation_success( + claim: AuthorityClaimHandle, + request, + response: AuthorityResponseReference, +) -> AuthorityAuditEventInput: + """Build the exact concrete success evidence for one canonical operation case.""" + event, permission, reason = { + AuthorityOperation.SERVICE_ACTOR_CREATE: ( + AuthorityEventType.SERVICE_ACTOR_PROVISIONED, + "actor.service.provision", + "manual_service_provisioning", + ), + AuthorityOperation.ADMIN_ROLE_GRANT_ISSUE: ( + AuthorityEventType.ADMIN_ROLE_GRANT_ISSUED, + "admin_role.grant", + "authority_assignment", + ), + AuthorityOperation.ADMIN_ROLE_GRANT_REVOKE: ( + AuthorityEventType.ADMIN_ROLE_GRANT_REVOKED, + "admin_role.revoke", + "authority_revocation", + ), + AuthorityOperation.PROJECT_ROLE_GRANT_ISSUE: ( + AuthorityEventType.PROJECT_ROLE_GRANT_REPLACED + if getattr(request, "replaced_grant_id", None) + else AuthorityEventType.PROJECT_ROLE_GRANT_ISSUED, + "project.role_grant.manage", + "authority_replacement" + if getattr(request, "replaced_grant_id", None) + else "authority_assignment", + ), + AuthorityOperation.PROJECT_ROLE_GRANT_REVOKE: ( + AuthorityEventType.PROJECT_ROLE_GRANT_REVOKED, + "project.role_grant.manage", + "authority_revocation", + ), + AuthorityOperation.ACTOR_PROFILE_SUSPEND: ( + AuthorityEventType.ACTOR_PROFILE_SUSPENDED, + "actor.profile.suspend", + "security_response", + ), + AuthorityOperation.ACTOR_PROFILE_REACTIVATE: ( + AuthorityEventType.ACTOR_PROFILE_REACTIVATED, + "actor.profile.reactivate", + "administrative_correction", + ), + AuthorityOperation.ACTOR_PROFILE_DEACTIVATE: ( + AuthorityEventType.ACTOR_PROFILE_DEACTIVATED, + "actor.profile.deactivate", + "security_response", + ), + AuthorityOperation.ACTOR_IDENTITY_LINK_REVOKE: ( + AuthorityEventType.ACTOR_IDENTITY_LINK_REVOKED, + "actor.identity_link.revoke", + "identity_lifecycle_change", + ), + AuthorityOperation.ACTOR_IDENTITY_LINK_REACTIVATE: ( + AuthorityEventType.ACTOR_IDENTITY_LINK_REACTIVATED, + "actor.identity_link.reactivate", + "identity_lifecycle_change", + ), + }[request.operation] + before_facts = None + after_facts = None + project_id = None + target_actor = None + matched_grant = None + if isinstance(request, ServiceActorCreateRequest): + after_facts = { + "status": "active", + "subject_kind": "service", + "provisioning_method": "manual_service_provisioning", + } + elif isinstance(request, AdminRoleGrantIssueRequest): + target_actor = request.target_actor_id + project_id = request.scope_project_id + after_facts = { + "status": "active", + "role": request.role.value, + "scope_type": request.scope_type.value, + "effective": True, + } + if project_id: + after_facts["scope_id"] = str(project_id) + elif isinstance(request, AdminRoleGrantRevokeRequest): + before_facts = { + "status": "active", + "role": "access_administrator", + "scope_type": "system", + "effective": True, + } + after_facts = before_facts | {"status": "revoked", "effective": False} + elif isinstance(request, ProjectRoleGrantIssueRequest): + project_id = request.project_id + target_actor = request.target_actor_id + matched_grant = request.replaced_grant_id + after_facts = { + "status": "active", + "role": request.role.value, + "scope_type": "project", + "scope_id": str(project_id), + "effective": True, + } + if matched_grant: + before_facts = after_facts | {"role": "reviewer"} + elif isinstance(request, ProjectRoleGrantRevokeRequest): + project_id = request.project_id + before_facts = { + "status": "active", + "role": "submitter", + "scope_type": "project", + "scope_id": str(project_id), + "effective": True, + } + after_facts = before_facts | {"status": "revoked", "effective": False} + elif isinstance(request, ActorProfileSuspendRequest): + before_facts, after_facts = {"status": "active"}, {"status": "suspended"} + elif isinstance(request, ActorProfileReactivateRequest): + before_facts, after_facts = {"status": "suspended"}, {"status": "active"} + elif isinstance(request, ActorProfileDeactivateRequest): + before_facts, after_facts = {"status": "active"}, {"status": "deactivated"} + elif isinstance(request, ActorIdentityLinkRevokeRequest): + before_facts, after_facts = {"status": "active"}, {"status": "revoked"} + elif isinstance(request, ActorIdentityLinkReactivateRequest): + before_facts, after_facts = {"status": "revoked"}, {"status": "active"} + + event_id = uuid4() + return AuthorityAuditEventInput( + event_id=event_id, + event_type=event, + entity_type=response.resource_type.value, + entity_id=str(response.resource_id), + actor_ref_kind=claim.actor_ref_kind, + actor_ref=claim.actor_ref, + request_id=uuid4(), + correlation_id=uuid4(), + target_actor_ref_kind=ActorReferenceKind.ACTOR_PROFILE if target_actor else None, + target_actor_ref=str(target_actor) if target_actor else None, + matched_grant_id=str(matched_grant) if matched_grant else None, + permission_id=permission, + project_id=str(project_id) if project_id else None, + resource_type=response.resource_type.value, + resource_id=str(response.resource_id), + target_ref_kind=response.resource_type.value, + target_ref_id=str(response.resource_id), + reason=reason, + idempotency_reference=claim.record_id, + before_facts=before_facts, + after_facts=after_facts, + ) + + async def _claim(service: AuthorityMutationService, actor: UUID, key: UUID, request): result = await service.reserve( idempotency_key=key, @@ -182,6 +335,187 @@ async def test_claim_completion_and_exact_replay_have_one_evidence_pair( assert counts == [("ActorProfileSuspended", 1), ("AuthorityInvalidationRequested", 1)] +@pytest.mark.asyncio +async def test_committed_record_rejects_additional_success_and_invalidation( + authorization_factory, +) -> None: + actor, key, request = uuid4(), uuid4(), _request() + async with authorization_factory() as session: + service = AuthorityMutationService(session) + claim = await _claim(service, actor, key, request) + completed = await _complete(service, claim, request) + await session.commit() + + async with authorization_factory() as session: + service = AuthorityMutationService(session) + extra_success = _success(claim, request) + with pytest.raises(IntegrityError, match="committed authority idempotency is closed"): + await service.complete( + claim=claim, + request=request.model_dump(), + response=completed.response, + success=extra_success, + invalidation=AuthorityInvalidationContext( + event_id=uuid4(), + request_id=extra_success.request_id, + correlation_id=extra_success.correlation_id, + ), + ) + await session.rollback() + + async with authorization_factory() as session: + cause_id = await session.scalar( + text( + "select id from audit_events where idempotency_reference=:id " + "and event_type='ActorProfileSuspended'" + ), + {"id": claim.record_id}, + ) + context_id, request_id, correlation_id = uuid4(), uuid4(), uuid4() + invalidation = AuthorityAuditEventInput( + event_id=context_id, + event_type=AuthorityEventType.AUTHORITY_INVALIDATION_REQUESTED, + entity_type="authority_invalidation", + entity_id=str(context_id), + actor_ref_kind=claim.actor_ref_kind, + actor_ref=claim.actor_ref, + request_id=request_id, + correlation_id=correlation_id, + permission_id="actor.profile.suspend", + resource_type="actor_profile", + resource_id=str(request.actor_profile_id), + reason="authority_state_changed", + idempotency_reference=claim.record_id, + invalidation_cause_event_id=UUID(cause_id), + invalidation_target_kind="actor_profile", + invalidation_target_ref=str(request.actor_profile_id), + before_facts={"effective": True}, + after_facts={"effective": False}, + ) + with pytest.raises(IntegrityError, match="committed authority idempotency is closed"): + await AuditService(session).add_authority_event(invalidation) + await session.rollback() + + async with authorization_factory() as session: + assert await session.scalar( + text("select count(*) from audit_events where idempotency_reference=:id"), + {"id": claim.record_id}, + ) == 2 + + +@pytest.mark.asyncio +async def test_completion_rejects_resource_and_project_not_bound_to_request( + authorization_factory, +) -> None: + actor, key, request = uuid4(), uuid4(), _request() + wrong_request = _request() + async with authorization_factory() as session: + service = AuthorityMutationService(session) + claim = await _claim(service, actor, key, request) + wrong_success = _success(claim, wrong_request) + with pytest.raises(TypeError, match="invalid authority completion input"): + await service.complete( + claim=claim, + request=request.model_dump(), + response=AuthorityResponseReference( + resource_type=AuthorityResourceType.ACTOR_PROFILE, + resource_id=wrong_request.actor_profile_id, + http_status=200, + ), + success=wrong_success, + invalidation=AuthorityInvalidationContext( + event_id=uuid4(), + request_id=wrong_success.request_id, + correlation_id=wrong_success.correlation_id, + ), + ) + await session.rollback() + + project_request = ProjectRoleGrantIssueRequest( + operation=AuthorityOperation.PROJECT_ROLE_GRANT_ISSUE, + project_id=uuid4(), + target_actor_id=uuid4(), + role=ProjectRole.SUBMITTER, + reason_digest=DIGEST, + ) + wrong_project = project_request.model_copy(update={"project_id": uuid4()}) + async with authorization_factory() as session: + service = AuthorityMutationService(session) + claim = await _claim(service, actor, uuid4(), project_request) + response = AuthorityResponseReference( + resource_type=AuthorityResourceType.PROJECT_ROLE_GRANT, + resource_id=uuid4(), + http_status=201, + ) + success = _operation_success(claim, wrong_project, response) + with pytest.raises(TypeError, match="invalid authority completion input"): + await service.complete( + claim=claim, + request=project_request.model_dump(), + response=response, + success=success, + invalidation=AuthorityInvalidationContext( + event_id=uuid4(), + request_id=success.request_id, + correlation_id=success.correlation_id, + ), + ) + await session.rollback() + + +@pytest.mark.asyncio +async def test_database_rejects_cross_actor_entity_and_cause_context_bypasses( + authorization_factory, +) -> None: + actor, request = uuid4(), _request() + async with authorization_factory() as session: + service = AuthorityMutationService(session) + claim = await _claim(service, actor, uuid4(), request) + cross_actor = _success(claim, request).model_copy(update={"actor_ref": str(uuid4())}) + with pytest.raises(IntegrityError, match="idempotency reference"): + await AuditService(session).add_authority_event(cross_actor) + await session.rollback() + + async with authorization_factory() as session: + service = AuthorityMutationService(session) + claim = await _claim(service, actor, uuid4(), request) + wrong_entity = _success(claim, request).model_copy( + update={"entity_type": "admin_role_grant"} + ) + with pytest.raises(IntegrityError, match="success event does not match operation"): + await AuditService(session).add_authority_event(wrong_entity) + await session.rollback() + + async with authorization_factory() as session: + service = AuthorityMutationService(session) + claim = await _claim(service, actor, uuid4(), request) + success = _success(claim, request) + await AuditService(session).add_authority_event(success) + event_id = uuid4() + wrong_context = AuthorityAuditEventInput( + event_id=event_id, + event_type=AuthorityEventType.AUTHORITY_INVALIDATION_REQUESTED, + entity_type="authority_invalidation", + entity_id=str(event_id), + actor_ref_kind=claim.actor_ref_kind, + actor_ref=claim.actor_ref, + request_id=uuid4(), + correlation_id=success.correlation_id, + permission_id="actor.profile.suspend", + resource_type="actor_profile", + resource_id=str(request.actor_profile_id), + reason="authority_state_changed", + idempotency_reference=claim.record_id, + invalidation_cause_event_id=success.event_id, + invalidation_target_kind="actor_profile", + invalidation_target_ref=str(request.actor_profile_id), + before_facts={"effective": True}, + after_facts={"effective": False}, + ) + with pytest.raises(IntegrityError, match="invalid linked authority cause"): + await AuditService(session).add_authority_event(wrong_context) + await session.rollback() + @pytest.mark.asyncio async def test_pending_commit_fails_and_rollback_allows_retry(authorization_factory) -> None: actor, key, request = uuid4(), uuid4(), _request() @@ -227,11 +561,13 @@ async def test_mismatch_is_private_and_denial_uses_clean_transaction(authorizati actor, key, request = uuid4(), uuid4(), _request() async with authorization_factory() as session: service = AuthorityMutationService(session) - await _complete(service, await _claim(service, actor, key, request), request) + claim = await _claim(service, actor, key, request) + await _complete(service, claim, request) await session.commit() different = _request() async with authorization_factory() as session: - result = await AuthorityMutationService(session).reserve( + service = AuthorityMutationService(session) + result = await service.reserve( idempotency_key=key, actor_ref_kind=ActorReferenceKind.ACTOR_PROFILE, actor_ref=str(actor), @@ -240,8 +576,6 @@ async def test_mismatch_is_private_and_denial_uses_clean_transaction(authorizati assert isinstance(result, MismatchedReservation) assert result.model_dump() == {"outcome": "mismatch"} await session.rollback() - async with authorization_factory() as session: - service = AuthorityMutationService(session) denial_id = await service.record_mismatch_denial( actor_ref_kind=ActorReferenceKind.ACTOR_PROFILE, actor_ref=str(actor), @@ -251,10 +585,24 @@ async def test_mismatch_is_private_and_denial_uses_clean_transaction(authorizati ), ) await session.commit() - denial = await session.scalar( - text("select denial_code from audit_events where id=:id"), {"id": str(denial_id)} + denial = ( + await session.execute( + text( + "select denial_code, idempotency_reference, resource_type, resource_id, " + "count(*) over () from audit_events where id=:id" + ), + {"id": str(denial_id)}, + ) + ).one() + linked = await session.scalar( + text( + "select count(*) from audit_events where idempotency_reference=:id " + "and event_type in ('ActorProfileSuspended','AuthorityInvalidationRequested')" + ), + {"id": claim.record_id}, ) - assert denial == "idempotency_mismatch" + assert denial == ("idempotency_mismatch", None, "actor_profile", str(different.actor_profile_id), 1) + assert linked == 2 def test_request_admission_is_frozen_bounded_and_nonretaining() -> None: @@ -277,6 +625,38 @@ def test_request_admission_is_frozen_bounded_and_nonretaining() -> None: assert caught.value.__cause__ is None assert caught.value.__context__ is None + for construct in ( + lambda: ActorProfileSuspendRequest( + operation=AuthorityOperation.ACTOR_PROFILE_SUSPEND, + actor_profile_id=uuid4(), + reason_digest=secret, + ), + lambda: ActorProfileSuspendRequest.model_validate( + { + "operation": AuthorityOperation.ACTOR_PROFILE_SUSPEND, + "actor_profile_id": uuid4(), + "reason_digest": secret, + } + ), + lambda: ActorProfileSuspendRequest.model_validate_json( + '{"operation":"actor_profile.suspend","actor_profile_id":"' + + str(uuid4()) + + '","reason_digest":"' + + secret + + '"}' + ), + lambda: derive_reason_digest("\ud800" + secret), + lambda: derive_service_identity_digest("https://identity.example", "\ud800" + secret), + lambda: derive_service_profile_digest("\ud800" + secret, "approved"), + ): + with pytest.raises(TypeError) as caught: + construct() + assert secret not in str(caught.value) + assert secret not in repr(caught.value.args) + assert secret not in repr(caught.value.__dict__) + assert caught.value.__cause__ is None + assert caught.value.__context__ is None + class _ChangingMapping(Mapping): def __init__(self, first: dict, second: dict) -> None: @@ -368,6 +748,179 @@ def test_every_operation_has_one_strict_canonical_request_variant() -> None: ) +@pytest.mark.asyncio +async def test_all_operation_and_replacement_mappings_commit_one_linked_pair( + authorization_factory, +) -> None: + project, actor, resource = uuid4(), uuid4(), uuid4() + requests = [ + ServiceActorCreateRequest( + operation=AuthorityOperation.SERVICE_ACTOR_CREATE, + identity_reference_digest=derive_service_identity_digest( + "https://identity.flowresearch.tech", "opaque-service-subject" + ), + profile_payload_digest=derive_service_profile_digest("Adapter", "Approved"), + ), + AdminRoleGrantIssueRequest( + operation=AuthorityOperation.ADMIN_ROLE_GRANT_ISSUE, + target_actor_id=actor, + role=AdminRole.PROJECT_MANAGER, + scope_type=AdminScope.PROJECT, + scope_project_id=project, + reason_digest=DIGEST, + ), + AdminRoleGrantRevokeRequest( + operation=AuthorityOperation.ADMIN_ROLE_GRANT_REVOKE, + grant_id=resource, + reason_digest=DIGEST, + ), + ProjectRoleGrantIssueRequest( + operation=AuthorityOperation.PROJECT_ROLE_GRANT_ISSUE, + project_id=project, + target_actor_id=actor, + role=ProjectRole.SUBMITTER, + reason_digest=DIGEST, + ), + ProjectRoleGrantIssueRequest( + operation=AuthorityOperation.PROJECT_ROLE_GRANT_ISSUE, + project_id=project, + target_actor_id=actor, + role=ProjectRole.SUBMITTER, + replaced_grant_id=resource, + reason_digest=DIGEST, + ), + ProjectRoleGrantRevokeRequest( + operation=AuthorityOperation.PROJECT_ROLE_GRANT_REVOKE, + project_id=project, + grant_id=resource, + reason_digest=DIGEST, + ), + ActorProfileSuspendRequest( + operation=AuthorityOperation.ACTOR_PROFILE_SUSPEND, + actor_profile_id=resource, + reason_digest=DIGEST, + ), + ActorProfileReactivateRequest( + operation=AuthorityOperation.ACTOR_PROFILE_REACTIVATE, + actor_profile_id=resource, + reason_digest=DIGEST, + ), + ActorProfileDeactivateRequest( + operation=AuthorityOperation.ACTOR_PROFILE_DEACTIVATE, + actor_profile_id=resource, + reason_digest=DIGEST, + ), + ActorIdentityLinkRevokeRequest( + operation=AuthorityOperation.ACTOR_IDENTITY_LINK_REVOKE, + identity_link_id=resource, + reason_digest=DIGEST, + ), + ActorIdentityLinkReactivateRequest( + operation=AuthorityOperation.ACTOR_IDENTITY_LINK_REACTIVATE, + identity_link_id=resource, + reason_digest=DIGEST, + ), + ] + resource_types = { + AuthorityOperation.SERVICE_ACTOR_CREATE: AuthorityResourceType.ACTOR_PROFILE, + AuthorityOperation.ADMIN_ROLE_GRANT_ISSUE: AuthorityResourceType.ADMIN_ROLE_GRANT, + AuthorityOperation.ADMIN_ROLE_GRANT_REVOKE: AuthorityResourceType.ADMIN_ROLE_GRANT, + AuthorityOperation.PROJECT_ROLE_GRANT_ISSUE: AuthorityResourceType.PROJECT_ROLE_GRANT, + AuthorityOperation.PROJECT_ROLE_GRANT_REVOKE: AuthorityResourceType.PROJECT_ROLE_GRANT, + AuthorityOperation.ACTOR_PROFILE_SUSPEND: AuthorityResourceType.ACTOR_PROFILE, + AuthorityOperation.ACTOR_PROFILE_REACTIVATE: AuthorityResourceType.ACTOR_PROFILE, + AuthorityOperation.ACTOR_PROFILE_DEACTIVATE: AuthorityResourceType.ACTOR_PROFILE, + AuthorityOperation.ACTOR_IDENTITY_LINK_REVOKE: AuthorityResourceType.ACTOR_IDENTITY_LINK, + AuthorityOperation.ACTOR_IDENTITY_LINK_REACTIVATE: AuthorityResourceType.ACTOR_IDENTITY_LINK, + } + create_operations = { + AuthorityOperation.SERVICE_ACTOR_CREATE, + AuthorityOperation.ADMIN_ROLE_GRANT_ISSUE, + AuthorityOperation.PROJECT_ROLE_GRANT_ISSUE, + } + record_ids = [] + async with authorization_factory() as session: + service = AuthorityMutationService(session) + for request in requests: + claim = await _claim(service, uuid4(), uuid4(), request) + response_id = ( + uuid4() + if request.operation in create_operations + else getattr( + request, + "grant_id", + getattr(request, "actor_profile_id", getattr(request, "identity_link_id", None)), + ) + ) + response = AuthorityResponseReference( + resource_type=resource_types[request.operation], + resource_id=response_id, + version=1, + http_status=201 if request.operation in create_operations else 200, + ) + success = _operation_success(claim, request, response) + completed = await service.complete( + claim=claim, + request=request.model_dump(), + response=response, + success=success, + invalidation=AuthorityInvalidationContext( + event_id=uuid4(), + request_id=success.request_id, + correlation_id=success.correlation_id, + ), + ) + assert completed.response == response + record_ids.append(claim.record_id) + await session.commit() + counts = ( + await session.execute( + text( + "select idempotency_reference, count(*) from audit_events " + "where idempotency_reference = any(:records) " + "group by idempotency_reference" + ), + {"records": record_ids}, + ) + ).all() + assert len(counts) == len(requests) + assert {count for _, count in counts} == {2} + + +@pytest.mark.asyncio +async def test_issue_mismatch_derives_project_and_omits_nonexistent_grant_resource( + authorization_factory, +) -> None: + project = uuid4() + request = ProjectRoleGrantIssueRequest( + operation=AuthorityOperation.PROJECT_ROLE_GRANT_ISSUE, + project_id=project, + target_actor_id=uuid4(), + role=ProjectRole.REVIEWER, + reason_digest=DIGEST, + ) + context = AuthorityMismatchContext( + event_id=uuid4(), request_id=uuid4(), correlation_id=uuid4() + ) + async with authorization_factory() as session: + event_id = await AuthorityMutationService(session).record_mismatch_denial( + actor_ref_kind=ActorReferenceKind.ACTOR_PROFILE, + actor_ref=str(uuid4()), + request=request.model_dump(), + context=context, + ) + await session.commit() + row = ( + await session.execute( + text( + "select project_id, resource_type, resource_id from audit_events where id=:id" + ), + {"id": str(event_id)}, + ) + ).one() + assert row == (str(project), None, None) + + @pytest.mark.asyncio async def test_failure_after_evidence_flush_rolls_back_claim_and_events( authorization_factory, monkeypatch @@ -376,6 +929,17 @@ async def test_failure_after_evidence_flush_rolls_back_claim_and_events( async with authorization_factory() as session: service = AuthorityMutationService(session) claim = await _claim(service, actor, key, request) + synthetic_id = uuid4() + await service.record_mismatch_denial( + actor_ref_kind=claim.actor_ref_kind, + actor_ref=claim.actor_ref, + request=request.model_dump(), + context=AuthorityMismatchContext( + event_id=synthetic_id, + request_id=uuid4(), + correlation_id=uuid4(), + ), + ) async def fail_completion(*_args, **_kwargs): raise RuntimeError("injected completion failure") @@ -393,6 +957,10 @@ async def fail_completion(*_args, **_kwargs): text("select count(*) from audit_events where idempotency_reference=:id"), {"id": claim.record_id}, ) == 0 + assert await session.scalar( + text("select count(*) from audit_events where id=:id"), + {"id": str(synthetic_id)}, + ) == 0 async def _wait_for_database_lock(database_url: str, application_name: str) -> None: @@ -474,22 +1042,37 @@ async def lose(candidate): @pytest.mark.asyncio -async def test_same_key_is_isolated_by_actor_reference_kind(authorization_factory) -> None: +async def test_same_key_is_isolated_independently_by_actor_and_reference_kind( + authorization_factory, +) -> None: key, request = uuid4(), _request() - async with authorization_factory() as first, authorization_factory() as second: + actor = str(uuid4()) + async with ( + authorization_factory() as first, + authorization_factory() as second, + authorization_factory() as third, + ): one = await AuthorityMutationService(first).reserve( idempotency_key=key, actor_ref_kind=ActorReferenceKind.ACTOR_PROFILE, - actor_ref=str(uuid4()), + actor_ref=actor, request=request.model_dump(), ) two = await AuthorityMutationService(second).reserve( idempotency_key=key, actor_ref_kind=ActorReferenceKind.LEGACY_ACTOR, + actor_ref=actor, + request=request.model_dump(), + ) + three = await AuthorityMutationService(third).reserve( + idempotency_key=key, + actor_ref_kind=ActorReferenceKind.ACTOR_PROFILE, actor_ref=str(uuid4()), request=request.model_dump(), ) assert isinstance(one, ClaimedReservation) assert isinstance(two, ClaimedReservation) + assert isinstance(three, ClaimedReservation) await first.rollback() await second.rollback() + await third.rollback() diff --git a/docs/architecture_data_model.md b/docs/architecture_data_model.md index dd3735ce..4cafb9c8 100644 --- a/docs/architecture_data_model.md +++ b/docs/architecture_data_model.md @@ -121,7 +121,8 @@ external disclosure. New linked audit rows use an actor-bound composite foreign key while the `NOT VALID` migration preserves pre-foundation forward references. Every committed authority mutation has exactly one concrete success event and one causally linked `AuthorityInvalidationRequested` event in `audit_events`. -This foundation persists the request only; no cache, queue, background job processor, or consumer +This foundation persists the request digest and typed replay reference only; no cache, queue, +background job processor, or consumer acts on invalidation yet. ### API Rate Control Counter From 4cb19847099761953e65da90a4bb4cb154397900 Mon Sep 17 00:00:00 2001 From: Abiorh001 Date: Tue, 14 Jul 2026 20:48:57 +0100 Subject: [PATCH 4/6] docs(auth): record idempotency review evidence --- .agent-loop/LOOP_STATE.md | 13 +- .agent-loop/REVIEW_LOG.md | 11 ++ .agent-loop/WORK_QUEUE.md | 7 +- .../CHUNK_MAP.md | 2 +- .../STATUS.md | 16 +- ...S-AUTH-001-05B-internal-review-evidence.md | 58 +++++++ .../WS-AUTH-001-05B-pr-trust-bundle.md | 144 ++++++++++++++++++ 7 files changed, 234 insertions(+), 17 deletions(-) create mode 100644 .agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/reviews/WS-AUTH-001-05B-internal-review-evidence.md create mode 100644 .agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/reviews/WS-AUTH-001-05B-pr-trust-bundle.md diff --git a/.agent-loop/LOOP_STATE.md b/.agent-loop/LOOP_STATE.md index c963e094..1af7b797 100644 --- a/.agent-loop/LOOP_STATE.md +++ b/.agent-loop/LOOP_STATE.md @@ -9,19 +9,20 @@ - Branch: `codex/ws-auth-001-05b-idempotency-invalidation` - Worktree: `/home/abiorh/flow/workstream-authorization-service` - Status: CAT post-merge memory merged through PR #118 as `eba7e2b` on - 2026-07-14. `WS-AUTH-001-05B` is active; its repaired L1 plan passed senior - engineering, QA/test, and security/auth/privacy review. + 2026-07-14. `WS-AUTH-001-05B` implementation and repair are complete at + reviewed runtime SHA `e083890`; every required internal review track passed. - Prior `WS-AUTH-001-01` reviewed implementation SHA: `be0b836` - Prior `WS-AUTH-001-01` final merged branch head: `b5217e1` - Latest integrated `main` merge commit: `eba7e2b` -- Current gate: bounded AUTH-05B implementation and deterministic evidence. +- Current gate: PR publication, GitHub checks, CodeRabbit, and human review. - Scope checkpoint: the 52 approved identifiers and `/api/v1` namespace remain unchanged. AUTH-05A's 49-identifier audit base remains runtime truth until the three planned recovery identifiers receive typed/SQL parity in AUTH-13/14. - Next chunk: `WS-AUTH-001-06` remains inactive. Do not start it automatically. -- Focused evidence: the audit/delegation suite passed 11 tests at 94.55 percent - audit-subsystem coverage. Final GitHub Backend passed 949 tests at 82.77 - percent global coverage and 91.07 percent artifact-foundation coverage. +- Focused evidence: 26 AUTH-05B authorization/audit/migration tests passed at + 96.88 percent authorization-subsystem coverage. The prior main Backend result + remains 949 tests at 82.77 percent global coverage and 91.07 percent + artifact-foundation coverage; GitHub CI will produce the new full result. - Parallel initiative: `WS-QUAL-001-01B2` is paused at the user's direction so AUTH receives the laptop's test capacity. Its last official whole-app result remains `6466/8159` statements (`79.249908%`); no replacement evidence exists. diff --git a/.agent-loop/REVIEW_LOG.md b/.agent-loop/REVIEW_LOG.md index 380311b1..c28f8b27 100644 --- a/.agent-loop/REVIEW_LOG.md +++ b/.agent-loop/REVIEW_LOG.md @@ -1,5 +1,16 @@ # Review Log +## 2026-07-14 - WS-AUTH-001-05B Internal Review Passed + +Reviewed runtime SHA `e083890` passed senior engineering, architecture, +product/ops, docs, reuse/dedup, QA/test, test delta, CI integrity, and +security/auth/privacy review after repair. Valid findings closed committed-row +evidence injection, request/result target substitution, incomplete linked-event +context, mismatch scope/resource semantics, rejected-value retention, and +missing migration/operation behavior proof. The focused suite passed 26 tests +at 96.88 percent authorization-subsystem coverage. PR publication and external +checks remain pending; AUTH-06 is inactive. + ## 2026-07-14 - WS-AUTH-001-05B Repaired Plan Passed The repaired L1 plan passes senior engineering, QA/test, and diff --git a/.agent-loop/WORK_QUEUE.md b/.agent-loop/WORK_QUEUE.md index 4413eb0c..774e8718 100644 --- a/.agent-loop/WORK_QUEUE.md +++ b/.agent-loop/WORK_QUEUE.md @@ -4,7 +4,7 @@ | Chunk | Title | Risk | Status | |---|---|---:|---| -| `WS-AUTH-001-05B` | Authority Idempotency And Invalidation Foundation | L1 | Active; repaired L1 plan passed, implementation in progress | +| `WS-AUTH-001-05B` | Authority Idempotency And Invalidation Foundation | L1 | Implemented; internal review complete; PR publication pending | ## Planned Next @@ -62,8 +62,9 @@ ## Proposed Next AUTH-05A merged through PR #115 as `8e1cde6`, and CAT plus its post-merge memory -merged through PRs #117 and #118. AUTH-05B's repaired L1 plan passed and bounded -implementation is active. Do not start AUTH-06 or POL-002-04 automatically. +merged through PRs #117 and #118. AUTH-05B implementation and internal review +are complete; PR publication is pending. Do not start AUTH-06 or POL-002-04 +automatically. Coverage R10 merged through PR #108. Do not start 01B2, chunk 02, or another coverage implementation chunk from this worktree. diff --git a/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/CHUNK_MAP.md b/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/CHUNK_MAP.md index 375538f8..d26d5614 100644 --- a/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/CHUNK_MAP.md +++ b/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/CHUNK_MAP.md @@ -21,7 +21,7 @@ stopped. | `WS-AUTH-001-05` | Authority Evidence And Idempotency Foundation | L1 | Split before implementation into 05A and 05B | | `WS-AUTH-001-05A` | Shared Audit Ownership And Append-Only Authority Evidence | L1 | Merged through PR #115 as `8e1cde6` | | `WS-AUTH-001-CAT` | Action And Resource Catalogue Reconciliation | L1 | Merged through PR #117 as `4c5d4fc` | -| `WS-AUTH-001-05B` | Authority Idempotency And Invalidation Foundation | L1 | Active; repaired L1 plan passed, implementation in progress | +| `WS-AUTH-001-05B` | Authority Idempotency And Invalidation Foundation | L1 | Implemented and internally reviewed; PR publication pending | | `WS-AUTH-001-06` | Canonical Actor Profile And Identity Link | L1 | Proposed | | `WS-AUTH-001-07` | Authorization Kernel And Permission Registry | L1 | Proposed | | `WS-AUTH-001-08` | Bootstrap And Administrative Role Grants | L1 | Proposed | diff --git a/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/STATUS.md b/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/STATUS.md index 3a25eba2..46afd8ce 100644 --- a/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/STATUS.md +++ b/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/STATUS.md @@ -59,18 +59,20 @@ CodeRabbit passed, and explicit human approval merged PR #115 as `8e1cde6` on The user requested action/resource catalogue reconciliation before AUTH-05B. That docs-only work passed required internal reviews, Backend, Agent Gates, and CodeRabbit; explicit human approval merged PR #117 as `4c5d4fc` on 2026-07-14. -Its post-merge memory merged through PR #118 as `eba7e2b`. AUTH-05B is active; -its repaired L1 plan passed senior engineering, QA/test, and -security/auth/privacy review before runtime edits. +Its post-merge memory merged through PR #118 as `eba7e2b`. AUTH-05B's repaired +L1 plan passed before runtime edits. Implementation, repair, focused evidence, +and all required internal review tracks now pass at reviewed runtime SHA +`e083890`; PR publication is pending. ## Active planning chunk -None. AUTH-05B implementation is active under its approved repaired contract. +None. AUTH-05B implementation is internally reviewed under its approved +repaired contract. ## Active implementation chunk -`WS-AUTH-001-05B` is active. Its repaired L1 plan passed and bounded runtime -implementation is in progress. +`WS-AUTH-001-05B` is implemented and internally reviewed. GitHub checks, +CodeRabbit, and explicit human review remain pending. ## Current implementation branch @@ -91,7 +93,7 @@ implementation is in progress. | `WS-AUTH-001-05` | Split | `codex/ws-auth-001-05-authority-evidence` | - | Parent split before implementation into 05A and 05B. | | `WS-AUTH-001-05A` | Merged | `codex/ws-auth-001-05-authority-evidence` | #115 | Merged as `8e1cde6`; reviewed code `ea16fd8`; final branch head `d023952`. | | `WS-AUTH-001-CAT` | Merged | `codex/ws-auth-001-action-catalogue-reconciliation` | #117 | Merged as `4c5d4fc`; final branch head `5b4ec96`. | -| `WS-AUTH-001-05B` | Active | `codex/ws-auth-001-05b-idempotency-invalidation` | - | Repaired L1 plan passed; bounded implementation active. | +| `WS-AUTH-001-05B` | In review | `codex/ws-auth-001-05b-idempotency-invalidation` | - | Reviewed runtime SHA `e083890`; PR publication pending. | | `WS-AUTH-001-06` | Proposed | - | - | Canonical actor profile and identity link. | | `WS-AUTH-001-07` | Proposed | - | - | Authorization kernel and permissions. | | `WS-AUTH-001-08` | Proposed | - | - | Bootstrap and administrative grants. | diff --git a/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/reviews/WS-AUTH-001-05B-internal-review-evidence.md b/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/reviews/WS-AUTH-001-05B-internal-review-evidence.md new file mode 100644 index 00000000..9a303c6e --- /dev/null +++ b/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/reviews/WS-AUTH-001-05B-internal-review-evidence.md @@ -0,0 +1,58 @@ +# WS-AUTH-001-05B Internal Review Evidence + +Reviewed code SHA: `e0838908dfa19227fab2f73e32c9b0c94dd39bb4` +Reviewed at: `2026-07-14T17:54:25Z` +Reviewer run IDs: `auth04b_final_eng`, `auth04b_final_qa`, +`auth04b_final_security` + +## Deterministic Evidence + +- Authorization, shared-audit, and exact migration-node suite: 26 passed in + 47.11 seconds. +- The new `app.modules.authorization` subsystem reached 96.88 percent coverage, + above the required 90 percent gate. +- Migration custody separately passed its upgrade/downgrade, immutable-row, + database-time, pre-0019 orphan, and observed concurrent-lock proof. +- Isolated database runner suite: 16 passed in 148.55 seconds. +- Ruff passed for backend application, tests, scripts, and migration code. +- Docstring coverage passed at 95.3 percent overall. +- Stale Workstream, authorization, and artifact contract scans passed. Changed + Markdown links and `git diff --check` passed. +- No dependency, workflow, coverage threshold, skip, exclusion, or package + script changed. +- Repository-wide isolated suite reached approximately 94 percent with no + failure before it was intentionally interrupted after two hours on a + four-core host at load average 13.7. This is not recorded as a pass; GitHub + Backend remains the authoritative full-suite and 78 percent global gate. + +## Reviewer Results + +| Reviewer | Result | Blocking findings | Notes | +|---|---|---|---| +| senior engineering | PASS | none | Exact request-to-result binding and transaction ownership pass. | +| qa/test | PASS | none | Ten operations plus replacement, concurrency, rollback, migration, and privacy behavior pass. | +| security/auth | PASS | none | Actor isolation, non-retention, claim integrity, and database bypass controls pass. | +| product/ops | PASS | none | Mutation, replay, mismatch, and invalidation semantics pass without route activation. | +| architecture | PASS | none | Shared audit ownership and caller-owned transaction boundaries remain intact. | +| ci integrity | PASS | none | CI policy, thresholds, dependencies, and exclusions are unchanged. | +| docs | PASS | none | Architecture and operations documentation match the implementation. | +| reuse/dedup | PASS | none | Existing hash and shared audit abstractions are reused. | +| test delta | PASS | none | Additive behavior tests replace no assertion, raises guard, skip, or xfail. | + +## Findings Resolved + +Review repair closed committed-record evidence injection, incomplete +entity/target/cause/context linkage, request-A/result-B substitution, +caller-selected mismatch scope, nonexistent grant-resource labels, rejected +Pydantic diagnostic retention, malformed-Unicode exception retention, weak +actor-kind isolation proof, missing positive operation parity, immutable-row +proof gaps, and downgrade/writer race proof. + +Valid findings addressed: yes + +Open sub-agent sessions: none + +## Remaining Gate + +GitHub Backend, Agent Gates, CodeRabbit, and explicit human merge approval +remain pending. `WS-AUTH-001-06` remains inactive. diff --git a/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/reviews/WS-AUTH-001-05B-pr-trust-bundle.md b/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/reviews/WS-AUTH-001-05B-pr-trust-bundle.md new file mode 100644 index 00000000..c07239e1 --- /dev/null +++ b/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/reviews/WS-AUTH-001-05B-pr-trust-bundle.md @@ -0,0 +1,144 @@ +# WS-AUTH-001-05B PR Trust Bundle + +## Chunk + +`WS-AUTH-001-05B` - Authority Idempotency And Invalidation Foundation + +## Goal + +Make the ten sensitive authority mutations concurrency-safe and retry-safe, +with typed replay results and exactly linked success/invalidation evidence. + +## Human-Approved Intent + +The human approved the repaired L1 contract and its semantic chunk boundary. +The work remains backend foundation only; AUTH-06 must not start automatically. + +## What Changed + +- Migration `0019` adds immutable actor-scoped idempotency reservations, a + pending-to-committed transition, typed replay references, database timestamps, + a deferred pending guard, and guarded downgrade custody. +- Strict frozen request variants cover exactly ten mutation operations and hash + only canonical, bounded, server-derived facts. +- The authorization service reserves, replays, records privacy-neutral + mismatches, validates concrete success evidence, constructs invalidations, + and completes the claim in one caller-owned transaction. +- PostgreSQL links actor, claim, operation, entity, resource, target, concrete + cause, request, correlation, and project context and closes committed records + to later evidence. +- Behavior tests cover all ten operations plus project-grant replacement, + exact and mismatched concurrency, rollback, privacy, direct bypasses, + immutable state, and upgrade/downgrade races. + +## Why It Changed + +Later actor, grant, and permission chunks need a trustworthy retry boundary. +Without it, a lost response or concurrent retry could duplicate authority +state, disclose another request's result, or leave misleading audit evidence. + +## Design Chosen + +The unique namespace is actor kind, actor reference, operation, and client key. +Only the canonical request digest and typed response reference persist. The +existing shared audit service remains the sole application writer, while +PostgreSQL independently enforces linkage and immutability. Repositories never +commit, roll back, retry, or create sessions. + +## Alternatives Rejected + +- Storing raw requests or response bodies was rejected for privacy and schema + stability. +- Application-only idempotency and evidence validation was rejected because + direct SQL must fail closed. +- A parallel invalidation table was rejected because AUTH-05A already owns the + shared authority evidence envelope. +- Route, grant, actor, cache, queue, and consumer implementation remains in + later owning chunks. + +## Scope Control + +No API route, permission evaluator, actor/grant table, token verifier, cache, +queue, invalidation consumer, product authorization decision, dependency, +workflow, or CI threshold changed. + +## Product Behavior + +There is no new externally reachable product behavior. Internal callers can +claim a mutation, receive an exact committed replay, or receive a private +mismatch signal. Completion requires one concrete success plus one linked +invalidation event. + +## Acceptance Criteria Proof + +- Independent concurrent retries serialize through PostgreSQL and produce one + committed result and one evidence pair. +- All ten operations and the project replacement branch have positive typed and + database-linked proof. +- Request target/project facts are bound to the success, response, and replay; + cross-actor, wrong-entity, wrong-target, and mismatched-cause paths fail. +- Committed rows reject new success or invalidation evidence and reject update, + delete, and truncate. +- Rejected secrets and malformed Unicode are absent from strings, args, + diagnostics, public state, cause, and context. +- Downgrade is observed waiting for an actual PostgreSQL writer lock, refuses + non-empty custody, preserves compatible pre-0019 rows, and restores head. + +## Tests And Checks Run + +- Focused behavior/migration suite: 26 passed. +- Authorization subsystem coverage: 96.88 percent. +- Isolated-runner suite: 16 passed. +- Ruff, 95.3 percent docstring coverage, stale contract scans, changed Markdown + links, internal review evidence structure, and diff integrity passed or are + rerun after this bundle is finalized. +- A local repository-wide isolated suite reached approximately 94 percent with + no failure before intentional interruption after two hours on an overloaded + four-core host. GitHub Backend owns the authoritative full-suite and global + coverage result. + +## Test Delta + +Tests are additive or strengthened. No assertion, raises guard, skip, xfail, +coverage threshold, or test selection was removed or weakened. + +## CI Integrity + +No workflow, dependency, package script, exclusion, or coverage setting +changed. GitHub Backend must still enforce the repository-wide 78 percent floor +and the existing artifact-foundation 90 percent floor. + +## Reviewer Results + +Exact runtime SHA `e0838908dfa19227fab2f73e32c9b0c94dd39bb4` passed senior +engineering, architecture, product/ops, docs, reuse/dedup, QA/test, test delta, +CI integrity, and security/auth/privacy review with no remaining findings. + +## External Review + +Pending GitHub checks, CodeRabbit, and human review. + +## Remaining Risks + +- Database-owner or DDL credentials can bypass normal-DML triggers; production + must use the documented non-owner runtime role. +- This chunk emits invalidation evidence but intentionally has no consumer. +- Replay references are internal; future routes must reload and reauthorize the + current resource before disclosure. + +## Follow-Up Work + +After merge, post-merge memory, stop, and a separate human start, AUTH-06 may +implement canonical actor profiles and identity links. Later chunks own the +authorization kernel, grants, resource cutovers, and invalidation consumers. + +## Human Review Focus + +Review the migration triggers and downgrade lock ordering, canonical digest +boundary, request-to-evidence binding, mismatch privacy, and proof that no +route or product authorization behavior entered scope. + +## Human Merge Ownership + +Only the human may approve and merge this PR. Internal or external checks do +not authorize merge. From b4f9b83524b7e28f2b0fd18f397e3256661a26c4 Mon Sep 17 00:00:00 2001 From: Abiorh001 Date: Tue, 14 Jul 2026 20:53:29 +0100 Subject: [PATCH 5/6] docs(auth): reconcile idempotency lifecycle evidence --- .../CHUNK_MAP.md | 6 +++--- .../WS-AUTH-001-workstream-authorization-service/STATUS.md | 5 +++-- .../reviews/WS-AUTH-001-05B-internal-review-evidence.md | 2 +- .../reviews/WS-AUTH-001-05B-pr-trust-bundle.md | 6 ++++-- 4 files changed, 11 insertions(+), 8 deletions(-) diff --git a/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/CHUNK_MAP.md b/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/CHUNK_MAP.md index d26d5614..c3bef0ad 100644 --- a/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/CHUNK_MAP.md +++ b/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/CHUNK_MAP.md @@ -109,6 +109,6 @@ the semantic AUTH-05A boundary. Required reviews and checks passed, and explicit human approval merged PR #115 as `8e1cde6` on 2026-07-14, followed by merged post-merge memory. `WS-AUTH-001-CAT` then merged through PR #117 as `4c5d4fc` after Backend, Agent Gates, CodeRabbit, and explicit human approval passed. The -CAT post-merge memory merged through PR #118 as `eba7e2b`; AUTH-05B's repaired -L1 plan passed and bounded implementation is active. Do not start AUTH-06 or -POL-002-04. +CAT post-merge memory merged through PR #118 as `eba7e2b`; AUTH-05B runtime SHA +`e083890` is internally reviewed, and PR publication is pending. Do not start +AUTH-06 or POL-002-04. diff --git a/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/STATUS.md b/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/STATUS.md index 46afd8ce..f794829c 100644 --- a/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/STATUS.md +++ b/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/STATUS.md @@ -124,8 +124,9 @@ permission identifiers remain approved, including `operations.checker.retry`; the three recovery identifiers receive persisted parity only in their owning later chunks. `WS-AUTH-001-CAT` retains only safe registry/conformance rules. This is a scope decision, not an AUTH-05B runtime -blocker. PR #118 is merged and AUTH-05B is active. Its current gate is repaired -L1 plan approval, not an external dependency or another user signal. +blocker. PR #118 is merged, and AUTH-05B runtime SHA `e083890` is internally +reviewed. Its current gate is PR publication and external checks; AUTH-06 and +POL-002-04 remain inactive. AUTH-04B review evidence and its PR trust bundle are recorded at `reviews/WS-AUTH-001-04B-internal-review-evidence.md` and diff --git a/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/reviews/WS-AUTH-001-05B-internal-review-evidence.md b/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/reviews/WS-AUTH-001-05B-internal-review-evidence.md index 9a303c6e..2d59951e 100644 --- a/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/reviews/WS-AUTH-001-05B-internal-review-evidence.md +++ b/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/reviews/WS-AUTH-001-05B-internal-review-evidence.md @@ -37,7 +37,7 @@ Reviewer run IDs: `auth04b_final_eng`, `auth04b_final_qa`, | ci integrity | PASS | none | CI policy, thresholds, dependencies, and exclusions are unchanged. | | docs | PASS | none | Architecture and operations documentation match the implementation. | | reuse/dedup | PASS | none | Existing hash and shared audit abstractions are reused. | -| test delta | PASS | none | Additive behavior tests replace no assertion, raises guard, skip, or xfail. | +| test delta | PASS | none | Existing audit assertions were adapted for mandatory idempotency linkage and positive operation coverage was strengthened; no coverage was weakened. | ## Findings Resolved diff --git a/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/reviews/WS-AUTH-001-05B-pr-trust-bundle.md b/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/reviews/WS-AUTH-001-05B-pr-trust-bundle.md index c07239e1..484565ab 100644 --- a/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/reviews/WS-AUTH-001-05B-pr-trust-bundle.md +++ b/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/reviews/WS-AUTH-001-05B-pr-trust-bundle.md @@ -99,8 +99,10 @@ invalidation event. ## Test Delta -Tests are additive or strengthened. No assertion, raises guard, skip, xfail, -coverage threshold, or test selection was removed or weakened. +Tests are additive or strengthened. Existing audit assertions were adapted for +mandatory idempotency linkage, and positive operation coverage replaced weaker +assertions. No raises guard, skip, xfail, coverage threshold, or test selection +was removed or weakened. ## CI Integrity From 83ca3e22b39a5c24eb4d22acd47d75f509ea03d4 Mon Sep 17 00:00:00 2001 From: Abiorh001 Date: Tue, 14 Jul 2026 20:55:50 +0100 Subject: [PATCH 6/6] docs(auth): bind final idempotency review provenance --- .../reviews/WS-AUTH-001-05B-internal-review-evidence.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/reviews/WS-AUTH-001-05B-internal-review-evidence.md b/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/reviews/WS-AUTH-001-05B-internal-review-evidence.md index 2d59951e..14c08c3d 100644 --- a/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/reviews/WS-AUTH-001-05B-internal-review-evidence.md +++ b/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/reviews/WS-AUTH-001-05B-internal-review-evidence.md @@ -1,7 +1,8 @@ # WS-AUTH-001-05B Internal Review Evidence -Reviewed code SHA: `e0838908dfa19227fab2f73e32c9b0c94dd39bb4` -Reviewed at: `2026-07-14T17:54:25Z` +Reviewed code SHA: `b4f9b83524b7e28f2b0fd18f397e3256661a26c4` +Reviewed runtime SHA: `e0838908dfa19227fab2f73e32c9b0c94dd39bb4` +Reviewed at: `2026-07-14T19:55:06Z` Reviewer run IDs: `auth04b_final_eng`, `auth04b_final_qa`, `auth04b_final_security`