diff --git a/.agent-loop/LOOP_STATE.md b/.agent-loop/LOOP_STATE.md index cdbc99b0..63acd88f 100644 --- a/.agent-loop/LOOP_STATE.md +++ b/.agent-loop/LOOP_STATE.md @@ -16,42 +16,43 @@ - AUTH-09B merged through PR #143 as `053242b`; it adds controlled service actor provisioning and leaves service runtime admission plus feature actions inactive. -- Active ART implementation chunk: `WS-ART-001-02A3` on - `codex/ws-art-001-02a3-artifact-store-v2-local-clean-cut`. -- Parallel AUTH, REV, and CON worktrees remain independently owned. This ART - branch consumes their merged handoff contracts without editing or activating - their runtime behavior. -- Start basis: PR #129 merged ART-02A2 into `main` as `9a04434`, and PR #139 - then merged the cross-initiative boundary reconciliation as `5d353b6`. +- PR #141 merged `WS-ART-001-02A3` into `main` as `a10d901` on + 2026-07-18; ART-02B1 remains inactive pending a separate explicit start. +- Active implementation chunk: `WS-AUTH-001-09C` on + `codex/ws-auth-001-09c-actor-identity-admin-reads`. +- AUTH-09C is bounded to two exact administrative read routes and activates + only `actor.profile.read` and `actor.identity_link.read`. AUTH-09D remains + inactive. +- Start basis: trusted `main` at `a10d901` after PR #141. - PR #119 merged `WS-AUTH-001-05B` as `ad71c7e`. - PR #120 merged `WS-ART-001-OBJECT-STORAGE-AMENDMENT` as `4408256`. - PR #122 merged the first automated post-merge memory implementation as `fc89fb6`; its schema-v1 cross-initiative next pointer is superseded by the schema-v2 initiative-local clean cut. -- Current gate: publish PR #141 for GitHub Actions, CodeRabbit, and explicit - human review after reconciling merged AUTH-09B and rebinding exact-SHA ART - evidence. No later ART chunk starts automatically. +- Current gate: PR #146's original Backend, Agent Gates, and CodeRabbit checks + passed. All five valid CodeRabbit proof findings are repaired at `c64bcc7`, + trusted `main` through PR #141 is integrated, and focused unit, real + PostgreSQL, and Agent Gates proof passes. All required exact integrated-head + internal repair tracks pass at `c47d627`; push and replacement external + checks remain. No service caller becomes executable before AUTH-09E. - 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 the two actor-self and seven AUTH-08 administrative actions - active, plus the AUTH-09B `actor.service.provision` action. Merged AUTH-09A - defines seven fixed artifact + ActionIds, with the two actor-self actions, seven AUTH-08 administrative + actions, and AUTH-09B `actor.service.provision` active. 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 merged through PR #127 as `f64a8e5` - and ART-02A2 merged through PR #129 as `9a04434`. ART-02A3 completed - merged-main deterministic repair, deterministic proof, and exact-SHA internal - review. PR #141 is open; refreshed evidence, external checks, and explicit - human merge approval remain. ART-02B1 remains inactive. -- Authorization checkpoint: AUTH-07B, AUTH-08, and AUTH-09A merged through PRs - #130, #131, and #132; AUTH-09B merged through PR #143 as `053242b`. - AUTH-09C remains inactive until a separate explicit user start. +- Parallel artifact checkpoint: ART-02A1, ART-02A2, and ART-02A3 merged through + PRs #127, #129, and #141. ART-02B1 remains inactive. +- Authorization checkpoint: AUTH-07B through AUTH-09B merged through PRs #130, + #131, #132, and #143. Signed memory stopped after 09B, and the user explicitly + started AUTH-09C. - 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 7c797162..58cd26be 100644 --- a/.agent-loop/REVIEW_LOG.md +++ b/.agent-loop/REVIEW_LOG.md @@ -1,5 +1,40 @@ # Review Log +## 2026-07-18 - WS-AUTH-001-09C External Repair Evidence Passed + +PR #146's original Backend, Agent Gates, and CodeRabbit checks passed. +CodeRabbit then identified five valid proof gaps: exact transition-backend lock +correlation, exact self-target timestamp payloads, exact authorization-kernel +facts, both missing-resource rollback routes, and both project-scoped Audit +Authority denials. Test-only repair `c64bcc7` closes all five. Trusted `main` +through PR #141 at `a10d901` is integrated, including migration `0025`; 10 +focused unit tests, 2 fresh real PostgreSQL lifecycle/race tests, Ruff, and all +Agent Gates pass. All required exact integrated-head internal repair tracks pass +at `c47d627`; replacement external checks remain. AUTH-09D is inactive. + +## 2026-07-18 - WS-AUTH-001-09C Internal Review Passed + +Current `main` at `e118e33` is integrated without reverting WS-CON. Every +required implementation review track passes at exact SHA `4331a6a` after +repairing self-target response freshness, stage-specific rollback evidence, +deterministic PostgreSQL lock-wait proof, exact persisted request evidence, and +log-privacy canaries. Focused actor and authorization branch coverage remain +91.06 and 92.04 percent. PR publication, external checks, and explicit human +merge approval remain; AUTH-09D is inactive. + +## 2026-07-18 - WS-AUTH-001-09C Deterministic Evidence Passed + +PR #143 merged AUTH-09B as `053242b`, signed memory stopped, and the user +explicitly started AUTH-09C. The repaired L1 contract passed every required +preimplementation review track at exact SHA `76c5427`. The bounded +implementation activates only `actor.profile.read` and +`actor.identity_link.read` for exact targets under effective system authority. +Real PostgreSQL lifecycle, two-session disabling races, rollback/privacy tests, +and the live HTTP contract drill pass. Focused branch coverage is 91.06 percent +for actors and 92.04 percent for authorization. Exact-head implementation +review, PR publication, external checks, and explicit human merge approval +remain; AUTH-09D is inactive. + ## 2026-07-17 - WS-AUTH-001-09B Coverage Repair Internal Review Passed PR #143's replacement Backend run passed all 1,242 tests and measured 84.92 diff --git a/.agent-loop/WORK_QUEUE.md b/.agent-loop/WORK_QUEUE.md index c34f8e32..9c438252 100644 --- a/.agent-loop/WORK_QUEUE.md +++ b/.agent-loop/WORK_QUEUE.md @@ -4,7 +4,7 @@ | Chunk | Title | Risk | Status | |---|---|---:|---| -| `WS-ART-001-02A3` | ArtifactStore v2 Local Clean Cut | L1 | Internal review and deterministic evidence passed; external checks pending | +| `WS-AUTH-001-09C` | Actor And Identity-Link Administration Reads | L1 | PR #146 open; all valid CodeRabbit findings repaired; integrated exact-head review passed; replacement external checks pending | Live post-merge state remains read from signed `automation/loop-memory` output. This authored queue records the separately approved parallel chunks. @@ -14,7 +14,7 @@ output. This authored queue records the separately approved parallel chunks. | Chunk | Title | Risk | Status | |---|---|---:|---| | `WS-QUAL-001-01B2` | Baseline Evidence And CI Ratchet | L1 | Paused for AUTH priority; no valid replacement baseline yet | -| `WS-AUTH-001-09C` | Actor And Identity-Link Administration Reads | L1 | Inactive until 09B merge/memory and explicit user start | +| `WS-AUTH-001-09D` | Actor And Identity-Link Lifecycle Mutations | L1 | Inactive until 09C merge/memory and explicit user start | | `WS-QUAL-001-02` | Project Service Coverage | L1 | Inactive until 01B2 merge/memory plus explicit user start | | `WS-POL-002-04` | Locked Runtime Execution And Routing Hardening | L1 | Inactive pending relevant authorization proof and a separate explicit user start | | `WS-ART-001-02B1` | S3-Compatible MinIO And AWS | L1 | Inactive until 02A3 merge and explicit user start | @@ -70,10 +70,13 @@ output. This authored queue records the separately approved parallel chunks. | `WS-AUTH-001-07A` | Closed Permission And Action Catalogue | L1 | Merged through PR #126 as `e9d72a1` on 2026-07-15 | | `WS-AUTH-001-07B` | Deny-By-Default Kernel And Self-Action Cutover | L1 | Merged through PR #130 as `90eca12` on 2026-07-15 | | `WS-AUTH-001-08` | Bootstrap Access Administrator Grant | L1 | Merged through PR #131 as `aa0fdcd` on 2026-07-16 | +| `WS-AUTH-001-09A` | Fixed Service Identity Foundation | L1 | Merged through PR #132 as `299363a` on 2026-07-17 | +| `WS-AUTH-001-09B` | Controlled Service Actor Provisioning | L1 | Merged through PR #143 as `053242b` on 2026-07-17 | | `WS-XINT-001-PLAN` | Lifecycle Boundary Reconciliation | L1 | Merged through PR #139 as `5d353b6` on 2026-07-17 | | `WS-ART-001-OBJECT-STORAGE-AMENDMENT` | AWS-First Object Storage Planning Amendment | L1 | Merged through PR #120 as `4408256` on 2026-07-14 | | `WS-ART-001-02A1` | External Service Adapter Foundation | L1 | Merged through PR #127 as `f64a8e5` on 2026-07-15 | | `WS-ART-001-02A2` | Committed Source And Local Preparation | L1 | Merged through PR #129 as `9a04434` on 2026-07-16 | +| `WS-ART-001-02A3` | ArtifactStore v2 Local Clean Cut | L1 | Merged through PR #141 as `a10d901` on 2026-07-18 | | `WS-AUTH-001-XINT` | Lifecycle Boundary Plan Reconciliation | L1 | Merged through PR #140 as `d541521` on 2026-07-17 | | `WS-ENG-001-02` | Automated Post-Merge Memory | L1 | Merged through PR #122 as `fc89fb6`; schema-v1 output superseded by WS-ENG-001-03 | @@ -85,17 +88,16 @@ AUTH-06 merged through PR #124 as `f599551`. AUTH-07A, AUTH-07B, and AUTH-08 merged through PRs #126, #130, and #131. WS-XINT planning merged through PR #139, and its AUTH owner reconciliation merged through PR #140 as `d541521`. AUTH-09A merged through PR #132 as `299363a`, and signed schema-v2 memory -stopped. AUTH-09B merged through PR #143 as `053242b`. Do not start AUTH-09C or +stopped. AUTH-09B merged through PR #143 as `053242b`; the user then explicitly +started AUTH-09C, whose PR #146 is in external repair. Do not start AUTH-09D or POL-002-04 automatically. 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`, and `02A2` are -merged. R2 and Flow Node are deferred. The user explicitly started `02A3` on -2026-07-16. Its merged-main deterministic proof and exact-SHA internal review -are complete; external checks remain pending and `02B1` must not start -automatically. +`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. +`02B1` remains inactive until a separate explicit user 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 f1e6676f..e94f569d 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 @@ -30,8 +30,8 @@ stopped. | `WS-AUTH-001-XINT` | Lifecycle Boundary Plan Reconciliation | L1 | Merged through PR #140 as `d541521` | | `WS-AUTH-001-09` | Actor State, Identity Revocation, And Service Actors | L1 | Split before runtime implementation | | `WS-AUTH-001-09A` | Fixed Service Identity Foundation | L1 | Merged through PR #132 as `299363a` | -| `WS-AUTH-001-09B` | Controlled Service Actor Provisioning | L1 | Active after explicit user start from signed 09A memory | -| `WS-AUTH-001-09C` | Actor And Identity-Link Administration Reads | L1 | Inactive until 09B merge/memory and explicit start | +| `WS-AUTH-001-09B` | Controlled Service Actor Provisioning | L1 | Merged through PR #143 as `053242b` | +| `WS-AUTH-001-09C` | Actor And Identity-Link Administration Reads | L1 | Active after explicit user start from signed 09B memory | | `WS-AUTH-001-09D` | Actor And Identity-Link Lifecycle Mutations | L1 | Inactive until 09C merge/memory and explicit start | | `WS-AUTH-001-09E` | Fixed Service Runtime Admission | L1 | Inactive until 09D merge/memory and explicit start | | `WS-AUTH-001-ART-CUSTODY` | ART Activation Custody Transfer | L1 | Inactive until 09E merge/memory and explicit start | @@ -188,5 +188,7 @@ through PR #131 as `aa0fdcd`. Parent AUTH-09 was split before implementation. PR #140 merged the required XINT planning reconciliation as `d541521`. PR #132 then merged seven identities, eleven static matrix memberships, eight planned actions, and migration `0023` as `299363a`; signed memory stopped. The user -explicitly started AUTH-09B. It must pass exact-head review and checks, then -stop for human merge. Do not start AUTH-09C or POL-002-04 automatically. +explicitly started AUTH-09B. PR #143 merged it as `053242b`; signed memory +stopped, and the user explicitly started AUTH-09C. It must pass exact-head +review and checks, then stop for human merge. Do not start AUTH-09D or +POL-002-04 automatically. 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 ba97afde..b8587510 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 @@ -88,13 +88,19 @@ tracks at `cbe7c6c`; bounded AUTH-08 implementation later merged through PR #131 as `aa0fdcd`. AUTH-09 was split before runtime implementation. PR #140 merged the authoritative AUTH XINT reconciliation as `d541521`; PR #132 then merged AUTH-09A as `299363a`. Signed memory stopped, and the user explicitly started -AUTH-09B from that trusted head. Its bounded implementation and external-review -repair passed every required internal reviewer track. PR #143's next Backend -run passed all 1,242 tests, then the new authorization coverage gate reported -89.75 percent. A behavior-test-only repair passes every required track at exact -SHA `127615fde8f1b5583acf9dbbb3c606db514a455d` and projects 90.31 percent on -the unchanged 1,600-statement denominator. Replacement external checks and -evidence rebinding are pending. No service caller or feature action is active. +AUTH-09B from that trusted head. Its bounded implementation, external-review +repair, coverage repair, and required checks passed before PR #143 merged as +`053242b`. Signed memory stopped, and the user explicitly started AUTH-09C. +Its bounded implementation activates only the exact actor-profile and +identity-link administrative reads. Deterministic proof passes, including the +live HTTP drill and focused actor and authorization branch coverage above 90 +percent. Every required implementation review track passes at exact SHA +`6791381ceb9cb0c7f6ba163d4525c6c770c02ca6`. PR #146's original external checks +passed. All five valid CodeRabbit proof findings are repaired at `c64bcc7`, and +trusted `main` through PR #141 at `a10d901` is integrated. Focused unit, fresh +real PostgreSQL, Ruff, and Agent Gates proof passes on the integrated candidate; +all required exact integrated-head repair tracks pass at `c47d627`. Replacement +external checks remain. No service caller or feature action is active. ## Active planning chunk @@ -102,12 +108,13 @@ None. `WS-AUTH-001-XINT` merged through PR #140. ## Active implementation chunk -`WS-AUTH-001-09B` - Controlled Service Actor Provisioning. This chunk activates -one human-administrator route and no service admission or feature action. +`WS-AUTH-001-09C` - Actor And Identity-Link Administration Reads. This chunk +activates two system-authority read routes and no lifecycle mutation, service +admission, or feature action. ## Current review branch -`codex/ws-auth-001-09b-controlled-service-provisioning` +`codex/ws-auth-001-09c-actor-identity-admin-reads` ## Chunk status @@ -132,8 +139,8 @@ one human-administrator route and no service admission or feature action. | `WS-AUTH-001-XINT` | Merged | `codex/ws-auth-001-xint-reconciliation` | #140 | Merged as `d541521`; signed schema-v2 memory passed. | | `WS-AUTH-001-09` | Split | - | - | Split into 09A through 09E before runtime implementation. | | `WS-AUTH-001-09A` | Merged | `codex/ws-auth-001-09-actor-state-service-actors` | #132 | Merged as `299363a`; signed memory passed. | -| `WS-AUTH-001-09B` | In progress | `codex/ws-auth-001-09b-controlled-service-provisioning` | #143 | Coverage-gate behavior repair internally reviewed; replacement checks pending. | -| `WS-AUTH-001-09C` | Proposed | - | - | Actor and identity-link administrative reads. | +| `WS-AUTH-001-09B` | Merged | `codex/ws-auth-001-09b-controlled-service-provisioning` | #143 | Merged as `053242b`; signed memory passed. | +| `WS-AUTH-001-09C` | In review | `codex/ws-auth-001-09c-actor-identity-admin-reads` | #146 | Original external checks passed; all valid CodeRabbit findings repaired; integrated exact-head review passed; replacement checks pending. | | `WS-AUTH-001-09D` | Proposed | - | - | Actor and identity-link lifecycle mutations. | | `WS-AUTH-001-09E` | Proposed | - | - | Fixed service runtime admission after 09D. | | `WS-AUTH-001-ART-CUSTODY` | Proposed | - | - | Availability-neutral 25-row ART owner transfer after 09E. | @@ -153,12 +160,14 @@ merged feature manifests and separate human starts exist. ## Blockers -AUTH-09B, its external-review repair, and the coverage-gate behavior repair -passed exact-head L1 internal review, atomic PostgreSQL behavior proof, and -privacy tests. Replacement GitHub Backend coverage, Agent Gates, CodeRabbit, -and explicit human merge approval remain. It must not add service grants, -dynamic assignments, token-role authority, service admission, or -feature-action activation. +AUTH-09C passed deterministic PostgreSQL behavior, concurrency, rollback, +privacy, live HTTP, and focused 90 percent coverage proof. The original +exact-head L1 internal and external checks passed. CodeRabbit's five valid proof +findings are repaired, trusted `main` at `a10d901` is integrated, and focused +integrated evidence passes. All required exact integrated-head internal tracks +pass at `c47d627`; replacement GitHub checks and explicit human merge approval +remain. It must not add lifecycle mutation, service grants, dynamic assignments, +token-role authority, service admission, or feature-action activation. The four proposed REV lifecycle actions and review-evidence binding action are blocked on complete feature-owned typed manifests. REV fixed services are also diff --git a/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/chunks/WS-AUTH-001-09C-actor-identity-admin-reads.md b/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/chunks/WS-AUTH-001-09C-actor-identity-admin-reads.md index 7fd95fba..2d82c743 100644 --- a/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/chunks/WS-AUTH-001-09C-actor-identity-admin-reads.md +++ b/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/chunks/WS-AUTH-001-09C-actor-identity-admin-reads.md @@ -2,8 +2,18 @@ ## Goal -Activate privacy-bounded administrative reads for one canonical actor and that -actor's single v0.1 identity link. +Activate only `actor.profile.read` and `actor.identity_link.read` so an +authorized human administrator can inspect one exact canonical ActorProfile or +that actor's single v0.1 ActorIdentityLink without exposing external identity, +contact, lifecycle-reason, grant, or static service-authority data. + +## Why this chunk exists + +AUTH-09B made controlled service identities provisionable, while actor and +identity-link lifecycle administration remains intentionally unavailable until +AUTH-09D. Operators need bounded, individually addressed reads before they can +safely select a lifecycle target. This chunk provides those reads without list, +search, mutation, service-token admission, or project-scoped authority. ## Allowed files @@ -15,8 +25,12 @@ backend/app/api/deps/authorization.py backend/tests/test_actors.py backend/tests/test_auth.py backend/tests/test_authorization.py +backend/tests/test_audit.py backend/tests/test_api_controls.py +backend/tests/test_api_contract_e2e.py backend/scripts/api_contract_e2e.py +scripts/test_agent_gates.py (only the authored lifecycle assertions that record +AUTH-09B merged and AUTH-09C active) docs/spec_authorization_service.md docs/operations_authorization_service.md .agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/** @@ -30,39 +44,145 @@ docs/operations_authorization_service.md ```text actor or identity-link mutation -collection/list endpoints, totals, or cursor pagination -issuer, subject, email, raw reason, token, grant, or assignment disclosure +collection, list, search, total, or cursor-pagination endpoints +issuer, subject, contact email, raw reason, token, claim, grant, assignment, +created-by, lifecycle-actor, or static service-action-matrix disclosure +project-scoped authority for actor-registry reads service-token admission schema migration or new persistence table artifact, task, review, project, or grant behavior +compatibility alias, fallback route, dual response shape, or legacy authority +removal, relaxation, runtime derivation, skipping, or reordering of any agent +gate, threshold, dependency pin, evidence, scanner, CI, or unrelated assertion ``` -## Exact surfaces +## Exact surfaces and authority -| Route | ActionId | PermissionId | Target | Candidate | +| Route | ActionId | PermissionId | Exact target | Candidate | |---|---|---|---|---| -| `GET /api/v1/actors/{actor_profile_id}` | `actor.profile.read` | `actor.profile.read_any` | exact actor profile | effective Access Administrator or Audit Authority grant | -| `GET /api/v1/actors/{actor_profile_id}/identity-links` | `actor.identity_link.read` | `actor.identity_link.read` | exact actor profile and linked identity metadata | effective Access Administrator or Audit Authority grant | - -Actor response fields are ID, kind, status, provisioning method, display name, -created/updated/last-seen timestamps, and bounded lifecycle timestamps. Link -response fields are link ID, actor ID, subject kind, status, linked/last-verified -and bounded revoke/reactivate timestamps. Neither response exposes issuer, -subject, contact email, raw reason, token material, claim data, grants, or -static service-matrix details. - -## Boundary and acceptance - -- Exactly two actions activate: totals are 65 actions, 12 active and 53 - planned. Every route has one manifest declaration and one typed target. -- Missing targets are concealed only after caller authorization; unauthorized - callers receive no existence, count, timing, or field-shape oracle. -- Reads are not charged to the admin mutation limiter. -- Only successful caller verification timestamps advance. Target timestamps - never change because they were read; denial and failure do not advance them. -- Allow/deny, role separation, suspended/deactivated caller, revoked caller - link, privacy, 503 rollback, and OpenAPI behavior tests pass with at least 90 - percent focused actor/authorization coverage and the global 78 percent floor. +| `GET /api/v1/actors/{actor_profile_id}` | `actor.profile.read` | `actor.profile.read_any` | requested ActorProfile UUID | effective system-scoped Access Administrator or Audit Authority grant | +| `GET /api/v1/actors/{actor_profile_id}/identity-links` | `actor.identity_link.read` | `actor.identity_link.read` | requested actor UUID and its one v0.1 link | effective system-scoped Access Administrator or Audit Authority grant | + +Project-scoped Audit Authority grants never authorize either route. Access +Administrator is already system-only. Both actions are administrative kernel +actions. `actor.profile.read` accepts only a frozen +`ActorProfileAdminReadResourceContext` with +`resource_type: Literal["actor_profile"]`, the requested actor UUID as +`resource_id`, and `read_kind: Literal["profile"]`. +`actor.identity_link.read` accepts only a separate frozen +`ActorIdentityLinkAdminReadResourceContext` with the same resource type and +requested actor UUID but `read_kind: Literal["identity_link"]`. Exact class and +read-kind pairing is enforced in the kernel and covered by negative cross-pair +tests. The server composes these contexts from the path and route; clients never +submit an ActionId, PermissionId, role, scope, or resource fact. + +The profile response contains exactly `actor_profile_id`, `actor_kind`, +`status`, `provisioning_method`, `service_identity`, `display_name`, +`created_at`, `updated_at`, `last_seen_at`, `suspended_at`, and +`deactivated_at`. `service_identity` is the closed `ServiceIdentity` enum for a +service actor and is null for a human; it is the stable local lifecycle target +name, not external identity or authority. The identity-link response is one +object, not a collection, and contains exactly `identity_link_id`, +`actor_profile_id`, `subject_kind`, `status`, `linked_at`, `last_verified_at`, +`revoked_at`, and `reactivated_at`. Both schemas forbid extra fields and enforce +the actor-kind/service-identity invariant. + +The routes may return active, suspended, or deactivated target profiles and +active or revoked target links because those lifecycle states are the subject +of later administration. They may return human or service targets. They never +return issuer, subject, `contact_email`, raw lifecycle reason, `created_by`, +`linked_by`, lifecycle actor IDs, token or claim data, grant or assignment data, +or any static service-action membership. + +## Authorization, lookup, and transaction contract + +The caller-owned transaction follows one order: + +1. resolve the already-supported human caller and exact verified identity link; +2. lock and revalidate the caller profile first and its exact link second; +3. require and lock the exact effective system-scoped Access Administrator or + Audit Authority grant for the route's active action and requested UUID; +4. only after the allowed decision, use `ActorService` and the existing + `ActorRepository.get_actor_profile()` / `get_identity_link_for_actor()` exact + lookup methods; the router and `AdminAuthorizationRepository` add no duplicate + actor target SQL; +5. if the target is absent, roll back the staged allowed decision and return the + same `404 actor_resource_not_found` envelope for a missing actor or link; +6. for a present target, stage only the caller's normal post-authorization + verification timestamp touch, serialize the strict response, and commit the + decision evidence and caller touch once in the route. + +Unauthorized callers are denied before any target existence query. The public +denial contains no target fields or counts. Missing actor and missing link +responses have identical status, code, and message; no list, alternate field +shape, or pre-authorization lookup provides an existence oracle. The contract +does not claim constant-time database behavior. + +The caller profile, exact caller link, and matched grant locks are held through +target lookup, timestamp touch, response composition, and commit. Two-session +barrier tests race both reads against caller suspension, caller deactivation, +caller-link revocation, and matched-grant revocation; disclosure is linearized +before the lifecycle/grant change or denied after it, and no timestamp advances +after the disabling change. These reads do not acquire `AuthorityControl`, do +not lock or mutate a distinct target as a read target, and are not charged to +the administrative mutation limiter. They use the existing shared-session +transaction and do not add a second session, generic teardown commit, read-side +idempotency, or invalidation event. + +SQL failure during authorization evidence, target lookup, timestamp touch, or +commit rolls back every staged change and maps to the retryable +`service_unavailable` 503 envelope. A 404, denial, validation failure, or 503 +does not advance verification timestamps. On a successful 200, only the +verified caller's `ActorProfile.last_seen_at`, `ActorProfile.updated_at`, and +exact `ActorIdentityLink.last_verified_at` may advance. If the caller requests +their own actor or link, those same rows advance solely in their caller role; +there is no additional target-read touch. + +## Evidence and API contract + +Each successful read persists one `SensitiveAuthorizationAllowed` decision for +the exact action, permission, actor-profile resource UUID, matched system grant, +request ID, and correlation ID. Denials retain the existing bounded evidence +mapping. The APIs add one generated manifest declaration per route and strict +OpenAPI response models. Path UUID validation may return the standard bounded +422 envelope but must not perform actor lookup or emit private identity data. + +Exactly two actions become active: the catalogue remains 65 actions total and +moves from 10 active / 55 planned to 12 active / 53 planned. AUTH-09C activates +no mutation, service, ART, REV, CON, task, project, or grant action. + +## Acceptance criteria + +- Access Administrator and Audit Authority each succeed only with an effective + system-scoped grant; project-scoped Audit Authority and every other role fail. +- Suspended/deactivated human and service targets plus revoked target links + remain visible to an authorized caller, while suspended/deactivated callers, + revoked caller links, service callers, and unsupported subject kinds fail + before target lookup. +- Missing actor and missing link have the one stable 404 contract after allowed + authorization and leave no decision evidence or timestamp change. +- Successful reads advance caller verification timestamps monotonically and do + not touch a distinct target. Self-target tests prove there is one caller touch, + not a second target touch. +- Caller lifecycle/link and matched-grant race tests prove the authorization + locks are held through disclosure and commit without `AuthorityControl` or + target-row locking. +- Response, OpenAPI, validation, audit, and log tests prove all prohibited + identity, contact, reason, provenance, grant, assignment, and service-matrix + fields remain absent. Service-target tests prove the response contains only + the exact closed `service_identity`, including an unverified service target + with null `last_seen_at` and null link `last_verified_at`; human responses + require null `service_identity`. +- SQL/evidence/touch/commit failures return retryable 503 and prove rollback of + evidence and timestamps with no partial product mutation. +- Route-manifest, action-count, exact resource-context, no-mutation-limiter, and + dependency-teardown behavior tests pass. The audit parity test uses one closed + expected set of exactly 12 active ActionIds and is not derived from runtime. +- The complete Agent Gates regression inventory passes unchanged except for the + exact authored AUTH-09B-merged and AUTH-09C-active lifecycle expectations; no + gate or unrelated assertion is removed, relaxed, skipped, or reordered. +- Focused actor and authorization subsystem branch coverage is each at least 90 + percent; GitHub Backend preserves the repository-wide 78 percent floor. ## Risk and reviewers @@ -73,20 +193,40 @@ architecture, CI integrity, docs, reuse/dedup, and test delta. ```bash (cd backend && .venv/bin/python -m ruff check app tests scripts/api_contract_e2e.py) -(cd backend && WORKSTREAM_DATABASE_URL= .venv/bin/python -m pytest -q \ +(cd backend && WORKSTREAM_TEST_DATABASE_URL= .venv/bin/python -m pytest -q \ tests/test_actors.py tests/test_auth.py tests/test_authorization.py \ + tests/test_audit.py tests/test_api_controls.py tests/test_api_contract_e2e.py) +(cd backend && WORKSTREAM_TEST_DATABASE_URL= .venv/bin/python -m coverage erase) +(cd backend && WORKSTREAM_TEST_DATABASE_URL= .venv/bin/python -m coverage run \ + --branch --source=app.modules.actors -m pytest -q tests/test_actors.py \ + tests/test_auth.py tests/test_authorization.py tests/test_api_controls.py) +(cd backend && .venv/bin/python -m coverage report --precision=2 --fail-under=90) +(cd backend && WORKSTREAM_TEST_DATABASE_URL= .venv/bin/python -m coverage erase) +(cd backend && WORKSTREAM_TEST_DATABASE_URL= .venv/bin/python -m coverage run \ + --branch --source=app.modules.authorization -m pytest -q \ + tests/test_authorization.py tests/test_actors.py tests/test_auth.py \ tests/test_api_controls.py) -(cd backend && WORKSTREAM_DATABASE_URL= .venv/bin/python scripts/api_contract_e2e.py) +(cd backend && .venv/bin/python -m 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/result.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 git diff --check ``` ## Human review focus -Review role separation, exact target loading, concealment order, caller-only -verification timestamps, and the absence of private identity or grant data. +Review system-only role separation, action-specific resource contexts, +caller/grant lock lifetime, authorization-before-lookup ordering, the +single-object link contract, caller-only timestamp semantics including +self-target reads, rollback on missing targets and failures, the bounded closed +service identity, and the absence of external identity, lifecycle provenance, +grant, and service-matrix data. ## Stop condition diff --git a/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/reviews/WS-AUTH-001-09C-external-review-response.md b/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/reviews/WS-AUTH-001-09C-external-review-response.md new file mode 100644 index 00000000..a004dac7 --- /dev/null +++ b/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/reviews/WS-AUTH-001-09C-external-review-response.md @@ -0,0 +1,55 @@ +# WS-AUTH-001-09C External Review Response + +PR: `#146` + +CodeRabbit run: `313d93f6-0386-4cce-97a6-d9442bdeebb3` + +Published review head: `f08f4435ba68bbf285c5b21287e52c5907539aa1` + +CodeRabbit test repair: `c64bcc726e45cd871bfc8514f61d350b8c1a960a` + +Final reviewed lifecycle head: `2f4711fd55462898c32f82489a149ae53d6af192` + +Reviewed against trusted main: +`a10d9018007d2e847b4870e9b26cbd24e24c7bb4` + +## Actionable Findings + +| Finding | Disposition | Repair and proof | +|---|---|---| +| Lock-wait helper accepted any PostgreSQL waiter | Accepted | Each transition publishes its exact backend PID immediately before the target mutation. The helper polls `pg_blocking_pids` for only that PID before releasing the paused administrative read. | +| Self-target response proof asserted only update count | Accepted | Profile reads assert exact `updated_at` and `last_seen_at` values; identity-link reads assert exact `last_verified_at`. | +| Effective-grant mock discarded positional authorization facts | Accepted | The mock records positional and keyword arguments, and tests assert the exact caller ActorProfile ID, action PermissionId, system-only mode, and locking options. | +| Missing-resource rollback covered only the profile route | Accepted | The behavior matrix covers both the profile and identity-link route with exact authorize, lookup, rollback, no-touch, and no-commit assertions. | +| Project-scoped Audit Authority denial covered only the profile route | Accepted | The real signed-token lifecycle test independently proves `permission_not_granted` for both administrative read routes. | + +## Trusted-Main Reconciliation + +PR #141 merged ART-02A3 as `a10d901` while the repair was in progress. The +branch integrates that trusted head without modifying ART production behavior. +The parallel-initiative Agent Gate now recognizes the exact merged record and +inactive ART-02B1 successor while preserving both original pre-merge review +branches. Internal review also removed obsolete AUTH-09C and ART-02A3 queue +prose and scoped its negative checks to the exact stale sentences. + +## Validation + +- Focused administrative route and kernel tests: 10 passed. +- Fresh isolated PostgreSQL lifecycle and exact lock-race tests: 2 passed in + 241.10 seconds through migration `0025_artifact_store_v2`. +- Authorization branch coverage remains 92.04 percent; production code is + unchanged by the repair. +- Ruff, stale Workstream/authorization/artifact scans, Markdown links, merge + intent, diff integrity, and all 80 Agent Gates: passed. +- No test was skipped, xfailed, removed, or weakened. No CI threshold, + dependency, exclusion, or workflow changed. + +## Review Result + +Senior engineering, QA/test, security/auth, product/ops, architecture, CI +integrity, docs, reuse/dedup, and test-delta tracks pass exact reviewed +lifecycle head `2f4711f`. All five valid CodeRabbit findings are addressed, and +no internal finding remains open. + +Replacement GitHub checks and explicit human merge approval remain. AUTH-09D is +inactive and must not start automatically. diff --git a/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/reviews/WS-AUTH-001-09C-internal-review-evidence.md b/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/reviews/WS-AUTH-001-09C-internal-review-evidence.md new file mode 100644 index 00000000..4f44c663 --- /dev/null +++ b/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/reviews/WS-AUTH-001-09C-internal-review-evidence.md @@ -0,0 +1,120 @@ +# WS-AUTH-001-09C Internal Review Evidence + +Reviewed code SHA: `2f4711fd55462898c32f82489a149ae53d6af192` + +Reviewed implementation SHA: `4331a6aec2f70d2efb001cb1d61bf1541bea36b9` + +Reviewed against trusted main: +`a10d9018007d2e847b4870e9b26cbd24e24c7bb4` + +Reviewed at: `2026-07-18T05:11:01Z` + +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 + +## External Repair Exact-Head Addendum + +- PR #146's original Backend, Agent Gates, and CodeRabbit checks passed. + CodeRabbit run `313d93f6-0386-4cce-97a6-d9442bdeebb3` produced five valid + proof findings against published head `f08f443`. +- Test-only repair `c64bcc7` correlates the exact transition PostgreSQL backend, + asserts exact self-target timestamp updates and authorization-kernel facts, + and covers both routes for missing-resource rollback and project-scoped Audit + Authority denial. +- Trusted main through PR #141 at `a10d901` is integrated. The fresh isolated + database upgraded through migration `0025_artifact_store_v2`; 2 real + PostgreSQL lifecycle/race tests passed in 241.10 seconds. Ten focused route + and kernel unit tests, Ruff, and all 80 Agent Gates passed. +- Internal review found and repaired two additional lifecycle-gate defects: stale + AUTH-09C/ART-02A3 queue prose and an initially overbroad negative phrase + assertion. The final gate rejects only the exact obsolete normalized + sentences and preserves both pre-merge ART branches. +- Senior engineering, QA/test, security/auth, product/ops, architecture, CI + integrity, docs, reuse/dedup, and test-delta tracks all pass exact reviewed + lifecycle head `2f4711fd55462898c32f82489a149ae53d6af192` with no open + findings. No AUTH or ART production behavior, workflow, dependency, threshold, + skip, or exclusion changed. + +## Original Implementation Deterministic Evidence + +- The original implementation review used `e118e33` as its merge base. PR + #144's WS-CON specification, ADR, review evidence, inventory, and merge intent + were preserved unchanged. +- The catalogue remains 74 PermissionIds and 65 ActionIds: exactly 12 active and + 53 planned. This chunk activates only `actor.profile.read` and + `actor.identity_link.read`. +- Only an effective system-scoped Access Administrator or Audit Authority grant + authorizes either exact target. Project-scoped Audit Authority is denied before + target lookup. +- Separate frozen resource contexts enforce exact action/read-kind pairing. The + caller profile, exact link, and matched grant are locked through disclosure and + commit without `AuthorityControl` or target-row locking. +- Authorized reads expose strict bounded profile/link schemas. Tests exclude + issuer, subject, contact, lifecycle reasons and actors, tokens, grants, + assignments, and static service-action membership from responses and logs. +- Human targets return null `service_identity`; service targets return only the + exact closed local identity. Unverified service timestamps remain null. +- Self-target profile and link reads return database-refreshed caller timestamps, + stage one caller touch, and persist exactly one allow decision per request. +- Persisted allow evidence binds the exact action, permission, target UUID, + matched system grant, request ID, correlation ID, caller, and allowed fact. +- Missing actor and link targets share one 404 envelope and roll back the staged + allow decision and caller timestamps. +- Injected authorization-evidence, target-lookup, caller-touch, and commit + failures on both routes return the retryable 503 envelope with no partial + evidence or timestamp advancement. +- Two-session lifecycle, link, and matched-grant races observe a real PostgreSQL + lock wait before releasing the read. The read linearizes before the disabling + change, and the next request is denied without advancing timestamps. +- The real signed-token lifecycle and race selection passed 2 tests in 165.38 + seconds. The final privacy/evidence repair test passed in 60.67 seconds. +- The live HTTP API contract drill passed and exercised both routes for human and + fixed-service targets without external identity disclosure. +- Actor branch coverage passed 145 tests at 91.06 percent. Authorization branch + coverage passed 136 tests at 92.04 percent. GitHub Backend remains responsible + for the repository-wide 78 percent floor. +- Ruff, exact route/kernel/OpenAPI/audit tests, merge-intent validation, stale + Workstream/authorization/artifact scans, Markdown links, diff integrity, and + all 80 Agent Gates tests pass. No threshold, skip, exclusion, workflow, or + dependency was weakened. + +## Reviewer Results + +| Reviewer | Result | Blocking findings | Notes | +|---|---|---|---| +| senior engineering | PASS AFTER FIXES | none | Current main is integrated; the bounded routes, response freshness, and transaction ownership are coherent. | +| QA/test | PASS AFTER FIXES | none | Behavior tests prove exact evidence, privacy, rollback, self-target semantics, and deterministic lock races. | +| security/auth | PASS AFTER FIXES | none | Authority is system-only, target lookup follows authorization, and private identity/provenance data remains concealed. | +| product/ops | PASS | none | Authorized operators can inspect exact lifecycle targets without list/search or mutation behavior. | +| architecture | PASS AFTER FIXES | none | Action-specific contexts and central AUTH ownership remain intact without target locks or compatibility paths. | +| CI integrity | PASS AFTER FIXES | none | The lifecycle-only Agent Gates update preserves all 80 tests and all coverage floors. | +| docs | PASS AFTER FIXES | none | Specification, operations, chunk, counts, and authored state match the reviewed runtime. | +| reuse/dedup | PASS | none | ActorService and existing ActorRepository exact lookup methods are reused without duplicate SQL. | +| test delta | PASS AFTER FIXES | none | Tests add assertions and failure/race proof; none are skipped, weakened, or rewritten to hide behavior. | + +## Findings Resolved + +Valid findings addressed: yes + +Open sub-agent sessions: none + +The original exact-head review rejected a stale-main candidate and identified +incomplete SQL-stage, self-target, deterministic-race, persisted-evidence, and +log-privacy proof. Current main was integrated without discarding WS-CON. The +repair refreshes self-target response timestamps after the one caller touch, +observes PostgreSQL lock waiting instead of sleeping, covers every failure stage +on both routes, binds audit IDs to response headers, and scans actual token, +grant, identity, contact, reason, and provenance canaries. A final lifecycle-only +Agent Gates assertion was aligned from `In progress` to `In review`; all 80 +tests pass. + +## Remaining Risk And Gate + +These APIs intentionally expose exact lifecycle timestamps and the closed local +service identity to system authorities. They do not expose external identity, +grant contents, list/search, mutation, or service admission. Replacement GitHub +Backend, Agent Gates, CodeRabbit, and explicit human merge approval remain +external gates. AUTH-09D must not start automatically. diff --git a/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/reviews/WS-AUTH-001-09C-pr-trust-bundle.md b/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/reviews/WS-AUTH-001-09C-pr-trust-bundle.md new file mode 100644 index 00000000..b435fed6 --- /dev/null +++ b/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/reviews/WS-AUTH-001-09C-pr-trust-bundle.md @@ -0,0 +1,134 @@ +# WS-AUTH-001-09C PR Trust Bundle + +## Chunk + +`WS-AUTH-001-09C` - Actor And Identity-Link Administration Reads + +## Goal + +Give authorized human system administrators one exact, privacy-bounded view of +an ActorProfile or its single v0.1 ActorIdentityLink before lifecycle mutations +are introduced. + +## Human-Approved Intent + +The user explicitly started AUTH-09C after PR #143 merged and signed memory +stopped. No compatibility route, service admission, lifecycle mutation, or +feature-owned authorization is authorized. + +## What Changed + +- Added strict `GET /api/v1/actors/{actor_profile_id}` and + `GET /api/v1/actors/{actor_profile_id}/identity-links` responses. +- Activated only `actor.profile.read` and `actor.identity_link.read`, moving the + catalogue to 12 active / 53 planned actions. +- Added separate frozen resource contexts and serialized caller/link/grant + revalidation through target disclosure and route commit. +- Added bounded actor-service projections, OpenAPI/manifest declarations, real + HTTP proof, and behavior-first privacy, evidence, rollback, and race tests. + +## Why It Changed + +AUTH-09D needs administrators to select an exact actor or identity-link target. +The selection surface must exist first without adding list/search or revealing +external identity and authority data. + +## Design Chosen + +The server composes action-specific contexts from the path, authorizes before +lookup, reuses ActorService and ActorRepository exact reads, touches only the +verified caller, then commits the allow evidence and touch once. Missing targets +roll back and share one 404 contract. Self-target responses use the refreshed +caller timestamps from that same touch. + +## Alternatives Rejected + +Collection/search APIs, client-supplied actions or roles, project-scoped actor +registry authority, target-row locks, `AuthorityControl` on reads, a second +session, compatibility aliases, and raw issuer/subject output were rejected as +outside this chunk or unsafe. + +## Scope Control + +Trusted main through PR #141 at `a10d901` is integrated, including ART migration +`0025`, without changing its behavior. No AUTH migration, dependency, workflow, +grant/assignment behavior, service admission, ART/REV/CON activation, or +lifecycle mutation changed. + +## Product Behavior + +Effective system Access Administrator and Audit Authority grants may inspect +active, suspended, or deactivated human/service profiles and active/revoked +links. Project-scoped Audit Authority and every unsupported caller are denied +before target lookup. + +## Acceptance Criteria Proof + +Strict response fields, exact system authority, authorization-before-lookup, +stable 404 rollback, self-target freshness, exact persisted evidence, null +unverified service timestamps, response/log privacy, SQL failure rollback, and +real lock serialization are all covered by behavior tests. + +## Tests And Checks Run + +- Real PostgreSQL lifecycle and race repair selection: 2 passed. +- Final signed-token privacy/evidence repair: 1 passed. +- Authorization coverage selection: 136 passed, 92.04 percent branch coverage. +- Actor coverage selection: 145 passed, 91.06 percent branch coverage. +- Live HTTP API contract drill: passed. +- Ruff, route/kernel/OpenAPI/audit checks, stale scans, Markdown links, merge + intent, diff integrity, and all 80 Agent Gates tests: passed. + +## Test Delta + +Tests add exact role/scope, cross-context, response, OpenAPI, audit, privacy, +self-target, missing-target, injected-failure, and two-session disabling-race +assertions. No test was skipped, weakened, or changed to conceal behavior. + +## CI Integrity + +The repository-wide 78 percent floor and focused 90 percent actor and +authorization floors are unchanged. No workflow, dependency, exclusion, or +threshold changed. GitHub Backend remains authoritative for the full suite. + +## Reviewer Results + +Senior engineering, QA/test, security/auth, product/ops, architecture, CI +integrity, docs, reuse/dedup, and test delta all pass the exact integrated repair +SHA `c47d6275c84e338e30f3d911d1cfdd9f1147fd86` after valid CodeRabbit and +internal-review findings were repaired. + +## External Review + +PR #146's original Backend, Agent Gates, and CodeRabbit checks passed. +CodeRabbit's five valid proof findings are repaired with exact transition +backend lock correlation, exact self-target and kernel assertions, both +missing-resource rollback routes, and both project-scoped Audit Authority +denials. Ten focused unit tests and two fresh real PostgreSQL lifecycle/race +tests pass after integrating trusted main. Exact integrated-head internal review +passes at `c47d627`; replacement external checks remain. The repair does not +change production behavior or weaken any test or threshold. + +## Remaining Risks + +The endpoints reveal bounded lifecycle timestamps and the closed local service +identity to system authorities by design. Exact external identity, provenance, +grant contents, and service authority remain concealed. + +## Follow-Up Work + +AUTH-09D owns lifecycle mutations only after this chunk merges, signed memory +passes, and the user gives a separate explicit start. AUTH-09E remains the +later fixed-service runtime-admission boundary. + +## Human Review Focus + +Review system-only role separation, caller/link/grant lock lifetime, response +privacy, self-target timestamp freshness, stable rollback behavior, exact audit +binding, and the absence of mutation or service admission. + +## Human Merge Ownership + +The agent may publish and repair this branch but may not merge it. Only the +human may approve and merge the PR. Trusted-main automation owns post-merge +schema-v2 memory; AUTH-09D does not start automatically. diff --git a/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/reviews/WS-AUTH-001-09C-preimplementation-plan-review.md b/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/reviews/WS-AUTH-001-09C-preimplementation-plan-review.md new file mode 100644 index 00000000..e41d6395 --- /dev/null +++ b/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/reviews/WS-AUTH-001-09C-preimplementation-plan-review.md @@ -0,0 +1,37 @@ +# WS-AUTH-001-09C Preimplementation Plan Review + +## Scope + +Privacy-bounded administrative reads for one exact ActorProfile and its single +v0.1 ActorIdentityLink. No list, mutation, project-scoped actor-registry +authority, service admission, or feature action activation is in scope. + +## Initial candidate + +- SHA: `9ea44c0a07b16126a6feed46e785ee5359baf680` +- Result: **FAIL** +- Runtime edits at review: none + +## Required repairs + +| Reviewer group | Required repair | +|---|---| +| security/auth and product/ops | Include exact audit parity and expose the closed local `service_identity` for service lifecycle targeting without external identity or matrix disclosure. | +| architecture/concurrency and reuse | Revalidate and lock caller profile, exact link, and matched grant through disclosure; enforce action-specific resource classes; reuse ActorService and ActorRepository target reads. | +| QA/CI and integration | Allow and run `test_audit.py`; require two-decimal 90 percent thresholds; add deterministic two-session race and strict API proof. | + +## Final candidate + +- SHA: `76c5427c5dedc0716d38c3fb1f9b1e45df8cdc8e` +- Result: **PASS** +- Runtime edits at review: none + +| Reviewer group | Result | Open finding | +|---|---|---| +| security/auth, roles, privacy, and product/ops | PASS | none | +| architecture, concurrency, service boundary, and reuse | PASS | none | +| QA/test, CI integrity, API contract, REV/CON isolation | PASS | none | + +The exact candidate also passed stale wording, authorization-doc consistency, +Markdown links, all agent-gate tests, and diff integrity. Runtime implementation +may proceed only within this reviewed AUTH-09C contract. diff --git a/.agent-loop/merge-intents/WS-AUTH-001-09C.json b/.agent-loop/merge-intents/WS-AUTH-001-09C.json new file mode 100644 index 00000000..a7c67a25 --- /dev/null +++ b/.agent-loop/merge-intents/WS-AUTH-001-09C.json @@ -0,0 +1,9 @@ +{ + "chunk_id": "WS-AUTH-001-09C", + "chunk_title": "Actor And Identity-Link Administration Reads", + "initiative_id": "WS-AUTH-001", + "next_chunk_id": "WS-AUTH-001-09D", + "next_chunk_title": "Actor And Identity-Link Lifecycle Mutations", + "next_requires_explicit_start": true, + "schema_version": 2 +} diff --git a/backend/app/modules/actors/schemas.py b/backend/app/modules/actors/schemas.py index 334a95ab..d6b7606c 100644 --- a/backend/app/modules/actors/schemas.py +++ b/backend/app/modules/actors/schemas.py @@ -4,9 +4,12 @@ from datetime import datetime from typing import Literal +from uuid import UUID from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator +from app.modules.actors.service_identities import ServiceIdentity + def normalize_skill_tags(value: list[str]) -> list[str]: """Return stable deduplicated legacy workflow skill tags.""" @@ -67,6 +70,49 @@ class ActorProfileSelfResponse(BaseModel): last_seen_at: datetime | None +class ActorProfileAdminResponse(BaseModel): + """Privacy-bounded administrative view of one canonical actor.""" + + model_config = ConfigDict(extra="forbid") + + actor_profile_id: UUID + actor_kind: Literal["human", "service"] + status: Literal["active", "suspended", "deactivated"] + provisioning_method: Literal[ + "automatic_first_access", + "manual_service_provisioning", + ] + service_identity: ServiceIdentity | None + display_name: str | None + created_at: datetime + updated_at: datetime + last_seen_at: datetime | None + suspended_at: datetime | None + deactivated_at: datetime | None + + @model_validator(mode="after") + def require_kind_identity_pair(self): + """Bind the closed local service identity to service actors only.""" + if (self.actor_kind == "service") != (self.service_identity is not None): + raise ValueError("actor kind and service identity are inconsistent") + return self + + +class ActorIdentityLinkAdminResponse(BaseModel): + """Privacy-bounded administrative view of one canonical identity link.""" + + model_config = ConfigDict(extra="forbid") + + identity_link_id: UUID + actor_profile_id: UUID + subject_kind: Literal["human", "service"] + status: Literal["active", "revoked"] + linked_at: datetime + last_verified_at: datetime | None + revoked_at: datetime | None + reactivated_at: datetime | None + + class LegacyWorkflowEligibilityActivationRequest(BaseModel): """Temporary non-authoritative intake metadata for existing task workflows.""" diff --git a/backend/app/modules/actors/service.py b/backend/app/modules/actors/service.py index 356152aa..441a67cc 100644 --- a/backend/app/modules/actors/service.py +++ b/backend/app/modules/actors/service.py @@ -19,6 +19,8 @@ ) from app.modules.actors.repository import ActorRepository from app.modules.actors.schemas import ( + ActorIdentityLinkAdminResponse, + ActorProfileAdminResponse, ActorProfileSelfResponse, ActorProfileUpdateRequest, LegacyWorkflowEligibilityActivationRequest, @@ -243,6 +245,47 @@ async def touch_after_authorization(self, resolved: ResolvedActor) -> ResolvedAc await self._session.refresh(resolved.identity_link) return resolved + async def read_admin_profile( + self, + actor_profile_id: UUID, + ) -> ActorProfileAdminResponse | None: + """Return one bounded actor view without changing target state.""" + profile = await self._repo.get_actor_profile(str(actor_profile_id)) + if profile is None: + return None + return ActorProfileAdminResponse( + actor_profile_id=UUID(profile.id), + actor_kind=profile.actor_kind, + status=profile.status, + provisioning_method=profile.provisioning_method, + service_identity=profile.service_identity, + display_name=profile.display_name, + created_at=profile.created_at, + updated_at=profile.updated_at, + last_seen_at=profile.last_seen_at, + suspended_at=profile.suspended_at, + deactivated_at=profile.deactivated_at, + ) + + async def read_admin_identity_link( + self, + actor_profile_id: UUID, + ) -> ActorIdentityLinkAdminResponse | None: + """Return one bounded link view selected by its canonical actor.""" + link = await self._repo.get_identity_link_for_actor(str(actor_profile_id)) + if link is None: + return None + return ActorIdentityLinkAdminResponse( + identity_link_id=UUID(link.id), + actor_profile_id=UUID(link.actor_profile_id), + subject_kind=link.subject_kind, + status=link.status, + linked_at=link.linked_at, + last_verified_at=link.last_verified_at, + revoked_at=link.revoked_at, + reactivated_at=link.reactivated_at, + ) + @staticmethod def actor_self_resource( actor_profile_id: str, diff --git a/backend/app/modules/authorization/catalogue.py b/backend/app/modules/authorization/catalogue.py index 698a72e7..4f86049a 100644 --- a/backend/app/modules/authorization/catalogue.py +++ b/backend/app/modules/authorization/catalogue.py @@ -267,7 +267,7 @@ def _active( PermissionId.ADMIN_ROLE_GRANT, ActionOwner.AUTH_08, ), - _planned( + _active( ActionId.ACTOR_PROFILE_READ, PermissionId.ACTOR_PROFILE_READ_ANY, ActionOwner.AUTH_09C, @@ -287,7 +287,7 @@ def _active( PermissionId.ACTOR_PROFILE_DEACTIVATE, ActionOwner.AUTH_09D, ), - _planned( + _active( ActionId.ACTOR_IDENTITY_LINK_READ, PermissionId.ACTOR_IDENTITY_LINK_READ, ActionOwner.AUTH_09C, @@ -578,6 +578,8 @@ def _index_actions( ActionId.ADMIN_ROLE_GRANT_ISSUE, ActionId.ADMIN_ROLE_GRANT_REVOKE, ActionId.ADMIN_ROLE_GRANT_BOOTSTRAP, + ActionId.ACTOR_PROFILE_READ, + ActionId.ACTOR_IDENTITY_LINK_READ, ActionId.ACTOR_SERVICE_PROVISION, } if { diff --git a/backend/app/modules/authorization/kernel.py b/backend/app/modules/authorization/kernel.py index 621f6248..e619af15 100644 --- a/backend/app/modules/authorization/kernel.py +++ b/backend/app/modules/authorization/kernel.py @@ -22,7 +22,9 @@ from app.modules.authorization.repository import AdminAuthorizationRepository from app.modules.authorization.runtime import ( ActorAdminRoleGrantHistoryResourceContext, + ActorIdentityLinkAdminReadResourceContext, ActorKind, + ActorProfileAdminReadResourceContext, ActorSelfResourceContext, ActorStatus, AdminRoleDefinitionsResourceContext, @@ -56,6 +58,14 @@ ActionId.ADMIN_ROLE_GRANT_REVOKE, ActionId.ADMIN_ROLE_GRANT_BOOTSTRAP, ActionId.ACTOR_SERVICE_PROVISION, + ActionId.ACTOR_PROFILE_READ, + ActionId.ACTOR_IDENTITY_LINK_READ, + } +) +_SERIALIZED_ADMIN_READS = frozenset( + { + ActionId.ACTOR_PROFILE_READ, + ActionId.ACTOR_IDENTITY_LINK_READ, } ) _ADMIN_MUTATIONS = frozenset( @@ -164,8 +174,10 @@ async def _admin_denial( return AuthorizationDenialCode.RESOURCE_GUARD_DENIED, context, None, None, False mutation = action.action_id in _ADMIN_MUTATIONS + serialized = mutation or action.action_id in _SERIALIZED_ADMIN_READS if mutation: await self._admin.lock_control() + if serialized: locked = await self._admin.lock_request_actor( context.identity_link_id, context.actor_profile_id, @@ -193,7 +205,7 @@ async def _admin_denial( action.permission_id, scope_project_id=project_id, system_scope_only=system_only, - for_update=mutation, + for_update=serialized, ) if matched is None: if project_id is not None and await self._admin.has_effective_permission_any_scope( @@ -203,7 +215,7 @@ async def _admin_denial( denial = AuthorizationDenialCode.SCOPE_NOT_AUTHORIZED else: denial = AuthorizationDenialCode.PERMISSION_NOT_GRANTED - return denial, context, None, None, mutation + return denial, context, None, None, serialized denial = await self._admin_guard(action.action_id, resource, context) return ( @@ -211,7 +223,7 @@ async def _admin_denial( context, matched.id if denial is None else None, project_id if denial is None else None, - mutation, + serialized, ) async def _admin_guard( @@ -266,6 +278,8 @@ def _admin_resource_matches( ActionId.ADMIN_ROLE_GRANT_ISSUE: AdminRoleGrantIssueResourceContext, ActionId.ADMIN_ROLE_GRANT_REVOKE: AdminRoleGrantResourceContext, ActionId.ACTOR_SERVICE_PROVISION: ServiceActorProvisionResourceContext, + ActionId.ACTOR_PROFILE_READ: ActorProfileAdminReadResourceContext, + ActionId.ACTOR_IDENTITY_LINK_READ: ActorIdentityLinkAdminReadResourceContext, }.get(action_id) return expected is not None and isinstance(resource, expected) diff --git a/backend/app/modules/authorization/router.py b/backend/app/modules/authorization/router.py index c938ab39..b6c2f661 100644 --- a/backend/app/modules/authorization/router.py +++ b/backend/app/modules/authorization/router.py @@ -18,6 +18,10 @@ from app.db.session import get_db_session from app.interfaces.auth import AuthVerificationUnavailableError, AuthVerifier from app.modules.actors.service import ActorService, ResolvedActor +from app.modules.actors.schemas import ( + ActorIdentityLinkAdminResponse, + ActorProfileAdminResponse, +) from app.modules.authorization.admin_schemas import ( AdminRoleDefinitionsResponse, AdminRoleGrantCollectionResponse, @@ -34,6 +38,8 @@ from app.modules.authorization.kernel import AuthorizationService from app.modules.authorization.runtime import ( ActorAdminRoleGrantHistoryResourceContext, + ActorIdentityLinkAdminReadResourceContext, + ActorProfileAdminReadResourceContext, AdminRoleDefinitionsResourceContext, AdminRoleGrantCollectionResourceContext, AdminRoleGrantIssueResourceContext, @@ -74,6 +80,10 @@ def _domain_error(status_code: int, code: str, message: str) -> StructuredHTTPEx ) +def _actor_resource_not_found() -> StructuredHTTPException: + return _domain_error(404, "actor_resource_not_found", "Actor resource not found") + + def _scope_resource_id(scope_type: AdminScope, project_id: UUID | None): if scope_type is AdminScope.SYSTEM and project_id is None: return "workstream:admin_role_grants" @@ -291,6 +301,87 @@ def _service_actor_conflict(conflict: ServiceActorConflict) -> StructuredHTTPExc return _domain_error(409, conflict.value, "Identity subject is already linked") +@router.get( + "/actors/{actor_profile_id}", + response_model=ActorProfileAdminResponse, + responses={404: {"model": ApiErrorResponse, "description": "Actor resource not found."}}, + openapi_extra={"x-workstream-action-id": ActionId.ACTOR_PROFILE_READ.value}, +) +async def read_actor_profile( + actor_profile_id: UUID, + resolved: Annotated[ResolvedActor, Depends(get_authorization_actor)], + authorization: Annotated[AuthorizationService, Depends(get_authorization_service)], + session: Annotated[AsyncSession, Depends(get_db_session)], +) -> ActorProfileAdminResponse: + await _database_call( + session, + authorization.require( + ActionId.ACTOR_PROFILE_READ, + ActorProfileAdminReadResourceContext( + resource_type="actor_profile", + resource_id=actor_profile_id, + read_kind="profile", + ), + ), + ) + response = await _database_call( + session, + ActorService(session).read_admin_profile(actor_profile_id), + ) + if response is None: + await session.rollback() + raise _actor_resource_not_found() + await _database_call(session, ActorService(session).touch_after_authorization(resolved)) + if actor_profile_id == UUID(resolved.profile.id): + response = response.model_copy( + update={ + "updated_at": resolved.profile.updated_at, + "last_seen_at": resolved.profile.last_seen_at, + } + ) + await _commit_or_unavailable(session) + return response + + +@router.get( + "/actors/{actor_profile_id}/identity-links", + response_model=ActorIdentityLinkAdminResponse, + responses={404: {"model": ApiErrorResponse, "description": "Actor resource not found."}}, + openapi_extra={"x-workstream-action-id": ActionId.ACTOR_IDENTITY_LINK_READ.value}, +) +async def read_actor_identity_link( + actor_profile_id: UUID, + resolved: Annotated[ResolvedActor, Depends(get_authorization_actor)], + authorization: Annotated[AuthorizationService, Depends(get_authorization_service)], + session: Annotated[AsyncSession, Depends(get_db_session)], +) -> ActorIdentityLinkAdminResponse: + await _database_call( + session, + authorization.require( + ActionId.ACTOR_IDENTITY_LINK_READ, + ActorIdentityLinkAdminReadResourceContext( + resource_type="actor_profile", + resource_id=actor_profile_id, + read_kind="identity_link", + ), + ), + ) + response = await _database_call( + session, + ActorService(session).read_admin_identity_link(actor_profile_id), + ) + if response is None: + await session.rollback() + raise _actor_resource_not_found() + await _database_call(session, ActorService(session).touch_after_authorization(resolved)) + if actor_profile_id == UUID(resolved.profile.id): + response = response.model_copy( + update={"last_verified_at": resolved.identity_link.last_verified_at} + ) + await _commit_or_unavailable(session) + return response + + @router.get( "/authorization/permissions", response_model=PermissionDefinitionsResponse, diff --git a/backend/app/modules/authorization/runtime.py b/backend/app/modules/authorization/runtime.py index 309919ad..061aa595 100644 --- a/backend/app/modules/authorization/runtime.py +++ b/backend/app/modules/authorization/runtime.py @@ -69,6 +69,24 @@ def require_unique_fields(self): return self +class ActorProfileAdminReadResourceContext(BaseModel): + """Server-composed selector for one administrative actor-profile read.""" + + model_config = _STRICT_FROZEN + resource_type: Literal["actor_profile"] + resource_id: UUID + read_kind: Literal["profile"] + + +class ActorIdentityLinkAdminReadResourceContext(BaseModel): + """Server-composed selector for one actor's administrative link read.""" + + model_config = _STRICT_FROZEN + resource_type: Literal["actor_profile"] + resource_id: UUID + read_kind: Literal["identity_link"] + + class SystemResourceContext(BaseModel): """Non-authoritative placeholder for later fixed system actions.""" @@ -174,6 +192,8 @@ class ServiceActorProvisionResourceContext(BaseModel): AuthorizationResourceContext = ( ActorSelfResourceContext + | ActorProfileAdminReadResourceContext + | ActorIdentityLinkAdminReadResourceContext | SystemResourceContext | PermissionCatalogueResourceContext | AdminRoleDefinitionsResourceContext diff --git a/backend/scripts/api_contract_e2e.py b/backend/scripts/api_contract_e2e.py index 532e9b34..148dfd4a 100644 --- a/backend/scripts/api_contract_e2e.py +++ b/backend/scripts/api_contract_e2e.py @@ -1010,6 +1010,30 @@ async def exercise_api_contract(base_url: str, env: dict[str, str]) -> None: ) assert replayed_service.status_code == 201, replayed_service.text assert replayed_service.json() == provisioned_body + service_actor_id = provisioned_body["actor_profile_id"] + service_admin_profile = await request_json( + client, + "GET", + f"/api/v1/actors/{service_actor_id}", + manager_token, + ) + service_admin_link = await request_json( + client, + "GET", + f"/api/v1/actors/{service_actor_id}/identity-links", + manager_token, + ) + assert service_admin_profile["service_identity"] == service_payload["service_identity"] + assert service_admin_profile["actor_kind"] == "service" + assert service_admin_profile["last_seen_at"] is None + assert service_admin_link["subject_kind"] == "service" + assert service_admin_link["last_verified_at"] is None + serialized_service_reads = json.dumps( + [service_admin_profile, service_admin_link], + sort_keys=True, + ) + assert service_payload["subject"] not in serialized_service_reads + assert flow_issuer not in serialized_service_reads project = await request_json( client, @@ -1139,6 +1163,27 @@ async def exercise_api_contract(base_url: str, env: dict[str, str]) -> None: assert "issuer" not in canonical_actor assert "subject" not in canonical_actor assert "roles" not in canonical_actor + worker_admin_profile = await request_json( + client, + "GET", + f"/api/v1/actors/{canonical_actor['actor_profile_id']}", + manager_token, + ) + worker_admin_link = await request_json( + client, + "GET", + f"/api/v1/actors/{canonical_actor['actor_profile_id']}/identity-links", + manager_token, + ) + assert worker_admin_profile["actor_kind"] == "human" + assert worker_admin_profile["service_identity"] is None + assert worker_admin_link["subject_kind"] == "human" + serialized_worker_reads = json.dumps( + [worker_admin_profile, worker_admin_link], + sort_keys=True, + ) + assert worker_subject not in serialized_worker_reads + assert flow_issuer not in serialized_worker_reads updated_actor = await request_json( client, "PATCH", diff --git a/backend/tests/test_actors.py b/backend/tests/test_actors.py index e5bd006a..1bbc1913 100644 --- a/backend/tests/test_actors.py +++ b/backend/tests/test_actors.py @@ -5,6 +5,7 @@ from datetime import UTC, datetime import hashlib from pathlib import Path +from typing import cast from uuid import uuid4 from alembic import command @@ -28,6 +29,7 @@ ) from app.modules.actors.repository import ActorRepository from app.modules.actors.schemas import ( + ActorProfileAdminResponse, ActorProfileUpdateRequest, LegacyWorkflowEligibilityActivationRequest, ) @@ -41,6 +43,7 @@ ServiceActorNotProvisioned, UnsupportedSubjectKind, ) +from app.modules.actors.service_identities import ServiceIdentity from app.modules.api_controls.service import RateControlDecision, RateControlUnavailableError from app.modules.audit.service import AuditService from app.modules.authorization.catalogue import ActionId @@ -56,6 +59,124 @@ RATE_SECRET = "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8=" +def test_actor_admin_response_requires_exact_service_identity_pair() -> None: + now = datetime.now(UTC) + common = { + "actor_profile_id": uuid4(), + "status": "active", + "display_name": None, + "created_at": now, + "updated_at": now, + "last_seen_at": None, + "suspended_at": None, + "deactivated_at": None, + } + human = ActorProfileAdminResponse( + **common, + actor_kind="human", + provisioning_method="automatic_first_access", + service_identity=None, + ) + service = ActorProfileAdminResponse( + **common, + actor_kind="service", + provisioning_method="manual_service_provisioning", + service_identity=ServiceIdentity.ARTIFACT_VERIFIER, + ) + + assert human.service_identity is None + assert service.service_identity is ServiceIdentity.ARTIFACT_VERIFIER + for actor_kind, provisioning_method, service_identity in ( + ("human", "automatic_first_access", ServiceIdentity.ARTIFACT_VERIFIER), + ("service", "manual_service_provisioning", None), + ): + with pytest.raises(ValidationError, match="service identity"): + ActorProfileAdminResponse( + **common, + actor_kind=actor_kind, + provisioning_method=provisioning_method, + service_identity=service_identity, + ) + + +async def test_actor_admin_reads_are_bounded_and_reuse_exact_repository_lookups() -> None: + now = datetime.now(UTC) + actor_id, link_id = uuid4(), uuid4() + profile = ActorProfile( + id=str(actor_id), + actor_kind="service", + status="active", + provisioning_method="manual_service_provisioning", + service_identity=ServiceIdentity.ARTIFACT_VERIFIER.value, + display_name=None, + contact_email="must-not-escape@example.test", + created_by=str(uuid4()), + created_at=now, + updated_at=now, + last_seen_at=None, + ) + link = ActorIdentityLink( + id=str(link_id), + actor_profile_id=str(actor_id), + issuer="private-issuer", + subject="private-subject", + subject_kind="service", + status="active", + linked_by=str(uuid4()), + linked_at=now, + last_verified_at=None, + ) + + class Repository: + calls: list[tuple[str, str]] = [] + + async def get_actor_profile(self, requested_id): + self.calls.append(("profile", requested_id)) + return profile + + async def get_identity_link_for_actor(self, requested_id): + self.calls.append(("link", requested_id)) + return link + + repository = Repository() + service = ActorService.__new__(ActorService) + service._repo = cast(ActorRepository, repository) + + profile_response = await service.read_admin_profile(actor_id) + link_response = await service.read_admin_identity_link(actor_id) + + assert profile_response is not None + assert link_response is not None + assert repository.calls == [("profile", str(actor_id)), ("link", str(actor_id))] + assert set(profile_response.model_dump()) == { + "actor_profile_id", + "actor_kind", + "status", + "provisioning_method", + "service_identity", + "display_name", + "created_at", + "updated_at", + "last_seen_at", + "suspended_at", + "deactivated_at", + } + assert set(link_response.model_dump()) == { + "identity_link_id", + "actor_profile_id", + "subject_kind", + "status", + "linked_at", + "last_verified_at", + "revoked_at", + "reactivated_at", + } + serialized = repr((profile_response.model_dump(), link_response.model_dump())) + assert "private-issuer" not in serialized + assert "private-subject" not in serialized + assert "must-not-escape@example.test" not in serialized + + def test_service_identity_lock_has_a_distinct_domain_without_changing_external_keys() -> None: issuer = "service_identity" subject = "workstream.artifact.verifier" diff --git a/backend/tests/test_api_controls.py b/backend/tests/test_api_controls.py index ce6485c2..25ef9e0c 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) == 55 + assert len(route_inventory) == 57 assert sha256("\n".join(route_inventory).encode()).hexdigest() == ( - "2e9a0023e08fa1ff58fd1d8a60843eef8c83f5f6f351f9664400d27e72b22df0" + "03f8b0166ded1cf563acfd32443aeefc3f7331eb59a46756ab4f551c6d673dc1" ) - assert len(protected_inventory) == 53 + assert len(protected_inventory) == 55 assert sha256("\n".join(protected_inventory).encode()).hexdigest() == ( - "ed0ae2fd8fabeb2745c998a6d473188ca2d39ced58c0d545b105c853d31a0b95" + "229a935e1d17697fd0b315ac4f8638644b1fdd7b6cf48a372e5dd2b584eef02c" ) assert set(schema["paths"]["/health"]["get"]["responses"]) == {"200", "400", "500"} assert {"401", "403", "503"} <= set( @@ -473,6 +473,8 @@ def test_openapi_documents_request_error_and_response_context() -> None: assert action_declarations == { "GET /api/v1/actors/me": "actor.profile.read_self", "PATCH /api/v1/actors/me": "actor.profile.update_self", + "GET /api/v1/actors/{actor_profile_id}": "actor.profile.read", + "GET /api/v1/actors/{actor_profile_id}/identity-links": "actor.identity_link.read", "POST /api/v1/service-actors": "actor.service.provision", "GET /api/v1/authorization/permissions": "authorization.permission_catalogue.read", "GET /api/v1/authorization/admin-role-definitions": ( @@ -485,6 +487,21 @@ def test_openapi_documents_request_error_and_response_context() -> None: ), "POST /api/v1/admin-role-grants/{grant_id}/revoke": "admin_role_grant.revoke", } + for path, schema_name in ( + ("/api/v1/actors/{actor_profile_id}", "ActorProfileAdminResponse"), + ( + "/api/v1/actors/{actor_profile_id}/identity-links", + "ActorIdentityLinkAdminResponse", + ), + ): + operation = schema["paths"][path]["get"] + assert operation["responses"]["200"]["content"]["application/json"]["schema"] == { + "$ref": f"#/components/schemas/{schema_name}" + } + assert operation["responses"]["404"]["content"]["application/json"]["schema"] == { + "$ref": "#/components/schemas/ApiErrorResponse" + } + assert schema["components"]["schemas"][schema_name]["additionalProperties"] is False assert not any("bootstrap" in path for path in schema["paths"]) assert {"404", "409"} <= set( schema["paths"]["/api/v1/projects/{project_id}/guides/{guide_id}/activate"][ diff --git a/backend/tests/test_audit.py b/backend/tests/test_audit.py index b641432a..c085d045 100644 --- a/backend/tests/test_audit.py +++ b/backend/tests/test_audit.py @@ -191,6 +191,8 @@ def test_action_aware_audit_input_enforces_mapping_and_action_availability() -> ActionId.ADMIN_ROLE_GRANT_ISSUE, ActionId.ADMIN_ROLE_GRANT_REVOKE, ActionId.ADMIN_ROLE_GRANT_BOOTSTRAP, + ActionId.ACTOR_PROFILE_READ, + ActionId.ACTOR_IDENTITY_LINK_READ, ActionId.ACTOR_SERVICE_PROVISION, } with pytest.raises(TypeError, match="invalid authority audit input"): diff --git a/backend/tests/test_auth.py b/backend/tests/test_auth.py index 1b5eb0ab..a5bcaea6 100644 --- a/backend/tests/test_auth.py +++ b/backend/tests/test_auth.py @@ -1620,6 +1620,7 @@ async def test_signed_tokens_bootstrap_and_admin_grant_lifecycle( auth_database_env: str, rsa_signing_material: tuple[rsa.RSAPrivateKey, dict[str, Any]], monkeypatch: pytest.MonkeyPatch, + caplog: pytest.LogCaptureFixture, ) -> None: """Prove the supported bootstrap, grant, replay, scope, and revoke flow.""" private_key, jwk = rsa_signing_material @@ -1646,6 +1647,41 @@ async def test_signed_tokens_bootstrap_and_admin_grant_lifecycle( audit_headers = {"Authorization": f"Bearer {audit_token}"} original_commit = AsyncSession.commit fail_feature_commit = False + fail_evidence_action: ActionId | None = None + fail_target_lookup: str | None = None + fail_touch = False + original_add_authority_event = AuditService.add_authority_event + original_profile_read = ActorService.read_admin_profile + original_link_read = ActorService.read_admin_identity_link + original_touch = ActorService.touch_after_authorization + + async def add_authority_event_with_failure(service, value): + nonlocal fail_evidence_action + if fail_evidence_action is not None and value.action_id is fail_evidence_action: + fail_evidence_action = None + raise SQLAlchemyError("forced authorization evidence failure") + return await original_add_authority_event(service, value) + + async def profile_read_with_failure(service, actor_profile_id): + nonlocal fail_target_lookup + if fail_target_lookup == "profile": + fail_target_lookup = None + raise SQLAlchemyError("forced profile lookup failure") + return await original_profile_read(service, actor_profile_id) + + async def link_read_with_failure(service, actor_profile_id): + nonlocal fail_target_lookup + if fail_target_lookup == "identity_link": + fail_target_lookup = None + raise SQLAlchemyError("forced identity-link lookup failure") + return await original_link_read(service, actor_profile_id) + + async def touch_with_failure(service, resolved): + nonlocal fail_touch + if fail_touch: + fail_touch = False + raise SQLAlchemyError("forced caller timestamp touch failure") + return await original_touch(service, resolved) async def commit_with_one_feature_failure(session: AsyncSession) -> None: nonlocal fail_feature_commit @@ -1664,6 +1700,10 @@ async def commit_with_one_feature_failure(session: AsyncSession) -> None: await original_commit(session) monkeypatch.setattr(AsyncSession, "commit", commit_with_one_feature_failure) + monkeypatch.setattr(AuditService, "add_authority_event", add_authority_event_with_failure) + monkeypatch.setattr(ActorService, "read_admin_profile", profile_read_with_failure) + monkeypatch.setattr(ActorService, "read_admin_identity_link", link_read_with_failure) + monkeypatch.setattr(ActorService, "touch_after_authorization", touch_with_failure) async def actor_state(actor_id: UUID) -> tuple: async with db_session.get_session_factory()() as session: @@ -1752,9 +1792,17 @@ def assert_retryable_service_unavailable(response: Response) -> None: "suspended": uuid4(), "no_active_link": uuid4(), } + private_provenance = { + "created_by": f"auth09c-created-by-{uuid4()}", + "linked_by": f"auth09c-linked-by-{uuid4()}", + "lifecycle_by": f"auth09c-lifecycle-by-{uuid4()}", + } project_one, project_two = uuid4(), uuid4() now = datetime.now(UTC) async with db_session.get_session_factory()() as session: + target_row = await session.get(ActorProfile, str(target_id)) + assert target_row is not None + target_row.contact_email = "auth09c-private-contact@example.test" session.add_all( [ Project( @@ -1775,15 +1823,15 @@ def assert_retryable_service_unavailable(response: Response) -> None: status="active", provisioning_method="manual_service_provisioning", service_identity="workstream.artifact.verifier", - created_by=str(admin_id), + created_by=private_provenance["created_by"], ), ActorProfile( id=str(concealed_targets["suspended"]), actor_kind="human", status="suspended", provisioning_method="automatic_first_access", - created_by=str(admin_id), - suspended_by=str(admin_id), + created_by=private_provenance["created_by"], + suspended_by=private_provenance["lifecycle_by"], suspended_at=now, suspension_reason="Concealment fixture", ), @@ -1792,7 +1840,7 @@ def assert_retryable_service_unavailable(response: Response) -> None: actor_kind="human", status="active", provisioning_method="automatic_first_access", - created_by=str(admin_id), + created_by=private_provenance["created_by"], ), ] ) @@ -1806,7 +1854,7 @@ def assert_retryable_service_unavailable(response: Response) -> None: subject="auth08-service-target", subject_kind="service", status="active", - linked_by=str(admin_id), + linked_by=private_provenance["linked_by"], ), ActorIdentityLink( id=str(uuid4()), @@ -1815,7 +1863,7 @@ def assert_retryable_service_unavailable(response: Response) -> None: subject="auth08-suspended-target", subject_kind="human", status="active", - linked_by=str(admin_id), + linked_by=private_provenance["linked_by"], last_verified_at=now, ), ActorIdentityLink( @@ -1825,9 +1873,9 @@ def assert_retryable_service_unavailable(response: Response) -> None: subject="auth08-revoked-link-target", subject_kind="human", status="revoked", - linked_by=str(admin_id), + linked_by=private_provenance["linked_by"], last_verified_at=now, - revoked_by=str(admin_id), + revoked_by=private_provenance["lifecycle_by"], revoked_at=now, revoked_reason="Concealment fixture", ), @@ -1835,6 +1883,245 @@ def assert_retryable_service_unavailable(response: Response) -> None: ) await session.commit() + profile_fields = { + "actor_profile_id", + "actor_kind", + "status", + "provisioning_method", + "service_identity", + "display_name", + "created_at", + "updated_at", + "last_seen_at", + "suspended_at", + "deactivated_at", + } + link_fields = { + "identity_link_id", + "actor_profile_id", + "subject_kind", + "status", + "linked_at", + "last_verified_at", + "revoked_at", + "reactivated_at", + } + caplog.clear() + caplog.set_level(logging.DEBUG, logger="app") + before_admin_actor_read = await actor_state(admin_id) + before_target_actor_read = await actor_state(target_id) + target_admin_profile = await client.get( + f"/api/v1/actors/{target_id}", + headers=admin_headers, + ) + target_admin_link = await client.get( + f"/api/v1/actors/{target_id}/identity-links", + headers=admin_headers, + ) + service_admin_profile = await client.get( + f"/api/v1/actors/{concealed_targets['service']}", + headers=admin_headers, + ) + service_admin_link = await client.get( + f"/api/v1/actors/{concealed_targets['service']}/identity-links", + headers=admin_headers, + ) + suspended_admin_profile = await client.get( + f"/api/v1/actors/{concealed_targets['suspended']}", + headers=admin_headers, + ) + revoked_admin_link = await client.get( + f"/api/v1/actors/{concealed_targets['no_active_link']}/identity-links", + headers=admin_headers, + ) + assert [ + response.status_code + for response in ( + target_admin_profile, + target_admin_link, + service_admin_profile, + service_admin_link, + suspended_admin_profile, + revoked_admin_link, + ) + ] == [200] * 6 + assert set(target_admin_profile.json()) == profile_fields + assert set(target_admin_link.json()) == link_fields + assert target_admin_profile.json()["actor_kind"] == "human" + assert target_admin_profile.json()["service_identity"] is None + assert service_admin_profile.json()["actor_kind"] == "service" + assert service_admin_profile.json()["service_identity"] == ( + ServiceIdentity.ARTIFACT_VERIFIER.value + ) + assert service_admin_profile.json()["last_seen_at"] is None + assert service_admin_link.json()["last_verified_at"] is None + assert suspended_admin_profile.json()["status"] == "suspended" + assert revoked_admin_link.json()["status"] == "revoked" + serialized_admin_reads = json.dumps( + [ + target_admin_profile.json(), + target_admin_link.json(), + service_admin_profile.json(), + service_admin_link.json(), + suspended_admin_profile.json(), + revoked_admin_link.json(), + ], + sort_keys=True, + ) + for private_value in ( + "auth08-target", + "auth08-service-target", + "auth08-suspended-target", + "auth08-revoked-link-target", + "https://identity.test", + "Concealment fixture", + "auth09c-private-contact@example.test", + admin_token, + target_token, + bootstrap["grant_id"], + *private_provenance.values(), + ): + assert private_value not in serialized_admin_reads + assert private_value not in caplog.text + assert await actor_state(target_id) == before_target_actor_read + after_admin_actor_read = await actor_state(admin_id) + assert after_admin_actor_read[1] > before_admin_actor_read[1] + assert after_admin_actor_read[2] > before_admin_actor_read[2] + + async with db_session.get_session_factory()() as session: + target_allow_events = ( + await session.scalars( + select(AuditEvent).where( + AuditEvent.event_type == "SensitiveAuthorizationAllowed", + AuditEvent.action_id.in_( + ("actor.profile.read", "actor.identity_link.read") + ), + AuditEvent.resource_id == str(target_id), + ) + ) + ).all() + assert len(target_allow_events) == 2 + expected_evidence = { + "actor.profile.read": ("actor.profile.read_any", target_admin_profile), + "actor.identity_link.read": ("actor.identity_link.read", target_admin_link), + } + assert {event.action_id for event in target_allow_events} == set(expected_evidence) + for event in target_allow_events: + expected_permission, route_response = expected_evidence[event.action_id] + assert event.permission_id == expected_permission + assert event.actor_id == str(admin_id) + assert event.target_actor_ref == str(target_id) + assert event.target_ref_id == str(target_id) + assert event.matched_grant_id == bootstrap["grant_id"] + assert event.project_id is None + assert event.after_facts == {"allowed": True} + assert UUID(str(event.request_id)) == UUID(route_response.headers["x-request-id"]) + assert UUID(str(event.correlation_id)) == UUID( + route_response.headers["x-correlation-id"] + ) + + async def actor_admin_state() -> tuple[datetime, datetime, datetime]: + async with db_session.get_session_factory()() as session: + return tuple( + ( + await session.execute( + text( + "select p.updated_at,p.last_seen_at,l.last_verified_at " + "from actor_profiles p join actor_identity_links l " + "on l.actor_profile_id=p.id where p.id=:actor" + ), + {"actor": str(admin_id)}, + ) + ).one() + ) + + before_self_profile = await actor_admin_state() + before_self_profile_counts = await authority_counts() + self_profile = await client.get( + f"/api/v1/actors/{admin_id}", + headers=admin_headers, + ) + assert self_profile.status_code == 200, self_profile.text + after_self_profile = await actor_admin_state() + assert after_self_profile[0] > before_self_profile[0] + assert after_self_profile[1] > before_self_profile[1] + assert after_self_profile[2] > before_self_profile[2] + assert datetime.fromisoformat(self_profile.json()["updated_at"]) == after_self_profile[0] + assert datetime.fromisoformat(self_profile.json()["last_seen_at"]) == after_self_profile[1] + after_self_profile_counts = await authority_counts() + assert after_self_profile_counts[:2] == before_self_profile_counts[:2] + assert after_self_profile_counts[2] == before_self_profile_counts[2] + 1 + + before_self_link = await actor_admin_state() + before_self_link_counts = await authority_counts() + self_link = await client.get( + f"/api/v1/actors/{admin_id}/identity-links", + headers=admin_headers, + ) + assert self_link.status_code == 200, self_link.text + after_self_link = await actor_admin_state() + assert after_self_link[0] > before_self_link[0] + assert after_self_link[1] > before_self_link[1] + assert after_self_link[2] > before_self_link[2] + assert datetime.fromisoformat(self_link.json()["last_verified_at"]) == after_self_link[2] + after_self_link_counts = await authority_counts() + assert after_self_link_counts[:2] == before_self_link_counts[:2] + assert after_self_link_counts[2] == before_self_link_counts[2] + 1 + + before_missing_reads = await actor_state(admin_id) + before_missing_authority_counts = await authority_counts() + absent_id = uuid4() + missing_profile = await client.get( + f"/api/v1/actors/{absent_id}", + headers=admin_headers, + ) + missing_link = await client.get( + f"/api/v1/actors/{absent_id}/identity-links", + headers=admin_headers, + ) + assert missing_profile.status_code == missing_link.status_code == 404 + concealed_missing_bodies = [] + for response in (missing_profile, missing_link): + body = response.json() + UUID(body["error"].pop("correlation_id")) + concealed_missing_bodies.append(body) + assert concealed_missing_bodies[0] == concealed_missing_bodies[1] + assert concealed_missing_bodies[0]["error"]["code"] == "actor_resource_not_found" + assert await actor_state(admin_id) == before_missing_reads + assert await authority_counts() == before_missing_authority_counts + + async def assert_failed_admin_read(path: str) -> None: + before_failure_state = await actor_state(admin_id) + before_failure_counts = await authority_counts() + failed_response = await client.get(path, headers=admin_headers) + assert_retryable_service_unavailable(failed_response) + assert await actor_state(admin_id) == before_failure_state + assert await authority_counts() == before_failure_counts + + profile_path = f"/api/v1/actors/{target_id}" + link_path = f"/api/v1/actors/{target_id}/identity-links" + for action_id, path in ( + (ActionId.ACTOR_PROFILE_READ, profile_path), + (ActionId.ACTOR_IDENTITY_LINK_READ, link_path), + ): + fail_evidence_action = action_id + await assert_failed_admin_read(path) + + for lookup_kind, path in ( + ("profile", profile_path), + ("identity_link", link_path), + ): + fail_target_lookup = lookup_kind + await assert_failed_admin_read(path) + + for path in (profile_path, link_path): + fail_touch = True + await assert_failed_admin_read(path) + + for path in (profile_path, link_path): + fail_feature_commit = True + await assert_failed_admin_read(path) + before_read = await actor_state(admin_id) fail_feature_commit = True failed_read = await client.get( @@ -2071,6 +2358,14 @@ def assert_retryable_service_unavailable(response: Response) -> None: headers=audit_headers, params={"scope_type": "project", "scope_project_id": str(project_one)}, ) + project_audit_actor_read = await client.get( + f"/api/v1/actors/{target_id}", + headers=audit_headers, + ) + project_audit_link_read = await client.get( + f"/api/v1/actors/{target_id}/identity-links", + headers=audit_headers, + ) audit_wrong_scope = await client.get( "/api/v1/admin-role-grants", headers=audit_headers, @@ -2098,6 +2393,9 @@ def assert_retryable_service_unavailable(response: Response) -> None: str(project_one) } assert audit_wrong_scope.status_code == audit_mutation.status_code == 403 + for project_audit_read in (project_audit_actor_read, project_audit_link_read): + assert project_audit_read.status_code == 403 + assert project_audit_read.json()["error"]["code"] == "permission_not_granted" assert audit_wrong_scope.json()["error"]["code"] == "scope_not_authorized" assert audit_mutation.json()["error"]["code"] == "permission_not_granted" assert audit_history_visible.status_code == 200 @@ -2134,8 +2432,13 @@ def assert_retryable_service_unavailable(response: Response) -> None: headers=audit_headers, params={"scope_type": "system", "status": "all"}, ), + await client.get(f"/api/v1/actors/{target_id}", headers=audit_headers), + await client.get( + f"/api/v1/actors/{target_id}/identity-links", + headers=audit_headers, + ), ] - assert [response.status_code for response in system_audit_reads] == [200] * 4 + assert [response.status_code for response in system_audit_reads] == [200] * 6 assert system_audit_reads[0].json()["total"] == 74 assert system_audit_reads[1].json()["total"] == 5 assert system_audit_reads[2].json()["total"] == 2 @@ -2955,6 +3258,281 @@ async def ordered_lock_control(self): await db_session.dispose_engine() +async def test_actor_admin_reads_hold_caller_and_grant_locks_through_disclosure( + auth_database_env: str, + rsa_signing_material: tuple[rsa.RSAPrivateKey, dict[str, Any]], + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Prove lifecycle and grant changes serialize after both bounded reads.""" + private_key, jwk = rsa_signing_material + settings = production_verifier_settings(database_url=auth_database_env) + app = create_app(settings) + app.state.auth_verifier = FlowAuthVerifier(settings, jwks_transport=jwks_transport(jwk)) + reader_token = issue_asymmetric_token( + private_key, + claims={"sub": "auth09c-profile-reader", "jti": "auth09c-profile-reader-token"}, + ) + link_reader_token = issue_asymmetric_token( + private_key, + claims={"sub": "auth09c-link-reader", "jti": "auth09c-link-reader-token"}, + ) + custodian_token = issue_asymmetric_token( + private_key, + claims={"sub": "auth09c-custodian", "jti": "auth09c-custodian-token"}, + ) + target_token = issue_asymmetric_token( + private_key, + claims={"sub": "auth09c-target", "jti": "auth09c-target-token"}, + ) + reader_headers = {"Authorization": f"Bearer {reader_token}"} + link_reader_headers = {"Authorization": f"Bearer {link_reader_token}"} + custodian_headers = {"Authorization": f"Bearer {custodian_token}"} + pause_kind: str | None = None + entered = asyncio.Event() + release = asyncio.Event() + transition_backend_pids: asyncio.Queue[int] = asyncio.Queue() + captured_grant_transition_tasks: set[asyncio.Task] = set() + original_profile_read = ActorService.read_admin_profile + original_link_read = ActorService.read_admin_identity_link + original_get_grant = AdminAuthorizationRepository.get_grant + + async def pause_profile_read(service: ActorService, actor_profile_id: UUID): + if pause_kind == "profile": + entered.set() + await release.wait() + return await original_profile_read(service, actor_profile_id) + + async def pause_link_read(service: ActorService, actor_profile_id: UUID): + if pause_kind == "identity_link": + entered.set() + await release.wait() + return await original_link_read(service, actor_profile_id) + + async def capture_grant_transition_backend(repository, *args, **kwargs): + task = asyncio.current_task() + if ( + task is not None + and task.get_name() == "auth09c-grant-revocation-transition" + and task not in captured_grant_transition_tasks + ): + captured_grant_transition_tasks.add(task) + backend_pid = await repository._session.scalar(text("select pg_backend_pid()")) + transition_backend_pids.put_nowait(int(backend_pid)) + return await original_get_grant(repository, *args, **kwargs) + + monkeypatch.setattr(ActorService, "read_admin_profile", pause_profile_read) + monkeypatch.setattr(ActorService, "read_admin_identity_link", pause_link_read) + monkeypatch.setattr( + AdminAuthorizationRepository, + "get_grant", + capture_grant_transition_backend, + ) + + async def capture_transition_backend(session: AsyncSession) -> None: + backend_pid = await session.scalar(text("select pg_backend_pid()")) + transition_backend_pids.put_nowait(int(backend_pid)) + + async def actor_timestamps(actor_id: UUID) -> tuple[datetime | None, datetime | None]: + async with db_session.get_session_factory()() as session: + return tuple( + ( + await session.execute( + text( + "select p.last_seen_at,l.last_verified_at from actor_profiles p " + "join actor_identity_links l on l.actor_profile_id=p.id " + "where p.id=:actor" + ), + {"actor": str(actor_id)}, + ) + ).one() + ) + + async def set_profile_state(actor_id: UUID, custodian_id: UUID, state: str) -> None: + async with db_session.get_session_factory()() as session: + await capture_transition_backend(session) + if state == "suspended": + statement = ( + "update actor_profiles set status='suspended',suspended_by=:by," + "suspended_at=clock_timestamp(),suspension_reason='race proof' " + "where id=:actor" + ) + else: + statement = ( + "update actor_profiles set status='deactivated',deactivated_by=:by," + "deactivated_at=clock_timestamp(),deactivation_reason='race proof' " + "where id=:actor" + ) + await session.execute(text(statement), {"actor": str(actor_id), "by": str(custodian_id)}) + await session.commit() + + async def reset_profile(actor_id: UUID) -> None: + async with db_session.get_session_factory()() as session: + await session.execute( + text( + "update actor_profiles set status='active',suspended_by=null," + "suspended_at=null,suspension_reason=null,deactivated_by=null," + "deactivated_at=null,deactivation_reason=null where id=:actor" + ), + {"actor": str(actor_id)}, + ) + await session.commit() + + async def revoke_link(actor_id: UUID, custodian_id: UUID) -> None: + async with db_session.get_session_factory()() as session: + await capture_transition_backend(session) + await session.execute( + text( + "update actor_identity_links set status='revoked',revoked_by=:by," + "revoked_at=clock_timestamp(),revoked_reason='race proof' " + "where actor_profile_id=:actor" + ), + {"actor": str(actor_id), "by": str(custodian_id)}, + ) + await session.commit() + + async def reset_link(actor_id: UUID) -> None: + async with db_session.get_session_factory()() as session: + await session.execute( + text( + "update actor_identity_links set status='active',revoked_by=null," + "revoked_at=null,revoked_reason=null where actor_profile_id=:actor" + ), + {"actor": str(actor_id)}, + ) + await session.commit() + + async def wait_for_transition_lock( + transition_task: asyncio.Task, + transition_backend_pid: int, + ) -> None: + for _ in range(250): + if transition_task.done(): + raise AssertionError("disabling transition bypassed the read lock") + async with db_session.get_session_factory()() as session: + waiting = await session.scalar( + text( + "select coalesce(cardinality(pg_blocking_pids(:pid)),0)>0" + ), + {"pid": transition_backend_pid}, + ) + if waiting: + return + await asyncio.sleep(0.02) + raise AssertionError("disabling transition never reached a PostgreSQL lock wait") + + async with AsyncClient( + transport=ASGITransport(app=app), + base_url="http://testserver", + ) as client: + reader = await client.get("/api/v1/actors/me", headers=reader_headers) + link_reader = await client.get("/api/v1/actors/me", headers=link_reader_headers) + custodian = await client.get("/api/v1/actors/me", headers=custodian_headers) + target = await client.get( + "/api/v1/actors/me", + headers={"Authorization": f"Bearer {target_token}"}, + ) + reader_id = UUID(reader.json()["actor_profile_id"]) + link_reader_id = UUID(link_reader.json()["actor_profile_id"]) + custodian_id = UUID(custodian.json()["actor_profile_id"]) + target_id = UUID(target.json()["actor_profile_id"]) + bootstrap_code, _bootstrap = await run_admin_bootstrap(custodian_id, execute=True) + assert bootstrap_code == 0 + reader_grants: dict[UUID, str] = {} + for current_reader_id in (reader_id, link_reader_id): + reader_grant = await client.post( + "/api/v1/admin-role-grants", + headers={**custodian_headers, "Idempotency-Key": str(uuid4())}, + json={ + "target_actor_profile_id": str(current_reader_id), + "role": "access_administrator", + "scope_type": "system", + "scope_project_id": None, + "reason": "AUTH-09C concurrency reader", + }, + ) + assert reader_grant.status_code == 201, reader_grant.text + reader_grants[current_reader_id] = reader_grant.json()["resource_id"] + + for current_kind, path_suffix, current_headers, current_reader_id in ( + ("profile", "", reader_headers, reader_id), + ("identity_link", "/identity-links", link_reader_headers, link_reader_id), + ): + current_grant_id = reader_grants[current_reader_id] + for transition in ("suspended", "link_revoked", "grant_revoked", "deactivated"): + pause_kind = current_kind + entered = asyncio.Event() + release = asyncio.Event() + read_task = asyncio.create_task( + client.get( + f"/api/v1/actors/{target_id}{path_suffix}", + headers=current_headers, + ) + ) + await asyncio.wait_for(entered.wait(), timeout=5) + if transition in {"suspended", "deactivated"}: + transition_task = asyncio.create_task( + set_profile_state(current_reader_id, custodian_id, transition) + ) + elif transition == "link_revoked": + transition_task = asyncio.create_task( + revoke_link(current_reader_id, custodian_id) + ) + else: + transition_task = asyncio.create_task( + client.post( + f"/api/v1/admin-role-grants/{current_grant_id}/revoke", + headers={ + **custodian_headers, + "Idempotency-Key": str(uuid4()), + }, + json={"reason": "AUTH-09C matched grant race"}, + ), + name="auth09c-grant-revocation-transition", + ) + transition_backend_pid = await asyncio.wait_for( + transition_backend_pids.get(), + timeout=5, + ) + await wait_for_transition_lock(transition_task, transition_backend_pid) + release.set() + read_response = await read_task + transition_result = await transition_task + assert read_response.status_code == 200, read_response.text + if transition == "grant_revoked": + assert transition_result.status_code == 200, transition_result.text + disabled_timestamps = await actor_timestamps(current_reader_id) + denied = await client.get( + f"/api/v1/actors/{target_id}{path_suffix}", + headers=current_headers, + ) + assert denied.status_code == 403 + assert await actor_timestamps(current_reader_id) == disabled_timestamps + + if transition in {"suspended", "deactivated"}: + if transition == "suspended": + await reset_profile(current_reader_id) + elif transition == "link_revoked": + await reset_link(current_reader_id) + else: + replacement = await client.post( + "/api/v1/admin-role-grants", + headers={ + **custodian_headers, + "Idempotency-Key": str(uuid4()), + }, + json={ + "target_actor_profile_id": str(current_reader_id), + "role": "access_administrator", + "scope_type": "system", + "scope_project_id": None, + "reason": "Restore AUTH-09C reader authority", + }, + ) + assert replacement.status_code == 201, replacement.text + current_grant_id = replacement.json()["resource_id"] + pause_kind = None + + async def test_controlled_service_actor_provisioning_is_atomic_private_and_concurrent( auth_database_env: str, rsa_signing_material: tuple[rsa.RSAPrivateKey, dict[str, Any]], diff --git a/backend/tests/test_authorization.py b/backend/tests/test_authorization.py index b6795562..7242e6b8 100644 --- a/backend/tests/test_authorization.py +++ b/backend/tests/test_authorization.py @@ -94,7 +94,9 @@ from app.modules.authorization.policy import ADMIN_ROLE_PERMISSIONS, ADMIN_ROLE_SCOPES from app.modules.authorization.runtime import ( ActorAdminRoleGrantHistoryResourceContext, + ActorIdentityLinkAdminReadResourceContext, ActorKind, + ActorProfileAdminReadResourceContext, ActorSelfResourceContext, ActorStatus, AdminRoleDefinitionsResourceContext, @@ -276,6 +278,8 @@ def test_closed_permission_and_action_catalogue_is_exact_and_non_executable() -> ActionId.ADMIN_ROLE_GRANT_ISSUE, ActionId.ADMIN_ROLE_GRANT_REVOKE, ActionId.ADMIN_ROLE_GRANT_BOOTSTRAP, + ActionId.ACTOR_PROFILE_READ, + ActionId.ACTOR_IDENTITY_LINK_READ, ActionId.ACTOR_SERVICE_PROVISION, } assert { @@ -491,6 +495,162 @@ async def touch_after_authorization(self, actor) -> None: assert roles.total == len(AdminRole) +@pytest.mark.parametrize( + ("route", "action_id", "resource_type", "read_method"), + [ + ( + authorization_router.read_actor_profile, + ActionId.ACTOR_PROFILE_READ, + ActorProfileAdminReadResourceContext, + "read_admin_profile", + ), + ( + authorization_router.read_actor_identity_link, + ActionId.ACTOR_IDENTITY_LINK_READ, + ActorIdentityLinkAdminReadResourceContext, + "read_admin_identity_link", + ), + ], +) +@pytest.mark.parametrize("self_target", [False, True]) +async def test_actor_admin_routes_authorize_before_lookup_touch_and_commit( + monkeypatch: pytest.MonkeyPatch, + route, + action_id: ActionId, + resource_type, + read_method: str, + self_target: bool, +) -> None: + target_id = uuid4() + resolved = SimpleNamespace( + profile=SimpleNamespace( + id=str(target_id if self_target else uuid4()), + updated_at=datetime.now(UTC), + last_seen_at=datetime.now(UTC), + ), + identity_link=SimpleNamespace(last_verified_at=datetime.now(UTC)), + ) + events: list[tuple[str, object]] = [] + + class Response: + def __init__(self) -> None: + self.updates: list[dict] = [] + + def model_copy(self, *, update): + self.updates.append(update) + return self + + response = Response() + + class Session: + async def commit(self) -> None: + events.append(("commit", None)) + + async def rollback(self) -> None: + events.append(("rollback", None)) + + class Authorization: + async def require(self, requested_action, resource): + events.append(("authorize", (requested_action, resource))) + + class ActorService: + def __init__(self, session) -> None: + assert session is test_session + + async def read_admin_profile(self, actor_profile_id): + assert read_method == "read_admin_profile" + events.append(("lookup", actor_profile_id)) + return response + + async def read_admin_identity_link(self, actor_profile_id): + assert read_method == "read_admin_identity_link" + events.append(("lookup", actor_profile_id)) + return response + + async def touch_after_authorization(self, actor) -> None: + assert actor is resolved + events.append(("touch", actor)) + + test_session = Session() + monkeypatch.setattr(authorization_router, "ActorService", ActorService) + + result = await route(target_id, resolved, Authorization(), test_session) + + requested_action, resource = events[0][1] + assert requested_action is action_id + assert isinstance(resource, resource_type) + assert resource.resource_id == target_id + assert result is response + assert [event for event, _ in events] == ["authorize", "lookup", "touch", "commit"] + if not self_target: + assert response.updates == [] + elif action_id is ActionId.ACTOR_PROFILE_READ: + assert response.updates == [ + { + "updated_at": resolved.profile.updated_at, + "last_seen_at": resolved.profile.last_seen_at, + } + ] + else: + assert response.updates == [ + {"last_verified_at": resolved.identity_link.last_verified_at} + ] + + +@pytest.mark.parametrize( + "route", + [ + authorization_router.read_actor_profile, + authorization_router.read_actor_identity_link, + ], +) +async def test_actor_admin_missing_resource_rolls_back_without_touch_or_commit( + monkeypatch: pytest.MonkeyPatch, + route, +) -> None: + events: list[str] = [] + + 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") + + class ActorService: + def __init__(self, _session) -> None: + pass + + async def read_admin_profile(self, _actor_profile_id): + events.append("lookup") + return None + + async def read_admin_identity_link(self, _actor_profile_id): + events.append("lookup") + return None + + async def touch_after_authorization(self, _resolved) -> None: + events.append("touch") + + monkeypatch.setattr(authorization_router, "ActorService", ActorService) + + with pytest.raises(StructuredHTTPException) as missing: + await route( + uuid4(), + SimpleNamespace(), + Authorization(), + Session(), + ) + + assert missing.value.status_code == 404 + assert missing.value.error_code == "actor_resource_not_found" + assert events == ["authorize", "lookup", "rollback"] + + async def test_authorization_route_database_failures_rollback_and_map_to_retryable_503() -> None: class Session: def __init__(self) -> None: @@ -662,6 +822,7 @@ def __init__(self, context: AuthorizationContext) -> None: ) self.request_actor_is_present = True self.control_locked = False + self.find_calls: list[tuple[tuple, dict]] = [] async def lock_control(self): self.control_locked = True @@ -675,7 +836,8 @@ async def lock_request_actor(self, identity_link_id, actor_profile_id): SimpleNamespace(id=str(actor_profile_id), actor_kind="human", status="active"), ) - async def find_effective_grant(self, *_args, **_kwargs): + async def find_effective_grant(self, *args, **kwargs): + self.find_calls.append((args, kwargs)) return self.matched async def has_effective_permission_any_scope(self, *_args, **_kwargs): @@ -725,6 +887,83 @@ async def test_admin_kernel_allows_only_a_matched_registered_grant() -> None: assert denied.value.public_code == "permission_not_granted" +@pytest.mark.parametrize( + ("action_id", "resource_type"), + [ + (ActionId.ACTOR_PROFILE_READ, ActorProfileAdminReadResourceContext), + (ActionId.ACTOR_IDENTITY_LINK_READ, ActorIdentityLinkAdminReadResourceContext), + ], +) +async def test_actor_admin_reads_serialize_system_authority_without_control_lock( + action_id: ActionId, + resource_type, +) -> None: + context = _runtime_context() + service, evidence, facts = _admin_runtime_service(context) + read_kind = "profile" if action_id is ActionId.ACTOR_PROFILE_READ else "identity_link" + resource = resource_type( + resource_type="actor_profile", + resource_id=uuid4(), + read_kind=read_kind, + ) + + decision = await service.require(action_id, resource) + + assert decision.allowed is True + assert decision.revalidated is True + assert decision.matched_grant_id == facts.matched.id + assert decision.matched_scope_project_id is None + assert facts.control_locked is False + assert facts.find_calls == [ + ( + (context.actor_profile_id, ACTION_BY_ID[action_id].permission_id), + { + "scope_project_id": None, + "system_scope_only": True, + "for_update": True, + }, + ) + ] + assert decision.resource_context_digest == authorization_resource_digest(resource) + assert evidence.events[0].resource_id == str(resource.resource_id) + + +@pytest.mark.parametrize( + ("action_id", "resource"), + [ + ( + ActionId.ACTOR_PROFILE_READ, + ActorIdentityLinkAdminReadResourceContext( + resource_type="actor_profile", + resource_id=uuid4(), + read_kind="identity_link", + ), + ), + ( + ActionId.ACTOR_IDENTITY_LINK_READ, + ActorProfileAdminReadResourceContext( + resource_type="actor_profile", + resource_id=uuid4(), + read_kind="profile", + ), + ), + ], +) +async def test_actor_admin_reads_reject_cross_paired_resource_contexts( + action_id: ActionId, + resource, +) -> None: + service, evidence, facts = _admin_runtime_service(_runtime_context()) + + with pytest.raises(AuthorizationDenied) as denied: + await service.require(action_id, resource) + + assert denied.value.public_code == "resource_guard_denied" + assert facts.find_calls == [] + assert evidence.events[0].after_facts == {"allowed": False} + assert evidence.events[0].denial_code == "resource_guard_denied" + + async def test_admin_kernel_conceals_targets_until_permission_and_scope_match() -> None: context = _runtime_context() service, _, facts = _admin_runtime_service(context) diff --git a/docs/operations_authorization_service.md b/docs/operations_authorization_service.md index 9c17a49e..e42b20db 100644 --- a/docs/operations_authorization_service.md +++ b/docs/operations_authorization_service.md @@ -564,10 +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-09B. The two AUTH-07B actor-self actions, seven AUTH-08 administrative -actions, and `actor.service.provision` are active; the other 55 entries remain -planned and non-executable. Seven of those planned rows are the AUTH-09C and -AUTH-09D actor/link actions introduced by `0023`. The target post-custody +AUTH-09C. The two AUTH-07B actor-self actions, seven AUTH-08 administrative +actions, `actor.service.provision`, `actor.profile.read`, and +`actor.identity_link.read` are active; the other 53 entries remain planned and +non-executable. Five of those planned rows are AUTH-09D actor/link mutations +introduced by `0023`. The target post-custody 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 @@ -634,10 +635,10 @@ exclusive audit-table lock before these checks and keeps it through destructive DDL. If any forward evidence exists, stop and recover forward rather than discarding it. -Canonical actor self-read/self-update and the seven AUTH-08 administrative -actions plus AUTH-09B controlled service provisioning are active. Project -capability context waits for AUTH-10 exact-project grants and canonical project -composition. +Canonical actor self-read/self-update, the seven AUTH-08 administrative +actions, AUTH-09B controlled service provisioning, and the two AUTH-09C +actor-registry reads are active. Project capability context waits for AUTH-10 +exact-project grants and canonical project composition. AUTH-10 is a clean cut to independent `submitter`, `reviewer`, and `adjudicator` grants. Before rollout, scan current typed schemas, audit facts, @@ -695,6 +696,29 @@ revoke. Audit Authority is read-only and sees only the system or exact-project scope covered by its current grant; filtering occurs before totals and cursors. Requested target role/scope is mutation data, never caller authority. +AUTH-09C adds `GET /api/v1/actors/{actor_profile_id}` and `GET +/api/v1/actors/{actor_profile_id}/identity-links`. Both require an effective +system-scoped Access Administrator or Audit Authority grant; a project-scoped +Audit Authority grant never covers the global actor registry. The link route +returns the one v0.1 link object, not a collection. Suspended/deactivated target +actors and revoked target links remain readable for lifecycle diagnosis. + +The profile response includes only canonical lifecycle state, timestamps, +display name, provisioning method, and the closed local `service_identity` for +a service actor (`null` for a human). The link response includes only its local +IDs, subject kind, lifecycle state, and timestamps. Neither response includes +issuer, subject, contact email, reason, lifecycle actor, token/claim, grant, +assignment, or service-action-matrix data. + +The kernel locks and revalidates the human caller profile, exact link, and +matched system grant before target lookup and holds those locks through +disclosure and commit. It does not acquire `AuthorityControl` or lock a distinct +target. Unauthorized callers are denied before lookup. Missing actor and link +targets both roll back the staged allow and return the same +`actor_resource_not_found` 404; they do not advance caller timestamps or leave +allow evidence. SQL/evidence/touch/commit failure rolls back and returns the +retryable `service_unavailable` 503. + Each protected route owns one caller-session transaction. A successful read, exact replay, issue, or revoke commits its decision evidence and advances the caller's `ActorProfile.last_seen_at` and `ActorIdentityLink.last_verified_at` diff --git a/docs/spec_authorization_service.md b/docs/spec_authorization_service.md index 27a51329..3f1cf544 100644 --- a/docs/spec_authorization_service.md +++ b/docs/spec_authorization_service.md @@ -238,12 +238,13 @@ 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-09B: ten active actions -and 55 planned rows. AUTH-08 adds seven active administrative definition, +The closed action registry contains 65 rows after AUTH-09C: 12 active actions +and 53 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`. The other planned rows cover +`actor.service.provision`, and AUTH-09C activates only `actor.profile.read` and +`actor.identity_link.read`. 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 @@ -278,10 +279,10 @@ AUTH-09A registers these exact planned actions through migration `0023`: | `actor.service.provision` | `actor.service.provision` | `WS-AUTH-001-09B` | AUTH-09B activates only `actor.service.provision` through the controlled route -described below. The other seven remain unavailable until their exact AUTH -owner supplies the route, typed resource context, evaluator, guards, -transaction proof, and availability change. AUTH-09A supplies none of those -runtime paths. +described below. AUTH-09C activates only the two bounded actor-registry reads. +The other five remain unavailable until AUTH-09D supplies the mutation route, +typed resource context, evaluator, guards, transaction proof, and availability +change. AUTH-09A supplies none of those runtime paths. The submission/review dependency matrix is closed. AUTH-07A registers only the four stable planned fields shown here; resource facts, candidates, guards, and @@ -683,7 +684,7 @@ Canonical route families use `/api/v1`: GET|PATCH /api/v1/actors/me GET /api/v1/authorization/permissions GET /api/v1/authorization/admin-role-definitions -GET|PATCH /api/v1/actors/{actor_profile_id} +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 @@ -704,7 +705,9 @@ selection is only the requested grant; it never supplies the caller's authority. AUTH-07B cuts existing `GET|PATCH /api/v1/actors/me` behavior over to the kernel. AUTH-08 activates the two definition reads, scoped grant/history reads, -issue/revoke APIs, and local bootstrap command. Project-scoped +issue/revoke APIs, and local bootstrap command. AUTH-09C activates exact actor +and identity-link reads for effective system Access Administrator or Audit +Authority grants. Project-scoped `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 494ba7d1..0fcfcb33 100644 --- a/scripts/test_agent_gates.py +++ b/scripts/test_agent_gates.py @@ -4136,11 +4136,13 @@ def test_parallel_initiative_status_matches_trusted_main() -> None: loop_state = Path(".agent-loop/LOOP_STATE.md").read_text(encoding="utf-8") assert "Merged through PR #131 as `aa0fdcd`" in auth_map - assert "`WS-AUTH-001-09B` - Controlled Service Actor Provisioning" in auth_status + assert "Merged through PR #143 as `053242b`" in auth_map + assert "`WS-AUTH-001-09C` - Actor And Identity-Link Administration Reads" in auth_status assert "| `WS-AUTH-001-08` | Merged |" in auth_status assert "| `WS-AUTH-001-XINT` | Merged |" in auth_status assert "| `WS-AUTH-001-09A` | Merged |" in auth_status - assert "| `WS-AUTH-001-09B` | In progress |" in auth_status + assert "| `WS-AUTH-001-09B` | Merged |" in auth_status + assert "| `WS-AUTH-001-09C` | In review |" in auth_status assert "Merged through PR #129 as `9a04434`" in artifact_map artifact_phases = ( "Deterministic proof passed; exact-SHA internal review in progress", @@ -4149,7 +4151,6 @@ def test_parallel_initiative_status_matches_trusted_main() -> None: selected_phases = [phase for phase in artifact_phases if phase in artifact_map] assert len(selected_phases) == 1 selected_phase = selected_phases[0] - assert selected_phase in work_queue assert f"Status: {selected_phase}" in artifact_contract assert ( "AUTH's owner reconciliation merged through PR #140 as\n" @@ -4159,10 +4160,32 @@ def test_parallel_initiative_status_matches_trusted_main() -> None: "`WS-ART-001-02A3` implementation and merged-main deterministic repair are\n" "complete" in artifact_status ) - if selected_phase == artifact_phases[0]: + artifact_02a3_merged = ( + "`WS-ART-001-02A3` | ArtifactStore v2 Local Clean Cut | L1 | " + "Merged through PR #141 as `a10d901`" in work_queue + ) + if artifact_02a3_merged: + normalized_work_queue = " ".join(work_queue.split()) + assert "PR #141 merged `WS-ART-001-02A3` into `main` as `a10d901`" in loop_state + assert ( + "`WS-ART-001-02B1` | S3-Compatible MinIO And AWS | L1 | " + "Inactive until 02A3 merge and explicit user start" in work_queue + ) + assert "ART-02B1 remains inactive" in loop_state + assert "Do not start AUTH-09C or POL-002-04 automatically." not in ( + normalized_work_queue + ) + assert ( + "Its merged-main deterministic proof and exact-SHA internal review are " + "complete; external checks remain pending and `02B1` must not start " + "automatically." not in normalized_work_queue + ) + elif selected_phase == artifact_phases[0]: + assert selected_phase in work_queue assert "The current gate is all nine\nexact-SHA internal tracks" in artifact_status assert "Current gate: complete all nine exact-SHA internal reviewer tracks" in loop_state else: + assert selected_phase in work_queue assert "The current gate is GitHub Actions, CodeRabbit, and explicit human review" in ( artifact_status.replace("\n", " ") )