diff --git a/.agent-loop/LOOP_STATE.md b/.agent-loop/LOOP_STATE.md index 1ebf0df06..62150a604 100644 --- a/.agent-loop/LOOP_STATE.md +++ b/.agent-loop/LOOP_STATE.md @@ -5,29 +5,39 @@ - This authored file is reviewed planning/history context, not canonical live post-merge state. Canonical state is the signed schema-v2 output on `automation/loop-memory`. -- Active initiative: none recorded here +- Active initiative: `WS-AUTH-001` - Workstream Authorization Service - Active planning chunk: none -- Active implementation chunk: none +- Active implementation chunk: `WS-AUTH-001-07A` - Closed Permission And Action + Catalogue +- Current branch: `codex/ws-auth-001-07-authorization-kernel` +- Start basis: the user explicitly started AUTH-07 after PR #124 merged AUTH-06 + as `f599551`; signed merge state required a separate explicit start. - PR #119 merged `WS-AUTH-001-05B` as `ad71c7e`. - PR #120 merged `WS-ART-001-OBJECT-STORAGE-AMENDMENT` as `4408256`. - PR #122 merged the first automated post-merge memory implementation as `fc89fb6`; its schema-v1 cross-initiative next pointer is superseded by the schema-v2 initiative-local clean cut. -- Current gate: no product chunk is selected by this authored file. A human - must explicitly start one candidate after reading current signed state. +- Current gate: AUTH-07A's canonical review/revision amendment passed every + required internal reviewer track at `160af8a`; deterministic evidence is + complete and PR #126 awaits external checks and explicit human approval. - Scope checkpoint: AWS S3 is the only v0.1 production provider; MinIO is local/CI S3 protocol proof; LocalStorage is focused development/test; R2 and Flow Node are deferred. Product modules receive narrow artifact capabilities, and AWS cannot instantiate in production without release-bound live proof. -- Authorization checkpoint: the approved catalogue contains 73 identifiers, - including 21 artifact permissions. AUTH-07 registers them, AUTH-08 defines +- Authorization checkpoint: the approved catalogue contains 74 PermissionIds + and 50 planned ActionIds, including 21 artifact permissions and one additive + `review.queue.override` permission. AUTH-07 registers them, AUTH-08 defines applicable Operator grants, AUTH-09 provisions fixed service principals, and each owning WS-ART feature chunk activates only its own canonical actions. -- Next artifact candidate: `WS-ART-001-02A1` remains inactive until the user - gives a separate explicit start signal. -- Parallel authorization work: `WS-AUTH-001-05B` merged through PR #119 as - `ad71c7e`. `WS-AUTH-001-06` remains inactive pending a separate explicit - user start. +- Parallel artifact checkpoint: `WS-ART-001-02A1` was explicitly started and + merged through PR #127 as `f64a8e5`; it is at the post-merge memory/stop + checkpoint. `WS-ART-001-02A2` remains inactive until signed memory completes + and the user gives a separate explicit start signal. +- Authorization checkpoint: `WS-AUTH-001-06` merged through PR #124 as + `f599551`. The user separately started parent `WS-AUTH-001-07`; required L1 + review split it into 07A/07B before runtime implementation. AUTH-07B and + AUTH-08 remain inactive until their predecessor merges, automated memory + completes, and the user gives another explicit start. - 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 0490c6e39..ba795e286 100644 --- a/.agent-loop/REVIEW_LOG.md +++ b/.agent-loop/REVIEW_LOG.md @@ -1,5 +1,97 @@ # Review Log +## 2026-07-15 - WS-AUTH-001-07A Canonical Review Amendment Passed + +Exact reviewed head `160af8afd030f042ee72ec963e6f47cd8b7d4c9a` reserves +the canonical `submission.create` dependency and 19 review actions. The closed +catalogue is now exactly 74 PermissionIds and 50 planned ActionIds; only +`review.queue.override` is additive. Initial and revision submissions share +`submission.create`. Required exact-head review passed after removing duplicate +session authority from the public contract: request-scoped +`AuthorizationService` binds the caller-owned `AsyncSession` and exposes only +`require(action_id, typed_resource_context)`. Full migration proof passed 16 +tests; focused authorization/audit coverage remains above 90 percent. PR #126 +is the current external and explicit-human gate; AUTH-07B remains inactive. + +## 2026-07-15 - WS-AUTH-001-07A Internal Review Passed + +Exact implementation SHA `478a819236b9cff1e1d7b61203015691ce0aaf45` +passed senior engineering, architecture/reuse, security/auth, product/ops, +docs, QA/test, test-delta, and CI-integrity review after all valid findings were +repaired. The final downgrade guard covers action evidence, new permissions, +permission-registry target references, and permission-registry invalidation +references under one exclusive table lock. Focused authorization/audit branch +coverage is 94/93 percent, all 37 focused behavior tests pass, and the full +isolated Alembic suite passes 16 tests at exact migration head. PR publication +is pending; AUTH-07B remains inactive. + +## 2026-07-15 - WS-AUTH-001-07A Repaired Plan Passed + +Exact-SHA `beb85ac` passed senior engineering, architecture/reuse, +security/auth, product/ops, docs, QA/test, test-delta, and CI-integrity review. +The final contract keeps planned/active availability in typed validation while +PostgreSQL enforces registered identifiers, decision-event use, exact +action-to-permission mapping, post-`0018` permission pairing, and guarded +downgrade custody. Seventy-one agent-gate tests and all static documentation +checks passed. Bounded AUTH-07A implementation may begin; AUTH-07B remains +inactive. + +## 2026-07-15 - WS-AUTH-001-07A Third Repaired Plan Failed + +Exact-SHA architecture/reuse and CI review of `8690ef5` passed all prior +mapping, downgrade, scope, and verification findings but rejected one +temporal-schema ambiguity. If migration `0021` froze all planned actions to +denial-only PostgreSQL evidence, AUTH-07B could not activate its two self +actions without an unowned migration. The repair keeps availability enforcement +in typed catalogue validation while PostgreSQL permanently enforces registered +identifiers, decision-event-only use, and exact action-to-permission mapping. +Runtime code remains unmodified pending fresh exact-SHA review. + +## 2026-07-15 - WS-AUTH-001-07A Second Repaired Plan Failed + +Exact-SHA senior engineering, architecture/reuse, QA/test, and CI-integrity +review passed `b1b47b0`, while security/auth, product/ops, and docs review found +one remaining audit-integrity blocker. The contract independently bounded +ActionIds and PermissionIds but did not enforce each action's exact permission +mapping, allowed newly admitted permissions without action evidence, did not +bar planned actions from allowed-decision evidence, and checked only non-null +actions before downgrade. The repair closes all four cases in typed and +PostgreSQL acceptance criteria and keeps runtime code unmodified pending fresh +exact-SHA review. + +## 2026-07-15 - WS-AUTH-001-07 Started + +PR #124 merged `WS-AUTH-001-06` as `f599551`; Backend, Agent Gates, +CodeRabbit, and signed automated merge memory passed. The user explicitly +started `WS-AUTH-001-07` on branch +`codex/ws-auth-001-07-authorization-kernel`. This L1 authorization chunk is in +read-only discovery and required plan review; runtime implementation has not +started. + +## 2026-07-15 - WS-AUTH-001-07 Combined Plan Rejected + +Architecture/reuse and security/auth/product/docs reviewers failed the combined +contract; QA/test and CI integrity passed only with blocking conditions. The +contract required grant-backed admin reads before AUTH-08, project capabilities +before AUTH-10, omitted the audit ORM and actor-route ownership files, and did +not define exact active actions, denial precedence, transaction ownership, or +coverage-compatible verification. No runtime code was written. + +The bounded repair splits parent AUTH-07 into 07A closed catalogue/action-aware +audit parity and 07B minimal kernel/actor self-action cutover. The repaired 07A +contract must pass fresh L1 plan review before implementation. + +## 2026-07-15 - WS-AUTH-001-07A First Repaired Plan Failed + +Exact-SHA review of `581ecd7` confirmed the split and deferrals but found four +remaining blockers: migration `0021` omitted the two AUTH-07B self ActionIds, +the planned catalogue lacked one exact mapping/owner table, AUTH-13/14 still +claimed later permission-registry migrations, and combined coverage could hide +a sub-90 materially changed subsystem. QA additionally corrected the isolated +database runner invocation, while security required an exclusive audit-table +lock before downgrade evidence checks. No runtime code was written. The second +repair closes those exact findings and requires another fresh exact-SHA review. + ## 2026-07-15 - WS-ENG-001-02 Internal Review Passed Reviewed implementation SHA `8670005` passed senior engineering, diff --git a/.agent-loop/WORK_QUEUE.md b/.agent-loop/WORK_QUEUE.md index 94b113000..b6dcfef2c 100644 --- a/.agent-loop/WORK_QUEUE.md +++ b/.agent-loop/WORK_QUEUE.md @@ -4,17 +4,18 @@ | Chunk | Title | Risk | Status | |---|---|---:|---| +| `WS-AUTH-001-07A` | Closed Permission And Action Catalogue | L1 | Review/revision amendment internally approved at `160af8a`; PR #126 external/human review pending | | `WS-ART-001-02A1` | External Service Adapter Foundation | L1 | Active after explicit user start on 2026-07-15 | Live post-merge state remains read from signed `automation/loop-memory` -output. This authored queue records the separately approved active chunk. +output. This authored queue records the separately approved parallel chunks. ## Planned Next | Chunk | Title | Risk | Status | |---|---|---:|---| -| `WS-AUTH-001-06` | Canonical Actor Profile And Identity Link | L1 | Inactive until a separate explicit user start | | `WS-QUAL-001-01B2` | Baseline Evidence And CI Ratchet | L1 | Paused for AUTH priority; no valid replacement baseline yet | +| `WS-AUTH-001-07B` | Deny-By-Default Kernel And Self-Action Cutover | L1 | Inactive until 07A 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-02A2` | Committed Source And Local Preparation | L1 | Inactive until 02A1 merge and explicit user start | @@ -68,6 +69,7 @@ output. This authored queue records the separately approved active chunk. | `WS-AUTH-001-CAT` | Action And Resource Catalogue Reconciliation | L1 | Merged through PR #117 as `4c5d4fc` on 2026-07-14 | | `WS-AUTH-001-CAT-MEMORY` | Catalogue Post-Merge Memory | L1 | Merged through PR #118 as `eba7e2b` on 2026-07-14 | | `WS-AUTH-001-05B` | Authority Idempotency And Invalidation Foundation | L1 | Merged through PR #119 as `ad71c7e` on 2026-07-14 | +| `WS-AUTH-001-06` | Canonical Actor Profile And Identity Link | L1 | Merged through PR #124 as `f599551` on 2026-07-15 | | `WS-ART-001-OBJECT-STORAGE-AMENDMENT` | AWS-First Object Storage Planning Amendment | L1 | Merged through PR #120 as `4408256` on 2026-07-14 | | `WS-ENG-001-02` | Automated Post-Merge Memory | L1 | Merged through PR #122 as `fc89fb6`; schema-v1 output superseded by WS-ENG-001-03 | @@ -75,7 +77,9 @@ output. This authored queue records the separately approved active chunk. AUTH-05A merged through PR #115 as `8e1cde6`, and CAT plus its post-merge memory merged through PRs #117 and #118. AUTH-05B merged through PR #119 as `ad71c7e`. -Do not start AUTH-06 or POL-002-04 automatically. +AUTH-06 merged through PR #124 as `f599551`, and the user explicitly started +AUTH-07. Required L1 review split it into 07A/07B before runtime implementation. +Do not start 07B, AUTH-08, or POL-002-04 automatically. Coverage R10 merged through PR #108. Do not start 01B2, chunk 02, or another coverage implementation chunk from this worktree. diff --git a/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/CHUNK_MAP.md b/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/CHUNK_MAP.md index 59ae9bac6..61646a675 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 @@ -22,8 +22,10 @@ stopped. | `WS-AUTH-001-05A` | Shared Audit Ownership And Append-Only Authority Evidence | L1 | Merged through PR #115 as `8e1cde6` | | `WS-AUTH-001-CAT` | Action And Resource Catalogue Reconciliation | L1 | Merged through PR #117 as `4c5d4fc` | | `WS-AUTH-001-05B` | Authority Idempotency And Invalidation Foundation | L1 | Merged through PR #119 as `ad71c7e` | -| `WS-AUTH-001-06` | Canonical Actor Profile And Identity Link | L1 | Proposed | -| `WS-AUTH-001-07` | Authorization Kernel And Permission Registry | L1 | Proposed | +| `WS-AUTH-001-06` | Canonical Actor Profile And Identity Link | L1 | Merged through PR #124 as `f599551` | +| `WS-AUTH-001-07` | Authorization Kernel And Permission Registry | L1 | Split before implementation after required L1 plan review | +| `WS-AUTH-001-07A` | Closed Permission And Action Catalogue | L1 | Implementation and repair internally approved at `478a819`; PR pending | +| `WS-AUTH-001-07B` | Deny-By-Default Kernel And Self-Action Cutover | L1 | Inactive until 07A merge/memory and explicit user start | | `WS-AUTH-001-08` | Bootstrap And Administrative Role Grants | L1 | Proposed | | `WS-AUTH-001-09` | Actor State, Identity Revocation, And Service Actors | L1 | Proposed | | `WS-AUTH-001-10` | Project Qualification And Contributor Role Grants | L1 | Proposed | @@ -47,7 +49,8 @@ WS-AUTH-001-PLAN -> WS-AUTH-001-CAT -> WS-AUTH-001-05B -> WS-AUTH-001-06 --> WS-AUTH-001-07 +-> WS-AUTH-001-07A +-> WS-AUTH-001-07B -> WS-AUTH-001-08 -> WS-AUTH-001-09 -> WS-AUTH-001-10 @@ -75,13 +78,16 @@ WS-AUTH-001-PLAN - Chunk 06 establishes canonical actor resolution while preserving only the enumerated non-authoritative legacy workflow-eligibility consumers required for intermediate-release operability. -- Chunk 07 provides the single authorization engine before grant APIs. +- Parent chunk 07 was split before runtime implementation. Chunk 07A owns the + closed permission/action catalogue and action-aware audit parity; chunk 07B + owns the minimal deny-by-default kernel and actor self-action cutover. - Chunks 08-10 establish local grant truth before product cutover. - Chunks 11-15 migrate bounded complete product/system surfaces. - Artifact upload, read, retention, release/delete, replication, integrity, and reconciliation remain mechanically owned by the artifact subsystem but must - receive centralized AUTH decisions. Chunk 07 owns the permission registry, - chunk 08 owns Operator grant definitions, chunk 09 owns fixed artifact service + receive centralized AUTH decisions. Chunk 07A owns the permission/action + registry, chunk 07B owns the central kernel, chunk 08 owns Operator grant + definitions, chunk 09 owns fixed artifact service principals, and each WS-ART feature chunk owns the canonical resource facts, guards, surface declarations, and behavior tests for the exact artifact actions it activates. AUTH-12, AUTH-14, and AUTH-15 do not pre-activate or @@ -116,5 +122,8 @@ human approval merged PR #115 as `8e1cde6` on 2026-07-14, followed by merged post-merge memory. `WS-AUTH-001-CAT` then merged through PR #117 as `4c5d4fc` after Backend, Agent Gates, CodeRabbit, and explicit human approval passed. The CAT post-merge memory merged through PR #118 as `eba7e2b`; AUTH-05B then merged -through PR #119 as `ad71c7e`. Do not start AUTH-06 or POL-002-04 without a -separate explicit user start. +through PR #119 as `ad71c7e`. AUTH-06 merged through PR #124 as `f599551`, its +signed automated memory completed, and the user explicitly started AUTH-07. +Required L1 review rejected the combined contract before runtime edits and +required 07A/07B. Do not start 07B, AUTH-08, or POL-002-04 without a separate +explicit user start after their prerequisites complete. diff --git a/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/DECISIONS.md b/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/DECISIONS.md index 9e974cbf3..bbe85ba65 100644 --- a/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/DECISIONS.md +++ b/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/DECISIONS.md @@ -209,8 +209,9 @@ not imply one another. The authorization decision and operation receipt must share bounded request/ correlation evidence, resource identity, operation, and service principal. -Receipts prove that storage work occurred; they do not create authority. AUTH-07 -registers exact artifact permissions, AUTH-08 defines applicable Operator +Receipts prove that storage work occurred; they do not create authority. AUTH-07A +registers exact artifact permissions and planned actions, AUTH-07B introduces +the central kernel, AUTH-08 defines applicable Operator grants, and AUTH-09 provisions fixed service principals. Each owning WS-ART feature chunk supplies the canonical resource composer, guards, surface declaration, and behavior tests that activate its exact actions. AUTH-12, @@ -222,7 +223,8 @@ no artifact permission or route attachment. Status: accepted by the user on 2026-07-14 after repository mapping and internal design review. -AUTH-07 introduces a closed typed action registry. Each active `ActionId` binds +AUTH-07A introduces a closed typed action registry, and AUTH-07B activates the +first bounded actions. Each active `ActionId` binds one already approved `PermissionId` to one canonical authorization target, candidate authority sources, mandatory guards, principal class, and transaction-revalidation rule. Multiple closed actions may map to one retained @@ -239,8 +241,9 @@ Each protected route or asynchronous command declares one primary registered action as its authorization entry point. Domain invariants remain owned by the feature service. Human bearer tokens are never executable worker authority, and collection filtering occurs before counts or cursors. Catalogue adoption is -staged: AUTH-07 owns types and its own current definitions, every route-owning -AUTH-07 through AUTH-15 chunk owns declarations during its feature cutover, and +staged: AUTH-07A owns identifiers and planned metadata, AUTH-07B owns the first +self-route definitions, every later route-owning chunk through AUTH-15 owns +declarations during its feature cutover, and AUTH-16 owns the aggregate generated route/command completeness manifest and final no-bypass proof. @@ -251,11 +254,11 @@ composer. An owning cutover chunk activates an action only with its adopted domain contract, canonical resource composer, route or command declaration, allow/deny behavior proof, and generated manifest-delta proof. -`ActionId` is security-significant evidence. AUTH-07 adds it to -`AuthorizationDecision`, bounded logs/metrics, and allowed/denied authority -events with exact typed/PostgreSQL registry parity. Historical events may remain -null; every AUTH-07-or-later action-based decision event requires a registered -identifier. Planned IDs can be registered before activation because they encode +`ActionId` is security-significant evidence. AUTH-07A adds typed/PostgreSQL +audit parity; AUTH-07B adds it to `AuthorizationDecision`, bounded logs/metrics, +and allowed/denied authority events. Historical events may remain null; every +AUTH-07B-or-later action-based decision event requires a registered identifier. +Planned IDs can be registered before activation because they encode only identifier, approved permission, owner, and availability, not foreign resource design. @@ -264,10 +267,10 @@ The reviewed proposal did not receive independent normative precedence. Its artifact, review, contribution, and compensation resources were rejected. The adopted `/api/v1` namespace and original 52 approved permission identifiers were unchanged by that catalogue review. The later approved artifact-storage -contract adds 21 exact identifiers, making the current closed total 73. +contract added 21 exact identifiers, making the pre-D17 closed total 73. AUTH-05A currently enforces a 49-identifier typed/PostgreSQL audit base; the three already approved Operator recovery identifiers and 21 artifact -identifiers remain planned and non-executable until AUTH-07 adds typed/SQL audit +identifiers remain planned and non-executable until AUTH-07A adds typed/SQL audit parity and the paired owning feature activates each action. Other permission additions or renames still require an approved specification/ADR change, typed and PostgreSQL registry migration, audit-history treatment, cutover ownership, and rollback proof. @@ -275,6 +278,69 @@ WS-REV, WS-CON, and the artifact-storage specification continue to own their resources and state transitions. Migration custody is reconciled with merged main: AUTH-05A owns `0018`, AUTH-05B -solely owns `0019`, AUTH-06 uses `0020`, AUTH-07 action evidence uses `0021`, +solely owns `0019`, AUTH-06 uses `0020`, AUTH-07A action evidence uses `0021`, AUTH-08 uses `0022`, AUTH-10 uses `0023`, AUTH-12 uses `0024`, AUTH-13 uses `0025`, and AUTH-14 uses `0026`. + +## D16: Split AUTH-07 at the catalogue and executable-kernel boundary + +Status: accepted as a required L1 preimplementation repair on 2026-07-15. + +Required architecture, security/auth, and QA/CI plan review found that the +combined AUTH-07 contract placed grant-backed administrative APIs before +AUTH-08, project capability composition before AUTH-10, and mixed the audit +migration with executable kernel/API behavior. No runtime code had started. + +At the split boundary, AUTH-07A owned only the exact 73-PermissionId catalogue, +30 four-field +planned ActionId definitions, including both later self actions, and +action-aware audit migration `0021`. AUTH-07B +later owns the minimal deny-by-default kernel and activates only +`actor.profile.read_self` and `actor.profile.update_self`. Permission and +admin-role definition APIs move to AUTH-08; project-scoped authorization +context moves to AUTH-10. Each child retains its own merge and explicit-start +gate. + +## D17: Adopt canonical review actions without moving review behavior into AUTH + +Status: accepted by the user on 2026-07-15 after mapping the revised WS-REV +source against the implemented AUTH and ART contracts and completing internal +architecture, docs/security, and test/migration review. + +AUTH-07A expands the closed catalogue to exactly 74 PermissionIds and 50 +planned ActionIds. `review.queue.override` is the only additive PermissionId and +is explicitly the 25th post-`0020` permission; the historical 49-value set does +not change. Twenty planned actions are added: canonical `submission.create` +owned by `WS-AUTH-001-14`, plus 19 review actions owned by exact +`WS-REV-001-05`, `06`, `07`, `08`, `09A`, `11`, and `12` chunks. All additions +remain four-field planned metadata and cannot authorize until their owner +supplies resource composition, guards, candidates, surface declarations, +revalidation, and behavior tests. + +Initial and revision submissions share the same `submission.create` action, +permission, and route. There is no `submission.revise`, `review.assign`, +`review_revision.record`, or separately callable revision-preparation action. +Revision preparation is an internal participant and lifecycle guard of the +canonical submission command. Finding and finding-response evidence intake use +distinct actions mapped to existing `review.decision` and `submission.create` +permissions because each is a protected human command that can create artifact +state before the final transaction. + +`artifact_recovery.request` is rejected. Operator recovery consumes the already +registered `artifact.verification_job.retry` action through the ART-owned +`ArtifactOperatorRecoveryPort`; ART retains recovery-attempt, execution, +fencing, and idempotency ownership. REV owns no projection dispatch/retry action +because the shared outbox owns dispatch, attempts, retry, dead-letter, and +delivery state. Immediate grant-revocation recovery remains a participant of +the originating AUTH mutation, while missed recovery uses the planned +`review.reconcile.run` action. + +AUTH-07B must expose one stable public feature boundary: a request-scoped +`AuthorizationService` bound to the current `AuthorizationContext` and +caller-owned `AsyncSession`. Feature modules call +`require(action_id, typed_resource_context)`; the bound session is the only +transaction source. The service returns and stages one bounded decision, never +commits, and never accepts a raw +PermissionId, candidate grant, or guard. REV owns its ResourceContext composers +and lifecycle invariants and may import only that public AUTH interface and +closed types, never AUTH persistence or grant queries. diff --git a/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/PLAN.md b/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/PLAN.md index 625a27bb2..16f77de53 100644 --- a/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/PLAN.md +++ b/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/PLAN.md @@ -15,7 +15,7 @@ Bearer token -> ActorResolver -> ActorIdentityLink + ActorProfile -> request-scoped AuthorizationContext --> AuthorizationService.require(permission, ResourceContext, uow) +-> AuthorizationService.require(ActionId, typed ResourceContext) -> AdminRoleGrant / ProjectRoleGrant candidates -> canonical project and ownership resolution -> actor, resource, and lifecycle guards @@ -133,8 +133,9 @@ proving the same token role alone no longer authorizes. 5. Evolve shared audit evidence in 05A, then add canonical idempotency/invalidation in 05B. 6. Migrate to canonical profile/link semantics and first-human resolution. -7. Implement the minimal registered permission and AuthorizationService kernel - before protected authority-management APIs. +7. Register the closed permission/action catalogue with audit parity in 07A, + then implement the minimal AuthorizationService kernel and canonical actor + self-action cutover in 07B before protected authority-management APIs. 8. Implement bootstrap, `AuthorityControl`, and immutable admin-role grants. 9. Implement actor/link state administration and controlled service actors. 10. Implement qualification snapshots and exact-project contributor grants. diff --git a/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/RISKS.md b/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/RISKS.md index 7e6fe7216..1131f6e80 100644 --- a/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/RISKS.md +++ b/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/RISKS.md @@ -34,6 +34,7 @@ | A18 | Authority evidence is mutable or denial events arrive late | Security decisions cannot be reconstructed | Insert/read-only repository API, database append-only enforcement, per-mutation allowed/denied event proof, operational retention controls | Update/delete rejection plus atomic allowed/denied event tests in owning chunks | | A19 | Authority invalidation releases a needs-revision task as ordinary ready work | Prior findings, context, supersession, or replay obligations are bypassed | Keep the task in needs_revision with a durable unassigned obligation and controlled replacement assignment | Replacement-contributor revision-context, supersession, and high/medium replay tests | | A20 | Rate-limit replicas split counters, leak identity keys, lose increments, or retain pseudonymous rows indefinitely | Abuse controls bypassed or privacy/availability incident | Canonical Base64 HMAC secret, exact framed digest, one committed PostgreSQL statement, coordinated quiesced rotation, bounded opportunistic pruning, and operator idle cleanup | Known-answer/privacy tests, synchronized independent-session concurrency, commit-failure proof, rotation runbook, and retention tests | +| A21 | Kernel APIs ship before their grant/resource authority exists | Fabricated authority, deny-only public surfaces, or hidden dual policy | Split AUTH-07 into 07A catalogue/audit and 07B self-only kernel; defer admin definitions to 08 and project context to 10 | Child-contract plan review, exact active-action tables, and no-grant/no-project surface scans | ## Required reviewers 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 8e69f7caf..90943cea9 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 @@ -62,19 +62,34 @@ CodeRabbit; explicit human approval merged PR #117 as `4c5d4fc` on 2026-07-14. Its post-merge memory merged through PR #118 as `eba7e2b`. AUTH-05B's repaired L1 plan, implementation, repair, focused evidence, and required review tracks passed before explicit human approval merged PR #119 as `ad71c7e`. +AUTH-06 then established canonical actor profiles and identity links. Its +runtime, migration, compatibility, privacy, and API evidence passed required +internal and external checks; explicit human approval merged PR #124 as +`f599551` on 2026-07-15. Signed automated memory stopped with AUTH-07 requiring +a separate start. The user explicitly started AUTH-07 on 2026-07-15; discovery +and required L1 plan review are complete. The repaired AUTH-07A contract passed +all required tracks at `beb85ac`; implementation, repair, deterministic +evidence, and required internal review pass at `478a819`. The canonical +review/revision amendment then passed exact-head review at `160af8a`, with 74 +PermissionIds and 50 planned ActionIds. +That review rejected the combined AUTH-07 contract before runtime edits because +grant-backed/project APIs preceded their authority sources and the audit/API +ownership files were incomplete. Parent AUTH-07 is now split into 07A catalogue +and audit parity, followed by 07B kernel and actor self-action cutover. AUTH-07A +is published as PR #126 and awaits external checks and explicit human approval; +AUTH-07B remains inactive. ## Active planning chunk -None. `WS-AUTH-001-06` remains an inactive candidate requiring a separate -explicit user start. +None. ## Active implementation chunk -None. +`WS-AUTH-001-07A` - Closed Permission And Action Catalogue. ## Current implementation branch -None recorded by this authored status file. +`codex/ws-auth-001-07-authorization-kernel` ## Chunk status @@ -91,8 +106,10 @@ None recorded by this authored status file. | `WS-AUTH-001-05A` | Merged | `codex/ws-auth-001-05-authority-evidence` | #115 | Merged as `8e1cde6`; reviewed code `ea16fd8`; final branch head `d023952`. | | `WS-AUTH-001-CAT` | Merged | `codex/ws-auth-001-action-catalogue-reconciliation` | #117 | Merged as `4c5d4fc`; final branch head `5b4ec96`. | | `WS-AUTH-001-05B` | Merged | `codex/ws-auth-001-05b-idempotency-invalidation` | #119 | Merged as `ad71c7e`; reviewed runtime SHA `e083890`. | -| `WS-AUTH-001-06` | Proposed | - | - | Canonical actor profile and identity link. | -| `WS-AUTH-001-07` | Proposed | - | - | Authorization kernel and permissions. | +| `WS-AUTH-001-06` | Merged | `codex/ws-auth-001-06-canonical-actor-profile` | #124 | Merged as `f599551`; final PR head `4a2193f`. | +| `WS-AUTH-001-07` | Split | `codex/ws-auth-001-07-authorization-kernel` | - | Required L1 review rejected the combined contract before runtime edits. | +| `WS-AUTH-001-07A` | Internally approved | `codex/ws-auth-001-07-authorization-kernel` | #126 | Reviewed amendment `160af8a`; 74 permissions, 50 planned actions, and action-aware audit parity only. | +| `WS-AUTH-001-07B` | Proposed | - | - | Inactive until 07A merge/memory and explicit user start. | | `WS-AUTH-001-08` | Proposed | - | - | Bootstrap and administrative grants. | | `WS-AUTH-001-09` | Proposed | - | - | Actor/link states and service actors. | | `WS-AUTH-001-10` | Proposed | - | - | Project contributor grants. | @@ -115,14 +132,20 @@ inferred kinds before the owning canonical actor migration. The proposed external catalogue cannot be adopted as a normative handoff: it conflicts with `/api/v1`, AUTH-05A's merged 49-identifier persisted audit base, -current project and artifact models, and staged domain ownership. All 52 -permission identifiers remain approved, including +current project and artifact models, and staged domain ownership. All 74 +permission identifiers are approved, including `operations.task.start_override`, `operations.submission_gate.repair`, and -`operations.checker.retry`; the three recovery identifiers receive persisted -parity only in their owning later chunks. `WS-AUTH-001-CAT` retains only safe -registry/conformance rules. This is a scope decision, not an AUTH-05B runtime -blocker. PR #118 and AUTH-05B PR #119 are merged. AUTH-06 and POL-002-04 remain -inactive until an explicit human start. +`operations.checker.retry`; AUTH-07A gives those recovery identifiers exact +typed/PostgreSQL parity while AUTH-13/14 retain action activation and feature +behavior ownership. `WS-AUTH-001-CAT` retains only safe registry/conformance +rules. This is a scope decision, not an AUTH-05B runtime +blocker. PR #118, AUTH-05B PR #119, and AUTH-06 PR #124 are merged. AUTH-07 has +an explicit user start. Required review split it before runtime implementation; +the repaired 07A contract passed all required tracks at `beb85ac`; implementation +and repair passed at `478a819`, and the review/revision amendment passed at +`160af8a`. AUTH-07B and +POL-002-04 remain inactive until separate explicit human starts and their +authorization prerequisites. AUTH-04B review evidence and its PR trust bundle are recorded at `reviews/WS-AUTH-001-04B-internal-review-evidence.md` and diff --git a/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/chunks/WS-AUTH-001-07-authorization-kernel.md b/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/chunks/WS-AUTH-001-07-authorization-kernel.md index f4d14a538..78a01f1d7 100644 --- a/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/chunks/WS-AUTH-001-07-authorization-kernel.md +++ b/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/chunks/WS-AUTH-001-07-authorization-kernel.md @@ -1,197 +1,44 @@ -# Chunk Contract: WS-AUTH-001-07 - Authorization Kernel And Permission Registry +# Parent Chunk: WS-AUTH-001-07 - Authorization Kernel And Permission Registry -## Parent initiative +## Status -`WS-AUTH-001` - Workstream Authorization Service +Split before runtime implementation on 2026-07-15 after required L1 plan +review. No application or migration code was written under the combined +contract. -## Goal +## Why the split was required -Implement the deny-by-default AuthorizationService kernel, registered -permissions, canonical ResourceContext, stable AuthorizationDecision, actor -state/global guards, and reusable FastAPI/application dependencies before any -grant-management API. +The combined contract crossed two independently reviewable persistence and +runtime boundaries: -## Why this chunk exists +- the closed permission/action catalogue and action-aware audit migration; and +- the deny-by-default evaluation kernel plus its first real API cutover. -Every later protected grant and product command must call one service rather -than introducing temporary direct grant queries. +It also proposed admin-definition and project-capability APIs before AUTH-08 +and AUTH-10 create their authoritative grant sources. Implementing those APIs +in AUTH-07 would require fabricated grants, token-role authority, or deny-only +public surfaces. All three outcomes violate the adopted authorization design. -## Approved plan reference +## Child chunks -- INTENT: `.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/INTENT.md` -- PLAN: `.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/PLAN.md` -- CHUNK_MAP: `.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/CHUNK_MAP.md` +| Chunk | Title | Boundary | +|---|---|---| +| `WS-AUTH-001-07A` | Closed Permission And Action Catalogue | Register exact approved permissions and planned actions, add action-aware audit parity, and make no action executable. | +| `WS-AUTH-001-07B` | Deny-By-Default Kernel And Self-Action Cutover | Implement the minimal kernel and activate only canonical actor self-read/self-update actions. | -## Risk class +## Deferred to owning chunks -L1 +- Permission and admin-role definition APIs move to AUTH-08, after bootstrap + and administrative grants exist. +- Project-scoped authorization context moves to AUTH-10, after exact-project + grants and canonical project capability composition exist. +- Feature resource loaders, grant matrices, concealment rules, and + revoke-versus-command races remain with their owning cutover chunks. -## SLA +## Ordering -P1 +`WS-AUTH-001-07A -> WS-AUTH-001-07B -> WS-AUTH-001-08` -## Allowed files - -```text -backend/app/modules/authorization/** -backend/app/modules/audit/** -backend/app/modules/actors/repository.py -backend/app/modules/actors/service.py -backend/app/api/deps/auth.py -backend/app/api/router.py -backend/app/main.py -backend/app/schemas/auth.py -backend/alembic/versions/0021_authorization_action_evidence.py -backend/tests/test_auth.py -backend/tests/test_audit.py -backend/tests/test_alembic.py -backend/tests/test_actors.py -backend/tests/test_app.py -backend/scripts/api_contract_e2e.py -docs/operations_authorization_service.md -docs/spec_authorization_service.md -.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/** -.agent-loop/LOOP_STATE.md -.agent-loop/WORK_QUEUE.md -.agent-loop/REVIEW_LOG.md -``` - -## Not allowed - -```text -dynamic/user-authored permissions or policy language -authorization decision cache across requests -direct role queries in routers -project/task/checker surface cutover -review/compensation permissions beyond registered future definitions -``` - -## Acceptance criteria - -- Permission identifiers are a closed registered enum/value set. -- The closed registry includes every exact artifact permission in - `docs/spec_authorization_service.md`: Operator reads/retry/recovery/audit, - guide-source ingest, contributor upload-session create/read/item/seal/cancel - actions, binding, - verification, pending-work scan, put-attempt resolution, guide-source read, checker-input - materialization, and checker-output write. Broad `operations.*` permissions - are not aliases for artifact authority. -- A closed typed action registry gives each active `ActionId` one approved - `PermissionId`, canonical target resource type, target-resolution rule, - allowed principal class, authority-candidate sources, mandatory registered - guards, and transaction-revalidation requirement. Multiple closed actions may - map to one retained broad permission with distinct targets/guards. Unknown - action, permission, resource, or guard identifiers deny during - registration/startup validation. -- Create and collection actions authorize against an existing parent or - `system` target; request-supplied project, owner, parent, and state values are - never canonical resource facts. -- Resource loading remains feature-owned. AUTH defines the bounded - `ResourceContext` protocol as closed typed per-resource variants and defines - the composition-root registration contract without importing feature - repositories or duplicating feature queries. Guards declare required facts; - missing, extra, or mistyped facts fail closed. -- Request context contains verified identity plus current local actor/grant state - and correlation/request IDs. -- Authorization resolves actor/link state, grant candidates, canonical project, - ownership, global guards, and resource state in a defined order. -- Unknown permissions deny. -- System scope is not superuser authority. -- Suspended/deactivated/revoked actors deny before permission expansion. -- Stable structured error envelope and concealment rules are defined. -- Table-driven tests cover every current permission/role/scope candidate. -- Sensitive mutations can revalidate inside the caller transaction/UoW. -- The bootstrap system operation and default-human self permissions are the - only usable candidates before grant tables ship. -- Direct role/grant authorization outside this service is explicitly banned. -- Reusable FastAPI and application-command dependencies accept one primary - registered action declaration. Domain invariants remain separate, and service - commands use fixed Workstream principals rather than serialized human tokens. -- Catalogue completeness is staged: this chunk validates current usable - definitions but does not require loaders or route declarations owned by - inactive feature-cutover or later domain initiatives. -- Reserved planned action metadata contains only stable `ActionId`, approved - `PermissionId`, owner, and availability; it never authorizes or predefines - another domain's target, facts, guards, or composer. Active definitions - require the owning domain contract, canonical composer, surface declaration, - and behavior tests. -- AUTH-07 registers every artifact-related planned `ActionId` in the canonical - table in `docs/spec_authorization_service.md` with its exact approved - `PermissionId` and owner. The 73-identifier count applies to PermissionIds, - not ActionIds. Registration is reserved metadata only: AUTH does not invent - artifact resource facts, guards, composers, or executable authority. -- `operations.artifact_storage_admission.read` is an operations-status action - mapped to the already approved `operations.status.read` PermissionId and the - deployment artifact-storage namespace. It is not an alias for any - `artifact.*` permission and grants no read/write access to artifact content. -- Artifact permissions are registered with typed/PostgreSQL audit parity here, - but remain reserved and non-executable until their owning WS-ART activation - row in `docs/spec_authorization_service.md` supplies the feature resource - composer, guards, surface declaration, and behavior tests. AUTH-12, AUTH-14, - and AUTH-15 are not alternate artifact activation paths. -- `AuthorizationDecision`, bounded logs/metrics, and every action-based allowed - or denied authority event carry the stable `ActionId`. Migration `0021` - preserves historical nulls, establishes exact typed/PostgreSQL action-registry - parity, and requires a registered identifier for AUTH-07-or-later - action-based decision evidence. Upgrade/downgrade/re-upgrade, direct-SQL, - unknown-ID, and preserved-history tests pass. -- The three authorization APIs introduced here have exact declarations: - permission and admin-role definition reads use `admin_role.read` against - `system` and expose only the authorized administrative metadata projection; - self authorization-context read uses `actor.profile.read_self` against the - current actor and returns active, currently authorized capabilities only. - Planned metadata never appears as an actor capability. -- Generated OpenAPI/command manifest-delta tests prove every protected surface - introduced by this chunk has exactly one active `ActionId` declaration. -- External denial precedence and concealment are an exact tested matrix. A - sensitive action declares its transaction linearization contract, including - ordered actor/link/grant/resource reload/locks or an explicitly approved - serializable retry strategy; each later mutation chunk proves its own - revoke-versus-command race. -- `GET /api/v1/authorization/permissions` and - `GET /api/v1/authorization/admin-role-definitions` return registered, - non-dynamic definitions with authorization/privacy tests. -- `GET /api/v1/actors/me/authorization-context` is introduced here, after the - closed registry exists, with project-scoped capability allowlist, privacy, - unknown-project, and inactive-actor tests. - -## Verification commands - -```bash -(cd backend && .venv/bin/python -m ruff check app tests) -(cd backend && WORKSTREAM_DATABASE_URL= .venv/bin/alembic upgrade head) -(cd backend && WORKSTREAM_DATABASE_URL= .venv/bin/alembic downgrade -1) -(cd backend && WORKSTREAM_DATABASE_URL= .venv/bin/alembic upgrade head) -(cd backend && WORKSTREAM_DATABASE_URL= .venv/bin/python -m pytest -q \ - tests/test_authorization.py tests/test_audit.py tests/test_alembic.py \ - --cov=app.modules.authorization \ - --cov-report=term-missing --cov-fail-under=90) -(cd backend && WORKSTREAM_DATABASE_URL= .venv/bin/python -m pytest -q) -(cd backend && WORKSTREAM_DATABASE_URL= .venv/bin/python scripts/api_contract_e2e.py) -python3 scripts/check_stale_workstream_wording.py -python3 scripts/check_markdown_links.py -git diff --check -``` - -## Required reviewers - -- senior engineering -- QA/test -- security/auth -- product/ops -- architecture -- CI integrity -- docs -- reuse/dedup -- test delta - -## Human review focus - -Review deny-by-default ordering, permission matrix completeness, canonical -resource scope, artifact permission separation, concealment behavior, and -transaction-local revalidation. - -## Stop conditions - -Stop if the service needs free-form client permissions, cached cross-request -decisions, or a generic policy engine. +Neither child starts the other automatically. Each child requires its own +evidence, internal review, PR, explicit human merge approval, signed merge +memory, and stop. diff --git a/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/chunks/WS-AUTH-001-07A-closed-permission-action-catalogue.md b/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/chunks/WS-AUTH-001-07A-closed-permission-action-catalogue.md new file mode 100644 index 000000000..adc67ee2d --- /dev/null +++ b/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/chunks/WS-AUTH-001-07A-closed-permission-action-catalogue.md @@ -0,0 +1,229 @@ +# Chunk Contract: WS-AUTH-001-07A - Closed Permission And Action Catalogue + +## Parent initiative + +`WS-AUTH-001` - Workstream Authorization Service + +## Goal + +Create one closed typed catalogue for all 74 approved PermissionIds and the +exact 50 reserved self, recovery, submission, review, and artifact ActionIds, +then add typed and PostgreSQL action evidence parity without making any action +executable. + +## Why this chunk exists + +The deny-by-default kernel cannot safely start until permission and action +identifiers have one validated source, planned actions fail closed, and audit +storage can preserve a stable action identifier. + +## Risk routing + +- Risk class: L1 +- SLA: P1 +- Work type: authorization architecture, audit schema, migration, tests, docs +- Human gate: explicit PR review and merge approval +- Required reviewers: senior engineering, QA/test, security/auth, product/ops, + architecture, CI integrity, docs, reuse/dedup, test delta + +## Allowed files + +```text +backend/app/modules/authorization/** +backend/app/modules/audit/** +backend/app/modules/tasks/models.py +backend/alembic/versions/0021_authorization_action_evidence.py +backend/tests/test_authorization.py +backend/tests/test_audit.py +backend/tests/test_alembic.py +docs/spec_authorization_service.md +docs/operations_authorization_service.md +.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/** +.agent-loop/merge-intents/WS-AUTH-001-07A.json +.agent-loop/LOOP_STATE.md +.agent-loop/WORK_QUEUE.md +.agent-loop/REVIEW_LOG.md +``` + +## Not allowed + +```text +authorization decision evaluation +active route or command declarations +grant tables, grant queries, or role expansion +actor, project, task, checker, review, contribution, or artifact API cutover +feature resource composers, guards, or repositories +dynamic permissions or policy language +public permission/admin-role/context APIs +``` + +## Exact planned action catalogue + +The owner is a full canonical chunk ID. The closed owner set is exactly the +distinct values in this table; abbreviations, free-form domains, and aliases +are invalid. Every row has `availability=planned` and cannot authorize. + +| ActionId | PermissionId | Owner | Availability | +|---|---|---|---| +| `actor.profile.read_self` | `actor.profile.read_self` | `WS-AUTH-001-07B` | `planned` | +| `actor.profile.update_self` | `actor.profile.update_self` | `WS-AUTH-001-07B` | `planned` | +| `operations.task.start_override` | `operations.task.start_override` | `WS-AUTH-001-13` | `planned` | +| `operations.submission_gate.repair` | `operations.submission_gate.repair` | `WS-AUTH-001-14` | `planned` | +| `operations.checker.retry` | `operations.checker.retry` | `WS-AUTH-001-14` | `planned` | +| `submission.create` | `submission.create` | `WS-AUTH-001-14` | `planned` | +| `review.queue.read` | `review.queue.read` | `WS-REV-001-05` | `planned` | +| `review.queue.inspect` | `review.queue.inspect` | `WS-REV-001-05` | `planned` | +| `review.claim` | `review.claim` | `WS-REV-001-06` | `planned` | +| `review.release` | `review.release` | `WS-REV-001-06` | `planned` | +| `review.decline_preference` | `review.decline_preference` | `WS-REV-001-06` | `planned` | +| `review.preference_expiry.run` | `operations.timer.run` | `WS-REV-001-06` | `planned` | +| `review.lease_expiry.run` | `operations.timer.run` | `WS-REV-001-06` | `planned` | +| `review.context.read` | `submission.read_for_review` | `WS-REV-001-07` | `planned` | +| `review.chain.read` | `review.chain.read` | `WS-REV-001-07` | `planned` | +| `review.finding_evidence.ingest` | `review.decision` | `WS-REV-001-07` | `planned` | +| `review.decision` | `review.decision` | `WS-REV-001-08` | `planned` | +| `review.finding_response_evidence.ingest` | `submission.create` | `WS-REV-001-09A` | `planned` | +| `review.lease.force_release` | `review.lease.force_release` | `WS-REV-001-11` | `planned` | +| `review.queue.routing.override` | `review.queue.override` | `WS-REV-001-11` | `planned` | +| `review.queue.routing.correct` | `review.queue.override` | `WS-REV-001-11` | `planned` | +| `review.queue.close` | `review.queue.override` | `WS-REV-001-11` | `planned` | +| `review.reconcile.run` | `operations.reconcile.run` | `WS-REV-001-11` | `planned` | +| `review.artifact_reference.reconcile` | `operations.reconcile.run` | `WS-REV-001-12` | `planned` | +| `review.projection.rebuild` | `operations.projection.rebuild` | `WS-REV-001-12` | `planned` | +| `artifact.binding.read` | `artifact.binding.read` | `WS-ART-001-02D` | `planned` | +| `artifact.replica.read` | `artifact.replica.read` | `WS-ART-001-02D` | `planned` | +| `artifact.receipt.read` | `artifact.receipt.read` | `WS-ART-001-02D` | `planned` | +| `artifact.verification_job.read` | `artifact.verification_job.read` | `WS-ART-001-02D` | `planned` | +| `artifact.verification_job.retry` | `artifact.verification_job.retry` | `WS-ART-001-02D` | `planned` | +| `artifact.recovery_attempt.read` | `artifact.recovery_attempt.read` | `WS-ART-001-02D` | `planned` | +| `artifact.audit.read` | `artifact.audit.read` | `WS-ART-001-02D` | `planned` | +| `operations.artifact_storage_admission.read` | `operations.status.read` | `WS-ART-001-02D` | `planned` | +| `artifact.guide_source.ingest` | `artifact.guide_source.ingest` | `WS-ART-001-03` | `planned` | +| `artifact.guide_source.read` | `artifact.guide_source.read` | `WS-ART-001-03` | `planned` | +| `artifact.upload_session.create` | `artifact.upload_session.create` | `WS-ART-001-04A` | `planned` | +| `artifact.upload_session.read` | `artifact.upload_session.read` | `WS-ART-001-04A` | `planned` | +| `artifact.upload_item.write` | `artifact.upload_item.write` | `WS-ART-001-04A` | `planned` | +| `artifact.upload_session.seal` | `artifact.upload_session.seal` | `WS-ART-001-04A` | `planned` | +| `artifact.upload_session.cancel` | `artifact.upload_session.cancel` | `WS-ART-001-04A` | `planned` | +| `artifact.upload_session.expire` | `artifact.upload_session.expire` | `WS-ART-001-04A` | `planned` | +| `artifact.guide_source.binding.create` | `artifact.binding.create` | `WS-ART-001-03` | `planned` | +| `artifact.submission.binding.create` | `artifact.binding.create` | `WS-ART-001-05` | `planned` | +| `artifact.checker_output.binding.create` | `artifact.binding.create` | `WS-ART-001-06B` | `planned` | +| `artifact.verification.execute` | `artifact.verification.execute` | `WS-ART-001-02D` | `planned` | +| `artifact.pending_work.scan` | `artifact.pending_work.scan` | `WS-ART-001-02D` | `planned` | +| `artifact.put_attempt.resolve` | `artifact.put_attempt.resolve` | `WS-ART-001-02D` | `planned` | +| `artifact.pre_submit.checker_input.materialize` | `artifact.checker_input.materialize` | `WS-ART-001-04B` | `planned` | +| `artifact.post_submit.checker_input.materialize` | `artifact.checker_input.materialize` | `WS-ART-001-06A` | `planned` | +| `artifact.checker_output.write` | `artifact.checker_output.write` | `WS-ART-001-06B` | `planned` | + +Each definition stores only these four fields. Principal class, resource facts, +guards, composers, concealment, and revalidation are activation blueprints +owned by later chunks and are not registered here. + +## Acceptance criteria + +- `PermissionId` is the single closed typed source for exactly the 74 approved + values in `docs/spec_authorization_service.md`. +- Existing audit validation consumes that source rather than maintaining a + second permission literal set. +- A frozen action catalogue contains exactly the 50 planned ActionIds above, + with exact approved PermissionId mapping, owner, and availability. +- Catalogue construction fails on duplicate or unknown actions, unknown + permissions, invalid owners, invalid availability, missing approved entries, + or extra entries. +- Planned actions cannot be resolved as executable and cannot be promoted by + configuration or request input. +- Artifact storage admission remains mapped only to + `operations.status.read`; it grants no `artifact.*` capability. +- Migration `0021` adds nullable `audit_events.action_id`, preserves all + historical rows as null, and constrains every non-null value to the registered + ActionId set. +- Typed validation and PostgreSQL enforce every non-null ActionId's exact + PermissionId mapping from the 50-row catalogue; an action cannot be persisted + with another registered permission. +- Every PermissionId outside migration `0018`'s historical 49-value set requires + a non-null ActionId whose catalogue row maps to that PermissionId. The 20 + planned actions mapped to a historical PermissionId may still carry their + registered action while the historical permission remains valid without one + for pre-`0021` event shapes. +- A non-null `action_id` is valid only for `SensitiveAuthorizationAllowed` or + `SensitiveAuthorizationDenied`; unrelated authority/lifecycle events must + remain null. +- Migration `0021` replaces the 49-value permission constraint with exact + 74-value typed/PostgreSQL parity without changing historical values. The + historical set remains the exact 49 identifiers from migration `0018`, and + `review.queue.override` is explicitly the 25th post-`0020` permission rather + than being inferred as historical by prefix. +- `AuthorityAuditEventInput` admits a bounded registered `action_id`; unknown + values and action/permission mismatches fail before rejected input can escape + diagnostics. +- Because all 50 actions remain `planned`, `AuthorityAuditEventInput` rejects + `SensitiveAuthorizationAllowed` when any of them is present. A planned action + may be persisted only as bounded `SensitiveAuthorizationDenied` evidence; + activation chunks own later allowed evidence. +- PostgreSQL is deliberately availability-neutral: it enforces registration, + decision-event-only use, and the exact action-to-permission mapping, but does + not freeze `planned` versus `active`. Later owner chunks activate catalogue + rows in typed code without altering migration `0021`. +- Existing non-action authority events remain valid with null `action_id`. +- Downgrade refuses when either any non-null action evidence or any PermissionId + outside the historical 49-value set exists in `permission_id`, a permission- + registry target reference, or a permission-registry invalidation reference. A + clean database satisfying all predicates can downgrade, restores the exact + prior 49-value permission constraint, drops the action column/constraint, and + re-upgrades. +- Downgrade takes `LOCK audit_events IN ACCESS EXCLUSIVE MODE` before checking + both refusal predicates or changing constraints. Deterministic independent- + session proof shows a concurrent insert cannot pass between the checks and + destructive DDL. +- Direct SQL accepts all 50 exact action/permission pairs as + `SensitiveAuthorizationDenied` and separately as + `SensitiveAuthorizationAllowed`, rejects all 50 wrong action/permission + pairs, and rejects all 25 new permissions without a mapped action. Typed + `AuthorityAuditEventInput` separately rejects allowed evidence for all 50 + while they remain planned; PostgreSQL deliberately does not freeze + availability. +- The canonical specification separates four-field planned registry metadata + from later feature activation blueprints. +- Operations docs cover startup catalogue failure, evidence inspection, and the + guarded rollback rule. +- No workflow, dependency, test skip, coverage exclusion, or global threshold + changes. + +## Verification commands + +```bash +(cd backend && .venv/bin/python -m ruff check app tests alembic/versions/0021_authorization_action_evidence.py) +(cd backend && tmp_dir=$(mktemp -d) && trap 'rm -rf "$tmp_dir"' EXIT && \ + .venv/bin/coverage erase && \ + WORKSTREAM_TEST_ADMIN_DATABASE_URL= \ + .venv/bin/python scripts/run_isolated_tests.py \ + --metadata-json "$tmp_dir/07a.json" --timeout-seconds 1800 -- \ + .venv/bin/python -m pytest -q tests/test_authorization.py tests/test_audit.py \ + tests/test_alembic.py --cov=app.modules.authorization \ + --cov=app.modules.audit --cov-branch --cov-report= --cov-fail-under=0 && \ + .venv/bin/coverage report --include='app/modules/authorization/*' \ + --fail-under=90 && \ + .venv/bin/coverage report --include='app/modules/audit/*' --fail-under=90) +python3 scripts/check_stale_workstream_wording.py +python3 scripts/check_stale_authorization_docs.py +python3 scripts/check_markdown_links.py +git diff --check +``` + +GitHub Backend remains authoritative for the full suite and repository-wide 78 +percent floor. + +## Human review focus + +Review exact 74-permission parity, exact 50-action planned mapping, typed/SQL +mapping enforcement, inability to record planned actions as allowed, audit +privacy, PostgreSQL constraint parity, historical null preservation, and guarded +downgrade. + +## Stop conditions + +Stop if the catalogue requires feature resource facts, executable grant +candidates, a generic policy engine, or dynamic/user-authored identifiers. + +Stop after merge and signed memory. Do not start AUTH-07B automatically. diff --git a/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/chunks/WS-AUTH-001-07B-deny-default-kernel-self-cutover.md b/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/chunks/WS-AUTH-001-07B-deny-default-kernel-self-cutover.md new file mode 100644 index 000000000..25cc9ce2b --- /dev/null +++ b/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/chunks/WS-AUTH-001-07B-deny-default-kernel-self-cutover.md @@ -0,0 +1,187 @@ +# Chunk Contract: WS-AUTH-001-07B - Deny-By-Default Kernel And Self-Action Cutover + +## Dependency + +AUTH-07A must be merged with signed memory, followed by a separate explicit +human start. + +## Goal + +Implement the minimal request-scoped AuthorizationService and activate it only +for canonical actor self-read and self-update. + +## Public feature interface + +The application dependency constructs one request-scoped +`AuthorizationService` bound to the current `AuthorizationContext` and +caller-owned `AsyncSession`. Feature application services call only: + +```python +decision = await authorization_service.require( + action_id, + typed_resource_context, +) +``` + +The service returns and stages one bounded `AuthorizationDecision`; it never +commits. It never accepts a raw `PermissionId`, candidate grant, guard, role, or +caller-authored resource fact. The caller owns commit/rollback and mutation +revalidation stays inside the same caller transaction. Feature modules own +their typed ResourceContext composers and lifecycle invariants and may import +only this public interface and closed AUTH types, never AUTH repositories, +models, grant loaders, or private evaluator helpers. + +## Risk routing + +- Risk class: L1 +- SLA: P1 +- Work type: authorization runtime, API cutover, audit evidence, tests, docs +- Human gate: explicit PR review and merge approval +- Required reviewers: senior engineering, QA/test, security/auth, product/ops, + architecture, CI integrity, docs, reuse/dedup, test delta + +## Allowed files + +```text +backend/app/modules/authorization/** +backend/app/modules/audit/** +backend/app/modules/actors/repository.py +backend/app/modules/actors/service.py +backend/app/api/deps/authorization.py +backend/app/api/routes/auth.py +backend/app/api/router.py +backend/app/schemas/auth.py +backend/tests/test_authorization.py +backend/tests/test_auth.py +backend/tests/test_actors.py +backend/tests/test_api_controls.py +backend/tests/test_app.py +backend/scripts/api_contract_e2e.py +docs/spec_authorization_service.md +docs/operations_authorization_service.md +.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/** +.agent-loop/merge-intents/WS-AUTH-001-07B.json +.agent-loop/LOOP_STATE.md +.agent-loop/WORK_QUEUE.md +.agent-loop/REVIEW_LOG.md +``` + +## Not allowed + +```text +grant tables, grant queries, or role matrices +permission/admin-role definition APIs +project-scoped authorization-context API +feature resource loaders outside canonical actor self resources +cross-request decision caching +token-role authority or fabricated grants +generic policy language or a second unit-of-work abstraction +project/task/checker/review/contribution/artifact cutover +``` + +## Active action definitions + +| ActionId | PermissionId | Target | Candidate source | Guards | Revalidate | Concealment | Owner | +|---|---|---|---|---|---|---|---| +| `actor.profile.read_self` | `actor.profile.read_self` | current canonical actor profile | active human self | active identity link; actor active or suspended; exact self target | no | none; caller owns target | AUTH-07B | +| `actor.profile.update_self` | `actor.profile.update_self` | current canonical actor profile | active human self | active identity link; actor active; exact self target; caller-owned fields only | yes, actor and link locked in caller transaction | none; caller owns target | AUTH-07B | + +No other action becomes executable. + +## Denial precedence + +| Order | Condition | External status/code | Internal decision | +|---:|---|---|---| +| 1 | Missing or invalid bearer token | 401 existing auth code | no authorization decision | +| 2 | Unsupported subject kind or unprovisioned service | 403 existing actor code | no action evidence before canonical actor exists | +| 3 | Revoked identity link | 403 `identity_link_revoked` | denied with exact ActionId when transaction-local recheck observes revocation | +| 4 | Deactivated actor | 403 `actor_deactivated` | denied with exact ActionId when transaction-local recheck observes deactivation | +| 5 | Suspended actor on update | 403 `actor_suspended` | denied with exact ActionId | +| 6 | Unknown or planned action | 403 `permission_not_granted` at a public boundary | internal `unknown_action` or `action_unavailable` without leaking catalogue metadata | +| 7 | Target/fact mismatch | 403 `resource_guard_denied` | exact internal guard denial | +| 8 | No candidate permission | 403 `permission_not_granted` | exact internal candidate denial | + +Suspended actors may read their own bounded profile but cannot update it. These +self routes do not conceal a separate resource, so they never collapse denial +to 404. Feature-owned concealment matrices begin in their owning cutover chunks. + +## Transaction ownership + +- The kernel validates and writes staged audit evidence but never commits. +- Read dependencies commit only the completed read decision evidence. +- Self-update revalidates and locks actor/link state, applies the bounded + profile mutation, writes allow evidence, and commits once in the route-owned + transaction. +- A denied self-update rolls back staged business changes before committing a + clean denial event in a new transaction. +- Later mutation chunks must define their own lock order and race proof; this + chunk proves the pattern only on self-update. + +## Acceptance criteria + +- `AuthorizationContext` contains canonical actor/link state and request/ + correlation IDs, never token roles or arbitrary client permissions. +- Closed strict `ActorSelfResourceContext` rejects missing, extra, mistyped, or + non-self facts. A minimal `SystemResourceContext` extension type may exist but + grants no system authority and has no active action. +- `AuthorizationDecision` is stable and privacy bounded, carrying ActionId, + PermissionId, allowed/denial code, resource reference, matched authority kind, + revalidation status, request ID, and correlation ID. +- Unknown permissions, unknown actions, and every planned action deny. +- The public feature interface has the exact two-argument request-scoped + `require` contract above. An API-signature test rejects any session or `uow` + argument, and import-boundary tests reject feature imports of AUTH persistence + or private evaluation helpers and reject raw permission/candidate/guard input. +- System scope is not superuser authority. +- Default human authority is exactly the two self candidates above. Token role + changes do not alter either decision. +- GET and PATCH `/api/v1/actors/me` each declare exactly one + `x-workstream-action-id` in OpenAPI and call the central kernel. +- PATCH accepts only its existing caller-owned display fields and preserves its + privacy contract. +- Real issuer-token tests resolve through the canonical actor dependency; no + fabricated AuthorizationContext or direct grant rows count as API proof. +- Allowed and denied decision evidence carries the exact active ActionId. +- Deterministic tests prove revoked-link and suspension/deactivation rechecks, + no cross-request cache, and a synchronized revoke-versus-update outcome + without sleeps. +- OpenAPI inventory and API E2E proofs are updated without weakening their + closed assertions. +- Permission/admin-role definition APIs remain deferred to AUTH-08. +- Project capability context remains deferred to AUTH-10 and no hidden project + existence is exposed here. +- Materially changed authorization/dependency/route behavior remains at or + above 90 percent branch coverage; global CI preserves the 78 percent floor. + +## Verification commands + +```bash +(cd backend && .venv/bin/python -m ruff check app tests scripts/api_contract_e2e.py) +(cd backend && WORKSTREAM_TEST_DATABASE_URL= \ + .venv/bin/python -m pytest -q tests/test_authorization.py tests/test_auth.py \ + tests/test_actors.py tests/test_api_controls.py tests/test_app.py \ + --cov=app.modules.authorization --cov=app.api.deps.authorization \ + --cov-branch --cov-report=term-missing --cov-fail-under=90) +(cd backend && WORKSTREAM_TEST_DATABASE_URL= \ + .venv/bin/python scripts/api_contract_e2e.py) +python3 scripts/check_stale_workstream_wording.py +python3 scripts/check_stale_authorization_docs.py +python3 scripts/check_markdown_links.py +git diff --check +``` + +GitHub Backend remains authoritative for the full suite and repository-wide 78 +percent floor. + +## Human review focus + +Review deny ordering, absence of token-role authority, exact self targeting, +suspended-read/update separation, transaction ownership, decision evidence, +OpenAPI declarations, and immediate state revalidation. + +## Stop conditions + +Stop if the kernel needs grant tables, project capability composition, feature +repositories, fabricated authority, or cross-request caching. + +Stop after merge and signed memory. Do not start AUTH-08 automatically. diff --git a/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/chunks/WS-AUTH-001-13-task-assignment-cutover.md b/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/chunks/WS-AUTH-001-13-task-assignment-cutover.md index 1c5cd4007..4d72062cf 100644 --- a/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/chunks/WS-AUTH-001-13-task-assignment-cutover.md +++ b/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/chunks/WS-AUTH-001-13-task-assignment-cutover.md @@ -86,10 +86,13 @@ token role or legacy active-worker-profile fallback reasoned start override is separately authorized as `operations.task.start_override` for Operator recovery; matched permission, scope, reason, and audit event distinguish the two paths. -- Before `operations.task.start_override` becomes active, migration `0025` and - the typed audit schema add that already approved identifier to the 49-item - AUTH-05A audit base. Upgrade/downgrade/re-upgrade and direct-SQL parity tests - preserve all prior audit rows and reject unknown identifiers. +- AUTH-07A migration `0021` already gives + `operations.task.start_override` PermissionId/ActionId typed and PostgreSQL + parity as planned metadata. This chunk promotes the action only with its task + resource composer, Operator candidate, guards, surface declaration, reason, + evidence, and behavior tests. Migration `0025` owns task/assignment and + Contributor-field schema changes only; it does not change the permission or + action registry. - Operator `operations.status.read` exposes a read-only cross-project task-queue operational projection with bounded fields; it does not grant task mutation. Audit Authority `audit.read` exposes only covered task evidence. Both paths diff --git a/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/chunks/WS-AUTH-001-14-submission-checker-cutover.md b/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/chunks/WS-AUTH-001-14-submission-checker-cutover.md index 756a7951e..bf3e1d3da 100644 --- a/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/chunks/WS-AUTH-001-14-submission-checker-cutover.md +++ b/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/chunks/WS-AUTH-001-14-submission-checker-cutover.md @@ -87,11 +87,13 @@ legacy active-worker-profile or workflow-eligibility compatibility fallback uses distinct `operations.submission_gate.repair` and `operations.checker.retry` permissions. Each path requires a reason and records matched grant/permission without granting general project authority. -- Before `operations.submission_gate.repair` or `operations.checker.retry` - becomes active, migration `0026` and the typed audit schema add both already - approved identifiers to the 50-item post-AUTH-13 audit base. Upgrade, - downgrade, re-upgrade, and direct-SQL parity tests preserve earlier audit rows - and establish exact 52-identifier typed/PostgreSQL parity. +- AUTH-07A migration `0021` already gives + `operations.submission_gate.repair` and `operations.checker.retry` + PermissionId/ActionId typed and PostgreSQL parity as planned metadata. This + chunk promotes each action only with its feature resource composer, Operator + candidate, guards, surface declaration, reason, evidence, and behavior tests. + Migration `0026` owns submission/checker Contributor-field schema changes + only; it does not change the permission or action registry. - Contributor reads preserve ownership, hidden-result redaction, and concealed not-found behavior. - Audit reads expose only permission-appropriate bounded fields before counts. diff --git a/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/reviews/WS-AUTH-001-07-preimplementation-plan-review.md b/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/reviews/WS-AUTH-001-07-preimplementation-plan-review.md new file mode 100644 index 000000000..73ff023e4 --- /dev/null +++ b/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/reviews/WS-AUTH-001-07-preimplementation-plan-review.md @@ -0,0 +1,82 @@ +# WS-AUTH-001-07 Preimplementation Plan Review + +## Initial verdict + +`FAIL` before runtime implementation. + +## Review tracks + +| Track | Result | Blocking findings | +|---|---|---| +| senior engineering / architecture / reuse | FAIL | Combined catalogue, migration, kernel, APIs, manifest, and transaction proof were not one bounded L1 change; required ORM and route files were omitted. | +| security/auth / product/ops / docs | FAIL | Grant-backed and project-scoped APIs preceded authority sources; active actions, denial precedence, transaction ownership, response privacy, and planned artifact boundaries were incomplete. | +| QA/test / CI integrity | PASS WITH CONDITIONS | Missing allowed tests, exact action tables, migration versioning, synchronized race proof, isolated-test runner contract, and changed-subsystem 90 percent coverage. | + +No application, migration, dependency, workflow, or test code was changed before +these verdicts. + +## Valid repair + +- Split parent AUTH-07 into AUTH-07A and AUTH-07B. +- AUTH-07A owns exactly 73 PermissionIds, 30 four-field planned ActionIds, and + migration `0021` action-aware audit parity. No action is executable. +- AUTH-07B owns the minimal deny-by-default kernel and activates exactly + `actor.profile.read_self` and `actor.profile.update_self` on the existing + `/api/v1/actors/me` routes. +- Permission/admin-role definition APIs move to AUTH-08. +- Project-scoped authorization context moves to AUTH-10. +- Feature loaders, grant matrices, concealment, and revoke races remain with + their owning cutovers. +- Verification uses `WORKSTREAM_TEST_ADMIN_DATABASE_URL`, the isolated-test runner, + branch coverage at or above 90 percent for materially changed subsystems, and + the unchanged repository-wide 78 percent CI floor. + +## Second repaired review + +Exact-SHA senior engineering, architecture/reuse, QA/test, and CI-integrity +review passed `b1b47b0`. Security/auth, product/ops, and docs review found one +remaining audit-integrity blocker before runtime implementation: + +- the contract admitted registered actions and permissions independently rather + than enforcing the exact action-to-permission mapping; +- a newly admitted permission could be stored without an action, so the guarded + downgrade predicate was incomplete; and +- planned actions were not explicitly barred from allowed-decision evidence. + +The next candidate closes those findings in both typed and PostgreSQL +acceptance criteria, requires denial-only evidence for planned actions, and +checks both action and post-`0018` permission evidence under the exclusive +downgrade lock. No runtime code was written. + +## Third repaired review + +Exact-SHA architecture/reuse and CI review of `8690ef5` confirmed the mapping +and downgrade repair but rejected one temporal-schema ambiguity. The contract +could be read as freezing planned actions to denied events in PostgreSQL, which +would prevent AUTH-07B from activating its two self actions without another +migration. The repair makes availability a typed catalogue invariant only. +PostgreSQL remains stable across activation and enforces registration, +decision-event-only use, and exact action-to-permission mapping. Direct-SQL +planned-action fixtures use denied evidence without asserting a database-level +availability rule. No runtime code was written. + +## Re-review gate + +`PASS` at exact planning SHA `beb85ac9d7d6ad0f7f12630cf3e8fdc4df8ac3f7`. + +Senior engineering, architecture/reuse, security/auth, product/ops, docs, +QA/test, test-delta, and CI-integrity review found no remaining blockers. The +reviewed contract keeps availability in typed validation, keeps PostgreSQL +availability-neutral, closes exact action-to-permission mapping and downgrade +custody, and preserves the 07A/07B/08/10 ownership boundaries. + +Deterministic planning evidence at the reviewed SHA: + +- 71 agent-gate tests passed with third-party pytest plugin autoload disabled; +- Markdown links passed; +- stale Workstream wording passed; +- stale authorization documentation passed; and +- `git diff --check` passed. + +Bounded AUTH-07A runtime implementation may begin. Prior failed results remain +historical evidence and do not approve code outside this contract. diff --git a/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/reviews/WS-AUTH-001-07A-external-review-response.md b/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/reviews/WS-AUTH-001-07A-external-review-response.md new file mode 100644 index 000000000..1f4491423 --- /dev/null +++ b/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/reviews/WS-AUTH-001-07A-external-review-response.md @@ -0,0 +1,53 @@ +# External Review Response: WS-AUTH-001-07A + +## Pull Request + +PR #126 - Add closed authorization action catalogue and audit parity + +CodeRabbit run: `920d63e5-f8b3-4525-9e61-18b44373ed1d` + +## Comments Addressed + +1. Corrected the specification compound noun from `self actions` to + `self-actions`. +2. Made the approved availability boundary explicit in code and behavior + evidence. Typed validation rejects allowed evidence for every one of the 50 + currently planned actions. PostgreSQL accepts exact registered allowed pairs + because migration `0021` is intentionally availability-neutral across later + owner activation. +3. Expanded the PR description to the repository trust-bundle structure. + +## Comment Declined + +CodeRabbit proposed restricting migration `0021` to denied action evidence. +That would contradict the approved AUTH-07A contract and freeze temporary +`planned` availability into a permanent database migration. AUTH-07B must be +able to activate its two self-actions through reviewed typed code without an +unowned migration `0022`. PostgreSQL therefore owns stable identifier, exact +mapping, and decision-event shape; typed catalogue validation owns temporal +availability. The direct SQL and typed tests now prove both sides of that +boundary for all 50 actions. + +## Non-Actionable Review Output + +CodeRabbit's diff-local docstring percentage is not a configured repository +gate. Existing public behavior and complex helpers retain useful docstrings; +narration-only docstrings were not added. + +## Commands Rerun + +```text +pytest focused catalogue/audit behavior +pytest targeted authorization action-evidence migration +ruff check changed backend files +python3 scripts/test_agent_gates.py +python3 scripts/check_internal_review_evidence.py +python3 scripts/check_loop_memory_state.py +python3 scripts/check_stale_workstream_wording.py +python3 scripts/check_stale_authorization_docs.py +python3 scripts/check_markdown_links.py +git diff --check +``` + +No GitHub thread is replied to or resolved by this evidence file. Thread writes +remain a separate explicit action after the repaired commit is pushed. diff --git a/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/reviews/WS-AUTH-001-07A-internal-review-evidence.md b/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/reviews/WS-AUTH-001-07A-internal-review-evidence.md new file mode 100644 index 000000000..0edd47b63 --- /dev/null +++ b/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/reviews/WS-AUTH-001-07A-internal-review-evidence.md @@ -0,0 +1,81 @@ +# WS-AUTH-001-07A Internal Review Evidence + +Reviewed code SHA: `6f1b7ce5171d763cff2b5d6393784f80d4248187` +Reviewed runtime SHA: `3365e67e7b44195069a5c7645fdee0af1d4e0180` +Reviewed at: `2026-07-15T12:51:26Z` +Reviewer run IDs: `auth06_final_ci`, `auth06_final_docs`, +`auth06_final_test_delta` + +## Deterministic Evidence + +- The focused authorization and audit suite passed all 37 collected behavior + tests against isolated PostgreSQL migration head `0021_auth_action_evidence`. +- Branch-aware subsystem coverage is 95 percent for authorization and 93 + percent for audit, above the required 90 percent threshold for materially + changed backend subsystems. +- The exact catalogue/startup matrix covers the independent 49 historical and + 25 new PermissionId sets, exact 50-action mapping, missing, + duplicate, extra, and hostile typed rows, immutability, and planned-action + non-executability. +- The complete isolated Alembic suite passed 16 tests in 503.16 seconds at + runtime SHA `3365e67`. It proves upgrade/downgrade/re-upgrade, historical-row + preservation, exact restored permission behavior, all forward-evidence + refusal paths, and the concurrent insert lock. +- Direct SQL accepts all 50 exact action/permission pairs as denied evidence, + rejects all 50 wrong registered-permission pairs, and rejects all 25 new + permissions without a mapped action. +- Amendment proof confirms typed validation rejects allowed evidence for all 50 + planned actions while PostgreSQL accepts all 50 exact allowed pairs as + availability-neutral storage. The targeted isolated migration test passed in + 43.53 seconds at runtime SHA `3365e67`. +- Ruff, stale Workstream wording, stale authorization documentation, changed + Markdown links, loop-memory state, and diff integrity passed. +- No workflow, dependency, test skip, coverage exclusion, package script, or + threshold changed. GitHub Backend remains authoritative for the repository- + wide 78 percent floor. + +## Reviewer Results + +| Reviewer | Result | Blocking findings | Notes | +|---|---|---|---| +| senior engineering | PASS AFTER FIXES | none | Closed catalogue, migration custody, and bounded test helpers are maintainable. | +| qa/test | PASS AFTER FIXES | none | Exact typed and SQL matrices, historical preservation, rollback paths, and concurrency are covered. | +| security/auth | PASS AFTER FIXES | none | No action is active; unknown or mismatched identifiers and unsafe rollback fail closed. | +| product/ops | PASS | none | No contributor, reviewer, project, artifact, or workflow capability is activated. | +| architecture | PASS | none | AUTH-07A remains a dependency-free catalogue and audit boundary; kernel and resource composition remain deferred. | +| ci integrity | PASS | none | CI, thresholds, dependencies, exclusions, and skips are unchanged. | +| docs | PASS | none | Contract, specification, operations, lifecycle, and rollback language match runtime behavior. | +| reuse/dedup | PASS | none | Audit validation consumes the single catalogue instead of retaining a second permission registry. | +| test delta | PASS AFTER FIXES | none | Assertions independently prove exact sets and exhaustive negative behavior without weakening prior tests. | + +## Findings Resolved + +Review repair added downgrade custody for new permissions stored in target and +invalidation permission-registry references. It also replaced count-only proof +with independent exact permission sets, made SQL negative coverage exhaustive, +completed startup failure cases, and proved a populated `0020` row survives +upgrade and clean downgrade with null action evidence. + +The final exact-head review confirmed that `478a819..f35f71b` contains only six +lifecycle/merge-intent files, records the evidence accurately, and keeps +`WS-AUTH-001-07B` inactive until merge, signed memory, and explicit human start. + +External repair review at `6287f57` confirmed CodeRabbit's proposed denial-only +SQL constraint would contradict the approved availability-neutral migration +contract. The review/revision amendment at `3365e67` adds one permission and 20 +planned action dependencies without runtime authority. Exact-head repair +`160af8a` removes duplicate session authority: the request-scoped service binds +the caller-owned session once and exposes only +`require(action_id, typed_resource_context)`. Evidence-only head `f5af798` then +passed exact lifecycle, docs, and test-evidence review. Latest-main merge and +checkpoint repair head `6f1b7ce` then passed merge-integrity, architecture, QA, +CI, lifecycle, and docs confirmation without AUTH runtime changes. + +Valid findings addressed: yes + +Open sub-agent sessions: none + +## Remaining Gate + +PR #126, GitHub Backend, Agent Gates, CodeRabbit, and explicit human merge +approval remain pending. Do not start `WS-AUTH-001-07B` automatically. diff --git a/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/reviews/WS-AUTH-001-07A-pr-trust-bundle.md b/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/reviews/WS-AUTH-001-07A-pr-trust-bundle.md new file mode 100644 index 000000000..19bf072a6 --- /dev/null +++ b/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/reviews/WS-AUTH-001-07A-pr-trust-bundle.md @@ -0,0 +1,93 @@ +# WS-AUTH-001-07A PR Trust Bundle + +## Chunk + +`WS-AUTH-001-07A` - Closed Permission And Action Catalogue + +## Goal + +Install one closed typed source for all 74 approved PermissionIds and exactly 50 +planned ActionIds, then preserve exact typed/PostgreSQL audit parity without +making any action executable. + +## What Changed + +- Added a dependency-free authorization catalogue containing the exact 49 + historical and 25 new permissions plus 50 four-field planned action rows. +- Reserved canonical `submission.create` plus 19 review actions. Revision + submission reuses `submission.create`; no revision-specific permission exists. +- Made authority audit validation consume the catalogue, enforce exact action- + permission mapping, reject planned allowed decisions, and bound hostile input. +- Added nullable `audit_events.action_id` and PostgreSQL constraints for exact + registry, mapping, decision-event, and new-permission evidence parity. +- Added guarded rollback custody under an exclusive table lock for action IDs + and new permissions stored as decisions, target references, or invalidation + references. +- Added exhaustive typed and PostgreSQL behavior tests and updated the canonical + specification, operations runbook, and future owner contracts. + +## Design Boundaries + +All actions remain `planned`. This PR does not implement a decision evaluator, +grants, principals, resource loaders, composers, guards, caches, route cutovers, +artifact operations, or public permission APIs. AUTH-07B later owns only actor +self-read/self-update kernel activation after this PR merges and a separate +human start. AUTH-08 and AUTH-10 remain the authority prerequisites for admin +and project contexts. + +## Acceptance Proof + +- The independent runtime test literals prove the exact 74-value permission set + and its exact 49/25 historical/new partition. +- Catalogue startup rejects missing, duplicate, extra, unknown, invalid-owner, + invalid-availability, and metadata-mismatched rows. +- Planned ActionIds cannot resolve as executable and cannot be recorded as + allowed decisions through the typed audit boundary. +- Direct SQL proves every exact pair, every wrong pair, every new permission + without action evidence, unknown actions, and non-decision action use. +- Migration proof preserves populated historical evidence, restores the exact + historical registry on downgrade, and blocks every forward-evidence path + before destructive DDL. + +## Tests And Coverage + +- 37 focused authorization/audit behavior tests passed on isolated PostgreSQL. +- Authorization branch-aware coverage: 95 percent. +- Audit branch-aware coverage: 93 percent. +- Full isolated Alembic suite: 16 passed in 503.16 seconds at reviewed runtime + SHA `3365e67e7b44195069a5c7645fdee0af1d4e0180`. +- Targeted exhaustive migration proof: 1 passed in 43.53 seconds. +- Catalogue/startup matrix: 10 passed. +- Ruff, stale wording, authorization-doc consistency, Markdown links, + loop-memory state, and diff integrity passed. + +GitHub Backend remains authoritative for the repository-wide 78 percent floor; +this PR does not change any coverage threshold or exclusion. + +## Reviewer Results + +Reviewed exact merged head `6f1b7ce5171d763cff2b5d6393784f80d4248187` +and runtime amendment `3365e67e7b44195069a5c7645fdee0af1d4e0180` passed +senior engineering, QA/test, security/auth, product/ops, architecture, CI +integrity, docs, reuse/dedup, and test-delta review with no remaining findings. + +## Remaining Risks + +- Database-owner credentials can bypass normal constraints and triggers; + production runtime credentials must remain non-owner. +- Downgrade is intentionally unavailable after any forward action or new- + permission evidence exists; recovery must proceed forward. +- Availability changes are typed-code owner decisions. PostgreSQL deliberately + remains availability-neutral so later owner chunks do not rewrite migration + `0021`. + +## Human Review Focus + +Review the exact 74/50 catalogue, planned-only typed behavior, exact SQL mapping, +historical audit preservation, four downgrade refusal paths, exclusive locking, +and the absence of kernel/grant/resource activation. + +## Human Merge Ownership + +Only the human may approve and merge this PR. GitHub checks, CodeRabbit, and +internal review do not authorize merge. diff --git a/.agent-loop/merge-intents/WS-AUTH-001-07A.json b/.agent-loop/merge-intents/WS-AUTH-001-07A.json new file mode 100644 index 000000000..334b8bcee --- /dev/null +++ b/.agent-loop/merge-intents/WS-AUTH-001-07A.json @@ -0,0 +1,9 @@ +{ + "chunk_id": "WS-AUTH-001-07A", + "chunk_title": "Closed Permission And Action Catalogue", + "initiative_id": "WS-AUTH-001", + "next_chunk_id": "WS-AUTH-001-07B", + "next_chunk_title": "Deny-By-Default Kernel And Self-Action Cutover", + "next_requires_explicit_start": true, + "schema_version": 2 +} diff --git a/backend/alembic/versions/0021_authorization_action_evidence.py b/backend/alembic/versions/0021_authorization_action_evidence.py new file mode 100644 index 000000000..d3d7a8034 --- /dev/null +++ b/backend/alembic/versions/0021_authorization_action_evidence.py @@ -0,0 +1,297 @@ +"""add closed permission and action audit parity + +Revision ID: 0021_auth_action_evidence +Revises: 0020_canonical_actor_profile +Create Date: 2026-07-15 +""" + +from __future__ import annotations + +from alembic import op +import sqlalchemy as sa + +revision = "0021_auth_action_evidence" +down_revision = "0020_canonical_actor_profile" +branch_labels = None +depends_on = None + +HISTORICAL_PERMISSIONS = """actor.profile.read_self actor.profile.update_self actor.profile.read_any +actor.profile.suspend actor.profile.reactivate actor.profile.deactivate actor.identity_link.read +actor.identity_link.revoke actor.identity_link.reactivate actor.service.provision admin_role.read +admin_role.grant admin_role.revoke project.create project.read project.update project.archive +project.guide.manage project.effective_policy.manage project.task.manage project.review_policy.manage +project.role_grant.read project.role_grant.manage task.queue.read task.claim submission.create +submission.read_own submission.read_for_review review.queue.read review.queue.inspect review.claim +review.release review.decline_preference review.decision review.lease.force_release review.chain.read +contribution.read_self contribution.read_project compensation.policy.manage +compensation.adapter_binding.manage compensation.award.read compensation.delivery.reconcile +operations.status.read operations.timer.run operations.reconcile.run operations.outbox.retry +operations.projection.rebuild audit.read audit.export""".split() + +NEW_PERMISSIONS = """operations.task.start_override operations.submission_gate.repair +operations.checker.retry artifact.binding.read artifact.replica.read artifact.receipt.read +artifact.verification_job.read artifact.verification_job.retry artifact.recovery_attempt.read +artifact.audit.read artifact.guide_source.ingest artifact.upload_session.create +artifact.upload_session.read artifact.upload_item.write artifact.upload_session.seal +artifact.upload_session.cancel artifact.upload_session.expire artifact.binding.create +artifact.verification.execute artifact.pending_work.scan artifact.put_attempt.resolve +artifact.guide_source.read artifact.checker_input.materialize artifact.checker_output.write +review.queue.override""".split() + +PERMISSIONS = HISTORICAL_PERMISSIONS + NEW_PERMISSIONS + +ACTION_PERMISSION_PAIRS = ( + ("actor.profile.read_self", "actor.profile.read_self"), + ("actor.profile.update_self", "actor.profile.update_self"), + ("operations.task.start_override", "operations.task.start_override"), + ("operations.submission_gate.repair", "operations.submission_gate.repair"), + ("operations.checker.retry", "operations.checker.retry"), + ("submission.create", "submission.create"), + ("review.queue.read", "review.queue.read"), + ("review.queue.inspect", "review.queue.inspect"), + ("review.claim", "review.claim"), + ("review.release", "review.release"), + ("review.decline_preference", "review.decline_preference"), + ("review.preference_expiry.run", "operations.timer.run"), + ("review.lease_expiry.run", "operations.timer.run"), + ("review.context.read", "submission.read_for_review"), + ("review.chain.read", "review.chain.read"), + ("review.finding_evidence.ingest", "review.decision"), + ("review.decision", "review.decision"), + ("review.finding_response_evidence.ingest", "submission.create"), + ("review.lease.force_release", "review.lease.force_release"), + ("review.queue.routing.override", "review.queue.override"), + ("review.queue.routing.correct", "review.queue.override"), + ("review.queue.close", "review.queue.override"), + ("review.reconcile.run", "operations.reconcile.run"), + ("review.artifact_reference.reconcile", "operations.reconcile.run"), + ("review.projection.rebuild", "operations.projection.rebuild"), + ("artifact.binding.read", "artifact.binding.read"), + ("artifact.replica.read", "artifact.replica.read"), + ("artifact.receipt.read", "artifact.receipt.read"), + ("artifact.verification_job.read", "artifact.verification_job.read"), + ("artifact.verification_job.retry", "artifact.verification_job.retry"), + ("artifact.recovery_attempt.read", "artifact.recovery_attempt.read"), + ("artifact.audit.read", "artifact.audit.read"), + ("operations.artifact_storage_admission.read", "operations.status.read"), + ("artifact.guide_source.ingest", "artifact.guide_source.ingest"), + ("artifact.guide_source.read", "artifact.guide_source.read"), + ("artifact.upload_session.create", "artifact.upload_session.create"), + ("artifact.upload_session.read", "artifact.upload_session.read"), + ("artifact.upload_item.write", "artifact.upload_item.write"), + ("artifact.upload_session.seal", "artifact.upload_session.seal"), + ("artifact.upload_session.cancel", "artifact.upload_session.cancel"), + ("artifact.upload_session.expire", "artifact.upload_session.expire"), + ("artifact.guide_source.binding.create", "artifact.binding.create"), + ("artifact.submission.binding.create", "artifact.binding.create"), + ("artifact.checker_output.binding.create", "artifact.binding.create"), + ("artifact.verification.execute", "artifact.verification.execute"), + ("artifact.pending_work.scan", "artifact.pending_work.scan"), + ("artifact.put_attempt.resolve", "artifact.put_attempt.resolve"), + ( + "artifact.pre_submit.checker_input.materialize", + "artifact.checker_input.materialize", + ), + ( + "artifact.post_submit.checker_input.materialize", + "artifact.checker_input.materialize", + ), + ("artifact.checker_output.write", "artifact.checker_output.write"), +) + +DENIAL_CODES = """required_scope_missing unsupported_subject_kind service_actor_not_provisioned +identity_link_revoked actor_suspended actor_deactivated permission_not_granted scope_not_authorized +self_grant_forbidden self_role_revoke_forbidden resource_guard_denied actor_not_found grant_not_found +resource_not_found actor_already_suspended actor_not_suspended actor_deactivated_terminal +last_access_administrator admin_role_grant_exists project_role_grant_exists identity_link_conflict +resource_project_mismatch idempotency_mismatch invalid_role_scope invalid_project_role +qualification_snapshot_invalid""".split() + +REASONS = { + "ActorProfileProvisioned": ("automatic_first_access",), + "ServiceActorProvisioned": ("manual_service_provisioning",), + "ActorIdentityLinked": ("identity_lifecycle_change",), + "ActorIdentityLinkRevoked": ("identity_lifecycle_change",), + "ActorIdentityLinkReactivated": ("identity_lifecycle_change",), + "ActorProfileSuspended": ("security_response", "administrative_correction"), + "ActorProfileReactivated": ("administrative_correction",), + "ActorProfileDeactivated": ("security_response", "administrative_correction"), + "InitialAccessAdministratorBootstrapped": ("initial_access_bootstrap",), + "AdminRoleGrantIssued": ("authority_assignment",), + "AdminRoleGrantRevoked": ("authority_revocation",), + "AdminRoleGrantIssueDenied": ("authorization_policy_denial",), + "LastAccessAdministratorOperationDenied": ("authorization_policy_denial",), + "ProjectRoleQualificationSnapshotCaptured": ("qualification_evidence_captured",), + "ProjectRoleGrantIssued": ("authority_assignment",), + "ProjectRoleGrantReplaced": ("authority_replacement",), + "ProjectRoleGrantRevoked": ("authority_revocation",), + "SensitiveAuthorizationAllowed": ("authorization_evaluation",), + "SensitiveAuthorizationDenied": ("authorization_evaluation",), + "AuthorityInvalidationRequested": ("authority_state_changed",), +} + + +def _tokens(values: list[str] | tuple[str, ...]) -> str: + return ", ".join(f"'{value}'" for value in values) + + +def _pair_tokens() -> str: + return ", ".join( + f"('{action}', '{permission}')" for action, permission in ACTION_PERMISSION_PAIRS + ) + + +def _create_registry_constraint(permissions: list[str]) -> None: + reason_rules = " or ".join( + f"(event_type = '{event}' and reason in ({_tokens(reasons)}))" + for event, reasons in REASONS.items() + ) + op.create_check_constraint( + "authority_registries", + "audit_events", + f""" + event_domain <> 'authority' or ( + reason is not null and ({reason_rules}) + and (permission_id is null or permission_id in ({_tokens(permissions)})) + and (denial_code is null or denial_code in ({_tokens(DENIAL_CODES)})) + ) + """, + ) + + +def _create_privacy_constraint(permissions: list[str]) -> None: + entity_tokens = _tokens( + ( + "actor_profile", + "actor_identity_link", + "admin_role_grant", + "qualification_snapshot", + "project_role_grant", + "authorization_decision", + "authority_invalidation", + ) + ) + resource_tokens = _tokens( + """actor_profile actor_identity_link admin_role_grant project project_role_grant task + submission review contribution compensation_award compensation_delivery operations + audit_event""".split() + ) + uuid_target_tokens = _tokens( + ( + "actor_profile", + "actor_identity_link", + "admin_role_grant", + "qualification_snapshot", + "project_role_grant", + ) + ) + uuid_pattern = r"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$" + permission_tokens = _tokens(permissions) + op.create_check_constraint( + "authority_privacy_bounds", + "audit_events", + f""" + event_domain <> 'authority' or ( + id ~ '{uuid_pattern}' and entity_type in ({entity_tokens}) and entity_id ~ '{uuid_pattern}' + and ( + (actor_ref_kind in ('legacy_actor', 'actor_profile') and actor_id ~ '{uuid_pattern}') + or (actor_ref_kind = 'system_principal' and actor_id = 'workstream:system:bootstrap') + ) + and ( + target_actor_ref is null + or (target_actor_ref_kind = 'actor_profile' and target_actor_ref ~ '{uuid_pattern}') + ) + and (matched_grant_id is null or matched_grant_id ~ '{uuid_pattern}') + and (project_id is null or project_id ~ '{uuid_pattern}') + and (resource_type is null or resource_type in ({resource_tokens})) + and (resource_id is null or resource_id ~ '{uuid_pattern}') + and ( + target_ref_kind is null + or (target_ref_kind in ({uuid_target_tokens}) and target_ref_id ~ '{uuid_pattern}') + or (target_ref_kind = 'permission_registry' and target_ref_id in ({permission_tokens})) + ) + and ( + invalidation_target_kind is null + or ( + invalidation_target_kind in ({uuid_target_tokens}) + and invalidation_target_ref ~ '{uuid_pattern}' + ) + or ( + invalidation_target_kind = 'permission_registry' + and invalidation_target_ref in ({permission_tokens}) + ) + ) + and ( + entity_type not in ('authorization_decision', 'authority_invalidation') + or entity_id = id + ) + and ( + resource_type <> 'project' or resource_id is null + or (project_id is not null and resource_id = project_id) + ) + ) + """, + ) + + +def upgrade() -> None: + """Install action-aware audit constraints without activating any action.""" + op.add_column("audit_events", sa.Column("action_id", sa.String(160), nullable=True)) + op.drop_constraint("authority_registries", "audit_events", type_="check") + op.drop_constraint("authority_privacy_bounds", "audit_events", type_="check") + _create_registry_constraint(PERMISSIONS) + _create_privacy_constraint(PERMISSIONS) + + # Availability is typed lifecycle state; SQL remains stable across owner activation. + pair_tokens = _pair_tokens() + op.create_check_constraint( + "authorization_action_evidence", + "audit_events", + f""" + ( + event_domain = 'legacy_lifecycle' and action_id is null + ) or ( + event_domain = 'authority' + and ( + action_id is null or ( + event_type in ('SensitiveAuthorizationAllowed', 'SensitiveAuthorizationDenied') + and permission_id is not null + and (action_id, permission_id) in ({pair_tokens}) + ) + ) + and ( + permission_id is null + or permission_id not in ({_tokens(NEW_PERMISSIONS)}) + or ( + action_id is not null + and (action_id, permission_id) in ({pair_tokens}) + ) + ) + ) + """, + ) + + +def downgrade() -> None: + """Remove action parity only when no forward evidence would be discarded.""" + bind = op.get_bind() + bind.execute(sa.text("lock table audit_events in access exclusive mode")) + has_forward_evidence = bind.execute( + sa.text( + "select exists(select 1 from audit_events where action_id is not null " + f"or permission_id in ({_tokens(NEW_PERMISSIONS)}) " + "or (target_ref_kind = 'permission_registry' " + f"and target_ref_id in ({_tokens(NEW_PERMISSIONS)})) " + "or (invalidation_target_kind = 'permission_registry' " + f"and invalidation_target_ref in ({_tokens(NEW_PERMISSIONS)})))" + ) + ).scalar_one() + if has_forward_evidence: + raise RuntimeError("cannot downgrade non-empty authorization action evidence") + + op.drop_constraint("authorization_action_evidence", "audit_events", type_="check") + op.drop_constraint("authority_registries", "audit_events", type_="check") + op.drop_constraint("authority_privacy_bounds", "audit_events", type_="check") + _create_registry_constraint(HISTORICAL_PERMISSIONS) + _create_privacy_constraint(HISTORICAL_PERMISSIONS) + op.drop_column("audit_events", "action_id") diff --git a/backend/app/modules/audit/schemas.py b/backend/app/modules/audit/schemas.py index 3433016f9..d7f88fb5c 100644 --- a/backend/app/modules/audit/schemas.py +++ b/backend/app/modules/audit/schemas.py @@ -10,6 +10,16 @@ from pydantic import BaseModel, ConfigDict, Field, model_validator +from app.modules.authorization.catalogue import ( + ACTION_BY_ID, + ACTION_IDS, + NEW_PERMISSION_IDS, + PERMISSION_IDS, + ActionAvailability, + ActionId, + PermissionId, +) + _ENTITY_TYPES = frozenset( { "actor_profile", @@ -29,21 +39,6 @@ _UUID_TARGET_KINDS = frozenset( {"actor_profile", "actor_identity_link", "admin_role_grant", "qualification_snapshot", "project_role_grant"} ) -_PERMISSIONS = frozenset( - """actor.profile.read_self actor.profile.update_self actor.profile.read_any - actor.profile.suspend actor.profile.reactivate actor.profile.deactivate - actor.identity_link.read actor.identity_link.revoke actor.identity_link.reactivate - actor.service.provision admin_role.read admin_role.grant admin_role.revoke project.create - project.read project.update project.archive project.guide.manage project.effective_policy.manage - project.task.manage project.review_policy.manage project.role_grant.read - project.role_grant.manage task.queue.read task.claim submission.create submission.read_own - submission.read_for_review review.queue.read review.queue.inspect review.claim review.release - review.decline_preference review.decision review.lease.force_release review.chain.read - contribution.read_self contribution.read_project compensation.policy.manage - compensation.adapter_binding.manage compensation.award.read compensation.delivery.reconcile - operations.status.read operations.timer.run operations.reconcile.run operations.outbox.retry - operations.projection.rebuild audit.read audit.export""".split() -) _DENIAL_CODES = frozenset( """required_scope_missing unsupported_subject_kind service_actor_not_provisioned identity_link_revoked actor_suspended actor_deactivated permission_not_granted @@ -247,7 +242,8 @@ class AuthorityAuditEventInput(BaseModel): target_actor_ref_kind: ActorReferenceKind | None = None target_actor_ref: Annotated[str, Field(max_length=120)] | None = None matched_grant_id: str | None = None - permission_id: str | None = None + permission_id: PermissionId | None = None + action_id: ActionId | None = None project_id: str | None = None resource_type: str | None = None resource_id: str | None = None @@ -298,7 +294,8 @@ def _inspect_privacy_safe_input(cls, value: object) -> dict | None: or kind is None or (kind == "system_principal" and actor_ref != "workstream:system:bootstrap") or (kind != "system_principal" and _uuid(actor_ref) is None) - or data.get("permission_id") is not None and not _registered(data["permission_id"], _PERMISSIONS) + or data.get("permission_id") is not None and not _registered(data["permission_id"], PERMISSION_IDS) + or data.get("action_id") is not None and not _registered(data["action_id"], ACTION_IDS) or data.get("denial_code") is not None and not _registered(data["denial_code"], _DENIAL_CODES) or data.get("resource_type") is not None and not _registered(data["resource_type"], _RESOURCE_TYPES) or data.get("target_ref_kind") is not None @@ -314,7 +311,7 @@ def _inspect_privacy_safe_input(cls, value: object) -> dict | None: ref = data.get(f"{prefix}_ref" if prefix == "invalidation_target" else f"{prefix}_id") invalid |= (ref_kind is None) != (ref is None) invalid |= _registered(ref_kind, _UUID_TARGET_KINDS) and ref is not None and _uuid(ref) is None - invalid |= ref_kind == "permission_registry" and not _registered(ref, _PERMISSIONS) + invalid |= ref_kind == "permission_registry" and not _registered(ref, PERMISSION_IDS) target_kind, target_ref = data.get("target_actor_ref_kind"), data.get("target_actor_ref") invalid |= (target_kind is None) != (target_ref is None) invalid |= target_kind is not None and ( @@ -322,6 +319,10 @@ def _inspect_privacy_safe_input(cls, value: object) -> dict | None: ) if invalid: return None + if data.get("permission_id") is not None: + data["permission_id"] = PermissionId(data["permission_id"]) + if data.get("action_id") is not None: + data["action_id"] = ActionId(data["action_id"]) data["before_facts"] = before_facts data["after_facts"] = after_facts return data @@ -363,9 +364,21 @@ def validate_shape(self) -> Self: if before and after and "scope_id" in before and before.get("scope_id") != after.get("scope_id"): raise ValueError("replacement cannot change project scope") invalidation = self.invalidation_cause_event_id is not None or self.invalidation_target_kind + action = ACTION_BY_ID.get(self.action_id) if self.action_id is not None else None + if action is not None and action.permission_id is not self.permission_id: + raise ValueError("action permission does not match catalogue") + if self.permission_id in NEW_PERMISSION_IDS and action is None: + raise ValueError("new permission requires registered action") + if self.action_id is not None and self.event_type not in { + AuthorityEventType.SENSITIVE_AUTHORIZATION_ALLOWED, + AuthorityEventType.SENSITIVE_AUTHORIZATION_DENIED, + }: + raise ValueError("action requires authorization decision event") if self.event_type == AuthorityEventType.SENSITIVE_AUTHORIZATION_ALLOWED: if self.permission_id is None or self.denial_code is not None or invalidation: raise ValueError("invalid allowed authorization evidence") + if action is not None and action.availability is ActionAvailability.PLANNED: + raise ValueError("planned action cannot produce allowed evidence") elif self.event_type == AuthorityEventType.SENSITIVE_AUTHORIZATION_DENIED: if self.permission_id is None or self.denial_code is None or invalidation or self.idempotency_reference: raise ValueError("invalid denied authorization evidence") diff --git a/backend/app/modules/authorization/catalogue.py b/backend/app/modules/authorization/catalogue.py new file mode 100644 index 000000000..30e8de24c --- /dev/null +++ b/backend/app/modules/authorization/catalogue.py @@ -0,0 +1,484 @@ +"""Closed authorization identifiers and staged action metadata.""" + +from __future__ import annotations + +from dataclasses import dataclass +from enum import StrEnum, unique +from types import MappingProxyType + + +@unique +class PermissionId(StrEnum): + """Closed product permission identifiers.""" + + ACTOR_PROFILE_READ_SELF = "actor.profile.read_self" + ACTOR_PROFILE_UPDATE_SELF = "actor.profile.update_self" + ACTOR_PROFILE_READ_ANY = "actor.profile.read_any" + ACTOR_PROFILE_SUSPEND = "actor.profile.suspend" + ACTOR_PROFILE_REACTIVATE = "actor.profile.reactivate" + ACTOR_PROFILE_DEACTIVATE = "actor.profile.deactivate" + ACTOR_IDENTITY_LINK_READ = "actor.identity_link.read" + ACTOR_IDENTITY_LINK_REVOKE = "actor.identity_link.revoke" + ACTOR_IDENTITY_LINK_REACTIVATE = "actor.identity_link.reactivate" + ACTOR_SERVICE_PROVISION = "actor.service.provision" + ADMIN_ROLE_READ = "admin_role.read" + ADMIN_ROLE_GRANT = "admin_role.grant" + ADMIN_ROLE_REVOKE = "admin_role.revoke" + PROJECT_CREATE = "project.create" + PROJECT_READ = "project.read" + PROJECT_UPDATE = "project.update" + PROJECT_ARCHIVE = "project.archive" + PROJECT_GUIDE_MANAGE = "project.guide.manage" + PROJECT_EFFECTIVE_POLICY_MANAGE = "project.effective_policy.manage" + PROJECT_TASK_MANAGE = "project.task.manage" + PROJECT_REVIEW_POLICY_MANAGE = "project.review_policy.manage" + PROJECT_ROLE_GRANT_READ = "project.role_grant.read" + PROJECT_ROLE_GRANT_MANAGE = "project.role_grant.manage" + TASK_QUEUE_READ = "task.queue.read" + TASK_CLAIM = "task.claim" + SUBMISSION_CREATE = "submission.create" + SUBMISSION_READ_OWN = "submission.read_own" + SUBMISSION_READ_FOR_REVIEW = "submission.read_for_review" + REVIEW_QUEUE_READ = "review.queue.read" + REVIEW_QUEUE_INSPECT = "review.queue.inspect" + REVIEW_CLAIM = "review.claim" + REVIEW_RELEASE = "review.release" + REVIEW_DECLINE_PREFERENCE = "review.decline_preference" + REVIEW_DECISION = "review.decision" + REVIEW_LEASE_FORCE_RELEASE = "review.lease.force_release" + REVIEW_CHAIN_READ = "review.chain.read" + REVIEW_QUEUE_OVERRIDE = "review.queue.override" + CONTRIBUTION_READ_SELF = "contribution.read_self" + CONTRIBUTION_READ_PROJECT = "contribution.read_project" + COMPENSATION_POLICY_MANAGE = "compensation.policy.manage" + COMPENSATION_ADAPTER_BINDING_MANAGE = "compensation.adapter_binding.manage" + COMPENSATION_AWARD_READ = "compensation.award.read" + COMPENSATION_DELIVERY_RECONCILE = "compensation.delivery.reconcile" + OPERATIONS_STATUS_READ = "operations.status.read" + OPERATIONS_TIMER_RUN = "operations.timer.run" + OPERATIONS_RECONCILE_RUN = "operations.reconcile.run" + OPERATIONS_OUTBOX_RETRY = "operations.outbox.retry" + OPERATIONS_PROJECTION_REBUILD = "operations.projection.rebuild" + OPERATIONS_TASK_START_OVERRIDE = "operations.task.start_override" + OPERATIONS_SUBMISSION_GATE_REPAIR = "operations.submission_gate.repair" + OPERATIONS_CHECKER_RETRY = "operations.checker.retry" + ARTIFACT_BINDING_READ = "artifact.binding.read" + ARTIFACT_REPLICA_READ = "artifact.replica.read" + ARTIFACT_RECEIPT_READ = "artifact.receipt.read" + ARTIFACT_VERIFICATION_JOB_READ = "artifact.verification_job.read" + ARTIFACT_VERIFICATION_JOB_RETRY = "artifact.verification_job.retry" + ARTIFACT_RECOVERY_ATTEMPT_READ = "artifact.recovery_attempt.read" + ARTIFACT_AUDIT_READ = "artifact.audit.read" + ARTIFACT_GUIDE_SOURCE_INGEST = "artifact.guide_source.ingest" + ARTIFACT_UPLOAD_SESSION_CREATE = "artifact.upload_session.create" + ARTIFACT_UPLOAD_SESSION_READ = "artifact.upload_session.read" + ARTIFACT_UPLOAD_ITEM_WRITE = "artifact.upload_item.write" + ARTIFACT_UPLOAD_SESSION_SEAL = "artifact.upload_session.seal" + ARTIFACT_UPLOAD_SESSION_CANCEL = "artifact.upload_session.cancel" + ARTIFACT_UPLOAD_SESSION_EXPIRE = "artifact.upload_session.expire" + ARTIFACT_BINDING_CREATE = "artifact.binding.create" + ARTIFACT_VERIFICATION_EXECUTE = "artifact.verification.execute" + ARTIFACT_PENDING_WORK_SCAN = "artifact.pending_work.scan" + ARTIFACT_PUT_ATTEMPT_RESOLVE = "artifact.put_attempt.resolve" + ARTIFACT_GUIDE_SOURCE_READ = "artifact.guide_source.read" + ARTIFACT_CHECKER_INPUT_MATERIALIZE = "artifact.checker_input.materialize" + ARTIFACT_CHECKER_OUTPUT_WRITE = "artifact.checker_output.write" + AUDIT_READ = "audit.read" + AUDIT_EXPORT = "audit.export" + + +@unique +class ActionId(StrEnum): + """Closed action identifiers reserved by approved owner chunks.""" + + ACTOR_PROFILE_READ_SELF = "actor.profile.read_self" + ACTOR_PROFILE_UPDATE_SELF = "actor.profile.update_self" + OPERATIONS_TASK_START_OVERRIDE = "operations.task.start_override" + OPERATIONS_SUBMISSION_GATE_REPAIR = "operations.submission_gate.repair" + OPERATIONS_CHECKER_RETRY = "operations.checker.retry" + SUBMISSION_CREATE = "submission.create" + REVIEW_QUEUE_READ = "review.queue.read" + REVIEW_QUEUE_INSPECT = "review.queue.inspect" + REVIEW_CLAIM = "review.claim" + REVIEW_RELEASE = "review.release" + REVIEW_DECLINE_PREFERENCE = "review.decline_preference" + REVIEW_PREFERENCE_EXPIRY_RUN = "review.preference_expiry.run" + REVIEW_LEASE_EXPIRY_RUN = "review.lease_expiry.run" + REVIEW_CONTEXT_READ = "review.context.read" + REVIEW_CHAIN_READ = "review.chain.read" + REVIEW_FINDING_EVIDENCE_INGEST = "review.finding_evidence.ingest" + REVIEW_DECISION = "review.decision" + REVIEW_FINDING_RESPONSE_EVIDENCE_INGEST = "review.finding_response_evidence.ingest" + REVIEW_LEASE_FORCE_RELEASE = "review.lease.force_release" + REVIEW_QUEUE_ROUTING_OVERRIDE = "review.queue.routing.override" + REVIEW_QUEUE_ROUTING_CORRECT = "review.queue.routing.correct" + REVIEW_QUEUE_CLOSE = "review.queue.close" + REVIEW_RECONCILE_RUN = "review.reconcile.run" + REVIEW_ARTIFACT_REFERENCE_RECONCILE = "review.artifact_reference.reconcile" + REVIEW_PROJECTION_REBUILD = "review.projection.rebuild" + ARTIFACT_BINDING_READ = "artifact.binding.read" + ARTIFACT_REPLICA_READ = "artifact.replica.read" + ARTIFACT_RECEIPT_READ = "artifact.receipt.read" + ARTIFACT_VERIFICATION_JOB_READ = "artifact.verification_job.read" + ARTIFACT_VERIFICATION_JOB_RETRY = "artifact.verification_job.retry" + ARTIFACT_RECOVERY_ATTEMPT_READ = "artifact.recovery_attempt.read" + ARTIFACT_AUDIT_READ = "artifact.audit.read" + OPERATIONS_ARTIFACT_STORAGE_ADMISSION_READ = "operations.artifact_storage_admission.read" + ARTIFACT_GUIDE_SOURCE_INGEST = "artifact.guide_source.ingest" + ARTIFACT_GUIDE_SOURCE_READ = "artifact.guide_source.read" + ARTIFACT_UPLOAD_SESSION_CREATE = "artifact.upload_session.create" + ARTIFACT_UPLOAD_SESSION_READ = "artifact.upload_session.read" + ARTIFACT_UPLOAD_ITEM_WRITE = "artifact.upload_item.write" + ARTIFACT_UPLOAD_SESSION_SEAL = "artifact.upload_session.seal" + ARTIFACT_UPLOAD_SESSION_CANCEL = "artifact.upload_session.cancel" + ARTIFACT_UPLOAD_SESSION_EXPIRE = "artifact.upload_session.expire" + ARTIFACT_GUIDE_SOURCE_BINDING_CREATE = "artifact.guide_source.binding.create" + ARTIFACT_SUBMISSION_BINDING_CREATE = "artifact.submission.binding.create" + ARTIFACT_CHECKER_OUTPUT_BINDING_CREATE = "artifact.checker_output.binding.create" + ARTIFACT_VERIFICATION_EXECUTE = "artifact.verification.execute" + ARTIFACT_PENDING_WORK_SCAN = "artifact.pending_work.scan" + ARTIFACT_PUT_ATTEMPT_RESOLVE = "artifact.put_attempt.resolve" + ARTIFACT_PRE_SUBMIT_CHECKER_INPUT_MATERIALIZE = "artifact.pre_submit.checker_input.materialize" + ARTIFACT_POST_SUBMIT_CHECKER_INPUT_MATERIALIZE = ( + "artifact.post_submit.checker_input.materialize" + ) + ARTIFACT_CHECKER_OUTPUT_WRITE = "artifact.checker_output.write" + + +@unique +class ActionOwner(StrEnum): + """Closed implementation chunks allowed to activate reserved actions.""" + + AUTH_07B = "WS-AUTH-001-07B" + AUTH_13 = "WS-AUTH-001-13" + AUTH_14 = "WS-AUTH-001-14" + REV_05 = "WS-REV-001-05" + REV_06 = "WS-REV-001-06" + REV_07 = "WS-REV-001-07" + REV_08 = "WS-REV-001-08" + REV_09A = "WS-REV-001-09A" + REV_11 = "WS-REV-001-11" + REV_12 = "WS-REV-001-12" + ART_02D = "WS-ART-001-02D" + ART_03 = "WS-ART-001-03" + ART_04A = "WS-ART-001-04A" + ART_04B = "WS-ART-001-04B" + ART_05 = "WS-ART-001-05" + ART_06A = "WS-ART-001-06A" + ART_06B = "WS-ART-001-06B" + + +@unique +class ActionAvailability(StrEnum): + """Whether an owning feature has activated an action.""" + + PLANNED = "planned" + ACTIVE = "active" + + +@dataclass(frozen=True, slots=True) +class ActionDefinition: + """Bounded action metadata; feature guards remain owner-defined.""" + + action_id: ActionId + permission_id: PermissionId + owner: ActionOwner + availability: ActionAvailability + + +def _planned( + action_id: ActionId, + permission_id: PermissionId, + owner: ActionOwner, +) -> ActionDefinition: + return ActionDefinition(action_id, permission_id, owner, ActionAvailability.PLANNED) + + +ACTION_DEFINITIONS = ( + _planned( + ActionId.ACTOR_PROFILE_READ_SELF, PermissionId.ACTOR_PROFILE_READ_SELF, ActionOwner.AUTH_07B + ), + _planned( + ActionId.ACTOR_PROFILE_UPDATE_SELF, + PermissionId.ACTOR_PROFILE_UPDATE_SELF, + ActionOwner.AUTH_07B, + ), + _planned( + ActionId.OPERATIONS_TASK_START_OVERRIDE, + PermissionId.OPERATIONS_TASK_START_OVERRIDE, + ActionOwner.AUTH_13, + ), + _planned( + ActionId.OPERATIONS_SUBMISSION_GATE_REPAIR, + PermissionId.OPERATIONS_SUBMISSION_GATE_REPAIR, + ActionOwner.AUTH_14, + ), + _planned( + ActionId.OPERATIONS_CHECKER_RETRY, + PermissionId.OPERATIONS_CHECKER_RETRY, + ActionOwner.AUTH_14, + ), + _planned(ActionId.SUBMISSION_CREATE, PermissionId.SUBMISSION_CREATE, ActionOwner.AUTH_14), + _planned(ActionId.REVIEW_QUEUE_READ, PermissionId.REVIEW_QUEUE_READ, ActionOwner.REV_05), + _planned( + ActionId.REVIEW_QUEUE_INSPECT, + PermissionId.REVIEW_QUEUE_INSPECT, + ActionOwner.REV_05, + ), + _planned(ActionId.REVIEW_CLAIM, PermissionId.REVIEW_CLAIM, ActionOwner.REV_06), + _planned(ActionId.REVIEW_RELEASE, PermissionId.REVIEW_RELEASE, ActionOwner.REV_06), + _planned( + ActionId.REVIEW_DECLINE_PREFERENCE, + PermissionId.REVIEW_DECLINE_PREFERENCE, + ActionOwner.REV_06, + ), + _planned( + ActionId.REVIEW_PREFERENCE_EXPIRY_RUN, + PermissionId.OPERATIONS_TIMER_RUN, + ActionOwner.REV_06, + ), + _planned( + ActionId.REVIEW_LEASE_EXPIRY_RUN, + PermissionId.OPERATIONS_TIMER_RUN, + ActionOwner.REV_06, + ), + _planned( + ActionId.REVIEW_CONTEXT_READ, + PermissionId.SUBMISSION_READ_FOR_REVIEW, + ActionOwner.REV_07, + ), + _planned(ActionId.REVIEW_CHAIN_READ, PermissionId.REVIEW_CHAIN_READ, ActionOwner.REV_07), + _planned( + ActionId.REVIEW_FINDING_EVIDENCE_INGEST, + PermissionId.REVIEW_DECISION, + ActionOwner.REV_07, + ), + _planned(ActionId.REVIEW_DECISION, PermissionId.REVIEW_DECISION, ActionOwner.REV_08), + _planned( + ActionId.REVIEW_FINDING_RESPONSE_EVIDENCE_INGEST, + PermissionId.SUBMISSION_CREATE, + ActionOwner.REV_09A, + ), + _planned( + ActionId.REVIEW_LEASE_FORCE_RELEASE, + PermissionId.REVIEW_LEASE_FORCE_RELEASE, + ActionOwner.REV_11, + ), + _planned( + ActionId.REVIEW_QUEUE_ROUTING_OVERRIDE, + PermissionId.REVIEW_QUEUE_OVERRIDE, + ActionOwner.REV_11, + ), + _planned( + ActionId.REVIEW_QUEUE_ROUTING_CORRECT, + PermissionId.REVIEW_QUEUE_OVERRIDE, + ActionOwner.REV_11, + ), + _planned( + ActionId.REVIEW_QUEUE_CLOSE, + PermissionId.REVIEW_QUEUE_OVERRIDE, + ActionOwner.REV_11, + ), + _planned( + ActionId.REVIEW_RECONCILE_RUN, + PermissionId.OPERATIONS_RECONCILE_RUN, + ActionOwner.REV_11, + ), + _planned( + ActionId.REVIEW_ARTIFACT_REFERENCE_RECONCILE, + PermissionId.OPERATIONS_RECONCILE_RUN, + ActionOwner.REV_12, + ), + _planned( + ActionId.REVIEW_PROJECTION_REBUILD, + PermissionId.OPERATIONS_PROJECTION_REBUILD, + ActionOwner.REV_12, + ), + _planned( + ActionId.ARTIFACT_BINDING_READ, PermissionId.ARTIFACT_BINDING_READ, ActionOwner.ART_02D + ), + _planned( + ActionId.ARTIFACT_REPLICA_READ, PermissionId.ARTIFACT_REPLICA_READ, ActionOwner.ART_02D + ), + _planned( + ActionId.ARTIFACT_RECEIPT_READ, PermissionId.ARTIFACT_RECEIPT_READ, ActionOwner.ART_02D + ), + _planned( + ActionId.ARTIFACT_VERIFICATION_JOB_READ, + PermissionId.ARTIFACT_VERIFICATION_JOB_READ, + ActionOwner.ART_02D, + ), + _planned( + ActionId.ARTIFACT_VERIFICATION_JOB_RETRY, + PermissionId.ARTIFACT_VERIFICATION_JOB_RETRY, + ActionOwner.ART_02D, + ), + _planned( + ActionId.ARTIFACT_RECOVERY_ATTEMPT_READ, + PermissionId.ARTIFACT_RECOVERY_ATTEMPT_READ, + ActionOwner.ART_02D, + ), + _planned(ActionId.ARTIFACT_AUDIT_READ, PermissionId.ARTIFACT_AUDIT_READ, ActionOwner.ART_02D), + _planned( + ActionId.OPERATIONS_ARTIFACT_STORAGE_ADMISSION_READ, + PermissionId.OPERATIONS_STATUS_READ, + ActionOwner.ART_02D, + ), + _planned( + ActionId.ARTIFACT_GUIDE_SOURCE_INGEST, + PermissionId.ARTIFACT_GUIDE_SOURCE_INGEST, + ActionOwner.ART_03, + ), + _planned( + ActionId.ARTIFACT_GUIDE_SOURCE_READ, + PermissionId.ARTIFACT_GUIDE_SOURCE_READ, + ActionOwner.ART_03, + ), + _planned( + ActionId.ARTIFACT_UPLOAD_SESSION_CREATE, + PermissionId.ARTIFACT_UPLOAD_SESSION_CREATE, + ActionOwner.ART_04A, + ), + _planned( + ActionId.ARTIFACT_UPLOAD_SESSION_READ, + PermissionId.ARTIFACT_UPLOAD_SESSION_READ, + ActionOwner.ART_04A, + ), + _planned( + ActionId.ARTIFACT_UPLOAD_ITEM_WRITE, + PermissionId.ARTIFACT_UPLOAD_ITEM_WRITE, + ActionOwner.ART_04A, + ), + _planned( + ActionId.ARTIFACT_UPLOAD_SESSION_SEAL, + PermissionId.ARTIFACT_UPLOAD_SESSION_SEAL, + ActionOwner.ART_04A, + ), + _planned( + ActionId.ARTIFACT_UPLOAD_SESSION_CANCEL, + PermissionId.ARTIFACT_UPLOAD_SESSION_CANCEL, + ActionOwner.ART_04A, + ), + _planned( + ActionId.ARTIFACT_UPLOAD_SESSION_EXPIRE, + PermissionId.ARTIFACT_UPLOAD_SESSION_EXPIRE, + ActionOwner.ART_04A, + ), + _planned( + ActionId.ARTIFACT_GUIDE_SOURCE_BINDING_CREATE, + PermissionId.ARTIFACT_BINDING_CREATE, + ActionOwner.ART_03, + ), + _planned( + ActionId.ARTIFACT_SUBMISSION_BINDING_CREATE, + PermissionId.ARTIFACT_BINDING_CREATE, + ActionOwner.ART_05, + ), + _planned( + ActionId.ARTIFACT_CHECKER_OUTPUT_BINDING_CREATE, + PermissionId.ARTIFACT_BINDING_CREATE, + ActionOwner.ART_06B, + ), + _planned( + ActionId.ARTIFACT_VERIFICATION_EXECUTE, + PermissionId.ARTIFACT_VERIFICATION_EXECUTE, + ActionOwner.ART_02D, + ), + _planned( + ActionId.ARTIFACT_PENDING_WORK_SCAN, + PermissionId.ARTIFACT_PENDING_WORK_SCAN, + ActionOwner.ART_02D, + ), + _planned( + ActionId.ARTIFACT_PUT_ATTEMPT_RESOLVE, + PermissionId.ARTIFACT_PUT_ATTEMPT_RESOLVE, + ActionOwner.ART_02D, + ), + _planned( + ActionId.ARTIFACT_PRE_SUBMIT_CHECKER_INPUT_MATERIALIZE, + PermissionId.ARTIFACT_CHECKER_INPUT_MATERIALIZE, + ActionOwner.ART_04B, + ), + _planned( + ActionId.ARTIFACT_POST_SUBMIT_CHECKER_INPUT_MATERIALIZE, + PermissionId.ARTIFACT_CHECKER_INPUT_MATERIALIZE, + ActionOwner.ART_06A, + ), + _planned( + ActionId.ARTIFACT_CHECKER_OUTPUT_WRITE, + PermissionId.ARTIFACT_CHECKER_OUTPUT_WRITE, + ActionOwner.ART_06B, + ), +) + +PERMISSION_IDS = frozenset(PermissionId) +ACTION_IDS = frozenset(ActionId) +NEW_PERMISSION_IDS = frozenset( + { + PermissionId.OPERATIONS_TASK_START_OVERRIDE, + PermissionId.OPERATIONS_SUBMISSION_GATE_REPAIR, + PermissionId.OPERATIONS_CHECKER_RETRY, + PermissionId.REVIEW_QUEUE_OVERRIDE, + PermissionId.ARTIFACT_BINDING_READ, + PermissionId.ARTIFACT_REPLICA_READ, + PermissionId.ARTIFACT_RECEIPT_READ, + PermissionId.ARTIFACT_VERIFICATION_JOB_READ, + PermissionId.ARTIFACT_VERIFICATION_JOB_RETRY, + PermissionId.ARTIFACT_RECOVERY_ATTEMPT_READ, + PermissionId.ARTIFACT_AUDIT_READ, + PermissionId.ARTIFACT_GUIDE_SOURCE_INGEST, + PermissionId.ARTIFACT_UPLOAD_SESSION_CREATE, + PermissionId.ARTIFACT_UPLOAD_SESSION_READ, + PermissionId.ARTIFACT_UPLOAD_ITEM_WRITE, + PermissionId.ARTIFACT_UPLOAD_SESSION_SEAL, + PermissionId.ARTIFACT_UPLOAD_SESSION_CANCEL, + PermissionId.ARTIFACT_UPLOAD_SESSION_EXPIRE, + PermissionId.ARTIFACT_BINDING_CREATE, + PermissionId.ARTIFACT_VERIFICATION_EXECUTE, + PermissionId.ARTIFACT_PENDING_WORK_SCAN, + PermissionId.ARTIFACT_PUT_ATTEMPT_RESOLVE, + PermissionId.ARTIFACT_GUIDE_SOURCE_READ, + PermissionId.ARTIFACT_CHECKER_INPUT_MATERIALIZE, + PermissionId.ARTIFACT_CHECKER_OUTPUT_WRITE, + } +) +HISTORICAL_PERMISSION_IDS = PERMISSION_IDS - NEW_PERMISSION_IDS + + +def _index_actions( + definitions: tuple[ActionDefinition, ...], +) -> MappingProxyType[ActionId, ActionDefinition]: + if any( + not isinstance(definition, ActionDefinition) + or not isinstance(definition.action_id, ActionId) + or not isinstance(definition.permission_id, PermissionId) + or not isinstance(definition.owner, ActionOwner) + or not isinstance(definition.availability, ActionAvailability) + for definition in definitions + ): + raise RuntimeError("authorization action catalogue contains an invalid row") + indexed = {definition.action_id: definition for definition in definitions} + if len(PERMISSION_IDS) != 74 or len(ACTION_IDS) != 50: + raise RuntimeError("authorization catalogue count mismatch") + if len(indexed) != len(definitions) or set(indexed) != ACTION_IDS: + raise RuntimeError("authorization action catalogue is incomplete") + if len(HISTORICAL_PERMISSION_IDS) != 49 or len(NEW_PERMISSION_IDS) != 25: + raise RuntimeError("authorization permission boundary mismatch") + if any(definition.availability is not ActionAvailability.PLANNED for definition in definitions): + raise RuntimeError("AUTH-07A actions must remain planned") + if set(definitions) != set(ACTION_DEFINITIONS): + raise RuntimeError("authorization action metadata mismatch") + if {definition.owner for definition in definitions} != set(ActionOwner): + raise RuntimeError("authorization action owner catalogue is incomplete") + return MappingProxyType(indexed) + + +ACTION_BY_ID = _index_actions(ACTION_DEFINITIONS) + + +def resolve_executable_action(action_id: ActionId) -> ActionDefinition: + """Return active metadata and fail closed for planned actions.""" + definition = ACTION_BY_ID[action_id] + if definition.availability is not ActionAvailability.ACTIVE: + raise ValueError("authorization action is not active") + return definition diff --git a/backend/app/modules/tasks/models.py b/backend/app/modules/tasks/models.py index 64f137048..988da3ab4 100644 --- a/backend/app/modules/tasks/models.py +++ b/backend/app/modules/tasks/models.py @@ -486,6 +486,7 @@ class AuditEvent(Base): target_actor_ref: Mapped[str | None] = mapped_column(String(100)) matched_grant_id: Mapped[str | None] = mapped_column(String(100)) permission_id: Mapped[str | None] = mapped_column(String(120)) + action_id: Mapped[str | None] = mapped_column(String(160)) project_id: Mapped[str | None] = mapped_column(String(36)) resource_type: Mapped[str | None] = mapped_column(String(80)) resource_id: Mapped[str | None] = mapped_column(String(100)) diff --git a/backend/tests/test_alembic.py b/backend/tests/test_alembic.py index 04f0ca61b..c4035df23 100644 --- a/backend/tests/test_alembic.py +++ b/backend/tests/test_alembic.py @@ -17,6 +17,11 @@ from sqlalchemy.ext.asyncio import create_async_engine from app.adapters.auth.dev import actor_id_from_external_identity +from app.modules.authorization.catalogue import ( + ACTION_DEFINITIONS, + HISTORICAL_PERMISSION_IDS, + NEW_PERMISSION_IDS, +) from app.modules.actors.legacy_classification import ( CLASSIFICATION_FILE_ENV, LegacyActorClassification, @@ -426,7 +431,7 @@ def test_canonical_actor_downgrade_refuses_nonactive_authority_state( ): command.downgrade(config, "0019_authority_idempotency") assert asyncio.run(_current_revision(isolated_database_env)) == ( - "0020_canonical_actor_profile" + "0021_auth_action_evidence" ) asyncio.run( _reset_canonical_actor_guard_state( @@ -440,6 +445,143 @@ def test_canonical_actor_downgrade_refuses_nonactive_authority_state( command.downgrade(config, "base") +def test_authorization_action_evidence_constraints_and_guarded_downgrade( + isolated_database_env: str, + migration_lock, +) -> None: + """Prove exact action parity, rollback custody, and downgrade locking.""" + config = _alembic_config() + historical_event = str(uuid4()) + with migration_lock(): + try: + command.downgrade(config, "base") + command.upgrade(config, "0020_canonical_actor_profile") + asyncio.run(_insert_authority_audit_fixture(isolated_database_env, historical_event)) + historical_before = asyncio.run( + _authorization_action_row(isolated_database_env, historical_event) + ) + command.upgrade(config, "head") + schema = asyncio.run(_authorization_action_schema(isolated_database_env)) + historical_upgraded = asyncio.run( + _authorization_action_row(isolated_database_env, historical_event) + ) + asyncio.run(_assert_authorization_action_sql_pairs(isolated_database_env)) + + action_event = asyncio.run( + _insert_authorization_action_event(isolated_database_env) + ) + with pytest.raises( + RuntimeError, + match="^cannot downgrade non-empty authorization action evidence$", + ): + command.downgrade(config, "0020_canonical_actor_profile") + asyncio.run( + _remove_authorization_action_events( + isolated_database_env, [action_event] + ) + ) + + permission_event = asyncio.run( + _insert_authorization_action_event(isolated_database_env) + ) + asyncio.run( + _convert_to_permission_only_forward_evidence( + isolated_database_env, permission_event + ) + ) + with pytest.raises( + RuntimeError, + match="^cannot downgrade non-empty authorization action evidence$", + ): + command.downgrade(config, "0020_canonical_actor_profile") + asyncio.run( + _remove_authorization_action_events( + isolated_database_env, [permission_event] + ) + ) + + target_reference_event = asyncio.run( + _insert_forward_permission_reference( + isolated_database_env, + historical_event, + reference_field="target", + ) + ) + with pytest.raises( + RuntimeError, + match="^cannot downgrade non-empty authorization action evidence$", + ): + command.downgrade(config, "0020_canonical_actor_profile") + asyncio.run( + _remove_authorization_action_events( + isolated_database_env, [target_reference_event] + ) + ) + + invalidation_reference_event = asyncio.run( + _insert_forward_permission_reference( + isolated_database_env, + historical_event, + reference_field="invalidation", + ) + ) + with pytest.raises( + RuntimeError, + match="^cannot downgrade non-empty authorization action evidence$", + ): + command.downgrade(config, "0020_canonical_actor_profile") + asyncio.run( + _remove_authorization_action_events( + isolated_database_env, [invalidation_reference_event] + ) + ) + + command.downgrade(config, "0020_canonical_actor_profile") + downgraded = asyncio.run( + _authorization_action_schema(isolated_database_env) + ) + historical_downgraded = asyncio.run( + _authorization_action_row(isolated_database_env, historical_event) + ) + asyncio.run(_assert_historical_permission_registry(isolated_database_env)) + asyncio.run( + _remove_authorization_action_events( + isolated_database_env, [historical_event] + ) + ) + command.upgrade(config, "head") + + lock_observed, raced_event = _action_downgrade_waits_for_insert( + config, isolated_database_env + ) + asyncio.run( + _remove_authorization_action_events( + isolated_database_env, [raced_event] + ) + ) + command.downgrade(config, "0020_canonical_actor_profile") + command.upgrade(config, "head") + finally: + command.downgrade(config, "base") + + assert schema == { + "revision": "0021_auth_action_evidence", + "action_column": True, + "action_constraint": True, + } + assert downgraded == { + "revision": "0020_canonical_actor_profile", + "action_column": False, + "action_constraint": False, + } + assert historical_before == historical_upgraded == historical_downgraded == { + "event_type": "SensitiveAuthorizationAllowed", + "permission_id": "actor.profile.read_any", + "action_id": None, + } + assert lock_observed is True + + def test_artifact_foundation_upgrade_preserves_prior_head_and_promotes_nothing( isolated_database_env: str, migration_lock, @@ -3485,3 +3627,434 @@ async def _remove_authority_idempotency_fixture( ) finally: await engine.dispose() + + +_ACTION_EVIDENCE_INSERT = text( + "insert into audit_events " + "(id, entity_type, entity_id, event_type, actor_id, actor_roles, claim_snapshot, " + "auth_source, is_dev_auth, event_payload, event_domain, event_version, actor_ref_kind, " + "request_id, correlation_id, permission_id, action_id, reason, denial_code, after_facts) " + "values (:id, 'authorization_decision', :id, 'SensitiveAuthorizationDenied', " + "'workstream:system:bootstrap', '[]'::json, '{}'::json, 'local_authority', false, " + "'{}'::json, 'authority', 1, 'system_principal', :request_id, :correlation_id, " + ":permission_id, :action_id, 'authorization_evaluation', 'permission_not_granted', " + "'{\"allowed\": false}'::json)" +) + +_ALLOWED_ACTION_EVIDENCE_INSERT = text( + "insert into audit_events " + "(id, entity_type, entity_id, event_type, actor_id, actor_roles, claim_snapshot, " + "auth_source, is_dev_auth, event_payload, event_domain, event_version, actor_ref_kind, " + "request_id, correlation_id, permission_id, action_id, reason, after_facts) " + "values (:id, 'authorization_decision', :id, 'SensitiveAuthorizationAllowed', " + "'workstream:system:bootstrap', '[]'::json, '{}'::json, 'local_authority', false, " + "'{}'::json, 'authority', 1, 'system_principal', :request_id, :correlation_id, " + ":permission_id, :action_id, 'authorization_evaluation', " + "'{\"allowed\": true}'::json)" +) + + +def _action_evidence_values( + action_id: str | None, permission_id: str +) -> dict[str, str | None]: + event_id = str(uuid4()) + return { + "id": event_id, + "request_id": str(uuid4()), + "correlation_id": str(uuid4()), + "permission_id": permission_id, + "action_id": action_id, + } + + +async def _authorization_action_schema(database_url: str) -> dict[str, object]: + """Return the migration revision and action-evidence schema markers.""" + engine = create_async_engine(database_url) + try: + async with engine.connect() as connection: + return { + "revision": await connection.scalar( + text("select version_num from alembic_version") + ), + "action_column": bool( + await connection.scalar( + text( + "select exists(select 1 from information_schema.columns " + "where table_schema='public' and table_name='audit_events' " + "and column_name='action_id')" + ) + ) + ), + "action_constraint": bool( + await connection.scalar( + text( + "select exists(select 1 from pg_constraint where " + "conrelid='audit_events'::regclass and " + "conname='ck_audit_events_authorization_action_evidence')" + ) + ) + ), + } + finally: + await engine.dispose() + + +async def _authorization_action_row( + database_url: str, event_id: str +) -> dict[str, object]: + """Fetch stable action evidence across both sides of migration 0021.""" + engine = create_async_engine(database_url) + try: + async with engine.connect() as connection: + has_action = await connection.scalar( + text( + "select exists(select 1 from information_schema.columns " + "where table_schema='public' and table_name='audit_events' " + "and column_name='action_id')" + ) + ) + action_column = ", action_id" if has_action else "" + row = ( + ( + await connection.execute( + text( + "select event_type, permission_id" + f"{action_column} from audit_events where id=:id" + ), + {"id": event_id}, + ) + ) + .mappings() + .one() + ) + result = dict(row) + result.setdefault("action_id", None) + return result + finally: + await engine.dispose() + + +async def _assert_authorization_action_sql_pairs(database_url: str) -> None: + """Prove exact pair closure without freezing typed availability in SQL.""" + engine = create_async_engine(database_url) + try: + for definition in ACTION_DEFINITIONS: + async with engine.connect() as connection: + transaction = await connection.begin() + await connection.execute( + _ACTION_EVIDENCE_INSERT, + _action_evidence_values( + definition.action_id.value, definition.permission_id.value + ), + ) + await transaction.rollback() + + for definition in ACTION_DEFINITIONS: + async with engine.connect() as connection: + transaction = await connection.begin() + await connection.execute( + _ALLOWED_ACTION_EVIDENCE_INSERT, + _action_evidence_values( + definition.action_id.value, definition.permission_id.value + ), + ) + await transaction.rollback() + + unknown_action = _action_evidence_values("unknown.action", "actor.profile.read_self") + async with engine.connect() as connection: + transaction = await connection.begin() + with pytest.raises(IntegrityError): + await connection.execute(_ACTION_EVIDENCE_INSERT, unknown_action) + await transaction.rollback() + + for definition in ACTION_DEFINITIONS: + wrong_permission = _action_evidence_values( + definition.action_id.value, "actor.profile.read_any" + ) + async with engine.connect() as connection: + transaction = await connection.begin() + with pytest.raises(IntegrityError): + await connection.execute(_ACTION_EVIDENCE_INSERT, wrong_permission) + await transaction.rollback() + + for permission in NEW_PERMISSION_IDS: + missing_action = _action_evidence_values(None, permission.value) + async with engine.connect() as connection: + transaction = await connection.begin() + with pytest.raises(IntegrityError): + await connection.execute(_ACTION_EVIDENCE_INSERT, missing_action) + await transaction.rollback() + + nondecision = text( + "insert into audit_events " + "(id, entity_type, entity_id, event_type, actor_id, actor_roles, claim_snapshot, " + "auth_source, is_dev_auth, event_payload, event_domain, event_version, " + "actor_ref_kind, request_id, correlation_id, permission_id, action_id, reason, " + "denial_code) values (:id, 'admin_role_grant', :entity_id, " + "'AdminRoleGrantIssueDenied', 'workstream:system:bootstrap', '[]'::json, " + "'{}'::json, 'local_authority', false, '{}'::json, 'authority', 1, " + "'system_principal', :request_id, :correlation_id, 'actor.profile.read_self', " + "'actor.profile.read_self', 'authorization_policy_denial', " + "'permission_not_granted')" + ) + async with engine.connect() as connection: + transaction = await connection.begin() + with pytest.raises(IntegrityError): + await connection.execute( + nondecision, + { + "id": str(uuid4()), + "entity_id": str(uuid4()), + "request_id": str(uuid4()), + "correlation_id": str(uuid4()), + }, + ) + await transaction.rollback() + finally: + await engine.dispose() + + +async def _insert_authorization_action_event(database_url: str) -> str: + """Commit one valid planned-action denial fixture.""" + values = _action_evidence_values("artifact.binding.read", "artifact.binding.read") + engine = create_async_engine(database_url) + try: + async with engine.begin() as connection: + await connection.execute(_ACTION_EVIDENCE_INSERT, values) + return values["id"] + finally: + await engine.dispose() + + +async def _convert_to_permission_only_forward_evidence( + database_url: str, event_id: str +) -> None: + """Simulate a pre-guard forward row to exercise the second rollback predicate.""" + engine = create_async_engine(database_url) + try: + async with engine.begin() as connection: + await connection.execute(text("lock table audit_events in access exclusive mode")) + await connection.execute( + text( + "alter table audit_events disable trigger " + "audit_events_reject_update_delete" + ) + ) + await connection.execute( + text( + "alter table audit_events drop constraint " + "ck_audit_events_authorization_action_evidence" + ) + ) + await connection.execute( + text("update audit_events set action_id=null where id=:id"), + {"id": event_id}, + ) + await connection.execute( + text( + "alter table audit_events add constraint " + "ck_audit_events_authorization_action_evidence " + "check (action_id is null) not valid" + ) + ) + await connection.execute( + text( + "alter table audit_events enable trigger " + "audit_events_reject_update_delete" + ) + ) + finally: + await engine.dispose() + + +async def _insert_forward_permission_reference( + database_url: str, + cause_event_id: str, + *, + reference_field: str, +) -> str: + """Commit one new permission-registry reference without an action ID.""" + event_id = str(uuid4()) + values = { + "id": event_id, + "request_id": str(uuid4()), + "correlation_id": str(uuid4()), + "permission": "artifact.binding.read", + "cause_id": cause_event_id, + } + if reference_field == "target": + statement = text( + "insert into audit_events " + "(id, entity_type, entity_id, event_type, actor_id, actor_roles, " + "claim_snapshot, auth_source, is_dev_auth, event_payload, event_domain, " + "event_version, actor_ref_kind, request_id, correlation_id, permission_id, " + "target_ref_kind, target_ref_id, reason, after_facts) values " + "(:id, 'authorization_decision', :id, 'SensitiveAuthorizationAllowed', " + "'workstream:system:bootstrap', '[]'::json, '{}'::json, 'local_authority', " + "false, '{}'::json, 'authority', 1, 'system_principal', :request_id, " + ":correlation_id, 'actor.profile.read_any', 'permission_registry', " + ":permission, 'authorization_evaluation', '{\"allowed\": true}'::json)" + ) + elif reference_field == "invalidation": + statement = text( + "insert into audit_events " + "(id, entity_type, entity_id, event_type, actor_id, actor_roles, " + "claim_snapshot, auth_source, is_dev_auth, event_payload, event_domain, " + "event_version, actor_ref_kind, request_id, correlation_id, " + "invalidation_cause_event_id, invalidation_target_kind, " + "invalidation_target_ref, reason, before_facts, after_facts) values " + "(:id, 'authority_invalidation', :id, 'AuthorityInvalidationRequested', " + "'workstream:system:bootstrap', '[]'::json, '{}'::json, 'local_authority', " + "false, '{}'::json, 'authority', 1, 'system_principal', :request_id, " + ":correlation_id, :cause_id, 'permission_registry', :permission, " + "'authority_state_changed', '{\"effective\": true}'::json, " + "'{\"effective\": false}'::json)" + ) + else: + raise ValueError(f"unsupported reference field: {reference_field}") + + engine = create_async_engine(database_url) + try: + async with engine.begin() as connection: + if reference_field == "invalidation": + await connection.execute( + text( + "alter table audit_events disable trigger " + "audit_events_validate_idempotency" + ) + ) + await connection.execute(statement, values) + if reference_field == "invalidation": + await connection.execute( + text( + "alter table audit_events enable trigger " + "audit_events_validate_idempotency" + ) + ) + return event_id + finally: + await engine.dispose() + + +async def _assert_historical_permission_registry(database_url: str) -> None: + """Prove downgrade restores every historical permission and rejects every new one.""" + statement = text( + "insert into audit_events " + "(id, entity_type, entity_id, event_type, actor_id, actor_roles, claim_snapshot, " + "auth_source, is_dev_auth, event_payload, event_domain, event_version, " + "actor_ref_kind, request_id, correlation_id, permission_id, reason, after_facts) " + "values (:id, 'authorization_decision', :id, 'SensitiveAuthorizationAllowed', " + "'workstream:system:bootstrap', '[]'::json, '{}'::json, 'local_authority', false, " + "'{}'::json, 'authority', 1, 'system_principal', :request_id, :correlation_id, " + ":permission_id, 'authorization_evaluation', '{\"allowed\": true}'::json)" + ) + engine = create_async_engine(database_url) + try: + for permission in HISTORICAL_PERMISSION_IDS: + async with engine.connect() as connection: + transaction = await connection.begin() + await connection.execute( + statement, _action_evidence_values(None, permission.value) + ) + await transaction.rollback() + + for permission in NEW_PERMISSION_IDS: + async with engine.connect() as connection: + transaction = await connection.begin() + with pytest.raises(IntegrityError): + await connection.execute( + statement, _action_evidence_values(None, permission.value) + ) + await transaction.rollback() + finally: + await engine.dispose() + + +async def _remove_authorization_action_events( + database_url: str, event_ids: list[str] +) -> None: + """Owner-only cleanup for immutable action-evidence test fixtures.""" + engine = create_async_engine(database_url) + try: + async with engine.begin() as connection: + await connection.execute(text("lock table audit_events in access exclusive mode")) + await connection.execute( + text( + "alter table audit_events disable trigger " + "audit_events_reject_update_delete" + ) + ) + await connection.execute( + text("delete from audit_events where id = any(:ids)"), + {"ids": event_ids}, + ) + await connection.execute( + text( + "alter table audit_events enable trigger " + "audit_events_reject_update_delete" + ) + ) + finally: + await engine.dispose() + + +def _action_downgrade_waits_for_insert( + config: Config, database_url: str +) -> tuple[bool, str]: + """Prove an insert cannot pass between the downgrade check and destructive DDL.""" + writer_ready = threading.Event() + release_writer = threading.Event() + values = _action_evidence_values("artifact.binding.read", "artifact.binding.read") + + async def hold_uncommitted_insert() -> None: + engine = create_async_engine(database_url) + try: + async with engine.connect() as connection: + transaction = await connection.begin() + await connection.execute(_ACTION_EVIDENCE_INSERT, values) + writer_ready.set() + await asyncio.to_thread(release_writer.wait) + await transaction.commit() + finally: + await engine.dispose() + + async def observe_downgrade_lock() -> bool: + engine = create_async_engine(database_url) + try: + async with engine.connect() as connection: + for _ in range(5000): + waiting = await connection.scalar( + text( + "select exists(select 1 from pg_locks locks " + "join pg_class relation on relation.oid=locks.relation " + "where relation.relname='audit_events' " + "and locks.mode='AccessExclusiveLock' and not locks.granted)" + ) + ) + if waiting: + return True + await asyncio.sleep(0) + return False + finally: + await engine.dispose() + + with ThreadPoolExecutor(max_workers=2) as executor: + writer = executor.submit(asyncio.run, hold_uncommitted_insert()) + if not writer_ready.wait(timeout=5): + release_writer.set() + writer.result(timeout=5) + return False, values["id"] + downgrade = executor.submit( + command.downgrade, config, "0020_canonical_actor_profile" + ) + try: + observed = asyncio.run(observe_downgrade_lock()) + finally: + release_writer.set() + writer.result(timeout=10) + with pytest.raises( + RuntimeError, + match="^cannot downgrade non-empty authorization action evidence$", + ): + downgrade.result(timeout=10) + return observed, values["id"] diff --git a/backend/tests/test_audit.py b/backend/tests/test_audit.py index caa738c29..cba757314 100644 --- a/backend/tests/test_audit.py +++ b/backend/tests/test_audit.py @@ -23,6 +23,7 @@ AuthorityEventType, ) from app.modules.audit.service import AuditService +from app.modules.authorization.catalogue import ACTION_DEFINITIONS, ActionId, PermissionId from app.modules.tasks.models import AuditEvent @@ -134,6 +135,77 @@ def _authority_input(event_type: AuthorityEventType, **overrides) -> AuthorityAu return AuthorityAuditEventInput(**values) +def test_action_aware_audit_input_enforces_mapping_and_planned_availability() -> None: + denied = _authority_input( + AuthorityEventType.SENSITIVE_AUTHORIZATION_DENIED, + permission_id="artifact.binding.read", + action_id="artifact.binding.read", + denial_code="permission_not_granted", + ) + assert denied.action_id is ActionId.ARTIFACT_BINDING_READ + assert denied.permission_id is PermissionId.ARTIFACT_BINDING_READ + + with pytest.raises(ValidationError, match="action permission"): + _authority_input( + AuthorityEventType.SENSITIVE_AUTHORIZATION_DENIED, + permission_id="artifact.replica.read", + action_id="artifact.binding.read", + denial_code="permission_not_granted", + ) + with pytest.raises(ValidationError, match="new permission requires"): + _authority_input( + AuthorityEventType.SENSITIVE_AUTHORIZATION_DENIED, + permission_id="artifact.binding.read", + action_id=None, + denial_code="permission_not_granted", + ) + for definition in ACTION_DEFINITIONS: + with pytest.raises(ValidationError, match="planned action"): + _authority_input( + AuthorityEventType.SENSITIVE_AUTHORIZATION_ALLOWED, + permission_id=definition.permission_id, + action_id=definition.action_id, + ) + with pytest.raises(TypeError, match="invalid authority audit input"): + _authority_input( + AuthorityEventType.SENSITIVE_AUTHORIZATION_DENIED, + permission_id="artifact.binding.read", + action_id="unknown.action", + denial_code="permission_not_granted", + ) + event_id = uuid4() + with pytest.raises(ValidationError, match="action requires authorization decision"): + AuthorityAuditEventInput( + event_id=event_id, + event_type=AuthorityEventType.ADMIN_ROLE_GRANT_ISSUE_DENIED, + entity_type="admin_role_grant", + entity_id=str(uuid4()), + actor_ref_kind=ActorReferenceKind.SYSTEM_PRINCIPAL, + actor_ref="workstream:system:bootstrap", + request_id=uuid4(), + correlation_id=uuid4(), + permission_id="actor.profile.read_self", + action_id="actor.profile.read_self", + reason="authorization_policy_denial", + denial_code="permission_not_granted", + ) + + +async def test_planned_action_denial_persists_with_bounded_mapping(audit_factory) -> None: + value = _authority_input( + AuthorityEventType.SENSITIVE_AUTHORIZATION_DENIED, + permission_id="artifact.binding.read", + action_id="artifact.binding.read", + denial_code="permission_not_granted", + ) + async with audit_factory() as session: + stored = await AuditService(session).add_authority_event(value) + await session.commit() + + assert stored.action_id == "artifact.binding.read" + assert stored.permission_id == "artifact.binding.read" + + def _authority_event_matrix() -> list[dict]: """Return every closed event with one valid and one fact-invalid shape.""" project_id = str(uuid4()) @@ -555,6 +627,7 @@ def __repr__(self): ({"reason": "secret-bearer-value"}, secret), ({"entity_type": "secret-bearer-value"}, secret), ({"permission_id": [secret]}, secret), + ({"action_id": [secret]}, secret), ): candidate = safe | patch for constructor in constructors: diff --git a/backend/tests/test_authorization.py b/backend/tests/test_authorization.py index d162948ea..915ed2a85 100644 --- a/backend/tests/test_authorization.py +++ b/backend/tests/test_authorization.py @@ -15,6 +15,21 @@ from app.modules.audit.schemas import ActorReferenceKind, AuthorityAuditEventInput, AuthorityEventType from app.modules.audit.service import AuditService +from app.modules.authorization.catalogue import ( + ACTION_BY_ID, + ACTION_DEFINITIONS, + ACTION_IDS, + HISTORICAL_PERMISSION_IDS, + NEW_PERMISSION_IDS, + PERMISSION_IDS, + ActionAvailability, + ActionDefinition, + ActionId, + ActionOwner, + PermissionId, + _index_actions, + resolve_executable_action, +) from app.modules.authorization.schemas import ( ActorIdentityLinkReactivateRequest, ActorIdentityLinkRevokeRequest, @@ -50,6 +65,211 @@ DIGEST = "sha256:" + "a" * 64 +def test_closed_permission_and_action_catalogue_is_exact_and_non_executable() -> None: + historical_permissions = frozenset( + """actor.profile.read_self actor.profile.update_self actor.profile.read_any + actor.profile.suspend actor.profile.reactivate actor.profile.deactivate + actor.identity_link.read actor.identity_link.revoke actor.identity_link.reactivate + actor.service.provision admin_role.read admin_role.grant admin_role.revoke + project.create project.read project.update project.archive project.guide.manage + project.effective_policy.manage project.task.manage project.review_policy.manage + project.role_grant.read project.role_grant.manage task.queue.read task.claim + submission.create submission.read_own submission.read_for_review review.queue.read + review.queue.inspect review.claim review.release review.decline_preference + review.decision review.lease.force_release review.chain.read contribution.read_self + contribution.read_project compensation.policy.manage + compensation.adapter_binding.manage compensation.award.read + compensation.delivery.reconcile operations.status.read operations.timer.run + operations.reconcile.run operations.outbox.retry operations.projection.rebuild + audit.read audit.export""".split() + ) + new_permissions = frozenset( + """operations.task.start_override operations.submission_gate.repair + operations.checker.retry artifact.binding.read artifact.replica.read + artifact.receipt.read artifact.verification_job.read + artifact.verification_job.retry artifact.recovery_attempt.read artifact.audit.read + artifact.guide_source.ingest artifact.upload_session.create + artifact.upload_session.read artifact.upload_item.write artifact.upload_session.seal + artifact.upload_session.cancel artifact.upload_session.expire artifact.binding.create + artifact.verification.execute artifact.pending_work.scan artifact.put_attempt.resolve + artifact.guide_source.read artifact.checker_input.materialize + artifact.checker_output.write review.queue.override""".split() + ) + expected = { + "actor.profile.read_self": ("actor.profile.read_self", "WS-AUTH-001-07B"), + "actor.profile.update_self": ("actor.profile.update_self", "WS-AUTH-001-07B"), + "operations.task.start_override": ("operations.task.start_override", "WS-AUTH-001-13"), + "operations.submission_gate.repair": ("operations.submission_gate.repair", "WS-AUTH-001-14"), + "operations.checker.retry": ("operations.checker.retry", "WS-AUTH-001-14"), + "submission.create": ("submission.create", "WS-AUTH-001-14"), + "review.queue.read": ("review.queue.read", "WS-REV-001-05"), + "review.queue.inspect": ("review.queue.inspect", "WS-REV-001-05"), + "review.claim": ("review.claim", "WS-REV-001-06"), + "review.release": ("review.release", "WS-REV-001-06"), + "review.decline_preference": ("review.decline_preference", "WS-REV-001-06"), + "review.preference_expiry.run": ("operations.timer.run", "WS-REV-001-06"), + "review.lease_expiry.run": ("operations.timer.run", "WS-REV-001-06"), + "review.context.read": ("submission.read_for_review", "WS-REV-001-07"), + "review.chain.read": ("review.chain.read", "WS-REV-001-07"), + "review.finding_evidence.ingest": ("review.decision", "WS-REV-001-07"), + "review.decision": ("review.decision", "WS-REV-001-08"), + "review.finding_response_evidence.ingest": ( + "submission.create", + "WS-REV-001-09A", + ), + "review.lease.force_release": ("review.lease.force_release", "WS-REV-001-11"), + "review.queue.routing.override": ("review.queue.override", "WS-REV-001-11"), + "review.queue.routing.correct": ("review.queue.override", "WS-REV-001-11"), + "review.queue.close": ("review.queue.override", "WS-REV-001-11"), + "review.reconcile.run": ("operations.reconcile.run", "WS-REV-001-11"), + "review.artifact_reference.reconcile": ( + "operations.reconcile.run", + "WS-REV-001-12", + ), + "review.projection.rebuild": ("operations.projection.rebuild", "WS-REV-001-12"), + "artifact.binding.read": ("artifact.binding.read", "WS-ART-001-02D"), + "artifact.replica.read": ("artifact.replica.read", "WS-ART-001-02D"), + "artifact.receipt.read": ("artifact.receipt.read", "WS-ART-001-02D"), + "artifact.verification_job.read": ("artifact.verification_job.read", "WS-ART-001-02D"), + "artifact.verification_job.retry": ("artifact.verification_job.retry", "WS-ART-001-02D"), + "artifact.recovery_attempt.read": ("artifact.recovery_attempt.read", "WS-ART-001-02D"), + "artifact.audit.read": ("artifact.audit.read", "WS-ART-001-02D"), + "operations.artifact_storage_admission.read": ("operations.status.read", "WS-ART-001-02D"), + "artifact.guide_source.ingest": ("artifact.guide_source.ingest", "WS-ART-001-03"), + "artifact.guide_source.read": ("artifact.guide_source.read", "WS-ART-001-03"), + "artifact.upload_session.create": ("artifact.upload_session.create", "WS-ART-001-04A"), + "artifact.upload_session.read": ("artifact.upload_session.read", "WS-ART-001-04A"), + "artifact.upload_item.write": ("artifact.upload_item.write", "WS-ART-001-04A"), + "artifact.upload_session.seal": ("artifact.upload_session.seal", "WS-ART-001-04A"), + "artifact.upload_session.cancel": ("artifact.upload_session.cancel", "WS-ART-001-04A"), + "artifact.upload_session.expire": ("artifact.upload_session.expire", "WS-ART-001-04A"), + "artifact.guide_source.binding.create": ("artifact.binding.create", "WS-ART-001-03"), + "artifact.submission.binding.create": ("artifact.binding.create", "WS-ART-001-05"), + "artifact.checker_output.binding.create": ("artifact.binding.create", "WS-ART-001-06B"), + "artifact.verification.execute": ("artifact.verification.execute", "WS-ART-001-02D"), + "artifact.pending_work.scan": ("artifact.pending_work.scan", "WS-ART-001-02D"), + "artifact.put_attempt.resolve": ("artifact.put_attempt.resolve", "WS-ART-001-02D"), + "artifact.pre_submit.checker_input.materialize": ( + "artifact.checker_input.materialize", + "WS-ART-001-04B", + ), + "artifact.post_submit.checker_input.materialize": ( + "artifact.checker_input.materialize", + "WS-ART-001-06A", + ), + "artifact.checker_output.write": ("artifact.checker_output.write", "WS-ART-001-06B"), + } + assert {item.value for item in HISTORICAL_PERMISSION_IDS} == historical_permissions + assert {item.value for item in NEW_PERMISSION_IDS} == new_permissions + assert {item.value for item in PERMISSION_IDS} == historical_permissions | new_permissions + assert len(ACTION_IDS) == len(ACTION_DEFINITIONS) == len(ACTION_BY_ID) == 50 + assert set(ACTION_BY_ID) == ACTION_IDS + assert {definition.owner for definition in ACTION_DEFINITIONS} == set(ActionOwner) + assert all( + definition.availability is ActionAvailability.PLANNED + for definition in ACTION_DEFINITIONS + ) + assert { + definition.action_id.value: ( + definition.permission_id.value, + definition.owner.value, + ) + for definition in ACTION_DEFINITIONS + } == expected + with pytest.raises(ValueError, match="not active"): + resolve_executable_action(ActionId.ACTOR_PROFILE_READ_SELF) + with pytest.raises(TypeError): + ACTION_BY_ID[ActionId.ACTOR_PROFILE_READ_SELF] = ACTION_DEFINITIONS[0] + + +@pytest.mark.parametrize( + "definitions, message", + [ + (ACTION_DEFINITIONS[:-1], "incomplete"), + (ACTION_DEFINITIONS[:-1] + (ACTION_DEFINITIONS[0],), "incomplete"), + (ACTION_DEFINITIONS + (ACTION_DEFINITIONS[0],), "incomplete"), + ( + ACTION_DEFINITIONS[:-1] + + ( + ActionDefinition( + ActionId.ARTIFACT_CHECKER_OUTPUT_WRITE, + PermissionId.ARTIFACT_CHECKER_OUTPUT_WRITE, + ActionOwner.ART_02D, + ActionAvailability.PLANNED, + ), + ), + "metadata mismatch", + ), + ( + ACTION_DEFINITIONS[:-1] + + ( + ActionDefinition( + ActionId.ARTIFACT_CHECKER_OUTPUT_WRITE, + PermissionId.ARTIFACT_CHECKER_OUTPUT_WRITE, + ActionOwner.ART_06B, + ActionAvailability.ACTIVE, + ), + ), + "must remain planned", + ), + ( + ACTION_DEFINITIONS[:-1] + + ( + ActionDefinition( + ActionId.ARTIFACT_CHECKER_OUTPUT_WRITE, + "unknown.permission", # type: ignore[arg-type] + ActionOwner.ART_06B, + ActionAvailability.PLANNED, + ), + ), + "invalid row", + ), + ( + ACTION_DEFINITIONS[:-1] + + ( + ActionDefinition( + "unknown.action", # type: ignore[arg-type] + PermissionId.ARTIFACT_CHECKER_OUTPUT_WRITE, + ActionOwner.ART_06B, + ActionAvailability.PLANNED, + ), + ), + "invalid row", + ), + ( + ACTION_DEFINITIONS[:-1] + + ( + ActionDefinition( + ActionId.ARTIFACT_CHECKER_OUTPUT_WRITE, + PermissionId.ARTIFACT_CHECKER_OUTPUT_WRITE, + "unknown.owner", # type: ignore[arg-type] + ActionAvailability.PLANNED, + ), + ), + "invalid row", + ), + ( + ACTION_DEFINITIONS[:-1] + + ( + ActionDefinition( + ActionId.ARTIFACT_CHECKER_OUTPUT_WRITE, + PermissionId.ARTIFACT_CHECKER_OUTPUT_WRITE, + ActionOwner.ART_06B, + "unknown.availability", # type: ignore[arg-type] + ), + ), + "invalid row", + ), + ], +) +def test_action_catalogue_construction_fails_closed( + definitions: tuple[ActionDefinition, ...], + message: str, +) -> None: + with pytest.raises(RuntimeError, match=message): + _index_actions(definitions) + + @pytest.fixture def authorization_database_env(postgres_database_url: str, migration_lock) -> str: """Ensure authorization tests run at the current isolated schema head.""" diff --git a/docs/decision_0012_workstream_authorization_service.md b/docs/decision_0012_workstream_authorization_service.md index 4b66b8e65..1ab5ccefc 100644 --- a/docs/decision_0012_workstream_authorization_service.md +++ b/docs/decision_0012_workstream_authorization_service.md @@ -45,7 +45,7 @@ external bearer token -> ActorResolver -> ActorProfile + ActorIdentityLink -> AuthorizationContext --> AuthorizationService.require(permission, ResourceContext, AsyncSession) +-> AuthorizationService.require(ActionId, typed ResourceContext) -> candidate grants -> resource and lifecycle guards -> allow or stable denial diff --git a/docs/operations_authorization_service.md b/docs/operations_authorization_service.md index f2fcc146f..09226f509 100644 --- a/docs/operations_authorization_service.md +++ b/docs/operations_authorization_service.md @@ -475,6 +475,49 @@ For each chunk: Do not cut over a resource family until its local actor, grant, permission, resource loader, lifecycle guards, negative tests, and evidence path exist. +### Catalogue And Action-Evidence Staging + +AUTH-07A installs exactly 74 PermissionIds and 50 planned ActionIds. Planned +entries contain only action, permission, owner, and availability; they are not +executable and must not receive deployment configuration, principals, resource +facts, or guards. Startup validation failure is a release blocker, not a reason +to skip or relax catalogue checks. + +Migration `0021` preserves historical audit rows with null `action_id`. Inspect +non-null action evidence only by bounded ActionId, request/correlation IDs, and +resource references; do not export event payloads or actor identity-link data +for routine diagnosis. Every action must carry its catalogue-mapped PermissionId, +and every permission added after migration `0018` must carry one of its mapped +actions. Planned actions can record bounded denial evidence but cannot record an +allowed decision through the typed writer. + +The historical permission set remains exactly 49 values. The post-`0020` set +contains exactly 25 values, including `review.queue.override`; do not derive +historical status from identifier prefixes. All submission/review rows remain +planned. Initial and revision submission share `submission.create`, and no +revision-specific permission or preparation action exists. + +Review code must consume the request-scoped public +`AuthorizationService.require(action_id, typed_resource_context)` boundary. +The service's bound caller-owned `AsyncSession` is the only transaction source; +the method accepts no session or `uow` argument. Review code must not query +grants, import AUTH persistence, select raw +PermissionIds, or implement permission unions. Artifact recovery remains the +ART-owned `artifact.verification_job.retry` action through +`ArtifactOperatorRecoveryPort`; shared outbox dispatch/retry remains outside +REV ownership. + +Downgrade is allowed only while every action ID remains null and no permission +outside migration `0018`'s historical 49-value set exists in the decision, +target-reference, or invalidation-reference fields. The migration takes an +exclusive audit-table lock before these checks and keeps it through destructive +DDL. If any forward evidence exists, stop and recover forward rather than +discarding it. + +AUTH-07B later activates only canonical actor self-read and self-update. Admin +definition reads wait for AUTH-08 grant truth, and project capability context +waits for AUTH-10 exact-project grants and canonical project composition. + ## Rollback Rollback stops rather than bypasses authorization. A deployment may roll back diff --git a/docs/spec_authorization_service.md b/docs/spec_authorization_service.md index 922f94637..3c576427f 100644 --- a/docs/spec_authorization_service.md +++ b/docs/spec_authorization_service.md @@ -152,6 +152,7 @@ review.decline_preference review.decision review.lease.force_release review.chain.read +review.queue.override contribution.read_self contribution.read_project @@ -199,24 +200,79 @@ audit.export Artifact permissions are deliberately resource- and operation-specific. `artifact.*.read` permissions do not authorize retry or recovery, human Operator permissions do not authorize internal execution, and internal service -permissions do not authorize Operator APIs. AUTH-07 owns this closed registry, -AUTH-08 owns the Operator grant definitions, AUTH-09 owns the service +permissions do not authorize Operator APIs. AUTH-07A owns this closed registry, +AUTH-07B introduces the central kernel, AUTH-08 owns the Operator grant +definitions, AUTH-09 owns the service principals, and WS-ART consumes the resulting decisions without registering permissions or inferring authority. Artifact actions activate only through the paired feature model below; AUTH-12, AUTH-14, and AUTH-15 do not activate or attach artifact actions on behalf of WS-ART. -These are 73 approved `PermissionId` values. `ActionId` values are a separate -closed registry layer and are not included in that permission count. AUTH-05A's current typed and -PostgreSQL audit registry accepts 49. The three approved Operator recovery -identifiers `operations.task.start_override`, -`operations.submission_gate.repair`, and `operations.checker.retry`, plus the -21 artifact identifiers above, are reserved planned metadata. AUTH-07 adds -their matching typed/SQL audit parity without making them executable. An -artifact action becomes active only when the owning WS-ART chunk supplies its -canonical resource composer, guards, surface declaration, behavior tests, and -transaction-local revalidation where required. Both halves are mandatory; -registry presence alone never grants authority. +These are 74 approved `PermissionId` values. `ActionId` values are a separate +closed registry layer and are not included in that permission count. AUTH-05A's +typed and PostgreSQL audit registry accepts the exact historical 49. The three +approved Operator recovery identifiers, 21 artifact identifiers, and +`review.queue.override` are the exact 25 post-`0020` permissions. AUTH-07A adds +their matching typed/SQL audit parity without making them executable. + +The closed action registry contains 50 planned rows: two actor-self actions, +three Operator recovery actions, 25 artifact actions, canonical +`submission.create`, and 19 review actions. An action becomes active only when +its owning chunk supplies its canonical resource composer, guards, surface or +command declaration, behavior tests, and transaction-local revalidation where +required. Both halves are mandatory; registry presence alone never grants +authority. + +AUTH-07A also reserves `actor.profile.read_self` and +`actor.profile.update_self` as four-field planned action metadata. AUTH-07B +later supplies their complete active definitions and self-route behavior proof +without changing migration `0021`. + +The submission/review dependency matrix is closed. AUTH-07A registers only the +four stable planned fields shown here; resource facts, candidates, guards, and +runtime activation remain with the listed owner. + +| ActionId | PermissionId | Owner | +|---|---|---| +| `submission.create` | `submission.create` | `WS-AUTH-001-14` | +| `review.queue.read` | `review.queue.read` | `WS-REV-001-05` | +| `review.queue.inspect` | `review.queue.inspect` | `WS-REV-001-05` | +| `review.claim` | `review.claim` | `WS-REV-001-06` | +| `review.release` | `review.release` | `WS-REV-001-06` | +| `review.decline_preference` | `review.decline_preference` | `WS-REV-001-06` | +| `review.preference_expiry.run` | `operations.timer.run` | `WS-REV-001-06` | +| `review.lease_expiry.run` | `operations.timer.run` | `WS-REV-001-06` | +| `review.context.read` | `submission.read_for_review` | `WS-REV-001-07` | +| `review.chain.read` | `review.chain.read` | `WS-REV-001-07` | +| `review.finding_evidence.ingest` | `review.decision` | `WS-REV-001-07` | +| `review.decision` | `review.decision` | `WS-REV-001-08` | +| `review.finding_response_evidence.ingest` | `submission.create` | `WS-REV-001-09A` | +| `review.lease.force_release` | `review.lease.force_release` | `WS-REV-001-11` | +| `review.queue.routing.override` | `review.queue.override` | `WS-REV-001-11` | +| `review.queue.routing.correct` | `review.queue.override` | `WS-REV-001-11` | +| `review.queue.close` | `review.queue.override` | `WS-REV-001-11` | +| `review.reconcile.run` | `operations.reconcile.run` | `WS-REV-001-11` | +| `review.artifact_reference.reconcile` | `operations.reconcile.run` | `WS-REV-001-12` | +| `review.projection.rebuild` | `operations.projection.rebuild` | `WS-REV-001-12` | + +Initial and revision submission use the same `submission.create` action, +permission, and route. Revision preparation is an internal participant and +lifecycle guard of that command; no `submission.revise` or revision-prepare +action exists. Finding and finding-response evidence intake are distinct +protected commands mapped to existing permissions. The only new permission is +`review.queue.override`. + +Artifact verification recovery remains the existing +`artifact.verification_job.retry` action through the ART-owned +`ArtifactOperatorRecoveryPort`; no `artifact_recovery.request` permission is +registered. Shared outbox dispatch/retry remains owned by the shared-outbox +subsystem and is not represented as a REV-owned projection action. + +Migration `0021` is availability-neutral. PostgreSQL enforces the closed +ActionId set, authorization-decision event shape, exact ActionId-to-PermissionId +mapping, and the requirement that every post-`0018` permission carry a mapped +action. Typed catalogue validation separately rejects allowed evidence until an +owning chunk changes an action from `planned` to `active`. The paired artifact activation matrix is closed: @@ -230,18 +286,19 @@ The paired artifact activation matrix is closed: | `WS-ART-001-06A` | `artifact.post_submit.checker_input.materialize` mapped to `artifact.checker_input.materialize` | | `WS-ART-001-06B` | `artifact.checker_output.write` and `artifact.checker_output.binding.create` mapped to `artifact.binding.create` using the checker-run resource | -Every row requires AUTH-07's registry first. A row with an Operator principal +Every row requires AUTH-07A's registry and AUTH-07B's kernel first. A row with an Operator principal also requires its AUTH-08 grant definition; a row with a fixed service principal also requires its AUTH-09 service-actor assignment. Feature code receives centralized decisions; it never queries grants or constructs permission identifiers dynamically. -The following table is the single source of truth for reserved artifact-related -`ActionId` metadata. AUTH-07 registers each row as `planned`; the owning WS-ART -chunk may activate it only with its canonical resource composer, guards, -surface declaration, and behavior tests. A mapping is not a permission alias: -authorization still evaluates the listed registered `PermissionId` against the -listed canonical resource and principal class. +The following table is the single source of truth for the owning WS-ART activation blueprint for +artifact-related `ActionId` values. AUTH-07A registers only each row's stable +`ActionId`, approved `PermissionId`, owning WS-ART chunk, and `planned` +availability. Its principal-class and canonical-resource columns are not AUTH +registry fields and are not executable authority; the owning WS-ART chunk must +adopt them with its canonical resource composer, guards, surface declaration, +and behavior tests before activation. A mapping is not a permission alias. | ActionId | PermissionId | Principal class | Canonical resource | Owning WS-ART chunk | |---|---|---|---|---| @@ -337,11 +394,12 @@ never serialize a human bearer token as executable authority. Collection actions authorize and filter against their canonical parent scope before counts, cursors, facets, or distinct values are computed. -Registration and completeness are staged with the approved chunk map. Chunk 07 -introduces the types and registry. Reserved action metadata contains only the +Registration and completeness are staged with the approved chunk map. Chunk +07A introduces the identifiers and planned registry; 07B introduces the kernel +and first active self-actions. Reserved action metadata contains only the stable `ActionId`, approved `PermissionId`, owning specification/chunk, and `planned` availability; it is not executable and does not predefine a -foreign-domain target, facts, or guards. Every route-owning chunk from 07 +foreign-domain target, facts, or guards. Every route-owning chunk from 07B through 15 may promote an action to active only when its owning domain contract, feature-owned resource composition, surface declaration, and behavior tests exist. Each such chunk generates a manifest-delta proof for every surface it @@ -375,9 +433,10 @@ List filtering occurs before counts and pagination cursors. `AuthorizationDecision` carries the stable `ActionId` in addition to permission, resource, scope, matched authority, and denial information. The action identifier is included in bounded logs/metrics and every action-based allowed or denied -authority event emitted by AUTH-07 or a later chunk. AUTH-07 adds nullable +authority event emitted by AUTH-07B or a later chunk. AUTH-07A adds nullable historical storage and exact typed/SQL registry parity; legacy rows remain null, -while new action-based decision events must contain a registered identifier. A +while new AUTH-07B-or-later action-based decision events must contain a +registered identifier. A new action identifier requires the same approved typed/PostgreSQL registry and migration treatment as a permission. @@ -481,6 +540,12 @@ POST /api/v1/projects/{project_id}/role-grants/{grant_id}/revoke Exact request/response/error contracts are introduced by their owning chunks. No route may accept role or scope from request JSON as canonical authority. +AUTH-07B cuts only existing `GET|PATCH /api/v1/actors/me` behavior over to the +kernel. Permission and admin-role definition reads begin in AUTH-08 after +bootstrap and administrative-grant truth exists. Project-scoped +`GET /api/v1/actors/me/authorization-context` begins in AUTH-10 after +exact-project grant and canonical project capability composition exists. + ## Migration And Compatibility The implementation order is fixed by the WS-AUTH-001 chunk map: