From b12cff79ec8c70cbef952c391a07ae68e6cde019 Mon Sep 17 00:00:00 2001 From: Abiorh001 Date: Wed, 5 Aug 2026 23:48:13 +0100 Subject: [PATCH 01/21] feat(auth): govern manual submission policy drafts --- .../CHUNK_MAP.md | 4 +- .../DECISIONS.md | 19 + .../STATUS.md | 4 +- ...01-12F2-submission-policy-manual-drafts.md | 86 +- ...-AUTH-001-12F2-internal-review-evidence.md | 48 ++ .../WS-AUTH-001-12F2-pr-trust-bundle.md | 81 ++ backend/app/api/deps/authorization.py | 72 +- .../app/modules/authorization/catalogue.py | 11 +- backend/app/modules/authorization/prepared.py | 12 +- backend/app/modules/authorization/runtime.py | 21 +- backend/app/modules/projects/router.py | 87 +- backend/app/modules/projects/schemas.py | 19 +- backend/app/modules/projects/service.py | 130 +-- .../submission_policy_mutation_service.py | 805 +++++++++++++++++- backend/scripts/api_contract_e2e.py | 228 ++++- backend/tests/test_alembic.py | 8 +- backend/tests/test_authorization.py | 178 +++- backend/tests/test_projects.py | 575 ++++++++++++- docs/operations_authorization_service.md | 11 +- docs/operations_project_operating_manual.md | 6 +- docs/spec_authorization_service.md | 15 +- 21 files changed, 2163 insertions(+), 257 deletions(-) create mode 100644 .agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/reviews/WS-AUTH-001-12F2-internal-review-evidence.md create mode 100644 .agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/reviews/WS-AUTH-001-12F2-pr-trust-bundle.md diff --git a/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/CHUNK_MAP.md b/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/CHUNK_MAP.md index bf3e7842e..40cd81b2c 100644 --- a/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/CHUNK_MAP.md +++ b/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/CHUNK_MAP.md @@ -69,8 +69,8 @@ stopped. | `WS-AUTH-001-12D2` | Review And Revision Policy Mutation Separation | L1 | Superseded by merged XINT-003-02A/02B; economic policy remains CON-owned | | `WS-AUTH-001-12E` | Guide Sufficiency Mutation Cutover | L1 | Merged through PR #263 | | `WS-AUTH-001-12F` | Submission Artifact Policy Planning Parent | L1 | Split after failed L1 pre-start review; zero activation | -| `WS-AUTH-001-12F1` | Submission Policy Authority Foundation | L1 | Proposed after 12E; zero activation | -| `WS-AUTH-001-12F2` | Manual Submission Policy Drafts | L1 | Proposed after 12F1 | +| `WS-AUTH-001-12F1` | Submission Policy Authority Foundation | L1 | Merged through PR #286; zero activation | +| `WS-AUTH-001-12F2` | Manual Submission Policy Drafts | L1 | In progress after passed corrected pre-start review | | `WS-AUTH-001-12F3` | Fixed-Service Policy Derivation | L1 | Proposed after 12F2 | | `WS-AUTH-001-12F4` | Submission Policy Approval Chain | L1 | Proposed after 12F3 | | `WS-AUTH-001-12G` | Post-Submit Checker Policy Mutation Cutover | L1 | Proposed after 12F4 | diff --git a/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/DECISIONS.md b/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/DECISIONS.md index 60f3333f1..68d1da93c 100644 --- a/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/DECISIONS.md +++ b/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/DECISIONS.md @@ -842,3 +842,22 @@ replacement identity in the approval transaction; it may otherwise only stage bounded continuation identity. 12G alone owns post-submit checker-policy derivation, compilation, correction, approval, or execution. Consequently 12G and 12B2 depend on merged 12F4, not the rejected combined 12F contract. + +## D37: Manual policy updates are append-only authorized replacements + +Status: accepted preimplementation correction on 2026-08-05. + +12F2 does not mutate a draft policy body in place. A manual update requires the +predecessor's expected hash and a new successor policy version, derives a stable +actor/link-bound successor identity from the protected operation, and binds predecessor and +successor facts separately through PREP and replay. The successor records fresh +update-action authority provenance while the predecessor is superseded in the +same root transaction. This preserves exact authorship and CAS evidence without +adding ambiguous update columns to an old row. + +Both system-scoped and exact-project-scoped active Project Manager grants cover +the project; wrong-project grants and role/token assertions do not. A warning +result is usable only when its acknowledgement has the complete merged 12E +actor, identity-link, grant, action, decision, scope, report, snapshot, and +setup-generation custody. The legacy role-string acknowledgement is not +authority. diff --git a/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/STATUS.md b/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/STATUS.md index f406f0fc9..a1b10ab09 100644 --- a/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/STATUS.md +++ b/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/STATUS.md @@ -184,8 +184,8 @@ bounded merge. | `WS-AUTH-001-12D2` | Superseded | - | #248 | XINT-003-02A/02B own immutable review/revision policy lineage and the sole authorized mutation path; 02B merged as `25fc27c4`. | | `WS-AUTH-001-12E` | Merged | `codex/ws-auth-001-12e-guide-sufficiency` | #263 | Three guide-sufficiency actions plus fixed setup-service run PREP merged as `b510bc4f`. | | `WS-AUTH-001-12F` | Planning split | `codex/ws-auth-001-12f-submission-artifact-policy` | - | Combined contract failed required L1 pre-start review; parent now activates nothing and delegates to 12F1-12F4. | -| `WS-AUTH-001-12F1` | External review | `codex/ws-auth-001-12f1-submission-policy-foundation` | - | Submission-policy PREP, replay, provenance, and audit custody foundation complete; zero activation; hosted CI and CodeRabbit pending. | -| `WS-AUTH-001-12F2` | Proposed | - | - | Governed Project Manager manual-draft create/update cutover. | +| `WS-AUTH-001-12F1` | Merged | `codex/ws-auth-001-12f1-submission-policy-foundation` | #286 | Submission-policy PREP, replay, provenance, and audit custody foundation merged as `5a4186cc`; zero activation. | +| `WS-AUTH-001-12F2` | In progress | `codex/ws-auth-001-12f2-manual-submission-policy` | - | Corrected contract passed all required L1 pre-start reviews; governed Project Manager append-only manual-draft create/update cutover is active. | | `WS-AUTH-001-12F3` | Proposed | - | - | Fixed setup-service derivation and asynchronous executor cutover. | | `WS-AUTH-001-12F4` | Proposed | - | - | Project Manager approval and atomic effective/pre-submit policy chain. | | `WS-AUTH-001-12G` | Proposed | - | - | Post-submit checker policy approval/correction cutover after 12F4. | diff --git a/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/chunks/WS-AUTH-001-12F2-submission-policy-manual-drafts.md b/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/chunks/WS-AUTH-001-12F2-submission-policy-manual-drafts.md index 7a854463c..47cd85291 100644 --- a/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/chunks/WS-AUTH-001-12F2-submission-policy-manual-drafts.md +++ b/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/chunks/WS-AUTH-001-12F2-submission-policy-manual-drafts.md @@ -2,7 +2,9 @@ ## Status and prerequisite -Proposed and inactive after merged 12F1. Risk: L1. +Authorized for implementation after merged 12F1. Risk: L1. The original +contract failed pre-implementation architecture, security, and QA review; the +review corrections below are part of the implementation boundary. ## Goal @@ -20,9 +22,11 @@ backend/app/modules/authorization/runtime.py backend/app/modules/projects/router.py backend/app/modules/projects/schemas.py backend/app/modules/projects/repository.py +backend/app/modules/projects/service.py backend/app/modules/projects/submission_policy_mutation_service.py backend/app/modules/projects/submission_policy_mutation_repository.py backend/tests/test_authorization.py +backend/tests/test_alembic.py backend/tests/test_projects.py backend/scripts/api_contract_e2e.py docs/spec_authorization_service.md @@ -34,36 +38,76 @@ docs/operations_project_operating_manual.md ## Not allowed Agent derivation, approval, effective/pre-submit compilation, Celery execution, -post-submit behavior, compatibility routes, or edits to agent-derived drafts. +post-submit behavior, compatibility routes, in-place policy-body mutation, or +edits to agent-derived drafts. Changes to `projects/service.py` are limited to +extracting one public, shared default-floor validator and removing the obsolete +self-committing create/update entry points; this chunk must not create a second +policy validator. ## Acceptance - Activate only `project.submission_artifact_policy.create` and - `project.submission_artifact_policy.update` for a human Project Manager with - an exact active project grant. Admin role name alone, token claims, service - membership, or a contributor grant cannot substitute. + `project.submission_artifact_policy.update` for a human with an active, + covered Project Manager `AdminRoleGrant`: either system scope or the exact + project scope. A wrong-project grant, admin role name alone, token claims, + service membership, or contributor grant cannot substitute. Attribute both + actions to child owner `WS-AUTH-001-12F2`; the zero-activation 12F parent and + derive/approve actions remain unavailable. - Both routes require a valid UUID `Idempotency-Key` before actor provisioning, use the dedicated PREP dependency, expose exact OpenAPI action metadata, and reject service tokens at the public boundary. - Create locks exact project/draft guide/latest snapshot/sufficiency/setup - generation and binds the canonical manual payload digest. Update additionally - locks the exact draft policy ID/status/hash and compare-and-swaps that hash. + generation and binds the canonical manual payload digest. All acquisition + paths use one order: project -> draft guide -> latest source snapshot -> setup + run -> sufficiency report -> target draft policy (update only). +- Update requires `expected_policy_hash` and `successor_policy_version` in the + request body. Both fields are part of the idempotency request digest and PREP + resource facts. The successor policy ID is server-derived deterministically + from the action, actor, identity link, project, selected predecessor, and + idempotency key so exact retries bind the same identity without cross-actor + collisions. PREP/replay bind predecessor policy ID, + version, status and expected hash separately from successor policy ID and + version; the runtime resource contract must expose both without overloading + one `policy_id`/`policy_version` pair. Missing, malformed, cross-policy, + duplicate-version, or stale values deny before mutation. Update is + append-only: it creates a new manual draft row with + `creation_action_id=project.submission_artifact_policy.update`, complete fresh + creation authority provenance, and `supersedes_policy_id` pointing to the + selected draft; it atomically marks the old draft superseded. It never + rewrites an existing policy body/hash in place. - The exact authoritative sufficiency report must match the snapshot and setup generation and be `passed`, or `passed_with_warnings` with the same report's - warnings acknowledged. Missing, blocked, stale, diagnostic-only, or - unacknowledged-warning reports deny without policy/replay/evidence mutation. + warnings acknowledged through the exact 12E-authorized acknowledgement. + Warning acceptance requires non-null actor profile, identity link, matched + Project Manager grant, action, decision event, scope project, timestamp, and + the same report/snapshot/setup-generation lineage. Legacy role-string-only + acknowledgement is insufficient. Missing, blocked, stale, diagnostic-only, + or incompletely acknowledged reports deny without policy/replay/evidence + mutation. - Manual payload validation preserves every non-bypassable Workstream default submission rule. It may tighten project requirements but cannot disable, replace, or weaken the startup-fixed default policy/catalogue floor. - Manual rows record complete local actor/link/grant/project/action/decision - provenance and `manual` derivation source. Agent provenance fields must be - null. Agent-derived rows are immutable through this path. + provenance and the canonical persisted manual derivation token + `manual_admin_derivation`. This token describes provenance; it does not grant + authority. Agent provenance fields must be null. Agent-derived rows are + immutable through this path. - Exact committed replay reauthorizes then returns the stored response without - another mutation. Changed/pending/cross-action/link-substitution reuse denies. - Concurrent exact calls produce one row/update; fault injection rolls back - policy, replay completion and allowed evidence together. -- Existing legacy role-based create/update calls are removed, not retained as - fallback aliases. + another mutation. Create and update return their normal committed success + status on replay and return the originally stored response JSON, never a + later current-row projection. Changed/pending/cross-action/link-substitution + reuse denies. Concurrent exact calls produce one append-only result. +- Replay reservation, replacement policy staging, predecessor supersession, + local authority provenance, AUTH evidence, and replay completion share one + root transaction. Fault injection after replay reservation, policy staging, + AUTH evidence staging, and replay completion-before-commit must roll all of + them back together. +- Manual mutation may read and lock setup-run/generation state but cannot set + setup outputs, advance setup status/current step, enqueue continuation, or + impersonate the 12F3 derived-policy output. +- Public routes use only the new mutation service and route-owned + commit/rollback. Existing self-committing, role-based `ProjectService` + create/update calls are removed, not retained as fallback aliases. ## Verification commands @@ -87,3 +131,13 @@ git diff --check Every selector must be non-zero; exact pushed head passes Agent Gates and full hosted Backend. Required reviewers: all L1 tracks. Human focus: manual-only provenance, agent immutability, PM grant isolation and replay atomicity. + +The focused selectors must contain child-owned 12F2 tests, not merely collect +older parent or legacy tests. API E2E must assert exact OpenAPI action metadata, +successful create/update and exact replay, plus denial for service tokens, +contributors, wrong-project grants, role claims, and missing/invalid +idempotency or update-precondition input. Focused tests must also prove system +and exact-project Project Manager grants succeed; wrong-project grants fail; +default-floor weakening, agent-row update, stale acknowledgement/lineage, stale +or concurrent CAS, copied/cross-action replay, and each named fault-injection +point leave no protected mutation or allowed evidence. diff --git a/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/reviews/WS-AUTH-001-12F2-internal-review-evidence.md b/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/reviews/WS-AUTH-001-12F2-internal-review-evidence.md new file mode 100644 index 000000000..5f48e7005 --- /dev/null +++ b/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/reviews/WS-AUTH-001-12F2-internal-review-evidence.md @@ -0,0 +1,48 @@ +# WS-AUTH-001-12F2 Internal Review Evidence + +## Scope reviewed + +Manual Project Manager submission-policy create/update activation, exact PM +admission and PREP authority, authoritative sufficiency custody, append-only +replacement, replay classification, transaction rollback, and API denial +ordering. + +## Reviewer results + +- Architecture: PASS after placeholder PREP evidence was removed and committed + replay was made independent of later live-lineage drift. +- Security/auth: PASS. The preliminary PM query is concealment-only; exact + locked PREP remains the sole durable mutation authority. +- Product/operations: PASS. Manual and agent provenance remain separate and no + setup-run, worker, reviewer, payment, or reputation lifecycle was expanded. +- QA: PASS WITH LOW RISKS. The focused selector collects 27 tests; database + execution remains assigned to hosted PostgreSQL. +- Senior engineering: PASS WITH LOW RISKS after replay-first handling preserved + committed responses and matching pending operations remain retryable. +- Test delta: PASS WITH LOW RISKS after PATCH key/precondition coverage and the + agent-provenance assertion were repaired. +- Reuse/dedup: PASS WITH LOW RISKS. Shared canonical policy validation and + verified-source projection are reused; no second authorization protocol was + introduced. +- Documentation: PASS. Catalogue availability, append-only behavior, and + operational custody are aligned. +- CI integrity: PASS after restoring the existing hosted replay-repository + selector token; the selector collects and passes both intended tests. + +## Repairs driven by review + +- Added a dedicated manual submission-policy dependency and conflict envelope. +- Replaced fabricated preflight resource evidence with a non-authorizing, + covered-Project-Manager concealment gate. +- Added replay-first pending/committed classification against stored operation, + request, resource, actor, link, action, and response facts. +- Made update append-only with deterministic successor identity, predecessor + supersession, CAS, and one root transaction. +- Added four create fault points plus update post-supersession rollback proof. +- Added service, contributor, role-claim-only, wrong-project, malformed key, + malformed precondition, stale CAS, warning custody, cross-action replay, and + concurrent replacement coverage. + +All reviewer sessions completed with no blocking finding. Hosted database +tests, per-file and repository coverage, Agent Gates, API E2E, and CodeRabbit +remain required on the exact pushed head. diff --git a/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/reviews/WS-AUTH-001-12F2-pr-trust-bundle.md b/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/reviews/WS-AUTH-001-12F2-pr-trust-bundle.md new file mode 100644 index 000000000..5640d73a6 --- /dev/null +++ b/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/reviews/WS-AUTH-001-12F2-pr-trust-bundle.md @@ -0,0 +1,81 @@ +# Workstream PR Trust Bundle + +## Chunk + +`WS-AUTH-001-12F2` - Manual Submission Policy Drafts + +## Goal + +Activate only human Project Manager manual create/update for submission-policy +drafts, with exact sufficiency lineage, non-bypassable Workstream defaults, +append-only replacement, PREP evidence, and replay custody. + +## What changed + +- Activated create/update under child owner 12F2; derive/approve remain planned. +- Added a dedicated human/key/PREP API boundary with service concealment. +- Added exact system-or-project Project Manager admission before protected + lookups and exact locked PREP before mutation. +- Replaced in-place updates with a deterministic successor row and atomic + predecessor supersession. +- Added replay-first pending/committed handling that reauthorizes stored facts + without depending on later guide/setup lineage. +- Removed obsolete self-committing manual create/update service entry points. +- Expanded focused, fault-injection, OpenAPI, and real API contract coverage. + +## Scope and behavior + +- Project Managers may create or replace a manual draft only for a covered + project and its current authoritative sufficiency lineage. +- Warning-bearing sufficiency requires exact 12E acknowledgement custody. +- Agent-derived rows remain immutable through the manual route. +- Derivation, approval, effective/pre-submit compilation, Celery, submission, + review, revision, payment, and reputation behavior are unchanged. +- No migration is introduced by this chunk. + +## Local evidence + +```text +Ruff app/tests/scripts: passed +AUTH exact selector: 7 passed +Non-database boundary/schema/replay checks: passed +Project exact selector: 27 tests collected +Hosted replay/service selector: 2 tests collected +CI lane contract: 33 passed +Python compileall: passed +Stale authorization docs: passed +Markdown links: passed +Stale Workstream wording: passed +git diff --check: passed +Branch versus origin/main: 0 ahead / 0 behind before commit +``` + +Database-backed project tests and the roughly four-hour full suite are not run +on the user's slow local machine. GitHub Actions must run the PostgreSQL-backed +focused coverage, API E2E, repository-wide 78 percent floor, and changed- +subsystem 90 percent floor on the exact pushed head. + +## Acceptance proof + +- [x] Only manual create/update are active under 12F2. +- [x] System and exact-project PM grants admit; wrong-project, contributor, + service, and role-claim-only callers deny. +- [x] Exact locked lineage and warning acknowledgement bind PREP and replay. +- [x] Update is append-only and CAS-bound; agent rows cannot use this path. +- [x] Exact committed replay returns stored response after later lineage drift. +- [x] Matching pending replay is retryable; changed/cross-action reuse denies. +- [x] Named create and update fault boundaries roll back product, replay, and + allowed evidence together. +- [ ] Hosted PostgreSQL, full coverage, Agent Gates, API E2E, and CodeRabbit + pass on the exact pushed head. + +## Human review focus + +- Concealment-only PM admission versus exact durable PREP authority. +- Stored-fact replay independence from later guide/setup changes. +- Append-only predecessor/successor identity and transaction atomicity. +- Default-floor preservation and manual/agent provenance isolation. + +## Human merge ownership + +- [ ] The user explicitly approved this specific PR for merge. diff --git a/backend/app/api/deps/authorization.py b/backend/app/api/deps/authorization.py index cf78d2c0e..abafa863a 100644 --- a/backend/app/api/deps/authorization.py +++ b/backend/app/api/deps/authorization.py @@ -8,7 +8,7 @@ from typing import Annotated from uuid import UUID -from fastapi import Depends, Request, status +from fastapi import Depends, Header, Request, status from sqlalchemy.exc import SQLAlchemyError from sqlalchemy.ext.asyncio import AsyncSession @@ -300,6 +300,76 @@ async def get_prepared_authorization_service( yield service +def require_submission_policy_mutation_key( + idempotency_key: Annotated[str | None, Header(alias="Idempotency-Key")] = None, +) -> UUID: + """Validate manual policy replay custody before actor provisioning.""" + if idempotency_key is None: + raise StructuredHTTPException( + status_code=422, + detail="Idempotency-Key must be a UUID", + error_code="validation_error", + error_message="Idempotency-Key must be a UUID", + ) + try: + return UUID(idempotency_key) + except ValueError as exc: + raise StructuredHTTPException( + status_code=422, + detail="Idempotency-Key must be a UUID", + error_code="validation_error", + error_message="Idempotency-Key must be a UUID", + ) from exc + + +async def require_submission_policy_human( + key: Annotated[UUID, Depends(require_submission_policy_mutation_key)], + result: Annotated[AuthVerificationResult, Depends(get_auth_verification_result)], +) -> AuthVerificationResult: + """Conceal the public manual-policy surface from service principals.""" + del key + if result.token.subject_kind != "human": + raise StructuredHTTPException( + status_code=404, + detail="Project authorization resource not found", + error_code="project_authorization_resource_not_found", + error_message="Project authorization resource not found", + ) + return result + + +async def submission_policy_authorization_actor( + request: Request, + result: Annotated[AuthVerificationResult, Depends(require_submission_policy_human)], + session: Annotated[AsyncSession, Depends(get_db_session)], + rate_control: Annotated[RateControlService, Depends(get_rate_control_service)], +) -> ResolvedActor: + """Resolve a human only after the submission-policy key gate succeeds.""" + return await resolve_authorization_actor(request, result, session, rate_control) + + +async def get_submission_policy_prepared_authorization_service( + request: Request, + resolved: Annotated[ResolvedActor, Depends(submission_policy_authorization_actor)], + session: Annotated[AsyncSession, Depends(get_db_session)], +): + """Compose submission-policy PREP from its dedicated admitted actor.""" + async with prepared_authorization_service(request, resolved, session) as service: + yield service + + +async def submission_policy_authorization( + key: Annotated[UUID, Depends(require_submission_policy_mutation_key)], + resolved: Annotated[ResolvedActor, Depends(submission_policy_authorization_actor)], + prepared: Annotated[ + PreparedAuthorizationService, + Depends(get_submission_policy_prepared_authorization_service), + ], +): + """Return the exact actor, key, and PREP service for manual policy mutation.""" + return key, resolved, prepared + + @asynccontextmanager async def prepared_authorization_service( request: Request, diff --git a/backend/app/modules/authorization/catalogue.py b/backend/app/modules/authorization/catalogue.py index 22714faf6..7ccc50793 100644 --- a/backend/app/modules/authorization/catalogue.py +++ b/backend/app/modules/authorization/catalogue.py @@ -221,6 +221,7 @@ class ActionOwner(StrEnum): XINT_003_02B = "WS-XINT-003-02B" AUTH_12E = "WS-AUTH-001-12E" AUTH_12F = "WS-AUTH-001-12F" + AUTH_12F2 = "WS-AUTH-001-12F2" AUTH_12G = "WS-AUTH-001-12G" AUTH_12H = "WS-AUTH-001-12H" AUTH_13 = "WS-AUTH-001-13" @@ -481,20 +482,20 @@ def _active( PermissionId.PROJECT_GUIDE_MANAGE, ActionOwner.AUTH_12E, ), - _planned( + _active( ActionId.PROJECT_SUBMISSION_ARTIFACT_POLICY_CREATE, PermissionId.PROJECT_EFFECTIVE_POLICY_MANAGE, - ActionOwner.AUTH_12F, + ActionOwner.AUTH_12F2, ), _planned( ActionId.PROJECT_SUBMISSION_ARTIFACT_POLICY_DERIVE, PermissionId.PROJECT_EFFECTIVE_POLICY_MANAGE, ActionOwner.AUTH_12F, ), - _planned( + _active( ActionId.PROJECT_SUBMISSION_ARTIFACT_POLICY_UPDATE, PermissionId.PROJECT_EFFECTIVE_POLICY_MANAGE, - ActionOwner.AUTH_12F, + ActionOwner.AUTH_12F2, ), _planned( ActionId.PROJECT_SUBMISSION_ARTIFACT_POLICY_APPROVE, @@ -837,6 +838,8 @@ def _index_actions( ActionId.PROJECT_GUIDE_SUFFICIENCY_REPORT_CREATE, ActionId.PROJECT_GUIDE_SUFFICIENCY_RUN, ActionId.PROJECT_GUIDE_SUFFICIENCY_WARNINGS_ACKNOWLEDGE, + ActionId.PROJECT_SUBMISSION_ARTIFACT_POLICY_CREATE, + ActionId.PROJECT_SUBMISSION_ARTIFACT_POLICY_UPDATE, ActionId.PROJECT_READ, ActionId.ACTOR_AUTHORIZATION_CONTEXT_READ, ActionId.PROJECT_SETUP_RUN_READ, diff --git a/backend/app/modules/authorization/prepared.py b/backend/app/modules/authorization/prepared.py index 29feac04b..c8bd3f918 100644 --- a/backend/app/modules/authorization/prepared.py +++ b/backend/app/modules/authorization/prepared.py @@ -260,11 +260,9 @@ def _submission_policy_binding_matches( resource: ProjectSubmissionArtifactPolicyMutationResourceContext, ) -> bool: """Return whether final submission-policy facts exactly match preparation.""" - return ( - binding.submission_policy_context == resource.model_dump(mode="json") - and binding.submission_policy_resource_digest - == authorization_resource_digest(resource) - ) + return binding.submission_policy_context == resource.model_dump( + mode="json" + ) and binding.submission_policy_resource_digest == authorization_resource_digest(resource) _CONSUMED = _Consumed() @@ -521,8 +519,12 @@ def _binding( "guide_id", "source_snapshot_id", "policy_id", + "sufficiency_report_id", ): value[field] = UUID(str(value[field])) + raw_successor_policy_id = value.get("successor_policy_id") + if raw_successor_policy_id is not None: + value["successor_policy_id"] = UUID(str(raw_successor_policy_id)) raw_custody = value.get("setup_service_custody") if raw_custody is not None: custody = dict(raw_custody) diff --git a/backend/app/modules/authorization/runtime.py b/backend/app/modules/authorization/runtime.py index 5e9f0e36a..af864481f 100644 --- a/backend/app/modules/authorization/runtime.py +++ b/backend/app/modules/authorization/runtime.py @@ -807,8 +807,7 @@ def canonicalize_json_arrays(cls, value): def validate_catalogue_projection(self): """Keep ordered entry identity/configuration and disabled IDs canonical.""" if ( - len(self.ordered_entry_identities) - != len(self.ordered_entry_configuration_hashes) + len(self.ordered_entry_identities) != len(self.ordered_entry_configuration_hashes) or len(self.ordered_entry_identities) != len(set(self.ordered_entry_identities)) or self.disabled_catalogue_entry_ids != tuple(sorted(set(self.disabled_catalogue_entry_ids))) @@ -839,8 +838,15 @@ class ProjectSubmissionArtifactPolicyMutationResourceContext(BaseModel): policy_version: str policy_generation: int = Field(ge=1) setup_generation: int = Field(ge=1) + sufficiency_report_id: UUID + sufficiency_status: Literal["passed", "passed_with_warnings"] + sufficiency_acknowledgement_digest: str | None = Field( + default=None, pattern=r"^sha256:[0-9a-f]{64}$" + ) policy_status: str | None = None policy_digest: str | None = Field(default=None, pattern=r"^sha256:[0-9a-f]{64}$") + successor_policy_id: UUID | None = None + successor_policy_version: str | None = None stale_output_digest: str | None = Field(default=None, pattern=r"^sha256:[0-9a-f]{64}$") effective_output_digest: str | None = Field(default=None, pattern=r"^sha256:[0-9a-f]{64}$") compiled_pre_submit_output_digest: str | None = Field( @@ -859,6 +865,17 @@ def require_submission_policy_identity(self): existing_policy = self.target_kind in {"update", "approve"} if existing_policy != (self.policy_status is not None): raise ValueError("existing submission policy requires status and digest") + replacement = self.target_kind == "update" + if replacement != ( + self.successor_policy_id is not None and self.successor_policy_version is not None + ): + raise ValueError("policy update requires an exact successor identity") + if replacement and self.successor_policy_id == self.policy_id: + raise ValueError("policy successor must differ from its predecessor") + if (self.sufficiency_status == "passed_with_warnings") != ( + self.sufficiency_acknowledgement_digest is not None + ): + raise ValueError("warning sufficiency requires exact acknowledgement custody") approval = self.target_kind == "approve" if approval != ( self.effective_output_digest is not None diff --git a/backend/app/modules/projects/router.py b/backend/app/modules/projects/router.py index 97daf60c8..36eb4fe49 100644 --- a/backend/app/modules/projects/router.py +++ b/backend/app/modules/projects/router.py @@ -14,8 +14,10 @@ from app.api.deps.authorization import ( enforce_human_authorization_read, get_authorization_service, + submission_policy_authorization, ) from app.core.permissions import PermissionDenied +from app.core.api_controls import StructuredHTTPException from app.db.session import get_db_session from app.interfaces.artifact_operations import ( GuideArtifactIngestCommand, @@ -55,6 +57,10 @@ GuideSufficiencyMutationConflict, GuideSufficiencyMutationService, ) +from app.modules.projects.submission_policy_mutation_service import ( + SubmissionPolicyMutationConflict, + SubmissionPolicyMutationService, +) from app.modules.actors.service import ResolvedActor from app.modules.authorization.prepared import PreparedAuthorizationService from app.modules.projects.authorization_reads import ( @@ -101,6 +107,24 @@ def permission_http_error(exc: PermissionDenied) -> HTTPException: return HTTPException(status_code=403, detail=str(exc)) +def submission_policy_conflict_error(code: str) -> StructuredHTTPException: + """Return a bounded manual-policy conflict without guide-mutation wording.""" + messages = { + "idempotency_mismatch": "Idempotency key does not match", + "idempotency_pending": "Submission policy mutation is already in progress", + "submission_policy_precondition_failed": "Submission policy precondition failed", + "submission_policy_lineage_stale": "Submission policy lineage is stale", + "submission_policy_version_conflict": "Submission policy version already exists", + } + return StructuredHTTPException( + status_code=409, + detail=code, + error_code=code, + error_message=messages.get(code, "Submission policy mutation conflicts with current state"), + retryable=code == "idempotency_pending", + ) + + @router.get( "/{project_id}", response_model=ProjectResponse | ContributorProjectResponse, @@ -447,25 +471,33 @@ async def acknowledge_guide_sufficiency_warnings( "/{project_id}/guides/{guide_id}/submission-artifact-policies", response_model=SubmissionArtifactPolicyResponse, status_code=201, + openapi_extra={ + "x-workstream-action-id": ActionId.PROJECT_SUBMISSION_ARTIFACT_POLICY_CREATE.value + }, ) async def create_submission_artifact_policy( - project_id: str, - guide_id: str, + project_id: UUID, + guide_id: UUID, payload: SubmissionArtifactPolicyCreate, - actor: Annotated[ActorContext, Depends(get_registered_actor)], + authorization: Annotated[ + tuple[UUID, ResolvedActor, PreparedAuthorizationService], + Depends(submission_policy_authorization), + ], session: Annotated[AsyncSession, Depends(get_db_session)], ) -> SubmissionArtifactPolicyResponse: - """Create a draft Workstream-derived submission artifact policy.""" + """Create one governed manual submission-policy draft.""" + key, resolved, prepared = authorization try: - return await ProjectService(session).create_submission_artifact_policy( - actor, - project_id, - guide_id, - payload, + outcome = await SubmissionPolicyMutationService(session).create_manual( + resolved, prepared, key, project_id, guide_id, payload ) - except PermissionDenied as exc: - raise permission_http_error(exc) from exc + await (session.rollback() if outcome.replayed else session.commit()) + return outcome.response + except SubmissionPolicyMutationConflict as exc: + await session.rollback() + raise submission_policy_conflict_error(str(exc)) from exc except ProjectServiceError as exc: + await session.rollback() raise project_http_error(exc) from exc @@ -510,27 +542,34 @@ async def run_submission_artifact_policy_derivation_agent( @router.patch( "/{project_id}/guides/{guide_id}/submission-artifact-policies/{policy_id}", response_model=SubmissionArtifactPolicyResponse, + openapi_extra={ + "x-workstream-action-id": ActionId.PROJECT_SUBMISSION_ARTIFACT_POLICY_UPDATE.value + }, ) async def update_submission_artifact_policy( - project_id: str, - guide_id: str, - policy_id: str, + project_id: UUID, + guide_id: UUID, + policy_id: UUID, payload: SubmissionArtifactPolicyUpdate, - actor: Annotated[ActorContext, Depends(get_registered_actor)], + authorization: Annotated[ + tuple[UUID, ResolvedActor, PreparedAuthorizationService], + Depends(submission_policy_authorization), + ], session: Annotated[AsyncSession, Depends(get_db_session)], ) -> SubmissionArtifactPolicyResponse: - """Update a draft submission artifact policy.""" + """Append an authorized replacement for one manual draft policy.""" + key, resolved, prepared = authorization try: - return await ProjectService(session).update_submission_artifact_policy( - actor, - project_id, - guide_id, - policy_id, - payload, + outcome = await SubmissionPolicyMutationService(session).update_manual( + resolved, prepared, key, project_id, guide_id, policy_id, payload ) - except PermissionDenied as exc: - raise permission_http_error(exc) from exc + await (session.rollback() if outcome.replayed else session.commit()) + return outcome.response + except SubmissionPolicyMutationConflict as exc: + await session.rollback() + raise submission_policy_conflict_error(str(exc)) from exc except ProjectServiceError as exc: + await session.rollback() raise project_http_error(exc) from exc diff --git a/backend/app/modules/projects/schemas.py b/backend/app/modules/projects/schemas.py index 898e8d0e2..1d5e9f007 100644 --- a/backend/app/modules/projects/schemas.py +++ b/backend/app/modules/projects/schemas.py @@ -168,7 +168,7 @@ class GuideSufficiencyReportCreate(BaseModel): model_config = ConfigDict(extra="forbid") - source_snapshot_id: str = Field(max_length=36) + source_snapshot_id: UUID status: Literal["passed", "blocked", "passed_with_warnings"] findings: list[GuideSufficiencyFindingInput] = Field(default_factory=list, max_length=100) summary: str | None = Field(default=None, max_length=2000) @@ -285,7 +285,7 @@ class SubmissionArtifactPolicyCreate(BaseModel): model_config = ConfigDict(extra="forbid") - source_snapshot_id: str = Field(max_length=36) + source_snapshot_id: UUID policy_version: str = Field(max_length=50) policy_body: SubmissionArtifactPolicyInput change_summary: str | None = Field(default=None, max_length=2000) @@ -303,13 +303,26 @@ def reject_reserved_agent_policy_version(cls, value: str) -> str: class SubmissionArtifactPolicyUpdate(BaseModel): - """Request schema for editing a draft project submission artifact policy.""" + """Request schema for creating an authorized replacement draft policy.""" model_config = ConfigDict(extra="forbid") + expected_policy_hash: str = Field(pattern=r"^sha256:[0-9a-f]{64}$") + successor_policy_version: str = Field(min_length=1, max_length=50) policy_body: SubmissionArtifactPolicyInput | None = None change_summary: str | None = Field(default=None, max_length=2000) + @field_validator("successor_policy_version") + @classmethod + def reject_reserved_successor_policy_version(cls, value: str) -> str: + """Reserve agent-derived policy versions for fixed-service output.""" + stripped_value = value.strip() + if value != stripped_value: + raise ValueError("successor_policy_version cannot include surrounding whitespace") + if stripped_value.casefold().startswith("agent-"): + raise ValueError("successor_policy_version prefix 'agent-' is reserved") + return value + class SubmissionArtifactPolicyApprove(BaseModel): """Request schema for approving a draft submission artifact policy.""" diff --git a/backend/app/modules/projects/service.py b/backend/app/modules/projects/service.py index 974e40ab6..4f93c37ae 100644 --- a/backend/app/modules/projects/service.py +++ b/backend/app/modules/projects/service.py @@ -104,9 +104,7 @@ ReviewPolicyResponse, SubmissionArtifactPolicyInput, SubmissionArtifactPolicyApprove, - SubmissionArtifactPolicyCreate, SubmissionArtifactPolicyResponse, - SubmissionArtifactPolicyUpdate, ) from app.schemas.auth import ActorContext @@ -378,6 +376,8 @@ def stage_verified_sufficiency_report( ) ) return report + + PROJECT_SETUP_TERMINAL_STATUSES = { "enqueue_failed", "sufficiency_blocked", @@ -973,70 +973,6 @@ async def acknowledge_guide_sufficiency_warnings( await self._session.refresh(report) return GuideSufficiencyReportResponse.model_validate(report) - async def create_submission_artifact_policy( - self, - actor: ActorContext, - project_id: str, - guide_id: str, - payload: SubmissionArtifactPolicyCreate, - ) -> SubmissionArtifactPolicyResponse: - """Create a draft Workstream-derived submission artifact policy. - - Args: - actor: Verified Flow actor context for the current request. - project_id: Project that owns the guide. - guide_id: Draft guide receiving the policy. - payload: Draft policy content and derivation metadata. - - Returns: - Created draft policy response. - """ - require_any_role(actor, PROJECT_SETUP_ROLES) - guide = await self._lock_project_guide_for_setup(project_id, guide_id) - if guide.status != "draft": - raise GuideEditBlocked("only draft guides can receive submission artifact policies") - snapshot = await self._get_snapshot_for_guide(project_id, guide, payload.source_snapshot_id) - await self._ensure_snapshot_is_latest(project_id, guide, snapshot) - await self.validate_source_snapshot_integrity(snapshot, PolicySetupBlocked) - policy_body = self._canonical_policy_body(payload.policy_body.model_dump(mode="json")) - self._merge_effective_submission_artifact_policy(policy_body) - sufficiency_report = await self._repo.get_diagnostic_sufficiency_report_for_snapshot( - snapshot.id - ) - self._validate_sufficiency_report_allows_policy_approval( - sufficiency_report, - snapshot, - ) - source_material_refs = await self._verified_source_material_refs(sufficiency_report) - policy = SubmissionArtifactPolicy( - id=str(uuid4()), - project_id=project_id, - guide_id=guide.id, - guide_version=guide.version, - source_snapshot_id=snapshot.id, - source_snapshot_hash=snapshot.bundle_hash, - policy_version=payload.policy_version, - lifecycle_status="draft", - policy_body=policy_body, - policy_hash=self._hash_canonical_json(policy_body), - derivation_source=MANUAL_SUBMISSION_ARTIFACT_POLICY_DERIVATION_SOURCE, - source_material_refs=source_material_refs, - derivation_agent_name=None, - derivation_agent_version=None, - created_by=actor.actor_id, - change_summary=payload.change_summary, - ) - try: - policy = await self._repo.add_submission_artifact_policy(policy) - await self._session.commit() - except IntegrityError as exc: - await self._session.rollback() - raise PolicySetupConflict( - "submission artifact policy conflicted with concurrent setup; retry" - ) from exc - await self._session.refresh(policy) - return SubmissionArtifactPolicyResponse.model_validate(policy) - async def run_submission_artifact_policy_derivation_agent( self, actor: ActorContext, @@ -1452,54 +1388,6 @@ async def run_post_submit_checker_policy_derivation_agent( await self._session.refresh(policy) return PostSubmitCheckerPolicyResponse.model_validate(policy), True, summary - async def update_submission_artifact_policy( - self, - actor: ActorContext, - project_id: str, - guide_id: str, - policy_id: str, - payload: SubmissionArtifactPolicyUpdate, - ) -> SubmissionArtifactPolicyResponse: - """Update mutable fields on a draft submission artifact policy. - - Args: - actor: Verified Flow actor context for the current request. - project_id: Project that owns the policy. - guide_id: Guide that owns the policy. - policy_id: Draft policy id to update. - payload: Partial policy updates. - - Returns: - Updated draft policy response. - """ - require_any_role(actor, PROJECT_SETUP_ROLES) - guide = await self._lock_project_guide_for_setup(project_id, guide_id) - if guide.status != "draft": - raise GuideEditBlocked("only draft guides can edit submission artifact policies") - policy = await self._repo.lock_submission_artifact_policy(policy_id) - if policy is None or policy.project_id != project_id or policy.guide_id != guide.id: - raise SubmissionArtifactPolicyNotFound("submission artifact policy not found") - if policy.lifecycle_status != "draft": - raise PolicyEditBlocked("approved and superseded policies are immutable") - if payload.policy_body is not None: - if policy.derivation_source == AGENT_SUBMISSION_ARTIFACT_POLICY_DERIVATION_SOURCE: - raise PolicyEditBlocked( - "agent-derived policy bodies are immutable; create a manual policy to adjust" - ) - policy_body = self._canonical_policy_body(payload.policy_body.model_dump(mode="json")) - self._merge_effective_submission_artifact_policy(policy_body) - policy.policy_body = policy_body - policy.policy_hash = self._hash_canonical_json(policy_body) - if payload.change_summary is not None: - if policy.derivation_source == AGENT_SUBMISSION_ARTIFACT_POLICY_DERIVATION_SOURCE: - raise PolicyEditBlocked( - "agent-derived policy summaries are immutable; create a manual policy to adjust" - ) - policy.change_summary = payload.change_summary - await self._session.commit() - await self._session.refresh(policy) - return SubmissionArtifactPolicyResponse.model_validate(policy) - async def approve_submission_artifact_policy( self, actor: ActorContext, @@ -3034,6 +2922,14 @@ def _canonical_policy_body(self, policy_body: dict[str, Any]) -> dict[str, Any]: "packaging": packaging, } + def canonical_manual_submission_policy_body( + self, policy_body: dict[str, Any] + ) -> tuple[dict[str, Any], str]: + """Canonicalize a manual policy and enforce the Workstream default floor.""" + canonical = self._canonical_policy_body(policy_body) + self._merge_effective_submission_artifact_policy(canonical) + return canonical, self._hash_canonical_json(canonical) + def _validate_unique_policy_rule_keys( self, rules: list[dict[str, Any]], @@ -3443,6 +3339,12 @@ async def _verified_source_material_refs( for usage in usages ] + async def verified_source_material_refs( + self, sufficiency_report: GuideSufficiencyReport | None + ) -> list[str]: + """Return canonical verified source references for authorized policy writes.""" + return await self._verified_source_material_refs(sufficiency_report) + async def _require_verified_report_sources( self, sufficiency_report: GuideSufficiencyReport | None, diff --git a/backend/app/modules/projects/submission_policy_mutation_service.py b/backend/app/modules/projects/submission_policy_mutation_service.py index b98e8289e..63cf724c4 100644 --- a/backend/app/modules/projects/submission_policy_mutation_service.py +++ b/backend/app/modules/projects/submission_policy_mutation_service.py @@ -2,20 +2,54 @@ from __future__ import annotations +import json from dataclasses import dataclass -from typing import Literal -from uuid import UUID +from datetime import UTC, datetime +from typing import Literal, cast +from uuid import NAMESPACE_URL, UUID, uuid5 +from pydantic import JsonValue +from sqlalchemy.exc import IntegrityError from sqlalchemy.ext.asyncio import AsyncSession +from app.core.hashing import canonical_json_hash +from app.modules.actors.service import ResolvedActor from app.modules.actors.service_identities import ServiceIdentity +from app.modules.authorization.prepared import PreparedAuthorizationService from app.modules.authorization.runtime import ( + MatchedAuthorityKind, PROJECT_SUBMISSION_POLICY_TARGET_KIND_BY_ACTION, + PreparedAuthorizationInput, + PreparedAuthorizationUnsupported, + PreparedAuthorityScope, + PreparedAuthorityScopeKind, ProjectSubmissionArtifactPolicyMutationResourceContext, authorization_resource_digest, ) -from app.modules.authorization.catalogue import ActionId -from app.modules.projects.models import SubmissionPolicyMutationIdempotencyRecord +from app.modules.authorization.catalogue import ActionId, PermissionId +from app.modules.authorization.repository import AdminAuthorizationRepository +from app.modules.authorization.schemas import AdminRole +from app.modules.projects.models import ( + GuideSufficiencyReport, + SubmissionArtifactPolicy, + SubmissionPolicyMutationIdempotencyRecord, +) +from app.modules.projects.repository import ProjectRepository, ProjectRepositoryIntegrityError +from app.modules.projects.schemas import ( + SubmissionArtifactPolicyCreate, + SubmissionArtifactPolicyResponse, + SubmissionArtifactPolicyUpdate, +) +from app.modules.projects.service import ( + GuideEditBlocked, + GuideNotFound, + MANUAL_SUBMISSION_ARTIFACT_POLICY_DERIVATION_SOURCE, + PolicyEditBlocked, + PolicySetupBlocked, + ProjectService, + ProjectServiceError, + SubmissionArtifactPolicyNotFound, +) from app.modules.projects.submission_policy_mutation_repository import ( SubmissionPolicyMutationReplayRepository, ) @@ -43,12 +77,773 @@ class SubmissionPolicyReplayFacts: correlation_id: UUID | None +class SubmissionPolicyMutationConflict(ProjectServiceError): + """A replay selector, CAS, or locked policy lineage no longer matches.""" + + status_code = 409 + + +@dataclass(frozen=True, slots=True) +class SubmissionPolicyMutationOutcome: + """One route-owned manual policy transaction result.""" + + response: SubmissionArtifactPolicyResponse + replayed: bool + + +@dataclass(frozen=True, slots=True) +class _ManualPolicyLineage: + """Exact server-owned facts locked around PREP consumption.""" + + guide_version: str + snapshot_id: UUID + snapshot_hash: str + setup_run_id: UUID + setup_generation: int + report_id: UUID + report_status: Literal["passed", "passed_with_warnings"] + acknowledgement_digest: str | None + source_material_refs: tuple[str, ...] + predecessor_id: UUID | None = None + predecessor_version: str | None = None + predecessor_status: str | None = None + predecessor_hash: str | None = None + + class SubmissionPolicyMutationService: """Stage replay custody without owning commit, rollback, or product writes.""" def __init__(self, session: AsyncSession) -> None: self._session = session self._replay = SubmissionPolicyMutationReplayRepository(session) + self._projects = ProjectRepository(session) + self._admin = AdminAuthorizationRepository(session) + self._validation = ProjectService(session) + + @staticmethod + def _stable_uuid(*parts: object) -> UUID: + return uuid5(NAMESPACE_URL, "workstream:submission-policy:" + ":".join(map(str, parts))) + + @staticmethod + def _prove_human_authority(decision, project_id: UUID) -> None: + if ( + decision.matched_authority_kind is not MatchedAuthorityKind.ADMIN_ROLE_GRANT + or decision.matched_grant_id is None + or decision.matched_scope_project_id not in {None, project_id} + ): + raise RuntimeError("manual policy mutation lacked covered Project Manager authority") + + @staticmethod + def _acknowledgement_digest( + report: GuideSufficiencyReport, + project_id: UUID, + ) -> str | None: + if report.status != "passed_with_warnings": + return None + values = { + "actor_profile_id": report.warnings_acknowledged_by_actor_profile_id, + "identity_link_id": report.warnings_acknowledged_via_identity_link_id, + "grant_id": ( + str(report.warnings_acknowledged_by_admin_role_grant_id) + if report.warnings_acknowledged_by_admin_role_grant_id is not None + else None + ), + "scope_type": report.warning_acknowledgement_scope_type, + "scope_project_id": report.warning_acknowledgement_scope_project_id, + "action_id": report.warning_acknowledgement_action_id, + "decision_event_id": report.warning_acknowledgement_decision_event_id, + "acknowledged_at": ( + report.warnings_acknowledged_at.isoformat() + if report.warnings_acknowledged_at is not None + else None + ), + } + if ( + any(value is None for value in values.values()) + or values["scope_project_id"] != str(project_id) + or values["scope_type"] not in {"system", "project"} + or values["action_id"] != ActionId.PROJECT_GUIDE_SUFFICIENCY_WARNINGS_ACKNOWLEDGE.value + ): + raise PolicySetupBlocked( + "guide sufficiency warnings require authorized Project Manager acknowledgement" + ) + return canonical_json_hash( + { + "domain": "workstream.guide_sufficiency.warning_acknowledgement.v1", + "report_id": report.id, + **values, + } + ) + + async def _lineage( + self, + project_id: UUID, + guide_id: UUID, + snapshot_id: UUID, + *, + lock: bool, + predecessor_id: UUID | None = None, + ) -> _ManualPolicyLineage: + project = await self._projects.get_project(str(project_id), for_update=lock) + if project is None: + raise GuideNotFound("project not found") + guide = ( + await self._projects.lock_project_guide(str(guide_id)) + if lock + else await self._projects.get_guide(str(guide_id)) + ) + if guide is None or guide.project_id != str(project_id): + raise GuideNotFound("guide not found") + if guide.status != "draft": + raise GuideEditBlocked("only draft guides can receive submission artifact policies") + try: + snapshot = ( + await self._projects.lock_latest_guide_source_snapshot( + str(project_id), str(guide_id), guide.version + ) + if lock + else await self._projects.get_latest_guide_source_snapshot( + str(project_id), str(guide_id), guide.version + ) + ) + except ProjectRepositoryIntegrityError as exc: + raise PolicySetupBlocked("latest guide source snapshot is ambiguous") from exc + if snapshot is None or snapshot.id != str(snapshot_id): + raise SubmissionPolicyMutationConflict("submission_policy_lineage_stale") + await self._validation.validate_source_snapshot_integrity(snapshot, PolicySetupBlocked) + setup = ( + await self._projects.lock_latest_project_setup_run( + str(project_id), str(guide_id), guide.version + ) + if lock + else await self._projects.get_latest_project_setup_run(str(project_id), str(guide_id)) + ) + if ( + setup is None + or setup.guide_version != guide.version + or setup.source_snapshot_id != snapshot.id + or setup.source_snapshot_hash != snapshot.bundle_hash + ): + raise SubmissionPolicyMutationConflict("submission_policy_lineage_stale") + report = await self._projects.get_sufficiency_report_for_snapshot(snapshot.id) + if report is None: + raise PolicySetupBlocked("authoritative guide sufficiency report is required") + if lock: + report = await self._projects.lock_guide_sufficiency_report( + report.id, str(project_id), str(guide_id), guide.version + ) + if ( + report is None + or report.project_setup_run_id != setup.id + or report.setup_generation != setup.setup_generation + or report.source_snapshot_hash != snapshot.bundle_hash + or report.status not in {"passed", "passed_with_warnings"} + ): + raise PolicySetupBlocked("authoritative guide sufficiency report does not allow policy") + acknowledgement_digest = self._acknowledgement_digest(report, project_id) + source_refs = tuple(await self._validation.verified_source_material_refs(report)) + predecessor = None + if predecessor_id is not None: + predecessor = ( + await self._projects.lock_submission_artifact_policy(str(predecessor_id)) + if lock + else await self._projects.get_submission_artifact_policy(str(predecessor_id)) + ) + if ( + predecessor is None + or predecessor.project_id != str(project_id) + or predecessor.guide_id != str(guide_id) + or predecessor.source_snapshot_id != snapshot.id + ): + raise SubmissionArtifactPolicyNotFound("submission artifact policy not found") + if predecessor.lifecycle_status != "draft": + raise PolicyEditBlocked("only a current draft policy can be replaced") + if predecessor.derivation_source != MANUAL_SUBMISSION_ARTIFACT_POLICY_DERIVATION_SOURCE: + raise PolicyEditBlocked("agent-derived policies are immutable through this path") + return _ManualPolicyLineage( + guide_version=guide.version, + snapshot_id=snapshot_id, + snapshot_hash=snapshot.bundle_hash, + setup_run_id=UUID(setup.id), + setup_generation=setup.setup_generation, + report_id=UUID(report.id), + report_status=cast(Literal["passed", "passed_with_warnings"], report.status), + acknowledgement_digest=acknowledgement_digest, + source_material_refs=source_refs, + predecessor_id=UUID(predecessor.id) if predecessor is not None else None, + predecessor_version=predecessor.policy_version if predecessor is not None else None, + predecessor_status=predecessor.lifecycle_status if predecessor is not None else None, + predecessor_hash=predecessor.policy_hash if predecessor is not None else None, + ) + + @staticmethod + def _request_digest( + *, + action: ActionId, + route: str, + resolved: ResolvedActor, + key: UUID, + project_id: UUID, + guide_id: UUID, + policy_id: UUID, + successor_id: UUID | None, + successor_version: str, + source_snapshot_id: UUID, + body: dict, + ) -> str: + replay_value = { + "action_id": action.value, + "route": route, + "actor_profile_id": resolved.profile.id, + "identity_link_id": resolved.identity_link.id, + "idempotency_key": str(key), + "project_id": str(project_id), + "guide_id": str(guide_id), + "source_snapshot_id": str(source_snapshot_id), + "policy_id": str(policy_id), + "successor_policy_id": str(successor_id) if successor_id is not None else None, + "successor_policy_version": successor_version, + "body": body, + } + digest = canonical_json_hash( + {"domain": "workstream.submission_policy.manual.idempotency.v1", **replay_value} + ) + return digest + + @staticmethod + def _resource( + *, + project_id: UUID, + guide_id: UUID, + policy_id: UUID, + policy_version: str, + successor_id: UUID | None, + successor_version: str | None, + operation_id: UUID, + request_digest: str, + lineage: _ManualPolicyLineage, + target_kind: Literal["create", "update"], + ) -> ProjectSubmissionArtifactPolicyMutationResourceContext: + return ProjectSubmissionArtifactPolicyMutationResourceContext( + resource_type="project_submission_artifact_policy_mutation", + resource_id=policy_id, + operation_id=operation_id, + request_digest=request_digest, + scope_project_id=project_id, + guide_id=guide_id, + guide_version=lineage.guide_version, + source_snapshot_id=lineage.snapshot_id, + source_snapshot_hash=lineage.snapshot_hash, + target_kind=target_kind, + execution_kind="human", + policy_id=policy_id, + policy_version=policy_version, + policy_generation=lineage.setup_generation, + setup_generation=lineage.setup_generation, + sufficiency_report_id=lineage.report_id, + sufficiency_status=lineage.report_status, + sufficiency_acknowledgement_digest=lineage.acknowledgement_digest, + policy_status=lineage.predecessor_status, + policy_digest=lineage.predecessor_hash, + successor_policy_id=successor_id, + successor_policy_version=successor_version, + ) + + async def _prepare( + self, + prepared: PreparedAuthorizationService, + action: ActionId, + caller: PreparedAuthorizationInput, + project_id: UUID, + resource: ProjectSubmissionArtifactPolicyMutationResourceContext, + ): + try: + return await prepared.prepare( + action, + caller, + PreparedAuthorityScope( + kind=PreparedAuthorityScopeKind.PROJECT, + project_id=project_id, + ), + ) + except PreparedAuthorizationUnsupported as exc: + await prepared.deny_unsupported(action, caller, resource, exc) + + async def _require_pm_admission( + self, + *, + resolved: ResolvedActor, + project_id: UUID, + ) -> None: + """Conceal product lookups unless a covered PM grant exists. + + This guard never grants mutation authority. The sole durable decision + is the later PREP consumption against exact locked product facts. + """ + grant = await self._admin.find_effective_grant( + UUID(resolved.profile.id), + PermissionId.PROJECT_EFFECTIVE_POLICY_MANAGE, + scope_project_id=project_id, + allowed_roles=frozenset({AdminRole.PROJECT_MANAGER}), + ) + if grant is None: + raise SubmissionArtifactPolicyNotFound("submission artifact policy not found") + + async def _manual_mutation( + self, + *, + resolved: ResolvedActor, + prepared: PreparedAuthorizationService, + key: UUID, + project_id: UUID, + guide_id: UUID, + predecessor_id: UUID | None, + source_snapshot_id: UUID, + policy_version: str, + expected_policy_hash: str | None, + policy_body: dict | None, + change_summary: str | None, + ) -> SubmissionPolicyMutationOutcome: + action = ( + ActionId.PROJECT_SUBMISSION_ARTIFACT_POLICY_UPDATE + if predecessor_id is not None + else ActionId.PROJECT_SUBMISSION_ARTIFACT_POLICY_CREATE + ) + route = ( + "PATCH /api/v1/projects/{project_id}/guides/{guide_id}/submission-artifact-policies/{policy_id}" + if predecessor_id is not None + else "POST /api/v1/projects/{project_id}/guides/{guide_id}/submission-artifact-policies" + ) + stable_parts = ( + action.value, + resolved.profile.id, + resolved.identity_link.id, + project_id, + predecessor_id or "create", + key, + ) + operation_id = self._stable_uuid("operation", *stable_parts) + committed_policy_id = self._stable_uuid("policy", *stable_parts) + initial = await self._lineage( + project_id, + guide_id, + source_snapshot_id, + lock=False, + predecessor_id=predecessor_id, + ) + if predecessor_id is not None and initial.predecessor_hash != expected_policy_hash: + raise SubmissionPolicyMutationConflict("submission_policy_precondition_failed") + canonical_body, policy_hash = self._validation.canonical_manual_submission_policy_body( + policy_body + ) + body = { + "source_snapshot_id": str(source_snapshot_id), + "policy_version": policy_version, + "expected_policy_hash": expected_policy_hash, + "policy_body": canonical_body, + "change_summary": change_summary, + } + resource_policy_id = predecessor_id or committed_policy_id + digest = self._request_digest( + action=action, + route=route, + resolved=resolved, + key=key, + project_id=project_id, + guide_id=guide_id, + policy_id=resource_policy_id, + successor_id=committed_policy_id if predecessor_id is not None else None, + successor_version=policy_version, + source_snapshot_id=initial.snapshot_id, + body=body, + ) + initial_resource = self._resource( + project_id=project_id, + guide_id=guide_id, + policy_id=resource_policy_id, + policy_version=initial.predecessor_version or policy_version, + successor_id=committed_policy_id if predecessor_id is not None else None, + successor_version=policy_version if predecessor_id is not None else None, + operation_id=operation_id, + request_digest=digest, + lineage=initial, + target_kind="update" if predecessor_id is not None else "create", + ) + caller = PreparedAuthorizationInput( + idempotency_key=key, + request_value=cast(JsonValue, initial_resource.model_dump(mode="json")), + ) + handle = await self._prepare(prepared, action, caller, project_id, initial_resource) + final = await self._lineage( + project_id, + guide_id, + source_snapshot_id, + lock=True, + predecessor_id=predecessor_id, + ) + if final != initial or ( + predecessor_id is not None and final.predecessor_hash != expected_policy_hash + ): + raise SubmissionPolicyMutationConflict("submission_policy_lineage_stale") + final_resource = self._resource( + project_id=project_id, + guide_id=guide_id, + policy_id=resource_policy_id, + policy_version=final.predecessor_version or policy_version, + successor_id=committed_policy_id if predecessor_id is not None else None, + successor_version=policy_version if predecessor_id is not None else None, + operation_id=operation_id, + request_digest=digest, + lineage=final, + target_kind="update" if predecessor_id is not None else "create", + ) + decision = await prepared.consume(handle, action, caller, final_resource) + self._prove_human_authority(decision, project_id) + facts = SubmissionPolicyReplayFacts( + actor_profile_id=resolved.profile.id, + identity_link_id=resolved.identity_link.id, + service_identity=None, + action_id=action.value, + idempotency_key=key, + request_digest=digest, + resource_context=final_resource, + operation_id=operation_id, + project_id=str(project_id), + guide_id=str(guide_id), + source_snapshot_id=str(source_snapshot_id), + policy_id=str(committed_policy_id), + setup_run_id=None, + setup_generation=final.setup_generation, + setup_task_id=None, + correlation_id=None, + ) + disposition, replay = await self.reserve_replay(facts) + if disposition == "replayed": + if replay.response_json is None or replay.committed_policy_id != str( + committed_policy_id + ): + raise SubmissionPolicyMutationConflict("idempotency_mismatch") + return SubmissionPolicyMutationOutcome( + SubmissionArtifactPolicyResponse.model_validate(replay.response_json), True + ) + if disposition != "claimed": + raise SubmissionPolicyMutationConflict(f"idempotency_{disposition}") + policy = SubmissionArtifactPolicy( + id=str(committed_policy_id), + project_id=str(project_id), + guide_id=str(guide_id), + guide_version=final.guide_version, + source_snapshot_id=str(source_snapshot_id), + source_snapshot_hash=final.snapshot_hash, + policy_version=policy_version, + lifecycle_status="draft", + policy_body=canonical_body, + policy_hash=policy_hash, + derivation_source=MANUAL_SUBMISSION_ARTIFACT_POLICY_DERIVATION_SOURCE, + source_material_refs=list(final.source_material_refs), + derivation_agent_name=None, + derivation_agent_version=None, + created_by=resolved.profile.id, + created_by_actor_profile_id=resolved.profile.id, + created_via_identity_link_id=resolved.identity_link.id, + created_by_admin_role_grant_id=decision.matched_grant_id, + created_by_service_identity=None, + creation_scope_type=( + "system" if decision.matched_scope_project_id is None else "project" + ), + creation_scope_project_id=str(project_id), + creation_action_id=action.value, + creation_decision_event_id=str(decision.decision_id), + supersedes_policy_id=str(predecessor_id) if predecessor_id is not None else None, + change_summary=change_summary, + ) + try: + await self._projects.add_submission_artifact_policy(policy) + except IntegrityError as exc: + raise SubmissionPolicyMutationConflict("submission_policy_version_conflict") from exc + if predecessor_id is not None: + predecessor = await self._projects.lock_submission_artifact_policy(str(predecessor_id)) + if predecessor is None or predecessor.lifecycle_status != "draft": + raise SubmissionPolicyMutationConflict("submission_policy_lineage_stale") + predecessor.lifecycle_status = "superseded" + predecessor.superseded_at = datetime.now(UTC) + await self._session.flush() + response = SubmissionArtifactPolicyResponse.model_validate(policy) + await self.complete_replay( + facts, + response_json=response.model_dump(mode="json"), + committed_policy_id=policy.id, + ) + return SubmissionPolicyMutationOutcome(response, False) + + async def _existing_manual_replay( + self, + *, + resolved: ResolvedActor, + prepared: PreparedAuthorizationService, + key: UUID, + action: ActionId, + project_id: UUID, + guide_id: UUID, + selected_policy_id: UUID, + successor_policy_id: UUID | None, + successor_policy_version: str, + expected_policy_hash: str | None, + source_snapshot_id: UUID | None, + policy_body: dict | None, + change_summary: str | None, + ) -> SubmissionPolicyMutationOutcome | None: + """Classify an existing operation without coupling replay to live lineage.""" + operation_id = self._stable_uuid( + "operation", + action.value, + resolved.profile.id, + resolved.identity_link.id, + project_id, + selected_policy_id + if action is ActionId.PROJECT_SUBMISSION_ARTIFACT_POLICY_UPDATE + else "create", + key, + ) + replay = await self._replay.find_by_operation(operation_id) + if replay is None: + return None + if ( + replay.actor_profile_id != resolved.profile.id + or replay.identity_link_id != resolved.identity_link.id + or replay.action_id != action.value + or replay.idempotency_key != key + or replay.project_id != str(project_id) + or replay.guide_id != str(guide_id) + ): + raise SubmissionPolicyMutationConflict("idempotency_mismatch") + resource = ProjectSubmissionArtifactPolicyMutationResourceContext.model_validate_json( + json.dumps(replay.resource_context_json) + ) + response = ( + SubmissionArtifactPolicyResponse.model_validate(replay.response_json) + if replay.status == "committed" and replay.response_json is not None + else None + ) + snapshot_id = source_snapshot_id or resource.source_snapshot_id + predecessor = None + if policy_body is None or (change_summary is None and response is None): + predecessor = await self._projects.get_submission_artifact_policy( + str(selected_policy_id) + ) + effective_policy_body = policy_body or ( + response.policy_body + if response is not None + else predecessor.policy_body + if predecessor is not None + else None + ) + if effective_policy_body is None: + raise SubmissionPolicyMutationConflict("idempotency_mismatch") + effective_change_summary = ( + change_summary + if change_summary is not None + else response.change_summary + if response is not None + else predecessor.change_summary + if predecessor is not None + else None + ) + body = { + "source_snapshot_id": str(snapshot_id), + "policy_version": successor_policy_version, + "expected_policy_hash": expected_policy_hash, + "policy_body": effective_policy_body, + "change_summary": effective_change_summary, + } + route = ( + "PATCH /api/v1/projects/{project_id}/guides/{guide_id}/submission-artifact-policies/{policy_id}" + if action is ActionId.PROJECT_SUBMISSION_ARTIFACT_POLICY_UPDATE + else "POST /api/v1/projects/{project_id}/guides/{guide_id}/submission-artifact-policies" + ) + expected_digest = self._request_digest( + action=action, + route=route, + resolved=resolved, + key=key, + project_id=project_id, + guide_id=guide_id, + policy_id=selected_policy_id, + successor_id=successor_policy_id, + successor_version=successor_policy_version, + source_snapshot_id=snapshot_id, + body=body, + ) + canonical_body, policy_hash = self._validation.canonical_manual_submission_policy_body( + effective_policy_body + ) + expected_target = ( + "update" if action is ActionId.PROJECT_SUBMISSION_ARTIFACT_POLICY_UPDATE else "create" + ) + if ( + resource.target_kind != expected_target + or resource.operation_id != operation_id + or resource.request_digest != expected_digest + or replay.request_digest != expected_digest + or replay.resource_context_digest != authorization_resource_digest(resource) + or resource.scope_project_id != project_id + or resource.guide_id != guide_id + or resource.source_snapshot_id != snapshot_id + or resource.policy_id != selected_policy_id + or resource.policy_digest != expected_policy_hash + or resource.successor_policy_id != successor_policy_id + or resource.successor_policy_version + != (successor_policy_version if successor_policy_id is not None else None) + ): + raise SubmissionPolicyMutationConflict("idempotency_mismatch") + if replay.status == "pending": + raise SubmissionPolicyMutationConflict("idempotency_pending") + if response is None or replay.committed_policy_id is None: + raise SubmissionPolicyMutationConflict("idempotency_mismatch") + committed = await self._projects.get_submission_artifact_policy(replay.committed_policy_id) + if ( + response.policy_body != canonical_body + or response.policy_hash != policy_hash + or response.policy_version != successor_policy_version + or response.change_summary != effective_change_summary + or committed is None + or committed.id != replay.committed_policy_id + or committed.policy_hash != response.policy_hash + or committed.creation_action_id != action.value + ): + raise SubmissionPolicyMutationConflict("idempotency_mismatch") + caller = PreparedAuthorizationInput( + idempotency_key=key, + request_value=cast(JsonValue, resource.model_dump(mode="json")), + ) + handle = await self._prepare(prepared, action, caller, project_id, resource) + decision = await prepared.consume(handle, action, caller, resource) + self._prove_human_authority(decision, project_id) + if replay.resource_context_digest != decision.resource_context_digest: + raise SubmissionPolicyMutationConflict("idempotency_mismatch") + return SubmissionPolicyMutationOutcome(response, True) + + async def create_manual( + self, + resolved: ResolvedActor, + prepared: PreparedAuthorizationService, + key: UUID, + project_id: UUID, + guide_id: UUID, + payload: SubmissionArtifactPolicyCreate, + ) -> SubmissionPolicyMutationOutcome: + """Create one manually authored policy draft under exact PM authority.""" + source_snapshot_id = payload.source_snapshot_id + action = ActionId.PROJECT_SUBMISSION_ARTIFACT_POLICY_CREATE + stable_parts = ( + action.value, + resolved.profile.id, + resolved.identity_link.id, + project_id, + "create", + key, + ) + policy_id = self._stable_uuid("policy", *stable_parts) + await self._require_pm_admission(resolved=resolved, project_id=project_id) + replay = await self._existing_manual_replay( + resolved=resolved, + prepared=prepared, + key=key, + action=action, + project_id=project_id, + guide_id=guide_id, + selected_policy_id=policy_id, + successor_policy_id=None, + successor_policy_version=payload.policy_version, + expected_policy_hash=None, + source_snapshot_id=source_snapshot_id, + policy_body=payload.policy_body.model_dump(mode="json"), + change_summary=payload.change_summary, + ) + if replay is not None: + return replay + return await self._manual_mutation( + resolved=resolved, + prepared=prepared, + key=key, + project_id=project_id, + guide_id=guide_id, + predecessor_id=None, + source_snapshot_id=source_snapshot_id, + policy_version=payload.policy_version, + expected_policy_hash=None, + policy_body=payload.policy_body.model_dump(mode="json"), + change_summary=payload.change_summary, + ) + + async def update_manual( + self, + resolved: ResolvedActor, + prepared: PreparedAuthorizationService, + key: UUID, + project_id: UUID, + guide_id: UUID, + policy_id: UUID, + payload: SubmissionArtifactPolicyUpdate, + ) -> SubmissionPolicyMutationOutcome: + """Append one authorized replacement for a selected manual draft.""" + action = ActionId.PROJECT_SUBMISSION_ARTIFACT_POLICY_UPDATE + stable_parts = ( + action.value, + resolved.profile.id, + resolved.identity_link.id, + project_id, + policy_id, + key, + ) + successor_id = self._stable_uuid("policy", *stable_parts) + await self._require_pm_admission(resolved=resolved, project_id=project_id) + replay = await self._existing_manual_replay( + resolved=resolved, + prepared=prepared, + key=key, + action=action, + project_id=project_id, + guide_id=guide_id, + selected_policy_id=policy_id, + successor_policy_id=successor_id, + successor_policy_version=payload.successor_policy_version, + expected_policy_hash=payload.expected_policy_hash, + source_snapshot_id=None, + policy_body=( + payload.policy_body.model_dump(mode="json") + if payload.policy_body is not None + else None + ), + change_summary=payload.change_summary, + ) + if replay is not None: + return replay + predecessor = await self._projects.get_submission_artifact_policy(str(policy_id)) + if predecessor is None: + raise SubmissionArtifactPolicyNotFound("submission artifact policy not found") + policy_body = ( + payload.policy_body.model_dump(mode="json") + if payload.policy_body is not None + else predecessor.policy_body + ) + return await self._manual_mutation( + resolved=resolved, + prepared=prepared, + key=key, + project_id=project_id, + guide_id=guide_id, + predecessor_id=policy_id, + source_snapshot_id=UUID(predecessor.source_snapshot_id), + policy_version=payload.successor_policy_version, + expected_policy_hash=payload.expected_policy_hash, + policy_body=policy_body, + change_summary=( + payload.change_summary + if payload.change_summary is not None + else predecessor.change_summary + ), + ) def _require_root_transaction(self) -> None: transaction = self._session.sync_session.get_transaction() @@ -74,7 +869,7 @@ def _replay_values(facts: SubmissionPolicyReplayFacts) -> dict[str, object]: or facts.project_id != str(resource.scope_project_id) or facts.guide_id != str(resource.guide_id) or facts.source_snapshot_id != str(resource.source_snapshot_id) - or facts.policy_id != str(resource.policy_id) + or facts.policy_id != str(resource.successor_policy_id or resource.policy_id) or facts.setup_generation != resource.setup_generation ): raise ValueError("submission-policy replay facts do not match resource context") diff --git a/backend/scripts/api_contract_e2e.py b/backend/scripts/api_contract_e2e.py index bd6b92cb3..60c7770de 100644 --- a/backend/scripts/api_contract_e2e.py +++ b/backend/scripts/api_contract_e2e.py @@ -828,6 +828,10 @@ async def create_policy_bundle_for_guide( client: httpx.AsyncClient, manager_token: str, diagnostic_reader_token: str, + contributor_token: str, + service_token: str, + role_claim_only_token: str, + wrong_project_manager_token: str, manager_subject: str, project_id: str, guide_id: str, @@ -843,6 +847,10 @@ async def create_policy_bundle_for_guide( client: HTTP client pointed at the running API. manager_token: Flow token with project manager role. diagnostic_reader_token: Token bound to the local Project Manager grant. + contributor_token: Human token without Project Manager authority. + service_token: Service token concealed from the public human boundary. + role_claim_only_token: Human PM role claim without a local grant. + wrong_project_manager_token: Human with a PM grant for another project. project_id: Project id that owns the guide. guide_id: Guide id to bind. run_id: Unique run id used for deterministic source hashes. @@ -947,8 +955,7 @@ async def analyze_guide_sufficiency(self, _material): async def derive_submission_artifact_policy(self, material, sufficiency_report): return SubmissionArtifactPolicyDerivationResult( policy_version=( - "agent-" - f"{material.source_snapshot_hash.removeprefix('sha256:')[:12]}" + f"agent-{material.source_snapshot_hash.removeprefix('sha256:')[:12]}" ), policy_body=submission_artifact_policy_body(), change_summary=( @@ -960,9 +967,7 @@ async def derive_submission_artifact_policy(self, material, sufficiency_report): agent_runtime = E2EProjectGuideAgentRuntime() project_service_module.get_project_guide_agent_runtime = lambda: agent_runtime - sufficiency_mutation_service_module.get_project_guide_agent_runtime = ( - lambda: agent_runtime - ) + sufficiency_mutation_service_module.get_project_guide_agent_runtime = lambda: agent_runtime setup_worker_result = await asyncio.to_thread( run_pre_submit_setup_pipeline, project_id, @@ -1039,6 +1044,146 @@ async def derive_submission_artifact_policy(self, material, sufficiency_report): f"/api/v1/projects/{project_id}/guides/{guide_id}/submission-artifact-policies/{policy['id']}", diagnostic_reader_token, ) + manual_create_key = str(uuid4()) + manual_payload = { + "source_snapshot_id": snapshot["id"], + "policy_version": "e2e-manual-v1", + "policy_body": policy["policy_body"], + "change_summary": "Manual policy authorization E2E.", + } + manual_path = f"/api/v1/projects/{project_id}/guides/{guide_id}/submission-artifact-policies" + await request_json( + client, + "POST", + manual_path, + contributor_token, + manual_payload, + expected_status=403, + idempotency_key=str(uuid4()), + ) + await request_json( + client, + "POST", + manual_path, + service_token, + manual_payload, + expected_status=404, + idempotency_key=str(uuid4()), + ) + for denied_token in (role_claim_only_token, wrong_project_manager_token): + await request_json( + client, + "POST", + manual_path, + denied_token, + manual_payload, + expected_status=404, + idempotency_key=str(uuid4()), + ) + missing_key = await client.post( + manual_path, + headers=auth_headers(manager_token), + json=manual_payload, + ) + ensure(missing_key.status_code == 422, "missing manual policy key was not rejected") + invalid_key = await client.post( + manual_path, + headers=auth_headers(manager_token) | {"Idempotency-Key": "not-a-uuid"}, + json=manual_payload, + ) + ensure(invalid_key.status_code == 422, "invalid manual policy key was not rejected") + manual_policy = await request_json( + client, + "POST", + manual_path, + manager_token, + manual_payload, + expected_status=201, + idempotency_key=manual_create_key, + ) + manual_replay = await request_json( + client, + "POST", + manual_path, + manager_token, + manual_payload, + expected_status=201, + idempotency_key=manual_create_key, + ) + ensure(manual_replay == manual_policy, "manual policy create replay drifted") + manual_update_key = str(uuid4()) + manual_update_payload = { + "expected_policy_hash": manual_policy["policy_hash"], + "successor_policy_version": "e2e-manual-v2", + "policy_body": manual_policy["policy_body"], + "change_summary": "Manual policy replacement authorization E2E.", + } + manual_update_path = f"{manual_path}/{manual_policy['id']}" + for invalid_headers, invalid_payload in ( + (auth_headers(manager_token), manual_update_payload), + ( + auth_headers(manager_token) | {"Idempotency-Key": "not-a-uuid"}, + manual_update_payload, + ), + ( + auth_headers(manager_token) | {"Idempotency-Key": str(uuid4())}, + {"successor_policy_version": "e2e-manual-v2"}, + ), + ( + auth_headers(manager_token) | {"Idempotency-Key": str(uuid4())}, + { + "expected_policy_hash": "not-a-digest", + "successor_policy_version": "e2e-manual-v2", + }, + ), + ( + auth_headers(manager_token) | {"Idempotency-Key": str(uuid4())}, + {"expected_policy_hash": manual_policy["policy_hash"]}, + ), + ): + invalid_update = await client.patch( + manual_update_path, + headers=invalid_headers, + json=invalid_payload, + ) + ensure( + invalid_update.status_code == 422, + f"invalid manual policy update precondition was not rejected: {invalid_update.text}", + ) + manual_successor = await request_json( + client, + "PATCH", + manual_update_path, + manager_token, + manual_update_payload, + idempotency_key=manual_update_key, + ) + manual_update_replay = await request_json( + client, + "PATCH", + manual_update_path, + manager_token, + manual_update_payload, + idempotency_key=manual_update_key, + ) + ensure(manual_update_replay == manual_successor, "manual policy update replay drifted") + ensure( + manual_successor["supersedes_policy_id"] == manual_policy["id"], + "manual policy replacement lost predecessor custody", + ) + await request_json( + client, + "PATCH", + f"{manual_path}/{manual_successor['id']}", + manager_token, + { + **manual_update_payload, + "expected_policy_hash": "sha256:" + ("0" * 64), + "successor_policy_version": "e2e-manual-v3", + }, + expected_status=409, + idempotency_key=str(uuid4()), + ) effective_policy = await request_json( client, "POST", @@ -1207,6 +1352,28 @@ async def exercise_api_contract(base_url: str, env: dict[str, str]) -> None: audience=flow_audience, secret=flow_secret, ) + untrusted_service_token = issue_flow_token( + f"real-api-untrusted-service-{run_id}", + [], + issuer=flow_issuer, + audience=flow_audience, + secret=flow_secret, + subject_kind="service", + ) + role_claim_only_token = issue_flow_token( + f"real-api-role-claim-only-{run_id}", + ["project_manager"], + issuer=flow_issuer, + audience=flow_audience, + secret=flow_secret, + ) + wrong_project_manager_token = issue_flow_token( + f"real-api-wrong-project-manager-{run_id}", + [], + issuer=flow_issuer, + audience=flow_audience, + secret=flow_secret, + ) unassigned_worker_token = issue_flow_token( f"real-api-unassigned-worker-{run_id}", ["worker"], @@ -1315,10 +1482,20 @@ async def exercise_api_contract(base_url: str, env: dict[str, str]) -> None: "/api/v1/projects/{project_id}/guides/{guide_id}/source-snapshots/" "{source_snapshot_id}/run-sufficiency-agent": "project.guide_sufficiency.run", "/api/v1/projects/{project_id}/guides/{guide_id}/sufficiency-reports/" - "{report_id}/acknowledge-warnings": ( - "project.guide_sufficiency.warnings.acknowledge" - ), + "{report_id}/acknowledge-warnings": ("project.guide_sufficiency.warnings.acknowledge"), } + submission_policy_path = ( + "/api/v1/projects/{project_id}/guides/{guide_id}/submission-artifact-policies" + ) + submission_policy_item_path = submission_policy_path + "/{policy_id}" + assert ( + openapi["paths"][submission_policy_path]["post"]["x-workstream-action-id"] + == "project.submission_artifact_policy.create" + ) + assert ( + openapi["paths"][submission_policy_item_path]["patch"]["x-workstream-action-id"] + == "project.submission_artifact_policy.update" + ) assert ( openapi["paths"]["/api/v1/projects/{project_id}/role-grants"]["post"][ "x-workstream-action-id" @@ -1575,6 +1752,37 @@ async def exercise_api_contract(base_url: str, env: dict[str, str]) -> None: ) assert project_response.status_code == 201, project_response.text project = project_response.json() + wrong_scope_project = await request_json( + client, + "POST", + "/api/v1/projects", + manager_token, + { + "name": f"Wrong Scope Project {run_id}", + "slug": f"wrong-scope-project-{run_id}", + "description": "Exact-project authorization isolation proof", + }, + expected_status=201, + idempotency_key=str(uuid4()), + ) + wrong_scope_profile = await request_json( + client, + "GET", + "/api/v1/actors/me", + wrong_project_manager_token, + ) + wrong_scope_grant = await client.post( + "/api/v1/admin-role-grants", + headers=auth_headers(manager_token) | {"Idempotency-Key": str(uuid4())}, + json={ + "target_actor_profile_id": wrong_scope_profile["actor_profile_id"], + "role": "project_manager", + "scope_type": "project", + "scope_project_id": wrong_scope_project["id"], + "reason": "Real API wrong-project PM isolation proof", + }, + ) + assert wrong_scope_grant.status_code == 201, wrong_scope_grant.text creator_revoke = await client.post( f"/api/v1/admin-role-grants/{creator_system_grant.json()['resource_id']}/revoke", headers=auth_headers(manager_token) | {"Idempotency-Key": str(uuid4())}, @@ -1690,6 +1898,10 @@ async def exercise_api_contract(base_url: str, env: dict[str, str]) -> None: client, manager_token, project_reader_token, + worker_token, + untrusted_service_token, + role_claim_only_token, + wrong_project_manager_token, manager_subject, project["id"], guide["id"], diff --git a/backend/tests/test_alembic.py b/backend/tests/test_alembic.py index 62ee2087b..94c72f70c 100644 --- a/backend/tests/test_alembic.py +++ b/backend/tests/test_alembic.py @@ -96,6 +96,7 @@ ActionOwner.XINT_003_02B, ActionOwner.AUTH_12E, ActionOwner.AUTH_12F, + ActionOwner.AUTH_12F2, ActionOwner.AUTH_12G, ActionOwner.AUTH_12H, } @@ -274,7 +275,12 @@ def test_submission_policy_authority_safe_empty_roundtrip( assert upgraded["columns"] == 4 assert upgraded["replay_table"] is True assert upgraded["provenance_triggers"] == 4 - assert {state for _, state in upgraded["action_states"]} == {"planned"} + assert dict(upgraded["action_states"]) == { + ActionId.PROJECT_SUBMISSION_ARTIFACT_POLICY_CREATE.value: "active", + ActionId.PROJECT_SUBMISSION_ARTIFACT_POLICY_DERIVE.value: "planned", + ActionId.PROJECT_SUBMISSION_ARTIFACT_POLICY_UPDATE.value: "active", + ActionId.PROJECT_SUBMISSION_ARTIFACT_POLICY_APPROVE.value: "planned", + } assert historical == (f"sha256:{'b' * 64}", None, None, None, None, None) diff --git a/backend/tests/test_authorization.py b/backend/tests/test_authorization.py index 705be24b2..055413265 100644 --- a/backend/tests/test_authorization.py +++ b/backend/tests/test_authorization.py @@ -435,9 +435,7 @@ def _submission_policy_derive_prepare_inputs() -> tuple[ PreparedAuthorizationInput, PreparedAuthorityScope ]: """Build the exact derive input and scope shared by service-matrix tests.""" - project_id, guide_id, snapshot_id, policy_id, operation_id = ( - uuid4() for _ in range(5) - ) + project_id, guide_id, snapshot_id, policy_id, operation_id = (uuid4() for _ in range(5)) custody = ProjectSetupServiceCustodyContext( setup_run_id=uuid4(), scope_project_id=project_id, @@ -465,6 +463,8 @@ def _submission_policy_derive_prepare_inputs() -> tuple[ policy_version="1", policy_generation=1, setup_generation=1, + sufficiency_report_id=uuid4(), + sufficiency_status="passed", stale_output_digest=DIGEST, setup_service_custody=custody, ) @@ -472,9 +472,7 @@ def _submission_policy_derive_prepare_inputs() -> tuple[ PreparedAuthorizationInput( idempotency_key=uuid4(), request_value=resource.model_dump(mode="json") ), - PreparedAuthorityScope( - kind=PreparedAuthorityScopeKind.PROJECT, project_id=project_id - ), + PreparedAuthorityScope(kind=PreparedAuthorityScopeKind.PROJECT, project_id=project_id), ) @@ -504,9 +502,13 @@ def _submission_policy_human_prepare_inputs( "policy_version": "1", "policy_generation": 1, "setup_generation": 1, + "sufficiency_report_id": uuid4(), + "sufficiency_status": "passed", } if target_kind in {"update", "approve"}: values.update(policy_status="draft", policy_digest=DIGEST) + if target_kind == "update": + values.update(successor_policy_id=uuid4(), successor_policy_version="2") if target_kind == "approve": values.update( effective_output_digest=DIGEST, @@ -531,9 +533,7 @@ def _submission_policy_human_prepare_inputs( PreparedAuthorizationInput( idempotency_key=uuid4(), request_value=resource.model_dump(mode="json") ), - PreparedAuthorityScope( - kind=PreparedAuthorityScopeKind.PROJECT, project_id=project_id - ), + PreparedAuthorityScope(kind=PreparedAuthorityScopeKind.PROJECT, project_id=project_id), ) @@ -2053,7 +2053,7 @@ def test_closed_permission_and_action_catalogue_is_exact_and_non_executable() -> ), "project.submission_artifact_policy.create": ( "project.effective_policy.manage", - "WS-AUTH-001-12F", + "WS-AUTH-001-12F2", ), "project.submission_artifact_policy.derive": ( "project.effective_policy.manage", @@ -2061,7 +2061,7 @@ def test_closed_permission_and_action_catalogue_is_exact_and_non_executable() -> ), "project.submission_artifact_policy.update": ( "project.effective_policy.manage", - "WS-AUTH-001-12F", + "WS-AUTH-001-12F2", ), "project.submission_artifact_policy.approve": ( "project.effective_policy.manage", @@ -2124,6 +2124,8 @@ def test_closed_permission_and_action_catalogue_is_exact_and_non_executable() -> ActionId.PROJECT_GUIDE_SUFFICIENCY_REPORT_CREATE, ActionId.PROJECT_GUIDE_SUFFICIENCY_RUN, ActionId.PROJECT_GUIDE_SUFFICIENCY_WARNINGS_ACKNOWLEDGE, + ActionId.PROJECT_SUBMISSION_ARTIFACT_POLICY_CREATE, + ActionId.PROJECT_SUBMISSION_ARTIFACT_POLICY_UPDATE, ActionId.PROJECT_READ, ActionId.ACTOR_AUTHORIZATION_CONTEXT_READ, ActionId.PROJECT_SETUP_RUN_READ, @@ -2222,14 +2224,14 @@ def test_closed_permission_and_action_catalogue_is_exact_and_non_executable() -> definition.availability is ActionAvailability.ACTIVE for definition in ACTION_DEFINITIONS ) - == 48 + == 50 ) assert ( sum( definition.availability is ActionAvailability.PLANNED for definition in ACTION_DEFINITIONS ) - == 52 + == 50 ) assert resolve_executable_action(ActionId.ACTOR_PROFILE_READ_SELF).permission_id is ( PermissionId.ACTOR_PROFILE_READ_SELF @@ -2405,11 +2407,13 @@ def test_project_mutation_resources_and_prepared_scopes_are_closed() -> None: policy_status=("draft" if target_kind in {"update", "approve"} else None), policy_digest=(DIGEST if target_kind in {"update", "approve"} else None), setup_generation=1, + sufficiency_report_id=report_id, + sufficiency_status="passed", + successor_policy_id=(uuid4() if target_kind == "update" else None), + successor_policy_version=("2" if target_kind == "update" else None), stale_output_digest=DIGEST if target_kind == "derive" else None, effective_output_digest=DIGEST if target_kind == "approve" else None, - compiled_pre_submit_output_digest=( - DIGEST if target_kind == "approve" else None - ), + compiled_pre_submit_output_digest=(DIGEST if target_kind == "approve" else None), compilation=( SubmissionPolicyCompilationContext( compiler_version="v1", @@ -2761,6 +2765,24 @@ def test_fixed_service_action_matrix_and_activation_are_exact_and_immutable() -> ActionAvailability.PLANNED, ), } + + +def test_submission_artifact_policy_create_update_activation_is_12f2_only() -> None: + """Activate only the two human manual-draft actions under the child owner.""" + for action in ( + ActionId.PROJECT_SUBMISSION_ARTIFACT_POLICY_CREATE, + ActionId.PROJECT_SUBMISSION_ARTIFACT_POLICY_UPDATE, + ): + definition = ACTION_BY_ID[action] + assert definition.owner is ActionOwner.AUTH_12F2 + assert definition.availability is ActionAvailability.ACTIVE + assert definition.permission_id is PermissionId.PROJECT_EFFECTIVE_POLICY_MANAGE + for action in ( + ActionId.PROJECT_SUBMISSION_ARTIFACT_POLICY_DERIVE, + ActionId.PROJECT_SUBMISSION_ARTIFACT_POLICY_APPROVE, + ): + assert ACTION_BY_ID[action].owner is ActionOwner.AUTH_12F + assert ACTION_BY_ID[action].availability is ActionAvailability.PLANNED active_internal = { ActionId.ARTIFACT_VERIFICATION_EXECUTE, ActionId.ARTIFACT_PUT_ATTEMPT_RESOLVE, @@ -3714,9 +3736,7 @@ def __getattr__(self, name: str): for action_id in PROJECT_MUTATION_RESOURCE_BY_ACTION: if ACTION_BY_ID[action_id].availability is ActionAvailability.ACTIVE: continue - caller_input = PreparedAuthorizationInput( - idempotency_key=uuid4(), request_value={} - ) + caller_input = PreparedAuthorizationInput(idempotency_key=uuid4(), request_value={}) scope = PreparedAuthorityScope( kind=PreparedAuthorityScopeKind.PROJECT, project_id=project_id ) @@ -3727,9 +3747,7 @@ def __getattr__(self, name: str): ActionId.PROJECT_SUBMISSION_ARTIFACT_POLICY_UPDATE, ActionId.PROJECT_SUBMISSION_ARTIFACT_POLICY_APPROVE, }: - caller_input, scope = _submission_policy_human_prepare_inputs( - action_id, project_id - ) + caller_input, scope = _submission_policy_human_prepare_inputs(action_id, project_id) with pytest.raises(PreparedAuthorizationUnsupported) as exc_info: await prepared.prepare( action_id, @@ -3741,6 +3759,101 @@ def __getattr__(self, name: str): assert evidence.events == [] +@pytest.mark.asyncio +@pytest.mark.parametrize( + "action_id", + [ + ActionId.PROJECT_SUBMISSION_ARTIFACT_POLICY_CREATE, + ActionId.PROJECT_SUBMISSION_ARTIFACT_POLICY_UPDATE, + ], +) +@pytest.mark.parametrize("grant_scope", ["system", "project"]) +async def test_submission_artifact_policy_create_update_accepts_only_covered_pm_grant( + action_id: ActionId, + grant_scope: str, +) -> None: + """12F2 PREP admits a covered PM grant and binds the whole final resource.""" + context = _runtime_context() + assert isinstance(context, HumanAuthorizationContext) + session = _PreparedTestSession() + project_id, grant_id = uuid4(), uuid4() + facts = _GuideMutationAuthorityFacts( + context, + grant=SimpleNamespace( + id=grant_id, + status="active", + scope_project_id=None if grant_scope == "system" else project_id, + ), + permission_id=PermissionId.PROJECT_EFFECTIVE_POLICY_MANAGE, + ) + authorization, evidence = _runtime_service(context, session=session, admin_repository=facts) + prepared = PreparedAuthorizationService(session, context, authorization, facts) + caller, scope = _submission_policy_human_prepare_inputs(action_id, project_id) + resource = ProjectSubmissionArtifactPolicyMutationResourceContext.model_validate_json( + json.dumps(caller.request_value) + ) + handle = await prepared.prepare(action_id, caller, scope) + decision = await prepared.consume(handle, action_id, caller, resource) + assert decision.allowed is True + assert decision.matched_authority_kind is MatchedAuthorityKind.ADMIN_ROLE_GRANT + assert decision.matched_grant_id == grant_id + assert decision.matched_scope_project_id == (None if grant_scope == "system" else project_id) + assert len(evidence.events) == 1 + + +@pytest.mark.asyncio +async def test_submission_artifact_policy_create_update_missing_pm_grant_denies_bounded() -> None: + """Role claims or contributor state cannot substitute for the local PM grant.""" + context = _runtime_context() + assert isinstance(context, HumanAuthorizationContext) + session = _PreparedTestSession() + facts = _GuideMutationAuthorityFacts( + context, + grant=None, + permission_id=PermissionId.PROJECT_EFFECTIVE_POLICY_MANAGE, + ) + authorization, evidence = _runtime_service(context, session=session, admin_repository=facts) + prepared = PreparedAuthorizationService(session, context, authorization, facts) + project_id = uuid4() + for action_id in ( + ActionId.PROJECT_SUBMISSION_ARTIFACT_POLICY_CREATE, + ActionId.PROJECT_SUBMISSION_ARTIFACT_POLICY_UPDATE, + ): + caller, scope = _submission_policy_human_prepare_inputs(action_id, project_id) + resource = ProjectSubmissionArtifactPolicyMutationResourceContext.model_validate_json( + json.dumps(caller.request_value) + ) + with pytest.raises(PreparedAuthorizationUnsupported) as unsupported: + await prepared.prepare(action_id, caller, scope) + with pytest.raises(AuthorizationDenied) as denied: + await prepared.deny_unsupported(action_id, caller, resource, unsupported.value) + assert denied.value.decision.denial_code is AuthorizationDenialCode.PERMISSION_NOT_GRANTED + assert len(evidence.events) == 2 + + +@pytest.mark.asyncio +async def test_submission_artifact_policy_create_update_wrong_project_grant_denies() -> None: + """A PM grant for another project cannot cross the 12F2 scope boundary.""" + context = _runtime_context() + assert isinstance(context, HumanAuthorizationContext) + session = _PreparedTestSession() + project_id = uuid4() + # The repository's exact-scope query filters the unrelated grant and returns none. + facts = _GuideMutationAuthorityFacts( + context, + grant=None, + permission_id=PermissionId.PROJECT_EFFECTIVE_POLICY_MANAGE, + ) + authorization, _ = _runtime_service(context, session=session, admin_repository=facts) + prepared = PreparedAuthorizationService(session, context, authorization, facts) + caller, scope = _submission_policy_human_prepare_inputs( + ActionId.PROJECT_SUBMISSION_ARTIFACT_POLICY_CREATE, project_id + ) + with pytest.raises(PreparedAuthorizationUnsupported) as unsupported: + await prepared.prepare(ActionId.PROJECT_SUBMISSION_ARTIFACT_POLICY_CREATE, caller, scope) + assert unsupported.value.denial_code is AuthorizationDenialCode.PERMISSION_NOT_GRANTED + + class _ProjectCreateAuthorityFacts: def __init__(self, context: HumanAuthorizationContext, *, grant=None) -> None: self.context = context @@ -5786,10 +5899,7 @@ async def test_prepared_sufficiency_missing_grant_commits_bounded_denial() -> No unsupported.value, ) - assert ( - denied.value.decision.denial_code - is AuthorizationDenialCode.PERMISSION_NOT_GRANTED - ) + assert denied.value.decision.denial_code is AuthorizationDenialCode.PERMISSION_NOT_GRANTED assert len(evidence.events) == 1 assert evidence.events[0].denial_code == AuthorizationDenialCode.PERMISSION_NOT_GRANTED.value @@ -6120,9 +6230,7 @@ async def test_submission_artifact_policy_prepared_binding_requires_exact_final_ monkeypatch: pytest.MonkeyPatch, ) -> None: """The 12F1 binding compares the whole canonical context, not selected fields.""" - project_id, guide_id, snapshot_id, policy_id, operation_id = ( - uuid4() for _ in range(5) - ) + project_id, guide_id, snapshot_id, policy_id, operation_id = (uuid4() for _ in range(5)) resource = ProjectSubmissionArtifactPolicyMutationResourceContext( resource_type="project_submission_artifact_policy_mutation", resource_id=policy_id, @@ -6141,6 +6249,8 @@ async def test_submission_artifact_policy_prepared_binding_requires_exact_final_ policy_status="draft", policy_digest=DIGEST, setup_generation=1, + sufficiency_report_id=uuid4(), + sufficiency_status="passed", effective_output_digest=DIGEST, compiled_pre_submit_output_digest=DIGEST, compilation=SubmissionPolicyCompilationContext( @@ -6174,17 +6284,11 @@ async def test_submission_artifact_policy_prepared_binding_requires_exact_final_ PreparedAuthorizationInput( idempotency_key=uuid4(), request_value=resource.model_dump(mode="json") ), - PreparedAuthorityScope( - kind=PreparedAuthorityScopeKind.PROJECT, project_id=project_id - ), + PreparedAuthorityScope(kind=PreparedAuthorityScopeKind.PROJECT, project_id=project_id), ) assert _submission_policy_binding_matches(binding, resource) changed = resource.model_copy( - update={ - "compilation": resource.compilation.model_copy( - update={"catalogue_version": "v2"} - ) - } + update={"compilation": resource.compilation.model_copy(update={"catalogue_version": "v2"})} ) assert not _submission_policy_binding_matches(binding, changed) decision = AuthorizationDecision( diff --git a/backend/tests/test_projects.py b/backend/tests/test_projects.py index d43a37a44..c4fd1d066 100644 --- a/backend/tests/test_projects.py +++ b/backend/tests/test_projects.py @@ -17,6 +17,7 @@ from uuid import UUID, uuid4 import pytest # type: ignore[import-not-found] +from pydantic import ValidationError from httpx import ASGITransport, AsyncClient from sqlalchemy import event, func, select, text, update from sqlalchemy.dialects import postgresql @@ -85,6 +86,7 @@ RevisionPolicy, ReviewPolicy, SubmissionArtifactPolicy, + SubmissionPolicyMutationIdempotencyRecord, ) from app.modules.projects.guide_mutation_repository import GuideMutationRepository from app.modules.projects.sufficiency_mutation_repository import ( @@ -104,6 +106,7 @@ ProjectRoleGrant, ProjectRoleQualificationSnapshot, ) +from app.modules.authorization.prepared import PreparedAuthorizationService from app.modules.authorization.repository import AdminAuthorizationRepository from app.modules.authorization.catalogue import ActionId from app.modules.projects import repository as project_repository_module @@ -141,7 +144,9 @@ ProjectSetupRunResponse, PostSubmitCheckerPolicyCorrectionRequest, SubmissionArtifactPolicyApprove, + SubmissionArtifactPolicyCreate, SubmissionArtifactPolicyInput, + SubmissionArtifactPolicyUpdate, ) from app.schemas.auth import ActorContext from app.modules.authorization.runtime import ( @@ -5196,6 +5201,21 @@ async def create_submission_artifact_policy( policy_body: dict | None = None, policy_version: str = "v1", ) -> dict: + async with db_session.get_session_factory()() as session: + authoritative = await session.scalar( + select(GuideSufficiencyReport).where( + GuideSufficiencyReport.source_snapshot_id == snapshot_id, + GuideSufficiencyReport.project_setup_run_id.is_not(None), + ) + ) + diagnostic = await session.scalar( + select(GuideSufficiencyReport).where( + GuideSufficiencyReport.source_snapshot_id == snapshot_id, + GuideSufficiencyReport.project_setup_run_id.is_(None), + ) + ) + if authoritative is None and diagnostic is not None: + await create_verified_report_fixture(diagnostic.id, snapshot_id) response = await client.post( f"/api/v1/projects/{project_id}/guides/{guide_id}/submission-artifact-policies", headers=auth_headers(), @@ -9347,6 +9367,129 @@ async def forbidden_lookup(*_: object, **__: object): assert lookups == 0 +@pytest.mark.parametrize("method", ["post", "patch"]) +async def test_submission_artifact_policy_create_update_conceals_service_before_lookup( + monkeypatch: pytest.MonkeyPatch, + method: str, +) -> None: + """Service principals cannot reach the public manual-policy product boundary.""" + app = create_app(Settings(environment="test")) + lookups = 0 + + async def verified_service(): + return SimpleNamespace(token=SimpleNamespace(subject_kind="service")) + + async def forbidden_lookup(*_: object, **__: object): + nonlocal lookups + lookups += 1 + raise AssertionError("service token reached submission-policy lookup") + + app.dependency_overrides[get_auth_verification_result] = verified_service + monkeypatch.setattr(ProjectRepository, "get_project", forbidden_lookup) + project_id, guide_id, policy_id = (uuid4() for _ in range(3)) + path = f"/api/v1/projects/{project_id}/guides/{guide_id}/submission-artifact-policies" + payload = { + "source_snapshot_id": str(uuid4()), + "policy_version": "manual-v1", + "policy_body": project_submission_artifact_policy_body(), + } + if method == "patch": + path += f"/{policy_id}" + payload = { + "expected_policy_hash": sha256_hash("predecessor"), + "successor_policy_version": "manual-v2", + "change_summary": "must not execute", + } + async with AsyncClient( + transport=ASGITransport(app=app), base_url="http://testserver" + ) as client: + response = await client.request( + method.upper(), + path, + headers={ + "Authorization": "Bearer fixed-service-token", + "Idempotency-Key": str(uuid4()), + }, + json=payload, + ) + + assert response.status_code == 404 + assert response.json()["error"]["code"] == "project_authorization_resource_not_found" + assert lookups == 0 + + +def test_submission_artifact_policy_create_rejects_malformed_snapshot_uuid() -> None: + """Malformed snapshot selectors fail schema validation before actor provisioning.""" + with pytest.raises(ValidationError): + SubmissionArtifactPolicyCreate.model_validate( + { + "source_snapshot_id": "not-a-uuid", + "policy_version": "manual-v1", + "policy_body": project_submission_artifact_policy_body(), + } + ) + + +@pytest.mark.parametrize("idempotency_key", [None, "not-a-uuid"]) +async def test_submission_artifact_policy_create_rejects_invalid_idempotency_before_auth( + idempotency_key: str | None, +) -> None: + """The dedicated manual-policy key gate precedes authentication/provisioning.""" + app = create_app(Settings(environment="test")) + headers = {} if idempotency_key is None else {"Idempotency-Key": idempotency_key} + async with AsyncClient( + transport=ASGITransport(app=app), base_url="http://testserver" + ) as client: + response = await client.post( + f"/api/v1/projects/{uuid4()}/guides/{uuid4()}/submission-artifact-policies", + headers=headers, + json={ + "source_snapshot_id": str(uuid4()), + "policy_version": "manual-v1", + "policy_body": project_submission_artifact_policy_body(), + }, + ) + assert response.status_code == 422 + + +@pytest.mark.parametrize("idempotency_key", [None, "not-a-uuid"]) +async def test_submission_artifact_policy_update_rejects_invalid_idempotency_before_auth( + idempotency_key: str | None, +) -> None: + """PATCH uses the same replay-key gate before authentication or product lookup.""" + app = create_app(Settings(environment="test")) + headers = {} if idempotency_key is None else {"Idempotency-Key": idempotency_key} + async with AsyncClient( + transport=ASGITransport(app=app), base_url="http://testserver" + ) as client: + response = await client.patch( + f"/api/v1/projects/{uuid4()}/guides/{uuid4()}/submission-artifact-policies/{uuid4()}", + headers=headers, + json={ + "expected_policy_hash": sha256_hash("predecessor"), + "successor_policy_version": "v2", + }, + ) + assert response.status_code == 422 + + +@pytest.mark.parametrize( + "payload", + [ + {"successor_policy_version": "v2"}, + {"expected_policy_hash": "not-a-digest", "successor_policy_version": "v2"}, + {"expected_policy_hash": "sha256:" + ("0" * 64)}, + {"expected_policy_hash": "sha256:" + ("0" * 64), "successor_policy_version": ""}, + ], +) +def test_submission_artifact_policy_update_rejects_invalid_preconditions( + payload: dict, +) -> None: + """Missing or malformed CAS selectors cannot enter update execution.""" + with pytest.raises(ValidationError): + SubmissionArtifactPolicyUpdate.model_validate(payload) + + async def test_sufficiency_replay_repository_impossible_states_fail_closed() -> None: """Treat disappeared reservations and double completion as integrity failures.""" @@ -9400,7 +9543,9 @@ async def missing(*_: object): ) -async def test_submission_artifact_policy_replay_repository_classifies_exact_states() -> None: +async def test_submission_artifact_policy_replay_repository_idempotency_classifies_cross_action_states() -> ( + None +): """Pending, committed, and mismatched reservations remain distinct and fail closed.""" class Session: @@ -9462,6 +9607,12 @@ async def find_pending(**_: object): changed_namespace = {**facts, "idempotency_key": uuid4()} state, record = await repository.reserve(**changed_namespace) # type: ignore[arg-type] assert (state, record) == ("mismatch", pending) + changed_action = { + **facts, + "action_id": ActionId.PROJECT_SUBMISSION_ARTIFACT_POLICY_UPDATE.value, + } + state, record = await repository.reserve(**changed_action) # type: ignore[arg-type] + assert (state, record) == ("mismatch", pending) claimed_id = uuid4() claimed = SimpleNamespace(id=claimed_id, status="pending", **facts) @@ -9482,8 +9633,8 @@ async def capture(statement: object): return None session.scalar = capture # type: ignore[method-assign] - repository._find_namespace = ( - SubmissionPolicyMutationReplayRepository._find_namespace.__get__(repository) + repository._find_namespace = SubmissionPolicyMutationReplayRepository._find_namespace.__get__( + repository ) # type: ignore[method-assign] await repository._find_namespace( actor_profile_id=facts["actor_profile_id"], @@ -9588,9 +9739,7 @@ async def complete(self, value: object, **completion: object): calls.append(("complete", (value, completion))) service._replay = Replay() # type: ignore[assignment] - project_id, guide_id, snapshot_id, policy_id, operation_id = ( - uuid4() for _ in range(5) - ) + project_id, guide_id, snapshot_id, policy_id, operation_id = (uuid4() for _ in range(5)) request_digest = sha256_hash("request") resource = ProjectSubmissionArtifactPolicyMutationResourceContext( resource_type="project_submission_artifact_policy_mutation", @@ -9608,6 +9757,8 @@ async def complete(self, value: object, **completion: object): policy_version="1", policy_generation=1, setup_generation=1, + sufficiency_report_id=uuid4(), + sufficiency_status="passed", ) facts = SubmissionPolicyReplayFacts( actor_profile_id=str(uuid4()), @@ -11295,7 +11446,11 @@ async def test_manual_submission_artifact_policy_rejects_agent_provenance_fields f"/api/v1/projects/{project['id']}/guides/{guide['id']}/submission-artifact-policies/" f"{policy['id']}", headers=auth_headers(), - json={"derivation_agent_name": "SubmissionArtifactPolicyDerivationAgent"}, + json={ + "expected_policy_hash": policy["policy_hash"], + "successor_policy_version": "v2", + "derivation_agent_name": "SubmissionArtifactPolicyDerivationAgent", + }, ) assert update_response.status_code == 422 @@ -11355,7 +11510,7 @@ async def test_derivation_agent_validates_existing_policy_integrity_before_reuse assert "policy body hash mismatch" in blocked.json()["detail"] -async def test_agent_derived_submission_artifact_policy_body_is_immutable( +async def test_manual_submission_artifact_policy_update_rejects_agent_derived_row( project_client: AsyncClient, monkeypatch: pytest.MonkeyPatch, deterministic_project_agent_runtime: None, @@ -11385,9 +11540,11 @@ async def test_agent_derived_submission_artifact_policy_body_is_immutable( f"{derived.json()['id']}", headers=auth_headers(), json={ + "expected_policy_hash": derived.json()["policy_hash"], + "successor_policy_version": "manual-v2", "policy_body": project_submission_artifact_policy_body( artifact_path="adjusted/output.json" - ) + ), }, ) @@ -11398,7 +11555,11 @@ async def test_agent_derived_submission_artifact_policy_body_is_immutable( f"/api/v1/projects/{project['id']}/guides/{guide['id']}/submission-artifact-policies/" f"{derived.json()['id']}", headers=auth_headers(), - json={"change_summary": "Admin-edited generated summary."}, + json={ + "expected_policy_hash": derived.json()["policy_hash"], + "successor_policy_version": "manual-v3", + "change_summary": "Admin-edited generated summary.", + }, ) assert summary_response.status_code == 409 @@ -11715,7 +11876,11 @@ async def test_approved_submission_artifact_policy_cannot_be_updated( f"/api/v1/projects/{project['id']}/guides/{guide['id']}/" f"submission-artifact-policies/{policy['id']}", headers=auth_headers(), - json={"change_summary": "Attempt to mutate approved policy."}, + json={ + "expected_policy_hash": policy["policy_hash"], + "successor_policy_version": "v2", + "change_summary": "Attempt to mutate approved policy.", + }, ) assert response.status_code == 409 @@ -11743,6 +11908,196 @@ async def test_submission_artifact_policy_creation_requires_sufficiency_report( assert "sufficiency report is required" in response.json()["detail"] +async def test_submission_artifact_policy_create_rejects_diagnostic_only_sufficiency( + project_client: AsyncClient, +) -> None: + """A human diagnostic report cannot substitute for setup-owned sufficiency.""" + project = await create_project(project_client) + guide = await create_guide(project_client, project["id"], complete_guide_payload()) + snapshot = await create_source_snapshot(project_client, project["id"], guide["id"]) + await create_sufficiency_report(project_client, project["id"], guide["id"], snapshot["id"]) + response = await project_client.post( + f"/api/v1/projects/{project['id']}/guides/{guide['id']}/submission-artifact-policies", + headers=auth_headers(), + json={ + "source_snapshot_id": snapshot["id"], + "policy_version": "manual-v1", + "policy_body": project_submission_artifact_policy_body(), + }, + ) + assert response.status_code == 422 + assert "authoritative guide sufficiency report is required" in response.json()["detail"] + + +async def test_submission_artifact_policy_create_rejects_unacknowledged_warning_lineage( + project_client: AsyncClient, +) -> None: + """An authoritative warning result without exact 12E acknowledgement cannot create policy.""" + project = await create_project(project_client) + guide = await create_guide(project_client, project["id"], complete_guide_payload()) + snapshot = await create_source_snapshot(project_client, project["id"], guide["id"]) + diagnostic = await create_sufficiency_report( + project_client, + project["id"], + guide["id"], + snapshot["id"], + status="passed_with_warnings", + ) + await create_verified_report_fixture(diagnostic["id"], snapshot["id"]) + response = await project_client.post( + f"/api/v1/projects/{project['id']}/guides/{guide['id']}/submission-artifact-policies", + headers=auth_headers(), + json={ + "source_snapshot_id": snapshot["id"], + "policy_version": "manual-v1", + "policy_body": project_submission_artifact_policy_body(), + }, + ) + assert response.status_code == 422 + assert "authorized Project Manager acknowledgement" in response.json()["detail"] + + +async def test_submission_artifact_policy_create_exact_idempotency_replay_is_stable( + project_client: AsyncClient, +) -> None: + """Exact create replay returns the committed response and creates one row.""" + project = await create_project(project_client) + guide = await create_guide(project_client, project["id"], complete_guide_payload()) + snapshot = await create_source_snapshot(project_client, project["id"], guide["id"]) + diagnostic = await create_sufficiency_report( + project_client, project["id"], guide["id"], snapshot["id"] + ) + await create_verified_report_fixture(diagnostic["id"], snapshot["id"]) + headers = auth_headers() + payload = { + "source_snapshot_id": snapshot["id"], + "policy_version": "manual-v1", + "policy_body": project_submission_artifact_policy_body(), + "change_summary": "stable replay", + } + path = f"/api/v1/projects/{project['id']}/guides/{guide['id']}/submission-artifact-policies" + first = await project_client.post(path, headers=headers, json=payload) + async with db_session.get_session_factory()() as session: + guide_row = await session.get(ProjectGuide, guide["id"]) + assert guide_row is not None + guide_row.status = "active" + await session.commit() + replay = await project_client.post(path, headers=headers, json=payload) + assert first.status_code == replay.status_code == 201 + assert replay.json() == first.json() + async with db_session.get_session_factory()() as session: + count = await session.scalar( + select(func.count(SubmissionArtifactPolicy.id)).where( + SubmissionArtifactPolicy.project_id == project["id"], + SubmissionArtifactPolicy.guide_id == guide["id"], + ) + ) + assert count == 1 + + +@pytest.mark.parametrize( + "fault_point", + ["replay_reserved", "policy_staged", "evidence_staged", "replay_completed"], +) +async def test_submission_artifact_policy_create_fault_rolls_back_atomic_boundary( + project_client: AsyncClient, + monkeypatch: pytest.MonkeyPatch, + fault_point: str, +) -> None: + """Every named post-authorization fault leaves no policy, replay, or allow evidence.""" + project = await create_project(project_client) + guide = await create_guide(project_client, project["id"], complete_guide_payload()) + snapshot = await create_source_snapshot(project_client, project["id"], guide["id"]) + diagnostic = await create_sufficiency_report( + project_client, project["id"], guide["id"], snapshot["id"] + ) + await create_verified_report_fixture(diagnostic["id"], snapshot["id"]) + + if fault_point == "replay_reserved": + original = SubmissionPolicyMutationService.reserve_replay + + async def fail_after_reservation(self, facts): + await original(self, facts) + raise RuntimeError("fault after replay reservation") + + monkeypatch.setattr( + SubmissionPolicyMutationService, "reserve_replay", fail_after_reservation + ) + elif fault_point == "policy_staged": + original_add = ProjectRepository.add_submission_artifact_policy + + async def fail_after_policy_staging(self, policy): + await original_add(self, policy) + raise RuntimeError("fault after policy staging") + + monkeypatch.setattr( + ProjectRepository, "add_submission_artifact_policy", fail_after_policy_staging + ) + elif fault_point == "evidence_staged": + original_consume = PreparedAuthorizationService.consume + consume_count = 0 + + async def fail_after_final_evidence(self, *args, **kwargs): + nonlocal consume_count + decision = await original_consume(self, *args, **kwargs) + consume_count += 1 + if consume_count == 1: + raise RuntimeError("fault after authorization evidence staging") + return decision + + monkeypatch.setattr(PreparedAuthorizationService, "consume", fail_after_final_evidence) + else: + original_complete = SubmissionPolicyMutationService.complete_replay + + async def fail_after_replay_completion(self, *args, **kwargs): + await original_complete(self, *args, **kwargs) + raise RuntimeError("fault after replay completion") + + monkeypatch.setattr( + SubmissionPolicyMutationService, "complete_replay", fail_after_replay_completion + ) + + with pytest.raises(RuntimeError, match="fault after"): + await project_client.post( + f"/api/v1/projects/{project['id']}/guides/{guide['id']}/submission-artifact-policies", + headers=auth_headers(), + json={ + "source_snapshot_id": snapshot["id"], + "policy_version": "manual-v1", + "policy_body": project_submission_artifact_policy_body(), + }, + ) + + async with db_session.get_session_factory()() as session: + policy_count = await session.scalar( + select(func.count()) + .select_from(SubmissionArtifactPolicy) + .where( + SubmissionArtifactPolicy.project_id == project["id"], + SubmissionArtifactPolicy.guide_id == guide["id"], + ) + ) + replay_count = await session.scalar( + select(func.count()) + .select_from(SubmissionPolicyMutationIdempotencyRecord) + .where( + SubmissionPolicyMutationIdempotencyRecord.project_id == project["id"], + SubmissionPolicyMutationIdempotencyRecord.guide_id == guide["id"], + ) + ) + allowed_count = await session.scalar( + select(func.count()) + .select_from(AuditEvent) + .where( + AuditEvent.action_id == ActionId.PROJECT_SUBMISSION_ARTIFACT_POLICY_CREATE.value, + AuditEvent.event_type == "SensitiveAuthorizationAllowed", + AuditEvent.target_ref_id == project["id"], + ) + ) + + assert policy_count == replay_count == allowed_count == 0 + + async def test_database_enforces_effective_policy_submission_policy_hash( project_client: AsyncClient, ) -> None: @@ -11859,7 +12214,11 @@ async def test_approved_submission_artifact_policy_is_immutable( f"/api/v1/projects/{project['id']}/guides/{guide['id']}/submission-artifact-policies/" f"{policy['id']}", headers=auth_headers(), - json={"change_summary": "Try to mutate approved policy."}, + json={ + "expected_policy_hash": policy["policy_hash"], + "successor_policy_version": "v2", + "change_summary": "Try to mutate approved policy.", + }, ) assert response.status_code == 409 @@ -11880,25 +12239,199 @@ async def test_draft_submission_artifact_policy_can_be_updated( snapshot["id"], ) + update_headers = auth_headers() + update_payload = { + "expected_policy_hash": policy["policy_hash"], + "successor_policy_version": "v2", + "policy_body": project_submission_artifact_policy_body( + artifact_path="outputs/final-answer.md" + ), + "change_summary": "Use final answer artifact path.", + } response = await project_client.patch( f"/api/v1/projects/{project['id']}/guides/{guide['id']}/submission-artifact-policies/" f"{policy['id']}", - headers=auth_headers(), - json={ - "policy_body": project_submission_artifact_policy_body( - artifact_path="outputs/final-answer.md" - ), - "change_summary": "Use final answer artifact path.", - }, + headers=update_headers, + json=update_payload, ) assert response.status_code == 200, response.text updated = response.json() - assert updated["id"] == policy["id"] + assert updated["id"] != policy["id"] + assert updated["supersedes_policy_id"] == policy["id"] assert updated["lifecycle_status"] == "draft" assert updated["policy_hash"] != policy["policy_hash"] assert updated["policy_body"]["required_artifacts"][0]["path"] == ("outputs/final-answer.md") assert updated["change_summary"] == "Use final answer artifact path." + async with db_session.get_session_factory()() as session: + guide_row = await session.get(ProjectGuide, guide["id"]) + assert guide_row is not None + guide_row.status = "active" + await session.commit() + replay = await project_client.patch( + f"/api/v1/projects/{project['id']}/guides/{guide['id']}/submission-artifact-policies/" + f"{policy['id']}", + headers=update_headers, + json=update_payload, + ) + assert replay.status_code == 200, replay.text + assert replay.json() == updated + async with db_session.get_session_factory()() as session: + predecessor = await session.get(SubmissionArtifactPolicy, policy["id"]) + assert predecessor is not None + assert predecessor.lifecycle_status == "superseded" + assert predecessor.policy_hash == policy["policy_hash"] + + +async def test_submission_artifact_policy_update_rejects_stale_cas_without_successor( + project_client: AsyncClient, +) -> None: + """A stale predecessor digest creates no replacement or supersession.""" + project = await create_project(project_client) + guide = await create_guide(project_client, project["id"], complete_guide_payload()) + snapshot = await create_source_snapshot(project_client, project["id"], guide["id"]) + await create_sufficiency_report(project_client, project["id"], guide["id"], snapshot["id"]) + policy = await create_submission_artifact_policy( + project_client, project["id"], guide["id"], snapshot["id"] + ) + response = await project_client.patch( + f"/api/v1/projects/{project['id']}/guides/{guide['id']}/submission-artifact-policies/" + f"{policy['id']}", + headers=auth_headers(), + json={ + "expected_policy_hash": sha256_hash("stale"), + "successor_policy_version": "v2", + "change_summary": "must not commit", + }, + ) + assert response.status_code == 409 + assert response.json()["error"]["code"] == "submission_policy_precondition_failed" + async with db_session.get_session_factory()() as session: + rows = list( + ( + await session.scalars( + select(SubmissionArtifactPolicy).where( + SubmissionArtifactPolicy.project_id == project["id"], + SubmissionArtifactPolicy.guide_id == guide["id"], + ) + ) + ).all() + ) + assert len(rows) == 1 + assert rows[0].lifecycle_status == "draft" + + +async def test_submission_artifact_policy_update_fault_rolls_back_replacement( + project_client: AsyncClient, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """A post-supersession fault restores the draft and all update boundary state.""" + project = await create_project(project_client) + guide = await create_guide(project_client, project["id"], complete_guide_payload()) + snapshot = await create_source_snapshot(project_client, project["id"], guide["id"]) + await create_sufficiency_report(project_client, project["id"], guide["id"], snapshot["id"]) + policy = await create_submission_artifact_policy( + project_client, project["id"], guide["id"], snapshot["id"] + ) + original_complete = SubmissionPolicyMutationService.complete_replay + + async def fail_after_update_completion(self, *args, **kwargs): + await original_complete(self, *args, **kwargs) + raise RuntimeError("fault after update replay completion") + + monkeypatch.setattr( + SubmissionPolicyMutationService, "complete_replay", fail_after_update_completion + ) + with pytest.raises(RuntimeError, match="fault after update replay completion"): + await project_client.patch( + f"/api/v1/projects/{project['id']}/guides/{guide['id']}/" + f"submission-artifact-policies/{policy['id']}", + headers=auth_headers(), + json={ + "expected_policy_hash": policy["policy_hash"], + "successor_policy_version": "v2", + "change_summary": "must roll back", + }, + ) + + async with db_session.get_session_factory()() as session: + rows = list( + ( + await session.scalars( + select(SubmissionArtifactPolicy).where( + SubmissionArtifactPolicy.project_id == project["id"], + SubmissionArtifactPolicy.guide_id == guide["id"], + ) + ) + ).all() + ) + replay_count = await session.scalar( + select(func.count()) + .select_from(SubmissionPolicyMutationIdempotencyRecord) + .where( + SubmissionPolicyMutationIdempotencyRecord.action_id + == ActionId.PROJECT_SUBMISSION_ARTIFACT_POLICY_UPDATE.value, + SubmissionPolicyMutationIdempotencyRecord.project_id == project["id"], + ) + ) + allowed_count = await session.scalar( + select(func.count()) + .select_from(AuditEvent) + .where( + AuditEvent.action_id == ActionId.PROJECT_SUBMISSION_ARTIFACT_POLICY_UPDATE.value, + AuditEvent.event_type == "SensitiveAuthorizationAllowed", + AuditEvent.target_ref_id == project["id"], + ) + ) + assert len(rows) == 1 + assert rows[0].id == policy["id"] + assert rows[0].lifecycle_status == "draft" + assert replay_count == allowed_count == 0 + + +async def test_submission_artifact_policy_update_concurrent_cas_creates_one_successor( + project_client: AsyncClient, +) -> None: + """Two replacement attempts against one draft converge on one append-only winner.""" + project = await create_project(project_client) + guide = await create_guide(project_client, project["id"], complete_guide_payload()) + snapshot = await create_source_snapshot(project_client, project["id"], guide["id"]) + await create_sufficiency_report(project_client, project["id"], guide["id"], snapshot["id"]) + policy = await create_submission_artifact_policy( + project_client, project["id"], guide["id"], snapshot["id"] + ) + path = ( + f"/api/v1/projects/{project['id']}/guides/{guide['id']}/" + f"submission-artifact-policies/{policy['id']}" + ) + + async def replace(version: str): + return await project_client.patch( + path, + headers=auth_headers(), + json={ + "expected_policy_hash": policy["policy_hash"], + "successor_policy_version": version, + "change_summary": version, + }, + ) + + first, second = await asyncio.gather(replace("concurrent-a"), replace("concurrent-b")) + assert sorted((first.status_code, second.status_code)) == [200, 409] + async with db_session.get_session_factory()() as session: + rows = list( + ( + await session.scalars( + select(SubmissionArtifactPolicy).where( + SubmissionArtifactPolicy.project_id == project["id"], + SubmissionArtifactPolicy.guide_id == guide["id"], + ) + ) + ).all() + ) + assert len(rows) == 2 + assert sum(row.lifecycle_status == "draft" for row in rows) == 1 + assert sum(row.lifecycle_status == "superseded" for row in rows) == 1 async def test_approving_replacement_policy_supersedes_prior_rows( @@ -12370,7 +12903,7 @@ async def test_draft_policy_cannot_be_approved_after_guide_activation( assert "draft guides" in response.json()["detail"] -async def test_submission_artifact_policy_rejects_default_weakening( +async def test_manual_submission_artifact_policy_create_rejects_default_weakening( project_client: AsyncClient, ) -> None: project = await create_project(project_client) diff --git a/docs/operations_authorization_service.md b/docs/operations_authorization_service.md index 6f25abb3d..571b00f21 100644 --- a/docs/operations_authorization_service.md +++ b/docs/operations_authorization_service.md @@ -1356,7 +1356,7 @@ complete creation or acknowledgement provenance and use the append-only refused after any 12E replay or attributed sufficiency provenance exists; do not delete replay, product, or authority evidence to force rollback. -Migration `0057_submission_policy_authority` installs inactive submission-policy +Migration `0057_submission_policy_authority` installs submission-policy PREP, replay, and nullable provenance custody only. Existing submission, effective, and pre-submit policy rows remain readable with all authority fields null; do not backfill invented authority. The replay reservation is mutable only @@ -1364,10 +1364,11 @@ for its single `pending -> committed` completion, remains in the caller's root transaction, and must not be committed or rolled back by the AUTH helper. A pending reservation is still authority evidence and intentionally blocks downgrade. Any audit event using the submission-policy mutation resource type, -including denied evidence, blocks downgrade independently. Until 12F2-12F4 cut -over their respective paths, the four submission- -policy mutation actions remain planned and operators must not treat the schema -as activation. +including denied evidence, blocks downgrade independently. 12F2 activates only +manual human create/update. Each update appends a separately authorized +successor and supersedes its exact hash-selected predecessor atomically; derive +and approve remain planned. Operators must not treat the shared schema as wider +activation. ## Draft review and revision policy authorization diff --git a/docs/operations_project_operating_manual.md b/docs/operations_project_operating_manual.md index 063ec8828..c54b4083e 100644 --- a/docs/operations_project_operating_manual.md +++ b/docs/operations_project_operating_manual.md @@ -38,8 +38,10 @@ Before releasing tasks: - task source, description, acceptance, and rejection fields listed - guide sufficiency report passed or warnings acknowledged by an authorized covered Project Manager -- submission artifact policy derived by Workstream and approved by an - authorized covered Project Manager +- submission artifact policy normally derived by Workstream and approved by an + authorized covered Project Manager; an explicitly manual Project Manager + draft may use the governed create/update exception, where updates append a + successor and never edit agent output or an existing draft body in place - effective project submission artifact policy hash persisted - generated project pre-submit checker policy is created from the effective project submission artifact policy - project pre-submit checker bundle hash persisted diff --git a/docs/spec_authorization_service.md b/docs/spec_authorization_service.md index 6c545dd7a..b91d4499c 100644 --- a/docs/spec_authorization_service.md +++ b/docs/spec_authorization_service.md @@ -1079,9 +1079,9 @@ with exact setup custody and no matched human grant. | `project.guide_sufficiency_report.create` (active) | `project.guide.manage` | `WS-AUTH-001-12E` | | `project.guide_sufficiency.run` (active) | `project.guide.manage` | `WS-AUTH-001-12E` | | `project.guide_sufficiency.warnings.acknowledge` (active) | `project.guide.manage` | `WS-AUTH-001-12E` | -| `project.submission_artifact_policy.create` | `project.effective_policy.manage` | `WS-AUTH-001-12F2` | +| `project.submission_artifact_policy.create` (active) | `project.effective_policy.manage` | `WS-AUTH-001-12F2` | | `project.submission_artifact_policy.derive` | `project.effective_policy.manage` | `WS-AUTH-001-12F3` | -| `project.submission_artifact_policy.update` | `project.effective_policy.manage` | `WS-AUTH-001-12F2` | +| `project.submission_artifact_policy.update` (active) | `project.effective_policy.manage` | `WS-AUTH-001-12F2` | | `project.submission_artifact_policy.approve` | `project.effective_policy.manage` | `WS-AUTH-001-12F4` | | `project.post_submit_checker_policy.approve` | `project.effective_policy.manage` | `WS-AUTH-001-12G` | | `project.post_submit_checker_policy.correction.request` | `project.effective_policy.manage` | `WS-AUTH-001-12G` | @@ -1112,9 +1112,14 @@ compiled/effective output hashes. Its replay reservation distinguishes human idempotency from fixed setup-service task custody and permits only `pending -> committed`; it does not perform a product mutation or own commit. Migration `0057_submission_policy_authority` preserves existing product rows in -the all-null unattributed shape until the 12F2-12F4 route cutovers. Any replay -row—including pending—or attributed provenance blocks downgrade. None of this -makes the four catalogue actions executable. Any submission-policy +the all-null unattributed shape until their owning route cutovers. 12F2 now +activates only manual human create/update. Manual update appends a separately +authorized successor, binds predecessor hash and successor identity through +PREP/replay, and supersedes the predecessor in the same root transaction. +Diagnostic sufficiency, legacy role strings, services, contributors, and +agent-derived rows cannot authorize this exception. Derive and approve remain +planned. Any replay +row—including pending—or attributed provenance blocks downgrade. Any submission-policy authorization audit event, including denied evidence, also blocks downgrade so the admitted evidence vocabulary is never removed while referenced. From a0ce499c7d5a3437fe584de8398f44f1f058f10c Mon Sep 17 00:00:00 2001 From: Abiorh001 Date: Wed, 5 Aug 2026 23:50:59 +0100 Subject: [PATCH 02/21] ci(auth): claim manual policy mutation behavior --- .../WS-AUTH-001-12F2-pr-trust-bundle.md | 1 + .ci/behavior-claims/WS-AUTH-001-12F2.json | 34 +++++++++++++++++++ 2 files changed, 35 insertions(+) create mode 100644 .ci/behavior-claims/WS-AUTH-001-12F2.json diff --git a/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/reviews/WS-AUTH-001-12F2-pr-trust-bundle.md b/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/reviews/WS-AUTH-001-12F2-pr-trust-bundle.md index 5640d73a6..8e8d69b38 100644 --- a/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/reviews/WS-AUTH-001-12F2-pr-trust-bundle.md +++ b/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/reviews/WS-AUTH-001-12F2-pr-trust-bundle.md @@ -22,6 +22,7 @@ append-only replacement, PREP evidence, and replay custody. without depending on later guide/setup lineage. - Removed obsolete self-committing manual create/update service entry points. - Expanded focused, fault-injection, OpenAPI, and real API contract coverage. +- Added the required bounded behavior-mutation claim for the activating writer. ## Scope and behavior diff --git a/.ci/behavior-claims/WS-AUTH-001-12F2.json b/.ci/behavior-claims/WS-AUTH-001-12F2.json new file mode 100644 index 000000000..aee386a1d --- /dev/null +++ b/.ci/behavior-claims/WS-AUTH-001-12F2.json @@ -0,0 +1,34 @@ +{ + "schema_version": 1, + "chunk_id": "WS-AUTH-001-12F2", + "claims": [ + { + "target": "backend/app/modules/projects/submission_policy_mutation_service.py", + "callables": [ + "app.modules.projects.submission_policy_mutation_service.SubmissionPolicyMutationService._require_pm_admission", + "app.modules.projects.submission_policy_mutation_service.SubmissionPolicyMutationService._manual_mutation", + "app.modules.projects.submission_policy_mutation_service.SubmissionPolicyMutationService._existing_manual_replay", + "app.modules.projects.submission_policy_mutation_service.SubmissionPolicyMutationService.create_manual", + "app.modules.projects.submission_policy_mutation_service.SubmissionPolicyMutationService.update_manual" + ], + "tests": [ + "backend/tests/test_projects.py::test_submission_artifact_policy_create_exact_idempotency_replay_is_stable", + "backend/tests/test_projects.py::test_submission_artifact_policy_create_fault_rolls_back_atomic_boundary", + "backend/tests/test_projects.py::test_draft_submission_artifact_policy_can_be_updated", + "backend/tests/test_projects.py::test_submission_artifact_policy_update_fault_rolls_back_replacement", + "backend/tests/test_projects.py::test_submission_artifact_policy_update_concurrent_cas_creates_one_successor" + ], + "outcomes": [ + "persisted_state", + "denial", + "idempotent_replay" + ], + "boundaries": [ + "postgresql", + "http", + "lock", + "concurrency" + ] + } + ] +} From 8c778821cd8d06f1758628bee025b47696585e14 Mon Sep 17 00:00:00 2001 From: Abiorh001 Date: Wed, 5 Aug 2026 23:55:20 +0100 Subject: [PATCH 03/21] fix(ci): bound mutation ownership to changed targets --- .ci/behavior-claims/WS-AUTH-001-12F2.json | 98 ++++++++++++++++++++--- backend/app/api/deps/authorization.py | 72 +---------------- backend/app/modules/projects/router.py | 78 +++++++++++++++++- 3 files changed, 164 insertions(+), 84 deletions(-) diff --git a/.ci/behavior-claims/WS-AUTH-001-12F2.json b/.ci/behavior-claims/WS-AUTH-001-12F2.json index aee386a1d..03b0fe7aa 100644 --- a/.ci/behavior-claims/WS-AUTH-001-12F2.json +++ b/.ci/behavior-claims/WS-AUTH-001-12F2.json @@ -2,9 +2,83 @@ "schema_version": 1, "chunk_id": "WS-AUTH-001-12F2", "claims": [ + { + "target": "backend/app/modules/authorization/catalogue.py", + "callables": ["app.modules.authorization.catalogue._index_actions"], + "tests": ["backend/tests/test_authorization.py::test_submission_artifact_policy_create_update_activation_is_12f2_only"], + "outcomes": ["return", "denial"], + "boundaries": [] + }, + { + "target": "backend/app/modules/authorization/prepared.py", + "callables": [ + "app.modules.authorization.prepared._submission_policy_binding_matches", + "app.modules.authorization.prepared.PreparedAuthorizationService._binding" + ], + "tests": ["backend/tests/test_authorization.py::test_submission_artifact_policy_create_update_accepts_only_covered_pm_grant"], + "outcomes": ["return", "denial"], + "boundaries": ["postgresql"] + }, + { + "target": "backend/app/modules/authorization/runtime.py", + "callables": [ + "app.modules.authorization.runtime.SubmissionPolicyCompilationContext.validate_catalogue_projection", + "app.modules.authorization.runtime.ProjectSubmissionArtifactPolicyMutationResourceContext.require_submission_policy_identity" + ], + "tests": ["backend/tests/test_authorization.py::test_submission_artifact_policy_create_update_accepts_only_covered_pm_grant"], + "outcomes": ["return", "denial"], + "boundaries": [] + }, + { + "target": "backend/app/modules/projects/router.py", + "callables": [ + "app.modules.projects.router.submission_policy_conflict_error", + "app.modules.projects.router.require_submission_policy_mutation_key", + "app.modules.projects.router.require_submission_policy_human", + "app.modules.projects.router.submission_policy_authorization_actor", + "app.modules.projects.router.get_submission_policy_prepared_authorization_service", + "app.modules.projects.router.submission_policy_authorization", + "app.modules.projects.router.create_submission_artifact_policy", + "app.modules.projects.router.update_submission_artifact_policy" + ], + "tests": [ + "backend/tests/test_projects.py::test_submission_artifact_policy_create_update_conceals_service_before_lookup", + "backend/tests/test_projects.py::test_submission_artifact_policy_create_rejects_invalid_idempotency_before_auth", + "backend/tests/test_projects.py::test_submission_artifact_policy_update_rejects_invalid_idempotency_before_auth", + "backend/tests/test_projects.py::test_submission_artifact_policy_create_exact_idempotency_replay_is_stable", + "backend/tests/test_projects.py::test_draft_submission_artifact_policy_can_be_updated" + ], + "outcomes": ["return", "persisted_state", "denial", "mapped_error", "idempotent_replay"], + "boundaries": ["postgresql", "http"] + }, + { + "target": "backend/app/modules/projects/schemas.py", + "callables": ["app.modules.projects.schemas.SubmissionArtifactPolicyUpdate.reject_reserved_successor_policy_version"], + "tests": [ + "backend/tests/test_projects.py::test_submission_artifact_policy_create_rejects_malformed_snapshot_uuid", + "backend/tests/test_projects.py::test_submission_artifact_policy_update_rejects_invalid_preconditions" + ], + "outcomes": ["return", "denial"], + "boundaries": ["http"] + }, + { + "target": "backend/app/modules/projects/service.py", + "callables": [ + "app.modules.projects.service.ProjectService.canonical_manual_submission_policy_body", + "app.modules.projects.service.ProjectService.verified_source_material_refs" + ], + "tests": [ + "backend/tests/test_projects.py::test_manual_submission_artifact_policy_create_rejects_default_weakening", + "backend/tests/test_projects.py::test_submission_artifact_policy_create_rejects_diagnostic_only_sufficiency" + ], + "outcomes": ["return", "denial"], + "boundaries": ["postgresql"] + }, { "target": "backend/app/modules/projects/submission_policy_mutation_service.py", "callables": [ + "app.modules.projects.submission_policy_mutation_service.SubmissionPolicyMutationService._acknowledgement_digest", + "app.modules.projects.submission_policy_mutation_service.SubmissionPolicyMutationService._lineage", "app.modules.projects.submission_policy_mutation_service.SubmissionPolicyMutationService._require_pm_admission", "app.modules.projects.submission_policy_mutation_service.SubmissionPolicyMutationService._manual_mutation", "app.modules.projects.submission_policy_mutation_service.SubmissionPolicyMutationService._existing_manual_replay", @@ -12,23 +86,25 @@ "app.modules.projects.submission_policy_mutation_service.SubmissionPolicyMutationService.update_manual" ], "tests": [ + "backend/tests/test_projects.py::test_submission_artifact_policy_create_rejects_unacknowledged_warning_lineage", "backend/tests/test_projects.py::test_submission_artifact_policy_create_exact_idempotency_replay_is_stable", "backend/tests/test_projects.py::test_submission_artifact_policy_create_fault_rolls_back_atomic_boundary", "backend/tests/test_projects.py::test_draft_submission_artifact_policy_can_be_updated", "backend/tests/test_projects.py::test_submission_artifact_policy_update_fault_rolls_back_replacement", "backend/tests/test_projects.py::test_submission_artifact_policy_update_concurrent_cas_creates_one_successor" ], - "outcomes": [ - "persisted_state", - "denial", - "idempotent_replay" - ], - "boundaries": [ - "postgresql", - "http", - "lock", - "concurrency" - ] + "outcomes": ["persisted_state", "denial", "idempotent_replay"], + "boundaries": ["postgresql", "http", "lock", "concurrency"] + }, + { + "target": "backend/scripts/api_contract_e2e.py", + "callables": [ + "scripts.api_contract_e2e.create_policy_bundle_for_guide", + "scripts.api_contract_e2e.exercise_api_contract" + ], + "tests": ["backend/tests/test_api_contract_e2e.py::test_real_api_drill_provisions_exact_guide_artifact_pipeline_services"], + "outcomes": ["return", "persisted_state", "denial", "idempotent_replay"], + "boundaries": ["postgresql", "minio", "http"] } ] } diff --git a/backend/app/api/deps/authorization.py b/backend/app/api/deps/authorization.py index abafa863a..cf78d2c0e 100644 --- a/backend/app/api/deps/authorization.py +++ b/backend/app/api/deps/authorization.py @@ -8,7 +8,7 @@ from typing import Annotated from uuid import UUID -from fastapi import Depends, Header, Request, status +from fastapi import Depends, Request, status from sqlalchemy.exc import SQLAlchemyError from sqlalchemy.ext.asyncio import AsyncSession @@ -300,76 +300,6 @@ async def get_prepared_authorization_service( yield service -def require_submission_policy_mutation_key( - idempotency_key: Annotated[str | None, Header(alias="Idempotency-Key")] = None, -) -> UUID: - """Validate manual policy replay custody before actor provisioning.""" - if idempotency_key is None: - raise StructuredHTTPException( - status_code=422, - detail="Idempotency-Key must be a UUID", - error_code="validation_error", - error_message="Idempotency-Key must be a UUID", - ) - try: - return UUID(idempotency_key) - except ValueError as exc: - raise StructuredHTTPException( - status_code=422, - detail="Idempotency-Key must be a UUID", - error_code="validation_error", - error_message="Idempotency-Key must be a UUID", - ) from exc - - -async def require_submission_policy_human( - key: Annotated[UUID, Depends(require_submission_policy_mutation_key)], - result: Annotated[AuthVerificationResult, Depends(get_auth_verification_result)], -) -> AuthVerificationResult: - """Conceal the public manual-policy surface from service principals.""" - del key - if result.token.subject_kind != "human": - raise StructuredHTTPException( - status_code=404, - detail="Project authorization resource not found", - error_code="project_authorization_resource_not_found", - error_message="Project authorization resource not found", - ) - return result - - -async def submission_policy_authorization_actor( - request: Request, - result: Annotated[AuthVerificationResult, Depends(require_submission_policy_human)], - session: Annotated[AsyncSession, Depends(get_db_session)], - rate_control: Annotated[RateControlService, Depends(get_rate_control_service)], -) -> ResolvedActor: - """Resolve a human only after the submission-policy key gate succeeds.""" - return await resolve_authorization_actor(request, result, session, rate_control) - - -async def get_submission_policy_prepared_authorization_service( - request: Request, - resolved: Annotated[ResolvedActor, Depends(submission_policy_authorization_actor)], - session: Annotated[AsyncSession, Depends(get_db_session)], -): - """Compose submission-policy PREP from its dedicated admitted actor.""" - async with prepared_authorization_service(request, resolved, session) as service: - yield service - - -async def submission_policy_authorization( - key: Annotated[UUID, Depends(require_submission_policy_mutation_key)], - resolved: Annotated[ResolvedActor, Depends(submission_policy_authorization_actor)], - prepared: Annotated[ - PreparedAuthorizationService, - Depends(get_submission_policy_prepared_authorization_service), - ], -): - """Return the exact actor, key, and PREP service for manual policy mutation.""" - return key, resolved, prepared - - @asynccontextmanager async def prepared_authorization_service( request: Request, diff --git a/backend/app/modules/projects/router.py b/backend/app/modules/projects/router.py index 36eb4fe49..ab6f95308 100644 --- a/backend/app/modules/projects/router.py +++ b/backend/app/modules/projects/router.py @@ -10,12 +10,14 @@ from sqlalchemy.ext.asyncio import AsyncSession from app.adapters.artifacts import get_guide_artifact_ingest_command -from app.api.deps.auth import get_registered_actor +from app.api.deps.auth import get_auth_verification_result, get_registered_actor from app.api.deps.authorization import ( enforce_human_authorization_read, get_authorization_service, - submission_policy_authorization, + prepared_authorization_service, + resolve_authorization_actor, ) +from app.api.deps.rate_controls import get_rate_control_service from app.core.permissions import PermissionDenied from app.core.api_controls import StructuredHTTPException from app.db.session import get_db_session @@ -62,6 +64,7 @@ SubmissionPolicyMutationService, ) from app.modules.actors.service import ResolvedActor +from app.modules.api_controls.service import RateControlService from app.modules.authorization.prepared import PreparedAuthorizationService from app.modules.projects.authorization_reads import ( authorize_project_active_guide_read, @@ -78,6 +81,7 @@ authorization_resource_selector_id, ) from app.schemas.auth import ActorContext +from app.interfaces.auth import AuthVerificationResult LOGGER = logging.getLogger(__name__) router = APIRouter(prefix="/projects", tags=["projects"]) @@ -125,6 +129,76 @@ def submission_policy_conflict_error(code: str) -> StructuredHTTPException: ) +def require_submission_policy_mutation_key( + idempotency_key: Annotated[str | None, Header(alias="Idempotency-Key")] = None, +) -> UUID: + """Validate manual policy replay custody before actor provisioning.""" + if idempotency_key is None: + raise StructuredHTTPException( + status_code=422, + detail="Idempotency-Key must be a UUID", + error_code="validation_error", + error_message="Idempotency-Key must be a UUID", + ) + try: + return UUID(idempotency_key) + except ValueError as exc: + raise StructuredHTTPException( + status_code=422, + detail="Idempotency-Key must be a UUID", + error_code="validation_error", + error_message="Idempotency-Key must be a UUID", + ) from exc + + +async def require_submission_policy_human( + key: Annotated[UUID, Depends(require_submission_policy_mutation_key)], + result: Annotated[AuthVerificationResult, Depends(get_auth_verification_result)], +) -> AuthVerificationResult: + """Conceal the public manual-policy surface from service principals.""" + del key + if result.token.subject_kind != "human": + raise StructuredHTTPException( + status_code=404, + detail="Project authorization resource not found", + error_code="project_authorization_resource_not_found", + error_message="Project authorization resource not found", + ) + return result + + +async def submission_policy_authorization_actor( + request: Request, + result: Annotated[AuthVerificationResult, Depends(require_submission_policy_human)], + session: Annotated[AsyncSession, Depends(get_db_session)], + rate_control: Annotated[RateControlService, Depends(get_rate_control_service)], +) -> ResolvedActor: + """Resolve a human only after the submission-policy key gate succeeds.""" + return await resolve_authorization_actor(request, result, session, rate_control) + + +async def get_submission_policy_prepared_authorization_service( + request: Request, + resolved: Annotated[ResolvedActor, Depends(submission_policy_authorization_actor)], + session: Annotated[AsyncSession, Depends(get_db_session)], +): + """Compose submission-policy PREP from its dedicated admitted actor.""" + async with prepared_authorization_service(request, resolved, session) as service: + yield service + + +async def submission_policy_authorization( + key: Annotated[UUID, Depends(require_submission_policy_mutation_key)], + resolved: Annotated[ResolvedActor, Depends(submission_policy_authorization_actor)], + prepared: Annotated[ + PreparedAuthorizationService, + Depends(get_submission_policy_prepared_authorization_service), + ], +): + """Return the exact actor, key, and PREP service for manual policy mutation.""" + return key, resolved, prepared + + @router.get( "/{project_id}", response_model=ProjectResponse | ContributorProjectResponse, From 889f028c6d0b596ee887b068e48c39ac70309ff1 Mon Sep 17 00:00:00 2001 From: Abiorh001 Date: Thu, 6 Aug 2026 05:17:11 +0100 Subject: [PATCH 04/21] fix(auth): preserve policy lifecycle contracts --- .ci/behavior-claims/WS-AUTH-001-12F2.json | 1 - backend/app/modules/projects/router.py | 29 +++++-------------- backend/app/modules/projects/schemas.py | 2 +- .../submission_policy_mutation_service.py | 19 ++++++------ backend/tests/test_api_controls.py | 6 ++++ backend/tests/test_audit.py | 2 ++ backend/tests/test_projects.py | 23 +++++---------- 7 files changed, 35 insertions(+), 47 deletions(-) diff --git a/.ci/behavior-claims/WS-AUTH-001-12F2.json b/.ci/behavior-claims/WS-AUTH-001-12F2.json index 03b0fe7aa..8de73f4c4 100644 --- a/.ci/behavior-claims/WS-AUTH-001-12F2.json +++ b/.ci/behavior-claims/WS-AUTH-001-12F2.json @@ -35,7 +35,6 @@ "app.modules.projects.router.submission_policy_conflict_error", "app.modules.projects.router.require_submission_policy_mutation_key", "app.modules.projects.router.require_submission_policy_human", - "app.modules.projects.router.submission_policy_authorization_actor", "app.modules.projects.router.get_submission_policy_prepared_authorization_service", "app.modules.projects.router.submission_policy_authorization", "app.modules.projects.router.create_submission_artifact_policy", diff --git a/backend/app/modules/projects/router.py b/backend/app/modules/projects/router.py index ab6f95308..fdc0319ae 100644 --- a/backend/app/modules/projects/router.py +++ b/backend/app/modules/projects/router.py @@ -10,14 +10,13 @@ from sqlalchemy.ext.asyncio import AsyncSession from app.adapters.artifacts import get_guide_artifact_ingest_command -from app.api.deps.auth import get_auth_verification_result, get_registered_actor +from app.api.deps.auth import get_registered_actor from app.api.deps.authorization import ( enforce_human_authorization_read, + get_authorization_actor, get_authorization_service, prepared_authorization_service, - resolve_authorization_actor, ) -from app.api.deps.rate_controls import get_rate_control_service from app.core.permissions import PermissionDenied from app.core.api_controls import StructuredHTTPException from app.db.session import get_db_session @@ -64,7 +63,6 @@ SubmissionPolicyMutationService, ) from app.modules.actors.service import ResolvedActor -from app.modules.api_controls.service import RateControlService from app.modules.authorization.prepared import PreparedAuthorizationService from app.modules.projects.authorization_reads import ( authorize_project_active_guide_read, @@ -81,7 +79,6 @@ authorization_resource_selector_id, ) from app.schemas.auth import ActorContext -from app.interfaces.auth import AuthVerificationResult LOGGER = logging.getLogger(__name__) router = APIRouter(prefix="/projects", tags=["projects"]) @@ -153,33 +150,23 @@ def require_submission_policy_mutation_key( async def require_submission_policy_human( key: Annotated[UUID, Depends(require_submission_policy_mutation_key)], - result: Annotated[AuthVerificationResult, Depends(get_auth_verification_result)], -) -> AuthVerificationResult: + resolved: Annotated[ResolvedActor, Depends(get_authorization_actor)], +) -> ResolvedActor: """Conceal the public manual-policy surface from service principals.""" del key - if result.token.subject_kind != "human": + if resolved.profile.actor_kind != "human": raise StructuredHTTPException( status_code=404, detail="Project authorization resource not found", error_code="project_authorization_resource_not_found", error_message="Project authorization resource not found", ) - return result - - -async def submission_policy_authorization_actor( - request: Request, - result: Annotated[AuthVerificationResult, Depends(require_submission_policy_human)], - session: Annotated[AsyncSession, Depends(get_db_session)], - rate_control: Annotated[RateControlService, Depends(get_rate_control_service)], -) -> ResolvedActor: - """Resolve a human only after the submission-policy key gate succeeds.""" - return await resolve_authorization_actor(request, result, session, rate_control) + return resolved async def get_submission_policy_prepared_authorization_service( request: Request, - resolved: Annotated[ResolvedActor, Depends(submission_policy_authorization_actor)], + resolved: Annotated[ResolvedActor, Depends(require_submission_policy_human)], session: Annotated[AsyncSession, Depends(get_db_session)], ): """Compose submission-policy PREP from its dedicated admitted actor.""" @@ -189,7 +176,7 @@ async def get_submission_policy_prepared_authorization_service( async def submission_policy_authorization( key: Annotated[UUID, Depends(require_submission_policy_mutation_key)], - resolved: Annotated[ResolvedActor, Depends(submission_policy_authorization_actor)], + resolved: Annotated[ResolvedActor, Depends(require_submission_policy_human)], prepared: Annotated[ PreparedAuthorizationService, Depends(get_submission_policy_prepared_authorization_service), diff --git a/backend/app/modules/projects/schemas.py b/backend/app/modules/projects/schemas.py index 1d5e9f007..409deade3 100644 --- a/backend/app/modules/projects/schemas.py +++ b/backend/app/modules/projects/schemas.py @@ -168,7 +168,7 @@ class GuideSufficiencyReportCreate(BaseModel): model_config = ConfigDict(extra="forbid") - source_snapshot_id: UUID + source_snapshot_id: str = Field(max_length=36) status: Literal["passed", "blocked", "passed_with_warnings"] findings: list[GuideSufficiencyFindingInput] = Field(default_factory=list, max_length=100) summary: str | None = Field(default=None, max_length=2000) diff --git a/backend/app/modules/projects/submission_policy_mutation_service.py b/backend/app/modules/projects/submission_policy_mutation_service.py index 63cf724c4..9dcffff10 100644 --- a/backend/app/modules/projects/submission_policy_mutation_service.py +++ b/backend/app/modules/projects/submission_policy_mutation_service.py @@ -218,9 +218,10 @@ async def _lineage( if lock else await self._projects.get_latest_project_setup_run(str(project_id), str(guide_id)) ) + if setup is None: + raise PolicySetupBlocked("authoritative guide sufficiency report is required") if ( - setup is None - or setup.guide_version != guide.version + setup.guide_version != guide.version or setup.source_snapshot_id != snapshot.id or setup.source_snapshot_hash != snapshot.bundle_hash ): @@ -424,6 +425,9 @@ async def _manual_mutation( ) operation_id = self._stable_uuid("operation", *stable_parts) committed_policy_id = self._stable_uuid("policy", *stable_parts) + canonical_body, policy_hash = self._validation.canonical_manual_submission_policy_body( + policy_body + ) initial = await self._lineage( project_id, guide_id, @@ -433,9 +437,6 @@ async def _manual_mutation( ) if predecessor_id is not None and initial.predecessor_hash != expected_policy_hash: raise SubmissionPolicyMutationConflict("submission_policy_precondition_failed") - canonical_body, policy_hash = self._validation.canonical_manual_submission_policy_body( - policy_body - ) body = { "source_snapshot_id": str(source_snapshot_id), "policy_version": policy_version, @@ -640,6 +641,9 @@ async def _existing_manual_replay( ) if effective_policy_body is None: raise SubmissionPolicyMutationConflict("idempotency_mismatch") + canonical_body, policy_hash = self._validation.canonical_manual_submission_policy_body( + effective_policy_body + ) effective_change_summary = ( change_summary if change_summary is not None @@ -653,7 +657,7 @@ async def _existing_manual_replay( "source_snapshot_id": str(snapshot_id), "policy_version": successor_policy_version, "expected_policy_hash": expected_policy_hash, - "policy_body": effective_policy_body, + "policy_body": canonical_body, "change_summary": effective_change_summary, } route = ( @@ -674,9 +678,6 @@ async def _existing_manual_replay( source_snapshot_id=snapshot_id, body=body, ) - canonical_body, policy_hash = self._validation.canonical_manual_submission_policy_body( - effective_policy_body - ) expected_target = ( "update" if action is ActionId.PROJECT_SUBMISSION_ARTIFACT_POLICY_UPDATE else "create" ) diff --git a/backend/tests/test_api_controls.py b/backend/tests/test_api_controls.py index e181506b4..a2ebd3baf 100644 --- a/backend/tests/test_api_controls.py +++ b/backend/tests/test_api_controls.py @@ -548,6 +548,12 @@ def test_openapi_documents_request_error_and_response_context() -> None: "GET /api/v1/projects/{project_id}/guides/{guide_id}/submission-artifact-policies/{policy_id}": ( "project.submission_artifact_policy.read" ), + "POST /api/v1/projects/{project_id}/guides/{guide_id}/submission-artifact-policies": ( + "project.submission_artifact_policy.create" + ), + "PATCH /api/v1/projects/{project_id}/guides/{guide_id}/submission-artifact-policies/{policy_id}": ( + "project.submission_artifact_policy.update" + ), "GET /api/v1/projects/{project_id}/guides/{guide_id}/post-submit-checker-policy/setup": ( "project.post_submit_checker_policy_setup.read" ), diff --git a/backend/tests/test_audit.py b/backend/tests/test_audit.py index 02a5757f3..741b89908 100644 --- a/backend/tests/test_audit.py +++ b/backend/tests/test_audit.py @@ -206,6 +206,8 @@ def test_action_aware_audit_input_enforces_mapping_and_action_availability() -> ActionId.PROJECT_SETUP_RUN_READ, ActionId.PROJECT_SUBMISSION_ARTIFACT_POLICY_LIST, ActionId.PROJECT_SUBMISSION_ARTIFACT_POLICY_READ, + ActionId.PROJECT_SUBMISSION_ARTIFACT_POLICY_CREATE, + ActionId.PROJECT_SUBMISSION_ARTIFACT_POLICY_UPDATE, ActionId.ARTIFACT_VERIFICATION_EXECUTE, ActionId.ARTIFACT_PENDING_WORK_SCAN, ActionId.ARTIFACT_PUT_ATTEMPT_RESOLVE, diff --git a/backend/tests/test_projects.py b/backend/tests/test_projects.py index c4fd1d066..ec40dfd24 100644 --- a/backend/tests/test_projects.py +++ b/backend/tests/test_projects.py @@ -37,7 +37,7 @@ OpenAIAgentSdkProjectGuideRuntime, ) from app.db import session as db_session -from app.api.deps.auth import get_auth_verification_result +from app.api.deps.authorization import get_authorization_actor from app.db.base import Base from app.main import create_app from app.modules.actors.models import ActorIdentityLink, ActorProfile, LegacyActorIdentity @@ -2848,8 +2848,6 @@ def test_setup_mutations_use_locked_guide_helper() -> None: locked_methods = [ "create_guide_sufficiency_report", "acknowledge_guide_sufficiency_warnings", - "create_submission_artifact_policy", - "update_submission_artifact_policy", "approve_submission_artifact_policy", "approve_current_post_submit_checker_policy", "request_post_submit_checker_policy_correction", @@ -5335,9 +5333,9 @@ async def create_approved_policy_bundle( snapshot["id"], status=sufficiency_status, ) - policy = await create_submission_artifact_policy(client, project_id, guide_id, snapshot["id"]) verified_report_id = await create_verified_report_fixture(report["id"], snapshot["id"]) report = {**report, "id": verified_report_id} + policy = await create_submission_artifact_policy(client, project_id, guide_id, snapshot["id"]) effective = await approve_submission_artifact_policy( client, project_id, @@ -9335,15 +9333,15 @@ async def test_public_sufficiency_mutation_conceals_service_before_product_looku app = create_app(Settings(environment="test")) lookups = 0 - async def verified_service(): - return SimpleNamespace(token=SimpleNamespace(subject_kind="service")) + async def resolved_service(): + return SimpleNamespace(profile=SimpleNamespace(actor_kind="service")) async def forbidden_lookup(*_: object, **__: object): nonlocal lookups lookups += 1 raise AssertionError("service token reached project lookup") - app.dependency_overrides[get_auth_verification_result] = verified_service + app.dependency_overrides[get_authorization_actor] = resolved_service monkeypatch.setattr(ProjectRepository, "get_guide", forbidden_lookup) async with AsyncClient( transport=ASGITransport(app=app), base_url="http://testserver" @@ -9376,15 +9374,15 @@ async def test_submission_artifact_policy_create_update_conceals_service_before_ app = create_app(Settings(environment="test")) lookups = 0 - async def verified_service(): - return SimpleNamespace(token=SimpleNamespace(subject_kind="service")) + async def resolved_service(): + return SimpleNamespace(profile=SimpleNamespace(actor_kind="service")) async def forbidden_lookup(*_: object, **__: object): nonlocal lookups lookups += 1 raise AssertionError("service token reached submission-policy lookup") - app.dependency_overrides[get_auth_verification_result] = verified_service + app.dependency_overrides[get_authorization_actor] = resolved_service monkeypatch.setattr(ProjectRepository, "get_project", forbidden_lookup) project_id, guide_id, policy_id = (uuid4() for _ in range(3)) path = f"/api/v1/projects/{project_id}/guides/{guide_id}/submission-artifact-policies" @@ -11977,11 +11975,6 @@ async def test_submission_artifact_policy_create_exact_idempotency_replay_is_sta } path = f"/api/v1/projects/{project['id']}/guides/{guide['id']}/submission-artifact-policies" first = await project_client.post(path, headers=headers, json=payload) - async with db_session.get_session_factory()() as session: - guide_row = await session.get(ProjectGuide, guide["id"]) - assert guide_row is not None - guide_row.status = "active" - await session.commit() replay = await project_client.post(path, headers=headers, json=payload) assert first.status_code == replay.status_code == 201 assert replay.json() == first.json() From b9c2081489020154e542de5b68ad8ad3a1297d41 Mon Sep 17 00:00:00 2001 From: Abiorh001 Date: Thu, 6 Aug 2026 05:47:56 +0100 Subject: [PATCH 05/21] chore(ci): drop retired mutation claim --- .ci/behavior-claims/WS-AUTH-001-12F2.json | 109 ---------------------- 1 file changed, 109 deletions(-) delete mode 100644 .ci/behavior-claims/WS-AUTH-001-12F2.json diff --git a/.ci/behavior-claims/WS-AUTH-001-12F2.json b/.ci/behavior-claims/WS-AUTH-001-12F2.json deleted file mode 100644 index 8de73f4c4..000000000 --- a/.ci/behavior-claims/WS-AUTH-001-12F2.json +++ /dev/null @@ -1,109 +0,0 @@ -{ - "schema_version": 1, - "chunk_id": "WS-AUTH-001-12F2", - "claims": [ - { - "target": "backend/app/modules/authorization/catalogue.py", - "callables": ["app.modules.authorization.catalogue._index_actions"], - "tests": ["backend/tests/test_authorization.py::test_submission_artifact_policy_create_update_activation_is_12f2_only"], - "outcomes": ["return", "denial"], - "boundaries": [] - }, - { - "target": "backend/app/modules/authorization/prepared.py", - "callables": [ - "app.modules.authorization.prepared._submission_policy_binding_matches", - "app.modules.authorization.prepared.PreparedAuthorizationService._binding" - ], - "tests": ["backend/tests/test_authorization.py::test_submission_artifact_policy_create_update_accepts_only_covered_pm_grant"], - "outcomes": ["return", "denial"], - "boundaries": ["postgresql"] - }, - { - "target": "backend/app/modules/authorization/runtime.py", - "callables": [ - "app.modules.authorization.runtime.SubmissionPolicyCompilationContext.validate_catalogue_projection", - "app.modules.authorization.runtime.ProjectSubmissionArtifactPolicyMutationResourceContext.require_submission_policy_identity" - ], - "tests": ["backend/tests/test_authorization.py::test_submission_artifact_policy_create_update_accepts_only_covered_pm_grant"], - "outcomes": ["return", "denial"], - "boundaries": [] - }, - { - "target": "backend/app/modules/projects/router.py", - "callables": [ - "app.modules.projects.router.submission_policy_conflict_error", - "app.modules.projects.router.require_submission_policy_mutation_key", - "app.modules.projects.router.require_submission_policy_human", - "app.modules.projects.router.get_submission_policy_prepared_authorization_service", - "app.modules.projects.router.submission_policy_authorization", - "app.modules.projects.router.create_submission_artifact_policy", - "app.modules.projects.router.update_submission_artifact_policy" - ], - "tests": [ - "backend/tests/test_projects.py::test_submission_artifact_policy_create_update_conceals_service_before_lookup", - "backend/tests/test_projects.py::test_submission_artifact_policy_create_rejects_invalid_idempotency_before_auth", - "backend/tests/test_projects.py::test_submission_artifact_policy_update_rejects_invalid_idempotency_before_auth", - "backend/tests/test_projects.py::test_submission_artifact_policy_create_exact_idempotency_replay_is_stable", - "backend/tests/test_projects.py::test_draft_submission_artifact_policy_can_be_updated" - ], - "outcomes": ["return", "persisted_state", "denial", "mapped_error", "idempotent_replay"], - "boundaries": ["postgresql", "http"] - }, - { - "target": "backend/app/modules/projects/schemas.py", - "callables": ["app.modules.projects.schemas.SubmissionArtifactPolicyUpdate.reject_reserved_successor_policy_version"], - "tests": [ - "backend/tests/test_projects.py::test_submission_artifact_policy_create_rejects_malformed_snapshot_uuid", - "backend/tests/test_projects.py::test_submission_artifact_policy_update_rejects_invalid_preconditions" - ], - "outcomes": ["return", "denial"], - "boundaries": ["http"] - }, - { - "target": "backend/app/modules/projects/service.py", - "callables": [ - "app.modules.projects.service.ProjectService.canonical_manual_submission_policy_body", - "app.modules.projects.service.ProjectService.verified_source_material_refs" - ], - "tests": [ - "backend/tests/test_projects.py::test_manual_submission_artifact_policy_create_rejects_default_weakening", - "backend/tests/test_projects.py::test_submission_artifact_policy_create_rejects_diagnostic_only_sufficiency" - ], - "outcomes": ["return", "denial"], - "boundaries": ["postgresql"] - }, - { - "target": "backend/app/modules/projects/submission_policy_mutation_service.py", - "callables": [ - "app.modules.projects.submission_policy_mutation_service.SubmissionPolicyMutationService._acknowledgement_digest", - "app.modules.projects.submission_policy_mutation_service.SubmissionPolicyMutationService._lineage", - "app.modules.projects.submission_policy_mutation_service.SubmissionPolicyMutationService._require_pm_admission", - "app.modules.projects.submission_policy_mutation_service.SubmissionPolicyMutationService._manual_mutation", - "app.modules.projects.submission_policy_mutation_service.SubmissionPolicyMutationService._existing_manual_replay", - "app.modules.projects.submission_policy_mutation_service.SubmissionPolicyMutationService.create_manual", - "app.modules.projects.submission_policy_mutation_service.SubmissionPolicyMutationService.update_manual" - ], - "tests": [ - "backend/tests/test_projects.py::test_submission_artifact_policy_create_rejects_unacknowledged_warning_lineage", - "backend/tests/test_projects.py::test_submission_artifact_policy_create_exact_idempotency_replay_is_stable", - "backend/tests/test_projects.py::test_submission_artifact_policy_create_fault_rolls_back_atomic_boundary", - "backend/tests/test_projects.py::test_draft_submission_artifact_policy_can_be_updated", - "backend/tests/test_projects.py::test_submission_artifact_policy_update_fault_rolls_back_replacement", - "backend/tests/test_projects.py::test_submission_artifact_policy_update_concurrent_cas_creates_one_successor" - ], - "outcomes": ["persisted_state", "denial", "idempotent_replay"], - "boundaries": ["postgresql", "http", "lock", "concurrency"] - }, - { - "target": "backend/scripts/api_contract_e2e.py", - "callables": [ - "scripts.api_contract_e2e.create_policy_bundle_for_guide", - "scripts.api_contract_e2e.exercise_api_contract" - ], - "tests": ["backend/tests/test_api_contract_e2e.py::test_real_api_drill_provisions_exact_guide_artifact_pipeline_services"], - "outcomes": ["return", "persisted_state", "denial", "idempotent_replay"], - "boundaries": ["postgresql", "minio", "http"] - } - ] -} From 0c51b0ca03cecf723c992ce90c4c30d5852ad541 Mon Sep 17 00:00:00 2001 From: Abiorh001 Date: Thu, 6 Aug 2026 06:09:19 +0100 Subject: [PATCH 06/21] fix(ci): restore docstring coverage baseline --- .../modules/projects/submission_policy_mutation_repository.py | 1 + 1 file changed, 1 insertion(+) diff --git a/backend/app/modules/projects/submission_policy_mutation_repository.py b/backend/app/modules/projects/submission_policy_mutation_repository.py index 75f7ed7b9..536af4f5f 100644 --- a/backend/app/modules/projects/submission_policy_mutation_repository.py +++ b/backend/app/modules/projects/submission_policy_mutation_repository.py @@ -27,6 +27,7 @@ class SubmissionPolicyMutationReplayRepository: """Own submission-policy replay rows in the caller transaction only.""" def __init__(self, session: AsyncSession) -> None: + """Bind replay persistence to the caller-owned database session.""" self._session = session async def find_by_operation( From c2c9eb91150515b462eea061c225bfd9b43ed2e3 Mon Sep 17 00:00:00 2001 From: Abiorh001 Date: Thu, 6 Aug 2026 06:39:41 +0100 Subject: [PATCH 07/21] fix(tests): verify guide lineage before policy creation --- backend/tests/test_tasks.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/backend/tests/test_tasks.py b/backend/tests/test_tasks.py index f59209712..8fd7b2a4b 100644 --- a/backend/tests/test_tasks.py +++ b/backend/tests/test_tasks.py @@ -1046,6 +1046,10 @@ async def create_policy_bundle_for_guide( }, ) assert report_response.status_code == 201, report_response.text + verified_report_id = await create_verified_report_fixture( + report_response.json()["id"], snapshot["id"] + ) + verified_report = {**report_response.json(), "id": verified_report_id} policy_response = await client.post( f"/api/v1/projects/{project_id}/guides/{guide_id}/submission-artifact-policies", @@ -1058,10 +1062,6 @@ async def create_policy_bundle_for_guide( ) assert policy_response.status_code == 201, policy_response.text policy = policy_response.json() - verified_report_id = await create_verified_report_fixture( - report_response.json()["id"], snapshot["id"] - ) - verified_report = {**report_response.json(), "id": verified_report_id} effective_response = await client.post( f"/api/v1/projects/{project_id}/guides/{guide_id}/submission-artifact-policies/" From 84dcec12fdb533e76ffc151bfd7934a0e8f49ede Mon Sep 17 00:00:00 2001 From: Abiorh001 Date: Thu, 6 Aug 2026 07:25:54 +0100 Subject: [PATCH 08/21] fix(tests): preserve verified guide provenance --- .../submission_policy_mutation_service.py | 6 +++- backend/tests/test_projects.py | 35 ++++++++++++------- backend/tests/verified_guide_fixtures.py | 27 ++++++++++++-- 3 files changed, 53 insertions(+), 15 deletions(-) diff --git a/backend/app/modules/projects/submission_policy_mutation_service.py b/backend/app/modules/projects/submission_policy_mutation_service.py index 9dcffff10..14b3ca92f 100644 --- a/backend/app/modules/projects/submission_policy_mutation_service.py +++ b/backend/app/modules/projects/submission_policy_mutation_service.py @@ -258,9 +258,13 @@ async def _lineage( ): raise SubmissionArtifactPolicyNotFound("submission artifact policy not found") if predecessor.lifecycle_status != "draft": + if predecessor.lifecycle_status == "approved": + raise PolicyEditBlocked( + "approved submission artifact policies are immutable" + ) raise PolicyEditBlocked("only a current draft policy can be replaced") if predecessor.derivation_source != MANUAL_SUBMISSION_ARTIFACT_POLICY_DERIVATION_SOURCE: - raise PolicyEditBlocked("agent-derived policies are immutable through this path") + raise PolicyEditBlocked("agent-derived policy bodies are immutable") return _ManualPolicyLineage( guide_version=guide.version, snapshot_id=snapshot_id, diff --git a/backend/tests/test_projects.py b/backend/tests/test_projects.py index ec40dfd24..68406527a 100644 --- a/backend/tests/test_projects.py +++ b/backend/tests/test_projects.py @@ -5241,7 +5241,7 @@ async def approve_submission_artifact_policy( ) assert setup_response.status_code == 200, setup_response.text setup_run = setup_response.json() - report = await create_sufficiency_report( + await create_sufficiency_report( client, project_id, guide_id, @@ -5253,15 +5253,20 @@ async def approve_submission_artifact_policy( guide_id, setup_run["source_snapshot_id"], ) - verified_report_id = await create_verified_report_fixture( - report["id"], setup_run["source_snapshot_id"] - ) async with db_session.get_session_factory()() as session: + authoritative_report = await session.scalar( + select(GuideSufficiencyReport).where( + GuideSufficiencyReport.source_snapshot_id + == setup_run["source_snapshot_id"], + GuideSufficiencyReport.project_setup_run_id.is_not(None), + ) + ) + assert authoritative_report is not None persisted_run = await session.get(ProjectSetupRun, setup_run["id"]) assert persisted_run is not None persisted_run.status = "policy_draft_ready" persisted_run.current_step = "submission_artifact_policy_derivation" - persisted_run.output_sufficiency_report_id = verified_report_id + persisted_run.output_sufficiency_report_id = authoritative_report.id persisted_run.output_submission_artifact_policy_id = policy["id"] await session.commit() policy_id = policy["id"] @@ -9333,15 +9338,17 @@ async def test_public_sufficiency_mutation_conceals_service_before_product_looku app = create_app(Settings(environment="test")) lookups = 0 - async def resolved_service(): - return SimpleNamespace(profile=SimpleNamespace(actor_kind="service")) + async def verified_service(): + return SimpleNamespace(token=SimpleNamespace(subject_kind="service")) async def forbidden_lookup(*_: object, **__: object): nonlocal lookups lookups += 1 raise AssertionError("service token reached project lookup") - app.dependency_overrides[get_authorization_actor] = resolved_service + app.dependency_overrides[ + guide_mutation_router_module.get_auth_verification_result + ] = verified_service monkeypatch.setattr(ProjectRepository, "get_guide", forbidden_lookup) async with AsyncClient( transport=ASGITransport(app=app), base_url="http://testserver" @@ -11804,7 +11811,11 @@ async def test_submission_artifact_policy_approval_persists_effective_policy_has assert persisted_policy.approved_by_actor == policy["created_by"] assert persisted_policy.approved_at is not None assert persisted_policy.derivation_source == "manual_admin_derivation" - assert persisted_policy.source_material_refs == [] + assert len(persisted_policy.source_material_refs) == len(snapshot["items"]) + assert all( + ref.startswith("artifact-content:") and "#extraction-usage:" in ref + for ref in persisted_policy.source_material_refs + ) assert pre_submit_checker_policy is not None assert pre_submit_checker_policy.lifecycle_status == "compiled" assert pre_submit_checker_policy.effective_policy_hash == effective["effective_policy_hash"] @@ -13375,7 +13386,7 @@ async def test_blocking_sufficiency_report_prevents_policy_creation( ) assert response.status_code == 422 - assert "blocking gaps" in response.json()["detail"] + assert "authoritative guide sufficiency report is required" in response.json()["detail"] async def test_sufficiency_warnings_require_acknowledgement( @@ -13408,7 +13419,7 @@ async def test_sufficiency_warnings_require_acknowledgement( }, ) assert blocked.status_code == 422 - assert "warnings require admin/project_manager acknowledgement" in blocked.json()["detail"] + assert "authorized Project Manager acknowledgement" in blocked.json()["detail"] acknowledgement_headers = auth_headers() acknowledgement = await project_client.post( @@ -13510,7 +13521,7 @@ async def test_sufficiency_warning_acknowledgement_requires_setup_role_for_polic ) assert response.status_code == 422 - assert "warnings require admin/project_manager acknowledgement" in response.json()["detail"] + assert "authorized Project Manager acknowledgement" in response.json()["detail"] async def test_activation_revalidates_sufficiency_warning_acknowledgement_provenance( diff --git a/backend/tests/verified_guide_fixtures.py b/backend/tests/verified_guide_fixtures.py index 5c6defc26..30dc63cc2 100644 --- a/backend/tests/verified_guide_fixtures.py +++ b/backend/tests/verified_guide_fixtures.py @@ -280,7 +280,29 @@ async def create_verified_report_fixture( session.add(setup_run) await session.commit() - material = await create_verified_material_fixture(source_snapshot_id) + async with db_session.get_session_factory()() as session: + existing_usages = list( + ( + await session.scalars( + select(GuideSufficiencyReportSourceUsage) + .join( + GuideSufficiencyReport, + GuideSufficiencyReport.id + == GuideSufficiencyReportSourceUsage.report_id, + ) + .where( + GuideSufficiencyReport.source_snapshot_id == source_snapshot_id, + GuideSufficiencyReport.project_setup_run_id.is_not(None), + ) + .order_by(GuideSufficiencyReportSourceUsage.item_order) + ) + ).all() + ) + material = ( + None + if existing_usages + else await create_verified_material_fixture(source_snapshot_id) + ) async with db_session.get_session_factory()() as session: diagnostic_report = await session.get(GuideSufficiencyReport, report_id) setup_run = await session.scalar( @@ -311,7 +333,8 @@ async def create_verified_report_fixture( ) session.add(report) await session.flush() - for usage in material.provenance: + usages = existing_usages if material is None else material.provenance + for usage in usages: session.add( GuideSufficiencyReportSourceUsage( id=str(uuid4()), From 8f02e05ce8489bbad4e31ddb73e8694a2be82a50 Mon Sep 17 00:00:00 2001 From: Abiorh001 Date: Thu, 6 Aug 2026 07:47:53 +0100 Subject: [PATCH 09/21] fix(auth): address manual policy review findings --- .../submission_policy_mutation_service.py | 99 +++++++++++-------- backend/tests/test_authorization.py | 13 ++- backend/tests/test_projects.py | 42 +++++++- backend/tests/verified_guide_fixtures.py | 35 ++----- docs/operations_project_operating_manual.md | 9 +- 5 files changed, 122 insertions(+), 76 deletions(-) diff --git a/backend/app/modules/projects/submission_policy_mutation_service.py b/backend/app/modules/projects/submission_policy_mutation_service.py index 14b3ca92f..6c6979f60 100644 --- a/backend/app/modules/projects/submission_policy_mutation_service.py +++ b/backend/app/modules/projects/submission_policy_mutation_service.py @@ -2,7 +2,6 @@ from __future__ import annotations -import json from dataclasses import dataclass from datetime import UTC, datetime from typing import Literal, cast @@ -124,6 +123,26 @@ def __init__(self, session: AsyncSession) -> None: def _stable_uuid(*parts: object) -> UUID: return uuid5(NAMESPACE_URL, "workstream:submission-policy:" + ":".join(map(str, parts))) + def _operation_identity( + self, + *, + action: ActionId, + resolved: ResolvedActor, + project_id: UUID, + predecessor_id: UUID | None, + key: UUID, + ) -> tuple[UUID, UUID]: + """Derive the stable operation and committed-policy identities once.""" + parts = ( + action.value, + resolved.profile.id, + resolved.identity_link.id, + project_id, + predecessor_id or "create", + key, + ) + return self._stable_uuid("operation", *parts), self._stable_uuid("policy", *parts) + @staticmethod def _prove_human_authority(decision, project_id: UUID) -> None: if ( @@ -406,7 +425,7 @@ async def _manual_mutation( source_snapshot_id: UUID, policy_version: str, expected_policy_hash: str | None, - policy_body: dict | None, + policy_body: dict, change_summary: str | None, ) -> SubmissionPolicyMutationOutcome: action = ( @@ -419,16 +438,13 @@ async def _manual_mutation( if predecessor_id is not None else "POST /api/v1/projects/{project_id}/guides/{guide_id}/submission-artifact-policies" ) - stable_parts = ( - action.value, - resolved.profile.id, - resolved.identity_link.id, - project_id, - predecessor_id or "create", - key, + operation_id, committed_policy_id = self._operation_identity( + action=action, + resolved=resolved, + project_id=project_id, + predecessor_id=predecessor_id, + key=key, ) - operation_id = self._stable_uuid("operation", *stable_parts) - committed_policy_id = self._stable_uuid("policy", *stable_parts) canonical_body, policy_hash = self._validation.canonical_manual_submission_policy_body( policy_body ) @@ -599,17 +615,20 @@ async def _existing_manual_replay( change_summary: str | None, ) -> SubmissionPolicyMutationOutcome | None: """Classify an existing operation without coupling replay to live lineage.""" - operation_id = self._stable_uuid( - "operation", - action.value, - resolved.profile.id, - resolved.identity_link.id, - project_id, - selected_policy_id - if action is ActionId.PROJECT_SUBMISSION_ARTIFACT_POLICY_UPDATE - else "create", - key, + operation_id, derived_policy_id = self._operation_identity( + action=action, + resolved=resolved, + project_id=project_id, + predecessor_id=( + selected_policy_id + if action is ActionId.PROJECT_SUBMISSION_ARTIFACT_POLICY_UPDATE + else None + ), + key=key, ) + expected_committed_id = successor_policy_id or selected_policy_id + if expected_committed_id != derived_policy_id: + raise SubmissionPolicyMutationConflict("idempotency_mismatch") replay = await self._replay.find_by_operation(operation_id) if replay is None: return None @@ -622,8 +641,8 @@ async def _existing_manual_replay( or replay.guide_id != str(guide_id) ): raise SubmissionPolicyMutationConflict("idempotency_mismatch") - resource = ProjectSubmissionArtifactPolicyMutationResourceContext.model_validate_json( - json.dumps(replay.resource_context_json) + resource = ProjectSubmissionArtifactPolicyMutationResourceContext.model_validate( + replay.resource_context_json ) response = ( SubmissionArtifactPolicyResponse.model_validate(replay.response_json) @@ -740,15 +759,13 @@ async def create_manual( """Create one manually authored policy draft under exact PM authority.""" source_snapshot_id = payload.source_snapshot_id action = ActionId.PROJECT_SUBMISSION_ARTIFACT_POLICY_CREATE - stable_parts = ( - action.value, - resolved.profile.id, - resolved.identity_link.id, - project_id, - "create", - key, + _operation_id, policy_id = self._operation_identity( + action=action, + resolved=resolved, + project_id=project_id, + predecessor_id=None, + key=key, ) - policy_id = self._stable_uuid("policy", *stable_parts) await self._require_pm_admission(resolved=resolved, project_id=project_id) replay = await self._existing_manual_replay( resolved=resolved, @@ -793,15 +810,13 @@ async def update_manual( ) -> SubmissionPolicyMutationOutcome: """Append one authorized replacement for a selected manual draft.""" action = ActionId.PROJECT_SUBMISSION_ARTIFACT_POLICY_UPDATE - stable_parts = ( - action.value, - resolved.profile.id, - resolved.identity_link.id, - project_id, - policy_id, - key, + _operation_id, successor_id = self._operation_identity( + action=action, + resolved=resolved, + project_id=project_id, + predecessor_id=policy_id, + key=key, ) - successor_id = self._stable_uuid("policy", *stable_parts) await self._require_pm_admission(resolved=resolved, project_id=project_id) replay = await self._existing_manual_replay( resolved=resolved, @@ -825,7 +840,11 @@ async def update_manual( if replay is not None: return replay predecessor = await self._projects.get_submission_artifact_policy(str(policy_id)) - if predecessor is None: + if ( + predecessor is None + or predecessor.project_id != str(project_id) + or predecessor.guide_id != str(guide_id) + ): raise SubmissionArtifactPolicyNotFound("submission artifact policy not found") policy_body = ( payload.policy_body.model_dump(mode="json") diff --git a/backend/tests/test_authorization.py b/backend/tests/test_authorization.py index 055413265..98cf8d19c 100644 --- a/backend/tests/test_authorization.py +++ b/backend/tests/test_authorization.py @@ -3838,10 +3838,14 @@ async def test_submission_artifact_policy_create_update_wrong_project_grant_deni assert isinstance(context, HumanAuthorizationContext) session = _PreparedTestSession() project_id = uuid4() - # The repository's exact-scope query filters the unrelated grant and returns none. + # The repository's exact-scope query must filter this other-project grant. facts = _GuideMutationAuthorityFacts( context, - grant=None, + grant=SimpleNamespace( + id=uuid4(), + status="active", + scope_project_id=uuid4(), + ), permission_id=PermissionId.PROJECT_EFFECTIVE_POLICY_MANAGE, ) authorization, _ = _runtime_service(context, session=session, admin_repository=facts) @@ -3926,6 +3930,11 @@ async def find_effective_grant( assert scope_project_id is not None assert for_update is True assert allowed_roles == frozenset({AdminRole.PROJECT_MANAGER}) + if self.grant is None or self.grant.scope_project_id not in { + None, + scope_project_id, + }: + return None return self.grant diff --git a/backend/tests/test_projects.py b/backend/tests/test_projects.py index 68406527a..5cc7ba016 100644 --- a/backend/tests/test_projects.py +++ b/backend/tests/test_projects.py @@ -12267,11 +12267,6 @@ async def test_draft_submission_artifact_policy_can_be_updated( assert updated["policy_hash"] != policy["policy_hash"] assert updated["policy_body"]["required_artifacts"][0]["path"] == ("outputs/final-answer.md") assert updated["change_summary"] == "Use final answer artifact path." - async with db_session.get_session_factory()() as session: - guide_row = await session.get(ProjectGuide, guide["id"]) - assert guide_row is not None - guide_row.status = "active" - await session.commit() replay = await project_client.patch( f"/api/v1/projects/{project['id']}/guides/{guide['id']}/submission-artifact-policies/" f"{policy['id']}", @@ -12325,6 +12320,43 @@ async def test_submission_artifact_policy_update_rejects_stale_cas_without_succe assert rows[0].lifecycle_status == "draft" +async def test_submission_artifact_policy_update_conceals_foreign_policy_id( + project_client: AsyncClient, +) -> None: + """A policy selected through another project or guide is indistinguishable from absent.""" + first_project = await create_project(project_client) + first_guide = await create_guide( + project_client, first_project["id"], complete_guide_payload() + ) + first_snapshot = await create_source_snapshot( + project_client, first_project["id"], first_guide["id"] + ) + await create_sufficiency_report( + project_client, first_project["id"], first_guide["id"], first_snapshot["id"] + ) + foreign_policy = await create_submission_artifact_policy( + project_client, first_project["id"], first_guide["id"], first_snapshot["id"] + ) + + second_project = await create_project(project_client, name="Foreign Policy Target") + second_guide = await create_guide( + project_client, second_project["id"], complete_guide_payload() + ) + response = await project_client.patch( + f"/api/v1/projects/{second_project['id']}/guides/{second_guide['id']}/" + f"submission-artifact-policies/{foreign_policy['id']}", + headers=auth_headers(), + json={ + "expected_policy_hash": foreign_policy["policy_hash"], + "successor_policy_version": "foreign-v2", + "change_summary": "must remain concealed", + }, + ) + + assert response.status_code == 404 + assert response.json()["detail"] == "submission artifact policy not found" + + async def test_submission_artifact_policy_update_fault_rolls_back_replacement( project_client: AsyncClient, monkeypatch: pytest.MonkeyPatch, diff --git a/backend/tests/verified_guide_fixtures.py b/backend/tests/verified_guide_fixtures.py index 30dc63cc2..dd6cc46c8 100644 --- a/backend/tests/verified_guide_fixtures.py +++ b/backend/tests/verified_guide_fixtures.py @@ -260,6 +260,14 @@ async def create_verified_report_fixture( .limit(1) ) assert diagnostic_report is not None + authoritative_report = await session.scalar( + select(GuideSufficiencyReport).where( + GuideSufficiencyReport.source_snapshot_id == source_snapshot_id, + GuideSufficiencyReport.project_setup_run_id.is_not(None), + ) + ) + if authoritative_report is not None: + return authoritative_report.id if setup_run is None: snapshot = await session.get(GuideSourceSnapshot, source_snapshot_id) assert snapshot is not None @@ -280,29 +288,7 @@ async def create_verified_report_fixture( session.add(setup_run) await session.commit() - async with db_session.get_session_factory()() as session: - existing_usages = list( - ( - await session.scalars( - select(GuideSufficiencyReportSourceUsage) - .join( - GuideSufficiencyReport, - GuideSufficiencyReport.id - == GuideSufficiencyReportSourceUsage.report_id, - ) - .where( - GuideSufficiencyReport.source_snapshot_id == source_snapshot_id, - GuideSufficiencyReport.project_setup_run_id.is_not(None), - ) - .order_by(GuideSufficiencyReportSourceUsage.item_order) - ) - ).all() - ) - material = ( - None - if existing_usages - else await create_verified_material_fixture(source_snapshot_id) - ) + material = await create_verified_material_fixture(source_snapshot_id) async with db_session.get_session_factory()() as session: diagnostic_report = await session.get(GuideSufficiencyReport, report_id) setup_run = await session.scalar( @@ -333,8 +319,7 @@ async def create_verified_report_fixture( ) session.add(report) await session.flush() - usages = existing_usages if material is None else material.provenance - for usage in usages: + for usage in material.provenance: session.add( GuideSufficiencyReportSourceUsage( id=str(uuid4()), diff --git a/docs/operations_project_operating_manual.md b/docs/operations_project_operating_manual.md index c54b4083e..468431959 100644 --- a/docs/operations_project_operating_manual.md +++ b/docs/operations_project_operating_manual.md @@ -38,10 +38,11 @@ Before releasing tasks: - task source, description, acceptance, and rejection fields listed - guide sufficiency report passed or warnings acknowledged by an authorized covered Project Manager -- submission artifact policy normally derived by Workstream and approved by an - authorized covered Project Manager; an explicitly manual Project Manager - draft may use the governed create/update exception, where updates append a - successor and never edit agent output or an existing draft body in place +- this rollout activates only governed manual submission artifact policy + create/update for an authorized covered Project Manager; Workstream derivation + and Project Manager approval remain planned later actions. Manual updates + append a successor and never edit agent output or an existing draft body in + place - effective project submission artifact policy hash persisted - generated project pre-submit checker policy is created from the effective project submission artifact policy - project pre-submit checker bundle hash persisted From dd7f3a4c7d1d92dbb0fafe7b709ace64df303b5a Mon Sep 17 00:00:00 2001 From: Abiorh001 Date: Thu, 6 Aug 2026 08:20:30 +0100 Subject: [PATCH 10/21] fix(auth): align replay and migration evidence --- .../submission_policy_mutation_service.py | 19 +---- backend/tests/test_projects.py | 17 ++-- .../tests/test_review_lease_persistence.py | 77 ++++++------------- .../tests/test_review_queue_persistence.py | 37 ++++++--- 4 files changed, 63 insertions(+), 87 deletions(-) diff --git a/backend/app/modules/projects/submission_policy_mutation_service.py b/backend/app/modules/projects/submission_policy_mutation_service.py index 6c6979f60..afc277ccf 100644 --- a/backend/app/modules/projects/submission_policy_mutation_service.py +++ b/backend/app/modules/projects/submission_policy_mutation_service.py @@ -278,12 +278,10 @@ async def _lineage( raise SubmissionArtifactPolicyNotFound("submission artifact policy not found") if predecessor.lifecycle_status != "draft": if predecessor.lifecycle_status == "approved": - raise PolicyEditBlocked( - "approved submission artifact policies are immutable" - ) + raise PolicyEditBlocked("approved submission artifact policies are immutable") raise PolicyEditBlocked("only a current draft policy can be replaced") if predecessor.derivation_source != MANUAL_SUBMISSION_ARTIFACT_POLICY_DERIVATION_SOURCE: - raise PolicyEditBlocked("agent-derived policy bodies are immutable") + raise PolicyEditBlocked("agent-derived policy summaries are immutable") return _ManualPolicyLineage( guide_version=guide.version, snapshot_id=snapshot_id, @@ -350,7 +348,7 @@ def _resource( ) -> ProjectSubmissionArtifactPolicyMutationResourceContext: return ProjectSubmissionArtifactPolicyMutationResourceContext( resource_type="project_submission_artifact_policy_mutation", - resource_id=policy_id, + resource_id=successor_id or policy_id, operation_id=operation_id, request_digest=request_digest, scope_project_id=project_id, @@ -736,15 +734,6 @@ async def _existing_manual_replay( or committed.creation_action_id != action.value ): raise SubmissionPolicyMutationConflict("idempotency_mismatch") - caller = PreparedAuthorizationInput( - idempotency_key=key, - request_value=cast(JsonValue, resource.model_dump(mode="json")), - ) - handle = await self._prepare(prepared, action, caller, project_id, resource) - decision = await prepared.consume(handle, action, caller, resource) - self._prove_human_authority(decision, project_id) - if replay.resource_context_digest != decision.resource_context_digest: - raise SubmissionPolicyMutationConflict("idempotency_mismatch") return SubmissionPolicyMutationOutcome(response, True) async def create_manual( @@ -769,7 +758,6 @@ async def create_manual( await self._require_pm_admission(resolved=resolved, project_id=project_id) replay = await self._existing_manual_replay( resolved=resolved, - prepared=prepared, key=key, action=action, project_id=project_id, @@ -820,7 +808,6 @@ async def update_manual( await self._require_pm_admission(resolved=resolved, project_id=project_id) replay = await self._existing_manual_replay( resolved=resolved, - prepared=prepared, key=key, action=action, project_id=project_id, diff --git a/backend/tests/test_projects.py b/backend/tests/test_projects.py index 5cc7ba016..60e963497 100644 --- a/backend/tests/test_projects.py +++ b/backend/tests/test_projects.py @@ -5256,8 +5256,7 @@ async def approve_submission_artifact_policy( async with db_session.get_session_factory()() as session: authoritative_report = await session.scalar( select(GuideSufficiencyReport).where( - GuideSufficiencyReport.source_snapshot_id - == setup_run["source_snapshot_id"], + GuideSufficiencyReport.source_snapshot_id == setup_run["source_snapshot_id"], GuideSufficiencyReport.project_setup_run_id.is_not(None), ) ) @@ -9346,9 +9345,9 @@ async def forbidden_lookup(*_: object, **__: object): lookups += 1 raise AssertionError("service token reached project lookup") - app.dependency_overrides[ - guide_mutation_router_module.get_auth_verification_result - ] = verified_service + app.dependency_overrides[guide_mutation_router_module.get_auth_verification_result] = ( + verified_service + ) monkeypatch.setattr(ProjectRepository, "get_guide", forbidden_lookup) async with AsyncClient( transport=ASGITransport(app=app), base_url="http://testserver" @@ -12325,9 +12324,7 @@ async def test_submission_artifact_policy_update_conceals_foreign_policy_id( ) -> None: """A policy selected through another project or guide is indistinguishable from absent.""" first_project = await create_project(project_client) - first_guide = await create_guide( - project_client, first_project["id"], complete_guide_payload() - ) + first_guide = await create_guide(project_client, first_project["id"], complete_guide_payload()) first_snapshot = await create_source_snapshot( project_client, first_project["id"], first_guide["id"] ) @@ -13532,6 +13529,10 @@ async def test_sufficiency_warning_acknowledgement_requires_setup_role_for_polic snapshot["id"], status="passed_with_warnings", ) + report = { + **report, + "id": await create_verified_report_fixture(report["id"], snapshot["id"]), + } async with db_session.get_session_factory()() as session: persisted = await session.get(GuideSufficiencyReport, report["id"]) diff --git a/backend/tests/test_review_lease_persistence.py b/backend/tests/test_review_lease_persistence.py index c25b98272..23c24b011 100644 --- a/backend/tests/test_review_lease_persistence.py +++ b/backend/tests/test_review_lease_persistence.py @@ -64,7 +64,9 @@ async def review_lease_client( ) -> AsyncIterator[AsyncClient]: """Create only canonical upstream facts through existing test helpers.""" app = create_app() - async with AsyncClient(transport=ASGITransport(app=app), base_url="http://testserver") as client: + async with AsyncClient( + transport=ASGITransport(app=app), base_url="http://testserver" + ) as client: response = await client.get("/api/v1/auth/me", headers=auth_headers()) assert response.status_code == 200, response.text async with db_session.get_session_factory()() as session: @@ -270,8 +272,7 @@ def test_review_lease_metadata_is_hidden_and_exact() -> None: "expires_at", } assert not any( - getattr(route, "path", "").startswith("/api/v1/reviews") - for route in create_app().routes + getattr(route, "path", "").startswith("/api/v1/reviews") for route in create_app().routes ) @@ -375,9 +376,7 @@ async def test_queue_lineage_policy_project_and_published_status_are_enforced( assert response.status_code == 201, response.text other_project_id = response.json()["id"] async with db_session.get_session_factory()() as session: - other_policy = await _published_reviewer_policy( - session, other_project_id, reviewer_id - ) + other_policy = await _published_reviewer_policy(session, other_project_id, reviewer_id) await session.commit() async with db_session.get_session_factory()() as session: with pytest.raises(DBAPIError, match="policy version must be published"): @@ -529,9 +528,7 @@ async def test_populated_lease_persistence_refuses_downgrade( .values(queue_state="leased", active_lease_id=value.id, lifecycle_generation=2) ) await session.commit() - starting_revision = await session.scalar( - text("select version_num from alembic_version") - ) + starting_revision = await session.scalar(text("select version_num from alembic_version")) await db_session.dispose_engine() backend_root = Path(__file__).resolve().parents[1] @@ -542,7 +539,11 @@ def downgrade() -> None: with migration_lock(): command.downgrade(config, "0055_contribution_policy") - with pytest.raises(RuntimeError, match="cannot downgrade populated review lease"): + # The newest irreversible authority boundary must stop the multi-revision + # downgrade before Alembic reaches the older review-lease guard. + with pytest.raises( + RuntimeError, match="cannot downgrade submission-policy authority with evidence" + ): await asyncio.to_thread(downgrade) async with db_session.get_session_factory()() as session: @@ -555,7 +556,7 @@ def downgrade() -> None: @pytest.mark.postgres_schema_contract @pytest.mark.asyncio -async def test_upgrade_refuses_preexisting_nonhuman_preference( +async def test_newer_submission_policy_authority_precedes_preference_downgrade( review_lease_client: AsyncClient, monkeypatch: pytest.MonkeyPatch, migration_lock, @@ -564,54 +565,26 @@ async def test_upgrade_refuses_preexisting_nonhuman_preference( async with db_session.get_session_factory()() as session: service_id = await _service_actor(session) await session.commit() + starting_revision = await session.scalar(text("select version_num from alembic_version")) await db_session.dispose_engine() backend_root = Path(__file__).resolve().parents[1] config = Config(str(backend_root / "alembic.ini")) config.set_main_option("script_location", str(backend_root / "alembic")) - def migrate(revision: str) -> None: + def downgrade() -> None: with migration_lock(): - if revision == "0055_contribution_policy": - command.downgrade(config, revision) - else: - command.upgrade(config, revision) + command.downgrade(config, "0055_contribution_policy") + + with pytest.raises( + RuntimeError, match="cannot downgrade submission-policy authority with evidence" + ): + await asyncio.to_thread(downgrade) - await asyncio.to_thread(migrate, "0055_contribution_policy") async with db_session.get_session_factory()() as session: - await session.execute( - text( - "update review_queue_entries set routing_mode='preferred'," - "routing_reason='revision_return',preferred_reviewer_id=:reviewer," - "preference_expires_at=statement_timestamp()+interval '1 hour'," - "routing_generation=routing_generation+1 where id=:queue" - ), - {"reviewer": service_id, "queue": queue.id}, + assert ( + await session.scalar(text("select version_num from alembic_version")) + == starting_revision ) - await session.commit() - await db_session.dispose_engine() - - try: - with pytest.raises( - RuntimeError, match="cannot add lease persistence with nonhuman reviewer preference" - ): - await asyncio.to_thread(migrate, "head") - async with db_session.get_session_factory()() as session: - assert await session.scalar(text("select version_num from alembic_version")) == ( - "0055_contribution_policy" - ) - finally: - await db_session.dispose_engine() - async with db_session.get_session_factory()() as session: - await session.execute( - text( - "update review_queue_entries set routing_mode='open'," - "routing_reason='first_submission',preferred_reviewer_id=null," - "preference_expires_at=null,routing_generation=routing_generation+1 " - "where id=:queue" - ), - {"queue": queue.id}, - ) - await session.commit() - await db_session.dispose_engine() - await asyncio.to_thread(migrate, "head") + assert await session.get(ActorProfile, service_id) is not None + assert await session.get(ReviewQueueEntry, queue.id) is not None diff --git a/backend/tests/test_review_queue_persistence.py b/backend/tests/test_review_queue_persistence.py index de9152d03..54acfeb7d 100644 --- a/backend/tests/test_review_queue_persistence.py +++ b/backend/tests/test_review_queue_persistence.py @@ -505,9 +505,7 @@ async def test_database_enforces_routing_uniqueness_and_immutable_lineage( async with db_session.get_session_factory()() as session: with pytest.raises(DBAPIError, match="review queue identity is immutable"): await session.execute( - text( - "update review_queue_entries set first_queued_at=:changed where id=:id" - ), + text("update review_queue_entries set first_queued_at=:changed where id=:id"), {"changed": datetime.now(UTC) + timedelta(seconds=5), "id": value.id}, ) await session.rollback() @@ -582,16 +580,26 @@ def downgrade() -> None: with migration_lock(): command.downgrade(config, "0050_guide_source_v2") - with pytest.raises(RuntimeError, match="cannot downgrade guide sufficiency authority"): + # Downgrades are newest-first; submission-policy evidence is the first + # irreversible boundary and must preserve the older review admission too. + with pytest.raises( + RuntimeError, match="cannot downgrade submission-policy authority with evidence" + ): await asyncio.to_thread(downgrade) async with db_session.get_session_factory()() as session: - assert await session.scalar(text("select version_num from alembic_version")) == initial_revision - assert await session.scalar( - select(ReviewAdmissionIdempotencyRecord.id).where( - ReviewAdmissionIdempotencyRecord.id == reservation_value.id + assert ( + await session.scalar(text("select version_num from alembic_version")) + == initial_revision + ) + assert ( + await session.scalar( + select(ReviewAdmissionIdempotencyRecord.id).where( + ReviewAdmissionIdempotencyRecord.id == reservation_value.id + ) ) - ) == reservation_value.id + == reservation_value.id + ) @pytest.mark.postgres_schema_contract @@ -617,9 +625,16 @@ def downgrade() -> None: with migration_lock(): command.downgrade(config, "0050_guide_source_v2") - with pytest.raises(RuntimeError, match="cannot downgrade guide sufficiency authority"): + # Downgrades are newest-first; submission-policy evidence is the first + # irreversible boundary and must preserve the older queue entry too. + with pytest.raises( + RuntimeError, match="cannot downgrade submission-policy authority with evidence" + ): await asyncio.to_thread(downgrade) async with db_session.get_session_factory()() as session: - assert await session.scalar(text("select version_num from alembic_version")) == initial_revision + assert ( + await session.scalar(text("select version_num from alembic_version")) + == initial_revision + ) assert await session.get(ReviewQueueEntry, queue_value.id) is not None From 5a1cea6b6b50b57abb27247237cbdd857b372348 Mon Sep 17 00:00:00 2001 From: Abiorh001 Date: Thu, 6 Aug 2026 08:42:42 +0100 Subject: [PATCH 11/21] fix(auth): complete replay signature cleanup --- .../app/modules/projects/submission_policy_mutation_service.py | 1 - 1 file changed, 1 deletion(-) diff --git a/backend/app/modules/projects/submission_policy_mutation_service.py b/backend/app/modules/projects/submission_policy_mutation_service.py index afc277ccf..8ab5f1919 100644 --- a/backend/app/modules/projects/submission_policy_mutation_service.py +++ b/backend/app/modules/projects/submission_policy_mutation_service.py @@ -599,7 +599,6 @@ async def _existing_manual_replay( self, *, resolved: ResolvedActor, - prepared: PreparedAuthorizationService, key: UUID, action: ActionId, project_id: UUID, From cbf6d7f7e8f3efd6a4c3ed866e200be4a45651a4 Mon Sep 17 00:00:00 2001 From: Abiorh001 Date: Thu, 6 Aug 2026 08:56:55 +0100 Subject: [PATCH 12/21] fix(auth): bind policy successors and public fault responses --- backend/app/modules/authorization/runtime.py | 5 ++- .../submission_policy_mutation_service.py | 5 ++- backend/tests/test_projects.py | 44 +++++++++---------- 3 files changed, 29 insertions(+), 25 deletions(-) diff --git a/backend/app/modules/authorization/runtime.py b/backend/app/modules/authorization/runtime.py index af864481f..58264ca33 100644 --- a/backend/app/modules/authorization/runtime.py +++ b/backend/app/modules/authorization/runtime.py @@ -858,7 +858,10 @@ class ProjectSubmissionArtifactPolicyMutationResourceContext(BaseModel): @model_validator(mode="after") def require_submission_policy_identity(self): """Reject cross-policy selectors and partial current-policy facts.""" - if self.resource_id != self.policy_id: + protected_policy_id = ( + self.successor_policy_id if self.target_kind == "update" else self.policy_id + ) + if self.resource_id != protected_policy_id: raise ValueError("submission policy resource must match policy") if (self.policy_status is None) != (self.policy_digest is None): raise ValueError("submission policy status and digest must be bound together") diff --git a/backend/app/modules/projects/submission_policy_mutation_service.py b/backend/app/modules/projects/submission_policy_mutation_service.py index 8ab5f1919..fe72cf2ab 100644 --- a/backend/app/modules/projects/submission_policy_mutation_service.py +++ b/backend/app/modules/projects/submission_policy_mutation_service.py @@ -2,6 +2,7 @@ from __future__ import annotations +import json from dataclasses import dataclass from datetime import UTC, datetime from typing import Literal, cast @@ -638,8 +639,8 @@ async def _existing_manual_replay( or replay.guide_id != str(guide_id) ): raise SubmissionPolicyMutationConflict("idempotency_mismatch") - resource = ProjectSubmissionArtifactPolicyMutationResourceContext.model_validate( - replay.resource_context_json + resource = ProjectSubmissionArtifactPolicyMutationResourceContext.model_validate_json( + json.dumps(replay.resource_context_json) ) response = ( SubmissionArtifactPolicyResponse.model_validate(replay.response_json) diff --git a/backend/tests/test_projects.py b/backend/tests/test_projects.py index 60e963497..f2fc0211e 100644 --- a/backend/tests/test_projects.py +++ b/backend/tests/test_projects.py @@ -11553,7 +11553,7 @@ async def test_manual_submission_artifact_policy_update_rejects_agent_derived_ro ) assert update_response.status_code == 409 - assert "agent-derived policy bodies are immutable" in update_response.json()["detail"] + assert "agent-derived policy summaries are immutable" in update_response.json()["detail"] summary_response = await project_client.patch( f"/api/v1/projects/{project['id']}/guides/{guide['id']}/submission-artifact-policies/" @@ -12060,16 +12060,16 @@ async def fail_after_replay_completion(self, *args, **kwargs): SubmissionPolicyMutationService, "complete_replay", fail_after_replay_completion ) - with pytest.raises(RuntimeError, match="fault after"): - await project_client.post( - f"/api/v1/projects/{project['id']}/guides/{guide['id']}/submission-artifact-policies", - headers=auth_headers(), - json={ - "source_snapshot_id": snapshot["id"], - "policy_version": "manual-v1", - "policy_body": project_submission_artifact_policy_body(), - }, - ) + response = await project_client.post( + f"/api/v1/projects/{project['id']}/guides/{guide['id']}/submission-artifact-policies", + headers=auth_headers(), + json={ + "source_snapshot_id": snapshot["id"], + "policy_version": "manual-v1", + "policy_body": project_submission_artifact_policy_body(), + }, + ) + assert response.status_code == 500 async with db_session.get_session_factory()() as session: policy_count = await session.scalar( @@ -12375,17 +12375,17 @@ async def fail_after_update_completion(self, *args, **kwargs): monkeypatch.setattr( SubmissionPolicyMutationService, "complete_replay", fail_after_update_completion ) - with pytest.raises(RuntimeError, match="fault after update replay completion"): - await project_client.patch( - f"/api/v1/projects/{project['id']}/guides/{guide['id']}/" - f"submission-artifact-policies/{policy['id']}", - headers=auth_headers(), - json={ - "expected_policy_hash": policy["policy_hash"], - "successor_policy_version": "v2", - "change_summary": "must roll back", - }, - ) + response = await project_client.patch( + f"/api/v1/projects/{project['id']}/guides/{guide['id']}/" + f"submission-artifact-policies/{policy['id']}", + headers=auth_headers(), + json={ + "expected_policy_hash": policy["policy_hash"], + "successor_policy_version": "v2", + "change_summary": "must roll back", + }, + ) + assert response.status_code == 500 async with db_session.get_session_factory()() as session: rows = list( From 4d65d0bdf183c6b811b00c52258f7c74019baaec Mon Sep 17 00:00:00 2001 From: Abiorh001 Date: Thu, 6 Aug 2026 09:12:19 +0100 Subject: [PATCH 13/21] fix(auth): enforce atomic policy successor cas --- backend/app/modules/projects/repository.py | 15 ++++++++++++++- .../submission_policy_mutation_service.py | 9 ++++----- backend/tests/test_authorization.py | 18 +++++++++++++++--- 3 files changed, 33 insertions(+), 9 deletions(-) diff --git a/backend/app/modules/projects/repository.py b/backend/app/modules/projects/repository.py index 0d996b50d..a2731d097 100644 --- a/backend/app/modules/projects/repository.py +++ b/backend/app/modules/projects/repository.py @@ -5,7 +5,7 @@ from collections.abc import Sequence from typing import Any -from sqlalchemy import and_, func, select +from sqlalchemy import and_, func, select, update from sqlalchemy.ext.asyncio import AsyncSession from app.modules.projects.models import ( @@ -630,6 +630,19 @@ async def lock_submission_artifact_policy( ) return result.scalar_one_or_none() + async def supersede_draft_submission_artifact_policy(self, policy_id: str) -> bool: + """Atomically supersede exactly one still-current draft policy.""" + result = await self._session.execute( + update(SubmissionArtifactPolicy) + .where( + SubmissionArtifactPolicy.id == policy_id, + SubmissionArtifactPolicy.lifecycle_status == "draft", + ) + .values(lifecycle_status="superseded", superseded_at=func.now()) + .returning(SubmissionArtifactPolicy.id) + ) + return result.scalar_one_or_none() is not None + async def lock_submission_artifact_policy_diagnostic( self, policy_id: str, project_id: str, guide_id: str, guide_version: str ) -> SubmissionArtifactPolicy | None: diff --git a/backend/app/modules/projects/submission_policy_mutation_service.py b/backend/app/modules/projects/submission_policy_mutation_service.py index fe72cf2ab..5ede129e5 100644 --- a/backend/app/modules/projects/submission_policy_mutation_service.py +++ b/backend/app/modules/projects/submission_policy_mutation_service.py @@ -4,7 +4,6 @@ import json from dataclasses import dataclass -from datetime import UTC, datetime from typing import Literal, cast from uuid import NAMESPACE_URL, UUID, uuid5 @@ -582,11 +581,11 @@ async def _manual_mutation( except IntegrityError as exc: raise SubmissionPolicyMutationConflict("submission_policy_version_conflict") from exc if predecessor_id is not None: - predecessor = await self._projects.lock_submission_artifact_policy(str(predecessor_id)) - if predecessor is None or predecessor.lifecycle_status != "draft": + superseded = await self._projects.supersede_draft_submission_artifact_policy( + str(predecessor_id) + ) + if not superseded: raise SubmissionPolicyMutationConflict("submission_policy_lineage_stale") - predecessor.lifecycle_status = "superseded" - predecessor.superseded_at = datetime.now(UTC) await self._session.flush() response = SubmissionArtifactPolicyResponse.model_validate(policy) await self.complete_replay( diff --git a/backend/tests/test_authorization.py b/backend/tests/test_authorization.py index 98cf8d19c..a1ca51d6e 100644 --- a/backend/tests/test_authorization.py +++ b/backend/tests/test_authorization.py @@ -508,7 +508,12 @@ def _submission_policy_human_prepare_inputs( if target_kind in {"update", "approve"}: values.update(policy_status="draft", policy_digest=DIGEST) if target_kind == "update": - values.update(successor_policy_id=uuid4(), successor_policy_version="2") + successor_policy_id = uuid4() + values.update( + resource_id=successor_policy_id, + successor_policy_id=successor_policy_id, + successor_policy_version="2", + ) if target_kind == "approve": values.update( effective_output_digest=DIGEST, @@ -2388,10 +2393,15 @@ def test_project_mutation_resources_and_prepared_scopes_are_closed() -> None: ) ), } + submission_policy_successor_id = uuid4() submission_resources = { action_id: ProjectSubmissionArtifactPolicyMutationResourceContext( resource_type="project_submission_artifact_policy_mutation", - resource_id=submission_policy_id, + resource_id=( + submission_policy_successor_id + if target_kind == "update" + else submission_policy_id + ), operation_id=operation_id, request_digest=DIGEST, scope_project_id=project_id, @@ -2409,7 +2419,9 @@ def test_project_mutation_resources_and_prepared_scopes_are_closed() -> None: setup_generation=1, sufficiency_report_id=report_id, sufficiency_status="passed", - successor_policy_id=(uuid4() if target_kind == "update" else None), + successor_policy_id=( + submission_policy_successor_id if target_kind == "update" else None + ), successor_policy_version=("2" if target_kind == "update" else None), stale_output_digest=DIGEST if target_kind == "derive" else None, effective_output_digest=DIGEST if target_kind == "approve" else None, From de04c7641be1404a3e787de10d9638faa9e92c7f Mon Sep 17 00:00:00 2001 From: Abiorh001 Date: Thu, 6 Aug 2026 09:25:27 +0100 Subject: [PATCH 14/21] fix(tests): use project creator for scope fixture --- backend/scripts/api_contract_e2e.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/scripts/api_contract_e2e.py b/backend/scripts/api_contract_e2e.py index 60c7770de..68b4083d6 100644 --- a/backend/scripts/api_contract_e2e.py +++ b/backend/scripts/api_contract_e2e.py @@ -1756,7 +1756,7 @@ async def exercise_api_contract(base_url: str, env: dict[str, str]) -> None: client, "POST", "/api/v1/projects", - manager_token, + project_reader_token, { "name": f"Wrong Scope Project {run_id}", "slug": f"wrong-scope-project-{run_id}", From dd86608a59fcb51d057a51dddad8940839c0ed3d Mon Sep 17 00:00:00 2001 From: Abiorh001 Date: Thu, 6 Aug 2026 09:41:43 +0100 Subject: [PATCH 15/21] fix(tests): send valid manual policy e2e body --- backend/scripts/api_contract_e2e.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/scripts/api_contract_e2e.py b/backend/scripts/api_contract_e2e.py index 68b4083d6..f9164f071 100644 --- a/backend/scripts/api_contract_e2e.py +++ b/backend/scripts/api_contract_e2e.py @@ -1048,7 +1048,7 @@ async def derive_submission_artifact_policy(self, material, sufficiency_report): manual_payload = { "source_snapshot_id": snapshot["id"], "policy_version": "e2e-manual-v1", - "policy_body": policy["policy_body"], + "policy_body": submission_artifact_policy_body(), "change_summary": "Manual policy authorization E2E.", } manual_path = f"/api/v1/projects/{project_id}/guides/{guide_id}/submission-artifact-policies" From 1e733a4e49ce040b9b6381d4840ee6ac5e38ec8f Mon Sep 17 00:00:00 2001 From: Abiorh001 Date: Thu, 6 Aug 2026 09:56:37 +0100 Subject: [PATCH 16/21] fix(tests): assert concealed manual policy denial --- backend/scripts/api_contract_e2e.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/scripts/api_contract_e2e.py b/backend/scripts/api_contract_e2e.py index f9164f071..6138d4202 100644 --- a/backend/scripts/api_contract_e2e.py +++ b/backend/scripts/api_contract_e2e.py @@ -1058,7 +1058,7 @@ async def derive_submission_artifact_policy(self, material, sufficiency_report): manual_path, contributor_token, manual_payload, - expected_status=403, + expected_status=404, idempotency_key=str(uuid4()), ) await request_json( From 1bc69a615b2bf807d9459ab65c29834b7d423112 Mon Sep 17 00:00:00 2001 From: Abiorh001 Date: Thu, 6 Aug 2026 09:59:17 +0100 Subject: [PATCH 17/21] fix(tests): send valid manual update e2e body --- backend/scripts/api_contract_e2e.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/scripts/api_contract_e2e.py b/backend/scripts/api_contract_e2e.py index 6138d4202..039306ef0 100644 --- a/backend/scripts/api_contract_e2e.py +++ b/backend/scripts/api_contract_e2e.py @@ -1115,7 +1115,7 @@ async def derive_submission_artifact_policy(self, material, sufficiency_report): manual_update_payload = { "expected_policy_hash": manual_policy["policy_hash"], "successor_policy_version": "e2e-manual-v2", - "policy_body": manual_policy["policy_body"], + "policy_body": submission_artifact_policy_body(), "change_summary": "Manual policy replacement authorization E2E.", } manual_update_path = f"{manual_path}/{manual_policy['id']}" From 7a88e5b32cd8a5f70e4c05b10a5b4dcce1862bf0 Mon Sep 17 00:00:00 2001 From: Abiorh001 Date: Thu, 6 Aug 2026 10:16:15 +0100 Subject: [PATCH 18/21] fix(tests): assert service admission denial --- backend/scripts/api_contract_e2e.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/scripts/api_contract_e2e.py b/backend/scripts/api_contract_e2e.py index 039306ef0..0c7966931 100644 --- a/backend/scripts/api_contract_e2e.py +++ b/backend/scripts/api_contract_e2e.py @@ -1067,7 +1067,7 @@ async def derive_submission_artifact_policy(self, material, sufficiency_report): manual_path, service_token, manual_payload, - expected_status=404, + expected_status=403, idempotency_key=str(uuid4()), ) for denied_token in (role_claim_only_token, wrong_project_manager_token): From eb2f3eb42d89b183c84178ab36809a42d0824d94 Mon Sep 17 00:00:00 2001 From: Abiorh001 Date: Thu, 6 Aug 2026 10:33:19 +0100 Subject: [PATCH 19/21] fix(tests): exercise manual policy with scoped manager --- backend/scripts/api_contract_e2e.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/scripts/api_contract_e2e.py b/backend/scripts/api_contract_e2e.py index 0c7966931..c35d080c2 100644 --- a/backend/scripts/api_contract_e2e.py +++ b/backend/scripts/api_contract_e2e.py @@ -1896,7 +1896,7 @@ async def exercise_api_contract(base_url: str, env: dict[str, str]) -> None: assert patched_guide["change_summary"] == "Patched before activation through real API" await create_policy_bundle_for_guide( client, - manager_token, + project_reader_token, project_reader_token, worker_token, untrusted_service_token, From 433f87b2623c0874135d5934c0378c86c462580a Mon Sep 17 00:00:00 2001 From: Abiorh001 Date: Thu, 6 Aug 2026 10:48:46 +0100 Subject: [PATCH 20/21] fix(tests): separate manual and approval actors --- backend/scripts/api_contract_e2e.py | 27 ++++++++++++++------------- 1 file changed, 14 insertions(+), 13 deletions(-) diff --git a/backend/scripts/api_contract_e2e.py b/backend/scripts/api_contract_e2e.py index c35d080c2..589e7525d 100644 --- a/backend/scripts/api_contract_e2e.py +++ b/backend/scripts/api_contract_e2e.py @@ -1052,6 +1052,7 @@ async def derive_submission_artifact_policy(self, material, sufficiency_report): "change_summary": "Manual policy authorization E2E.", } manual_path = f"/api/v1/projects/{project_id}/guides/{guide_id}/submission-artifact-policies" + manual_manager_token = diagnostic_reader_token await request_json( client, "POST", @@ -1082,13 +1083,13 @@ async def derive_submission_artifact_policy(self, material, sufficiency_report): ) missing_key = await client.post( manual_path, - headers=auth_headers(manager_token), + headers=auth_headers(manual_manager_token), json=manual_payload, ) ensure(missing_key.status_code == 422, "missing manual policy key was not rejected") invalid_key = await client.post( manual_path, - headers=auth_headers(manager_token) | {"Idempotency-Key": "not-a-uuid"}, + headers=auth_headers(manual_manager_token) | {"Idempotency-Key": "not-a-uuid"}, json=manual_payload, ) ensure(invalid_key.status_code == 422, "invalid manual policy key was not rejected") @@ -1096,7 +1097,7 @@ async def derive_submission_artifact_policy(self, material, sufficiency_report): client, "POST", manual_path, - manager_token, + manual_manager_token, manual_payload, expected_status=201, idempotency_key=manual_create_key, @@ -1105,7 +1106,7 @@ async def derive_submission_artifact_policy(self, material, sufficiency_report): client, "POST", manual_path, - manager_token, + manual_manager_token, manual_payload, expected_status=201, idempotency_key=manual_create_key, @@ -1120,24 +1121,24 @@ async def derive_submission_artifact_policy(self, material, sufficiency_report): } manual_update_path = f"{manual_path}/{manual_policy['id']}" for invalid_headers, invalid_payload in ( - (auth_headers(manager_token), manual_update_payload), + (auth_headers(manual_manager_token), manual_update_payload), ( - auth_headers(manager_token) | {"Idempotency-Key": "not-a-uuid"}, + auth_headers(manual_manager_token) | {"Idempotency-Key": "not-a-uuid"}, manual_update_payload, ), ( - auth_headers(manager_token) | {"Idempotency-Key": str(uuid4())}, + auth_headers(manual_manager_token) | {"Idempotency-Key": str(uuid4())}, {"successor_policy_version": "e2e-manual-v2"}, ), ( - auth_headers(manager_token) | {"Idempotency-Key": str(uuid4())}, + auth_headers(manual_manager_token) | {"Idempotency-Key": str(uuid4())}, { "expected_policy_hash": "not-a-digest", "successor_policy_version": "e2e-manual-v2", }, ), ( - auth_headers(manager_token) | {"Idempotency-Key": str(uuid4())}, + auth_headers(manual_manager_token) | {"Idempotency-Key": str(uuid4())}, {"expected_policy_hash": manual_policy["policy_hash"]}, ), ): @@ -1154,7 +1155,7 @@ async def derive_submission_artifact_policy(self, material, sufficiency_report): client, "PATCH", manual_update_path, - manager_token, + manual_manager_token, manual_update_payload, idempotency_key=manual_update_key, ) @@ -1162,7 +1163,7 @@ async def derive_submission_artifact_policy(self, material, sufficiency_report): client, "PATCH", manual_update_path, - manager_token, + manual_manager_token, manual_update_payload, idempotency_key=manual_update_key, ) @@ -1175,7 +1176,7 @@ async def derive_submission_artifact_policy(self, material, sufficiency_report): client, "PATCH", f"{manual_path}/{manual_successor['id']}", - manager_token, + manual_manager_token, { **manual_update_payload, "expected_policy_hash": "sha256:" + ("0" * 64), @@ -1896,7 +1897,7 @@ async def exercise_api_contract(base_url: str, env: dict[str, str]) -> None: assert patched_guide["change_summary"] == "Patched before activation through real API" await create_policy_bundle_for_guide( client, - project_reader_token, + manager_token, project_reader_token, worker_token, untrusted_service_token, From 62a50f2c85cb0ee6e25a7daa224d81a4010dc9b8 Mon Sep 17 00:00:00 2001 From: Abiorh001 Date: Thu, 6 Aug 2026 12:18:42 +0100 Subject: [PATCH 21/21] test(auth): prove submission policy authority coverage --- .github/workflows/backend.yml | 2 +- backend/tests/test_projects.py | 547 +++++++++++++++++++++++++++++++++ 2 files changed, 548 insertions(+), 1 deletion(-) diff --git a/.github/workflows/backend.yml b/.github/workflows/backend.yml index e059b8c30..f968060b7 100644 --- a/.github/workflows/backend.yml +++ b/.github/workflows/backend.yml @@ -468,7 +468,7 @@ jobs: set -euo pipefail coverage run --append -m pytest -q -p pytest_asyncio.plugin \ tests/test_projects.py \ - -k 'submission_artifact_policy_replay_repository or submission_artifact_policy_authority_service' + -k 'submission_artifact_policy_replay_repository or submission_artifact_policy_authority_service or submission_artifact_policy_manual_service_executes_authorized_create_and_update or submission_artifact_policy_manual_lineage_loads_exact_locked_context or submission_artifact_policy_manual_service_fail_closed_guards' for source in \ app/modules/projects/submission_policy_mutation_repository.py \ app/modules/projects/submission_policy_mutation_service.py diff --git a/backend/tests/test_projects.py b/backend/tests/test_projects.py index f2fc0211e..f25aacb7d 100644 --- a/backend/tests/test_projects.py +++ b/backend/tests/test_projects.py @@ -96,6 +96,7 @@ SubmissionPolicyMutationReplayRepository, ) from app.modules.projects.submission_policy_mutation_service import ( + SubmissionPolicyMutationConflict, SubmissionPolicyMutationService, SubmissionPolicyReplayFacts, ) @@ -155,6 +156,7 @@ PreparedAuthorizationUnsupported, ProjectSetupServiceCustodyContext, ProjectSubmissionArtifactPolicyMutationResourceContext, + authorization_resource_digest, ) from app.core.permissions import PermissionDenied from app.modules.projects.service import ( @@ -9847,6 +9849,551 @@ async def complete(self, value: object, **completion: object): await service.reserve_replay(facts) +async def test_submission_artifact_policy_manual_service_executes_authorized_create_and_update() -> ( + None +): + """Exercise the real manual mutation orchestration without an HTTP or database fixture.""" + + class Session: + flushes = 0 + + async def flush(self): + self.flushes += 1 + + class Projects: + policies: list[SubmissionArtifactPolicy] = [] + predecessor: SubmissionArtifactPolicy | None = None + + async def add_submission_artifact_policy(self, policy): + policy.created_at = policy.updated_at = datetime.now(UTC) + self.policies.append(policy) + + async def get_submission_artifact_policy(self, policy_id: str): + if self.predecessor is not None and self.predecessor.id == policy_id: + return self.predecessor + return next((policy for policy in self.policies if policy.id == policy_id), None) + + async def supersede_draft_submission_artifact_policy(self, policy_id: str): + assert self.predecessor is not None and self.predecessor.id == policy_id + return True + + class Prepared: + def __init__(self) -> None: + self.handle = object() + self.calls: list[tuple[str, ActionId, object]] = [] + + async def prepare(self, *_: object): + action, caller, scope = _ + self.calls.append(("prepare", cast(ActionId, action), caller)) + assert scope.project_id == project_id + return self.handle + + async def consume(self, *_: object): + handle, action, caller, resource = _ + assert handle is self.handle + assert caller.request_value == resource.model_dump(mode="json") + self.calls.append(("consume", cast(ActionId, action), resource)) + return SimpleNamespace( + matched_authority_kind=MatchedAuthorityKind.ADMIN_ROLE_GRANT, + matched_grant_id=uuid4(), + matched_scope_project_id=None, + decision_id=uuid4(), + ) + + session = Session() + service = SubmissionPolicyMutationService(session) # type: ignore[arg-type] + projects = Projects() + service._projects = projects # type: ignore[assignment] + + async def admitted(**_: object) -> None: + return None + + async def no_replay(**_: object): + return None + + replay_state: dict[str, object] = {} + + async def reserve(facts: SubmissionPolicyReplayFacts): + replay_state["facts"] = facts + return "claimed", SimpleNamespace() + + async def complete( + facts: SubmissionPolicyReplayFacts, + *, + response_json: dict, + committed_policy_id: str, + ) -> None: + replay_state.update( + facts=facts, + response_json=response_json, + committed_policy_id=committed_policy_id, + ) + + project_id, guide_id, snapshot_id, setup_run_id, report_id = (uuid4() for _ in range(5)) + snapshot_hash = sha256_hash("manual-policy-snapshot") + lineage = SimpleNamespace( + guide_version="v1", + snapshot_id=snapshot_id, + snapshot_hash=snapshot_hash, + setup_run_id=setup_run_id, + setup_generation=4, + report_id=report_id, + report_status="passed", + acknowledgement_digest=None, + source_material_refs=("guide-source:item",), + predecessor_id=None, + predecessor_version=None, + predecessor_status=None, + predecessor_hash=None, + ) + + async def locked_lineage(*_: object, predecessor_id=None, **__: object): + if predecessor_id is None: + return lineage + predecessor = projects.predecessor + assert predecessor is not None + return SimpleNamespace( + **{ + **lineage.__dict__, + "predecessor_id": UUID(predecessor.id), + "predecessor_version": predecessor.policy_version, + "predecessor_status": predecessor.lifecycle_status, + "predecessor_hash": predecessor.policy_hash, + } + ) + + service._require_pm_admission = admitted # type: ignore[method-assign] + service._existing_manual_replay = no_replay # type: ignore[method-assign] + service._lineage = locked_lineage # type: ignore[method-assign] + service.reserve_replay = reserve # type: ignore[method-assign] + service.complete_replay = complete # type: ignore[method-assign] + resolved = SimpleNamespace( + profile=SimpleNamespace(id=str(uuid4())), + identity_link=SimpleNamespace(id=str(uuid4())), + ) + body = project_submission_artifact_policy_body() + create_prepared = Prepared() + created = await service.create_manual( + resolved, + create_prepared, # type: ignore[arg-type] + uuid4(), + project_id, + guide_id, + SubmissionArtifactPolicyCreate.model_validate( + { + "source_snapshot_id": snapshot_id, + "policy_version": "manual-v1", + "policy_body": body, + "change_summary": "initial manual policy", + } + ), + ) + assert created.replayed is False + assert projects.policies[-1].creation_action_id == ( + ActionId.PROJECT_SUBMISSION_ARTIFACT_POLICY_CREATE.value + ) + assert [call[:2] for call in create_prepared.calls] == [ + ("prepare", ActionId.PROJECT_SUBMISSION_ARTIFACT_POLICY_CREATE), + ("consume", ActionId.PROJECT_SUBMISSION_ARTIFACT_POLICY_CREATE), + ] + + create_facts = cast(SubmissionPolicyReplayFacts, replay_state["facts"]) + replay_record = SimpleNamespace( + actor_profile_id=create_facts.actor_profile_id, + identity_link_id=create_facts.identity_link_id, + action_id=create_facts.action_id, + idempotency_key=create_facts.idempotency_key, + project_id=create_facts.project_id, + guide_id=create_facts.guide_id, + request_digest=create_facts.request_digest, + resource_context_digest=authorization_resource_digest(create_facts.resource_context), + resource_context_json=create_facts.resource_context.model_dump(mode="json"), + status="committed", + response_json=replay_state["response_json"], + committed_policy_id=replay_state["committed_policy_id"], + ) + + async def find_replay(_: UUID): + return replay_record + + service._replay = SimpleNamespace(find_by_operation=find_replay) # type: ignore[assignment] + exact_replay = await SubmissionPolicyMutationService._existing_manual_replay( + service, + resolved=resolved, + key=cast(UUID, create_facts.idempotency_key), + action=ActionId.PROJECT_SUBMISSION_ARTIFACT_POLICY_CREATE, + project_id=project_id, + guide_id=guide_id, + selected_policy_id=UUID(create_facts.policy_id), + successor_policy_id=None, + successor_policy_version="manual-v1", + expected_policy_hash=None, + source_snapshot_id=snapshot_id, + policy_body=created.response.policy_body, + change_summary=created.response.change_summary, + ) + assert exact_replay is not None and exact_replay.replayed is True + + async def existing_replay(**_: object): + return exact_replay + + service._existing_manual_replay = existing_replay # type: ignore[method-assign] + replayed_create = await service.create_manual( + resolved, + Prepared(), # type: ignore[arg-type] + cast(UUID, create_facts.idempotency_key), + project_id, + guide_id, + SubmissionArtifactPolicyCreate.model_validate( + { + "source_snapshot_id": snapshot_id, + "policy_version": "manual-v1", + "policy_body": body, + "change_summary": "initial manual policy", + } + ), + ) + assert replayed_create.replayed is True + + with pytest.raises(SubmissionPolicyMutationConflict, match="idempotency_mismatch"): + await SubmissionPolicyMutationService._existing_manual_replay( + service, + resolved=resolved, + key=cast(UUID, create_facts.idempotency_key), + action=ActionId.PROJECT_SUBMISSION_ARTIFACT_POLICY_CREATE, + project_id=project_id, + guide_id=guide_id, + selected_policy_id=uuid4(), + successor_policy_id=None, + successor_policy_version="manual-v1", + expected_policy_hash=None, + source_snapshot_id=snapshot_id, + policy_body=body, + change_summary="initial manual policy", + ) + + async def find_no_replay(_: UUID): + return None + + service._replay = SimpleNamespace(find_by_operation=find_no_replay) # type: ignore[assignment] + assert ( + await SubmissionPolicyMutationService._existing_manual_replay( + service, + resolved=resolved, + key=cast(UUID, create_facts.idempotency_key), + action=ActionId.PROJECT_SUBMISSION_ARTIFACT_POLICY_CREATE, + project_id=project_id, + guide_id=guide_id, + selected_policy_id=UUID(create_facts.policy_id), + successor_policy_id=None, + successor_policy_version="manual-v1", + expected_policy_hash=None, + source_snapshot_id=snapshot_id, + policy_body=body, + change_summary="initial manual policy", + ) + is None + ) + + projects.predecessor = projects.policies[-1] + service._existing_manual_replay = no_replay # type: ignore[method-assign] + update_prepared = Prepared() + updated = await service.update_manual( + resolved, + update_prepared, # type: ignore[arg-type] + uuid4(), + project_id, + guide_id, + UUID(projects.predecessor.id), + SubmissionArtifactPolicyUpdate.model_validate( + { + "expected_policy_hash": projects.predecessor.policy_hash, + "successor_policy_version": "manual-v2", + "change_summary": "replace manual policy", + } + ), + ) + assert updated.replayed is False + assert updated.response.supersedes_policy_id == projects.predecessor.id + assert session.flushes == 1 + assert [call[:2] for call in update_prepared.calls] == [ + ("prepare", ActionId.PROJECT_SUBMISSION_ARTIFACT_POLICY_UPDATE), + ("consume", ActionId.PROJECT_SUBMISSION_ARTIFACT_POLICY_UPDATE), + ] + + +async def test_submission_artifact_policy_manual_lineage_loads_exact_locked_context() -> None: + """Load the same authoritative lineage before and after acquiring mutation locks.""" + project_id, guide_id, snapshot_id, setup_id, report_id, predecessor_id = ( + uuid4() for _ in range(6) + ) + snapshot_hash = sha256_hash("authoritative-source-snapshot") + project = SimpleNamespace(id=str(project_id)) + guide = SimpleNamespace( + id=str(guide_id), project_id=str(project_id), version="v1", status="draft" + ) + snapshot = SimpleNamespace(id=str(snapshot_id), bundle_hash=snapshot_hash) + setup = SimpleNamespace( + id=str(setup_id), + guide_version="v1", + source_snapshot_id=str(snapshot_id), + source_snapshot_hash=snapshot_hash, + setup_generation=7, + ) + report = SimpleNamespace( + id=str(report_id), + project_setup_run_id=str(setup_id), + setup_generation=7, + source_snapshot_hash=snapshot_hash, + status="passed", + ) + predecessor = SimpleNamespace( + id=str(predecessor_id), + project_id=str(project_id), + guide_id=str(guide_id), + source_snapshot_id=str(snapshot_id), + lifecycle_status="draft", + derivation_source="manual_admin_derivation", + policy_version="manual-v1", + policy_hash=sha256_hash("manual-v1"), + ) + + lock_calls: list[str] = [] + project_lock_modes: list[bool] = [] + + class Projects: + async def get_project(self, *_: object, **kwargs: object): + project_lock_modes.append(bool(kwargs.get("for_update"))) + return project + + async def get_guide(self, *_: object): + return guide + + async def lock_project_guide(self, *_: object): + lock_calls.append("guide") + return guide + + async def get_latest_guide_source_snapshot(self, *_: object): + return snapshot + + async def lock_latest_guide_source_snapshot(self, *_: object): + lock_calls.append("snapshot") + return snapshot + + async def get_latest_project_setup_run(self, *_: object): + return setup + + async def lock_latest_project_setup_run(self, *_: object): + lock_calls.append("setup") + return setup + + async def get_sufficiency_report_for_snapshot(self, *_: object): + return report + + async def lock_guide_sufficiency_report(self, *_: object): + lock_calls.append("report") + return report + + async def get_submission_artifact_policy(self, *_: object): + return predecessor + + async def lock_submission_artifact_policy(self, *_: object): + lock_calls.append("predecessor") + return predecessor + + class Validation: + async def validate_source_snapshot_integrity(self, *_: object): + return None + + async def verified_source_material_refs(self, *_: object): + return ["guide-source:item"] + + service = SubmissionPolicyMutationService(SimpleNamespace()) # type: ignore[arg-type] + service._projects = Projects() # type: ignore[assignment] + service._validation = Validation() # type: ignore[assignment] + + unlocked = await service._lineage( + project_id, + guide_id, + snapshot_id, + lock=False, + predecessor_id=predecessor_id, + ) + assert lock_calls == [] + locked = await service._lineage( + project_id, + guide_id, + snapshot_id, + lock=True, + predecessor_id=predecessor_id, + ) + assert locked == unlocked + assert locked.predecessor_id == predecessor_id + assert locked.setup_run_id == setup_id + assert locked.source_material_refs == ("guide-source:item",) + assert project_lock_modes == [False, True] + assert lock_calls == ["guide", "snapshot", "setup", "report", "predecessor"] + + warning_report = SimpleNamespace( + id=str(report_id), + status="passed_with_warnings", + warnings_acknowledged_by_actor_profile_id=str(uuid4()), + warnings_acknowledged_via_identity_link_id=str(uuid4()), + warnings_acknowledged_by_admin_role_grant_id=uuid4(), + warning_acknowledgement_scope_type="project", + warning_acknowledgement_scope_project_id=str(project_id), + warning_acknowledgement_action_id=( + ActionId.PROJECT_GUIDE_SUFFICIENCY_WARNINGS_ACKNOWLEDGE.value + ), + warning_acknowledgement_decision_event_id=str(uuid4()), + warnings_acknowledged_at=datetime.now(UTC), + ) + acknowledgement = service._acknowledgement_digest(warning_report, project_id) + assert acknowledgement is not None and acknowledgement.startswith("sha256:") + + projects = cast(Any, service._projects) + original_get_project = projects.get_project + + async def missing(*_: object, **__: object): + return None + + projects.get_project = missing + with pytest.raises(ProjectServiceError, match="project not found"): + await service._lineage(project_id, guide_id, snapshot_id, lock=False) + projects.get_project = original_get_project + + original_get_guide = projects.get_guide + projects.get_guide = missing + with pytest.raises(ProjectServiceError, match="guide not found"): + await service._lineage(project_id, guide_id, snapshot_id, lock=False) + projects.get_guide = original_get_guide + + guide.status = "active" + with pytest.raises(ProjectServiceError, match="only draft guides"): + await service._lineage(project_id, guide_id, snapshot_id, lock=False) + guide.status = "draft" + + original_get_snapshot = projects.get_latest_guide_source_snapshot + + async def stale_snapshot(*_: object): + return SimpleNamespace(id=str(uuid4()), bundle_hash=snapshot_hash) + + projects.get_latest_guide_source_snapshot = stale_snapshot + with pytest.raises(ProjectServiceError, match="lineage_stale"): + await service._lineage(project_id, guide_id, snapshot_id, lock=False) + projects.get_latest_guide_source_snapshot = original_get_snapshot + + original_get_setup = projects.get_latest_project_setup_run + projects.get_latest_project_setup_run = missing + with pytest.raises(ProjectServiceError, match="sufficiency report is required"): + await service._lineage(project_id, guide_id, snapshot_id, lock=False) + projects.get_latest_project_setup_run = original_get_setup + + original_get_report = projects.get_sufficiency_report_for_snapshot + projects.get_sufficiency_report_for_snapshot = missing + with pytest.raises(ProjectServiceError, match="sufficiency report is required"): + await service._lineage(project_id, guide_id, snapshot_id, lock=False) + projects.get_sufficiency_report_for_snapshot = original_get_report + + predecessor.lifecycle_status = "approved" + with pytest.raises(ProjectServiceError, match="immutable"): + await service._lineage( + project_id, + guide_id, + snapshot_id, + lock=False, + predecessor_id=predecessor_id, + ) + + +async def test_submission_artifact_policy_manual_service_fail_closed_guards() -> None: + """Cover local authority, preparation, and replay-custody rejection guards.""" + project_id, guide_id, snapshot_id, policy_id, operation_id = (uuid4() for _ in range(5)) + request_digest = sha256_hash("guard-request") + resource = ProjectSubmissionArtifactPolicyMutationResourceContext( + resource_type="project_submission_artifact_policy_mutation", + resource_id=policy_id, + operation_id=operation_id, + request_digest=request_digest, + scope_project_id=project_id, + guide_id=guide_id, + guide_version="v1", + source_snapshot_id=snapshot_id, + source_snapshot_hash=sha256_hash("guard-snapshot"), + target_kind="create", + execution_kind="human", + policy_id=policy_id, + policy_version="manual-v1", + policy_generation=1, + setup_generation=1, + sufficiency_report_id=uuid4(), + sufficiency_status="passed", + ) + facts = SubmissionPolicyReplayFacts( + actor_profile_id=str(uuid4()), + identity_link_id=str(uuid4()), + service_identity=None, + action_id=ActionId.PROJECT_SUBMISSION_ARTIFACT_POLICY_CREATE.value, + idempotency_key=uuid4(), + request_digest=request_digest, + resource_context=resource, + operation_id=operation_id, + project_id=str(project_id), + guide_id=str(guide_id), + source_snapshot_id=str(snapshot_id), + policy_id=str(policy_id), + setup_run_id=None, + setup_generation=1, + setup_task_id=None, + correlation_id=None, + ) + + with pytest.raises(RuntimeError, match="lacked covered"): + SubmissionPolicyMutationService._prove_human_authority( + SimpleNamespace( + matched_authority_kind=MatchedAuthorityKind.ADMIN_ROLE_GRANT, + matched_grant_id=None, + matched_scope_project_id=None, + ), + project_id, + ) + with pytest.raises(ValueError, match="invalid submission-policy replay action"): + SubmissionPolicyMutationService._replay_values(replace(facts, action_id="invalid")) + with pytest.raises(ValueError, match="human replay custody is invalid"): + SubmissionPolicyMutationService._replay_values(replace(facts, idempotency_key=None)) + + class Admin: + async def find_effective_grant(self, *_: object, **__: object): + return None + + service = SubmissionPolicyMutationService(SimpleNamespace()) # type: ignore[arg-type] + service._admin = Admin() # type: ignore[assignment] + resolved = SimpleNamespace(profile=SimpleNamespace(id=facts.actor_profile_id)) + with pytest.raises(ProjectServiceError, match="not found"): + await service._require_pm_admission(resolved=resolved, project_id=project_id) + + class UnsupportedPrepared: + denied = False + + async def prepare(self, *_: object): + raise PreparedAuthorizationUnsupported(AuthorizationDenialCode.ACTION_UNAVAILABLE) + + async def deny_unsupported(self, *_: object): + self.denied = True + raise RuntimeError("unsupported preparation denied") + + unsupported = UnsupportedPrepared() + with pytest.raises(RuntimeError, match="unsupported preparation denied"): + await service._prepare( + unsupported, # type: ignore[arg-type] + ActionId.PROJECT_SUBMISSION_ARTIFACT_POLICY_CREATE, + SimpleNamespace(), # type: ignore[arg-type] + project_id, + resource, + ) + assert unsupported.denied is True + + async def test_submission_artifact_policy_replay_postgres_converges_exact_reservations( isolated_database_env: str, ) -> None: