diff --git a/.agent-loop/LOOP_STATE.md b/.agent-loop/LOOP_STATE.md index 18cc796b3..90cb62344 100644 --- a/.agent-loop/LOOP_STATE.md +++ b/.agent-loop/LOOP_STATE.md @@ -19,10 +19,9 @@ - AUTH-09C merged through PR #146 as `0ffdabf`; signed schema-v2 memory at `eeb3dc2` recorded its two administrative reads and stopped. - PR #141 merged `WS-ART-001-02A3` into `main` as `a10d901` on 2026-07-18. - PR #151 then merged `WS-ART-001-02B1` as `1b5422f` on 2026-07-19; - the user then explicitly started ART-02C1. -- Active ART implementation chunk: `WS-ART-001-02C1` on - `codex/ws-art-001-02c1-admission-put-attempt`. + PR #151 then merged `WS-ART-001-02B1` as `1b5422f`, and PR #154 merged + `WS-ART-001-02C1` as `44f2467c` on 2026-07-19 with ART-owned migration + `0028_artifact_admission`. - The ART worktree consumes merged AUTH, REV, and CON contracts without editing or activating their independently owned runtime behavior. - AUTH-09D-A merged through PR #148 as `99ae4c9`; signed schema-v2 memory at @@ -37,18 +36,14 @@ - 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. -- Active implementation chunk: `WS-AUTH-001-CONTRIBUTOR-FOUNDATION`, explicitly - started by the user on 2026-07-19 from trusted `main` at `93dd392`. Current - exact contract `2a21166d` passed required L1 preimplementation review; - initial findings are repaired, and exact code SHA `4d1fc507` passed all nine - required internal tracks. PR publication and external checks are the current - gate; Backend must still prove 78/90 percent aggregate coverage. It changes no action - availability, and no service caller becomes executable before AUTH-09E. -- Current ART gate: integrate trusted `main`, complete deterministic 02C1 - proof, and pass all nine exact-SHA internal reviewer tracks. Those steps are - complete; publish the final candidate to existing PR #154, then pass fresh - external checks and explicit human review. No later ART chunk starts - automatically. +- PR-gate chunk: `WS-AUTH-001-09E`, explicitly started by the + user on 2026-07-19 from trusted `main` at `8d5eb15b` after contributor + foundation PR #153 and signed memory `66ab58d`. Its refreshed contract passed + all nine required L1 preimplementation tracks after resolving context, + feature-boundary, transaction, verification, coverage, docs, and reuse + findings. Runtime implementation, focused evidence, and all nine internal + reviewer tracks pass after repair; hosted Backend CI and human review are the + current gates. No feature action or call site becomes active in this chunk. - 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, @@ -65,12 +60,13 @@ AUTH planning requires availability-neutral ART custody transfer, fixed-service admission, prepared mutation authority, and exact AUTH-only activation chunks; neither reconciliation PR activates feature behavior. -- Parallel artifact checkpoint: ART-02A1, ART-02A2, ART-02A3, and ART-02B1 - merged through PRs #127, #129, #141, and #151. ART-02C1 is active and adds - only durable admission plus prepared put-attempt state before provider I/O. -- Authorization checkpoint: AUTH-07B through AUTH-09D-B and the contributor - foundation merged through PRs #130, #131, #132, #143, #146, #148, #152, - and #153. AUTH-09E remains inactive. +- Parallel artifact checkpoint: ART-02A1 through ART-02C1 merged through PRs + #127, #129, #141, #151, and #154. ART-02C1 owns durable admission, prepared + put-attempt state, and migration `0028_artifact_admission`. +- Authorization checkpoint: AUTH-07B through AUTH-09D-B merged through PRs + #130, #131, #132, #143, #146, #148, and #152. Contributor foundation PR #153 + merged as `8d5eb15b`; AUTH-09E is now the sole active AUTH implementation + chunk. - 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 9e4c552d0..0bcada82e 100644 --- a/.agent-loop/REVIEW_LOG.md +++ b/.agent-loop/REVIEW_LOG.md @@ -1,5 +1,65 @@ # Review Log +## 2026-07-20 - WS-AUTH-001-09E External Review Repair + +CodeRabbit raised two valid findings. The specification now says the service +variant carries a “required, closed” identity. The serialized human +administrative path now checks the locked profile remains human before +reconstructing `HumanAuthorizationContext`; kind drift returns bounded +`permission_not_granted` evidence and performs no grant lookup. Ruff and 11 +focused authorization tests pass. The repair requires fresh exact-SHA internal +review, evidence rebinding, hosted CI, and CodeRabbit re-review before human +merge review. + +## 2026-07-19 - WS-AUTH-001-09E Integrated Review Passed + +Trusted main advanced through ART PR #154 as `44f2467c`, bringing ART-owned +`0028_artifact_admission` and a strict artifact-admission boundary. Merge +candidate `d2d974eb` passed senior and QA integration review but security found +that ART's exact check against the former concrete `AuthorizationContext` could +never accept AUTH-09E's closed human/service union. Integrated candidate +`98376fd1` accepts only the two exact concrete context types, updates the test +fixture to construct the matching discriminated context, and changes no +artifact lifecycle, persistence, capacity, provider I/O, or action availability. +Four isolated PostgreSQL admission tests, Ruff, diff integrity, and all 88 agent +gates pass. Senior engineering, QA/test, security/auth, product/ops, +architecture, CI integrity, reuse/dedup, and test-delta review pass; the stale +pre-ART-merge documentation finding was then repaired. AUTH-09E adds or +allocates no migration after ART's merged `0028`. + +## 2026-07-19 - WS-AUTH-001-09E Implementation Review Passed + +Exact implementation SHA `881ac7fc` and documentation repair `d859af3d` passed +senior engineering, QA/test, security/auth, product/ops, architecture, CI +integrity, docs, reuse/dedup, and test-delta review after every valid finding +was repaired. Initial candidate `65ee8887` incorrectly staged observations for +inactive service rows and lacked direct real-callback drift proof. The repair +gates timestamp observation on active profile/link state and proves locked +lifecycle, identity, matrix, and availability drift denial. One stale +pre-09E operations sentence and the human-only dependency docstring were also +corrected. Focused PostgreSQL and HTTP evidence passes; GitHub Backend remains +the authoritative full-suite coverage gate. No migration is added or allocated; +ART owns merged migration `0028_artifact_admission`. + +## 2026-07-19 - WS-AUTH-001-09E Preimplementation Review Passed + +- The user explicitly started AUTH-09E from trusted `main` `8d5eb15b` after + contributor foundation PR #153 and signed memory `66ab58d` completed. +- Initial review rejected missing deterministic verification commands and an + acceptance criterion that overclaimed feature-owned locked resource + recomposition while feature call sites were forbidden. +- The repaired contract defines a discriminated human/service context union, + exact ActionId matrix-before-availability dispatch, one AUTH-owned + transaction-local service revalidation seam, rollback-safe observations, + separate 90 percent actor/authorization/dependency coverage, and explicit + feature-boundary, privacy, cancellation, and persistence-failure proof. +- Senior engineering, QA/test, security/auth, product/ops, architecture, CI + integrity, docs, reuse/dedup, and test-delta tracks passed. Implementation may + begin for AUTH-09E only; no feature action or call site may activate. +- The human confirmed ART owns migration `0028`. AUTH-09E contains no migration + and allocates no revision; later AUTH migration work must wait for the ART PR + to merge and then allocate from trusted main. + ## 2026-07-19 - WS-AUTH-001-CONTRIBUTOR-FOUNDATION Internal Review Passed - Exact code SHA `4d1fc507c343d483677a332c2a91885e32571693` passed senior, diff --git a/.agent-loop/WORK_QUEUE.md b/.agent-loop/WORK_QUEUE.md index 2ece0c24b..e46c11373 100644 --- a/.agent-loop/WORK_QUEUE.md +++ b/.agent-loop/WORK_QUEUE.md @@ -4,8 +4,7 @@ | Chunk | Title | Risk | Status | |---|---|---:|---| -| `WS-AUTH-001-CONTRIBUTOR-FOUNDATION` | Contributor Fields And Canonical-Human Lineage | L1 | Internal review passed at `4d1fc507`; PR/external checks pending; aggregate coverage mandatory in Backend | -| `WS-ART-001-02C1` | Admission And Put-Attempt Foundation | L1 | Active after PR #151 and explicit user start; implementation and deterministic proof in progress | +| `WS-AUTH-001-09E` | Fixed Service Runtime Admission | L1 | Runtime, focused evidence, and all nine internal tracks pass after repair; hosted Backend CI and human review remain | Live post-merge state remains read from signed `automation/loop-memory` output. This authored queue records the separately approved parallel chunks. @@ -15,10 +14,9 @@ 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-09E` | Fixed Service Runtime Admission | L1 | Inactive until contributor-foundation merge/memory and explicit user start | | `WS-QUAL-001-02` | Project Service Coverage | L1 | Inactive until 01B2 merge/memory plus explicit user start | | `WS-POL-002-04` | Locked Runtime Execution And Routing Hardening | L1 | Inactive pending relevant authorization proof and a separate explicit user start | -| `WS-ART-001-02C2` | Verification Publication And Fencing | L1 | Inactive until 02C1 merge and explicit user start | +| `WS-ART-001-02C2` | Verification Publication And Fencing | L1 | 02C1 merged; inactive until explicit user start | | `WS-ART-001-02C3` | Recovery Attempt And Idempotency Chain | L1 | Inactive until 02C2 merge and explicit user start | | `WS-ART-001-02D` | Operator Artifact Operations And AWS Readiness | L1 | Inactive until 02C3 and exact AUTH prerequisites | @@ -26,6 +24,7 @@ output. This authored queue records the separately approved parallel chunks. | Chunk | Title | Risk | Status | |---|---|---:|---| +| `WS-ART-001-02C1` | Admission And Put-Attempt Foundation | L1 | Merged through PR #154 as `44f2467c` on 2026-07-19 | | `WS-ART-001-02B1` | S3-Compatible MinIO And AWS | L1 | Merged through PR #151 as `1b5422fc` on 2026-07-19 | | `WS-AUTH-001-09D-A` | Profile Lifecycle And Evidence Repair | L1 | Merged through PR #148 as `99ae4c9` on 2026-07-18 | | `WS-AUTH-001-09C` | Actor And Identity-Link Administration Reads | L1 | Merged through PR #146 as `0ffdabf` on 2026-07-18 | 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 0a1916b8d..14b0950d6 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 @@ -35,8 +35,8 @@ stopped. | `WS-AUTH-001-09D` | Actor And Identity-Link Lifecycle Mutations | L1 | Split before runtime implementation into 09D-A and 09D-B | | `WS-AUTH-001-09D-A` | Profile Lifecycle And Evidence Repair | L1 | Merged through PR #148 as `99ae4c9`; signed memory `cf8a3e8` passed | | `WS-AUTH-001-09D-B` | Identity-Link Lifecycle And Race Closure | L1 | Merged through PR #152 as `93dd392`; signed memory `912a6254` passed | -| `WS-AUTH-001-CONTRIBUTOR-FOUNDATION` | Contributor Fields And Canonical-Human Lineage | L1 | Internal review passed at `4d1fc507`; PR/external checks pending; Backend coverage mandatory | -| `WS-AUTH-001-09E` | Fixed Service Runtime Admission | L1 | Inactive until contributor-foundation merge/memory and explicit start | +| `WS-AUTH-001-CONTRIBUTOR-FOUNDATION` | Contributor Fields And Canonical-Human Lineage | L1 | Merged through PR #153 as `8d5eb15b`; signed memory `66ab58d` passed and stopped | +| `WS-AUTH-001-09E` | Fixed Service Runtime Admission | L1 | Runtime, focused evidence, and all nine internal tracks pass after repair; hosted Backend CI and human review remain | | `WS-AUTH-001-ART-CUSTODY` | ART Activation Custody Transfer | L1 | Inactive until 09E merge/memory and explicit start | | `WS-AUTH-001-REV-CUSTODY` | REV Activation Custody Transfer | L1 | Inactive until 09E merge/memory and explicit start | | `WS-AUTH-001-PREP` | Prepared Mutation Authorization Protocol | L1 | Inactive until 09E merge/memory and explicit start | 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 1b6e425b2..529c9593b 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 @@ -115,15 +115,14 @@ None. `WS-AUTH-001-XINT` merged through PR #140. ## Active implementation chunk -`WS-AUTH-001-CONTRIBUTOR-FOUNDATION` - Contributor Fields And Canonical-Human -Lineage. Explicitly started from trusted `main` at `93dd392`; exact contract -`2a21166d` passed required L1 review. Internal review passed at code SHA -`4d1fc507`; PR/external checks are current and aggregate coverage is mandatory -in Backend. +`WS-AUTH-001-09E` - Fixed Service Runtime Admission. Explicitly started from +trusted `main` at `8d5eb15b`; the refreshed contract passed all nine required +L1 preimplementation tracks. Runtime implementation is active and changes no +feature action availability. ## Current review branch -`codex/ws-auth-001-contributor-foundation` +`codex/ws-auth-001-09e-fixed-service-runtime-admission` ## Chunk status @@ -153,8 +152,8 @@ in Backend. | `WS-AUTH-001-09D` | Split | `codex/ws-auth-001-09d-actor-identity-lifecycle` | - | Required L1 review rejected the combined contract before runtime edits. | | `WS-AUTH-001-09D-A` | Merged | `codex/ws-auth-001-09d-actor-identity-lifecycle` | #148 | Merged as `99ae4c9`; signed memory `cf8a3e8` passed and stopped. | | `WS-AUTH-001-09D-B` | Merged | `codex/ws-auth-001-09d-b-identity-link-lifecycle` | #152 | Merged as `93dd392`; signed memory `912a6254` passed and stopped. | -| `WS-AUTH-001-CONTRIBUTOR-FOUNDATION` | PR ready | `codex/ws-auth-001-contributor-foundation` | - | Internal review passed at `4d1fc507`; PR/external checks pending; Backend coverage mandatory. | -| `WS-AUTH-001-09E` | Proposed | - | - | Fixed service runtime admission after the contributor foundation. | +| `WS-AUTH-001-CONTRIBUTOR-FOUNDATION` | Merged | `codex/ws-auth-001-contributor-foundation` | #153 | Merged as `8d5eb15b`; signed memory `66ab58d` passed and stopped. | +| `WS-AUTH-001-09E` | PR gate | `codex/ws-auth-001-09e-fixed-service-runtime-admission` | - | Runtime, focused evidence, and all nine internal tracks pass after repair; hosted Backend CI and human review remain. | | `WS-AUTH-001-ART-CUSTODY` | Proposed | - | - | Availability-neutral 25-row ART owner transfer after 09E. | | `WS-AUTH-001-REV-CUSTODY` | Proposed | - | - | Availability-neutral 19-row REV owner transfer after 09E. | | `WS-AUTH-001-PREP` | Proposed | - | - | AUTH-first prepared mutation protocol after 09E. | @@ -182,7 +181,13 @@ required L1 track. Initial implementation candidate `e41c33c0` failed privacy, proof, docs, and evidence review. Bounded repair and deterministic evidence are complete; exact code SHA `4d1fc507` passed all nine internal tracks. PR/external checks are current and aggregate coverage remains mandatory in Backend. -AUTH-09E remains inactive behind that foundation. +The user explicitly started AUTH-09E after contributor foundation PR #153 and +signed memory completed. Its first preimplementation review rejected missing +verification commands and an overbroad feature-resource revalidation claim. +The refreshed contract limits this chunk to AUTH-owned service authority +resolution/revalidation, defers feature-row recomposition to later activation +chunks, and passed all nine required L1 tracks. Runtime implementation is +active; all feature actions remain planned. The four proposed REV lifecycle actions and review-evidence binding action are blocked on complete feature-owned typed manifests. REV fixed services are also diff --git a/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/chunks/WS-AUTH-001-09E-fixed-service-runtime-admission.md b/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/chunks/WS-AUTH-001-09E-fixed-service-runtime-admission.md index d93e642eb..093a15950 100644 --- a/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/chunks/WS-AUTH-001-09E-fixed-service-runtime-admission.md +++ b/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/chunks/WS-AUTH-001-09E-fixed-service-runtime-admission.md @@ -28,11 +28,14 @@ product/ops, architecture, CI integrity, docs, reuse/dedup, and test delta. backend/app/api/deps/authorization.py backend/app/modules/actors/** backend/app/modules/authorization/** +backend/app/modules/artifacts/service.py backend/tests/test_actors.py backend/tests/test_auth.py backend/tests/test_authorization.py backend/tests/test_api_controls.py +backend/tests/test_artifact_admission.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/** @@ -42,6 +45,19 @@ docs/operations_authorization_service.md .agent-loop/REVIEW_LOG.md ``` +`scripts/test_agent_gates.py` may change only to maintain exact deterministic +authored-state assertions for contributor foundation PR #153 and active +AUTH-09E branch/queue/map/status wording. Preserve stale-state negative checks, +unrelated initiative assertions, gate discovery and failure behavior, required +tracks, skips/exclusions, and every coverage threshold. + +The artifact service and admission test are allowed only for the merge-induced +closed-context compatibility repair after trusted main introduced ART-02C1. +They may replace the stale exact check against the former concrete +`AuthorizationContext` with exact membership in the human/service context +types and update fixtures/proof accordingly. They may not change artifact +lifecycle, persistence, capacity, provider I/O, or action availability. + ## Not allowed ```text @@ -51,6 +67,9 @@ client-supplied service identity, ActionId, PermissionId, role, or matrix member dynamic service grants, shared catch-all service identity, or permission union feature resource composition, lifecycle behavior, adapter I/O, or action activation application startup failure solely because a provisionable service row is absent +any Alembic migration or migration-number allocation; ART-owned +`0028_artifact_admission` is merged, AUTH-09E adds no revision, and any later +AUTH migration must allocate after `0028` from then-current trusted main ``` ## Admission contract @@ -71,6 +90,42 @@ the closed `service_identity`; human context cannot carry one. The service candidate evaluator is selected before any human grant lookup and cannot fall through to administrative or project contributor candidates. +The runtime types are an explicit closed union of +`HumanAuthorizationContext` and `ServiceAuthorizationContext`, discriminated by +literal `actor_kind`. Only the service context carries a required +`ServiceIdentity`. Existing human call sites migrate mechanically to the human +type; no optional service flag or compatibility fallback is permitted. + +Service dispatch occurs before actor-self, administrative, project-role, or +contributor candidate lookup. It derives the exact link and profile only from +the verified token's issuer and opaque subject, validates service kind and +lifecycle state, converts the stored immutable identity through the closed +`ServiceIdentity` enum, selects exact `ActionId` membership from +`SERVICE_ACTIONS_BY_IDENTITY`, and then independently checks action +availability. PermissionId equivalence never supplies service authority. + +AUTH-09E owns one reusable transaction-local service-authority revalidation +seam. It locks profile then the exact identity link, verifies unchanged row +identity, service kind, lifecycle state, `service_identity`, exact matrix +membership, and current action availability, and returns refreshed typed +service authority without committing. Later feature activation chunks own the +feature-composed locked `ResourceContext`, final feature-row recomposition, and +terminal mutation proof; this chunk neither fabricates those facts nor claims +end-to-end feature mutation coverage. + +Successful exact active service resolution may stage monotonic +`last_verified_at` and `last_seen_at` observations in the caller-owned request +transaction. Unknown, mismatched, inactive, malformed, or unprovisioned +subjects stage no observations. Planned-action or other authorization denial, +cancellation, and persistence failure roll back staged observations before +bounded denial evidence is restaged in a clean transaction. Evidence contains +no issuer, subject, bearer material, token claims/scopes, or service secret. + +Static code catalogue/matrix mismatch is an import/startup invariant and may +fail application startup. Missing provisioned service ActorProfile or +ActorIdentityLink database rows are request-local denials and must never block +startup or Access Administrator provisioning. + ## Acceptance criteria - Unknown, unprovisioned, mismatched, suspended, deactivated, or link-revoked @@ -79,23 +134,66 @@ through to administrative or project contributor candidates. PermissionId used by an allowed row. - Planned actions remain unavailable. This chunk changes no feature action to active and attaches no ART, REV, CON, project, task, or checker call site. -- Sensitive mutation revalidation reloads and locks the exact link/profile, - verifies unchanged `service_identity`, matrix membership, and active action, - then evaluates a feature-composed canonical `ResourceContext` recomputed from - locked rows before terminal state can commit. Request- or service-supplied - resource facts remain untrusted hints and never become authority. +- Sensitive mutation revalidation exposes the reusable AUTH-owned lock/reload + seam described above and direct tests prove lifecycle, identity, matrix, and + availability drift denial. Each later feature activation chunk must compose + its canonical `ResourceContext` from its own locked rows before terminal + state can commit. Request- or service-supplied resource facts remain + untrusted hints and never become authority. - Service callers never enter human self actions, AdminRoleGrant evaluation, ProjectRoleGrant evaluation, contributor candidates, or human rate controls. - Missing provisioned rows deny the request but do not prevent startup or the Access Administrator provisioning path. - Static matrix and admission parity reject missing/extra identities, rows, actions, changed mappings, and cross-service substitution. -- Tests prove every fixed artifact service is denied every other service's - actions, a human cannot use service candidates, and a service cannot use - human grants. +- Tests prove exact candidate-row selection independently of universal planned + action denial: every fixed artifact service matches only its own ActionIds, + rejects every other service's ActionIds as `permission_not_granted` + (including any same-PermissionId sibling) before availability can mask the + missing candidate, and receives `action_unavailable` only for its own planned + rows. + A human cannot use service candidates and a service cannot use human grants. +- Dependency and kernel tests cover unknown/unprovisioned subjects, token/link/ + profile kind mismatch, revoked links, suspended/deactivated profiles, + malformed stored service identity, zero first-access/rate-control/grant calls, + zero persistence on admission denial, rollback of staged observations, + missing database rows without startup failure, static matrix parity, and + lock-time lifecycle/identity/matrix/action drift. +- Cancellation and injected decision-evidence persistence failure tests prove + staged observations roll back, bounded denial evidence is restaged only from + a clean transaction, and no issuer, subject, bearer material, claims, scopes, + provider data, or service secret enters decision evidence. - Focused authorization/actor coverage remains at least 90 percent and the repository-wide coverage floor does not regress. +## Verification commands + +```bash +test -n "$WORKSTREAM_TEST_ADMIN_DATABASE_URL" +metadata_dir=$(mktemp -d) +trap 'rm -rf "$metadata_dir"' EXIT +(cd backend && .venv/bin/python -m ruff check app tests scripts) +(cd backend && .venv/bin/python scripts/run_isolated_tests.py \ + --metadata-json "$metadata_dir/auth-09e.json" \ + --timeout-seconds 3600 -- .venv/bin/python -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 scripts/run_isolated_tests.py \ + --metadata-json "$metadata_dir/api.json" \ + --timeout-seconds 3600 -- .venv/bin/python scripts/api_contract_e2e.py) +# After internal review, open the PR and require the GitHub Backend workflow. +# It runs the full suite at the repository-wide 78 percent floor and enforces +# the actor and authorization subsystem 90 percent floors on hosted runners. +gh pr checks --watch +python3 scripts/test_agent_gates.py +python3 scripts/check_stale_workstream_wording.py +python3 scripts/check_stale_authorization_docs.py +python3 scripts/check_markdown_links.py +python3 scripts/check_internal_review_evidence.py +python3 scripts/check_loop_memory_state.py +git diff --check +``` + ## Stop condition Stop after merge and signed memory. Do not start AUTH-10 or activate any feature diff --git a/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/chunks/WS-AUTH-001-ART-CUSTODY-activation-custody-transfer.md b/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/chunks/WS-AUTH-001-ART-CUSTODY-activation-custody-transfer.md index 69eb8bf1b..fd5347bcd 100644 --- a/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/chunks/WS-AUTH-001-ART-CUSTODY-activation-custody-transfer.md +++ b/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/chunks/WS-AUTH-001-ART-CUSTODY-activation-custody-transfer.md @@ -1,4 +1,4 @@ -# Chunk Contract: WS-AUTH-001-ART-CUSTODY — ART Activation Custody Transfer +# Chunk Contract: WS-AUTH-001-ART-CUSTODY - ART Activation Custody Transfer ## Parent initiative diff --git a/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/reviews/WS-AUTH-001-09E-external-review-response.md b/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/reviews/WS-AUTH-001-09E-external-review-response.md new file mode 100644 index 000000000..e0cf81c27 --- /dev/null +++ b/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/reviews/WS-AUTH-001-09E-external-review-response.md @@ -0,0 +1,34 @@ +# WS-AUTH-001-09E External Review Response + +## Comments Addressed + +1. CodeRabbit minor wording finding in `docs/spec_authorization_service.md`: + changed “required closed” to “required, closed” without changing the typed + context contract. +2. CodeRabbit stability finding in the post-lock human administrative path: + added an exact human-kind guard before rebuilding + `HumanAuthorizationContext`. Locked actor-kind drift now produces bounded + `permission_not_granted` evidence instead of an uncaught validation error. + A focused regression test proves the request is revalidated and no grant + lookup occurs after drift. + +## Comments Deferred + +None. + +## Human Decisions Needed + +None. + +## Commands Rerun + +- `.venv/bin/python -m ruff check app/modules/authorization/kernel.py tests/test_authorization.py` +- `PYTEST_DISABLE_PLUGIN_AUTOLOAD=1 .venv/bin/python -m pytest -p pytest_asyncio.plugin -q tests/test_authorization.py -k 'locked_human_kind_drift or inactive_service_dependency or real_revalidation_rejects_locked_drift or revalidation_rejects_matrix_or_availability_drift'` + +Result: Ruff passed; 11 focused tests passed. + +## Remaining Risks + +GitHub Backend, Agent Gates, CodeRabbit re-review, and explicit human review +remain mandatory. This response changes no action availability, feature call +site, outbox integration, or migration. diff --git a/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/reviews/WS-AUTH-001-09E-internal-review-evidence.md b/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/reviews/WS-AUTH-001-09E-internal-review-evidence.md new file mode 100644 index 000000000..f73d762de --- /dev/null +++ b/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/reviews/WS-AUTH-001-09E-internal-review-evidence.md @@ -0,0 +1,97 @@ +# WS-AUTH-001-09E Internal Review Evidence + +Reviewed code SHA: `d5162ce717993d69bd6c4879f743bf6e24a4825d` + +Reviewed implementation SHA: `881ac7fc` + +Reviewed integrated implementation SHA: `98376fd13aca199980c2b2d8a0b703eb12a367ce` + +Reviewed against trusted main: `8d5eb15b384fd75787ce98a099400a1d335d2560` + +Integrated implementation reviewed against trusted main: `44f2467c` + +Final closeout reviewed against trusted main: +`3b1d63796c086f53fc2b0aeefe096387b82485ec` + +Latest closeout reviewed against trusted main: +`cb9d5f9f9c311e644ed20a988c69843d3618a6b0` + +Reviewed at: `2026-07-20T04:43:38Z` + +Reviewer run IDs: `review_senior`, `review_qa`, `review_security`, +`review_product_ops`, `review_architecture`, `review_ci`, `review_docs`, +`review_reuse`, `review_test_delta` + +Reviewer tracks: senior engineering, QA/test, security/auth, product/ops, +architecture, CI integrity, docs, reuse/dedup, and test delta + +## Deterministic Evidence + +- Fresh isolated PostgreSQL focused evidence passed 312 tests in 2,626.88 + seconds after two repaired expectation failures were rerun successfully. +- Ten direct repair tests pass for inactive observation suppression and real + lock-time lifecycle, identity, matrix, and availability drift denial. +- Four isolated PostgreSQL ART admission tests pass after the trusted-main + integration repair, proving exact human and checker-service context + composition while preserving locked identity validation. +- The real isolated HTTP API contract drill passed, including unprovisioned, + provisioned, suspended, reactivated, revoked-link, and repaired-link service + states. +- Ruff, 90.3 percent repository docstring coverage, both stale scanners, + Markdown links, diff integrity, and all 88 agent gates pass. +- No workflow, dependency manifest, Alembic revision, migration allocation, + skip, xfail, test deletion, coverage exclusion, or threshold reduction was + introduced. Migration `0028` remains owned by the independent ART work. +- GitHub Backend is the mandatory hosted proof for the unchanged 78 percent + repository-wide floor and the persistent 90 percent actor and authorization + subsystem reports. The intentionally stopped local full run is not claimed + as evidence. + +## Reviewer Results + +| Reviewer | Result | Blocking findings | Notes | +|---|---|---|---| +| senior engineering | PASS AFTER FIXES | none | Inactive subjects no longer stage observations; maintainability is sound. | +| QA/test | PASS AFTER FIXES | none | Real revalidation proves lifecycle, identity, matrix, and availability drift denial. | +| security/auth | PASS | none | Exact identity resolution, fail-closed dispatch, rollback, and evidence privacy hold. | +| product/ops | PASS | none | No feature action, human workflow, payment, review, or reputation behavior activates. | +| architecture | PASS | none | Typed AUTH boundaries and the reusable actor lock seam remain closed. | +| CI integrity | PASS | none | Hosted full coverage retains every existing threshold and failure gate. | +| docs | PASS AFTER FIXES | none | Operations now describe present central AUTH admission and legacy rejection. | +| reuse/dedup | PASS | none | Existing lookup, lock, matrix, timestamp, and evidence paths are reused. | +| test delta | PASS | none | No weakened, removed, skipped, or xfailed test; integration proof complements mocks. | + +## Findings Resolved + +Valid findings addressed: yes + +Open sub-agent sessions: none + +Candidate `65ee8887` staged service observations before lifecycle denial and +tested drift through an overly shallow callback. Repaired candidate `881ac7fc` +gates observations on active rows, fails malformed locked reconstruction +closed, and directly proves lifecycle, identity, matrix, and availability +drift. Documentation candidate `d859af3d` removes one stale pre-09E operations +sentence and corrects the dependency docstring. All required tracks pass with +no remaining blocking finding. Trusted main then advanced through ART PR #154. +Security review rejected merge candidate `d2d974eb` because ART admission still +checked the former concrete `AuthorizationContext`; integrated candidate +`98376fd1` repairs that boundary with exact membership in the two closed +concrete context types and passes all nine tracks after the stale timing docs +were corrected. Current main then advanced through CON PR #155. Merge head +`4fd3537a` inherits CON's shared outbox and `0029_shared_transactional_outbox` +unchanged, has no conflict-resolution delta, and passes all nine integration +tracks; AUTH still adds no outbox coupling or migration. +CodeRabbit then identified unclear specification wording and an unguarded +post-lock human actor-kind reconstruction. Repair `d5162ce7` clarifies the +wording and returns bounded `permission_not_granted` before context +reconstruction or grant lookup when the locked profile is no longer human. +Ruff and 11 focused tests pass, and all nine exact-SHA repair tracks report no +remaining finding. + +## Remaining Risk And Gate + +GitHub Backend, Agent Gates, external review, and explicit human review remain. +No feature action is active. ART owns migration `0028`; this chunk adds no +migration, and its same-initiative successor still requires a separate explicit +start after this PR and signed memory, subject to its own contract prerequisites. diff --git a/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/reviews/WS-AUTH-001-09E-pr-trust-bundle.md b/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/reviews/WS-AUTH-001-09E-pr-trust-bundle.md new file mode 100644 index 000000000..a4041369e --- /dev/null +++ b/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/reviews/WS-AUTH-001-09E-pr-trust-bundle.md @@ -0,0 +1,76 @@ +# WS-AUTH-001-09E PR Trust Bundle + +## Goal + +Admit only explicitly provisioned fixed service actors into central AUTH while +keeping every feature action unavailable and every human authority path +separate. + +## Changes And Design + +- Introduces structurally distinct human and service authorization contexts. +- Resolves service tokens through the existing exact actor lookup with no first + access, human rate control, role grant, or fallback path. +- Dispatches services before human/admin evaluation, checks exact ActionId + matrix membership before availability, and revalidates locked actor rows in + the caller transaction. +- Stages timestamps only for initially active service rows and rolls them back + on denial, cancellation, or persistence failure. +- Adds focused unit, PostgreSQL, and real HTTP lifecycle/drift proof plus current + specification and operations guidance. + +## Scope Control + +No service feature action, ART/REV/CON call site, migration, schema, role, +grant, payment, review, revision, or reputation behavior is activated. ART PR +#154 and its owned `0028_artifact_admission` are already merged; AUTH-09E adds +or allocates no migration. + +## Proof And CI Integrity + +- 312 focused actor/auth/API-control tests passed on isolated PostgreSQL. +- 10 repair tests passed for inactive observation suppression and real + revalidation drift. +- Real isolated API contract E2E passed. +- Ruff, 90.3 percent docstring coverage, stale scans, Markdown links, diff + integrity, and 88 agent gates passed. +- No tests, assertions, skips, xfails, workflows, exclusions, or coverage + thresholds were weakened. +- GitHub Backend remains the authoritative mandatory proof for the full 78 + percent repository floor and actor/authorization 90 percent subsystem gates. + +## Internal Review + +Candidate `881ac7fc` and docs repair `d859af3d`, against trusted main +`8d5eb15b`, pass senior engineering, QA/test, security/auth, product/ops, +architecture, CI integrity, docs, reuse/dedup, and test-delta review after all +valid findings were repaired. Integrated candidate `98376fd1`, against trusted +main `44f2467c`, passes the same nine tracks after repairing ART admission's +stale exact-type check for the new closed human/service context union. + +## External Review + +CodeRabbit raised one wording issue and one post-lock human-kind drift issue. +Both are repaired: the specification wording is clear, and human administrative +revalidation now denies actor-kind drift before context reconstruction or grant +lookup. The focused repair suite passes 11 tests; fresh external and hosted CI +checks remain required on the repair head. + +## Remaining Risk And Follow-up + +Hosted Backend CI and external review remain. The same-initiative +`WS-AUTH-001-ART-CUSTODY` successor is only a recorded next gate; it must not +start until this PR and signed memory are complete, its own contract +prerequisites pass, and the user explicitly starts it. + +## Human Review Focus + +Review service-before-human dispatch, exact matrix-before-availability order, +active-only observation staging, lock-time drift denial, bounded evidence, and +the absence of feature activation or migration changes. + +## Human Merge Ownership + +The agent may publish and repair this branch but may not merge it. Only the +human may approve this PR for merge. Trusted-main automation owns post-merge +schema-v2 memory generation when the workflow succeeds. diff --git a/.agent-loop/merge-intents/WS-AUTH-001-09E.json b/.agent-loop/merge-intents/WS-AUTH-001-09E.json new file mode 100644 index 000000000..3d117066a --- /dev/null +++ b/.agent-loop/merge-intents/WS-AUTH-001-09E.json @@ -0,0 +1,9 @@ +{ + "chunk_id": "WS-AUTH-001-09E", + "chunk_title": "Fixed Service Runtime Admission", + "initiative_id": "WS-AUTH-001", + "next_chunk_id": "WS-AUTH-001-ART-CUSTODY", + "next_chunk_title": "ART Activation Custody Transfer", + "next_requires_explicit_start": true, + "schema_version": 2 +} diff --git a/backend/app/api/deps/authorization.py b/backend/app/api/deps/authorization.py index 97bfc0d63..69c3fdf7e 100644 --- a/backend/app/api/deps/authorization.py +++ b/backend/app/api/deps/authorization.py @@ -22,7 +22,6 @@ ActorRegistryError, ActorService, ResolvedActor, - ServiceActorNotProvisioned, UnsupportedSubjectKind, ) from app.modules.api_controls.service import FIRST_ACCESS_SCOPE, RateControlService @@ -34,8 +33,12 @@ AuthorizationContext, AuthorizationDenied, AuthorizationEvidenceUnavailable, + HumanAuthorizationContext, IdentityLinkStatus, + ServiceAuthorizationContext, ) +from app.modules.actors.service_identities import ServiceIdentity +from app.modules.authorization.catalogue import ActionId from app.schemas.auth import AuthVerificationResult @@ -45,15 +48,21 @@ def _authorization_context( correlation_id: UUID, ) -> AuthorizationContext: """Project canonical actor rows into the strict request context.""" - return AuthorizationContext( + common = dict( actor_profile_id=UUID(resolved.profile.id), - actor_kind=ActorKind(resolved.profile.actor_kind), actor_status=ActorStatus(resolved.profile.status), identity_link_id=UUID(resolved.identity_link.id), identity_link_status=IdentityLinkStatus(resolved.identity_link.status), request_id=request_id, correlation_id=correlation_id, ) + if resolved.profile.actor_kind == ActorKind.SERVICE: + return ServiceAuthorizationContext( + actor_kind=ActorKind.SERVICE, + service_identity=ServiceIdentity(resolved.profile.service_identity), + **common, + ) + return HumanAuthorizationContext(actor_kind=ActorKind.HUMAN, **common) async def get_authorization_actor( @@ -62,15 +71,13 @@ async def get_authorization_actor( session: Annotated[AsyncSession, Depends(get_db_session)], rate_control: Annotated[RateControlService, Depends(get_rate_control_service)], ) -> ResolvedActor: - """Resolve an exact human self target without pre-kernel lifecycle denial.""" - if result.token.subject_kind == "service": - raise actor_registry_http_error( - ServiceActorNotProvisioned("Service actor is not provisioned") - ) - if result.token.subject_kind != "human": + """Resolve an exact human or fixed-service target before kernel lifecycle denial.""" + if result.token.subject_kind not in {"human", "service"}: raise actor_registry_http_error(UnsupportedSubjectKind("Unsupported subject kind")) service = ActorService(session) try: + if result.token.subject_kind == "service": + return await service.resolve_service_for_authorization(result.token) existing = await service.find_actor_for_authorization(result.token) if existing is None: settings = request.app.state.settings @@ -145,12 +152,36 @@ async def revalidate_actor_self( locked = await actor_service.lock_actor_self_for_authorization(resolved) return _authorization_context(locked, request_id, correlation_id) + async def revalidate_service( + context: ServiceAuthorizationContext, + _action_id: ActionId, + ) -> ServiceAuthorizationContext | None: + """Rebuild fixed-service authority from exact locked actor rows.""" + try: + locked = await actor_service.lock_actor_for_authorization(resolved) + refreshed = _authorization_context(locked, request_id, correlation_id) + except (RuntimeError, ValueError): + return None + if not isinstance(refreshed, ServiceAuthorizationContext): + return None + if refreshed.service_identity is not context.service_identity: + return None + return refreshed + + context = _authorization_context(resolved, request_id, correlation_id) service = AuthorizationService( session, - _authorization_context(resolved, request_id, correlation_id), + context, revalidate_actor_self=revalidate_actor_self, + revalidate_service=revalidate_service, ) try: + if ( + isinstance(context, ServiceAuthorizationContext) + and context.actor_status is ActorStatus.ACTIVE + and context.identity_link_status is IdentityLinkStatus.ACTIVE + ): + await actor_service.touch_after_authorization(resolved) yield service except AuthorizationDenied as exc: await session.rollback() @@ -164,6 +195,9 @@ async def revalidate_actor_self( except AuthorizationEvidenceUnavailable as exc: await session.rollback() raise actor_registry_unavailable_error() from exc + except SQLAlchemyError as exc: + await session.rollback() + raise actor_registry_unavailable_error() from exc except BaseException: await session.rollback() raise diff --git a/backend/app/modules/actors/service.py b/backend/app/modules/actors/service.py index 17fcfc078..5b58ac9be 100644 --- a/backend/app/modules/actors/service.py +++ b/backend/app/modules/actors/service.py @@ -26,6 +26,7 @@ LegacyWorkflowEligibilityActivationRequest, LegacyWorkflowEligibilityResponse, ) +from app.modules.actors.service_identities import ServiceIdentity from app.modules.audit.repository import AuditRepository from app.modules.audit.schemas import ( ActorReferenceKind, @@ -242,11 +243,30 @@ async def resolve_actor_for_authorization( ) return resolved - async def lock_actor_self_for_authorization( + async def resolve_service_for_authorization( + self, + token: VerifiedIssuerToken, + ) -> ResolvedActor: + """Resolve one explicitly provisioned service without first access.""" + if token.subject_kind != "service": + raise UnsupportedSubjectKind("Unsupported subject kind") + try: + resolved = await self.find_actor_for_authorization(token) + except RuntimeError as exc: + raise ServiceActorNotProvisioned("Service actor is not provisioned") from exc + if resolved is None or resolved.profile.service_identity is None: + raise ServiceActorNotProvisioned("Service actor is not provisioned") + try: + ServiceIdentity(resolved.profile.service_identity) + except ValueError as exc: + raise ServiceActorNotProvisioned("Service actor is not provisioned") from exc + return resolved + + async def lock_actor_for_authorization( self, resolved: ResolvedActor, ) -> ResolvedActor: - """Lock the exact profile then its link and reject identity drift.""" + """Lock profile then exact link and reject missing or drifted rows.""" profile = await self._repo.get_actor_profile(resolved.profile.id, for_update=True) if profile is None: raise RuntimeError("resolved actor profile disappeared") @@ -266,6 +286,13 @@ async def lock_actor_self_for_authorization( raise RuntimeError("resolved actor identity changed") return ResolvedActor(profile=profile, identity_link=link) + async def lock_actor_self_for_authorization( + self, + resolved: ResolvedActor, + ) -> ResolvedActor: + """Lock the exact profile then its link and reject identity drift.""" + return await self.lock_actor_for_authorization(resolved) + async def require_active_human_write_actor(self, actor: ActorContext) -> None: """Lock and revalidate one exact human caller in the current transaction.""" profile = await self._repo.get_actor_profile(actor.actor_id, for_update=True) diff --git a/backend/app/modules/artifacts/service.py b/backend/app/modules/artifacts/service.py index dd5297b94..15715ef6d 100644 --- a/backend/app/modules/artifacts/service.py +++ b/backend/app/modules/artifacts/service.py @@ -39,7 +39,9 @@ ActorKind, ActorStatus, AuthorizationContext, + HumanAuthorizationContext, IdentityLinkStatus, + ServiceAuthorizationContext, ) @@ -290,7 +292,10 @@ def _validate_request_boundary(request: ArtifactAdmissionRequest) -> None: CheckerOutputArtifactAdmissionRequest, }: raise TypeError("invalid artifact admission request") - if type(request.authorization_context) is not AuthorizationContext: + if type(request.authorization_context) not in { + HumanAuthorizationContext, + ServiceAuthorizationContext, + }: raise TypeError("invalid artifact admission authorization context") if type(request.source) is not CommittedArtifactSource: raise TypeError("invalid artifact admission source") diff --git a/backend/app/modules/authorization/kernel.py b/backend/app/modules/authorization/kernel.py index 81eb9cd15..112538511 100644 --- a/backend/app/modules/authorization/kernel.py +++ b/backend/app/modules/authorization/kernel.py @@ -16,6 +16,7 @@ from app.modules.audit.service import AuditService from app.modules.authorization.catalogue import ( ACTION_BY_ID, + SERVICE_ACTIONS_BY_IDENTITY, ActionAvailability, ActionId, ) @@ -39,15 +40,21 @@ AuthorizationDenialCode, AuthorizationEvidenceUnavailable, AuthorizationResourceContext, + HumanAuthorizationContext, IdentityLinkStatus, MatchedAuthorityKind, PermissionCatalogueResourceContext, + ServiceAuthorizationContext, ServiceActorProvisionResourceContext, authorization_resource_digest, ) ContextRevalidator = Callable[ - [AuthorizationContext, ActorSelfResourceContext], Awaitable[AuthorizationContext] + [HumanAuthorizationContext, ActorSelfResourceContext], Awaitable[HumanAuthorizationContext] +] +ServiceContextRevalidator = Callable[ + [ServiceAuthorizationContext, ActionId], + Awaitable[ServiceAuthorizationContext | None], ] _ADMIN_ACTIONS = frozenset( @@ -98,11 +105,13 @@ def __init__( context: AuthorizationContext, *, revalidate_actor_self: ContextRevalidator | None = None, + revalidate_service: ServiceContextRevalidator | None = None, ) -> None: self._audit = AuditService(session) self._admin = AdminAuthorizationRepository(session) self._context = context self._revalidate_actor_self = revalidate_actor_self + self._revalidate_service = revalidate_service self._pending_denial: AuthorizationDecision | None = None async def require( @@ -118,7 +127,13 @@ async def require( matched_grant_id = None matched_project_id = None matched_kind = None - if action is not None and action.action_id in _ADMIN_ACTIONS: + if isinstance(context, ServiceAuthorizationContext): + denial, context, revalidated = await self._service_denial( + action_id, + action, + context, + ) + elif action is not None and action.action_id in _ADMIN_ACTIONS: ( denial, context, @@ -166,6 +181,39 @@ async def require( raise AuthorizationDenied(decision) return decision + async def _service_denial( + self, + requested_action: object, + action, + context: ServiceAuthorizationContext, + ) -> tuple[AuthorizationDenialCode | None, ServiceAuthorizationContext, bool]: + """Evaluate one fixed service before every human authority path.""" + lifecycle = self._lifecycle_denial(context) + if lifecycle is not None: + return lifecycle, context, False + if action is None: + return AuthorizationDenialCode.UNKNOWN_ACTION, context, False + if requested_action not in SERVICE_ACTIONS_BY_IDENTITY[context.service_identity]: + return AuthorizationDenialCode.PERMISSION_NOT_GRANTED, context, False + if action.availability is not ActionAvailability.ACTIVE: + return AuthorizationDenialCode.ACTION_UNAVAILABLE, context, False + if self._revalidate_service is None: + return AuthorizationDenialCode.RESOURCE_GUARD_DENIED, context, False + refreshed = await self._revalidate_service(context, action.action_id) + if refreshed is None: + return AuthorizationDenialCode.PERMISSION_NOT_GRANTED, context, True + lifecycle = self._lifecycle_denial(refreshed) + if lifecycle is not None: + return lifecycle, refreshed, True + if ( + refreshed.service_identity is not context.service_identity + or action.action_id + not in SERVICE_ACTIONS_BY_IDENTITY[refreshed.service_identity] + or ACTION_BY_ID[action.action_id].availability is not ActionAvailability.ACTIVE + ): + return AuthorizationDenialCode.PERMISSION_NOT_GRANTED, refreshed, True + return AuthorizationDenialCode.RESOURCE_GUARD_DENIED, refreshed, True + async def _admin_denial( self, action, @@ -201,7 +249,9 @@ async def _admin_denial( if locked is None: return AuthorizationDenialCode.IDENTITY_LINK_REVOKED, context, None, None, True link, profile = locked - context = AuthorizationContext( + if profile.actor_kind != ActorKind.HUMAN.value: + return AuthorizationDenialCode.PERMISSION_NOT_GRANTED, context, None, None, True + context = HumanAuthorizationContext( actor_profile_id=UUID(profile.id), actor_kind=ActorKind(profile.actor_kind), actor_status=ActorStatus(profile.status), diff --git a/backend/app/modules/authorization/runtime.py b/backend/app/modules/authorization/runtime.py index 6a3cde2a5..f1ecddebb 100644 --- a/backend/app/modules/authorization/runtime.py +++ b/backend/app/modules/authorization/runtime.py @@ -38,13 +38,13 @@ class IdentityLinkStatus(StrEnum): REVOKED = "revoked" -class AuthorizationContext(BaseModel): - """Bounded canonical identity state for one request only.""" +class HumanAuthorizationContext(BaseModel): + """Bounded canonical human identity state for one request only.""" model_config = _STRICT_FROZEN actor_profile_id: UUID - actor_kind: ActorKind + actor_kind: Literal[ActorKind.HUMAN] actor_status: ActorStatus identity_link_id: UUID identity_link_status: IdentityLinkStatus @@ -52,6 +52,24 @@ class AuthorizationContext(BaseModel): correlation_id: UUID +class ServiceAuthorizationContext(BaseModel): + """Bounded canonical fixed-service identity state for one request only.""" + + model_config = _STRICT_FROZEN + + actor_profile_id: UUID + actor_kind: Literal[ActorKind.SERVICE] + actor_status: ActorStatus + identity_link_id: UUID + identity_link_status: IdentityLinkStatus + service_identity: ServiceIdentity + request_id: UUID + correlation_id: UUID + + +AuthorizationContext = HumanAuthorizationContext | ServiceAuthorizationContext + + class ActorSelfResourceContext(BaseModel): """Server-composed facts for the caller's own actor profile.""" diff --git a/backend/scripts/api_contract_e2e.py b/backend/scripts/api_contract_e2e.py index e94067759..e61c5cad3 100644 --- a/backend/scripts/api_contract_e2e.py +++ b/backend/scripts/api_contract_e2e.py @@ -113,6 +113,7 @@ def issue_flow_token( issued_at: datetime | None = None, expires_at: datetime | None = None, not_before: datetime | None = None, + subject_kind: str = "human", ) -> str: """Issue a local Flow-compatible signed token for one QA actor. @@ -125,28 +126,33 @@ def issue_flow_token( issued_at: Optional issued-at timestamp override. expires_at: Optional expiration timestamp override. not_before: Optional not-before timestamp override. + subject_kind: Canonical human or fixed-service token kind. Returns: HMAC-signed bearer token consumed by ``FlowAuthVerifier``. """ now = issued_at or datetime.now(UTC) header = base64url_json({"alg": "HS256", "typ": "JWT"}) - payload = base64url_json( - { - "iss": issuer, - "aud": audience, - "sub": subject, - "jti": f"local-e2e-{uuid4()}", - "subject_kind": "human", - "scope": "workstream:access", - "email": f"{subject}@flow.local", - "name": subject.replace("-", " ").title(), - "roles": roles, - "iat": int(now.timestamp()), - "nbf": int((not_before or (now - timedelta(seconds=5))).timestamp()), - "exp": int((expires_at or (now + timedelta(minutes=30))).timestamp()), - } - ) + claims = { + "iss": issuer, + "aud": audience, + "sub": subject, + "jti": f"local-e2e-{uuid4()}", + "subject_kind": subject_kind, + "scope": "workstream:service" if subject_kind == "service" else "workstream:access", + "iat": int(now.timestamp()), + "nbf": int((not_before or (now - timedelta(seconds=5))).timestamp()), + "exp": int((expires_at or (now + timedelta(minutes=30))).timestamp()), + } + if subject_kind == "human": + claims.update( + { + "email": f"{subject}@flow.local", + "name": subject.replace("-", " ").title(), + "roles": roles, + } + ) + payload = base64url_json(claims) signed_content = f"{header}.{payload}".encode() signature = hmac.new(secret.encode(), signed_content, hashlib.sha256).digest() return f"{header}.{payload}.{base64url_bytes(signature)}" @@ -988,6 +994,23 @@ async def exercise_api_contract(base_url: str, env: dict[str, str]) -> None: "subject": f"real-api-artifact-verifier-{run_id}", "reason": "Real HTTP controlled service provisioning proof", } + fixed_service_token = issue_flow_token( + service_payload["subject"], + [], + issuer=flow_issuer, + audience=flow_audience, + secret=flow_secret, + subject_kind="service", + ) + unprovisioned_service = await client.get( + "/api/v1/actors/me", + headers=auth_headers(fixed_service_token), + ) + assert unprovisioned_service.status_code == 403 + assert ( + unprovisioned_service.json()["error"]["code"] + == "service_actor_not_provisioned" + ) service_headers = auth_headers(manager_token) | { "Idempotency-Key": str(uuid4()), "X-Request-ID": str(uuid4()), @@ -1028,6 +1051,12 @@ async def exercise_api_contract(base_url: str, env: dict[str, str]) -> None: 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 + admitted_service = await client.get( + "/api/v1/actors/me", + headers=auth_headers(fixed_service_token), + ) + assert admitted_service.status_code == 403 + assert admitted_service.json()["error"]["code"] == "permission_not_granted" serialized_service_reads = json.dumps( [service_admin_profile, service_admin_link], sort_keys=True, @@ -1049,12 +1078,27 @@ async def exercise_api_contract(base_url: str, env: dict[str, str]) -> None: "http_status": 200, } assert lifecycle_reason not in suspended_service.text + suspended_service_admission = await client.get( + "/api/v1/actors/me", + headers=auth_headers(fixed_service_token), + ) + assert suspended_service_admission.status_code == 403 + assert suspended_service_admission.json()["error"]["code"] == "actor_suspended" reactivated_service = await client.post( f"/api/v1/actors/{service_actor_id}/reactivate", headers=auth_headers(manager_token) | {"Idempotency-Key": str(uuid4())}, json={"reason": "Real HTTP service profile correction"}, ) assert reactivated_service.status_code == 200, reactivated_service.text + reactivated_service_admission = await client.get( + "/api/v1/actors/me", + headers=auth_headers(fixed_service_token), + ) + assert reactivated_service_admission.status_code == 403 + assert ( + reactivated_service_admission.json()["error"]["code"] + == "permission_not_granted" + ) service_link_id = service_admin_link["identity_link_id"] link_lifecycle_key = str(uuid4()) @@ -1073,6 +1117,15 @@ async def exercise_api_contract(base_url: str, env: dict[str, str]) -> None: "http_status": 200, } assert link_lifecycle_reason not in revoked_service_link.text + revoked_service_admission = await client.get( + "/api/v1/actors/me", + headers=auth_headers(fixed_service_token), + ) + assert revoked_service_admission.status_code == 403 + assert ( + revoked_service_admission.json()["error"]["code"] + == "identity_link_revoked" + ) replayed_service_link = await client.post( f"/api/v1/actor-identity-links/{service_link_id}/revoke", headers=auth_headers(manager_token) @@ -1113,6 +1166,12 @@ async def exercise_api_contract(base_url: str, env: dict[str, str]) -> None: ) assert repaired_service_link_view["status"] == "active" assert repaired_service_link_view["last_verified_at"] is None + repaired_service_admission = await client.get( + "/api/v1/actors/me", + headers=auth_headers(fixed_service_token), + ) + assert repaired_service_admission.status_code == 403 + assert repaired_service_admission.json()["error"]["code"] == "permission_not_granted" deactivated_service = await client.post( f"/api/v1/actors/{service_actor_id}/deactivate", diff --git a/backend/tests/test_actors.py b/backend/tests/test_actors.py index fef1e04b4..1db49c26a 100644 --- a/backend/tests/test_actors.py +++ b/backend/tests/test_actors.py @@ -102,6 +102,48 @@ def test_actor_admin_response_requires_exact_service_identity_pair() -> None: ) +async def test_service_admission_rejects_malformed_stored_identity_without_writes() -> None: + profile = ActorProfile( + id=str(uuid4()), + actor_kind="service", + status="active", + provisioning_method="manual_service_provisioning", + service_identity="private-invalid-service-identity", + created_by=str(uuid4()), + ) + link = ActorIdentityLink( + id=str(uuid4()), + actor_profile_id=profile.id, + issuer=ISSUER, + subject="malformed-service", + subject_kind="service", + status="active", + linked_by=str(uuid4()), + ) + + class Repository: + calls: list[str] = [] + + async def get_identity_link(self, _issuer, _subject): + self.calls.append("link") + return link + + async def get_actor_profile(self, _profile_id): + self.calls.append("profile") + return profile + + repository = Repository() + service = ActorService.__new__(ActorService) + service._repo = cast(ActorRepository, repository) + + with pytest.raises(ServiceActorNotProvisioned, match="not provisioned"): + await service.resolve_service_for_authorization( + verified_token("malformed-service", kind="service") + ) + + assert repository.calls == ["link", "profile"] + + async def test_actor_admin_reads_are_bounded_and_reuse_exact_repository_lookups() -> None: now = datetime.now(UTC) actor_id, link_id = uuid4(), uuid4() @@ -726,12 +768,17 @@ async def test_unsupported_subject_kinds_create_nothing( async def test_unknown_service_creates_nothing(actor_database_env: str) -> None: async with db_session.get_session_factory()() as session: + service = ActorService(session) with pytest.raises(ServiceActorNotProvisioned): - await ActorService(session).resolve_verified_actor( + await service.resolve_verified_actor( verified_token("unknown-service", kind="service"), request_id=uuid4(), correlation_id=uuid4(), ) + with pytest.raises(ServiceActorNotProvisioned): + await service.resolve_service_for_authorization( + verified_token("unknown-service", kind="service") + ) await session.rollback() async with db_session.get_session_factory()() as session: assert await session.scalar(select(func.count()).select_from(ActorProfile)) == 0 @@ -1403,6 +1450,11 @@ async def test_existing_actor_and_legacy_negative_states_fail_closed( ) persisted = await service.find_actor_for_authorization(service_token) assert persisted is not None + admitted = await service.resolve_service_for_authorization(service_token) + assert admitted.profile.id == service_actor_id + locked = await service.lock_actor_for_authorization(admitted) + assert locked is not None + assert locked.profile.service_identity == ServiceIdentity.ARTIFACT_VERIFIER assert persisted.profile.last_seen_at is None assert persisted.identity_link.last_verified_at is None diff --git a/backend/tests/test_artifact_admission.py b/backend/tests/test_artifact_admission.py index 975a878d7..8a4a4167a 100644 --- a/backend/tests/test_artifact_admission.py +++ b/backend/tests/test_artifact_admission.py @@ -52,7 +52,9 @@ ActorKind, ActorStatus, AuthorizationContext, + HumanAuthorizationContext, IdentityLinkStatus, + ServiceAuthorizationContext, ) from app.modules.projects.models import ( EffectiveProjectSubmissionArtifactPolicy, @@ -137,15 +139,21 @@ def _context( identity_link_id: UUID | None = None, actor_kind: ActorKind = ActorKind.HUMAN, ) -> AuthorizationContext: - return AuthorizationContext( + common = dict( actor_profile_id=actor_profile_id or uuid4(), - actor_kind=actor_kind, actor_status=ActorStatus.ACTIVE, identity_link_id=identity_link_id or uuid4(), identity_link_status=IdentityLinkStatus.ACTIVE, request_id=uuid4(), correlation_id=uuid4(), ) + if actor_kind is ActorKind.SERVICE: + return ServiceAuthorizationContext( + actor_kind=ActorKind.SERVICE, + service_identity=ServiceIdentity.ARTIFACT_CHECKER_OUTPUT, + **common, + ) + return HumanAuthorizationContext(actor_kind=ActorKind.HUMAN, **common) async def _seed_human_actor( diff --git a/backend/tests/test_auth.py b/backend/tests/test_auth.py index 2ce825e5c..aa6f99e0f 100644 --- a/backend/tests/test_auth.py +++ b/backend/tests/test_auth.py @@ -3707,6 +3707,17 @@ async def capture_response(response: Response) -> None: "subject": service_subject, "reason": reason, } + unprovisioned_service = await client.post( + "/api/v1/service-actors", + headers={**service_headers, "Idempotency-Key": str(uuid4())}, + json=payload, + ) + assert unprovisioned_service.status_code == 403 + assert ( + unprovisioned_service.json()["error"]["code"] + == "service_actor_not_provisioned" + ) + assert await service_state(ServiceIdentity.ARTIFACT_VERIFIER) is None key = str(uuid4()) created = await client.post( "/api/v1/service-actors", @@ -3749,6 +3760,14 @@ async def capture_response(response: Response) -> None: str(admin_id), ) assert state[11] is None + service_human_path_denial = await client.post( + "/api/v1/service-actors", + headers={**service_headers, "Idempotency-Key": str(uuid4())}, + json=payload, + ) + assert service_human_path_denial.status_code == 403 + assert service_human_path_denial.json()["error"]["code"] == "permission_not_granted" + assert await service_state(ServiceIdentity.ARTIFACT_VERIFIER) == state caller_after_create = await actor_timestamps(admin_id) assert caller_after_create[0] > caller_before[0] assert caller_after_create[1] > caller_before[1] @@ -3878,10 +3897,13 @@ async def unavailable_replay(self, **kwargs): assert service_subject not in invalid_header.text assert reason not in invalid_header.text - for path in ("/api/v1/actors/me", "/api/v1/auth/me"): + for path, expected_code in ( + ("/api/v1/actors/me", "permission_not_granted"), + ("/api/v1/auth/me", "service_actor_not_provisioned"), + ): service_denial = await client.get(path, headers=service_headers) assert service_denial.status_code == 403 - assert service_denial.json()["error"]["code"] == "service_actor_not_provisioned" + assert service_denial.json()["error"]["code"] == expected_code assert await service_state(ServiceIdentity.ARTIFACT_VERIFIER) == state race_key = str(uuid4()) diff --git a/backend/tests/test_authorization.py b/backend/tests/test_authorization.py index bea952d24..1839fa4ec 100644 --- a/backend/tests/test_authorization.py +++ b/backend/tests/test_authorization.py @@ -22,7 +22,7 @@ from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine from starlette.requests import Request -from app.api.deps.authorization import get_authorization_service +from app.api.deps.authorization import get_authorization_actor, get_authorization_service from app.core.api_controls import StructuredHTTPException from app.core.config import get_settings from app.modules.audit.schemas import ( @@ -121,10 +121,12 @@ AuthorizationDecision, AuthorizationDenied, AuthorizationDenialCode, + HumanAuthorizationContext, IdentityLinkStatus, MatchedAuthorityKind, PermissionCatalogueResourceContext, ServiceActorProvisionResourceContext, + ServiceAuthorizationContext, SystemResourceContext, authorization_resource_digest, ) @@ -966,7 +968,17 @@ def _runtime_context( link_status: IdentityLinkStatus = IdentityLinkStatus.ACTIVE, actor_kind: ActorKind = ActorKind.HUMAN, ) -> AuthorizationContext: - return AuthorizationContext( + context_type = ( + ServiceAuthorizationContext + if actor_kind is ActorKind.SERVICE + else HumanAuthorizationContext + ) + service_fields = ( + {"service_identity": ServiceIdentity.ARTIFACT_VERIFIER} + if actor_kind is ActorKind.SERVICE + else {} + ) + return context_type( actor_profile_id=uuid4(), actor_kind=actor_kind, actor_status=actor_status, @@ -974,6 +986,7 @@ def _runtime_context( identity_link_status=link_status, request_id=uuid4(), correlation_id=uuid4(), + **service_fields, ) @@ -992,13 +1005,19 @@ def _runtime_service( context: AuthorizationContext, *, revalidate=_DEFAULT_REVALIDATOR, + revalidate_service=None, ) -> tuple[AuthorizationService, _DecisionEvidence]: if revalidate is _DEFAULT_REVALIDATOR: async def revalidate(current, _resource): return current - service = AuthorizationService(object(), context, revalidate_actor_self=revalidate) # type: ignore[arg-type] + service = AuthorizationService( + object(), # type: ignore[arg-type] + context, + revalidate_actor_self=revalidate, + revalidate_service=revalidate_service, + ) evidence = _DecisionEvidence() service._audit = evidence # type: ignore[assignment] return service, evidence @@ -1020,6 +1039,7 @@ def __init__(self, context: AuthorizationContext) -> None: target_actor_profile_id=str(uuid4()), ) self.request_actor_is_present = True + self.request_actor_kind = "human" self.lifecycle_target_is_present = True self.link_lifecycle_target_is_present = True self.control_locked = False @@ -1034,7 +1054,11 @@ async def lock_request_actor(self, identity_link_id, actor_profile_id): return None return ( SimpleNamespace(id=str(identity_link_id), status="active"), - SimpleNamespace(id=str(actor_profile_id), actor_kind="human", status="active"), + SimpleNamespace( + id=str(actor_profile_id), + actor_kind=self.request_actor_kind, + status="active", + ), ) async def find_effective_grant(self, *args, **kwargs): @@ -1106,6 +1130,25 @@ async def test_admin_kernel_allows_only_a_matched_registered_grant() -> None: assert denied.value.public_code == "permission_not_granted" +async def test_admin_kernel_denies_locked_human_kind_drift_without_grant_lookup() -> None: + context = _runtime_context() + service, evidence, facts = _admin_runtime_service(context) + facts.request_actor_kind = "service" + resource = ActorProfileAdminReadResourceContext( + resource_type="actor_profile", + resource_id=uuid4(), + read_kind="profile", + ) + + with pytest.raises(AuthorizationDenied) as denied: + await service.require(ActionId.ACTOR_PROFILE_READ, resource) + + assert denied.value.decision.denial_code is AuthorizationDenialCode.PERMISSION_NOT_GRANTED + assert denied.value.decision.revalidated is True + assert facts.find_calls == [] + assert evidence.events[0].event_type is AuthorityEventType.SENSITIVE_AUTHORIZATION_DENIED + + @pytest.mark.parametrize( ("action_id", "resource_type"), [ @@ -2631,6 +2674,213 @@ async def retain_resolved_actor(_service, current): assert session.rollback_count == 1 +async def test_authorization_dependency_admits_service_without_human_rate_control( + monkeypatch: pytest.MonkeyPatch, +) -> None: + token = SimpleNamespace(subject_kind="service") + admitted = SimpleNamespace(profile=object(), identity_link=object()) + calls: list[object] = [] + + async def resolve_service(_self, current): + calls.append(current) + return admitted + + async def forbidden_human_lookup(*_args, **_kwargs): + raise AssertionError("service admission entered the human path") + + monkeypatch.setattr(ActorService, "resolve_service_for_authorization", resolve_service) + monkeypatch.setattr( + ActorService, + "find_actor_for_authorization", + forbidden_human_lookup, + ) + request = Request({"type": "http", "method": "GET", "path": "/", "headers": []}) + result = SimpleNamespace(token=token) + + resolved = await get_authorization_actor( + request, + result, # type: ignore[arg-type] + object(), # type: ignore[arg-type] + object(), # type: ignore[arg-type] + ) + + assert resolved is admitted + assert calls == [token] + + +@pytest.mark.parametrize( + ("profile_status", "link_status"), + [("suspended", "active"), ("deactivated", "active"), ("active", "revoked")], +) +async def test_inactive_service_dependency_stages_no_observation( + monkeypatch: pytest.MonkeyPatch, + profile_status: str, + link_status: str, +) -> None: + class Session: + async def rollback(self): + return None + + def in_transaction(self): + return True + + async def forbidden_touch(*_args, **_kwargs): + raise AssertionError("inactive service staged an observation") + + monkeypatch.setattr(ActorService, "touch_after_authorization", forbidden_touch) + resolved = SimpleNamespace( + profile=SimpleNamespace( + id=str(uuid4()), + actor_kind="service", + status=profile_status, + service_identity=ServiceIdentity.ARTIFACT_VERIFIER.value, + ), + identity_link=SimpleNamespace(id=str(uuid4()), status=link_status), + ) + request = Request({"type": "http", "method": "GET", "path": "/", "headers": []}) + dependency = get_authorization_service(request, resolved, Session()) # type: ignore[arg-type] + + await anext(dependency) + await dependency.aclose() + + +async def test_service_denial_rolls_back_observations_before_clean_restage( + monkeypatch: pytest.MonkeyPatch, +) -> None: + class Session: + rollback_count = 0 + commit_count = 0 + + async def rollback(self): + self.rollback_count += 1 + + async def commit(self): + self.commit_count += 1 + + def in_transaction(self): + return True + + session = Session() + actor_id, link_id = uuid4(), uuid4() + resolved = SimpleNamespace( + profile=SimpleNamespace( + id=str(actor_id), + actor_kind="service", + status="active", + service_identity=ServiceIdentity.ARTIFACT_VERIFIER.value, + ), + identity_link=SimpleNamespace(id=str(link_id), status="active"), + ) + observations: list[str] = [] + + async def stage_observation(_self, _resolved): + observations.append("staged") + return _resolved + + monkeypatch.setattr(ActorService, "touch_after_authorization", stage_observation) + request = Request({"type": "http", "method": "GET", "path": "/", "headers": []}) + dependency = get_authorization_service(request, resolved, session) # type: ignore[arg-type] + service = await anext(dependency) + + class Evidence: + rollback_counts: list[int] = [] + + async def add_authority_event(self, _event): + self.rollback_counts.append(session.rollback_count) + + evidence = Evidence() + service._audit = evidence # type: ignore[assignment] + resource = SystemResourceContext(resource_type="system", resource_id="workstream:system") + with pytest.raises(AuthorizationDenied) as exc_info: + await service.require(ActionId.ARTIFACT_VERIFICATION_EXECUTE, resource) + with pytest.raises(StructuredHTTPException) as public: + await dependency.athrow(exc_info.value) + + assert public.value.error_code == "permission_not_granted" + assert observations == ["staged"] + assert evidence.rollback_counts == [0, 1] + assert session.rollback_count == 1 + assert session.commit_count == 1 + + +async def test_service_dependency_cancellation_rolls_back_staged_observation( + monkeypatch: pytest.MonkeyPatch, +) -> None: + class Session: + rollback_count = 0 + + async def rollback(self): + self.rollback_count += 1 + + def in_transaction(self): + return True + + session = Session() + resolved = SimpleNamespace( + profile=SimpleNamespace( + id=str(uuid4()), + actor_kind="service", + status="active", + service_identity=ServiceIdentity.ARTIFACT_VERIFIER.value, + ), + identity_link=SimpleNamespace(id=str(uuid4()), status="active"), + ) + observations: list[str] = [] + + async def stage_observation(_self, _resolved): + observations.append("staged") + return _resolved + + monkeypatch.setattr(ActorService, "touch_after_authorization", stage_observation) + request = Request({"type": "http", "method": "GET", "path": "/", "headers": []}) + dependency = get_authorization_service(request, resolved, session) # type: ignore[arg-type] + await anext(dependency) + + with pytest.raises(asyncio.CancelledError): + await dependency.athrow(asyncio.CancelledError()) + + assert observations == ["staged"] + assert session.rollback_count == 1 + + +async def test_service_observation_persistence_failure_is_retryable_and_private( + monkeypatch: pytest.MonkeyPatch, +) -> None: + private_subject = "private-service-subject" + + class Session: + rollback_count = 0 + + async def rollback(self): + self.rollback_count += 1 + + session = Session() + resolved = SimpleNamespace( + profile=SimpleNamespace( + id=str(uuid4()), + actor_kind="service", + status="active", + service_identity=ServiceIdentity.ARTIFACT_VERIFIER.value, + ), + identity_link=SimpleNamespace(id=str(uuid4()), status="active"), + ) + + async def fail_observation(_self, _resolved): + raise SQLAlchemyError(private_subject) + + monkeypatch.setattr(ActorService, "touch_after_authorization", fail_observation) + request = Request({"type": "http", "method": "GET", "path": "/", "headers": []}) + dependency = get_authorization_service(request, resolved, session) # type: ignore[arg-type] + + with pytest.raises(StructuredHTTPException) as exc_info: + await anext(dependency) + + assert exc_info.value.status_code == 503 + assert exc_info.value.error_code == "service_unavailable" + assert private_subject not in str(exc_info.value) + assert session.rollback_count == 1 + + def test_authorization_runtime_contracts_are_strict_and_two_argument() -> None: context = _runtime_context() public_methods = { @@ -2654,10 +2904,19 @@ def test_authorization_runtime_contracts_are_strict_and_two_argument() -> None: assert "request_id" not in inspect.signature(method).parameters assert "correlation_id" not in inspect.signature(method).parameters with pytest.raises(ValidationError): - AuthorizationContext( + HumanAuthorizationContext( **context.model_dump(), roles=("admin",), ) + with pytest.raises(ValidationError): + HumanAuthorizationContext( + **context.model_dump(), + service_identity=ServiceIdentity.ARTIFACT_VERIFIER, + ) + with pytest.raises(ValidationError): + ServiceAuthorizationContext( + **{**context.model_dump(), "actor_kind": ActorKind.SERVICE}, + ) with pytest.raises(ValidationError): ActorSelfResourceContext( resource_type="actor_profile", @@ -2738,6 +2997,229 @@ async def test_authorization_kernel_denies_planned_and_system_actions( assert evidence.events[0].event_type is AuthorityEventType.SENSITIVE_AUTHORIZATION_DENIED +async def test_fixed_service_kernel_selects_exact_action_before_availability() -> None: + resource = SystemResourceContext(resource_type="system", resource_id="workstream:system") + for identity, own_actions in SERVICE_ACTIONS_BY_IDENTITY.items(): + context = ServiceAuthorizationContext( + actor_profile_id=uuid4(), + actor_kind=ActorKind.SERVICE, + actor_status=ActorStatus.ACTIVE, + identity_link_id=uuid4(), + identity_link_status=IdentityLinkStatus.ACTIVE, + service_identity=identity, + request_id=uuid4(), + correlation_id=uuid4(), + ) + service, _ = _runtime_service(context) + for action in set().union(*SERVICE_ACTIONS_BY_IDENTITY.values()): + with pytest.raises(AuthorizationDenied) as exc_info: + await service.require(action, resource) + expected = ( + AuthorizationDenialCode.ACTION_UNAVAILABLE + if action in own_actions + else AuthorizationDenialCode.PERMISSION_NOT_GRANTED + ) + assert exc_info.value.decision.denial_code is expected + + +async def test_fixed_service_kernel_never_enters_human_grant_evaluation() -> None: + context = _runtime_context(actor_kind=ActorKind.SERVICE) + service, _ = _runtime_service(context) + + class HumanFacts: + async def find_effective_grant(self, *_args, **_kwargs): + raise AssertionError("service context entered human grant evaluation") + + service._admin = HumanFacts() # type: ignore[assignment] + resource = PermissionCatalogueResourceContext( + resource_type="permission_catalogue", + resource_id="workstream:permission_catalogue", + ) + with pytest.raises(AuthorizationDenied) as exc_info: + await service.require(ActionId.AUTHORIZATION_PERMISSION_CATALOGUE_READ, resource) + assert exc_info.value.decision.denial_code is AuthorizationDenialCode.PERMISSION_NOT_GRANTED + + +async def test_fixed_service_active_candidate_uses_one_revalidation_seam( + monkeypatch: pytest.MonkeyPatch, +) -> None: + context = _runtime_context(actor_kind=ActorKind.SERVICE) + calls: list[tuple[ServiceIdentity, ActionId]] = [] + + async def revalidate(current: ServiceAuthorizationContext, action: ActionId): + calls.append((current.service_identity, action)) + return current + + service, _ = _runtime_service(context, revalidate_service=revalidate) + action = ActionId.ARTIFACT_VERIFICATION_EXECUTE + definition = ACTION_BY_ID[action] + monkeypatch.setattr( + authorization_kernel, + "ACTION_BY_ID", + { + **ACTION_BY_ID, + action: replace(definition, availability=ActionAvailability.ACTIVE), + }, + ) + resource = SystemResourceContext(resource_type="system", resource_id="workstream:system") + + with pytest.raises(AuthorizationDenied) as exc_info: + await service.require(action, resource) + + assert exc_info.value.decision.denial_code is AuthorizationDenialCode.RESOURCE_GUARD_DENIED + assert exc_info.value.decision.revalidated is True + assert calls == [(ServiceIdentity.ARTIFACT_VERIFIER, action)] + + +@pytest.mark.parametrize( + ("actor_status", "link_status", "expected"), + [ + (ActorStatus.ACTIVE, IdentityLinkStatus.REVOKED, AuthorizationDenialCode.IDENTITY_LINK_REVOKED), + (ActorStatus.SUSPENDED, IdentityLinkStatus.ACTIVE, AuthorizationDenialCode.ACTOR_SUSPENDED), + (ActorStatus.DEACTIVATED, IdentityLinkStatus.ACTIVE, AuthorizationDenialCode.ACTOR_DEACTIVATED), + ], +) +async def test_fixed_service_lifecycle_denies_before_matrix_evaluation( + actor_status: ActorStatus, + link_status: IdentityLinkStatus, + expected: AuthorizationDenialCode, +) -> None: + context = _runtime_context( + actor_kind=ActorKind.SERVICE, + actor_status=actor_status, + link_status=link_status, + ) + service, _ = _runtime_service(context) + resource = SystemResourceContext(resource_type="system", resource_id="workstream:system") + + with pytest.raises(AuthorizationDenied) as exc_info: + await service.require(ActionId.ARTIFACT_VERIFICATION_EXECUTE, resource) + + assert exc_info.value.decision.denial_code is expected + + +@pytest.mark.parametrize( + ("profile_status", "link_status", "service_identity", "expected"), + [ + ("suspended", "active", ServiceIdentity.ARTIFACT_VERIFIER.value, AuthorizationDenialCode.ACTOR_SUSPENDED), + ("deactivated", "active", ServiceIdentity.ARTIFACT_VERIFIER.value, AuthorizationDenialCode.ACTOR_DEACTIVATED), + ("active", "revoked", ServiceIdentity.ARTIFACT_VERIFIER.value, AuthorizationDenialCode.IDENTITY_LINK_REVOKED), + ("active", "active", ServiceIdentity.ARTIFACT_SCHEDULER.value, AuthorizationDenialCode.PERMISSION_NOT_GRANTED), + ("active", "active", "malformed-service-identity", AuthorizationDenialCode.PERMISSION_NOT_GRANTED), + ], +) +async def test_fixed_service_real_revalidation_rejects_locked_drift( + monkeypatch: pytest.MonkeyPatch, + profile_status: str, + link_status: str, + service_identity: str, + expected: AuthorizationDenialCode, +) -> None: + class Session: + async def rollback(self): + return None + + def in_transaction(self): + return True + + actor_id, link_id = uuid4(), uuid4() + resolved = SimpleNamespace( + profile=SimpleNamespace( + id=str(actor_id), + actor_kind="service", + status="active", + service_identity=ServiceIdentity.ARTIFACT_VERIFIER.value, + ), + identity_link=SimpleNamespace(id=str(link_id), status="active"), + ) + locked = SimpleNamespace( + profile=SimpleNamespace( + id=str(actor_id), + actor_kind="service", + status=profile_status, + service_identity=service_identity, + ), + identity_link=SimpleNamespace(id=str(link_id), status=link_status), + ) + + async def no_observation(_self, current): + return current + + async def lock_drifted(_self, _resolved): + return locked + + monkeypatch.setattr(ActorService, "touch_after_authorization", no_observation) + monkeypatch.setattr(ActorService, "lock_actor_for_authorization", lock_drifted) + + action = ActionId.ARTIFACT_VERIFICATION_EXECUTE + monkeypatch.setattr( + authorization_kernel, + "ACTION_BY_ID", + { + **ACTION_BY_ID, + action: replace(ACTION_BY_ID[action], availability=ActionAvailability.ACTIVE), + }, + ) + request = Request({"type": "http", "method": "GET", "path": "/", "headers": []}) + dependency = get_authorization_service(request, resolved, Session()) # type: ignore[arg-type] + service = await anext(dependency) + + class Evidence: + async def add_authority_event(self, _event): + return None + + service._audit = Evidence() # type: ignore[assignment] + resource = SystemResourceContext(resource_type="system", resource_id="workstream:system") + + with pytest.raises(AuthorizationDenied) as exc_info: + await service.require(action, resource) + + assert exc_info.value.decision.denial_code is expected + assert exc_info.value.decision.revalidated is True + await dependency.aclose() + + +@pytest.mark.parametrize("drift", ["matrix", "availability"]) +async def test_fixed_service_revalidation_rejects_matrix_or_availability_drift( + monkeypatch: pytest.MonkeyPatch, + drift: str, +) -> None: + context = _runtime_context(actor_kind=ActorKind.SERVICE) + action = ActionId.ARTIFACT_VERIFICATION_EXECUTE + active_actions = { + **ACTION_BY_ID, + action: replace(ACTION_BY_ID[action], availability=ActionAvailability.ACTIVE), + } + monkeypatch.setattr(authorization_kernel, "ACTION_BY_ID", active_actions) + + async def revalidate(current: ServiceAuthorizationContext, _action: ActionId): + if drift == "matrix": + monkeypatch.setattr( + authorization_kernel, + "SERVICE_ACTIONS_BY_IDENTITY", + {**SERVICE_ACTIONS_BY_IDENTITY, current.service_identity: frozenset()}, + ) + else: + monkeypatch.setattr( + authorization_kernel, + "ACTION_BY_ID", + { + **active_actions, + action: replace(active_actions[action], availability=ActionAvailability.PLANNED), + }, + ) + return current + + service, _ = _runtime_service(context, revalidate_service=revalidate) + resource = SystemResourceContext(resource_type="system", resource_id="workstream:system") + + with pytest.raises(AuthorizationDenied) as exc_info: + await service.require(action, resource) + + assert exc_info.value.decision.denial_code is AuthorizationDenialCode.PERMISSION_NOT_GRANTED + assert exc_info.value.decision.revalidated is True + + async def test_authorization_kernel_denies_active_action_without_implemented_authority( monkeypatch: pytest.MonkeyPatch, ) -> None: diff --git a/docs/operations_authorization_service.md b/docs/operations_authorization_service.md index c1fe5ddf5..0357e2ae7 100644 --- a/docs/operations_authorization_service.md +++ b/docs/operations_authorization_service.md @@ -706,6 +706,31 @@ manifest followed by AUTH-owned enum/constraint/matrix, provisioning, admission, and cross-service denial proof. Do not create a shared review service or a database service-grant table. +Fixed-service admission is request-local. Resolve only the verified issuer and +opaque subject through the exact stored link and active service profile; never +accept a service identity, action, permission, or matrix row from request or +token claims. An unknown or absent provisioned row returns bounded +`service_actor_not_provisioned` and does not block startup or Access +Administrator provisioning. Static catalogue/matrix mismatch is instead a +code invariant and may fail startup. + +For an admitted service, AUTH selects its exact static `ActionId` row before +availability. Cross-service actions deny without human grant lookup, including +actions sharing a `PermissionId`; own rows remain unavailable while planned. +Revoked links and suspended or deactivated profiles deny from current database +state. Failed admission creates no first-access row, invokes no human rate +control, and advances no timestamp. If exact active resolution staged +observation timestamps before a later denial, cancellation, or evidence +failure, the request transaction rolls them back before secret-free denial +evidence is restaged from a clean transaction. + +Diagnose fixed-service denials only through request/correlation ID, local actor +reference, exact ActionId, and bounded denial facts. Never log or export issuer, +opaque subject, bearer material, claims, scopes, provider payloads, mapping-file +contents, or service credentials. Lifecycle or immutable-identity drift during +the profile-then-link lock/revalidation step is a denial, not a fallback to +human grants. + ## Actor Self Decision Operations `GET /api/v1/actors/me` declares `actor.profile.read_self`; it permits an @@ -801,9 +826,9 @@ request/correlation IDs and ActionId `actor.service.provision`. Provisioning is not token verification. The new service profile's `last_seen_at` and link's `last_verified_at` stay null, including on replay. Only the human caller timestamps advance on a committed create or replay. -Service tokens remain denied before actor lookup on both central AUTH and -legacy dependencies until AUTH-09E explicitly activates fixed-service -admission. +Provisioned service tokens enter only the central AUTH typed-service path; +legacy dependencies continue to reject them. Unprovisioned service tokens deny +without actor first-access, timestamp observation, or rate-control activity. If route-owned persistence or decision evidence fails, roll back the entire unit and return the bounded retryable `503 service_unavailable` envelope. Do diff --git a/docs/spec_authorization_service.md b/docs/spec_authorization_service.md index 9fa2e1db5..a715da7bd 100644 --- a/docs/spec_authorization_service.md +++ b/docs/spec_authorization_service.md @@ -501,7 +501,10 @@ For every protected operation: 1. Verify the external token through the existing verifier boundary. 2. Resolve the canonical identity link and actor profile without preempting the action's lifecycle guards. -3. Build request-scoped `AuthorizationContext` without token-role authority. +3. Build the closed request-scoped + `HumanAuthorizationContext | ServiceAuthorizationContext` union without + token-role authority. Only the service variant carries a required, closed + `service_identity`. 4. Load the canonical resource through its owning repository/service. 5. Compose `ResourceContext` in the application service. 6. Load active candidate grants using database time. @@ -517,6 +520,13 @@ Each decision carries a bounded SHA-256 digest of its complete typed resource context so feature code cannot reuse it with substituted role, scope, target, or replay facts. List filtering occurs before counts and pagination cursors. +Context-type dispatch occurs before candidate lookup. A service context never +enters actor-self, administrative-grant, project-grant, contributor, or human +rate-control paths. AUTH checks exact `ActionId` membership in the fixed +identity's static matrix row before checking availability; a different row +denies even when both actions share one `PermissionId`. An own-row action still +denies while its availability is `planned`. + Sensitive mutations use the prepared protocol instead of evaluating final authority against unlocked feature facts: @@ -539,6 +549,13 @@ link locks; they are not database rows or lock targets. Existing actor-self, administrative, and lifecycle mutations must use the same authority-row order before any prepared consumer ships. +AUTH-09E supplies the reusable transaction-local service-authority lock and +revalidation seam: reload and lock profile then exact link, recheck row +identity, lifecycle, immutable service identity, exact matrix membership, and +current action availability, and return refreshed typed authority without +committing. Later feature activation chunks own locked feature-row +recomposition, their exact `ResourceContext`, and terminal mutation proof. + The handle is single-use, nonserializable, and never a route schema or caller input. Consumption matches the exact session, action, actor reference kind, actor reference, idempotency key, and request digest before feature mutation. @@ -613,6 +630,13 @@ human grant evaluation. Feature actions remain unavailable until their owning feature supplies canonical resource facts, guards, hidden behavior, and proof and AUTH separately activates them. +Exact active resolution may stage monotonic profile/link observation timestamps +in the caller-owned request transaction. Admission denial stages no +observations. Planned-action denial, cancellation, decision-evidence failure, +or other request failure rolls staged observations back; bounded denial +evidence is restaged only from a clean transaction and contains no issuer, +subject, bearer material, claims, scopes, provider data, or service secret. + New fixed services are added only after the owning feature publishes an exact identity-to-ActionId manifest. AUTH then owns one closed enum/constraint/matrix extension, controlled provisioning, admission reuse, and all-pairs diff --git a/scripts/test_agent_gates.py b/scripts/test_agent_gates.py index c631c5744..345ae55f0 100644 --- a/scripts/test_agent_gates.py +++ b/scripts/test_agent_gates.py @@ -4171,10 +4171,8 @@ def test_parallel_initiative_status_matches_trusted_main() -> None: assert "Merged through PR #148 as `99ae4c9`" in auth_map assert "| `WS-AUTH-001-09D-A` | Merged |" in auth_status assert "| `WS-AUTH-001-09D-B` | Merged |" in auth_status - assert "Active implementation chunk\n\n`WS-AUTH-001-CONTRIBUTOR-FOUNDATION`" in ( - auth_status - ) - assert "`codex/ws-auth-001-contributor-foundation`" in auth_status + assert "Active implementation chunk\n\n`WS-AUTH-001-09E`" in auth_status + assert "`codex/ws-auth-001-09e-fixed-service-runtime-admission`" in auth_status assert "PR #148 is open" not in auth_status stale_auth_09d_state = ( "Only 09D-A implementation is active", @@ -4188,39 +4186,35 @@ def test_parallel_initiative_status_matches_trusted_main() -> None: assert stale_text not in auth_map assert stale_text not in work_queue assert ( - "Internal review passed at `4d1fc507`; PR/external checks pending; " - "aggregate coverage mandatory in Backend" in work_queue - ) - assert ( - "Active implementation chunk: `WS-AUTH-001-CONTRIBUTOR-FOUNDATION`" - in loop_state + "Runtime, focused evidence, and all nine internal tracks pass after " + "repair; hosted Backend CI and human review remain" + in work_queue ) + assert "PR-gate chunk: `WS-AUTH-001-09E`" in loop_state assert "ActionIds, with 17 active actions" in loop_state assert "candidate total of 17" not in loop_state assert "with 12 active actions" not in loop_state assert "five 09D-A/09D-B lifecycle actions" not in loop_state assert ( "| `WS-AUTH-001-CONTRIBUTOR-FOUNDATION` | Contributor Fields And " - "Canonical-Human Lineage | L1 | Internal review passed at `4d1fc507`; " - "PR/external checks pending; Backend coverage mandatory" in auth_map - ) - assert ( - "| `WS-AUTH-001-CONTRIBUTOR-FOUNDATION` | PR ready |" in auth_status + "Canonical-Human Lineage | L1 | Merged through PR #153 as `8d5eb15b`" + in auth_map ) + assert "| `WS-AUTH-001-CONTRIBUTOR-FOUNDATION` | Merged |" in auth_status assert ( "| `WS-AUTH-001-09E` | Fixed Service Runtime Admission | L1 | " - "Inactive until contributor-foundation merge/memory and explicit start" + "Runtime, focused evidence, and all nine internal tracks pass after " + "repair; hosted Backend CI and human review remain" in auth_map ) - assert "| `WS-AUTH-001-09E` | Proposed |" in auth_status + assert "| `WS-AUTH-001-09E` | PR gate |" in auth_status assert ( "| `WS-AUTH-001-09E` | Fixed Service Runtime Admission | L1 | " - "Inactive until contributor-foundation merge/memory and explicit user start" + "Runtime, focused evidence, and all nine internal tracks pass after " + "repair; hosted Backend CI and human review remain" in work_queue ) - assert "no service caller becomes executable before AUTH-09E" in loop_state.replace( - "\n", " " - ) + assert "No feature action or call site becomes active" in " ".join(loop_state.split()) assert "Merged through PR #129 as `9a04434`" in artifact_map assert "Merged through PR #141 as `a10d901`" in artifact_map assert "Merged through PR #151 as `1b5422fc`" in artifact_map @@ -4241,12 +4235,11 @@ def test_parallel_initiative_status_matches_trusted_main() -> None: "| `WS-AUTH-001-09C` | Actor And Identity-Link Administration Reads | L1 | " "Merged through PR #146 as `0ffdabf`" in work_queue ) - assert "| `WS-ART-001-02C1` | Admission And Put-Attempt Foundation | L1 | Active" in ( - work_queue - ) - assert "Current ART gate: integrate trusted `main`, complete deterministic 02C1" in ( - loop_state + assert ( + "| `WS-ART-001-02C1` | Admission And Put-Attempt Foundation | L1 | " + "Merged through PR #154 as `44f2467c`" in work_queue ) + assert "PR #154 merged" in loop_state def test_stale_authorization_discovery_includes_new_untracked_docs() -> None: