diff --git a/.agent-loop/LOOP_STATE.md b/.agent-loop/LOOP_STATE.md index 63acd88f..93319361 100644 --- a/.agent-loop/LOOP_STATE.md +++ b/.agent-loop/LOOP_STATE.md @@ -16,25 +16,29 @@ - AUTH-09B merged through PR #143 as `053242b`; it adds controlled service actor provisioning and leaves service runtime admission plus feature actions inactive. +- AUTH-09C merged through PR #146 as `0ffdabf`; signed schema-v2 memory at + `eeb3dc2` recorded its two administrative reads and stopped. - PR #141 merged `WS-ART-001-02A3` into `main` as `a10d901` on 2026-07-18; ART-02B1 remains inactive pending a separate explicit start. -- Active implementation chunk: `WS-AUTH-001-09C` on - `codex/ws-auth-001-09c-actor-identity-admin-reads`. -- AUTH-09C is bounded to two exact administrative read routes and activates - only `actor.profile.read` and `actor.identity_link.read`. AUTH-09D remains - inactive. -- Start basis: trusted `main` at `a10d901` after PR #141. +- Active implementation chunk: `WS-AUTH-001-09D-A`. The explicitly started parent + `WS-AUTH-001-09D` failed required preimplementation review and was split + before runtime edits into `WS-AUTH-001-09D-A` and `WS-AUTH-001-09D-B`. +- Active branch: `WS-AUTH-001-09D-A` on + `codex/ws-auth-001-09d-actor-identity-lifecycle`. It owns lifecycle evidence + repair plus three exact profile lifecycle mutations. `WS-AUTH-001-09D-B` + remains inactive until 09D-A merge, signed memory, and explicit user start. +- Start basis: trusted `main` at `0ffdabf` after PR #146. - PR #119 merged `WS-AUTH-001-05B` as `ad71c7e`. - PR #120 merged `WS-ART-001-OBJECT-STORAGE-AMENDMENT` as `4408256`. - PR #122 merged the first automated post-merge memory implementation as `fc89fb6`; its schema-v1 cross-initiative next pointer is superseded by the schema-v2 initiative-local clean cut. -- Current gate: PR #146's original Backend, Agent Gates, and CodeRabbit checks - passed. All five valid CodeRabbit proof findings are repaired at `c64bcc7`, - trusted `main` through PR #141 is integrated, and focused unit, real - PostgreSQL, and Agent Gates proof passes. All required exact integrated-head - internal repair tracks pass at `c47d627`; push and replacement external - checks remain. No service caller becomes executable before AUTH-09E. +- Current gate: PR #148 external repair aligns database lifecycle-reason + whitespace with the API and repairs stale reviewer evidence. PostgreSQL proof + plus required exact-head internal review pass at `7c33e64`; canonical evidence + is refreshed against that SHA after repairing the Backend fixture cascade. + Replacement external checks and explicit human approval remain. No service + caller becomes executable before AUTH-09E. - Scope checkpoint: AWS S3 is the only v0.1 production provider; MinIO is local/CI S3 protocol proof; LocalStorage is focused development/test; R2 and Flow Node are deferred. Product modules receive narrow artifact capabilities, @@ -50,9 +54,9 @@ neither reconciliation PR activates feature behavior. - Parallel artifact checkpoint: ART-02A1, ART-02A2, and ART-02A3 merged through PRs #127, #129, and #141. ART-02B1 remains inactive. -- Authorization checkpoint: AUTH-07B through AUTH-09B merged through PRs #130, - #131, #132, and #143. Signed memory stopped after 09B, and the user explicitly - started AUTH-09C. +- Authorization checkpoint: AUTH-07B through AUTH-09C merged through PRs #130, + #131, #132, #143, and #146. Signed memory stopped after 09C, and the user + explicitly started AUTH-09D; required review split it before runtime edits. - Parallel coverage work: `WS-QUAL-001-01B2` remains paused. Its last official whole-app result is `6466/8159` statements (`79.249908%`); no replacement evidence exists. diff --git a/.agent-loop/REVIEW_LOG.md b/.agent-loop/REVIEW_LOG.md index 58cd26be..ca5fadb7 100644 --- a/.agent-loop/REVIEW_LOG.md +++ b/.agent-loop/REVIEW_LOG.md @@ -1,5 +1,85 @@ # Review Log +## 2026-07-18 - WS-AUTH-001-09D-A External Repair Review Passed + +- PR #148 CodeRabbit correctly found an API/database normalization mismatch: + one-argument PostgreSQL `btrim` allowed tab and newline padding rejected or + normalized by the public lifecycle schema. +- The repair applies one explicit whitespace contract to both lifecycle + tables, migration `0026` dirty-row refusal, and installed constraints. + Direct-write and previous-head migration behavior tests pass. +- CodeRabbit and GitHub checks also correctly rejected stale internal-review + evidence. All required tracks pass the repaired exact head `efc4e6c`; the + evidence now requires canonical rows and that exact reviewed SHA. + Replacement external checks and human approval remain required. + +## 2026-07-18 - WS-AUTH-001-09D-A Exact-Head Internal Review Passed + +- Senior engineering, QA/test, security/auth, product/ops, architecture, + migration/data integrity, CI integrity, docs, reuse/dedup, and test delta pass + exact integrated head `cc7e6cc` against trusted main `f18b620`. +- All valid findings are repaired. Exact owner parity is 65 actions, 15 active, + and 50 planned; identity-link mutations remain inactive under 09D-B. +- Internal evidence and the PR trust bundle record the behavior, migration, + coverage, CI-integrity, and reviewer proof. Ready PR #148 is open; external + checks and explicit human merge approval are the current gate. + +## 2026-07-18 - WS-AUTH-001-09D-A Deterministic Evidence Repaired + +- Initial exact-head QA/test-delta review rejected `43192d` because lifecycle + denial rows retained matched-grant IDs, database guards permitted missing or + stale reactivation provenance, the service/failure matrix was incomplete, + and ordered races did not observe a PostgreSQL waiter. +- Candidate `b025460` removes matched-grant internals from lifecycle denials, + requires fresh complete profile/link reactivation provenance, covers a fixed + service target and nine injected transaction stages, and observes every + ordered second request in PostgreSQL `wait_event_type='Lock'`. +- A second exact-head architecture/migration review rejected `e1ea98f` because + code-owned activation custody still named the retired 09D parent and the + migration tests did not exercise every upgrade/downgrade refusal branch. +- Candidate `3d68933` assigns profile actions to exact owner 09D-A and inactive + link actions to 09D-B, then proves partial attribution, padded and multibyte + reason refusal, profile/link success evidence, and both link-denial tokens. +- The next exact-head review found four historical Alembic catalogue assertions + still using the removed parent owner. Candidate `e64b03f` clean-cuts all four + to the two child owners; the three affected migration tests pass in 199.52 + seconds and still assert exactly eight AUTH-09 definitions. +- All five exact migration proofs pass in 181.50 seconds. The expanded real + PostgreSQL matrix passes in 100.36 seconds and the blocker-controlled race + proof passes in 140.69 seconds. No timing sleeps establish race order. +- Focused branch coverage passes at 90.70 percent for actors and 91.78 percent + for authorization; the clean authorization selection passes all 110 tests and + the new lifecycle service is 100 percent branch-covered. +- Ruff, the real HTTP API contract, stale scans, Markdown links, 87 Agent + Gates, merge-intent validation, and diff integrity pass. Exact-head internal + review remains before PR publication. External checks and explicit human + merge approval follow. + `WS-AUTH-001-09D-B` and `WS-AUTH-001-09E` remain inactive. + +## 2026-07-18 - WS-AUTH-001-09D-A Preimplementation Review Passed + +- Exact reviewed contract SHA: `7f941a53b4a30e6116d9a13fce6246f7aa943ac3`. +- Senior, security/auth, product/ops, QA/test, test-delta, architecture, + migration, CI integrity, docs, and reuse/dedup tracks passed after three + bounded repair rounds. +- Runtime implementation may begin for 09D-A only. 09D-B and 09E remain + inactive behind separate merge, signed-memory, and explicit-start gates. + +## 2026-07-18 - WS-AUTH-001-09D Preimplementation Review Failed And Split + +- Basis: trusted `main` at `0ffdabf` after PR #146 and signed memory + `eeb3dc2`. +- Result: the combined lifecycle contract failed required L1 review before any + runtime edit. It mixed profile and identity-link mutations, omitted required + forward migration ownership, conflicted with canonical reserve-first and + singleton/caller-first ordering, and did not close reactivation evidence, + provenance, conflict, response, or final-admin concurrency semantics. +- Repair: split the parent into active contract-review child + `WS-AUTH-001-09D-A` and inactive child `WS-AUTH-001-09D-B`. The first child + owns migration `0026`, lifecycle evidence repair, and only profile suspend, + reactivate, and terminal deactivate. Runtime implementation remains gated on + exact repaired-contract review. + ## 2026-07-18 - WS-AUTH-001-09C External Repair Evidence Passed PR #146's original Backend, Agent Gates, and CodeRabbit checks passed. diff --git a/.agent-loop/WORK_QUEUE.md b/.agent-loop/WORK_QUEUE.md index 9c438252..54e88279 100644 --- a/.agent-loop/WORK_QUEUE.md +++ b/.agent-loop/WORK_QUEUE.md @@ -4,7 +4,7 @@ | Chunk | Title | Risk | Status | |---|---|---:|---| -| `WS-AUTH-001-09C` | Actor And Identity-Link Administration Reads | L1 | PR #146 open; all valid CodeRabbit findings repaired; integrated exact-head review passed; replacement external checks pending | +| `WS-AUTH-001-09D-A` | Profile Lifecycle And Evidence Repair | L1 | PR #148 external repair and exact-head review pass at `efc4e6c`; canonical evidence refresh pending | Live post-merge state remains read from signed `automation/loop-memory` output. This authored queue records the separately approved parallel chunks. @@ -14,7 +14,7 @@ output. This authored queue records the separately approved parallel chunks. | Chunk | Title | Risk | Status | |---|---|---:|---| | `WS-QUAL-001-01B2` | Baseline Evidence And CI Ratchet | L1 | Paused for AUTH priority; no valid replacement baseline yet | -| `WS-AUTH-001-09D` | Actor And Identity-Link Lifecycle Mutations | L1 | Inactive until 09C merge/memory and explicit user start | +| `WS-AUTH-001-09D-B` | Identity-Link Lifecycle And Race Closure | L1 | Inactive until 09D-A merge/memory and explicit user start | | `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 | | `WS-ART-001-02B1` | S3-Compatible MinIO And AWS | L1 | Inactive until 02A3 merge and explicit user start | @@ -89,8 +89,11 @@ merged through PRs #126, #130, and #131. WS-XINT planning merged through PR #139 and its AUTH owner reconciliation merged through PR #140 as `d541521`. AUTH-09A merged through PR #132 as `299363a`, and signed schema-v2 memory stopped. AUTH-09B merged through PR #143 as `053242b`; the user then explicitly -started AUTH-09C, whose PR #146 is in external repair. Do not start AUTH-09D or -POL-002-04 automatically. +started AUTH-09C. PR #146 merged it as `0ffdabf`; signed memory at `eeb3dc2` +stopped. The user explicitly started AUTH-09D, and required review split it +before runtime edits. Only 09D-A implementation is active after exact-SHA +preimplementation review passed. Do not start 09D-B, +09E, 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 e94f569d..4e8aaad2 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 @@ -31,9 +31,11 @@ stopped. | `WS-AUTH-001-09` | Actor State, Identity Revocation, And Service Actors | L1 | Split before runtime implementation | | `WS-AUTH-001-09A` | Fixed Service Identity Foundation | L1 | Merged through PR #132 as `299363a` | | `WS-AUTH-001-09B` | Controlled Service Actor Provisioning | L1 | Merged through PR #143 as `053242b` | -| `WS-AUTH-001-09C` | Actor And Identity-Link Administration Reads | L1 | Active after explicit user start from signed 09B memory | -| `WS-AUTH-001-09D` | Actor And Identity-Link Lifecycle Mutations | L1 | Inactive until 09C merge/memory and explicit start | -| `WS-AUTH-001-09E` | Fixed Service Runtime Admission | L1 | Inactive until 09D merge/memory and explicit start | +| `WS-AUTH-001-09C` | Actor And Identity-Link Administration Reads | L1 | Merged through PR #146 as `0ffdabf` | +| `WS-AUTH-001-09D` | Actor And Identity-Link Lifecycle Mutations | L1 | Split before runtime implementation into 09D-A and 09D-B | +| `WS-AUTH-001-09D-A` | Profile Lifecycle And Evidence Repair | L1 | Implementation and deterministic proof passed; exact-head internal review pending | +| `WS-AUTH-001-09D-B` | Identity-Link Lifecycle And Race Closure | L1 | Inactive until 09D-A merge/memory and explicit start | +| `WS-AUTH-001-09E` | Fixed Service Runtime Admission | L1 | Inactive until 09D-B merge/memory and explicit start | | `WS-AUTH-001-ART-CUSTODY` | ART Activation Custody Transfer | L1 | Inactive until 09E merge/memory and explicit start | | `WS-AUTH-001-REV-CUSTODY` | REV Activation Custody Transfer | L1 | Inactive until 09E merge/memory and explicit start | | `WS-AUTH-001-PREP` | Prepared Mutation Authorization Protocol | L1 | Inactive until 09E merge/memory and explicit start | @@ -93,7 +95,8 @@ WS-AUTH-001-PLAN -> WS-AUTH-001-09A -> WS-AUTH-001-09B -> WS-AUTH-001-09C --> WS-AUTH-001-09D +-> WS-AUTH-001-09D-A +-> WS-AUTH-001-09D-B -> WS-AUTH-001-09E -> WS-AUTH-001-ART-CUSTODY and WS-AUTH-001-REV-CUSTODY -> WS-AUTH-001-PREP @@ -189,6 +192,9 @@ PR #140 merged the required XINT planning reconciliation as `d541521`. PR #132 then merged seven identities, eleven static matrix memberships, eight planned actions, and migration `0023` as `299363a`; signed memory stopped. The user explicitly started AUTH-09B. PR #143 merged it as `053242b`; signed memory -stopped, and the user explicitly started AUTH-09C. It must pass exact-head -review and checks, then stop for human merge. Do not start AUTH-09D or -POL-002-04 automatically. +stopped, and the user explicitly started AUTH-09C. PR #146 merged it as +`0ffdabf`; signed memory at `eeb3dc2` stopped. The user explicitly started +AUTH-09D. Required preimplementation review rejected the combined lifecycle +contract before runtime edits, so it is split into 09D-A and 09D-B. Only 09D-A +may proceed after its repaired contract passes exact review. Do not start +09D-B, 09E, or POL-002-04 automatically. diff --git a/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/DECISIONS.md b/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/DECISIONS.md index 695ae1fd..becbc799 100644 --- a/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/DECISIONS.md +++ b/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/DECISIONS.md @@ -457,7 +457,7 @@ idempotency hashes the requested role; revoke derives the role from the locked grant. Same key/different role mismatches, new-key duplicate same-role issue is a stable audited conflict, and replay reauthorizes before disclosure. -AUTH-10 replaces current typed and PostgreSQL validators in migration `0026` +AUTH-10 replaces current typed and PostgreSQL validators in migration `0027` without editing `0018`, `0019`, or `0022`. It fails closed if obsolete combined or replacement evidence exists and refuses an unsafe downgrade rather than converting or deleting evidence. Only issued and revoked success events remain. @@ -574,8 +574,8 @@ PermissionId mapping, canonical target, principal class, candidates, guards, surface, and revalidation rule before runtime edits. Each owning migration updates current typed and PostgreSQL audit validation; no chunk may promise an active surface whose ActionId is absent. AUTH-11 therefore owns migration -`0027`, and later migration reservations shift through AUTH-15 as recorded in -D29. AUTH-16 aggregates proof; it does not discover or backfill missing +`0028`, and later migration reservations shift through AUTH-15 as superseded +by D30. AUTH-16 aggregates proof; it does not discover or backfill missing registrations. ## D28: Service provisioning records an unverified issuer binding @@ -632,3 +632,29 @@ AUTH-15 owns `0031`. This decision supersedes only the future migration-number allocations in D15, D27, and D28. It does not modify any merged migration, action ownership, authorization behavior, or feature activation boundary. + +## D30: Split lifecycle mutations and repair evidence before activation + +Status: accepted preimplementation repair on 2026-07-18. + +Required L1 review rejected the combined AUTH-09D contract before runtime +edits. The parent is split semantically: AUTH-09D-A owns lifecycle evidence +repair plus profile suspend, reactivate, and terminal deactivate; AUTH-09D-B +later owns identity-link revoke/reactivate plus the mixed profile/link/grant +race closure. Each child requires its own explicit gate, review, merge intent, +PR, merge, and signed-memory stop. + +The existing database and typed lifecycle evidence force every non-admin +transition from effective to ineffective. That is false for profile and link +reactivation, and ActorProfile lacks durable reactivation provenance. AUTH-09D-A +therefore owns forward migration `0026_actor_profile_lifecycle`; historical +migrations remain immutable. AUTH-10 through AUTH-15 shift to `0027` through +`0032`. This supersedes only D29's future inactive reservations. + +Lifecycle routes reserve idempotency before authorization, retain the canonical +singleton/caller-first authorization locks, and disclose target existence only +after an exact permission match. The singleton serializes every lifecycle or +grant path that can remove the final effective Access Administrator, so no +target-first alternate lock path is introduced. Profile and link reactivation +invalidate from ineffective to effective; loss transitions invalidate from +effective to ineffective. diff --git a/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/DISCOVERY.md b/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/DISCOVERY.md index 56130778..89f65ba9 100644 --- a/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/DISCOVERY.md +++ b/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/DISCOVERY.md @@ -26,8 +26,9 @@ mutations, service admission, and concurrency. Required L1 review split it into 09A through 09E before runtime implementation. AUTH-09A takes migration `0023`; 09B requires forward migration `0024` so provisioned service links remain unverified until a token is actually verified. ART owns `0025` for the -ArtifactStore v2 clean cut. AUTH-10 through AUTH-15 therefore shift to `0026` -through `0031`, without allocating migrations to the +ArtifactStore v2 clean cut. AUTH-09D-A requires `0026` for lifecycle evidence +and profile reactivation provenance repair. AUTH-10 through AUTH-15 therefore +shift to `0027` through `0032`, without allocating migrations to the availability-neutral custody or PREP chunks. ## Current behavior @@ -303,7 +304,7 @@ need an independently reviewable contract and production-code budget. `replaced_grant_id`, replacement events, and replacement reasons. - Migration `0022_bootstrap_admin_grants.py` recreates current PostgreSQL audit and linked-idempotency validators with the same combined/replacement values. - Historical migrations remain immutable; AUTH-10 `0026` must replace current + Historical migrations remain immutable; AUTH-10 `0027` must replace current validators and fail closed on incompatible evidence. - The current kernel has request-scoped `require()` but no AUTH-first prepared mutation handle. Cross-module mutations therefore lack the merged lock, diff --git a/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/PLAN.md b/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/PLAN.md index ac00f8e0..4b55e3fd 100644 --- a/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/PLAN.md +++ b/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/PLAN.md @@ -86,8 +86,10 @@ Every cutover chunk enumerates its exact ActionIds before implementation and updates current typed/PostgreSQL audit mapping validation in its own migration. After AUTH-09A migration `0023`, AUTH-09B uses `0024` for service-link verification timestamp semantics and ART owns `0025` for the ArtifactStore v2 -clean cut. Core reservations are AUTH-10 `0026`, AUTH-11 `0027`, AUTH-12 -`0028`, AUTH-13 `0029`, AUTH-14 `0030`, and AUTH-15 `0031`. +clean cut. AUTH-09D-A uses `0026` to repair lifecycle evidence and profile +reactivation provenance. Core reservations are therefore AUTH-10 `0027`, +AUTH-11 `0028`, AUTH-12 `0029`, AUTH-13 `0030`, AUTH-14 `0031`, and AUTH-15 +`0032`. Blocked cross-initiative additions allocate the next trusted-main migration head only when their complete feature contracts become executable. No migration number is reserved or allocated while the work remains blocked. 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 b8587510..ba79eed2 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 @@ -91,16 +91,18 @@ AUTH-09A as `299363a`. Signed memory stopped, and the user explicitly started AUTH-09B from that trusted head. Its bounded implementation, external-review repair, coverage repair, and required checks passed before PR #143 merged as `053242b`. Signed memory stopped, and the user explicitly started AUTH-09C. -Its bounded implementation activates only the exact actor-profile and -identity-link administrative reads. Deterministic proof passes, including the -live HTTP drill and focused actor and authorization branch coverage above 90 -percent. Every required implementation review track passes at exact SHA -`6791381ceb9cb0c7f6ba163d4525c6c770c02ca6`. PR #146's original external checks -passed. All five valid CodeRabbit proof findings are repaired at `c64bcc7`, and -trusted `main` through PR #141 at `a10d901` is integrated. Focused unit, fresh -real PostgreSQL, Ruff, and Agent Gates proof passes on the integrated candidate; -all required exact integrated-head repair tracks pass at `c47d627`. Replacement -external checks remain. No service caller or feature action is active. +Its bounded implementation and external repair passed before PR #146 merged as +`0ffdabf`; signed schema-v2 memory at `eeb3dc2` recorded completion and stopped. +The user explicitly started AUTH-09D. Required L1 preimplementation review +rejected the combined lifecycle contract before runtime edits, so the parent is +split into 09D-A and 09D-B. AUTH-09D-A exact-head internal review passed at +`cc7e6cc` after all valid findings were repaired; ready PR #148 is open for +external checks and explicit human approval. CodeRabbit then found a valid +API/database lifecycle-reason whitespace mismatch and stale exact-head review +evidence. The bounded repair passes direct PostgreSQL and request-schema proof; +required exact-head internal review and canonical evidence pass at `7c33e64`; +replacement external checks remain. No service caller or feature action is +active. ## Active planning chunk @@ -108,13 +110,17 @@ None. `WS-AUTH-001-XINT` merged through PR #140. ## Active implementation chunk -`WS-AUTH-001-09C` - Actor And Identity-Link Administration Reads. This chunk -activates two system-authority read routes and no lifecycle mutation, service -admission, or feature action. +`WS-AUTH-001-09D-A` - Profile Lifecycle And Evidence Repair. Exact-SHA +preimplementation review passed at `7f941a5`; the three profile lifecycle +routes, migration `0026`, and repaired deterministic proof pass. PR #148's +bounded external repair aligns database whitespace rejection with the API and +passes focused proof plus exact-head internal review and canonical evidence at +`7c33e64`. Replacement external checks and explicit human approval are the +current gate. ## Current review branch -`codex/ws-auth-001-09c-actor-identity-admin-reads` +`codex/ws-auth-001-09d-actor-identity-lifecycle` ## Chunk status @@ -140,9 +146,11 @@ admission, or feature action. | `WS-AUTH-001-09` | Split | - | - | Split into 09A through 09E before runtime implementation. | | `WS-AUTH-001-09A` | Merged | `codex/ws-auth-001-09-actor-state-service-actors` | #132 | Merged as `299363a`; signed memory passed. | | `WS-AUTH-001-09B` | Merged | `codex/ws-auth-001-09b-controlled-service-provisioning` | #143 | Merged as `053242b`; signed memory passed. | -| `WS-AUTH-001-09C` | In review | `codex/ws-auth-001-09c-actor-identity-admin-reads` | #146 | Original external checks passed; all valid CodeRabbit findings repaired; integrated exact-head review passed; replacement checks pending. | -| `WS-AUTH-001-09D` | Proposed | - | - | Actor and identity-link lifecycle mutations. | -| `WS-AUTH-001-09E` | Proposed | - | - | Fixed service runtime admission after 09D. | +| `WS-AUTH-001-09C` | Merged | `codex/ws-auth-001-09c-actor-identity-admin-reads` | #146 | Merged as `0ffdabf`; signed memory `eeb3dc2` passed and stopped. | +| `WS-AUTH-001-09D` | Split | `codex/ws-auth-001-09d-actor-identity-lifecycle` | - | Required L1 review rejected the combined contract before runtime edits. | +| `WS-AUTH-001-09D-A` | Active | `codex/ws-auth-001-09d-actor-identity-lifecycle` | #148 | External and Backend repair, exact-head review, and canonical evidence pass at `7c33e64`; replacement external checks pending. | +| `WS-AUTH-001-09D-B` | Inactive | - | - | Identity-link lifecycle and race closure after 09D-A merge/memory and explicit start. | +| `WS-AUTH-001-09E` | Proposed | - | - | Fixed service runtime admission after 09D-B. | | `WS-AUTH-001-ART-CUSTODY` | Proposed | - | - | Availability-neutral 25-row ART owner transfer after 09E. | | `WS-AUTH-001-REV-CUSTODY` | Proposed | - | - | Availability-neutral 19-row REV owner transfer after 09E. | | `WS-AUTH-001-PREP` | Proposed | - | - | AUTH-first prepared mutation protocol after 09E. | @@ -160,13 +168,10 @@ merged feature manifests and separate human starts exist. ## Blockers -AUTH-09C passed deterministic PostgreSQL behavior, concurrency, rollback, -privacy, live HTTP, and focused 90 percent coverage proof. The original -exact-head L1 internal and external checks passed. CodeRabbit's five valid proof -findings are repaired, trusted `main` at `a10d901` is integrated, and focused -integrated evidence passes. All required exact integrated-head internal tracks -pass at `c47d627`; replacement GitHub checks and explicit human merge approval -remain. It must not add lifecycle mutation, service grants, dynamic assignments, +AUTH-09C has no remaining blocker. PR #146 merged as `0ffdabf` and signed +memory passed at `eeb3dc2`. AUTH-09D-A's repaired contract passed required L1 +preimplementation review at `7f941a5`; no planning blocker remains. It must not +add identity-link mutation, service grants, dynamic assignments, token-role authority, service admission, or feature-action activation. The four proposed REV lifecycle actions and review-evidence binding action are @@ -179,8 +184,9 @@ AUTH-10 additionally owns the clean cut across the typed `ProjectRole`, audit, and idempotency contracts plus the current PostgreSQL validators recreated by migration `0022`, removing obsolete `ProjectRole.BOTH` and replacement evidence. AUTH-09B owns migration `0024` for service-link verification timestamp -semantics, and ART owns `0025` for the ArtifactStore v2 clean cut. AUTH-10 -through AUTH-15 own shifted migrations `0026` through `0031` for their +semantics, ART owns `0025` for the ArtifactStore v2 clean cut, and AUTH-09D-A +owns `0026` for lifecycle evidence and profile reactivation provenance repair. +AUTH-10 through AUTH-15 own shifted migrations `0027` through `0032` for their action/evidence parity. AUTH-05A and CAT post-merge memory have no remaining blocker and are merged. diff --git a/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/chunks/WS-AUTH-001-09D-A-profile-lifecycle.md b/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/chunks/WS-AUTH-001-09D-A-profile-lifecycle.md new file mode 100644 index 00000000..1d715462 --- /dev/null +++ b/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/chunks/WS-AUTH-001-09D-A-profile-lifecycle.md @@ -0,0 +1,340 @@ +# Chunk Contract: WS-AUTH-001-09D-A - Profile Lifecycle And Evidence Repair + +Initiative: `WS-AUTH-001` | Risk: L1 / P1 | Status: preimplementation review + +## Goal + +Repair lifecycle evidence parity and activate exact Access Administrator +profile suspension, reactivation, and terminal deactivation with atomic replay, +audit, invalidation, and final-admin safety. + +## Why This Chunk Exists + +AUTH-09C supplies privacy-bounded exact-target reads. Administrators now need +safe profile lifecycle controls before service admission or project cutovers. +The existing schema pre-registers lifecycle operations, but it lacks profile +reactivation provenance and incorrectly forces reactivation invalidation from +`effective=true` to `effective=false`. + +## Allowed Files + +```text +backend/alembic/versions/0026_actor_profile_lifecycle.py +backend/app/modules/actors/models.py +backend/app/modules/actors/repository.py +backend/app/modules/actors/schemas.py +backend/app/modules/actors/service.py +backend/app/modules/authorization/catalogue.py +backend/app/modules/authorization/runtime.py +backend/app/modules/authorization/kernel.py +backend/app/modules/authorization/repository.py +backend/app/modules/authorization/schemas.py +backend/app/modules/authorization/service.py +backend/app/modules/authorization/router.py +backend/app/modules/authorization/lifecycle_schemas.py +backend/app/modules/authorization/lifecycle_service.py +backend/app/modules/audit/schemas.py +backend/tests/test_actors.py +backend/tests/test_auth.py +backend/tests/test_authorization.py +backend/tests/test_audit.py +backend/tests/test_api_controls.py +backend/tests/test_api_rate_controls.py +backend/tests/test_alembic.py +backend/scripts/api_contract_e2e.py +scripts/test_agent_gates.py +docs/spec_authorization_service.md +docs/operations_authorization_service.md +docs/architecture_data_model.md +.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/** +.agent-loop/merge-intents/WS-AUTH-001-09D-A.json +.agent-loop/LOOP_STATE.md +.agent-loop/WORK_QUEUE.md +.agent-loop/REVIEW_LOG.md +``` + +`scripts/test_agent_gates.py` may change only to require the exact 09D-A/09D-B +order, merged 09C state, and active 09D-A gate. The lifecycle service owns only +orchestration and reuses the existing authority mutation, idempotency, audit, +actor repository, admin repository, limiter, error, and route-owned transaction +primitives. No parallel ledger, authorization kernel, unit of work, or database +engine is allowed. + +## Not Allowed + +```text +identity-link lifecycle routes or action activation +new identity link or actor replacement +deactivation reversal or actor/link deletion +service admission or service-action evaluation +AdminRoleGrant, ProjectRoleGrant, Contributor, task, review, revision, artifact, +payment, or reputation mutation +compatibility route, response alias, fallback path, or old migration edit +``` + +## Exact Surface And Authority + +```text +POST /api/v1/actors/{actor_profile_id}/suspend +POST /api/v1/actors/{actor_profile_id}/reactivate +POST /api/v1/actors/{actor_profile_id}/deactivate +``` + +The routes declare and activate only the identically named ActionIds and +PermissionIds. Each requires an effective system-scoped Access Administrator, +the admin mutation limiter, a UUID `Idempotency-Key`, and one strict reason body. +Audit Authority, project-scoped authority, service callers, token roles, and +every other candidate deny before target lookup. + +The lifecycle reason is trimmed at its two ends, must contain 1 to 500 UTF-8 +bytes after trimming, and is then frozen for persistence and digest derivation. +Unknown fields, blank input, invalid UTF-8 length, and rejected values return a +non-echoing 422 response. + +The request body is exactly `{ "reason": "..." }`; `reason` is required and no +other request field is accepted. + +## Public Response + +Success and exact replay return exactly: + +```json +{ + "resource_type": "actor_profile", + "resource_id": "uuid", + "version": null, + "http_status": 200 +} +``` + +No event ID, grant, issuer, subject, contact data, raw reason, lifecycle actor, +or internal digest is returned. The idempotency record stores only this typed +resource reference; replay does not claim to return an evidence reference. + +## State And Field Contract + +| Operation | Allowed state | Mutation | New-key conflict | +|---|---|---|---| +| suspend | `active` | set `suspended`; write caller, database time, normalized reason; preserve prior reactivation provenance | `actor_already_suspended` or `actor_deactivated_terminal` | +| reactivate | `suspended` | set `active`; clear suspension fields; write profile reactivation caller/time/reason; never touch grants | `actor_not_suspended` or `actor_deactivated_terminal` | +| deactivate | `active` or `suspended` | set terminal `deactivated`; write caller/time/reason; preserve suspension/reactivation provenance | `actor_deactivated_terminal` | + +Human and fixed-service profiles are valid targets. Self-suspend and +self-deactivate return 403 `resource_guard_denied` before target disclosure. +A missing target returns 404 `actor_not_found` only after the caller has a +matching effective permission. Deactivated profiles never reactivate. + +Each domain conflict rolls back the pending reservation and staged allow +decision, then commits one privacy-safe denial in a clean transaction. It does +not consume the idempotency key. The exact committed success is the only replay. +State, final-admin, and authorized-missing-target conflicts use exactly +`SensitiveAuthorizationDenied` with the lifecycle ActionId/PermissionId, +target ActorProfile, categorical `authorization_evaluation` reason, exact +denial code, and no idempotency reference. No lifecycle variant of +`LastAccessAdministratorOperationDenied` is introduced. + +## Migration 0026 + +Migration `0026_actor_profile_lifecycle`: + +- adds nullable `reactivated_by`, `reactivated_at`, and `reactivation_reason` + columns to `actor_profiles` with all-null/all-present parity; +- adds all-null/all-present parity for existing identity-link reactivation + attribution and database UTF-8 byte bounds for lifecycle reasons; +- replaces profile/link lifecycle guards so terminal fields and attribution + cannot be rewritten without an allowed state transition; +- expands the closed denial vocabulary with + `identity_link_already_revoked` and `identity_link_not_revoked` for 09D-B; +- replaces current linked-evidence validation so profile and link reactivation + require `effective=false -> effective=true`, while suspension, deactivation, + and link revocation remain `effective=true -> effective=false`; +- makes link lifecycle invalidation target the owning ActorProfile authority + projection while the success response remains the exact link; +- updates typed validators and PostgreSQL functions together without editing + historical migrations. + +For lifecycle evidence, `effective` describes only the mutated profile or link +component. It does not assert whole-actor authenticatability, current grant +authority, or fixed-service admission. Profile reactivation is allowed while +the target link remains revoked; the profile becomes active but the actor +remains unable to authenticate. Service profile reactivation never admits the +service or activates a service action. + +Upgrade refuses before DDL when existing lifecycle rows contain partial +reactivation attribution or stored lifecycle reasons outside the new normalized +1-to-500-byte bounds. It never normalizes or deletes existing evidence. + +Downgrade fails closed if profile reactivation provenance or committed profile/ +link reactivation evidence exists, or if any audit row uses either new +identity-link conflict denial code. The refusal happens before DDL and leaves +schema and evidence unchanged. Otherwise it restores the prior functions, +closed denial vocabulary, constraints, guards, and columns. AUTH-10 through +AUTH-15 shift to migrations `0027` through `0032`. + +## Reservation, Lock, And Transaction Order + +For every new request: + +1. validate and freeze the canonical request; +2. reserve idempotency as the first database write; +3. authorize the exact action; +4. inside authorization, lock `AuthorityControl(id=1)`, caller profile, caller + link, and exact matched caller grant; +5. only after permission match, lock the target profile, its exact link, and any + active system Access Administrator target grant; +6. disclose replay/mismatch only after current authority succeeds; +7. enforce target state and final-admin safety; +8. mutate state, touch only the verified caller, append success and exactly one + invalidation obligation, complete idempotency, and commit once. + +All lifecycle mutations, including reactivation, take the singleton lock. This +preserves authorization-before-target-lookup concealment and gives profile, +link, and grant paths one caller-first order. The singleton serializes competing +final-admin changes, so cross-target actor-ID sorting is neither required nor +permitted as an alternate path. Target locks never advance target verification +timestamps. + +Suspension or deactivation of a currently effective Access Administrator fails +with 409 `last_access_administrator` when the post-transition count would be +zero. The check uses an active human profile, active exact link, and active +system Access Administrator grant. Grant revoke already uses the same singleton, +so crossed profile/grant loss cannot commit both transitions. + +## Replay, Failure, And Timestamp Contract + +- Exact replay reauthorizes, returns the stored resource reference even if the + target later changed state, and advances only the successful human caller's + verification timestamps. +- Changed reason or target under the same operation returns + `idempotency_mismatch` only after reauthorization. Operation is part of the + persisted idempotency namespace, so the same UUID used for another operation + is an independent claim. Replay after authority loss denies without + disclosure. +- Validation, rate limit, denial, missing target, conflict, mismatch, and every + SQL/evidence/completion/commit failure advance no timestamp. +- Authorization evidence, target lookup, reservation, state flush, caller + touch, success event, invalidation event, idempotency completion, and commit + failures return the stable retryable 503 envelope with no partial state, + evidence, timestamp, or pending claim. +- Reactivation is administration, not identity verification. Target + `last_seen_at` and link `last_verified_at` never advance. + +## Acceptance Criteria + +- Exactly three actions activate: catalogue totals become 65 actions, 15 active + and 50 planned. One generated manifest declaration exists per route. +- Profile state, readable attribution, exact action decision, success evidence, + ActorProfile invalidation, and idempotency completion commit atomically. +- Real PostgreSQL proof covers same-key replay, different-key same transition, + suspend/deactivate, reactivate/deactivate, profile/grant revoke, and + different-target final-admin loss without sleeps or deadlocks. +- Race outcomes are normative: + +| Race | Required committed outcome | +|---|---| +| same key, same operation/request | one success and one exact replay; one success/invalidation pair; no denial; completed key | +| different keys, same transition | one success and one state-conflict denial; one success/invalidation pair; one denial; losing key reusable | +| active suspend versus deactivate | suspend-first permits later deactivation; deactivate-first makes suspend terminal-conflict; final state deactivated | +| suspended reactivate versus deactivate | reactivate-first permits later deactivation; deactivate-first makes reactivate terminal-conflict; final state deactivated | +| two effective admins, reciprocal loss | one loss commits; the second request's caller is now inactive and fails current-authority revalidation; exactly one effective human admin remains | +| three effective admins, one caller targets the other two | both serialized losses may commit; the unchanged caller remains the one effective human admin | +| profile loss versus grant revoke | singleton order permits at most one final-authority loss; exactly one effective human admin remains | + +Every race asserts no deadlock, no pending claim, exact success/invalidation/ +denial counts, each key's completed or reusable disposition, and the final +profile/grant state. PostgreSQL blockers, never timing sleeps, establish order. +- Behavior matrices cover human/service targets, every state, self operations, + target concealment, replay/mismatch, caller authority changes, rollback, + timestamps, rate limits, OpenAPI, manifest parity, and privacy canaries. +- Responses, errors, logs, OpenAPI, and denial evidence exclude issuer, subject, + email, token data, raw reason, attribution IDs, matched-grant internals, and + stored digests. +- Focused actor and authorization branch coverage is at least 90 percent. + GitHub Backend preserves the repository-wide 78 percent floor. +- The authorization spec, operations runbook, and live architecture data model + document migration `0026`, profile reactivation provenance, link provenance + parity, component-scoped effectiveness, and the shift of AUTH-10 through + AUTH-15 to `0027` through `0032`. Archived reference specifications do not + change. + +## Verification Commands + +```bash +(cd backend && .venv/bin/python -m ruff check app tests scripts/api_contract_e2e.py) +(metadata_dir="$(mktemp -d)"; trap 'rm -rf "$metadata_dir"' EXIT; \ + cd backend && WORKSTREAM_TEST_ADMIN_DATABASE_URL= \ + .venv/bin/python scripts/run_isolated_tests.py \ + --metadata-json "$metadata_dir/migration.json" --timeout-seconds 1800 -- \ + bash -lc '.venv/bin/alembic downgrade -1 && .venv/bin/alembic upgrade head && \ + .venv/bin/python -m pytest -q \ + tests/test_alembic.py::test_actor_profile_lifecycle_fresh_and_prior_head_upgrade \ + tests/test_alembic.py::test_actor_profile_lifecycle_constraint_and_trigger_parity \ + tests/test_alembic.py::test_actor_profile_lifecycle_upgrade_refuses_dirty_rows \ + tests/test_alembic.py::test_actor_profile_lifecycle_safe_downgrade_and_reupgrade \ + tests/test_alembic.py::test_actor_profile_lifecycle_downgrade_refuses_forward_evidence') +(metadata_dir="$(mktemp -d)"; trap 'rm -rf "$metadata_dir"' EXIT; \ + cd backend && WORKSTREAM_TEST_ADMIN_DATABASE_URL= \ + .venv/bin/python scripts/run_isolated_tests.py \ + --metadata-json "$metadata_dir/focused.json" --timeout-seconds 3600 -- \ + .venv/bin/python -m pytest -q \ + tests/test_actors.py tests/test_authorization.py tests/test_audit.py \ + tests/test_api_controls.py tests/test_api_rate_controls.py tests/test_alembic.py \ + tests/test_auth.py::test_actor_profile_lifecycle_real_postgres_matrix \ + tests/test_auth.py::test_actor_profile_lifecycle_real_postgres_concurrency) +(metadata_dir="$(mktemp -d)"; trap 'rm -rf "$metadata_dir"' EXIT; \ + cd backend && WORKSTREAM_TEST_ADMIN_DATABASE_URL= \ + .venv/bin/python scripts/run_isolated_tests.py \ + --metadata-json "$metadata_dir/actor-coverage.json" --timeout-seconds 3600 -- \ + bash -lc '.venv/bin/coverage erase && .venv/bin/coverage run --branch \ + --source=app/modules/actors -m pytest -q \ + tests/test_actor_legacy_classification.py tests/test_actor_migration_tools.py \ + tests/test_actors.py \ + tests/test_auth.py::test_actor_profile_lifecycle_real_postgres_matrix \ + tests/test_auth.py::test_actor_profile_lifecycle_real_postgres_concurrency && \ + .venv/bin/coverage report --precision=2 --fail-under=90') +(metadata_dir="$(mktemp -d)"; trap 'rm -rf "$metadata_dir"' EXIT; \ + cd backend && WORKSTREAM_TEST_ADMIN_DATABASE_URL= \ + .venv/bin/python scripts/run_isolated_tests.py \ + --metadata-json "$metadata_dir/authorization-coverage.json" --timeout-seconds 3600 -- \ + bash -lc '.venv/bin/coverage erase && .venv/bin/coverage run --branch \ + --source=app/modules/authorization -m pytest -q tests/test_authorization.py \ + tests/test_auth.py::test_signed_flow_token_authorizes_actor_self_read_and_update \ + tests/test_auth.py::test_signed_tokens_bootstrap_and_admin_grant_lifecycle \ + tests/test_auth.py::test_admin_bootstrap_replay_and_cross_revoke_are_concurrency_safe \ + tests/test_auth.py::test_actor_admin_reads_hold_caller_and_grant_locks_through_disclosure \ + tests/test_auth.py::test_controlled_service_actor_provisioning_is_atomic_private_and_concurrent \ + tests/test_auth.py::test_service_actor_provisioning_failure_and_authority_races_are_atomic \ + tests/test_auth.py::test_actor_profile_lifecycle_real_postgres_matrix \ + tests/test_auth.py::test_actor_profile_lifecycle_real_postgres_concurrency && \ + .venv/bin/coverage report --precision=2 --fail-under=90') +(metadata_dir="$(mktemp -d)"; trap 'rm -rf "$metadata_dir"' EXIT; \ + cd backend && WORKSTREAM_TEST_ADMIN_DATABASE_URL= \ + .venv/bin/python scripts/run_isolated_tests.py \ + --metadata-json "$metadata_dir/api-contract.json" --timeout-seconds 3600 -- \ + .venv/bin/python scripts/api_contract_e2e.py) +python3 scripts/check_stale_workstream_wording.py +python3 scripts/check_stale_authorization_docs.py +python3 scripts/check_markdown_links.py +python3 scripts/test_agent_gates.py +python3 scripts/update_post_merge_memory.py validate-merge-intent --base-ref origin/main +git diff --check +``` + +The two named PostgreSQL nodes are created by this chunk and are mandatory +before implementation review; missing nodes make the proof command fail. + +## Required Reviewers + +Senior engineering, QA/test, security/auth, product/ops, architecture, CI +integrity, docs, reuse/dedup, and test delta. + +## Human Review Focus + +Review terminal deactivation, reactivation provenance, truthful invalidation +direction, reserve-before-authorize replay ordering, singleton-first serialized +authority, final-admin safety, caller-only timestamps, and rollback atomicity. + +## Stop Condition + +Stop after merge and signed memory. Do not start `WS-AUTH-001-09D-B` or +`WS-AUTH-001-09E` automatically. diff --git a/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/chunks/WS-AUTH-001-09D-B-identity-link-lifecycle.md b/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/chunks/WS-AUTH-001-09D-B-identity-link-lifecycle.md new file mode 100644 index 00000000..d2553afa --- /dev/null +++ b/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/chunks/WS-AUTH-001-09D-B-identity-link-lifecycle.md @@ -0,0 +1,70 @@ +# Chunk Contract: WS-AUTH-001-09D-B - Identity-Link Lifecycle And Race Closure + +Initiative: `WS-AUTH-001` | Risk: L1 / P1 | Status: inactive + +## Goal + +Activate exact identity-link revoke/reactivate behavior and close the mixed +profile, link, and grant final-admin concurrency matrix. + +## Start Gate + +AUTH-09D-A must merge, signed memory must stop, and the user must explicitly +start this child. No implementation begins from the 09D-A branch. + +## Allowed Boundary + +This child may change actor/authorization/audit runtime, tests, API drill, +authorization docs, its initiative artifacts, and its one merge intent. It may +not add a migration, new link, replacement actor, compatibility path, service +admission, grant mutation, or consumer lifecycle behavior. + +## Exact Surface + +```text +POST /api/v1/actor-identity-links/{identity_link_id}/revoke +POST /api/v1/actor-identity-links/{identity_link_id}/reactivate +``` + +Both require effective system Access Administrator authority, the mutation +limiter, UUID `Idempotency-Key`, and the same strict normalized lifecycle reason +contract as 09D-A. Success and replay return exactly the typed link resource ID, +null version, and HTTP 200. + +## Behavior Contract + +- Active-link revoke writes caller, database time, and reason. A revoked link + returns 409 `identity_link_already_revoked`. +- Revoked-link reactivate clears revoke fields; writes reactivation caller, + database time, and reason; preserves its immutable issuer/subject binding; and + returns 409 `identity_link_not_revoked` for an active link. +- A deactivated owning profile returns `actor_deactivated_terminal` and cannot + regain an authenticatable link. A suspended profile may have its link repaired + but remains unable to authenticate until separately reactivated. +- Human and fixed-service links are valid targets. Self-link revoke returns 403 + `resource_guard_denied`; a caller with a revoked own link cannot authorize its + own reactivation. +- Link success evidence targets the exact link and also binds the owning target + ActorProfile. Its invalidation obligation targets that ActorProfile authority + projection. Reactivation is `effective=false -> effective=true`; revocation is + `effective=true -> effective=false`. +- Link reactivation never restores a separately revoked AdminRoleGrant or + ProjectRoleGrant and never advances target verification timestamps. +- Domain conflicts roll back the reservation and staged allow, commit one exact + privacy-safe denial in a clean transaction, and do not consume the key. + +## Concurrency Closure + +All operations reuse 09D-A's reservation-first and singleton/caller-first lock +order. Real PostgreSQL tests cover revoke/reactivate, same-target different-key +races, profile/link loss, link/grant loss, and different-target final-admin loss. +At least one authenticatable effective Access Administrator remains after every +committed combination, and no test uses timing sleeps as lock evidence. + +## Acceptance And Stop + +Exactly two actions activate, producing the parent final totals of 65 actions, +17 active and 48 planned. Full replay, mismatch, conflict, rollback, timestamp, +privacy, rate/OpenAPI, manifest, coverage, and mixed-race matrices pass. + +Stop after merge and signed memory. AUTH-09E requires a separate explicit start. diff --git a/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/chunks/WS-AUTH-001-09D-actor-identity-lifecycle.md b/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/chunks/WS-AUTH-001-09D-actor-identity-lifecycle.md index 266532bb..a98bf68f 100644 --- a/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/chunks/WS-AUTH-001-09D-actor-identity-lifecycle.md +++ b/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/chunks/WS-AUTH-001-09D-actor-identity-lifecycle.md @@ -1,137 +1,46 @@ -# Chunk Contract: WS-AUTH-001-09D - Actor And Identity-Link Lifecycle Mutations +# Parent Chunk: WS-AUTH-001-09D - Actor And Identity-Link Lifecycle Mutations -## Goal - -Activate actor suspension/reactivation/deactivation and identity-link -revoke/reactivate with terminal history, idempotent atomic evidence, immediate -invalidation, and final Access Administrator concurrency safety. - -## Allowed files +Status: split before runtime implementation by required L1 plan review on +2026-07-18. -```text -backend/app/modules/actors/** -backend/app/modules/authorization/** -backend/app/modules/audit/** -backend/app/api/router.py -backend/app/api/deps/authorization.py -backend/tests/test_actors.py -backend/tests/test_auth.py -backend/tests/test_authorization.py -backend/tests/test_audit.py -backend/tests/test_api_controls.py -backend/tests/test_api_rate_controls.py -backend/tests/test_alembic.py -backend/scripts/api_contract_e2e.py -docs/spec_authorization_service.md -docs/operations_authorization_service.md -.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/** -.agent-loop/merge-intents/WS-AUTH-001-09D.json -.agent-loop/LOOP_STATE.md -.agent-loop/WORK_QUEUE.md -.agent-loop/REVIEW_LOG.md -``` - -## Not allowed +## Goal -```text -new table or migration -deactivation reversal or actor/link deletion -new or replacement identity-link creation -service, AdminRoleGrant, ProjectRoleGrant, or Contributor authority changes -task, review, assignment, or revision lifecycle mutation -artifact action activation or adapter attachment -``` +Provide irreversible, idempotent, audited actor and identity-link lifecycle +administration without losing the final authenticatable Access Administrator. -## Exact surfaces +## Why This Parent Was Split -```text -POST /api/v1/actors/{actor_profile_id}/suspend -POST /api/v1/actors/{actor_profile_id}/reactivate -POST /api/v1/actors/{actor_profile_id}/deactivate -POST /api/v1/actor-identity-links/{identity_link_id}/revoke -POST /api/v1/actor-identity-links/{identity_link_id}/reactivate -``` +The inherited contract combined five routes, a PostgreSQL evidence repair, +profile schema provenance, two resource families, and all mixed final-admin +races. Required review found that it also contradicted the implemented +reservation order, lacked a public lifecycle response, and could not represent +truthful reactivation invalidation. -Each route declares the identically named registered ActionId and PermissionId. -Only an effective Access Administrator candidate is valid. Bodies contain one -required normalized reason; mutations use an `Idempotency-Key` and the admin -mutation limiter. +No runtime code was changed under the rejected parent contract. -## State and replay contract +## Children -| Operation | Allowed current state | New-key conflict | +| Chunk | Scope | Gate | |---|---|---| -| suspend profile | active | `actor_already_suspended` or `actor_deactivated_terminal` | -| reactivate profile | suspended | `actor_not_suspended` or `actor_deactivated_terminal` | -| deactivate profile | active or suspended | `actor_deactivated_terminal` | -| revoke link | active link | `identity_link_already_revoked` | -| reactivate link | revoked link and non-deactivated profile | `identity_link_not_revoked` or `actor_deactivated_terminal` | - -An exact committed replay is resolved only after current caller authority is -revalidated and returns the original result/evidence reference. A mismatched -payload returns `idempotency_mismatch`. Reactivation never restores a separately -revoked grant. Suspended-to-deactivated preserves suspension attribution and -adds terminal deactivation attribution; deactivation is irreversible. - -## Atomic lock and evidence contract - -Final-admin-affecting mutations use one order in the route-owned transaction: -`AuthorityControl(id=1) FOR UPDATE`; all acting and target principals ordered by -ActorProfile ID; for each human, ActorProfile then exact ActorIdentityLink and -exact matched grant; exact idempotency reservation; effective-admin count after -the proposed transition; state, success event, exactly one invalidation -obligation, and idempotency completion; commit. -The effective count requires an active human profile, active identity link, and -active system-scoped Access Administrator grant. Self-suspend, -self-deactivate, final-admin loss, and unsafe concurrent different-target -combinations fail without partial state. - -## Boundary and acceptance - -- Exactly five actions activate: final totals are 65 actions, 17 active and 48 - planned. Every route has one generated manifest declaration. -- State, idempotency, success evidence, and invalidation commit atomically; - evidence/SQL/commit failure returns stable retryable 503 and rolls back all - partial state. Denial/conflict evidence follows the closed audit contract. -- Same-target races and mixed link-revoke/grant-revoke/suspend/deactivate races - leave at least one effective, authenticatable Access Administrator. -- Only successful caller verification timestamps advance. Target verification - timestamps never advance from administration, reactivation is not token - verification, and replay/denial/failure behavior is explicit in tests. -- AUTH-09D emits the durable invalidation obligation but does not mutate task or - review state. AUTH-13 and WS-REV own consumption; no `needs_revision` work is - returned to ready here. -- Focused actor and authorization coverage is at least 90 percent; GitHub - Backend preserves the repository-wide 78 percent floor. - -## Risk and reviewers - -L1 / P1. Required: senior engineering, QA/test, security/auth, product/ops, -architecture, CI integrity, docs, reuse/dedup, and test delta. - -## Verification commands - -```bash -(cd backend && .venv/bin/python -m ruff check app tests scripts/api_contract_e2e.py) -(cd backend && WORKSTREAM_DATABASE_URL= .venv/bin/python -m pytest -q \ - tests/test_actors.py tests/test_auth.py tests/test_authorization.py \ - tests/test_audit.py tests/test_api_controls.py tests/test_api_rate_controls.py \ - tests/test_alembic.py) -(cd backend && WORKSTREAM_DATABASE_URL= .venv/bin/python scripts/api_contract_e2e.py) -python3 scripts/check_stale_workstream_wording.py -python3 scripts/check_stale_authorization_docs.py -python3 scripts/check_markdown_links.py -git diff --check -``` - -## Human review focus - -Review terminal state transitions, exact replay ordering, route-owned atomicity, -shared final-admin locking, caller-only verification timestamps, and downstream -invalidation without consumer-state mutation. - -## Stop condition - -Stop if any consumer lifecycle must be mutated or service authority must be -treated as a human grant. Stop after merge and signed memory; AUTH-09E requires -a new explicit start. +| `WS-AUTH-001-09D-A` | Shared typed/PostgreSQL lifecycle evidence parity, profile reactivation provenance, and profile suspend/reactivate/deactivate | Active after the user's 09D start and repaired-contract review | +| `WS-AUTH-001-09D-B` | Identity-link revoke/reactivate plus mixed profile/link/grant final-admin race closure | Inactive until 09D-A merge, signed memory, and a separate explicit start | + +## Shared Invariants + +- Only effective system-scoped Access Administrators mutate lifecycle state. +- Reservation is the first database write. Current authority is revalidated + before replay or mismatch is disclosed. +- Every lifecycle mutation uses `AuthorityControl(id=1)` before serialized + caller and target locks. The singleton serializes every final-admin-affecting + path, so no competing mutation uses a different authority lock order. +- Deactivation is terminal. Reactivation never restores a revoked grant. +- State, success evidence, one invalidation obligation, and idempotency + completion commit once in the route-owned transaction. +- Consumer task/review state, service admission, grants, and assignments are not + mutated by either child. + +## Stop Condition + +Each child stops after merge and signed memory. `WS-AUTH-001-09E` remains +inactive until both children merge and the user gives a separate explicit +start. diff --git a/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/chunks/WS-AUTH-001-09E-fixed-service-runtime-admission.md b/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/chunks/WS-AUTH-001-09E-fixed-service-runtime-admission.md index c3320420..d93e642e 100644 --- a/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/chunks/WS-AUTH-001-09E-fixed-service-runtime-admission.md +++ b/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/chunks/WS-AUTH-001-09E-fixed-service-runtime-admission.md @@ -17,8 +17,8 @@ product/ops, architecture, CI integrity, docs, reuse/dedup, and test delta. merged; - AUTH-09B controlled provisioning can create the exact service ActorProfile and one active identity link; -- AUTH-09C administrative reads and AUTH-09D actor/link lifecycle mutations are - merged; +- AUTH-09C administrative reads plus AUTH-09D-A profile lifecycle and 09D-B + identity-link lifecycle are merged; - service lifecycle state is canonical and no feature service action is active merely because this chunk starts. diff --git a/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/chunks/WS-AUTH-001-10-project-role-grants.md b/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/chunks/WS-AUTH-001-10-project-role-grants.md index 1d72a868..e0e44598 100644 --- a/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/chunks/WS-AUTH-001-10-project-role-grants.md +++ b/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/chunks/WS-AUTH-001-10-project-role-grants.md @@ -45,7 +45,7 @@ backend/app/modules/projects/repository.py backend/app/api/router.py backend/app/db/models.py backend/app/modules/audit/** -backend/alembic/versions/0026_*.py +backend/alembic/versions/0027_*.py backend/tests/test_actors.py backend/tests/test_projects.py backend/tests/test_auth.py @@ -140,9 +140,9 @@ editing migrations `0018`, `0019`, or `0022` project deny, pagination/count concealment, minimal-field, rate-limit, and inactive/non-human exclusion tests; no UUID must be recovered from logs or direct database access. -- Migration `0026` enforces exact three-role checks, composite snapshot/grant +- Migration `0027` enforces exact three-role checks, composite snapshot/grant ownership, partial unique/supporting indexes, database-time fields, and - immutability. Migration `0026` refuses upgrade when obsolete combined or + immutability. Migration `0027` refuses upgrade when obsolete combined or replacement evidence exists and never converts or deletes those rows. It replaces current audit/idempotency validators without editing historical migrations and refuses an unsafe downgrade without mutating evidence. diff --git a/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/chunks/WS-AUTH-001-11-project-read-cutover.md b/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/chunks/WS-AUTH-001-11-project-read-cutover.md index 15fa9711..f1f1fe67 100644 --- a/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/chunks/WS-AUTH-001-11-project-read-cutover.md +++ b/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/chunks/WS-AUTH-001-11-project-read-cutover.md @@ -3,7 +3,7 @@ ## Status Proposed and inactive. The exact project read/list ActionId inventory and -migration `0027` mapping delta must be added before implementation review. +migration `0028` mapping delta must be added before implementation review. ## Parent initiative @@ -43,7 +43,7 @@ backend/app/modules/projects/repository.py backend/app/modules/projects/schemas.py backend/app/modules/authorization/** backend/app/modules/audit/** -backend/alembic/versions/0027_*.py +backend/alembic/versions/0028_*.py backend/app/api/deps/auth.py backend/tests/test_projects.py backend/tests/test_auth.py @@ -91,7 +91,7 @@ issuer-role fallback or authorization pagination after unfiltered counts read/list surface migrated here has exactly one active `ActionId` declaration. - Before runtime edits, the contract enumerates every new ActionId, existing PermissionId mapping, canonical target, principal class, facts, guards, and - surface. Migration `0027` updates typed/PostgreSQL action-evidence parity and + surface. Migration `0028` updates typed/PostgreSQL action-evidence parity and proves prior-head upgrade, downgrade, re-upgrade, and fresh replay. - ProjectRepository remains the canonical project/guide/source persistence query owner and returns domain records. The project application service or a diff --git a/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/chunks/WS-AUTH-001-12-project-mutation-cutover.md b/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/chunks/WS-AUTH-001-12-project-mutation-cutover.md index c7583ccb..6c12ba4a 100644 --- a/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/chunks/WS-AUTH-001-12-project-mutation-cutover.md +++ b/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/chunks/WS-AUTH-001-12-project-mutation-cutover.md @@ -2,7 +2,7 @@ ## Status -Proposed and inactive. Exact ActionIds and migration `0028` mapping/provenance +Proposed and inactive. Exact ActionIds and migration `0029` mapping/provenance delta must be enumerated before implementation; AUTH-PREP is required. ## Parent initiative @@ -42,7 +42,7 @@ backend/app/modules/projects/** backend/app/modules/authorization/** backend/app/api/deps/auth.py backend/app/workers/project_setup.py -backend/alembic/versions/0028_*.py +backend/alembic/versions/0029_*.py backend/tests/test_projects.py backend/tests/test_auth.py backend/tests/test_alembic.py @@ -89,7 +89,7 @@ unscoped project-manager access or token role fallback declaration. - Approval provenance records matched local grant/actor/scope while preserving historical bootstrap provenance. -- Migration `0028` adds exact action-evidence parity plus matched local +- Migration `0029` adds exact action-evidence parity plus matched local grant/scope provenance and ownership constraints to project policy approval records without rewriting historical bootstrap values; prior-head upgrade, downgrade, and re-upgrade preserve diff --git a/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/chunks/WS-AUTH-001-13-task-assignment-cutover.md b/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/chunks/WS-AUTH-001-13-task-assignment-cutover.md index 9b41f90c..6b7568e2 100644 --- a/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/chunks/WS-AUTH-001-13-task-assignment-cutover.md +++ b/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/chunks/WS-AUTH-001-13-task-assignment-cutover.md @@ -2,7 +2,7 @@ ## Status -Proposed and inactive. Exact ActionIds and migration `0029` must be enumerated +Proposed and inactive. Exact ActionIds and migration `0030` must be enumerated before implementation; AUTH-PREP is required for mutations. ## Parent initiative @@ -44,7 +44,7 @@ backend/app/modules/tasks/repository.py backend/app/modules/tasks/schemas.py backend/app/modules/tasks/models.py backend/app/modules/tasks/lifecycle.py -backend/alembic/versions/0029_*.py +backend/alembic/versions/0030_*.py backend/app/modules/authorization/** backend/app/modules/audit/** backend/app/api/deps/auth.py @@ -97,7 +97,7 @@ token role or legacy active-worker-profile fallback `operations.task.start_override` PermissionId/ActionId typed and PostgreSQL parity as planned metadata. This chunk promotes the action only with its task resource composer, Operator candidate, guards, surface declaration, reason, - evidence, and behavior tests. Migration `0029` owns task/assignment, + evidence, and behavior tests. Migration `0030` owns task/assignment, Contributor-field, and exact new ActionId evidence parity; it changes no PermissionId mapping. - Operator `operations.status.read` exposes a read-only cross-project task-queue @@ -138,7 +138,7 @@ token role or legacy active-worker-profile fallback remains bounded only because chunk 14 still owns the final submission compatibility consumer; task queue/claim/start no longer depend on it. - The assignment persistence column, model/schema/service fields, response - contract, and new audit payload keys use `contributor_id`. Migration `0029` + contract, and new audit payload keys use `contributor_id`. Migration `0030` preserves every existing assignment owner, supports downgrade, and removes the legacy storage name without exposing a public compatibility alias. - Full backend suite and API contract drill pass. diff --git a/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/chunks/WS-AUTH-001-14-submission-checker-cutover.md b/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/chunks/WS-AUTH-001-14-submission-checker-cutover.md index c1030d0b..55044437 100644 --- a/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/chunks/WS-AUTH-001-14-submission-checker-cutover.md +++ b/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/chunks/WS-AUTH-001-14-submission-checker-cutover.md @@ -2,7 +2,7 @@ ## Status -Proposed and inactive. Exact ActionIds and migration `0030` must be enumerated +Proposed and inactive. Exact ActionIds and migration `0031` must be enumerated before implementation; AUTH-PREP is required for mutations. ## Parent initiative @@ -46,7 +46,7 @@ backend/app/modules/checkers/** backend/app/modules/projects/schemas.py backend/app/modules/projects/service.py backend/app/adapters/project_agents/openai_agent_sdk.py -backend/alembic/versions/0030_*.py +backend/alembic/versions/0031_*.py backend/app/modules/authorization/** backend/app/modules/audit/** backend/app/api/deps/auth.py @@ -101,7 +101,7 @@ legacy active-worker-profile or workflow-eligibility compatibility fallback PermissionId/ActionId typed and PostgreSQL parity as planned metadata. This chunk promotes each action only with its feature resource composer, Operator candidate, guards, surface declaration, reason, evidence, and behavior tests. - Migration `0030` owns submission/checker Contributor-field schema changes and + Migration `0031` owns submission/checker Contributor-field schema changes and exact new ActionId evidence parity; it changes no PermissionId mapping. - Contributor reads preserve ownership, hidden-result redaction, and concealed not-found behavior. @@ -121,7 +121,7 @@ legacy active-worker-profile or workflow-eligibility compatibility fallback `contributor_suggested_fix`, `contributor_evidence_refs`, and `contributor_visible` across persistence, models, schemas, services, runner contracts, audit payloads, and tests. Submission-policy JSON and derivation - contracts use `contributor_facing_fix`. Migration `0030` preserves all values, + contracts use `contributor_facing_fix`. Migration `0031` preserves all values, supports downgrade, and removes legacy storage/property names without public API aliases. - With the final consumer removed, the legacy `/api/v1/workers/me/profile` diff --git a/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/chunks/WS-AUTH-001-15-worker-authority-removal.md b/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/chunks/WS-AUTH-001-15-worker-authority-removal.md index 0d93f5f5..f5aa4cc6 100644 --- a/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/chunks/WS-AUTH-001-15-worker-authority-removal.md +++ b/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/chunks/WS-AUTH-001-15-worker-authority-removal.md @@ -3,7 +3,7 @@ ## Status Proposed and inactive. Exact remaining command ActionIds, service identities, -and migration `0031` parity must be enumerated before implementation. +and migration `0032` parity must be enumerated before implementation. ## Parent initiative @@ -51,7 +51,7 @@ backend/app/core/permissions.py backend/app/schemas/auth.py backend/app/api/deps/auth.py backend/app/modules/audit/** -backend/alembic/versions/0031_*.py +backend/alembic/versions/0032_*.py backend/scripts/api_contract_e2e.py backend/tests/test_projects.py backend/tests/test_tasks.py @@ -112,7 +112,7 @@ review/contribution/compensation implementation - Scanner has regression tests and runs in CI. - Scanner regression includes a known-bad fixture for each forbidden authority pattern and proves the gate fails rather than silently allowlisting it. -- Migration `0031` adds exact remaining command ActionId evidence parity and any +- Migration `0032` adds exact remaining command ActionId evidence parity and any approved fixed-service constraints; it changes no existing permission mapping and proves prior-head/fresh upgrade, downgrade, and re-upgrade. - Full backend suite and API contract drill pass. diff --git a/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/reviews/WS-AUTH-001-09D-A-external-review-response.md b/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/reviews/WS-AUTH-001-09D-A-external-review-response.md new file mode 100644 index 00000000..b445cab5 --- /dev/null +++ b/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/reviews/WS-AUTH-001-09D-A-external-review-response.md @@ -0,0 +1,72 @@ +# WS-AUTH-001-09D-A External Review Response + +PR: `#148` + +External review basis: branch head `e7f54c374482d097d8ecec77ba059448fa4b9a9f` + +## Valid Findings + +### Database And API Reason Normalization Differed + +CodeRabbit correctly found that `ActorLifecycleBody` uses Python `str.strip()` +while the PostgreSQL lifecycle constraints used one-argument `btrim`, which +removes ordinary spaces but not tab or newline padding. A direct database write +could therefore persist a reason the public API would normalize. + +The repair gives both ActorProfile and ActorIdentityLink lifecycle constraints +the exact 29 code points treated as whitespace by `str.strip()` across the +currently supported Python runtimes, including ASCII whitespace, non-breaking +space, Unicode separators, and ideographic space. Migration `0026` uses the +same expression for its pre-DDL dirty-row refusal and installed constraints. +Direct-write tests cover +all five lifecycle reason fields plus non-breaking space, and previous-head +dirty-row cases prove ASCII and Unicode padding stop the upgrade without +changing revision, schema, or data. + +### Internal Review Evidence Was Stale + +CodeRabbit and both GitHub workflows correctly found that the evidence file's +reviewed SHA preceded later non-evidence state changes. The evidence table also +placed an unrecognized `migration/data integrity` row between canonical rows, +causing the deterministic parser to stop before CI integrity, docs, +reuse/dedup, and test delta. + +The repair requires all reviewer tracks to rerun against the final candidate. +The regenerated evidence will use only recognized canonical rows in its table, +record migration/data-integrity proof outside that table, and name the exact +reviewed SHA. No global queue or review-log file will change after that SHA. + +### Full-Suite Migration Fixture Used An Invalid Reactivation + +The replacement Backend run exposed one historical migration test helper that +reset a revoked identity link by clearing revocation fields directly. Migration +`0026` correctly rejected that unattributed reactivation. The first failure +left the shared migration fixture inactive, which caused the remaining 18 +Alembic failures and 354 downstream setup errors. + +The test-only repair disables both lifecycle history guards for explicit +fixture cleanup, restores profile and identity-link status plus all lifecycle +provenance to the neutral active state, and re-enables both guards in `finally`. +The original rollback-refusal assertion and the immediately following migration +test pass together, proving clean state transfer between tests. Production +guards and migration behavior are unchanged. + +## Non-Actionable Warning + +CodeRabbit's generic diff-local docstring percentage is not a configured +Workstream gate and does not distinguish production APIs from migration and +behavior-test helpers. Adding narration-only docstrings would not improve the +public contract or behavior proof. Existing repository Ruff, coverage, review, +and documentation gates remain authoritative and unchanged. + +## Repair Evidence + +- Direct installed-constraint and previous-head dirty-row migration nodes: + `2 passed in 111.55s`. +- Full five-node lifecycle migration suite: `5 passed in 250.95s`. +- Historical rollback-guard cleanup plus following migration node: + `2 passed in 110.04s`. +- Strict lifecycle request-schema node: `1 passed in 9.46s`. +- Focused Ruff and `git diff --check`: passed. +- Required exact-head internal review, replacement GitHub checks, CodeRabbit, + and explicit human merge approval remain required. diff --git a/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/reviews/WS-AUTH-001-09D-A-internal-review-evidence.md b/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/reviews/WS-AUTH-001-09D-A-internal-review-evidence.md new file mode 100644 index 00000000..ed31f782 --- /dev/null +++ b/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/reviews/WS-AUTH-001-09D-A-internal-review-evidence.md @@ -0,0 +1,100 @@ +# WS-AUTH-001-09D-A Internal Review Evidence + +Reviewed code SHA: `7c33e6453a2c91256c8fd416c63e30b95fd9d825` + +Reviewed implementation SHA: `7c33e6453a2c91256c8fd416c63e30b95fd9d825` + +Reviewed against trusted main: +`f18b620932bb257dc1dc355bc0504271813dc6b1` + +Reviewed at: `2026-07-18T18:08:51Z` + +Reviewer run IDs: `auth_xint_roles`, `auth_xint_rev_con`, +`auth_xint_art_service` + +Reviewer tracks: senior engineering, QA/test, security/auth, product/ops, +architecture, migration/data integrity, CI integrity, docs, reuse/dedup, and +test delta + +## Deterministic Evidence + +- Migration `0026_actor_profile_lifecycle` is the sole head after trusted ART + migration `0025`. Five exact upgrade, guard, dirty-row, downgrade, and + re-upgrade nodes pass in 250.95 seconds on isolated PostgreSQL. The two + repaired guard and dirty-row nodes pass in 111.55 seconds and prove all 29 + `str.strip()` whitespace code points plus previous-head NBSP refusal. +- The real signed-token lifecycle matrix covers human and fixed-service profile + suspend, reactivate, and terminal deactivate behavior. It also covers all nine + injected transaction failure stages with stable 503 responses and no partial + state, audit, timestamp, or idempotency residue. It passes in 100.36 seconds. +- Ordered races identify each second PostgreSQL connection and observe + `wait_event_type='Lock'` before releasing the first request. The exact race + node passes in 140.69 seconds without timing sleeps establishing order. +- Actor branch coverage passes at 90.70 percent. Authorization branch coverage + passes 110 tests at 91.78 percent, and the lifecycle service is 100 percent + branch-covered. The external repair changes reason normalization constraints + and additive behavior proof without changing route or authorization logic. +- The exact catalogue test passes with 65 ActionIds: 15 active and 50 planned. + Three historical migration nodes affected by the owner split pass in 199.52 + seconds, and all 30 Alembic tests collect without a stale enum error. +- The Backend failure was one historical fixture reset violating the new + identity-link reactivation guard, followed by 18 Alembic failures and 354 + downstream setup errors. The repaired rollback node and immediately following + migration node pass together in 110.04 seconds without weakening the original + revoked, suspended, or deactivated refusal assertions. +- The real HTTP API contract, repository-wide Ruff, stale Workstream and + authorization scans, Markdown links, all 87 Agent Gates, merge-intent + validation, and diff integrity pass. No workflow, dependency, threshold, + exclusion, or skip changed. + +## Reviewer Results + +| Reviewer | Result | Blocking findings | Notes | +|---|---|---|---| +| senior engineering | PASS AFTER FIXES | none | Lifecycle state, transaction ownership, idempotency, rollback, and lock order are coherent. | +| QA/test | PASS AFTER FIXES | none | Additive proof covers every Python whitespace point, all five reason fields, prior-head refusal, and the original lifecycle matrix. | +| security/auth | PASS AFTER FIXES | none | Denials conceal matched-grant internals, target disclosure follows authorization, and fresh reactivation attribution is enforced. | +| product/ops | PASS | none | Authorized administrators can manage exact profiles without admitting service callers or activating link mutations. | +| architecture | PASS AFTER FIXES | none | Central AUTH ownership, route-owned commit, and exact 09D-A/09D-B activation custody remain intact. | +| CI integrity | PASS AFTER FIXES | none | No workflow, dependency, threshold, exclusion, or skip changed; deterministic gates remain intact. | +| docs | PASS AFTER FIXES | none | Supported-runtime wording, specification, operations, trust, and durable state evidence agree. | +| reuse/dedup | PASS | none | Existing AUTH kernel, repositories, evidence, idempotency, and transaction helpers are reused. | +| test delta | PASS AFTER FIXES | none | Tests are additive and behavior-based; no assertion, threshold, or selection was weakened. | + +## Findings Resolved + +The first QA review found four blockers: denial evidence retained matched-grant +IDs, database guards accepted missing or stale reactivation provenance, the +fixed-service/failure matrix was incomplete, and races did not observe a real +database waiter. All four were repaired and independently re-reviewed. + +Architecture/migration review then found two blockers: code-owned activation +custody still named the rejected 09D parent, and migration refusal branches were +not all exercised. Exact child owners and the complete refusal matrix were +added. Final review found four historical Alembic selectors using the removed +parent enum; they now preserve their original include/exclude semantics with +both child owners. + +External review then found PostgreSQL's one-argument `btrim` did not match API +`str.strip()` behavior and that exact-head evidence was stale. The database now +captures the 29-point supported-runtime whitespace set in live metadata and +migration `0026`; direct constraint and dirty-upgrade tests prove the boundary. +The canonical reviewer table no longer contains an unrecognized migration row. +Migration/data-integrity review passes outside the canonical table: `0026` +remains the sole head, pre-DDL refusal preserves revision/schema/data, and all +five migration nodes pass. The replacement Backend failure then exposed a +test-only cleanup path that performed an unattributed reactivation. Both history +guards are now disabled only around neutral fixture restoration and re-enabled +in `finally`; all lifecycle provenance is cleared and production guards remain +unchanged. All required tracks pass final exact reviewed head +`7c33e6453a2c91256c8fd416c63e30b95fd9d825`. + +Valid findings addressed: yes + +Open sub-agent sessions: none + +## Remaining Gate + +GitHub Backend, Agent Gates, CodeRabbit, and explicit human merge approval are +external gates. `WS-AUTH-001-09D-B` remains inactive until this chunk merges, +trusted-main memory passes, and the user explicitly starts it. diff --git a/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/reviews/WS-AUTH-001-09D-A-pr-trust-bundle.md b/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/reviews/WS-AUTH-001-09D-A-pr-trust-bundle.md new file mode 100644 index 00000000..e05bea1e --- /dev/null +++ b/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/reviews/WS-AUTH-001-09D-A-pr-trust-bundle.md @@ -0,0 +1,119 @@ +# WS-AUTH-001-09D-A PR Trust Bundle + +## Chunk + +`WS-AUTH-001-09D-A` - Profile Lifecycle And Evidence Repair + +## Goal + +Add exact administrative ActorProfile suspension, reactivation, and terminal +deactivation while repairing truthful lifecycle provenance and linked authority +evidence at the database boundary. + +## Human-Approved Intent + +The user explicitly started AUTH-09D. Required L1 review rejected the combined +parent contract before runtime edits and split it into 09D-A and separately +gated 09D-B. This PR implements only 09D-A. + +## What Changed + +- Added migration `0026_actor_profile_lifecycle` with profile reactivation + provenance, API-equivalent whitespace normalization and 1-to-500-byte reason + guards, fresh profile/link transition attribution, truthful reactivation + direction, and fail-closed upgrade/downgrade checks. +- Added exact profile suspend, reactivate, and terminal deactivate routes using + reservation-first idempotency, the central AUTH kernel, serialized authority + locks, one route-owned commit, and bounded responses. +- Activated only the three profile lifecycle actions. Their exact owner is + `WS-AUTH-001-09D-A`; both identity-link lifecycle actions remain planned under + `WS-AUTH-001-09D-B`. +- Added behavior proof for privacy, evidence, rollback, fixed-service targets, + failure injection, real PostgreSQL lock waits, migration refusal, OpenAPI, + and the live API contract. + +## Design Chosen + +The route reserves its idempotency key before authorization, locks the authority +singleton and verified caller lineage before the exact target, revalidates the +current system grant, stages the profile transition and linked invalidation +evidence in one transaction, touches only the human caller, and commits once. +Missing targets and SQL failures roll back all staged evidence and state. + +Reactivation provenance must be complete, non-null, and different from the +previous transition tuple. Deactivation is terminal. Fixed-service profiles may +be administrative targets, but this chunk never admits a service caller. + +## Scope Control + +Trusted main through REV PR #147 at `f18b620` is integrated. No identity-link +mutation route, service admission, feature-owned authorization, compatibility +alias, workflow, dependency, coverage threshold, or historical migration +changed. + +## Tests And Checks Run + +- Five exact isolated PostgreSQL migration nodes: passed in 250.95 seconds. +- Repaired constraint and dirty-row nodes: passed in 111.55 seconds, covering + all 29 supported-runtime `str.strip()` whitespace code points and NBSP + previous-head refusal. +- Historical rollback-guard cleanup and the immediately following migration + node: passed together in 110.04 seconds. +- Expanded real lifecycle/service/failure matrix: passed in 100.36 seconds. +- PostgreSQL-observed concurrency node: passed in 140.69 seconds. +- Three historical owner-split migration nodes: passed in 199.52 seconds. +- Authorization coverage: 110 passed, 91.78 percent branch coverage. +- Actor coverage: 90.70 percent branch coverage. +- Lifecycle service: 100 percent branch coverage. +- Live HTTP API contract: passed. +- Repository-wide Ruff, stale scans, Markdown links, 87 Agent Gates, + merge-intent validation, and diff integrity: passed. + +## Test Delta + +Tests assert exact action/permission/owner parity, normalized reason bounds, +API/database whitespace parity for all 29 supported-runtime points, fresh +transition provenance, denial privacy, service-target invariance, all nine +transaction failure stages, reusable failed idempotency keys, and actual +PostgreSQL waiter observation. No test was skipped, weakened, or rewritten to +conceal behavior. + +## CI Integrity + +The repository-wide 78 percent floor and focused 90 percent subsystem floors +remain unchanged. No workflow, dependency, threshold, skip, or exclusion was +modified. GitHub Backend remains authoritative for the full suite. + +## Reviewer Results + +Senior engineering, QA/test, security/auth, product/ops, architecture, +migration/data integrity, CI integrity, docs, reuse/dedup, and test delta all +pass exact integrated head `7c33e6453a2c91256c8fd416c63e30b95fd9d825` +after every valid finding was repaired. + +## Remaining Risks + +These system-authority routes intentionally change exact profile lifecycle +state and produce append-only authority evidence. PostgreSQL serialization and +database guards contain concurrent and direct-write risk. Identity-link state +can still make a reactivated profile unable to authenticate; that component +truth is deliberate and remains visible to later AUTH logic. + +## Follow-Up Work + +`WS-AUTH-001-09D-B` owns identity-link revoke/reactivate routes and mixed +profile/link/grant race closure only after this PR merges, trusted-main memory +passes, and the user gives a new explicit start. `WS-AUTH-001-09E` remains the +later fixed-service runtime-admission boundary. + +## Human Review Focus + +Review terminal deactivation, fresh reactivation provenance, denial privacy, +reservation and lock order, fixed-service non-admission, rollback atomicity, +exact child action ownership, and observed PostgreSQL waiter proof. + +## Human Merge Ownership + +The agent may publish and repair this branch but may not merge it. Only the +human may approve and merge the PR. Trusted-main automation owns post-merge +memory; 09D-B does not start automatically. diff --git a/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/reviews/WS-AUTH-001-09D-A-preimplementation-review-evidence.md b/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/reviews/WS-AUTH-001-09D-A-preimplementation-review-evidence.md new file mode 100644 index 00000000..42d9772a --- /dev/null +++ b/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/reviews/WS-AUTH-001-09D-A-preimplementation-review-evidence.md @@ -0,0 +1,44 @@ +# WS-AUTH-001-09D-A Preimplementation Review Evidence + +Reviewed contract SHA: `7f941a53b4a30e6116d9a13fce6246f7aa943ac3` + +## Result + +PASS. Runtime implementation may begin for 09D-A only. + +## Tracks + +| Track | Result | Bound review focus | +|---|---|---| +| Senior engineering | PASS | Bounded split, canonical reuse, transaction ownership, terminal lifecycle semantics | +| Security/auth | PASS | Permission-before-disclosure, replay reauthorization, self guards, final-admin serialization, privacy | +| Product/ops | PASS | Human/service targets, profile state transitions, service non-admission, stable conflicts | +| QA/test delta | PASS | Normative state/replay/race/rollback matrices, exact future nodes, no test weakening | +| Architecture | PASS | Reservation-first plus singleton/caller-first locks, canonical repository, no parallel authority path | +| Migration | PASS | Forward `0026`, dirty-row preflight, safe/refused downgrade, truthful invalidation direction | +| CI integrity | PASS | Isolated PostgreSQL commands, metadata, migration round trip, 90.00 percent focused coverage, 78 percent global floor | +| Documentation | PASS | Live spec/runbook/data-model custody; archived references excluded | +| Reuse/dedup | PASS | Existing idempotency, audit, actor, admin repository, limiter, error, and route commit primitives reused | + +## Repaired Findings + +- Split combined 09D into separately gated 09D-A and 09D-B before runtime edits. +- Assigned lifecycle evidence repair and profile reactivation provenance to + migration `0026`; shifted AUTH-10 through AUTH-15 to `0027` through `0032`. +- Reconciled reservation, authorization, lock, response, replay, conflict, + component-effectiveness, final-admin, timestamp, and privacy semantics. +- Added exact upgrade/downgrade refusal rules, normative race outcomes, real + PostgreSQL proof, API drill, and precise coverage commands. +- Corrected canonical repository reuse and distinguished mutable current-row + attribution from immutable append-only lifecycle evidence. + +## Deterministic Planning Evidence + +- `python3 scripts/test_agent_gates.py`: 80 passed. +- stale Workstream wording: passed. +- stale authorization documentation: passed. +- Markdown links: passed. +- `git diff --check`: passed. + +PostgreSQL implementation nodes intentionally do not exist at this gate. Their +absence remains a failing implementation proof, not preimplementation evidence. diff --git a/.agent-loop/merge-intents/WS-AUTH-001-09D-A.json b/.agent-loop/merge-intents/WS-AUTH-001-09D-A.json new file mode 100644 index 00000000..3672c280 --- /dev/null +++ b/.agent-loop/merge-intents/WS-AUTH-001-09D-A.json @@ -0,0 +1,9 @@ +{ + "chunk_id": "WS-AUTH-001-09D-A", + "chunk_title": "Profile Lifecycle And Evidence Repair", + "initiative_id": "WS-AUTH-001", + "next_chunk_id": "WS-AUTH-001-09D-B", + "next_chunk_title": "Identity-Link Lifecycle And Race Closure", + "next_requires_explicit_start": true, + "schema_version": 2 +} diff --git a/backend/alembic/versions/0026_actor_profile_lifecycle.py b/backend/alembic/versions/0026_actor_profile_lifecycle.py new file mode 100644 index 00000000..292dbea3 --- /dev/null +++ b/backend/alembic/versions/0026_actor_profile_lifecycle.py @@ -0,0 +1,394 @@ +"""repair actor lifecycle provenance and authority evidence + +Revision ID: 0026_actor_profile_lifecycle +Revises: 0025_artifact_store_v2 +Create Date: 2026-07-18 +""" + +from __future__ import annotations + +from alembic import op +import re +import sqlalchemy as sa + +revision = "0026_actor_profile_lifecycle" +down_revision = "0025_artifact_store_v2" +branch_labels = depends_on = None + +_NEW_DENIAL_CODES = ( + "identity_link_already_revoked", + "identity_link_not_revoked", +) +# Historical capture of str.strip() across the supported Python runtimes. +_PYTHON_STRIP_CHARACTERS_SQL = ( + "(E' \\t\\n\\r\\f\\013'" + "||chr(28)||chr(29)||chr(30)||chr(31)||chr(133)||chr(160)||chr(5760)" + "||chr(8192)||chr(8193)||chr(8194)||chr(8195)||chr(8196)||chr(8197)" + "||chr(8198)||chr(8199)||chr(8200)||chr(8201)||chr(8202)||chr(8232)" + "||chr(8233)||chr(8239)||chr(8287)||chr(12288))" +) + + +def _replace_denial_registry(*, add: bool) -> None: + """Extend the exact prior registry without coupling history to live constants.""" + bind = op.get_bind() + name = "ck_audit_events_authority_registries" + definition = bind.execute( + sa.text( + "select pg_get_constraintdef(oid) from pg_constraint " + "where conrelid='audit_events'::regclass and conname=:name" + ), + {"name": name}, + ).scalar_one() + cast = r"(?P::(?:character varying|text))?" + marker = re.compile(rf"'identity_link_conflict'{cast}") + if add: + matches = tuple(marker.finditer(definition)) + if len(matches) != 1 or any(value in definition for value in _NEW_DENIAL_CODES): + raise RuntimeError("unexpected authority denial registry definition") + match = matches[0] + suffix = match.group("cast") or "" + inserted = ", ".join(f"'{value}'{suffix}" for value in _NEW_DENIAL_CODES) + replacement = definition[: match.end()] + ", " + inserted + definition[match.end() :] + else: + first = ( + r"(?:\('identity_link_already_revoked'::character varying\)::text|" + r"'identity_link_already_revoked'(?:::(?:character varying|text))?)" + ) + second = ( + r"(?:\('identity_link_not_revoked'::character varying\)::text|" + r"'identity_link_not_revoked'(?:::(?:character varying|text))?)" + ) + removal = re.compile( + rf",\s*{first}\s*,\s*{second}" + ) + if len(tuple(removal.finditer(definition))) != 1: + raise RuntimeError("unexpected authority denial registry definition") + replacement = removal.sub("", definition, count=1) + op.drop_constraint("authority_registries", "audit_events", type_="check") + op.execute(f"alter table audit_events add constraint {name} {replacement}") + + +def _replace_linked_authority_guard(*, lifecycle_reactivation: bool) -> None: + projection_branch = ( + "record_row.operation in ('admin_role_grant.issue','admin_role_grant.revoke'," + "'actor_identity_link.revoke','actor_identity_link.reactivate')" + if lifecycle_reactivation + else "record_row.operation in ('admin_role_grant.issue','admin_role_grant.revoke')" + ) + if lifecycle_reactivation: + direction_checks = """ + or (record_row.operation in ('admin_role_grant.issue','actor_profile.reactivate','actor_identity_link.reactivate') and + (new.before_facts::jsonb <> '{"effective": false}'::jsonb or new.after_facts::jsonb <> '{"effective": true}'::jsonb)) + or (record_row.operation not in ('admin_role_grant.issue','actor_profile.reactivate','actor_identity_link.reactivate') and + (new.before_facts::jsonb <> '{"effective": true}'::jsonb or new.after_facts::jsonb <> '{"effective": false}'::jsonb)) + """ + else: + direction_checks = """ + or (record_row.operation='admin_role_grant.issue' and + (new.before_facts::jsonb <> '{"effective": false}'::jsonb or new.after_facts::jsonb <> '{"effective": true}'::jsonb)) + or (record_row.operation='admin_role_grant.revoke' and + (new.before_facts::jsonb <> '{"effective": true}'::jsonb or new.after_facts::jsonb <> '{"effective": false}'::jsonb)) + or (not (record_row.operation in ('admin_role_grant.issue','admin_role_grant.revoke')) and + (new.before_facts::jsonb <> '{"effective": true}'::jsonb or new.after_facts::jsonb <> '{"effective": false}'::jsonb)) + """ + op.execute( + f""" + create or replace 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; expected_invalidation_resource text; + expected_invalidation_id 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; + 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' + 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_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; + expected_invalidation_resource := case when {projection_branch} then 'actor_profile' else expected_resource end; + expected_invalidation_id := case when {projection_branch} then cause_row.target_actor_ref else cause_row.resource_id end; + 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 expected_resource + or new.resource_type is distinct from expected_invalidation_resource + or new.resource_id is distinct from expected_invalidation_id + or new.invalidation_target_kind is distinct from expected_invalidation_resource + or new.invalidation_target_ref is distinct from expected_invalidation_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 ({projection_branch} and (cause_row.target_actor_ref_kind <> 'actor_profile' or cause_row.target_actor_ref is null)) + {direction_checks} + 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; + else + if new.resource_type <> expected_resource or 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 $$ + """ + ) + + +def _replace_lifecycle_guards(*, upgraded: bool) -> None: + if upgraded: + profile_function = """ + create or replace function guard_actor_profile_history() returns trigger language plpgsql as $$ + begin + if tg_op='DELETE' then raise exception 'actor profiles are immutable history' using errcode='55000'; end if; + if (new.id,new.actor_kind,new.provisioning_method,new.created_by,new.created_at) + is distinct from (old.id,old.actor_kind,old.provisioning_method,old.created_by,old.created_at) then + raise exception 'actor profile identity is immutable' using errcode='55000'; + end if; + if old.status='deactivated' and new.status <> 'deactivated' then + raise exception 'deactivated actor is terminal' using errcode='23514'; + end if; + if new.status = old.status and + (new.suspended_by,new.suspended_at,new.suspension_reason,new.reactivated_by,new.reactivated_at,new.reactivation_reason, + new.deactivated_by,new.deactivated_at,new.deactivation_reason) is distinct from + (old.suspended_by,old.suspended_at,old.suspension_reason,old.reactivated_by,old.reactivated_at,old.reactivation_reason, + old.deactivated_by,old.deactivated_at,old.deactivation_reason) then + raise exception 'actor lifecycle attribution requires a transition' using errcode='23514'; + end if; + if old.status='active' and new.status='suspended' and + (new.reactivated_by,new.reactivated_at,new.reactivation_reason,new.deactivated_by,new.deactivated_at,new.deactivation_reason) + is distinct from + (old.reactivated_by,old.reactivated_at,old.reactivation_reason,old.deactivated_by,old.deactivated_at,old.deactivation_reason) then + raise exception 'invalid actor suspension attribution' using errcode='23514'; + end if; + if old.status='suspended' and new.status='active' and + ((new.suspended_by,new.suspended_at,new.suspension_reason) is distinct from (null,null,null) + or (new.reactivated_by,new.reactivated_at,new.reactivation_reason) is not distinct from (null,null,null) + or (new.reactivated_by,new.reactivated_at,new.reactivation_reason) is not distinct from + (old.reactivated_by,old.reactivated_at,old.reactivation_reason) + or (new.deactivated_by,new.deactivated_at,new.deactivation_reason) is distinct from + (old.deactivated_by,old.deactivated_at,old.deactivation_reason)) then + raise exception 'invalid actor reactivation attribution' using errcode='23514'; + end if; + if new.status='deactivated' and old.status in ('active','suspended') and + (new.suspended_by,new.suspended_at,new.suspension_reason,new.reactivated_by,new.reactivated_at,new.reactivation_reason) + is distinct from + (old.suspended_by,old.suspended_at,old.suspension_reason,old.reactivated_by,old.reactivated_at,old.reactivation_reason) then + raise exception 'invalid actor deactivation attribution' using errcode='23514'; + end if; + if new.status <> old.status and not ( + (old.status='active' and new.status in ('suspended','deactivated')) or + (old.status='suspended' and new.status in ('active','deactivated'))) then + raise exception 'invalid actor lifecycle transition' using errcode='23514'; + end if; + new.updated_at = statement_timestamp(); return new; + end $$ + """ + link_function = """ + create or replace function guard_actor_identity_link_history() returns trigger language plpgsql as $$ + begin + if tg_op='DELETE' then raise exception 'actor identity links are immutable history' using errcode='55000'; end if; + if (new.id,new.actor_profile_id,new.issuer,new.subject,new.subject_kind,new.linked_by,new.linked_at) + is distinct from (old.id,old.actor_profile_id,old.issuer,old.subject,old.subject_kind,old.linked_by,old.linked_at) then + raise exception 'actor identity link anchor is immutable' using errcode='55000'; + end if; + if new.status=old.status and + (new.revoked_by,new.revoked_at,new.revoked_reason,new.reactivated_by,new.reactivated_at,new.reactivation_reason) + is distinct from + (old.revoked_by,old.revoked_at,old.revoked_reason,old.reactivated_by,old.reactivated_at,old.reactivation_reason) then + raise exception 'identity link attribution requires a transition' using errcode='23514'; + end if; + if old.status='active' and new.status='revoked' and + (new.reactivated_by,new.reactivated_at,new.reactivation_reason) is distinct from + (old.reactivated_by,old.reactivated_at,old.reactivation_reason) then + raise exception 'invalid identity link revocation attribution' using errcode='23514'; + end if; + if old.status='revoked' and new.status='active' and + ((new.revoked_by,new.revoked_at,new.revoked_reason) is distinct from (null,null,null) + or (new.reactivated_by,new.reactivated_at,new.reactivation_reason) is not distinct from (null,null,null) + or (new.reactivated_by,new.reactivated_at,new.reactivation_reason) is not distinct from + (old.reactivated_by,old.reactivated_at,old.reactivation_reason)) then + raise exception 'invalid identity link reactivation attribution' using errcode='23514'; + end if; + if new.status <> old.status and not ( + (old.status='active' and new.status='revoked') or + (old.status='revoked' and new.status='active')) then + raise exception 'invalid identity link lifecycle transition' using errcode='23514'; + end if; + return new; + end $$ + """ + else: + profile_function = """ + create or replace function guard_actor_profile_history() returns trigger language plpgsql as $$ + begin + if tg_op='DELETE' then raise exception 'actor profiles are immutable history' using errcode='55000'; end if; + if (new.id,new.actor_kind,new.provisioning_method,new.created_by,new.created_at) + is distinct from (old.id,old.actor_kind,old.provisioning_method,old.created_by,old.created_at) then + raise exception 'actor profile identity is immutable' using errcode='55000'; + end if; + if old.status='deactivated' and new.status <> 'deactivated' then + raise exception 'deactivated actor is terminal' using errcode='23514'; + end if; + new.updated_at = statement_timestamp(); return new; + end $$ + """ + link_function = """ + create or replace function guard_actor_identity_link_history() returns trigger language plpgsql as $$ + begin + if tg_op='DELETE' then raise exception 'actor identity links are immutable history' using errcode='55000'; end if; + if (new.id,new.actor_profile_id,new.issuer,new.subject,new.subject_kind,new.linked_by,new.linked_at) + is distinct from (old.id,old.actor_profile_id,old.issuer,old.subject,old.subject_kind,old.linked_by,old.linked_at) then + raise exception 'actor identity link anchor is immutable' using errcode='55000'; + end if; + return new; + end $$ + """ + op.execute(profile_function) + op.execute(link_function) + + +def _dirty_lifecycle_rows(bind) -> bool: + return bool( + bind.execute( + sa.text( + "select exists(select 1 from actor_identity_links where " + "(reactivated_by is null)::int + (reactivated_at is null)::int + " + "(reactivation_reason is null)::int not in (0,3)) or exists(" + "select 1 from actor_profiles where " + f"(suspension_reason is not null and (suspension_reason<>btrim(suspension_reason, {_PYTHON_STRIP_CHARACTERS_SQL}) or octet_length(suspension_reason) not between 1 and 500)) or " + f"(deactivation_reason is not null and (deactivation_reason<>btrim(deactivation_reason, {_PYTHON_STRIP_CHARACTERS_SQL}) or octet_length(deactivation_reason) not between 1 and 500))) or exists(" + "select 1 from actor_identity_links where " + f"(revoked_reason is not null and (revoked_reason<>btrim(revoked_reason, {_PYTHON_STRIP_CHARACTERS_SQL}) or octet_length(revoked_reason) not between 1 and 500)) or " + f"(reactivation_reason is not null and (reactivation_reason<>btrim(reactivation_reason, {_PYTHON_STRIP_CHARACTERS_SQL}) or octet_length(reactivation_reason) not between 1 and 500)))" + ) + ).scalar_one() + ) + + +def upgrade() -> None: + """Install truthful profile lifecycle provenance and evidence guards.""" + bind = op.get_bind() + bind.execute(sa.text("lock table actor_profiles, actor_identity_links, audit_events in access exclusive mode")) + if _dirty_lifecycle_rows(bind): + raise RuntimeError("cannot adopt dirty actor lifecycle rows") + + op.add_column("actor_profiles", sa.Column("reactivated_by", sa.String(120))) + op.add_column("actor_profiles", sa.Column("reactivated_at", sa.DateTime(timezone=True))) + op.add_column("actor_profiles", sa.Column("reactivation_reason", sa.String(500))) + op.create_check_constraint( + op.f("ck_actor_profiles_reactivation_fields"), + "actor_profiles", + "(reactivated_by is null and reactivated_at is null and reactivation_reason is null) or " + "(reactivated_by is not null and reactivated_at is not null and reactivation_reason is not null)", + ) + op.create_check_constraint( + op.f("ck_actor_profiles_lifecycle_reason_bounds"), + "actor_profiles", + f"(suspension_reason is null or (suspension_reason=btrim(suspension_reason, {_PYTHON_STRIP_CHARACTERS_SQL}) and octet_length(suspension_reason) between 1 and 500)) and " + f"(reactivation_reason is null or (reactivation_reason=btrim(reactivation_reason, {_PYTHON_STRIP_CHARACTERS_SQL}) and octet_length(reactivation_reason) between 1 and 500)) and " + f"(deactivation_reason is null or (deactivation_reason=btrim(deactivation_reason, {_PYTHON_STRIP_CHARACTERS_SQL}) and octet_length(deactivation_reason) between 1 and 500))", + ) + op.create_check_constraint( + op.f("ck_actor_identity_links_reactivation_fields"), + "actor_identity_links", + "(reactivated_by is null and reactivated_at is null and reactivation_reason is null) or " + "(reactivated_by is not null and reactivated_at is not null and reactivation_reason is not null)", + ) + op.create_check_constraint( + op.f("ck_actor_identity_links_lifecycle_reason_bounds"), + "actor_identity_links", + f"(revoked_reason is null or (revoked_reason=btrim(revoked_reason, {_PYTHON_STRIP_CHARACTERS_SQL}) and octet_length(revoked_reason) between 1 and 500)) and " + f"(reactivation_reason is null or (reactivation_reason=btrim(reactivation_reason, {_PYTHON_STRIP_CHARACTERS_SQL}) and octet_length(reactivation_reason) between 1 and 500))", + ) + _replace_denial_registry(add=True) + _replace_linked_authority_guard(lifecycle_reactivation=True) + _replace_lifecycle_guards(upgraded=True) + + +def downgrade() -> None: + """Restore 0025 only before any forward lifecycle evidence exists.""" + bind = op.get_bind() + bind.execute(sa.text("lock table actor_profiles, actor_identity_links, audit_events in access exclusive mode")) + blocked = bind.execute( + sa.text( + "select exists(select 1 from actor_profiles where reactivated_by is not null or reactivated_at is not null or reactivation_reason is not null) " + "or exists(select 1 from audit_events where event_domain='authority' and (" + "event_type in ('ActorProfileReactivated','ActorIdentityLinkReactivated') or " + "denial_code in ('identity_link_already_revoked','identity_link_not_revoked')))" + ) + ).scalar_one() + if blocked: + raise RuntimeError("cannot downgrade actor lifecycle evidence") + _replace_lifecycle_guards(upgraded=False) + _replace_linked_authority_guard(lifecycle_reactivation=False) + _replace_denial_registry(add=False) + op.drop_constraint(op.f("ck_actor_identity_links_lifecycle_reason_bounds"), "actor_identity_links", type_="check") + op.drop_constraint(op.f("ck_actor_identity_links_reactivation_fields"), "actor_identity_links", type_="check") + op.drop_constraint(op.f("ck_actor_profiles_lifecycle_reason_bounds"), "actor_profiles", type_="check") + op.drop_constraint(op.f("ck_actor_profiles_reactivation_fields"), "actor_profiles", type_="check") + op.drop_column("actor_profiles", "reactivation_reason") + op.drop_column("actor_profiles", "reactivated_at") + op.drop_column("actor_profiles", "reactivated_by") diff --git a/backend/app/modules/actors/models.py b/backend/app/modules/actors/models.py index 53970046..7e37b3f7 100644 --- a/backend/app/modules/actors/models.py +++ b/backend/app/modules/actors/models.py @@ -28,6 +28,14 @@ LEGACY_PROFILE_STATUSES = ("observed", "active", "disabled") GLOBAL_PROFILE_SCOPE_TYPE = "global" GLOBAL_PROFILE_SCOPE_ID = "global" +# PostgreSQL equivalent of str.strip() across the supported Python runtimes. +_PYTHON_STRIP_CHARACTERS_SQL = ( + "(E' \\t\\n\\r\\f\\013'" + "||chr(28)||chr(29)||chr(30)||chr(31)||chr(133)||chr(160)||chr(5760)" + "||chr(8192)||chr(8193)||chr(8194)||chr(8195)||chr(8196)||chr(8197)" + "||chr(8198)||chr(8199)||chr(8200)||chr(8201)||chr(8202)||chr(8232)" + "||chr(8233)||chr(8239)||chr(8287)||chr(12288))" +) def _sql_values(values: tuple[str, ...]) -> str: @@ -68,6 +76,20 @@ class ActorProfile(Base): "and deactivation_reason is not null)", name="lifecycle_fields", ), + CheckConstraint( + "(reactivated_by is null and reactivated_at is null and reactivation_reason is null) or " + "(reactivated_by is not null and reactivated_at is not null and reactivation_reason is not null)", + name="reactivation_fields", + ), + CheckConstraint( + f"(suspension_reason is null or (suspension_reason = btrim(suspension_reason, {_PYTHON_STRIP_CHARACTERS_SQL}) and " + "octet_length(suspension_reason) between 1 and 500)) and " + f"(reactivation_reason is null or (reactivation_reason = btrim(reactivation_reason, {_PYTHON_STRIP_CHARACTERS_SQL}) and " + "octet_length(reactivation_reason) between 1 and 500)) and " + f"(deactivation_reason is null or (deactivation_reason = btrim(deactivation_reason, {_PYTHON_STRIP_CHARACTERS_SQL}) and " + "octet_length(deactivation_reason) between 1 and 500))", + name="lifecycle_reason_bounds", + ), Index("ix_actor_profiles_status_actor_kind", "status", "actor_kind"), Index("ix_actor_profiles_last_seen_at", "last_seen_at"), UniqueConstraint("service_identity", name="service_identity"), @@ -87,6 +109,9 @@ class ActorProfile(Base): suspended_by: Mapped[str | None] = mapped_column(String(120)) suspended_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) suspension_reason: Mapped[str | None] = mapped_column(String(500)) + reactivated_by: Mapped[str | None] = mapped_column(String(120)) + reactivated_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) + reactivation_reason: Mapped[str | None] = mapped_column(String(500)) deactivated_by: Mapped[str | None] = mapped_column(String(120)) deactivated_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) deactivation_reason: Mapped[str | None] = mapped_column(String(500)) @@ -117,6 +142,18 @@ class ActorIdentityLink(Base): "subject_kind = 'service' or last_verified_at is not null", name="human_verified", ), + CheckConstraint( + "(reactivated_by is null and reactivated_at is null and reactivation_reason is null) or " + "(reactivated_by is not null and reactivated_at is not null and reactivation_reason is not null)", + name="reactivation_fields", + ), + CheckConstraint( + f"(revoked_reason is null or (revoked_reason = btrim(revoked_reason, {_PYTHON_STRIP_CHARACTERS_SQL}) and " + "octet_length(revoked_reason) between 1 and 500)) and " + f"(reactivation_reason is null or (reactivation_reason = btrim(reactivation_reason, {_PYTHON_STRIP_CHARACTERS_SQL}) and " + "octet_length(reactivation_reason) between 1 and 500))", + name="lifecycle_reason_bounds", + ), UniqueConstraint("issuer", "subject", name="external_identity"), UniqueConstraint("actor_profile_id", name="actor_profile"), Index( diff --git a/backend/app/modules/actors/schemas.py b/backend/app/modules/actors/schemas.py index d6b7606c..3206ad4c 100644 --- a/backend/app/modules/actors/schemas.py +++ b/backend/app/modules/actors/schemas.py @@ -88,6 +88,7 @@ class ActorProfileAdminResponse(BaseModel): updated_at: datetime last_seen_at: datetime | None suspended_at: datetime | None + reactivated_at: datetime | None deactivated_at: datetime | None @model_validator(mode="after") diff --git a/backend/app/modules/actors/service.py b/backend/app/modules/actors/service.py index 441a67cc..1393075b 100644 --- a/backend/app/modules/actors/service.py +++ b/backend/app/modules/actors/service.py @@ -264,6 +264,7 @@ async def read_admin_profile( updated_at=profile.updated_at, last_seen_at=profile.last_seen_at, suspended_at=profile.suspended_at, + reactivated_at=profile.reactivated_at, deactivated_at=profile.deactivated_at, ) diff --git a/backend/app/modules/audit/schemas.py b/backend/app/modules/audit/schemas.py index 717bdc59..619cbcdf 100644 --- a/backend/app/modules/audit/schemas.py +++ b/backend/app/modules/audit/schemas.py @@ -45,7 +45,8 @@ scope_not_authorized self_grant_forbidden self_role_revoke_forbidden resource_guard_denied actor_not_found grant_not_found resource_not_found actor_already_suspended actor_not_suspended actor_deactivated_terminal last_access_administrator admin_role_grant_exists - project_role_grant_exists identity_link_conflict resource_project_mismatch idempotency_mismatch + project_role_grant_exists identity_link_conflict identity_link_already_revoked + identity_link_not_revoked resource_project_mismatch idempotency_mismatch invalid_role_scope invalid_project_role qualification_snapshot_invalid""".split() ) _ADMIN_ROLES = frozenset( @@ -395,9 +396,14 @@ def validate_shape(self) -> Self: elif self.event_type == AuthorityEventType.AUTHORITY_INVALIDATION_REQUESTED: if self.invalidation_cause_event_id is None or self.invalidation_target_kind is None or self.denial_code: raise ValueError("invalid authority invalidation evidence") + restoration = self.permission_id in { + PermissionId.ADMIN_ROLE_GRANT, + PermissionId.ACTOR_PROFILE_REACTIVATE, + PermissionId.ACTOR_IDENTITY_LINK_REACTIVATE, + } expected_direction = ( ({"effective": False}, {"effective": True}) - if self.permission_id is PermissionId.ADMIN_ROLE_GRANT + if restoration else ({"effective": True}, {"effective": False}) ) if (before, after) != expected_direction: diff --git a/backend/app/modules/authorization/catalogue.py b/backend/app/modules/authorization/catalogue.py index 4f86049a..384a2a63 100644 --- a/backend/app/modules/authorization/catalogue.py +++ b/backend/app/modules/authorization/catalogue.py @@ -170,7 +170,8 @@ class ActionOwner(StrEnum): AUTH_08 = "WS-AUTH-001-08" AUTH_09B = "WS-AUTH-001-09B" AUTH_09C = "WS-AUTH-001-09C" - AUTH_09D = "WS-AUTH-001-09D" + AUTH_09D_A = "WS-AUTH-001-09D-A" + AUTH_09D_B = "WS-AUTH-001-09D-B" AUTH_13 = "WS-AUTH-001-13" AUTH_14 = "WS-AUTH-001-14" REV_05 = "WS-REV-001-05" @@ -272,20 +273,20 @@ def _active( PermissionId.ACTOR_PROFILE_READ_ANY, ActionOwner.AUTH_09C, ), - _planned( + _active( ActionId.ACTOR_PROFILE_SUSPEND, PermissionId.ACTOR_PROFILE_SUSPEND, - ActionOwner.AUTH_09D, + ActionOwner.AUTH_09D_A, ), - _planned( + _active( ActionId.ACTOR_PROFILE_REACTIVATE, PermissionId.ACTOR_PROFILE_REACTIVATE, - ActionOwner.AUTH_09D, + ActionOwner.AUTH_09D_A, ), - _planned( + _active( ActionId.ACTOR_PROFILE_DEACTIVATE, PermissionId.ACTOR_PROFILE_DEACTIVATE, - ActionOwner.AUTH_09D, + ActionOwner.AUTH_09D_A, ), _active( ActionId.ACTOR_IDENTITY_LINK_READ, @@ -295,12 +296,12 @@ def _active( _planned( ActionId.ACTOR_IDENTITY_LINK_REVOKE, PermissionId.ACTOR_IDENTITY_LINK_REVOKE, - ActionOwner.AUTH_09D, + ActionOwner.AUTH_09D_B, ), _planned( ActionId.ACTOR_IDENTITY_LINK_REACTIVATE, PermissionId.ACTOR_IDENTITY_LINK_REACTIVATE, - ActionOwner.AUTH_09D, + ActionOwner.AUTH_09D_B, ), _active( ActionId.ACTOR_SERVICE_PROVISION, @@ -581,6 +582,9 @@ def _index_actions( ActionId.ACTOR_PROFILE_READ, ActionId.ACTOR_IDENTITY_LINK_READ, ActionId.ACTOR_SERVICE_PROVISION, + ActionId.ACTOR_PROFILE_SUSPEND, + ActionId.ACTOR_PROFILE_REACTIVATE, + ActionId.ACTOR_PROFILE_DEACTIVATE, } if { definition.action_id diff --git a/backend/app/modules/authorization/kernel.py b/backend/app/modules/authorization/kernel.py index e619af15..4a208541 100644 --- a/backend/app/modules/authorization/kernel.py +++ b/backend/app/modules/authorization/kernel.py @@ -25,6 +25,7 @@ ActorIdentityLinkAdminReadResourceContext, ActorKind, ActorProfileAdminReadResourceContext, + ActorProfileLifecycleResourceContext, ActorSelfResourceContext, ActorStatus, AdminRoleDefinitionsResourceContext, @@ -60,6 +61,9 @@ ActionId.ACTOR_SERVICE_PROVISION, ActionId.ACTOR_PROFILE_READ, ActionId.ACTOR_IDENTITY_LINK_READ, + ActionId.ACTOR_PROFILE_SUSPEND, + ActionId.ACTOR_PROFILE_REACTIVATE, + ActionId.ACTOR_PROFILE_DEACTIVATE, } ) _SERIALIZED_ADMIN_READS = frozenset( @@ -73,6 +77,9 @@ ActionId.ADMIN_ROLE_GRANT_ISSUE, ActionId.ADMIN_ROLE_GRANT_REVOKE, ActionId.ACTOR_SERVICE_PROVISION, + ActionId.ACTOR_PROFILE_SUSPEND, + ActionId.ACTOR_PROFILE_REACTIVATE, + ActionId.ACTOR_PROFILE_DEACTIVATE, } ) @@ -251,6 +258,14 @@ async def _admin_guard( return AuthorizationDenialCode.GRANT_NOT_FOUND if grant.target_actor_profile_id == str(context.actor_profile_id): return AuthorizationDenialCode.SELF_ROLE_REVOKE_FORBIDDEN + elif isinstance(resource, ActorProfileLifecycleResourceContext): + if ( + resource.resource_id == context.actor_profile_id + and resource.transition in {"suspend", "deactivate"} + ): + return AuthorizationDenialCode.RESOURCE_GUARD_DENIED + if await self._admin.lock_actor_lifecycle_target(resource.resource_id) is None: + return AuthorizationDenialCode.ACTOR_NOT_FOUND elif isinstance( resource, (AdminRoleGrantCollectionResourceContext, ActorAdminRoleGrantHistoryResourceContext), @@ -280,8 +295,18 @@ def _admin_resource_matches( ActionId.ACTOR_SERVICE_PROVISION: ServiceActorProvisionResourceContext, ActionId.ACTOR_PROFILE_READ: ActorProfileAdminReadResourceContext, ActionId.ACTOR_IDENTITY_LINK_READ: ActorIdentityLinkAdminReadResourceContext, + ActionId.ACTOR_PROFILE_SUSPEND: ActorProfileLifecycleResourceContext, + ActionId.ACTOR_PROFILE_REACTIVATE: ActorProfileLifecycleResourceContext, + ActionId.ACTOR_PROFILE_DEACTIVATE: ActorProfileLifecycleResourceContext, + }.get(action_id) + if expected is None or not isinstance(resource, expected): + return False + transition = { + ActionId.ACTOR_PROFILE_SUSPEND: "suspend", + ActionId.ACTOR_PROFILE_REACTIVATE: "reactivate", + ActionId.ACTOR_PROFILE_DEACTIVATE: "deactivate", }.get(action_id) - return expected is not None and isinstance(resource, expected) + return transition is None or resource.transition == transition @staticmethod def _resource_project_id(resource: AuthorizationResourceContext): @@ -400,7 +425,7 @@ async def _stage_decision( target_actor_ref=str(decision.resource_id) if target_is_actor else None, matched_grant_id=( str(decision.matched_grant_id) - if decision.matched_grant_id is not None + if decision.allowed and decision.matched_grant_id is not None else None ), permission_id=decision.permission_id, diff --git a/backend/app/modules/authorization/lifecycle_schemas.py b/backend/app/modules/authorization/lifecycle_schemas.py new file mode 100644 index 00000000..f8731095 --- /dev/null +++ b/backend/app/modules/authorization/lifecycle_schemas.py @@ -0,0 +1,35 @@ +"""Strict public schemas for actor-profile lifecycle administration.""" + +from __future__ import annotations + +from typing import Literal +from uuid import UUID + +from pydantic import BaseModel, ConfigDict, field_validator + +_STRICT = ConfigDict(extra="forbid", strict=True, hide_input_in_errors=True) + + +class ActorLifecycleBody(BaseModel): + """One normalized bounded lifecycle reason.""" + + model_config = _STRICT + reason: str + + @field_validator("reason") + @classmethod + def normalize_reason(cls, value: str) -> str: + normalized = value.strip() + if "\x00" in normalized or not 1 <= len(normalized.encode("utf-8")) <= 500: + raise ValueError("reason must contain 1 to 500 UTF-8 bytes") + return normalized + + +class ActorLifecycleMutationResponse(BaseModel): + """Stable privacy-bounded reference for one profile lifecycle result.""" + + model_config = _STRICT + resource_type: Literal["actor_profile"] + resource_id: UUID + version: None = None + http_status: Literal[200] diff --git a/backend/app/modules/authorization/lifecycle_service.py b/backend/app/modules/authorization/lifecycle_service.py new file mode 100644 index 00000000..9cb83de1 --- /dev/null +++ b/backend/app/modules/authorization/lifecycle_service.py @@ -0,0 +1,314 @@ +"""Actor-profile lifecycle orchestration on the shared authority foundation.""" + +from __future__ import annotations + +from uuid import UUID, uuid4 + +from sqlalchemy import func +from sqlalchemy.ext.asyncio import AsyncSession + +from app.modules.actors.models import ActorProfile +from app.modules.audit.schemas import ( + ActorReferenceKind, + AuthorityAuditEventInput, + AuthorityEventType, +) +from app.modules.audit.service import AuditService +from app.modules.authorization.catalogue import ActionId, PermissionId +from app.modules.authorization.lifecycle_schemas import ActorLifecycleMutationResponse +from app.modules.authorization.repository import AdminAuthorizationRepository +from app.modules.authorization.runtime import ( + ActorProfileLifecycleResourceContext, + AuthorizationDecision, + MatchedAuthorityKind, + authorization_resource_digest, +) +from app.modules.authorization.schemas import ( + ActorProfileDeactivateRequest, + ActorProfileReactivateRequest, + ActorProfileSuspendRequest, + AuthorityClaimHandle, + AuthorityInvalidationContext, + AuthorityMismatchContext, + AuthorityOperation, + AuthorityReservationResult, + AuthorityResourceType, + AuthorityResponseReference, + derive_reason_digest, +) +from app.modules.authorization.service import AuthorityMutationService + +ActorLifecycleRequest = ( + ActorProfileSuspendRequest + | ActorProfileReactivateRequest + | ActorProfileDeactivateRequest +) + + +class ActorLifecycleConflict(RuntimeError): + """One exact target-state or final-administrator conflict.""" + + def __init__(self, code: str) -> None: + super().__init__(code) + self.code = code + + +_ACTION = { + AuthorityOperation.ACTOR_PROFILE_SUSPEND: ActionId.ACTOR_PROFILE_SUSPEND, + AuthorityOperation.ACTOR_PROFILE_REACTIVATE: ActionId.ACTOR_PROFILE_REACTIVATE, + AuthorityOperation.ACTOR_PROFILE_DEACTIVATE: ActionId.ACTOR_PROFILE_DEACTIVATE, +} +_PERMISSION = { + AuthorityOperation.ACTOR_PROFILE_SUSPEND: PermissionId.ACTOR_PROFILE_SUSPEND, + AuthorityOperation.ACTOR_PROFILE_REACTIVATE: PermissionId.ACTOR_PROFILE_REACTIVATE, + AuthorityOperation.ACTOR_PROFILE_DEACTIVATE: PermissionId.ACTOR_PROFILE_DEACTIVATE, +} +_TRANSITION = { + AuthorityOperation.ACTOR_PROFILE_SUSPEND: "suspend", + AuthorityOperation.ACTOR_PROFILE_REACTIVATE: "reactivate", + AuthorityOperation.ACTOR_PROFILE_DEACTIVATE: "deactivate", +} +_EVENT = { + AuthorityOperation.ACTOR_PROFILE_SUSPEND: AuthorityEventType.ACTOR_PROFILE_SUSPENDED, + AuthorityOperation.ACTOR_PROFILE_REACTIVATE: AuthorityEventType.ACTOR_PROFILE_REACTIVATED, + AuthorityOperation.ACTOR_PROFILE_DEACTIVATE: AuthorityEventType.ACTOR_PROFILE_DEACTIVATED, +} + + +class ActorLifecycleService: + """Stage one profile transition and its exact evidence in the caller transaction.""" + + def __init__(self, session: AsyncSession) -> None: + self._session = session + self._repository = AdminAuthorizationRepository(session) + self._mutation = AuthorityMutationService(session) + self._audit = AuditService(session) + + async def reserve( + self, + *, + idempotency_key: UUID, + actor_profile_id: UUID, + request: ActorLifecycleRequest, + ) -> AuthorityReservationResult: + """Reserve one caller/operation/key namespace before authorization.""" + return await self._mutation.reserve( + idempotency_key=idempotency_key, + actor_ref_kind=ActorReferenceKind.ACTOR_PROFILE, + actor_ref=str(actor_profile_id), + request=request.model_dump(), + ) + + async def complete( + self, + *, + claim: AuthorityClaimHandle, + request: ActorLifecycleRequest, + decision: AuthorizationDecision, + actor_profile_id: UUID, + reason: str, + ) -> ActorLifecycleMutationResponse: + """Apply one valid transition and complete success/invalidation evidence.""" + if not _decision_matches(decision, request, existing=False): + raise TypeError("actor lifecycle mutation requires exact matched authority") + if request.reason_digest != derive_reason_digest(reason): + raise TypeError("actor lifecycle reason digest changed") + locked = await self._repository.lock_actor_lifecycle_target(request.actor_profile_id) + if locked is None: + raise RuntimeError("authorized actor lifecycle target disappeared") + link, profile, access_grant = locked + conflict = await self._conflict(request, profile, link.status, access_grant is not None) + if conflict is not None: + raise ActorLifecycleConflict(conflict) + + before_status = profile.status + self._apply(profile, request, actor_profile_id, reason) + await self._session.flush() + response = AuthorityResponseReference( + resource_type=AuthorityResourceType.ACTOR_PROFILE, + resource_id=request.actor_profile_id, + version=None, + http_status=200, + ) + await self._mutation.complete( + claim=claim, + request=request.model_dump(), + response=response, + success=AuthorityAuditEventInput( + event_id=uuid4(), + event_type=_EVENT[request.operation], + entity_type="actor_profile", + entity_id=str(request.actor_profile_id), + actor_ref_kind=ActorReferenceKind.ACTOR_PROFILE, + actor_ref=str(actor_profile_id), + request_id=decision.request_id, + correlation_id=decision.correlation_id, + target_actor_ref_kind=ActorReferenceKind.ACTOR_PROFILE, + target_actor_ref=str(request.actor_profile_id), + matched_grant_id=str(decision.matched_grant_id), + permission_id=_PERMISSION[request.operation], + 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="administrative_correction", + idempotency_reference=claim.record_id, + before_facts={"status": before_status}, + after_facts={"status": profile.status}, + ), + invalidation=AuthorityInvalidationContext( + event_id=uuid4(), + request_id=decision.request_id, + correlation_id=decision.correlation_id, + ), + ) + return ActorLifecycleMutationResponse( + resource_type="actor_profile", + resource_id=response.resource_id, + version=None, + http_status=200, + ) + + async def record_mismatch( + self, + *, + actor_profile_id: UUID, + request: ActorLifecycleRequest, + decision: AuthorizationDecision, + ) -> None: + """Write one action-bound mismatch after rolling back the reservation.""" + if not _decision_matches(decision, request, existing=True): + raise TypeError("actor lifecycle mismatch requires exact authority") + await self._mutation.record_mismatch_denial( + actor_ref_kind=ActorReferenceKind.ACTOR_PROFILE, + actor_ref=str(actor_profile_id), + request=request.model_dump(), + context=AuthorityMismatchContext( + event_id=uuid4(), + request_id=decision.request_id, + correlation_id=decision.correlation_id, + matched_grant_id=None, + ), + ) + + async def record_conflict( + self, + *, + actor_profile_id: UUID, + request: ActorLifecycleRequest, + decision: AuthorizationDecision, + code: str, + ) -> None: + """Write one clean post-allow lifecycle denial without consuming the key.""" + if not _decision_matches(decision, request, existing=False): + raise TypeError("actor lifecycle conflict requires exact authority") + event_id = uuid4() + await self._audit.add_authority_event( + AuthorityAuditEventInput( + event_id=event_id, + event_type=AuthorityEventType.SENSITIVE_AUTHORIZATION_DENIED, + entity_type="authorization_decision", + entity_id=str(event_id), + actor_ref_kind=ActorReferenceKind.ACTOR_PROFILE, + actor_ref=str(actor_profile_id), + request_id=decision.request_id, + correlation_id=decision.correlation_id, + target_actor_ref_kind=ActorReferenceKind.ACTOR_PROFILE, + target_actor_ref=str(request.actor_profile_id), + matched_grant_id=None, + permission_id=_PERMISSION[request.operation], + action_id=_ACTION[request.operation], + 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="authorization_evaluation", + denial_code=code, + after_facts={"allowed": False}, + ) + ) + + async def _conflict( + self, + request: ActorLifecycleRequest, + profile: ActorProfile, + link_status: str, + has_access_grant: bool, + ) -> str | None: + status = profile.status + if status == "deactivated": + return "actor_deactivated_terminal" + if request.operation is AuthorityOperation.ACTOR_PROFILE_SUSPEND and status == "suspended": + return "actor_already_suspended" + if request.operation is AuthorityOperation.ACTOR_PROFILE_REACTIVATE and status != "suspended": + return "actor_not_suspended" + loses_effective_admin = ( + request.operation + in { + AuthorityOperation.ACTOR_PROFILE_SUSPEND, + AuthorityOperation.ACTOR_PROFILE_DEACTIVATE, + } + and profile.actor_kind == "human" + and status == "active" + and link_status == "active" + and has_access_grant + ) + if ( + loses_effective_admin + and await self._repository.count_effective_access_administrators() <= 1 + ): + return "last_access_administrator" + return None + + @staticmethod + def _apply( + profile: ActorProfile, + request: ActorLifecycleRequest, + actor_profile_id: UUID, + reason: str, + ) -> None: + actor_id = str(actor_profile_id) + if request.operation is AuthorityOperation.ACTOR_PROFILE_SUSPEND: + profile.status = "suspended" + profile.suspended_by = actor_id + profile.suspended_at = func.clock_timestamp() + profile.suspension_reason = reason + elif request.operation is AuthorityOperation.ACTOR_PROFILE_REACTIVATE: + profile.status = "active" + profile.suspended_by = None + profile.suspended_at = None + profile.suspension_reason = None + profile.reactivated_by = actor_id + profile.reactivated_at = func.clock_timestamp() + profile.reactivation_reason = reason + else: + profile.status = "deactivated" + profile.deactivated_by = actor_id + profile.deactivated_at = func.clock_timestamp() + profile.deactivation_reason = reason + + +def _decision_matches( + decision: AuthorizationDecision, + request: ActorLifecycleRequest, + *, + existing: bool, +) -> bool: + resource = ActorProfileLifecycleResourceContext( + resource_type="actor_profile", + resource_id=request.actor_profile_id, + transition=_TRANSITION[request.operation], + existing_idempotency_record=existing, + ) + return ( + decision.allowed + and decision.action_id is _ACTION[request.operation] + and decision.permission_id is _PERMISSION[request.operation] + and decision.resource_type == "actor_profile" + and decision.resource_id == request.actor_profile_id + and decision.resource_context_digest == authorization_resource_digest(resource) + and decision.matched_authority_kind is MatchedAuthorityKind.ADMIN_ROLE_GRANT + and decision.matched_grant_id is not None + and decision.matched_scope_project_id is None + and decision.revalidated + ) diff --git a/backend/app/modules/authorization/repository.py b/backend/app/modules/authorization/repository.py index a10e44ab..1a99c98b 100644 --- a/backend/app/modules/authorization/repository.py +++ b/backend/app/modules/authorization/repository.py @@ -188,6 +188,39 @@ async def actor_exists(self, actor_profile_id: UUID) -> bool: is not None ) + async def lock_actor_lifecycle_target( + self, + actor_profile_id: UUID, + ) -> tuple[ActorIdentityLink, ActorProfile, AdminRoleGrant | None] | None: + """Lock one lifecycle target in profile, link, then exact grant order.""" + profile = await self._session.scalar( + select(ActorProfile) + .where(ActorProfile.id == str(actor_profile_id)) + .with_for_update() + .execution_options(populate_existing=True) + ) + if profile is None: + return None + link = await self._session.scalar( + select(ActorIdentityLink) + .where(ActorIdentityLink.actor_profile_id == str(actor_profile_id)) + .with_for_update() + .execution_options(populate_existing=True) + ) + if link is None: + raise RuntimeError("actor lifecycle target is missing its identity link") + grant = await self._session.scalar( + select(AdminRoleGrant) + .where( + AdminRoleGrant.target_actor_profile_id == str(actor_profile_id), + AdminRoleGrant.role == AdminRole.ACCESS_ADMINISTRATOR.value, + AdminRoleGrant.scope_type == AdminScope.SYSTEM.value, + AdminRoleGrant.status == "active", + ) + .with_for_update() + ) + return link, profile, grant + async def has_effective_permission_any_scope( self, actor_profile_id: UUID, diff --git a/backend/app/modules/authorization/router.py b/backend/app/modules/authorization/router.py index b6c2f661..0b4551e5 100644 --- a/backend/app/modules/authorization/router.py +++ b/backend/app/modules/authorization/router.py @@ -36,10 +36,20 @@ ) from app.modules.authorization.catalogue import ActionId from app.modules.authorization.kernel import AuthorizationService +from app.modules.authorization.lifecycle_schemas import ( + ActorLifecycleBody, + ActorLifecycleMutationResponse, +) +from app.modules.authorization.lifecycle_service import ( + ActorLifecycleConflict, + ActorLifecycleRequest, + ActorLifecycleService, +) from app.modules.authorization.runtime import ( ActorAdminRoleGrantHistoryResourceContext, ActorIdentityLinkAdminReadResourceContext, ActorProfileAdminReadResourceContext, + ActorProfileLifecycleResourceContext, AdminRoleDefinitionsResourceContext, AdminRoleGrantCollectionResourceContext, AdminRoleGrantIssueResourceContext, @@ -51,6 +61,9 @@ AdminRole, AdminRoleGrantIssueRequest, AdminRoleGrantRevokeRequest, + ActorProfileDeactivateRequest, + ActorProfileReactivateRequest, + ActorProfileSuspendRequest, AdminScope, AuthorityOperation, ServiceActorCreateRequest, @@ -123,6 +136,24 @@ def _revoke_request(grant_id: UUID, reason: str) -> AdminRoleGrantRevokeRequest: ) +def _lifecycle_request( + actor_profile_id: UUID, + reason: str, + operation: AuthorityOperation, +) -> ActorLifecycleRequest: + values = { + "operation": operation, + "actor_profile_id": actor_profile_id, + "reason_digest": derive_reason_digest(reason), + } + request_type = { + AuthorityOperation.ACTOR_PROFILE_SUSPEND: ActorProfileSuspendRequest, + AuthorityOperation.ACTOR_PROFILE_REACTIVATE: ActorProfileReactivateRequest, + AuthorityOperation.ACTOR_PROFILE_DEACTIVATE: ActorProfileDeactivateRequest, + }[operation] + return request_type(**values) + + def _service_actor_request( payload: ServiceActorProvisionBody, issuer: str, @@ -167,6 +198,98 @@ async def _database_call(session: AsyncSession, operation: Awaitable[T]) -> T: raise service_unavailable_error() from exc +async def _mutate_actor_lifecycle( + *, + actor_profile_id: UUID, + payload: ActorLifecycleBody, + idempotency_key: UUID, + resolved: ResolvedActor, + authorization: AuthorizationService, + session: AsyncSession, + operation: AuthorityOperation, + action: ActionId, + transition: Literal["suspend", "reactivate", "deactivate"], +) -> ActorLifecycleMutationResponse: + canonical = _lifecycle_request(actor_profile_id, payload.reason, operation) + caller_id = UUID(resolved.profile.id) + service = ActorLifecycleService(session) + reservation = await _database_call( + session, + service.reserve( + idempotency_key=idempotency_key, + actor_profile_id=caller_id, + request=canonical, + ), + ) + decision = await _database_call( + session, + authorization.require( + action, + ActorProfileLifecycleResourceContext( + resource_type="actor_profile", + resource_id=actor_profile_id, + transition=transition, + existing_idempotency_record=reservation.outcome in {"replay", "mismatch"}, + ), + ), + ) + if reservation.outcome == "mismatch": + await session.rollback() + await _database_call( + session, + service.record_mismatch( + actor_profile_id=caller_id, + request=canonical, + decision=decision, + ), + ) + await _commit_or_unavailable(session) + raise _domain_error(409, "idempotency_mismatch", "Idempotency key does not match") + if reservation.outcome == "replay": + response = ActorLifecycleMutationResponse( + resource_type="actor_profile", + resource_id=reservation.response.resource_id, + version=None, + http_status=200, + ) + await _database_call(session, ActorService(session).touch_after_authorization(resolved)) + await _commit_or_unavailable(session) + return response + try: + await ActorService(session).touch_after_authorization(resolved) + response = await service.complete( + claim=reservation.claim, + request=canonical, + decision=decision, + actor_profile_id=caller_id, + reason=payload.reason, + ) + await session.commit() + return response + except ActorLifecycleConflict as exc: + await session.rollback() + await _database_call( + session, + service.record_conflict( + actor_profile_id=caller_id, + request=canonical, + decision=decision, + code=exc.code, + ), + ) + await _commit_or_unavailable(session) + messages = { + "actor_already_suspended": "Actor is already suspended", + "actor_not_suspended": "Actor is not suspended", + "actor_deactivated_terminal": "Actor is permanently deactivated", + "last_access_administrator": "Final Access Administrator cannot be disabled", + } + raise _domain_error(409, exc.code, messages[exc.code]) from exc + except SQLAlchemyError as exc: + await session.rollback() + raise service_unavailable_error() from exc + + @router.post( "/service-actors", status_code=status.HTTP_201_CREATED, @@ -301,6 +424,95 @@ def _service_actor_conflict(conflict: ServiceActorConflict) -> StructuredHTTPExc return _domain_error(409, conflict.value, "Identity subject is already linked") +_LIFECYCLE_CONFLICT_RESPONSE = { + 409: {"model": ApiErrorResponse, "description": "Actor lifecycle conflict."} +} + + +@router.post( + "/actors/{actor_profile_id}/suspend", + response_model=ActorLifecycleMutationResponse, + dependencies=[Depends(enforce_admin_mutation_rate_limit)], + responses=_LIFECYCLE_CONFLICT_RESPONSE, + openapi_extra={"x-workstream-action-id": ActionId.ACTOR_PROFILE_SUSPEND.value}, +) +async def suspend_actor_profile( + actor_profile_id: UUID, + payload: ActorLifecycleBody, + idempotency_key: Annotated[UUID, Header(alias="Idempotency-Key")], + resolved: Annotated[ResolvedActor, Depends(get_authorization_actor)], + authorization: Annotated[AuthorizationService, Depends(get_authorization_service)], + session: Annotated[AsyncSession, Depends(get_db_session)], +) -> ActorLifecycleMutationResponse: + return await _mutate_actor_lifecycle( + actor_profile_id=actor_profile_id, + payload=payload, + idempotency_key=idempotency_key, + resolved=resolved, + authorization=authorization, + session=session, + operation=AuthorityOperation.ACTOR_PROFILE_SUSPEND, + action=ActionId.ACTOR_PROFILE_SUSPEND, + transition="suspend", + ) + + +@router.post( + "/actors/{actor_profile_id}/reactivate", + response_model=ActorLifecycleMutationResponse, + dependencies=[Depends(enforce_admin_mutation_rate_limit)], + responses=_LIFECYCLE_CONFLICT_RESPONSE, + openapi_extra={"x-workstream-action-id": ActionId.ACTOR_PROFILE_REACTIVATE.value}, +) +async def reactivate_actor_profile( + actor_profile_id: UUID, + payload: ActorLifecycleBody, + idempotency_key: Annotated[UUID, Header(alias="Idempotency-Key")], + resolved: Annotated[ResolvedActor, Depends(get_authorization_actor)], + authorization: Annotated[AuthorizationService, Depends(get_authorization_service)], + session: Annotated[AsyncSession, Depends(get_db_session)], +) -> ActorLifecycleMutationResponse: + return await _mutate_actor_lifecycle( + actor_profile_id=actor_profile_id, + payload=payload, + idempotency_key=idempotency_key, + resolved=resolved, + authorization=authorization, + session=session, + operation=AuthorityOperation.ACTOR_PROFILE_REACTIVATE, + action=ActionId.ACTOR_PROFILE_REACTIVATE, + transition="reactivate", + ) + + +@router.post( + "/actors/{actor_profile_id}/deactivate", + response_model=ActorLifecycleMutationResponse, + dependencies=[Depends(enforce_admin_mutation_rate_limit)], + responses=_LIFECYCLE_CONFLICT_RESPONSE, + openapi_extra={"x-workstream-action-id": ActionId.ACTOR_PROFILE_DEACTIVATE.value}, +) +async def deactivate_actor_profile( + actor_profile_id: UUID, + payload: ActorLifecycleBody, + idempotency_key: Annotated[UUID, Header(alias="Idempotency-Key")], + resolved: Annotated[ResolvedActor, Depends(get_authorization_actor)], + authorization: Annotated[AuthorizationService, Depends(get_authorization_service)], + session: Annotated[AsyncSession, Depends(get_db_session)], +) -> ActorLifecycleMutationResponse: + return await _mutate_actor_lifecycle( + actor_profile_id=actor_profile_id, + payload=payload, + idempotency_key=idempotency_key, + resolved=resolved, + authorization=authorization, + session=session, + operation=AuthorityOperation.ACTOR_PROFILE_DEACTIVATE, + action=ActionId.ACTOR_PROFILE_DEACTIVATE, + transition="deactivate", + ) + + @router.get( "/actors/{actor_profile_id}", response_model=ActorProfileAdminResponse, diff --git a/backend/app/modules/authorization/runtime.py b/backend/app/modules/authorization/runtime.py index 061aa595..1d604717 100644 --- a/backend/app/modules/authorization/runtime.py +++ b/backend/app/modules/authorization/runtime.py @@ -87,6 +87,16 @@ class ActorIdentityLinkAdminReadResourceContext(BaseModel): read_kind: Literal["identity_link"] +class ActorProfileLifecycleResourceContext(BaseModel): + """Server-composed target for one exact profile lifecycle transition.""" + + model_config = _STRICT_FROZEN + resource_type: Literal["actor_profile"] + resource_id: UUID + transition: Literal["suspend", "reactivate", "deactivate"] + existing_idempotency_record: bool = False + + class SystemResourceContext(BaseModel): """Non-authoritative placeholder for later fixed system actions.""" @@ -194,6 +204,7 @@ class ServiceActorProvisionResourceContext(BaseModel): ActorSelfResourceContext | ActorProfileAdminReadResourceContext | ActorIdentityLinkAdminReadResourceContext + | ActorProfileLifecycleResourceContext | SystemResourceContext | PermissionCatalogueResourceContext | AdminRoleDefinitionsResourceContext diff --git a/backend/app/modules/authorization/service.py b/backend/app/modules/authorization/service.py index cecf09a1..e7f5345b 100644 --- a/backend/app/modules/authorization/service.py +++ b/backend/app/modules/authorization/service.py @@ -92,21 +92,21 @@ class _OperationEvidence: ), AuthorityOperation.ACTOR_PROFILE_SUSPEND: _OperationEvidence( "actor.profile.suspend", - None, + ActionId.ACTOR_PROFILE_SUSPEND, AuthorityResourceType.ACTOR_PROFILE, 200, (AuthorityEventType.ACTOR_PROFILE_SUSPENDED,), ), AuthorityOperation.ACTOR_PROFILE_REACTIVATE: _OperationEvidence( "actor.profile.reactivate", - None, + ActionId.ACTOR_PROFILE_REACTIVATE, AuthorityResourceType.ACTOR_PROFILE, 200, (AuthorityEventType.ACTOR_PROFILE_REACTIVATED,), ), AuthorityOperation.ACTOR_PROFILE_DEACTIVATE: _OperationEvidence( "actor.profile.deactivate", - None, + ActionId.ACTOR_PROFILE_DEACTIVATE, AuthorityResourceType.ACTOR_PROFILE, 200, (AuthorityEventType.ACTOR_PROFILE_DEACTIVATED,), @@ -216,22 +216,32 @@ async def complete( mutation, (AdminRoleGrantIssueRequest, AdminRoleGrantRevokeRequest), ) + identity_link_mutation = isinstance( + mutation, + (ActorIdentityLinkRevokeRequest, ActorIdentityLinkReactivateRequest), + ) invalidation_target_kind = spec.resource_type.value invalidation_target_ref = success.resource_id invalidation_resource_type = success.resource_type invalidation_resource_id = success.resource_id before_facts = {"effective": True} after_facts = {"effective": False} - if admin_mutation: + if admin_mutation or identity_link_mutation: if success.target_actor_ref is None: - raise TypeError("admin grant success requires target actor") + raise TypeError("projected authority success requires target actor") invalidation_target_kind = AuthorityResourceType.ACTOR_PROFILE.value invalidation_target_ref = success.target_actor_ref invalidation_resource_type = AuthorityResourceType.ACTOR_PROFILE.value invalidation_resource_id = success.target_actor_ref - if isinstance(mutation, AdminRoleGrantIssueRequest): + if isinstance( + mutation, + (AdminRoleGrantIssueRequest, ActorIdentityLinkReactivateRequest), + ): before_facts = {"effective": False} after_facts = {"effective": True} + elif isinstance(mutation, ActorProfileReactivateRequest): + before_facts = {"effective": False} + after_facts = {"effective": True} invalidation_input = AuthorityAuditEventInput( event_id=invalidation.event_id, event_type=AuthorityEventType.AUTHORITY_INVALIDATION_REQUESTED, @@ -365,5 +375,10 @@ def _request_matches_success( ): 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 ( + resource_id == request.identity_link_id + and success.project_id is None + and success.target_actor_ref_kind == ActorReferenceKind.ACTOR_PROFILE + and success.target_actor_ref is not None + ) return False diff --git a/backend/scripts/api_contract_e2e.py b/backend/scripts/api_contract_e2e.py index 148dfd4a..8c52cb5e 100644 --- a/backend/scripts/api_contract_e2e.py +++ b/backend/scripts/api_contract_e2e.py @@ -1035,6 +1035,40 @@ async def exercise_api_contract(base_url: str, env: dict[str, str]) -> None: assert service_payload["subject"] not in serialized_service_reads assert flow_issuer not in serialized_service_reads + lifecycle_reason = "Real HTTP service profile lifecycle proof" + suspended_service = await client.post( + f"/api/v1/actors/{service_actor_id}/suspend", + headers=auth_headers(manager_token) | {"Idempotency-Key": str(uuid4())}, + json={"reason": lifecycle_reason}, + ) + assert suspended_service.status_code == 200, suspended_service.text + assert suspended_service.json() == { + "resource_type": "actor_profile", + "resource_id": service_actor_id, + "version": None, + "http_status": 200, + } + assert lifecycle_reason not in suspended_service.text + reactivated_service = await client.post( + f"/api/v1/actors/{service_actor_id}/reactivate", + headers=auth_headers(manager_token) | {"Idempotency-Key": str(uuid4())}, + json={"reason": "Real HTTP service profile correction"}, + ) + assert reactivated_service.status_code == 200, reactivated_service.text + deactivated_service = await client.post( + f"/api/v1/actors/{service_actor_id}/deactivate", + headers=auth_headers(manager_token) | {"Idempotency-Key": str(uuid4())}, + json={"reason": "Real HTTP terminal service profile response"}, + ) + assert deactivated_service.status_code == 200, deactivated_service.text + terminal_service = await client.post( + f"/api/v1/actors/{service_actor_id}/reactivate", + headers=auth_headers(manager_token) | {"Idempotency-Key": str(uuid4())}, + json={"reason": "Terminal profiles remain terminal"}, + ) + assert terminal_service.status_code == 409, terminal_service.text + assert terminal_service.json()["error"]["code"] == "actor_deactivated_terminal" + project = await request_json( client, "POST", diff --git a/backend/tests/test_actors.py b/backend/tests/test_actors.py index 1bbc1913..f60fbbbd 100644 --- a/backend/tests/test_actors.py +++ b/backend/tests/test_actors.py @@ -69,6 +69,7 @@ def test_actor_admin_response_requires_exact_service_identity_pair() -> None: "updated_at": now, "last_seen_at": None, "suspended_at": None, + "reactivated_at": None, "deactivated_at": None, } human = ActorProfileAdminResponse( @@ -157,9 +158,10 @@ async def get_identity_link_for_actor(self, requested_id): "display_name", "created_at", "updated_at", - "last_seen_at", - "suspended_at", - "deactivated_at", + "last_seen_at", + "suspended_at", + "reactivated_at", + "deactivated_at", } assert set(link_response.model_dump()) == { "identity_link_id", diff --git a/backend/tests/test_alembic.py b/backend/tests/test_alembic.py index e048e20f..96cc80cc 100644 --- a/backend/tests/test_alembic.py +++ b/backend/tests/test_alembic.py @@ -500,7 +500,6 @@ def test_canonical_actor_downgrade_refuses_nonactive_authority_state( _reset_canonical_actor_guard_state( isolated_database_env, actor_id, - owner_reset=state == "deactivated", ) ) command.downgrade(config, "0019_authority_idempotency") @@ -539,7 +538,8 @@ def test_authorization_action_evidence_constraints_and_guarded_downgrade( ActionOwner.AUTH_08, ActionOwner.AUTH_09B, ActionOwner.AUTH_09C, - ActionOwner.AUTH_09D, + ActionOwner.AUTH_09D_A, + ActionOwner.AUTH_09D_B, } ), ) @@ -692,7 +692,8 @@ def test_bootstrap_admin_grant_schema_is_immutable_and_guarded( not in { ActionOwner.AUTH_09B, ActionOwner.AUTH_09C, - ActionOwner.AUTH_09D, + ActionOwner.AUTH_09D_A, + ActionOwner.AUTH_09D_B, } ), ) @@ -767,7 +768,8 @@ def test_fixed_service_identity_schema_mapping_and_guarded_downgrade( in { ActionOwner.AUTH_09B, ActionOwner.AUTH_09C, - ActionOwner.AUTH_09D, + ActionOwner.AUTH_09D_A, + ActionOwner.AUTH_09D_B, } ), ) @@ -842,7 +844,8 @@ def test_fixed_service_identity_schema_mapping_and_guarded_downgrade( in { ActionOwner.AUTH_09B, ActionOwner.AUTH_09C, - ActionOwner.AUTH_09D, + ActionOwner.AUTH_09D_A, + ActionOwner.AUTH_09D_B, } ) assert len(auth09_definitions) == 8 @@ -1323,6 +1326,719 @@ def guarded_upgrade() -> None: } +def test_actor_profile_lifecycle_fresh_and_prior_head_upgrade( + isolated_database_env: str, + migration_lock, +) -> None: + """Install 0026 from the exact prior head and preserve reversible shape.""" + config = _alembic_config() + + async def shape() -> tuple[str, bool, bool]: + engine = create_async_engine(isolated_database_env) + try: + async with engine.connect() as connection: + return ( + str(await connection.scalar(text("select version_num from alembic_version"))), + bool( + await connection.scalar( + text( + "select exists(select 1 from information_schema.columns " + "where table_name='actor_profiles' and column_name='reactivated_at')" + ) + ) + ), + bool( + await connection.scalar( + text( + "select exists(select 1 from pg_constraint where " + "conname='ck_actor_identity_links_reactivation_fields')" + ) + ) + ), + ) + finally: + await engine.dispose() + + with migration_lock(): + try: + command.downgrade(config, "base") + command.upgrade(config, "0025_artifact_store_v2") + assert asyncio.run(shape()) == ("0025_artifact_store_v2", False, False) + command.upgrade(config, "head") + assert asyncio.run(shape()) == ("0026_actor_profile_lifecycle", True, True) + command.downgrade(config, "0025_artifact_store_v2") + assert asyncio.run(shape()) == ("0025_artifact_store_v2", False, False) + command.upgrade(config, "head") + assert asyncio.run(shape()) == ("0026_actor_profile_lifecycle", True, True) + finally: + command.downgrade(config, "base") + + +def test_actor_profile_lifecycle_constraint_and_trigger_parity( + isolated_database_env: str, + migration_lock, +) -> None: + """Enforce normalized attribution and only legal profile lifecycle transitions.""" + config = _alembic_config() + actor_id = str(uuid4()) + + async def prove_guards() -> None: + engine = create_async_engine(isolated_database_env) + try: + async with engine.begin() as connection: + await _insert_canonical_actor(connection, actor_id, "lifecycle-parity", "human") + await connection.execute( + text( + "update actor_profiles set status='suspended',suspended_by=:actor," + "suspended_at=clock_timestamp(),suspension_reason='investigate' where id=:actor" + ), + {"actor": actor_id}, + ) + with pytest.raises(DBAPIError): + async with engine.begin() as connection: + await connection.execute( + text( + "update actor_profiles set status='active',suspended_by=null," + "suspended_at=null,suspension_reason=null where id=:actor" + ), + {"actor": actor_id}, + ) + async with engine.begin() as connection: + await connection.execute( + text( + "update actor_profiles set status='active',suspended_by=null," + "suspended_at=null,suspension_reason=null,reactivated_by=:actor," + "reactivated_at=clock_timestamp(),reactivation_reason='restored' where id=:actor" + ), + {"actor": actor_id}, + ) + await connection.execute( + text( + "update actor_profiles set status='suspended',suspended_by=:actor," + "suspended_at=clock_timestamp(),suspension_reason='second review' " + "where id=:actor" + ), + {"actor": actor_id}, + ) + with pytest.raises(DBAPIError): + async with engine.begin() as connection: + await connection.execute( + text( + "update actor_profiles set status='active',suspended_by=null," + "suspended_at=null,suspension_reason=null where id=:actor" + ), + {"actor": actor_id}, + ) + async with engine.begin() as connection: + await connection.execute( + text( + "update actor_profiles set status='active',suspended_by=null," + "suspended_at=null,suspension_reason=null,reactivated_by=:actor," + "reactivated_at=clock_timestamp(),reactivation_reason='restored again' " + "where id=:actor" + ), + {"actor": actor_id}, + ) + await connection.execute( + text( + "update actor_identity_links set status='revoked',revoked_by=:actor," + "revoked_at=clock_timestamp(),revoked_reason='link review' " + "where actor_profile_id=:actor" + ), + {"actor": actor_id}, + ) + with pytest.raises(DBAPIError): + async with engine.begin() as connection: + await connection.execute( + text( + "update actor_identity_links set status='active',revoked_by=null," + "revoked_at=null,revoked_reason=null where actor_profile_id=:actor" + ), + {"actor": actor_id}, + ) + async with engine.begin() as connection: + await connection.execute( + text( + "update actor_identity_links set status='active',revoked_by=null," + "revoked_at=null,revoked_reason=null,reactivated_by=:actor," + "reactivated_at=clock_timestamp(),reactivation_reason='link restored' " + "where actor_profile_id=:actor" + ), + {"actor": actor_id}, + ) + await connection.execute( + text( + "update actor_identity_links set status='revoked',revoked_by=:actor," + "revoked_at=clock_timestamp(),revoked_reason='second link review' " + "where actor_profile_id=:actor" + ), + {"actor": actor_id}, + ) + with pytest.raises(DBAPIError): + async with engine.begin() as connection: + await connection.execute( + text( + "update actor_identity_links set status='active',revoked_by=null," + "revoked_at=null,revoked_reason=null where actor_profile_id=:actor" + ), + {"actor": actor_id}, + ) + invalid = ( + "update actor_profiles set reactivation_reason='rewritten' where id=:actor", + "update actor_profiles set status='suspended',suspended_by=:actor," + "suspended_at=clock_timestamp(),suspension_reason=' padded ' where id=:actor", + "update actor_identity_links set reactivation_reason='rewritten' " + "where actor_profile_id=:actor", + ) + for statement in invalid: + with pytest.raises(DBAPIError): + async with engine.begin() as connection: + await connection.execute(text(statement), {"actor": actor_id}) + invalid_reason_updates = ( + ( + "actor_profiles", + "update actor_profiles set status='suspended',suspended_by=:actor," + "suspended_at=clock_timestamp(),suspension_reason=chr(9)||'hold' " + "where id=:actor", + ), + ( + "actor_profiles", + "update actor_profiles set reactivation_reason='restored'||chr(10) " + "where id=:actor", + ), + ( + "actor_profiles", + "update actor_profiles set status='deactivated',deactivated_by=:actor," + "deactivated_at=clock_timestamp(),deactivation_reason=chr(13)||'terminal' " + "where id=:actor", + ), + ( + "actor_identity_links", + "update actor_identity_links set revoked_reason=chr(12)||'link review' " + "where actor_profile_id=:actor", + ), + ( + "actor_identity_links", + "update actor_identity_links set reactivation_reason='link restored'||chr(11) " + "where actor_profile_id=:actor", + ), + ) + for table, statement in invalid_reason_updates: + with pytest.raises(DBAPIError): + async with engine.begin() as connection: + await connection.execute(text(f"alter table {table} disable trigger user")) + await connection.execute(text(statement), {"actor": actor_id}) + python_strip_code_points = ( + 9, + 10, + 11, + 12, + 13, + 28, + 29, + 30, + 31, + 32, + 133, + 160, + 5760, + 8192, + 8193, + 8194, + 8195, + 8196, + 8197, + 8198, + 8199, + 8200, + 8201, + 8202, + 8232, + 8233, + 8239, + 8287, + 12288, + ) + assert python_strip_code_points == tuple( + code_point + for code_point in range(0x110000) + if chr(code_point).isspace() + ) + for code_point in python_strip_code_points: + with pytest.raises(DBAPIError): + async with engine.begin() as connection: + await connection.execute( + text("alter table actor_profiles disable trigger user") + ) + await connection.execute( + text( + "update actor_profiles set status='suspended'," + "suspended_by=:actor,suspended_at=clock_timestamp()," + "suspension_reason=chr(:code_point)||'hold' where id=:actor" + ), + {"actor": actor_id, "code_point": code_point}, + ) + async with engine.begin() as connection: + await connection.execute(text("alter table actor_profiles disable trigger user")) + await connection.execute( + text( + "update actor_profiles set reactivated_by=null,reactivated_at=null," + "reactivation_reason=null where id=:actor" + ), + {"actor": actor_id}, + ) + await connection.execute(text("alter table actor_profiles enable trigger user")) + await connection.execute( + text("alter table actor_identity_links disable trigger user") + ) + await connection.execute( + text( + "update actor_identity_links set status='active',revoked_by=null," + "revoked_at=null,revoked_reason=null,reactivated_by=null," + "reactivated_at=null,reactivation_reason=null " + "where actor_profile_id=:actor" + ), + {"actor": actor_id}, + ) + await connection.execute( + text("alter table actor_identity_links enable trigger user") + ) + finally: + await engine.dispose() + + with migration_lock(): + try: + command.downgrade(config, "base") + command.upgrade(config, "head") + asyncio.run(prove_guards()) + finally: + command.downgrade(config, "base") + + +def test_actor_profile_lifecycle_upgrade_refuses_dirty_rows( + isolated_database_env: str, + migration_lock, +) -> None: + """Refuse partial provenance and non-normalized reasons before 0026 DDL.""" + config = _alembic_config() + cases = ( + ( + str(uuid4()), + "partial-link-reactivation", + "update actor_identity_links set reactivated_by=:actor " + "where actor_profile_id=:actor", + "update actor_identity_links set reactivated_by=null " + "where actor_profile_id=:actor", + ), + ( + str(uuid4()), + "padded-profile-suspension", + "update actor_profiles set status='suspended',suspended_by=:actor," + "suspended_at=clock_timestamp(),suspension_reason=' padded ' where id=:actor", + "update actor_profiles set suspension_reason='valid suspension' where id=:actor", + ), + ( + str(uuid4()), + "tab-padded-profile-suspension", + "update actor_profiles set status='suspended',suspended_by=:actor," + "suspended_at=clock_timestamp(),suspension_reason=chr(9)||'padded' " + "where id=:actor", + "update actor_profiles set suspension_reason='valid suspension' where id=:actor", + ), + ( + str(uuid4()), + "multibyte-profile-deactivation", + "update actor_profiles set status='deactivated',deactivated_by=:actor," + "deactivated_at=clock_timestamp(),deactivation_reason=repeat(chr(233),251) " + "where id=:actor", + "update actor_profiles set deactivation_reason='valid deactivation' where id=:actor", + ), + ( + str(uuid4()), + "nbsp-profile-deactivation", + "update actor_profiles set status='deactivated',deactivated_by=:actor," + "deactivated_at=clock_timestamp(),deactivation_reason=chr(160)||'padded' " + "where id=:actor", + "update actor_profiles set deactivation_reason='valid deactivation' where id=:actor", + ), + ( + str(uuid4()), + "padded-link-revocation", + "update actor_identity_links set status='revoked',revoked_by=:actor," + "revoked_at=clock_timestamp(),revoked_reason=' padded ' " + "where actor_profile_id=:actor", + "update actor_identity_links set revoked_reason='valid revocation' " + "where actor_profile_id=:actor", + ), + ( + str(uuid4()), + "newline-padded-link-revocation", + "update actor_identity_links set status='revoked',revoked_by=:actor," + "revoked_at=clock_timestamp(),revoked_reason='padded'||chr(10) " + "where actor_profile_id=:actor", + "update actor_identity_links set revoked_reason='valid revocation' " + "where actor_profile_id=:actor", + ), + ( + str(uuid4()), + "multibyte-link-reactivation", + "update actor_identity_links set reactivated_by=:actor," + "reactivated_at=clock_timestamp(),reactivation_reason=repeat(chr(233),251) " + "where actor_profile_id=:actor", + "update actor_identity_links set reactivation_reason='valid reactivation' " + "where actor_profile_id=:actor", + ), + ) + + async def seed_actors() -> None: + engine = create_async_engine(isolated_database_env) + try: + async with engine.begin() as connection: + for actor_id, subject, _, _ in cases: + await _insert_canonical_actor(connection, actor_id, subject, "human") + finally: + await engine.dispose() + + async def execute(statement: str, actor_id: str) -> None: + engine = create_async_engine(isolated_database_env) + try: + async with engine.begin() as connection: + await connection.execute(text(statement), {"actor": actor_id}) + finally: + await engine.dispose() + + async def prior_head_state(actor_id: str) -> tuple[object, ...]: + engine = create_async_engine(isolated_database_env) + try: + async with engine.connect() as connection: + columns = await connection.execute( + text( + "select exists(select 1 from information_schema.columns " + "where table_schema='public' and table_name='actor_profiles' " + "and column_name='reactivated_by')" + ) + ) + profile = await connection.execute( + text( + "select status,suspension_reason,deactivation_reason " + "from actor_profiles where id=:actor" + ), + {"actor": actor_id}, + ) + link = await connection.execute( + text( + "select status,revoked_reason,reactivated_by,reactivation_reason " + "from actor_identity_links where actor_profile_id=:actor" + ), + {"actor": actor_id}, + ) + audit_count = await connection.scalar(text("select count(*) from audit_events")) + return ( + columns.scalar_one(), + tuple(profile.one()), + tuple(link.one()), + audit_count, + ) + finally: + await engine.dispose() + + async def restore_active_fixtures() -> None: + engine = create_async_engine(isolated_database_env) + try: + async with engine.begin() as connection: + await connection.execute(text("alter table actor_profiles disable trigger user")) + await connection.execute( + text( + "update actor_profiles set status='active',suspended_by=null," + "suspended_at=null,suspension_reason=null,deactivated_by=null," + "deactivated_at=null,deactivation_reason=null,reactivated_by=null," + "reactivated_at=null,reactivation_reason=null where id=any(:actors)" + ), + {"actors": [case[0] for case in cases]}, + ) + await connection.execute(text("alter table actor_profiles enable trigger user")) + await connection.execute( + text("alter table actor_identity_links disable trigger user") + ) + await connection.execute( + text( + "update actor_identity_links set status='active',revoked_by=null," + "revoked_at=null,revoked_reason=null,reactivated_by=null," + "reactivated_at=null,reactivation_reason=null " + "where actor_profile_id=any(:actors)" + ), + {"actors": [case[0] for case in cases]}, + ) + await connection.execute( + text("alter table actor_identity_links enable trigger user") + ) + finally: + await engine.dispose() + + with migration_lock(): + try: + command.downgrade(config, "base") + command.upgrade(config, "0025_artifact_store_v2") + asyncio.run(seed_actors()) + for actor_id, _, dirty_statement, clean_statement in cases: + asyncio.run(execute(dirty_statement, actor_id)) + before = asyncio.run(prior_head_state(actor_id)) + with pytest.raises(RuntimeError, match="dirty actor lifecycle rows"): + command.upgrade(config, "head") + assert asyncio.run(_current_revision(isolated_database_env)) == ( + "0025_artifact_store_v2" + ) + assert asyncio.run(prior_head_state(actor_id)) == before + asyncio.run(execute(clean_statement, actor_id)) + command.upgrade(config, "head") + asyncio.run(restore_active_fixtures()) + finally: + command.downgrade(config, "base") + + +def test_actor_profile_lifecycle_safe_downgrade_and_reupgrade( + isolated_database_env: str, + migration_lock, +) -> None: + """Restore the exact prior schema when no forward evidence exists.""" + config = _alembic_config() + with migration_lock(): + try: + command.downgrade(config, "base") + command.upgrade(config, "head") + command.downgrade(config, "0025_artifact_store_v2") + assert asyncio.run(_current_revision(isolated_database_env)) == "0025_artifact_store_v2" + command.upgrade(config, "head") + assert asyncio.run(_current_revision(isolated_database_env)) == "0026_actor_profile_lifecycle" + finally: + command.downgrade(config, "base") + + +def test_actor_profile_lifecycle_downgrade_refuses_forward_evidence( + isolated_database_env: str, + migration_lock, +) -> None: + """Keep 0026 intact for every profile, link, or denial evidence branch.""" + config = _alembic_config() + actor_id = str(uuid4()) + + async def seed_actor() -> None: + engine = create_async_engine(isolated_database_env) + try: + async with engine.begin() as connection: + await _insert_canonical_actor(connection, actor_id, "forward-lifecycle", "human") + finally: + await engine.dispose() + + async def write_profile_reactivation(*, clear: bool = False) -> None: + engine = create_async_engine(isolated_database_env) + try: + async with engine.begin() as connection: + if clear: + await connection.execute(text("alter table actor_profiles disable trigger user")) + await connection.execute( + text( + "update actor_profiles set reactivated_by=null,reactivated_at=null," + "reactivation_reason=null where id=:actor" + ), + {"actor": actor_id}, + ) + await connection.execute(text("alter table actor_profiles enable trigger user")) + return + await connection.execute( + text( + "update actor_profiles set status='suspended',suspended_by=:actor," + "suspended_at=clock_timestamp(),suspension_reason='hold' where id=:actor" + ), + {"actor": actor_id}, + ) + await connection.execute( + text( + "update actor_profiles set status='active',suspended_by=null," + "suspended_at=null,suspension_reason=null,reactivated_by=:actor," + "reactivated_at=clock_timestamp(),reactivation_reason='restored' where id=:actor" + ), + {"actor": actor_id}, + ) + finally: + await engine.dispose() + + async def insert_audit_blocker(blocker: str) -> str: + engine = create_async_engine(isolated_database_env) + event_id = str(uuid4()) + try: + async with engine.begin() as connection: + link_id = await connection.scalar( + text( + "select id from actor_identity_links where actor_profile_id=:actor" + ), + {"actor": actor_id}, + ) + if blocker in {"ActorProfileReactivated", "ActorIdentityLinkReactivated"}: + is_profile = blocker == "ActorProfileReactivated" + resource_type = "actor_profile" if is_profile else "actor_identity_link" + resource_id = actor_id if is_profile else link_id + permission_id = ( + "actor.profile.reactivate" + if is_profile + else "actor.identity_link.reactivate" + ) + reason = "administrative_correction" if is_profile else "identity_lifecycle_change" + before_facts = '{"status":"suspended"}' if is_profile else '{"status":"revoked"}' + await connection.execute( + text( + "alter table audit_events disable trigger " + "audit_events_validate_idempotency" + ) + ) + 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," + "target_actor_ref_kind,target_actor_ref,permission_id," + "resource_type,resource_id,target_ref_kind,target_ref_id,reason," + "before_facts,after_facts) values " + "(:id,:resource_type,:resource_id,:event_type,:actor,'[]'::json," + "'{}'::json,'local_authority',false,'{}'::json,'authority',1," + "'actor_profile',:request_id,:correlation_id,'actor_profile',:actor," + ":permission_id,:resource_type,:resource_id," + ":resource_type,:resource_id,:reason,cast(:before_facts as json)," + "'{\"status\":\"active\"}'::json)" + ), + { + "id": event_id, + "resource_type": resource_type, + "resource_id": resource_id, + "event_type": blocker, + "actor": actor_id, + "request_id": str(uuid4()), + "correlation_id": str(uuid4()), + "permission_id": permission_id, + "reason": reason, + "before_facts": before_facts, + }, + ) + await connection.execute( + text( + "alter table audit_events enable trigger " + "audit_events_validate_idempotency" + ) + ) + else: + 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," + "target_actor_ref_kind,target_actor_ref,permission_id,action_id," + "resource_type,resource_id,target_ref_kind,target_ref_id,reason," + "denial_code,after_facts) values " + "(:id,'authorization_decision',:id,'SensitiveAuthorizationDenied'," + ":actor,'[]'::json,'{}'::json,'local_authority',false,'{}'::json," + "'authority',1,'actor_profile',:request_id,:correlation_id," + "'actor_profile',:actor,'actor.identity_link.revoke'," + "'actor.identity_link.revoke','actor_identity_link',:link_id," + "'actor_identity_link',:link_id,'authorization_evaluation'," + ":denial_code,cast(:after_facts as json))" + ), + { + "id": event_id, + "actor": actor_id, + "request_id": str(uuid4()), + "correlation_id": str(uuid4()), + "link_id": link_id, + "denial_code": blocker, + "after_facts": '{"allowed": false}', + }, + ) + return event_id + finally: + await engine.dispose() + + async def remove_audit_blocker(event_id: str) -> None: + engine = create_async_engine(isolated_database_env) + try: + async with engine.begin() as connection: + await connection.execute( + text( + "alter table audit_events disable trigger " + "audit_events_reject_update_delete" + ) + ) + await connection.execute( + text("delete from audit_events where id=:id"), {"id": event_id} + ) + await connection.execute( + text( + "alter table audit_events enable trigger " + "audit_events_reject_update_delete" + ) + ) + finally: + await engine.dispose() + + async def forward_state() -> tuple[object, ...]: + engine = create_async_engine(isolated_database_env) + try: + async with engine.connect() as connection: + profile = await connection.execute( + text( + "select reactivated_by,reactivated_at,reactivation_reason " + "from actor_profiles where id=:actor" + ), + {"actor": actor_id}, + ) + events = await connection.execute( + text( + "select id,event_type,denial_code from audit_events " + "where event_domain='authority' order by id" + ) + ) + column_exists = await connection.scalar( + text( + "select exists(select 1 from information_schema.columns " + "where table_schema='public' and table_name='actor_profiles' " + "and column_name='reactivated_by')" + ) + ) + return column_exists, tuple(profile.one()), tuple(events.all()) + finally: + await engine.dispose() + + def refuse_downgrade_without_change() -> None: + before = asyncio.run(forward_state()) + with pytest.raises(RuntimeError, match="cannot downgrade actor lifecycle evidence"): + command.downgrade(config, "0025_artifact_store_v2") + assert asyncio.run(_current_revision(isolated_database_env)) == ( + "0026_actor_profile_lifecycle" + ) + assert asyncio.run(forward_state()) == before + + with migration_lock(): + try: + command.downgrade(config, "base") + command.upgrade(config, "head") + asyncio.run(seed_actor()) + asyncio.run(write_profile_reactivation()) + refuse_downgrade_without_change() + asyncio.run(write_profile_reactivation(clear=True)) + for blocker in ( + "ActorProfileReactivated", + "ActorIdentityLinkReactivated", + "identity_link_already_revoked", + "identity_link_not_revoked", + ): + event_id = asyncio.run(insert_audit_blocker(blocker)) + refuse_downgrade_without_change() + asyncio.run(remove_audit_blocker(event_id)) + command.downgrade(config, "0025_artifact_store_v2") + finally: + command.downgrade(config, "base") + + def test_api_rate_control_schema_preserves_domain_and_guards_downgrade( isolated_database_env: str, migration_lock, @@ -1934,46 +2650,58 @@ async def _set_canonical_actor_guard_state( async def _reset_canonical_actor_guard_state( database_url: str, actor_id: str, - *, - owner_reset: bool, ) -> None: """Restore test-owned state after proving the migration refuses it.""" engine = create_async_engine(database_url) - history_guard_disabled = False + history_guards_disabled = False try: try: - if owner_reset: - async with engine.begin() as connection: - await connection.execute( - text( - "alter table actor_profiles disable trigger actor_profile_history_guard" - ) + async with engine.begin() as connection: + await connection.execute( + text( + "alter table actor_profiles disable trigger actor_profile_history_guard" + ) + ) + await connection.execute( + text( + "alter table actor_identity_links disable trigger " + "actor_identity_link_history_guard" ) - history_guard_disabled = True + ) + history_guards_disabled = True async with engine.begin() as connection: await connection.execute( text( "update actor_profiles set status='active', suspended_by=null, " "suspended_at=null, suspension_reason=null, deactivated_by=null, " - "deactivated_at=null, deactivation_reason=null where id=:actor" + "deactivated_at=null, deactivation_reason=null, reactivated_by=null, " + "reactivated_at=null, reactivation_reason=null where id=:actor" ), {"actor": actor_id}, ) await connection.execute( text( "update actor_identity_links set status='active', revoked_by=null, " - "revoked_at=null, revoked_reason=null where actor_profile_id=:actor" + "revoked_at=null, revoked_reason=null, reactivated_by=null, " + "reactivated_at=null, reactivation_reason=null " + "where actor_profile_id=:actor" ), {"actor": actor_id}, ) finally: - if history_guard_disabled: + if history_guards_disabled: async with engine.begin() as connection: await connection.execute( text( "alter table actor_profiles enable trigger actor_profile_history_guard" ) ) + await connection.execute( + text( + "alter table actor_identity_links enable trigger " + "actor_identity_link_history_guard" + ) + ) finally: await engine.dispose() diff --git a/backend/tests/test_api_controls.py b/backend/tests/test_api_controls.py index 25ef9e0c..f7cc958c 100644 --- a/backend/tests/test_api_controls.py +++ b/backend/tests/test_api_controls.py @@ -439,13 +439,13 @@ def test_openapi_documents_request_error_and_response_context() -> None: for method, operation in path_item.items() if method in methods and operation.get("security") ) - assert len(route_inventory) == 57 + assert len(route_inventory) == 60 assert sha256("\n".join(route_inventory).encode()).hexdigest() == ( - "03f8b0166ded1cf563acfd32443aeefc3f7331eb59a46756ab4f551c6d673dc1" + "e5d6200a4403e6de55ed15300cba3370e8f0e0f20c0041bb4e4781be2bc1206c" ) - assert len(protected_inventory) == 55 + assert len(protected_inventory) == 58 assert sha256("\n".join(protected_inventory).encode()).hexdigest() == ( - "229a935e1d17697fd0b315ac4f8638644b1fdd7b6cf48a372e5dd2b584eef02c" + "afeda1d10510c4280a810f994eafbdc0996d5fc4cbd944d27a6ced032cb17435" ) assert set(schema["paths"]["/health"]["get"]["responses"]) == {"200", "400", "500"} assert {"401", "403", "503"} <= set( @@ -475,6 +475,9 @@ def test_openapi_documents_request_error_and_response_context() -> None: "PATCH /api/v1/actors/me": "actor.profile.update_self", "GET /api/v1/actors/{actor_profile_id}": "actor.profile.read", "GET /api/v1/actors/{actor_profile_id}/identity-links": "actor.identity_link.read", + "POST /api/v1/actors/{actor_profile_id}/suspend": "actor.profile.suspend", + "POST /api/v1/actors/{actor_profile_id}/reactivate": "actor.profile.reactivate", + "POST /api/v1/actors/{actor_profile_id}/deactivate": "actor.profile.deactivate", "POST /api/v1/service-actors": "actor.service.provision", "GET /api/v1/authorization/permissions": "authorization.permission_catalogue.read", "GET /api/v1/authorization/admin-role-definitions": ( diff --git a/backend/tests/test_audit.py b/backend/tests/test_audit.py index c085d045..1f5e60ec 100644 --- a/backend/tests/test_audit.py +++ b/backend/tests/test_audit.py @@ -192,9 +192,12 @@ def test_action_aware_audit_input_enforces_mapping_and_action_availability() -> ActionId.ADMIN_ROLE_GRANT_REVOKE, ActionId.ADMIN_ROLE_GRANT_BOOTSTRAP, ActionId.ACTOR_PROFILE_READ, - ActionId.ACTOR_IDENTITY_LINK_READ, - ActionId.ACTOR_SERVICE_PROVISION, - } + ActionId.ACTOR_IDENTITY_LINK_READ, + ActionId.ACTOR_SERVICE_PROVISION, + ActionId.ACTOR_PROFILE_SUSPEND, + ActionId.ACTOR_PROFILE_REACTIVATE, + ActionId.ACTOR_PROFILE_DEACTIVATE, + } with pytest.raises(TypeError, match="invalid authority audit input"): _authority_input( AuthorityEventType.SENSITIVE_AUTHORIZATION_DENIED, diff --git a/backend/tests/test_auth.py b/backend/tests/test_auth.py index a5bcaea6..6557010b 100644 --- a/backend/tests/test_auth.py +++ b/backend/tests/test_auth.py @@ -22,7 +22,7 @@ from httpx import ASGITransport, AsyncClient, MockTransport, Request, Response from sqlalchemy import func, select, text from sqlalchemy.exc import SQLAlchemyError -from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine from fastapi import HTTPException from app.adapters.auth.dev import DevelopmentAuthVerifier @@ -53,6 +53,7 @@ AdminAuthorizationRepository, AuthorityIdempotencyRepository, ) +from app.modules.authorization.schemas import derive_reason_digest from app.modules.authorization.service_actor_service import ( ServiceActorProvisioningService, ServiceActorProvisioningUnavailable, @@ -1894,6 +1895,7 @@ def assert_retryable_service_unavailable(response: Response) -> None: "updated_at", "last_seen_at", "suspended_at", + "reactivated_at", "deactivated_at", } link_fields = { @@ -2251,21 +2253,29 @@ async def assert_failed_admin_read(path: str) -> None: for response in concealed_responses } == {("Actor not found", "actor_not_found", "Actor not found", False)} async with db_session.get_session_factory()() as session: + await session.execute(text("alter table actor_profiles disable trigger user")) + await session.execute( + text("alter table actor_identity_links disable trigger user") + ) await session.execute( text( "update actor_profiles set status='active',suspended_by=null," - "suspended_at=null,suspension_reason=null where id=:actor" + "suspended_at=null,suspension_reason=null,reactivated_by=null," + "reactivated_at=null,reactivation_reason=null where id=:actor" ), {"actor": str(concealed_targets["suspended"])}, ) await session.execute( text( "update actor_identity_links set status='active',revoked_by=null," - "revoked_at=null,revoked_reason=null " + "revoked_at=null,revoked_reason=null,reactivated_by=null," + "reactivated_at=null,reactivation_reason=null " "where actor_profile_id=:actor" ), {"actor": str(concealed_targets["no_active_link"])}, ) + await session.execute(text("alter table actor_identity_links enable trigger user")) + await session.execute(text("alter table actor_profiles enable trigger user")) await session.commit() malformed_responses = [ @@ -3367,14 +3377,17 @@ async def set_profile_state(actor_id: UUID, custodian_id: UUID, state: str) -> N async def reset_profile(actor_id: UUID) -> None: async with db_session.get_session_factory()() as session: + await session.execute(text("alter table actor_profiles disable trigger user")) await session.execute( text( "update actor_profiles set status='active',suspended_by=null," "suspended_at=null,suspension_reason=null,deactivated_by=null," - "deactivated_at=null,deactivation_reason=null where id=:actor" + "deactivated_at=null,deactivation_reason=null,reactivated_by=null," + "reactivated_at=null,reactivation_reason=null where id=:actor" ), {"actor": str(actor_id)}, ) + await session.execute(text("alter table actor_profiles enable trigger user")) await session.commit() async def revoke_link(actor_id: UUID, custodian_id: UUID) -> None: @@ -3392,13 +3405,21 @@ async def revoke_link(actor_id: UUID, custodian_id: UUID) -> None: async def reset_link(actor_id: UUID) -> None: async with db_session.get_session_factory()() as session: + await session.execute( + text("alter table actor_identity_links disable trigger user") + ) await session.execute( text( "update actor_identity_links set status='active',revoked_by=null," - "revoked_at=null,revoked_reason=null where actor_profile_id=:actor" + "revoked_at=null,revoked_reason=null,reactivated_by=null," + "reactivated_at=null,reactivation_reason=null " + "where actor_profile_id=:actor" ), {"actor": str(actor_id)}, ) + await session.execute( + text("alter table actor_identity_links enable trigger user") + ) await session.commit() async def wait_for_transition_lock( @@ -4793,6 +4814,1013 @@ async def test_permission_policy_rejects_missing_role() -> None: require_any_role(actor, {"finance"}) +async def test_actor_profile_lifecycle_real_postgres_matrix( + auth_database_env: str, + rsa_signing_material: tuple[rsa.RSAPrivateKey, dict[str, Any]], + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Prove profile state, replay, reusable conflicts, concealment, and privacy.""" + private_key, jwk = rsa_signing_material + settings = production_verifier_settings(database_url=auth_database_env) + app = create_app(settings) + app.state.auth_verifier = FlowAuthVerifier(settings, jwks_transport=jwks_transport(jwk)) + tokens = { + name: issue_asymmetric_token( + private_key, + claims={ + "sub": f"auth09d-a-{name}", + "jti": f"auth09d-a-{name}-token", + "email": f"private-{name}@example.test", + }, + ) + for name in ("admin", "target", "ordinary", "replay_target", "failure_target") + } + headers = { + name: {"Authorization": f"Bearer {token}"} for name, token in tokens.items() + } + + async def profile_state(actor_id: UUID) -> tuple: + async with db_session.get_session_factory()() as session: + return tuple( + ( + await session.execute( + text( + "select p.status,p.suspended_by,p.suspended_at,p.suspension_reason," + "p.reactivated_by,p.reactivated_at,p.reactivation_reason," + "p.deactivated_by,p.deactivated_at,p.deactivation_reason," + "p.last_seen_at,l.last_verified_at from actor_profiles p " + "join actor_identity_links l on l.actor_profile_id=p.id " + "where p.id=:actor" + ), + {"actor": str(actor_id)}, + ) + ).one() + ) + + async with AsyncClient( + transport=ASGITransport(app=app), + base_url="http://testserver", + ) as client: + profiles = { + name: UUID( + (await client.get("/api/v1/actors/me", headers=actor_headers)).json()[ + "actor_profile_id" + ] + ) + for name, actor_headers in headers.items() + } + assert (await run_admin_bootstrap(profiles["admin"], execute=True))[0] == 0 + + provisioned_service = await client.post( + "/api/v1/service-actors", + headers={**headers["admin"], "Idempotency-Key": str(uuid4())}, + json={ + "service_identity": ServiceIdentity.ARTIFACT_VERIFIER.value, + "subject": "auth09d-a-service-target", + "reason": "Provision exact lifecycle service target", + }, + ) + assert provisioned_service.status_code == 201, provisioned_service.text + service_target_id = UUID(provisioned_service.json()["actor_profile_id"]) + service_before = await profile_state(service_target_id) + service_suspend = await client.post( + f"/api/v1/actors/{service_target_id}/suspend", + headers={**headers["admin"], "Idempotency-Key": str(uuid4())}, + json={"reason": "Suspend fixed service target"}, + ) + assert service_suspend.status_code == 200, service_suspend.text + service_suspended = await profile_state(service_target_id) + assert service_suspended[0] == "suspended" + assert service_suspended[1] == str(profiles["admin"]) + assert service_suspended[10:] == service_before[10:] + service_reactivate = await client.post( + f"/api/v1/actors/{service_target_id}/reactivate", + headers={**headers["admin"], "Idempotency-Key": str(uuid4())}, + json={"reason": "Reactivate fixed service target"}, + ) + assert service_reactivate.status_code == 200, service_reactivate.text + service_active = await profile_state(service_target_id) + assert service_active[0] == "active" + assert service_active[1:4] == (None, None, None) + assert service_active[4] == str(profiles["admin"]) + assert service_active[10:] == service_before[10:] + service_deactivate = await client.post( + f"/api/v1/actors/{service_target_id}/deactivate", + headers={**headers["admin"], "Idempotency-Key": str(uuid4())}, + json={"reason": "Terminally deactivate fixed service target"}, + ) + assert service_deactivate.status_code == 200, service_deactivate.text + service_terminal = await profile_state(service_target_id) + assert service_terminal[0] == "deactivated" + assert service_terminal[7] == str(profiles["admin"]) + assert service_terminal[10:] == service_before[10:] + + async def lifecycle_atomic_state(actor_id: UUID) -> tuple: + async with db_session.get_session_factory()() as session: + return ( + await profile_state(actor_id), + await profile_state(profiles["admin"]), + int(await session.scalar(text("select count(*) from audit_events")) or 0), + int( + await session.scalar( + text("select count(*) from authority_idempotency_records") + ) + or 0 + ), + int( + await session.scalar( + text( + "select count(*) from authority_idempotency_records " + "where status='pending'" + ) + ) + or 0 + ), + ) + + failure_target = profiles["failure_target"] + failure_path = f"/api/v1/actors/{failure_target}/suspend" + original_reserve = AuthorityIdempotencyRepository.reserve + original_add_event = AuditService.add_authority_event + original_target_lookup = AdminAuthorizationRepository.lock_actor_lifecycle_target + original_flush = AsyncSession.flush + original_touch = ActorService.touch_after_authorization + original_complete = AuthorityIdempotencyRepository.complete + original_commit = AsyncSession.commit + + async def fail_reservation(*_args, **_kwargs): + raise SQLAlchemyError("forced lifecycle reservation failure") + + async def fail_authorization_evidence(service, event): + if event.event_type is AuthorityEventType.SENSITIVE_AUTHORIZATION_ALLOWED: + raise SQLAlchemyError("forced lifecycle authorization evidence failure") + return await original_add_event(service, event) + + async def fail_target_lookup(*_args, **_kwargs): + raise SQLAlchemyError("forced lifecycle target lookup failure") + + async def fail_state_flush(session, *args, **kwargs): + if any( + isinstance(value, ActorProfile) + and value.id == str(failure_target) + and value.status == "suspended" + for value in session.dirty + ): + raise SQLAlchemyError("forced lifecycle state flush failure") + return await original_flush(session, *args, **kwargs) + + async def fail_caller_touch(*_args, **_kwargs): + raise SQLAlchemyError("forced lifecycle caller touch failure") + + async def fail_success_evidence(service, event): + if event.event_type is AuthorityEventType.ACTOR_PROFILE_SUSPENDED: + raise SQLAlchemyError("forced lifecycle success evidence failure") + return await original_add_event(service, event) + + async def fail_invalidation_evidence(service, event): + if event.event_type is AuthorityEventType.AUTHORITY_INVALIDATION_REQUESTED: + raise SQLAlchemyError("forced lifecycle invalidation evidence failure") + return await original_add_event(service, event) + + async def fail_completion(*_args, **_kwargs): + raise SQLAlchemyError("forced lifecycle idempotency completion failure") + + async def fail_commit(*_args, **_kwargs): + raise SQLAlchemyError("forced lifecycle commit failure") + + failure_stages = ( + (AuthorityIdempotencyRepository, "reserve", original_reserve, fail_reservation), + (AuditService, "add_authority_event", original_add_event, fail_authorization_evidence), + ( + AdminAuthorizationRepository, + "lock_actor_lifecycle_target", + original_target_lookup, + fail_target_lookup, + ), + (AsyncSession, "flush", original_flush, fail_state_flush), + (ActorService, "touch_after_authorization", original_touch, fail_caller_touch), + (AuditService, "add_authority_event", original_add_event, fail_success_evidence), + (AuditService, "add_authority_event", original_add_event, fail_invalidation_evidence), + (AuthorityIdempotencyRepository, "complete", original_complete, fail_completion), + (AsyncSession, "commit", original_commit, fail_commit), + ) + failed_keys: list[str] = [] + for owner, attribute, original, failure in failure_stages: + before_failure = await lifecycle_atomic_state(failure_target) + failure_key = str(uuid4()) + failed_keys.append(failure_key) + monkeypatch.setattr(owner, attribute, failure) + try: + failed_response = await client.post( + failure_path, + headers={**headers["admin"], "Idempotency-Key": failure_key}, + json={"reason": f"Atomic lifecycle failure at {attribute}"}, + ) + finally: + monkeypatch.setattr(owner, attribute, original) + assert failed_response.status_code == 503, failed_response.text + failure_error = failed_response.json()["error"] + assert failure_error["code"] == "service_unavailable" + assert failure_error["message"] == "Service unavailable" + assert failure_error["retryable"] is True + assert failure_error["details"] == {} + UUID(failure_error["correlation_id"]) + assert await lifecycle_atomic_state(failure_target) == before_failure + async with db_session.get_session_factory()() as session: + assert ( + await session.scalar( + text( + "select count(*) from authority_idempotency_records " + "where idempotency_key=:key" + ), + {"key": failure_key}, + ) + or 0 + ) == 0 + + reused_failed_key = await client.post( + failure_path, + headers={**headers["admin"], "Idempotency-Key": failed_keys[-1]}, + json={"reason": "Atomic lifecycle failure at commit"}, + ) + assert reused_failed_key.status_code == 200, reused_failed_key.text + restored_failure_target = await client.post( + f"/api/v1/actors/{failure_target}/reactivate", + headers={**headers["admin"], "Idempotency-Key": str(uuid4())}, + json={"reason": "Restore failure matrix target"}, + ) + assert restored_failure_target.status_code == 200, restored_failure_target.text + + missing = await client.post( + f"/api/v1/actors/{uuid4()}/suspend", + headers={**headers["ordinary"], "Idempotency-Key": str(uuid4())}, + json={"reason": "must not disclose the target"}, + ) + assert missing.status_code == 403 + assert missing.json()["error"]["code"] == "permission_not_granted" + authorized_missing = await client.post( + f"/api/v1/actors/{uuid4()}/suspend", + headers={**headers["admin"], "Idempotency-Key": str(uuid4())}, + json={"reason": "authorized target lookup"}, + ) + assert authorized_missing.status_code == 404 + assert authorized_missing.json()["error"]["code"] == "actor_not_found" + self_denial = await client.post( + f"/api/v1/actors/{profiles['admin']}/suspend", + headers={**headers["admin"], "Idempotency-Key": str(uuid4())}, + json={"reason": "self suspension must fail"}, + ) + assert self_denial.status_code == 403 + assert self_denial.json()["error"]["code"] == "resource_guard_denied" + self_deactivate = await client.post( + f"/api/v1/actors/{profiles['admin']}/deactivate", + headers={**headers["admin"], "Idempotency-Key": str(uuid4())}, + json={"reason": "self deactivation must fail"}, + ) + assert self_deactivate.status_code == 403 + assert self_deactivate.json()["error"]["code"] == "resource_guard_denied" + + delegated = await client.post( + "/api/v1/admin-role-grants", + headers={**headers["admin"], "Idempotency-Key": str(uuid4())}, + json={ + "target_actor_profile_id": str(profiles["ordinary"]), + "role": "access_administrator", + "scope_type": "system", + "scope_project_id": None, + "reason": "Replay authority-loss proof", + }, + ) + assert delegated.status_code == 201, delegated.text + authority_replay_key = str(uuid4()) + delegated_mutation = await client.post( + f"/api/v1/actors/{profiles['replay_target']}/suspend", + headers={**headers["ordinary"], "Idempotency-Key": authority_replay_key}, + json={"reason": "Delegated lifecycle proof"}, + ) + assert delegated_mutation.status_code == 200, delegated_mutation.text + disable_delegate = await client.post( + f"/api/v1/actors/{profiles['ordinary']}/suspend", + headers={**headers["admin"], "Idempotency-Key": str(uuid4())}, + json={"reason": "Temporarily remove caller authority"}, + ) + assert disable_delegate.status_code == 200, disable_delegate.text + delegate_disabled_state = await profile_state(profiles["ordinary"]) + denied_replay = await client.post( + f"/api/v1/actors/{profiles['replay_target']}/suspend", + headers={**headers["ordinary"], "Idempotency-Key": authority_replay_key}, + json={"reason": "Delegated lifecycle proof"}, + ) + assert denied_replay.status_code == 403 + assert denied_replay.json()["error"]["code"] == "actor_suspended" + assert await profile_state(profiles["ordinary"]) == delegate_disabled_state + restore_delegate = await client.post( + f"/api/v1/actors/{profiles['ordinary']}/reactivate", + headers={**headers["admin"], "Idempotency-Key": str(uuid4())}, + json={"reason": "Restore delegated administrator"}, + ) + assert restore_delegate.status_code == 200, restore_delegate.text + + target_before = await profile_state(profiles["target"]) + reason = " Investigate bounded lifecycle access " + suspend_key = str(uuid4()) + original_add_event = AuditService.add_authority_event + + async def fail_lifecycle_success(service, event): + if event.event_type is AuthorityEventType.ACTOR_PROFILE_SUSPENDED: + raise SQLAlchemyError("forced lifecycle evidence failure") + return await original_add_event(service, event) + + monkeypatch.setattr(AuditService, "add_authority_event", fail_lifecycle_success) + failed = await client.post( + f"/api/v1/actors/{profiles['target']}/suspend", + headers={**headers["admin"], "Idempotency-Key": suspend_key}, + json={"reason": reason}, + ) + monkeypatch.setattr(AuditService, "add_authority_event", original_add_event) + assert failed.status_code == 503 + assert failed.json()["error"]["code"] == "service_unavailable" + assert await profile_state(profiles["target"]) == target_before + + suspended = await client.post( + f"/api/v1/actors/{profiles['target']}/suspend", + headers={**headers["admin"], "Idempotency-Key": suspend_key}, + json={"reason": reason}, + ) + assert suspended.status_code == 200, suspended.text + assert suspended.json() == { + "resource_type": "actor_profile", + "resource_id": str(profiles["target"]), + "version": None, + "http_status": 200, + } + assert reason.strip() not in suspended.text + assert "private-target@example.test" not in suspended.text + after_suspend = await profile_state(profiles["target"]) + assert after_suspend[0] == "suspended" + assert after_suspend[1] == str(profiles["admin"]) + assert after_suspend[2] is not None + assert after_suspend[3] == reason.strip() + assert after_suspend[10:] == target_before[10:] + + mismatch = await client.post( + f"/api/v1/actors/{profiles['target']}/suspend", + headers={**headers["admin"], "Idempotency-Key": suspend_key}, + json={"reason": "changed reason under one operation"}, + ) + assert mismatch.status_code == 409 + assert mismatch.json()["error"]["code"] == "idempotency_mismatch" + + conflict_key = str(uuid4()) + conflict = await client.post( + f"/api/v1/actors/{profiles['target']}/suspend", + headers={**headers["admin"], "Idempotency-Key": conflict_key}, + json={"reason": "second transition"}, + ) + assert conflict.status_code == 409 + assert conflict.json()["error"]["code"] == "actor_already_suspended" + + reactivated = await client.post( + f"/api/v1/actors/{profiles['target']}/reactivate", + headers={**headers["admin"], "Idempotency-Key": suspend_key}, + json={"reason": "Correction complete"}, + ) + assert reactivated.status_code == 200, reactivated.text + after_reactivate = await profile_state(profiles["target"]) + assert after_reactivate[0] == "active" + assert after_reactivate[1:4] == (None, None, None) + assert after_reactivate[4] == str(profiles["admin"]) + assert after_reactivate[5] is not None + assert after_reactivate[6] == "Correction complete" + assert after_reactivate[10:] == target_before[10:] + visible_reactivation = await client.get( + f"/api/v1/actors/{profiles['target']}", + headers=headers["admin"], + ) + assert visible_reactivation.status_code == 200 + assert visible_reactivation.json()["reactivated_at"] is not None + + retry_conflict_key = await client.post( + f"/api/v1/actors/{profiles['target']}/suspend", + headers={**headers["admin"], "Idempotency-Key": conflict_key}, + json={"reason": "second transition"}, + ) + assert retry_conflict_key.status_code == 200, retry_conflict_key.text + + replay = await client.post( + f"/api/v1/actors/{profiles['target']}/suspend", + headers={**headers["admin"], "Idempotency-Key": suspend_key}, + json={"reason": reason}, + ) + assert replay.status_code == 200 + assert replay.json() == suspended.json() + + deactivated = await client.post( + f"/api/v1/actors/{profiles['target']}/deactivate", + headers={**headers["admin"], "Idempotency-Key": str(uuid4())}, + json={"reason": "Terminal security response"}, + ) + assert deactivated.status_code == 200, deactivated.text + terminal = await client.post( + f"/api/v1/actors/{profiles['target']}/reactivate", + headers={**headers["admin"], "Idempotency-Key": str(uuid4())}, + json={"reason": "must remain terminal"}, + ) + assert terminal.status_code == 409 + assert terminal.json()["error"]["code"] == "actor_deactivated_terminal" + after_deactivate = await profile_state(profiles["target"]) + assert after_deactivate[0] == "deactivated" + assert after_deactivate[7] == str(profiles["admin"]) + assert after_deactivate[8] is not None + assert after_deactivate[9] == "Terminal security response" + assert after_deactivate[10:] == target_before[10:] + + async with db_session.get_session_factory()() as session: + lifecycle_records = ( + await session.execute( + text( + "select operation,status,count(*) from authority_idempotency_records " + "where response_resource_id=:target " + "group by operation,status order by operation,status" + ), + {"target": str(profiles["target"])}, + ) + ).all() + assert lifecycle_records == [ + ("actor_profile.deactivate", "committed", 1), + ("actor_profile.reactivate", "committed", 1), + ("actor_profile.suspend", "committed", 2), + ] + event_counts = dict( + ( + await session.execute( + text( + "select event_type,count(*) from audit_events " + "where resource_type='actor_profile' and resource_id=:target " + "and (event_type like 'ActorProfile%' " + "or event_type='SensitiveAuthorizationDenied') " + "group by event_type" + ), + {"target": str(profiles["target"])}, + ) + ).all() + ) + assert event_counts["ActorProfileSuspended"] == 2 + assert event_counts["ActorProfileReactivated"] == 1 + assert event_counts["ActorProfileDeactivated"] == 1 + assert event_counts["SensitiveAuthorizationDenied"] == 3 + denial_rows = ( + await session.execute( + text( + "select matched_grant_id,reason,denial_code,before_facts,after_facts " + "from audit_events where event_type='SensitiveAuthorizationDenied' " + "and action_id in ('actor.profile.suspend','actor.profile.reactivate'," + "'actor.profile.deactivate')" + ) + ) + ).all() + assert denial_rows + assert all(row.matched_grant_id is None for row in denial_rows) + denial_evidence = json.dumps([tuple(row) for row in denial_rows], default=str) + for private_value in ( + settings.token_issuer, + *tokens.values(), + *(f"private-{name}@example.test" for name in headers), + reason.strip(), + derive_reason_digest(reason.strip()), + ): + assert private_value not in denial_evidence + assert ( + await session.scalar( + select(func.count()) + .select_from(AuthorityIdempotencyRecord) + .where(AuthorityIdempotencyRecord.status == "pending") + ) + or 0 + ) == 0 + + +async def _wait_for_named_database_lock(database_url: str, application_name: str) -> None: + """Observe the ordered lifecycle request waiting on a PostgreSQL lock.""" + 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("ordered lifecycle request never reached the database lock") + + +async def test_actor_profile_lifecycle_real_postgres_concurrency( + auth_database_env: str, + rsa_signing_material: tuple[rsa.RSAPrivateKey, dict[str, Any]], + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Serialize exact replay and competing transitions without timing sleeps.""" + private_key, jwk = rsa_signing_material + settings = production_verifier_settings(database_url=auth_database_env) + app = create_app(settings) + app.state.auth_verifier = FlowAuthVerifier(settings, jwks_transport=jwks_transport(jwk)) + admin_headers = { + "Authorization": "Bearer " + + issue_asymmetric_token( + private_key, + claims={"sub": "auth09d-a-race-admin", "jti": "auth09d-a-race-admin-token"}, + ) + } + target_headers = { + "Authorization": "Bearer " + + issue_asymmetric_token( + private_key, + claims={"sub": "auth09d-a-race-target", "jti": "auth09d-a-race-target-token"}, + ) + } + + async with AsyncClient( + transport=ASGITransport(app=app), + base_url="http://testserver", + ) as client: + admin_id = UUID( + (await client.get("/api/v1/actors/me", headers=admin_headers)).json()[ + "actor_profile_id" + ] + ) + target_id = UUID( + (await client.get("/api/v1/actors/me", headers=target_headers)).json()[ + "actor_profile_id" + ] + ) + bootstrap_code, bootstrap = await run_admin_bootstrap(admin_id, execute=True) + assert bootstrap_code == 0 + path = f"/api/v1/actors/{target_id}/suspend" + payload = {"reason": "Concurrent exact profile suspension"} + + async def concurrent_posts(keys: tuple[str, str]) -> tuple[Response, Response]: + original_reserve = AuthorityIdempotencyRepository.reserve + ready = asyncio.Event() + arrivals = 0 + + async def barrier_reserve(self, **kwargs): + nonlocal arrivals + arrivals += 1 + if arrivals == 2: + ready.set() + await ready.wait() + return await original_reserve(self, **kwargs) + + monkeypatch.setattr(AuthorityIdempotencyRepository, "reserve", barrier_reserve) + try: + responses = await asyncio.wait_for( + asyncio.gather( + client.post( + path, + headers={**admin_headers, "Idempotency-Key": keys[0]}, + json=payload, + ), + client.post( + path, + headers={**admin_headers, "Idempotency-Key": keys[1]}, + json=payload, + ), + ), + timeout=60, + ) + return responses[0], responses[1] + finally: + monkeypatch.setattr( + AuthorityIdempotencyRepository, + "reserve", + original_reserve, + ) + + shared_key = str(uuid4()) + same_key = await concurrent_posts((shared_key, shared_key)) + assert [response.status_code for response in same_key] == [200, 200] + assert same_key[0].json() == same_key[1].json() + + reactivated = await client.post( + f"/api/v1/actors/{target_id}/reactivate", + headers={**admin_headers, "Idempotency-Key": str(uuid4())}, + json={"reason": "Prepare distinct-key race"}, + ) + assert reactivated.status_code == 200, reactivated.text + + competing_keys = (str(uuid4()), str(uuid4())) + different_keys = await concurrent_posts(competing_keys) + assert sorted(response.status_code for response in different_keys) == [200, 409] + loser = next( + (key, response) + for key, response in zip(competing_keys, different_keys, strict=True) + if response.status_code == 409 + ) + assert loser[1].json()["error"]["code"] == "actor_already_suspended" + + reactivated_again = await client.post( + f"/api/v1/actors/{target_id}/reactivate", + headers={**admin_headers, "Idempotency-Key": str(uuid4())}, + json={"reason": "Prove losing key remains reusable"}, + ) + assert reactivated_again.status_code == 200, reactivated_again.text + reused_loser = await client.post( + path, + headers={**admin_headers, "Idempotency-Key": loser[0]}, + json=payload, + ) + assert reused_loser.status_code == 200, reused_loser.text + + async def create_race_actor(name: str) -> tuple[UUID, dict[str, str]]: + token = issue_asymmetric_token( + private_key, + claims={"sub": f"auth09d-a-race-{name}", "jti": f"auth09d-a-race-{name}-token"}, + ) + actor_headers = {"Authorization": f"Bearer {token}"} + response = await client.get("/api/v1/actors/me", headers=actor_headers) + assert response.status_code == 200, response.text + return UUID(response.json()["actor_profile_id"]), actor_headers + + async def grant_access_administrator(target: UUID, reason: str) -> str: + response = await client.post( + "/api/v1/admin-role-grants", + headers={**admin_headers, "Idempotency-Key": str(uuid4())}, + json={ + "target_actor_profile_id": str(target), + "role": "access_administrator", + "scope_type": "system", + "scope_project_id": None, + "reason": reason, + }, + ) + assert response.status_code == 201, response.text + return response.json()["resource_id"] + + async def ordered_requests( + first_name: str, + requests: tuple[ + tuple[str, str, dict[str, str], dict[str, str], str], + tuple[str, str, dict[str, str], dict[str, str], str], + ], + ) -> tuple[tuple[str, Response], tuple[str, Response]]: + original_lock_control = AdminAuthorizationRepository.lock_control + first_locked = asyncio.Event() + waiter_name = f"auth09d-{uuid4().hex}" + + async def ordered_lock_control(self): + if asyncio.current_task().get_name() == first_name: + control = await original_lock_control(self) + first_locked.set() + await asyncio.wait_for( + _wait_for_named_database_lock(auth_database_env, waiter_name), + timeout=5, + ) + return control + await first_locked.wait() + await self._session.execute( + text("select set_config('application_name', :name, true)"), + {"name": waiter_name}, + ) + return await original_lock_control(self) + + monkeypatch.setattr( + AdminAuthorizationRepository, + "lock_control", + ordered_lock_control, + ) + try: + tasks = [ + asyncio.create_task( + client.post( + request_path, + headers={**request_headers, "Idempotency-Key": key}, + json=body, + ), + name=name, + ) + for name, request_path, request_headers, body, key in requests + ] + responses = await asyncio.wait_for(asyncio.gather(*tasks), timeout=60) + return ( + (requests[0][4], responses[0]), + (requests[1][4], responses[1]), + ) + finally: + monkeypatch.setattr( + AdminAuthorizationRepository, + "lock_control", + original_lock_control, + ) + + def lifecycle_request( + *, + name: str, + target: UUID, + transition: str, + request_headers: dict[str, str] = admin_headers, + ) -> tuple[str, str, dict[str, str], dict[str, str], str]: + return ( + name, + f"/api/v1/actors/{target}/{transition}", + request_headers, + {"reason": f"Ordered {transition} proof for {name}"}, + str(uuid4()), + ) + + reusable_keys: list[str] = [] + + suspend_first_id, _ = await create_race_actor("suspend-first") + suspend_first = await ordered_requests( + "suspend-first", + ( + lifecycle_request( + name="suspend-first", + target=suspend_first_id, + transition="suspend", + ), + lifecycle_request( + name="deactivate-second", + target=suspend_first_id, + transition="deactivate", + ), + ), + ) + assert [result.status_code for _, result in suspend_first] == [200, 200] + + deactivate_first_id, _ = await create_race_actor("deactivate-first") + deactivate_first = await ordered_requests( + "deactivate-first", + ( + lifecycle_request( + name="deactivate-first", + target=deactivate_first_id, + transition="deactivate", + ), + lifecycle_request( + name="suspend-second", + target=deactivate_first_id, + transition="suspend", + ), + ), + ) + assert [result.status_code for _, result in deactivate_first] == [200, 409] + assert deactivate_first[1][1].json()["error"]["code"] == "actor_deactivated_terminal" + reusable_keys.append(deactivate_first[1][0]) + + reactivate_first_id, _ = await create_race_actor("reactivate-first") + prepared_reactivate_first = await client.post( + f"/api/v1/actors/{reactivate_first_id}/suspend", + headers={**admin_headers, "Idempotency-Key": str(uuid4())}, + json={"reason": "Prepare reactivation-first race"}, + ) + assert prepared_reactivate_first.status_code == 200, prepared_reactivate_first.text + reactivate_first = await ordered_requests( + "reactivate-first", + ( + lifecycle_request( + name="reactivate-first", + target=reactivate_first_id, + transition="reactivate", + ), + lifecycle_request( + name="deactivate-after-reactivation", + target=reactivate_first_id, + transition="deactivate", + ), + ), + ) + assert [result.status_code for _, result in reactivate_first] == [200, 200] + + suspended_deactivate_first_id, _ = await create_race_actor( + "suspended-deactivate-first" + ) + prepared_deactivate_first = await client.post( + f"/api/v1/actors/{suspended_deactivate_first_id}/suspend", + headers={**admin_headers, "Idempotency-Key": str(uuid4())}, + json={"reason": "Prepare deactivation-first suspended race"}, + ) + assert prepared_deactivate_first.status_code == 200, prepared_deactivate_first.text + suspended_deactivate_first = await ordered_requests( + "deactivate-suspended-first", + ( + lifecycle_request( + name="deactivate-suspended-first", + target=suspended_deactivate_first_id, + transition="deactivate", + ), + lifecycle_request( + name="reactivate-after-deactivation", + target=suspended_deactivate_first_id, + transition="reactivate", + ), + ), + ) + assert [result.status_code for _, result in suspended_deactivate_first] == [200, 409] + assert ( + suspended_deactivate_first[1][1].json()["error"]["code"] + == "actor_deactivated_terminal" + ) + reusable_keys.append(suspended_deactivate_first[1][0]) + + admin_two_id, _ = await create_race_actor("three-admin-two") + admin_three_id, _ = await create_race_actor("three-admin-three") + await grant_access_administrator(admin_two_id, "Three-admin race participant two") + await grant_access_administrator(admin_three_id, "Three-admin race participant three") + three_admin = await ordered_requests( + "three-admin-first-loss", + ( + lifecycle_request( + name="three-admin-first-loss", + target=admin_two_id, + transition="suspend", + ), + lifecycle_request( + name="three-admin-second-loss", + target=admin_three_id, + transition="suspend", + ), + ), + ) + assert [result.status_code for _, result in three_admin] == [200, 200] + + grant_race_id, grant_race_headers = await create_race_actor("grant-race") + await grant_access_administrator(grant_race_id, "Profile and grant race participant") + grant_race_key = str(uuid4()) + profile_grant_race = await ordered_requests( + "profile-loss-first", + ( + lifecycle_request( + name="profile-loss-first", + target=grant_race_id, + transition="suspend", + ), + ( + "grant-revoke-second", + f"/api/v1/admin-role-grants/{bootstrap['grant_id']}/revoke", + grant_race_headers, + {"reason": "Reciprocal profile and grant loss"}, + grant_race_key, + ), + ), + ) + assert [result.status_code for _, result in profile_grant_race] == [200, 403] + assert profile_grant_race[1][1].json()["error"]["code"] == "actor_suspended" + reusable_keys.append(grant_race_key) + + reciprocal_one_id, reciprocal_one_headers = await create_race_actor( + "reciprocal-one" + ) + reciprocal_two_id, reciprocal_two_headers = await create_race_actor( + "reciprocal-two" + ) + await grant_access_administrator(reciprocal_one_id, "Reciprocal administrator one") + await grant_access_administrator(reciprocal_two_id, "Reciprocal administrator two") + disable_bootstrap = await client.post( + f"/api/v1/actors/{admin_id}/suspend", + headers={**reciprocal_one_headers, "Idempotency-Key": str(uuid4())}, + json={"reason": "Leave exactly two effective administrators"}, + ) + assert disable_bootstrap.status_code == 200, disable_bootstrap.text + reciprocal_second_key = str(uuid4()) + reciprocal = await ordered_requests( + "reciprocal-one-first", + ( + lifecycle_request( + name="reciprocal-one-first", + target=reciprocal_two_id, + transition="suspend", + request_headers=reciprocal_one_headers, + ), + ( + "reciprocal-two-second", + f"/api/v1/actors/{reciprocal_one_id}/suspend", + reciprocal_two_headers, + {"reason": "Reciprocal final-authority loss"}, + reciprocal_second_key, + ), + ), + ) + assert [result.status_code for _, result in reciprocal] == [200, 403] + assert reciprocal[1][1].json()["error"]["code"] == "actor_suspended" + reusable_keys.append(reciprocal_second_key) + + async with db_session.get_session_factory()() as session: + state = ( + await session.execute( + text( + "select p.status,count(r.id) filter (where r.status='committed') " + "from actor_profiles p left join authority_idempotency_records r " + "on r.response_resource_id=p.id::uuid where p.id=:target " + "group by p.status" + ), + {"target": str(target_id)}, + ) + ).one() + assert tuple(state) == ("suspended", 5) + counts = dict( + ( + await session.execute( + text( + "select event_type,count(*) from audit_events " + "where target_actor_ref=:target and event_type in " + "('ActorProfileSuspended','ActorProfileReactivated'," + "'SensitiveAuthorizationDenied') group by event_type" + ), + {"target": str(target_id)}, + ) + ).all() + ) + assert counts == { + "ActorProfileSuspended": 3, + "ActorProfileReactivated": 2, + "SensitiveAuthorizationDenied": 1, + } + ordered_states = dict( + ( + await session.execute( + select(ActorProfile.id, ActorProfile.status).where( + ActorProfile.id.in_( + [ + str(suspend_first_id), + str(deactivate_first_id), + str(reactivate_first_id), + str(suspended_deactivate_first_id), + str(admin_two_id), + str(admin_three_id), + str(grant_race_id), + str(admin_id), + str(reciprocal_one_id), + str(reciprocal_two_id), + ] + ) + ) + ) + ).all() + ) + assert ordered_states == { + str(suspend_first_id): "deactivated", + str(deactivate_first_id): "deactivated", + str(reactivate_first_id): "deactivated", + str(suspended_deactivate_first_id): "deactivated", + str(admin_two_id): "suspended", + str(admin_three_id): "suspended", + str(grant_race_id): "suspended", + str(admin_id): "suspended", + str(reciprocal_one_id): "active", + str(reciprocal_two_id): "suspended", + } + assert await AdminAuthorizationRepository( + session + ).count_effective_access_administrators() == 1 + assert ( + await session.scalar( + select(func.count()) + .select_from(AuthorityIdempotencyRecord) + .where(AuthorityIdempotencyRecord.status == "pending") + ) + or 0 + ) == 0 + assert ( + await session.scalar( + select(func.count()) + .select_from(AuthorityIdempotencyRecord) + .where(AuthorityIdempotencyRecord.idempotency_key.in_(reusable_keys)) + ) + or 0 + ) == 0 + lifecycle_event_counts = dict( + ( + await session.execute( + select(AuditEvent.event_type, func.count()) + .where( + AuditEvent.event_type.in_( + [ + "ActorProfileSuspended", + "ActorProfileReactivated", + "ActorProfileDeactivated", + "SensitiveAuthorizationDenied", + ] + ) + ) + .group_by(AuditEvent.event_type) + ) + ).all() + ) + assert lifecycle_event_counts == { + "ActorProfileSuspended": 11, + "ActorProfileReactivated": 3, + "ActorProfileDeactivated": 4, + "SensitiveAuthorizationDenied": 5, + } + + async def test_no_local_login_password_or_session_routes() -> None: app = create_app() paths = {path.lower() for path in _application_paths(app)} diff --git a/backend/tests/test_authorization.py b/backend/tests/test_authorization.py index 7242e6b8..3ec22822 100644 --- a/backend/tests/test_authorization.py +++ b/backend/tests/test_authorization.py @@ -36,6 +36,14 @@ from app.modules.authorization import catalogue as authorization_catalogue from app.modules.authorization import kernel as authorization_kernel from app.modules.authorization import router as authorization_router +from app.modules.authorization.lifecycle_schemas import ( + ActorLifecycleBody, + ActorLifecycleMutationResponse, +) +from app.modules.authorization.lifecycle_service import ( + ActorLifecycleConflict, + ActorLifecycleService, +) from app.modules.authorization.catalogue import ( ACTION_BY_ID, ACTION_DEFINITIONS, @@ -97,6 +105,7 @@ ActorIdentityLinkAdminReadResourceContext, ActorKind, ActorProfileAdminReadResourceContext, + ActorProfileLifecycleResourceContext, ActorSelfResourceContext, ActorStatus, AdminRoleDefinitionsResourceContext, @@ -247,14 +256,14 @@ def test_closed_permission_and_action_catalogue_is_exact_and_non_executable() -> "admin_role_grant.revoke": ("admin_role.revoke", "WS-AUTH-001-08"), "admin_role_grant.bootstrap": ("admin_role.grant", "WS-AUTH-001-08"), "actor.profile.read": ("actor.profile.read_any", "WS-AUTH-001-09C"), - "actor.profile.suspend": ("actor.profile.suspend", "WS-AUTH-001-09D"), - "actor.profile.reactivate": ("actor.profile.reactivate", "WS-AUTH-001-09D"), - "actor.profile.deactivate": ("actor.profile.deactivate", "WS-AUTH-001-09D"), + "actor.profile.suspend": ("actor.profile.suspend", "WS-AUTH-001-09D-A"), + "actor.profile.reactivate": ("actor.profile.reactivate", "WS-AUTH-001-09D-A"), + "actor.profile.deactivate": ("actor.profile.deactivate", "WS-AUTH-001-09D-A"), "actor.identity_link.read": ("actor.identity_link.read", "WS-AUTH-001-09C"), - "actor.identity_link.revoke": ("actor.identity_link.revoke", "WS-AUTH-001-09D"), + "actor.identity_link.revoke": ("actor.identity_link.revoke", "WS-AUTH-001-09D-B"), "actor.identity_link.reactivate": ( "actor.identity_link.reactivate", - "WS-AUTH-001-09D", + "WS-AUTH-001-09D-B", ), "actor.service.provision": ("actor.service.provision", "WS-AUTH-001-09B"), } @@ -279,9 +288,12 @@ def test_closed_permission_and_action_catalogue_is_exact_and_non_executable() -> ActionId.ADMIN_ROLE_GRANT_REVOKE, ActionId.ADMIN_ROLE_GRANT_BOOTSTRAP, ActionId.ACTOR_PROFILE_READ, - ActionId.ACTOR_IDENTITY_LINK_READ, - ActionId.ACTOR_SERVICE_PROVISION, - } + ActionId.ACTOR_IDENTITY_LINK_READ, + ActionId.ACTOR_SERVICE_PROVISION, + ActionId.ACTOR_PROFILE_SUSPEND, + ActionId.ACTOR_PROFILE_REACTIVATE, + ActionId.ACTOR_PROFILE_DEACTIVATE, + } assert { definition.action_id.value: ( definition.permission_id.value, @@ -821,6 +833,7 @@ def __init__(self, context: AuthorizationContext) -> None: target_actor_profile_id=str(uuid4()), ) self.request_actor_is_present = True + self.lifecycle_target_is_present = True self.control_locked = False self.find_calls: list[tuple[tuple, dict]] = [] @@ -855,6 +868,15 @@ async def actor_exists(self, _actor_profile_id): async def get_grant(self, _grant_id, **_kwargs): return self.grant + async def lock_actor_lifecycle_target(self, actor_profile_id): + if not self.lifecycle_target_is_present: + return None + return ( + SimpleNamespace(status="active"), + SimpleNamespace(id=str(actor_profile_id), status="active"), + None, + ) + def _admin_runtime_service( context: AuthorizationContext, @@ -964,6 +986,299 @@ async def test_actor_admin_reads_reject_cross_paired_resource_contexts( assert evidence.events[0].denial_code == "resource_guard_denied" +@pytest.mark.parametrize( + ("action_id", "transition"), + [ + (ActionId.ACTOR_PROFILE_SUSPEND, "suspend"), + (ActionId.ACTOR_PROFILE_REACTIVATE, "reactivate"), + (ActionId.ACTOR_PROFILE_DEACTIVATE, "deactivate"), + ], +) +async def test_actor_profile_lifecycle_kernel_locks_control_and_exact_target( + action_id: ActionId, + transition: str, +) -> None: + service, evidence, facts = _admin_runtime_service(_runtime_context()) + resource = ActorProfileLifecycleResourceContext( + resource_type="actor_profile", + resource_id=uuid4(), + transition=transition, + ) + + decision = await service.require(action_id, resource) + + assert decision.allowed is True + assert decision.revalidated is True + assert decision.resource_context_digest == authorization_resource_digest(resource) + assert facts.control_locked is True + assert evidence.events[0].resource_id == str(resource.resource_id) + + +async def test_actor_profile_lifecycle_kernel_guards_self_pairing_and_disclosure() -> None: + context = _runtime_context() + service, evidence, facts = _admin_runtime_service(context) + self_resource = ActorProfileLifecycleResourceContext( + resource_type="actor_profile", + resource_id=context.actor_profile_id, + transition="suspend", + ) + with pytest.raises(AuthorizationDenied) as self_denial: + await service.require(ActionId.ACTOR_PROFILE_SUSPEND, self_resource) + assert self_denial.value.public_code == "resource_guard_denied" + + crossed = self_resource.model_copy( + update={"resource_id": uuid4(), "transition": "deactivate"} + ) + with pytest.raises(AuthorizationDenied) as crossed_denial: + await service.require(ActionId.ACTOR_PROFILE_SUSPEND, crossed) + assert crossed_denial.value.public_code == "resource_guard_denied" + + missing = self_resource.model_copy(update={"resource_id": uuid4()}) + facts.lifecycle_target_is_present = False + with pytest.raises(AuthorizationDenied) as missing_denial: + await service.require(ActionId.ACTOR_PROFILE_SUSPEND, missing) + assert missing_denial.value.public_code == "actor_not_found" + assert [event.denial_code for event in evidence.events] == [ + "resource_guard_denied", + "resource_guard_denied", + "actor_not_found", + ] + + +def _actor_lifecycle_decision( + request: ActorProfileSuspendRequest | ActorProfileReactivateRequest, + *, + existing: bool, +) -> AuthorizationDecision: + action = { + AuthorityOperation.ACTOR_PROFILE_SUSPEND: ActionId.ACTOR_PROFILE_SUSPEND, + AuthorityOperation.ACTOR_PROFILE_REACTIVATE: ActionId.ACTOR_PROFILE_REACTIVATE, + }[request.operation] + permission = { + AuthorityOperation.ACTOR_PROFILE_SUSPEND: PermissionId.ACTOR_PROFILE_SUSPEND, + AuthorityOperation.ACTOR_PROFILE_REACTIVATE: PermissionId.ACTOR_PROFILE_REACTIVATE, + }[request.operation] + transition = { + AuthorityOperation.ACTOR_PROFILE_SUSPEND: "suspend", + AuthorityOperation.ACTOR_PROFILE_REACTIVATE: "reactivate", + }[request.operation] + resource = ActorProfileLifecycleResourceContext( + resource_type="actor_profile", + resource_id=request.actor_profile_id, + transition=transition, + existing_idempotency_record=existing, + ) + return AuthorizationDecision( + decision_id=uuid4(), + action_id=action, + permission_id=permission, + allowed=True, + denial_code=None, + resource_type="actor_profile", + resource_id=request.actor_profile_id, + resource_context_digest=authorization_resource_digest(resource), + matched_authority_kind=MatchedAuthorityKind.ADMIN_ROLE_GRANT, + matched_grant_id=uuid4(), + matched_scope_project_id=None, + revalidated=True, + request_id=uuid4(), + correlation_id=uuid4(), + ) + + +async def test_actor_lifecycle_service_rejects_crossed_reason_and_missing_target() -> None: + target, caller = uuid4(), uuid4() + reason = "Bounded suspension reason" + request = ActorProfileSuspendRequest( + operation=AuthorityOperation.ACTOR_PROFILE_SUSPEND, + actor_profile_id=target, + reason_digest=derive_reason_digest(reason), + ) + decision = _actor_lifecycle_decision(request, existing=False) + service = ActorLifecycleService(object()) # type: ignore[arg-type] + claim = AuthorityClaimHandle( + record_id=uuid4(), + idempotency_key=uuid4(), + actor_ref_kind=ActorReferenceKind.ACTOR_PROFILE, + actor_ref=str(caller), + operation=request.operation, + request_digest=DIGEST, + ) + + with pytest.raises(TypeError, match="exact matched authority"): + await service.complete( + claim=claim, + request=request, + decision=decision.model_copy(update={"revalidated": False}), + actor_profile_id=caller, + reason=reason, + ) + with pytest.raises(TypeError, match="reason digest changed"): + await service.complete( + claim=claim, + request=request, + decision=decision, + actor_profile_id=caller, + reason="Substituted reason", + ) + + class MissingTargetRepository: + async def lock_actor_lifecycle_target(self, _actor_profile_id): + return None + + service._repository = MissingTargetRepository() # type: ignore[assignment] + with pytest.raises(RuntimeError, match="target disappeared"): + await service.complete( + claim=claim, + request=request, + decision=decision, + actor_profile_id=caller, + reason=reason, + ) + + +async def test_actor_lifecycle_service_applies_success_and_guards_conflicts() -> None: + target, caller = uuid4(), uuid4() + reason = "Apply exact profile suspension" + + class Session: + flushed = 0 + + async def flush(self): + self.flushed += 1 + + class Repository: + def __init__(self, profile): + self.profile = profile + + async def lock_actor_lifecycle_target(self, _actor_profile_id): + return SimpleNamespace(status="active"), self.profile, None + + async def count_effective_access_administrators(self): + return 1 + + class Mutation: + completed = None + mismatch = None + + async def complete(self, **kwargs): + self.completed = kwargs + + async def record_mismatch_denial(self, **kwargs): + self.mismatch = kwargs + + class Audit: + event = None + + async def add_authority_event(self, event): + self.event = event + + session = Session() + profile = SimpleNamespace( + actor_kind="human", + status="active", + suspended_by=None, + suspended_at=None, + suspension_reason=None, + reactivated_by=None, + reactivated_at=None, + reactivation_reason=None, + deactivated_by=None, + deactivated_at=None, + deactivation_reason=None, + ) + repository = Repository(profile) + mutation = Mutation() + service = ActorLifecycleService(session) # type: ignore[arg-type] + service._repository = repository # type: ignore[assignment] + service._mutation = mutation # type: ignore[assignment] + audit = Audit() + service._audit = audit # type: ignore[assignment] + claim = AuthorityClaimHandle( + record_id=uuid4(), + idempotency_key=uuid4(), + actor_ref_kind=ActorReferenceKind.ACTOR_PROFILE, + actor_ref=str(caller), + operation=AuthorityOperation.ACTOR_PROFILE_SUSPEND, + request_digest=DIGEST, + ) + request = ActorProfileSuspendRequest( + operation=AuthorityOperation.ACTOR_PROFILE_SUSPEND, + actor_profile_id=target, + reason_digest=derive_reason_digest(reason), + ) + decision = _actor_lifecycle_decision(request, existing=False) + + response = await service.complete( + claim=claim, + request=request, + decision=decision, + actor_profile_id=caller, + reason=reason, + ) + assert response.resource_id == target + assert session.flushed == 1 + assert profile.status == "suspended" + assert profile.suspended_by == str(caller) + assert profile.suspension_reason == reason + assert mutation.completed["success"].before_facts == {"status": "active"} + assert mutation.completed["success"].after_facts == {"status": "suspended"} + + reactivate = ActorProfileReactivateRequest( + operation=AuthorityOperation.ACTOR_PROFILE_REACTIVATE, + actor_profile_id=target, + reason_digest=derive_reason_digest("Reactivate active target"), + ) + profile.status = "active" + with pytest.raises(ActorLifecycleConflict) as not_suspended: + await service.complete( + claim=claim.model_copy(update={"operation": reactivate.operation}), + request=reactivate, + decision=_actor_lifecycle_decision(reactivate, existing=False), + actor_profile_id=caller, + reason="Reactivate active target", + ) + assert not_suspended.value.code == "actor_not_suspended" + + assert ( + await service._conflict( + request, + SimpleNamespace(actor_kind="human", status="active"), + "active", + True, + ) + == "last_access_administrator" + ) + crossed = decision.model_copy(update={"revalidated": False}) + with pytest.raises(TypeError, match="mismatch requires exact authority"): + await service.record_mismatch( + actor_profile_id=caller, + request=request, + decision=crossed, + ) + with pytest.raises(TypeError, match="conflict requires exact authority"): + await service.record_conflict( + actor_profile_id=caller, + request=request, + decision=crossed, + code="actor_already_suspended", + ) + + await service.record_mismatch( + actor_profile_id=caller, + request=request, + decision=_actor_lifecycle_decision(request, existing=True), + ) + assert mutation.mismatch["context"].matched_grant_id is None + await service.record_conflict( + actor_profile_id=caller, + request=request, + decision=decision, + code="actor_already_suspended", + ) + assert audit.event.matched_grant_id is None + + async def test_admin_kernel_conceals_targets_until_permission_and_scope_match() -> None: context = _runtime_context() service, _, facts = _admin_runtime_service(context) @@ -2390,16 +2705,20 @@ async def test_authorization_locks_refresh_cached_actor_lifecycle_state( await lifecycle.execute( text( "update actor_profiles set status='active', suspended_by=null, " - "suspended_at=null, suspension_reason=null where id=:actor" + "suspended_at=null, suspension_reason=null, reactivated_by=:actor, " + "reactivated_at=:changed_at, reactivation_reason='security restored' " + "where id=:actor" ), - {"actor": str(profile_id)}, + {"actor": str(profile_id), "changed_at": now}, ) await lifecycle.execute( text( "update actor_identity_links set status='active', revoked_by=null, " - "revoked_at=null, revoked_reason=null where id=:link" + "revoked_at=null, revoked_reason=null, reactivated_by=:actor, " + "reactivated_at=:changed_at, reactivation_reason='credential restored' " + "where id=:link" ), - {"link": str(link_id)}, + {"actor": str(profile_id), "changed_at": now, "link": str(link_id)}, ) await lifecycle.commit() @@ -2414,6 +2733,27 @@ async def test_authorization_locks_refresh_cached_actor_lifecycle_state( assert eligible_link.status == "active" await stale.rollback() + async with authorization_factory() as cleanup: + await cleanup.execute(text("alter table actor_profiles disable trigger user")) + await cleanup.execute(text("alter table actor_identity_links disable trigger user")) + await cleanup.execute( + text( + "update actor_profiles set reactivated_by=null,reactivated_at=null," + "reactivation_reason=null where id=:actor" + ), + {"actor": str(profile_id)}, + ) + await cleanup.execute( + text( + "update actor_identity_links set reactivated_by=null,reactivated_at=null," + "reactivation_reason=null where id=:link" + ), + {"link": str(link_id)}, + ) + await cleanup.execute(text("alter table actor_identity_links enable trigger user")) + await cleanup.execute(text("alter table actor_profiles enable trigger user")) + await cleanup.commit() + def _request(target: UUID | None = None) -> ActorProfileSuspendRequest: return ActorProfileSuspendRequest( @@ -2459,6 +2799,7 @@ def _operation_success( *, admin_authorizer_grant_id: UUID | None = None, admin_revoke_target: UUID | None = None, + identity_link_target: UUID | None = None, ) -> AuthorityAuditEventInput: """Build the exact concrete success evidence for one canonical operation case.""" event, permission, reason = { @@ -2586,8 +2927,14 @@ def _operation_success( elif isinstance(request, ActorProfileDeactivateRequest): before_facts, after_facts = {"status": "active"}, {"status": "deactivated"} elif isinstance(request, ActorIdentityLinkRevokeRequest): + if identity_link_target is None: + raise AssertionError("identity-link revoke proof requires its owning actor") + target_actor = identity_link_target before_facts, after_facts = {"status": "active"}, {"status": "revoked"} elif isinstance(request, ActorIdentityLinkReactivateRequest): + if identity_link_target is None: + raise AssertionError("identity-link reactivation proof requires its owning actor") + target_actor = identity_link_target before_facts, after_facts = {"status": "revoked"}, {"status": "active"} event_id = uuid4() @@ -3413,11 +3760,47 @@ def test_every_operation_has_one_strict_canonical_request_variant() -> None: ) +def test_actor_profile_lifecycle_public_schemas_are_strict_bounded_and_typed() -> None: + target = uuid4() + assert ActorLifecycleBody(reason=" approved correction ").reason == "approved correction" + assert ActorLifecycleBody(reason="\tapproved correction\n").reason == "approved correction" + assert ActorLifecycleBody(reason="\u00a0approved correction\u00a0").reason == ( + "approved correction" + ) + assert ActorLifecycleBody(reason="é" * 250).reason == "é" * 250 + for value in ("", " ", "contains\x00null", "é" * 251, "x" * 501, 1, None): + with pytest.raises(ValidationError): + ActorLifecycleBody.model_validate({"reason": value}) + with pytest.raises(ValidationError): + ActorLifecycleBody.model_validate({"reason": "valid", "unexpected": True}) + assert ActorLifecycleMutationResponse( + resource_type="actor_profile", + resource_id=target, + version=None, + http_status=200, + ).model_dump() == { + "resource_type": "actor_profile", + "resource_id": target, + "version": None, + "http_status": 200, + } + with pytest.raises(ValidationError): + ActorLifecycleMutationResponse.model_validate( + { + "resource_type": "actor_profile", + "resource_id": str(target), + "version": None, + "http_status": 200, + } + ) + + @pytest.mark.asyncio async def test_all_operation_and_replacement_mappings_commit_one_linked_pair( authorization_factory, ) -> None: project, actor, resource, admin_revoke_target = uuid4(), uuid4(), uuid4(), uuid4() + identity_link_target = uuid4() admin_authorizer_grant_id = uuid4() requests = [ ServiceActorCreateRequest( @@ -3543,6 +3926,15 @@ async def test_all_operation_and_replacement_mappings_commit_one_linked_pair( if request.operation is AuthorityOperation.ADMIN_ROLE_GRANT_REVOKE else None ), + identity_link_target=( + identity_link_target + if request.operation + in { + AuthorityOperation.ACTOR_IDENTITY_LINK_REVOKE, + AuthorityOperation.ACTOR_IDENTITY_LINK_REACTIVATE, + } + else None + ), ) if request.operation is AuthorityOperation.ADMIN_ROLE_GRANT_REVOKE: assert success.target_actor_ref == str(admin_revoke_target) @@ -3594,13 +3986,20 @@ async def test_all_operation_and_replacement_mappings_commit_one_linked_pair( in { AuthorityEventType.ADMIN_ROLE_GRANT_ISSUED, AuthorityEventType.ADMIN_ROLE_GRANT_REVOKED, + AuthorityEventType.ACTOR_IDENTITY_LINK_REVOKED, + AuthorityEventType.ACTOR_IDENTITY_LINK_REACTIVATED, } else success.resource_id ) assert invalidation_row.invalidation_target_ref == expected_target expected_before = ( {"effective": False} - if success.event_type is AuthorityEventType.ADMIN_ROLE_GRANT_ISSUED + if success.event_type + in { + AuthorityEventType.ADMIN_ROLE_GRANT_ISSUED, + AuthorityEventType.ACTOR_PROFILE_REACTIVATED, + AuthorityEventType.ACTOR_IDENTITY_LINK_REACTIVATED, + } else {"effective": True} ) expected_after = {"effective": not expected_before["effective"]} diff --git a/docs/architecture_data_model.md b/docs/architecture_data_model.md index ce3dfedb..d79832be 100644 --- a/docs/architecture_data_model.md +++ b/docs/architecture_data_model.md @@ -77,13 +77,23 @@ Fields include: - `status` (`active`, `suspended`, `deactivated`) - permitted display/profile metadata - database-time creation/update fields +- bounded suspension and reactivation attribution plus immutable terminal + deactivation attribution after AUTH-09D-A migration `0026` Profile status is a guard, not a role or project grant. +Lifecycle invalidation effectiveness is component-scoped. Reactivating a +profile records `effective=false -> effective=true` for that profile projection +without asserting that its identity link, grants, or fixed-service admission +make the whole actor effective. ### ActorIdentityLink An identity link binds one canonical external issuer and opaque subject to one -ActorProfile. It has active/revoked state plus immutable revocation provenance. +ActorProfile. It has active/revoked state plus state-transition-guarded current +revocation and reactivation attribution. AUTH-09D-A migration `0026` enforces +complete attribution and bounded lifecycle reasons before AUTH-09D-B activates +link mutations. Append-only audit evidence preserves immutable transition +history; the current row carries only the latest state-compatible attribution. Raw tokens, provider credentials, and full claim payloads are not stored. The database enforces a unique `(issuer, subject)` pair across all links and, in v0.1, at most one active identity link per ActorProfile. Revocation preserves diff --git a/docs/operations_authorization_service.md b/docs/operations_authorization_service.md index e42b20db..c853aa5c 100644 --- a/docs/operations_authorization_service.md +++ b/docs/operations_authorization_service.md @@ -564,11 +564,11 @@ resource loader, lifecycle guards, negative tests, and evidence path exist. ### Catalogue And Action-Evidence Staging The catalogue contains exactly 74 PermissionIds and 65 ActionIds after -AUTH-09C. The two AUTH-07B actor-self actions, seven AUTH-08 administrative +AUTH-09D-A. The two AUTH-07B actor-self actions, seven AUTH-08 administrative actions, `actor.service.provision`, `actor.profile.read`, and -`actor.identity_link.read` are active; the other 53 entries remain planned and -non-executable. Five of those planned rows are AUTH-09D actor/link mutations -introduced by `0023`. The target post-custody +`actor.identity_link.read`, plus the three profile lifecycle actions are active; +the other 50 entries remain planned and non-executable. The two identity-link +lifecycle rows introduced by `0023` remain planned for AUTH-09D-B. The target post-custody invariant is that planned runtime entries contain only action, permission, exact AUTH activation owner, and availability. Until the availability-neutral custody transfers merge, the 25 ART and 19 REV rows retain their historical feature @@ -643,7 +643,7 @@ exact-project grants and canonical project composition. AUTH-10 is a clean cut to independent `submitter`, `reviewer`, and `adjudicator` grants. Before rollout, scan current typed schemas, audit facts, idempotency records, and PostgreSQL validators for `both`, replacement fields, -replacement events, and replacement reasons. Migration `0026` must stop on any +replacement events, and replacement reasons. Migration `0027` must stop on any incompatible evidence; operators must remediate through a separately approved data decision, never an automatic conversion. A safe downgrade also refuses rather than deleting adjudicator or new exact-role evidence. @@ -836,6 +836,20 @@ resource IDs as metric labels. ## Authority Mutation Idempotency +AUTH-09D-A exposes only: + +```text +POST /api/v1/actors/{actor_profile_id}/suspend +POST /api/v1/actors/{actor_profile_id}/reactivate +POST /api/v1/actors/{actor_profile_id}/deactivate +``` + +Each route requires an effective system Access Administrator, the administrative +mutation limiter, a UUID `Idempotency-Key`, and exactly one normalized bounded +`reason`. Conflicts do not consume the key. Deactivation is terminal, and a +profile reactivation does not restore a revoked identity link, grant, or fixed +service admission. Identity-link lifecycle routes remain unavailable. + 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 diff --git a/docs/spec_authorization_service.md b/docs/spec_authorization_service.md index 3f1cf544..291d6c62 100644 --- a/docs/spec_authorization_service.md +++ b/docs/spec_authorization_service.md @@ -119,7 +119,7 @@ The active model has no `both`, replacement field, replacement event, or replacement reason. Qualification evidence is bound to the same actor, project, and exact requested role. One active row is permitted per actor/project/role. Issue idempotency includes the requested role; revoke derives -the role from the locked grant. Migration `0026` refuses upgrade when obsolete +the role from the locked grant. Migration `0027` refuses upgrade when obsolete combined or replacement evidence exists and never converts or deletes those rows. It replaces current typed and PostgreSQL validators without changing historical migrations. @@ -238,13 +238,14 @@ approved Operator recovery identifiers, 21 artifact identifiers, and `review.queue.override` are the exact 25 post-`0020` permissions. AUTH-07A adds their matching typed/SQL audit parity without making them executable. -The closed action registry contains 65 rows after AUTH-09C: 12 active actions -and 53 planned rows. AUTH-08 adds seven active administrative definition, +The closed action registry contains 65 rows after AUTH-09D-A: 15 active actions +and 50 planned rows. AUTH-08 adds seven active administrative definition, grant-history, issue, revoke, and local-bootstrap actions without adding a permission. AUTH-09A adds eight planned actor, identity-link, and service provisioning actions without activating a route; AUTH-09B activates only -`actor.service.provision`, and AUTH-09C activates only `actor.profile.read` and -`actor.identity_link.read`. The other planned rows cover +`actor.service.provision`, AUTH-09C activates only `actor.profile.read` and +`actor.identity_link.read`, and AUTH-09D-A activates only the three profile +lifecycle actions. The other planned rows cover three Operator recovery actions, 25 artifact actions, canonical `submission.create`, and 19 review actions. An action becomes active only when its feature owner has merged the canonical resource composer, guards, surface or @@ -270,19 +271,20 @@ AUTH-09A registers these exact planned actions through migration `0023`: | ActionId | PermissionId | Activation owner | |---|---|---| | `actor.profile.read` | `actor.profile.read_any` | `WS-AUTH-001-09C` | -| `actor.profile.suspend` | `actor.profile.suspend` | `WS-AUTH-001-09D` | -| `actor.profile.reactivate` | `actor.profile.reactivate` | `WS-AUTH-001-09D` | -| `actor.profile.deactivate` | `actor.profile.deactivate` | `WS-AUTH-001-09D` | +| `actor.profile.suspend` | `actor.profile.suspend` | `WS-AUTH-001-09D-A` | +| `actor.profile.reactivate` | `actor.profile.reactivate` | `WS-AUTH-001-09D-A` | +| `actor.profile.deactivate` | `actor.profile.deactivate` | `WS-AUTH-001-09D-A` | | `actor.identity_link.read` | `actor.identity_link.read` | `WS-AUTH-001-09C` | -| `actor.identity_link.revoke` | `actor.identity_link.revoke` | `WS-AUTH-001-09D` | -| `actor.identity_link.reactivate` | `actor.identity_link.reactivate` | `WS-AUTH-001-09D` | +| `actor.identity_link.revoke` | `actor.identity_link.revoke` | `WS-AUTH-001-09D-B` | +| `actor.identity_link.reactivate` | `actor.identity_link.reactivate` | `WS-AUTH-001-09D-B` | | `actor.service.provision` | `actor.service.provision` | `WS-AUTH-001-09B` | AUTH-09B activates only `actor.service.provision` through the controlled route described below. AUTH-09C activates only the two bounded actor-registry reads. -The other five remain unavailable until AUTH-09D supplies the mutation route, -typed resource context, evaluator, guards, transaction proof, and availability -change. AUTH-09A supplies none of those runtime paths. +AUTH-09D-A activates profile suspend, reactivate, and terminal deactivate. The +two identity-link mutations remain unavailable until AUTH-09D-B supplies their +route, typed resource context, evaluator, guards, transaction proof, and +availability change. AUTH-09A supplies none of those runtime paths. The submission/review dependency matrix is closed. AUTH-07A registers only the four stable planned fields shown here; resource facts, candidates, guards, and @@ -637,6 +639,9 @@ effective count. Suspension, deactivation, identity-link revocation, and grant revocation take effect on the next request and on the next sensitive transaction recheck. Each mutation writes an invalidation event atomically with state and evidence. +Profile reactivation writes the inverse `effective=false -> effective=true` +component invalidation. That fact describes the profile only: a revoked link, +missing grant, or unadmitted fixed service can still make the actor ineffective. Assignment reconciliation preserves immutable work history. A revoked actor's ordinary claimed/in-progress assignment may be released by the owning later @@ -707,7 +712,10 @@ AUTH-07B cuts existing `GET|PATCH /api/v1/actors/me` behavior over to the kernel. AUTH-08 activates the two definition reads, scoped grant/history reads, issue/revoke APIs, and local bootstrap command. AUTH-09C activates exact actor and identity-link reads for effective system Access Administrator or Audit -Authority grants. Project-scoped +Authority grants. AUTH-09D-A activates the three profile lifecycle routes for +effective system Access Administrators only. The two identity-link lifecycle +routes remain unavailable until AUTH-09D-B; the project-role route family also +remains planned. Project-scoped `GET /api/v1/actors/me/authorization-context` begins in AUTH-10 after exact-project grant and canonical project capability composition exists. @@ -728,17 +736,20 @@ The implementation order is fixed by the WS-AUTH-001 chunk map: provisioning with an unverified service link until AUTH-09E verifies the service token; 11. `WS-AUTH-001-09C`: actor and identity-link administrative reads; -12. `WS-AUTH-001-09D`: actor and identity-link lifecycle mutations; -13. `WS-AUTH-001-09E`: fixed service runtime admission without human grant +12. `WS-AUTH-001-09D-A`: lifecycle evidence repair and actor-profile suspend, + reactivate, and terminal deactivate; +13. `WS-AUTH-001-09D-B`: identity-link revoke/reactivate and mixed lifecycle + race closure; +14. `WS-AUTH-001-09E`: fixed service runtime admission without human grant evaluation or feature action activation; -14. `WS-AUTH-001-ART-CUSTODY` and `WS-AUTH-001-REV-CUSTODY`: +15. `WS-AUTH-001-ART-CUSTODY` and `WS-AUTH-001-REV-CUSTODY`: availability-neutral transfer to exact AUTH activation owners; -15. `WS-AUTH-001-PREP`: prepared mutation authorization protocol; -16. `WS-AUTH-001-10`: independent project contributor grants; -17. `WS-AUTH-001-11` through `WS-AUTH-001-14`: complete resource-family +16. `WS-AUTH-001-PREP`: prepared mutation authorization protocol; +17. `WS-AUTH-001-10`: independent project contributor grants; +18. `WS-AUTH-001-11` through `WS-AUTH-001-14`: complete resource-family cutovers; -18. `WS-AUTH-001-15`: obsolete authority removal and scanner enforcement; -19. `WS-AUTH-001-16`: conformance, observability, concurrency, and live API +19. `WS-AUTH-001-15`: obsolete authority removal and scanner enforcement; +20. `WS-AUTH-001-16`: conformance, observability, concurrency, and live API proof. No implementation may add a compatibility alias, fallback authority source, diff --git a/scripts/test_agent_gates.py b/scripts/test_agent_gates.py index 3e771cc1..eb5b8f63 100644 --- a/scripts/test_agent_gates.py +++ b/scripts/test_agent_gates.py @@ -4088,7 +4088,7 @@ def test_activation_custody_discovery_includes_canonical_handoffs() -> None: def test_auth_spec_orders_service_admission_before_project_roles() -> None: - """AUTH-09A through 09E precede project contributor grants.""" + """AUTH-09A through both 09D children and 09E precede project grants.""" spec = Path("docs/spec_authorization_service.md").read_text(encoding="utf-8") order = spec.split("## Migration And Compatibility", maxsplit=1)[1].split( "## Error And Privacy Contract", @@ -4098,7 +4098,8 @@ def test_auth_spec_orders_service_admission_before_project_roles() -> None: "WS-AUTH-001-09A", "WS-AUTH-001-09B", "WS-AUTH-001-09C", - "WS-AUTH-001-09D", + "WS-AUTH-001-09D-A", + "WS-AUTH-001-09D-B", "WS-AUTH-001-09E", "WS-AUTH-001-10", ) @@ -4131,15 +4132,15 @@ def test_parallel_initiative_status_matches_trusted_main() -> None: assert "Merged through PR #131 as `aa0fdcd`" in auth_map assert "Merged through PR #143 as `053242b`" in auth_map - assert ( - "`WS-AUTH-001-09C` - Actor And Identity-Link Administration Reads" - in auth_status - ) + assert "Merged through PR #146 as `0ffdabf`" in auth_map assert "| `WS-AUTH-001-08` | Merged |" in auth_status assert "| `WS-AUTH-001-XINT` | Merged |" in auth_status assert "| `WS-AUTH-001-09A` | Merged |" in auth_status assert "| `WS-AUTH-001-09B` | Merged |" in auth_status - assert "| `WS-AUTH-001-09C` | In review |" in auth_status + assert "| `WS-AUTH-001-09C` | Merged |" in auth_status + assert "| `WS-AUTH-001-09D` | Split |" in auth_status + assert "| `WS-AUTH-001-09D-A` | Active |" in auth_status + assert "| `WS-AUTH-001-09D-B` | Inactive |" in auth_status assert "Merged through PR #129 as `9a04434`" in artifact_map artifact_phases = ( "Deterministic proof passed; exact-SHA internal review in progress",