diff --git a/.agent-loop/LOOP_STATE.md b/.agent-loop/LOOP_STATE.md index f798f4d14..76f24372e 100644 --- a/.agent-loop/LOOP_STATE.md +++ b/.agent-loop/LOOP_STATE.md @@ -18,42 +18,50 @@ 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; the user then explicitly started ART-02B1. -- Active ART implementation chunk: `WS-ART-001-02B1` on - `codex/ws-art-001-02b1-s3-compatible-minio-aws`. -- The ART worktree consumes merged AUTH, REV, and CON contracts without - editing or activating their independently owned runtime behavior. -- AUTH-09D-A merged through PR #148 as `99ae4c9`; AUTH-09D-B remains inactive - until signed memory and an explicit human start. -- ART integration basis: trusted `main` at `99ae4c9` after PR #148. +- PR #141 merged `WS-ART-001-02A3` into `main` as `a10d901` on 2026-07-18. + PR #151 then merged `WS-ART-001-02B1` as `1b5422f` on 2026-07-19; + ART-02C1 remains inactive pending signed memory and a separate explicit start. +- AUTH-09D-A merged through PR #148 as `99ae4c9`; signed schema-v2 memory at + `cf8a3e8` recorded the stopped gate and exact 09D-B successor. +- PR-ready implementation chunk: `WS-AUTH-001-09D-B` in PR #152 on + `codex/ws-auth-001-09d-b-identity-link-lifecycle`, started from trusted + `main` at `99ae4c9` after the user's explicit start signal. Contract repair + passed required L1 preimplementation review at exact contract `9ec6390b`. + Implementation, deterministic proof, and required internal review pass; the + branch now integrates trusted `main` at `1b5422f`. - 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 ART gate: integrate trusted `main`, complete deterministic 02B1 - proof, and pass all nine exact-SHA internal reviewer tracks before opening - the ART PR. No later ART chunk starts automatically. +- Current gate: refreshed external checks and explicit human review for PR + #152. The inactive + `WS-AUTH-001-CONTRIBUTOR-FOUNDATION` is the next same-initiative gate; it + changes no action availability. 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, and AWS cannot instantiate in production without release-bound live proof. -- Authorization checkpoint: merged main contains 74 PermissionIds and 65 - ActionIds, with 12 active actions: the two actor-self actions, seven AUTH-08 - administrative actions, AUTH-09B `actor.service.provision`, and AUTH-09C - `actor.profile.read` plus `actor.identity_link.read`. Merged AUTH-09A defines - seven fixed artifact +- Authorization checkpoint: trusted main contains 74 PermissionIds and 65 + ActionIds, with 15 active actions: the two actor-self actions, seven AUTH-08 + administrative actions, AUTH-09B `actor.service.provision`, AUTH-09C + `actor.profile.read` plus `actor.identity_link.read`, and the three merged + AUTH-09D-A profile lifecycle actions. PR #152 activates only the two 09D-B + identity-link lifecycle actions, producing a candidate total of 17. + Merged AUTH-09A defines seven fixed artifact service identities and eleven exact planned static matrix memberships. ART feature chunks supply hidden canonical behavior/resource composition. Merged AUTH planning requires availability-neutral ART custody transfer, fixed-service admission, prepared mutation authority, and exact AUTH-only activation chunks; 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 is active and adds real MinIO protocol - proof plus a fail-closed, runtime-ineligible native AWS profile. +- Parallel artifact checkpoint: ART-02A1, ART-02A2, ART-02A3, and ART-02B1 + merged through PRs #127, #129, #141, and #151. ART-02B1 adds real MinIO + protocol proof plus a fail-closed, runtime-ineligible native AWS profile; + ART-02C1 remains inactive. - Authorization checkpoint: AUTH-07B through AUTH-09D-A merged through PRs - #130, #131, #132, #143, #146, and #148. AUTH-09D-B remains inactive. + #130, #131, #132, #143, #146, and #148. AUTH-09D-B is the reviewed PR #152 + candidate; its contributor foundation and AUTH-09E remain inactive. - 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 ca5fadb7d..413a683a0 100644 --- a/.agent-loop/REVIEW_LOG.md +++ b/.agent-loop/REVIEW_LOG.md @@ -1,5 +1,55 @@ # Review Log +## 2026-07-18 - WS-AUTH-001-09D-B Internal Review Passed + +- Identity-link revoke/reactivate implementation, real PostgreSQL concurrency, + exact rollback/evidence proof, and the contributor-foundation sequencing + repair passed all required reviewer tracks at exact head `bd0b063b`. +- QA's four proof blockers, architecture/docs' three closeout blockers, and the + contributor-contract's two scope/allowlist blockers were repaired without a + production compatibility path, threshold reduction, skip, or lifecycle + expansion. +- Exact evidence is 112 authorization tests at 90.11 percent branch coverage, + two mandatory PostgreSQL nodes in 241.09 seconds, the live HTTP drill, Ruff, + both stale scans, Markdown links, 87 Agent Gates, merge-intent validation, + and diff integrity. +- Ready PR publication is the remaining local gate. The contributor foundation + remains inactive behind merge, signed memory, and a separate explicit start. + +## 2026-07-18 - AUTH Contributor Foundation Sequence Reconciled + +- Current REV planning correctly identified that deferring assignment and + Submission ownership renames to AUTH-13/14 creates a dependency cycle. +- Durable AUTH planning now names inactive same-initiative successor + `WS-AUTH-001-CONTRIBUTOR-FOUNDATION` immediately after 09D-B. Its bounded + contract owns only the two `contributor_id` clean cuts, database-backed + canonical-human lineage, and transaction-local active-human revalidation. +- AUTH-13/14 now consume those canonical fields and retain their later + authorization/lifecycle responsibilities. Fixed future migration + reservations are retired; each unmerged chunk allocates from trusted `main`. +- This planning repair changes no 09D-B runtime behavior and starts no successor. + +## 2026-07-18 - WS-AUTH-001-09D-B Preimplementation Review Passed + +- Required L1 review initially rejected broad race, rollback, lock-order, + allowed-file, evidence, and process-state contracts before runtime edits. +- Exact candidate `9ec6390b` repairs every valid finding: canonical + profile/link/grant target locking, exact link evidence, closed missing-target + denial flow, four actor-self lock/timestamp cases, nine-stage rollback on both + operations, blocker-observed PostgreSQL races, 90 percent authorization + coverage, and explicit 09E inactivity. +- Senior engineering, QA/test, security/auth, product/ops, architecture, CI + integrity, docs, reuse/dedup, and test delta pass. Bounded runtime + implementation may begin for 09D-B only. + +## 2026-07-18 - WS-AUTH-001-09D-B Explicitly Started + +- PR #148 merged AUTH-09D-A as `99ae4c9`; signed schema-v2 memory `cf8a3e8` + stopped and named 09D-B as the same-initiative successor. +- The user explicitly started 09D-B. Its broad inherited contract is being + repaired to exact files, exclusions, behavior, proof, reviewers, and human + review focus before any runtime edit. + ## 2026-07-18 - WS-AUTH-001-09D-A External Repair Review Passed - PR #148 CodeRabbit correctly found an API/database normalization mismatch: diff --git a/.agent-loop/WORK_QUEUE.md b/.agent-loop/WORK_QUEUE.md index fc318af2e..6826bc3bd 100644 --- a/.agent-loop/WORK_QUEUE.md +++ b/.agent-loop/WORK_QUEUE.md @@ -4,7 +4,7 @@ | Chunk | Title | Risk | Status | |---|---|---:|---| -| `WS-ART-001-02B1` | S3-Compatible MinIO And AWS | L1 | Active after explicit user start; implementation repair and exact-head internal review in progress | +| `WS-AUTH-001-09D-B` | Identity-Link Lifecycle And Race Closure | L1 | PR #152 open; trusted main `1b5422f` integrated; refreshed checks and explicit human review pending | Live post-merge state remains read from signed `automation/loop-memory` output. This authored queue records the separately approved parallel chunks. @@ -14,10 +14,11 @@ 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-B` | Identity-Link Lifecycle And Race Closure | L1 | Inactive until 09D-A merge/memory and explicit user start | +| `WS-AUTH-001-CONTRIBUTOR-FOUNDATION` | Contributor Fields And Canonical-Human Lineage | L1 | Inactive until 09D-B merge/memory and explicit user start | +| `WS-AUTH-001-09E` | Fixed Service Runtime Admission | L1 | Inactive until contributor-foundation 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-02C1` | Admission And Put-Attempt Foundation | L1 | Inactive until 02B1 merge and explicit user start | +| `WS-ART-001-02C1` | Admission And Put-Attempt Foundation | L1 | Inactive until signed 02B1 merge memory and explicit user start | | `WS-ART-001-02C2` | Verification Publication And Fencing | L1 | Inactive until 02C1 merge and explicit user start | | `WS-ART-001-02C3` | Recovery Attempt And Idempotency Chain | L1 | Inactive until 02C2 merge and explicit user start | | `WS-ART-001-02D` | Operator Artifact Operations And AWS Readiness | L1 | Inactive until 02C3 and exact AUTH prerequisites | @@ -26,6 +27,7 @@ output. This authored queue records the separately approved parallel chunks. | Chunk | Title | Risk | Status | |---|---|---:|---| +| `WS-ART-001-02B1` | S3-Compatible MinIO And AWS | L1 | Merged through PR #151 as `1b5422f` on 2026-07-19 | | `WS-AUTH-001-09D-A` | Profile Lifecycle And Evidence Repair | L1 | Merged through PR #148 as `99ae4c9` on 2026-07-18 | | `WS-AUTH-001-09C` | Actor And Identity-Link Administration Reads | L1 | Merged through PR #146 as `0ffdabf` on 2026-07-18 | | `WS-ENG-001-01` | Codex-native zero-trust loop bootstrap | L1 | Merged through PR #23 on 2026-06-20 | @@ -91,17 +93,22 @@ 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. PR #146 merged it as `0ffdabf`; signed memory at `eeb3dc2` -stopped. Required review split AUTH-09D before runtime edits, and AUTH-09D-A -merged through PR #148 as `99ae4c9`. Do not start 09D-B, 09E, or POL-002-04 -automatically. +stopped. The user explicitly started AUTH-09D, and required review split it +before runtime edits. PR #148 merged 09D-A as `99ae4c9`; signed memory +`cf8a3e8` stopped and named 09D-B. The user explicitly started 09D-B; exact +contract `9ec6390b` passed required L1 review. Implementation, deterministic +proof, and required internal review pass. PR #152 is open and integrates trusted +main at `1b5422f`; refreshed external checks and explicit human review are the +current gate. The contributor foundation is the next AUTH gate; 09E and +POL-002-04 remain inactive pending their own gates and explicit starts. Coverage R10 merged through PR #108. Do not start 01B2, chunk 02, or another coverage implementation chunk from this worktree. -`WS-ART-001-01`, the AWS-first planning amendment, `02A1`, `02A2`, and `02A3` -are merged; PR #141 merged `02A3` as `a10d901`. R2 and Flow Node are deferred. -The user explicitly started `02B1` on 2026-07-18. `02C1` remains inactive -until `02B1` merges and receives a separate explicit start. +`WS-ART-001-01`, the AWS-first planning amendment, `02A1`, `02A2`, `02A3`, and +`02B1` are merged; PR #151 merged `02B1` as `1b5422f`. R2 and Flow Node are +deferred. `02C1` remains inactive until signed merge memory and a separate +explicit start. Coverage work proceeds independently in its own worktree and is not owned by this AUTH queue update. 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 4e8aaad26..64770b5d4 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,9 +33,10 @@ 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 | 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-09D-A` | Profile Lifecycle And Evidence Repair | L1 | Merged through PR #148 as `99ae4c9`; signed memory `cf8a3e8` passed | +| `WS-AUTH-001-09D-B` | Identity-Link Lifecycle And Race Closure | L1 | Implemented; deterministic proof and required internal review pass; ready PR publication | +| `WS-AUTH-001-CONTRIBUTOR-FOUNDATION` | Contributor Fields And Canonical-Human Lineage | L1 | Inactive until 09D-B merge/memory and explicit start | +| `WS-AUTH-001-09E` | Fixed Service Runtime Admission | L1 | Inactive until contributor-foundation 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 | @@ -97,6 +98,7 @@ WS-AUTH-001-PLAN -> WS-AUTH-001-09C -> WS-AUTH-001-09D-A -> WS-AUTH-001-09D-B +-> WS-AUTH-001-CONTRIBUTOR-FOUNDATION -> WS-AUTH-001-09E -> WS-AUTH-001-ART-CUSTODY and WS-AUTH-001-REV-CUSTODY -> WS-AUTH-001-PREP @@ -132,8 +134,11 @@ WS-AUTH-001-PLAN - PR #139 merged the WS-XINT boundary contract. `WS-AUTH-001-XINT` is the planning-only AUTH owner response; it changes no runtime. - Chunks 08-10 establish local grant truth before product cutover. Parent chunk - 09 is split into 09A through 09E with no inserted dependency; 09E separately - admits fixed services without entering human grant evaluation. ART/REV custody + 09 is split into 09A through 09E. The separately reviewed contributor + foundation follows 09D-B so REV can consume canonical human attribution + without waiting for the full AUTH-13/14 cutovers. It changes no authority or + lifecycle behavior. 09E separately admits fixed services without entering + human grant evaluation. ART/REV custody transfer follows 09E and changes only owner metadata and availability-neutral parity. PREP then establishes AUTH-first locking and caller-owned commit before sensitive product/review mutations. @@ -195,6 +200,10 @@ explicitly started AUTH-09B. PR #143 merged it as `053242b`; signed memory 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. +contract before runtime edits, so it was split into 09D-A and 09D-B. PR #148 +merged 09D-A as `99ae4c9`; signed memory `cf8a3e8` stopped and named 09D-B. The +user explicitly started 09D-B; exact contract `9ec6390b` passed required L1 +review. Implementation, deterministic proof, and required internal review pass; +ready PR publication is the current gate. The contributor foundation is the next +same-initiative gate; 09E and POL-002-04 remain inactive pending their own gates +and explicit starts. 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 becbc7998..cb9f65f05 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 @@ -122,14 +122,19 @@ product vocabulary or authority concepts. The field cutover is explicitly owned as follows: -- `WS-AUTH-001-13` renames assignment ownership from legacy `worker_id` to - `contributor_id` across storage, models, services, schemas, audits, and tests. -- `WS-AUTH-001-14` renames submission ownership/attestation and checker-result - visibility fields from legacy `worker_*` names to their `contributor_*` - equivalents across storage, models, services, schemas, audits, and tests. It - also renames the submission-policy JSON field `worker_facing_fix` to +- `WS-AUTH-001-CONTRIBUTOR-FOUNDATION` clean-cuts both retired assignment and + Submission human-owner identifiers to `contributor_id` across + storage, models, services, schemas, audits, and tests. It also supplies + database-backed canonical-human ActorProfile lineage and a transaction-local + active-human revalidation capability. +- `WS-AUTH-001-13` consumes the canonical assignment field during task + authorization cutover; it does not rename it again. +- `WS-AUTH-001-14` consumes the canonical Submission owner field and renames + the remaining submission attestation and checker-result visibility fields + from legacy `worker_*` names to their `contributor_*` equivalents. It also + renames the submission-policy JSON field `worker_facing_fix` to `contributor_facing_fix` across derivation schemas, prompts, persistence, and - compatibility tests. + tests. - Revision replay is not implemented yet and must begin with `contributor_claim_status`; it must not introduce the legacy name. - `ContributionRecord`, `CompensationAward`, @@ -658,3 +663,24 @@ 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. + +## D31: Prioritize the contributor/canonical-human foundation after AUTH-09D-B + +Status: accepted sequencing reconciliation on 2026-07-18. + +The prior plan left assignment ownership renaming in AUTH-13 and Submission +ownership renaming in AUTH-14. That creates an unnecessary dependency cycle: +REV needs canonical human attribution before its first runtime child, while the +full AUTH-13/14 product cutovers depend on later REV preparation behavior. + +`WS-AUTH-001-CONTRIBUTOR-FOUNDATION` is therefore the next same-initiative gate +after AUTH-09D-B and before AUTH-09E. It clean-cuts only the two ownership fields, +adds reusable database-backed human ActorProfile lineage, and exposes bounded +transaction-local active-human revalidation. It changes no permission, action, +grant, lifecycle, or feature availability. AUTH-13/14 retain their later +authorization and lifecycle cutovers and consume the canonical fields. + +The foundation allocates only the then-current next migration from trusted +`main`. This decision supersedes only the future fixed-number reservation +clauses in D29, D30, and earlier decisions; their other boundaries remain in +force. Merged migration ownership through AUTH-09D-A `0026` is unchanged. 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 89f65ba9a..a1240fe4b 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 @@ -27,9 +27,11 @@ mutations, service admission, and concurrency. Required L1 review split it into 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-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. +and profile reactivation provenance repair. AUTH-10 through AUTH-15 shifted to +`0027` through `0032` at that historical checkpoint, without allocating +migrations to the availability-neutral custody or PREP chunks. That future +allocation is superseded by D31: the contributor foundation and later AUTH +chunks allocate only from the then-current trusted-main head. ## Current behavior @@ -304,8 +306,8 @@ 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 `0027` must replace current - validators and fail closed on incompatible evidence. + Historical migrations remain immutable; AUTH-10's then-current migration 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, recompose, evaluate-once, flush, and caller-commit protocol. 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 4b55e3fde..8354e48b5 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 @@ -87,9 +87,10 @@ 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. 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`. +reactivation provenance. `WS-AUTH-001-CONTRIBUTOR-FOUNDATION` allocates the +then-current next migration only after AUTH-09D-B merges. AUTH-10 through +AUTH-15 then allocate from trusted `main` when each contract becomes +executable; their old `0027` through `0032` reservations are retired. 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. @@ -183,9 +184,10 @@ proving the same issuer role metadata alone no longer authorizes. self-action cutover in 07B before protected authority-management APIs. 8. Implement bootstrap, `AuthorityControl`, and immutable admin-role grants. 9. Reconcile merged WS-XINT boundaries and converge AUTH-09A on trusted `main`. -10. Complete the uninterrupted AUTH-09B through 09E sequence: controlled - provisioning, actor/link administration, lifecycle mutations, and fixed - service runtime admission without activating feature actions. +10. Complete AUTH-09B through 09D-B, then merge the separately reviewed + contributor/canonical-human foundation before AUTH-09E fixed service + runtime admission. The inserted foundation changes no permission or action + availability. 11. Transfer all current ART/REV owner labels to exact AUTH custodians without changing availability, then add the prepared mutation authorization protocol. 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 ba79eed23..1e2e871c8 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 @@ -94,33 +94,29 @@ repair, coverage repair, and required checks passed before PR #143 merged as Its bounded implementation and external repair passed before PR #146 merged as `0ffdabf`; signed schema-v2 memory at `eeb3dc2` recorded completion and stopped. The user explicitly started AUTH-09D. Required L1 preimplementation review -rejected the combined lifecycle contract before runtime edits, so the parent is -split into 09D-A and 09D-B. AUTH-09D-A exact-head internal review passed at -`cc7e6cc` after all valid findings were repaired; ready PR #148 is open for -external checks and explicit human approval. CodeRabbit then found a valid -API/database lifecycle-reason whitespace mismatch and stale exact-head review -evidence. The bounded repair passes direct PostgreSQL and request-schema proof; -required exact-head internal review and canonical evidence pass at `7c33e64`; -replacement external checks remain. No service caller or feature action is -active. +rejected the combined lifecycle contract before runtime edits, so the parent was +split into 09D-A and 09D-B. After bounded external repair and exact-head review, +PR #148 merged AUTH-09D-A as `99ae4c9`; signed schema-v2 memory `cf8a3e8` +recorded the stopped gate and named 09D-B. The user explicitly started 09D-B +from that trusted head. Exact contract `9ec6390b` passed required L1 +preimplementation review. Implementation, deterministic proof, and required +internal review pass; ready PR publication is current. No service caller or +consumer feature action is active. ## Active planning chunk None. `WS-AUTH-001-XINT` merged through PR #140. -## Active implementation chunk +## PR-ready implementation chunk -`WS-AUTH-001-09D-A` - Profile Lifecycle And Evidence Repair. Exact-SHA -preimplementation review passed at `7f941a5`; the three profile lifecycle -routes, migration `0026`, and repaired deterministic proof pass. PR #148's -bounded external repair aligns database whitespace rejection with the API and -passes focused proof plus exact-head internal review and canonical evidence at -`7c33e64`. Replacement external checks and explicit human approval are the -current gate. +`WS-AUTH-001-09D-B` - Identity-Link Lifecycle And Race Closure. Exact contract +`9ec6390b` passed required L1 preimplementation review. Implementation, +deterministic proof, and required internal review pass. Ready PR publication is +the current gate. ## Current review branch -`codex/ws-auth-001-09d-actor-identity-lifecycle` +`codex/ws-auth-001-09d-b-identity-link-lifecycle` ## Chunk status @@ -148,9 +144,10 @@ 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 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-09D-A` | Merged | `codex/ws-auth-001-09d-actor-identity-lifecycle` | #148 | Merged as `99ae4c9`; signed memory `cf8a3e8` passed and stopped. | +| `WS-AUTH-001-09D-B` | Ready for PR | `codex/ws-auth-001-09d-b-identity-link-lifecycle` | - | Implementation, deterministic proof, and required internal review pass. | +| `WS-AUTH-001-CONTRIBUTOR-FOUNDATION` | Proposed | - | - | Contributor-field clean cut and canonical-human lineage after 09D-B. | +| `WS-AUTH-001-09E` | Proposed | - | - | Fixed service runtime admission after the contributor foundation. | | `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. | @@ -169,10 +166,15 @@ 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'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. +memory passed at `eeb3dc2`. PR #148 merged AUTH-09D-A as `99ae4c9`; signed +memory `cf8a3e8` passed and stopped. The user explicitly started AUTH-09D-B; +exact contract `9ec6390b` passed required L1 review. Implementation, +deterministic proof, and required internal review pass; ready PR publication is +the current gate. It must not add service grants, +dynamic assignments, token-role authority, service admission, or consumer +feature-action activation. The contributor foundation is the next +same-initiative gate but remains inactive until merge/memory and an explicit +start. AUTH-09E remains inactive behind that foundation. The four proposed REV lifecycle actions and review-evidence binding action are blocked on complete feature-owned typed manifests. REV fixed services are also @@ -186,8 +188,9 @@ migration `0022`, removing obsolete `ProjectRole.BOTH` and replacement evidence. AUTH-09B owns migration `0024` for service-link verification timestamp 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. +The contributor foundation allocates the then-current next migration after +09D-B merges. AUTH-10 through AUTH-15 no longer reserve fixed migration numbers; +each allocates from trusted `main` when its contract becomes executable. AUTH-05A and CAT post-merge memory have no remaining blocker and are merged. The combined AUTH-05 contract 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 index d2553afa9..7c6e32aae 100644 --- 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 @@ -1,70 +1,317 @@ # Chunk Contract: WS-AUTH-001-09D-B - Identity-Link Lifecycle And Race Closure -Initiative: `WS-AUTH-001` | Risk: L1 / P1 | Status: inactive +Initiative: `WS-AUTH-001` | Risk: L1 / P1 | Status: implemented and internally reviewed ## Goal Activate exact identity-link revoke/reactivate behavior and close the mixed -profile, link, and grant final-admin concurrency matrix. +profile, link, and grant final-Access-Administrator concurrency matrix on the +single authorization foundation established by 09D-A. + +## Why This Chunk Exists + +AUTH-09D-A made lifecycle evidence, invalidation direction, transaction order, +and final-administrator locking truthful. Its migration intentionally +pre-registered identity-link lifecycle provenance and denial vocabulary, while +leaving the two link actions inactive. This child supplies only their public +administrative behavior and the remaining cross-operation race proof. ## 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. +AUTH-09D-A merged through PR #148 as `99ae4c9`; signed schema-v2 memory +`cf8a3e8` stopped and named this child; the user then explicitly started it. +Implementation begins only after this exact contract passes required L1 review. + +## Allowed Files -## Allowed Boundary +```text +backend/app/modules/authorization/catalogue.py +backend/app/modules/authorization/kernel.py +backend/app/modules/authorization/runtime.py +backend/app/modules/authorization/repository.py +backend/app/modules/authorization/service.py +backend/app/modules/authorization/lifecycle_schemas.py +backend/app/modules/authorization/lifecycle_service.py +backend/app/modules/authorization/router.py +backend/tests/test_authorization.py +backend/tests/test_auth.py +backend/tests/test_api_controls.py +backend/tests/test_api_rate_controls.py +backend/tests/test_audit.py +backend/scripts/api_contract_e2e.py +scripts/test_agent_gates.py +docs/spec_authorization_service.md +docs/operations_authorization_service.md +docs/architecture_data_model.md +.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/** +.agent-loop/merge-intents/WS-AUTH-001-09D-B.json +.agent-loop/LOOP_STATE.md +.agent-loop/WORK_QUEUE.md +.agent-loop/REVIEW_LOG.md +``` -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. +The lifecycle service owns orchestration only. It reuses the existing +reservation, authority decision, idempotency completion, append-only audit, +invalidation, actor touch, limiter, error, route-owned commit, and +`AdminAuthorizationRepository` primitives. `authorization/service.py` may only +bind the two existing link operation-evidence entries to their exact ActionIds. +Exact implementation discovery may remove an allowed path; adding a path +requires contract repair and renewed preimplementation review before editing it. -## Exact Surface +## Not Allowed + +```text +Alembic migration or historical migration edit +actor-profile schema, model, or lifecycle behavior change +new identity link, actor replacement, link deletion, or issuer/subject rewrite +AdminRoleGrant or ProjectRoleGrant mutation or grant restoration +fixed-service admission or service-action activation +project, contributor, task, submission, check, review, revision, artifact, +payment, contribution-record, or reputation behavior +compatibility route, alias, fallback, dual path, or legacy response +parallel ledger, authorization kernel, unit of work, session, or database engine +AUTH-09E or another initiative's implementation +``` + +## Exact Surface And Authority ```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. +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 exactly +`{ "reason": "..." }`. Audit Authority, project-scoped authority, fixed-service +callers, token roles, and every other candidate deny before target disclosure. + +The shared lifecycle body trims both ends, requires 1 to 500 UTF-8 bytes after +trimming, rejects NUL and unknown fields, and freezes the normalized value for +persistence and digest derivation. Validation returns a non-echoing 422. + +## Public Response + +Success and exact replay return exactly: + +```json +{ + "resource_type": "actor_identity_link", + "resource_id": "uuid", + "version": null, + "http_status": 200 +} +``` + +No actor ID, issuer, subject, contact data, raw reason, lifecycle attribution, +event ID, grant data, or digest is returned. The idempotency record stores only +this typed resource reference. + +## State And Field Contract + +| Operation | Allowed state | Mutation | New-key conflict | +|---|---|---|---| +| revoke | link `active`; owner `active` or `suspended` | set `revoked`; write caller, database time, normalized reason; preserve prior reactivation provenance | `identity_link_already_revoked` or `actor_deactivated_terminal` | +| reactivate | link `revoked`; owner `active` or `suspended` | set `active`; clear revoke fields; write caller, database time, normalized reason; preserve immutable issuer/subject binding | `identity_link_not_revoked` or `actor_deactivated_terminal` | + +Human and fixed-service links are valid targets. A suspended profile's link may +be repaired, but the actor remains unable to authenticate. A deactivated +profile is terminal and its link cannot change. Reactivation does not admit a +fixed service, restore any grant, or advance target verification timestamps. + +Self-link revoke returns 403 `resource_guard_denied` before target disclosure. +A caller whose own link is revoked fails authentication and cannot reactivate +itself. An authorized missing link returns the existing privacy-safe 404 +`resource_not_found` code after exact permission match. It rolls back the +reservation and provisional denial, then restages and commits exactly one +privacy-safe denial in a clean transaction. It leaves no pending claim, +advances no timestamp, and leaves the key reusable. No allow decision exists +for a missing target. + +Each domain conflict rolls back the pending reservation and staged allow, then +commits one `SensitiveAuthorizationDenied` row in a clean transaction with the +exact link ActionId/PermissionId, exact link resource, owning ActorProfile +target, categorical `authorization_evaluation` reason, exact denial code, and +no matched-grant or idempotency reference. The losing key remains reusable. + +## Evidence And Invalidation + +- Success evidence uses the exact link as entity/resource and binds the owning + target ActorProfile without exposing issuer or subject. +- Its single invalidation obligation targets the owning ActorProfile authority + projection. +- Revocation records `effective=true -> effective=false`; reactivation records + `effective=false -> effective=true`. These facts describe the link component, + not whole-actor authenticatability or current authority. +- State, attribution, caller touch, success evidence, invalidation, + idempotency completion, and commit are one atomic transaction. + +## Reservation, Lock, And Transaction Order + +Every new request follows the reviewed 09D-A order: + +1. validate and freeze the 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 the exact matched caller grant; +5. only after permission match, resolve the immutable owner ID from the target + link, then lock the owning target profile, exact target link, and any active + system Access Administrator target grant; +6. disclose replay or mismatch only after current authority succeeds; +7. enforce self, owner, link-state, and final-administrator guards; +8. mutate, touch only the verified caller, append one success and one + invalidation, complete idempotency, and commit once. + +The singleton lock serializes profile, link, and grant authority loss. Link +revocation of a currently effective human Access Administrator fails with 409 +`last_access_administrator` when the post-transition count would be zero. The +count requires active human profile, active exact link, and active system Access +Administrator grant. Link reactivation is not a final-admin loss. + +Target locking must use one canonical repository method. It accepts the link ID, +resolves the immutable owning profile ID after permission match, then takes +database locks in the established profile/link/grant order and validates the +association. Its return tuple may remain link/profile/grant. It does not +introduce another lifecycle lookup or lock path. The same order is required +because actor-self GET/PATCH/timestamp touch locks profile before link and does +not take the authority singleton. + +## Replay, Failure, And Timestamp Contract + +- Exact replay reauthorizes, returns the stored link reference even if later + state changed, and advances only the successful human caller's verification + timestamps. +- Changed reason or target under the same operation returns + `idempotency_mismatch` only after reauthorization. Operation is part of the + namespace, so using the same UUID for the other link operation is independent. +- 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 evidence, invalidation, completion, and commit failures return + the stable retryable 503 envelope with no partial state, evidence, timestamp, + or pending claim. +- Target `ActorProfile.last_seen_at` and `ActorIdentityLink.last_verified_at` + never advance for administrative link mutation. + +## Acceptance Criteria + +- Exactly two actions activate. Catalogue totals become 65 actions, 17 active, + and 48 planned, with one generated manifest declaration per route. +- State, attribution, exact action decision, link success evidence, + ActorProfile invalidation, and idempotency completion commit atomically. +- Behavior matrices cover human/service targets, active/revoked links, + active/suspended/deactivated owners, self guard, target concealment, + replay/mismatch, authority loss, rollback, timestamps, rate limits, OpenAPI, + manifest parity, exact allow/deny ActionId/PermissionId audit parity, and + privacy canaries. Link mismatch evidence carries the exact link ActionId. +- Parameterized PostgreSQL failure injection covers reservation, authorization + evidence, target lookup, state flush, caller touch, success evidence, + invalidation, idempotency completion, and commit for one valid revoke and one + valid reactivate. Every case returns the stable retryable 503 and asserts exact + pre/post equality for profile, link, grant, evidence, and verification + timestamps, no pending claim, and a reusable key where reservation began. +- Real PostgreSQL tests cover the following normative races without sleeps: + +| Initial state and blocker order | Exact committed outcome | +|---|---| +| active link; same key, same revoke | one 200 success and one 200 exact replay; one link success and one invalidation; no denial; key completed; final revoked | +| revoked link; same key, same reactivate | one 200 success and one 200 exact replay; one link success and one invalidation; no denial; key completed; final active | +| active link; different revoke keys | one 200 success and one 409 `identity_link_already_revoked`; one success/invalidation pair and one denial; winner completed, loser reusable; final revoked | +| revoked link; different reactivate keys | one 200 success and one 409 `identity_link_not_revoked`; one success/invalidation pair and one denial; winner completed, loser reusable; final active | +| active link; revoke holds singleton first | revoke 200 then reactivate 200; two success/invalidation pairs, no denial, both keys completed; final active | +| active link; reactivate holds singleton first | reactivate 409 `identity_link_not_revoked`, then revoke 200; one success/invalidation pair and one denial; revoke key completed, reactivate key reusable; final revoked | +| revoked link; reactivate holds singleton first | reactivate 200 then revoke 200; two success/invalidation pairs, no denial, both keys completed; final revoked | +| revoked link; revoke holds singleton first | revoke 409 `identity_link_already_revoked`, then reactivate 200; one success/invalidation pair and one denial; reactivate key completed, revoke key reusable; final active | +| two effective admins A/B; A profile-disables B before B link-revokes A | profile loss 200; B then denies with the exact suspended/deactivated current-authority code; one lifecycle success/invalidation pair and one authorization denial; A remains the sole effective admin; success key completed and denied key reusable | +| two effective admins A/B; B link-revokes A before A profile-disables B | link revoke 200; A then denies `identity_link_revoked`; one link success/invalidation pair and one authorization denial; B remains the sole effective admin; success key completed and denied key reusable | +| two effective admins A/B; A link-revokes B before B grant-revokes A | link revoke 200; B then denies `identity_link_revoked`; one link success/invalidation pair and one authorization denial; A remains sole effective admin; success key completed and denied key reusable | +| two effective admins A/B; B grant-revokes A before A link-revokes B | grant revoke 200; A then denies `permission_not_granted`; one grant success/invalidation pair and one authorization denial; B remains sole effective admin; success key completed and denied key reusable | +| three effective admins A/B/C; A profile-disables B, B link-revokes C, C grant-revokes A, with A then C holding the singleton first | A and C return 200, B denies the exact current-authority code; exactly two success/invalidation pairs and one authorization denial; B profile and A grant carry the two losses, C link remains active, and C is the sole effective admin; success keys completed and denied key reusable | +| active target link; target self GET holds profile lock first | GET returns 200 and advances the target/self profile and link verification timestamps exactly once; revoke waits, then returns 200 and advances only its separate admin caller; final target link revoked; one link success/invalidation pair; lifecycle key completed; no deadlock | +| active target link; target self PATCH holds profile lock first | PATCH returns 200 and advances the target/self profile and link verification timestamps exactly once; revoke waits, then returns 200 and advances only its separate admin caller; final target link revoked; one link success/invalidation pair; lifecycle key completed; no deadlock | +| active target link; revoke holds target profile lock before target self GET | revoke returns 200 and advances only its admin caller; GET waits then denies `identity_link_revoked` and advances neither target timestamp; final target link revoked; one link success/invalidation pair plus one authorization denial; lifecycle key completed; no deadlock | +| active target link; revoke holds target profile lock before target self PATCH | revoke returns 200 and advances only its admin caller; PATCH waits then denies `identity_link_revoked` and advances neither target timestamp; final target link revoked; one link success/invalidation pair plus one authorization denial; lifecycle key completed; no deadlock | + +Every race asserts no deadlock, no pending claim, exact success/invalidation/ +denial counts, completed or reusable key disposition, and final profile/link/ +grant state. Blocker-controlled PostgreSQL sessions establish order, and the +test must observe the waiter in `pg_stat_activity` with +`wait_event_type = 'Lock'`; timing sleeps are not lock evidence. +The actor-self rows are one parameterized matrix over both GET and PATCH and +both blocker orders; proving only one endpoint or one order fails acceptance. + +- Responses, errors, logs, OpenAPI, and evidence exclude issuer, subject, email, + token data, raw reason, attribution IDs, matched-grant internals, and digests. +- Focused authorization branch coverage is at least 90.00 percent. Actor + runtime is unchanged and its 09D-A coverage proof is not re-run as a false + delta metric. + GitHub Backend preserves the repository-wide 78 percent floor. +- Authorization spec, operations runbook, and live data model document link + lifecycle administration, component-scoped effectiveness, final-admin guard, + and non-restoration of grants. Archived reference specifications do not + change. + +## Verification Commands + +```bash +(cd backend && .venv/bin/python -m ruff check app tests scripts/api_contract_e2e.py) +(metadata_dir="$(mktemp -d)"; trap 'rm -rf "$metadata_dir"' EXIT; \ + cd backend && WORKSTREAM_TEST_ADMIN_DATABASE_URL= \ + .venv/bin/python scripts/run_isolated_tests.py \ + --metadata-json "$metadata_dir/focused.json" --timeout-seconds 3600 -- \ + .venv/bin/python -m pytest -q \ + tests/test_authorization.py tests/test_audit.py tests/test_api_controls.py \ + tests/test_api_rate_controls.py \ + tests/test_auth.py::test_actor_identity_link_lifecycle_real_postgres_matrix \ + tests/test_auth.py::test_actor_identity_link_lifecycle_real_postgres_concurrency) +(metadata_dir="$(mktemp -d)"; trap 'rm -rf "$metadata_dir"' EXIT; \ + cd backend && WORKSTREAM_TEST_ADMIN_DATABASE_URL= \ + .venv/bin/python scripts/run_isolated_tests.py \ + --metadata-json "$metadata_dir/authorization-coverage.json" \ + --timeout-seconds 3600 -- \ + bash -lc '.venv/bin/coverage erase && .venv/bin/coverage run --branch \ + --source=app/modules/authorization -m pytest -q \ + tests/test_authorization.py \ + tests/test_auth.py::test_signed_tokens_bootstrap_and_admin_grant_lifecycle \ + tests/test_auth.py::test_actor_profile_lifecycle_real_postgres_matrix \ + tests/test_auth.py::test_actor_profile_lifecycle_real_postgres_concurrency \ + tests/test_auth.py::test_actor_identity_link_lifecycle_real_postgres_matrix \ + tests/test_auth.py::test_actor_identity_link_lifecycle_real_postgres_concurrency && \ + .venv/bin/coverage report --precision=2 --fail-under=90') +(metadata_dir="$(mktemp -d)"; trap 'rm -rf "$metadata_dir"' EXIT; \ + cd backend && WORKSTREAM_TEST_ADMIN_DATABASE_URL= \ + .venv/bin/python scripts/run_isolated_tests.py \ + --metadata-json "$metadata_dir/api-contract.json" --timeout-seconds 3600 -- \ + .venv/bin/python scripts/api_contract_e2e.py) +python3 scripts/check_stale_workstream_wording.py +python3 scripts/check_stale_authorization_docs.py +python3 scripts/check_markdown_links.py +python3 scripts/test_agent_gates.py +python3 scripts/check_internal_review_evidence.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 +implementation proof. Full Backend CI remains the authoritative repository-wide +78 percent floor and integration regression gate. + +## Required Reviewers + +Senior engineering, QA/test, security/auth, product/ops, architecture, CI +integrity, docs, reuse/dedup, and test delta. + +## Human Review Focus + +Review permission-before-disclosure, self-link safety, terminal owner behavior, +component-scoped reactivation, link-to-profile evidence binding, truthful +invalidation direction, singleton serialization across profile/link/grant loss, +final-administrator preservation, caller-only timestamps, and rollback +atomicity. + +## Stop Condition + +Stop after merge and signed memory. Do not start +`WS-AUTH-001-CONTRIBUTOR-FOUNDATION`, `WS-AUTH-001-09E`, or another initiative +automatically. 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 a98bf68fe..d629234b2 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 @@ -41,6 +41,7 @@ No runtime code was changed under the rejected parent contract. ## 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. +Each child stops after merge and signed memory. +`WS-AUTH-001-CONTRIBUTOR-FOUNDATION` is the next separate gate after both +children merge. AUTH-09E remains inactive until that foundation merges and the +user gives a separate explicit start. 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 e0e445984..97e2192db 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/0027_*.py +backend/alembic/versions/_*.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 `0027` enforces exact three-role checks, composite snapshot/grant +- The then-current migration enforces exact three-role checks, composite snapshot/grant ownership, partial unique/supporting indexes, database-time fields, and - immutability. Migration `0027` refuses upgrade when obsolete combined or + immutability. It 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 f1f1fe679..bfcb2c692 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 `0028` mapping delta must be added before implementation review. +then-current migration 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/0028_*.py +backend/alembic/versions/_*.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 `0028` updates typed/PostgreSQL action-evidence parity and + surface. The then-current migration 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 6c12ba4a5..405e44a1c 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,8 +2,9 @@ ## Status -Proposed and inactive. Exact ActionIds and migration `0029` mapping/provenance -delta must be enumerated before implementation; AUTH-PREP is required. +Proposed and inactive. Exact ActionIds and the then-current migration +mapping/provenance delta must be enumerated before implementation; AUTH-PREP is +required. ## Parent initiative @@ -42,7 +43,7 @@ backend/app/modules/projects/** backend/app/modules/authorization/** backend/app/api/deps/auth.py backend/app/workers/project_setup.py -backend/alembic/versions/0029_*.py +backend/alembic/versions/_*.py backend/tests/test_projects.py backend/tests/test_auth.py backend/tests/test_alembic.py @@ -89,7 +90,7 @@ unscoped project-manager access or token role fallback declaration. - Approval provenance records matched local grant/actor/scope while preserving historical bootstrap provenance. -- Migration `0029` adds exact action-evidence parity plus matched local +- The then-current migration 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 6b7568e2c..3ff73d48e 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,8 +2,9 @@ ## Status -Proposed and inactive. Exact ActionIds and migration `0030` must be enumerated -before implementation; AUTH-PREP is required for mutations. +Proposed and inactive. Exact ActionIds and its then-current migration must be +enumerated before implementation; AUTH-PREP and the merged contributor +foundation are required for mutations. ## Parent initiative @@ -44,7 +45,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/0030_*.py +backend/alembic/versions/_*.py backend/app/modules/authorization/** backend/app/modules/audit/** backend/app/api/deps/auth.py @@ -97,9 +98,9 @@ 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 `0030` owns task/assignment, - Contributor-field, and exact new ActionId evidence parity; it changes no - PermissionId mapping. + evidence, and behavior tests. This chunk's then-current migration owns only + exact new ActionId evidence parity; it changes no PermissionId mapping or + contributor-field storage. - Operator `operations.status.read` exposes a read-only cross-project task-queue operational projection with bounded fields; it does not grant task mutation. Audit Authority `audit.read` exposes only covered task evidence. Both paths @@ -137,10 +138,10 @@ token role or legacy active-worker-profile fallback supported service/API path before claim. The legacy workflow-profile route 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 `0030` - preserves every existing assignment owner, supports downgrade, and removes - the legacy storage name without exposing a public compatibility alias. +- The merged contributor foundation already supplies canonical + `TaskAssignment.contributor_id`, API/schema/service fields, and audit payload + keys. This chunk consumes that field and must not recreate a retired storage + name or compatibility alias. - Full backend suite and API contract drill pass. - Tests cover revoke/suspend/reactivate before claim, while claimed, while in progress, at needs-revision, after submit, duplicate reconciliation, 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 550444379..f2ff5a3ac 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,8 +2,9 @@ ## Status -Proposed and inactive. Exact ActionIds and migration `0031` must be enumerated -before implementation; AUTH-PREP is required for mutations. +Proposed and inactive. Exact ActionIds and its then-current migration must be +enumerated before implementation; AUTH-PREP and the merged contributor +foundation are required for mutations. ## Parent initiative @@ -46,7 +47,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/0031_*.py +backend/alembic/versions/_*.py backend/app/modules/authorization/** backend/app/modules/audit/** backend/app/api/deps/auth.py @@ -101,8 +102,9 @@ 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 `0031` owns submission/checker Contributor-field schema changes and - exact new ActionId evidence parity; it changes no PermissionId mapping. + This chunk's then-current migration owns checker-field changes and exact new + ActionId evidence parity; it changes no PermissionId mapping and does not + rename Submission ownership again. - Contributor reads preserve ownership, hidden-result redaction, and concealed not-found behavior. - Audit reads expose only permission-appropriate bounded fields before counts. @@ -121,9 +123,10 @@ 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 `0031` preserves all values, - supports downgrade, and removes legacy storage/property names without public - API aliases. + contracts use `contributor_facing_fix`. The merged contributor foundation + already supplies canonical `Submission.contributor_id`; this chunk consumes + it while removing its remaining separately enumerated legacy property names + without public API aliases. - With the final consumer removed, the legacy `/api/v1/workers/me/profile` route, typed-profile activation service/schema, and token-role workflow observation fields plus the now-unused compatibility adapter/allowlist are 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 f5aa4cc68..5590bb460 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 `0032` parity must be enumerated before implementation. +and then-current migration 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/0032_*.py +backend/alembic/versions/_*.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 `0032` adds exact remaining command ActionId evidence parity and any +- The then-current migration adds exact remaining command ActionId evidence parity and any approved fixed-service constraints; it changes no existing permission mapping and proves prior-head/fresh upgrade, downgrade, and re-upgrade. - Full backend suite and API contract drill pass. diff --git a/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/chunks/WS-AUTH-001-CONTRIBUTOR-FOUNDATION-contributor-fields-human-lineage.md b/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/chunks/WS-AUTH-001-CONTRIBUTOR-FOUNDATION-contributor-fields-human-lineage.md new file mode 100644 index 000000000..605d98cd3 --- /dev/null +++ b/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/chunks/WS-AUTH-001-CONTRIBUTOR-FOUNDATION-contributor-fields-human-lineage.md @@ -0,0 +1,178 @@ +# Chunk Contract: WS-AUTH-001-CONTRIBUTOR-FOUNDATION - Contributor Fields And Canonical-Human Lineage + +## Status + +Proposed and inactive. It may start only after AUTH-09D-B merges, automated +memory records this same-initiative successor, trusted `main` is refreshed, and +the user gives a separate explicit start. + +## Parent initiative + +`WS-AUTH-001` - Workstream Authorization Service + +## Goal + +Clean-cut the two current human attribution fields to `contributor_id`, bind +them to canonical human `ActorProfile` records at the database boundary, and +expose one transaction-local canonical-active-human revalidation capability for +later task and revision mutations. + +## Why this chunk exists + +The previous plan deferred the assignment rename to AUTH-13 and the submission +rename to AUTH-14. That ordering blocks REV from safely adding canonical task, +guide, and submission lineage until most product authorization cutovers finish. +This bounded schema foundation removes that cycle without activating an action, +changing a grant, or implementing review behavior. + +## Risk class + +L1 + +## SLA + +P1 + +## Preconditions + +- AUTH-09D-B is merged and signed automated memory names this chunk as its + same-initiative successor. +- The branch is created from current trusted `main`, records the exact single + Alembic head, and allocates only the then-current next migration. +- Preimplementation review approves the exact database primitive used to + enforce human ActorProfile lineage and its downgrade behavior. + +## Allowed files + +```text +backend/app/modules/actors/{models,repository}.py +backend/app/modules/authorization/{repository,service,runtime}.py +backend/app/modules/tasks/{models,schemas,repository,service}.py +backend/app/modules/checkers/** only for exact renamed contributor-ID reads +backend/app/db/models.py +backend/alembic/versions/_contributor_foundation.py +backend/tests/test_actors.py +backend/tests/test_authorization.py +backend/tests/test_auth.py +backend/tests/test_tasks.py +backend/tests/test_checkers.py +backend/tests/test_alembic.py +backend/scripts/api_contract_e2e.py +.github/workflows/backend.yml only if a persistent focused coverage command is missing +docs/architecture_data_model.md +docs/operations_authorization_service.md +docs/spec_authorization_service.md +.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/** +.agent-loop/merge-intents/WS-AUTH-001-CONTRIBUTOR-FOUNDATION.json +.agent-loop/LOOP_STATE.md +.agent-loop/WORK_QUEUE.md +.agent-loop/REVIEW_LOG.md +``` + +## Not allowed + +```text +permission, ActionId, owner, evaluator, or availability changes +project/admin role grant changes +AUTH-09E service admission or fixed-service authority +task, assignment, submission, checker, review, or revision lifecycle state or +transition changes beyond the exact active-human write guard below +Submission task-assignment lineage, predecessor chains, or guide stamps owned by REV +renaming the separately enumerated AUTH-14 attestation and contributor-facing fields +token-role removal, legacy workflow eligibility removal, or AUTH-13/14 route cutover +compatibility aliases, dual fields, fallback reads/writes, or data duplication +``` + +## Acceptance criteria + +- The retired `TaskAssignment` human-owner field is clean-cut to + `contributor_id` across the + PostgreSQL column and index, SQLAlchemy model, Pydantic response, service and + repository references, audit payloads, scripts, and tests. The old name is + absent; no property, response alias, shadow column, or dual write remains. +- The retired `Submission` human-owner field receives the same clean cut. + Existing task, + submission, checker, and revision behavior and attribution remain unchanged + apart from the intentional response-field rename and fail-closed + transaction-local active-human write revalidation. +- Both `contributor_id` columns are non-null foreign keys to the canonical + `actor_profiles.id` root. A single reviewed, reusable PostgreSQL lineage + primitive rejects a service ActorProfile for either field and is suitable for + later canonical-human actor fields without creating another actor registry. +- The migration preflight reports every missing ActorProfile, non-human + ActorProfile, malformed identifier, and inconsistent assignment/submission + attribution with bounded row identifiers, then aborts atomically. It never + guesses an actor, maps by email, selects the latest profile, or fabricates + remediation data. +- Existing valid values are preserved exactly by column rename. Upgrade and + downgrade preserve values and indexes; downgrade refuses if a downstream + constraint depends on the reusable lineage primitive. +- Direct SQL tests reject missing and service ActorProfiles for both tables, + accept canonical human ActorProfiles, and prove later suspension or + deactivation does not rewrite immutable historical attribution. +- AUTH exposes one narrow transaction-local operation that locks and + revalidates an exact ActorProfile as active and human under the canonical + profile-before-resource order. It returns no grants or identity claims and + introduces no second authorization path. +- Task claim and submission creation consume that operation at their sensitive + write boundary without changing their existing role, task-state, assignment, + checker, or commit semantics. Profile suspension/deactivation racing either + write is proved in both lock orders; the losing write leaves no assignment, + submission, checker result, audit mutation, or partial evidence. +- Behavior tests preserve claim, start, initial submit, checker-caused revision + resubmission, audit history, idempotency, concealment, and rollback behavior + while asserting the canonical `contributor_id` response and evidence shape. +- Migration tests cover valid upgrade/downgrade/upgrade, each unsafe preflight + refusal, direct-SQL kind enforcement, dependency-aware downgrade refusal, and + one Alembic head. +- Changed actor, authorization, and task modules remain at or above 90 percent + focused coverage; repository-wide coverage does not fall below 78 percent. + +## Verification commands + +```bash +(cd backend && .venv/bin/python -m ruff check app tests scripts) +(cd backend && WORKSTREAM_TEST_DATABASE_URL= .venv/bin/python -m pytest -q \ + tests/test_actors.py tests/test_auth.py tests/test_alembic.py \ + --cov=app.modules.actors --cov-report=term-missing --cov-fail-under=90) +(cd backend && WORKSTREAM_TEST_DATABASE_URL= .venv/bin/python -m pytest -q \ + tests/test_authorization.py tests/test_auth.py tests/test_alembic.py \ + --cov=app.modules.authorization --cov-report=term-missing --cov-fail-under=90) +(cd backend && WORKSTREAM_TEST_DATABASE_URL= .venv/bin/python -m pytest -q \ + tests/test_tasks.py tests/test_checkers.py tests/test_alembic.py \ + --cov=app.modules.tasks --cov-report=term-missing --cov-fail-under=90) +(cd backend && WORKSTREAM_TEST_DATABASE_URL= .venv/bin/python -m pytest -q) +(cd backend && WORKSTREAM_DATABASE_URL= .venv/bin/python scripts/api_contract_e2e.py) +(cd backend && WORKSTREAM_DATABASE_URL= .venv/bin/alembic upgrade head) +(cd backend && WORKSTREAM_DATABASE_URL= .venv/bin/alembic downgrade -1) +(cd backend && WORKSTREAM_DATABASE_URL= .venv/bin/alembic upgrade head) +python3 scripts/check_stale_workstream_wording.py +python3 scripts/check_stale_authorization_docs.py +python3 scripts/check_markdown_links.py +git diff --check +``` + +## Required reviewers + +- senior engineering +- QA/test +- security/auth +- product/ops +- architecture +- CI integrity +- docs +- reuse/dedup +- test delta + +## Human review focus + +Review the clean absence of retired human-owner identifiers, exact preservation of attribution, +database rejection of service identities, migration remediation/refusal, race +closure, and the absence of AUTH-13/14 or REV behavior. + +## Stop conditions + +Stop if migration requires guessed identity mapping, a second actor registry, +an API compatibility alias, changed lifecycle behavior, or an authorization +availability change. Stop after merge and signed memory; do not start AUTH-09E, +REV-02A, or another chunk automatically. diff --git a/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/reviews/WS-AUTH-001-09D-B-internal-review-evidence.md b/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/reviews/WS-AUTH-001-09D-B-internal-review-evidence.md new file mode 100644 index 000000000..75995b074 --- /dev/null +++ b/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/reviews/WS-AUTH-001-09D-B-internal-review-evidence.md @@ -0,0 +1,95 @@ +# WS-AUTH-001-09D-B Internal Review Evidence + +Reviewed code SHA: `ab6669ebde0d5947ab8b4631667c9ec552ba7687` + +Reviewed implementation SHA: `4bd377fb` + +Reviewed against trusted main: +`1b5422fcaa361152af7c2b1f82a763d99c0e6db5` + +Reviewed at: `2026-07-19T02:06:03Z` + +Reviewer run IDs: `auth_xint_roles`, `auth_xint_art_service`, +`auth_xint_rev_con` + +Reviewer tracks: senior engineering, QA/test, security/auth, product/ops, +architecture, CI integrity, docs, reuse/dedup, and test delta + +## Deterministic Evidence + +- Exactly `actor.identity_link.revoke` and + `actor.identity_link.reactivate` become active. The catalogue contains 65 + ActionIds: 17 active and 48 planned. +- The two exact PostgreSQL lifecycle/concurrency nodes passed in 241.09 seconds. + They prove lock-observed same-key, different-key, mixed profile/link/grant, + three-administrator, and actor-self GET/PATCH races without timing sleeps. +- The isolated authorization selection passed 112 tests in 543.89 seconds at + 90.11 percent branch coverage, above the required 90 percent subsystem floor. +- After integrating ART-02B1 from trusted main, the exact focused PostgreSQL + behavior selection passed 167 tests in 299.64 seconds. ART provider, + workflow, storage, and initiative files are byte-identical to trusted main in + this PR's final diff. +- Failure injection proves exact rollback of profile, link, grant, audit, + idempotency, and verification-timestamp state with reusable losing keys and + the stable retryable 503 response. +- Behavior tests prove exact replay after later state changes, mismatch and + conflict preservation, self-revoke denial, missing-target concealment, + final-Access-Administrator protection, service-target handling, and exact + success, invalidation, and denial evidence. +- The old PR-head Backend run passed 1,334 tests at 85.09 percent global + coverage, then exposed an 89.39 percent authorization slice. A five-case + behavior test now proves route success, replay, mismatch, conflict, and SQL + failure ordering and executes 29 previously uncounted lifecycle-route + statements, exceeding the measured 12-statement deficit. All five cases pass; + replacement CI remains authoritative for the final percentage. +- The isolated real HTTP API contract drill passed. Ruff, stale Workstream, + authorization, and artifact scans, Markdown links, all 88 agent-gate tests, + merge-intent validation, and diff integrity also passed. +- No test was skipped or marked xfail, and no coverage threshold, workflow + gate, or production behavior was weakened after the deterministic proof. +- The planning repair inserts inactive + `WS-AUTH-001-CONTRIBUTOR-FOUNDATION` immediately after 09D-B. It owns the + clean `contributor_id` field transition and canonical-human lineage; AUTH-13 + and AUTH-14 consume that foundation instead of renaming the fields later. + +## Reviewer Results + +| Reviewer | Result | Blocking findings | Notes | +|---|---|---|---| +| senior engineering | PASS AFTER FIXES | none | The lifecycle orchestration, transaction ownership, exact resource binding, and contributor-foundation sequence are coherent. | +| QA/test | PASS AFTER FIXES | none | Real database locks and route outcomes assert exact final state, ordering, evidence, rollback, timestamps, and idempotency disposition. | +| security/auth | PASS AFTER FIXES | none | Authority is revalidated before disclosure; privacy-safe denial, final-admin preservation, and fail-closed rollback are proved. | +| product/ops | PASS AFTER FIXES | none | Only an effective system Access Administrator may mutate a link; reactivation restores neither a grant nor service admission. | +| architecture | PASS AFTER FIXES | none | AUTH retains centralized catalogue, evaluator, guard, evidence, and activation ownership without compatibility paths. | +| CI integrity | PASS AFTER FIXES | none | The genuine route behavior repair addresses the measured coverage deficit without changing the 90 percent authorization floor, repository floor, or isolated runner. | +| docs | PASS AFTER FIXES | none | API, operations, data model, lifecycle state, and successor gates match the implemented behavior. | +| reuse/dedup | PASS AFTER FIXES | none | The implementation reuses the canonical locks, authorization decision, evidence, idempotency, limiter, and route-owned commit paths. | +| test delta | PASS AFTER FIXES | none | Assertions were strengthened around lock observation, exact row snapshots, privacy, replay, and denial rather than relaxed to fit the code. | + +## Findings Resolved + +Valid findings addressed: yes + +Open sub-agent sessions: none + +QA's four initial proof findings were repaired by observing PostgreSQL lock +waits, asserting exact mixed-race final state, comparing complete rollback row +snapshots, and binding denial/replay timestamp behavior. Architecture and docs +findings added the immutable merge intent, retired fixed future migration +reservations, and aligned the public specification and data model. The +contributor-foundation contract was narrowed to exact files and lifecycle +guards. ART-main integration review corrected the exact 15-plus-2 action +arithmetic and strengthened deterministic rejection of retired AUTH and ART +state wording. Final test-delta review at `ab6669eb` confirmed that the CI +repair covers five real public route outcomes without production or threshold +changes. + +## Remaining Risk And Gate + +External GitHub Backend, Agent Gates, CodeRabbit, and explicit human review +remain. This chunk does not add fixed-service admission, grants, feature action +activation, or the contributor-field migration. + +`WS-AUTH-001-CONTRIBUTOR-FOUNDATION` remains inactive until this chunk merges, +trusted-main automation records signed memory, and the user explicitly starts +it. Do not start the foundation, AUTH-09E, or another initiative automatically. diff --git a/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/reviews/WS-AUTH-001-09D-B-pr-trust-bundle.md b/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/reviews/WS-AUTH-001-09D-B-pr-trust-bundle.md new file mode 100644 index 000000000..61caf189f --- /dev/null +++ b/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/reviews/WS-AUTH-001-09D-B-pr-trust-bundle.md @@ -0,0 +1,121 @@ +# WS-AUTH-001-09D-B PR Trust Bundle + +## Chunk + +`WS-AUTH-001-09D-B` - Identity-Link Lifecycle And Race Closure + +## Goal + +Let an effective system Access Administrator revoke or reactivate an exact +human or fixed-service identity link while preserving current authority, +privacy, final-administrator safety, idempotency, and atomic evidence. + +## Changes + +- Added strict idempotent revoke and reactivate endpoints for an exact + ActorIdentityLink. +- Activated only the two matching ActionIds and PermissionIds; the catalogue is + 65 actions with 17 active and 48 planned. +- Reused centralized authorization, profile/link/grant locking, lifecycle + guards, evidence, invalidation, idempotency, limiter, and route-owned commit. +- Added exact state, attribution, replay, mismatch, conflict, rollback, + timestamp, privacy, API, OpenAPI, and real PostgreSQL concurrency proof. +- Corrected the AUTH plan so an inactive contributor foundation follows 09D-B + and AUTH-13/14 consume its `contributor_id` fields rather than rename them + later. + +## Boundary + +This PR does not add a migration, fixed-service token admission, grant +mutation, service assignment, project authorization cutover, feature action, +compatibility route, fallback, or legacy response. Reactivating a link restores +neither an AdminRoleGrant nor service admission. Deactivated actors remain +terminal, suspended actors remain unable to authenticate, and target +verification timestamps do not advance. + +## Design + +Each new request validates and freezes its body, reserves idempotency, then +authorizes before target disclosure. AUTH locks the singleton authority +control, caller profile/link/grant, and target profile/link/grant in the +canonical order. State, caller touch, success evidence, ActorProfile +invalidation, idempotency completion, and commit are one transaction. Expected +domain conflicts are restaged as one privacy-safe denial after rollback; SQL or +evidence failures return the stable retryable 503 with no partial state. + +## Proof + +- Mandatory PostgreSQL lifecycle/concurrency nodes: 2 passed in 241.09 seconds. +- Isolated authorization behavior and coverage selection: 112 passed in 543.89 + seconds at 90.11 percent branch coverage. +- ART-integrated focused PostgreSQL selection: 167 passed in 299.64 seconds. +- Direct route outcome matrix: 5 passed, covering success, replay, mismatch, + conflict, and retryable SQL failure with exact transaction ordering. +- Isolated real HTTP API contract drill: passed. +- Ruff, stale Workstream, authorization, and artifact scans, Markdown links, + all 88 agent + gates, merge-intent validation, and diff integrity: passed. +- No skips, xfails, threshold reductions, or workflow-gate weakening. + +Exact reviewed code SHA +`ab6669ebde0d5947ab8b4631667c9ec552ba7687` against trusted main +`1b5422fcaa361152af7c2b1f82a763d99c0e6db5` passed senior engineering, +QA/test, security/auth, product/ops, architecture, CI integrity, docs, +reuse/dedup, and test-delta review after every valid finding was repaired. + +## External CI Repair + +The old PR head passed 1,334 Backend tests at 85.09 percent repository-wide +coverage, then failed the unchanged authorization floor at 89.39 percent. The +repair adds no exclusion, skip, xfail, threshold change, or production branch. +Its five behavior cases execute 29 lifecycle-route statements that the old +full-suite dataset did not count, exceeding the measured 12-statement deficit. +Replacement GitHub Backend remains the authoritative final percentage. + +## Reviewer Results + +| Track | Result | Blocking findings | +|---|---|---| +| Senior engineering | PASS AFTER FIXES | none | +| QA/test | PASS AFTER FIXES | none | +| Security/auth | PASS AFTER FIXES | none | +| Product/ops | PASS AFTER FIXES | none | +| Architecture | PASS AFTER FIXES | none | +| CI integrity | PASS AFTER FIXES | none | +| Docs | PASS AFTER FIXES | none | +| Reuse/dedup | PASS AFTER FIXES | none | +| Test delta | PASS AFTER FIXES | none | + +## Findings Closed + +The review loop repaired real lock observation, exact race outcomes, complete +rollback snapshots, denial and replay timestamp assertions, merge-intent and +documentation gaps, stale migration reservations, contributor-foundation +scope, and contradictory lifecycle-state wording. No production compatibility +path or broader authority was introduced. ART-main reconciliation then fixed +stale shared-state assertions and exact action arithmetic; the CI repair added +only genuine route behavior coverage. + +## Follow-up + +The immutable schema-v2 merge intent names +`WS-AUTH-001-CONTRIBUTOR-FOUNDATION` as the same-initiative successor. That +chunk will clean-cut TaskAssignment and Submission ownership to +`contributor_id`, add database-backed canonical-human lineage, and expose +transaction-local active-human revalidation. It remains inactive until 09D-B +merges, signed automated memory passes, and the user explicitly starts it. +AUTH-09E and all later consumers remain separate gates. + +## Human Review Focus + +Review authorization-before-disclosure, canonical lock order, final-admin +preservation, replay after later state changes, privacy-safe denial evidence, +atomic rollback, absence of grant/service restoration, and the inactive +contributor-foundation boundary. + +## Merge Ownership + +The agent may publish and repair this branch but may not merge it. Only the +human may approve this PR for merge. After merge, trusted-main automation owns +schema-v2 memory generation; no manual post-merge memory PR should be opened if +that workflow succeeds. diff --git a/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/reviews/WS-AUTH-001-09D-B-preimplementation-review-evidence.md b/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/reviews/WS-AUTH-001-09D-B-preimplementation-review-evidence.md new file mode 100644 index 000000000..b9c3f16bc --- /dev/null +++ b/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/reviews/WS-AUTH-001-09D-B-preimplementation-review-evidence.md @@ -0,0 +1,49 @@ +# WS-AUTH-001-09D-B Preimplementation Review Evidence + +Reviewed contract SHA: `9ec6390bfedacff46d9ead7d33b836cacf7af13e` + +Trusted base: `99ae4c963e53f317175dcb308b9e47c93ccf19ed` + +## Result + +PASS. Runtime implementation may begin for 09D-B only. + +## Tracks + +| Track | Result | Bound review focus | +|---|---|---| +| Senior engineering | PASS | Bounded child, canonical reuse, transaction ownership, exact public lifecycle surface | +| Security/auth | PASS | Permission-before-disclosure, self-link safety, replay reauthorization, final-admin serialization, privacy | +| Product/ops | PASS | Human/service targets, terminal owners, suspended-owner repair, grant non-restoration | +| QA/test delta | PASS | Exact state/replay/rollback/race/timestamp matrices, mandatory future nodes, no test weakening | +| Architecture | PASS | Owner resolution plus profile/link/grant locks, singleton serialization, no parallel authority path | +| CI integrity | PASS | Isolated PostgreSQL proof, lock observation, 90.00 percent focused coverage, 78 percent global floor | +| Documentation | PASS | Live spec/runbook/data-model custody; archived references excluded | +| Reuse/dedup | PASS | Existing reservation, audit, invalidation, actor touch, limiter, error, repository, and commit primitives | + +## Repaired Findings + +- Replaced the broad boundary with exact allowed and forbidden paths. +- Corrected target locks to the canonical profile, exact link, grant order and + required both actor-self GET and PATCH races in both blocker orders. +- Added exact ActionId mismatch evidence custody and the active-action audit + parity test boundary. +- Kept missing targets in the closed `resource_not_found` vocabulary with no + false allow evidence and one clean privacy-safe denial. +- Made same-key, different-key, state, final-admin, and mixed + profile/link/grant race outcomes normative with exact evidence and key state. +- Required all nine SQL/commit failure stages for both revoke and reactivate. +- Removed stale 09D-A process state and pinned 09E inactive in deterministic + agent gates. + +## Deterministic Planning Evidence + +- `python3 scripts/test_agent_gates.py`: 87 passed. +- stale Workstream wording: passed. +- stale authorization documentation: passed. +- Markdown links: passed. +- `git diff --check`: passed. + +The two mandatory PostgreSQL implementation nodes intentionally do not exist at +this gate. Their absence remains a failing implementation proof, not planning +evidence. diff --git a/.agent-loop/merge-intents/WS-AUTH-001-09D-B.json b/.agent-loop/merge-intents/WS-AUTH-001-09D-B.json new file mode 100644 index 000000000..2e2aff5a9 --- /dev/null +++ b/.agent-loop/merge-intents/WS-AUTH-001-09D-B.json @@ -0,0 +1,9 @@ +{ + "chunk_id": "WS-AUTH-001-09D-B", + "chunk_title": "Identity-Link Lifecycle And Race Closure", + "initiative_id": "WS-AUTH-001", + "next_chunk_id": "WS-AUTH-001-CONTRIBUTOR-FOUNDATION", + "next_chunk_title": "Contributor Fields And Canonical-Human Lineage", + "next_requires_explicit_start": true, + "schema_version": 2 +} diff --git a/backend/app/modules/authorization/catalogue.py b/backend/app/modules/authorization/catalogue.py index 384a2a637..0b06fcd99 100644 --- a/backend/app/modules/authorization/catalogue.py +++ b/backend/app/modules/authorization/catalogue.py @@ -293,12 +293,12 @@ def _active( PermissionId.ACTOR_IDENTITY_LINK_READ, ActionOwner.AUTH_09C, ), - _planned( + _active( ActionId.ACTOR_IDENTITY_LINK_REVOKE, PermissionId.ACTOR_IDENTITY_LINK_REVOKE, ActionOwner.AUTH_09D_B, ), - _planned( + _active( ActionId.ACTOR_IDENTITY_LINK_REACTIVATE, PermissionId.ACTOR_IDENTITY_LINK_REACTIVATE, ActionOwner.AUTH_09D_B, @@ -585,6 +585,8 @@ def _index_actions( ActionId.ACTOR_PROFILE_SUSPEND, ActionId.ACTOR_PROFILE_REACTIVATE, ActionId.ACTOR_PROFILE_DEACTIVATE, + ActionId.ACTOR_IDENTITY_LINK_REVOKE, + ActionId.ACTOR_IDENTITY_LINK_REACTIVATE, } if { definition.action_id diff --git a/backend/app/modules/authorization/kernel.py b/backend/app/modules/authorization/kernel.py index 4a2085419..81eb9cd15 100644 --- a/backend/app/modules/authorization/kernel.py +++ b/backend/app/modules/authorization/kernel.py @@ -23,6 +23,7 @@ from app.modules.authorization.runtime import ( ActorAdminRoleGrantHistoryResourceContext, ActorIdentityLinkAdminReadResourceContext, + ActorIdentityLinkLifecycleResourceContext, ActorKind, ActorProfileAdminReadResourceContext, ActorProfileLifecycleResourceContext, @@ -64,6 +65,8 @@ ActionId.ACTOR_PROFILE_SUSPEND, ActionId.ACTOR_PROFILE_REACTIVATE, ActionId.ACTOR_PROFILE_DEACTIVATE, + ActionId.ACTOR_IDENTITY_LINK_REVOKE, + ActionId.ACTOR_IDENTITY_LINK_REACTIVATE, } ) _SERIALIZED_ADMIN_READS = frozenset( @@ -80,6 +83,8 @@ ActionId.ACTOR_PROFILE_SUSPEND, ActionId.ACTOR_PROFILE_REACTIVATE, ActionId.ACTOR_PROFILE_DEACTIVATE, + ActionId.ACTOR_IDENTITY_LINK_REVOKE, + ActionId.ACTOR_IDENTITY_LINK_REACTIVATE, } ) @@ -126,7 +131,11 @@ async def require( else: if ( action is not None - and action.action_id is ActionId.ACTOR_PROFILE_UPDATE_SELF + and action.action_id + in { + ActionId.ACTOR_PROFILE_READ_SELF, + ActionId.ACTOR_PROFILE_UPDATE_SELF, + } and isinstance(resource_context, ActorSelfResourceContext) and self._revalidate_actor_self is not None ): @@ -266,6 +275,17 @@ async def _admin_guard( 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, ActorIdentityLinkLifecycleResourceContext): + if ( + resource.resource_id == context.identity_link_id + and resource.transition == "revoke" + ): + return AuthorizationDenialCode.RESOURCE_GUARD_DENIED + if ( + await self._admin.lock_identity_link_lifecycle_target(resource.resource_id) + is None + ): + return AuthorizationDenialCode.RESOURCE_NOT_FOUND elif isinstance( resource, (AdminRoleGrantCollectionResourceContext, ActorAdminRoleGrantHistoryResourceContext), @@ -298,6 +318,8 @@ def _admin_resource_matches( ActionId.ACTOR_PROFILE_SUSPEND: ActorProfileLifecycleResourceContext, ActionId.ACTOR_PROFILE_REACTIVATE: ActorProfileLifecycleResourceContext, ActionId.ACTOR_PROFILE_DEACTIVATE: ActorProfileLifecycleResourceContext, + ActionId.ACTOR_IDENTITY_LINK_REVOKE: ActorIdentityLinkLifecycleResourceContext, + ActionId.ACTOR_IDENTITY_LINK_REACTIVATE: ActorIdentityLinkLifecycleResourceContext, }.get(action_id) if expected is None or not isinstance(resource, expected): return False @@ -305,6 +327,8 @@ def _admin_resource_matches( ActionId.ACTOR_PROFILE_SUSPEND: "suspend", ActionId.ACTOR_PROFILE_REACTIVATE: "reactivate", ActionId.ACTOR_PROFILE_DEACTIVATE: "deactivate", + ActionId.ACTOR_IDENTITY_LINK_REVOKE: "revoke", + ActionId.ACTOR_IDENTITY_LINK_REACTIVATE: "reactivate", }.get(action_id) return transition is None or resource.transition == transition @@ -373,7 +397,10 @@ def _denial( return AuthorizationDenialCode.RESOURCE_GUARD_DENIED if context.actor_kind is not ActorKind.HUMAN: return AuthorizationDenialCode.PERMISSION_NOT_GRANTED - if action.action_id is ActionId.ACTOR_PROFILE_UPDATE_SELF and not revalidated: + if action.action_id in { + ActionId.ACTOR_PROFILE_READ_SELF, + ActionId.ACTOR_PROFILE_UPDATE_SELF, + } and not revalidated: return AuthorizationDenialCode.RESOURCE_GUARD_DENIED return None @@ -402,8 +429,8 @@ async def _stage_decision( audit_resource_type = None if target_is_actor: audit_resource_type = "actor_profile" - elif decision.resource_type == "admin_role_grant": - audit_resource_type = "admin_role_grant" + elif decision.resource_type in {"actor_identity_link", "admin_role_grant"}: + audit_resource_type = decision.resource_type try: await self._audit.add_authority_event( AuthorityAuditEventInput( diff --git a/backend/app/modules/authorization/lifecycle_schemas.py b/backend/app/modules/authorization/lifecycle_schemas.py index f87310951..418a47026 100644 --- a/backend/app/modules/authorization/lifecycle_schemas.py +++ b/backend/app/modules/authorization/lifecycle_schemas.py @@ -33,3 +33,13 @@ class ActorLifecycleMutationResponse(BaseModel): resource_id: UUID version: None = None http_status: Literal[200] + + +class IdentityLinkLifecycleMutationResponse(BaseModel): + """Stable privacy-bounded reference for one identity-link lifecycle result.""" + + model_config = _STRICT + resource_type: Literal["actor_identity_link"] + 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 index 9cb83de1d..d15845a5b 100644 --- a/backend/app/modules/authorization/lifecycle_service.py +++ b/backend/app/modules/authorization/lifecycle_service.py @@ -7,7 +7,7 @@ from sqlalchemy import func from sqlalchemy.ext.asyncio import AsyncSession -from app.modules.actors.models import ActorProfile +from app.modules.actors.models import ActorIdentityLink, ActorProfile from app.modules.audit.schemas import ( ActorReferenceKind, AuthorityAuditEventInput, @@ -15,15 +15,21 @@ ) 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.lifecycle_schemas import ( + ActorLifecycleMutationResponse, + IdentityLinkLifecycleMutationResponse, +) from app.modules.authorization.repository import AdminAuthorizationRepository from app.modules.authorization.runtime import ( + ActorIdentityLinkLifecycleResourceContext, ActorProfileLifecycleResourceContext, AuthorizationDecision, MatchedAuthorityKind, authorization_resource_digest, ) from app.modules.authorization.schemas import ( + ActorIdentityLinkReactivateRequest, + ActorIdentityLinkRevokeRequest, ActorProfileDeactivateRequest, ActorProfileReactivateRequest, ActorProfileSuspendRequest, @@ -43,6 +49,7 @@ | ActorProfileReactivateRequest | ActorProfileDeactivateRequest ) +IdentityLinkLifecycleRequest = ActorIdentityLinkRevokeRequest | ActorIdentityLinkReactivateRequest class ActorLifecycleConflict(RuntimeError): @@ -53,6 +60,15 @@ def __init__(self, code: str) -> None: self.code = code +class IdentityLinkLifecycleConflict(RuntimeError): + """One exact link-state, owner-state, or final-administrator conflict.""" + + def __init__(self, code: str, actor_profile_id: UUID) -> None: + super().__init__(code) + self.code = code + self.actor_profile_id = actor_profile_id + + _ACTION = { AuthorityOperation.ACTOR_PROFILE_SUSPEND: ActionId.ACTOR_PROFILE_SUSPEND, AuthorityOperation.ACTOR_PROFILE_REACTIVATE: ActionId.ACTOR_PROFILE_REACTIVATE, @@ -73,6 +89,30 @@ def __init__(self, code: str) -> None: AuthorityOperation.ACTOR_PROFILE_REACTIVATE: AuthorityEventType.ACTOR_PROFILE_REACTIVATED, AuthorityOperation.ACTOR_PROFILE_DEACTIVATE: AuthorityEventType.ACTOR_PROFILE_DEACTIVATED, } +_LINK_ACTION = { + AuthorityOperation.ACTOR_IDENTITY_LINK_REVOKE: ActionId.ACTOR_IDENTITY_LINK_REVOKE, + AuthorityOperation.ACTOR_IDENTITY_LINK_REACTIVATE: ( + ActionId.ACTOR_IDENTITY_LINK_REACTIVATE + ), +} +_LINK_PERMISSION = { + AuthorityOperation.ACTOR_IDENTITY_LINK_REVOKE: PermissionId.ACTOR_IDENTITY_LINK_REVOKE, + AuthorityOperation.ACTOR_IDENTITY_LINK_REACTIVATE: ( + PermissionId.ACTOR_IDENTITY_LINK_REACTIVATE + ), +} +_LINK_TRANSITION = { + AuthorityOperation.ACTOR_IDENTITY_LINK_REVOKE: "revoke", + AuthorityOperation.ACTOR_IDENTITY_LINK_REACTIVATE: "reactivate", +} +_LINK_EVENT = { + AuthorityOperation.ACTOR_IDENTITY_LINK_REVOKE: ( + AuthorityEventType.ACTOR_IDENTITY_LINK_REVOKED + ), + AuthorityOperation.ACTOR_IDENTITY_LINK_REACTIVATE: ( + AuthorityEventType.ACTOR_IDENTITY_LINK_REACTIVATED + ), +} class ActorLifecycleService: @@ -288,6 +328,218 @@ def _apply( profile.deactivation_reason = reason +class IdentityLinkLifecycleService: + """Stage one identity-link transition and exact evidence in one 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: IdentityLinkLifecycleRequest, + ) -> 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: IdentityLinkLifecycleRequest, + decision: AuthorizationDecision, + actor_profile_id: UUID, + reason: str, + ) -> IdentityLinkLifecycleMutationResponse: + """Apply one valid link transition and complete its evidence pair.""" + if not _identity_link_decision_matches(decision, request, existing=False): + raise TypeError("identity link lifecycle mutation requires exact matched authority") + if request.reason_digest != derive_reason_digest(reason): + raise TypeError("identity link lifecycle reason digest changed") + locked = await self._repository.lock_identity_link_lifecycle_target( + request.identity_link_id + ) + if locked is None: + raise RuntimeError("authorized identity link lifecycle target disappeared") + link, profile, access_grant = locked + conflict = await self._conflict(request, link, profile, access_grant is not None) + if conflict is not None: + raise IdentityLinkLifecycleConflict(conflict, UUID(profile.id)) + + before_status = link.status + self._apply(link, request, actor_profile_id, reason) + await self._session.flush() + response = AuthorityResponseReference( + resource_type=AuthorityResourceType.ACTOR_IDENTITY_LINK, + resource_id=request.identity_link_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=_LINK_EVENT[request.operation], + entity_type="actor_identity_link", + entity_id=str(request.identity_link_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=profile.id, + matched_grant_id=str(decision.matched_grant_id), + permission_id=_LINK_PERMISSION[request.operation], + resource_type="actor_identity_link", + resource_id=str(request.identity_link_id), + target_ref_kind="actor_identity_link", + target_ref_id=str(request.identity_link_id), + reason="identity_lifecycle_change", + idempotency_reference=claim.record_id, + before_facts={"status": before_status}, + after_facts={"status": link.status}, + ), + invalidation=AuthorityInvalidationContext( + event_id=uuid4(), + request_id=decision.request_id, + correlation_id=decision.correlation_id, + ), + ) + return IdentityLinkLifecycleMutationResponse( + resource_type="actor_identity_link", + resource_id=response.resource_id, + version=None, + http_status=200, + ) + + async def record_mismatch( + self, + *, + actor_profile_id: UUID, + request: IdentityLinkLifecycleRequest, + decision: AuthorizationDecision, + ) -> None: + """Write one action-bound mismatch after rolling back the reservation.""" + if not _identity_link_decision_matches(decision, request, existing=True): + raise TypeError("identity link lifecycle mismatch requires exact authority") + await self._mutation.record_mismatch_denial( + actor_ref_kind=ActorReferenceKind.ACTOR_PROFILE, + actor_ref=str(actor_profile_id), + request=request.model_dump(), + context=AuthorityMismatchContext( + event_id=uuid4(), + request_id=decision.request_id, + correlation_id=decision.correlation_id, + matched_grant_id=None, + ), + ) + + async def record_conflict( + self, + *, + actor_profile_id: UUID, + target_actor_profile_id: UUID, + request: IdentityLinkLifecycleRequest, + decision: AuthorizationDecision, + code: str, + ) -> None: + """Write one clean post-allow link denial without consuming the key.""" + if not _identity_link_decision_matches(decision, request, existing=False): + raise TypeError("identity link 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(target_actor_profile_id), + matched_grant_id=None, + permission_id=_LINK_PERMISSION[request.operation], + action_id=_LINK_ACTION[request.operation], + resource_type="actor_identity_link", + resource_id=str(request.identity_link_id), + target_ref_kind="actor_identity_link", + target_ref_id=str(request.identity_link_id), + reason="authorization_evaluation", + denial_code=code, + after_facts={"allowed": False}, + ) + ) + + async def _conflict( + self, + request: IdentityLinkLifecycleRequest, + link: ActorIdentityLink, + profile: ActorProfile, + has_access_grant: bool, + ) -> str | None: + if profile.status == "deactivated": + return "actor_deactivated_terminal" + if ( + request.operation is AuthorityOperation.ACTOR_IDENTITY_LINK_REVOKE + and link.status == "revoked" + ): + return "identity_link_already_revoked" + if ( + request.operation is AuthorityOperation.ACTOR_IDENTITY_LINK_REACTIVATE + and link.status != "revoked" + ): + return "identity_link_not_revoked" + loses_effective_admin = ( + request.operation is AuthorityOperation.ACTOR_IDENTITY_LINK_REVOKE + and profile.actor_kind == "human" + and profile.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( + link: ActorIdentityLink, + request: IdentityLinkLifecycleRequest, + actor_profile_id: UUID, + reason: str, + ) -> None: + actor_id = str(actor_profile_id) + if request.operation is AuthorityOperation.ACTOR_IDENTITY_LINK_REVOKE: + link.status = "revoked" + link.revoked_by = actor_id + link.revoked_at = func.clock_timestamp() + link.revoked_reason = reason + else: + link.status = "active" + link.revoked_by = None + link.revoked_at = None + link.revoked_reason = None + link.reactivated_by = actor_id + link.reactivated_at = func.clock_timestamp() + link.reactivation_reason = reason + + def _decision_matches( decision: AuthorizationDecision, request: ActorLifecycleRequest, @@ -312,3 +564,29 @@ def _decision_matches( and decision.matched_scope_project_id is None and decision.revalidated ) + + +def _identity_link_decision_matches( + decision: AuthorizationDecision, + request: IdentityLinkLifecycleRequest, + *, + existing: bool, +) -> bool: + resource = ActorIdentityLinkLifecycleResourceContext( + resource_type="actor_identity_link", + resource_id=request.identity_link_id, + transition=_LINK_TRANSITION[request.operation], + existing_idempotency_record=existing, + ) + return ( + decision.allowed + and decision.action_id is _LINK_ACTION[request.operation] + and decision.permission_id is _LINK_PERMISSION[request.operation] + and decision.resource_type == "actor_identity_link" + and decision.resource_id == request.identity_link_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 1a99c98b6..a8c4e8c6b 100644 --- a/backend/app/modules/authorization/repository.py +++ b/backend/app/modules/authorization/repository.py @@ -221,6 +221,49 @@ async def lock_actor_lifecycle_target( ) return link, profile, grant + async def lock_identity_link_lifecycle_target( + self, + identity_link_id: UUID, + ) -> tuple[ActorIdentityLink, ActorProfile, AdminRoleGrant | None] | None: + """Resolve one link owner, then lock profile, link, and exact grant.""" + actor_profile_id = await self._session.scalar( + select(ActorIdentityLink.actor_profile_id).where( + ActorIdentityLink.id == str(identity_link_id) + ) + ) + if actor_profile_id is None: + return None + profile = await self._session.scalar( + select(ActorProfile) + .where(ActorProfile.id == actor_profile_id) + .with_for_update() + .execution_options(populate_existing=True) + ) + if profile is None: + raise RuntimeError("identity link lifecycle target is missing its actor profile") + link = await self._session.scalar( + select(ActorIdentityLink) + .where( + ActorIdentityLink.id == str(identity_link_id), + ActorIdentityLink.actor_profile_id == actor_profile_id, + ) + .with_for_update() + .execution_options(populate_existing=True) + ) + if link is None: + raise RuntimeError("identity link lifecycle target changed owner") + grant = await self._session.scalar( + select(AdminRoleGrant) + .where( + AdminRoleGrant.target_actor_profile_id == 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 0b4551e51..c930969ee 100644 --- a/backend/app/modules/authorization/router.py +++ b/backend/app/modules/authorization/router.py @@ -39,15 +39,20 @@ from app.modules.authorization.lifecycle_schemas import ( ActorLifecycleBody, ActorLifecycleMutationResponse, + IdentityLinkLifecycleMutationResponse, ) from app.modules.authorization.lifecycle_service import ( ActorLifecycleConflict, ActorLifecycleRequest, ActorLifecycleService, + IdentityLinkLifecycleConflict, + IdentityLinkLifecycleRequest, + IdentityLinkLifecycleService, ) from app.modules.authorization.runtime import ( ActorAdminRoleGrantHistoryResourceContext, ActorIdentityLinkAdminReadResourceContext, + ActorIdentityLinkLifecycleResourceContext, ActorProfileAdminReadResourceContext, ActorProfileLifecycleResourceContext, AdminRoleDefinitionsResourceContext, @@ -64,6 +69,8 @@ ActorProfileDeactivateRequest, ActorProfileReactivateRequest, ActorProfileSuspendRequest, + ActorIdentityLinkReactivateRequest, + ActorIdentityLinkRevokeRequest, AdminScope, AuthorityOperation, ServiceActorCreateRequest, @@ -154,6 +161,23 @@ def _lifecycle_request( return request_type(**values) +def _identity_link_lifecycle_request( + identity_link_id: UUID, + reason: str, + operation: AuthorityOperation, +) -> IdentityLinkLifecycleRequest: + values = { + "operation": operation, + "identity_link_id": identity_link_id, + "reason_digest": derive_reason_digest(reason), + } + request_type = { + AuthorityOperation.ACTOR_IDENTITY_LINK_REVOKE: ActorIdentityLinkRevokeRequest, + AuthorityOperation.ACTOR_IDENTITY_LINK_REACTIVATE: ActorIdentityLinkReactivateRequest, + }[operation] + return request_type(**values) + + def _service_actor_request( payload: ServiceActorProvisionBody, issuer: str, @@ -290,6 +314,99 @@ async def _mutate_actor_lifecycle( raise service_unavailable_error() from exc +async def _mutate_identity_link_lifecycle( + *, + identity_link_id: UUID, + payload: ActorLifecycleBody, + idempotency_key: UUID, + resolved: ResolvedActor, + authorization: AuthorizationService, + session: AsyncSession, + operation: AuthorityOperation, + action: ActionId, + transition: Literal["revoke", "reactivate"], +) -> IdentityLinkLifecycleMutationResponse: + canonical = _identity_link_lifecycle_request(identity_link_id, payload.reason, operation) + caller_id = UUID(resolved.profile.id) + service = IdentityLinkLifecycleService(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, + ActorIdentityLinkLifecycleResourceContext( + resource_type="actor_identity_link", + resource_id=identity_link_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 = IdentityLinkLifecycleMutationResponse( + resource_type="actor_identity_link", + 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 IdentityLinkLifecycleConflict as exc: + await session.rollback() + await _database_call( + session, + service.record_conflict( + actor_profile_id=caller_id, + target_actor_profile_id=exc.actor_profile_id, + request=canonical, + decision=decision, + code=exc.code, + ), + ) + await _commit_or_unavailable(session) + messages = { + "identity_link_already_revoked": "Identity link is already revoked", + "identity_link_not_revoked": "Identity link is not revoked", + "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, @@ -513,6 +630,64 @@ async def deactivate_actor_profile( ) +@router.post( + "/actor-identity-links/{identity_link_id}/revoke", + response_model=IdentityLinkLifecycleMutationResponse, + dependencies=[Depends(enforce_admin_mutation_rate_limit)], + responses=_LIFECYCLE_CONFLICT_RESPONSE, + openapi_extra={"x-workstream-action-id": ActionId.ACTOR_IDENTITY_LINK_REVOKE.value}, +) +async def revoke_actor_identity_link( + identity_link_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)], +) -> IdentityLinkLifecycleMutationResponse: + return await _mutate_identity_link_lifecycle( + identity_link_id=identity_link_id, + payload=payload, + idempotency_key=idempotency_key, + resolved=resolved, + authorization=authorization, + session=session, + operation=AuthorityOperation.ACTOR_IDENTITY_LINK_REVOKE, + action=ActionId.ACTOR_IDENTITY_LINK_REVOKE, + transition="revoke", + ) + + +@router.post( + "/actor-identity-links/{identity_link_id}/reactivate", + response_model=IdentityLinkLifecycleMutationResponse, + dependencies=[Depends(enforce_admin_mutation_rate_limit)], + responses=_LIFECYCLE_CONFLICT_RESPONSE, + openapi_extra={ + "x-workstream-action-id": ActionId.ACTOR_IDENTITY_LINK_REACTIVATE.value + }, +) +async def reactivate_actor_identity_link( + identity_link_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)], +) -> IdentityLinkLifecycleMutationResponse: + return await _mutate_identity_link_lifecycle( + identity_link_id=identity_link_id, + payload=payload, + idempotency_key=idempotency_key, + resolved=resolved, + authorization=authorization, + session=session, + operation=AuthorityOperation.ACTOR_IDENTITY_LINK_REACTIVATE, + action=ActionId.ACTOR_IDENTITY_LINK_REACTIVATE, + transition="reactivate", + ) + + @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 1d6047172..6a3cde2a5 100644 --- a/backend/app/modules/authorization/runtime.py +++ b/backend/app/modules/authorization/runtime.py @@ -97,6 +97,16 @@ class ActorProfileLifecycleResourceContext(BaseModel): existing_idempotency_record: bool = False +class ActorIdentityLinkLifecycleResourceContext(BaseModel): + """Server-composed target for one exact identity-link transition.""" + + model_config = _STRICT_FROZEN + resource_type: Literal["actor_identity_link"] + resource_id: UUID + transition: Literal["revoke", "reactivate"] + existing_idempotency_record: bool = False + + class SystemResourceContext(BaseModel): """Non-authoritative placeholder for later fixed system actions.""" @@ -205,6 +215,7 @@ class ServiceActorProvisionResourceContext(BaseModel): | ActorProfileAdminReadResourceContext | ActorIdentityLinkAdminReadResourceContext | ActorProfileLifecycleResourceContext + | ActorIdentityLinkLifecycleResourceContext | SystemResourceContext | PermissionCatalogueResourceContext | AdminRoleDefinitionsResourceContext @@ -260,6 +271,7 @@ class AuthorizationDecision(BaseModel): denial_code: AuthorizationDenialCode | None resource_type: Literal[ "actor_profile", + "actor_identity_link", "system", "permission_catalogue", "admin_role_definitions", diff --git a/backend/app/modules/authorization/service.py b/backend/app/modules/authorization/service.py index e7f5345ba..3f0c0fd81 100644 --- a/backend/app/modules/authorization/service.py +++ b/backend/app/modules/authorization/service.py @@ -113,14 +113,14 @@ class _OperationEvidence: ), AuthorityOperation.ACTOR_IDENTITY_LINK_REVOKE: _OperationEvidence( "actor.identity_link.revoke", - None, + ActionId.ACTOR_IDENTITY_LINK_REVOKE, AuthorityResourceType.ACTOR_IDENTITY_LINK, 200, (AuthorityEventType.ACTOR_IDENTITY_LINK_REVOKED,), ), AuthorityOperation.ACTOR_IDENTITY_LINK_REACTIVATE: _OperationEvidence( "actor.identity_link.reactivate", - None, + ActionId.ACTOR_IDENTITY_LINK_REACTIVATE, AuthorityResourceType.ACTOR_IDENTITY_LINK, 200, (AuthorityEventType.ACTOR_IDENTITY_LINK_REACTIVATED,), diff --git a/backend/scripts/api_contract_e2e.py b/backend/scripts/api_contract_e2e.py index 8c52cb5e3..284ba0a42 100644 --- a/backend/scripts/api_contract_e2e.py +++ b/backend/scripts/api_contract_e2e.py @@ -1055,6 +1055,65 @@ async def exercise_api_contract(base_url: str, env: dict[str, str]) -> None: json={"reason": "Real HTTP service profile correction"}, ) assert reactivated_service.status_code == 200, reactivated_service.text + + service_link_id = service_admin_link["identity_link_id"] + link_lifecycle_key = str(uuid4()) + link_lifecycle_reason = "Real HTTP service identity-link lifecycle proof" + revoked_service_link = await client.post( + f"/api/v1/actor-identity-links/{service_link_id}/revoke", + headers=auth_headers(manager_token) + | {"Idempotency-Key": link_lifecycle_key}, + json={"reason": link_lifecycle_reason}, + ) + assert revoked_service_link.status_code == 200, revoked_service_link.text + assert revoked_service_link.json() == { + "resource_type": "actor_identity_link", + "resource_id": service_link_id, + "version": None, + "http_status": 200, + } + assert link_lifecycle_reason not in revoked_service_link.text + replayed_service_link = await client.post( + f"/api/v1/actor-identity-links/{service_link_id}/revoke", + headers=auth_headers(manager_token) + | {"Idempotency-Key": link_lifecycle_key}, + json={"reason": link_lifecycle_reason}, + ) + assert replayed_service_link.status_code == 200, replayed_service_link.text + assert replayed_service_link.json() == revoked_service_link.json() + mismatched_service_link = await client.post( + f"/api/v1/actor-identity-links/{service_link_id}/revoke", + headers=auth_headers(manager_token) + | {"Idempotency-Key": link_lifecycle_key}, + json={"reason": "Different link lifecycle request"}, + ) + assert mismatched_service_link.status_code == 409, mismatched_service_link.text + assert mismatched_service_link.json()["error"]["code"] == "idempotency_mismatch" + conflicting_service_link = await client.post( + f"/api/v1/actor-identity-links/{service_link_id}/revoke", + headers=auth_headers(manager_token) | {"Idempotency-Key": str(uuid4())}, + json={"reason": "Conflicting link lifecycle request"}, + ) + assert conflicting_service_link.status_code == 409, conflicting_service_link.text + assert ( + conflicting_service_link.json()["error"]["code"] + == "identity_link_already_revoked" + ) + repaired_service_link = await client.post( + f"/api/v1/actor-identity-links/{service_link_id}/reactivate", + headers=auth_headers(manager_token) | {"Idempotency-Key": str(uuid4())}, + json={"reason": "Real HTTP service identity-link correction"}, + ) + assert repaired_service_link.status_code == 200, repaired_service_link.text + repaired_service_link_view = await request_json( + client, + "GET", + f"/api/v1/actors/{service_actor_id}/identity-links", + manager_token, + ) + assert repaired_service_link_view["status"] == "active" + assert repaired_service_link_view["last_verified_at"] is None + deactivated_service = await client.post( f"/api/v1/actors/{service_actor_id}/deactivate", headers=auth_headers(manager_token) | {"Idempotency-Key": str(uuid4())}, diff --git a/backend/tests/test_api_controls.py b/backend/tests/test_api_controls.py index f7cc958c4..ee9463dc3 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) == 60 + assert len(route_inventory) == 62 assert sha256("\n".join(route_inventory).encode()).hexdigest() == ( - "e5d6200a4403e6de55ed15300cba3370e8f0e0f20c0041bb4e4781be2bc1206c" + "a0b23568d205adad690e6a28b97830f5e5b5b5cd04a1b1ec56ef2b8aee47cc37" ) - assert len(protected_inventory) == 58 + assert len(protected_inventory) == 60 assert sha256("\n".join(protected_inventory).encode()).hexdigest() == ( - "afeda1d10510c4280a810f994eafbdc0996d5fc4cbd944d27a6ced032cb17435" + "6da64ee69f2eb5a0a75156fbb70c93219646db1e9d1d28ef1a88f09074bf4ace" ) assert set(schema["paths"]["/health"]["get"]["responses"]) == {"200", "400", "500"} assert {"401", "403", "503"} <= set( @@ -478,6 +478,12 @@ def test_openapi_documents_request_error_and_response_context() -> None: "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/actor-identity-links/{identity_link_id}/revoke": ( + "actor.identity_link.revoke" + ), + "POST /api/v1/actor-identity-links/{identity_link_id}/reactivate": ( + "actor.identity_link.reactivate" + ), "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 709c46ff6..de06ef20d 100644 --- a/backend/tests/test_audit.py +++ b/backend/tests/test_audit.py @@ -173,12 +173,14 @@ 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_PROFILE_SUSPEND, - ActionId.ACTOR_PROFILE_REACTIVATE, - ActionId.ACTOR_PROFILE_DEACTIVATE, - } + ActionId.ACTOR_IDENTITY_LINK_READ, + ActionId.ACTOR_SERVICE_PROVISION, + ActionId.ACTOR_PROFILE_SUSPEND, + ActionId.ACTOR_PROFILE_REACTIVATE, + ActionId.ACTOR_PROFILE_DEACTIVATE, + ActionId.ACTOR_IDENTITY_LINK_REVOKE, + ActionId.ACTOR_IDENTITY_LINK_REACTIVATE, + } 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 6557010bd..2ce825e5c 100644 --- a/backend/tests/test_auth.py +++ b/backend/tests/test_auth.py @@ -5300,6 +5300,543 @@ async def fail_lifecycle_success(service, event): ) == 0 +async def test_actor_identity_link_lifecycle_real_postgres_matrix( + auth_database_env: str, + rsa_signing_material: tuple[rsa.RSAPrivateKey, dict[str, Any]], + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Prove link state, atomic failure, replay, owner, grant, and privacy behavior.""" + private_key, jwk = rsa_signing_material + settings = production_verifier_settings(database_url=auth_database_env).model_copy( + update={"api_admin_mutation_rate_limit": 1_000} + ) + app = create_app(settings) + app.state.auth_verifier = FlowAuthVerifier(settings, jwks_transport=jwks_transport(jwk)) + names = ("admin", "target", "ordinary", "failure") + tokens = { + name: issue_asymmetric_token( + private_key, + claims={ + "sub": f"auth09d-b-{name}", + "jti": f"auth09d-b-{name}-token", + "email": f"private-auth09d-b-{name}@example.test", + }, + ) + for name in names + } + headers = { + name: {"Authorization": f"Bearer {token}"} for name, token in tokens.items() + } + + async def actor_link_state(actor_id: UUID) -> tuple: + async with db_session.get_session_factory()() as session: + return tuple( + ( + await session.execute( + text( + "select p.status,p.last_seen_at,l.id,l.status,l.revoked_by," + "l.revoked_at,l.revoked_reason,l.reactivated_by,l.reactivated_at," + "l.reactivation_reason,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 def idempotency_count(key: str) -> int: + async with db_session.get_session_factory()() as session: + return int( + await session.scalar( + text( + "select count(*) from authority_idempotency_records " + "where idempotency_key=:key" + ), + {"key": key}, + ) + or 0 + ) + + async with AsyncClient( + transport=ASGITransport(app=app), + base_url="http://testserver", + ) as client: + profiles = { + name: UUID( + (await client.get("/api/v1/actors/me", headers=headers[name])).json()[ + "actor_profile_id" + ] + ) + for name in names + } + assert (await run_admin_bootstrap(profiles["admin"], execute=True))[0] == 0 + states = {name: await actor_link_state(actor_id) for name, actor_id in profiles.items()} + links = {name: UUID(state[2]) for name, state in states.items()} + + async def atomic_state(target: UUID) -> tuple: + async with db_session.get_session_factory()() as session: + return ( + await actor_link_state(target), + await actor_link_state(profiles["admin"]), + tuple( + await session.scalars( + text( + "select to_jsonb(g)::text from admin_role_grants g " + "order by g.id" + ) + ) + ), + tuple( + await session.scalars( + text( + "select to_jsonb(e)::text from audit_events e order by e.id" + ) + ) + ), + tuple( + await session.scalars( + text( + "select to_jsonb(i)::text from " + "authority_idempotency_records i order by i.id" + ) + ) + ), + ) + + async def link_authorization_events(resource_id: UUID) -> tuple[tuple, ...]: + async with db_session.get_session_factory()() as session: + return tuple( + ( + await session.execute( + select( + AuditEvent.event_type, + AuditEvent.action_id, + AuditEvent.permission_id, + AuditEvent.resource_type, + AuditEvent.resource_id, + AuditEvent.denial_code, + AuditEvent.matched_grant_id, + ) + .where(AuditEvent.resource_id == str(resource_id)) + .order_by(AuditEvent.created_at, AuditEvent.id) + ) + ).all() + ) + + failure_target = profiles["failure"] + failure_link = links["failure"] + original_reserve = AuthorityIdempotencyRepository.reserve + original_add_event = AuditService.add_authority_event + original_target_lookup = ( + AdminAuthorizationRepository.lock_identity_link_lifecycle_target + ) + original_flush = AsyncSession.flush + original_touch = ActorService.touch_after_authorization + original_complete = AuthorityIdempotencyRepository.complete + original_commit = AsyncSession.commit + + async def run_failure_matrix(operation: str) -> str: + target_status = "revoked" if operation == "revoke" else "active" + event_type = ( + AuthorityEventType.ACTOR_IDENTITY_LINK_REVOKED + if operation == "revoke" + else AuthorityEventType.ACTOR_IDENTITY_LINK_REACTIVATED + ) + + async def fail_reservation(*_args, **_kwargs): + raise SQLAlchemyError("forced link lifecycle reservation failure") + + async def fail_authorization_evidence(service, event): + if event.event_type is AuthorityEventType.SENSITIVE_AUTHORIZATION_ALLOWED: + raise SQLAlchemyError("forced link lifecycle authorization evidence failure") + return await original_add_event(service, event) + + async def fail_target_lookup(*_args, **_kwargs): + raise SQLAlchemyError("forced link lifecycle target lookup failure") + + async def fail_state_flush(session, *args, **kwargs): + if any( + isinstance(value, ActorIdentityLink) + and value.id == str(failure_link) + and value.status == target_status + for value in session.dirty + ): + raise SQLAlchemyError("forced link lifecycle state flush failure") + return await original_flush(session, *args, **kwargs) + + async def fail_caller_touch(*_args, **_kwargs): + raise SQLAlchemyError("forced link lifecycle caller touch failure") + + async def fail_success_evidence(service, event): + if event.event_type is event_type: + raise SQLAlchemyError("forced link 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 link lifecycle invalidation failure") + return await original_add_event(service, event) + + async def fail_completion(*_args, **_kwargs): + raise SQLAlchemyError("forced link lifecycle completion failure") + + async def fail_commit(*_args, **_kwargs): + raise SQLAlchemyError("forced link lifecycle commit failure") + + stages = ( + (AuthorityIdempotencyRepository, "reserve", original_reserve, fail_reservation), + (AuditService, "add_authority_event", original_add_event, fail_authorization_evidence), + ( + AdminAuthorizationRepository, + "lock_identity_link_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), + ) + final_key = "" + for owner, attribute, original, failure in stages: + before = await atomic_state(failure_target) + final_key = str(uuid4()) + monkeypatch.setattr(owner, attribute, failure) + try: + response = await client.post( + f"/api/v1/actor-identity-links/{failure_link}/{operation}", + headers={**headers["admin"], "Idempotency-Key": final_key}, + json={"reason": f"Atomic {operation} failure at {attribute}"}, + ) + finally: + monkeypatch.setattr(owner, attribute, original) + assert response.status_code == 503, response.text + assert response.json()["error"] | {"correlation_id": None} == { + "code": "service_unavailable", + "message": "Service unavailable", + "details": {}, + "correlation_id": None, + "retryable": True, + } + UUID(response.json()["error"]["correlation_id"]) + assert await atomic_state(failure_target) == before + assert await idempotency_count(final_key) == 0 + return final_key + + revoke_retry_key = await run_failure_matrix("revoke") + revoke_after_failures = await client.post( + f"/api/v1/actor-identity-links/{failure_link}/revoke", + headers={**headers["admin"], "Idempotency-Key": revoke_retry_key}, + json={"reason": "Atomic revoke failure at commit"}, + ) + assert revoke_after_failures.status_code == 200, revoke_after_failures.text + reactivate_retry_key = await run_failure_matrix("reactivate") + reactivate_after_failures = await client.post( + f"/api/v1/actor-identity-links/{failure_link}/reactivate", + headers={**headers["admin"], "Idempotency-Key": reactivate_retry_key}, + json={"reason": "Atomic reactivate failure at commit"}, + ) + assert reactivate_after_failures.status_code == 200, reactivate_after_failures.text + + missing_link = uuid4() + private_missing_key = str(uuid4()) + private_missing = await client.post( + f"/api/v1/actor-identity-links/{missing_link}/revoke", + headers={**headers["ordinary"], "Idempotency-Key": private_missing_key}, + json={"reason": "must not disclose missing link"}, + ) + assert private_missing.status_code == 403 + assert private_missing.json()["error"]["code"] == "permission_not_granted" + assert await idempotency_count(private_missing_key) == 0 + authorized_missing_key = str(uuid4()) + missing_caller_before = await actor_link_state(profiles["admin"]) + missing_events_before = await link_authorization_events(missing_link) + authorized_missing = await client.post( + f"/api/v1/actor-identity-links/{missing_link}/revoke", + headers={**headers["admin"], "Idempotency-Key": authorized_missing_key}, + json={"reason": "authorized missing link"}, + ) + assert authorized_missing.status_code == 404 + assert authorized_missing.json()["error"]["code"] == "resource_not_found" + assert await idempotency_count(authorized_missing_key) == 0 + assert await actor_link_state(profiles["admin"]) == missing_caller_before + missing_events_after = await link_authorization_events(missing_link) + assert missing_events_after[: len(missing_events_before)] == missing_events_before + assert missing_events_after[len(missing_events_before) :] == ( + ( + "SensitiveAuthorizationDenied", + "actor.identity_link.revoke", + "actor.identity_link.revoke", + "actor_identity_link", + str(missing_link), + "resource_not_found", + None, + ), + ) + self_key = str(uuid4()) + self_revoke = await client.post( + f"/api/v1/actor-identity-links/{links['admin']}/revoke", + headers={**headers["admin"], "Idempotency-Key": self_key}, + json={"reason": "self link revocation must fail"}, + ) + assert self_revoke.status_code == 403 + assert self_revoke.json()["error"]["code"] == "resource_guard_denied" + assert await idempotency_count(self_key) == 0 + + target_before = await actor_link_state(profiles["target"]) + normalized_reason = " Investigate exact identity link " + revoke_key = str(uuid4()) + revoked = await client.post( + f"/api/v1/actor-identity-links/{links['target']}/revoke", + headers={**headers["admin"], "Idempotency-Key": revoke_key}, + json={"reason": normalized_reason}, + ) + assert revoked.status_code == 200, revoked.text + assert revoked.json() == { + "resource_type": "actor_identity_link", + "resource_id": str(links["target"]), + "version": None, + "http_status": 200, + } + assert normalized_reason.strip() not in revoked.text + target_revoked = await actor_link_state(profiles["target"]) + assert target_revoked[0] == "active" + assert target_revoked[3] == "revoked" + assert target_revoked[4] == str(profiles["admin"]) + assert target_revoked[6] == normalized_reason.strip() + assert target_revoked[1] == target_before[1] + assert target_revoked[10] == target_before[10] + + caller_before_mismatch = await actor_link_state(profiles["admin"]) + mismatch = await client.post( + f"/api/v1/actor-identity-links/{links['target']}/revoke", + headers={**headers["admin"], "Idempotency-Key": revoke_key}, + json={"reason": "changed link lifecycle reason"}, + ) + assert mismatch.status_code == 409 + assert mismatch.json()["error"]["code"] == "idempotency_mismatch" + assert await actor_link_state(profiles["admin"]) == caller_before_mismatch + assert await actor_link_state(profiles["target"]) == target_revoked + conflict_key = str(uuid4()) + caller_before_conflict = await actor_link_state(profiles["admin"]) + conflict = await client.post( + f"/api/v1/actor-identity-links/{links['target']}/revoke", + headers={**headers["admin"], "Idempotency-Key": conflict_key}, + json={"reason": "second link revocation"}, + ) + assert conflict.status_code == 409 + assert conflict.json()["error"]["code"] == "identity_link_already_revoked" + assert await idempotency_count(conflict_key) == 0 + assert await actor_link_state(profiles["admin"]) == caller_before_conflict + assert await actor_link_state(profiles["target"]) == target_revoked + + reactivated = await client.post( + f"/api/v1/actor-identity-links/{links['target']}/reactivate", + headers={**headers["admin"], "Idempotency-Key": revoke_key}, + json={"reason": "Repair exact identity link"}, + ) + assert reactivated.status_code == 200, reactivated.text + target_reactivated = await actor_link_state(profiles["target"]) + assert target_reactivated[3] == "active" + assert target_reactivated[4:7] == (None, None, None) + assert target_reactivated[7] == str(profiles["admin"]) + assert target_reactivated[9] == "Repair exact identity link" + assert target_reactivated[1] == target_before[1] + assert target_reactivated[10] == target_before[10] + + caller_before_replay = await actor_link_state(profiles["admin"]) + replay = await client.post( + f"/api/v1/actor-identity-links/{links['target']}/revoke", + headers={**headers["admin"], "Idempotency-Key": revoke_key}, + json={"reason": normalized_reason}, + ) + assert replay.status_code == 200 + assert replay.json() == revoked.json() + assert await actor_link_state(profiles["target"]) == target_reactivated + caller_after_replay = await actor_link_state(profiles["admin"]) + assert caller_after_replay[1] > caller_before_replay[1] + assert caller_after_replay[10] > caller_before_replay[10] + + reused_conflict = await client.post( + f"/api/v1/actor-identity-links/{links['target']}/revoke", + headers={**headers["admin"], "Idempotency-Key": conflict_key}, + json={"reason": "second link revocation"}, + ) + assert reused_conflict.status_code == 200, reused_conflict.text + + repair_for_suspension = await client.post( + f"/api/v1/actor-identity-links/{links['target']}/reactivate", + headers={**headers["admin"], "Idempotency-Key": str(uuid4())}, + json={"reason": "Prepare suspended owner proof"}, + ) + assert repair_for_suspension.status_code == 200, repair_for_suspension.text + suspend_owner = await client.post( + f"/api/v1/actors/{profiles['target']}/suspend", + headers={**headers["admin"], "Idempotency-Key": str(uuid4())}, + json={"reason": "Suspend owner while repairing link"}, + ) + assert suspend_owner.status_code == 200, suspend_owner.text + suspended_revoke = await client.post( + f"/api/v1/actor-identity-links/{links['target']}/revoke", + headers={**headers["admin"], "Idempotency-Key": str(uuid4())}, + json={"reason": "Revoke suspended owner link"}, + ) + assert suspended_revoke.status_code == 200, suspended_revoke.text + suspended_reactivate = await client.post( + f"/api/v1/actor-identity-links/{links['target']}/reactivate", + headers={**headers["admin"], "Idempotency-Key": str(uuid4())}, + json={"reason": "Repair suspended owner link"}, + ) + assert suspended_reactivate.status_code == 200, suspended_reactivate.text + owner_still_blocked = await client.patch( + "/api/v1/actors/me", + headers=headers["target"], + json={"display_name": "Suspended owner cannot mutate"}, + ) + assert owner_still_blocked.status_code == 403 + assert owner_still_blocked.json()["error"]["code"] == "actor_suspended" + restore_owner = await client.post( + f"/api/v1/actors/{profiles['target']}/reactivate", + headers={**headers["admin"], "Idempotency-Key": str(uuid4())}, + json={"reason": "Restore suspended owner"}, + ) + assert restore_owner.status_code == 200, restore_owner.text + + 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": "Link reactivation grant non-restoration proof", + }, + ) + assert delegated.status_code == 201, delegated.text + ordinary_revoke = await client.post( + f"/api/v1/actor-identity-links/{links['ordinary']}/revoke", + headers={**headers["admin"], "Idempotency-Key": str(uuid4())}, + json={"reason": "Revoke delegated actor link"}, + ) + assert ordinary_revoke.status_code == 200, ordinary_revoke.text + revoke_grant = await client.post( + f"/api/v1/admin-role-grants/{delegated.json()['resource_id']}/revoke", + headers={**headers["admin"], "Idempotency-Key": str(uuid4())}, + json={"reason": "Revoke grant independently"}, + ) + assert revoke_grant.status_code == 200, revoke_grant.text + ordinary_reactivate = await client.post( + f"/api/v1/actor-identity-links/{links['ordinary']}/reactivate", + headers={**headers["admin"], "Idempotency-Key": str(uuid4())}, + json={"reason": "Reactivate without restoring grant"}, + ) + assert ordinary_reactivate.status_code == 200, ordinary_reactivate.text + async with db_session.get_session_factory()() as session: + assert ( + await session.scalar( + select(AdminRoleGrant.status).where( + AdminRoleGrant.id == UUID(delegated.json()["resource_id"]) + ) + ) + == "revoked" + ) + + provisioned_service = await client.post( + "/api/v1/service-actors", + headers={**headers["admin"], "Idempotency-Key": str(uuid4())}, + json={ + "service_identity": ServiceIdentity.ARTIFACT_SCHEDULER.value, + "subject": "auth09d-b-service-link-target", + "reason": "Provision service link lifecycle target", + }, + ) + assert provisioned_service.status_code == 201, provisioned_service.text + service_id = UUID(provisioned_service.json()["actor_profile_id"]) + service_state = await actor_link_state(service_id) + service_link = UUID(service_state[2]) + service_revoke = await client.post( + f"/api/v1/actor-identity-links/{service_link}/revoke", + headers={**headers["admin"], "Idempotency-Key": str(uuid4())}, + json={"reason": "Revoke fixed service link"}, + ) + assert service_revoke.status_code == 200, service_revoke.text + service_reactivate = await client.post( + f"/api/v1/actor-identity-links/{service_link}/reactivate", + headers={**headers["admin"], "Idempotency-Key": str(uuid4())}, + json={"reason": "Reactivate fixed service link without admission"}, + ) + assert service_reactivate.status_code == 200, service_reactivate.text + assert (await actor_link_state(service_id))[10] is None + + deactivate_target = await client.post( + f"/api/v1/actors/{profiles['target']}/deactivate", + headers={**headers["admin"], "Idempotency-Key": str(uuid4())}, + json={"reason": "Terminal owner proof"}, + ) + assert deactivate_target.status_code == 200, deactivate_target.text + terminal_key = str(uuid4()) + terminal_link = await client.post( + f"/api/v1/actor-identity-links/{links['target']}/revoke", + headers={**headers["admin"], "Idempotency-Key": terminal_key}, + json={"reason": "Terminal owner link must not change"}, + ) + assert terminal_link.status_code == 409 + assert terminal_link.json()["error"]["code"] == "actor_deactivated_terminal" + assert await idempotency_count(terminal_key) == 0 + + async with db_session.get_session_factory()() as session: + assert ( + await session.scalar( + select(func.count()) + .select_from(AuthorityIdempotencyRecord) + .where(AuthorityIdempotencyRecord.status == "pending") + ) + or 0 + ) == 0 + link_events = ( + await session.execute( + select(AuditEvent).where( + AuditEvent.event_type.in_( + ["ActorIdentityLinkRevoked", "ActorIdentityLinkReactivated"] + ) + ) + ) + ).scalars().all() + assert link_events + assert all(event.target_actor_ref for event in link_events) + evidence = json.dumps( + [ + ( + event.event_type, + event.resource_type, + event.resource_id, + event.target_actor_ref, + event.before_facts, + event.after_facts, + ) + for event in link_events + ], + default=str, + ) + for private in ( + settings.token_issuer, + *tokens.values(), + *(f"private-auth09d-b-{name}@example.test" for name in names), + normalized_reason.strip(), + derive_reason_digest(normalized_reason.strip()), + ): + assert private not in evidence + + 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) @@ -5821,6 +6358,945 @@ def lifecycle_request( } +async def test_actor_identity_link_lifecycle_real_postgres_concurrency( + auth_database_env: str, + rsa_signing_material: tuple[rsa.RSAPrivateKey, dict[str, Any]], + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Prove link and mixed-authority races with observed PostgreSQL blockers.""" + private_key, jwk = rsa_signing_material + settings = production_verifier_settings(database_url=auth_database_env).model_copy( + update={"api_admin_mutation_rate_limit": 1_000} + ) + app = create_app(settings) + app.state.auth_verifier = FlowAuthVerifier(settings, jwks_transport=jwks_transport(jwk)) + + async with AsyncClient( + transport=ASGITransport(app=app), + base_url="http://testserver", + ) as client: + actors: dict[str, tuple[UUID, UUID, dict[str, str]]] = {} + + async def create_actor(name: str) -> tuple[UUID, UUID, dict[str, str]]: + token = issue_asymmetric_token( + private_key, + claims={ + "sub": f"auth09d-b-race-{name}", + "jti": f"auth09d-b-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 + actor_id = UUID(response.json()["actor_profile_id"]) + async with db_session.get_session_factory()() as session: + link_id = UUID( + await session.scalar( + select(ActorIdentityLink.id).where( + ActorIdentityLink.actor_profile_id == str(actor_id) + ) + ) + ) + actors[name] = (actor_id, link_id, actor_headers) + return actors[name] + + bootstrap_id, _, bootstrap_headers = await create_actor("bootstrap") + bootstrap_code, bootstrap = await run_admin_bootstrap(bootstrap_id, execute=True) + assert bootstrap_code == 0 + + async def grant_admin( + target_id: UUID, + caller_headers: dict[str, str], + reason: str, + ) -> UUID: + response = await client.post( + "/api/v1/admin-role-grants", + headers={**caller_headers, "Idempotency-Key": str(uuid4())}, + json={ + "target_actor_profile_id": str(target_id), + "role": "access_administrator", + "scope_type": "system", + "scope_project_id": None, + "reason": reason, + }, + ) + assert response.status_code == 201, response.text + return UUID(response.json()["resource_id"]) + + async def link_post( + link_id: UUID, + operation: str, + caller_headers: dict[str, str], + key: str, + reason: str, + ) -> Response: + return await client.post( + f"/api/v1/actor-identity-links/{link_id}/{operation}", + headers={**caller_headers, "Idempotency-Key": key}, + json={"reason": reason}, + ) + + tracked_events = ( + "ActorIdentityLinkRevoked", + "ActorIdentityLinkReactivated", + "ActorProfileSuspended", + "AdminRoleGrantRevoked", + "AuthorityInvalidationRequested", + "SensitiveAuthorizationDenied", + ) + + async def audit_counts() -> dict[str, int]: + async with db_session.get_session_factory()() as session: + return { + event_type: count + for event_type, count in ( + await session.execute( + select(AuditEvent.event_type, func.count()) + .where(AuditEvent.event_type.in_(tracked_events)) + .group_by(AuditEvent.event_type) + ) + ).all() + } + + async def assert_audit_delta( + before: dict[str, int], + expected: dict[str, int], + ) -> None: + after = await audit_counts() + assert { + event_type: after.get(event_type, 0) - before.get(event_type, 0) + for event_type in tracked_events + } == {event_type: expected.get(event_type, 0) for event_type in tracked_events} + + async def idempotency_status(key: str) -> str | None: + async with db_session.get_session_factory()() as session: + return await session.scalar( + select(AuthorityIdempotencyRecord.status).where( + AuthorityIdempotencyRecord.idempotency_key == key + ) + ) + + async def actor_state(actor_id: UUID) -> tuple[str, datetime, datetime, str]: + async with db_session.get_session_factory()() as session: + row = ( + await session.execute( + select( + ActorProfile.status, + ActorProfile.last_seen_at, + ActorIdentityLink.last_verified_at, + ActorIdentityLink.status, + ) + .join( + ActorIdentityLink, + ActorIdentityLink.actor_profile_id == ActorProfile.id, + ) + .where(ActorProfile.id == str(actor_id)) + ) + ).one() + assert row[1] is not None and row[2] is not None + return row[0], row[1], row[2], row[3] + + async def grant_state(grant_id: UUID) -> tuple[str, str]: + async with db_session.get_session_factory()() as session: + row = ( + await session.execute( + select( + AdminRoleGrant.status, + AdminRoleGrant.target_actor_profile_id, + ).where(AdminRoleGrant.id == grant_id) + ) + ).one() + return row[0], row[1] + + async def effective_access_administrators() -> set[str]: + async with db_session.get_session_factory()() as session: + return set( + await session.scalars( + select(AdminRoleGrant.target_actor_profile_id) + .join( + ActorProfile, + ActorProfile.id == AdminRoleGrant.target_actor_profile_id, + ) + .join( + ActorIdentityLink, + ActorIdentityLink.actor_profile_id == ActorProfile.id, + ) + .where( + AdminRoleGrant.role == "access_administrator", + AdminRoleGrant.scope_type == "system", + AdminRoleGrant.status == "active", + ActorProfile.actor_kind == "human", + ActorProfile.status == "active", + ActorIdentityLink.status == "active", + ) + .distinct() + ) + ) + + async def concurrent_link_posts( + link_id: UUID, + operation: str, + keys: tuple[str, str], + ) -> tuple[Response, Response]: + original_reserve = AuthorityIdempotencyRepository.reserve + original_lock_control = AdminAuthorizationRepository.lock_control + first_blocker_acquired = asyncio.Event() + release_first = asyncio.Event() + second_entered = asyncio.Event() + waiter_name = f"auth09db-link-{uuid4().hex}" + same_key = keys[0] == keys[1] + + async def observed_reserve(self, **kwargs): + task_name = asyncio.current_task().get_name() + if not same_key: + return await original_reserve(self, **kwargs) + if task_name == "concurrent-link-first": + result = await original_reserve(self, **kwargs) + first_blocker_acquired.set() + await release_first.wait() + return result + await first_blocker_acquired.wait() + await self._session.execute( + text("select set_config('application_name', :name, true)"), + {"name": waiter_name}, + ) + second_entered.set() + return await original_reserve(self, **kwargs) + + async def observed_lock_control(self): + task_name = asyncio.current_task().get_name() + if same_key: + return await original_lock_control(self) + if task_name == "concurrent-link-first": + control = await original_lock_control(self) + first_blocker_acquired.set() + await release_first.wait() + return control + await first_blocker_acquired.wait() + await self._session.execute( + text("select set_config('application_name', :name, true)"), + {"name": waiter_name}, + ) + second_entered.set() + return await original_lock_control(self) + + monkeypatch.setattr( + AuthorityIdempotencyRepository, + "reserve", + observed_reserve, + ) + monkeypatch.setattr( + AdminAuthorizationRepository, + "lock_control", + observed_lock_control, + ) + try: + first_task = asyncio.create_task( + link_post( + link_id, + operation, + bootstrap_headers, + keys[0], + f"Concurrent {operation} exact link", + ), + name="concurrent-link-first", + ) + await asyncio.wait_for(first_blocker_acquired.wait(), timeout=20) + second_task = asyncio.create_task( + link_post( + link_id, + operation, + bootstrap_headers, + keys[1], + f"Concurrent {operation} exact link", + ), + name="concurrent-link-second", + ) + await asyncio.wait_for(second_entered.wait(), timeout=20) + await asyncio.wait_for( + _wait_for_named_database_lock(auth_database_env, waiter_name), + timeout=20, + ) + release_first.set() + first, second = await asyncio.wait_for( + asyncio.gather(first_task, second_task), + timeout=60, + ) + return first, second + finally: + release_first.set() + monkeypatch.setattr( + AuthorityIdempotencyRepository, + "reserve", + original_reserve, + ) + monkeypatch.setattr( + AdminAuthorizationRepository, + "lock_control", + original_lock_control, + ) + + expected_one_link_success = { + "AuthorityInvalidationRequested": 1, + "SensitiveAuthorizationDenied": 0, + } + for operation, initial_revoke, final_status, conflict_code, success_event in ( + ( + "revoke", + False, + "revoked", + "identity_link_already_revoked", + "ActorIdentityLinkRevoked", + ), + ( + "reactivate", + True, + "active", + "identity_link_not_revoked", + "ActorIdentityLinkReactivated", + ), + ): + for key_kind in ("same", "different"): + actor_id, link_id, _ = await create_actor(f"{operation}-{key_kind}") + if initial_revoke: + prepared = await link_post( + link_id, + "revoke", + bootstrap_headers, + str(uuid4()), + "Prepare revoked-link concurrency row", + ) + assert prepared.status_code == 200, prepared.text + before = await audit_counts() + keys = ( + (shared := str(uuid4()), shared) + if key_kind == "same" + else (str(uuid4()), str(uuid4())) + ) + responses = await concurrent_link_posts(link_id, operation, keys) + statuses = sorted(response.status_code for response in responses) + if key_kind == "same": + assert statuses == [200, 200] + assert responses[0].json() == responses[1].json() + assert await idempotency_status(keys[0]) == "committed" + denial_count = 0 + else: + assert statuses == [200, 409] + loser_index = next( + index + for index, response in enumerate(responses) + if response.status_code == 409 + ) + assert responses[loser_index].json()["error"]["code"] == conflict_code + assert await idempotency_status(keys[1 - loser_index]) == "committed" + assert await idempotency_status(keys[loser_index]) is None + denial_count = 1 + assert (await actor_state(actor_id))[3] == final_status + await assert_audit_delta( + before, + { + **expected_one_link_success, + success_event: 1, + "SensitiveAuthorizationDenied": denial_count, + }, + ) + + async def ordered_requests( + first_name: str, + requests: tuple[ + tuple[str, str, dict[str, str], dict[str, str], str], + tuple[str, str, dict[str, str], dict[str, str], str], + ], + ) -> tuple[tuple[str, Response], tuple[str, Response]]: + original_lock_control = AdminAuthorizationRepository.lock_control + first_locked = asyncio.Event() + waiter_name = f"auth09db-{uuid4().hex}" + + async def ordered_lock_control(self): + if asyncio.current_task().get_name() == first_name: + control = await original_lock_control(self) + first_locked.set() + await asyncio.wait_for( + _wait_for_named_database_lock(auth_database_env, waiter_name), + timeout=5, + ) + return control + await first_locked.wait() + await self._session.execute( + text("select set_config('application_name', :name, true)"), + {"name": waiter_name}, + ) + return await original_lock_control(self) + + monkeypatch.setattr( + AdminAuthorizationRepository, + "lock_control", + ordered_lock_control, + ) + try: + tasks = [ + asyncio.create_task( + client.post( + path, + headers={**request_headers, "Idempotency-Key": key}, + json=body, + ), + name=name, + ) + for name, 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 link_request( + name: str, + link_id: UUID, + operation: str, + caller_headers: dict[str, str] = bootstrap_headers, + ) -> tuple[str, str, dict[str, str], dict[str, str], str]: + return ( + name, + f"/api/v1/actor-identity-links/{link_id}/{operation}", + caller_headers, + {"reason": f"Ordered {operation} for {name}"}, + str(uuid4()), + ) + + ordered_rows = ( + (False, "revoke", "reactivate", [200, 200], None, "active"), + (False, "reactivate", "revoke", [409, 200], "identity_link_not_revoked", "revoked"), + (True, "reactivate", "revoke", [200, 200], None, "revoked"), + (True, "revoke", "reactivate", [409, 200], "identity_link_already_revoked", "active"), + ) + for index, (initial_revoke, first, second, statuses, error, final_status) in enumerate( + ordered_rows + ): + actor_id, link_id, _ = await create_actor(f"ordered-{index}") + if initial_revoke: + prepared = await link_post( + link_id, + "revoke", + bootstrap_headers, + str(uuid4()), + "Prepare ordered revoked-link row", + ) + assert prepared.status_code == 200, prepared.text + before = await audit_counts() + results = await ordered_requests( + f"ordered-{index}-first", + ( + link_request(f"ordered-{index}-first", link_id, first), + link_request(f"ordered-{index}-second", link_id, second), + ), + ) + assert [response.status_code for _, response in results] == statuses + if error is not None: + assert results[0][1].json()["error"]["code"] == error + assert await idempotency_status(results[0][0]) is None + expected_denials = 1 + expected_invalidation = 1 + else: + assert [await idempotency_status(key) for key, _ in results] == [ + "committed", + "committed", + ] + expected_denials = 0 + expected_invalidation = 2 + assert await idempotency_status(results[1][0]) == "committed" + assert (await actor_state(actor_id))[3] == final_status + await assert_audit_delta( + before, + { + "ActorIdentityLinkRevoked": int(error is None or second == "revoke"), + "ActorIdentityLinkReactivated": int( + error is None or second == "reactivate" + ), + "AuthorityInvalidationRequested": expected_invalidation, + "SensitiveAuthorizationDenied": expected_denials, + }, + ) + + custodian = (bootstrap_id, bootstrap_headers) + + async def two_admins( + name: str, + ) -> tuple[ + tuple[UUID, UUID, dict[str, str], UUID], + tuple[UUID, UUID, dict[str, str], UUID], + ]: + nonlocal custodian + a_id, a_link, a_headers = await create_actor(f"{name}-a") + b_id, b_link, b_headers = await create_actor(f"{name}-b") + a_grant = await grant_admin(a_id, custodian[1], f"Grant {name} administrator A") + b_grant = await grant_admin(b_id, custodian[1], f"Grant {name} administrator B") + disabled = await client.post( + f"/api/v1/actors/{custodian[0]}/suspend", + headers={**a_headers, "Idempotency-Key": str(uuid4())}, + json={"reason": f"Leave exactly two administrators for {name}"}, + ) + assert disabled.status_code == 200, disabled.text + return (a_id, a_link, a_headers, a_grant), ( + b_id, + b_link, + b_headers, + b_grant, + ) + + async def assert_mixed_row( + first_request: tuple[str, str, dict[str, str], dict[str, str], str], + second_request: tuple[str, str, dict[str, str], dict[str, str], str], + denial_code: str, + success_event: str, + ) -> tuple[str, str]: + before = await audit_counts() + results = await ordered_requests(first_request[0], (first_request, second_request)) + assert [response.status_code for _, response in results] == [200, 403] + assert results[1][1].json()["error"]["code"] == denial_code + assert await idempotency_status(results[0][0]) == "committed" + assert await idempotency_status(results[1][0]) is None + await assert_audit_delta( + before, + { + success_event: 1, + "AuthorityInvalidationRequested": 1, + "SensitiveAuthorizationDenied": 1, + }, + ) + return results[0][0], results[1][0] + + a, b = await two_admins("profile-first") + await assert_mixed_row( + ( + "profile-first-a", + f"/api/v1/actors/{b[0]}/suspend", + a[2], + {"reason": "Profile loss before link loss"}, + str(uuid4()), + ), + link_request("profile-first-b", a[1], "revoke", b[2]), + "actor_suspended", + "ActorProfileSuspended", + ) + assert (await actor_state(a[0]))[0::3] == ("active", "active") + assert (await actor_state(b[0]))[0::3] == ("suspended", "active") + assert await grant_state(a[3]) == ("active", str(a[0])) + assert await grant_state(b[3]) == ("active", str(b[0])) + assert await effective_access_administrators() == {str(a[0])} + custodian = (a[0], a[2]) + + a, b = await two_admins("link-first") + await assert_mixed_row( + link_request("link-first-b", a[1], "revoke", b[2]), + ( + "link-first-a", + f"/api/v1/actors/{b[0]}/suspend", + a[2], + {"reason": "Profile loss after link loss"}, + str(uuid4()), + ), + "identity_link_revoked", + "ActorIdentityLinkRevoked", + ) + assert (await actor_state(a[0]))[0::3] == ("active", "revoked") + assert (await actor_state(b[0]))[0::3] == ("active", "active") + assert await grant_state(a[3]) == ("active", str(a[0])) + assert await grant_state(b[3]) == ("active", str(b[0])) + assert await effective_access_administrators() == {str(b[0])} + custodian = (b[0], b[2]) + + a, b = await two_admins("link-grant") + await assert_mixed_row( + link_request("link-grant-a", b[1], "revoke", a[2]), + ( + "link-grant-b", + f"/api/v1/admin-role-grants/{a[3]}/revoke", + b[2], + {"reason": "Grant loss after link loss"}, + str(uuid4()), + ), + "identity_link_revoked", + "ActorIdentityLinkRevoked", + ) + assert (await actor_state(a[0]))[0::3] == ("active", "active") + assert (await actor_state(b[0]))[0::3] == ("active", "revoked") + assert await grant_state(a[3]) == ("active", str(a[0])) + assert await grant_state(b[3]) == ("active", str(b[0])) + assert await effective_access_administrators() == {str(a[0])} + custodian = (a[0], a[2]) + + a, b = await two_admins("grant-link") + await assert_mixed_row( + ( + "grant-link-b", + f"/api/v1/admin-role-grants/{a[3]}/revoke", + b[2], + {"reason": "Grant loss before link loss"}, + str(uuid4()), + ), + link_request("grant-link-a", b[1], "revoke", a[2]), + "permission_not_granted", + "AdminRoleGrantRevoked", + ) + assert (await actor_state(a[0]))[0::3] == ("active", "active") + assert (await actor_state(b[0]))[0::3] == ("active", "active") + assert await grant_state(a[3]) == ("revoked", str(a[0])) + assert await grant_state(b[3]) == ("active", str(b[0])) + assert await effective_access_administrators() == {str(b[0])} + custodian = (b[0], b[2]) + + a_id, _, a_headers = await create_actor("three-a") + b_id, _, b_headers = await create_actor("three-b") + c_id, c_link, c_headers = await create_actor("three-c") + a_grant = await grant_admin(a_id, custodian[1], "Grant three-way administrator A") + b_grant = await grant_admin(b_id, custodian[1], "Grant three-way administrator B") + c_grant = await grant_admin(c_id, custodian[1], "Grant three-way administrator C") + remove_custodian = await client.post( + f"/api/v1/actors/{custodian[0]}/suspend", + headers={**a_headers, "Idempotency-Key": str(uuid4())}, + json={"reason": "Leave exactly three administrators"}, + ) + assert remove_custodian.status_code == 200, remove_custodian.text + original_lock_control = AdminAuthorizationRepository.lock_control + first_locked = asyncio.Event() + release_first = asyncio.Event() + second_entered = asyncio.Event() + third_entered = asyncio.Event() + second_name = f"auth09db-three-second-{uuid4().hex}" + third_name = f"auth09db-three-third-{uuid4().hex}" + + async def three_way_lock_control(self): + task_name = asyncio.current_task().get_name() + if task_name == "three-profile-first": + control = await original_lock_control(self) + first_locked.set() + await release_first.wait() + return control + await first_locked.wait() + application_name = ( + second_name if task_name == "three-grant-second" else third_name + ) + await self._session.execute( + text("select set_config('application_name', :name, true)"), + {"name": application_name}, + ) + (second_entered if task_name == "three-grant-second" else third_entered).set() + return await original_lock_control(self) + + monkeypatch.setattr( + AdminAuthorizationRepository, + "lock_control", + three_way_lock_control, + ) + three_before = await audit_counts() + three_keys = (str(uuid4()), str(uuid4()), str(uuid4())) + try: + first_task = asyncio.create_task( + client.post( + f"/api/v1/actors/{b_id}/suspend", + headers={**a_headers, "Idempotency-Key": three_keys[0]}, + json={"reason": "Three-way profile loss first"}, + ), + name="three-profile-first", + ) + await first_locked.wait() + second_task = asyncio.create_task( + client.post( + f"/api/v1/admin-role-grants/{a_grant}/revoke", + headers={**c_headers, "Idempotency-Key": three_keys[1]}, + json={"reason": "Three-way grant loss second"}, + ), + name="three-grant-second", + ) + await asyncio.wait_for(second_entered.wait(), timeout=20) + await asyncio.wait_for( + _wait_for_named_database_lock(auth_database_env, second_name), + timeout=20, + ) + third_task = asyncio.create_task( + link_post( + c_link, + "revoke", + b_headers, + three_keys[2], + "Three-way link loss denied third", + ), + name="three-link-third", + ) + await asyncio.wait_for(third_entered.wait(), timeout=20) + await asyncio.wait_for( + _wait_for_named_database_lock(auth_database_env, third_name), + timeout=20, + ) + release_first.set() + three_responses = await asyncio.wait_for( + asyncio.gather(first_task, second_task, third_task), + timeout=60, + ) + finally: + release_first.set() + monkeypatch.setattr( + AdminAuthorizationRepository, + "lock_control", + original_lock_control, + ) + assert [response.status_code for response in three_responses] == [200, 200, 403] + assert three_responses[2].json()["error"]["code"] == "actor_suspended" + assert [await idempotency_status(key) for key in three_keys] == [ + "committed", + "committed", + None, + ] + await assert_audit_delta( + three_before, + { + "ActorProfileSuspended": 1, + "AdminRoleGrantRevoked": 1, + "AuthorityInvalidationRequested": 2, + "SensitiveAuthorizationDenied": 1, + }, + ) + assert (await actor_state(a_id))[0::3] == ("active", "active") + assert (await actor_state(b_id))[0::3] == ("suspended", "active") + assert (await actor_state(c_id))[0::3] == ("active", "active") + assert await grant_state(a_grant) == ("revoked", str(a_id)) + assert await grant_state(b_grant) == ("active", str(b_id)) + assert await grant_state(c_grant) == ("active", str(c_id)) + assert await effective_access_administrators() == {str(c_id)} + custodian = (c_id, c_headers) + + async def actor_self_race(method: str, lifecycle_first: bool, index: int) -> None: + target_id, target_link, target_headers = await create_actor( + f"self-{method.lower()}-{lifecycle_first}-{index}" + ) + before_target = await actor_state(target_id) + before_admin = await actor_state(custodian[0]) + before_events = await audit_counts() + lifecycle_key = str(uuid4()) + self_name = f"self-{index}" + lifecycle_name = f"lifecycle-{index}" + waiter_name = f"auth09db-self-{uuid4().hex}" + self_lock_owner = ActorService + self_lock_attribute = "lock_actor_self_for_authorization" + original_self_lock = getattr(self_lock_owner, self_lock_attribute) + original_link_lock = ( + AdminAuthorizationRepository.lock_identity_link_lifecycle_target + ) + first_locked = asyncio.Event() + release_first = asyncio.Event() + waiter_entered = asyncio.Event() + captured_after_self: list[tuple[datetime, datetime]] = [] + + if lifecycle_first: + + async def ordered_link_lock(self, identity_link_id): + locked = await original_link_lock(self, identity_link_id) + first_locked.set() + await release_first.wait() + return locked + + async def ordered_self_lock(service, *args): + await first_locked.wait() + await service._session.execute( + text("select set_config('application_name', :name, true)"), + {"name": waiter_name}, + ) + waiter_entered.set() + return await original_self_lock(service, *args) + + else: + + async def ordered_self_lock(service, *args): + locked = await original_self_lock(service, *args) + first_locked.set() + await release_first.wait() + return locked + + async def ordered_link_lock(self, identity_link_id): + await first_locked.wait() + await self._session.execute( + text("select set_config('application_name', :name, true)"), + {"name": waiter_name}, + ) + waiter_entered.set() + locked = await original_link_lock(self, identity_link_id) + if locked is not None: + captured_after_self.append( + (locked[1].last_seen_at, locked[0].last_verified_at) + ) + return locked + + monkeypatch.setattr( + self_lock_owner, + self_lock_attribute, + ordered_self_lock, + ) + monkeypatch.setattr( + AdminAuthorizationRepository, + "lock_identity_link_lifecycle_target", + ordered_link_lock, + ) + self_task: asyncio.Task[Response] | None = None + lifecycle_task: asyncio.Task[Response] | None = None + try: + async def self_request() -> Response: + return await client.request( + method, + "/api/v1/actors/me", + headers=target_headers, + json=( + {"display_name": f"Ordered self patch {index}"} + if method == "PATCH" + else None + ), + ) + + async def lifecycle_request() -> Response: + return await link_post( + target_link, + "revoke", + custodian[1], + lifecycle_key, + f"Ordered self lifecycle {index}", + ) + + async def require_first_lock(task: asyncio.Task[Response]) -> None: + lock_task = asyncio.create_task(first_locked.wait()) + done, _ = await asyncio.wait( + {task, lock_task}, + timeout=20, + return_when=asyncio.FIRST_COMPLETED, + ) + if lock_task in done: + return + lock_task.cancel() + if task in done: + early = task.result() + raise AssertionError( + f"blocker returned before target lock: " + f"{early.status_code} {early.text}" + ) + raise AssertionError("blocker did not reach the target lock") + + async def require_waiter(task: asyncio.Task[Response]) -> None: + waiter_task = asyncio.create_task(waiter_entered.wait()) + done, _ = await asyncio.wait( + {task, waiter_task}, + timeout=20, + return_when=asyncio.FIRST_COMPLETED, + ) + if waiter_task in done: + return + waiter_task.cancel() + if task in done: + early = task.result() + raise AssertionError( + f"{method} lifecycle_first={lifecycle_first} waiter returned " + f"before target lock: {early.status_code} {early.text}" + ) + raise AssertionError( + f"{method} lifecycle_first={lifecycle_first} waiter did not " + "reach the target lock" + ) + + if lifecycle_first: + lifecycle_task = asyncio.create_task( + lifecycle_request(), name=lifecycle_name + ) + await require_first_lock(lifecycle_task) + self_task = asyncio.create_task(self_request(), name=self_name) + else: + self_task = asyncio.create_task(self_request(), name=self_name) + await require_first_lock(self_task) + lifecycle_task = asyncio.create_task( + lifecycle_request(), name=lifecycle_name + ) + waiter = self_task if lifecycle_first else lifecycle_task + assert waiter is not None + await require_waiter(waiter) + await asyncio.wait_for( + _wait_for_named_database_lock(auth_database_env, waiter_name), + timeout=20, + ) + release_first.set() + self_response, lifecycle_response = await asyncio.wait_for( + asyncio.gather(self_task, lifecycle_task), + timeout=60, + ) + finally: + release_first.set() + tasks = [ + task + for task in (self_task, lifecycle_task) + if task is not None and not task.done() + ] + for task in tasks: + task.cancel() + if tasks: + await asyncio.gather(*tasks, return_exceptions=True) + monkeypatch.setattr( + self_lock_owner, + self_lock_attribute, + original_self_lock, + ) + monkeypatch.setattr( + AdminAuthorizationRepository, + "lock_identity_link_lifecycle_target", + original_link_lock, + ) + assert lifecycle_response.status_code == 200, lifecycle_response.text + assert await idempotency_status(lifecycle_key) == "committed" + final_target = await actor_state(target_id) + final_admin = await actor_state(custodian[0]) + assert final_target[3] == "revoked" + assert final_admin[1] > before_admin[1] + assert final_admin[2] > before_admin[2] + if lifecycle_first: + assert self_response.status_code == 403, self_response.text + assert self_response.json()["error"]["code"] == "identity_link_revoked" + assert final_target[1:3] == before_target[1:3] + denial_count = 1 + else: + assert self_response.status_code == 200, self_response.text + assert captured_after_self + assert final_target[1:3] == captured_after_self[0] + assert final_target[1] > before_target[1] + assert final_target[2] > before_target[2] + denial_count = 0 + await assert_audit_delta( + before_events, + { + "ActorIdentityLinkRevoked": 1, + "AuthorityInvalidationRequested": 1, + "SensitiveAuthorizationDenied": denial_count, + }, + ) + + index = 0 + for method in ("GET", "PATCH"): + for lifecycle_first in (False, True): + index += 1 + await actor_self_race(method, lifecycle_first, index) + + async with db_session.get_session_factory()() as session: + 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 + + 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 3ec22822f..bea952d24 100644 --- a/backend/tests/test_authorization.py +++ b/backend/tests/test_authorization.py @@ -32,6 +32,7 @@ ) from app.modules.audit.service import AuditService from app.modules.actors.models import ActorIdentityLink, ActorProfile +from app.modules.actors.service import ActorService from app.modules.actors.service_identities import SERVICE_IDENTITIES, ServiceIdentity from app.modules.authorization import catalogue as authorization_catalogue from app.modules.authorization import kernel as authorization_kernel @@ -39,10 +40,13 @@ from app.modules.authorization.lifecycle_schemas import ( ActorLifecycleBody, ActorLifecycleMutationResponse, + IdentityLinkLifecycleMutationResponse, ) from app.modules.authorization.lifecycle_service import ( ActorLifecycleConflict, ActorLifecycleService, + IdentityLinkLifecycleConflict, + IdentityLinkLifecycleService, ) from app.modules.authorization.catalogue import ( ACTION_BY_ID, @@ -103,6 +107,7 @@ from app.modules.authorization.runtime import ( ActorAdminRoleGrantHistoryResourceContext, ActorIdentityLinkAdminReadResourceContext, + ActorIdentityLinkLifecycleResourceContext, ActorKind, ActorProfileAdminReadResourceContext, ActorProfileLifecycleResourceContext, @@ -288,12 +293,14 @@ 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_PROFILE_SUSPEND, - ActionId.ACTOR_PROFILE_REACTIVATE, - ActionId.ACTOR_PROFILE_DEACTIVATE, - } + ActionId.ACTOR_IDENTITY_LINK_READ, + ActionId.ACTOR_SERVICE_PROVISION, + ActionId.ACTOR_PROFILE_SUSPEND, + ActionId.ACTOR_PROFILE_REACTIVATE, + ActionId.ACTOR_PROFILE_DEACTIVATE, + ActionId.ACTOR_IDENTITY_LINK_REVOKE, + ActionId.ACTOR_IDENTITY_LINK_REACTIVATE, + } assert { definition.action_id.value: ( definition.permission_id.value, @@ -693,6 +700,164 @@ async def failed_operation() -> None: assert session.rollbacks == 2 +@pytest.mark.parametrize( + ("outcome", "expected_error", "expected_events"), + [ + ("success", None, ["reserve", "authorize", "touch", "complete", "commit"]), + ("replay", None, ["reserve", "authorize", "touch", "commit"]), + ( + "mismatch", + "idempotency_mismatch", + ["reserve", "authorize", "rollback", "record_mismatch", "commit"], + ), + ( + "conflict", + "identity_link_already_revoked", + [ + "reserve", + "authorize", + "touch", + "complete", + "rollback", + "record_conflict", + "commit", + ], + ), + ( + "sql_failure", + "service_unavailable", + ["reserve", "authorize", "touch", "complete", "rollback"], + ), + ], +) +async def test_identity_link_lifecycle_route_preserves_outcome_transaction_contract( + monkeypatch: pytest.MonkeyPatch, + outcome: str, + expected_error: str | None, + expected_events: list[str], +) -> None: + """Prove route-owned ordering and stable mapping for every lifecycle outcome.""" + caller_id = uuid4() + target_link_id = uuid4() + target_actor_id = uuid4() + idempotency_key = uuid4() + events: list[str] = [] + response_reference = AuthorityResponseReference( + resource_type=AuthorityResourceType.ACTOR_IDENTITY_LINK, + resource_id=target_link_id, + version=None, + http_status=200, + ) + response = IdentityLinkLifecycleMutationResponse( + resource_type="actor_identity_link", + resource_id=target_link_id, + version=None, + http_status=200, + ) + claim = AuthorityClaimHandle( + record_id=uuid4(), + idempotency_key=idempotency_key, + actor_ref_kind=ActorReferenceKind.ACTOR_PROFILE, + actor_ref=str(caller_id), + operation=AuthorityOperation.ACTOR_IDENTITY_LINK_REVOKE, + request_digest=DIGEST, + ) + reservation = { + "success": ClaimedReservation(claim=claim), + "replay": ReplayedReservation(response=response_reference), + "mismatch": MismatchedReservation(), + "conflict": ClaimedReservation(claim=claim), + "sql_failure": ClaimedReservation(claim=claim), + }[outcome] + + class Session: + async def commit(self) -> None: + events.append("commit") + + async def rollback(self) -> None: + events.append("rollback") + + class Authorization: + async def require(self, action_id, resource): + events.append("authorize") + assert action_id is ActionId.ACTOR_IDENTITY_LINK_REVOKE + assert isinstance(resource, ActorIdentityLinkLifecycleResourceContext) + assert resource.resource_id == target_link_id + assert resource.transition == "revoke" + assert resource.existing_idempotency_record is (outcome in {"replay", "mismatch"}) + return SimpleNamespace(revalidated=True) + + class RouteActorService: + def __init__(self, session) -> None: + assert session is test_session + + async def touch_after_authorization(self, resolved) -> None: + assert resolved.profile.id == str(caller_id) + events.append("touch") + + class RouteLifecycleService: + def __init__(self, session) -> None: + assert session is test_session + + async def reserve(self, **kwargs): + events.append("reserve") + assert kwargs["idempotency_key"] == idempotency_key + assert kwargs["actor_profile_id"] == caller_id + assert kwargs["request"].identity_link_id == target_link_id + return reservation + + async def record_mismatch(self, **kwargs) -> None: + events.append("record_mismatch") + assert kwargs["actor_profile_id"] == caller_id + + async def complete(self, **kwargs): + events.append("complete") + assert kwargs["actor_profile_id"] == caller_id + assert kwargs["reason"] == "Revoke exact identity link" + if outcome == "conflict": + raise IdentityLinkLifecycleConflict( + "identity_link_already_revoked", + target_actor_id, + ) + if outcome == "sql_failure": + raise SQLAlchemyError("lifecycle write failed") + return response + + async def record_conflict(self, **kwargs) -> None: + events.append("record_conflict") + assert kwargs["target_actor_profile_id"] == target_actor_id + assert kwargs["code"] == "identity_link_already_revoked" + + test_session = Session() + monkeypatch.setattr(authorization_router, "ActorService", RouteActorService) + monkeypatch.setattr( + authorization_router, + "IdentityLinkLifecycleService", + RouteLifecycleService, + ) + + call = authorization_router._mutate_identity_link_lifecycle( + identity_link_id=target_link_id, + payload=ActorLifecycleBody(reason="Revoke exact identity link"), + idempotency_key=idempotency_key, + resolved=SimpleNamespace(profile=SimpleNamespace(id=str(caller_id))), + authorization=Authorization(), + session=test_session, # type: ignore[arg-type] + operation=AuthorityOperation.ACTOR_IDENTITY_LINK_REVOKE, + action=ActionId.ACTOR_IDENTITY_LINK_REVOKE, + transition="revoke", + ) + if expected_error is None: + assert await call == response + else: + with pytest.raises(StructuredHTTPException) as failure: + await call + assert failure.value.error_code == expected_error + assert failure.value.status_code == (503 if outcome == "sql_failure" else 409) + assert failure.value.retryable is (outcome == "sql_failure") + assert events == expected_events + + @pytest.mark.parametrize( "definitions, message", [ @@ -781,6 +946,20 @@ def test_action_catalogue_construction_fails_closed( _index_actions(definitions) +def test_action_catalogue_rejects_count_and_permission_partition_drift( + monkeypatch: pytest.MonkeyPatch, +) -> None: + with monkeypatch.context() as patch: + patch.setattr(authorization_catalogue, "PERMISSION_IDS", frozenset()) + with pytest.raises(RuntimeError, match="catalogue count mismatch"): + _index_actions(ACTION_DEFINITIONS) + + with monkeypatch.context() as patch: + patch.setattr(authorization_catalogue, "HISTORICAL_PERMISSION_IDS", frozenset()) + with pytest.raises(RuntimeError, match="permission boundary mismatch"): + _index_actions(ACTION_DEFINITIONS) + + def _runtime_context( *, actor_status: ActorStatus = ActorStatus.ACTIVE, @@ -806,11 +985,19 @@ async def add_authority_event(self, event: AuthorityAuditEventInput) -> None: self.events.append(event) +_DEFAULT_REVALIDATOR = object() + + def _runtime_service( context: AuthorizationContext, *, - revalidate=None, + revalidate=_DEFAULT_REVALIDATOR, ) -> tuple[AuthorizationService, _DecisionEvidence]: + if revalidate is _DEFAULT_REVALIDATOR: + + async def revalidate(current, _resource): + return current + service = AuthorizationService(object(), context, revalidate_actor_self=revalidate) # type: ignore[arg-type] evidence = _DecisionEvidence() service._audit = evidence # type: ignore[assignment] @@ -834,6 +1021,7 @@ def __init__(self, context: AuthorizationContext) -> None: ) self.request_actor_is_present = True self.lifecycle_target_is_present = True + self.link_lifecycle_target_is_present = True self.control_locked = False self.find_calls: list[tuple[tuple, dict]] = [] @@ -877,6 +1065,15 @@ async def lock_actor_lifecycle_target(self, actor_profile_id): None, ) + async def lock_identity_link_lifecycle_target(self, identity_link_id): + if not self.link_lifecycle_target_is_present: + return None + return ( + SimpleNamespace(id=str(identity_link_id), status="active"), + SimpleNamespace(id=str(uuid4()), actor_kind="human", status="active"), + None, + ) + def _admin_runtime_service( context: AuthorizationContext, @@ -1045,6 +1242,64 @@ async def test_actor_profile_lifecycle_kernel_guards_self_pairing_and_disclosure ] +@pytest.mark.parametrize( + ("action_id", "transition"), + [ + (ActionId.ACTOR_IDENTITY_LINK_REVOKE, "revoke"), + (ActionId.ACTOR_IDENTITY_LINK_REACTIVATE, "reactivate"), + ], +) +async def test_identity_link_lifecycle_kernel_locks_control_and_exact_target( + action_id: ActionId, + transition: str, +) -> None: + service, evidence, facts = _admin_runtime_service(_runtime_context()) + resource = ActorIdentityLinkLifecycleResourceContext( + resource_type="actor_identity_link", + 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_identity_link_lifecycle_kernel_guards_self_pairing_and_disclosure() -> None: + context = _runtime_context() + service, evidence, facts = _admin_runtime_service(context) + self_revoke = ActorIdentityLinkLifecycleResourceContext( + resource_type="actor_identity_link", + resource_id=context.identity_link_id, + transition="revoke", + ) + with pytest.raises(AuthorizationDenied) as self_denial: + await service.require(ActionId.ACTOR_IDENTITY_LINK_REVOKE, self_revoke) + assert self_denial.value.public_code == "resource_guard_denied" + + crossed = self_revoke.model_copy( + update={"resource_id": uuid4(), "transition": "reactivate"} + ) + with pytest.raises(AuthorizationDenied) as crossed_denial: + await service.require(ActionId.ACTOR_IDENTITY_LINK_REVOKE, crossed) + assert crossed_denial.value.public_code == "resource_guard_denied" + + missing = self_revoke.model_copy(update={"resource_id": uuid4()}) + facts.link_lifecycle_target_is_present = False + with pytest.raises(AuthorizationDenied) as missing_denial: + await service.require(ActionId.ACTOR_IDENTITY_LINK_REVOKE, missing) + assert missing_denial.value.public_code == "resource_not_found" + assert [event.denial_code for event in evidence.events] == [ + "resource_guard_denied", + "resource_guard_denied", + "resource_not_found", + ] + + def _actor_lifecycle_decision( request: ActorProfileSuspendRequest | ActorProfileReactivateRequest, *, @@ -1086,6 +1341,53 @@ def _actor_lifecycle_decision( ) +def _identity_link_lifecycle_decision( + request: ActorIdentityLinkRevokeRequest | ActorIdentityLinkReactivateRequest, + *, + existing: bool, +) -> AuthorizationDecision: + action = { + AuthorityOperation.ACTOR_IDENTITY_LINK_REVOKE: ActionId.ACTOR_IDENTITY_LINK_REVOKE, + AuthorityOperation.ACTOR_IDENTITY_LINK_REACTIVATE: ( + ActionId.ACTOR_IDENTITY_LINK_REACTIVATE + ), + }[request.operation] + permission = { + AuthorityOperation.ACTOR_IDENTITY_LINK_REVOKE: ( + PermissionId.ACTOR_IDENTITY_LINK_REVOKE + ), + AuthorityOperation.ACTOR_IDENTITY_LINK_REACTIVATE: ( + PermissionId.ACTOR_IDENTITY_LINK_REACTIVATE + ), + }[request.operation] + transition = { + AuthorityOperation.ACTOR_IDENTITY_LINK_REVOKE: "revoke", + AuthorityOperation.ACTOR_IDENTITY_LINK_REACTIVATE: "reactivate", + }[request.operation] + resource = ActorIdentityLinkLifecycleResourceContext( + resource_type="actor_identity_link", + resource_id=request.identity_link_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_identity_link", + resource_id=request.identity_link_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" @@ -1279,6 +1581,159 @@ async def add_authority_event(self, event): assert audit.event.matched_grant_id is None +async def test_identity_link_lifecycle_service_applies_success_and_guards_conflicts() -> None: + target_link, target_actor, caller = uuid4(), uuid4(), uuid4() + reason = "Revoke exact identity link" + + class Session: + flushed = 0 + + async def flush(self): + self.flushed += 1 + + class Repository: + def __init__(self, link, profile): + self.link = link + self.profile = profile + + async def lock_identity_link_lifecycle_target(self, _identity_link_id): + return self.link, self.profile, None + + async def count_effective_access_administrators(self): + return 1 + + class Mutation: + completed = None + mismatch = None + + async def complete(self, **kwargs): + self.completed = kwargs + + async def record_mismatch_denial(self, **kwargs): + self.mismatch = kwargs + + class Audit: + event = None + + async def add_authority_event(self, event): + self.event = event + + session = Session() + link = SimpleNamespace( + id=str(target_link), + status="active", + revoked_by=None, + revoked_at=None, + revoked_reason=None, + reactivated_by=None, + reactivated_at=None, + reactivation_reason=None, + ) + profile = SimpleNamespace(id=str(target_actor), actor_kind="human", status="active") + repository = Repository(link, profile) + mutation = Mutation() + service = IdentityLinkLifecycleService(session) # type: ignore[arg-type] + service._repository = repository # type: ignore[assignment] + service._mutation = mutation # type: ignore[assignment] + audit = Audit() + service._audit = audit # type: ignore[assignment] + claim = AuthorityClaimHandle( + record_id=uuid4(), + idempotency_key=uuid4(), + actor_ref_kind=ActorReferenceKind.ACTOR_PROFILE, + actor_ref=str(caller), + operation=AuthorityOperation.ACTOR_IDENTITY_LINK_REVOKE, + request_digest=DIGEST, + ) + request = ActorIdentityLinkRevokeRequest( + operation=AuthorityOperation.ACTOR_IDENTITY_LINK_REVOKE, + identity_link_id=target_link, + reason_digest=derive_reason_digest(reason), + ) + decision = _identity_link_lifecycle_decision(request, existing=False) + + response = await service.complete( + claim=claim, + request=request, + decision=decision, + actor_profile_id=caller, + reason=reason, + ) + assert response == IdentityLinkLifecycleMutationResponse( + resource_type="actor_identity_link", + resource_id=target_link, + version=None, + http_status=200, + ) + assert session.flushed == 1 + assert link.status == "revoked" + assert link.revoked_by == str(caller) + assert link.revoked_reason == reason + success = mutation.completed["success"] + assert success.target_actor_ref == str(target_actor) + assert success.before_facts == {"status": "active"} + assert success.after_facts == {"status": "revoked"} + + reactivate_reason = "Reactivate active identity link" + reactivate = ActorIdentityLinkReactivateRequest( + operation=AuthorityOperation.ACTOR_IDENTITY_LINK_REACTIVATE, + identity_link_id=target_link, + reason_digest=derive_reason_digest(reactivate_reason), + ) + link.status = "active" + with pytest.raises(IdentityLinkLifecycleConflict) as not_revoked: + await service.complete( + claim=claim.model_copy(update={"operation": reactivate.operation}), + request=reactivate, + decision=_identity_link_lifecycle_decision(reactivate, existing=False), + actor_profile_id=caller, + reason=reactivate_reason, + ) + assert not_revoked.value.code == "identity_link_not_revoked" + assert not_revoked.value.actor_profile_id == target_actor + + assert ( + await service._conflict( + request, + SimpleNamespace(status="active"), + SimpleNamespace(actor_kind="human", status="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, + target_actor_profile_id=target_actor, + request=request, + decision=crossed, + code="identity_link_already_revoked", + ) + + await service.record_mismatch( + actor_profile_id=caller, + request=request, + decision=_identity_link_lifecycle_decision(request, existing=True), + ) + assert mutation.mismatch["context"].matched_grant_id is None + await service.record_conflict( + actor_profile_id=caller, + target_actor_profile_id=target_actor, + request=request, + decision=decision, + code="identity_link_already_revoked", + ) + assert audit.event.matched_grant_id is None + assert audit.event.target_actor_ref == str(target_actor) + + async def test_admin_kernel_conceals_targets_until_permission_and_scope_match() -> None: context = _runtime_context() service, _, facts = _admin_runtime_service(context) @@ -2127,7 +2582,9 @@ async def _add_validated_authority_event(self, event): } -async def test_authorization_dependency_rolls_back_a_forgotten_route_transaction() -> None: +async def test_authorization_dependency_rolls_back_a_forgotten_route_transaction( + monkeypatch: pytest.MonkeyPatch, +) -> None: class ForgottenCommitSession: def __init__(self) -> None: self.rollback_count = 0 @@ -2150,6 +2607,15 @@ async def rollback(self) -> None: evidence = _DecisionEvidence() service._audit = evidence # type: ignore[assignment] + async def retain_resolved_actor(_service, current): + return current + + monkeypatch.setattr( + ActorService, + "lock_actor_self_for_authorization", + retain_resolved_actor, + ) + await service.require( ActionId.ACTOR_PROFILE_READ_SELF, ActorSelfResourceContext( @@ -2236,7 +2702,7 @@ async def test_authorization_kernel_allows_only_exact_actor_self_actions() -> No assert decision.allowed is True assert decision.action_id is ActionId.ACTOR_PROFILE_READ_SELF assert decision.permission_id is PermissionId.ACTOR_PROFILE_READ_SELF - assert decision.revalidated is False + assert decision.revalidated is True assert len(evidence.events) == 1 assert evidence.events[0].action_id is ActionId.ACTOR_PROFILE_READ_SELF assert evidence.events[0].after_facts == {"allowed": True} @@ -2544,12 +3010,25 @@ async def revalidate(_context, _resource): async def test_actor_self_update_requires_transaction_revalidation() -> None: context = _runtime_context() + read_resource = ActorSelfResourceContext( + resource_type="actor_profile", + resource_id=context.actor_profile_id, + requested_fields=(), + ) resource = ActorSelfResourceContext( resource_type="actor_profile", resource_id=context.actor_profile_id, requested_fields=("contact_email",), ) - without_recheck, _ = _runtime_service(context) + without_read_recheck, _ = _runtime_service(context, revalidate=None) + with pytest.raises(AuthorizationDenied) as read_exc_info: + await without_read_recheck.require(ActionId.ACTOR_PROFILE_READ_SELF, read_resource) + assert ( + read_exc_info.value.decision.denial_code + is AuthorizationDenialCode.RESOURCE_GUARD_DENIED + ) + + without_recheck, _ = _runtime_service(context, revalidate=None) with pytest.raises(AuthorizationDenied) as exc_info: await without_recheck.require(ActionId.ACTOR_PROFILE_UPDATE_SELF, resource) assert exc_info.value.decision.denial_code is AuthorizationDenialCode.RESOURCE_GUARD_DENIED diff --git a/docs/architecture_data_model.md b/docs/architecture_data_model.md index f2bae7ed6..1357443a2 100644 --- a/docs/architecture_data_model.md +++ b/docs/architecture_data_model.md @@ -91,13 +91,16 @@ make the whole actor effective. An identity link binds one canonical external issuer and opaque subject to one 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. +complete attribution and bounded lifecycle reasons. AUTH-09D-B activates exact +link revoke/reactivate 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. +Link reactivation is component-scoped: it restores only that exact credential +binding, does not reactivate its ActorProfile or restore grants, and cannot +bypass final-effective-Access-Administrator preservation. ### AdminRoleGrant diff --git a/docs/operations_authorization_service.md b/docs/operations_authorization_service.md index c853aa5ce..1839dc66a 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-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`, 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 +AUTH-09D-B. The two AUTH-07B actor-self actions, seven AUTH-08 administrative +actions, `actor.service.provision`, `actor.profile.read`, +`actor.identity_link.read`, the three profile lifecycle actions, and the two +identity-link lifecycle actions are active; the other 48 entries remain planned +and non-executable. 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 @@ -665,12 +665,13 @@ database service-grant table. ## Actor Self Decision Operations `GET /api/v1/actors/me` declares `actor.profile.read_self`; it permits an -active identity link with an active or suspended human actor and commits only -the bounded read-decision evidence after authorization. `PATCH -/api/v1/actors/me` declares `actor.profile.update_self`; it locks the exact -actor profile first and its exact identity link second, rechecks current state, mutates -only `display_name` or `contact_email`, and commits mutation plus allow evidence -once. The authorization kernel never commits or rolls back. +active identity link with an active or suspended human actor. Both self routes +lock the exact actor profile first and its exact identity link second and +recheck current state before deciding. GET then advances verification timestamps +and commits bounded read-decision evidence. `PATCH /api/v1/actors/me` declares +`actor.profile.update_self`; it additionally mutates only `display_name` or +`contact_email` and commits mutation plus allow evidence once. The authorization +kernel never commits or rolls back. Self routes return explicit 403 errors because the caller owns the target: `identity_link_revoked`, then `actor_deactivated`, then `actor_suspended` for an @@ -836,19 +837,24 @@ resource IDs as metric labels. ## Authority Mutation Idempotency -AUTH-09D-A exposes only: +AUTH-09D-A and AUTH-09D-B expose: ```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 ``` 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 admission. Link revoke/reactivate preserves the immutable issuer and +subject binding, permits repair while its owner is suspended, refuses terminal +owners, and never restores a grant or fixed-service admission. An administrator +cannot revoke their own identity link. Service-actor creation, administrative/project grant issue or revocation, actor suspension/reactivation/deactivation, and identity-link diff --git a/docs/spec_authorization_service.md b/docs/spec_authorization_service.md index 291d6c62a..33609c2b9 100644 --- a/docs/spec_authorization_service.md +++ b/docs/spec_authorization_service.md @@ -238,14 +238,15 @@ 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-09D-A: 15 active actions -and 50 planned rows. AUTH-08 adds seven active administrative definition, +The closed action registry contains 65 rows after AUTH-09D-B: 17 active actions +and 48 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`, 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 +`actor.identity_link.read`, AUTH-09D-A activates the three profile lifecycle +actions, and AUTH-09D-B activates the two identity-link 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 @@ -281,8 +282,8 @@ AUTH-09A registers these exact planned actions through migration `0023`: AUTH-09B activates only `actor.service.provision` through the controlled route described below. AUTH-09C activates only the two bounded actor-registry reads. -AUTH-09D-A activates profile suspend, reactivate, and terminal deactivate. The -two identity-link mutations remain unavailable until AUTH-09D-B supplies their +AUTH-09D-A profile lifecycle activation is complemented by AUTH-09D-B, which +activates exact identity-link revoke and reactivate behavior and their route, typed resource context, evaluator, guards, transaction proof, and availability change. AUTH-09A supplies none of those runtime paths. @@ -507,8 +508,8 @@ For every protected operation: 7. Expand only registered permission candidates compatible with grant scope. 8. Apply actor, exact-project, ownership, assignment, separation-of-duties, task-ban, and lifecycle guards. -9. For sensitive mutations, revalidate authority inside the same transaction - immediately before commit. +9. For actor-self reads/updates and sensitive mutations, revalidate current + identity or authority inside the same transaction immediately before acting. 10. Return allow or a stable denial code without leaking hidden resources. Authorization decisions are request-scoped and are not cached across requests. @@ -692,7 +693,7 @@ GET /api/v1/authorization/admin-role-definitions GET /api/v1/actors/{actor_profile_id} POST /api/v1/actors/{actor_profile_id}/suspend|reactivate|deactivate GET /api/v1/actors/{actor_profile_id}/identity-links -POST /api/v1/actor-identity-links/{link_id}/revoke|reactivate +POST /api/v1/actor-identity-links/{identity_link_id}/revoke|reactivate POST /api/v1/service-actors POST|GET /api/v1/admin-role-grants @@ -713,9 +714,9 @@ 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. 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 +effective system Access Administrators only. AUTH-09D-B activates exact +identity-link revoke and reactivate for the same authority; the project-role +route family 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 b6ab772e1..904d7ebfd 100644 --- a/scripts/test_agent_gates.py +++ b/scripts/test_agent_gates.py @@ -4167,8 +4167,67 @@ 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` | Active |" in auth_status - assert "| `WS-AUTH-001-09D-B` | Inactive |" in auth_status + assert "Merged through PR #148 as `99ae4c9`" in auth_map + assert "| `WS-AUTH-001-09D-A` | Merged |" in auth_status + assert "| `WS-AUTH-001-09D-B` | Ready for PR |" in auth_status + assert "PR-ready implementation chunk\n\n`WS-AUTH-001-09D-B`" in auth_status + assert "`codex/ws-auth-001-09d-b-identity-link-lifecycle`" in auth_status + assert "PR #148 is open" not in auth_status + stale_auth_09d_state = ( + "Only 09D-A implementation is active", + "Only 09D-A may proceed", + "AUTH-09D-A's repaired contract passed required L1", + "bounded implementation is active", + "Bounded implementation is the current gate", + ) + for stale_text in stale_auth_09d_state: + assert stale_text not in auth_status + assert stale_text not in auth_map + assert stale_text not in work_queue + assert ( + "| `WS-AUTH-001-09D-B` | Identity-Link Lifecycle And Race Closure | L1 | " + "PR #152 open; trusted main `1b5422f` integrated; refreshed checks and " + "explicit human review pending" + in work_queue + ) + assert ( + "PR-ready implementation chunk: `WS-AUTH-001-09D-B` in PR #152" + in loop_state + ) + assert ( + "Current gate: refreshed external checks and explicit human review for PR\n" + " #152" in loop_state + ) + assert "ActionIds, with 15 active actions" in loop_state + assert ( + "PR #152 activates only the two 09D-B\n" + " identity-link lifecycle actions, producing a candidate total of 17" + in loop_state + ) + assert "with 12 active actions" not in loop_state + assert "five 09D-A/09D-B lifecycle actions" not in loop_state + assert ( + "| `WS-AUTH-001-CONTRIBUTOR-FOUNDATION` | Contributor Fields And " + "Canonical-Human Lineage | L1 | Inactive until 09D-B merge/memory and " + "explicit start" in auth_map + ) + assert ( + "| `WS-AUTH-001-CONTRIBUTOR-FOUNDATION` | Proposed |" in auth_status + ) + assert ( + "| `WS-AUTH-001-09E` | Fixed Service Runtime Admission | L1 | " + "Inactive until contributor-foundation merge/memory and explicit start" + in auth_map + ) + assert "| `WS-AUTH-001-09E` | Proposed |" in auth_status + assert ( + "| `WS-AUTH-001-09E` | Fixed Service Runtime Admission | L1 | " + "Inactive until contributor-foundation merge/memory and explicit user start" + in work_queue + ) + assert ( + "No service caller becomes executable before\n AUTH-09E" in loop_state + ) assert "Merged through PR #129 as `9a04434`" in artifact_map assert "Merged through PR #141 as `a10d901`" in artifact_map assert "Active after 02A3 merged through PR #141" in artifact_map @@ -4188,13 +4247,22 @@ def test_parallel_initiative_status_matches_trusted_main() -> None: "| `WS-AUTH-001-09C` | Actor And Identity-Link Administration Reads | L1 | " "Merged through PR #146 as `0ffdabf`" in work_queue ) - assert "| `WS-ART-001-02B1` | S3-Compatible MinIO And AWS | L1 | Active" in ( - work_queue + assert ( + "| `WS-ART-001-02B1` | S3-Compatible MinIO And AWS | L1 | " + "Merged through PR #151 as `1b5422f` on 2026-07-19" in work_queue + ) + assert ( + "PR #151 then merged `WS-ART-001-02B1` as `1b5422f` on 2026-07-19" + in loop_state ) - assert "Current ART gate: integrate trusted `main`, complete deterministic 02B1" in ( - loop_state + assert ( + "ART-02C1 remains inactive pending signed memory and a separate explicit start" + in loop_state.replace("\n", " ") + ) + assert "Current ART gate: integrate trusted `main`" not in loop_state + assert "| `WS-ART-001-02B1` | S3-Compatible MinIO And AWS | L1 | Active" not in ( + work_queue ) - assert "No later ART chunk starts automatically" in loop_state.replace("\n", " ") def test_stale_authorization_discovery_includes_new_untracked_docs() -> None: