diff --git a/.agent-loop/CURRENT_STATE.md b/.agent-loop/CURRENT_STATE.md index 32310849c..9c28356ff 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) | Complete through `WS-ARCH-001-02G` on merge, including activation of only contributor bundle preparation | Begin the separately bounded `02H` consumption/binding activation | +| [WS-ARCH-001](initiatives/WS-ARCH-001-modular-monolith-boundaries/STATUS.md) | Complete through `WS-ARCH-001-02H` on merge, including hidden preparation and consumption/binding activation | Repair the split 03/04/05 prerequisites before the deferred `02I` public clean cut | | [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 711876be6..5713c0d55 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 @@ -12,7 +12,7 @@ | `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 | Complete on merge | -| `WS-ARCH-001-02H` | AUTH human/fixed-service consumption activation | L1 | Next after 02G merges | +| `WS-ARCH-001-02H` | AUTH human/fixed-service consumption activation | L1 | Complete on merge; public route remains unchanged | | `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 b068b0a11..876d1de3b 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 @@ -32,6 +32,9 @@ transaction; production remains deny-only and route-unreachable. - Complete on merge: WS-ARCH-001-02G AUTH contributor-preparation activation. Open pull requests show transient ownership. +- Complete on merge: WS-ARCH-001-02H activates exact human Submission + consumption and fixed ART binding authority against the hidden atomic + transaction. The public route remains unchanged. - Repository housekeeping after PR #315 found no competing clean-up initiative: WS-ARCH-001 remains the general boundary owner, WS-AUTH-003 owns AUTH-specific debt, and test-structure repairs remain incremental with the diff --git a/.agent-loop/initiatives/WS-ARCH-001-modular-monolith-boundaries/chunks/WS-ARCH-001-02H-auth-consumption-activation.md b/.agent-loop/initiatives/WS-ARCH-001-modular-monolith-boundaries/chunks/WS-ARCH-001-02H-auth-consumption-activation.md index 1325b3044..4f3ad5fdb 100644 --- a/.agent-loop/initiatives/WS-ARCH-001-modular-monolith-boundaries/chunks/WS-ARCH-001-02H-auth-consumption-activation.md +++ b/.agent-loop/initiatives/WS-ARCH-001-modular-monolith-boundaries/chunks/WS-ARCH-001-02H-auth-consumption-activation.md @@ -1,5 +1,9 @@ # Chunk Contract: WS-ARCH-001-02H — AUTH Submission Consumption Activation +## Merge state + +- Outcome on merge: `complete` + ## Parent initiative WS-ARCH-001 — Modular Monolith Boundaries @@ -38,17 +42,46 @@ admission/binding, and 02F transaction manifests against current `main`. ```text backend/app/modules/authorization/api/** backend/app/modules/authorization/**/submission*.py +backend/app/modules/authorization/submission_consumption.py +backend/app/modules/authorization/submission_creation_authorization.py +backend/app/modules/authorization/catalogue.py +backend/app/modules/authorization/artifact_project_authority.py +backend/app/modules/authorization/kernel.py +backend/app/modules/authorization/prepared.py +backend/app/modules/authorization/runtime.py backend/app/adapters/auth/** +backend/app/adapters/tasks/__init__.py +backend/app/adapters/artifacts/__init__.py +backend/app/api/deps/authorization.py +backend/app/modules/artifacts/authorization.py +backend/app/modules/tasks/api/submission_command.py +backend/app/modules/tasks/submission_composition.py +backend/app/main.py backend/alembic/versions/.py backend/tests/test_authorization.py -backend/tests/test_submission_concurrency.py +backend/tests/test_submission_composition.py +backend/tests/test_submission_preparation_authorization.py +backend/tests/test_artifact_bindings.py +backend/tests/test_artifact_bindings_db.py +backend/tests/test_audit.py +backend/tests/authorization/test_fixed_service_action_context.py backend/tests/architecture/test_authorization_boundary.py +backend/scripts/behavior_ownership.py +backend/scripts/run_test_lanes.py +.ci/behavior-ownership/partition.v1.json +.agent-loop/initiatives/WS-AUTH-003-module-boundary-recovery/TEST_STRUCTURE_DEBT.json +.agent-loop/CURRENT_STATE.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/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-ARCH-001-modular-monolith-boundaries/chunks/WS-ARCH-001-02H-auth-consumption-activation.md .agent-loop/initiatives/WS-ARCH-001-modular-monolith-boundaries/evidence/WS-ARCH-001-02H-consumption-activation.md +.agent-loop/initiatives/WS-ARCH-001-modular-monolith-boundaries/reviews/WS-ARCH-001-02H-external-review-response.md docs/spec_authorization_service.md +docs/operations_authorization_service.md +docs/roadmap_status.md ``` ## Not allowed @@ -75,8 +108,12 @@ TASK persistence; human substitution for fixed service authority. ## 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_concurrency.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/modules/artifacts/authorization.py app/modules/tasks/api/submission_command.py app/modules/tasks/submission_composition.py app/adapters/auth app/adapters/tasks app/adapters/artifacts app/api/deps/authorization.py app/main.py tests/test_authorization.py tests/test_submission_composition.py tests/test_submission_preparation_authorization.py tests/test_artifact_bindings.py tests/test_artifact_bindings_db.py tests/authorization/test_fixed_service_action_context.py tests/architecture/test_authorization_boundary.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_composition.py tests/test_submission_preparation_authorization.py tests/test_artifact_bindings.py tests/test_artifact_bindings_db.py tests/authorization/test_fixed_service_action_context.py tests/architecture/test_authorization_boundary.py --cov=app.modules.authorization --cov-fail-under=90) +(cd backend && export WORKSTREAM_TEST_DATABASE_URL="${WORKSTREAM_TEST_DATABASE_URL:?set WORKSTREAM_TEST_DATABASE_URL}" && .venv/bin/python -m pytest -q tests/test_artifact_bindings.py tests/test_artifact_bindings_db.py tests/test_submission_composition.py --cov=app.modules.artifacts --cov-fail-under=90) +(cd backend && export WORKSTREAM_TEST_DATABASE_URL="${WORKSTREAM_TEST_DATABASE_URL:?set WORKSTREAM_TEST_DATABASE_URL}" && .venv/bin/python -m pytest -q tests/test_submission_composition.py --cov=app.modules.tasks --cov-fail-under=90) +gh pr checks --watch +(cd backend && .venv/bin/python -m scripts.test_structure_boundary validate --policy ../.agent-loop/initiatives/WS-AUTH-003-module-boundary-recovery/TEST_STRUCTURE_POLICY.md --ledger ../.agent-loop/initiatives/WS-AUTH-003-module-boundary-recovery/TEST_STRUCTURE_DEBT.json) python3 scripts/check_stale_authorization_docs.py (cd backend && .venv/bin/python -m scripts.module_boundaries validate --protected-base origin/main) python3 scripts/check_markdown_links.py diff --git a/.agent-loop/initiatives/WS-ARCH-001-modular-monolith-boundaries/evidence/WS-ARCH-001-02H-consumption-activation.md b/.agent-loop/initiatives/WS-ARCH-001-modular-monolith-boundaries/evidence/WS-ARCH-001-02H-consumption-activation.md new file mode 100644 index 000000000..2eeb63cd1 --- /dev/null +++ b/.agent-loop/initiatives/WS-ARCH-001-modular-monolith-boundaries/evidence/WS-ARCH-001-02H-consumption-activation.md @@ -0,0 +1,37 @@ +# WS-ARCH-001-02H Consumption Activation Evidence + +## Activated authority + +- Human `submission.create` is active only for the exact human contributor + holding an active submitter project-role grant. +- Fixed-service `artifact.submission.binding.create` is active only for + `workstream.artifact.binding` through the existing static service matrix. +- No new action, permission, service identity, route, or persistence schema is + introduced. + +## Exact human facts + +TASK passes its already-locked immutable submission context into final AUTH +consumption. AUTH binds the decision to the actor, project, task, assignment, +admission, predecessor identity/version, Submission identity/version, lifecycle +kind/status, guide/source snapshot, effective artifact policy, and pre-submit +checker policy. AUTH independently locks the actor, identity link, and active +submitter project-role grant. + +## Exact fixed-service facts + +ART binds the fixed-service decision to the admission, evidence set, actor and +identity link attribution, project, task, assignment, predecessor, Submission, +guide/source/policy lineage, semantic manifest, immutable content digest and +size, and `submission_bundle_original` logical role. + +## Transaction and denial + +Both decisions use the existing opaque process-local PREP protocol in the one +02F root transaction. Handles remain non-copyable, non-serializable, +session-bound, transaction-bound, action-bound, resource-bound, and single-use. +Authorization evidence is written by AUTH in that transaction. Any human or +service denial, cancellation, persistence failure, or transaction rollback +rolls back the provisional Submission, binding, admission transition, and +authorization evidence together. The public Submission route remains +unchanged. diff --git a/.agent-loop/initiatives/WS-ARCH-001-modular-monolith-boundaries/reviews/WS-ARCH-001-02H-external-review-response.md b/.agent-loop/initiatives/WS-ARCH-001-modular-monolith-boundaries/reviews/WS-ARCH-001-02H-external-review-response.md new file mode 100644 index 000000000..d718efd8e --- /dev/null +++ b/.agent-loop/initiatives/WS-ARCH-001-modular-monolith-boundaries/reviews/WS-ARCH-001-02H-external-review-response.md @@ -0,0 +1,39 @@ +# WS-ARCH-001-02H External Review Response + +## Comments addressed + +- Expanded the chunk verification contract to retain the repository-wide 78% + gate and require 90% coverage for AUTH, ART, and TASK. +- Moved strict Submission creation resource construction inside its owner + translation boundary and moved fixed-service ART resource construction inside + the ART translation boundary. +- Changed revoked-link proof to raise the canonical + `PreparedAuthorizationUnsupported` lifecycle denial before asserting the + concealed ART error and zero database effect. +- Changed service-matrix proof to use active `project.read`, which the artifact + binding service does not own. +- Kept 02H under Integration In Progress until the PR merges. +- Split the oversized PostgreSQL proof and extracted new AUTH behavior into + bounded submission-specific modules. The structural-debt ledger records only + measured shrinkage and the validator rejects new or growing debt. + +## Comments deferred + +None. + +## Human decisions needed + +None. + +## Commands rerun + +- Focused Ruff checks. +- AUTH module-boundary and behavior-ownership validators. +- AUTH structural-debt validator. +- Stale authorization wording and Markdown-link checks. +- Isolated PostgreSQL focused suites and coverage gates. +- Hosted GitHub Actions exact-head lanes: pending the final pushed correction. + +## Remaining risks + +The public Submission route remains unchanged and gated by 02I. 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 8d6812399..c498f3080 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 @@ -46,7 +46,7 @@ mappings, and availability must remain identical. | `WS-XINT-002-04A` | Active: `artifact.guide_source.ingest` | | `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-AUTH-001-ART-05` | Active on WS-ARCH-001-02H merge: `artifact.submission.binding.create`; only the fixed artifact-binding service may consume it | | `WS-XINT-002-06B` | Planned: `artifact.post_submit.checker_input.materialize`, `artifact.checker_output.write`, `artifact.checker_output.binding.create` | | `WS-XINT-002-07A` | Planned: `artifact.review_packet.materialize` only | | Future REV-owned activation, not approved for v0.1 | Planned/unavailable: `artifact.review_evidence.binding.create` | @@ -60,15 +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. Fifteen actions remain planned after the three ART foundation +entitlement. Fourteen 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 +binding/read actions, `artifact.submission_bundle.prepare`, and fixed-service +`artifact.submission.binding.create` 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 current catalogue -has 73 PermissionIds, 102 ActionIds, 55 active actions, and 47 planned actions, +has 73 PermissionIds, 102 ActionIds, 57 active actions, and 45 planned actions, with fourteen fixed-service identities and twenty-two matrix memberships. ## REV custody transfer diff --git a/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/CHUNK_MAP.md b/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/CHUNK_MAP.md index 1d26d4454..2de5b062c 100644 --- a/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/CHUNK_MAP.md +++ b/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/CHUNK_MAP.md @@ -94,7 +94,7 @@ feature manifest exists and then uses the ordinary bounded contribution loop. | `WS-AUTH-001-ART-03` | ART 03 Guide Source Action Activation | L1 | Feature-gated | | `WS-XINT-002-06A` | Pre-Submit Materialization Activation | L1 | Merged through PR #293; ART-04C1 is its next ART consumer | | `WS-XINT-002-05A` | Historical Submission Bundle Preparation Activation | L1 | Superseded/non-executable; replacement WS-ARCH-001-02G requires 02A-02F | -| `WS-XINT-002-05B` | Historical Submission Binding Activation | L1 | Superseded/non-executable; replacement WS-ARCH-001-02H requires the hidden 02F transaction and 02G | +| `WS-XINT-002-05B` | Historical Submission Binding Activation | L1 | Superseded/non-executable; replacement WS-ARCH-001-02H is complete on merge | | `WS-XINT-002-06B` | Post-Submit Materialization And Checker Output Activation | L1 | Feature-gated on POL-06B/07 unified plan plus ART-06A/06B evidence | | `WS-AUTH-001-REV-05` | REV 05 Queue Read Activation | L1 | Feature-gated | | `WS-AUTH-001-REV-06` | REV 06 Claim Lease And Expiry Activation | L1 | Feature/service-gated | 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 e399c4ca7..65ad448a3 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": "e708ad45f832e909e52d63daed72ed8a75ca9b9ebc336af3ca9049254a3aec8f", - "end_line": 1587, + "content_sha256": "b0ea9af1158200255cffa1a39216500024e50f6d5c64e7f6fdceb399af3db32f", + "end_line": 1583, "hard_limit": 1200, "kind": "production_file", - "observed_lines": 1587, + "observed_lines": 1583, "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": "e7c45706df1b1a958512e82d718a3254fdb39f1a27437c3eb49689154c6a8f4e", - "end_line": 1692, + "content_sha256": "1038fbc2b306f00958ea95f4f2d52cee4850cdaf672621872ca69351e73af5c0", + "end_line": 1691, "hard_limit": 1200, "kind": "production_file", - "observed_lines": 1692, + "observed_lines": 1691, "path": "backend/app/modules/authorization/runtime.py", "qualified_symbol": null, "removal_chunk": "WS-AUTH-003-CLOSE", @@ -38,35 +38,35 @@ }, { "capability": "unassigned_legacy_auth", - "content_sha256": "17251cc63f24b1cd34969fc192848459d4800ab2877e85d9244e468b4c82dfb7", - "end_line": 1114, + "content_sha256": "0a1e990ef039786066276283bc519669a9274aceb2949e1d320f93e75203926e", + "end_line": 1124, "hard_limit": 100, "kind": "production_function", - "observed_lines": 126, + "observed_lines": 120, "path": "backend/app/modules/authorization/catalogue.py", "qualified_symbol": "_index_service_actions", "removal_chunk": "WS-AUTH-003-CLOSE", - "start_line": 989 + "start_line": 1005 }, { "capability": "unassigned_legacy_auth", - "content_sha256": "656952eb7af29cb9086f6c35a80bbfd5456c613d98647d37ad54c7a784fab1f2", + "content_sha256": "8d244d50995874f504894c53f39a2cf465f22407a4cb6e508b808cf3d8241067", "end_line": 597, "hard_limit": 100, "kind": "production_function", - "observed_lines": 205, + "observed_lines": 203, "path": "backend/app/modules/authorization/kernel.py", "qualified_symbol": "AuthorizationService._prepare_prelocked", "removal_chunk": "WS-AUTH-003-CLOSE", - "start_line": 393 + "start_line": 395 }, { "capability": "unassigned_legacy_auth", - "content_sha256": "dab30f827a5971d03bf2cd59024930e8ece15fef21c67a04586b65addd534139", - "end_line": 1102, + "content_sha256": "dd54f160d45efecadc0cdcb33e7271c17e34945d3c6cdbc7b1736381c28eb7a5", + "end_line": 1098, "hard_limit": 100, "kind": "production_function", - "observed_lines": 169, + "observed_lines": 165, "path": "backend/app/modules/authorization/kernel.py", "qualified_symbol": "AuthorizationService._require_prelocked", "removal_chunk": "WS-AUTH-003-CLOSE", @@ -75,26 +75,26 @@ { "capability": "unassigned_legacy_auth", "content_sha256": "8cb806aad12f5010d1e8875c4ba31eee43856a120e338d31a02d87e1ea30ad0c", - "end_line": 1587, + "end_line": 1583, "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": 1463 + "start_line": 1459 }, { "capability": "unassigned_legacy_auth", - "content_sha256": "bd049d790d8e3baa1529967fad4b11c60537942958b6cb6755730db13982b7cf", - "end_line": 842, + "content_sha256": "168c4f0e965a8ec1090dc2365df4d54a2ff793c462f729a2dda3786f175d7a01", + "end_line": 859, "hard_limit": 100, "kind": "production_function", - "observed_lines": 281, + "observed_lines": 278, "path": "backend/app/modules/authorization/prepared.py", "qualified_symbol": "PreparedAuthorizationService._binding", "removal_chunk": "WS-AUTH-003-CLOSE", - "start_line": 562 + "start_line": 582 }, { "capability": "unassigned_legacy_auth", @@ -194,11 +194,11 @@ }, { "capability": "unassigned_legacy_auth", - "content_sha256": "d10520f1cfc34bc31b7dd5e00d66bb7705b8eedc004cae1a2b606486e98bc22c", - "end_line": 1805, + "content_sha256": "2a4de4b91958afda6dc057f8a3b706990aa3fabf809dd84702dd980fd4fd639f", + "end_line": 1804, "hard_limit": 1200, "kind": "test_file", - "observed_lines": 1805, + "observed_lines": 1804, "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": "8c3c604f433fc0816f6c3c096a8e32ebc056268cee70f89bd99d7c46be6a4503", - "end_line": 13417, + "content_sha256": "b208f0ad84feb415e88445c96cd7341b09432b04e39b23bddef0c772221640bf", + "end_line": 13407, "hard_limit": 1200, "kind": "test_file", - "observed_lines": 13417, + "observed_lines": 13407, "path": "backend/tests/test_authorization.py", "qualified_symbol": null, "removal_chunk": "WS-AUTH-003-CLOSE", @@ -386,11 +386,11 @@ }, { "capability": "unassigned_legacy_auth", - "content_sha256": "03e9a4232e9f0cd24a5c106557f51abae9a9ab0eb4771519cdfd72f53b14c9d5", - "end_line": 254, + "content_sha256": "825d122c0def8a2de8298e85e4922da14d8072b7d3a19132590b57a9599d3268", + "end_line": 253, "hard_limit": 120, "kind": "test_function", - "observed_lines": 130, + "observed_lines": 129, "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": 754, + "end_line": 753, "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": 566 + "start_line": 565 }, { "capability": "unassigned_legacy_auth", "content_sha256": "36820d7d6c9db7dac729710d8d87ab233d4882febd049e862961449550b52e8e", - "end_line": 1315, + "end_line": 1314, "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": 1106 + "start_line": 1105 }, { "capability": "unassigned_legacy_auth", @@ -531,182 +531,182 @@ { "capability": "unassigned_legacy_auth", "content_sha256": "951b44cc07e36002118fe93b7974e8d65851b0e2c3cec89a3031cbe42b014e2d", - "end_line": 8517, + "end_line": 8513, "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": 8378 + "start_line": 8374 }, { "capability": "unassigned_legacy_auth", "content_sha256": "569084d6de89ff9eae71d526fc6c157aea7638f55ca93ad128b721fbda339be6", - "end_line": 9007, + "end_line": 9003, "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": 8886 + "start_line": 8882 }, { "capability": "unassigned_legacy_auth", "content_sha256": "90b8b670b4d277209617cc1aa794188b4fb3cd9d894b69d5dc3d0adfc885f6ed", - "end_line": 9235, + "end_line": 9231, "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": 9104 + "start_line": 9100 }, { "capability": "unassigned_legacy_auth", "content_sha256": "78d9bf3df08e5633e9a75720b1e4bf5b7d7b24019bc392b4cb7496a9ac5e5e8e", - "end_line": 11069, + "end_line": 11059, "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": 10948 + "start_line": 10938 }, { "capability": "unassigned_legacy_auth", - "content_sha256": "e4c5f03e4cbde880efc7a8bcda444e686946f169c87eca4b19ec8ca308940cde", - "end_line": 2248, + "content_sha256": "e4d94ac97842b84c9b64d46a0a86801294a90eeca7b074de5b281c8014a8fcb5", + "end_line": 2243, "hard_limit": 120, "kind": "test_function", - "observed_lines": 324, + "observed_lines": 322, "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": 1925 + "start_line": 1922 }, { "capability": "unassigned_legacy_auth", "content_sha256": "df1df6ec2ba6aa55445e21cfcf4e4e3ad9664c9504313484a495b6a6df1e9047", - "end_line": 3477, + "end_line": 3473, "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": 3352 + "start_line": 3348 }, { "capability": "unassigned_legacy_auth", "content_sha256": "05d1b020ecff0f9bc0a0567adc07f5b31a2f9dfb7828ae3ad34d4e1e7757797c", - "end_line": 8670, + "end_line": 8666, "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": 8520 + "start_line": 8516 }, { "capability": "unassigned_legacy_auth", "content_sha256": "c5d6d0d480ced964354915614f15202c0159bfbb281658da0d44186dffd17848", - "end_line": 7465, + "end_line": 7461, "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": 7324 + "start_line": 7320 }, { "capability": "unassigned_legacy_auth", "content_sha256": "ebb76e63671195aa4d806ac602bfe58cb22bf82d00c8a70ab186785f3acd7f9b", - "end_line": 7806, + "end_line": 7802, "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": 7471 + "start_line": 7467 }, { "capability": "unassigned_legacy_auth", "content_sha256": "9ea4fc0ddbab4c7262a43bc3f498ee1afea3318e9a0b6c93c87763f9f22aae9c", - "end_line": 7296, + "end_line": 7292, "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": 6779 + "start_line": 6775 }, { "capability": "unassigned_legacy_auth", "content_sha256": "46e0b031394da6fee856e48615732a17ff8d2276d9cedfb0ecaa3a6879410adb", - "end_line": 4682, + "end_line": 4678, "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": 4526 + "start_line": 4522 }, { "capability": "unassigned_legacy_auth", "content_sha256": "e95d2a03e3829df89c43210a9de92efca31a25905c9648c3179d17b078386b17", - "end_line": 2649, + "end_line": 2644, "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": 2253 + "start_line": 2248 }, { "capability": "unassigned_legacy_auth", "content_sha256": "105667302ed6e8f2fd16ea7e95d642e72e41514673e1152503536e0520f9c362", - "end_line": 10944, + "end_line": 10934, "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": 10741 + "start_line": 10731 }, { "capability": "unassigned_legacy_auth", "content_sha256": "756b7f99f9a743284934b4d85ce263617710d9b8119792ccb4526a1de04070a1", - "end_line": 12446, + "end_line": 12436, "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": 12214 + "start_line": 12204 }, { "capability": "unassigned_legacy_auth", "content_sha256": "2e1d7db74ddb955b90a0ee12e4fb72b651a0f85d2746c76e09079c05b0b85252", - "end_line": 13417, + "end_line": 13407, "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": 12737 + "start_line": 12727 }, { "capability": "unassigned_legacy_auth", @@ -735,14 +735,14 @@ { "capability": "unassigned_legacy_auth", "content_sha256": "05621e885ed2f88d0ba1a072c1f263fc923939f7ce755a514e9872112aa830a1", - "end_line": 11963, + "end_line": 11953, "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": 11801 + "start_line": 11791 }, { "capability": "unassigned_legacy_auth", @@ -1011,26 +1011,26 @@ { "capability": "unassigned_legacy_auth", "content_sha256": "efca18e30f3e8e9d7b16310a956b427262dc92bdf13165146b58523b25ea2687", - "end_line": 520, + "end_line": 519, "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": 293 + "start_line": 292 }, { "capability": "unassigned_legacy_auth", "content_sha256": "1a0a9f3e2be6965e29f76aa74272ccfa2fe4b99e4e0c3bde5ec8ba2c374b369b", - "end_line": 11277, + "end_line": 11267, "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": 11109 + "start_line": 11099 }, { "capability": "unassigned_legacy_auth", diff --git a/.agent-loop/initiatives/WS-XINT-002-art-auth-end-to-end/CHUNK_MAP.md b/.agent-loop/initiatives/WS-XINT-002-art-auth-end-to-end/CHUNK_MAP.md index 83046414f..6fe307305 100644 --- a/.agent-loop/initiatives/WS-XINT-002-art-auth-end-to-end/CHUNK_MAP.md +++ b/.agent-loop/initiatives/WS-XINT-002-art-auth-end-to-end/CHUNK_MAP.md @@ -9,7 +9,7 @@ | `04B` | Activate fixed-service guide binding and read. | L1 | Merged/active in PR #245 (`6babf81b`) | | `06A` | Activate only pre-submit checker-input materialization. | L1 | Merged through PR #293; precedes ART-04C1 and 05A | | `05A` | Historical initial contributor preparation activation proposal. | L1 | Superseded/non-executable; replacement activation is WS-ARCH-001-02G after 02A-02F | -| `05B` | Historical Submission binding/consumption proposal. | L1 | Superseded/non-executable; replacement activation/cutover is WS-ARCH-001-02H/02I | +| `05B` | Historical Submission binding/consumption proposal. | L1 | Superseded/non-executable; replacement activation is complete through WS-ARCH-001-02H and public cutover remains 02I | | `05C` | Historical checker-remediation submission context proposal. | L1 | Superseded/non-executable; future exact WS-ARCH-001-04 replacement requires 02H and must merge before 02I | | `05D` | Historical human-review revision context proposal. | L1 | Superseded/non-executable; future exact WS-ARCH-001-05 replacement requires 02H and must merge before 02I | | `06B` | Activate post-submit materialization and checker output write/binding. | L1 | Non-executable pending POL/ART evidence plus WS-ARCH-001 checker capability contract | diff --git a/.ci/behavior-ownership/partition.v1.json b/.ci/behavior-ownership/partition.v1.json index 03c8ae7e4..800245d0c 100644 --- a/.ci/behavior-ownership/partition.v1.json +++ b/.ci/behavior-ownership/partition.v1.json @@ -464,6 +464,14 @@ "group": "auth", "target": "backend/app/modules/authorization/service_actor_service.py" }, + { + "group": "auth", + "target": "backend/app/modules/authorization/submission_consumption.py" + }, + { + "group": "auth", + "target": "backend/app/modules/authorization/submission_creation_authorization.py" + }, { "group": "auth", "target": "backend/app/modules/authorization/submission_preparation.py" @@ -837,7 +845,7 @@ "target": "backend/scripts/week2_api_e2e.py" } ], - "authority_digest": "d01214349f276a98624b34236b0b163c82cb68bc8426c0137e4aa71c6471c024", + "authority_digest": "8ee0aa0c7ab30e28e3f7a665b66bcfd981d220197f783e5fb72a000bd2948cb4", "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 f1cf9108d..2359a6b69 100644 --- a/backend/app/adapters/artifacts/__init__.py +++ b/backend/app/adapters/artifacts/__init__.py @@ -240,6 +240,26 @@ def get_submission_bundle_preparation_authorization( return PreparedSubmissionBundlePreparationAuthorization(session, context) +def submission_admission_consumption_port( + session: AsyncSession, + *, + request_id: UUID, + correlation_id: UUID, +): + """Compose the active fixed-service ART participant for the hidden transaction.""" + from app.modules.artifacts.authorization import PreparedSubmissionBindingAuthorization + from app.modules.artifacts.submission_bindings import SubmissionAdmissionConsumptionService + + return SubmissionAdmissionConsumptionService( + session, + PreparedSubmissionBindingAuthorization( + session, + request_id=request_id, + correlation_id=correlation_id, + ), + ) + + def get_submission_bundle_preparation_command( request: Request, session: Annotated[AsyncSession, Depends(get_db_session)], diff --git a/backend/app/adapters/tasks/__init__.py b/backend/app/adapters/tasks/__init__.py index 40697c057..879f39930 100644 --- a/backend/app/adapters/tasks/__init__.py +++ b/backend/app/adapters/tasks/__init__.py @@ -39,10 +39,19 @@ async def authorize(self, facts: SubmissionCreationPreparationFacts) -> None: del facts raise SubmissionCreationUnavailable("submission creation is unavailable") - async def consume(self, facts: SubmissionCreationAuthorityFacts) -> None: + async def prepare(self, facts: SubmissionCreationAuthorityFacts) -> object: del facts raise SubmissionCreationUnavailable("submission creation is unavailable") + async def consume( + self, prepared_authorization: object, facts: SubmissionCreationAuthorityFacts + ) -> None: + del prepared_authorization, facts + raise SubmissionCreationUnavailable("submission creation is unavailable") + + def close(self, prepared_authorization: object) -> None: + del prepared_authorization + class _ArtifactAdmissionAdapter: def __init__(self, admissions: SubmissionAdmissionConsumptionPort) -> None: diff --git a/backend/app/api/deps/authorization.py b/backend/app/api/deps/authorization.py index e8394d408..f14a08b90 100644 --- a/backend/app/api/deps/authorization.py +++ b/backend/app/api/deps/authorization.py @@ -53,6 +53,31 @@ logger = logging.getLogger(__name__) +def compose_hidden_submission_creation_command( + session: AsyncSession, + context: AuthorizationContext, + *, + request_id: UUID, + correlation_id: UUID, +): + """Wire the active hidden TASK/ART transaction without exposing a route.""" + from app.adapters.artifacts import submission_admission_consumption_port + from app.adapters.tasks import TransactionalSubmissionCreationCommand + from app.modules.authorization.prepared import ( + PreparedSubmissionCreationAuthorization, + ) + + return TransactionalSubmissionCreationCommand( + session, + authorization=PreparedSubmissionCreationAuthorization(session, context), + admissions=submission_admission_consumption_port( + session, + request_id=request_id, + correlation_id=correlation_id, + ), + ) + + def _authorization_context( resolved: ResolvedActor, request_id: UUID, diff --git a/backend/app/modules/artifacts/authorization.py b/backend/app/modules/artifacts/authorization.py index c8c824963..915c1ce5d 100644 --- a/backend/app/modules/artifacts/authorization.py +++ b/backend/app/modules/artifacts/authorization.py @@ -32,11 +32,14 @@ PreSubmitMaterializationAuthorityFacts, PreSubmitMaterializationPreparationFacts, ) +from app.modules.artifacts.submission_bindings import SubmissionBindingAuthorityFacts +from app.modules.artifacts.api import SubmissionAdmissionConsumptionError 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, PreparedAuthorizationService, + fixed_service_action_context, fixed_service_authorization_context, fixed_service_context_revalidator, ) @@ -62,6 +65,7 @@ AuthorizationContext, SubmissionBundlePreparationPreflightResourceContext, SubmissionBundlePreparationResourceContext, + SubmissionBindingResourceContext, ) @@ -570,6 +574,7 @@ def __init__( | GuideSourceReadAuthorityFacts | PreSubmitMaterializationAuthorityFacts | PreSubmitMaterializationPreparationFacts + | SubmissionBindingAuthorityFacts | None ) = None @@ -578,21 +583,22 @@ async def prepare( *, facts: GuideSourceBindingAuthorityFacts | GuideSourceReadAuthorityFacts - | PreSubmitMaterializationPreparationFacts, + | PreSubmitMaterializationPreparationFacts + | SubmissionBindingAuthorityFacts, idempotency_key: UUID, ) -> PreparedAuthorizationHandle: """Prepare one process-local capability bound to every canonical fact.""" if self._prepared is not None: raise ArtifactAuthorityDeniedError("artifact service authority is invalid") - resource = _artifact_service_resource_context(facts) try: + resource = _artifact_service_resource_context(facts) context = await fixed_service_authorization_context( self._session, self._service_identity, self._request_id, self._correlation_id, ) - except PreparedAuthorizationUnsupported as exc: + except (PreparedAuthorizationUnsupported, ValidationError) as exc: raise ArtifactAuthorityDeniedError("artifact service principal is unavailable") from exc repository = AdminAuthorizationRepository(self._session) @@ -639,7 +645,8 @@ async def consume( prepared_authorization: PreparedAuthorizationHandle, facts: GuideSourceBindingAuthorityFacts | GuideSourceReadAuthorityFacts - | PreSubmitMaterializationAuthorityFacts, + | PreSubmitMaterializationAuthorityFacts + | SubmissionBindingAuthorityFacts, ) -> None: """Consume only the exact handle and facts prepared by this adapter.""" if ( @@ -689,6 +696,52 @@ def __init__(self, session: AsyncSession, *, request_id: UUID, correlation_id: U ) +class PreparedSubmissionBindingAuthorization: + """Prepare and consume exact authority for the fixed artifact binding service.""" + + def __init__(self, session: AsyncSession, *, request_id: UUID, correlation_id: UUID) -> None: + self._session = session + self._request_id = request_id + self._correlation_id = correlation_id + self._delegate = _PreparedArtifactServiceAuthorization( + session, + service_identity=ServiceIdentity.ARTIFACT_BINDING, + action_id=ActionId.ARTIFACT_SUBMISSION_BINDING_CREATE, + request_id=request_id, + correlation_id=correlation_id, + ) + + async def authorize(self, request) -> None: + """Resolve the exact active fixed service before ART state is revealed.""" + del request + try: + await fixed_service_action_context( + self._session, + service_identity=ServiceIdentity.ARTIFACT_BINDING, + action_id=ActionId.ARTIFACT_SUBMISSION_BINDING_CREATE, + request_id=self._request_id, + correlation_id=self._correlation_id, + ) + except PreparedAuthorizationUnsupported as exc: + raise SubmissionAdmissionConsumptionError( + "submission_bundle_admission_unavailable" + ) from exc + + async def consume(self, facts: SubmissionBindingAuthorityFacts) -> None: + """Prepare and immediately consume within the protected transaction.""" + try: + handle = await self._delegate.prepare( + facts=facts, idempotency_key=facts.admission_id + ) + await self._delegate.consume(prepared_authorization=handle, facts=facts) + except ArtifactAuthorityDeniedError as exc: + raise SubmissionAdmissionConsumptionError( + "submission_bundle_admission_unavailable" + ) from exc + finally: + self._delegate.close() + + class PreparedGuideSourceReadAuthorization(_PreparedArtifactServiceAuthorization): """Prepared authority reserved to the fixed guide reader service.""" @@ -745,12 +798,14 @@ def _artifact_service_resource_context( facts: GuideSourceBindingAuthorityFacts | GuideSourceReadAuthorityFacts | PreSubmitMaterializationAuthorityFacts - | PreSubmitMaterializationPreparationFacts, + | PreSubmitMaterializationPreparationFacts + | SubmissionBindingAuthorityFacts, ) -> ( GuideSourceBindingResourceContext | GuideSourceReadResourceContext | PreSubmitCheckerInputResourceContext | PreSubmitCheckerInputPreparationContext + | SubmissionBindingResourceContext ): """Compose the canonical AUTH resource context for fixed ART service facts.""" values = asdict(facts) @@ -774,6 +829,12 @@ def _artifact_service_resource_context( resource_id=facts.guide_source_item_id, **values, ) + if isinstance(facts, SubmissionBindingAuthorityFacts): + return SubmissionBindingResourceContext( + resource_type="submission_binding", + resource_id=facts.admission_id, + **asdict(facts), + ) return GuideSourceReadResourceContext( resource_type="guide_source_read", resource_id=facts.binding_id, @@ -785,10 +846,12 @@ def _prepared_artifact_facts_match( prepared: GuideSourceBindingAuthorityFacts | GuideSourceReadAuthorityFacts | PreSubmitMaterializationPreparationFacts - | PreSubmitMaterializationAuthorityFacts, + | PreSubmitMaterializationAuthorityFacts + | SubmissionBindingAuthorityFacts, final: GuideSourceBindingAuthorityFacts | GuideSourceReadAuthorityFacts - | PreSubmitMaterializationAuthorityFacts, + | PreSubmitMaterializationAuthorityFacts + | SubmissionBindingAuthorityFacts, ) -> bool: """Require final facts to preserve every fact bound during preparation.""" if isinstance(prepared, PreSubmitMaterializationPreparationFacts): diff --git a/backend/app/modules/authorization/artifact_project_authority.py b/backend/app/modules/authorization/artifact_project_authority.py index 82ed42a47..2a6adc03e 100644 --- a/backend/app/modules/authorization/artifact_project_authority.py +++ b/backend/app/modules/authorization/artifact_project_authority.py @@ -10,6 +10,7 @@ PreparedAuthorizationUnsupported, PreparedAuthorityScopeKind, SubmissionBundlePreparationResourceContext, + SubmissionCreationResourceContext, ) @@ -85,7 +86,10 @@ def evaluate_submitter_authority(action, context, authority, resource, lifecycle 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): + elif denial is None and not isinstance( + resource, + (SubmissionBundlePreparationResourceContext, SubmissionCreationResourceContext), + ): denial = AuthorizationDenialCode.RESOURCE_GUARD_DENIED elif denial is None and resource.scope_project_id != authority.scope_project_id: denial = AuthorizationDenialCode.SCOPE_NOT_AUTHORIZED diff --git a/backend/app/modules/authorization/catalogue.py b/backend/app/modules/authorization/catalogue.py index 166d7c197..ae3528865 100644 --- a/backend/app/modules/authorization/catalogue.py +++ b/backend/app/modules/authorization/catalogue.py @@ -558,7 +558,7 @@ def _active( PermissionId.OPERATIONS_CHECKER_RETRY, ActionOwner.AUTH_14, ), - _planned(ActionId.SUBMISSION_CREATE, PermissionId.SUBMISSION_CREATE, ActionOwner.AUTH_14), + _active(ActionId.SUBMISSION_CREATE, PermissionId.SUBMISSION_CREATE, ActionOwner.AUTH_14), _planned(ActionId.REVIEW_QUEUE_READ, PermissionId.REVIEW_QUEUE_READ, ActionOwner.AUTH_REV_05), _planned( ActionId.REVIEW_QUEUE_INSPECT, @@ -714,7 +714,7 @@ def _active( PermissionId.ARTIFACT_BINDING_CREATE, ActionOwner.XINT_002_04B, ), - _planned( + _active( ActionId.ARTIFACT_SUBMISSION_BINDING_CREATE, PermissionId.ARTIFACT_BINDING_CREATE, ActionOwner.AUTH_ART_05, @@ -876,6 +876,8 @@ def _index_actions( ActionId.ARTIFACT_GUIDE_SOURCE_BINDING_CREATE, ActionId.ARTIFACT_GUIDE_SOURCE_READ, ActionId.ARTIFACT_SUBMISSION_BUNDLE_PREPARE, + ActionId.SUBMISSION_CREATE, + ActionId.ARTIFACT_SUBMISSION_BINDING_CREATE, ActionId.ARTIFACT_VERIFICATION_EXECUTE, ActionId.ARTIFACT_PENDING_WORK_SCAN, ActionId.ARTIFACT_PUT_ATTEMPT_RESOLVE, @@ -986,7 +988,23 @@ def _index_actions( ) -def _index_service_actions(rows: dict[ServiceIdentity, frozenset[ActionId]]) -> MappingProxyType[ServiceIdentity, frozenset[ActionId]]: +_ACTIVE_SERVICE_ACTIONS = { + ActionId.ARTIFACT_VERIFICATION_EXECUTE, + ActionId.ARTIFACT_PUT_ATTEMPT_RESOLVE, + ActionId.ARTIFACT_PENDING_WORK_SCAN, + ActionId.ARTIFACT_GUIDE_SOURCE_BINDING_CREATE, + ActionId.ARTIFACT_SUBMISSION_BINDING_CREATE, + ActionId.ARTIFACT_GUIDE_SOURCE_READ, + ActionId.ARTIFACT_PRE_SUBMIT_CHECKER_INPUT_MATERIALIZE, + ActionId.PROJECT_GUIDE_SUFFICIENCY_RUN, + ActionId.PROJECT_GUIDE_COMPILATION_EXECUTE, + ActionId.PROJECT_SUBMISSION_ARTIFACT_POLICY_DERIVE, +} + + +def _index_service_actions( + rows: dict[ServiceIdentity, frozenset[ActionId]], +) -> MappingProxyType[ServiceIdentity, frozenset[ActionId]]: expected_metadata = { ActionId.ARTIFACT_VERIFICATION_EXECUTE: ( PermissionId.ARTIFACT_VERIFICATION_EXECUTE, @@ -1081,7 +1099,10 @@ def _index_service_actions(rows: dict[ServiceIdentity, frozenset[ActionId]]) -> raise RuntimeError("service action matrix identity mismatch") if any(not actions for actions in rows.values()): raise RuntimeError("service action matrix row mismatch") - if frozenset((i, a) for i, actions in rows.items() for a in actions) != _EXPECTED_SERVICE_ACTION_MEMBERSHIPS: + if ( + frozenset((i, a) for i, actions in rows.items() for a in actions) + != _EXPECTED_SERVICE_ACTION_MEMBERSHIPS + ): raise RuntimeError("service action matrix row mismatch") if not FUTURE_INTENT_REQUIRED_ACTIONS.isdisjoint( action for actions in rows.values() for action in actions @@ -1091,18 +1112,7 @@ def _index_service_actions(rows: dict[ServiceIdentity, frozenset[ActionId]]) -> definition = ACTION_BY_ID[action] expected_availability = ( ActionAvailability.ACTIVE - if action - in { - ActionId.ARTIFACT_VERIFICATION_EXECUTE, - ActionId.ARTIFACT_PUT_ATTEMPT_RESOLVE, - ActionId.ARTIFACT_PENDING_WORK_SCAN, - ActionId.ARTIFACT_GUIDE_SOURCE_BINDING_CREATE, - ActionId.ARTIFACT_GUIDE_SOURCE_READ, - ActionId.ARTIFACT_PRE_SUBMIT_CHECKER_INPUT_MATERIALIZE, - ActionId.PROJECT_GUIDE_SUFFICIENCY_RUN, - ActionId.PROJECT_GUIDE_COMPILATION_EXECUTE, - ActionId.PROJECT_SUBMISSION_ARTIFACT_POLICY_DERIVE, - } + if action in _ACTIVE_SERVICE_ACTIONS else ActionAvailability.PLANNED ) if ( diff --git a/backend/app/modules/authorization/kernel.py b/backend/app/modules/authorization/kernel.py index 4b7d0cc66..c8b21cee3 100644 --- a/backend/app/modules/authorization/kernel.py +++ b/backend/app/modules/authorization/kernel.py @@ -54,6 +54,7 @@ ArtifactVerificationJobResourceContext, GuideSourceBindingResourceContext, GuideSourceReadResourceContext, + SubmissionBindingResourceContext, PreSubmitCheckerInputResourceContext, AdminRoleDefinitionsResourceContext, AdminRoleGrantCollectionResourceContext, @@ -206,6 +207,7 @@ "pre_submit_checker_input", PreSubmitCheckerInputResourceContext, ), + ActionId.ARTIFACT_SUBMISSION_BINDING_CREATE: ("submission_binding", SubmissionBindingResourceContext), ActionId.ARTIFACT_PUT_ATTEMPT_RESOLVE: ( "artifact_put_attempt", ArtifactPutAttemptResourceContext, @@ -559,10 +561,8 @@ async def _prepare_prelocked( AuthorizationDenialCode.PERMISSION_NOT_GRANTED ) elif action_id is ActionId.ARTIFACT_GUIDE_SOURCE_INGEST: - context, grant = await lock_guide_ingest_authority( - self._admin, context, scope, action.permission_id, self._locked_human_context - ) - elif action_id is ActionId.ARTIFACT_SUBMISSION_BUNDLE_PREPARE: + context, grant = await lock_guide_ingest_authority(self._admin, context, scope, action.permission_id, self._locked_human_context) + elif action_id in {ActionId.ARTIFACT_SUBMISSION_BUNDLE_PREPARE, ActionId.SUBMISSION_CREATE}: context, grant = await lock_submitter_authority( self._admin, context, scope, self._locked_human_context ) @@ -1078,15 +1078,11 @@ async def _require_prelocked( matched_grant_id = authority.matched_grant_id elif action_id is ActionId.ARTIFACT_GUIDE_SOURCE_INGEST: denial, matched_kind, matched_grant_id, matched_project_id = ( - evaluate_guide_ingest_authority( - action, authority, resource_context, self._lifecycle_denial(context) - ) + evaluate_guide_ingest_authority(action, authority, resource_context, self._lifecycle_denial(context)) ) - elif action_id is ActionId.ARTIFACT_SUBMISSION_BUNDLE_PREPARE: + elif action_id in {ActionId.ARTIFACT_SUBMISSION_BUNDLE_PREPARE, ActionId.SUBMISSION_CREATE}: denial, matched_kind, matched_grant_id, matched_project_id = ( - evaluate_submitter_authority( - action, context, authority, resource_context, self._lifecycle_denial(context) - ) + evaluate_submitter_authority(action, context, authority, resource_context, self._lifecycle_denial(context)) ) else: denial = AuthorizationDenialCode.ACTION_UNAVAILABLE diff --git a/backend/app/modules/authorization/prepared.py b/backend/app/modules/authorization/prepared.py index df5e3c6a6..5760b9d6a 100644 --- a/backend/app/modules/authorization/prepared.py +++ b/backend/app/modules/authorization/prepared.py @@ -15,7 +15,12 @@ from app.modules.actors.repository import ActorRepository from app.modules.actors.service_identities import ServiceIdentity from app.modules.audit.schemas import ActorReferenceKind -from app.modules.authorization.catalogue import ActionId +from app.modules.authorization.catalogue import ( + ACTION_BY_ID, + SERVICE_ACTIONS_BY_IDENTITY, + ActionAvailability, + ActionId, +) from app.modules.authorization.kernel import ( _ADMIN_MUTATIONS, _PrelockedAuthority, @@ -41,9 +46,10 @@ ArtifactVerificationJobResourceContext, GuideSourceBindingResourceContext, GuideSourceReadResourceContext, + SubmissionBindingResourceContext, + SubmissionCreationResourceContext, GuideSourceIngestResourceContext, PreSubmitCheckerInputResourceContext, - PreSubmitCheckerInputPreparationContext, AuthorizationContext, AuthorizationDenialCode, AuthorizationDecision, @@ -76,6 +82,7 @@ SubmissionBundlePreparationPreflightResourceContext, SubmissionBundlePreparationResourceContext, ) +from app.modules.authorization.submission_consumption import parse_consumption_binding from app.modules.authorization.pre_submit_materialization import ( initialize_artifact_bindings, parse_materialization_binding, @@ -131,6 +138,10 @@ def __reduce__(self) -> NoReturn: "pre_submit_checker_input", PreSubmitCheckerInputResourceContext, ), + ActionId.ARTIFACT_SUBMISSION_BINDING_CREATE: ( + "submission_binding", + SubmissionBindingResourceContext, + ), } @@ -304,16 +315,20 @@ def _submission_policy_binding_matches( def _exact_artifact_binding_matches( binding: _PreparedAuthorizationBinding, - resource: PreSubmitCheckerInputResourceContext, + resource: PreSubmitCheckerInputResourceContext + | SubmissionBindingResourceContext + | SubmissionCreationResourceContext, ) -> bool: """Require every materialization fact to equal the prepared request.""" - preparation = PreSubmitCheckerInputPreparationContext.model_validate( - resource.model_dump(mode="python", exclude={"semantic_manifest_sha256"}) - ) - context = preparation.model_dump(mode="json") + if isinstance(resource, PreSubmitCheckerInputResourceContext): + context = resource.model_dump(mode="json", exclude={"semantic_manifest_sha256"}) + return binding.exact_artifact_context == context and ( + binding.exact_artifact_resource_digest + == canonical_json_hash({"pre_submit_checker_input_preparation": context}) + ) + context = resource.model_dump(mode="json") return binding.exact_artifact_context == context and ( - binding.exact_artifact_resource_digest - == canonical_json_hash({"pre_submit_checker_input_preparation": context}) + binding.exact_artifact_resource_digest == authorization_resource_digest(resource) ) @@ -467,7 +482,12 @@ async def consume( ) and not _submission_policy_binding_matches(issuance.binding, final_resource_context): raise PreparedAuthorizationHandleInvalid("invalid prepared authorization handle") if isinstance( - final_resource_context, PreSubmitCheckerInputResourceContext + final_resource_context, + ( + PreSubmitCheckerInputResourceContext, + SubmissionBindingResourceContext, + SubmissionCreationResourceContext, + ), ) and not _exact_artifact_binding_matches(issuance.binding, final_resource_context): raise PreparedAuthorizationHandleInvalid("invalid prepared authorization handle") if isinstance( @@ -578,21 +598,18 @@ def _binding( policy_mutation_guide_status = None sufficiency: dict[str, object] = {} 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() + 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: - exact_artifact_context, exact_artifact_resource_digest = ( - parse_materialization_binding( - dict(caller_input.request_value), PreparedAuthorizationHandleInvalid - ) + exact_artifact_context, exact_artifact_resource_digest = parse_materialization_binding( + dict(caller_input.request_value), PreparedAuthorizationHandleInvalid ) + consumption_resource = parse_consumption_binding( + action_id, caller_input.request_value, PreparedAuthorizationHandleInvalid + ) + if consumption_resource is not None: + exact_artifact_context = consumption_resource.model_dump(mode="json") + exact_artifact_resource_digest = authorization_resource_digest(consumption_resource) if action_id is ActionId.ARTIFACT_SUBMISSION_BUNDLE_PREPARE: submission_binding = parse_submission_binding( dict(caller_input.request_value), @@ -860,29 +877,8 @@ def _scope_from_resource( kind=PreparedAuthorityScopeKind.ACTOR_SELF, actor_profile_id=resource.resource_id, ) - if action_id in _ADMIN_MUTATIONS and AuthorizationService._admin_resource_matches( - action_id, resource - ): - project_id = AuthorizationService._resource_project_id(resource) - target_actor_profile_id = None - role = None - grant_id = None - if action_id is ActionId.PROJECT_ROLE_GRANT_ISSUE: - target_actor_profile_id = resource.target_actor_profile_id - role = resource.role - elif action_id is ActionId.PROJECT_ROLE_GRANT_REVOKE: - grant_id = resource.resource_id - return PreparedAuthorityScope( - kind=( - PreparedAuthorityScopeKind.PROJECT - if project_id is not None - else PreparedAuthorityScopeKind.SYSTEM - ), - project_id=project_id, - target_actor_profile_id=target_actor_profile_id, - role=role, - grant_id=grant_id, - ) + if admin_scope := _admin_prepared_scope(action_id, resource): + return admin_scope expected_project_mutation = PROJECT_MUTATION_RESOURCE_BY_ACTION.get( action_id ) or COMPILATION_RESOURCE_BY_ACTION.get(action_id) @@ -941,9 +937,37 @@ def _scope_from_resource( kind=PreparedAuthorityScopeKind.PROJECT, project_id=resource.scope_project_id, ) + if isinstance(resource, SubmissionCreationResourceContext): + return PreparedAuthorityScope( + kind=PreparedAuthorityScopeKind.PROJECT, + project_id=resource.scope_project_id, + ) raise PreparedAuthorizationHandleInvalid("invalid prepared authorization handle") +def _admin_prepared_scope(action_id, resource): + """Return the exact administrative prepared scope when applicable.""" + if action_id not in _ADMIN_MUTATIONS or not AuthorizationService._admin_resource_matches( + action_id, resource + ): + return None + project_id = AuthorizationService._resource_project_id(resource) + target_actor_profile_id = role = grant_id = None + if action_id is ActionId.PROJECT_ROLE_GRANT_ISSUE: + target_actor_profile_id, role = resource.target_actor_profile_id, resource.role + elif action_id is ActionId.PROJECT_ROLE_GRANT_REVOKE: + grant_id = resource.resource_id + return PreparedAuthorityScope( + kind=( + PreparedAuthorityScopeKind.PROJECT if project_id else PreparedAuthorityScopeKind.SYSTEM + ), + project_id=project_id, + target_actor_profile_id=target_actor_profile_id, + role=role, + grant_id=grant_id, + ) + + async def fixed_service_authorization_context( session: AsyncSession, service_identity: ServiceIdentity, @@ -977,6 +1001,29 @@ async def fixed_service_authorization_context( raise PreparedAuthorizationUnsupported(AuthorizationDenialCode.ACTOR_NOT_FOUND) from exc +async def fixed_service_action_context( + session: AsyncSession, + *, + service_identity: ServiceIdentity, + action_id: ActionId, + request_id: UUID, + correlation_id: UUID, +) -> ServiceAuthorizationContext: + """Resolve one active fixed service for one active matrix action.""" + context = await fixed_service_authorization_context( + session, service_identity, request_id, correlation_id + ) + action = ACTION_BY_ID[action_id] + if ( + context.actor_status is not ActorStatus.ACTIVE + or context.identity_link_status is not IdentityLinkStatus.ACTIVE + or action.availability is not ActionAvailability.ACTIVE + or action_id not in SERVICE_ACTIONS_BY_IDENTITY.get(service_identity, ()) + ): + raise PreparedAuthorizationUnsupported(AuthorizationDenialCode.PERMISSION_NOT_GRANTED) + return context + + def fixed_service_context_revalidator( repository: AdminAuthorizationRepository, expected_identity: ServiceIdentity, @@ -1043,3 +1090,8 @@ async def fixed_service_prepared_authorization( ) finally: prepared.close() + + +from app.modules.authorization.submission_creation_authorization import ( # noqa: E402, F401 + PreparedSubmissionCreationAuthorization, +) diff --git a/backend/app/modules/authorization/runtime.py b/backend/app/modules/authorization/runtime.py index 8e67bde0c..58a12d982 100644 --- a/backend/app/modules/authorization/runtime.py +++ b/backend/app/modules/authorization/runtime.py @@ -20,6 +20,7 @@ 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 +from app.modules.authorization.submission_consumption import SubmissionBindingResourceContext, SubmissionCreationResourceContext _STRICT_FROZEN = ConfigDict(extra="forbid", frozen=True, strict=True) PROJECT_DIAGNOSTIC_TARGET_KIND_BY_ACTION = { ActionId.PROJECT_SETUP_RUN_READ: "setup_run", @@ -110,17 +111,15 @@ class PreparedAuthorityScope(BaseModel): target_actor_profile_id: UUID | None = None role: ProjectRole | None = None grant_id: UUID | None = None - artifact_resource_type: ( - Literal[ + artifact_resource_type: Literal[ "artifact_put_attempt", "artifact_verification_job", "artifact_pending_work", "guide_source_binding", "guide_source_read", "pre_submit_checker_input", - ] - | None - ) = None + "submission_binding", + ] | None = None artifact_resource_id: UUID | Literal["workstream:artifact_pending_work"] | None = None @model_validator(mode="after") @@ -178,7 +177,7 @@ def validate_selector(self): "guide_source_binding", "guide_source_read", "pre_submit_checker_input", - } + "submission_binding"} and isinstance(self.artifact_resource_id, UUID) ) ) @@ -1517,6 +1516,7 @@ class PreSubmitCheckerInputResourceContext(PreSubmitCheckerInputPreparationConte | ArtifactPendingWorkResourceContext | GuideSourceBindingResourceContext | GuideSourceReadResourceContext + | SubmissionCreationResourceContext | SubmissionBindingResourceContext | PreSubmitCheckerInputResourceContext | SubmissionBundlePreparationPreflightResourceContext | SubmissionBundlePreparationResourceContext @@ -1558,7 +1558,6 @@ class AuthorizationDenialCode(StrEnum): class MatchedAuthorityKind(StrEnum): """Privacy-bounded authority source classifications.""" - ACTOR_SELF = "actor_self" ADMIN_ROLE_GRANT = "admin_role_grant" PROJECT_ROLE_GRANT = "project_role_grant" @@ -1567,9 +1566,7 @@ class MatchedAuthorityKind(StrEnum): class AuthorizationDecision(BaseModel): """Frozen decision safe for feature code, evidence, and error mapping.""" - model_config = _STRICT_FROZEN - decision_id: UUID action_id: ActionId | None permission_id: PermissionId | None @@ -1613,6 +1610,7 @@ class AuthorizationDecision(BaseModel): "pre_submit_checker_input", "submission_bundle_preparation_preflight", "submission_bundle_preparation", + "submission_creation", "submission_binding", ] resource_id: ( UUID @@ -1677,6 +1675,7 @@ def __init__(self, decision: AuthorizationDecision) -> None: raise TypeError("authorization denial requires a denied decision") self.decision = decision super().__init__("Authorization denied") + @property def public_code(self) -> str: denial_code = self.decision.denial_code diff --git a/backend/app/modules/authorization/submission_consumption.py b/backend/app/modules/authorization/submission_consumption.py new file mode 100644 index 000000000..37fa09218 --- /dev/null +++ b/backend/app/modules/authorization/submission_consumption.py @@ -0,0 +1,114 @@ +"""Exact AUTH resource contracts for hidden Submission consumption.""" + +import json +from typing import Literal +from uuid import UUID + +from pydantic import BaseModel, ConfigDict, Field, model_validator + +from app.modules.authorization.catalogue import ActionId + +_STRICT_FROZEN = ConfigDict(extra="forbid", frozen=True, strict=True) + + +class SubmissionCreationResourceContext(BaseModel): + """Exact TASK-owned facts authorized for one immutable Submission.""" + + model_config = _STRICT_FROZEN + resource_type: Literal["submission_creation"] + resource_id: UUID + scope_project_id: UUID + actor_profile_id: UUID + identity_link_id: UUID + task_id: UUID + assignment_id: UUID + admission_id: UUID + predecessor_submission_id: UUID | None + predecessor_submission_version: int | None = Field(default=None, ge=1) + submission_id: UUID + submission_version: int = Field(ge=1) + task_status: str = Field(min_length=1) + submission_kind: Literal["initial", "revision"] + guide_version: str = Field(min_length=1) + 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}$") + + @model_validator(mode="after") + def bind_submission_and_predecessor(self): + """Require one exact Submission and a complete predecessor pair.""" + if self.resource_id != self.submission_id: + raise ValueError("submission resource must match submission") + if (self.predecessor_submission_id is None) != ( + self.predecessor_submission_version is None + ): + raise ValueError("submission predecessor identity is incomplete") + return self + + +class SubmissionBindingResourceContext(BaseModel): + """Exact ART lineage authorized for one fixed-service Submission binding.""" + + model_config = _STRICT_FROZEN + resource_type: Literal["submission_binding"] + resource_id: UUID + admission_id: UUID + evidence_set_id: UUID + actor_profile_id: UUID + identity_link_id: UUID + project_id: UUID + task_id: UUID + assignment_id: UUID + predecessor_submission_id: UUID | None + predecessor_submission_version: int | None = Field(default=None, ge=1) + submission_id: UUID + submission_version: int = Field(ge=1) + guide_id: UUID + guide_version: str = Field(min_length=1) + 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}$") + locked_policy_context_hash: 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}$") + content_id: UUID + sha256: str = Field(pattern=r"^sha256:[0-9a-f]{64}$") + byte_count: int = Field(ge=0) + logical_role: Literal["submission_bundle_original"] + + @model_validator(mode="after") + def bind_admission(self): + """Require one exact admission and a complete predecessor pair.""" + if self.resource_id != self.admission_id: + raise ValueError("submission binding resource must match admission") + if (self.predecessor_submission_id is None) != ( + self.predecessor_submission_version is None + ): + raise ValueError("submission binding predecessor is incomplete") + return self + + +RESOURCE_BY_ACTION = { + ActionId.ARTIFACT_SUBMISSION_BINDING_CREATE: SubmissionBindingResourceContext, + ActionId.SUBMISSION_CREATE: SubmissionCreationResourceContext, +} + + +def parse_consumption_binding(action_id: ActionId, value, invalid): + """Parse exact submission facts into their canonical context and digest.""" + resource_type = RESOURCE_BY_ACTION.get(action_id) + if resource_type is None: + return None + try: + resource = resource_type.model_validate_json( + json.dumps(value, separators=(",", ":"), sort_keys=True) + ) + except (TypeError, ValueError) as exc: + raise invalid("invalid prepared authorization handle") from exc + return resource diff --git a/backend/app/modules/authorization/submission_creation_authorization.py b/backend/app/modules/authorization/submission_creation_authorization.py new file mode 100644 index 000000000..053dfb432 --- /dev/null +++ b/backend/app/modules/authorization/submission_creation_authorization.py @@ -0,0 +1,173 @@ +"""Human prepared authorization adapter for hidden Submission creation.""" + +from pydantic import ValidationError +from sqlalchemy.ext.asyncio import AsyncSession + +from app.modules.tasks.api import ( + SubmissionCreationAuthorityFacts, + SubmissionCreationPreparationFacts, + SubmissionCreationUnavailable, +) +from app.modules.authorization.catalogue import ActionId +from app.modules.authorization.kernel import AuthorizationService +from app.modules.authorization.repository import AdminAuthorizationRepository +from app.modules.authorization.runtime import ( + ActorKind, + ActorStatus, + AuthorizationContext, + AuthorizationDenied, + HumanAuthorizationContext, + IdentityLinkStatus, + PreparedAuthorizationHandleInvalid, + PreparedAuthorizationInput, + PreparedAuthorizationUnsupported, + PreparedAuthorityScope, + PreparedAuthorityScopeKind, +) +from app.modules.authorization.submission_consumption import ( + SubmissionCreationResourceContext, +) + + +class PreparedSubmissionCreationAuthorization: + """Consume fresh human authority using locked TASK-owned final facts.""" + + def __init__(self, session: AsyncSession, context: AuthorizationContext) -> None: + self._session = session + self._context = context + + async def authorize(self, facts: SubmissionCreationPreparationFacts) -> None: + """Reject any caller other than the exact human contributor.""" + context = self._context + if ( + not isinstance(context, HumanAuthorizationContext) + or context.actor_kind is not ActorKind.HUMAN + or context.actor_profile_id != facts.contributor_id + or context.actor_status is not ActorStatus.ACTIVE + or context.identity_link_status is not IdentityLinkStatus.ACTIVE + ): + raise SubmissionCreationUnavailable("submission creation is unavailable") + + async def prepare(self, facts: SubmissionCreationAuthorityFacts) -> object: + """Prepare fresh project-scoped authority before ART state is inspected.""" + from app.modules.authorization.prepared import PreparedAuthorizationService + + context = self._context + repository = AdminAuthorizationRepository(self._session) + prepared = PreparedAuthorizationService( + self._session, + context, + AuthorizationService(self._session, context, admin_repository=repository), + repository, + ) + retained = False + try: + resource = _creation_resource(context, facts) + prepared_input = PreparedAuthorizationInput( + idempotency_key=facts.submission_id, + request_value=resource.model_dump(mode="json"), + ) + handle = await prepared.prepare( + ActionId.SUBMISSION_CREATE, + prepared_input, + PreparedAuthorityScope( + kind=PreparedAuthorityScopeKind.PROJECT, + project_id=resource.scope_project_id, + ), + ) + retained = True + return _PreparedSubmissionCreation(prepared, handle, prepared_input, resource) + except ( + AuthorizationDenied, + PreparedAuthorizationHandleInvalid, + PreparedAuthorizationUnsupported, + ValidationError, + ) as exc: + raise SubmissionCreationUnavailable("submission creation is unavailable") from exc + finally: + if not retained: + prepared.close() + + async def consume( + self, prepared_authorization: object, facts: SubmissionCreationAuthorityFacts + ) -> None: + """Consume the exact capability prepared before admission inspection.""" + if not isinstance(prepared_authorization, _PreparedSubmissionCreation): + raise SubmissionCreationUnavailable("submission creation is unavailable") + prepared = prepared_authorization.service + try: + resource = _creation_resource(self._context, facts) + prepared_input = PreparedAuthorizationInput( + idempotency_key=facts.submission_id, + request_value=resource.model_dump(mode="json"), + ) + if ( + prepared_input != prepared_authorization.prepared_input + or resource != prepared_authorization.resource + ): + raise SubmissionCreationUnavailable("submission creation is unavailable") + await prepared.consume( + prepared_authorization.handle, + ActionId.SUBMISSION_CREATE, + prepared_input, + resource, + ) + except ( + AuthorizationDenied, + PreparedAuthorizationHandleInvalid, + PreparedAuthorizationUnsupported, + ValidationError, + ) as exc: + raise SubmissionCreationUnavailable("submission creation is unavailable") from exc + finally: + prepared.close() + + def close(self, prepared_authorization: object) -> None: + """Discard an AUTH-owned process-local carrier on every exit path.""" + if isinstance(prepared_authorization, _PreparedSubmissionCreation): + prepared_authorization.service.close() + + +class _PreparedSubmissionCreation: + """AUTH-private carrier for one process-local prepared capability.""" + + __slots__ = ("handle", "prepared_input", "resource", "service") + + def __init__(self, service, handle, prepared_input, resource) -> None: + self.service = service + self.handle = handle + self.prepared_input = prepared_input + self.resource = resource + + +def _creation_resource( + context: AuthorizationContext, + facts: SubmissionCreationAuthorityFacts, +) -> SubmissionCreationResourceContext: + """Compose strict final resource facts inside the translation boundary.""" + task = facts.task_context + project = task.locked_project_context + predecessor = task.predecessor + return SubmissionCreationResourceContext( + resource_type="submission_creation", + resource_id=facts.submission_id, + scope_project_id=project.project_id, + actor_profile_id=facts.contributor_id, + identity_link_id=context.identity_link_id, + task_id=facts.task_id, + assignment_id=facts.assignment_id, + admission_id=facts.admission_id, + predecessor_submission_id=facts.predecessor_submission_id, + predecessor_submission_version=(predecessor.version if predecessor else None), + submission_id=facts.submission_id, + submission_version=facts.submission_version, + task_status=task.status, + submission_kind=task.kind, + guide_version=project.guide_version, + source_snapshot_id=project.source_snapshot_id, + source_snapshot_sha256=project.source_snapshot_hash, + effective_policy_id=project.effective_policy_id, + effective_policy_sha256=project.effective_policy_hash, + pre_submit_policy_id=project.pre_submit_policy_id, + pre_submit_policy_sha256=project.pre_submit_policy_bundle_hash, + ) diff --git a/backend/app/modules/tasks/api/submission_command.py b/backend/app/modules/tasks/api/submission_command.py index da5ee3354..e11511213 100644 --- a/backend/app/modules/tasks/api/submission_command.py +++ b/backend/app/modules/tasks/api/submission_command.py @@ -47,10 +47,23 @@ class SubmissionCreationAuthorityFacts(SubmissionCreationPreparationFacts): submission_id: UUID submission_version: int + task_context: TaskSubmissionContextFacts def __post_init__(self) -> None: if self.submission_version < 1: raise ValueError("submission version is invalid") + if ( + self.task_context.task_id != self.task_id + or self.task_context.assignment_id != self.assignment_id + or self.task_context.contributor_id != self.contributor_id + or ( + self.task_context.predecessor.submission_id + if self.task_context.predecessor is not None + else None + ) + != self.predecessor_submission_id + ): + raise ValueError("submission authority context is inconsistent") class SubmissionCreationAuthorizationPort(Protocol): @@ -59,9 +72,17 @@ class SubmissionCreationAuthorizationPort(Protocol): async def authorize(self, facts: SubmissionCreationPreparationFacts) -> None: """Conceal denial before TASK state is locked or revealed.""" - async def consume(self, facts: SubmissionCreationAuthorityFacts) -> None: + async def prepare(self, facts: SubmissionCreationAuthorityFacts) -> object: + """Prepare fresh exact authority before ART admission state is inspected.""" + + async def consume( + self, prepared_authorization: object, facts: SubmissionCreationAuthorityFacts + ) -> None: """Consume final exact authority after protected facts are known.""" + def close(self, prepared_authorization: object) -> None: + """Discard process-local authority after every success or failure path.""" + @dataclass(frozen=True, slots=True) class SubmissionArtifactAdmissionRequest: diff --git a/backend/app/modules/tasks/submission_composition.py b/backend/app/modules/tasks/submission_composition.py index e63fbb3ec..bb0189d02 100644 --- a/backend/app/modules/tasks/submission_composition.py +++ b/backend/app/modules/tasks/submission_composition.py @@ -4,13 +4,14 @@ from collections.abc import Sequence from typing import Any -from uuid import uuid4 +from uuid import UUID, uuid4 from sqlalchemy.ext.asyncio import AsyncSession from app.modules.tasks.api import ( SubmissionArtifactAdmissionPort, SubmissionArtifactAdmissionRequest, + SubmissionArtifactAdmissionResult, SubmissionCreationAuthorizationPort, SubmissionCreationAuthorityFacts, SubmissionCreationPreparationFacts, @@ -124,20 +125,6 @@ async def create(self, request: SubmissionCreationRequest) -> SubmissionCreation supersedes_submission_id=(str(context.predecessor.submission_id) if context.predecessor else None), ) - await self._repository.add_submission(submission) - consumed = await self._admissions.consume( - SubmissionArtifactAdmissionRequest( - admission_id=request.admission_id, - submission_id=submission_id, - submission_version=version, - task_context=context, - ) - ) - submission.submission_bundle_admission_id = str(request.admission_id) - submission.task_assignment_id = str(request.assignment_id) - submission.artifact_binding_id = str(consumed.binding_id) - submission.artifact_content_id = str(consumed.content_id) - await self._session.flush() final = SubmissionCreationAuthorityFacts( task_id=preliminary.task_id, assignment_id=preliminary.assignment_id, @@ -146,8 +133,33 @@ async def create(self, request: SubmissionCreationRequest) -> SubmissionCreation predecessor_submission_id=preliminary.predecessor_submission_id, submission_id=submission_id, submission_version=version, + task_context=context, ) - await self._authorization.consume(final) + prepared_authorization = await self._authorization.prepare(final) + try: + await self._repository.add_submission(submission) + consumed = await self._admissions.consume( + SubmissionArtifactAdmissionRequest( + admission_id=request.admission_id, + submission_id=submission_id, + submission_version=version, + task_context=context, + ) + ) + if ( + type(consumed) is not SubmissionArtifactAdmissionResult + or not isinstance(consumed.binding_id, UUID) + or not isinstance(consumed.content_id, UUID) + ): + raise RuntimeError("artifact admission did not produce exact binding facts") + submission.submission_bundle_admission_id = str(request.admission_id) + submission.task_assignment_id = str(request.assignment_id) + submission.artifact_binding_id = str(consumed.binding_id) + submission.artifact_content_id = str(consumed.content_id) + await self._session.flush() + await self._authorization.consume(prepared_authorization, final) + finally: + self._authorization.close(prepared_authorization) return SubmissionCreationResult( submission_id=submission_id, submission_version=version, diff --git a/backend/scripts/behavior_ownership.py b/backend/scripts/behavior_ownership.py index 629285986..126a95bf3 100644 --- a/backend/scripts/behavior_ownership.py +++ b/backend/scripts/behavior_ownership.py @@ -99,6 +99,12 @@ "backend/app/modules/authorization/submission_preparation.py", } ) +ARCH_02H_AUTH_CONSUMPTION_TARGETS = frozenset( + { + "backend/app/modules/authorization/submission_consumption.py", + "backend/app/modules/authorization/submission_creation_authorization.py", + } +) POL_03A_CALLABLE_TARGETS = frozenset( { "backend/app/modules/authorization/api/project_guide_compilation.py", @@ -287,6 +293,7 @@ def _validate_additive_partition_transition( | AUTH_12I_TARGETS | ARCH_02F_SUBMISSION_COMPOSITION_TARGETS | ARCH_02G_AUTH_PREPARATION_TARGETS + | ARCH_02H_AUTH_CONSUMPTION_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 a2e5d64d9..94c2046c8 100644 --- a/backend/scripts/run_test_lanes.py +++ b/backend/scripts/run_test_lanes.py @@ -139,6 +139,7 @@ class TestLane: "tests/authorization/guide_compilation/test_adapter_contract.py", "tests/authorization/guide_compilation/test_domain_contract.py", "tests/authorization/guide_compilation/test_migration_contract.py", + "tests/authorization/test_fixed_service_action_context.py", "tests/test_behavior_ownership.py", "tests/test_artifact_admission.py", "tests/test_submission_bundle_admission.py", diff --git a/backend/tests/authorization/test_fixed_service_action_context.py b/backend/tests/authorization/test_fixed_service_action_context.py new file mode 100644 index 000000000..3ad0f541b --- /dev/null +++ b/backend/tests/authorization/test_fixed_service_action_context.py @@ -0,0 +1,114 @@ +"""Focused fixed-service action preflight behavior.""" + +from uuid import uuid4 + +import pytest + +from app.modules.actors.service_identities import ServiceIdentity +from app.modules.artifacts.api import SubmissionAdmissionConsumptionError +from app.modules.artifacts.authorization import PreparedSubmissionBindingAuthorization +from app.modules.artifacts.submission_bindings import SubmissionBindingAuthorityFacts +from app.modules.authorization import prepared +from app.modules.authorization.catalogue import ActionId +from app.modules.authorization.runtime import ( + ActorKind, + ActorStatus, + AuthorizationDenialCode, + IdentityLinkStatus, + PreparedAuthorizationUnsupported, + ServiceAuthorizationContext, +) + + +@pytest.mark.parametrize( + ("actor_status", "link_status", "action_id", "expected_denial"), + [ + ( + ActorStatus.ACTIVE, + IdentityLinkStatus.ACTIVE, + ActionId.ARTIFACT_SUBMISSION_BINDING_CREATE, + None, + ), + ( + ActorStatus.SUSPENDED, + IdentityLinkStatus.ACTIVE, + ActionId.ARTIFACT_SUBMISSION_BINDING_CREATE, + AuthorizationDenialCode.PERMISSION_NOT_GRANTED, + ), + ( + ActorStatus.ACTIVE, + IdentityLinkStatus.REVOKED, + ActionId.ARTIFACT_SUBMISSION_BINDING_CREATE, + AuthorizationDenialCode.PERMISSION_NOT_GRANTED, + ), + ( + ActorStatus.ACTIVE, + IdentityLinkStatus.ACTIVE, + ActionId.PROJECT_READ, + AuthorizationDenialCode.PERMISSION_NOT_GRANTED, + ), + ], +) +@pytest.mark.asyncio +async def test_fixed_service_action_context_enforces_lifecycle_and_matrix( + actor_status: ActorStatus, + link_status: IdentityLinkStatus, + action_id: ActionId, + expected_denial: AuthorizationDenialCode | None, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Allow only an active principal with an active matrix action.""" + context = ServiceAuthorizationContext( + actor_profile_id=uuid4(), + actor_kind=ActorKind.SERVICE, + actor_status=actor_status, + identity_link_id=uuid4(), + identity_link_status=link_status, + service_identity=ServiceIdentity.ARTIFACT_BINDING, + request_id=uuid4(), + correlation_id=uuid4(), + ) + + async def fixed_context(*_args): + return context + + monkeypatch.setattr(prepared, "fixed_service_authorization_context", fixed_context) + operation = prepared.fixed_service_action_context( + object(), + service_identity=ServiceIdentity.ARTIFACT_BINDING, + action_id=action_id, + request_id=context.request_id, + correlation_id=context.correlation_id, + ) + if expected_denial is None: + assert await operation is context + else: + with pytest.raises(PreparedAuthorizationUnsupported) as exc_info: + await operation + assert exc_info.value.denial_code is expected_denial + + +@pytest.mark.asyncio +async def test_submission_binding_validation_is_concealed() -> None: + """Malformed exact binding facts never expose Pydantic validation details.""" + ids = [uuid4() for _ in range(12)] + facts = SubmissionBindingAuthorityFacts( + admission_id=ids[0], evidence_set_id=ids[1], actor_profile_id=ids[2], + identity_link_id=ids[3], project_id=ids[4], task_id=ids[5], + assignment_id=ids[6], predecessor_submission_id=None, + predecessor_submission_version=None, submission_id=ids[7], + submission_version=1, guide_id=ids[8], guide_version="1", + source_snapshot_id=ids[9], source_snapshot_sha256="invalid", + effective_policy_id=ids[10], effective_policy_sha256="sha256:" + "1" * 64, + pre_submit_policy_id=ids[11], pre_submit_policy_sha256="sha256:" + "2" * 64, + locked_policy_context_hash="sha256:" + "3" * 64, + semantic_manifest_id=uuid4(), semantic_manifest_sha256="sha256:" + "4" * 64, + content_id=uuid4(), sha256="sha256:" + "5" * 64, byte_count=1, + logical_role="submission_bundle_original", + ) + authority = PreparedSubmissionBindingAuthorization( + object(), request_id=uuid4(), correlation_id=uuid4() + ) + with pytest.raises(SubmissionAdmissionConsumptionError) as exc_info: + await authority.consume(facts) + assert exc_info.value.code == "submission_bundle_admission_unavailable" diff --git a/backend/tests/test_artifact_bindings_db.py b/backend/tests/test_artifact_bindings_db.py index 8cacace80..b966fd70e 100644 --- a/backend/tests/test_artifact_bindings_db.py +++ b/backend/tests/test_artifact_bindings_db.py @@ -24,7 +24,22 @@ from app.adapters.tasks import TransactionalSubmissionCreationCommand from app.modules.tasks.api import SubmissionCreationRequest, SubmissionCreationUnavailable from app.modules.tasks.models import Submission +from app.modules.tasks.models import AuditEvent from app.modules.tasks.repository import TaskRepository +from app.api.deps.authorization import compose_hidden_submission_creation_command +from app.modules.authorization.repository import AdminAuthorizationRepository +from app.modules.authorization import prepared as prepared_authorization +from app.modules.artifacts import authorization as artifact_authorization +from app.modules.authorization.runtime import ( + ActorKind, + ActorStatus, + AuthorizationDenialCode, + HumanAuthorizationContext, + IdentityLinkStatus, + ServiceAuthorizationContext, + PreparedAuthorizationUnsupported, +) +from app.modules.actors.service_identities import ServiceIdentity from test_artifact_bindings import _Allow, _lineage, _request @@ -34,6 +49,7 @@ SubmissionBundleAdmission.__table__, ArtifactBinding.__table__, Submission.__table__, + AuditEvent.__table__, ) @@ -135,10 +151,17 @@ class _FinalDeny: async def authorize(self, facts) -> None: del facts - async def consume(self, facts) -> None: + async def prepare(self, facts) -> object: del facts raise SubmissionCreationUnavailable("submission creation is unavailable") + async def consume(self, prepared_authorization, facts) -> None: + del prepared_authorization, facts + raise AssertionError("unreachable") + + def close(self, prepared_authorization) -> None: + del prepared_authorization + @pytest.mark.asyncio async def test_composed_final_denial_rolls_back_task_and_art_rows( @@ -316,3 +339,178 @@ async def consume_competing(value): ) assert status == "ready" assert bindings == 0 + + +def _wire_hidden_authority(monkeypatch: pytest.MonkeyPatch): + """Install deterministic TASK/AUTH collaborators for the hidden command.""" + art_request = _request() + context = art_request.task_context + request_id, correlation_id, human_link_id, service_actor_id, service_link_id = ( + uuid4() for _ in range(5) + ) + human = HumanAuthorizationContext( + actor_profile_id=context.contributor_id, + actor_kind=ActorKind.HUMAN, + actor_status=ActorStatus.ACTIVE, + identity_link_id=human_link_id, + identity_link_status=IdentityLinkStatus.ACTIVE, + request_id=request_id, + correlation_id=correlation_id, + ) + service = ServiceAuthorizationContext( + actor_profile_id=service_actor_id, + actor_kind=ActorKind.SERVICE, + actor_status=ActorStatus.ACTIVE, + identity_link_id=service_link_id, + identity_link_status=IdentityLinkStatus.ACTIVE, + service_identity=ServiceIdentity.ARTIFACT_BINDING, + request_id=request_id, + correlation_id=correlation_id, + ) + task = type("LockedTask", (), { + "id": str(context.task_id), "locked_guide_version": "1", + "locked_post_submit_checker_policy_id": str(uuid4()), + "locked_post_submit_checker_policy_version": "1", + "locked_post_submit_checker_policy_hash": "sha256:" + "4" * 64, + "locked_post_submit_checker_policy_body": {}, + "locked_review_policy_id": str(uuid4()), "locked_review_policy_generation": 1, + "locked_review_policy_hash": "sha256:" + "5" * 64, + "locked_revision_policy_id": str(uuid4()), "locked_revision_policy_generation": 1, + "locked_revision_policy_hash": "sha256:" + "6" * 64, + "locked_payment_policy_version": "1", + "locked_guide_source_snapshot_id": str(context.locked_project_context.source_snapshot_id), + "locked_guide_source_snapshot_hash": context.locked_project_context.source_snapshot_hash, + "locked_effective_project_submission_artifact_policy_id": str(context.locked_project_context.effective_policy_id), + "locked_effective_project_submission_artifact_policy_hash": context.locked_project_context.effective_policy_hash, + "locked_pre_submit_checker_policy_id": str(context.locked_project_context.pre_submit_policy_id), + "locked_pre_submit_checker_bundle_hash": context.locked_project_context.pre_submit_policy_bundle_hash, + })() + + async def lock_context(_self, _request): return context + async def get_task(_self, _task_id, **_kwargs): return task + async def lock_actor(_self, link_id, actor_id): + is_service = actor_id == service_actor_id + return ( + type("Link", (), {"id": str(link_id), "actor_profile_id": str(actor_id), "status": "active"})(), + type("Profile", (), { + "id": str(actor_id), + "actor_kind": "service" if is_service else "human", + "service_identity": service.service_identity.value if is_service else None, + "status": "active", + })(), + ) + async def find_role(_self, **_kwargs): + return type("Grant", (), {"id": uuid4(), "status": "active", "scope_project_id": None})() + async def fixed_context(*_args, **_kwargs): return service + + monkeypatch.setattr(TaskRepository, "lock_submission_context", lock_context) + monkeypatch.setattr(TaskRepository, "get_task", get_task) + monkeypatch.setattr(AdminAuthorizationRepository, "lock_request_actor", lock_actor) + monkeypatch.setattr(AdminAuthorizationRepository, "find_active_project_role", find_role) + monkeypatch.setattr( + prepared_authorization, "fixed_service_authorization_context", fixed_context + ) + monkeypatch.setattr( + artifact_authorization, "fixed_service_authorization_context", fixed_context + ) + request = SubmissionCreationRequest( + admission_id=art_request.admission_id, task_id=context.task_id, + assignment_id=context.assignment_id, contributor_id=context.contributor_id, + predecessor_submission_id=None, summary="summary", + contributor_attestation="attestation", + ) + return art_request, human, request, request_id, correlation_id + + +async def _create_hidden(factory, schema, human, request, request_id, correlation_id): + """Run one hidden command and return its stable result or boundary error.""" + async with factory() as session: + await session.execute(text(f'set search_path to "{schema}"')) + await session.commit() + command = compose_hidden_submission_creation_command( + session, human, request_id=request_id, correlation_id=correlation_id + ) + try: + return await command.create(request) + except (SubmissionAdmissionConsumptionError, SubmissionCreationUnavailable) as exc: + return exc + + +@pytest.mark.asyncio +async def test_live_hidden_authority_commits_one_complete_concurrent_effect( + isolated_database_env: str, monkeypatch: pytest.MonkeyPatch, +) -> None: + """Concurrent hidden commands commit exactly one complete authorized effect.""" + art_request, human, request, request_id, correlation_id = _wire_hidden_authority( + monkeypatch + ) + + async with _isolated_binding_schema(isolated_database_env) as (schema, factory): + async with factory.begin() as seed: + await _seed(seed, schema, art_request) + outcomes = await asyncio.gather(*( + _create_hidden(factory, schema, human, request, request_id, correlation_id) + for _ in range(2) + )) + assert sum(not isinstance(value, Exception) for value in outcomes) == 1 + failures = [value for value in outcomes if isinstance(value, Exception)] + assert len(failures) == 1 + assert isinstance(failures[0], SubmissionAdmissionConsumptionError) + assert failures[0].code == "submission_bundle_admission_already_consumed" + async with factory() as session: + await _set_schema(session, schema) + assert await session.scalar(text("select count(*) from submissions")) == 1 + assert await session.scalar(text("select count(*) from artifact_bindings")) == 1 + assert await session.scalar( + text( + "select count(*) from audit_events where event_domain='authority' " + "and event_type='SensitiveAuthorizationAllowed'" + ) + ) == 2 + + +@pytest.mark.asyncio +async def test_revoked_binding_service_rolls_back_the_hidden_command( + isolated_database_env: str, monkeypatch: pytest.MonkeyPatch, +) -> None: + """A revoked fixed-service link leaves every protected effect absent.""" + art_request, human, request, request_id, correlation_id = _wire_hidden_authority( + monkeypatch + ) + async def revoked_context(*_args, **_kwargs): + raise PreparedAuthorizationUnsupported( + AuthorizationDenialCode.IDENTITY_LINK_REVOKED + ) + monkeypatch.setattr( + prepared_authorization, "fixed_service_authorization_context", revoked_context + ) + monkeypatch.setattr( + artifact_authorization, "fixed_service_authorization_context", revoked_context + ) + async with _isolated_binding_schema(isolated_database_env) as (schema, factory): + denied_art_request = replace( + art_request, admission_id=uuid4(), submission_id=uuid4() + ) + async with factory.begin() as seed: + await _seed(seed, schema, denied_art_request) + denied = await _create_hidden( + factory, schema, human, + replace(request, admission_id=denied_art_request.admission_id), + request_id, correlation_id, + ) + assert isinstance(denied, SubmissionAdmissionConsumptionError) + assert denied.code == "submission_bundle_admission_unavailable" + async with factory() as session: + await _set_schema(session, schema) + assert await session.scalar(text("select count(*) from submissions")) == 0 + assert await session.scalar(text("select count(*) from artifact_bindings")) == 0 + assert await session.scalar( + text( + "select count(*) from submission_bundle_admissions " + "where id=:id and status='ready'" + ), + {"id": str(denied_art_request.admission_id)}, + ) == 1 + assert await session.scalar( + text("select count(*) from audit_events") + ) == 0 diff --git a/backend/tests/test_audit.py b/backend/tests/test_audit.py index 49edd90bb..58f2f2615 100644 --- a/backend/tests/test_audit.py +++ b/backend/tests/test_audit.py @@ -207,13 +207,12 @@ def test_action_aware_audit_input_enforces_mapping_and_action_availability() -> ActionId.PROJECT_SUBMISSION_ARTIFACT_POLICY_CREATE, ActionId.PROJECT_SUBMISSION_ARTIFACT_POLICY_DERIVE, ActionId.PROJECT_SUBMISSION_ARTIFACT_POLICY_UPDATE, - ActionId.ARTIFACT_VERIFICATION_EXECUTE, - ActionId.ARTIFACT_PENDING_WORK_SCAN, - ActionId.ARTIFACT_PUT_ATTEMPT_RESOLVE, + ActionId.ARTIFACT_VERIFICATION_EXECUTE, ActionId.ARTIFACT_PENDING_WORK_SCAN, ActionId.ARTIFACT_PUT_ATTEMPT_RESOLVE, 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_SUBMISSION_BUNDLE_PREPARE, + ActionId.SUBMISSION_CREATE, ActionId.ARTIFACT_SUBMISSION_BINDING_CREATE, } artifact_allowed = _authority_input( AuthorityEventType.SENSITIVE_AUTHORIZATION_ALLOWED, diff --git a/backend/tests/test_authorization.py b/backend/tests/test_authorization.py index 90c1e8bcd..14803237d 100644 --- a/backend/tests/test_authorization.py +++ b/backend/tests/test_authorization.py @@ -31,10 +31,10 @@ create_async_engine, ) from starlette.requests import Request - from app.api.deps.authorization import ( authorization_http_error, get_authorization_actor, + get_authorization_actor_identity, get_authorization_service, get_prepared_authorization_service, ) @@ -1745,18 +1745,15 @@ async def consume_rate() -> None: ), "artifact.submission_bundle.prepare": ( "submission.create", - "WS-XINT-002-05A", "active", + "WS-XINT-002-05A", + "active", ), "artifact.pre_submit.checker_input.materialize": ( "artifact.checker_input.materialize", "WS-XINT-002-06A", "active", ), - "artifact.submission.binding.create": ( - "artifact.binding.create", - "WS-AUTH-001-ART-05", - "planned", - ), + "artifact.submission.binding.create": ("artifact.binding.create", "WS-AUTH-001-ART-05", "active"), "artifact.post_submit.checker_input.materialize": ( "artifact.checker_input.materialize", "WS-AUTH-001-ART-06A", @@ -1923,8 +1920,7 @@ def _admin_resource_context( def test_closed_permission_and_action_catalogue_is_exact_and_non_executable() -> None: - historical_permissions = frozenset( - """actor.profile.read_self actor.profile.update_self actor.profile.read_any + historical_permissions = frozenset("""actor.profile.read_self actor.profile.update_self actor.profile.read_any actor.profile.suspend actor.profile.reactivate actor.profile.deactivate actor.identity_link.read actor.identity_link.revoke actor.identity_link.reactivate actor.service.provision admin_role.read admin_role.grant admin_role.revoke @@ -1938,8 +1934,7 @@ def test_closed_permission_and_action_catalogue_is_exact_and_non_executable() -> compensation.adapter_binding.manage compensation.award.read compensation.delivery.reconcile operations.status.read operations.timer.run operations.reconcile.run operations.outbox.retry operations.projection.rebuild - audit.read audit.export""".split() - ) + audit.read audit.export""".split()) new_permissions = frozenset( """project.setup_diagnostic.read project.effective_policy.read operations.task.start_override operations.submission_gate.repair @@ -2152,7 +2147,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_SUBMISSION_BUNDLE_PREPARE, + ActionId.ARTIFACT_PRE_SUBMIT_CHECKER_INPUT_MATERIALIZE, ActionId.ARTIFACT_SUBMISSION_BUNDLE_PREPARE, ActionId.SUBMISSION_CREATE, ActionId.ARTIFACT_SUBMISSION_BINDING_CREATE, } assert { definition.action_id.value: ( @@ -2233,13 +2228,13 @@ def test_closed_permission_and_action_catalogue_is_exact_and_non_executable() -> sum( definition.availability is ActionAvailability.ACTIVE for definition in ACTION_DEFINITIONS - ) == 55 + ) == 57 ) assert ( sum( definition.availability is ActionAvailability.PLANNED for definition in ACTION_DEFINITIONS - ) == 47 + ) == 45 ) assert resolve_executable_action(ActionId.ACTOR_PROFILE_READ_SELF).permission_id is PermissionId.ACTOR_PROFILE_READ_SELF with pytest.raises(ValueError, match="not active"): @@ -2797,6 +2792,7 @@ def test_submission_artifact_policy_draft_actions_have_exact_child_owners() -> N ActionId.ARTIFACT_PRE_SUBMIT_CHECKER_INPUT_MATERIALIZE, ActionId.ARTIFACT_PENDING_WORK_SCAN, ActionId.ARTIFACT_GUIDE_SOURCE_BINDING_CREATE, + ActionId.ARTIFACT_SUBMISSION_BINDING_CREATE, ActionId.ARTIFACT_GUIDE_SOURCE_READ, ActionId.PROJECT_GUIDE_COMPILATION_EXECUTE, ActionId.PROJECT_GUIDE_SUFFICIENCY_RUN, @@ -2904,7 +2900,7 @@ def test_art_custody_documentation_matches_the_independent_activation_fixture() assert "does not grant Operator" in operations assert "verification retry remains independently gated" in operations assert ( - "73 PermissionIds, 102 ActionIds, 54 active actions, and\n48 planned actions" in operations + "73 PermissionIds, 102 ActionIds, 57 active actions, and\n45 planned actions" in operations ) def test_rev_custody_documentation_matches_the_independent_catalogue_fixture() -> None: @@ -9721,25 +9717,19 @@ async def test_authorization_dependency_admits_service_without_human_rate_contro monkeypatch: pytest.MonkeyPatch, ) -> None: token = SimpleNamespace(subject_kind="service") - admitted = SimpleNamespace(profile=object(), identity_link=object()) + admitted = SimpleNamespace(profile=SimpleNamespace( + id=str(uuid4()), actor_kind="service", service_identity="workstream.artifact.binding", + ), identity_link=SimpleNamespace(id=str(uuid4()))) calls: list[object] = [] - async def resolve_service(_self, current): calls.append(current) return admitted - async def forbidden_human_lookup(*_args, **_kwargs): raise AssertionError("service admission entered the human path") - monkeypatch.setattr(ActorService, "resolve_service_for_authorization", resolve_service) - monkeypatch.setattr( - ActorService, - "find_actor_for_authorization", - forbidden_human_lookup, - ) + monkeypatch.setattr(ActorService, "find_actor_for_authorization", forbidden_human_lookup) request = Request({"type": "http", "method": "GET", "path": "/", "headers": []}) result = SimpleNamespace(token=token) - resolved = await get_authorization_actor( request, result, # type: ignore[arg-type] @@ -9747,10 +9737,12 @@ async def forbidden_human_lookup(*_args, **_kwargs): object(), # type: ignore[arg-type] ) - assert resolved is admitted + identity = await get_authorization_actor_identity(resolved) + assert identity.service_identity == "workstream.artifact.binding" + human = SimpleNamespace(profile=SimpleNamespace(id=str(uuid4()), actor_kind="human", service_identity=None), identity_link=SimpleNamespace(id=str(uuid4()))) + assert (await get_authorization_actor_identity(human)).actor_kind.value == "human" assert calls == [token] - @pytest.mark.parametrize( ("profile_status", "link_status"), [("suspended", "active"), ("deactivated", "active"), ("active", "revoked")], @@ -9786,7 +9778,6 @@ async def forbidden_touch(*_args, **_kwargs): await anext(dependency) await dependency.aclose() - async def test_service_denial_rolls_back_observations_before_clean_restage( monkeypatch: pytest.MonkeyPatch, ) -> None: @@ -9845,7 +9836,6 @@ async def add_authority_event(self, _event): assert session.rollback_count == 1 assert session.commit_count == 1 - async def test_service_dependency_cancellation_rolls_back_staged_observation( monkeypatch: pytest.MonkeyPatch, ) -> None: @@ -9887,9 +9877,10 @@ async def stage_observation(_self, _resolved): assert observations == ["staged"] assert session.rollback_count == 1 - +@pytest.mark.parametrize("failure_type", [AuthorizationEvidenceUnavailable, SQLAlchemyError]) async def test_service_observation_persistence_failure_is_retryable_and_private( monkeypatch: pytest.MonkeyPatch, + failure_type: type[Exception], ) -> None: private_subject = "private-service-subject" @@ -9911,7 +9902,7 @@ async def rollback(self): ) async def fail_observation(_self, _resolved): - raise SQLAlchemyError(private_subject) + raise failure_type(private_subject) monkeypatch.setattr(ActorService, "touch_after_authorization", fail_observation) request = Request({"type": "http", "method": "GET", "path": "/", "headers": []}) @@ -9925,7 +9916,6 @@ async def fail_observation(_self, _resolved): assert private_subject not in str(exc_info.value) assert session.rollback_count == 1 - def test_authorization_runtime_contracts_are_strict_and_two_argument() -> None: context = _runtime_context() public_methods = { diff --git a/backend/tests/test_submission_composition.py b/backend/tests/test_submission_composition.py index 2d89efab4..9f2058aef 100644 --- a/backend/tests/test_submission_composition.py +++ b/backend/tests/test_submission_composition.py @@ -14,6 +14,19 @@ TaskSubmissionContextFacts, ) from app.modules.tasks.submission_composition import TaskSubmissionCreationService +from app.api.deps.authorization import compose_hidden_submission_creation_command +from app.adapters.tasks import TransactionalSubmissionCreationCommand +from app.modules.artifacts.submission_bindings import SubmissionAdmissionConsumptionService +from app.modules.artifacts.authorization import PreparedSubmissionBindingAuthorization +from app.modules.authorization.prepared import ( + PreparedSubmissionCreationAuthorization, +) +from app.modules.authorization.runtime import ( + ActorKind, + ActorStatus, + HumanAuthorizationContext, + IdentityLinkStatus, +) class _Session: @@ -71,6 +84,38 @@ def _task(): return SimpleNamespace(**values) +@pytest.mark.parametrize( + ("actor_status", "link_status"), + [ + (ActorStatus.SUSPENDED, IdentityLinkStatus.ACTIVE), + (ActorStatus.ACTIVE, IdentityLinkStatus.REVOKED), + ], +) +@pytest.mark.asyncio +async def test_human_lifecycle_denial_precedes_task_state( + actor_status, link_status, +): + request = _request() + context = HumanAuthorizationContext( + actor_profile_id=request.contributor_id, + actor_kind=ActorKind.HUMAN, + actor_status=actor_status, + identity_link_id=uuid4(), + identity_link_status=link_status, + request_id=uuid4(), + correlation_id=uuid4(), + ) + authority = PreparedSubmissionCreationAuthorization(object(), context) + service = TaskSubmissionCreationService( + _Session(), authorization=authority, admissions=None + ) + service._repository = SimpleNamespace( + lock_submission_context=lambda value: pytest.fail("TASK state was revealed") + ) + with pytest.raises(SubmissionCreationUnavailable): + await service.create(request) + + @pytest.mark.asyncio async def test_command_orders_authority_task_art_persistence_and_final_consumption(): request = _request() @@ -78,7 +123,13 @@ async def test_command_orders_authority_task_art_persistence_and_final_consumpti class Authority: async def authorize(self, facts): events.append(("authorize", facts.task_id)) - async def consume(self, facts): events.append(("final", facts.submission_version)) + async def prepare(self, facts): + events.append(("prepare", facts.submission_version)) + return "prepared" + async def consume(self, handle, facts): + assert handle == "prepared" + events.append(("final", facts.submission_version)) + def close(self, handle): assert handle == "prepared" class Admissions: async def consume(self, value): @@ -96,7 +147,9 @@ async def add_submission(self, submission): events.append(("persist", submission service._repository = Repository() result = await service.create(request) - assert [event[0] for event in events] == ["authorize", "task", "persist", "art", "final"] + assert [event[0] for event in events] == [ + "authorize", "task", "prepare", "persist", "art", "final" + ] assert result.submission_version == 1 @@ -104,7 +157,9 @@ async def add_submission(self, submission): events.append(("persist", submission async def test_denial_precedes_task_lock_and_all_mutation(): class Authority: async def authorize(self, facts): raise SubmissionCreationUnavailable - async def consume(self, facts): raise AssertionError("unreachable") + async def prepare(self, facts): raise AssertionError("unreachable") + async def consume(self, handle, facts): raise AssertionError("unreachable") + def close(self, handle): raise AssertionError("unreachable") service = TaskSubmissionCreationService(_Session(), authorization=Authority(), admissions=None) service._repository = SimpleNamespace( @@ -114,17 +169,23 @@ async def consume(self, facts): raise AssertionError("unreachable") await service.create(_request()) +@pytest.mark.parametrize("revocation", ["identity_link_revoked", "submitter_grant_missing"]) @pytest.mark.asyncio -async def test_final_authority_failure_remains_inside_caller_transaction(): +async def test_fresh_authority_denial_precedes_art_and_mutation(revocation): request = _request() + events = [] class Authority: async def authorize(self, facts): pass - async def consume(self, facts): raise SubmissionCreationUnavailable + async def prepare(self, facts): + events.append(revocation) + raise SubmissionCreationUnavailable("submission creation is unavailable") + async def consume(self, handle, facts): raise AssertionError("unreachable") + def close(self, handle): raise AssertionError("unreachable") class Admissions: async def consume(self, value): - return SubmissionArtifactAdmissionResult(binding_id=uuid4(), content_id=uuid4()) + raise AssertionError("ART admission state was inspected") service = TaskSubmissionCreationService(_Session(), authorization=Authority(), admissions=Admissions()) persisted = [] @@ -137,7 +198,56 @@ async def add_submission(self, submission): persisted.append(submission) service._repository = Repository() with pytest.raises(SubmissionCreationUnavailable): await service.create(request) + assert events == [revocation] + assert persisted == [] + + +@pytest.mark.asyncio +async def test_invalid_admission_result_denies_before_lineage_and_final_authority(): + request = _request() + events = [] + + class Authority: + async def authorize(self, facts): events.append("authorize") + async def prepare(self, facts): + events.append("prepare") + return "prepared" + async def consume(self, handle, facts): events.append("final") + def close(self, handle): assert handle == "prepared" + + class Admissions: + async def consume(self, value): + events.append("art") + return SimpleNamespace(binding_id=None, content_id=uuid4()) + + service = TaskSubmissionCreationService( + _Session(), authorization=Authority(), admissions=Admissions() + ) + persisted = [] + + class Repository: + async def lock_submission_context(self, value): return _context(request) + async def get_task(self, task_id): return _task() + async def add_submission(self, submission): persisted.append(submission) + + service._repository = Repository() + with pytest.raises(RuntimeError, match="exact binding facts"): + await service.create(request) + assert events == ["authorize", "prepare", "art"] assert len(persisted) == 1 + assert persisted[0].artifact_binding_id is None + + +def test_hidden_composition_uses_both_active_authority_adapters() -> None: + session = SimpleNamespace() + context = SimpleNamespace() + command = compose_hidden_submission_creation_command( + session, context, request_id=uuid4(), correlation_id=uuid4() + ) + assert type(command) is TransactionalSubmissionCreationCommand + assert type(command._authorization) is PreparedSubmissionCreationAuthorization + assert type(command._admissions) is SubmissionAdmissionConsumptionService + assert type(command._admissions._authorization) is PreparedSubmissionBindingAuthorization @pytest.mark.asyncio @@ -160,7 +270,13 @@ async def test_revision_increments_and_binds_the_exact_predecessor(): class Authority: async def authorize(self, facts): pass - async def consume(self, facts): seen.update(final=facts) + async def prepare(self, facts): + seen["prepared"] = facts + return "prepared" + async def consume(self, handle, facts): + assert handle == "prepared" + seen.update(final=facts) + def close(self, handle): assert handle == "prepared" class Admissions: async def consume(self, value): diff --git a/docs/operations_authorization_service.md b/docs/operations_authorization_service.md index f0e4ef3cc..f79615b7c 100644 --- a/docs/operations_authorization_service.md +++ b/docs/operations_authorization_service.md @@ -494,14 +494,16 @@ v0.1 baseline. The REV transfer adds no migration. The ART transfer does not grant Operator authority; its `OPERATOR` suffix denotes only future activation custody, and verification retry remains independently gated from read/status actions. -Catalogue totals are 73 PermissionIds, 102 ActionIds, 54 active actions, and -48 planned actions. AUTH-12I adds and activates only the unified compilation +Catalogue totals are 73 PermissionIds, 102 ActionIds, 57 active actions, and +45 planned actions. AUTH-12I adds and activates only the unified compilation request/execute pair. AUTH-11C2 activates three current effective-policy and active-guide reads in addition to AUTH-11C1's six diagnostic reads. The exact route mapping is in `docs/spec_authorization_service.md`. WS-XINT-002-04A activates Project Manager guide-source ingest, and WS-XINT-002-04B activates -only the fixed-service guide binding and read actions. The other 16 ART actions -remain planned, including every Operator artifact action. +only the fixed-service guide binding and read actions. WS-ARCH-001-02G activates +contributor bundle preparation; 02H activates hidden human Submission creation +and fixed-service submission binding. The other 14 ART actions remain planned, +including every Operator artifact action. The v0.1 schema keeps each allowed or denied internal ART decision bound to the exact privacy-bounded resource-context digest in append-only audit facts. @@ -533,9 +535,10 @@ The historical permission set remains exactly 49 values. The post-`0020` set contains exactly 24 values, including `review.queue.override`, the two compilation permissions, `project.setup_diagnostic.read`, and `project.effective_policy.read`; do not derive -historical status from identifier prefixes. All submission/review rows remain -planned. Initial and revision submission share `submission.create`, and no -revision-specific permission or preparation action exists. +historical status from identifier prefixes. WS-ARCH-001-02H activates the +hidden base `submission.create` boundary; review rows remain planned. Initial +and revision submission share `submission.create`, and no revision-specific +permission or preparation action exists. Review reads consume the request-scoped public `AuthorizationService.require(action_id, typed_resource_context)` boundary. @@ -569,9 +572,12 @@ PREP currently supports actor-self profile update, the eight active AdminRoleGrant-backed administrative mutations, the three active fixed-service ART foundation actions, Project Manager `artifact.guide_source.ingest`, and the fixed-service `artifact.guide_source.binding.create` and -`artifact.guide_source.read` actions. Submission, checker, review, and generic -artifact-read actions remain planned; no other production feature command is -cut over. Callers begin and own one root transaction, call `prepare`, +`artifact.guide_source.read`, contributor `artifact.submission_bundle.prepare`, +hidden human `submission.create`, and fixed-service +`artifact.submission.binding.create` actions. Checker, review, generic +artifact-read, and the public Submission cutover remain planned; no other +production feature command is cut over. Callers begin and own one root +transaction, call `prepare`, lock their participant rows, compose final typed facts, call `consume` with the independently expected ActionId and the same strict request/idempotency input, flush participant work, and commit once. AUTH never commits in dependency diff --git a/docs/roadmap_status.md b/docs/roadmap_status.md index 178787378..51c0ff5d0 100644 --- a/docs/roadmap_status.md +++ b/docs/roadmap_status.md @@ -62,8 +62,9 @@ remain outside v0.1. 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. + PREP boundary from WS-ARCH-001-02G. Hidden TASK-owned Submission creation, + admission consumption, and fixed-service binding are active through + WS-ARCH-001-02H; only the later public clean cut remains gated. 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 @@ -106,9 +107,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 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. + preparation authority is active through WS-ARCH-001-02G; hidden admission + consumption, TASK-owned Submission creation, and final binding are active + through 02H; the later public clean cut remains gated. - 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 f3c51ff5f..f4baa1502 100644 --- a/docs/spec_authorization_service.md +++ b/docs/spec_authorization_service.md @@ -257,7 +257,10 @@ 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. WS-ARCH-001-02G subsequently activates the existing contributor-preparation row, producing 55 active and 47 -planned rows without adding an action or permission. +planned rows without adding an action or permission. WS-ARCH-001-02H activates +the existing human `submission.create` and fixed-service +`artifact.submission.binding.create` rows, producing 57 active and 45 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 @@ -483,7 +486,7 @@ A mapping is not a permission alias. | `WS-XINT-002-04B` | Active: `artifact.guide_source.read`, `artifact.guide_source.binding.create` | | `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-AUTH-001-ART-05` | `artifact.submission.binding.create`; activated by replacement implementation chunk WS-ARCH-001-02H for only the fixed artifact-binding service | | `WS-XINT-002-06B` | `artifact.post_submit.checker_input.materialize`, `artifact.checker_output.write`, `artifact.checker_output.binding.create` | | `WS-XINT-002-07A` | `artifact.review_packet.materialize` only | | Future REV-owned activation, not approved for v0.1 | `artifact.review_evidence.binding.create` remains planned/unavailable | @@ -502,8 +505,9 @@ 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, and -WS-ARCH-001-02G activates contributor bundle preparation; the other 15 ART -actions remain planned and unavailable. +WS-ARCH-001-02G activates contributor bundle preparation. WS-ARCH-001-02H +activates submission binding for the fixed artifact-binding service; the other +14 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 @@ -723,7 +727,9 @@ active AdminRoleGrant-backed administrative mutations, the three active fixed ART foundation service actions, Project Manager `artifact.guide_source.ingest`, and the fixed-service `artifact.guide_source.binding.create` and `artifact.guide_source.read` -actions. Submission, checker, review, and generic artifact-read actions remain +actions, contributor `artifact.submission_bundle.prepare`, hidden human +`submission.create`, and fixed-service `artifact.submission.binding.create`. +Checker, review, generic artifact-read, and the public Submission cutover remain planned and issue no handle. Actor-self preparation locks the exact caller profile and then its exact identity link. Administrative preparation locks