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