From 2811d219e264ccf30e01dbd932fd83e825ce3c21 Mon Sep 17 00:00:00 2001 From: Abiorh001 Date: Sat, 18 Jul 2026 08:28:24 +0100 Subject: [PATCH 01/27] docs(auth): split actor lifecycle implementation --- .agent-loop/LOOP_STATE.md | 30 +-- .agent-loop/REVIEW_LOG.md | 15 ++ .agent-loop/WORK_QUEUE.md | 10 +- .../CHUNK_MAP.md | 20 +- .../DECISIONS.md | 28 +- .../DISCOVERY.md | 7 +- .../PLAN.md | 6 +- .../STATUS.md | 46 ++-- .../WS-AUTH-001-09D-A-profile-lifecycle.md | 248 ++++++++++++++++++ ...-AUTH-001-09D-B-identity-link-lifecycle.md | 70 +++++ ...S-AUTH-001-09D-actor-identity-lifecycle.md | 165 +++--------- ...001-09E-fixed-service-runtime-admission.md | 4 +- .../WS-AUTH-001-10-project-role-grants.md | 6 +- .../WS-AUTH-001-11-project-read-cutover.md | 6 +- ...WS-AUTH-001-12-project-mutation-cutover.md | 6 +- .../WS-AUTH-001-13-task-assignment-cutover.md | 8 +- ...-AUTH-001-14-submission-checker-cutover.md | 8 +- ...WS-AUTH-001-15-worker-authority-removal.md | 6 +- docs/spec_authorization_service.md | 19 +- scripts/test_agent_gates.py | 12 +- 20 files changed, 500 insertions(+), 220 deletions(-) create mode 100644 .agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/chunks/WS-AUTH-001-09D-A-profile-lifecycle.md create mode 100644 .agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/chunks/WS-AUTH-001-09D-B-identity-link-lifecycle.md diff --git a/.agent-loop/LOOP_STATE.md b/.agent-loop/LOOP_STATE.md index 63acd88f..568df644 100644 --- a/.agent-loop/LOOP_STATE.md +++ b/.agent-loop/LOOP_STATE.md @@ -16,25 +16,25 @@ - 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: none. 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 contract review: `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: repair and approve the exact AUTH-09D-A contract before any + runtime edit. 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 +50,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..95a4fc85 100644 --- a/.agent-loop/REVIEW_LOG.md +++ b/.agent-loop/REVIEW_LOG.md @@ -1,5 +1,20 @@ # Review Log +## 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..cccebf52 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 | Active contract repair and required preimplementation review; no runtime edits yet | 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,10 @@ 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 contract review is active. 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..4ee938d5 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 | Active contract repair and preimplementation review; no runtime edits | +| `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..c601cb3a 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. @@ -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..d6b955bd 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,11 @@ 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. No service caller or feature action is active. ## Active planning chunk @@ -108,13 +103,12 @@ 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. +None. `WS-AUTH-001-09D-A` is in contract repair and required +preimplementation review. Runtime implementation has not started. ## Current review branch -`codex/ws-auth-001-09c-actor-identity-admin-reads` +`codex/ws-auth-001-09d-actor-identity-lifecycle` ## Chunk status @@ -140,9 +134,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` | Contract review | `codex/ws-auth-001-09d-actor-identity-lifecycle` | - | Profile lifecycle and evidence repair; no runtime edits yet. | +| `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 +156,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 cannot enter runtime implementation until +its repaired exact contract passes required L1 preimplementation review. 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 +172,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..8fef195b --- /dev/null +++ b/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/chunks/WS-AUTH-001-09D-A-profile-lifecycle.md @@ -0,0 +1,248 @@ +# 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/db/models.py +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 +scripts/test_agent_gates.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-A.json +.agent-loop/LOOP_STATE.md +.agent-loop/WORK_QUEUE.md +.agent-loop/REVIEW_LOG.md +``` + +## 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. + +## 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. + +Downgrade fails closed if profile reactivation provenance or committed profile/ +link reactivation evidence exists. 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, target, or operation returns `idempotency_mismatch` only after + reauthorization. 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. +- 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. + +## Verification Commands + +```bash +(cd backend && .venv/bin/python -m ruff check app tests scripts/api_contract_e2e.py) +(cd backend && WORKSTREAM_TEST_ADMIN_DATABASE_URL= \ + .venv/bin/python scripts/run_isolated_tests.py --timeout-seconds 1800 -- \ + .venv/bin/python -m pytest -q \ + tests/test_auth.py::test_actor_profile_lifecycle_real_postgres_matrix \ + tests/test_auth.py::test_actor_profile_lifecycle_real_postgres_concurrency) +(cd backend && .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) +(cd backend && .venv/bin/coverage erase && .venv/bin/coverage run --branch \ + --source=app/modules/actors -m pytest -q tests/test_actors.py \ + tests/test_auth.py -k 'actor_profile_lifecycle' && \ + .venv/bin/coverage report --fail-under=90) +(cd backend && .venv/bin/coverage erase && .venv/bin/coverage run --branch \ + --source=app/modules/authorization -m pytest -q tests/test_authorization.py \ + tests/test_auth.py -k 'actor_profile_lifecycle or admin_role' && \ + .venv/bin/coverage report --fail-under=90) +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/docs/spec_authorization_service.md b/docs/spec_authorization_service.md index 3f1cf544..466b173f 100644 --- a/docs/spec_authorization_service.md +++ b/docs/spec_authorization_service.md @@ -728,17 +728,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 0fcfcb33..ff0534cb 100644 --- a/scripts/test_agent_gates.py +++ b/scripts/test_agent_gates.py @@ -4094,7 +4094,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", @@ -4104,7 +4104,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", ) @@ -4137,12 +4138,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` | Contract review |" 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", From b2156e2812657f5f08f986635072bbcb75b87f35 Mon Sep 17 00:00:00 2001 From: Abiorh001 Date: Sat, 18 Jul 2026 08:35:54 +0100 Subject: [PATCH 02/27] docs(auth): make lifecycle proof executable --- .../WS-AUTH-001-09D-A-profile-lifecycle.md | 44 ++++++++++++++----- 1 file changed, 32 insertions(+), 12 deletions(-) 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 index 8fef195b..dd921a4d 100644 --- 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 @@ -204,22 +204,42 @@ so crossed profile/grant loss cannot commit both transitions. ```bash (cd backend && .venv/bin/python -m ruff check app tests scripts/api_contract_e2e.py) -(cd backend && WORKSTREAM_TEST_ADMIN_DATABASE_URL= \ - .venv/bin/python scripts/run_isolated_tests.py --timeout-seconds 1800 -- \ +(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') +(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) -(cd backend && .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) -(cd backend && .venv/bin/coverage erase && .venv/bin/coverage run --branch \ - --source=app/modules/actors -m pytest -q tests/test_actors.py \ - tests/test_auth.py -k 'actor_profile_lifecycle' && \ - .venv/bin/coverage report --fail-under=90) -(cd backend && .venv/bin/coverage erase && .venv/bin/coverage run --branch \ +(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_actors.py tests/test_auth.py \ + -k "actor_profile_lifecycle" && \ + .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 -k 'actor_profile_lifecycle or admin_role' && \ - .venv/bin/coverage report --fail-under=90) + tests/test_auth.py -k "actor_profile_lifecycle or admin_role" && \ + .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 From 733f260f18fce4db02f02fa5e99b80d9875d92f0 Mon Sep 17 00:00:00 2001 From: Abiorh001 Date: Sat, 18 Jul 2026 08:45:27 +0100 Subject: [PATCH 03/27] docs(auth): close lifecycle contract review gaps --- .../DECISIONS.md | 4 +- .../WS-AUTH-001-09D-A-profile-lifecycle.md | 81 ++++++++++++++++--- docs/architecture_data_model.md | 9 ++- docs/operations_authorization_service.md | 2 +- docs/spec_authorization_service.md | 2 +- 5 files changed, 82 insertions(+), 16 deletions(-) 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 c601cb3a..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 @@ -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 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 index dd921a4d..2a9c5b7c 100644 --- 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 @@ -20,12 +20,20 @@ reactivation provenance and incorrectly forces reactivation invalidation from ```text backend/alembic/versions/0026_actor_profile_lifecycle.py -backend/app/db/models.py -backend/app/modules/actors/** -backend/app/modules/authorization/** -backend/app/modules/audit/** -backend/app/api/router.py -backend/app/api/deps/authorization.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/admin_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 @@ -37,6 +45,7 @@ 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 @@ -44,6 +53,13 @@ docs/operations_authorization_service.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 @@ -111,6 +127,11 @@ 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 @@ -132,8 +153,21 @@ Migration `0026_actor_profile_lifecycle`: - 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. Otherwise it restores the prior functions, +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`. @@ -171,8 +205,11 @@ so crossed profile/grant loss cannot commit both transitions. - 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, target, or operation returns `idempotency_mismatch` only after - reauthorization. Replay after authority loss denies without disclosure. +- 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 @@ -191,6 +228,20 @@ so crossed profile/grant loss cannot commit both transitions. - 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, different-target loss | one loss commits, one `last_access_administrator` denial; exactly one effective human admin remains | +| 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. @@ -199,6 +250,11 @@ so crossed profile/grant loss cannot commit both transitions. 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 @@ -209,7 +265,12 @@ so crossed profile/grant loss cannot commit both transitions. .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') + .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 \ diff --git a/docs/architecture_data_model.md b/docs/architecture_data_model.md index e9dfcf7c..3b96f016 100644 --- a/docs/architecture_data_model.md +++ b/docs/architecture_data_model.md @@ -72,14 +72,19 @@ 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. ### ActorIdentityLink An identity link binds one canonical external issuer and opaque subject to one -ActorProfile. It has active/revoked state plus immutable revocation provenance. -Raw tokens, provider credentials, and full claim payloads are not stored. +ActorProfile. It has active/revoked state plus immutable revocation and +reactivation provenance. AUTH-09D-A migration `0026` enforces complete +reactivation attribution and bounded lifecycle reasons before AUTH-09D-B +activates link mutations. 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 the immutable link and provenance; it does not free the pair for rebinding. diff --git a/docs/operations_authorization_service.md b/docs/operations_authorization_service.md index e42b20db..5da07142 100644 --- a/docs/operations_authorization_service.md +++ b/docs/operations_authorization_service.md @@ -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. diff --git a/docs/spec_authorization_service.md b/docs/spec_authorization_service.md index 466b173f..75b023b2 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. From 7f941a53b4a30e6116d9a13fce6246f7aa943ac3 Mon Sep 17 00:00:00 2001 From: Abiorh001 Date: Sat, 18 Jul 2026 08:51:59 +0100 Subject: [PATCH 04/27] docs(auth): correct lifecycle reuse and race topology --- .../chunks/WS-AUTH-001-09D-A-profile-lifecycle.md | 5 +++-- docs/architecture_data_model.md | 11 ++++++----- 2 files changed, 9 insertions(+), 7 deletions(-) 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 index 2a9c5b7c..d9db12f9 100644 --- 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 @@ -27,7 +27,7 @@ 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/admin_repository.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 @@ -236,7 +236,8 @@ so crossed profile/grant loss cannot commit both transitions. | 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, different-target loss | one loss commits, one `last_access_administrator` denial; exactly one effective human admin remains | +| 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/ diff --git a/docs/architecture_data_model.md b/docs/architecture_data_model.md index 3b96f016..9f1f560b 100644 --- a/docs/architecture_data_model.md +++ b/docs/architecture_data_model.md @@ -80,11 +80,12 @@ Profile status is a guard, not a role or project grant. ### ActorIdentityLink An identity link binds one canonical external issuer and opaque subject to one -ActorProfile. It has active/revoked state plus immutable revocation and -reactivation provenance. AUTH-09D-A migration `0026` enforces complete -reactivation attribution and bounded lifecycle reasons before AUTH-09D-B -activates link mutations. Raw tokens, provider credentials, and full claim -payloads are not stored. +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 the immutable link and provenance; it does not free the pair for rebinding. From 86d216a887b9b373951a2a60efee30538ae5029e Mon Sep 17 00:00:00 2001 From: Abiorh001 Date: Sat, 18 Jul 2026 08:58:44 +0100 Subject: [PATCH 05/27] docs(auth): approve profile lifecycle implementation --- .agent-loop/LOOP_STATE.md | 9 ++-- .agent-loop/REVIEW_LOG.md | 9 ++++ .agent-loop/WORK_QUEUE.md | 2 +- .../CHUNK_MAP.md | 2 +- .../STATUS.md | 13 +++--- ...09D-A-preimplementation-review-evidence.md | 44 +++++++++++++++++++ 6 files changed, 67 insertions(+), 12 deletions(-) create mode 100644 .agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/reviews/WS-AUTH-001-09D-A-preimplementation-review-evidence.md diff --git a/.agent-loop/LOOP_STATE.md b/.agent-loop/LOOP_STATE.md index 568df644..56e40920 100644 --- a/.agent-loop/LOOP_STATE.md +++ b/.agent-loop/LOOP_STATE.md @@ -20,10 +20,10 @@ `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: none. The explicitly started parent +- 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 contract review: `WS-AUTH-001-09D-A` on +- 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. @@ -33,8 +33,9 @@ - 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: repair and approve the exact AUTH-09D-A contract before any - runtime edit. No service caller becomes executable before AUTH-09E. +- Current gate: implement the exact reviewed AUTH-09D-A contract, then run its + deterministic proof and exact-head internal review. 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, diff --git a/.agent-loop/REVIEW_LOG.md b/.agent-loop/REVIEW_LOG.md index 95a4fc85..1cb95148 100644 --- a/.agent-loop/REVIEW_LOG.md +++ b/.agent-loop/REVIEW_LOG.md @@ -1,5 +1,14 @@ # Review Log +## 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 diff --git a/.agent-loop/WORK_QUEUE.md b/.agent-loop/WORK_QUEUE.md index cccebf52..12d8a444 100644 --- a/.agent-loop/WORK_QUEUE.md +++ b/.agent-loop/WORK_QUEUE.md @@ -4,7 +4,7 @@ | Chunk | Title | Risk | Status | |---|---|---:|---| -| `WS-AUTH-001-09D-A` | Profile Lifecycle And Evidence Repair | L1 | Active contract repair and required preimplementation review; no runtime edits yet | +| `WS-AUTH-001-09D-A` | Profile Lifecycle And Evidence Repair | L1 | Active implementation; exact-SHA preimplementation review passed at `7f941a5` | Live post-merge state remains read from signed `automation/loop-memory` output. This authored queue records the separately approved parallel chunks. 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 4ee938d5..89f5e713 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 @@ -33,7 +33,7 @@ stopped. | `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 | 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 | Active contract repair and preimplementation review; no runtime edits | +| `WS-AUTH-001-09D-A` | Profile Lifecycle And Evidence Repair | L1 | Active implementation after exact-SHA preimplementation review passed | | `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 | 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 d6b955bd..658e69bf 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 @@ -103,8 +103,9 @@ None. `WS-AUTH-001-XINT` merged through PR #140. ## Active implementation chunk -None. `WS-AUTH-001-09D-A` is in contract repair and required -preimplementation review. Runtime implementation has not started. +`WS-AUTH-001-09D-A` - Profile Lifecycle And Evidence Repair. Exact-SHA +preimplementation review passed at `7f941a5`; implementation may now begin for +the three profile lifecycle routes and migration `0026` only. ## Current review branch @@ -136,7 +137,7 @@ preimplementation review. Runtime implementation has not started. | `WS-AUTH-001-09B` | Merged | `codex/ws-auth-001-09b-controlled-service-provisioning` | #143 | Merged as `053242b`; signed memory passed. | | `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` | Contract review | `codex/ws-auth-001-09d-actor-identity-lifecycle` | - | Profile lifecycle and evidence repair; no runtime edits yet. | +| `WS-AUTH-001-09D-A` | Active | `codex/ws-auth-001-09d-actor-identity-lifecycle` | - | Exact-SHA preimplementation review passed at `7f941a5`; implementation may begin. | | `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. | @@ -157,9 +158,9 @@ merged feature manifests and separate human starts exist. ## Blockers AUTH-09C has no remaining blocker. PR #146 merged as `0ffdabf` and signed -memory passed at `eeb3dc2`. AUTH-09D-A cannot enter runtime implementation until -its repaired exact contract passes required L1 preimplementation review. It -must not add identity-link mutation, service grants, dynamic assignments, +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 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. From 213ea345867801446e068d4d307b66d59c5f4072 Mon Sep 17 00:00:00 2001 From: Abiorh001 Date: Sat, 18 Jul 2026 12:00:50 +0100 Subject: [PATCH 06/27] feat(auth): add actor profile lifecycle --- .agent-loop/WORK_QUEUE.md | 3 +- .../merge-intents/WS-AUTH-001-09D-A.json | 9 + .../versions/0026_actor_profile_lifecycle.py | 380 +++++++++++++++ backend/app/modules/actors/models.py | 29 ++ backend/app/modules/actors/schemas.py | 1 + backend/app/modules/actors/service.py | 1 + backend/app/modules/audit/schemas.py | 10 +- .../app/modules/authorization/catalogue.py | 9 +- backend/app/modules/authorization/kernel.py | 27 +- .../authorization/lifecycle_schemas.py | 35 ++ .../authorization/lifecycle_service.py | 314 +++++++++++++ .../app/modules/authorization/repository.py | 33 ++ backend/app/modules/authorization/router.py | 212 +++++++++ backend/app/modules/authorization/runtime.py | 11 + backend/app/modules/authorization/service.py | 29 +- backend/scripts/api_contract_e2e.py | 34 ++ backend/tests/test_actors.py | 8 +- backend/tests/test_alembic.py | 220 +++++++++ backend/tests/test_api_controls.py | 11 +- backend/tests/test_audit.py | 9 +- backend/tests/test_auth.py | 434 ++++++++++++++++++ backend/tests/test_authorization.py | 140 +++++- docs/architecture_data_model.md | 4 + docs/operations_authorization_service.md | 22 +- docs/spec_authorization_service.md | 34 +- scripts/test_agent_gates.py | 2 +- 26 files changed, 1975 insertions(+), 46 deletions(-) create mode 100644 .agent-loop/merge-intents/WS-AUTH-001-09D-A.json create mode 100644 backend/alembic/versions/0026_actor_profile_lifecycle.py create mode 100644 backend/app/modules/authorization/lifecycle_schemas.py create mode 100644 backend/app/modules/authorization/lifecycle_service.py diff --git a/.agent-loop/WORK_QUEUE.md b/.agent-loop/WORK_QUEUE.md index 12d8a444..53c0eebb 100644 --- a/.agent-loop/WORK_QUEUE.md +++ b/.agent-loop/WORK_QUEUE.md @@ -91,7 +91,8 @@ 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. 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 contract review is active. Do not start 09D-B, +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 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..6a298459 --- /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", + "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..f0237248 --- /dev/null +++ b/backend/alembic/versions/0026_actor_profile_lifecycle.py @@ -0,0 +1,380 @@ +"""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", +) + + +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.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) 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 " + "(suspension_reason is not null and (suspension_reason<>btrim(suspension_reason) or octet_length(suspension_reason) not between 1 and 500)) or " + "(deactivation_reason is not null and (deactivation_reason<>btrim(deactivation_reason) or octet_length(deactivation_reason) not between 1 and 500))) or exists(" + "select 1 from actor_identity_links where " + "(revoked_reason is not null and (revoked_reason<>btrim(revoked_reason) or octet_length(revoked_reason) not between 1 and 500)) or " + "(reactivation_reason is not null and (reactivation_reason<>btrim(reactivation_reason) 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", + "(suspension_reason is null or (suspension_reason=btrim(suspension_reason) and octet_length(suspension_reason) between 1 and 500)) and " + "(reactivation_reason is null or (reactivation_reason=btrim(reactivation_reason) and octet_length(reactivation_reason) between 1 and 500)) and " + "(deactivation_reason is null or (deactivation_reason=btrim(deactivation_reason) 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", + "(revoked_reason is null or (revoked_reason=btrim(revoked_reason) and octet_length(revoked_reason) between 1 and 500)) and " + "(reactivation_reason is null or (reactivation_reason=btrim(reactivation_reason) 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..49ddb33e 100644 --- a/backend/app/modules/actors/models.py +++ b/backend/app/modules/actors/models.py @@ -68,6 +68,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( + "(suspension_reason is null or (suspension_reason = btrim(suspension_reason) and " + "octet_length(suspension_reason) between 1 and 500)) and " + "(reactivation_reason is null or (reactivation_reason = btrim(reactivation_reason) and " + "octet_length(reactivation_reason) between 1 and 500)) and " + "(deactivation_reason is null or (deactivation_reason = btrim(deactivation_reason) 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 +101,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 +134,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( + "(revoked_reason is null or (revoked_reason = btrim(revoked_reason) and " + "octet_length(revoked_reason) between 1 and 500)) and " + "(reactivation_reason is null or (reactivation_reason = btrim(reactivation_reason) 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..323b772a 100644 --- a/backend/app/modules/authorization/catalogue.py +++ b/backend/app/modules/authorization/catalogue.py @@ -272,17 +272,17 @@ def _active( PermissionId.ACTOR_PROFILE_READ_ANY, ActionOwner.AUTH_09C, ), - _planned( + _active( ActionId.ACTOR_PROFILE_SUSPEND, PermissionId.ACTOR_PROFILE_SUSPEND, ActionOwner.AUTH_09D, ), - _planned( + _active( ActionId.ACTOR_PROFILE_REACTIVATE, PermissionId.ACTOR_PROFILE_REACTIVATE, ActionOwner.AUTH_09D, ), - _planned( + _active( ActionId.ACTOR_PROFILE_DEACTIVATE, PermissionId.ACTOR_PROFILE_DEACTIVATE, ActionOwner.AUTH_09D, @@ -581,6 +581,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..af3e77a1 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): 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..48f7c59e --- /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=decision.matched_grant_id, + ), + ) + + 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=str(decision.matched_grant_id), + 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..42f24f56 100644 --- a/backend/tests/test_alembic.py +++ b/backend/tests/test_alembic.py @@ -1323,6 +1323,226 @@ 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}, + ) + 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}, + ) + 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 reactivated_by=:actor 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}) + 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")) + 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 prior-head provenance before adding any 0026 DDL.""" + config = _alembic_config() + actor_id = str(uuid4()) + + async def seed_partial(value: str | None) -> None: + engine = create_async_engine(isolated_database_env) + try: + async with engine.begin() as connection: + if value is not None: + await _insert_canonical_actor(connection, actor_id, "dirty-lifecycle", "human") + await connection.execute( + text( + "update actor_identity_links set reactivated_by=:value " + "where actor_profile_id=:actor" + ), + {"value": value, "actor": actor_id}, + ) + finally: + await engine.dispose() + + with migration_lock(): + try: + command.downgrade(config, "base") + command.upgrade(config, "0025_artifact_store_v2") + asyncio.run(seed_partial(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" + asyncio.run(seed_partial(None)) + command.upgrade(config, "head") + 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 once profile reactivation provenance exists.""" + config = _alembic_config() + actor_id = str(uuid4()) + + async def write_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 _insert_canonical_actor(connection, actor_id, "forward-lifecycle", "human") + 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() + + with migration_lock(): + try: + command.downgrade(config, "base") + command.upgrade(config, "head") + asyncio.run(write_reactivation()) + 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" + asyncio.run(write_reactivation(clear=True)) + 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, 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..06e6fff8 100644 --- a/backend/tests/test_auth.py +++ b/backend/tests/test_auth.py @@ -4793,6 +4793,440 @@ 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") + } + 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 + + 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 + assert ( + await session.scalar( + select(func.count()) + .select_from(AuthorityIdempotencyRecord) + .where(AuthorityIdempotencyRecord.status == "pending") + ) + or 0 + ) == 0 + + +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" + ] + ) + assert (await run_admin_bootstrap(admin_id, execute=True))[0] == 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 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, + } + + 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..bd1dc62f 100644 --- a/backend/tests/test_authorization.py +++ b/backend/tests/test_authorization.py @@ -36,6 +36,10 @@ 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.catalogue import ( ACTION_BY_ID, ACTION_DEFINITIONS, @@ -97,6 +101,7 @@ ActorIdentityLinkAdminReadResourceContext, ActorKind, ActorProfileAdminReadResourceContext, + ActorProfileLifecycleResourceContext, ActorSelfResourceContext, ActorStatus, AdminRoleDefinitionsResourceContext, @@ -279,9 +284,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 +829,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 +864,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 +982,65 @@ 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", + ] + + async def test_admin_kernel_conceals_targets_until_permission_and_scope_match() -> None: context = _runtime_context() service, _, facts = _admin_runtime_service(context) @@ -2459,6 +2536,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 +2664,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 +3497,43 @@ 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="é" * 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 +3659,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 +3719,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 9f1f560b..de4f67af 100644 --- a/docs/architecture_data_model.md +++ b/docs/architecture_data_model.md @@ -76,6 +76,10 @@ Fields include: 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 diff --git a/docs/operations_authorization_service.md b/docs/operations_authorization_service.md index 5da07142..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 @@ -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 75b023b2..291d6c62 100644 --- a/docs/spec_authorization_service.md +++ b/docs/spec_authorization_service.md @@ -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. diff --git a/scripts/test_agent_gates.py b/scripts/test_agent_gates.py index ff0534cb..4d768a66 100644 --- a/scripts/test_agent_gates.py +++ b/scripts/test_agent_gates.py @@ -4145,7 +4145,7 @@ def test_parallel_initiative_status_matches_trusted_main() -> None: assert "| `WS-AUTH-001-09B` | Merged |" 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` | Contract review |" 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 = ( From 9101a591bc9261ca1ab014104d43e8092f1912ec Mon Sep 17 00:00:00 2001 From: Abiorh001 Date: Sat, 18 Jul 2026 12:14:24 +0100 Subject: [PATCH 07/27] test(auth): prove actor lifecycle race outcomes --- backend/tests/test_auth.py | 347 ++++++++++++++++++++++++++++++++++++- 1 file changed, 346 insertions(+), 1 deletion(-) diff --git a/backend/tests/test_auth.py b/backend/tests/test_auth.py index 06e6fff8..9da39876 100644 --- a/backend/tests/test_auth.py +++ b/backend/tests/test_auth.py @@ -5117,7 +5117,8 @@ async def test_actor_profile_lifecycle_real_postgres_concurrency( "actor_profile_id" ] ) - assert (await run_admin_bootstrap(admin_id, execute=True))[0] == 0 + 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"} @@ -5194,6 +5195,273 @@ async def barrier_reserve(self, **kwargs): ) 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() + second_attempted = asyncio.Event() + + async def ordered_lock_control(self): + if asyncio.current_task().get_name() == first_name: + control = await original_lock_control(self) + first_locked.set() + await second_attempted.wait() + return control + await first_locked.wait() + second_attempted.set() + 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( @@ -5225,6 +5493,83 @@ async def barrier_reserve(self, **kwargs): "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: From 027df1e1b2fcba67f773835ad7ed50dbc27dd30a Mon Sep 17 00:00:00 2001 From: Abiorh001 Date: Sat, 18 Jul 2026 13:19:50 +0100 Subject: [PATCH 08/27] test(auth): strengthen lifecycle behavior proof --- .../WS-AUTH-001-09D-A-profile-lifecycle.md | 16 +- backend/tests/test_auth.py | 1 + backend/tests/test_authorization.py | 212 ++++++++++++++++++ 3 files changed, 226 insertions(+), 3 deletions(-) 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 index d9db12f9..1d715462 100644 --- 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 @@ -286,8 +286,11 @@ profile/grant state. PostgreSQL blockers, never timing sleeps, establish order. .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_actors.py tests/test_auth.py \ - -k "actor_profile_lifecycle" && \ + --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= \ @@ -295,7 +298,14 @@ profile/grant state. PostgreSQL blockers, never timing sleeps, establish order. --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 -k "actor_profile_lifecycle or admin_role" && \ + 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= \ diff --git a/backend/tests/test_auth.py b/backend/tests/test_auth.py index 9da39876..6b5bffdd 100644 --- a/backend/tests/test_auth.py +++ b/backend/tests/test_auth.py @@ -1894,6 +1894,7 @@ def assert_retryable_service_unavailable(response: Response) -> None: "updated_at", "last_seen_at", "suspended_at", + "reactivated_at", "deactivated_at", } link_fields = { diff --git a/backend/tests/test_authorization.py b/backend/tests/test_authorization.py index bd1dc62f..7791809c 100644 --- a/backend/tests/test_authorization.py +++ b/backend/tests/test_authorization.py @@ -40,6 +40,10 @@ ActorLifecycleBody, ActorLifecycleMutationResponse, ) +from app.modules.authorization.lifecycle_service import ( + ActorLifecycleConflict, + ActorLifecycleService, +) from app.modules.authorization.catalogue import ( ACTION_BY_ID, ACTION_DEFINITIONS, @@ -1041,6 +1045,214 @@ async def test_actor_profile_lifecycle_kernel_guards_self_pairing_and_disclosure ] +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 + + async def complete(self, **kwargs): + self.completed = kwargs + + 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] + 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", + ) + + async def test_admin_kernel_conceals_targets_until_permission_and_scope_match() -> None: context = _runtime_context() service, _, facts = _admin_runtime_service(context) From a4029bd623996b1e6d71c9d7536bfe0104d12a9c Mon Sep 17 00:00:00 2001 From: Abiorh001 Date: Sat, 18 Jul 2026 13:27:29 +0100 Subject: [PATCH 09/27] chore(auth): align lifecycle merge evidence --- .agent-loop/merge-intents/WS-AUTH-001-09D-A.json | 2 +- backend/alembic/versions/0026_actor_profile_lifecycle.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.agent-loop/merge-intents/WS-AUTH-001-09D-A.json b/.agent-loop/merge-intents/WS-AUTH-001-09D-A.json index 6a298459..3672c280 100644 --- a/.agent-loop/merge-intents/WS-AUTH-001-09D-A.json +++ b/.agent-loop/merge-intents/WS-AUTH-001-09D-A.json @@ -3,7 +3,7 @@ "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", + "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 index f0237248..22477e32 100644 --- a/backend/alembic/versions/0026_actor_profile_lifecycle.py +++ b/backend/alembic/versions/0026_actor_profile_lifecycle.py @@ -302,10 +302,10 @@ def _dirty_lifecycle_rows(bind) -> bool: 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(" + "(reactivation_reason is null)::int not in (0,3)) or exists(" "select 1 from actor_profiles where " "(suspension_reason is not null and (suspension_reason<>btrim(suspension_reason) or octet_length(suspension_reason) not between 1 and 500)) or " - "(deactivation_reason is not null and (deactivation_reason<>btrim(deactivation_reason) or octet_length(deactivation_reason) not between 1 and 500))) or exists(" + "(deactivation_reason is not null and (deactivation_reason<>btrim(deactivation_reason) or octet_length(deactivation_reason) not between 1 and 500))) or exists(" "select 1 from actor_identity_links where " "(revoked_reason is not null and (revoked_reason<>btrim(revoked_reason) or octet_length(revoked_reason) not between 1 and 500)) or " "(reactivation_reason is not null and (reactivation_reason<>btrim(reactivation_reason) or octet_length(reactivation_reason) not between 1 and 500)))" From 43192d413b015b46c59635e61cbc73f3d773ffa3 Mon Sep 17 00:00:00 2001 From: Abiorh001 Date: Sat, 18 Jul 2026 13:31:24 +0100 Subject: [PATCH 10/27] docs(auth): record lifecycle implementation evidence --- .agent-loop/LOOP_STATE.md | 6 +++--- .agent-loop/REVIEW_LOG.md | 14 ++++++++++++++ .agent-loop/WORK_QUEUE.md | 2 +- .../CHUNK_MAP.md | 2 +- .../STATUS.md | 11 +++++++---- 5 files changed, 26 insertions(+), 9 deletions(-) diff --git a/.agent-loop/LOOP_STATE.md b/.agent-loop/LOOP_STATE.md index 56e40920..813a489e 100644 --- a/.agent-loop/LOOP_STATE.md +++ b/.agent-loop/LOOP_STATE.md @@ -33,9 +33,9 @@ - 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: implement the exact reviewed AUTH-09D-A contract, then run its - deterministic proof and exact-head internal review. No service caller becomes - executable before AUTH-09E. +- Current gate: AUTH-09D-A implementation and deterministic proof passed at + `a4029bd`; run exact-head internal review, address valid findings, and publish + one ready PR. 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, diff --git a/.agent-loop/REVIEW_LOG.md b/.agent-loop/REVIEW_LOG.md index 1cb95148..8b5d9664 100644 --- a/.agent-loop/REVIEW_LOG.md +++ b/.agent-loop/REVIEW_LOG.md @@ -1,5 +1,19 @@ # Review Log +## 2026-07-18 - WS-AUTH-001-09D-A Deterministic Evidence Passed + +- Candidate `a4029bd` implements migration `0026`, truthful linked lifecycle + invalidation, and only the three reviewed actor-profile lifecycle routes. +- The five exact migration proofs, expanded PostgreSQL lifecycle matrix and + blocker-controlled race proof, focused regression repairs, and the real HTTP + API contract pass. No timing sleeps establish concurrency order. +- Focused branch coverage passes at 90.70 percent for actors and 91.78 percent + for authorization; the new lifecycle service is 100 percent branch-covered. +- Ruff, stale wording and authorization scans, Markdown links, 87 Agent Gates, + merge-intent validation, and diff integrity pass. Exact-head internal review, + PR publication, external checks, and explicit human merge approval remain. + `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`. diff --git a/.agent-loop/WORK_QUEUE.md b/.agent-loop/WORK_QUEUE.md index 53c0eebb..b9ad7d8d 100644 --- a/.agent-loop/WORK_QUEUE.md +++ b/.agent-loop/WORK_QUEUE.md @@ -4,7 +4,7 @@ | Chunk | Title | Risk | Status | |---|---|---:|---| -| `WS-AUTH-001-09D-A` | Profile Lifecycle And Evidence Repair | L1 | Active implementation; exact-SHA preimplementation review passed at `7f941a5` | +| `WS-AUTH-001-09D-A` | Profile Lifecycle And Evidence Repair | L1 | Implementation and deterministic proof passed at `a4029bd`; exact-head internal review pending | Live post-merge state remains read from signed `automation/loop-memory` output. This authored queue records the separately approved parallel chunks. 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 89f5e713..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 @@ -33,7 +33,7 @@ stopped. | `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 | 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 | Active implementation after exact-SHA preimplementation review passed | +| `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 | 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 658e69bf..95e924e7 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 @@ -95,7 +95,9 @@ 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. No service caller or feature action is active. +split into 09D-A and 09D-B. AUTH-09D-A implementation and deterministic proof +pass at `a4029bd`; exact-head internal review remains. No service caller or +feature action is active. ## Active planning chunk @@ -104,8 +106,9 @@ None. `WS-AUTH-001-XINT` merged through PR #140. ## Active implementation chunk `WS-AUTH-001-09D-A` - Profile Lifecycle And Evidence Repair. Exact-SHA -preimplementation review passed at `7f941a5`; implementation may now begin for -the three profile lifecycle routes and migration `0026` only. +preimplementation review passed at `7f941a5`; the three profile lifecycle +routes, migration `0026`, and deterministic proof pass at `a4029bd`. Exact-head +internal review is the current gate. ## Current review branch @@ -137,7 +140,7 @@ the three profile lifecycle routes and migration `0026` only. | `WS-AUTH-001-09B` | Merged | `codex/ws-auth-001-09b-controlled-service-provisioning` | #143 | Merged as `053242b`; signed memory passed. | | `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` | - | Exact-SHA preimplementation review passed at `7f941a5`; implementation may begin. | +| `WS-AUTH-001-09D-A` | Active | `codex/ws-auth-001-09d-actor-identity-lifecycle` | - | Implementation and deterministic proof passed at `a4029bd`; exact-head internal review 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. | From 3258153289b2816d9bbbfe1ec55378888c9acaba Mon Sep 17 00:00:00 2001 From: Abiorh001 Date: Sat, 18 Jul 2026 14:46:16 +0100 Subject: [PATCH 11/27] fix(auth): close lifecycle review gaps --- .../versions/0026_actor_profile_lifecycle.py | 8 +- backend/app/modules/authorization/kernel.py | 2 +- .../authorization/lifecycle_service.py | 4 +- backend/tests/test_alembic.py | 99 +++++++- backend/tests/test_auth.py | 239 +++++++++++++++++- backend/tests/test_authorization.py | 26 ++ 6 files changed, 368 insertions(+), 10 deletions(-) diff --git a/backend/alembic/versions/0026_actor_profile_lifecycle.py b/backend/alembic/versions/0026_actor_profile_lifecycle.py index 22477e32..2690cb77 100644 --- a/backend/alembic/versions/0026_actor_profile_lifecycle.py +++ b/backend/alembic/versions/0026_actor_profile_lifecycle.py @@ -217,6 +217,9 @@ def _replace_lifecycle_guards(*, upgraded: bool) -> None: 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'; @@ -255,7 +258,10 @@ def _replace_lifecycle_guards(*, upgraded: bool) -> None: 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) then + ((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 ( diff --git a/backend/app/modules/authorization/kernel.py b/backend/app/modules/authorization/kernel.py index af3e77a1..4a208541 100644 --- a/backend/app/modules/authorization/kernel.py +++ b/backend/app/modules/authorization/kernel.py @@ -425,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_service.py b/backend/app/modules/authorization/lifecycle_service.py index 48f7c59e..9cb83de1 100644 --- a/backend/app/modules/authorization/lifecycle_service.py +++ b/backend/app/modules/authorization/lifecycle_service.py @@ -187,7 +187,7 @@ async def record_mismatch( event_id=uuid4(), request_id=decision.request_id, correlation_id=decision.correlation_id, - matched_grant_id=decision.matched_grant_id, + matched_grant_id=None, ), ) @@ -215,7 +215,7 @@ async def record_conflict( 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), + matched_grant_id=None, permission_id=_PERMISSION[request.operation], action_id=_ACTION[request.operation], resource_type="actor_profile", diff --git a/backend/tests/test_alembic.py b/backend/tests/test_alembic.py index 42f24f56..26ca80e5 100644 --- a/backend/tests/test_alembic.py +++ b/backend/tests/test_alembic.py @@ -1391,6 +1391,16 @@ async def prove_guards() -> None: ), {"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," @@ -1399,11 +1409,83 @@ async def prove_guards() -> None: ), {"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 reactivated_by=:actor where actor_profile_id=:actor", + "update actor_identity_links set reactivation_reason='rewritten' " + "where actor_profile_id=:actor", ) for statement in invalid: with pytest.raises(DBAPIError): @@ -1419,6 +1501,21 @@ async def prove_guards() -> None: {"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() diff --git a/backend/tests/test_auth.py b/backend/tests/test_auth.py index 6b5bffdd..89961063 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, @@ -4813,7 +4814,7 @@ async def test_actor_profile_lifecycle_real_postgres_matrix( "email": f"private-{name}@example.test", }, ) - for name in ("admin", "target", "ordinary", "replay_target") + for name in ("admin", "target", "ordinary", "replay_target", "failure_target") } headers = { name: {"Authorization": f"Bearer {token}"} for name, token in tokens.items() @@ -4851,6 +4852,186 @@ async def profile_state(actor_id: UUID) -> tuple: } 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())}, @@ -5069,6 +5250,27 @@ async def fail_lifecycle_success(service, event): 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()) @@ -5079,6 +5281,27 @@ async def fail_lifecycle_success(service, event): ) == 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]], @@ -5230,16 +5453,22 @@ async def ordered_requests( ) -> tuple[tuple[str, Response], tuple[str, Response]]: original_lock_control = AdminAuthorizationRepository.lock_control first_locked = asyncio.Event() - second_attempted = 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 second_attempted.wait() + await asyncio.wait_for( + _wait_for_named_database_lock(auth_database_env, waiter_name), + timeout=5, + ) return control await first_locked.wait() - second_attempted.set() + await self._session.execute( + text("select set_config('application_name', :name, true)"), + {"name": waiter_name}, + ) return await original_lock_control(self) monkeypatch.setattr( diff --git a/backend/tests/test_authorization.py b/backend/tests/test_authorization.py index 7791809c..ef52d6fd 100644 --- a/backend/tests/test_authorization.py +++ b/backend/tests/test_authorization.py @@ -1159,10 +1159,20 @@ async def count_effective_access_administrators(self): 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", @@ -1182,6 +1192,8 @@ async def complete(self, **kwargs): 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(), @@ -1252,6 +1264,20 @@ async def complete(self, **kwargs): 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() From 603300f515863d92f5903d9e0e3f15def1ae3487 Mon Sep 17 00:00:00 2001 From: Abiorh001 Date: Sat, 18 Jul 2026 14:59:56 +0100 Subject: [PATCH 12/27] test(auth): clean lifecycle provenance fixture --- backend/tests/test_authorization.py | 33 +++++++++++++++++++++++++---- 1 file changed, 29 insertions(+), 4 deletions(-) diff --git a/backend/tests/test_authorization.py b/backend/tests/test_authorization.py index ef52d6fd..b96d8aca 100644 --- a/backend/tests/test_authorization.py +++ b/backend/tests/test_authorization.py @@ -2705,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() @@ -2729,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( From b025460afa855594d06056dc9b93b0b9c9b1da59 Mon Sep 17 00:00:00 2001 From: Abiorh001 Date: Sat, 18 Jul 2026 15:23:02 +0100 Subject: [PATCH 13/27] test(auth): isolate legacy lifecycle fixtures --- backend/tests/test_auth.py | 27 +++++++++++++++++++++++---- 1 file changed, 23 insertions(+), 4 deletions(-) diff --git a/backend/tests/test_auth.py b/backend/tests/test_auth.py index 89961063..6557010b 100644 --- a/backend/tests/test_auth.py +++ b/backend/tests/test_auth.py @@ -2253,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 = [ @@ -3369,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: @@ -3394,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( From af506c432649089770c86b453091501476fc9cd7 Mon Sep 17 00:00:00 2001 From: Abiorh001 Date: Sat, 18 Jul 2026 15:36:45 +0100 Subject: [PATCH 14/27] docs(auth): record lifecycle review repair --- .agent-loop/LOOP_STATE.md | 4 +-- .agent-loop/REVIEW_LOG.md | 30 ++++++++++++------- .agent-loop/WORK_QUEUE.md | 2 +- .../STATUS.md | 8 ++--- 4 files changed, 26 insertions(+), 18 deletions(-) diff --git a/.agent-loop/LOOP_STATE.md b/.agent-loop/LOOP_STATE.md index 813a489e..896065c2 100644 --- a/.agent-loop/LOOP_STATE.md +++ b/.agent-loop/LOOP_STATE.md @@ -33,8 +33,8 @@ - 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: AUTH-09D-A implementation and deterministic proof passed at - `a4029bd`; run exact-head internal review, address valid findings, and publish +- Current gate: AUTH-09D-A implementation and repaired deterministic proof pass + at `b025460`; rerun exact-head internal review and publish one ready PR. 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 diff --git a/.agent-loop/REVIEW_LOG.md b/.agent-loop/REVIEW_LOG.md index 8b5d9664..63ae15c8 100644 --- a/.agent-loop/REVIEW_LOG.md +++ b/.agent-loop/REVIEW_LOG.md @@ -1,17 +1,25 @@ # Review Log -## 2026-07-18 - WS-AUTH-001-09D-A Deterministic Evidence Passed - -- Candidate `a4029bd` implements migration `0026`, truthful linked lifecycle - invalidation, and only the three reviewed actor-profile lifecycle routes. -- The five exact migration proofs, expanded PostgreSQL lifecycle matrix and - blocker-controlled race proof, focused regression repairs, and the real HTTP - API contract pass. No timing sleeps establish concurrency order. +## 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'`. +- All five exact migration proofs pass in 184.12 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 new lifecycle service is 100 percent branch-covered. -- Ruff, stale wording and authorization scans, Markdown links, 87 Agent Gates, - merge-intent validation, and diff integrity pass. Exact-head internal review, - PR publication, external checks, and explicit human merge approval remain. + 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 diff --git a/.agent-loop/WORK_QUEUE.md b/.agent-loop/WORK_QUEUE.md index b9ad7d8d..21094baf 100644 --- a/.agent-loop/WORK_QUEUE.md +++ b/.agent-loop/WORK_QUEUE.md @@ -4,7 +4,7 @@ | Chunk | Title | Risk | Status | |---|---|---:|---| -| `WS-AUTH-001-09D-A` | Profile Lifecycle And Evidence Repair | L1 | Implementation and deterministic proof passed at `a4029bd`; exact-head internal review pending | +| `WS-AUTH-001-09D-A` | Profile Lifecycle And Evidence Repair | L1 | Repaired implementation and deterministic proof passed at `b025460`; exact-head internal review pending | Live post-merge state remains read from signed `automation/loop-memory` output. This authored queue records the separately approved parallel chunks. 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 95e924e7..6c8846ad 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 @@ -95,8 +95,8 @@ 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 implementation and deterministic proof -pass at `a4029bd`; exact-head internal review remains. No service caller or +split into 09D-A and 09D-B. AUTH-09D-A repaired implementation and deterministic +proof pass at `b025460`; exact-head internal review remains. No service caller or feature action is active. ## Active planning chunk @@ -107,7 +107,7 @@ None. `WS-AUTH-001-XINT` merged through PR #140. `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 deterministic proof pass at `a4029bd`. Exact-head +routes, migration `0026`, and repaired deterministic proof pass at `b025460`. Exact-head internal review is the current gate. ## Current review branch @@ -140,7 +140,7 @@ internal review is the current gate. | `WS-AUTH-001-09B` | Merged | `codex/ws-auth-001-09b-controlled-service-provisioning` | #143 | Merged as `053242b`; signed memory passed. | | `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` | - | Implementation and deterministic proof passed at `a4029bd`; exact-head internal review pending. | +| `WS-AUTH-001-09D-A` | Active | `codex/ws-auth-001-09d-actor-identity-lifecycle` | - | Repaired implementation and deterministic proof passed at `b025460`; exact-head internal review 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. | From 3d689332dd2edc3d0f9dc0324cb6644394ac58ef Mon Sep 17 00:00:00 2001 From: Abiorh001 Date: Sat, 18 Jul 2026 16:14:31 +0100 Subject: [PATCH 15/27] test(auth): close lifecycle migration review gaps --- .../app/modules/authorization/catalogue.py | 13 +- backend/tests/test_alembic.py | 329 ++++++++++++++++-- backend/tests/test_authorization.py | 10 +- 3 files changed, 320 insertions(+), 32 deletions(-) diff --git a/backend/app/modules/authorization/catalogue.py b/backend/app/modules/authorization/catalogue.py index 323b772a..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" @@ -275,17 +276,17 @@ def _active( _active( ActionId.ACTOR_PROFILE_SUSPEND, PermissionId.ACTOR_PROFILE_SUSPEND, - ActionOwner.AUTH_09D, + ActionOwner.AUTH_09D_A, ), _active( ActionId.ACTOR_PROFILE_REACTIVATE, PermissionId.ACTOR_PROFILE_REACTIVATE, - ActionOwner.AUTH_09D, + ActionOwner.AUTH_09D_A, ), _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, diff --git a/backend/tests/test_alembic.py b/backend/tests/test_alembic.py index 26ca80e5..d08de837 100644 --- a/backend/tests/test_alembic.py +++ b/backend/tests/test_alembic.py @@ -1532,22 +1532,133 @@ def test_actor_profile_lifecycle_upgrade_refuses_dirty_rows( isolated_database_env: str, migration_lock, ) -> None: - """Refuse partial prior-head provenance before adding any 0026 DDL.""" + """Refuse partial provenance and non-normalized reasons before 0026 DDL.""" config = _alembic_config() - actor_id = str(uuid4()) + 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()), + "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()), + "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()), + "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 seed_partial(value: str | None) -> None: + async def restore_active_fixtures() -> None: engine = create_async_engine(isolated_database_env) try: async with engine.begin() as connection: - if value is not None: - await _insert_canonical_actor(connection, actor_id, "dirty-lifecycle", "human") + await connection.execute(text("alter table actor_profiles disable trigger user")) await connection.execute( text( - "update actor_identity_links set reactivated_by=:value " - "where actor_profile_id=:actor" + "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)" ), - {"value": value, "actor": actor_id}, + {"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() @@ -1556,12 +1667,19 @@ async def seed_partial(value: str | None) -> None: try: command.downgrade(config, "base") command.upgrade(config, "0025_artifact_store_v2") - asyncio.run(seed_partial(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" - asyncio.run(seed_partial(None)) + 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") @@ -1588,11 +1706,19 @@ def test_actor_profile_lifecycle_downgrade_refuses_forward_evidence( isolated_database_env: str, migration_lock, ) -> None: - """Keep 0026 intact once profile reactivation provenance exists.""" + """Keep 0026 intact for every profile, link, or denial evidence branch.""" config = _alembic_config() actor_id = str(uuid4()) - async def write_reactivation(*, clear: bool = False) -> None: + 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: @@ -1607,7 +1733,6 @@ async def write_reactivation(*, clear: bool = False) -> None: ) await connection.execute(text("alter table actor_profiles enable trigger user")) return - await _insert_canonical_actor(connection, actor_id, "forward-lifecycle", "human") await connection.execute( text( "update actor_profiles set status='suspended',suspended_by=:actor," @@ -1626,15 +1751,177 @@ async def write_reactivation(*, clear: bool = False) -> None: 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(write_reactivation()) - 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" - asyncio.run(write_reactivation(clear=True)) + 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") diff --git a/backend/tests/test_authorization.py b/backend/tests/test_authorization.py index b96d8aca..fa7ed16d 100644 --- a/backend/tests/test_authorization.py +++ b/backend/tests/test_authorization.py @@ -256,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"), } From d2644ae1251b165945b09ba610d04be73a17d984 Mon Sep 17 00:00:00 2001 From: Abiorh001 Date: Sat, 18 Jul 2026 16:21:28 +0100 Subject: [PATCH 16/27] docs(auth): record lifecycle migration review repair --- .agent-loop/LOOP_STATE.md | 4 ++-- .agent-loop/REVIEW_LOG.md | 8 +++++++- .agent-loop/WORK_QUEUE.md | 2 +- .../STATUS.md | 8 ++++---- 4 files changed, 14 insertions(+), 8 deletions(-) diff --git a/.agent-loop/LOOP_STATE.md b/.agent-loop/LOOP_STATE.md index 896065c2..5717f008 100644 --- a/.agent-loop/LOOP_STATE.md +++ b/.agent-loop/LOOP_STATE.md @@ -33,8 +33,8 @@ - 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: AUTH-09D-A implementation and repaired deterministic proof pass - at `b025460`; rerun exact-head internal review and publish +- Current gate: AUTH-09D-A implementation and twice-repaired deterministic proof + pass at `3d68933`; rerun exact-head internal review and publish one ready PR. 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 diff --git a/.agent-loop/REVIEW_LOG.md b/.agent-loop/REVIEW_LOG.md index 63ae15c8..d6b2fe42 100644 --- a/.agent-loop/REVIEW_LOG.md +++ b/.agent-loop/REVIEW_LOG.md @@ -10,7 +10,13 @@ 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'`. -- All five exact migration proofs pass in 184.12 seconds. The expanded real +- 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. +- 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 diff --git a/.agent-loop/WORK_QUEUE.md b/.agent-loop/WORK_QUEUE.md index 21094baf..bef993df 100644 --- a/.agent-loop/WORK_QUEUE.md +++ b/.agent-loop/WORK_QUEUE.md @@ -4,7 +4,7 @@ | Chunk | Title | Risk | Status | |---|---|---:|---| -| `WS-AUTH-001-09D-A` | Profile Lifecycle And Evidence Repair | L1 | Repaired implementation and deterministic proof passed at `b025460`; exact-head internal review pending | +| `WS-AUTH-001-09D-A` | Profile Lifecycle And Evidence Repair | L1 | Twice-repaired implementation and deterministic proof passed at `3d68933`; exact-head internal review pending | Live post-merge state remains read from signed `automation/loop-memory` output. This authored queue records the separately approved parallel chunks. 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 6c8846ad..97190163 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 @@ -95,8 +95,8 @@ 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 repaired implementation and deterministic -proof pass at `b025460`; exact-head internal review remains. No service caller or +split into 09D-A and 09D-B. AUTH-09D-A twice-repaired implementation and +deterministic proof pass at `3d68933`; exact-head internal review remains. No service caller or feature action is active. ## Active planning chunk @@ -107,7 +107,7 @@ None. `WS-AUTH-001-XINT` merged through PR #140. `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 at `b025460`. Exact-head +routes, migration `0026`, and twice-repaired deterministic proof pass at `3d68933`. Exact-head internal review is the current gate. ## Current review branch @@ -140,7 +140,7 @@ internal review is the current gate. | `WS-AUTH-001-09B` | Merged | `codex/ws-auth-001-09b-controlled-service-provisioning` | #143 | Merged as `053242b`; signed memory passed. | | `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` | - | Repaired implementation and deterministic proof passed at `b025460`; exact-head internal review pending. | +| `WS-AUTH-001-09D-A` | Active | `codex/ws-auth-001-09d-actor-identity-lifecycle` | - | Twice-repaired implementation and deterministic proof passed at `3d68933`; exact-head internal review 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. | From e64b03f0baca2defd668a3cb806d04f06387bd9c Mon Sep 17 00:00:00 2001 From: Abiorh001 Date: Sat, 18 Jul 2026 16:32:07 +0100 Subject: [PATCH 17/27] test(auth): align historical migration owners --- backend/tests/test_alembic.py | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/backend/tests/test_alembic.py b/backend/tests/test_alembic.py index d08de837..9afd857e 100644 --- a/backend/tests/test_alembic.py +++ b/backend/tests/test_alembic.py @@ -539,7 +539,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 +693,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 +769,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 +845,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 From cc7e6cc4c12d2f378c71a9ddd65ebe8f3bed9643 Mon Sep 17 00:00:00 2001 From: Abiorh001 Date: Sat, 18 Jul 2026 16:32:50 +0100 Subject: [PATCH 18/27] docs(auth): record historical owner repair --- .agent-loop/LOOP_STATE.md | 4 ++-- .agent-loop/REVIEW_LOG.md | 4 ++++ .agent-loop/WORK_QUEUE.md | 2 +- .../STATUS.md | 8 ++++---- 4 files changed, 11 insertions(+), 7 deletions(-) diff --git a/.agent-loop/LOOP_STATE.md b/.agent-loop/LOOP_STATE.md index 5717f008..761f1eb0 100644 --- a/.agent-loop/LOOP_STATE.md +++ b/.agent-loop/LOOP_STATE.md @@ -33,8 +33,8 @@ - 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: AUTH-09D-A implementation and twice-repaired deterministic proof - pass at `3d68933`; rerun exact-head internal review and publish +- Current gate: AUTH-09D-A implementation and repaired deterministic proof pass + at `e64b03f`; rerun exact-head internal review and publish one ready PR. 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 diff --git a/.agent-loop/REVIEW_LOG.md b/.agent-loop/REVIEW_LOG.md index d6b2fe42..6e90e0ba 100644 --- a/.agent-loop/REVIEW_LOG.md +++ b/.agent-loop/REVIEW_LOG.md @@ -16,6 +16,10 @@ - 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. diff --git a/.agent-loop/WORK_QUEUE.md b/.agent-loop/WORK_QUEUE.md index bef993df..8bc622cd 100644 --- a/.agent-loop/WORK_QUEUE.md +++ b/.agent-loop/WORK_QUEUE.md @@ -4,7 +4,7 @@ | Chunk | Title | Risk | Status | |---|---|---:|---| -| `WS-AUTH-001-09D-A` | Profile Lifecycle And Evidence Repair | L1 | Twice-repaired implementation and deterministic proof passed at `3d68933`; exact-head internal review pending | +| `WS-AUTH-001-09D-A` | Profile Lifecycle And Evidence Repair | L1 | Repaired implementation and deterministic proof passed at `e64b03f`; exact-head internal review pending | Live post-merge state remains read from signed `automation/loop-memory` output. This authored queue records the separately approved parallel chunks. 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 97190163..ad00dfb4 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 @@ -95,8 +95,8 @@ 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 twice-repaired implementation and -deterministic proof pass at `3d68933`; exact-head internal review remains. No service caller or +split into 09D-A and 09D-B. AUTH-09D-A repaired implementation and +deterministic proof pass at `e64b03f`; exact-head internal review remains. No service caller or feature action is active. ## Active planning chunk @@ -107,7 +107,7 @@ None. `WS-AUTH-001-XINT` merged through PR #140. `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 twice-repaired deterministic proof pass at `3d68933`. Exact-head +routes, migration `0026`, and repaired deterministic proof pass at `e64b03f`. Exact-head internal review is the current gate. ## Current review branch @@ -140,7 +140,7 @@ internal review is the current gate. | `WS-AUTH-001-09B` | Merged | `codex/ws-auth-001-09b-controlled-service-provisioning` | #143 | Merged as `053242b`; signed memory passed. | | `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` | - | Twice-repaired implementation and deterministic proof passed at `3d68933`; exact-head internal review pending. | +| `WS-AUTH-001-09D-A` | Active | `codex/ws-auth-001-09d-actor-identity-lifecycle` | - | Repaired implementation and deterministic proof passed at `e64b03f`; exact-head internal review 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. | From cc2939a60740eb5ba753fc6713d8f8165a3e34b6 Mon Sep 17 00:00:00 2001 From: Abiorh001 Date: Sat, 18 Jul 2026 16:43:53 +0100 Subject: [PATCH 19/27] docs(auth): publish lifecycle review evidence --- .agent-loop/LOOP_STATE.md | 6 +- .agent-loop/REVIEW_LOG.md | 11 ++ .agent-loop/WORK_QUEUE.md | 2 +- .../STATUS.md | 12 +- ...AUTH-001-09D-A-internal-review-evidence.md | 80 +++++++++++++ .../WS-AUTH-001-09D-A-pr-trust-bundle.md | 113 ++++++++++++++++++ 6 files changed, 214 insertions(+), 10 deletions(-) create mode 100644 .agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/reviews/WS-AUTH-001-09D-A-internal-review-evidence.md create mode 100644 .agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/reviews/WS-AUTH-001-09D-A-pr-trust-bundle.md diff --git a/.agent-loop/LOOP_STATE.md b/.agent-loop/LOOP_STATE.md index 761f1eb0..814aaae0 100644 --- a/.agent-loop/LOOP_STATE.md +++ b/.agent-loop/LOOP_STATE.md @@ -33,9 +33,9 @@ - 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: AUTH-09D-A implementation and repaired deterministic proof pass - at `e64b03f`; rerun exact-head internal review and publish - one ready PR. No service caller becomes executable before AUTH-09E. +- Current gate: AUTH-09D-A exact-head internal review passed at `cc7e6cc` after + all valid findings were repaired; publish one ready PR. 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, diff --git a/.agent-loop/REVIEW_LOG.md b/.agent-loop/REVIEW_LOG.md index 6e90e0ba..3681a42c 100644 --- a/.agent-loop/REVIEW_LOG.md +++ b/.agent-loop/REVIEW_LOG.md @@ -1,5 +1,16 @@ # Review Log +## 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 publication is the next + gate; external checks and explicit human merge approval follow. + ## 2026-07-18 - WS-AUTH-001-09D-A Deterministic Evidence Repaired - Initial exact-head QA/test-delta review rejected `43192d` because lifecycle diff --git a/.agent-loop/WORK_QUEUE.md b/.agent-loop/WORK_QUEUE.md index 8bc622cd..5846bd66 100644 --- a/.agent-loop/WORK_QUEUE.md +++ b/.agent-loop/WORK_QUEUE.md @@ -4,7 +4,7 @@ | Chunk | Title | Risk | Status | |---|---|---:|---| -| `WS-AUTH-001-09D-A` | Profile Lifecycle And Evidence Repair | L1 | Repaired implementation and deterministic proof passed at `e64b03f`; exact-head internal review pending | +| `WS-AUTH-001-09D-A` | Profile Lifecycle And Evidence Repair | L1 | Exact-head internal review passed at `cc7e6cc`; ready PR publication pending | Live post-merge state remains read from signed `automation/loop-memory` output. This authored queue records the separately approved parallel chunks. 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 ad00dfb4..e575f13b 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 @@ -95,9 +95,9 @@ 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 repaired implementation and -deterministic proof pass at `e64b03f`; exact-head internal review remains. No service caller or -feature action is active. +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 publication remains. +No service caller or feature action is active. ## Active planning chunk @@ -107,8 +107,8 @@ None. `WS-AUTH-001-XINT` merged through PR #140. `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 at `e64b03f`. Exact-head -internal review is the current gate. +routes, migration `0026`, and repaired deterministic proof pass. Exact-head +internal review passed at `cc7e6cc`; ready PR publication is the current gate. ## Current review branch @@ -140,7 +140,7 @@ internal review is the current gate. | `WS-AUTH-001-09B` | Merged | `codex/ws-auth-001-09b-controlled-service-provisioning` | #143 | Merged as `053242b`; signed memory passed. | | `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` | - | Repaired implementation and deterministic proof passed at `e64b03f`; exact-head internal review pending. | +| `WS-AUTH-001-09D-A` | Active | `codex/ws-auth-001-09d-actor-identity-lifecycle` | - | Exact-head internal review passed at `cc7e6cc`; ready PR publication 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. | 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..e6e0ed7b --- /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,80 @@ +# WS-AUTH-001-09D-A Internal Review Evidence + +Reviewed code SHA: `cc7e6cc4c12d2f378c71a9ddd65ebe8f3bed9643` + +Reviewed implementation SHA: `e64b03f0baca2defd668a3cb806d04f06387bd9c` + +Reviewed against trusted main: +`f18b620932bb257dc1dc355bc0504271813dc6b1` + +Reviewed at: `2026-07-18T15:39:32Z` + +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 181.50 seconds on isolated PostgreSQL. +- 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 later production delta changes only closed action-owner + metadata, which is exercised by exact catalogue and migration tests. +- 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 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 | Behavior proof covers privacy, fixed-service targets, nine failure stages, database refusal branches, and observed lock waits. | +| 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. | +| migration/data integrity | PASS AFTER FIXES | none | Upgrade and downgrade refusal matrices preserve revision, schema, rows, and evidence. | +| CI integrity | PASS AFTER FIXES | none | Historical owner selectors use both child owners and all affected database tests pass. | +| docs | PASS AFTER FIXES | none | Specification, architecture, operations, chunk state, and catalogue ownership 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. All required tracks pass the final exact reviewed head. + +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..4dec40a0 --- /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,113 @@ +# 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, normalized 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 181.50 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, +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 `cc7e6cc4c12d2f378c71a9ddd65ebe8f3bed9643` +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. From e7f54c374482d097d8ecec77ba059448fa4b9a9f Mon Sep 17 00:00:00 2001 From: Abiorh001 Date: Sat, 18 Jul 2026 16:49:53 +0100 Subject: [PATCH 20/27] docs(auth): record lifecycle pull request --- .agent-loop/LOOP_STATE.md | 5 +++-- .agent-loop/REVIEW_LOG.md | 4 ++-- .agent-loop/WORK_QUEUE.md | 2 +- .../STATUS.md | 10 ++++++---- 4 files changed, 12 insertions(+), 9 deletions(-) diff --git a/.agent-loop/LOOP_STATE.md b/.agent-loop/LOOP_STATE.md index 814aaae0..0761c2c2 100644 --- a/.agent-loop/LOOP_STATE.md +++ b/.agent-loop/LOOP_STATE.md @@ -34,8 +34,9 @@ `fc89fb6`; its schema-v1 cross-initiative next pointer is superseded by the schema-v2 initiative-local clean cut. - Current gate: AUTH-09D-A exact-head internal review passed at `cc7e6cc` after - all valid findings were repaired; publish one ready PR. No service caller - becomes executable before AUTH-09E. + all valid findings were repaired; ready PR #148 is open for external checks + and explicit human approval. 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, diff --git a/.agent-loop/REVIEW_LOG.md b/.agent-loop/REVIEW_LOG.md index 3681a42c..9d786fd7 100644 --- a/.agent-loop/REVIEW_LOG.md +++ b/.agent-loop/REVIEW_LOG.md @@ -8,8 +8,8 @@ - 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 publication is the next - gate; external checks and explicit human merge approval follow. + 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 diff --git a/.agent-loop/WORK_QUEUE.md b/.agent-loop/WORK_QUEUE.md index 5846bd66..c602a673 100644 --- a/.agent-loop/WORK_QUEUE.md +++ b/.agent-loop/WORK_QUEUE.md @@ -4,7 +4,7 @@ | Chunk | Title | Risk | Status | |---|---|---:|---| -| `WS-AUTH-001-09D-A` | Profile Lifecycle And Evidence Repair | L1 | Exact-head internal review passed at `cc7e6cc`; ready PR publication pending | +| `WS-AUTH-001-09D-A` | Profile Lifecycle And Evidence Repair | L1 | Exact-head internal review passed at `cc7e6cc`; ready PR #148 open for external checks and human approval | Live post-merge state remains read from signed `automation/loop-memory` output. This authored queue records the separately approved parallel chunks. 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 e575f13b..d9b3f79a 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 @@ -96,8 +96,9 @@ Its bounded implementation and external repair passed before PR #146 merged as 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 publication remains. -No service caller or feature action is active. +`cc7e6cc` after all valid findings were repaired; ready PR #148 is open for +external checks and explicit human approval. No service caller or feature +action is active. ## Active planning chunk @@ -108,7 +109,8 @@ None. `WS-AUTH-001-XINT` merged through PR #140. `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. Exact-head -internal review passed at `cc7e6cc`; ready PR publication is the current gate. +internal review passed at `cc7e6cc`; ready PR #148 external checks and explicit +human approval are the current gate. ## Current review branch @@ -140,7 +142,7 @@ internal review passed at `cc7e6cc`; ready PR publication is the current gate. | `WS-AUTH-001-09B` | Merged | `codex/ws-auth-001-09b-controlled-service-provisioning` | #143 | Merged as `053242b`; signed memory passed. | | `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` | - | Exact-head internal review passed at `cc7e6cc`; ready PR publication pending. | +| `WS-AUTH-001-09D-A` | Active | `codex/ws-auth-001-09d-actor-identity-lifecycle` | #148 | Exact-head internal review passed at `cc7e6cc`; external checks and explicit human approval 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. | From d8e9287eeb1aa298cc4e67a0ba18f466d902dd5c Mon Sep 17 00:00:00 2001 From: Abiorh001 Date: Sat, 18 Jul 2026 17:27:07 +0100 Subject: [PATCH 21/27] fix(auth): align lifecycle reason normalization --- .agent-loop/LOOP_STATE.md | 9 ++-- .agent-loop/REVIEW_LOG.md | 13 +++++ .agent-loop/WORK_QUEUE.md | 2 +- .../STATUS.md | 16 +++--- ...AUTH-001-09D-A-external-review-response.md | 53 +++++++++++++++++++ .../versions/0026_actor_profile_lifecycle.py | 18 +++---- backend/app/modules/actors/models.py | 10 ++-- backend/tests/test_alembic.py | 51 ++++++++++++++++++ backend/tests/test_authorization.py | 1 + 9 files changed, 148 insertions(+), 25 deletions(-) create mode 100644 .agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/reviews/WS-AUTH-001-09D-A-external-review-response.md diff --git a/.agent-loop/LOOP_STATE.md b/.agent-loop/LOOP_STATE.md index 0761c2c2..70bbfc2e 100644 --- a/.agent-loop/LOOP_STATE.md +++ b/.agent-loop/LOOP_STATE.md @@ -33,10 +33,11 @@ - 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: 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. 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. Focused + PostgreSQL proof passes; required exact-head internal rereview, 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, diff --git a/.agent-loop/REVIEW_LOG.md b/.agent-loop/REVIEW_LOG.md index 9d786fd7..cb98e796 100644 --- a/.agent-loop/REVIEW_LOG.md +++ b/.agent-loop/REVIEW_LOG.md @@ -1,5 +1,18 @@ # Review Log +## 2026-07-18 - WS-AUTH-001-09D-A External Repair In Review + +- 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 must rerun against the repaired exact head, + after which only canonical evidence rows and the exact reviewed SHA will be + recorded. 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, diff --git a/.agent-loop/WORK_QUEUE.md b/.agent-loop/WORK_QUEUE.md index c602a673..9c323063 100644 --- a/.agent-loop/WORK_QUEUE.md +++ b/.agent-loop/WORK_QUEUE.md @@ -4,7 +4,7 @@ | Chunk | Title | Risk | Status | |---|---|---:|---| -| `WS-AUTH-001-09D-A` | Profile Lifecycle And Evidence Repair | L1 | Exact-head internal review passed at `cc7e6cc`; ready PR #148 open for external checks and human approval | +| `WS-AUTH-001-09D-A` | Profile Lifecycle And Evidence Repair | L1 | PR #148 external normalization/evidence repair passes focused proof; exact-head internal rereview pending | Live post-merge state remains read from signed `automation/loop-memory` output. This authored queue records the separately approved parallel chunks. 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 d9b3f79a..9260a7af 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 @@ -97,8 +97,11 @@ 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. No service caller or feature -action is active. +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 rereview and replacement external checks remain. +No service caller or feature action is active. ## Active planning chunk @@ -108,9 +111,10 @@ None. `WS-AUTH-001-XINT` merged through PR #140. `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. Exact-head -internal review passed at `cc7e6cc`; ready PR #148 external checks and explicit -human approval are the current gate. +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. Exact-head internal rereview, replacement external +checks, and explicit human approval are the current gate. ## Current review branch @@ -142,7 +146,7 @@ human approval are the current gate. | `WS-AUTH-001-09B` | Merged | `codex/ws-auth-001-09b-controlled-service-provisioning` | #143 | Merged as `053242b`; signed memory passed. | | `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 | Exact-head internal review passed at `cc7e6cc`; external checks and explicit human approval pending. | +| `WS-AUTH-001-09D-A` | Active | `codex/ws-auth-001-09d-actor-identity-lifecycle` | #148 | External normalization/evidence repair passes focused proof; exact-head internal rereview 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. | 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..589e368b --- /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,53 @@ +# 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 +an explicit ASCII whitespace set: space, tab, newline, carriage return, form +feed, and vertical tab. 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, and previous-head dirty-row cases prove tab +and newline 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. + +## 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 93.33s`. +- 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/backend/alembic/versions/0026_actor_profile_lifecycle.py b/backend/alembic/versions/0026_actor_profile_lifecycle.py index 2690cb77..0573df6f 100644 --- a/backend/alembic/versions/0026_actor_profile_lifecycle.py +++ b/backend/alembic/versions/0026_actor_profile_lifecycle.py @@ -310,11 +310,11 @@ def _dirty_lifecycle_rows(bind) -> bool: "(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 " - "(suspension_reason is not null and (suspension_reason<>btrim(suspension_reason) or octet_length(suspension_reason) not between 1 and 500)) or " - "(deactivation_reason is not null and (deactivation_reason<>btrim(deactivation_reason) or octet_length(deactivation_reason) not between 1 and 500))) or exists(" + "(suspension_reason is not null and (suspension_reason<>btrim(suspension_reason, E' \\t\\n\\r\\f\\013') or octet_length(suspension_reason) not between 1 and 500)) or " + "(deactivation_reason is not null and (deactivation_reason<>btrim(deactivation_reason, E' \\t\\n\\r\\f\\013') or octet_length(deactivation_reason) not between 1 and 500))) or exists(" "select 1 from actor_identity_links where " - "(revoked_reason is not null and (revoked_reason<>btrim(revoked_reason) or octet_length(revoked_reason) not between 1 and 500)) or " - "(reactivation_reason is not null and (reactivation_reason<>btrim(reactivation_reason) or octet_length(reactivation_reason) not between 1 and 500)))" + "(revoked_reason is not null and (revoked_reason<>btrim(revoked_reason, E' \\t\\n\\r\\f\\013') or octet_length(revoked_reason) not between 1 and 500)) or " + "(reactivation_reason is not null and (reactivation_reason<>btrim(reactivation_reason, E' \\t\\n\\r\\f\\013') or octet_length(reactivation_reason) not between 1 and 500)))" ) ).scalar_one() ) @@ -339,9 +339,9 @@ def upgrade() -> None: op.create_check_constraint( op.f("ck_actor_profiles_lifecycle_reason_bounds"), "actor_profiles", - "(suspension_reason is null or (suspension_reason=btrim(suspension_reason) and octet_length(suspension_reason) between 1 and 500)) and " - "(reactivation_reason is null or (reactivation_reason=btrim(reactivation_reason) and octet_length(reactivation_reason) between 1 and 500)) and " - "(deactivation_reason is null or (deactivation_reason=btrim(deactivation_reason) and octet_length(deactivation_reason) between 1 and 500))", + "(suspension_reason is null or (suspension_reason=btrim(suspension_reason, E' \\t\\n\\r\\f\\013') and octet_length(suspension_reason) between 1 and 500)) and " + "(reactivation_reason is null or (reactivation_reason=btrim(reactivation_reason, E' \\t\\n\\r\\f\\013') and octet_length(reactivation_reason) between 1 and 500)) and " + "(deactivation_reason is null or (deactivation_reason=btrim(deactivation_reason, E' \\t\\n\\r\\f\\013') and octet_length(deactivation_reason) between 1 and 500))", ) op.create_check_constraint( op.f("ck_actor_identity_links_reactivation_fields"), @@ -352,8 +352,8 @@ def upgrade() -> None: op.create_check_constraint( op.f("ck_actor_identity_links_lifecycle_reason_bounds"), "actor_identity_links", - "(revoked_reason is null or (revoked_reason=btrim(revoked_reason) and octet_length(revoked_reason) between 1 and 500)) and " - "(reactivation_reason is null or (reactivation_reason=btrim(reactivation_reason) and octet_length(reactivation_reason) between 1 and 500))", + "(revoked_reason is null or (revoked_reason=btrim(revoked_reason, E' \\t\\n\\r\\f\\013') and octet_length(revoked_reason) between 1 and 500)) and " + "(reactivation_reason is null or (reactivation_reason=btrim(reactivation_reason, E' \\t\\n\\r\\f\\013') and octet_length(reactivation_reason) between 1 and 500))", ) _replace_denial_registry(add=True) _replace_linked_authority_guard(lifecycle_reactivation=True) diff --git a/backend/app/modules/actors/models.py b/backend/app/modules/actors/models.py index 49ddb33e..6c7b7324 100644 --- a/backend/app/modules/actors/models.py +++ b/backend/app/modules/actors/models.py @@ -74,11 +74,11 @@ class ActorProfile(Base): name="reactivation_fields", ), CheckConstraint( - "(suspension_reason is null or (suspension_reason = btrim(suspension_reason) and " + "(suspension_reason is null or (suspension_reason = btrim(suspension_reason, E' \\t\\n\\r\\f\\013') and " "octet_length(suspension_reason) between 1 and 500)) and " - "(reactivation_reason is null or (reactivation_reason = btrim(reactivation_reason) and " + "(reactivation_reason is null or (reactivation_reason = btrim(reactivation_reason, E' \\t\\n\\r\\f\\013') and " "octet_length(reactivation_reason) between 1 and 500)) and " - "(deactivation_reason is null or (deactivation_reason = btrim(deactivation_reason) and " + "(deactivation_reason is null or (deactivation_reason = btrim(deactivation_reason, E' \\t\\n\\r\\f\\013') and " "octet_length(deactivation_reason) between 1 and 500))", name="lifecycle_reason_bounds", ), @@ -140,9 +140,9 @@ class ActorIdentityLink(Base): name="reactivation_fields", ), CheckConstraint( - "(revoked_reason is null or (revoked_reason = btrim(revoked_reason) and " + "(revoked_reason is null or (revoked_reason = btrim(revoked_reason, E' \\t\\n\\r\\f\\013') and " "octet_length(revoked_reason) between 1 and 500)) and " - "(reactivation_reason is null or (reactivation_reason = btrim(reactivation_reason) and " + "(reactivation_reason is null or (reactivation_reason = btrim(reactivation_reason, E' \\t\\n\\r\\f\\013') and " "octet_length(reactivation_reason) between 1 and 500))", name="lifecycle_reason_bounds", ), diff --git a/backend/tests/test_alembic.py b/backend/tests/test_alembic.py index 9afd857e..b9b0890f 100644 --- a/backend/tests/test_alembic.py +++ b/backend/tests/test_alembic.py @@ -1495,6 +1495,40 @@ async def prove_guards() -> None: 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}) async with engine.begin() as connection: await connection.execute(text("alter table actor_profiles disable trigger user")) await connection.execute( @@ -1554,6 +1588,14 @@ def test_actor_profile_lifecycle_upgrade_refuses_dirty_rows( "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", @@ -1571,6 +1613,15 @@ def test_actor_profile_lifecycle_upgrade_refuses_dirty_rows( "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", diff --git a/backend/tests/test_authorization.py b/backend/tests/test_authorization.py index fa7ed16d..d4594b27 100644 --- a/backend/tests/test_authorization.py +++ b/backend/tests/test_authorization.py @@ -3763,6 +3763,7 @@ 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="é" * 250).reason == "é" * 250 for value in ("", " ", "contains\x00null", "é" * 251, "x" * 501, 1, None): with pytest.raises(ValidationError): From d44a00f669231854a7a88f403ed1ed87e3bbe301 Mon Sep 17 00:00:00 2001 From: Abiorh001 Date: Sat, 18 Jul 2026 18:28:03 +0100 Subject: [PATCH 22/27] fix(auth): enforce unicode reason parity --- ...AUTH-001-09D-A-external-review-response.md | 15 ++--- .../versions/0026_actor_profile_lifecycle.py | 26 ++++++--- backend/app/modules/actors/models.py | 18 ++++-- backend/tests/test_alembic.py | 58 +++++++++++++++++++ backend/tests/test_authorization.py | 3 + 5 files changed, 99 insertions(+), 21 deletions(-) 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 index 589e368b..6b937741 100644 --- 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 @@ -14,12 +14,13 @@ 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 -an explicit ASCII whitespace set: space, tab, newline, carriage return, form -feed, and vertical tab. Migration `0026` uses the same expression for its +the exact 29 code points treated as whitespace by the locked Python 3.13 +runtime, 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, and previous-head dirty-row cases prove tab -and newline padding stop the upgrade without changing revision, schema, or -data. +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 @@ -45,9 +46,9 @@ and documentation gates remain authoritative and unchanged. ## Repair Evidence - Direct installed-constraint and previous-head dirty-row migration nodes: - `2 passed in 93.33s`. + `2 passed in 111.55s`. +- Full five-node lifecycle migration suite: `5 passed in 250.95s`. - 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/backend/alembic/versions/0026_actor_profile_lifecycle.py b/backend/alembic/versions/0026_actor_profile_lifecycle.py index 0573df6f..572935b3 100644 --- a/backend/alembic/versions/0026_actor_profile_lifecycle.py +++ b/backend/alembic/versions/0026_actor_profile_lifecycle.py @@ -19,6 +19,14 @@ "identity_link_already_revoked", "identity_link_not_revoked", ) +# Historical PostgreSQL equivalent of the migration's Python 3.13 str.strip(). +_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: @@ -310,11 +318,11 @@ def _dirty_lifecycle_rows(bind) -> bool: "(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 " - "(suspension_reason is not null and (suspension_reason<>btrim(suspension_reason, E' \\t\\n\\r\\f\\013') or octet_length(suspension_reason) not between 1 and 500)) or " - "(deactivation_reason is not null and (deactivation_reason<>btrim(deactivation_reason, E' \\t\\n\\r\\f\\013') or octet_length(deactivation_reason) not between 1 and 500))) or exists(" + 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 " - "(revoked_reason is not null and (revoked_reason<>btrim(revoked_reason, E' \\t\\n\\r\\f\\013') or octet_length(revoked_reason) not between 1 and 500)) or " - "(reactivation_reason is not null and (reactivation_reason<>btrim(reactivation_reason, E' \\t\\n\\r\\f\\013') or octet_length(reactivation_reason) not between 1 and 500)))" + 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() ) @@ -339,9 +347,9 @@ def upgrade() -> None: op.create_check_constraint( op.f("ck_actor_profiles_lifecycle_reason_bounds"), "actor_profiles", - "(suspension_reason is null or (suspension_reason=btrim(suspension_reason, E' \\t\\n\\r\\f\\013') and octet_length(suspension_reason) between 1 and 500)) and " - "(reactivation_reason is null or (reactivation_reason=btrim(reactivation_reason, E' \\t\\n\\r\\f\\013') and octet_length(reactivation_reason) between 1 and 500)) and " - "(deactivation_reason is null or (deactivation_reason=btrim(deactivation_reason, E' \\t\\n\\r\\f\\013') and octet_length(deactivation_reason) between 1 and 500))", + 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"), @@ -352,8 +360,8 @@ def upgrade() -> None: op.create_check_constraint( op.f("ck_actor_identity_links_lifecycle_reason_bounds"), "actor_identity_links", - "(revoked_reason is null or (revoked_reason=btrim(revoked_reason, E' \\t\\n\\r\\f\\013') and octet_length(revoked_reason) between 1 and 500)) and " - "(reactivation_reason is null or (reactivation_reason=btrim(reactivation_reason, E' \\t\\n\\r\\f\\013') and octet_length(reactivation_reason) between 1 and 500))", + 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) diff --git a/backend/app/modules/actors/models.py b/backend/app/modules/actors/models.py index 6c7b7324..a89818c5 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 Python 3.13 str.strip() for database/API parity. +_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: @@ -74,11 +82,11 @@ class ActorProfile(Base): name="reactivation_fields", ), CheckConstraint( - "(suspension_reason is null or (suspension_reason = btrim(suspension_reason, E' \\t\\n\\r\\f\\013') and " + 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 " - "(reactivation_reason is null or (reactivation_reason = btrim(reactivation_reason, E' \\t\\n\\r\\f\\013') 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 " - "(deactivation_reason is null or (deactivation_reason = btrim(deactivation_reason, E' \\t\\n\\r\\f\\013') 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", ), @@ -140,9 +148,9 @@ class ActorIdentityLink(Base): name="reactivation_fields", ), CheckConstraint( - "(revoked_reason is null or (revoked_reason = btrim(revoked_reason, E' \\t\\n\\r\\f\\013') and " + 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 " - "(reactivation_reason is null or (reactivation_reason = btrim(reactivation_reason, E' \\t\\n\\r\\f\\013') 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", ), diff --git a/backend/tests/test_alembic.py b/backend/tests/test_alembic.py index b9b0890f..0c2004c6 100644 --- a/backend/tests/test_alembic.py +++ b/backend/tests/test_alembic.py @@ -1529,6 +1529,56 @@ async def prove_guards() -> None: 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( @@ -1604,6 +1654,14 @@ def test_actor_profile_lifecycle_upgrade_refuses_dirty_rows( "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", diff --git a/backend/tests/test_authorization.py b/backend/tests/test_authorization.py index d4594b27..3ec22822 100644 --- a/backend/tests/test_authorization.py +++ b/backend/tests/test_authorization.py @@ -3764,6 +3764,9 @@ def test_actor_profile_lifecycle_public_schemas_are_strict_bounded_and_typed() - 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): From efc4e6caa199fbd9c52c4b3447dd4ebb39530a6e Mon Sep 17 00:00:00 2001 From: Abiorh001 Date: Sat, 18 Jul 2026 18:38:09 +0100 Subject: [PATCH 23/27] docs(auth): correct supported Python wording --- .../reviews/WS-AUTH-001-09D-A-external-review-response.md | 7 ++++--- backend/alembic/versions/0026_actor_profile_lifecycle.py | 2 +- backend/app/modules/actors/models.py | 2 +- 3 files changed, 6 insertions(+), 5 deletions(-) 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 index 6b937741..8b077ff9 100644 --- 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 @@ -14,9 +14,10 @@ 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 the locked Python 3.13 -runtime, including ASCII whitespace, non-breaking space, Unicode separators, -and ideographic space. Migration `0026` uses the same expression for its +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 diff --git a/backend/alembic/versions/0026_actor_profile_lifecycle.py b/backend/alembic/versions/0026_actor_profile_lifecycle.py index 572935b3..292dbea3 100644 --- a/backend/alembic/versions/0026_actor_profile_lifecycle.py +++ b/backend/alembic/versions/0026_actor_profile_lifecycle.py @@ -19,7 +19,7 @@ "identity_link_already_revoked", "identity_link_not_revoked", ) -# Historical PostgreSQL equivalent of the migration's Python 3.13 str.strip(). +# 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)" diff --git a/backend/app/modules/actors/models.py b/backend/app/modules/actors/models.py index a89818c5..7e37b3f7 100644 --- a/backend/app/modules/actors/models.py +++ b/backend/app/modules/actors/models.py @@ -28,7 +28,7 @@ LEGACY_PROFILE_STATUSES = ("observed", "active", "disabled") GLOBAL_PROFILE_SCOPE_TYPE = "global" GLOBAL_PROFILE_SCOPE_ID = "global" -# PostgreSQL equivalent of Python 3.13 str.strip() for database/API parity. +# 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)" From ef80338a799b8e735987e712b85e9ed48fc4b362 Mon Sep 17 00:00:00 2001 From: Abiorh001 Date: Sat, 18 Jul 2026 18:42:17 +0100 Subject: [PATCH 24/27] docs(auth): synchronize external repair state --- .agent-loop/LOOP_STATE.md | 8 ++++---- .agent-loop/REVIEW_LOG.md | 8 ++++---- .agent-loop/WORK_QUEUE.md | 2 +- .../STATUS.md | 12 +++++++----- 4 files changed, 16 insertions(+), 14 deletions(-) diff --git a/.agent-loop/LOOP_STATE.md b/.agent-loop/LOOP_STATE.md index 70bbfc2e..67e5c551 100644 --- a/.agent-loop/LOOP_STATE.md +++ b/.agent-loop/LOOP_STATE.md @@ -34,10 +34,10 @@ `fc89fb6`; its schema-v1 cross-initiative next pointer is superseded by the schema-v2 initiative-local clean cut. - Current gate: PR #148 external repair aligns database lifecycle-reason - whitespace with the API and repairs stale reviewer evidence. Focused - PostgreSQL proof passes; required exact-head internal rereview, replacement - external checks, and explicit human approval remain. No service caller - becomes executable before AUTH-09E. + whitespace with the API and repairs stale reviewer evidence. PostgreSQL proof + and required exact-head internal review pass at `efc4e6c`; canonical evidence + refresh, 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, diff --git a/.agent-loop/REVIEW_LOG.md b/.agent-loop/REVIEW_LOG.md index cb98e796..ca5fadb7 100644 --- a/.agent-loop/REVIEW_LOG.md +++ b/.agent-loop/REVIEW_LOG.md @@ -1,6 +1,6 @@ # Review Log -## 2026-07-18 - WS-AUTH-001-09D-A External Repair In Review +## 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 @@ -9,9 +9,9 @@ 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 must rerun against the repaired exact head, - after which only canonical evidence rows and the exact reviewed SHA will be - recorded. Replacement external checks and human approval remain required. + 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 diff --git a/.agent-loop/WORK_QUEUE.md b/.agent-loop/WORK_QUEUE.md index 9c323063..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-09D-A` | Profile Lifecycle And Evidence Repair | L1 | PR #148 external normalization/evidence repair passes focused proof; exact-head internal rereview 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. 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 9260a7af..0925367b 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 @@ -100,8 +100,9 @@ split into 09D-A and 09D-B. AUTH-09D-A exact-head internal review passed at 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 rereview and replacement external checks remain. -No service caller or feature action is active. +required exact-head internal review passes at `efc4e6c`; canonical evidence +refresh and replacement external checks remain. No service caller or feature +action is active. ## Active planning chunk @@ -113,8 +114,9 @@ None. `WS-AUTH-001-XINT` merged through PR #140. 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. Exact-head internal rereview, replacement external -checks, and explicit human approval are the current gate. +passes focused proof plus exact-head internal review at `efc4e6c`. Canonical +evidence refresh, replacement external checks, and explicit human approval are +the current gate. ## Current review branch @@ -146,7 +148,7 @@ checks, and explicit human approval are the current gate. | `WS-AUTH-001-09B` | Merged | `codex/ws-auth-001-09b-controlled-service-provisioning` | #143 | Merged as `053242b`; signed memory passed. | | `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 normalization/evidence repair passes focused proof; exact-head internal rereview pending. | +| `WS-AUTH-001-09D-A` | Active | `codex/ws-auth-001-09d-actor-identity-lifecycle` | #148 | External repair and exact-head review pass at `efc4e6c`; canonical evidence refresh 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. | From 0e32f0eb4efa3afc56d0c99ee62b1948162a7a1d Mon Sep 17 00:00:00 2001 From: Abiorh001 Date: Sat, 18 Jul 2026 18:47:17 +0100 Subject: [PATCH 25/27] docs(auth): refresh exact-head review evidence --- .agent-loop/LOOP_STATE.md | 6 ++-- .../STATUS.md | 14 ++++---- ...AUTH-001-09D-A-internal-review-evidence.md | 33 ++++++++++++------- .../WS-AUTH-001-09D-A-pr-trust-bundle.md | 16 +++++---- 4 files changed, 42 insertions(+), 27 deletions(-) diff --git a/.agent-loop/LOOP_STATE.md b/.agent-loop/LOOP_STATE.md index 67e5c551..6a8e3898 100644 --- a/.agent-loop/LOOP_STATE.md +++ b/.agent-loop/LOOP_STATE.md @@ -35,9 +35,9 @@ schema-v2 initiative-local clean cut. - Current gate: PR #148 external repair aligns database lifecycle-reason whitespace with the API and repairs stale reviewer evidence. PostgreSQL proof - and required exact-head internal review pass at `efc4e6c`; canonical evidence - refresh, replacement external checks, and explicit human approval remain. No - service caller becomes executable before AUTH-09E. + and required exact-head internal review pass at `ef80338`; canonical evidence + is refreshed against that SHA. 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, 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 0925367b..1f3dc8f3 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 @@ -100,9 +100,9 @@ split into 09D-A and 09D-B. AUTH-09D-A exact-head internal review passed at 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 passes at `efc4e6c`; canonical evidence -refresh and replacement external checks remain. No service caller or feature -action is active. +required exact-head internal review and canonical evidence pass at `ef80338`; +replacement external checks remain. No service caller or feature action is +active. ## Active planning chunk @@ -114,9 +114,9 @@ None. `WS-AUTH-001-XINT` merged through PR #140. 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 at `efc4e6c`. Canonical -evidence refresh, replacement external checks, and explicit human approval are -the current gate. +passes focused proof plus exact-head internal review and canonical evidence at +`ef80338`. Replacement external checks and explicit human approval are the +current gate. ## Current review branch @@ -148,7 +148,7 @@ the current gate. | `WS-AUTH-001-09B` | Merged | `codex/ws-auth-001-09b-controlled-service-provisioning` | #143 | Merged as `053242b`; signed memory passed. | | `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 repair and exact-head review pass at `efc4e6c`; canonical evidence refresh pending. | +| `WS-AUTH-001-09D-A` | Active | `codex/ws-auth-001-09d-actor-identity-lifecycle` | #148 | External repair, exact-head review, and canonical evidence pass at `ef80338`; 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. | 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 index e6e0ed7b..ebfe64cd 100644 --- 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 @@ -1,13 +1,13 @@ # WS-AUTH-001-09D-A Internal Review Evidence -Reviewed code SHA: `cc7e6cc4c12d2f378c71a9ddd65ebe8f3bed9643` +Reviewed code SHA: `ef80338a799b8e735987e712b85e9ed48fc4b362` -Reviewed implementation SHA: `e64b03f0baca2defd668a3cb806d04f06387bd9c` +Reviewed implementation SHA: `ef80338a799b8e735987e712b85e9ed48fc4b362` Reviewed against trusted main: `f18b620932bb257dc1dc355bc0504271813dc6b1` -Reviewed at: `2026-07-18T15:39:32Z` +Reviewed at: `2026-07-18T17:45:52Z` Reviewer run IDs: `auth_xint_roles`, `auth_xint_rev_con`, `auth_xint_art_service` @@ -20,7 +20,9 @@ test delta - 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 181.50 seconds on isolated PostgreSQL. + 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 @@ -30,8 +32,8 @@ test delta 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 later production delta changes only closed action-owner - metadata, which is exercised by exact catalogue and migration tests. + 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. @@ -45,13 +47,12 @@ test delta | 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 | Behavior proof covers privacy, fixed-service targets, nine failure stages, database refusal branches, and observed lock waits. | +| 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. | -| migration/data integrity | PASS AFTER FIXES | none | Upgrade and downgrade refusal matrices preserve revision, schema, rows, and evidence. | -| CI integrity | PASS AFTER FIXES | none | Historical owner selectors use both child owners and all affected database tests pass. | -| docs | PASS AFTER FIXES | none | Specification, architecture, operations, chunk state, and catalogue ownership agree. | +| 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. | @@ -67,7 +68,17 @@ 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. All required tracks pass the final exact reviewed head. +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. All required tracks pass final exact reviewed head +`ef80338a799b8e735987e712b85e9ed48fc4b362`. Valid findings addressed: yes 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 index 4dec40a0..022cbe05 100644 --- 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 @@ -19,9 +19,9 @@ gated 09D-B. This PR implements only 09D-A. ## What Changed - Added migration `0026_actor_profile_lifecycle` with profile reactivation - provenance, normalized 1-to-500-byte reason guards, fresh profile/link - transition attribution, truthful reactivation direction, and fail-closed - upgrade/downgrade checks. + 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. @@ -53,7 +53,10 @@ changed. ## Tests And Checks Run -- Five exact isolated PostgreSQL migration nodes: passed in 181.50 seconds. +- 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. - 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. @@ -67,7 +70,8 @@ changed. ## Test Delta Tests assert exact action/permission/owner parity, normalized reason bounds, -fresh transition provenance, denial privacy, service-target invariance, all nine +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. @@ -82,7 +86,7 @@ modified. GitHub Backend remains authoritative for the full suite. 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 `cc7e6cc4c12d2f378c71a9ddd65ebe8f3bed9643` +pass exact integrated head `ef80338a799b8e735987e712b85e9ed48fc4b362` after every valid finding was repaired. ## Remaining Risks From 7c33e6453a2c91256c8fd416c63e30b95fd9d825 Mon Sep 17 00:00:00 2001 From: Abiorh001 Date: Sat, 18 Jul 2026 19:05:29 +0100 Subject: [PATCH 26/27] test(auth): reset lifecycle migration fixtures safely --- ...AUTH-001-09D-A-external-review-response.md | 21 +++++++++- backend/tests/test_alembic.py | 39 ++++++++++++------- 2 files changed, 44 insertions(+), 16 deletions(-) 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 index 8b077ff9..b445cab5 100644 --- 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 @@ -17,8 +17,8 @@ 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 +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. @@ -36,6 +36,21 @@ 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 @@ -49,6 +64,8 @@ and documentation gates remain authoritative and unchanged. - 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, diff --git a/backend/tests/test_alembic.py b/backend/tests/test_alembic.py index 0c2004c6..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") @@ -2651,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() From 9c5ef8a1feffd6324acfd947e67042921955320b Mon Sep 17 00:00:00 2001 From: Abiorh001 Date: Sat, 18 Jul 2026 19:09:49 +0100 Subject: [PATCH 27/27] docs(auth): bind review evidence to CI repair --- .agent-loop/LOOP_STATE.md | 7 ++++--- .../STATUS.md | 6 +++--- ...AUTH-001-09D-A-internal-review-evidence.md | 19 ++++++++++++++----- .../WS-AUTH-001-09D-A-pr-trust-bundle.md | 4 +++- 4 files changed, 24 insertions(+), 12 deletions(-) diff --git a/.agent-loop/LOOP_STATE.md b/.agent-loop/LOOP_STATE.md index 6a8e3898..93319361 100644 --- a/.agent-loop/LOOP_STATE.md +++ b/.agent-loop/LOOP_STATE.md @@ -35,9 +35,10 @@ schema-v2 initiative-local clean cut. - Current gate: PR #148 external repair aligns database lifecycle-reason whitespace with the API and repairs stale reviewer evidence. PostgreSQL proof - and required exact-head internal review pass at `ef80338`; canonical evidence - is refreshed against that SHA. Replacement external checks and explicit - human approval remain. No service caller becomes executable before AUTH-09E. + 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, 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 1f3dc8f3..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 @@ -100,7 +100,7 @@ split into 09D-A and 09D-B. AUTH-09D-A exact-head internal review passed at 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 `ef80338`; +required exact-head internal review and canonical evidence pass at `7c33e64`; replacement external checks remain. No service caller or feature action is active. @@ -115,7 +115,7 @@ 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 -`ef80338`. Replacement external checks and explicit human approval are the +`7c33e64`. Replacement external checks and explicit human approval are the current gate. ## Current review branch @@ -148,7 +148,7 @@ current gate. | `WS-AUTH-001-09B` | Merged | `codex/ws-auth-001-09b-controlled-service-provisioning` | #143 | Merged as `053242b`; signed memory passed. | | `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 repair, exact-head review, and canonical evidence pass at `ef80338`; replacement external checks pending. | +| `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. | 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 index ebfe64cd..ed31f782 100644 --- 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 @@ -1,13 +1,13 @@ # WS-AUTH-001-09D-A Internal Review Evidence -Reviewed code SHA: `ef80338a799b8e735987e712b85e9ed48fc4b362` +Reviewed code SHA: `7c33e6453a2c91256c8fd416c63e30b95fd9d825` -Reviewed implementation SHA: `ef80338a799b8e735987e712b85e9ed48fc4b362` +Reviewed implementation SHA: `7c33e6453a2c91256c8fd416c63e30b95fd9d825` Reviewed against trusted main: `f18b620932bb257dc1dc355bc0504271813dc6b1` -Reviewed at: `2026-07-18T17:45:52Z` +Reviewed at: `2026-07-18T18:08:51Z` Reviewer run IDs: `auth_xint_roles`, `auth_xint_rev_con`, `auth_xint_art_service` @@ -37,6 +37,11 @@ test delta - 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, @@ -77,8 +82,12 @@ 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. All required tracks pass final exact reviewed head -`ef80338a799b8e735987e712b85e9ed48fc4b362`. +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 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 index 022cbe05..e05bea1e 100644 --- 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 @@ -57,6 +57,8 @@ changed. - 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. @@ -86,7 +88,7 @@ modified. GitHub Backend remains authoritative for the full suite. 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 `ef80338a799b8e735987e712b85e9ed48fc4b362` +pass exact integrated head `7c33e6453a2c91256c8fd416c63e30b95fd9d825` after every valid finding was repaired. ## Remaining Risks