diff --git a/.agent-loop/CURRENT_STATE.md b/.agent-loop/CURRENT_STATE.md index a64237146..32310849c 100644 --- a/.agent-loop/CURRENT_STATE.md +++ b/.agent-loop/CURRENT_STATE.md @@ -28,7 +28,7 @@ authority; these records do not grant or withhold it. | Initiative | Durable state on `main` | Remaining boundary | |---|---|---| -| [WS-ARCH-001](initiatives/WS-ARCH-001-modular-monolith-boundaries/STATUS.md) | Boundary foundation and hidden submission capabilities are complete through `WS-ARCH-001-02F`, including the TASK-owned atomic Submission composition; no product route or AUTH action was activated | `WS-ARCH-001-02G` is the AUTH contributor-preparation activation boundary | +| [WS-ARCH-001](initiatives/WS-ARCH-001-modular-monolith-boundaries/STATUS.md) | Complete through `WS-ARCH-001-02G` on merge, including activation of only contributor bundle preparation | Begin the separately bounded `02H` consumption/binding activation | | [WS-ART-001](initiatives/WS-ART-001-immutable-artifact-storage/STATUS.md) | Active delivery initiative; verified ready-admission publication and hidden preparation are merged through `04C2` | Replace the non-executable 05-wave XINT/ART contracts with WS-ARCH-001 split public-API contracts before activation, Submission consumption, or live cutover | | [WS-AUTH-001](initiatives/WS-AUTH-001-workstream-authorization-service/STATUS.md) | Active delivery initiative; project-policy authority and unified compilation authorization are merged through `12I` | POL-03B consumes 12I next; remaining AUTH activation chunks wait for their exact hidden owner behavior | | [WS-CON-001](initiatives/WS-CON-001-contribution-compensation-boundary/STATUS.md) | Active delivery initiative; policy persistence and shared lifecycle audit are merged | Complete hidden services, contribution records, conditional awards, fulfillment, and reconciliation after named AUTH and REV gates | diff --git a/.agent-loop/initiatives/WS-ARCH-001-modular-monolith-boundaries/CHUNK_MAP.md b/.agent-loop/initiatives/WS-ARCH-001-modular-monolith-boundaries/CHUNK_MAP.md index a79f29206..711876be6 100644 --- a/.agent-loop/initiatives/WS-ARCH-001-modular-monolith-boundaries/CHUNK_MAP.md +++ b/.agent-loop/initiatives/WS-ARCH-001-modular-monolith-boundaries/CHUNK_MAP.md @@ -11,8 +11,8 @@ | `WS-ARCH-001-02D` | ART hidden preparation public API and private-edge migration | L1 | Complete; production remains deny-only | | `WS-ARCH-001-02E` | ART ready-admission consumption and binding hidden module-level capability | L1 | Complete; production remains deny-only and route-unreachable | | `WS-ARCH-001-02F` | TASK-owned immutable Submission command and hidden composed transaction | L1 | Complete; production remains deny-only and route-unreachable | -| `WS-ARCH-001-02G` | AUTH contributor preparation activation after the complete hidden path | L1 | Next durable boundary | -| `WS-ARCH-001-02H` | AUTH human/fixed-service consumption activation | L1 | Proposed after 02G | +| `WS-ARCH-001-02G` | AUTH contributor preparation activation after the complete hidden path | L1 | Complete on merge | +| `WS-ARCH-001-02H` | AUTH human/fixed-service consumption activation | L1 | Next after 02G merges | | `WS-ARCH-001-02I` | Admission-only public API/dispatch cutover and complete legacy removal | L1 | Deferred after 02H plus split 03/04/05 remediation, revision, checker-output and REV admission prerequisites | | `WS-ARCH-001-03` | PROJECT/TASK guide, locked-context, task and assignment capability repairs | L1 | Non-executable placeholder; requires a split contract | | `WS-ARCH-001-04` | ART/CHECKER materialization, run and result capability repairs | L1 | Non-executable placeholder; requires a split contract | diff --git a/.agent-loop/initiatives/WS-ARCH-001-modular-monolith-boundaries/STATUS.md b/.agent-loop/initiatives/WS-ARCH-001-modular-monolith-boundaries/STATUS.md index dea42a2dd..b068b0a11 100644 --- a/.agent-loop/initiatives/WS-ARCH-001-modular-monolith-boundaries/STATUS.md +++ b/.agent-loop/initiatives/WS-ARCH-001-modular-monolith-boundaries/STATUS.md @@ -27,10 +27,10 @@ serializes binding identity, persists the consumed Submission id/version, creates one provider-neutral generic binding, and proves replay, concurrency, rollback, and stable conflicts without activating a route or AUTH action. -- WS-ARCH-001-02F is complete in this change. TASK owns the immutable +- WS-ARCH-001-02F is complete. TASK owns the immutable admission-backed Submission command and the adapter owns one hidden root transaction; production remains deny-only and route-unreachable. -- Next durable boundary: WS-ARCH-001-02G only, AUTH contributor-preparation +- Complete on merge: WS-ARCH-001-02G AUTH contributor-preparation activation. Open pull requests show transient ownership. - Repository housekeeping after PR #315 found no competing clean-up initiative: WS-ARCH-001 remains the general boundary owner, WS-AUTH-003 owns diff --git a/.agent-loop/initiatives/WS-ARCH-001-modular-monolith-boundaries/chunks/WS-ARCH-001-02G-auth-preparation-activation.md b/.agent-loop/initiatives/WS-ARCH-001-modular-monolith-boundaries/chunks/WS-ARCH-001-02G-auth-preparation-activation.md index 77789ccec..37c4331f6 100644 --- a/.agent-loop/initiatives/WS-ARCH-001-modular-monolith-boundaries/chunks/WS-ARCH-001-02G-auth-preparation-activation.md +++ b/.agent-loop/initiatives/WS-ARCH-001-modular-monolith-boundaries/chunks/WS-ARCH-001-02G-auth-preparation-activation.md @@ -40,48 +40,102 @@ remained unavailable through those chunks. ```text backend/app/modules/authorization/api/** backend/app/modules/authorization/**/submission*.py +backend/app/modules/authorization/catalogue.py +backend/app/modules/authorization/kernel.py +backend/app/modules/authorization/prepared.py +backend/app/modules/authorization/runtime.py +backend/app/modules/authorization/repository.py +backend/app/modules/authorization/submission_preparation.py +backend/app/modules/authorization/artifact_project_authority.py +backend/app/modules/authorization/pre_submit_materialization.py backend/app/adapters/auth/** backend/app/adapters/artifacts/__init__.py -backend/alembic/versions/.py +backend/app/modules/artifacts/submission_authorization.py +backend/app/modules/artifacts/authorization.py +backend/app/modules/artifacts/submission_admission.py +backend/app/modules/artifacts/service.py backend/tests/test_authorization.py backend/tests/test_submission_bundle_admission.py +backend/tests/test_default_pre_submit_execution.py +backend/tests/submission_preparation_auth_helpers.py +backend/tests/test_submission_preparation_adapter.py +backend/tests/test_submission_preparation_authorization.py backend/tests/architecture/test_authorization_boundary.py +backend/scripts/behavior_ownership.py +backend/scripts/run_test_lanes.py .agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/ACTIVATION_CUSTODY.md .agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/CHUNK_MAP.md .agent-loop/initiatives/WS-XINT-002-art-auth-end-to-end/CHUNK_MAP.md +.agent-loop/initiatives/WS-XINT-002-art-auth-end-to-end/STATUS.md .agent-loop/initiatives/WS-ARCH-001-modular-monolith-boundaries/chunks/WS-ARCH-001-02G-auth-preparation-activation.md +.agent-loop/initiatives/WS-ARCH-001-modular-monolith-boundaries/CHUNK_MAP.md +.agent-loop/initiatives/WS-ARCH-001-modular-monolith-boundaries/STATUS.md +.agent-loop/CURRENT_STATE.md .agent-loop/initiatives/WS-ARCH-001-modular-monolith-boundaries/evidence/WS-ARCH-001-02G-preparation-activation.md +.agent-loop/initiatives/WS-AUTH-003-module-boundary-recovery/TEST_STRUCTURE_DEBT.json +.ci/behavior-ownership/partition.v1.json docs/spec_authorization_service.md +docs/architecture_data_model.md +docs/roadmap_status.md ``` ## Not allowed -New action/permission/service identity; Submission creation or binding action; +New action/permission/service identity; TASK `submission.create` command +activation or `artifact.submission.binding.create` activation; revision/remediation contexts; ART/TASK persistence; generic download; private -AUTH imports by product modules; migration number chosen before rebase. +AUTH imports by product modules; database migration or schema mutation. +Do not create a submission-local handle registry, evaluator, or second PREP +protocol; extend the existing kernel and `PreparedAuthorizationService` exact +binding path. ## Acceptance criteria -- [ ] Only `artifact.submission_bundle.prepare -> submission.create` becomes - available for an exact active assigned contributor. +- [ ] Only ActionId `artifact.submission_bundle.prepare`, mapped to existing + PermissionId `submission.create`, becomes available for an exact active + assigned contributor. This does not activate the TASK Submission command + or the later fixed binding action. - [ ] Preflight and final transaction-bound preparation use the merged opaque - handle/public fact manifest. + handle/public fact manifest and the existing kernel/prelocked/PREP path. +- [ ] Preliminary AUTH concealment runs before TASK disclosure; ART then locks + exact assignment/task/project facts through public owner ports and calls + the typed AUTH revalidation seam before reading the first request byte. + AUTH does not import TASK persistence. +- [ ] AUTH owns strict preliminary/final resource contexts. Final binding + covers actor and identity link; project, task, assignment and predecessor + id/version; passing evidence and prepared generation; guide, snapshot, + effective policy, checker policy and effective plan; semantic manifest; + archive digest/bytes/media type; storage scheme; operation identity; + idempotency/request digest; session/transaction; and nullable replay + intent exactly as recorded by the merged ART manifest. +- [ ] Registry custody remains the existing `WS-XINT-002-05A` row while this + replacement executable chunk flips only its availability; no alias, + duplicate owner, or second action path is introduced. - [ ] Live positive proof consumes the prepared handle before capacity, put intent, or provider I/O and produces one verified ready admission through the hidden route; exact replay produces no second write, charge, evidence - set, or admission. + set, admission, or provider I/O. The proof uses PostgreSQL and real AUTH + PREP composition rather than forged or monkeypatched handles. - [ ] Revoked identity/grant/assignment, stale context, wrong action/session/ transaction/resource, replay and copied handle deny before protected work. -- [ ] No fixed binding authority or Submission consumption becomes available. +- [ ] Existing fixed pre-submit materialization and internal storage-service + authorities remain unchanged; no fixed binding authority or TASK + Submission consumption becomes available. - [ ] AUTH and general boundary ledgers do not grow. +- [ ] New preparation behavior is factored into bounded production and test + modules; the frozen AUTH structural-debt ledger is regenerated only for + verified shrinkage, never to accept new or enlarged debt. ## Verification commands ```bash -(cd backend && .venv/bin/python -m ruff check app/modules/authorization app/adapters/auth tests/test_authorization.py) -(cd backend && export WORKSTREAM_TEST_DATABASE_URL="${WORKSTREAM_TEST_DATABASE_URL:?set WORKSTREAM_TEST_DATABASE_URL}" && .venv/bin/python -m pytest -q tests/test_authorization.py tests/test_submission_bundle_admission.py tests/architecture/test_authorization_boundary.py --cov=app.modules.authorization --cov-fail-under=90) +(cd backend && .venv/bin/python -m ruff check app/modules/authorization app/adapters/auth app/adapters/artifacts app/modules/artifacts/submission_authorization.py app/modules/artifacts/submission_admission.py app/modules/artifacts/authorization.py tests/test_authorization.py tests/test_submission_bundle_admission.py) +(cd backend && export WORKSTREAM_TEST_DATABASE_URL="${WORKSTREAM_TEST_DATABASE_URL:?set WORKSTREAM_TEST_DATABASE_URL}" && .venv/bin/python -m pytest -q tests/test_authorization.py tests/test_submission_bundle_admission.py tests/test_default_pre_submit_execution.py tests/architecture/test_authorization_boundary.py --cov=app.modules.authorization --cov-fail-under=90) python3 scripts/check_stale_authorization_docs.py +python3 scripts/check_stale_artifact_contracts.py +python3 scripts/check_chunk_state_sync.py --base-ref origin/main (cd backend && .venv/bin/python -m scripts.module_boundaries validate --protected-base origin/main) +(cd backend && .venv/bin/python -m pytest -q tests/architecture/test_module_boundaries.py tests/test_artifact_architecture.py --no-cov) python3 scripts/check_markdown_links.py git diff --check ``` @@ -100,3 +154,9 @@ authority was activated. Stop if 02A-02F are not merged, either resource manifest changed, or activation needs a new catalogue value or broader grant. + +## Merge state + +- Outcome on merge: `complete` +- `WS-ARCH-001-02G` becomes complete only after the exact-head hosted gates, + required internal reviewers, and human merge succeed. diff --git a/.agent-loop/initiatives/WS-ARCH-001-modular-monolith-boundaries/evidence/WS-ARCH-001-02G-preparation-activation.md b/.agent-loop/initiatives/WS-ARCH-001-modular-monolith-boundaries/evidence/WS-ARCH-001-02G-preparation-activation.md new file mode 100644 index 000000000..e52dfed8e --- /dev/null +++ b/.agent-loop/initiatives/WS-ARCH-001-modular-monolith-boundaries/evidence/WS-ARCH-001-02G-preparation-activation.md @@ -0,0 +1,30 @@ +# WS-ARCH-001-02G Evidence + +## Outcome + +Activates only `artifact.submission_bundle.prepare`, retaining historical +`WS-XINT-002-05A` catalogue custody. The active path uses the existing opaque +AUTH PREP service for project-scoped submitter revalidation before byte intake +and exact final-fact consumption before capacity, durable intent, or provider +I/O. TASK Submission creation and artifact binding remain unavailable. + +## Local evidence + +- Ruff passed for all changed AUTH, ART adapter, and focused test files. +- Focused submission admission and AUTH boundary tests passed: 100 tests. +- Focused real PREP full-fact, mismatch, and replay proof passed. +- Module boundary validation and architecture tests passed without growing the + AUTH private-import ledger. +- Stale AUTH/ART wording, atomic chunk state, Markdown links, and diff checks + passed. +- The PostgreSQL end-to-end test now composes the real contributor preparation + authority; GitHub Actions owns its execution and the full coverage gate. + +## Human review focus + +- Preliminary actor concealment and project-scoped submitter revalidation occur + before request bytes are read. +- Final preparation binds every durable-intent fact in the same root database + transaction and is consumed before capacity or provider work. +- No adjacent action, permission, route, TASK command, or fixed binding action + becomes active. diff --git a/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/ACTIVATION_CUSTODY.md b/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/ACTIVATION_CUSTODY.md index 959dc81d4..8d6812399 100644 --- a/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/ACTIVATION_CUSTODY.md +++ b/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/ACTIVATION_CUSTODY.md @@ -44,7 +44,7 @@ mappings, and availability must remain identical. | `WS-AUTH-001-ART-02D-OPERATOR` | Planned: `artifact.binding.read`, `artifact.replica.read`, `artifact.receipt.read`, `artifact.verification_job.read`, `artifact.verification_job.retry`, `artifact.recovery_attempt.read`, `artifact.audit.read`, `operations.artifact_storage_admission.read` | | `WS-XINT-002-04B` | Active: `artifact.guide_source.read`, `artifact.guide_source.binding.create` | | `WS-XINT-002-04A` | Active: `artifact.guide_source.ingest` | -| `WS-XINT-002-05A` | Planned: `artifact.submission_bundle.prepare`; registry custody is retained while replacement implementation chunk WS-ARCH-001-02G activates after 02A-02F | +| `WS-XINT-002-05A` | Active: `artifact.submission_bundle.prepare`; registry custody remains historical while replacement implementation chunk WS-ARCH-001-02G supplies the executable PREP boundary | | `WS-XINT-002-06A` | Active: `artifact.pre_submit.checker_input.materialize` | | `WS-AUTH-001-ART-05` | Planned: `artifact.submission.binding.create`; registry custody is retained while replacement implementation chunk WS-ARCH-001-02H performs activation | | `WS-XINT-002-06B` | Planned: `artifact.post_submit.checker_input.materialize`, `artifact.checker_output.write`, `artifact.checker_output.binding.create` | @@ -60,18 +60,16 @@ reconciles the live catalogue by removing the six unused multi-step upload rows and registering three end-to-end bundle/review rows. The resulting 22 rows have exact action cardinalities `3/8/2/1/1/1/1/3/1/1` in the table order above. The `OPERATOR` suffix denotes only future activation custody; it grants no Operator -entitlement. Sixteen actions remain planned after the three ART foundation -service actions, `artifact.guide_source.ingest`, and the two fixed-service -guide binding/read actions activate. The independently +entitlement. Fifteen actions remain planned after the three ART foundation +service actions, `artifact.guide_source.ingest`, the two fixed-service guide +binding/read actions, and `artifact.submission_bundle.prepare` activate. The independently gated `artifact.verification_job.retry` remains planned and cannot be activated by read/status proof. The historical transfer added no migration because owner and availability are typed metadata. WS-XINT-002-01 -reconciles PostgreSQL parity through migration `0036`. The post-12E-merge -expected catalogue has 71 PermissionIds, 100 ActionIds, 48 active actions, and -52 planned actions, with fourteen fixed-service identities and twenty-two -matrix memberships; until 12E is human-merged, trusted `main` retains its prior -45 active and 55 planned counts. +reconciles PostgreSQL parity through migration `0036`. The current catalogue +has 73 PermissionIds, 102 ActionIds, 55 active actions, and 47 planned actions, +with fourteen fixed-service identities and twenty-two matrix memberships. ## REV custody transfer diff --git a/.agent-loop/initiatives/WS-AUTH-003-module-boundary-recovery/TEST_STRUCTURE_DEBT.json b/.agent-loop/initiatives/WS-AUTH-003-module-boundary-recovery/TEST_STRUCTURE_DEBT.json index be14dfaa6..e399c4ca7 100644 --- a/.agent-loop/initiatives/WS-AUTH-003-module-boundary-recovery/TEST_STRUCTURE_DEBT.json +++ b/.agent-loop/initiatives/WS-AUTH-003-module-boundary-recovery/TEST_STRUCTURE_DEBT.json @@ -2,11 +2,11 @@ "entries": [ { "capability": "unassigned_legacy_auth", - "content_sha256": "2a5e5c4a07996ca7e0a5be65197324602b1a6783440f3e143832aa6a9353659f", - "end_line": 1600, + "content_sha256": "e708ad45f832e909e52d63daed72ed8a75ca9b9ebc336af3ca9049254a3aec8f", + "end_line": 1587, "hard_limit": 1200, "kind": "production_file", - "observed_lines": 1600, + "observed_lines": 1587, "path": "backend/app/modules/authorization/kernel.py", "qualified_symbol": null, "removal_chunk": "WS-AUTH-003-CLOSE", @@ -26,11 +26,11 @@ }, { "capability": "unassigned_legacy_auth", - "content_sha256": "bf5ea8baf23e45ab71cd6a87d7fc48b3b8321b26fd616b2841d90fd90a18b1cc", - "end_line": 1694, + "content_sha256": "e7c45706df1b1a958512e82d718a3254fdb39f1a27437c3eb49689154c6a8f4e", + "end_line": 1692, "hard_limit": 1200, "kind": "production_file", - "observed_lines": 1694, + "observed_lines": 1692, "path": "backend/app/modules/authorization/runtime.py", "qualified_symbol": null, "removal_chunk": "WS-AUTH-003-CLOSE", @@ -39,62 +39,62 @@ { "capability": "unassigned_legacy_auth", "content_sha256": "17251cc63f24b1cd34969fc192848459d4800ab2877e85d9244e468b4c82dfb7", - "end_line": 1113, + "end_line": 1114, "hard_limit": 100, "kind": "production_function", "observed_lines": 126, "path": "backend/app/modules/authorization/catalogue.py", "qualified_symbol": "_index_service_actions", "removal_chunk": "WS-AUTH-003-CLOSE", - "start_line": 988 + "start_line": 989 }, { "capability": "unassigned_legacy_auth", - "content_sha256": "a7acb9f31de3a2b2e32c9e8419d1ea8546610f86dcd05f021c87ed340a134590", - "end_line": 604, + "content_sha256": "656952eb7af29cb9086f6c35a80bbfd5456c613d98647d37ad54c7a784fab1f2", + "end_line": 597, "hard_limit": 100, "kind": "production_function", - "observed_lines": 217, + "observed_lines": 205, "path": "backend/app/modules/authorization/kernel.py", "qualified_symbol": "AuthorizationService._prepare_prelocked", "removal_chunk": "WS-AUTH-003-CLOSE", - "start_line": 388 + "start_line": 393 }, { "capability": "unassigned_legacy_auth", - "content_sha256": "152e3da2ea2843ecfe2c0cfd30a5ba38fe87e4bc03308e1b3ee02351dda4b7c7", - "end_line": 1115, + "content_sha256": "dab30f827a5971d03bf2cd59024930e8ece15fef21c67a04586b65addd534139", + "end_line": 1102, "hard_limit": 100, "kind": "production_function", - "observed_lines": 175, + "observed_lines": 169, "path": "backend/app/modules/authorization/kernel.py", "qualified_symbol": "AuthorizationService._require_prelocked", "removal_chunk": "WS-AUTH-003-CLOSE", - "start_line": 941 + "start_line": 934 }, { "capability": "unassigned_legacy_auth", "content_sha256": "8cb806aad12f5010d1e8875c4ba31eee43856a120e338d31a02d87e1ea30ad0c", - "end_line": 1600, + "end_line": 1587, "hard_limit": 100, "kind": "production_function", "observed_lines": 125, "path": "backend/app/modules/authorization/kernel.py", "qualified_symbol": "AuthorizationService._stage_decision", "removal_chunk": "WS-AUTH-003-CLOSE", - "start_line": 1476 + "start_line": 1463 }, { "capability": "unassigned_legacy_auth", - "content_sha256": "cb697c0c6e970a22d59a7a7c93a8be3fdc7a79933c1e47f29466db64ae8e038c", - "end_line": 802, + "content_sha256": "bd049d790d8e3baa1529967fad4b11c60537942958b6cb6755730db13982b7cf", + "end_line": 842, "hard_limit": 100, "kind": "production_function", - "observed_lines": 284, + "observed_lines": 281, "path": "backend/app/modules/authorization/prepared.py", "qualified_symbol": "PreparedAuthorizationService._binding", "removal_chunk": "WS-AUTH-003-CLOSE", - "start_line": 519 + "start_line": 562 }, { "capability": "unassigned_legacy_auth", @@ -194,11 +194,11 @@ }, { "capability": "unassigned_legacy_auth", - "content_sha256": "7717de94f55d517c03dbf8ca674cf4803f33d4d4412c5ea9b1fde3e3b57d199c", - "end_line": 1806, + "content_sha256": "d10520f1cfc34bc31b7dd5e00d66bb7705b8eedc004cae1a2b606486e98bc22c", + "end_line": 1805, "hard_limit": 1200, "kind": "test_file", - "observed_lines": 1806, + "observed_lines": 1805, "path": "backend/tests/test_audit.py", "qualified_symbol": null, "removal_chunk": "WS-AUTH-003-CLOSE", @@ -218,11 +218,11 @@ }, { "capability": "unassigned_legacy_auth", - "content_sha256": "ebb3b0ede78653a373c17904bc29d40da7ed93e96e0416e0742079386ca7c7f5", - "end_line": 13421, + "content_sha256": "8c3c604f433fc0816f6c3c096a8e32ebc056268cee70f89bd99d7c46be6a4503", + "end_line": 13417, "hard_limit": 1200, "kind": "test_file", - "observed_lines": 13421, + "observed_lines": 13417, "path": "backend/tests/test_authorization.py", "qualified_symbol": null, "removal_chunk": "WS-AUTH-003-CLOSE", @@ -230,11 +230,11 @@ }, { "capability": "unassigned_legacy_auth", - "content_sha256": "ce68513010bfa3af071aa5b73289a1d793a2c20593e7099726787b03e058b5a7", - "end_line": 1720, + "content_sha256": "3482eae7d037ca1953dd5324e57cc16a42effad54e0c30eceb6d93e7f79f3167", + "end_line": 1713, "hard_limit": 1200, "kind": "test_file", - "observed_lines": 1720, + "observed_lines": 1713, "path": "backend/tests/test_default_pre_submit_execution.py", "qualified_symbol": null, "removal_chunk": "WS-AUTH-003-CLOSE", @@ -386,11 +386,11 @@ }, { "capability": "unassigned_legacy_auth", - "content_sha256": "02ed6da571dafee7f7b6f0051fb17620ad3058f92c4e283dc5cde24d098d4c31", - "end_line": 255, + "content_sha256": "03e9a4232e9f0cd24a5c106557f51abae9a9ab0eb4771519cdfd72f53b14c9d5", + "end_line": 254, "hard_limit": 120, "kind": "test_function", - "observed_lines": 131, + "observed_lines": 130, "path": "backend/tests/test_audit.py", "qualified_symbol": "test_action_aware_audit_input_enforces_mapping_and_action_availability", "removal_chunk": "WS-AUTH-003-CLOSE", @@ -399,26 +399,26 @@ { "capability": "unassigned_legacy_auth", "content_sha256": "982f7c01fbc085454707e4b4491b9e79289af6203b7c2718635732d61ba8c9f1", - "end_line": 755, + "end_line": 754, "hard_limit": 120, "kind": "test_function", "observed_lines": 189, "path": "backend/tests/test_audit.py", "qualified_symbol": "test_authority_input_rejects_unbounded_or_inconsistent_evidence", "removal_chunk": "WS-AUTH-003-CLOSE", - "start_line": 567 + "start_line": 566 }, { "capability": "unassigned_legacy_auth", "content_sha256": "36820d7d6c9db7dac729710d8d87ab233d4882febd049e862961449550b52e8e", - "end_line": 1316, + "end_line": 1315, "hard_limit": 120, "kind": "test_function", "observed_lines": 210, "path": "backend/tests/test_audit.py", "qualified_symbol": "test_database_rejects_malformed_and_mutated_audit_rows", "removal_chunk": "WS-AUTH-003-CLOSE", - "start_line": 1107 + "start_line": 1106 }, { "capability": "unassigned_legacy_auth", @@ -531,182 +531,182 @@ { "capability": "unassigned_legacy_auth", "content_sha256": "951b44cc07e36002118fe93b7974e8d65851b0e2c3cec89a3031cbe42b014e2d", - "end_line": 8521, + "end_line": 8517, "hard_limit": 120, "kind": "test_function", "observed_lines": 140, "path": "backend/tests/test_authorization.py", "qualified_symbol": "test_actor_lifecycle_service_applies_success_and_guards_conflicts", "removal_chunk": "WS-AUTH-003-CLOSE", - "start_line": 8382 + "start_line": 8378 }, { "capability": "unassigned_legacy_auth", "content_sha256": "569084d6de89ff9eae71d526fc6c157aea7638f55ca93ad128b721fbda339be6", - "end_line": 9011, + "end_line": 9007, "hard_limit": 120, "kind": "test_function", "observed_lines": 122, "path": "backend/tests/test_authorization.py", "qualified_symbol": "test_admin_resource_digest_alone_rejects_substituted_role_and_disposition", "removal_chunk": "WS-AUTH-003-CLOSE", - "start_line": 8890 + "start_line": 8886 }, { "capability": "unassigned_legacy_auth", "content_sha256": "90b8b670b4d277209617cc1aa794188b4fb3cd9d894b69d5dc3d0adfc885f6ed", - "end_line": 9239, + "end_line": 9235, "hard_limit": 120, "kind": "test_function", "observed_lines": 132, "path": "backend/tests/test_authorization.py", "qualified_symbol": "test_admin_revoke_stages_complete_state_and_evidence", "removal_chunk": "WS-AUTH-003-CLOSE", - "start_line": 9108 + "start_line": 9104 }, { "capability": "unassigned_legacy_auth", "content_sha256": "78d9bf3df08e5633e9a75720b1e4bf5b7d7b24019bc392b4cb7496a9ac5e5e8e", - "end_line": 11073, + "end_line": 11069, "hard_limit": 120, "kind": "test_function", "observed_lines": 122, "path": "backend/tests/test_authorization.py", "qualified_symbol": "test_authorization_locks_refresh_cached_actor_lifecycle_state", "removal_chunk": "WS-AUTH-003-CLOSE", - "start_line": 10952 + "start_line": 10948 }, { "capability": "unassigned_legacy_auth", - "content_sha256": "aab80cce1a72714c4ec2e95b0596031e59a463459c69a4079627070f79edf349", - "end_line": 2252, + "content_sha256": "e4c5f03e4cbde880efc7a8bcda444e686946f169c87eca4b19ec8ca308940cde", + "end_line": 2248, "hard_limit": 120, "kind": "test_function", - "observed_lines": 327, + "observed_lines": 324, "path": "backend/tests/test_authorization.py", "qualified_symbol": "test_closed_permission_and_action_catalogue_is_exact_and_non_executable", "removal_chunk": "WS-AUTH-003-CLOSE", - "start_line": 1926 + "start_line": 1925 }, { "capability": "unassigned_legacy_auth", "content_sha256": "df1df6ec2ba6aa55445e21cfcf4e4e3ad9664c9504313484a495b6a6df1e9047", - "end_line": 3481, + "end_line": 3477, "hard_limit": 120, "kind": "test_function", "observed_lines": 126, "path": "backend/tests/test_authorization.py", "qualified_symbol": "test_identity_link_lifecycle_route_preserves_outcome_transaction_contract", "removal_chunk": "WS-AUTH-003-CLOSE", - "start_line": 3356 + "start_line": 3352 }, { "capability": "unassigned_legacy_auth", "content_sha256": "05d1b020ecff0f9bc0a0567adc07f5b31a2f9dfb7828ae3ad34d4e1e7757797c", - "end_line": 8674, + "end_line": 8670, "hard_limit": 120, "kind": "test_function", "observed_lines": 151, "path": "backend/tests/test_authorization.py", "qualified_symbol": "test_identity_link_lifecycle_service_applies_success_and_guards_conflicts", "removal_chunk": "WS-AUTH-003-CLOSE", - "start_line": 8524 + "start_line": 8520 }, { "capability": "unassigned_legacy_auth", "content_sha256": "c5d6d0d480ced964354915614f15202c0159bfbb281658da0d44186dffd17848", - "end_line": 7469, + "end_line": 7465, "hard_limit": 120, "kind": "test_function", "observed_lines": 142, "path": "backend/tests/test_authorization.py", "qualified_symbol": "test_prepared_actor_authority_crossed_mutations_complete_in_both_orders", "removal_chunk": "WS-AUTH-003-CLOSE", - "start_line": 7328 + "start_line": 7324 }, { "capability": "unassigned_legacy_auth", "content_sha256": "ebb76e63671195aa4d806ac602bfe58cb22bf82d00c8a70ab186785f3acd7f9b", - "end_line": 7810, + "end_line": 7806, "hard_limit": 120, "kind": "test_function", "observed_lines": 336, "path": "backend/tests/test_authorization.py", "qualified_symbol": "test_prepared_crosses_real_lifecycle_service_transactions", "removal_chunk": "WS-AUTH-003-CLOSE", - "start_line": 7475 + "start_line": 7471 }, { "capability": "unassigned_legacy_auth", "content_sha256": "9ea4fc0ddbab4c7262a43bc3f498ee1afea3318e9a0b6c93c87763f9f22aae9c", - "end_line": 7300, + "end_line": 7296, "hard_limit": 120, "kind": "test_function", "observed_lines": 518, "path": "backend/tests/test_authorization.py", "qualified_symbol": "test_prepared_postgresql_failure_and_cancellation_are_atomic", "removal_chunk": "WS-AUTH-003-CLOSE", - "start_line": 6783 + "start_line": 6779 }, { "capability": "unassigned_legacy_auth", "content_sha256": "46e0b031394da6fee856e48615732a17ff8d2276d9cedfb0ecaa3a6879410adb", - "end_line": 4686, + "end_line": 4682, "hard_limit": 120, "kind": "test_function", "observed_lines": 157, "path": "backend/tests/test_authorization.py", "qualified_symbol": "test_project_11c2_reads_require_exact_admin_context_and_role_allowlist", "removal_chunk": "WS-AUTH-003-CLOSE", - "start_line": 4530 + "start_line": 4526 }, { "capability": "unassigned_legacy_auth", "content_sha256": "e95d2a03e3829df89c43210a9de92efca31a25905c9648c3179d17b078386b17", - "end_line": 2653, + "end_line": 2649, "hard_limit": 120, "kind": "test_function", "observed_lines": 397, "path": "backend/tests/test_authorization.py", "qualified_symbol": "test_project_mutation_resources_and_prepared_scopes_are_closed", "removal_chunk": "WS-AUTH-003-CLOSE", - "start_line": 2257 + "start_line": 2253 }, { "capability": "unassigned_legacy_auth", "content_sha256": "105667302ed6e8f2fd16ea7e95d642e72e41514673e1152503536e0520f9c362", - "end_line": 10948, + "end_line": 10944, "hard_limit": 120, "kind": "test_function", "observed_lines": 204, "path": "backend/tests/test_authorization.py", "qualified_symbol": "test_project_read_permissions_have_postgresql_role_scope_matrix", "removal_chunk": "WS-AUTH-003-CLOSE", - "start_line": 10745 + "start_line": 10741 }, { "capability": "unassigned_legacy_auth", "content_sha256": "756b7f99f9a743284934b4d85ce263617710d9b8119792ccb4526a1de04070a1", - "end_line": 12450, + "end_line": 12446, "hard_limit": 120, "kind": "test_function", "observed_lines": 233, "path": "backend/tests/test_authorization.py", "qualified_symbol": "test_project_role_and_all_operation_mappings_commit_one_linked_pair", "removal_chunk": "WS-AUTH-003-CLOSE", - "start_line": 12218 + "start_line": 12214 }, { "capability": "unassigned_legacy_auth", "content_sha256": "2e1d7db74ddb955b90a0ee12e4fb72b651a0f85d2746c76e09079c05b0b85252", - "end_line": 13421, + "end_line": 13417, "hard_limit": 120, "kind": "test_function", "observed_lines": 681, "path": "backend/tests/test_authorization.py", "qualified_symbol": "test_project_role_issue_postgresql_prep_binds_target_role_and_scope", "removal_chunk": "WS-AUTH-003-CLOSE", - "start_line": 12741 + "start_line": 12737 }, { "capability": "unassigned_legacy_auth", @@ -735,26 +735,26 @@ { "capability": "unassigned_legacy_auth", "content_sha256": "05621e885ed2f88d0ba1a072c1f263fc923939f7ce755a514e9872112aa830a1", - "end_line": 11967, + "end_line": 11963, "hard_limit": 120, "kind": "test_function", "observed_lines": 163, "path": "backend/tests/test_authorization.py", "qualified_symbol": "test_service_actor_replay_fails_closed_on_committed_state_drift", "removal_chunk": "WS-AUTH-003-CLOSE", - "start_line": 11805 + "start_line": 11801 }, { "capability": "unassigned_legacy_auth", - "content_sha256": "be162594febdd56816d1a6a947b3d120ca35538f3fae5864ff0cac7a168493c3", - "end_line": 1134, + "content_sha256": "b74ba975652c973dab113217666628da52ec815b418204de2e679e38917d0e94", + "end_line": 1127, "hard_limit": 120, "kind": "test_function", - "observed_lines": 699, + "observed_lines": 698, "path": "backend/tests/test_default_pre_submit_execution.py", "qualified_symbol": "test_effective_evidence_workflow_persists_once_and_replays_exactly", "removal_chunk": "WS-AUTH-003-CLOSE", - "start_line": 436 + "start_line": 430 }, { "capability": "unassigned_legacy_auth", @@ -1011,26 +1011,26 @@ { "capability": "unassigned_legacy_auth", "content_sha256": "efca18e30f3e8e9d7b16310a956b427262dc92bdf13165146b58523b25ea2687", - "end_line": 521, + "end_line": 520, "hard_limit": 100, "kind": "test_helper", "observed_lines": 228, "path": "backend/tests/test_audit.py", "qualified_symbol": "_authority_event_matrix", "removal_chunk": "WS-AUTH-003-CLOSE", - "start_line": 294 + "start_line": 293 }, { "capability": "unassigned_legacy_auth", "content_sha256": "1a0a9f3e2be6965e29f76aa74272ccfa2fe4b99e4e0c3bde5ec8ba2c374b369b", - "end_line": 11281, + "end_line": 11277, "hard_limit": 100, "kind": "test_helper", "observed_lines": 169, "path": "backend/tests/test_authorization.py", "qualified_symbol": "_operation_success", "removal_chunk": "WS-AUTH-003-CLOSE", - "start_line": 11113 + "start_line": 11109 }, { "capability": "unassigned_legacy_auth", diff --git a/.agent-loop/initiatives/WS-XINT-002-art-auth-end-to-end/STATUS.md b/.agent-loop/initiatives/WS-XINT-002-art-auth-end-to-end/STATUS.md index c85bed5a1..cb46951d3 100644 --- a/.agent-loop/initiatives/WS-XINT-002-art-auth-end-to-end/STATUS.md +++ b/.agent-loop/initiatives/WS-XINT-002-art-auth-end-to-end/STATUS.md @@ -31,7 +31,8 @@ merge. 04A is merged and active. 04B production implementation merged in PR #245 at `6babf81b`; guide read and binding are active for their fixed services. -Chunk 06 is split: merged 06A activates pre-submit materialization before -05A, and 06B later activates post-submit materialization plus checker output +Chunk 06 is split: merged 06A activates pre-submit materialization before the +replacement WS-ARCH-001-02G contributor-preparation activation; historical +05A is superseded. 06B later activates post-submit materialization plus checker output write/binding. Reviewer packet activation is independent; review-evidence binding remains planned without approved reviewer-upload intent. diff --git a/.ci/behavior-ownership/partition.v1.json b/.ci/behavior-ownership/partition.v1.json index 0f1a1f6cf..03c8ae7e4 100644 --- a/.ci/behavior-ownership/partition.v1.json +++ b/.ci/behavior-ownership/partition.v1.json @@ -356,6 +356,10 @@ "group": "auth", "target": "backend/app/modules/authorization/api/project_guide_compilation.py" }, + { + "group": "auth", + "target": "backend/app/modules/authorization/artifact_project_authority.py" + }, { "group": "auth", "target": "backend/app/modules/authorization/catalogue.py" @@ -408,6 +412,10 @@ "group": "auth", "target": "backend/app/modules/authorization/policy.py" }, + { + "group": "auth", + "target": "backend/app/modules/authorization/pre_submit_materialization.py" + }, { "group": "auth", "target": "backend/app/modules/authorization/prepared.py" @@ -456,6 +464,10 @@ "group": "auth", "target": "backend/app/modules/authorization/service_actor_service.py" }, + { + "group": "auth", + "target": "backend/app/modules/authorization/submission_preparation.py" + }, { "group": "artifacts", "target": "backend/app/modules/checkers/api/pre_submit.py" @@ -825,7 +837,7 @@ "target": "backend/scripts/week2_api_e2e.py" } ], - "authority_digest": "6e8c9f70a0917f12e28205d7056404a29e1a0611992b7d87b59c54f96e2b5ea4", + "authority_digest": "d01214349f276a98624b34236b0b163c82cb68bc8426c0137e4aa71c6471c024", "protected_base_commit": "7676ce4347db0c9694962a9b587a20765e16eac6", "schema": "workstream.behavior-ownership-partition.v1" } diff --git a/backend/app/adapters/artifacts/__init__.py b/backend/app/adapters/artifacts/__init__.py index 1de501e0b..f1cf9108d 100644 --- a/backend/app/adapters/artifacts/__init__.py +++ b/backend/app/adapters/artifacts/__init__.py @@ -41,10 +41,13 @@ from app.modules.artifacts.authorization import ( GuideArtifactPreparedAuthorization, PreparedArtifactInternalAuthority, + get_artifact_authorization_context, get_guide_artifact_prepared_authorization, ) from app.modules.actors.service_identities import ServiceIdentity from app.modules.authorization.api import ActorIdentityFacts + + async def get_submission_bundle_preparation_actor( actor: Annotated[ ActorIdentityFacts, @@ -222,13 +225,19 @@ async def runtime(): return PreparedGuideArtifactIngestCommand(service, authority) -def get_submission_bundle_preparation_authorization(): - """Keep contributor preparation fail closed until XINT-05A activation.""" - from app.modules.artifacts.submission_authorization import ( - DenySubmissionBundlePreparationAuthorization, +def get_submission_bundle_preparation_authorization( + session: Annotated[AsyncSession, Depends(get_db_session)], + context: Annotated[ + object, + Depends(get_artifact_authorization_context), + ], +): + """Compose the active contributor PREP adapter from canonical AUTH context.""" + from app.modules.artifacts.authorization import ( + PreparedSubmissionBundlePreparationAuthorization, ) - return DenySubmissionBundlePreparationAuthorization() + return PreparedSubmissionBundlePreparationAuthorization(session, context) def get_submission_bundle_preparation_command( diff --git a/backend/app/modules/artifacts/authorization.py b/backend/app/modules/artifacts/authorization.py index 7061a7021..c8c824963 100644 --- a/backend/app/modules/artifacts/authorization.py +++ b/backend/app/modules/artifacts/authorization.py @@ -4,7 +4,7 @@ from contextlib import AbstractAsyncContextManager, asynccontextmanager from dataclasses import asdict -from typing import Annotated, Protocol +from typing import Annotated, NoReturn, Protocol from uuid import UUID from fastapi import Depends, Request @@ -32,7 +32,7 @@ PreSubmitMaterializationAuthorityFacts, PreSubmitMaterializationPreparationFacts, ) -from app.modules.authorization.catalogue import ActionId +from app.modules.authorization.catalogue import ACTION_BY_ID, ActionAvailability, ActionId from app.modules.authorization.kernel import AuthorizationService from app.modules.authorization.prepared import ( PreparedAuthorizationHandle, @@ -60,9 +60,196 @@ PreparedAuthorizationUnsupported, ServiceAuthorizationContext, AuthorizationContext, + SubmissionBundlePreparationPreflightResourceContext, + SubmissionBundlePreparationResourceContext, ) +class PreparedSubmissionBundlePreparationAuthorization: + """Translate ART facts into AUTH's existing request-local PREP protocol.""" + + def __init__(self, session: AsyncSession, context: AuthorizationContext) -> None: + """Compose one request-local ART-to-AUTH preparation adapter.""" + self._session = session + self._context = context + self._repository = AdminAuthorizationRepository(session) + self._authorization = AuthorizationService( + session, context, admin_repository=self._repository + ) + self._prepared = PreparedAuthorizationService( + session, context, self._authorization, self._repository + ) + self._input: PreparedAuthorizationInput | None = None + + @staticmethod + def _deny(exc: BaseException | None = None) -> NoReturn: + """Conceal every internal authorization failure from ART callers.""" + denial = ArtifactAuthorityDeniedError("submission bundle preparation is unavailable") + if exc is None: + raise denial + raise denial from exc + + @staticmethod + def _request_input(request, project_id: UUID) -> PreparedAuthorizationInput: + """Build immutable preliminary selectors from the ART request.""" + return PreparedAuthorizationInput( + idempotency_key=request.idempotency_key, + request_value={ + "scope_project_id": str(project_id), + "actor_profile_id": str(request.actor.actor_profile_id), + "identity_link_id": str(request.actor.identity_link_id), + "task_id": str(request.task_id), + "assignment_id": str(request.assignment_id), + "predecessor_submission_id": ( + str(request.predecessor_submission_id) + if request.predecessor_submission_id is not None + else None + ), + }, + ) + + def _final_input(self, facts) -> PreparedAuthorizationInput: + """Build exact final request facts for transaction-bound PREP.""" + request_value = self._final_values(facts, json=True) + return PreparedAuthorizationInput( + idempotency_key=self._input.idempotency_key, + request_value=request_value, + ) + + @staticmethod + def _final_values(facts, *, json: bool) -> dict: + values = asdict(facts) + project_id = values.pop("project_id") + values["scope_project_id"] = project_id + if json: + return { + key: str(value) if isinstance(value, UUID) else value + for key, value in values.items() + } + return values + + async def preflight(self, *, request) -> None: + action = ACTION_BY_ID[ActionId.ARTIFACT_SUBMISSION_BUNDLE_PREPARE] + invalid = ( + not isinstance(self._context, HumanAuthorizationContext) + or action.availability is not ActionAvailability.ACTIVE + or self._context.actor_status.value != "active" + or self._context.identity_link_status.value != "active" + or request.actor.actor_profile_id != self._context.actor_profile_id + or request.actor.identity_link_id != self._context.identity_link_id + ) + if invalid: + self._deny() + async with self._session.begin(): + locked = await self._repository.lock_request_actor( + self._context.identity_link_id, self._context.actor_profile_id + ) + if locked is None: + self._deny() + link, profile = locked + if ( + link.id != str(self._context.identity_link_id) + or link.actor_profile_id != str(self._context.actor_profile_id) + or link.status != "active" + or profile.id != str(self._context.actor_profile_id) + or profile.actor_kind != "human" + or profile.status != "active" + ): + self._deny() + + async def revalidate(self, *, request, project_id: UUID | None = None) -> None: + if project_id is None: + if self._input is None: + self._deny() + project_id = UUID(str(self._input.request_value["scope_project_id"])) + self._input = self._request_input(request, project_id) + resource = SubmissionBundlePreparationPreflightResourceContext( + resource_type="submission_bundle_preparation_preflight", + resource_id=request.assignment_id, + scope_project_id=project_id, + actor_profile_id=request.actor.actor_profile_id, + identity_link_id=request.actor.identity_link_id, + task_id=request.task_id, + assignment_id=request.assignment_id, + predecessor_submission_id=request.predecessor_submission_id, + ) + try: + await self._prepared.preflight( + ActionId.ARTIFACT_SUBMISSION_BUNDLE_PREPARE, + self._input, + PreparedAuthorityScope( + kind=PreparedAuthorityScopeKind.PROJECT, + project_id=project_id, + ), + resource, + ) + except (PreparedAuthorizationHandleInvalid, PreparedAuthorizationUnsupported) as exc: + self._deny(exc) + + @asynccontextmanager + async def transaction(self): + async with self._session.begin(): + yield + + async def prepare_final(self, *, facts): + if self._input is None: + self._deny() + final_input = self._final_input(facts) + expected = self._input.request_value + actual = final_input.request_value + for field in ( + "scope_project_id", + "actor_profile_id", + "identity_link_id", + "task_id", + "assignment_id", + "predecessor_submission_id", + ): + if actual[field] != expected[field]: + self._deny() + if final_input.idempotency_key != self._input.idempotency_key: + self._deny() + try: + handle = await self._prepared.prepare( + ActionId.ARTIFACT_SUBMISSION_BUNDLE_PREPARE, + final_input, + PreparedAuthorityScope( + kind=PreparedAuthorityScopeKind.PROJECT, + project_id=facts.project_id, + ), + ) + except (PreparedAuthorizationHandleInvalid, PreparedAuthorizationUnsupported) as exc: + self._deny(exc) + self._input = final_input + return handle + + async def consume(self, *, prepared_authorization: object, facts) -> None: + if self._input is None or type(prepared_authorization) is not PreparedAuthorizationHandle: + self._deny() + try: + resource = SubmissionBundlePreparationResourceContext( + resource_type="submission_bundle_preparation", + resource_id=facts.prepared_generation_id, + **self._final_values(facts, json=False), + ) + await self._prepared.consume( + prepared_authorization, + ActionId.ARTIFACT_SUBMISSION_BUNDLE_PREPARE, + self._input, + resource, + ) + except ( + AuthorizationDenied, + PreparedAuthorizationHandleInvalid, + PreparedAuthorizationUnsupported, + ValidationError, + ) as exc: + self._deny(exc) + + def close(self) -> None: + self._prepared.close() + + async def get_artifact_authorization_context( request: Request, resolved: Annotated[ResolvedActor, Depends(get_authorization_actor)], diff --git a/backend/app/modules/artifacts/service.py b/backend/app/modules/artifacts/service.py index b69aad87a..bed52ab2e 100644 --- a/backend/app/modules/artifacts/service.py +++ b/backend/app/modules/artifacts/service.py @@ -1885,7 +1885,7 @@ async def admit( if type(request) is SubmissionBundleArtifactAdmissionRequest: if ( submission_prepared_authorization is None - or type(prepared_authorization) is not PreparedAuthorizationHandle + or prepared_authorization is not None ): raise ArtifactAuthorityDeniedError( "submission bundle durable preparation is unavailable" @@ -1912,6 +1912,9 @@ async def admit( raise ArtifactAdmissionRelationshipError( "submission bundle pass capability evidence changed" ) + prepared_authorization = await submission_prepared_authorization.prepare_final( + facts=authority_facts + ) await submission_prepared_authorization.consume( prepared_authorization=prepared_authorization, facts=authority_facts, diff --git a/backend/app/modules/artifacts/submission_admission.py b/backend/app/modules/artifacts/submission_admission.py index 23291a64a..fab2b629b 100644 --- a/backend/app/modules/artifacts/submission_admission.py +++ b/backend/app/modules/artifacts/submission_admission.py @@ -481,6 +481,10 @@ async def prepare( runtime.catalogue, ) predecessor = await self._load_predecessor(task_context) + await self._authority.revalidate( + request=request, + project_id=task_context.locked_project_context.project_id, + ) prepared = await runtime.preparation.prepare( request.byte_source, media_type="application/zip", @@ -544,10 +548,9 @@ async def prepare( evidence.evidence.evidence_set_id ) async with self._authority.transaction(): - final_handle = await self._authority.prepare_final(request=request) retained, _, durable = await runtime.durable_put.admit_in_transaction( SubmissionBundleDurablePutRequest( - prepared_authorization=final_handle, + prepared_authorization=None, prepared_artifact=prepared, pass_capability=evidence.pass_capability, replay_durable_intent_id=replay_intent_id, diff --git a/backend/app/modules/artifacts/submission_authorization.py b/backend/app/modules/artifacts/submission_authorization.py index 51c967b97..8c108713e 100644 --- a/backend/app/modules/artifacts/submission_authorization.py +++ b/backend/app/modules/artifacts/submission_authorization.py @@ -4,6 +4,8 @@ from contextlib import AbstractAsyncContextManager from typing import Protocol +from uuid import UUID + from app.modules.artifacts.api import SubmissionBundlePreparationRequest from app.modules.artifacts.schemas import ( ArtifactAuthorityDeniedError, @@ -35,6 +37,7 @@ async def revalidate( self, *, request: SubmissionBundlePreparationRequest, + project_id: UUID | None = None, ) -> None: ... def transaction(self) -> AbstractAsyncContextManager[object]: ... @@ -42,7 +45,7 @@ def transaction(self) -> AbstractAsyncContextManager[object]: ... async def prepare_final( self, *, - request: SubmissionBundlePreparationRequest, + facts: SubmissionBundleDurableIntentAuthorityFacts, ) -> object: ... def close(self) -> None: ... @@ -51,6 +54,14 @@ def close(self) -> None: ... class DenySubmissionBundlePreparedAuthorization: """Keep contributor durable preparation unavailable until XINT-05A.""" + async def prepare_final( + self, + *, + facts: SubmissionBundleDurableIntentAuthorityFacts, + ) -> object: + del facts + raise ArtifactAuthorityDeniedError("submission bundle durable preparation is unavailable") + async def consume( self, *, diff --git a/backend/app/modules/authorization/artifact_project_authority.py b/backend/app/modules/authorization/artifact_project_authority.py new file mode 100644 index 000000000..82ed42a47 --- /dev/null +++ b/backend/app/modules/authorization/artifact_project_authority.py @@ -0,0 +1,108 @@ +"""Narrow kernel rules for human project-scoped ART actions.""" + +from __future__ import annotations + +from app.modules.authorization.catalogue import ActionAvailability +from app.modules.authorization.runtime import ( + AuthorizationDenialCode, + HumanAuthorizationContext, + MatchedAuthorityKind, + PreparedAuthorizationUnsupported, + PreparedAuthorityScopeKind, + SubmissionBundlePreparationResourceContext, +) + + +async def lock_guide_ingest_authority(repository, context, scope, permission_id, locked_context): + """Lock the exact human identity and project-scoped guide-ingest grant.""" + if ( + not isinstance(context, HumanAuthorizationContext) + or scope.kind is not PreparedAuthorityScopeKind.PROJECT + or scope.project_id is None + ): + raise PreparedAuthorizationUnsupported(AuthorizationDenialCode.SCOPE_NOT_AUTHORIZED) + locked = await repository.lock_request_actor(context.identity_link_id, context.actor_profile_id) + context = locked_context(locked, context) + grant = await repository.find_effective_grant( + context.actor_profile_id, + permission_id, + scope_project_id=scope.project_id, + for_update=True, + ) + if grant is None: + raise PreparedAuthorizationUnsupported(AuthorizationDenialCode.PERMISSION_NOT_GRANTED) + return context, grant + + +def evaluate_guide_ingest_authority(action, authority, resource, lifecycle_denial): + """Evaluate guide-ingest facts against the locked project authority.""" + from app.modules.authorization.runtime import GuideSourceIngestResourceContext + + denial = lifecycle_denial + if denial is None and action.availability is not ActionAvailability.ACTIVE: + denial = AuthorizationDenialCode.ACTION_UNAVAILABLE + elif denial is None and not isinstance(resource, GuideSourceIngestResourceContext): + denial = AuthorizationDenialCode.RESOURCE_GUARD_DENIED + elif denial is None and resource.scope_project_id != authority.scope_project_id: + denial = AuthorizationDenialCode.SCOPE_NOT_AUTHORIZED + elif denial is None and ( + authority.matched_grant_id is None or authority.matched_grant_status != "active" + ): + denial = AuthorizationDenialCode.PERMISSION_NOT_GRANTED + if denial is not None: + return denial, None, None, None + return ( + None, + MatchedAuthorityKind.ADMIN_ROLE_GRANT, + authority.matched_grant_id, + authority.scope_project_id, + ) + + +async def lock_submitter_authority(repository, context, scope, locked_context): + """Lock the exact human identity and active submitter grant.""" + if ( + not isinstance(context, HumanAuthorizationContext) + or scope.kind is not PreparedAuthorityScopeKind.PROJECT + or scope.project_id is None + ): + raise PreparedAuthorizationUnsupported(AuthorizationDenialCode.SCOPE_NOT_AUTHORIZED) + locked = await repository.lock_request_actor(context.identity_link_id, context.actor_profile_id) + context = locked_context(locked, context) + grant = await repository.find_active_project_role( + project_id=scope.project_id, + actor_profile_id=context.actor_profile_id, + role="submitter", + for_update=True, + ) + if grant is None: + raise PreparedAuthorizationUnsupported(AuthorizationDenialCode.PERMISSION_NOT_GRANTED) + return context, grant + + +def evaluate_submitter_authority(action, context, authority, resource, lifecycle_denial): + """Evaluate the exact final facts against the locked submitter authority.""" + denial = lifecycle_denial + if denial is None and action.availability is not ActionAvailability.ACTIVE: + denial = AuthorizationDenialCode.ACTION_UNAVAILABLE + elif denial is None and not isinstance(resource, SubmissionBundlePreparationResourceContext): + denial = AuthorizationDenialCode.RESOURCE_GUARD_DENIED + elif denial is None and resource.scope_project_id != authority.scope_project_id: + denial = AuthorizationDenialCode.SCOPE_NOT_AUTHORIZED + elif denial is None and ( + resource.actor_profile_id != context.actor_profile_id + or resource.identity_link_id != context.identity_link_id + ): + denial = AuthorizationDenialCode.RESOURCE_GUARD_DENIED + elif denial is None and ( + authority.matched_grant_id is None or authority.matched_grant_status != "active" + ): + denial = AuthorizationDenialCode.PERMISSION_NOT_GRANTED + if denial is not None: + return denial, None, None, None + return ( + None, + MatchedAuthorityKind.PROJECT_ROLE_GRANT, + authority.matched_grant_id, + authority.scope_project_id, + ) diff --git a/backend/app/modules/authorization/catalogue.py b/backend/app/modules/authorization/catalogue.py index 6afc77645..166d7c197 100644 --- a/backend/app/modules/authorization/catalogue.py +++ b/backend/app/modules/authorization/catalogue.py @@ -704,7 +704,7 @@ def _active( PermissionId.ARTIFACT_GUIDE_SOURCE_READ, ActionOwner.XINT_002_04B, ), - _planned( + _active( ActionId.ARTIFACT_SUBMISSION_BUNDLE_PREPARE, PermissionId.SUBMISSION_CREATE, ActionOwner.XINT_002_05A, @@ -875,6 +875,7 @@ def _index_actions( ActionId.ARTIFACT_GUIDE_SOURCE_INGEST, ActionId.ARTIFACT_GUIDE_SOURCE_BINDING_CREATE, ActionId.ARTIFACT_GUIDE_SOURCE_READ, + ActionId.ARTIFACT_SUBMISSION_BUNDLE_PREPARE, ActionId.ARTIFACT_VERIFICATION_EXECUTE, ActionId.ARTIFACT_PENDING_WORK_SCAN, ActionId.ARTIFACT_PUT_ATTEMPT_RESOLVE, diff --git a/backend/app/modules/authorization/kernel.py b/backend/app/modules/authorization/kernel.py index 2dfcb4c72..4b7d0cc66 100644 --- a/backend/app/modules/authorization/kernel.py +++ b/backend/app/modules/authorization/kernel.py @@ -66,7 +66,6 @@ AuthorizationEvidenceUnavailable, AuthorizationResourceContext, HumanAuthorizationContext, - GuideSourceIngestResourceContext, IdentityLinkStatus, MatchedAuthorityKind, PermissionCatalogueResourceContext, @@ -91,6 +90,12 @@ ServiceActorProvisionResourceContext, authorization_resource_digest, ) +from app.modules.authorization.artifact_project_authority import ( + evaluate_guide_ingest_authority, + evaluate_submitter_authority, + lock_guide_ingest_authority, + lock_submitter_authority, +) ContextRevalidator = Callable[ [ @@ -518,7 +523,8 @@ async def _prepare_prelocked( action.permission_id, scope_project_id=scope.project_id, for_update=True, - allowed_roles=frozenset({AdminRole.PROJECT_MANAGER}), exact_project_scope=action_id is ActionId.PROJECT_GUIDE_COMPILATION_REQUEST, + allowed_roles=frozenset({AdminRole.PROJECT_MANAGER}), + exact_project_scope=action_id is ActionId.PROJECT_GUIDE_COMPILATION_REQUEST, ) if grant is None: raise PreparedAuthorizationUnsupported( @@ -553,26 +559,13 @@ async def _prepare_prelocked( AuthorizationDenialCode.PERMISSION_NOT_GRANTED ) elif action_id is ActionId.ARTIFACT_GUIDE_SOURCE_INGEST: - if ( - not isinstance(context, HumanAuthorizationContext) - or scope.kind is not PreparedAuthorityScopeKind.PROJECT - or scope.project_id is None - ): - raise PreparedAuthorizationUnsupported(AuthorizationDenialCode.SCOPE_NOT_AUTHORIZED) - locked = await self._admin.lock_request_actor( - context.identity_link_id, context.actor_profile_id + context, grant = await lock_guide_ingest_authority( + self._admin, context, scope, action.permission_id, self._locked_human_context ) - context = self._locked_human_context(locked, context) - grant = await self._admin.find_effective_grant( - context.actor_profile_id, - action.permission_id, - scope_project_id=scope.project_id, - for_update=True, + elif action_id is ActionId.ARTIFACT_SUBMISSION_BUNDLE_PREPARE: + context, grant = await lock_submitter_authority( + self._admin, context, scope, self._locked_human_context ) - if grant is None: - raise PreparedAuthorizationUnsupported( - AuthorizationDenialCode.PERMISSION_NOT_GRANTED - ) else: raise PreparedAuthorizationUnsupported( AuthorizationDenialCode.PERMISSION_NOT_GRANTED @@ -1084,23 +1077,17 @@ async def _require_prelocked( matched_kind = MatchedAuthorityKind.ADMIN_ROLE_GRANT matched_grant_id = authority.matched_grant_id elif action_id is ActionId.ARTIFACT_GUIDE_SOURCE_INGEST: - denial = self._lifecycle_denial(context) - if denial is None and action.availability is not ActionAvailability.ACTIVE: - denial = AuthorizationDenialCode.ACTION_UNAVAILABLE - if denial is None and not isinstance( - resource_context, GuideSourceIngestResourceContext - ): - denial = AuthorizationDenialCode.RESOURCE_GUARD_DENIED - if denial is None and resource_context.scope_project_id != authority.scope_project_id: - denial = AuthorizationDenialCode.SCOPE_NOT_AUTHORIZED - if denial is None and ( - authority.matched_grant_id is None or authority.matched_grant_status != "active" - ): - denial = AuthorizationDenialCode.PERMISSION_NOT_GRANTED - if denial is None: - matched_kind = MatchedAuthorityKind.ADMIN_ROLE_GRANT - matched_grant_id = authority.matched_grant_id - matched_project_id = authority.scope_project_id + denial, matched_kind, matched_grant_id, matched_project_id = ( + evaluate_guide_ingest_authority( + action, authority, resource_context, self._lifecycle_denial(context) + ) + ) + elif action_id is ActionId.ARTIFACT_SUBMISSION_BUNDLE_PREPARE: + denial, matched_kind, matched_grant_id, matched_project_id = ( + evaluate_submitter_authority( + action, context, authority, resource_context, self._lifecycle_denial(context) + ) + ) else: denial = AuthorizationDenialCode.ACTION_UNAVAILABLE return await self._complete_decision( diff --git a/backend/app/modules/authorization/pre_submit_materialization.py b/backend/app/modules/authorization/pre_submit_materialization.py new file mode 100644 index 000000000..e02c7d885 --- /dev/null +++ b/backend/app/modules/authorization/pre_submit_materialization.py @@ -0,0 +1,46 @@ +"""Prepared binding parser for pre-submit checker materialization.""" + +from uuid import UUID + +from app.core.hashing import canonical_json_hash +from app.modules.authorization.runtime import PreSubmitCheckerInputPreparationContext + + +def parse_materialization_binding(raw: dict, invalid_error) -> tuple[dict, str]: + """Parse and digest exact checker-materialization preparation facts.""" + try: + value = dict(raw) + for field in ( + "resource_id", "task_id", "assignment_id", "project_id", "guide_id", + "source_snapshot_id", "submission_artifact_policy_id", "checker_policy_id", + "prepared_generation_id", + ): + value[field] = UUID(str(value[field])) + resource = PreSubmitCheckerInputPreparationContext.model_validate(value) + except (KeyError, TypeError, ValueError) as exc: + raise invalid_error("invalid prepared authorization handle") from exc + context = resource.model_dump(mode="json") + return context, canonical_json_hash({"pre_submit_checker_input_preparation": context}) + + +def initialize_artifact_bindings() -> tuple[None, None, None, None, None, None]: + """Return empty prepared-artifact binding slots.""" + return None, None, None, None, None, None + + +def parse_submission_binding(raw: dict, invalid_error, parser) -> tuple: + """Delegate submission preparation parsing through its bounded parser.""" + return parser(raw, invalid_error) + + +def parse_project_create_binding(raw: dict, invalid_error): + """Parse and validate exact project-create operation selectors.""" + try: + operation_id = UUID(str(raw["operation_id"])) + project_id = UUID(str(raw["project_id"])) + generation = raw["operation_generation"] + except (KeyError, TypeError, ValueError) as exc: + raise invalid_error("invalid prepared authorization handle") from exc + if type(generation) is not int or generation < 1 or operation_id == project_id: + raise invalid_error("invalid prepared authorization handle") + return operation_id, project_id, generation diff --git a/backend/app/modules/authorization/prepared.py b/backend/app/modules/authorization/prepared.py index 15e5eb9d9..df5e3c6a6 100644 --- a/backend/app/modules/authorization/prepared.py +++ b/backend/app/modules/authorization/prepared.py @@ -69,6 +69,19 @@ ProjectRevisionPolicyMutationResourceContext, ServiceAuthorizationContext, ) +from app.modules.authorization.submission_preparation import ( + parse_submission_preparation_or_invalid, + submission_preparation_binding_fields, + submission_preparation_binding_matches, + SubmissionBundlePreparationPreflightResourceContext, + SubmissionBundlePreparationResourceContext, +) +from app.modules.authorization.pre_submit_materialization import ( + initialize_artifact_bindings, + parse_materialization_binding, + parse_project_create_binding, + parse_submission_binding, +) @dataclass(frozen=True, slots=True) @@ -165,6 +178,10 @@ class _PreparedAuthorizationBinding: submission_policy_resource_digest: str | None = None exact_artifact_context: dict | None = None exact_artifact_resource_digest: str | None = None + submission_preparation_context: dict | None = None + submission_preparation_resource_digest: str | None = None + submission_preparation_final_context: dict | None = None + submission_preparation_final_digest: str | None = None guide_compilation_context: dict | None = None guide_compilation_resource_digest: str | None = None @@ -300,6 +317,20 @@ def _exact_artifact_binding_matches( ) +def _submission_preparation_binding_matches( + binding: _PreparedAuthorizationBinding, + resource: SubmissionBundlePreparationPreflightResourceContext + | SubmissionBundlePreparationResourceContext, +) -> bool: + return submission_preparation_binding_matches( + request_context=binding.submission_preparation_context, + request_digest=binding.submission_preparation_resource_digest, + final_context=binding.submission_preparation_final_context, + final_digest=binding.submission_preparation_final_digest, + resource=resource, + ) + + _CONSUMED = _Consumed() @@ -356,19 +387,27 @@ async def preflight( """Revalidate current authority without issuing a handle or staging evidence.""" self._root_transaction() binding = self._binding(action_id, caller_input, scope) - if self._scope_from_resource(action_id, resource) != scope or not prepared_compilation_matches( - binding.guide_compilation_context, - binding.guide_compilation_resource_digest, - resource, - ): + scope_matches = self._scope_from_resource(action_id, resource) == scope + if isinstance(resource, SubmissionBundlePreparationPreflightResourceContext): + resource_matches = _submission_preparation_binding_matches(binding, resource) + else: + resource_matches = prepared_compilation_matches( + binding.guide_compilation_context, + binding.guide_compilation_resource_digest, + resource, + ) + if not scope_matches or not resource_matches: raise PreparedAuthorizationHandleInvalid("invalid prepared authorization preflight") authority = await self._authorization._prepare_prelocked( self._consumer_token, action_id, scope ) try: - if project_setup_resource_matches( + setup_match = project_setup_resource_matches( action_id, resource, authority.scope_project_id - ) is not True: + ) + if setup_match is False or ( + setup_match is None and action_id is not ActionId.ARTIFACT_SUBMISSION_BUNDLE_PREPARE + ): raise PreparedAuthorizationUnsupported( AuthorizationDenialCode.RESOURCE_GUARD_DENIED ) @@ -431,6 +470,10 @@ async def consume( final_resource_context, PreSubmitCheckerInputResourceContext ) and not _exact_artifact_binding_matches(issuance.binding, final_resource_context): raise PreparedAuthorizationHandleInvalid("invalid prepared authorization handle") + if isinstance( + final_resource_context, SubmissionBundlePreparationResourceContext + ) and not _submission_preparation_binding_matches(issuance.binding, final_resource_context): + raise PreparedAuthorizationHandleInvalid("invalid prepared authorization handle") if isinstance( final_resource_context, ( @@ -534,47 +577,36 @@ def _binding( policy_mutation_predecessor_id = None policy_mutation_guide_status = None sufficiency: dict[str, object] = {} - submission_policy_context = submission_policy_resource_digest = exact_artifact_context = exact_artifact_resource_digest = None + submission_policy_context = submission_policy_resource_digest = None + ( + exact_artifact_context, + exact_artifact_resource_digest, + submission_preparation_context, + submission_preparation_resource_digest, + submission_preparation_final_context, + submission_preparation_final_digest, + ) = initialize_artifact_bindings() compilation_binding = parse_prepared_compilation(action_id, caller_input.request_value) if action_id is ActionId.ARTIFACT_PRE_SUBMIT_CHECKER_INPUT_MATERIALIZE: - try: - value = dict(caller_input.request_value) - for field in ( - "resource_id", - "task_id", - "assignment_id", - "project_id", - "guide_id", - "source_snapshot_id", - "submission_artifact_policy_id", - "checker_policy_id", - "prepared_generation_id", - ): - value[field] = UUID(str(value[field])) - resource = PreSubmitCheckerInputPreparationContext.model_validate(value) - except (KeyError, TypeError, ValueError) as exc: - raise PreparedAuthorizationHandleInvalid( - "invalid prepared authorization handle" - ) from exc - exact_artifact_context = resource.model_dump(mode="json") - exact_artifact_resource_digest = canonical_json_hash( - {"pre_submit_checker_input_preparation": exact_artifact_context} + exact_artifact_context, exact_artifact_resource_digest = ( + parse_materialization_binding( + dict(caller_input.request_value), PreparedAuthorizationHandleInvalid + ) ) + if action_id is ActionId.ARTIFACT_SUBMISSION_BUNDLE_PREPARE: + submission_binding = parse_submission_binding( + dict(caller_input.request_value), + PreparedAuthorizationHandleInvalid, + parse_submission_preparation_or_invalid, + ) + submission_preparation_context = submission_binding[0] + submission_preparation_resource_digest = submission_binding[1] + submission_preparation_final_context = submission_binding[2] + submission_preparation_final_digest = submission_binding[3] if action_id is ActionId.PROJECT_CREATE: - try: - operation_id = UUID(str(caller_input.request_value["operation_id"])) - project_id = UUID(str(caller_input.request_value["project_id"])) - operation_generation = caller_input.request_value["operation_generation"] - except (KeyError, TypeError, ValueError) as exc: - raise PreparedAuthorizationHandleInvalid( - "invalid prepared authorization handle" - ) from exc - if ( - type(operation_generation) is not int - or operation_generation < 1 - or operation_id == project_id - ): - raise PreparedAuthorizationHandleInvalid("invalid prepared authorization handle") + operation_id, project_id, operation_generation = parse_project_create_binding( + dict(caller_input.request_value), PreparedAuthorizationHandleInvalid + ) if action_id in { ActionId.PROJECT_GUIDE_CREATE, ActionId.PROJECT_GUIDE_UPDATE, @@ -798,6 +830,14 @@ def _binding( submission_policy_resource_digest=submission_policy_resource_digest, exact_artifact_context=exact_artifact_context, exact_artifact_resource_digest=exact_artifact_resource_digest, + **submission_preparation_binding_fields( + ( + submission_preparation_context, + submission_preparation_resource_digest, + submission_preparation_final_context, + submission_preparation_final_digest, + ) + ), **compilation_binding, ) @@ -890,6 +930,17 @@ def _scope_from_resource( kind=PreparedAuthorityScopeKind.PROJECT, project_id=resource.scope_project_id, ) + if action_id is ActionId.ARTIFACT_SUBMISSION_BUNDLE_PREPARE and isinstance( + resource, + ( + SubmissionBundlePreparationPreflightResourceContext, + SubmissionBundlePreparationResourceContext, + ), + ): + return PreparedAuthorityScope( + kind=PreparedAuthorityScopeKind.PROJECT, + project_id=resource.scope_project_id, + ) raise PreparedAuthorizationHandleInvalid("invalid prepared authorization handle") diff --git a/backend/app/modules/authorization/repository.py b/backend/app/modules/authorization/repository.py index c02b86462..e7c1a6b1d 100644 --- a/backend/app/modules/authorization/repository.py +++ b/backend/app/modules/authorization/repository.py @@ -136,16 +136,22 @@ async def take_project_role_issue_lock(self, key: int) -> None: await self._session.execute(text("select pg_advisory_xact_lock(:key)"), {"key": key}) async def find_active_project_role( - self, *, project_id: UUID, actor_profile_id: UUID, role: str + self, + *, + project_id: UUID, + actor_profile_id: UUID, + role: str, + for_update: bool = False, ) -> ProjectRoleGrant | None: - return await self._session.scalar( - select(ProjectRoleGrant).where( + query = select(ProjectRoleGrant).where( ProjectRoleGrant.project_id == str(project_id), ProjectRoleGrant.actor_profile_id == str(actor_profile_id), ProjectRoleGrant.role == role, ProjectRoleGrant.status == "active", ) - ) + if for_update: + query = query.with_for_update(of=ProjectRoleGrant) + return await self._session.scalar(query) async def find_active_project_role_any( self, *, project_id: UUID, actor_profile_id: UUID, for_update: bool = False diff --git a/backend/app/modules/authorization/runtime.py b/backend/app/modules/authorization/runtime.py index 496adfab7..8e67bde0c 100644 --- a/backend/app/modules/authorization/runtime.py +++ b/backend/app/modules/authorization/runtime.py @@ -19,7 +19,7 @@ from app.modules.actors.service_identities import ServiceIdentity from app.modules.authorization.catalogue import ActionId, PermissionId from app.modules.authorization.schemas import AdminRole, AdminScope, ProjectRole - +from app.modules.authorization.submission_preparation import SubmissionBundlePreparationPreflightResourceContext, SubmissionBundlePreparationResourceContext _STRICT_FROZEN = ConfigDict(extra="forbid", frozen=True, strict=True) PROJECT_DIAGNOSTIC_TARGET_KIND_BY_ACTION = { ActionId.PROJECT_SETUP_RUN_READ: "setup_run", @@ -1518,6 +1518,8 @@ class PreSubmitCheckerInputResourceContext(PreSubmitCheckerInputPreparationConte | GuideSourceBindingResourceContext | GuideSourceReadResourceContext | PreSubmitCheckerInputResourceContext + | SubmissionBundlePreparationPreflightResourceContext + | SubmissionBundlePreparationResourceContext ) @@ -1609,6 +1611,8 @@ class AuthorizationDecision(BaseModel): "guide_source_binding", "guide_source_read", "pre_submit_checker_input", + "submission_bundle_preparation_preflight", + "submission_bundle_preparation", ] resource_id: ( UUID @@ -1668,17 +1672,13 @@ def validate_outcome(self): class AuthorizationDenied(Exception): - """Fail-closed control flow carrying only one bounded decision.""" - def __init__(self, decision: AuthorizationDecision) -> None: if decision.allowed or decision.denial_code is None: raise TypeError("authorization denial requires a denied decision") self.decision = decision super().__init__("Authorization denied") - @property def public_code(self) -> str: - """Map internal catalogue outcomes to the stable public denial.""" denial_code = self.decision.denial_code if denial_code is None: raise RuntimeError("authorization denial lost its denial code") @@ -1688,7 +1688,5 @@ def public_code(self) -> str: }: return AuthorizationDenialCode.PERMISSION_NOT_GRANTED.value return denial_code.value - - class AuthorizationEvidenceUnavailable(RuntimeError): - """Authorization evidence could not be persisted safely.""" + pass diff --git a/backend/app/modules/authorization/submission_preparation.py b/backend/app/modules/authorization/submission_preparation.py new file mode 100644 index 000000000..f4313bca5 --- /dev/null +++ b/backend/app/modules/authorization/submission_preparation.py @@ -0,0 +1,160 @@ +"""Strict AUTH-owned resource facts for contributor bundle preparation.""" + +from __future__ import annotations + +from typing import Literal +from uuid import UUID + +from pydantic import BaseModel, ConfigDict, Field, model_validator + +from app.core.hashing import canonical_json_hash + +_STRICT_FROZEN = ConfigDict(extra="forbid", frozen=True, strict=True) + + +class SubmissionBundlePreparationRequestContext(BaseModel): + """Contributor selectors known before reading submission bytes.""" + model_config = _STRICT_FROZEN + scope_project_id: UUID + actor_profile_id: UUID + identity_link_id: UUID + task_id: UUID + assignment_id: UUID + predecessor_submission_id: UUID | None + + +class SubmissionBundlePreparationPreflightResourceContext( + SubmissionBundlePreparationRequestContext +): + """Exact selectors revalidated before submission bytes are read.""" + resource_type: Literal["submission_bundle_preparation_preflight"] + resource_id: UUID + + +class SubmissionBundlePreparationResourceContext(SubmissionBundlePreparationRequestContext): + """Exact final contributor, policy, evidence, and artifact facts.""" + resource_type: Literal["submission_bundle_preparation"] + resource_id: UUID + predecessor_submission_version: int | None = Field(default=None, ge=1) + pre_submit_evidence_set_id: UUID + prepared_generation_id: UUID + guide_id: UUID + guide_version: str + source_snapshot_id: UUID + source_snapshot_sha256: str = Field(pattern=r"^sha256:[0-9a-f]{64}$") + effective_policy_id: UUID + effective_policy_sha256: str = Field(pattern=r"^sha256:[0-9a-f]{64}$") + pre_submit_policy_id: UUID + pre_submit_policy_sha256: str = Field(pattern=r"^sha256:[0-9a-f]{64}$") + effective_plan_sha256: str = Field(pattern=r"^sha256:[0-9a-f]{64}$") + semantic_manifest_id: UUID + semantic_manifest_sha256: str = Field(pattern=r"^sha256:[0-9a-f]{64}$") + archive_sha256: str = Field(pattern=r"^sha256:[0-9a-f]{64}$") + archive_byte_count: int = Field(ge=0) + media_type: Literal["application/zip"] + storage_scheme: Literal["local", "s3"] + operation_identity: str = Field(min_length=1) + replay_durable_intent_id: UUID | None + + @model_validator(mode="after") + def require_exact_identity_and_predecessor(self): + """Require a complete generation and predecessor identity.""" + if self.resource_id != self.prepared_generation_id: + raise ValueError("submission preparation resource must match generation") + if (self.predecessor_submission_id is None) != ( + self.predecessor_submission_version is None + ): + raise ValueError("submission predecessor identity is incomplete") + return self + + +def parse_submission_preparation( + raw: dict, +) -> tuple[dict, str, dict | None, str | None]: + """Parse caller facts once and return canonical request/final bindings.""" + value = dict(raw) + for field in ( + "scope_project_id", + "actor_profile_id", + "identity_link_id", + "task_id", + "assignment_id", + ): + value[field] = UUID(str(value[field])) + if value.get("predecessor_submission_id") is not None: + value["predecessor_submission_id"] = UUID(str(value["predecessor_submission_id"])) + request = SubmissionBundlePreparationRequestContext.model_validate( + {field: value[field] for field in SubmissionBundlePreparationRequestContext.model_fields} + ) + request_context = request.model_dump(mode="json") + request_digest = canonical_json_hash({"submission_bundle_preparation": request_context}) + if "prepared_generation_id" not in value: + return request_context, request_digest, None, None + for field in ( + "pre_submit_evidence_set_id", + "prepared_generation_id", + "guide_id", + "source_snapshot_id", + "effective_policy_id", + "pre_submit_policy_id", + "semantic_manifest_id", + ): + value[field] = UUID(str(value[field])) + if value.get("replay_durable_intent_id") is not None: + value["replay_durable_intent_id"] = UUID(str(value["replay_durable_intent_id"])) + final = SubmissionBundlePreparationResourceContext( + resource_type="submission_bundle_preparation", + resource_id=value["prepared_generation_id"], + **value, + ) + final_context = final.model_dump(mode="json") + final_digest = canonical_json_hash( + {"resource_context": final.model_dump(mode="json", exclude_none=True)} + ) + return request_context, request_digest, final_context, final_digest + + +def parse_submission_preparation_or_invalid(raw: dict, invalid_error): + """Map parsing errors to the opaque prepared-handle failure.""" + try: + return parse_submission_preparation(raw) + except (KeyError, TypeError, ValueError) as exc: + raise invalid_error("invalid prepared authorization handle") from exc + + +def submission_preparation_binding_fields(values: tuple) -> dict: + """Name canonical preparation values for the PREP binding record.""" + request_context, request_digest, final_context, final_digest = values + return { + "submission_preparation_context": request_context, + "submission_preparation_resource_digest": request_digest, + "submission_preparation_final_context": final_context, + "submission_preparation_final_digest": final_digest, + } + + +def submission_preparation_binding_matches( + *, + request_context: dict | None, + request_digest: str | None, + final_context: dict | None, + final_digest: str | None, + resource: SubmissionBundlePreparationPreflightResourceContext + | SubmissionBundlePreparationResourceContext, +) -> bool: + """Require the consumed facts to equal the prepared request or final facts.""" + if isinstance(resource, SubmissionBundlePreparationResourceContext): + context = resource.model_dump(mode="json") + digest = canonical_json_hash( + {"resource_context": resource.model_dump(mode="json", exclude_none=True)} + ) + return final_context == context and final_digest == digest + context = SubmissionBundlePreparationRequestContext.model_validate( + { + field: getattr(resource, field) + for field in SubmissionBundlePreparationRequestContext.model_fields + } + ).model_dump(mode="json") + return request_context == context and request_digest == canonical_json_hash( + {"submission_bundle_preparation": context} + ) diff --git a/backend/scripts/behavior_ownership.py b/backend/scripts/behavior_ownership.py index 6330fb675..629285986 100644 --- a/backend/scripts/behavior_ownership.py +++ b/backend/scripts/behavior_ownership.py @@ -92,6 +92,13 @@ "backend/app/modules/tasks/submission_composition.py", } ) +ARCH_02G_AUTH_PREPARATION_TARGETS = frozenset( + { + "backend/app/modules/authorization/artifact_project_authority.py", + "backend/app/modules/authorization/pre_submit_materialization.py", + "backend/app/modules/authorization/submission_preparation.py", + } +) POL_03A_CALLABLE_TARGETS = frozenset( { "backend/app/modules/authorization/api/project_guide_compilation.py", @@ -279,6 +286,7 @@ def _validate_additive_partition_transition( | POL_03A_CALLABLE_TARGETS | AUTH_12I_TARGETS | ARCH_02F_SUBMISSION_COMPOSITION_TARGETS + | ARCH_02G_AUTH_PREPARATION_TARGETS | V01_BASELINE_ADDED_TARGETS ) expected_additions = (approved_additions & additions) - set(trusted_targets) diff --git a/backend/scripts/run_test_lanes.py b/backend/scripts/run_test_lanes.py index 39bf7244a..a2e5d64d9 100644 --- a/backend/scripts/run_test_lanes.py +++ b/backend/scripts/run_test_lanes.py @@ -142,6 +142,8 @@ class TestLane: "tests/test_behavior_ownership.py", "tests/test_artifact_admission.py", "tests/test_submission_bundle_admission.py", + "tests/test_submission_preparation_adapter.py", + "tests/test_submission_preparation_authorization.py", "tests/test_submission_composition.py", "tests/test_artifact_bindings.py", "tests/test_artifact_bindings_db.py", diff --git a/backend/tests/submission_preparation_auth_helpers.py b/backend/tests/submission_preparation_auth_helpers.py new file mode 100644 index 000000000..fabe955c9 --- /dev/null +++ b/backend/tests/submission_preparation_auth_helpers.py @@ -0,0 +1,126 @@ +"""Focused PostgreSQL setup helpers for submission-preparation AUTH proof.""" + +import json +from uuid import uuid4 + +from sqlalchemy import text + +from app.modules.artifacts.authorization import PreparedSubmissionBundlePreparationAuthorization +from app.modules.authorization.runtime import ( + ActorKind, + ActorStatus, + HumanAuthorizationContext, + IdentityLinkStatus, +) + + +async def install_submitter_grant(connection, params) -> None: + authorizer_id = str(uuid4()) + authorizer_link_id = str(uuid4()) + admin_grant_id, qualification_id, project_grant_id = uuid4(), uuid4(), uuid4() + await connection.execute( + text( + "insert into actor_profiles " + "(id,actor_kind,status,provisioning_method,created_by) values " + "(:authorizer,'human','active','automatic_first_access','test')" + ), + {"authorizer": authorizer_id}, + ) + await connection.execute( + text( + "insert into actor_identity_links " + "(id,actor_profile_id,issuer,subject,subject_kind,status,linked_by," + "last_verified_at) values " + "(:link,:authorizer,'flow-test',:authorizer,'human','active','test',now())" + ), + {"link": authorizer_link_id, "authorizer": authorizer_id}, + ) + await connection.execute( + text( + "insert into admin_role_grants " + "(id,target_actor_profile_id,role,scope_type,status,version," + "granted_by_system_principal,grant_reason) values " + "(:admin_grant,:authorizer,'access_administrator','system','active',1," + "'workstream:system:bootstrap','submission preparation test bootstrap')" + ), + {"admin_grant": admin_grant_id, "authorizer": authorizer_id}, + ) + await connection.execute( + text( + "update authority_control set bootstrap_completed=true, version=1, " + "bootstrap_grant_id=:admin_grant, updated_at=clock_timestamp() where id=1" + ), + {"admin_grant": admin_grant_id}, + ) + await connection.execute( + text( + "insert into project_role_qualification_snapshots " + "(id,project_id,actor_profile_id,requested_role,skills_snapshot," + "reputation_snapshot,prior_project_work_refs,external_expertise_refs," + "captured_by_actor_profile_id,captured_by_admin_role_grant_id) values " + "(:qualification,:project,:actor,'submitter',cast(:skills as json)," + "cast(:reputation as json)," + "'[]'::json,'[]'::json,:authorizer,:admin_grant)" + ), + { + **params, + "authorizer": authorizer_id, + "qualification": qualification_id, + "admin_grant": admin_grant_id, + "skills": json.dumps( + { + "availability": "available", + "reference_ids": ["skill:test"], + "unavailable_reason": None, + } + ), + "reputation": json.dumps( + { + "availability": "unavailable", + "reference_ids": [], + "unavailable_reason": "no_record", + } + ), + }, + ) + await connection.execute( + text( + "insert into project_role_grants " + "(id,project_id,actor_profile_id,role,status,version,grant_method," + "qualification_snapshot_id,granted_by_actor_profile_id," + "granted_by_admin_role_grant_id,grant_reason) values " + "(:project_grant,:project,:actor,'submitter','active',1,'manual'," + ":qualification,:authorizer,:admin_grant,'submission preparation test')" + ), + { + **params, + "authorizer": authorizer_id, + "project_grant": project_grant_id, + "qualification": qualification_id, + "admin_grant": admin_grant_id, + }, + ) + + +async def table_counts(connection, tables) -> dict[str, int]: + return { + table: int(await connection.scalar(text(f"select count(*) from {table}")) or 0) + for table in tables + } + + +async def prepared_submitter_authority(session, request, actor_id, identity_link_id, project_id): + authority = PreparedSubmissionBundlePreparationAuthorization( + session, + HumanAuthorizationContext( + actor_profile_id=actor_id, + actor_kind=ActorKind.HUMAN, + actor_status=ActorStatus.ACTIVE, + identity_link_id=identity_link_id, + identity_link_status=IdentityLinkStatus.ACTIVE, + request_id=request.request_id, + correlation_id=request.correlation_id, + ), + ) + await authority.revalidate(request=request, project_id=project_id) + return authority diff --git a/backend/tests/test_audit.py b/backend/tests/test_audit.py index 36beebea2..49edd90bb 100644 --- a/backend/tests/test_audit.py +++ b/backend/tests/test_audit.py @@ -129,8 +129,7 @@ def test_action_aware_audit_input_enforces_mapping_and_action_availability() -> 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 + assert (denied.action_id, denied.permission_id) == (ActionId.ARTIFACT_BINDING_READ, PermissionId.ARTIFACT_BINDING_READ) with pytest.raises(ValidationError, match="action permission"): _authority_input( AuthorityEventType.SENSITIVE_AUTHORIZATION_DENIED, @@ -214,7 +213,7 @@ def test_action_aware_audit_input_enforces_mapping_and_action_availability() -> ActionId.ARTIFACT_GUIDE_SOURCE_INGEST, ActionId.ARTIFACT_GUIDE_SOURCE_BINDING_CREATE, ActionId.ARTIFACT_GUIDE_SOURCE_READ, - ActionId.ARTIFACT_PRE_SUBMIT_CHECKER_INPUT_MATERIALIZE, + ActionId.ARTIFACT_PRE_SUBMIT_CHECKER_INPUT_MATERIALIZE, ActionId.ARTIFACT_SUBMISSION_BUNDLE_PREPARE, } artifact_allowed = _authority_input( AuthorityEventType.SENSITIVE_AUTHORIZATION_ALLOWED, diff --git a/backend/tests/test_authorization.py b/backend/tests/test_authorization.py index b63370aeb..90c1e8bcd 100644 --- a/backend/tests/test_authorization.py +++ b/backend/tests/test_authorization.py @@ -1745,8 +1745,7 @@ async def consume_rate() -> None: ), "artifact.submission_bundle.prepare": ( "submission.create", - "WS-XINT-002-05A", - "planned", + "WS-XINT-002-05A", "active", ), "artifact.pre_submit.checker_input.materialize": ( "artifact.checker_input.materialize", @@ -2126,8 +2125,7 @@ def test_closed_permission_and_action_catalogue_is_exact_and_non_executable() -> ActionId.PROJECT_ROLE_GRANT_ISSUE, ActionId.PROJECT_ROLE_GRANT_REVOKE, ActionId.PROJECT_CREATE, - ActionId.PROJECT_GUIDE_CREATE, - ActionId.PROJECT_GUIDE_UPDATE, + ActionId.PROJECT_GUIDE_CREATE, ActionId.PROJECT_GUIDE_UPDATE, ActionId.PROJECT_GUIDE_SOURCE_SNAPSHOT_CREATE, ActionId.PROJECT_GUIDE_COMPILATION_REQUEST, ActionId.PROJECT_GUIDE_COMPILATION_EXECUTE, ActionId.PROJECT_REVIEW_POLICY_UPDATE, @@ -2154,7 +2152,7 @@ def test_closed_permission_and_action_catalogue_is_exact_and_non_executable() -> ActionId.ARTIFACT_VERIFICATION_EXECUTE, ActionId.ARTIFACT_PENDING_WORK_SCAN, ActionId.ARTIFACT_PUT_ATTEMPT_RESOLVE, - ActionId.ARTIFACT_PRE_SUBMIT_CHECKER_INPUT_MATERIALIZE, + ActionId.ARTIFACT_PRE_SUBMIT_CHECKER_INPUT_MATERIALIZE, ActionId.ARTIFACT_SUBMISSION_BUNDLE_PREPARE, } assert { definition.action_id.value: ( @@ -2235,15 +2233,13 @@ def test_closed_permission_and_action_catalogue_is_exact_and_non_executable() -> sum( definition.availability is ActionAvailability.ACTIVE for definition in ACTION_DEFINITIONS - ) - == 54 + ) == 55 ) assert ( sum( definition.availability is ActionAvailability.PLANNED for definition in ACTION_DEFINITIONS - ) - == 48 + ) == 47 ) assert resolve_executable_action(ActionId.ACTOR_PROFILE_READ_SELF).permission_id is PermissionId.ACTOR_PROFILE_READ_SELF with pytest.raises(ValueError, match="not active"): diff --git a/backend/tests/test_default_pre_submit_execution.py b/backend/tests/test_default_pre_submit_execution.py index 7d4be09be..f5c0285e9 100644 --- a/backend/tests/test_default_pre_submit_execution.py +++ b/backend/tests/test_default_pre_submit_execution.py @@ -91,23 +91,17 @@ evidence_workflow, submission_preparation_request, ) +from tests.submission_preparation_auth_helpers import ( + install_submitter_grant, + prepared_submitter_authority, + table_counts, +) async def _bytes(value: bytes): yield value -class _AllowSubmissionPreparedAuthorization: - """Test-only final authority that records transaction-bound consumption.""" - - def __init__(self) -> None: - self.facts = None - - async def consume(self, *, prepared_authorization, facts) -> None: - assert type(prepared_authorization) is PreparedAuthorizationHandle - self.facts = facts - - class _AllowOperatorAuthority: async def authorize(self, *, facts, **_values) -> ArtifactOperatorAuthorizationEvidence: return ArtifactOperatorAuthorizationEvidence( @@ -456,7 +450,7 @@ async def test_effective_evidence_workflow_persists_once_and_replays_exactly( ("review_policies", "review_policy_mutation_custody"), ("revision_policies", "revision_policy_mutation_custody"), # noqa: E501 ) blocked_prepared = replay_prepared = drift_prepared = denied_prepared = None - original_prepared_closed = bool() + original_prepared_closed = False tables = ( "artifact_contents", "artifact_replicas", @@ -642,10 +636,8 @@ async def test_effective_evidence_workflow_persists_once_and_replays_exactly( ), params, ) - before = { - table: int(await connection.scalar(text(f"select count(*) from {table}")) or 0) - for table in tables - } + await install_submitter_grant(connection, params) + before = await table_counts(connection, tables) session_factory = async_sessionmaker(engine, expire_on_commit=False) async with session_factory() as session: preparation_authority = cast(Any, SimpleNamespace(revalidate=AsyncMock())) @@ -662,7 +654,6 @@ async def test_effective_evidence_workflow_persists_once_and_replays_exactly( actor_profile_id=actor_id, identity_link_id=identity_link_id, ) - async def fresh_checked_bundle(): prepared = await preparation.prepare( _bytes(_archive()), @@ -717,25 +708,27 @@ async def fresh_checked_bundle(): execute_committed_put=AsyncMock(), resume_committed_put=AsyncMock(), ) - final_authority = _AllowSubmissionPreparedAuthorization() - durable_service = SubmissionBundleDurablePutService( - session=session, - admission=ArtifactAdmissionService( - session, - admission_settings, - namespace, - ), - storage=provider, - authorization=final_authority, - ) async with session.begin(): + final_authority = await prepared_submitter_authority( + session, preparation_request, actor_id, identity_link_id, lineage.project_id, + ) + durable_service = SubmissionBundleDurablePutService( + session=session, + admission=ArtifactAdmissionService( + session, + admission_settings, + namespace, + ), + storage=provider, + authorization=final_authority, + ) ( retained, selected_evidence_id, first_admission, ) = await durable_service.admit_in_transaction( SubmissionBundleDurablePutRequest( - prepared_authorization=object.__new__(PreparedAuthorizationHandle), + prepared_authorization=None, prepared_artifact=request.prepared_artifact, pass_capability=first.pass_capability, ) @@ -762,7 +755,7 @@ async def fresh_checked_bundle(): replay_admission, ) = await durable_service.admit_in_transaction( SubmissionBundleDurablePutRequest( - prepared_authorization=object.__new__(PreparedAuthorizationHandle), + prepared_authorization=None, prepared_artifact=replay_prepared, pass_capability=fresh.pass_capability, replay_durable_intent_id=replay_intent_id, @@ -794,7 +787,7 @@ async def fresh_checked_bundle(): async with session.begin(): await durable_service.admit_in_transaction( SubmissionBundleDurablePutRequest( - prepared_authorization=object.__new__(PreparedAuthorizationHandle), + prepared_authorization=None, prepared_artifact=drift_prepared, pass_capability=drift.pass_capability, ) @@ -820,7 +813,7 @@ async def fresh_checked_bundle(): async with session.begin(): await denied_service.admit_in_transaction( SubmissionBundleDurablePutRequest( - prepared_authorization=object.__new__(PreparedAuthorizationHandle), + prepared_authorization=None, prepared_artifact=denied_prepared, pass_capability=denied.pass_capability, ) diff --git a/backend/tests/test_submission_bundle_admission.py b/backend/tests/test_submission_bundle_admission.py index f88f2b023..4cfdbdda4 100644 --- a/backend/tests/test_submission_bundle_admission.py +++ b/backend/tests/test_submission_bundle_admission.py @@ -105,14 +105,18 @@ async def test_artifact_adapter_preserves_public_actor_facts() -> None: @pytest.mark.asyncio -async def test_artifact_adapter_default_preparation_authority_denies() -> None: - authority = artifact_adapters.get_submission_bundle_preparation_authorization() +async def test_explicit_deny_preparation_authority_denies() -> None: + from app.modules.artifacts.submission_authorization import ( + DenySubmissionBundlePreparationAuthorization, + ) + + authority = DenySubmissionBundlePreparationAuthorization() request = _preparation_request(byte_source=object()) with pytest.raises(ArtifactAuthorityDeniedError): await authority.preflight(request=request) with pytest.raises(ArtifactAuthorityDeniedError): - await authority.revalidate(request=request) + await authority.revalidate(request=request, project_id=uuid4()) with pytest.raises(ArtifactAuthorityDeniedError): authority.transaction() with pytest.raises(ArtifactAuthorityDeniedError): @@ -120,6 +124,14 @@ async def test_artifact_adapter_default_preparation_authority_denies() -> None: authority.close() +@pytest.mark.asyncio +async def test_explicit_deny_durable_authority_denies_final_preparation() -> None: + authority = DenySubmissionBundlePreparedAuthorization() + + with pytest.raises(ArtifactAuthorityDeniedError): + await authority.prepare_final(facts=object()) # type: ignore[arg-type] + + @pytest.mark.asyncio async def test_artifact_adapter_stale_scratch_cleanup_closes_manager( monkeypatch: pytest.MonkeyPatch, @@ -216,7 +228,7 @@ def test_artifact_adapter_composes_submission_command_from_owner_ports( ) session = object() internal_authority = object() - authority = artifact_adapters.get_submission_bundle_preparation_authorization() + authority = SimpleNamespace(close=Mock()) task_contexts = object() project_contexts = object() monkeypatch.setattr( @@ -459,8 +471,17 @@ async def test_hidden_preparation_replays_persisted_checked_custody(monkeypatch) inspect=AsyncMock(return_value=object()), close=AsyncMock(), ) + events: list[str] = [] + + async def prepare_bytes(*_args, **_kwargs): + events.append("prepare_bytes") + return prepared + + async def revalidate(**_kwargs): + events.append("revalidate") + runtime = SimpleNamespace( - preparation=SimpleNamespace(prepare=AsyncMock(return_value=prepared)), + preparation=SimpleNamespace(prepare=AsyncMock(side_effect=prepare_bytes)), inspector=object(), catalogue=object(), materialization=SimpleNamespace(prepare_authorization=AsyncMock(return_value=object())), @@ -490,7 +511,10 @@ async def runtime_factory(): "evaluate_submission_change", Mock(return_value=object()), ) - authority = SimpleNamespace(preflight=AsyncMock(), close=Mock()) + project_id = uuid4() + authority = SimpleNamespace( + preflight=AsyncMock(), revalidate=AsyncMock(side_effect=revalidate), close=Mock() + ) command = PreparedSubmissionBundlePreparationCommand( session=SimpleNamespace(begin=_transaction), authority=authority, @@ -499,14 +523,19 @@ async def runtime_factory(): runtime_factory=runtime_factory, ) command._lock_context = AsyncMock( - return_value=(SimpleNamespace(predecessor=None), locked) + return_value=( + SimpleNamespace( + predecessor=None, + locked_project_context=SimpleNamespace(project_id=project_id), + ), + locked, + ) ) command._compile_plan = Mock(return_value=object()) command._load_predecessor = AsyncMock(return_value=None) command._existing_durable_result = AsyncMock(return_value=expected) - result = await command.prepare( - SubmissionBundlePreparationRequest( + request = SubmissionBundlePreparationRequest( actor=ActorIdentityFacts( actor_profile_id=actor_id, identity_link_id=uuid4(), @@ -523,10 +552,12 @@ async def runtime_factory(): media_type="application/zip", byte_source=artifact_byte_stream(b"PK\x03\x04replay"), ) - ) + result = await command.prepare(request) assert result == expected runtime.preparation.prepare.assert_awaited_once() + authority.revalidate.assert_awaited_once_with(request=request, project_id=project_id) + assert events[:2] == ["revalidate", "prepare_bytes"] runtime.evidence.materialize.assert_awaited_once() runtime.evidence.persist.assert_awaited_once() prepared.close.assert_awaited_once() @@ -844,6 +875,8 @@ async def admit(request, **values): assert request.custody.prepared_generation_id == prepared.generation_id assert type(request.custody.pass_capability) is PreSubmitPassCapability assert values["existing_transaction"] is True + assert values["prepared_authorization"] is None + assert values["submission_prepared_authorization"] is service._authorization return admission_result admission = SimpleNamespace(admit=admit) @@ -857,11 +890,10 @@ async def admit(request, **values): storage=storage, authorization=object(), ) - handle = object.__new__(PreparedAuthorizationHandle) try: retained, selected_evidence_id, durable = await service.admit_in_transaction( SubmissionBundleDurablePutRequest( - prepared_authorization=handle, + prepared_authorization=None, prepared_artifact=prepared, pass_capability=_capability(prepared, evidence_set_id), ) diff --git a/backend/tests/test_submission_preparation_adapter.py b/backend/tests/test_submission_preparation_adapter.py new file mode 100644 index 000000000..265ea24f2 --- /dev/null +++ b/backend/tests/test_submission_preparation_adapter.py @@ -0,0 +1,93 @@ +"""Focused ART adapter proofs for AUTH submission preparation.""" + +from uuid import uuid4 +from types import SimpleNamespace +from unittest.mock import AsyncMock + +import pytest + +from app.adapters import artifacts as artifact_adapters +from app.modules.artifacts.authorization import PreparedSubmissionBundlePreparationAuthorization +from app.modules.artifacts.schemas import ( + ArtifactAuthorityDeniedError, + SubmissionBundleDurableIntentAuthorityFacts, +) +from app.modules.authorization.runtime import PreparedAuthorizationInput + + +def _sha(value: str) -> str: + return "sha256:" + value * 64 + + +def test_artifact_adapter_composes_active_preparation_authority() -> None: + session, context = object(), object() + authority = artifact_adapters.get_submission_bundle_preparation_authorization(session, context) + assert type(authority) is PreparedSubmissionBundlePreparationAuthorization + assert authority._session is session + assert authority._context is context + + +@pytest.mark.asyncio +async def test_final_preparation_rejects_cross_request_facts() -> None: + authority = object.__new__(PreparedSubmissionBundlePreparationAuthorization) + project_id, actor_id, link_id, task_id, assignment_id = (uuid4() for _ in range(5)) + authority._input = PreparedAuthorizationInput( + idempotency_key=uuid4(), + request_value={ + "scope_project_id": str(project_id), + "actor_profile_id": str(actor_id), + "identity_link_id": str(link_id), + "task_id": str(task_id), + "assignment_id": str(assignment_id), + "predecessor_submission_id": None, + }, + ) + facts = SubmissionBundleDurableIntentAuthorityFacts( + actor_profile_id=actor_id, + identity_link_id=link_id, + project_id=project_id, + task_id=task_id, + assignment_id=uuid4(), + predecessor_submission_id=None, + predecessor_submission_version=None, + pre_submit_evidence_set_id=uuid4(), + prepared_generation_id=uuid4(), + guide_id=uuid4(), + guide_version="v1", + source_snapshot_id=uuid4(), + source_snapshot_sha256=_sha("1"), + effective_policy_id=uuid4(), + effective_policy_sha256=_sha("2"), + pre_submit_policy_id=uuid4(), + pre_submit_policy_sha256=_sha("3"), + effective_plan_sha256=_sha("4"), + semantic_manifest_id=uuid4(), + semantic_manifest_sha256=_sha("5"), + archive_sha256=_sha("6"), + archive_byte_count=42, + media_type="application/zip", + storage_scheme="s3", + operation_identity=_sha("7"), + replay_durable_intent_id=None, + ) + with pytest.raises(ArtifactAuthorityDeniedError): + await authority.prepare_final(facts=facts) + + +@pytest.mark.asyncio +async def test_preflight_rejects_nonhuman_context_before_database_access() -> None: + authority = object.__new__(PreparedSubmissionBundlePreparationAuthorization) + authority._context = object() + authority._session = SimpleNamespace(begin=AsyncMock()) + + with pytest.raises(ArtifactAuthorityDeniedError): + await authority.preflight(request=SimpleNamespace(actor=object())) + + +@pytest.mark.asyncio +async def test_final_preparation_requires_preflight_input() -> None: + authority = object.__new__(PreparedSubmissionBundlePreparationAuthorization) + authority._input = None + + with pytest.raises(ArtifactAuthorityDeniedError): + await authority.prepare_final(facts=object()) diff --git a/backend/tests/test_submission_preparation_authorization.py b/backend/tests/test_submission_preparation_authorization.py new file mode 100644 index 000000000..03bdcbe93 --- /dev/null +++ b/backend/tests/test_submission_preparation_authorization.py @@ -0,0 +1,159 @@ +"""Focused prepared-capability proofs for contributor bundle preparation.""" + +from types import SimpleNamespace +from uuid import UUID, uuid4 + +import pytest + +from app.modules.authorization.catalogue import ActionId +from app.modules.authorization.prepared import ( + PreparedAuthorizationHandleInvalid, + PreparedAuthorizationService, +) +from app.modules.authorization.runtime import ( + MatchedAuthorityKind, + PreparedAuthorizationInput, + PreparedAuthorityScope, + PreparedAuthorityScopeKind, +) +from app.modules.authorization.runtime import ( + SubmissionBundlePreparationPreflightResourceContext, + SubmissionBundlePreparationResourceContext, +) +from tests.test_authorization import _PreparedTestSession, _runtime_context, _runtime_service + + +def _request_values(context, project_id, task_id, assignment_id): + return { + "scope_project_id": str(project_id), + "actor_profile_id": str(context.actor_profile_id), + "identity_link_id": str(context.identity_link_id), + "task_id": str(task_id), + "assignment_id": str(assignment_id), + "predecessor_submission_id": None, + } + + +def _final_values(request_values): + values = { + "predecessor_submission_version": None, + "guide_version": "v1", + "source_snapshot_sha256": "sha256:" + "1" * 64, + "effective_policy_sha256": "sha256:" + "2" * 64, + "pre_submit_policy_sha256": "sha256:" + "3" * 64, + "effective_plan_sha256": "sha256:" + "4" * 64, + "semantic_manifest_sha256": "sha256:" + "5" * 64, + "archive_sha256": "sha256:" + "6" * 64, + "archive_byte_count": 42, + "media_type": "application/zip", + "storage_scheme": "s3", + "operation_identity": "sha256:" + "7" * 64, + "replay_durable_intent_id": None, + } + for field in ( + "pre_submit_evidence_set_id", + "prepared_generation_id", + "guide_id", + "source_snapshot_id", + "effective_policy_id", + "pre_submit_policy_id", + "semantic_manifest_id", + ): + values[field] = str(uuid4()) + return {**request_values, **values} + + +def _resource(values): + typed = dict(values) + for field in ( + "scope_project_id", + "actor_profile_id", + "identity_link_id", + "task_id", + "assignment_id", + "pre_submit_evidence_set_id", + "prepared_generation_id", + "guide_id", + "source_snapshot_id", + "effective_policy_id", + "pre_submit_policy_id", + "semantic_manifest_id", + ): + typed[field] = UUID(typed[field]) + return SubmissionBundlePreparationResourceContext( + resource_type="submission_bundle_preparation", + resource_id=typed["prepared_generation_id"], + **typed, + ) + + +@pytest.mark.asyncio +async def test_submission_preparation_binds_exact_facts_and_rejects_replay() -> None: + context, session = _runtime_context(), _PreparedTestSession() + project_id, task_id, assignment_id = uuid4(), uuid4(), uuid4() + + class Facts: + async def lock_request_actor(self, identity_link_id, actor_profile_id): + return ( + SimpleNamespace( + id=str(identity_link_id), + actor_profile_id=str(actor_profile_id), + status="active", + ), + SimpleNamespace(id=str(actor_profile_id), actor_kind="human", status="active"), + ) + + async def find_active_project_role(self, **values): + assert values == { + "project_id": project_id, + "actor_profile_id": context.actor_profile_id, + "role": "submitter", + "for_update": True, + } + return SimpleNamespace(id=uuid4(), status="active") + + facts = Facts() + authorization, evidence = _runtime_service(context, session=session, admin_repository=facts) + prepared = PreparedAuthorizationService(session, context, authorization, facts) + values = _request_values(context, project_id, task_id, assignment_id) + caller = PreparedAuthorizationInput(idempotency_key=uuid4(), request_value=values) + scope = PreparedAuthorityScope(kind=PreparedAuthorityScopeKind.PROJECT, project_id=project_id) + await prepared.preflight( + ActionId.ARTIFACT_SUBMISSION_BUNDLE_PREPARE, + caller, + scope, + SubmissionBundlePreparationPreflightResourceContext( + resource_type="submission_bundle_preparation_preflight", + resource_id=assignment_id, + **{key: UUID(value) if value is not None else None for key, value in values.items()}, + ), + ) + final = _final_values(values) + final_caller = PreparedAuthorizationInput( + idempotency_key=caller.idempotency_key, request_value=final + ) + resource = _resource(final) + mismatch_handle = await prepared.prepare( + ActionId.ARTIFACT_SUBMISSION_BUNDLE_PREPARE, final_caller, scope + ) + with pytest.raises(PreparedAuthorizationHandleInvalid): + await prepared.consume( + mismatch_handle, + ActionId.ARTIFACT_SUBMISSION_BUNDLE_PREPARE, + final_caller, + resource.model_copy(update={"archive_byte_count": 43}), + ) + assert evidence.events == [] + handle = await prepared.prepare( + ActionId.ARTIFACT_SUBMISSION_BUNDLE_PREPARE, final_caller, scope + ) + decision = await prepared.consume( + handle, ActionId.ARTIFACT_SUBMISSION_BUNDLE_PREPARE, final_caller, resource + ) + assert decision.allowed is True + assert decision.matched_authority_kind is MatchedAuthorityKind.PROJECT_ROLE_GRANT + assert len(evidence.events) == 1 + with pytest.raises(PreparedAuthorizationHandleInvalid): + await prepared.consume( + handle, ActionId.ARTIFACT_SUBMISSION_BUNDLE_PREPARE, final_caller, resource + ) diff --git a/docs/architecture_data_model.md b/docs/architecture_data_model.md index ad304ee1c..c16919b44 100644 --- a/docs/architecture_data_model.md +++ b/docs/architecture_data_model.md @@ -837,7 +837,7 @@ and route-unreachable until the later TASK composition and AUTH activation chunks. The hidden preparation route keeps scratch and prepared handles process-local, returns before durable verification finishes, and remains excluded from OpenAPI -and unavailable until AUTH activates `artifact.submission_bundle.prepare`. +and crosses the active `artifact.submission_bundle.prepare` PREP boundary before durable intent. Blocking pre-submit failures prevent submission creation, create no submission row, no submission version, no task transition to `submitted`, and no diff --git a/docs/roadmap_status.md b/docs/roadmap_status.md index 596f5353a..178787378 100644 --- a/docs/roadmap_status.md +++ b/docs/roadmap_status.md @@ -61,6 +61,9 @@ remain outside v0.1. complete planned REV action and fixed-service catalogues, and the typed fail-closed PREP/read handoff required for hidden REV implementation. These foundations merged through PRs #242, #248, #255, and #257 respectively. + Contributor bundle preparation now uses the exact assigned-contributor AUTH + PREP boundary from WS-ARCH-001-02G; Submission consumption and binding remain + separately gated by 02H. These readiness contracts do not make the review lifecycle available. - Project Manager-authorized guide-sufficiency creation, asynchronous agent-run requests, and warning acknowledgement with UUID replay custody; automatic @@ -103,9 +106,9 @@ but are not all complete as one production path: platform/default and project-policy phases; cleanup precedes one immutable combined evidence set. Evidence-linked durable put intent, verification, and capacity-charged ready-admission publication are merged. Contributor - preparation authority, admission consumption, TASK-owned Submission - creation, and final binding are not yet part of the complete production - path and require the public-module boundary reconciliation. + preparation authority is active through WS-ARCH-001-02G; admission + consumption, TASK-owned Submission creation, and final binding remain + separately gated by 02H and the later clean cut. - integration of the merged review/revision policy and authorization readiness contracts into hidden REV lifecycle behavior; diff --git a/docs/spec_authorization_service.md b/docs/spec_authorization_service.md index 8fb62748b..f3c51ff5f 100644 --- a/docs/spec_authorization_service.md +++ b/docs/spec_authorization_service.md @@ -255,7 +255,9 @@ project-mutation rows, producing the historical 96-row state of 37 active and pre-02C state to 45 active and 51 planned. WS-XINT-003-02C adds four planned REV rows. Subsequent approved activations advanced the pre-12I catalogue to 52 active and 48 planned rows. AUTH-12I adds and activates the two compilation -request/execute rows, producing 102 rows: 54 active and 48 planned. +request/execute rows, producing 102 rows. WS-ARCH-001-02G subsequently +activates the existing contributor-preparation row, producing 55 active and 47 +planned rows without adding an action or permission. AUTH-10A added five project-role read/manage rows; AUTH-10B owns and activates the three reads, while AUTH-10C owns and activates the two reason-bound, idempotent project-role mutations. AUTH-11A adds eleven @@ -479,7 +481,7 @@ A mapping is not a permission alias. | `WS-AUTH-001-ART-02D-OPERATOR` | `artifact.binding.read`, `artifact.replica.read`, `artifact.receipt.read`, `artifact.verification_job.read`, `artifact.verification_job.retry`, `artifact.recovery_attempt.read`, `artifact.audit.read`, `operations.artifact_storage_admission.read` | | `WS-XINT-002-04A` | Active: `artifact.guide_source.ingest` | | `WS-XINT-002-04B` | Active: `artifact.guide_source.read`, `artifact.guide_source.binding.create` | -| `WS-XINT-002-05A` | `artifact.submission_bundle.prepare`; registry custody retained while replacement implementation chunk WS-ARCH-001-02G performs activation | +| `WS-XINT-002-05A` | Active `artifact.submission_bundle.prepare`; registry custody retained while replacement implementation chunk WS-ARCH-001-02G supplies the executable PREP boundary | | `WS-XINT-002-06A` | `artifact.pre_submit.checker_input.materialize` | | `WS-AUTH-001-ART-05` | `artifact.submission.binding.create`; registry custody retained while replacement implementation chunk WS-ARCH-001-02H performs activation | | `WS-XINT-002-06B` | `artifact.post_submit.checker_input.materialize`, `artifact.checker_output.write`, `artifact.checker_output.binding.create` | @@ -499,8 +501,9 @@ planned and unavailable unless a later REV-owned intent explicitly approves it. The `OPERATOR` suffix names future activation custody only; it creates no Operator grant or entitlement. WS-XINT-002-03 activates the three internal service actions, WS-XINT-002-04A activates guide-source ingest, and -WS-XINT-002-04B activates the two fixed-service guide binding/read actions; the -other 16 ART actions remain planned and unavailable. +WS-XINT-002-04B activates the two fixed-service guide binding/read actions, and +WS-ARCH-001-02G activates contributor bundle preparation; the other 15 ART +actions remain planned and unavailable. The v0.1 baseline admits the exact privacy-bounded ART resource-context digest in append-only authorization decision facts; it adds no table or column. `artifact.verification_job.retry` requires its own later evaluator, guards, and