From 9ea44c0a07b16126a6feed46e785ee5359baf680 Mon Sep 17 00:00:00 2001 From: Abiorh001 Date: Sat, 18 Jul 2026 00:08:44 +0100 Subject: [PATCH 01/16] docs(auth): reconcile actor admin read contract --- ...AUTH-001-09C-actor-identity-admin-reads.md | 168 ++++++++++++++---- 1 file changed, 136 insertions(+), 32 deletions(-) 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..4a6d796d 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 @@ -16,6 +26,7 @@ backend/tests/test_actors.py backend/tests/test_auth.py backend/tests/test_authorization.py backend/tests/test_api_controls.py +backend/tests/test_api_contract_e2e.py backend/scripts/api_contract_e2e.py docs/spec_authorization_service.md docs/operations_authorization_service.md @@ -30,39 +41,115 @@ 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-authority 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 ``` -## 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 and use one frozen `ActorRegistryReadResourceContext` containing exactly +`resource_type: Literal["actor_profile"]`, the requested actor UUID as +`resource_id`, and `read_kind: Literal["profile", "identity_link"]`. The +server composes this context 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`, `display_name`, `created_at`, `updated_at`, +`last_seen_at`, `suspended_at`, and `deactivated_at`. 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. + +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 `service_identity` and its static action matrix. + +## 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. evaluate the route's active action against the requested UUID and require an + effective system-scoped Access Administrator or Audit Authority grant; +3. only after the allowed decision, query the exact target profile or the exact + target profile-and-link in one bounded repository operation; +4. 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; +5. 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. + +These reads do not acquire `AuthorityControl`, do not lock or mutate the 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. +- Response, OpenAPI, validation, audit, and log tests prove all prohibited + identity, contact, reason, provenance, grant, assignment, and service-matrix + fields remain absent. +- 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. +- 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,10 +160,25 @@ 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_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 --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 --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 @@ -85,8 +187,10 @@ 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, authorization-before-lookup ordering, the +single-object link contract, caller-only timestamp semantics including +self-target reads, rollback on missing targets and failures, and the absence of +private identity, lifecycle provenance, grant, and service-matrix data. ## Stop condition From 76c5427c5dedc0716d38c3fb1f9b1e45df8cdc8e Mon Sep 17 00:00:00 2001 From: Abiorh001 Date: Sat, 18 Jul 2026 00:19:22 +0100 Subject: [PATCH 02/16] docs(auth): harden actor admin read plan --- ...AUTH-001-09C-actor-identity-admin-reads.md | 84 ++++++++++++------- 1 file changed, 56 insertions(+), 28 deletions(-) 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 4a6d796d..6f52048b 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 @@ -25,6 +25,7 @@ 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 @@ -43,7 +44,7 @@ docs/operations_authorization_service.md actor or identity-link mutation 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-authority disclosure +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 @@ -60,38 +61,50 @@ compatibility alias, fallback route, dual response shape, or legacy authority Project-scoped Audit Authority grants never authorize either route. Access Administrator is already system-only. Both actions are administrative kernel -actions and use one frozen `ActorRegistryReadResourceContext` containing exactly +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", "identity_link"]`. The -server composes this context from the path and route; clients never submit an -ActionId, PermissionId, role, scope, or resource fact. +`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`, `display_name`, `created_at`, `updated_at`, -`last_seen_at`, `suspended_at`, and `deactivated_at`. The identity-link response -is one object, not a collection, and contains exactly `identity_link_id`, +`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. +`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 `service_identity` and its static action matrix. +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. evaluate the route's active action against the requested UUID and require an - effective system-scoped Access Administrator or Audit Authority grant; -3. only after the allowed decision, query the exact target profile or the exact - target profile-and-link in one bounded repository operation; -4. if the target is absent, roll back the staged allowed decision and return the +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; -5. for a present target, stage only the caller's normal post-authorization +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. @@ -101,10 +114,16 @@ 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. -These reads do not acquire `AuthorityControl`, do not lock or mutate the 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. +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 @@ -141,13 +160,20 @@ no mutation, service, ART, REV, CON, task, project, or grant action. - 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. + 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. + 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. - Focused actor and authorization subsystem branch coverage is each at least 90 percent; GitHub Backend preserves the repository-wide 78 percent floor. @@ -162,18 +188,18 @@ architecture, CI integrity, docs, reuse/dedup, and test delta. (cd backend && .venv/bin/python -m ruff check app tests scripts/api_contract_e2e.py) (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_api_controls.py tests/test_api_contract_e2e.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 --fail-under=90) +(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 && .venv/bin/python -m coverage report --fail-under=90) +(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 \ @@ -187,10 +213,12 @@ git diff --check ## Human review focus -Review system-only role separation, authorization-before-lookup ordering, the +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, and the absence of -private identity, lifecycle provenance, grant, and service-matrix data. +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 From 6e0410a62c658bbf7cc79069f7715ee882be47a9 Mon Sep 17 00:00:00 2001 From: Abiorh001 Date: Sat, 18 Jul 2026 01:33:07 +0100 Subject: [PATCH 03/16] docs(auth): allow lifecycle gate regression proof --- .../chunks/WS-AUTH-001-09C-actor-identity-admin-reads.md | 2 ++ 1 file changed, 2 insertions(+) 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 6f52048b..97f38751 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 @@ -29,6 +29,7 @@ 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 docs/spec_authorization_service.md docs/operations_authorization_service.md .agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/** @@ -208,6 +209,7 @@ architecture, CI integrity, docs, reuse/dedup, and test delta. 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 ``` From f99a16aa517691899507603954a57d886d900481 Mon Sep 17 00:00:00 2001 From: Abiorh001 Date: Sat, 18 Jul 2026 01:35:38 +0100 Subject: [PATCH 04/16] docs(auth): constrain lifecycle gate repair --- .../chunks/WS-AUTH-001-09C-actor-identity-admin-reads.md | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) 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 97f38751..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 @@ -29,7 +29,8 @@ 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 +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/** @@ -51,6 +52,8 @@ 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 and authority @@ -175,6 +178,9 @@ no mutation, service, ART, REV, CON, task, project, or grant action. - 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. From 2a8534de1f8e521e788df8f01cd3891712d5f490 Mon Sep 17 00:00:00 2001 From: Abiorh001 Date: Sat, 18 Jul 2026 01:38:38 +0100 Subject: [PATCH 05/16] feat(auth): add bounded actor administration reads --- .agent-loop/LOOP_STATE.md | 30 +- .agent-loop/REVIEW_LOG.md | 13 + .agent-loop/WORK_QUEUE.md | 6 +- .../CHUNK_MAP.md | 10 +- .../STATUS.md | 38 +- ...H-001-09C-preimplementation-plan-review.md | 37 ++ .../merge-intents/WS-AUTH-001-09C.json | 9 + backend/app/modules/actors/schemas.py | 46 +++ backend/app/modules/actors/service.py | 43 ++ .../app/modules/authorization/catalogue.py | 6 +- backend/app/modules/authorization/kernel.py | 20 +- backend/app/modules/authorization/router.py | 80 ++++ backend/app/modules/authorization/runtime.py | 20 + backend/scripts/api_contract_e2e.py | 45 +++ backend/tests/test_actors.py | 121 ++++++ backend/tests/test_api_controls.py | 25 +- backend/tests/test_audit.py | 2 + backend/tests/test_auth.py | 368 +++++++++++++++++- backend/tests/test_authorization.py | 191 +++++++++ docs/operations_authorization_service.md | 40 +- docs/spec_authorization_service.md | 21 +- scripts/test_agent_gates.py | 6 +- 22 files changed, 1109 insertions(+), 68 deletions(-) create mode 100644 .agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/reviews/WS-AUTH-001-09C-preimplementation-plan-review.md create mode 100644 .agent-loop/merge-intents/WS-AUTH-001-09C.json diff --git a/.agent-loop/LOOP_STATE.md b/.agent-loop/LOOP_STATE.md index 37439294..874c1e5e 100644 --- a/.agent-loop/LOOP_STATE.md +++ b/.agent-loop/LOOP_STATE.md @@ -8,23 +8,23 @@ - Active initiatives include parallel `WS-AUTH-001` and `WS-ART-001`. - PR #132 merged `WS-AUTH-001-09A` into `main` as `299363a` on 2026-07-17; signed schema-v2 memory recorded the stopped checkpoint. -- Active implementation chunk: `WS-AUTH-001-09B` on - `codex/ws-auth-001-09b-controlled-service-provisioning` after explicit user - start. -- AUTH-09B is bounded to one controlled service-actor provisioning route and - activates only `actor.service.provision`. AUTH-09C remains inactive. -- Start basis: trusted `main` at `299363a` after PR #132, integrated through - `a947b86` after PR #142 before publication. +- PR #143 merged `WS-AUTH-001-09B` into `main` as `053242b` on + 2026-07-17; the user explicitly started `WS-AUTH-001-09C` afterward. +- 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 `053242b` after PR #143. - PR #119 merged `WS-AUTH-001-05B` as `ad71c7e`. - PR #120 merged `WS-ART-001-OBJECT-STORAGE-AMENDMENT` as `4408256`. - PR #122 merged the first automated post-merge memory implementation as `fc89fb6`; its schema-v1 cross-initiative next pointer is superseded by the schema-v2 initiative-local clean cut. -- Current gate: PR #143's replacement Backend run passed all 1,242 tests but - exposed 89.75 percent authorization coverage at the new 90 percent gate. The - behavior-test repair passes every required reviewer track at exact SHA - `127615f` and projects 90.31 percent on the unchanged 1,600-statement - denominator. Replacement GitHub checks and evidence rebinding remain; no +- Current gate: AUTH-09C deterministic implementation proof passes, including + real PostgreSQL lifecycle and race tests, the live HTTP contract drill, 91.06 + percent actor branch coverage, and 91.80 percent authorization branch + coverage. Exact-head implementation review and PR publication 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 @@ -41,9 +41,9 @@ merged through PR #127 as `f64a8e5`; it is at the post-merge memory/stop checkpoint. ART-02A2 merged through PR #129, and ART-02A3 is reviewed in its isolated parallel worktree but has no open PR at this checkpoint. -- Authorization checkpoint: AUTH-07B, AUTH-08, and AUTH-09A merged through PRs - #130, #131, and #132. Signed memory stopped after 09A, and the user explicitly - started 09B. +- 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 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 0990684e..8e2a4443 100644 --- a/.agent-loop/REVIEW_LOG.md +++ b/.agent-loop/REVIEW_LOG.md @@ -1,5 +1,18 @@ # Review Log +## 2026-07-18 - WS-AUTH-001-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 91.80 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 95f2d7c6..fd75223e 100644 --- a/.agent-loop/WORK_QUEUE.md +++ b/.agent-loop/WORK_QUEUE.md @@ -4,7 +4,7 @@ | Chunk | Title | Risk | Status | |---|---|---:|---| -| `WS-AUTH-001-09B` | Controlled Service Actor Provisioning | L1 | PR #143 open; coverage-gate behavior repair internally reviewed; replacement checks pending | +| `WS-AUTH-001-09C` | Actor And Identity-Link Administration Reads | L1 | Implemented; deterministic evidence passed; exact-head internal review pending | Live post-merge state remains read from signed `automation/loop-memory` output. This authored queue records the separately approved parallel chunks. @@ -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-02A3` | ArtifactStore v2 Local Clean Cut | L1 | Reviewed in isolated parallel worktree; pending its own PR and merge | @@ -71,6 +71,8 @@ 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-ART-001-02A2` | Committed Source And Local Preparation | L1 | Merged through PR #129 as `9a04434` on 2026-07-16 | | `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 | 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 b3440c91..bf604b77 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,14 @@ 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. Exact-head implementation review remains. No service caller or feature +action is active. ## Active planning chunk @@ -102,12 +103,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 +134,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 progress | `codex/ws-auth-001-09c-actor-identity-admin-reads` | - | Deterministic evidence passed; exact-head internal review 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 +155,12 @@ 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. Exact-head L1 +internal review, GitHub Backend, Agent Gates, CodeRabbit, 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/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..87701962 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,76 @@ 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)) + 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)) + 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..0f002767 100644 --- a/backend/tests/test_auth.py +++ b/backend/tests/test_auth.py @@ -1835,6 +1835,135 @@ 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", + } + 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", + target_token, + ): + assert private_value not in serialized_admin_reads + 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] + + 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 + + before_failed_actor_read = await actor_state(admin_id) + fail_feature_commit = True + failed_actor_read = await client.get( + f"/api/v1/actors/{target_id}", + headers=admin_headers, + ) + assert_retryable_service_unavailable(failed_actor_read) + assert await actor_state(admin_id) == before_failed_actor_read + before_read = await actor_state(admin_id) fail_feature_commit = True failed_read = await client.get( @@ -2071,6 +2200,10 @@ 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, + ) audit_wrong_scope = await client.get( "/api/v1/admin-role-grants", headers=audit_headers, @@ -2098,6 +2231,8 @@ def assert_retryable_service_unavailable(response: Response) -> None: str(project_one) } assert audit_wrong_scope.status_code == audit_mutation.status_code == 403 + assert project_audit_actor_read.status_code == 403 + assert project_audit_actor_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 +2269,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 +3095,232 @@ 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() + original_profile_read = ActorService.read_admin_profile + original_link_read = ActorService.read_admin_identity_link + + 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) + + monkeypatch.setattr(ActorService, "read_admin_profile", pause_profile_read) + monkeypatch.setattr(ActorService, "read_admin_identity_link", pause_link_read) + + 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: + 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 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 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"}, + ) + ) + await asyncio.sleep(0.1) + assert transition_task.done() is False + 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..0b908315 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,119 @@ 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", + ), + ], +) +async def test_actor_admin_routes_authorize_before_lookup_touch_and_commit( + monkeypatch: pytest.MonkeyPatch, + route, + action_id: ActionId, + resource_type, + read_method: str, +) -> None: + target_id = uuid4() + resolved = SimpleNamespace(profile=SimpleNamespace(id=str(uuid4()))) + events: list[tuple[str, object]] = [] + response = SimpleNamespace(target_id=target_id) + + 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"] + + +async def test_actor_admin_missing_resource_rolls_back_without_touch_or_commit( + monkeypatch: pytest.MonkeyPatch, +) -> 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 touch_after_authorization(self, _resolved) -> None: + events.append("touch") + + monkeypatch.setattr(authorization_router, "ActorService", ActorService) + + with pytest.raises(StructuredHTTPException) as missing: + await authorization_router.read_actor_profile( + 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 +779,7 @@ def __init__(self, context: AuthorizationContext) -> None: ) self.request_actor_is_present = True self.control_locked = False + self.find_calls: list[dict] = [] async def lock_control(self): self.control_locked = True @@ -676,6 +794,7 @@ async def lock_request_actor(self, identity_link_id, actor_profile_id): ) async def find_effective_grant(self, *_args, **_kwargs): + self.find_calls.append(_kwargs) return self.matched async def has_effective_permission_any_scope(self, *_args, **_kwargs): @@ -725,6 +844,78 @@ 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 == [{ + "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 8b01f4bb..22cf4587 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 2bf5afbd..e941f5bd 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 cf1a9c37..64c279dd 100644 --- a/scripts/test_agent_gates.py +++ b/scripts/test_agent_gates.py @@ -4130,11 +4130,13 @@ def test_parallel_initiative_status_matches_trusted_main() -> None: ).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 progress |" in auth_status assert "Merged through PR #129 as `9a04434`" in artifact_map assert "Reviewed in isolated worktree; PR publication pending" in artifact_map assert "No artifact implementation chunk is active." in artifact_status From 20c409dfbabcc5ad7a4d361cf82248de996e867d Mon Sep 17 00:00:00 2001 From: Abiorh001 Date: Sat, 18 Jul 2026 02:08:13 +0100 Subject: [PATCH 06/16] test(auth): harden administration read evidence --- .agent-loop/LOOP_STATE.md | 2 +- .agent-loop/REVIEW_LOG.md | 2 +- backend/app/modules/authorization/router.py | 11 ++ backend/tests/test_auth.py | 182 ++++++++++++++++++-- backend/tests/test_authorization.py | 23 ++- 5 files changed, 206 insertions(+), 14 deletions(-) diff --git a/.agent-loop/LOOP_STATE.md b/.agent-loop/LOOP_STATE.md index 874c1e5e..488c5adf 100644 --- a/.agent-loop/LOOP_STATE.md +++ b/.agent-loop/LOOP_STATE.md @@ -23,7 +23,7 @@ schema-v2 initiative-local clean cut. - Current gate: AUTH-09C deterministic implementation proof passes, including real PostgreSQL lifecycle and race tests, the live HTTP contract drill, 91.06 - percent actor branch coverage, and 91.80 percent authorization branch + percent actor branch coverage, and 92.04 percent authorization branch coverage. Exact-head implementation review and PR publication remain; no service caller becomes executable before AUTH-09E. - Scope checkpoint: AWS S3 is the only v0.1 production provider; MinIO is diff --git a/.agent-loop/REVIEW_LOG.md b/.agent-loop/REVIEW_LOG.md index 8e2a4443..b63fcc0c 100644 --- a/.agent-loop/REVIEW_LOG.md +++ b/.agent-loop/REVIEW_LOG.md @@ -9,7 +9,7 @@ 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 91.80 percent for authorization. Exact-head implementation +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. diff --git a/backend/app/modules/authorization/router.py b/backend/app/modules/authorization/router.py index 87701962..b6c2f661 100644 --- a/backend/app/modules/authorization/router.py +++ b/backend/app/modules/authorization/router.py @@ -332,6 +332,13 @@ async def read_actor_profile( 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 @@ -367,6 +374,10 @@ async def read_actor_identity_link( 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 diff --git a/backend/tests/test_auth.py b/backend/tests/test_auth.py index 0f002767..59615792 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: @@ -1755,6 +1795,9 @@ def assert_retryable_service_unavailable(response: Response) -> None: 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( @@ -1858,6 +1901,8 @@ def assert_retryable_service_unavailable(response: Response) -> None: "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( @@ -1925,14 +1970,92 @@ def assert_retryable_service_unavailable(response: Response) -> None: "auth08-revoked-link-target", "https://identity.test", "Concealment fixture", + "auth09c-private-contact@example.test", target_token, ): 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_permissions = { + "actor.profile.read": "actor.profile.read_any", + "actor.identity_link.read": "actor.identity_link.read", + } + for event in target_allow_events: + assert event.permission_id == expected_permissions[event.action_id] + 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} + UUID(str(event.request_id)) + UUID(str(event.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() @@ -1955,14 +2078,37 @@ def assert_retryable_service_unavailable(response: Response) -> None: assert await actor_state(admin_id) == before_missing_reads assert await authority_counts() == before_missing_authority_counts - before_failed_actor_read = await actor_state(admin_id) - fail_feature_commit = True - failed_actor_read = await client.get( - f"/api/v1/actors/{target_id}", - headers=admin_headers, - ) - assert_retryable_service_unavailable(failed_actor_read) - assert await actor_state(admin_id) == before_failed_actor_read + 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 @@ -3212,6 +3358,23 @@ async def reset_link(actor_id: UUID) -> None: ) await session.commit() + async def wait_for_transition_lock(transition_task: asyncio.Task) -> 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 exists(select 1 from pg_stat_activity " + "where datname=current_database() and pid<>pg_backend_pid() " + "and wait_event_type='Lock')" + ) + ) + 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", @@ -3280,8 +3443,7 @@ async def reset_link(actor_id: UUID) -> None: json={"reason": "AUTH-09C matched grant race"}, ) ) - await asyncio.sleep(0.1) - assert transition_task.done() is False + await wait_for_transition_lock(transition_task) release.set() read_response = await read_task transition_result = await transition_task diff --git a/backend/tests/test_authorization.py b/backend/tests/test_authorization.py index 0b908315..4651f867 100644 --- a/backend/tests/test_authorization.py +++ b/backend/tests/test_authorization.py @@ -512,17 +512,35 @@ async def touch_after_authorization(self, actor) -> None: ), ], ) +@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(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]] = [] - response = SimpleNamespace(target_id=target_id) + + 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: @@ -564,6 +582,7 @@ async def touch_after_authorization(self, actor) -> None: assert resource.resource_id == target_id assert result is response assert [event for event, _ in events] == ["authorize", "lookup", "touch", "commit"] + assert len(response.updates) == int(self_target) async def test_actor_admin_missing_resource_rolls_back_without_touch_or_commit( From 4331a6aec2f70d2efb001cb1d61bf1541bea36b9 Mon Sep 17 00:00:00 2001 From: Abiorh001 Date: Sat, 18 Jul 2026 02:21:12 +0100 Subject: [PATCH 07/16] test(auth): bind read privacy and request evidence --- backend/tests/test_auth.py | 40 +++++++++++++++++++++++++------------- 1 file changed, 26 insertions(+), 14 deletions(-) diff --git a/backend/tests/test_auth.py b/backend/tests/test_auth.py index 59615792..5beaaee6 100644 --- a/backend/tests/test_auth.py +++ b/backend/tests/test_auth.py @@ -1792,6 +1792,11 @@ 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: @@ -1818,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", ), @@ -1835,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"], ), ] ) @@ -1849,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()), @@ -1858,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( @@ -1868,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", ), @@ -1971,7 +1976,10 @@ def assert_retryable_service_unavailable(response: Response) -> None: "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 @@ -1993,20 +2001,24 @@ def assert_retryable_service_unavailable(response: Response) -> None: ) ).all() assert len(target_allow_events) == 2 - expected_permissions = { - "actor.profile.read": "actor.profile.read_any", - "actor.identity_link.read": "actor.identity_link.read", + 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: - assert event.permission_id == expected_permissions[event.action_id] + 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} - UUID(str(event.request_id)) - UUID(str(event.correlation_id)) + 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: From d3b5267f3578b0d8619caf24ec509119c571c051 Mon Sep 17 00:00:00 2001 From: Abiorh001 Date: Sat, 18 Jul 2026 02:25:38 +0100 Subject: [PATCH 08/16] docs(auth): record administration read review --- .agent-loop/LOOP_STATE.md | 5 +++-- .agent-loop/REVIEW_LOG.md | 10 ++++++++++ .agent-loop/WORK_QUEUE.md | 2 +- .../STATUS.md | 11 ++++++----- 4 files changed, 20 insertions(+), 8 deletions(-) diff --git a/.agent-loop/LOOP_STATE.md b/.agent-loop/LOOP_STATE.md index 488c5adf..fad5b4e8 100644 --- a/.agent-loop/LOOP_STATE.md +++ b/.agent-loop/LOOP_STATE.md @@ -24,8 +24,9 @@ - Current gate: AUTH-09C deterministic implementation proof passes, including real PostgreSQL lifecycle and race tests, the live HTTP contract drill, 91.06 percent actor branch coverage, and 92.04 percent authorization branch - coverage. Exact-head implementation review and PR publication remain; no - service caller becomes executable before AUTH-09E. + coverage. Every required internal review track passes at exact SHA + `4331a6a`; PR publication and 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, diff --git a/.agent-loop/REVIEW_LOG.md b/.agent-loop/REVIEW_LOG.md index b63fcc0c..2f93ee34 100644 --- a/.agent-loop/REVIEW_LOG.md +++ b/.agent-loop/REVIEW_LOG.md @@ -1,5 +1,15 @@ # Review Log +## 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 diff --git a/.agent-loop/WORK_QUEUE.md b/.agent-loop/WORK_QUEUE.md index fd75223e..b7f923c6 100644 --- a/.agent-loop/WORK_QUEUE.md +++ b/.agent-loop/WORK_QUEUE.md @@ -4,7 +4,7 @@ | Chunk | Title | Risk | Status | |---|---|---:|---| -| `WS-AUTH-001-09C` | Actor And Identity-Link Administration Reads | L1 | Implemented; deterministic evidence passed; exact-head internal review pending | +| `WS-AUTH-001-09C` | Actor And Identity-Link Administration Reads | L1 | Implemented; deterministic evidence and exact-head internal review passed; PR publication pending | Live post-merge state remains read from signed `automation/loop-memory` output. This authored queue records the separately approved parallel chunks. diff --git a/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/STATUS.md b/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/STATUS.md index bf604b77..aff6df33 100644 --- a/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/STATUS.md +++ b/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/STATUS.md @@ -94,8 +94,9 @@ repair, coverage repair, and required checks passed before PR #143 merged as 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. Exact-head implementation review remains. No service caller or feature -action is active. +percent. Every required implementation review track passes at exact SHA +`4331a6aec2f70d2efb001cb1d61bf1541bea36b9`. PR publication and external +checks remain. No service caller or feature action is active. ## Active planning chunk @@ -135,7 +136,7 @@ admission, or feature action. | `WS-AUTH-001-09` | Split | - | - | Split into 09A through 09E before runtime implementation. | | `WS-AUTH-001-09A` | Merged | `codex/ws-auth-001-09-actor-state-service-actors` | #132 | Merged as `299363a`; signed memory passed. | | `WS-AUTH-001-09B` | Merged | `codex/ws-auth-001-09b-controlled-service-provisioning` | #143 | Merged as `053242b`; signed memory passed. | -| `WS-AUTH-001-09C` | In progress | `codex/ws-auth-001-09c-actor-identity-admin-reads` | - | Deterministic evidence passed; exact-head internal review pending. | +| `WS-AUTH-001-09C` | In review | `codex/ws-auth-001-09c-actor-identity-admin-reads` | - | Deterministic evidence and exact-head internal review passed; PR publication 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. | @@ -157,8 +158,8 @@ merged feature manifests and separate human starts exist. AUTH-09C passed deterministic PostgreSQL behavior, concurrency, rollback, privacy, live HTTP, and focused 90 percent coverage proof. Exact-head L1 -internal review, GitHub Backend, Agent Gates, CodeRabbit, and explicit human -merge approval remain. It must not add lifecycle mutation, service grants, +internal review passes; GitHub Backend, Agent Gates, CodeRabbit, 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. From 6791381ceb9cb0c7f6ba163d4525c6c770c02ca6 Mon Sep 17 00:00:00 2001 From: Abiorh001 Date: Sat, 18 Jul 2026 02:29:25 +0100 Subject: [PATCH 09/16] test(loop): align AUTH-09C review state --- scripts/test_agent_gates.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/test_agent_gates.py b/scripts/test_agent_gates.py index 64c279dd..01bb8268 100644 --- a/scripts/test_agent_gates.py +++ b/scripts/test_agent_gates.py @@ -4136,7 +4136,7 @@ def test_parallel_initiative_status_matches_trusted_main() -> None: assert "| `WS-AUTH-001-XINT` | Merged |" in auth_status assert "| `WS-AUTH-001-09A` | Merged |" in auth_status assert "| `WS-AUTH-001-09B` | Merged |" in auth_status - assert "| `WS-AUTH-001-09C` | In progress |" in auth_status + assert "| `WS-AUTH-001-09C` | In review |" in auth_status assert "Merged through PR #129 as `9a04434`" in artifact_map assert "Reviewed in isolated worktree; PR publication pending" in artifact_map assert "No artifact implementation chunk is active." in artifact_status From f08f4435ba68bbf285c5b21287e52c5907539aa1 Mon Sep 17 00:00:00 2001 From: Abiorh001 Date: Sat, 18 Jul 2026 02:33:06 +0100 Subject: [PATCH 10/16] docs(auth): bind AUTH-09C review evidence --- .agent-loop/LOOP_STATE.md | 2 +- .../STATUS.md | 2 +- ...S-AUTH-001-09C-internal-review-evidence.md | 96 +++++++++++++ .../WS-AUTH-001-09C-pr-trust-bundle.md | 126 ++++++++++++++++++ 4 files changed, 224 insertions(+), 2 deletions(-) create mode 100644 .agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/reviews/WS-AUTH-001-09C-internal-review-evidence.md create mode 100644 .agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/reviews/WS-AUTH-001-09C-pr-trust-bundle.md diff --git a/.agent-loop/LOOP_STATE.md b/.agent-loop/LOOP_STATE.md index fad5b4e8..b96b0475 100644 --- a/.agent-loop/LOOP_STATE.md +++ b/.agent-loop/LOOP_STATE.md @@ -25,7 +25,7 @@ real PostgreSQL lifecycle and race tests, the live HTTP contract drill, 91.06 percent actor branch coverage, and 92.04 percent authorization branch coverage. Every required internal review track passes at exact SHA - `4331a6a`; PR publication and external checks remain. No service caller + `6791381`; PR publication and 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 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 aff6df33..b1db5eab 100644 --- a/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/STATUS.md +++ b/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/STATUS.md @@ -95,7 +95,7 @@ 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 -`4331a6aec2f70d2efb001cb1d61bf1541bea36b9`. PR publication and external +`6791381ceb9cb0c7f6ba163d4525c6c770c02ca6`. PR publication and external checks remain. No service caller or feature action is active. ## Active planning chunk 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..cf039dd4 --- /dev/null +++ b/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/reviews/WS-AUTH-001-09C-internal-review-evidence.md @@ -0,0 +1,96 @@ +# WS-AUTH-001-09C Internal Review Evidence + +Reviewed code SHA: `6791381ceb9cb0c7f6ba163d4525c6c770c02ca6` + +Reviewed implementation SHA: `4331a6aec2f70d2efb001cb1d61bf1541bea36b9` + +Reviewed against trusted main: +`e118e33afcd89b8ee78ecfc8f0e0d585ae0ee4b9` + +Reviewed at: `2026-07-18T01:31:40Z` + +Reviewer run IDs: `auth_xint_roles`, `auth_xint_art_service`, +`auth_xint_rev_con` + +Reviewer tracks: senior engineering, QA/test, security/auth, product/ops, +architecture, CI integrity, docs, reuse/dedup, and test delta + +## Deterministic Evidence + +- Current `main` at `e118e33` is the merge base. PR #144's WS-CON specification, + ADR, review evidence, inventory, and merge intent are 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 first 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. 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..8c1bcc00 --- /dev/null +++ b/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/reviews/WS-AUTH-001-09C-pr-trust-bundle.md @@ -0,0 +1,126 @@ +# 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 + +Current main at `e118e33` is integrated and PR #144's WS-CON artifacts are +unchanged. No 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 exact reviewed code SHA +`6791381ceb9cb0c7f6ba163d4525c6c770c02ca6` after valid findings were repaired. + +## External Review + +GitHub Backend, Agent Gates, CodeRabbit, and human review are pending on the +published head. External findings will be triaged without weakening the chunk. + +## 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. From c64bcc726e45cd871bfc8514f61d350b8c1a960a Mon Sep 17 00:00:00 2001 From: Abiorh001 Date: Sat, 18 Jul 2026 05:26:24 +0100 Subject: [PATCH 11/16] test(auth): strengthen administration read proofs --- backend/tests/test_auth.py | 56 ++++++++++++++++++++++++----- backend/tests/test_authorization.py | 49 +++++++++++++++++++------ 2 files changed, 86 insertions(+), 19 deletions(-) diff --git a/backend/tests/test_auth.py b/backend/tests/test_auth.py index 5beaaee6..a5bcaea6 100644 --- a/backend/tests/test_auth.py +++ b/backend/tests/test_auth.py @@ -2362,6 +2362,10 @@ async def assert_failed_admin_read(path: str) -> None: 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, @@ -2389,8 +2393,9 @@ async def assert_failed_admin_read(path: str) -> None: str(project_one) } assert audit_wrong_scope.status_code == audit_mutation.status_code == 403 - assert project_audit_actor_read.status_code == 403 - assert project_audit_actor_read.json()["error"]["code"] == "permission_not_granted" + 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 @@ -3285,8 +3290,11 @@ async def test_actor_admin_reads_hold_caller_and_grant_locks_through_disclosure( 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": @@ -3300,8 +3308,29 @@ async def pause_link_read(service: ActorService, actor_profile_id: UUID): 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: @@ -3320,6 +3349,7 @@ async def actor_timestamps(actor_id: UUID) -> tuple[datetime | None, datetime | 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," @@ -3349,6 +3379,7 @@ async def reset_profile(actor_id: UUID) -> None: 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," @@ -3370,17 +3401,19 @@ async def reset_link(actor_id: UUID) -> None: ) await session.commit() - async def wait_for_transition_lock(transition_task: asyncio.Task) -> None: + 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 exists(select 1 from pg_stat_activity " - "where datname=current_database() and pid<>pg_backend_pid() " - "and wait_event_type='Lock')" - ) + "select coalesce(cardinality(pg_blocking_pids(:pid)),0)>0" + ), + {"pid": transition_backend_pid}, ) if waiting: return @@ -3453,9 +3486,14 @@ async def wait_for_transition_lock(transition_task: asyncio.Task) -> None: "Idempotency-Key": str(uuid4()), }, json={"reason": "AUTH-09C matched grant race"}, - ) + ), + name="auth09c-grant-revocation-transition", ) - await wait_for_transition_lock(transition_task) + 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 diff --git a/backend/tests/test_authorization.py b/backend/tests/test_authorization.py index 4651f867..7242e6b8 100644 --- a/backend/tests/test_authorization.py +++ b/backend/tests/test_authorization.py @@ -582,11 +582,31 @@ async def touch_after_authorization(self, actor) -> None: assert resource.resource_id == target_id assert result is response assert [event for event, _ in events] == ["authorize", "lookup", "touch", "commit"] - assert len(response.updates) == int(self_target) + 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] = [] @@ -609,13 +629,17 @@ 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 authorization_router.read_actor_profile( + await route( uuid4(), SimpleNamespace(), Authorization(), @@ -798,7 +822,7 @@ def __init__(self, context: AuthorizationContext) -> None: ) self.request_actor_is_present = True self.control_locked = False - self.find_calls: list[dict] = [] + self.find_calls: list[tuple[tuple, dict]] = [] async def lock_control(self): self.control_locked = True @@ -812,8 +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): - self.find_calls.append(_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): @@ -890,11 +914,16 @@ async def test_actor_admin_reads_serialize_system_authority_without_control_lock 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 == [{ - "scope_project_id": None, - "system_scope_only": True, - "for_update": True, - }] + 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) From 00a9ad728fba04c203803cc414b38e80b7de4d70 Mon Sep 17 00:00:00 2001 From: Abiorh001 Date: Sat, 18 Jul 2026 05:51:30 +0100 Subject: [PATCH 12/16] test(auth): reconcile external proof with current main --- .agent-loop/LOOP_STATE.md | 7 +++--- .agent-loop/REVIEW_LOG.md | 12 ++++++++++ .agent-loop/WORK_QUEUE.md | 2 +- .../STATUS.md | 22 ++++++++++++------- .../WS-AUTH-001-09C-pr-trust-bundle.md | 17 +++++++++----- scripts/test_agent_gates.py | 16 ++++++++++++-- 6 files changed, 57 insertions(+), 19 deletions(-) diff --git a/.agent-loop/LOOP_STATE.md b/.agent-loop/LOOP_STATE.md index 76803782..b09c1bcb 100644 --- a/.agent-loop/LOOP_STATE.md +++ b/.agent-loop/LOOP_STATE.md @@ -30,9 +30,10 @@ `fc89fb6`; its schema-v1 cross-initiative next pointer is superseded by the schema-v2 initiative-local clean cut. - Current gate: PR #146's original Backend, Agent Gates, and CodeRabbit checks - passed. All five valid CodeRabbit proof findings are repaired locally at - `c64bcc7`; focused unit and PostgreSQL tests pass, and exact-head internal - repair review is completing before push. No service caller becomes executable + 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. Exact integrated-head internal + repair review remains before push. No service caller becomes executable before AUTH-09E. - Scope checkpoint: AWS S3 is the only v0.1 production provider; MinIO is local/CI S3 protocol proof; LocalStorage is focused development/test; R2 and diff --git a/.agent-loop/REVIEW_LOG.md b/.agent-loop/REVIEW_LOG.md index 630ca250..9a29257e 100644 --- a/.agent-loop/REVIEW_LOG.md +++ b/.agent-loop/REVIEW_LOG.md @@ -1,5 +1,17 @@ # 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. Exact integrated-head internal repair review and 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 diff --git a/.agent-loop/WORK_QUEUE.md b/.agent-loop/WORK_QUEUE.md index 23704090..1bbae9e5 100644 --- a/.agent-loop/WORK_QUEUE.md +++ b/.agent-loop/WORK_QUEUE.md @@ -4,7 +4,7 @@ | Chunk | Title | Risk | Status | |---|---|---:|---| -| `WS-AUTH-001-09C` | Actor And Identity-Link Administration Reads | L1 | PR #146 open; CodeRabbit repair passed focused proof; exact-head repair review completing | +| `WS-AUTH-001-09C` | Actor And Identity-Link Administration Reads | L1 | PR #146 open; all valid CodeRabbit findings repaired; integrated exact-head review pending | Live post-merge state remains read from signed `automation/loop-memory` output. This authored queue records the separately approved parallel chunks. diff --git a/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/STATUS.md b/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/STATUS.md index 6564284b..162639b3 100644 --- a/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/STATUS.md +++ b/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/STATUS.md @@ -95,8 +95,12 @@ 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 publication and external -checks remain. No service caller or feature action is active. +`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; +exact integrated-head internal repair review remains. No service caller or +feature action is active. ## Active planning chunk @@ -136,7 +140,7 @@ admission, or feature action. | `WS-AUTH-001-09` | Split | - | - | Split into 09A through 09E before runtime implementation. | | `WS-AUTH-001-09A` | Merged | `codex/ws-auth-001-09-actor-state-service-actors` | #132 | Merged as `299363a`; signed memory passed. | | `WS-AUTH-001-09B` | Merged | `codex/ws-auth-001-09b-controlled-service-provisioning` | #143 | Merged as `053242b`; signed memory passed. | -| `WS-AUTH-001-09C` | In review | `codex/ws-auth-001-09c-actor-identity-admin-reads` | - | Deterministic evidence and exact-head internal review passed; PR publication pending. | +| `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 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. | @@ -157,11 +161,13 @@ merged feature manifests and separate human starts exist. ## Blockers AUTH-09C passed deterministic PostgreSQL behavior, concurrency, rollback, -privacy, live HTTP, and focused 90 percent coverage proof. Exact-head L1 -internal review passes; GitHub Backend, Agent Gates, CodeRabbit, 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. +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. Exact integrated-head internal review, 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/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 index 8c1bcc00..607ba4da 100644 --- 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 @@ -50,9 +50,10 @@ outside this chunk or unsafe. ## Scope Control -Current main at `e118e33` is integrated and PR #144's WS-CON artifacts are -unchanged. No migration, dependency, workflow, grant/assignment behavior, -service admission, ART/REV/CON activation, or lifecycle mutation changed. +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 @@ -98,8 +99,14 @@ integrity, docs, reuse/dedup, and test delta all pass exact reviewed code SHA ## External Review -GitHub Backend, Agent Gates, CodeRabbit, and human review are pending on the -published head. External findings will be triaged without weakening the chunk. +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 +and replacement external checks remain; the repair does not change production +behavior or weaken any test or threshold. ## Remaining Risks diff --git a/scripts/test_agent_gates.py b/scripts/test_agent_gates.py index 158c4444..4c5f1f4c 100644 --- a/scripts/test_agent_gates.py +++ b/scripts/test_agent_gates.py @@ -4151,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" @@ -4161,10 +4160,23 @@ 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: + 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 + 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", " ") ) From 2a8ab59b8c0e68c302dcb1765cf42c3cbe8289e6 Mon Sep 17 00:00:00 2001 From: Abiorh001 Date: Sat, 18 Jul 2026 05:58:21 +0100 Subject: [PATCH 13/16] test(loop): reject stale parallel chunk gates --- .agent-loop/WORK_QUEUE.md | 11 +++++------ scripts/test_agent_gates.py | 2 ++ 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/.agent-loop/WORK_QUEUE.md b/.agent-loop/WORK_QUEUE.md index 1bbae9e5..72b5fb13 100644 --- a/.agent-loop/WORK_QUEUE.md +++ b/.agent-loop/WORK_QUEUE.md @@ -88,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/scripts/test_agent_gates.py b/scripts/test_agent_gates.py index 4c5f1f4c..bb684126 100644 --- a/scripts/test_agent_gates.py +++ b/scripts/test_agent_gates.py @@ -4171,6 +4171,8 @@ def test_parallel_initiative_status_matches_trusted_main() -> None: "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" not in work_queue + assert "external checks remain pending" not in 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 From c47d6275c84e338e30f3d911d1cfdd9f1147fd86 Mon Sep 17 00:00:00 2001 From: Abiorh001 Date: Sat, 18 Jul 2026 06:00:46 +0100 Subject: [PATCH 14/16] test(loop): scope stale gate assertions --- scripts/test_agent_gates.py | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/scripts/test_agent_gates.py b/scripts/test_agent_gates.py index bb684126..0fcfcb33 100644 --- a/scripts/test_agent_gates.py +++ b/scripts/test_agent_gates.py @@ -4165,14 +4165,21 @@ def test_parallel_initiative_status_matches_trusted_main() -> None: "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" not in work_queue - assert "external checks remain pending" not in work_queue + 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 From 2f4711fd55462898c32f82489a149ae53d6af192 Mon Sep 17 00:00:00 2001 From: Abiorh001 Date: Sat, 18 Jul 2026 06:06:44 +0100 Subject: [PATCH 15/16] docs(auth): record integrated external repair review --- .agent-loop/LOOP_STATE.md | 6 +++--- .agent-loop/REVIEW_LOG.md | 4 ++-- .agent-loop/WORK_QUEUE.md | 2 +- .../STATUS.md | 14 +++++++------- .../reviews/WS-AUTH-001-09C-pr-trust-bundle.md | 9 +++++---- 5 files changed, 18 insertions(+), 17 deletions(-) diff --git a/.agent-loop/LOOP_STATE.md b/.agent-loop/LOOP_STATE.md index b09c1bcb..63acd88f 100644 --- a/.agent-loop/LOOP_STATE.md +++ b/.agent-loop/LOOP_STATE.md @@ -32,9 +32,9 @@ - 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. Exact integrated-head internal - repair review remains before push. No service caller becomes executable - before AUTH-09E. + 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, diff --git a/.agent-loop/REVIEW_LOG.md b/.agent-loop/REVIEW_LOG.md index 9a29257e..58cd26be 100644 --- a/.agent-loop/REVIEW_LOG.md +++ b/.agent-loop/REVIEW_LOG.md @@ -9,8 +9,8 @@ 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. Exact integrated-head internal repair review and replacement -external checks remain; AUTH-09D is inactive. +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 diff --git a/.agent-loop/WORK_QUEUE.md b/.agent-loop/WORK_QUEUE.md index 72b5fb13..9c438252 100644 --- a/.agent-loop/WORK_QUEUE.md +++ b/.agent-loop/WORK_QUEUE.md @@ -4,7 +4,7 @@ | Chunk | Title | Risk | Status | |---|---|---:|---| -| `WS-AUTH-001-09C` | Actor And Identity-Link Administration Reads | L1 | PR #146 open; all valid CodeRabbit findings repaired; integrated exact-head review 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. 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 162639b3..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 @@ -99,8 +99,8 @@ percent. Every required implementation review track passes at exact SHA 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; -exact integrated-head internal repair review remains. No service caller or -feature action is active. +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 @@ -140,7 +140,7 @@ admission, or feature action. | `WS-AUTH-001-09` | Split | - | - | Split into 09A through 09E before runtime implementation. | | `WS-AUTH-001-09A` | Merged | `codex/ws-auth-001-09-actor-state-service-actors` | #132 | Merged as `299363a`; signed memory passed. | | `WS-AUTH-001-09B` | Merged | `codex/ws-auth-001-09b-controlled-service-provisioning` | #143 | Merged as `053242b`; signed memory passed. | -| `WS-AUTH-001-09C` | In review | `codex/ws-auth-001-09c-actor-identity-admin-reads` | #146 | Original external checks passed; all valid CodeRabbit findings repaired; integrated exact-head review pending. | +| `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. | @@ -164,10 +164,10 @@ 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. Exact integrated-head internal review, 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. +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/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 index 607ba4da..b435fed6 100644 --- 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 @@ -94,8 +94,9 @@ 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 exact reviewed code SHA -`6791381ceb9cb0c7f6ba163d4525c6c770c02ca6` after valid findings were repaired. +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 @@ -105,8 +106,8 @@ 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 -and replacement external checks remain; the repair does not change production -behavior or weaken any test or threshold. +passes at `c47d627`; replacement external checks remain. The repair does not +change production behavior or weaken any test or threshold. ## Remaining Risks From a3d6babc0679595f4571c6a3f7bdd2f0961f3ac5 Mon Sep 17 00:00:00 2001 From: Abiorh001 Date: Sat, 18 Jul 2026 06:14:06 +0100 Subject: [PATCH 16/16] docs(auth): record CodeRabbit repair evidence --- ...S-AUTH-001-09C-external-review-response.md | 55 +++++++++++++++++++ ...S-AUTH-001-09C-internal-review-evidence.md | 46 ++++++++++++---- 2 files changed, 90 insertions(+), 11 deletions(-) create mode 100644 .agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/reviews/WS-AUTH-001-09C-external-review-response.md 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 index cf039dd4..4f44c663 100644 --- 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 @@ -1,13 +1,13 @@ # WS-AUTH-001-09C Internal Review Evidence -Reviewed code SHA: `6791381ceb9cb0c7f6ba163d4525c6c770c02ca6` +Reviewed code SHA: `2f4711fd55462898c32f82489a149ae53d6af192` Reviewed implementation SHA: `4331a6aec2f70d2efb001cb1d61bf1541bea36b9` Reviewed against trusted main: -`e118e33afcd89b8ee78ecfc8f0e0d585ae0ee4b9` +`a10d9018007d2e847b4870e9b26cbd24e24c7bb4` -Reviewed at: `2026-07-18T01:31:40Z` +Reviewed at: `2026-07-18T05:11:01Z` Reviewer run IDs: `auth_xint_roles`, `auth_xint_art_service`, `auth_xint_rev_con` @@ -15,10 +15,34 @@ Reviewer run IDs: `auth_xint_roles`, `auth_xint_art_service`, Reviewer tracks: senior engineering, QA/test, security/auth, product/ops, architecture, CI integrity, docs, reuse/dedup, and test delta -## Deterministic Evidence - -- Current `main` at `e118e33` is the merge base. PR #144's WS-CON specification, - ADR, review evidence, inventory, and merge intent are preserved unchanged. +## 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`. @@ -77,7 +101,7 @@ Valid findings addressed: yes Open sub-agent sessions: none -The first exact-head review rejected a stale-main candidate and identified +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, @@ -91,6 +115,6 @@ tests pass. 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. GitHub Backend, -Agent Gates, CodeRabbit, and explicit human merge approval remain external -gates. AUTH-09D must not start automatically. +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.