From 91f8aa065b768ec09cca9065b5722cb776d13922 Mon Sep 17 00:00:00 2001 From: Abiorh001 Date: Wed, 5 Aug 2026 13:15:47 +0100 Subject: [PATCH 01/10] feat(auth): add submission policy authority foundation --- .../STATUS.md | 2 +- ...-submission-policy-authority-foundation.md | 19 +- ...-AUTH-001-12F1-internal-review-evidence.md | 44 + .../WS-AUTH-001-12F1-pr-trust-bundle.md | 85 ++ .github/workflows/backend.yml | 17 + .../0057_submission_policy_authority.py | 749 ++++++++++++++++++ backend/app/modules/audit/schemas.py | 2 +- backend/app/modules/authorization/kernel.py | 47 +- backend/app/modules/authorization/prepared.py | 71 ++ backend/app/modules/authorization/runtime.py | 75 +- backend/app/modules/projects/models.py | 212 +++++ .../submission_policy_mutation_repository.py | 232 ++++++ .../submission_policy_mutation_service.py | 153 ++++ backend/tests/test_alembic.py | 472 ++++++++++- backend/tests/test_authorization.py | 225 +++++- backend/tests/test_projects.py | 369 ++++++++- docs/operations_authorization_service.md | 13 + docs/spec_authorization_service.md | 15 + 18 files changed, 2785 insertions(+), 17 deletions(-) create mode 100644 .agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/reviews/WS-AUTH-001-12F1-internal-review-evidence.md create mode 100644 .agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/reviews/WS-AUTH-001-12F1-pr-trust-bundle.md create mode 100644 backend/alembic/versions/0057_submission_policy_authority.py create mode 100644 backend/app/modules/projects/submission_policy_mutation_repository.py create mode 100644 backend/app/modules/projects/submission_policy_mutation_service.py 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 78ef29636..f406f0fc9 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,7 +184,7 @@ 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` | Proposed | - | - | Submission-policy PREP, replay, and provenance foundation; zero activation. | +| `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-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. | diff --git a/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/chunks/WS-AUTH-001-12F1-submission-policy-authority-foundation.md b/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/chunks/WS-AUTH-001-12F1-submission-policy-authority-foundation.md index 9143d1866..63ddb95f2 100644 --- a/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/chunks/WS-AUTH-001-12F1-submission-policy-authority-foundation.md +++ b/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/chunks/WS-AUTH-001-12F1-submission-policy-authority-foundation.md @@ -19,10 +19,12 @@ backend/app/modules/projects/repository.py backend/app/modules/authorization/kernel.py backend/app/modules/authorization/prepared.py backend/app/modules/authorization/runtime.py +backend/app/modules/audit/schemas.py backend/alembic/versions/_submission_policy_authority.py backend/tests/test_authorization.py backend/tests/test_projects.py backend/tests/test_alembic.py +.github/workflows/backend.yml docs/spec_authorization_service.md docs/operations_authorization_service.md .agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/** @@ -51,14 +53,17 @@ mutation, post-submit behavior, or edits to historical migrations. response and exact target IDs once. The append-only authorization decision stream remains separate audit evidence. A unique constraint owns the human namespace `(actor_profile_id, idempotency_key)`; a second unique constraint - owns `(service_actor_profile_id, setup_run_id, setup_generation, + owns `(actor_profile_id, setup_run_id, setup_generation, setup_task_id, correlation_id, action_id)`. Reserve/find/complete and rollback locate the same row by operation UUID, and changed namespace facts never attach to an existing reservation. - Add nullable local provenance columns and closed constraints for draft policy, effective policy, and pre-submit policy. Historical bootstrap rows - remain readable and are never backfilled or rewritten. Newly authorized rows - require complete actor/link/grant-or-service/scope/action/decision evidence. + and rows still written by the uncut legacy routes remain readable in the + all-null unattributed shape and are never backfilled or rewritten. Only rows + written through the new 12F orchestrator use the complete authorized shape; + those rows require complete actor/link/grant-or-service/scope/action/decision + evidence. 12F2-12F4 own removal of the unattributed write paths. - The new orchestrator is flush-only. Route/Celery owners commit or roll back; no wrapper calls a legacy self-committing mutation method. - Reuse the merged `PreparedAuthorizationService`/runtime context and the 12E @@ -67,6 +72,9 @@ mutation, post-submit behavior, or edits to historical migrations. second authorization protocol, replay state machine, UUID parser, or locking protocol; shared dependencies are extracted when their semantics are exact. - The catalogue remains planned and database/runtime parity remains exact. + `backend/app/modules/authorization/catalogue.py` is out of scope: 12F1 may + neither change action ownership/availability nor make the existing planned + fixed-service membership usable. ## Acceptance and proof @@ -79,8 +87,9 @@ mutation, post-submit behavior, or edits to historical migrations. - Fault injection proves product rows, replay completion, allowed decision evidence, and local provenance roll back together. - Seeded historical rows survive upgrade unchanged; populated authorized - evidence blocks downgrade, empty downgrade succeeds, and re-upgrade restores - the schema. Migration allocation is taken only from then-current main. + evidence or any replay reservation row, including `pending`, blocks + downgrade; empty downgrade succeeds, and re-upgrade restores the schema. + Migration allocation is taken only from then-current main. - No action becomes active and no route behavior changes. ## Verification commands diff --git a/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/reviews/WS-AUTH-001-12F1-internal-review-evidence.md b/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/reviews/WS-AUTH-001-12F1-internal-review-evidence.md new file mode 100644 index 000000000..836fea606 --- /dev/null +++ b/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/reviews/WS-AUTH-001-12F1-internal-review-evidence.md @@ -0,0 +1,44 @@ +# WS-AUTH-001-12F1 Internal Review Evidence + +## Scope reviewed + +Zero-activation submission-policy PREP binding, human/fixed-service replay +custody, nullable historical provenance, migration 0057, audit evidence shape, +deferred database custody, and focused coverage enforcement. + +## Reviewer results + +- Architecture: PASS after full replay namespace completion and exact + policy/effective/pre-submit lineage and digest binding were added. +- Security/auth: PASS WITH LOW RISKS after kernel, typed audit input, database + audit vocabulary, project target, and custody trigger evidence were aligned. +- QA: PASS WITH LOW RISKS. Full writer rollback proof remains assigned to the + first activating writer chunks, 12F2-12F4. +- Senior engineering: PASS WITH LOW RISKS. Operation identity, provenance + immutability, and typed completion targets are closed. +- Test delta: PASS. Real PostgreSQL convergence, migration replay guards, + downgrade blockers, and AUTH evidence mapping are covered. +- CI integrity: PASS. The new repository and service have an explicit hosted + per-file 90 percent gate; local focused coverage is 93.75 percent. +- Documentation: PASS WITH LOW RISKS; all valid wording findings were fixed. +- Reuse/dedup: PASS. Existing PREP, resource digest, setup custody, and replay + conventions are reused without a second authorization protocol. +- Product/operations: PASS WITH LOW RISKS after approval and generated-output + provenance became immutable once attributed; zero activation is preserved. + +## Repairs driven by review + +- Added the complete immutable catalogue/compiler projection and JSON-safe + canonicalization. +- Made operation UUID and every human/service namespace fact exact across + reserve, conflict lookup, and completion. +- Derived replay JSON and digest only from the typed authorization context. +- Added typed committed policy, effective-policy, and pre-submit-policy IDs. +- Added deferred replay/product/audit custody with exact project, guide, + snapshot, policy, output hash, grant/service, and decision evidence checks. +- Preserved all-null historical rows while blocking downgrade on replay, + attributed provenance, or submission-policy authorization audit evidence. +- Kept all four submission-policy actions planned and unavailable. + +All internal reviewer sessions completed. Hosted database tests, aggregate +coverage, Agent Gates, 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-12F1-pr-trust-bundle.md b/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/reviews/WS-AUTH-001-12F1-pr-trust-bundle.md new file mode 100644 index 000000000..9b914dec2 --- /dev/null +++ b/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/reviews/WS-AUTH-001-12F1-pr-trust-bundle.md @@ -0,0 +1,85 @@ +# Workstream PR Trust Bundle + +## Chunk + +`WS-AUTH-001-12F1` - Submission Policy Authority Foundation + +## Goal + +Install exact PREP, replay, provenance, audit, and transaction custody needed by +12F2-12F4 without activating any submission-policy mutation action or changing +route, worker, or product behavior. + +## What changed + +- Extended the typed submission-policy authorization context with exact + operation, request, policy, setup, compiler, catalogue, and output facts. +- Added whole-context PREP equality and canonical resource-digest binding. +- Added flush-only human/fixed-service replay reservation and typed completion. +- Added migration 0057 nullable provenance, replay constraints, immutable + completion, deferred product/evidence custody, and guarded downgrade. +- Aligned kernel, audit schema, and database evidence vocabulary for the future + exact submission-policy mutation resource. +- Added focused unit, PostgreSQL convergence, migration, audit, documentation, + and per-file coverage proof. + +## Scope and behavior + +- No action is activated. +- No API route, Celery worker, agent call, or product-policy writer changes. +- Historical product rows remain readable with all authority provenance null. +- 12F2 owns manual create/update, 12F3 owns fixed-service derive, and 12F4 owns + approval plus effective/pre-submit output creation. + +## Evidence + +```text +Ruff app/tests/scripts: passed +AUTH exact selector: 3 passed +Replay/service unit selector: 2 passed +CI lane contract: 33 passed +Focused per-file coverage: repository 92.31%, service 94.74%, total 93.75% +Semantic collection: 3,277 tests +Stale authorization docs: passed +Markdown links: passed +git diff --check: passed +``` + +PostgreSQL and Alembic selectors require `WORKSTREAM_TEST_DATABASE_URL` and are +delegated to the hosted Backend matrix rather than the user's slow local host. + +## Acceptance proof + +- [x] All submission-policy mutation actions remain planned/unavailable. +- [x] PREP binds the exact typed context and computed resource digest. +- [x] Human and fixed-service replay namespaces are disjoint and exact. +- [x] Completion uses operation UUID plus the complete immutable namespace. +- [x] Product provenance, replay completion, and allowed evidence are linked by + deferred database custody. +- [x] Historical null provenance survives upgrade and empty roundtrip. +- [x] Pending replay and admitted audit evidence block downgrade. +- [x] New subsystem files have a non-weakened hosted 90 percent coverage gate. + +## Internal review + +Architecture, security, QA, senior engineering, test delta, CI integrity, +documentation, and reuse/dedup completed with no blocking findings. Product/ops +review passed after approval/output provenance immutability was added. + +## Remaining risk and follow-up + +- Hosted PostgreSQL migration/custody tests and repository-wide aggregate + coverage must pass on the exact pushed head. +- Full real-writer fault injection is intentionally proved in 12F2-12F4 because + this foundation activates no product writer. + +## Human review focus + +- Zero activation and absence of route/worker behavior changes. +- Full human/service replay namespace and single completion transition. +- Deferred custody across policy/effective/pre-submit rows and AUTH evidence. +- Historical all-null provenance and downgrade refusal predicates. + +## Human merge ownership + +- [ ] The user explicitly approved this specific PR for merge. diff --git a/.github/workflows/backend.yml b/.github/workflows/backend.yml index fc91249f8..e059b8c30 100644 --- a/.github/workflows/backend.yml +++ b/.github/workflows/backend.yml @@ -459,6 +459,23 @@ jobs: coverage report --include="${source}" --precision=2 --fail-under=90 done + - name: Submission policy authority foundation per-file coverage + working-directory: backend + shell: bash + env: + PYTEST_DISABLE_PLUGIN_AUTOLOAD: "1" + run: | + 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' + for source in \ + app/modules/projects/submission_policy_mutation_repository.py \ + app/modules/projects/submission_policy_mutation_service.py + do + coverage report --include="${source}" --precision=2 --fail-under=90 + done + - name: API controls coverage working-directory: backend run: >- diff --git a/backend/alembic/versions/0057_submission_policy_authority.py b/backend/alembic/versions/0057_submission_policy_authority.py new file mode 100644 index 000000000..165a8cc09 --- /dev/null +++ b/backend/alembic/versions/0057_submission_policy_authority.py @@ -0,0 +1,749 @@ +"""install submission-policy authorization foundation + +Revision ID: 0057_submission_policy_authority +Revises: 0056_review_lease_preference +Create Date: 2026-08-05 +""" + +from __future__ import annotations + +from alembic import op +import sqlalchemy as sa + + +revision = "0057_submission_policy_authority" +down_revision = "0056_review_lease_preference" +branch_labels = depends_on = None + +_SUBMISSION_CREATION_COLUMNS = ( + ("created_by_actor_profile_id", sa.String(36)), + ("created_via_identity_link_id", sa.String(36)), + ("created_by_admin_role_grant_id", sa.Uuid()), + ("created_by_service_identity", sa.String(160)), + ("creation_scope_type", sa.String(16)), + ("creation_scope_project_id", sa.String(36)), + ("creation_action_id", sa.String(160)), + ("creation_decision_event_id", sa.String(36)), +) +_SUBMISSION_APPROVAL_COLUMNS = ( + ("approved_by_actor_profile_id", sa.String(36)), + ("approved_via_identity_link_id", sa.String(36)), + ("approved_by_admin_role_grant_id", sa.Uuid()), + ("approval_scope_type", sa.String(16)), + ("approval_scope_project_id", sa.String(36)), + ("approval_action_id", sa.String(160)), + ("approval_decision_event_id", sa.String(36)), +) +_APPROVAL_OUTPUT_COLUMNS = ( + ("created_by_actor_profile_id", sa.String(36)), + ("created_via_identity_link_id", sa.String(36)), + ("created_by_admin_role_grant_id", sa.Uuid()), + ("creation_scope_type", sa.String(16)), + ("creation_scope_project_id", sa.String(36)), + ("creation_action_id", sa.String(160)), + ("creation_decision_event_id", sa.String(36)), +) +_AUDIT_RESOURCE_MARKER = "('project_create_operation'::character varying)::text" +_AUDIT_RESOURCE_ADDITION = ( + ", ('project_submission_artifact_policy_mutation'::character varying)::text" +) + + +def _rewrite_audit_resource(*, add: bool) -> None: + bind = op.get_bind() + definition = bind.execute( + sa.text( + "select pg_get_constraintdef(oid) from pg_constraint " + "where conrelid='audit_events'::regclass " + "and conname='ck_audit_events_authority_privacy_bounds'" + ) + ).scalar_one() + expanded = _AUDIT_RESOURCE_MARKER + _AUDIT_RESOURCE_ADDITION + source, target = ( + (_AUDIT_RESOURCE_MARKER, expanded) + if add + else (expanded, _AUDIT_RESOURCE_MARKER) + ) + if definition.count(source) != 1 or (add and expanded in definition): + raise RuntimeError("unexpected authority privacy constraint") + op.drop_constraint("authority_privacy_bounds", "audit_events", type_="check") + op.execute( + "alter table audit_events add constraint " + f"ck_audit_events_authority_privacy_bounds {definition.replace(source, target, 1)}" + ) + + +def _add_columns(table: str, columns: tuple[tuple[str, sa.types.TypeEngine], ...]) -> None: + for name, column_type in columns: + op.add_column(table, sa.Column(name, column_type)) + + +def _add_common_foreign_keys(table: str, prefix: str, *, approval: bool = False) -> None: + stem = "approval" if approval else "creation" + actor = "approved_by_actor_profile_id" if approval else "created_by_actor_profile_id" + link = "approved_via_identity_link_id" if approval else "created_via_identity_link_id" + grant = ( + "approved_by_admin_role_grant_id" if approval else "created_by_admin_role_grant_id" + ) + project = "approval_scope_project_id" if approval else "creation_scope_project_id" + decision = "approval_decision_event_id" if approval else "creation_decision_event_id" + for suffix, column, remote_table in ( + ("actor", actor, "actor_profiles"), + ("link", link, "actor_identity_links"), + ("grant", grant, "admin_role_grants"), + ("project", project, "projects"), + ("decision", decision, "audit_events"), + ): + op.create_foreign_key( + f"fk_{prefix}_{stem}_{suffix}", table, remote_table, [column], ["id"] + ) + + +def upgrade() -> None: + """Install nullable provenance and replay custody without activation.""" + op.execute("lock table audit_events in access exclusive mode") + _rewrite_audit_resource(add=True) + _add_columns( + "submission_artifact_policies", + (*_SUBMISSION_CREATION_COLUMNS, *_SUBMISSION_APPROVAL_COLUMNS), + ) + _add_common_foreign_keys("submission_artifact_policies", "submission_policy") + _add_common_foreign_keys( + "submission_artifact_policies", "submission_policy", approval=True + ) + _add_columns("effective_project_submission_artifact_policies", _APPROVAL_OUTPUT_COLUMNS) + _add_common_foreign_keys("effective_project_submission_artifact_policies", "effective_policy") + _add_columns("pre_submit_checker_policies", _APPROVAL_OUTPUT_COLUMNS) + _add_common_foreign_keys("pre_submit_checker_policies", "pre_submit_policy") + + op.create_check_constraint( + "ck_submission_policy_creation_authority_shape", + "submission_artifact_policies", + "(created_by_actor_profile_id is null and created_via_identity_link_id is null " + "and created_by_admin_role_grant_id is null and created_by_service_identity is null " + "and creation_scope_type is null and creation_scope_project_id is null " + "and creation_action_id is null and creation_decision_event_id is null) or " + "(created_by_actor_profile_id is not null and created_via_identity_link_id is not null " + "and creation_scope_project_id is not null and creation_decision_event_id is not null " + "and creation_action_id in ('project.submission_artifact_policy.create'," + "'project.submission_artifact_policy.derive'," + "'project.submission_artifact_policy.update') and " + "((created_by_admin_role_grant_id is not null and created_by_service_identity is null " + "and creation_scope_type in ('system','project')) or " + "(created_by_admin_role_grant_id is null " + "and created_by_service_identity='workstream.project.setup' " + "and creation_scope_type='service' " + "and creation_action_id='project.submission_artifact_policy.derive')))", + ) + op.create_check_constraint( + "ck_submission_policy_approval_authority_shape", + "submission_artifact_policies", + "(approved_by_actor_profile_id is null and approved_via_identity_link_id is null " + "and approved_by_admin_role_grant_id is null and approval_scope_type is null " + "and approval_scope_project_id is null and approval_action_id is null " + "and approval_decision_event_id is null) or " + "(approved_by_actor_profile_id is not null and approved_via_identity_link_id is not null " + "and approved_by_admin_role_grant_id is not null " + "and approval_scope_type in ('system','project') " + "and approval_scope_project_id is not null " + "and approval_action_id='project.submission_artifact_policy.approve' " + "and approval_decision_event_id is not null)", + ) + output_shape = ( + "(created_by_actor_profile_id is null and created_via_identity_link_id is null " + "and created_by_admin_role_grant_id is null and creation_scope_type is null " + "and creation_scope_project_id is null and creation_action_id is null " + "and creation_decision_event_id is null) or " + "(created_by_actor_profile_id is not null and created_via_identity_link_id is not null " + "and created_by_admin_role_grant_id is not null " + "and creation_scope_type in ('system','project') " + "and creation_scope_project_id is not null " + "and creation_action_id='project.submission_artifact_policy.approve' " + "and creation_decision_event_id is not null)" + ) + op.create_check_constraint( + "ck_effective_submission_policy_authority_shape", + "effective_project_submission_artifact_policies", + output_shape, + ) + op.create_check_constraint( + "ck_pre_submit_policy_authority_shape", + "pre_submit_checker_policies", + output_shape, + ) + + op.create_table( + "submission_policy_mutation_idempotency_records", + sa.Column("id", sa.Uuid(), primary_key=True), + sa.Column("actor_profile_id", sa.String(36), sa.ForeignKey("actor_profiles.id"), nullable=False), + sa.Column("identity_link_id", sa.String(36), sa.ForeignKey("actor_identity_links.id"), nullable=False), + sa.Column("service_identity", sa.String(160)), + sa.Column("action_id", sa.String(160), nullable=False), + sa.Column("idempotency_key", sa.Uuid()), + sa.Column("request_digest", sa.String(71), nullable=False), + sa.Column("resource_context_digest", sa.String(71), nullable=False), + sa.Column("resource_context_json", sa.JSON(), nullable=False), + sa.Column("operation_id", sa.Uuid(), nullable=False), + sa.Column("project_id", sa.String(36), sa.ForeignKey("projects.id"), nullable=False), + sa.Column("guide_id", sa.String(36), sa.ForeignKey("project_guides.id"), nullable=False), + sa.Column("source_snapshot_id", sa.String(36), sa.ForeignKey("guide_source_snapshots.id"), nullable=False), + sa.Column("policy_id", sa.String(36), nullable=False), + sa.Column("setup_run_id", sa.String(36), sa.ForeignKey("project_setup_runs.id")), + sa.Column("setup_generation", sa.BigInteger(), nullable=False), + sa.Column("setup_task_id", sa.Uuid()), + sa.Column("correlation_id", sa.Uuid()), + sa.Column("status", sa.String(16), nullable=False), + sa.Column("response_json", sa.JSON()), + sa.Column( + "committed_policy_id", + sa.String(36), + sa.ForeignKey("submission_artifact_policies.id"), + ), + sa.Column( + "committed_effective_policy_id", + sa.String(36), + sa.ForeignKey("effective_project_submission_artifact_policies.id"), + ), + sa.Column( + "committed_pre_submit_policy_id", + sa.String(36), + sa.ForeignKey("pre_submit_checker_policies.id"), + ), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False), + sa.Column("committed_at", sa.DateTime(timezone=True)), + sa.UniqueConstraint("operation_id", name="uq_submission_policy_operation_identity"), + sa.CheckConstraint( + "action_id in ('project.submission_artifact_policy.create'," + "'project.submission_artifact_policy.derive'," + "'project.submission_artifact_policy.update'," + "'project.submission_artifact_policy.approve')", + name="ck_submission_policy_mutation_action", + ), + sa.CheckConstraint( + "request_digest ~ '^sha256:[0-9a-f]{64}$' and " + "resource_context_digest ~ '^sha256:[0-9a-f]{64}$'", + name="ck_submission_policy_mutation_digests", + ), + sa.CheckConstraint("setup_generation > 0", name="ck_submission_policy_generation"), + sa.CheckConstraint( + "(service_identity is null and idempotency_key is not null " + "and setup_run_id is null and setup_task_id is null and correlation_id is null) or " + "(service_identity='workstream.project.setup' and idempotency_key is null " + "and action_id='project.submission_artifact_policy.derive' " + "and setup_run_id is not null and setup_task_id is not null " + "and correlation_id is not null)", + name="ck_submission_policy_replay_principal_shape", + ), + sa.CheckConstraint( + "status in ('pending','committed')", name="ck_submission_policy_replay_status" + ), + sa.CheckConstraint( + "(status='pending' and response_json is null and committed_at is null " + "and committed_policy_id is null and committed_effective_policy_id is null " + "and committed_pre_submit_policy_id is null) or " + "(status='committed' and response_json is not null and committed_at is not null " + "and committed_policy_id is not null and " + "((action_id='project.submission_artifact_policy.approve' " + "and committed_effective_policy_id is not null " + "and committed_pre_submit_policy_id is not null) or " + "(action_id<>'project.submission_artifact_policy.approve' " + "and committed_effective_policy_id is null " + "and committed_pre_submit_policy_id is null)))", + name="ck_submission_policy_replay_state_shape", + ), + ) + op.create_index( + "uq_submission_policy_human_replay_namespace", + "submission_policy_mutation_idempotency_records", + ["actor_profile_id", "idempotency_key"], + unique=True, + postgresql_where=sa.text("service_identity is null"), + ) + op.create_index( + "uq_submission_policy_service_replay_namespace", + "submission_policy_mutation_idempotency_records", + [ + "actor_profile_id", + "setup_run_id", + "setup_generation", + "setup_task_id", + "correlation_id", + "action_id", + ], + unique=True, + postgresql_where=sa.text("service_identity is not null"), + ) + op.execute( + """ + create function reject_submission_policy_replay_mutation() returns trigger + language plpgsql as $$ + begin + if tg_op = 'DELETE' then + raise exception 'submission-policy replay rows cannot be deleted'; + end if; + if old.status <> 'pending' or new.status <> 'committed' + or (new.id,new.actor_profile_id,new.identity_link_id,new.service_identity, + new.action_id,new.idempotency_key,new.request_digest, + new.resource_context_digest,new.resource_context_json,new.operation_id, + new.project_id,new.guide_id,new.source_snapshot_id,new.policy_id, + new.setup_run_id,new.setup_generation,new.setup_task_id, + new.correlation_id,new.created_at) + is distinct from + (old.id,old.actor_profile_id,old.identity_link_id,old.service_identity, + old.action_id,old.idempotency_key,old.request_digest, + old.resource_context_digest,old.resource_context_json,old.operation_id, + old.project_id,old.guide_id,old.source_snapshot_id,old.policy_id, + old.setup_run_id,old.setup_generation,old.setup_task_id, + old.correlation_id,old.created_at) + then + raise exception 'invalid submission-policy replay mutation'; + end if; + return new; + end $$ + """ + ) + op.execute( + "create trigger trg_submission_policy_replay_immutable before update or delete " + "on submission_policy_mutation_idempotency_records for each row " + "execute function reject_submission_policy_replay_mutation()" + ) + op.execute( + """ + create function reject_submission_policy_replay_truncate() returns trigger + language plpgsql as $$ begin + raise exception 'submission-policy replay rows cannot be truncated'; + end $$ + """ + ) + op.execute( + "create trigger trg_submission_policy_replay_no_truncate before truncate " + "on submission_policy_mutation_idempotency_records for each statement " + "execute function reject_submission_policy_replay_truncate()" + ) + op.execute( + """ + create function protect_submission_policy_creation_provenance() returns trigger + language plpgsql as $$ + begin + if old.creation_action_id is not null and + (new.created_by_actor_profile_id,new.created_via_identity_link_id, + new.created_by_admin_role_grant_id,new.created_by_service_identity, + new.creation_scope_type,new.creation_scope_project_id, + new.creation_action_id,new.creation_decision_event_id) + is distinct from + (old.created_by_actor_profile_id,old.created_via_identity_link_id, + old.created_by_admin_role_grant_id,old.created_by_service_identity, + old.creation_scope_type,old.creation_scope_project_id, + old.creation_action_id,old.creation_decision_event_id) then + raise exception 'submission-policy creation provenance is immutable' + using errcode='23514'; + end if; + return new; + end $$ + """ + ) + op.execute( + "create trigger submission_policy_creation_provenance_immutable before update on " + "submission_artifact_policies for each row " + "execute function protect_submission_policy_creation_provenance()" + ) + op.execute( + """ + create function protect_submission_policy_approval_provenance() returns trigger + language plpgsql as $$ + begin + if old.approval_action_id is not null and + (new.approved_by_actor_profile_id,new.approved_via_identity_link_id, + new.approved_by_admin_role_grant_id,new.approval_scope_type, + new.approval_scope_project_id,new.approval_action_id, + new.approval_decision_event_id) + is distinct from + (old.approved_by_actor_profile_id,old.approved_via_identity_link_id, + old.approved_by_admin_role_grant_id,old.approval_scope_type, + old.approval_scope_project_id,old.approval_action_id, + old.approval_decision_event_id) then + raise exception 'submission-policy approval provenance is immutable' + using errcode='23514'; + end if; + return new; + end $$ + """ + ) + op.execute( + "create trigger submission_policy_approval_provenance_immutable before update on " + "submission_artifact_policies for each row " + "execute function protect_submission_policy_approval_provenance()" + ) + op.execute( + """ + create function protect_submission_policy_output_provenance() returns trigger + language plpgsql as $$ + begin + if old.creation_action_id is not null and + (new.created_by_actor_profile_id,new.created_via_identity_link_id, + new.created_by_admin_role_grant_id,new.creation_scope_type, + new.creation_scope_project_id,new.creation_action_id, + new.creation_decision_event_id) + is distinct from + (old.created_by_actor_profile_id,old.created_via_identity_link_id, + old.created_by_admin_role_grant_id,old.creation_scope_type, + old.creation_scope_project_id,old.creation_action_id, + old.creation_decision_event_id) then + raise exception 'submission-policy output provenance is immutable' + using errcode='23514'; + end if; + return new; + end $$ + """ + ) + for trigger, table in ( + ( + "effective_submission_policy_provenance_immutable", + "effective_project_submission_artifact_policies", + ), + ("pre_submit_policy_provenance_immutable", "pre_submit_checker_policies"), + ): + op.execute( + f"create trigger {trigger} before update on {table} for each row " + "execute function protect_submission_policy_output_provenance()" + ) + op.execute( + """ + create function validate_submission_policy_creation_custody() returns trigger + language plpgsql as $$ + declare reservation submission_policy_mutation_idempotency_records%rowtype; + evidence audit_events%rowtype; + begin + if new.creation_action_id is null then return null; end if; + select * into reservation from submission_policy_mutation_idempotency_records + where committed_policy_id=new.id and action_id=new.creation_action_id + and status='committed'; + if reservation.id is null + or reservation.actor_profile_id + is distinct from new.created_by_actor_profile_id + or reservation.identity_link_id + is distinct from new.created_via_identity_link_id + or reservation.service_identity + is distinct from new.created_by_service_identity + or reservation.project_id is distinct from new.project_id + or reservation.policy_id is distinct from new.id + or reservation.guide_id is distinct from new.guide_id + or reservation.source_snapshot_id is distinct from new.source_snapshot_id + or reservation.resource_context_json->>'guide_version' + is distinct from new.guide_version then + raise exception 'submission-policy creation custody mismatch' using errcode='23514'; + end if; + select * into evidence from audit_events where id=new.creation_decision_event_id; + if evidence.id is null or evidence.event_domain is distinct from 'authority' + or evidence.event_type is distinct from 'SensitiveAuthorizationAllowed' + or evidence.denial_code is not null + or evidence.actor_ref_kind is distinct from 'actor_profile' + or evidence.actor_id is distinct from new.created_by_actor_profile_id + or evidence.matched_grant_id + is distinct from new.created_by_admin_role_grant_id::text + or evidence.permission_id is distinct from 'project.effective_policy.manage' + or evidence.action_id is distinct from new.creation_action_id + or evidence.resource_type + is distinct from 'project_submission_artifact_policy_mutation' + or evidence.resource_id is distinct from new.id + or evidence.project_id is distinct from reservation.project_id + or evidence.target_ref_kind is distinct from 'project' + or evidence.target_ref_id is distinct from reservation.project_id + or evidence.after_facts->>'allowed' is distinct from 'true' + or evidence.after_facts->>'resource_context_digest' + is distinct from reservation.resource_context_digest then + raise exception 'submission-policy creation evidence mismatch' + using errcode='23514'; + end if; + return null; + end $$ + """ + ) + op.execute( + "create constraint trigger submission_policy_creation_custody " + "after insert or update on submission_artifact_policies " + "deferrable initially deferred for each row " + "execute function validate_submission_policy_creation_custody()" + ) + op.execute( + """ + create function validate_submission_policy_authority_custody() returns trigger + language plpgsql as $$ + declare reservation submission_policy_mutation_idempotency_records%rowtype; + evidence audit_events%rowtype; + actor_id varchar; link_id varchar; grant_id uuid; service_id varchar; + action_value varchar; decision_id varchar; product_project varchar; + product_id varchar; approval_outputs_valid boolean; + begin + if tg_table_name='submission_policy_mutation_idempotency_records' then + if new.status='pending' then return null; end if; + reservation:=new; + select project_id,id, + case when reservation.action_id='project.submission_artifact_policy.approve' + then approved_by_actor_profile_id else created_by_actor_profile_id end, + case when reservation.action_id='project.submission_artifact_policy.approve' + then approved_via_identity_link_id else created_via_identity_link_id end, + case when reservation.action_id='project.submission_artifact_policy.approve' + then approved_by_admin_role_grant_id + else created_by_admin_role_grant_id end, + case when reservation.action_id='project.submission_artifact_policy.approve' + then null else created_by_service_identity end, + case when reservation.action_id='project.submission_artifact_policy.approve' + then approval_action_id else creation_action_id end, + case when reservation.action_id='project.submission_artifact_policy.approve' + then approval_decision_event_id else creation_decision_event_id end + into product_project,product_id,actor_id,link_id,grant_id,service_id, + action_value,decision_id + from submission_artifact_policies where id=reservation.committed_policy_id; + if reservation.action_id='project.submission_artifact_policy.approve' then + select exists( + select 1 + from submission_artifact_policies s + join effective_project_submission_artifact_policies e + on e.id=reservation.committed_effective_policy_id + and e.submission_artifact_policy_id=s.id + and e.submission_artifact_policy_hash=s.policy_hash + join pre_submit_checker_policies p + on p.id=reservation.committed_pre_submit_policy_id + and p.project_id=e.project_id + where s.id=reservation.committed_policy_id + and s.id=reservation.policy_id + and s.guide_id=reservation.guide_id + and s.source_snapshot_id=reservation.source_snapshot_id + and s.guide_version=reservation.resource_context_json->>'guide_version' + and s.policy_hash=reservation.resource_context_json->>'policy_digest' + and e.effective_policy_hash= + reservation.resource_context_json->>'effective_output_digest' + and p.compiled_bundle_hash= + reservation.resource_context_json->>'compiled_pre_submit_output_digest' + and e.project_id=reservation.project_id + and e.guide_id=s.guide_id and p.guide_id=s.guide_id + and e.guide_version=s.guide_version + and p.guide_version=s.guide_version + and e.source_snapshot_id=s.source_snapshot_id + and p.source_snapshot_id=s.source_snapshot_id + and e.source_snapshot_hash=s.source_snapshot_hash + and p.source_snapshot_hash=s.source_snapshot_hash + and e.submission_artifact_policy_id=reservation.committed_policy_id + and p.effective_policy_id=e.id + and p.effective_policy_hash=e.effective_policy_hash + and e.created_by_actor_profile_id=reservation.actor_profile_id + and p.created_by_actor_profile_id=reservation.actor_profile_id + and e.created_via_identity_link_id=reservation.identity_link_id + and p.created_via_identity_link_id=reservation.identity_link_id + and e.created_by_admin_role_grant_id=grant_id + and p.created_by_admin_role_grant_id=grant_id + and e.creation_scope_project_id=reservation.project_id + and p.creation_scope_project_id=reservation.project_id + and e.creation_action_id=reservation.action_id + and p.creation_action_id=reservation.action_id + and e.creation_decision_event_id=decision_id + and p.creation_decision_event_id=decision_id + ) into approval_outputs_valid; + if approval_outputs_valid is not true then + raise exception 'submission-policy approval output custody mismatch' + using errcode='23514'; + end if; + end if; + elsif tg_table_name='submission_artifact_policies' then + if new.creation_action_id is null and new.approval_action_id is null then + return null; + end if; + if new.approval_action_id is not null then + select * into reservation from submission_policy_mutation_idempotency_records + where committed_policy_id=new.id and action_id=new.approval_action_id + and status='committed'; + actor_id:=new.approved_by_actor_profile_id; + link_id:=new.approved_via_identity_link_id; + grant_id:=new.approved_by_admin_role_grant_id; + service_id:=null; action_value:=new.approval_action_id; + decision_id:=new.approval_decision_event_id; + else + select * into reservation from submission_policy_mutation_idempotency_records + where committed_policy_id=new.id and action_id=new.creation_action_id + and status='committed'; + actor_id:=new.created_by_actor_profile_id; + link_id:=new.created_via_identity_link_id; + grant_id:=new.created_by_admin_role_grant_id; + service_id:=new.created_by_service_identity; + action_value:=new.creation_action_id; + decision_id:=new.creation_decision_event_id; + end if; + product_project:=new.project_id; product_id:=new.id; + elsif tg_table_name='effective_project_submission_artifact_policies' then + if new.creation_action_id is null then return null; end if; + select * into reservation from submission_policy_mutation_idempotency_records + where committed_effective_policy_id=new.id and status='committed'; + actor_id:=new.created_by_actor_profile_id; + link_id:=new.created_via_identity_link_id; + grant_id:=new.created_by_admin_role_grant_id; + service_id:=null; action_value:=new.creation_action_id; + decision_id:=new.creation_decision_event_id; + product_project:=new.project_id; product_id:=reservation.committed_policy_id; + else + if new.creation_action_id is null then return null; end if; + select * into reservation from submission_policy_mutation_idempotency_records + where committed_pre_submit_policy_id=new.id and status='committed'; + actor_id:=new.created_by_actor_profile_id; + link_id:=new.created_via_identity_link_id; + grant_id:=new.created_by_admin_role_grant_id; + service_id:=null; action_value:=new.creation_action_id; + decision_id:=new.creation_decision_event_id; + product_project:=new.project_id; product_id:=reservation.committed_policy_id; + end if; + if reservation.id is null or product_id is null + or reservation.actor_profile_id is distinct from actor_id + or reservation.identity_link_id is distinct from link_id + or reservation.action_id is distinct from action_value + or reservation.project_id is distinct from product_project + or reservation.committed_policy_id is distinct from product_id + or reservation.service_identity is distinct from service_id then + raise exception 'submission-policy mutation custody mismatch' using errcode='23514'; + end if; + select * into evidence from audit_events where id=decision_id; + if evidence.id is null or evidence.event_domain is distinct from 'authority' + or evidence.event_type is distinct from 'SensitiveAuthorizationAllowed' + or evidence.denial_code is not null + or evidence.actor_ref_kind is distinct from 'actor_profile' + or evidence.actor_id is distinct from actor_id + or evidence.matched_grant_id is distinct from grant_id::text + or evidence.permission_id is distinct from 'project.effective_policy.manage' + or evidence.action_id is distinct from action_value + or evidence.resource_type + is distinct from 'project_submission_artifact_policy_mutation' + or evidence.resource_id is distinct from product_id + or evidence.project_id is distinct from reservation.project_id + or evidence.target_ref_kind is distinct from 'project' + or evidence.target_ref_id is distinct from reservation.project_id + or evidence.after_facts->>'allowed' is distinct from 'true' + or evidence.after_facts->>'resource_context_digest' + is distinct from reservation.resource_context_digest then + raise exception 'submission-policy authorization evidence mismatch' + using errcode='23514'; + end if; + return null; + end $$ + """ + ) + for trigger, table in ( + ("submission_policy_product_custody", "submission_artifact_policies"), + ("effective_submission_policy_custody", "effective_project_submission_artifact_policies"), + ("pre_submit_policy_custody", "pre_submit_checker_policies"), + ("submission_policy_replay_custody", "submission_policy_mutation_idempotency_records"), + ): + op.execute( + f"create constraint trigger {trigger} after insert or update on {table} " + "deferrable initially deferred for each row " + "execute function validate_submission_policy_authority_custody()" + ) + + +def downgrade() -> None: + """Remove the inactive foundation only when no replay/provenance exists.""" + connection = op.get_bind() + replay_count = connection.execute( + sa.text("select count(*) from submission_policy_mutation_idempotency_records") + ).scalar_one() + provenance_count = connection.execute( + sa.text( + "select " + "(select count(*) from submission_artifact_policies where " + "created_by_actor_profile_id is not null or approved_by_actor_profile_id is not null) + " + "(select count(*) from effective_project_submission_artifact_policies where " + "created_by_actor_profile_id is not null) + " + "(select count(*) from pre_submit_checker_policies where " + "created_by_actor_profile_id is not null)" + ) + ).scalar_one() + audit_count = connection.execute( + sa.text( + "select count(*) from audit_events where " + "resource_type='project_submission_artifact_policy_mutation'" + ) + ).scalar_one() + if replay_count or provenance_count or audit_count: + raise RuntimeError("cannot downgrade submission-policy authority with evidence") + + for trigger, table in ( + ("submission_policy_replay_custody", "submission_policy_mutation_idempotency_records"), + ("pre_submit_policy_custody", "pre_submit_checker_policies"), + ("effective_submission_policy_custody", "effective_project_submission_artifact_policies"), + ("submission_policy_product_custody", "submission_artifact_policies"), + ): + op.execute(f"drop trigger {trigger} on {table}") + op.execute("drop function validate_submission_policy_authority_custody()") + op.execute( + "drop trigger submission_policy_creation_custody on submission_artifact_policies" + ) + op.execute("drop function validate_submission_policy_creation_custody()") + for trigger, table in ( + ("pre_submit_policy_provenance_immutable", "pre_submit_checker_policies"), + ( + "effective_submission_policy_provenance_immutable", + "effective_project_submission_artifact_policies", + ), + ): + op.execute(f"drop trigger {trigger} on {table}") + op.execute("drop function protect_submission_policy_output_provenance()") + op.execute( + "drop trigger submission_policy_approval_provenance_immutable " + "on submission_artifact_policies" + ) + op.execute("drop function protect_submission_policy_approval_provenance()") + op.execute( + "drop trigger submission_policy_creation_provenance_immutable " + "on submission_artifact_policies" + ) + op.execute("drop function protect_submission_policy_creation_provenance()") + + op.execute( + "drop trigger trg_submission_policy_replay_no_truncate " + "on submission_policy_mutation_idempotency_records" + ) + op.execute("drop function reject_submission_policy_replay_truncate()") + op.execute( + "drop trigger trg_submission_policy_replay_immutable " + "on submission_policy_mutation_idempotency_records" + ) + op.execute("drop function reject_submission_policy_replay_mutation()") + op.drop_table("submission_policy_mutation_idempotency_records") + + for table, constraint in ( + ("pre_submit_checker_policies", "ck_pre_submit_policy_authority_shape"), + ( + "effective_project_submission_artifact_policies", + "ck_effective_submission_policy_authority_shape", + ), + ("submission_artifact_policies", "ck_submission_policy_approval_authority_shape"), + ("submission_artifact_policies", "ck_submission_policy_creation_authority_shape"), + ): + op.drop_constraint(constraint, table, type_="check") + + for table, prefix, columns, approval in ( + ("pre_submit_checker_policies", "pre_submit_policy", _APPROVAL_OUTPUT_COLUMNS, False), + ( + "effective_project_submission_artifact_policies", + "effective_policy", + _APPROVAL_OUTPUT_COLUMNS, + False, + ), + ( + "submission_artifact_policies", + "submission_policy", + _SUBMISSION_APPROVAL_COLUMNS, + True, + ), + ( + "submission_artifact_policies", + "submission_policy", + _SUBMISSION_CREATION_COLUMNS, + False, + ), + ): + stem = "approval" if approval else "creation" + for suffix in ("actor", "link", "grant", "project", "decision"): + op.drop_constraint(f"fk_{prefix}_{stem}_{suffix}", table, type_="foreignkey") + for name, _column_type in reversed(columns): + op.drop_column(table, name) + op.execute("lock table audit_events in access exclusive mode") + _rewrite_audit_resource(add=False) diff --git a/backend/app/modules/audit/schemas.py b/backend/app/modules/audit/schemas.py index 42876580e..f00050619 100644 --- a/backend/app/modules/audit/schemas.py +++ b/backend/app/modules/audit/schemas.py @@ -35,7 +35,7 @@ _RESOURCE_TYPES = frozenset( """actor_profile actor_identity_link admin_role_grant project qualification_snapshot project_role_grant task submission review contribution compensation_award compensation_delivery operations - audit_event project_create_operation""".split() + audit_event project_create_operation project_submission_artifact_policy_mutation""".split() ) _UUID_TARGET_KINDS = frozenset( { diff --git a/backend/app/modules/authorization/kernel.py b/backend/app/modules/authorization/kernel.py index 00ccce1eb..13c621ee0 100644 --- a/backend/app/modules/authorization/kernel.py +++ b/backend/app/modules/authorization/kernel.py @@ -67,6 +67,7 @@ ProjectCreateResourceContext, ProjectGuideMutationPrepareDenialResourceContext, ProjectGuideSufficiencyMutationResourceContext, + ProjectSubmissionArtifactPolicyMutationResourceContext, ProjectReadResourceContext, ProjectDiagnosticReadResourceContext, ProjectPolicyReadResourceContext, @@ -104,8 +105,12 @@ 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_SUBMISSION_ARTIFACT_POLICY_APPROVE, } ) +_SUBMISSION_POLICY_MUTATIONS = frozenset(PROJECT_SUBMISSION_POLICY_TARGET_KIND_BY_ACTION) ServiceContextRevalidator = Callable[ [ServiceAuthorizationContext, ActionId], @@ -399,7 +404,12 @@ async def _prepare_prelocked( and scope.kind is PreparedAuthorityScopeKind.PROJECT and scope.project_id is not None ) - if not project_setup_sufficiency and ( + project_setup_submission_policy = ( + action_id is ActionId.PROJECT_SUBMISSION_ARTIFACT_POLICY_DERIVE + and scope.kind is PreparedAuthorityScopeKind.PROJECT + and scope.project_id is not None + ) + if not (project_setup_sufficiency or project_setup_submission_policy) and ( expected_resource is None or scope.kind is not PreparedAuthorityScopeKind.ARTIFACT_INTERNAL or scope.artifact_resource_type != expected_resource[0] @@ -420,7 +430,11 @@ async def _prepare_prelocked( transaction=transaction, context=context, action_id=action_id, - scope_project_id=(scope.project_id if project_setup_sufficiency else None), + scope_project_id=( + scope.project_id + if project_setup_sufficiency or project_setup_submission_policy + else None + ), matched_grant_id=None, matched_grant_scope_project_id=None, matched_grant_status=None, @@ -966,6 +980,16 @@ async def _require_prelocked( or resource_context.scope_project_id != authority.scope_project_id ): denial = AuthorizationDenialCode.RESOURCE_GUARD_DENIED + elif action_id is ActionId.PROJECT_SUBMISSION_ARTIFACT_POLICY_DERIVE: + if denial is None and ( + not isinstance( + resource_context, ProjectSubmissionArtifactPolicyMutationResourceContext + ) + or resource_context.execution_kind != "setup_service" + or resource_context.target_kind != "derive" + or resource_context.scope_project_id != authority.scope_project_id + ): + denial = AuthorizationDenialCode.RESOURCE_GUARD_DENIED elif denial is None and ( expected_resource is None or not isinstance(resource_context, expected_resource[1]) @@ -1024,6 +1048,16 @@ async def _require_prelocked( ) ): denial = AuthorizationDenialCode.RESOURCE_GUARD_DENIED + submission_policy_kind = PROJECT_SUBMISSION_POLICY_TARGET_KIND_BY_ACTION.get(action_id) + if ( + denial is None + and submission_policy_kind is not None + and ( + resource_context.target_kind != submission_policy_kind + or resource_context.execution_kind != "human" + ) + ): + denial = AuthorizationDenialCode.RESOURCE_GUARD_DENIED if denial is None and ( authority.matched_grant_id is None or authority.matched_grant_status != "active" ): @@ -1472,6 +1506,14 @@ async def _stage_decision( audit_resource_id = str(resource_context.resource_id) target_ref_kind = "project" target_ref_id = str(resource_context.requested_project_id) + elif decision.action_id in _SUBMISSION_POLICY_MUTATIONS and isinstance( + resource_context, ProjectSubmissionArtifactPolicyMutationResourceContext + ): + audit_project_id = str(resource_context.scope_project_id) + audit_resource_type = resource_context.resource_type + audit_resource_id = str(resource_context.resource_id) + target_ref_kind = "project" + target_ref_id = str(resource_context.scope_project_id) elif decision.action_id in _GUIDE_BOUND_PROJECT_MANAGER_MUTATIONS: if resource_context is not None: project_id = self._resource_project_id(resource_context) @@ -1495,6 +1537,7 @@ async def _stage_decision( ActionId.ARTIFACT_GUIDE_SOURCE_INGEST, ActionId.PROJECT_CREATE, *_GUIDE_BOUND_PROJECT_MANAGER_MUTATIONS, + *_SUBMISSION_POLICY_MUTATIONS, }: after_facts["resource_context_digest"] = decision.resource_context_digest try: diff --git a/backend/app/modules/authorization/prepared.py b/backend/app/modules/authorization/prepared.py index 678d4504b..29feac04b 100644 --- a/backend/app/modules/authorization/prepared.py +++ b/backend/app/modules/authorization/prepared.py @@ -36,6 +36,7 @@ AuthorizationDenialCode, AuthorizationDecision, AuthorizationResourceContext, + authorization_resource_digest, IdentityLinkStatus, PreparedAuthorizationHandleInvalid, PreparedAuthorizationInput, @@ -43,9 +44,11 @@ PreparedAuthorityScope, PreparedAuthorityScopeKind, PROJECT_MUTATION_RESOURCE_BY_ACTION, + PROJECT_SUBMISSION_POLICY_TARGET_KIND_BY_ACTION, ProjectCreateResourceContext, ProjectGuideMutationResourceContext, ProjectGuideSufficiencyMutationResourceContext, + ProjectSubmissionArtifactPolicyMutationResourceContext, ProjectGuideMutationPrepareDenialResourceContext, ProjectGuideSourceSnapshotMutationResourceContext, ProjectSetupServiceCustodyContext, @@ -142,6 +145,8 @@ class _PreparedAuthorizationBinding: sufficiency_stale_output_digest: str | None = None sufficiency_material_digest: str | None = None sufficiency_setup_service_custody: dict | None = None + submission_policy_context: dict | None = None + submission_policy_resource_digest: str | None = None @dataclass(slots=True) @@ -250,6 +255,18 @@ def _sufficiency_binding_matches( ) +def _submission_policy_binding_matches( + binding: _PreparedAuthorizationBinding, + 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) + ) + + _CONSUMED = _Consumed() @@ -344,6 +361,10 @@ async def consume( final_resource_context, ProjectGuideSufficiencyMutationResourceContext ) and not _sufficiency_binding_matches(issuance.binding, final_resource_context): raise PreparedAuthorizationHandleInvalid("invalid prepared authorization handle") + if isinstance( + final_resource_context, ProjectSubmissionArtifactPolicyMutationResourceContext + ) and not _submission_policy_binding_matches(issuance.binding, final_resource_context): + raise PreparedAuthorizationHandleInvalid("invalid prepared authorization handle") self._issued[handle] = _CONSUMED return await self._authorization._require_prelocked( self._consumer_token, @@ -378,6 +399,10 @@ async def deny_unsupported( final_resource_context, ProjectGuideSufficiencyMutationResourceContext ) and not _sufficiency_binding_matches(binding, final_resource_context): raise PreparedAuthorizationHandleInvalid("invalid prepared authorization handle") + if isinstance( + final_resource_context, ProjectSubmissionArtifactPolicyMutationResourceContext + ) and not _submission_policy_binding_matches(binding, final_resource_context): + raise PreparedAuthorizationHandleInvalid("invalid prepared authorization handle") await self._authorization._complete_prepared_denial( self._consumer_token, action_id, @@ -431,6 +456,8 @@ def _binding( policy_mutation_predecessor_id = None policy_mutation_guide_status = None sufficiency: dict[str, object] = {} + submission_policy_context: dict | None = None + submission_policy_resource_digest: str | None = None if action_id is ActionId.PROJECT_CREATE: try: operation_id = UUID(str(caller_input.request_value["operation_id"])) @@ -479,6 +506,48 @@ def _binding( ) ): raise PreparedAuthorizationHandleInvalid("invalid prepared authorization handle") + if action_id in { + ActionId.PROJECT_SUBMISSION_ARTIFACT_POLICY_CREATE, + ActionId.PROJECT_SUBMISSION_ARTIFACT_POLICY_DERIVE, + ActionId.PROJECT_SUBMISSION_ARTIFACT_POLICY_UPDATE, + ActionId.PROJECT_SUBMISSION_ARTIFACT_POLICY_APPROVE, + }: + try: + value = dict(caller_input.request_value) + for field in ( + "resource_id", + "operation_id", + "scope_project_id", + "guide_id", + "source_snapshot_id", + "policy_id", + ): + value[field] = UUID(str(value[field])) + raw_custody = value.get("setup_service_custody") + if raw_custody is not None: + custody = dict(raw_custody) + for field in ( + "setup_run_id", + "scope_project_id", + "guide_id", + "source_snapshot_id", + "task_id", + "correlation_id", + ): + custody[field] = UUID(str(custody[field])) + value["setup_service_custody"] = custody + resource = ProjectSubmissionArtifactPolicyMutationResourceContext.model_validate( + value + ) + except (KeyError, TypeError, ValueError) as exc: + raise PreparedAuthorizationHandleInvalid( + "invalid prepared authorization handle" + ) from exc + expected_target = PROJECT_SUBMISSION_POLICY_TARGET_KIND_BY_ACTION[action_id] + if resource.target_kind != expected_target: + raise PreparedAuthorizationHandleInvalid("invalid prepared authorization handle") + submission_policy_context = resource.model_dump(mode="json") + submission_policy_resource_digest = authorization_resource_digest(resource) if action_id in { ActionId.PROJECT_GUIDE_SUFFICIENCY_REPORT_CREATE, ActionId.PROJECT_GUIDE_SUFFICIENCY_RUN, @@ -619,6 +688,8 @@ def _binding( if sufficiency.get("setup_service_custody") is not None else None ), + submission_policy_context=submission_policy_context, + submission_policy_resource_digest=submission_policy_resource_digest, ) @staticmethod diff --git a/backend/app/modules/authorization/runtime.py b/backend/app/modules/authorization/runtime.py index 62e101620..5e9f0e36a 100644 --- a/backend/app/modules/authorization/runtime.py +++ b/backend/app/modules/authorization/runtime.py @@ -4,10 +4,10 @@ from types import MappingProxyType from enum import StrEnum -from typing import Literal +from typing import Annotated, Literal from uuid import NAMESPACE_URL, UUID, uuid5 -from pydantic import BaseModel, ConfigDict, Field, JsonValue, model_validator +from pydantic import BaseModel, ConfigDict, Field, JsonValue, field_validator, model_validator from app.core.hashing import canonical_json_hash from app.modules.actors.service_identities import ServiceIdentity @@ -772,6 +772,53 @@ def require_sufficiency_identity(self): return self +class SubmissionPolicyCompilationContext(BaseModel): + """Exact server-owned compiler and immutable catalogue facts.""" + + model_config = _STRICT_FROZEN + + compiler_version: str + bundle_schema_version: str + catalogue_id: str + catalogue_version: str + catalogue_schema_version: str + catalogue_manifest_sha256: str = Field(pattern=r"^sha256:[0-9a-f]{64}$") + ordered_entry_identities: tuple[str, ...] = Field(min_length=1) + ordered_entry_configuration_hashes: tuple[ + Annotated[str, Field(pattern=r"^sha256:[0-9a-f]{64}$")], ... + ] = Field(min_length=1) + disabled_catalogue_entry_ids: tuple[str, ...] + disabled_catalogue_config_digest: str = Field(pattern=r"^sha256:[0-9a-f]{64}$") + compiled_bundle_hash: str = Field(pattern=r"^sha256:[0-9a-f]{64}$") + effective_plan_hash: str | None = Field(default=None, pattern=r"^sha256:[0-9a-f]{64}$") + + @field_validator( + "ordered_entry_identities", + "ordered_entry_configuration_hashes", + "disabled_catalogue_entry_ids", + mode="before", + ) + @classmethod + def canonicalize_json_arrays(cls, value): + """Accept canonical JSON arrays while retaining immutable tuple storage.""" + return tuple(value) if isinstance(value, list) else value + + @model_validator(mode="after") + 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) + 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))) + or any(not value for value in self.ordered_entry_identities) + or any(not value for value in self.disabled_catalogue_entry_ids) + ): + raise ValueError("submission policy catalogue projection is invalid") + return self + + class ProjectSubmissionArtifactPolicyMutationResourceContext(BaseModel): """Canonical submission-artifact policy lineage for one mutation.""" @@ -779,6 +826,8 @@ class ProjectSubmissionArtifactPolicyMutationResourceContext(BaseModel): resource_type: Literal["project_submission_artifact_policy_mutation"] resource_id: UUID + operation_id: UUID + request_digest: str = Field(pattern=r"^sha256:[0-9a-f]{64}$") scope_project_id: UUID guide_id: UUID guide_version: str @@ -787,6 +836,7 @@ class ProjectSubmissionArtifactPolicyMutationResourceContext(BaseModel): target_kind: Literal["create", "derive", "update", "approve"] execution_kind: Literal["human", "setup_service"] policy_id: UUID + policy_version: str policy_generation: int = Field(ge=1) setup_generation: int = Field(ge=1) policy_status: str | None = None @@ -796,6 +846,7 @@ class ProjectSubmissionArtifactPolicyMutationResourceContext(BaseModel): compiled_pre_submit_output_digest: str | None = Field( default=None, pattern=r"^sha256:[0-9a-f]{64}$" ) + compilation: SubmissionPolicyCompilationContext | None = None setup_service_custody: ProjectSetupServiceCustodyContext | None = None @model_validator(mode="after") @@ -805,6 +856,25 @@ def require_submission_policy_identity(self): 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") + 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") + approval = self.target_kind == "approve" + if approval != ( + self.effective_output_digest is not None + and self.compiled_pre_submit_output_digest is not None + and self.compilation is not None + ): + raise ValueError("submission policy approval requires exact compilation outputs") + if not approval and any( + value is not None + for value in ( + self.effective_output_digest, + self.compiled_pre_submit_output_digest, + self.compilation, + ) + ): + raise ValueError("submission policy compilation facts require approval") service_execution = self.execution_kind == "setup_service" if service_execution != (self.setup_service_custody is not None): raise ValueError("policy service execution requires exact setup custody") @@ -1468,6 +1538,7 @@ class AuthorizationDecision(BaseModel): "project_revision_policy_mutation", "project_policy_mutation_request", "project_guide_sufficiency_mutation", + "project_submission_artifact_policy_mutation", "actor_identity_link", "system", "permission_catalogue", diff --git a/backend/app/modules/projects/models.py b/backend/app/modules/projects/models.py index 4996ab0a3..757ac22cd 100644 --- a/backend/app/modules/projects/models.py +++ b/backend/app/modules/projects/models.py @@ -225,6 +225,108 @@ class GuideSufficiencyMutationIdempotencyRecord(Base): committed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) +class SubmissionPolicyMutationIdempotencyRecord(Base): + """Replay custody for one authorized submission-policy mutation.""" + + __tablename__ = "submission_policy_mutation_idempotency_records" + __table_args__ = ( + Index( + "uq_submission_policy_human_replay_namespace", + "actor_profile_id", + "idempotency_key", + unique=True, + postgresql_where=text("service_identity is null"), + ), + Index( + "uq_submission_policy_service_replay_namespace", + "actor_profile_id", + "setup_run_id", + "setup_generation", + "setup_task_id", + "correlation_id", + "action_id", + unique=True, + postgresql_where=text("service_identity is not null"), + ), + UniqueConstraint("operation_id", name="uq_submission_policy_operation_identity"), + CheckConstraint( + "action_id in ('project.submission_artifact_policy.create'," + "'project.submission_artifact_policy.derive'," + "'project.submission_artifact_policy.update'," + "'project.submission_artifact_policy.approve')", + name="ck_submission_policy_mutation_action", + ), + CheckConstraint( + "request_digest ~ '^sha256:[0-9a-f]{64}$' and " + "resource_context_digest ~ '^sha256:[0-9a-f]{64}$'", + name="ck_submission_policy_mutation_digests", + ), + CheckConstraint("setup_generation > 0", name="ck_submission_policy_generation"), + CheckConstraint( + "(service_identity is null and idempotency_key is not null " + "and setup_run_id is null and setup_task_id is null and correlation_id is null) or " + "(service_identity = 'workstream.project.setup' and idempotency_key is null " + "and action_id = 'project.submission_artifact_policy.derive' " + "and setup_run_id is not null and setup_task_id is not null " + "and correlation_id is not null)", + name="ck_submission_policy_replay_principal_shape", + ), + CheckConstraint( + "status in ('pending','committed')", name="ck_submission_policy_replay_status" + ), + CheckConstraint( + "(status='pending' and response_json is null and committed_at is null " + "and committed_policy_id is null and committed_effective_policy_id is null " + "and committed_pre_submit_policy_id is null) or " + "(status='committed' and response_json is not null and committed_at is not null " + "and committed_policy_id is not null and " + "((action_id='project.submission_artifact_policy.approve' " + "and committed_effective_policy_id is not null " + "and committed_pre_submit_policy_id is not null) or " + "(action_id<>'project.submission_artifact_policy.approve' " + "and committed_effective_policy_id is null " + "and committed_pre_submit_policy_id is null)))", + name="ck_submission_policy_replay_state_shape", + ), + ) + + id: Mapped[UUID] = mapped_column(Uuid(), primary_key=True) + actor_profile_id: Mapped[str] = mapped_column(ForeignKey("actor_profiles.id"), nullable=False) + identity_link_id: Mapped[str] = mapped_column( + ForeignKey("actor_identity_links.id"), nullable=False + ) + service_identity: Mapped[str | None] = mapped_column(String(160)) + action_id: Mapped[str] = mapped_column(String(160), nullable=False) + idempotency_key: Mapped[UUID | None] = mapped_column(Uuid()) + request_digest: Mapped[str] = mapped_column(String(71), nullable=False) + resource_context_digest: Mapped[str] = mapped_column(String(71), nullable=False) + resource_context_json: Mapped[dict] = mapped_column(JSON, nullable=False) + operation_id: Mapped[UUID] = mapped_column(Uuid(), nullable=False) + project_id: Mapped[str] = mapped_column(ForeignKey("projects.id"), nullable=False) + guide_id: Mapped[str] = mapped_column(ForeignKey("project_guides.id"), nullable=False) + source_snapshot_id: Mapped[str] = mapped_column( + ForeignKey("guide_source_snapshots.id"), nullable=False + ) + policy_id: Mapped[str] = mapped_column(String(36), nullable=False) + setup_run_id: Mapped[str | None] = mapped_column(ForeignKey("project_setup_runs.id")) + setup_generation: Mapped[int] = mapped_column(BigInteger, nullable=False) + setup_task_id: Mapped[UUID | None] = mapped_column(Uuid()) + correlation_id: Mapped[UUID | None] = mapped_column(Uuid()) + status: Mapped[str] = mapped_column(String(16), nullable=False, default="pending") + response_json: Mapped[dict | None] = mapped_column(JSON) + committed_policy_id: Mapped[str | None] = mapped_column( + ForeignKey("submission_artifact_policies.id") + ) + committed_effective_policy_id: Mapped[str | None] = mapped_column( + ForeignKey("effective_project_submission_artifact_policies.id") + ) + committed_pre_submit_policy_id: Mapped[str | None] = mapped_column( + ForeignKey("pre_submit_checker_policies.id") + ) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) + committed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) + + class PolicyMutationIdempotencyRecord(Base): """Replay custody for one guide-bound policy replacement.""" @@ -1177,6 +1279,37 @@ class SubmissionArtifactPolicy(Base): "policy_version", name="uq_submission_artifact_policies_project_version_policy", ), + CheckConstraint( + "(created_by_actor_profile_id is null and created_via_identity_link_id is null " + "and created_by_admin_role_grant_id is null and created_by_service_identity is null " + "and creation_scope_type is null and creation_scope_project_id is null " + "and creation_action_id is null and creation_decision_event_id is null) or " + "(created_by_actor_profile_id is not null and created_via_identity_link_id is not null " + "and creation_scope_project_id is not null and creation_decision_event_id is not null " + "and creation_action_id in ('project.submission_artifact_policy.create'," + "'project.submission_artifact_policy.derive'," + "'project.submission_artifact_policy.update') and " + "((created_by_admin_role_grant_id is not null and created_by_service_identity is null " + "and creation_scope_type in ('system','project')) or " + "(created_by_admin_role_grant_id is null " + "and created_by_service_identity='workstream.project.setup' " + "and creation_scope_type='service' " + "and creation_action_id='project.submission_artifact_policy.derive')))", + name="ck_submission_policy_creation_authority_shape", + ), + CheckConstraint( + "(approved_by_actor_profile_id is null and approved_via_identity_link_id is null " + "and approved_by_admin_role_grant_id is null and approval_scope_type is null " + "and approval_scope_project_id is null and approval_action_id is null " + "and approval_decision_event_id is null) or " + "(approved_by_actor_profile_id is not null and approved_via_identity_link_id is not null " + "and approved_by_admin_role_grant_id is not null " + "and approval_scope_type in ('system','project') " + "and approval_scope_project_id is not null " + "and approval_action_id='project.submission_artifact_policy.approve' " + "and approval_decision_event_id is not null)", + name="ck_submission_policy_approval_authority_shape", + ), ) id: Mapped[str] = mapped_column(String(36), primary_key=True) @@ -1202,6 +1335,20 @@ class SubmissionArtifactPolicy(Base): derivation_agent_name: Mapped[str | None] = mapped_column(String(100)) derivation_agent_version: Mapped[str | None] = mapped_column(String(50)) created_by: Mapped[str] = mapped_column(String(100), nullable=False) + created_by_actor_profile_id: Mapped[str | None] = mapped_column( + ForeignKey("actor_profiles.id") + ) + created_via_identity_link_id: Mapped[str | None] = mapped_column( + ForeignKey("actor_identity_links.id") + ) + created_by_admin_role_grant_id: Mapped[UUID | None] = mapped_column( + Uuid(), ForeignKey("admin_role_grants.id") + ) + created_by_service_identity: Mapped[str | None] = mapped_column(String(160)) + creation_scope_type: Mapped[str | None] = mapped_column(String(16)) + creation_scope_project_id: Mapped[str | None] = mapped_column(ForeignKey("projects.id")) + creation_action_id: Mapped[str | None] = mapped_column(String(160)) + creation_decision_event_id: Mapped[str | None] = mapped_column(ForeignKey("audit_events.id")) created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) updated_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), @@ -1210,6 +1357,19 @@ class SubmissionArtifactPolicy(Base): ) approved_by_role: Mapped[str | None] = mapped_column(String(50)) approved_by_actor: Mapped[str | None] = mapped_column(String(100)) + approved_by_actor_profile_id: Mapped[str | None] = mapped_column( + ForeignKey("actor_profiles.id") + ) + approved_via_identity_link_id: Mapped[str | None] = mapped_column( + ForeignKey("actor_identity_links.id") + ) + approved_by_admin_role_grant_id: Mapped[UUID | None] = mapped_column( + Uuid(), ForeignKey("admin_role_grants.id") + ) + approval_scope_type: Mapped[str | None] = mapped_column(String(16)) + approval_scope_project_id: Mapped[str | None] = mapped_column(ForeignKey("projects.id")) + approval_action_id: Mapped[str | None] = mapped_column(String(160)) + approval_decision_event_id: Mapped[str | None] = mapped_column(ForeignKey("audit_events.id")) approved_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) supersedes_policy_id: Mapped[str | None] = mapped_column( ForeignKey("submission_artifact_policies.id"), @@ -1247,6 +1407,19 @@ class EffectiveProjectSubmissionArtifactPolicy(Base): "effective_policy_hash", name="uq_effective_project_submission_artifact_policies_id_hash", ), + CheckConstraint( + "(created_by_actor_profile_id is null and created_via_identity_link_id is null " + "and created_by_admin_role_grant_id is null and creation_scope_type is null " + "and creation_scope_project_id is null and creation_action_id is null " + "and creation_decision_event_id is null) or " + "(created_by_actor_profile_id is not null and created_via_identity_link_id is not null " + "and created_by_admin_role_grant_id is not null " + "and creation_scope_type in ('system','project') " + "and creation_scope_project_id is not null " + "and creation_action_id='project.submission_artifact_policy.approve' " + "and creation_decision_event_id is not null)", + name="ck_effective_submission_policy_authority_shape", + ), ) id: Mapped[str] = mapped_column(String(36), primary_key=True) @@ -1272,6 +1445,19 @@ class EffectiveProjectSubmissionArtifactPolicy(Base): effective_policy: Mapped[dict] = mapped_column(JSON, nullable=False) effective_policy_hash: Mapped[str] = mapped_column(String(71), nullable=False, index=True) created_by: Mapped[str] = mapped_column(String(100), nullable=False) + created_by_actor_profile_id: Mapped[str | None] = mapped_column( + ForeignKey("actor_profiles.id") + ) + created_via_identity_link_id: Mapped[str | None] = mapped_column( + ForeignKey("actor_identity_links.id") + ) + created_by_admin_role_grant_id: Mapped[UUID | None] = mapped_column( + Uuid(), ForeignKey("admin_role_grants.id") + ) + creation_scope_type: Mapped[str | None] = mapped_column(String(16)) + creation_scope_project_id: Mapped[str | None] = mapped_column(ForeignKey("projects.id")) + creation_action_id: Mapped[str | None] = mapped_column(String(160)) + creation_decision_event_id: Mapped[str | None] = mapped_column(ForeignKey("audit_events.id")) created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) supersedes_effective_policy_id: Mapped[str | None] = mapped_column( ForeignKey("effective_project_submission_artifact_policies.id"), @@ -1318,6 +1504,19 @@ class PreSubmitCheckerPolicy(Base): "compiled_bundle_hash", name="uq_pre_submit_checker_policies_id_compiled_bundle_hash", ), + CheckConstraint( + "(created_by_actor_profile_id is null and created_via_identity_link_id is null " + "and created_by_admin_role_grant_id is null and creation_scope_type is null " + "and creation_scope_project_id is null and creation_action_id is null " + "and creation_decision_event_id is null) or " + "(created_by_actor_profile_id is not null and created_via_identity_link_id is not null " + "and created_by_admin_role_grant_id is not null " + "and creation_scope_type in ('system','project') " + "and creation_scope_project_id is not null " + "and creation_action_id='project.submission_artifact_policy.approve' " + "and creation_decision_event_id is not null)", + name="ck_pre_submit_policy_authority_shape", + ), ) id: Mapped[str] = mapped_column(String(36), primary_key=True) @@ -1346,6 +1545,19 @@ class PreSubmitCheckerPolicy(Base): checker_names: Mapped[list[str]] = mapped_column(JSON, nullable=False, default=list) checker_configs: Mapped[dict] = mapped_column(JSON, nullable=False, default=dict) created_by: Mapped[str] = mapped_column(String(100), nullable=False) + created_by_actor_profile_id: Mapped[str | None] = mapped_column( + ForeignKey("actor_profiles.id") + ) + created_via_identity_link_id: Mapped[str | None] = mapped_column( + ForeignKey("actor_identity_links.id") + ) + created_by_admin_role_grant_id: Mapped[UUID | None] = mapped_column( + Uuid(), ForeignKey("admin_role_grants.id") + ) + creation_scope_type: Mapped[str | None] = mapped_column(String(16)) + creation_scope_project_id: Mapped[str | None] = mapped_column(ForeignKey("projects.id")) + creation_action_id: Mapped[str | None] = mapped_column(String(160)) + creation_decision_event_id: Mapped[str | None] = mapped_column(ForeignKey("audit_events.id")) created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) supersedes_pre_submit_checker_policy_id: Mapped[str | None] = mapped_column( ForeignKey("pre_submit_checker_policies.id"), diff --git a/backend/app/modules/projects/submission_policy_mutation_repository.py b/backend/app/modules/projects/submission_policy_mutation_repository.py new file mode 100644 index 000000000..b1662a12e --- /dev/null +++ b/backend/app/modules/projects/submission_policy_mutation_repository.py @@ -0,0 +1,232 @@ +"""Replay-only persistence for submission-policy mutations.""" + +from __future__ import annotations + +from datetime import UTC, datetime +from typing import Literal +from uuid import UUID, uuid4 + +from sqlalchemy import and_, select, update +from sqlalchemy.dialects.postgresql import insert +from sqlalchemy.ext.asyncio import AsyncSession + +from app.modules.projects.models import SubmissionPolicyMutationIdempotencyRecord +from app.modules.projects.repository import ProjectRepositoryIntegrityError + + +class SubmissionPolicyMutationReplayRepository: + """Own submission-policy replay rows in the caller transaction only.""" + + def __init__(self, session: AsyncSession) -> None: + self._session = session + + async def find_by_operation( + self, operation_id: UUID + ) -> SubmissionPolicyMutationIdempotencyRecord | None: + """Locate replay custody by its immutable public operation identity.""" + return await self._session.scalar( + select(SubmissionPolicyMutationIdempotencyRecord).where( + SubmissionPolicyMutationIdempotencyRecord.operation_id == operation_id + ) + ) + + async def _find_namespace( + self, + *, + actor_profile_id: str, + idempotency_key: UUID | None, + service_identity: str | None, + setup_run_id: str | None, + setup_generation: int, + setup_task_id: UUID | None, + correlation_id: UUID | None, + action_id: str, + ) -> SubmissionPolicyMutationIdempotencyRecord | None: + """Find the one human or fixed-service replay namespace.""" + namespace = ( + and_( + SubmissionPolicyMutationIdempotencyRecord.service_identity.is_(None), + SubmissionPolicyMutationIdempotencyRecord.idempotency_key == idempotency_key, + ) + if service_identity is None + else and_( + SubmissionPolicyMutationIdempotencyRecord.service_identity == service_identity, + SubmissionPolicyMutationIdempotencyRecord.setup_run_id == setup_run_id, + SubmissionPolicyMutationIdempotencyRecord.setup_generation == setup_generation, + SubmissionPolicyMutationIdempotencyRecord.setup_task_id == setup_task_id, + SubmissionPolicyMutationIdempotencyRecord.correlation_id == correlation_id, + SubmissionPolicyMutationIdempotencyRecord.action_id == action_id, + ) + ) + return await self._session.scalar( + select(SubmissionPolicyMutationIdempotencyRecord).where( + SubmissionPolicyMutationIdempotencyRecord.actor_profile_id == actor_profile_id, + namespace, + ) + ) + + async def reserve( + self, + *, + actor_profile_id: str, + identity_link_id: str, + service_identity: str | None, + action_id: str, + idempotency_key: UUID | None, + request_digest: str, + resource_context_digest: str, + resource_context_json: dict, + operation_id: UUID, + project_id: str, + guide_id: str, + source_snapshot_id: str, + policy_id: str, + setup_run_id: str | None, + setup_generation: int, + setup_task_id: UUID | None, + correlation_id: UUID | None, + ) -> tuple[ + Literal["claimed", "mismatch", "pending", "replayed"], + SubmissionPolicyMutationIdempotencyRecord, + ]: + """Reserve or classify one exact immutable replay namespace.""" + values = { + "id": uuid4(), + "actor_profile_id": actor_profile_id, + "identity_link_id": identity_link_id, + "service_identity": service_identity, + "action_id": action_id, + "idempotency_key": idempotency_key, + "request_digest": request_digest, + "resource_context_digest": resource_context_digest, + "resource_context_json": resource_context_json, + "operation_id": operation_id, + "project_id": project_id, + "guide_id": guide_id, + "source_snapshot_id": source_snapshot_id, + "policy_id": policy_id, + "setup_run_id": setup_run_id, + "setup_generation": setup_generation, + "setup_task_id": setup_task_id, + "correlation_id": correlation_id, + "status": "pending", + } + record_id = await self._session.scalar( + insert(SubmissionPolicyMutationIdempotencyRecord) + .values(**values) + .on_conflict_do_nothing() + .returning(SubmissionPolicyMutationIdempotencyRecord.id) + ) + claimed = record_id is not None + if record_id is None: + record = await self.find_by_operation(operation_id) + if record is None: + record = await self._find_namespace( + actor_profile_id=actor_profile_id, + idempotency_key=idempotency_key, + service_identity=service_identity, + setup_run_id=setup_run_id, + setup_generation=setup_generation, + setup_task_id=setup_task_id, + correlation_id=correlation_id, + action_id=action_id, + ) + if record is None: + raise ProjectRepositoryIntegrityError( + "submission-policy replay reservation disappeared" + ) + else: + record = await self._session.get( + SubmissionPolicyMutationIdempotencyRecord, record_id + ) + if record is None: + raise ProjectRepositoryIntegrityError( + "submission-policy replay reservation disappeared" + ) + if claimed: + return "claimed", record + if any( + ( + record.actor_profile_id != actor_profile_id, + record.identity_link_id != identity_link_id, + record.service_identity != service_identity, + record.action_id != action_id, + record.idempotency_key != idempotency_key, + record.request_digest != request_digest, + record.resource_context_digest != resource_context_digest, + record.resource_context_json != resource_context_json, + record.operation_id != operation_id, + record.project_id != project_id, + record.guide_id != guide_id, + record.source_snapshot_id != source_snapshot_id, + record.policy_id != policy_id, + record.setup_run_id != setup_run_id, + record.setup_generation != setup_generation, + record.setup_task_id != setup_task_id, + record.correlation_id != correlation_id, + ) + ): + return "mismatch", record + return ("replayed" if record.status == "committed" else "pending"), record + + async def complete( + self, + operation_id: UUID, + *, + actor_profile_id: str, + identity_link_id: str, + service_identity: str | None, + action_id: str, + idempotency_key: UUID | None, + request_digest: str, + resource_context_digest: str, + setup_run_id: str | None, + setup_generation: int, + setup_task_id: UUID | None, + correlation_id: UUID | None, + response_json: dict, + committed_policy_id: str, + committed_effective_policy_id: str | None = None, + committed_pre_submit_policy_id: str | None = None, + ) -> None: + """Complete one pending operation exactly once.""" + completed = await self._session.scalar( + update(SubmissionPolicyMutationIdempotencyRecord) + .where( + SubmissionPolicyMutationIdempotencyRecord.operation_id == operation_id, + SubmissionPolicyMutationIdempotencyRecord.actor_profile_id == actor_profile_id, + SubmissionPolicyMutationIdempotencyRecord.identity_link_id == identity_link_id, + SubmissionPolicyMutationIdempotencyRecord.service_identity + .is_(None) if service_identity is None else + SubmissionPolicyMutationIdempotencyRecord.service_identity == service_identity, + SubmissionPolicyMutationIdempotencyRecord.action_id == action_id, + SubmissionPolicyMutationIdempotencyRecord.idempotency_key + .is_(None) if idempotency_key is None else + SubmissionPolicyMutationIdempotencyRecord.idempotency_key == idempotency_key, + SubmissionPolicyMutationIdempotencyRecord.request_digest == request_digest, + SubmissionPolicyMutationIdempotencyRecord.resource_context_digest + == resource_context_digest, + SubmissionPolicyMutationIdempotencyRecord.setup_run_id + .is_(None) if setup_run_id is None else + SubmissionPolicyMutationIdempotencyRecord.setup_run_id == setup_run_id, + SubmissionPolicyMutationIdempotencyRecord.setup_generation == setup_generation, + SubmissionPolicyMutationIdempotencyRecord.setup_task_id + .is_(None) if setup_task_id is None else + SubmissionPolicyMutationIdempotencyRecord.setup_task_id == setup_task_id, + SubmissionPolicyMutationIdempotencyRecord.correlation_id + .is_(None) if correlation_id is None else + SubmissionPolicyMutationIdempotencyRecord.correlation_id == correlation_id, + SubmissionPolicyMutationIdempotencyRecord.status == "pending", + ) + .values( + status="committed", + response_json=response_json, + committed_policy_id=committed_policy_id, + committed_effective_policy_id=committed_effective_policy_id, + committed_pre_submit_policy_id=committed_pre_submit_policy_id, + committed_at=datetime.now(UTC), + ) + .returning(SubmissionPolicyMutationIdempotencyRecord.id) + ) + if completed is None: + raise ProjectRepositoryIntegrityError("invalid submission-policy replay completion") diff --git a/backend/app/modules/projects/submission_policy_mutation_service.py b/backend/app/modules/projects/submission_policy_mutation_service.py new file mode 100644 index 000000000..eec5267bb --- /dev/null +++ b/backend/app/modules/projects/submission_policy_mutation_service.py @@ -0,0 +1,153 @@ +"""Flush-only submission-policy authority foundation.""" + +from __future__ import annotations + +from dataclasses import dataclass +from uuid import UUID + +from sqlalchemy.ext.asyncio import AsyncSession + +from app.modules.authorization.runtime import ( + PROJECT_SUBMISSION_POLICY_TARGET_KIND_BY_ACTION, + ProjectSubmissionArtifactPolicyMutationResourceContext, + authorization_resource_digest, +) +from app.modules.authorization.catalogue import ActionId +from app.modules.projects.submission_policy_mutation_repository import ( + SubmissionPolicyMutationReplayRepository, +) + + +@dataclass(frozen=True, slots=True) +class SubmissionPolicyReplayFacts: + """Canonical replay facts supplied by later authorized mutation children.""" + + actor_profile_id: str + identity_link_id: str + service_identity: str | None + action_id: str + idempotency_key: UUID | None + request_digest: str + resource_context: ProjectSubmissionArtifactPolicyMutationResourceContext + operation_id: UUID + project_id: str + guide_id: str + source_snapshot_id: str + policy_id: str + setup_run_id: str | None + setup_generation: int + setup_task_id: UUID | None + correlation_id: UUID | 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) + + def _require_root_transaction(self) -> None: + transaction = self._session.sync_session.get_transaction() + if ( + transaction is None + or not transaction.is_active + or self._session.in_nested_transaction() + ): + raise RuntimeError("submission-policy mutation requires one root transaction") + + @staticmethod + def _replay_values(facts: SubmissionPolicyReplayFacts) -> dict[str, object]: + resource = facts.resource_context + try: + action = ActionId(facts.action_id) + target_kind = PROJECT_SUBMISSION_POLICY_TARGET_KIND_BY_ACTION[action] + except (KeyError, ValueError) as exc: + raise ValueError("invalid submission-policy replay action") from exc + if ( + resource.target_kind != target_kind + or facts.request_digest != resource.request_digest + or facts.operation_id != resource.operation_id + 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.setup_generation != resource.setup_generation + ): + raise ValueError("submission-policy replay facts do not match resource context") + custody = resource.setup_service_custody + if resource.execution_kind == "setup_service": + if ( + facts.service_identity != "workstream.project.setup" + or facts.idempotency_key is not None + or custody is None + or facts.setup_run_id != str(custody.setup_run_id) + or facts.setup_task_id != custody.task_id + or facts.correlation_id != custody.correlation_id + ): + raise ValueError("submission-policy service replay custody is invalid") + elif facts.idempotency_key is None or any( + value is not None + for value in ( + facts.service_identity, + facts.setup_run_id, + facts.setup_task_id, + facts.correlation_id, + ) + ): + raise ValueError("submission-policy human replay custody is invalid") + return { + "actor_profile_id": facts.actor_profile_id, + "identity_link_id": facts.identity_link_id, + "service_identity": facts.service_identity, + "action_id": facts.action_id, + "idempotency_key": facts.idempotency_key, + "request_digest": facts.request_digest, + "resource_context_digest": authorization_resource_digest(resource), + "resource_context_json": resource.model_dump(mode="json"), + "operation_id": facts.operation_id, + "project_id": facts.project_id, + "guide_id": facts.guide_id, + "source_snapshot_id": facts.source_snapshot_id, + "policy_id": facts.policy_id, + "setup_run_id": facts.setup_run_id, + "setup_generation": facts.setup_generation, + "setup_task_id": facts.setup_task_id, + "correlation_id": facts.correlation_id, + } + + async def reserve_replay(self, facts: SubmissionPolicyReplayFacts): + """Reserve replay custody while leaving transaction ownership to the caller.""" + self._require_root_transaction() + return await self._replay.reserve(**self._replay_values(facts)) + + async def complete_replay( + self, + facts: SubmissionPolicyReplayFacts, + *, + response_json: dict, + committed_policy_id: str, + committed_effective_policy_id: str | None = None, + committed_pre_submit_policy_id: str | None = None, + ) -> None: + """Flush one replay completion without committing the caller transaction.""" + self._require_root_transaction() + values = self._replay_values(facts) + await self._replay.complete( + facts.operation_id, + actor_profile_id=facts.actor_profile_id, + identity_link_id=facts.identity_link_id, + service_identity=facts.service_identity, + action_id=facts.action_id, + idempotency_key=facts.idempotency_key, + request_digest=facts.request_digest, + resource_context_digest=str(values["resource_context_digest"]), + setup_run_id=facts.setup_run_id, + setup_generation=facts.setup_generation, + setup_task_id=facts.setup_task_id, + correlation_id=facts.correlation_id, + response_json=response_json, + committed_policy_id=committed_policy_id, + committed_effective_policy_id=committed_effective_policy_id, + committed_pre_submit_policy_id=committed_pre_submit_policy_id, + ) diff --git a/backend/tests/test_alembic.py b/backend/tests/test_alembic.py index 0ed660a0f..0b64115ff 100644 --- a/backend/tests/test_alembic.py +++ b/backend/tests/test_alembic.py @@ -73,7 +73,7 @@ snapshot_existing_service_rows, ) -HEAD_REVISION = "0056_review_lease_preference" +HEAD_REVISION = "0057_submission_policy_authority" pytestmark = pytest.mark.postgres_schema_contract @@ -101,6 +101,476 @@ } +async def _submission_policy_authority_shape(database_url: str) -> dict[str, object]: + engine = create_async_engine(database_url) + try: + async with engine.connect() as connection: + columns = int( + await connection.scalar( + text( + "select count(*) from information_schema.columns where " + "table_schema=current_schema() and " + "((table_name='submission_artifact_policies' and " + "column_name in ('created_by_actor_profile_id'," + "'approved_by_actor_profile_id')) or " + "(table_name in ('effective_project_submission_artifact_policies'," + "'pre_submit_checker_policies') and " + "column_name='created_by_actor_profile_id'))" + ) + ) + or 0 + ) + replay_table = bool( + await connection.scalar( + text( + "select to_regclass(current_schema() || " + "'.submission_policy_mutation_idempotency_records') is not null" + ) + ) + ) + provenance_triggers = int( + await connection.scalar( + text( + "select count(*) from pg_trigger where not tgisinternal and " + "tgname=any(:names)" + ), + { + "names": [ + "submission_policy_creation_provenance_immutable", + "submission_policy_approval_provenance_immutable", + "effective_submission_policy_provenance_immutable", + "pre_submit_policy_provenance_immutable", + ] + }, + ) + or 0 + ) + action_states = tuple( + ( + definition.action_id.value, + definition.availability.value, + ) + for definition in ACTION_DEFINITIONS + if definition.action_id.value.startswith( + "project.submission_artifact_policy." + ) + ) + return { + "columns": columns, + "replay_table": replay_table, + "provenance_triggers": provenance_triggers, + "action_states": action_states, + } + finally: + await engine.dispose() + + +async def _seed_historical_submission_policy(database_url: str, ids: dict[str, str]) -> None: + engine = create_async_engine(database_url) + digest = f"sha256:{'a' * 64}" + policy_digest = f"sha256:{'b' * 64}" + try: + async with engine.begin() as connection: + await insert_historical_project( + connection, + project_id=ids["project"], + name="0057 historical policy", + slug=f"submission-policy-{ids['project']}", + ) + for table, trigger in ( + ("project_guides", "guide_mutation_product_custody"), + ("guide_source_snapshots", "source_snapshot_product_custody"), + ): + await connection.execute(text(f"alter table {table} disable trigger {trigger}")) + await connection.execute( + text( + "insert into project_guides(id,project_id,version,status,content_markdown," + "created_by) values(:guide,:project,'v1','draft','# guide','migration-test')" + ), + ids, + ) + await connection.execute( + text( + "insert into guide_source_snapshots(id,project_id,guide_id,guide_version," + "manifest_schema_version,manifest_json,bundle_hash,captured_by) values(" + ":snapshot,:project,:guide,'v1','1','{}'::json,:digest,'migration-test')" + ), + {**ids, "digest": digest}, + ) + for table, trigger in ( + ("project_guides", "guide_mutation_product_custody"), + ("guide_source_snapshots", "source_snapshot_product_custody"), + ): + await connection.execute(text(f"alter table {table} enable trigger {trigger}")) + await connection.execute( + text( + "insert into submission_artifact_policies(" + "id,project_id,guide_id,guide_version,source_snapshot_id," + "source_snapshot_hash,policy_version,lifecycle_status,policy_body," + "policy_hash,derivation_source,source_material_refs,created_by) values(" + ":policy,:project,:guide,'v1',:snapshot,:digest,'v1','draft','{}'::json," + ":policy_digest,'migration-test','[]'::json,'migration-test')" + ), + {**ids, "digest": digest, "policy_digest": policy_digest}, + ) + finally: + await engine.dispose() + + +async def _historical_submission_policy_authority(database_url: str, policy_id: str): + engine = create_async_engine(database_url) + try: + async with engine.connect() as connection: + return tuple( + ( + await connection.execute( + text( + "select policy_hash,created_by_actor_profile_id," + "created_via_identity_link_id,creation_action_id," + "approved_by_actor_profile_id,approval_action_id " + "from submission_artifact_policies where id=:id" + ), + {"id": policy_id}, + ) + ).one() + ) + finally: + await engine.dispose() + + +def test_submission_policy_authority_safe_empty_roundtrip( + isolated_database_env: str, + migration_lock, +) -> None: + """0057 installs only inactive custody and is reversible while unused.""" + config = _alembic_config() + ids = {name: str(uuid4()) for name in ("project", "guide", "snapshot", "policy")} + with migration_lock(): + try: + command.downgrade(config, "0056_review_lease_preference") + prior = asyncio.run(_submission_policy_authority_shape(isolated_database_env)) + asyncio.run(_seed_historical_submission_policy(isolated_database_env, ids)) + command.upgrade(config, HEAD_REVISION) + upgraded = asyncio.run(_submission_policy_authority_shape(isolated_database_env)) + historical = asyncio.run( + _historical_submission_policy_authority( + isolated_database_env, ids["policy"] + ) + ) + command.downgrade(config, "0056_review_lease_preference") + restored = asyncio.run(_submission_policy_authority_shape(isolated_database_env)) + command.upgrade(config, HEAD_REVISION) + repeated = asyncio.run(_submission_policy_authority_shape(isolated_database_env)) + finally: + command.upgrade(config, "head") + + assert prior == restored + assert prior["columns"] == 0 + assert prior["replay_table"] is False + assert prior["provenance_triggers"] == 0 + assert upgraded == repeated + 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 historical == (f"sha256:{'b' * 64}", None, None, None, None, None) + + +def test_submission_policy_authority_pending_replay_blocks_downgrade( + isolated_database_env: str, + migration_lock, +) -> None: + """Even an uncommitted replay reservation is durable authority custody.""" + config = _alembic_config() + ids = { + name: str(uuid4()) + for name in ("profile", "link", "project", "guide", "snapshot", "policy") + } + replay_id, operation_id, idempotency_key = uuid4(), uuid4(), uuid4() + grant_id, decision_id = uuid4(), str(uuid4()) + digest = f"sha256:{'c' * 64}" + + async def seed_pending() -> None: + engine = create_async_engine(isolated_database_env) + try: + async with engine.begin() as connection: + await insert_historical_project( + connection, + project_id=ids["project"], + name="0057 pending replay", + slug=f"submission-replay-{ids['project']}", + ) + await connection.execute( + text( + "insert into actor_profiles(id,actor_kind,status,provisioning_method," + "created_by) values(:profile,'human','active','automatic_first_access'," + ":profile)" + ), + ids, + ) + await connection.execute( + text( + "insert into admin_role_grants(" + "id,target_actor_profile_id,role,scope_type,scope_project_id,status," + "version,granted_by_system_principal,grant_reason) values(" + ":grant,:profile,'project_manager','project',:project,'active',1," + "'workstream:system:bootstrap','0057 custody proof')" + ), + {**ids, "grant": grant_id}, + ) + await connection.execute( + text( + "insert into actor_identity_links(id,actor_profile_id,issuer,subject," + "subject_kind,status,linked_by,last_verified_at) values(:link,:profile," + "'https://identity.test',:profile,'human','active',:profile," + "clock_timestamp())" + ), + ids, + ) + for table, trigger in ( + ("project_guides", "guide_mutation_product_custody"), + ("guide_source_snapshots", "source_snapshot_product_custody"), + ): + await connection.execute( + text(f"alter table {table} disable trigger {trigger}") + ) + await connection.execute( + text( + "insert into project_guides(id,project_id,version,status,content_markdown," + "created_by) values(:guide,:project,'v1','draft','# guide','migration-test')" + ), + ids, + ) + await connection.execute( + text( + "insert into guide_source_snapshots(id,project_id,guide_id,guide_version," + "manifest_schema_version,manifest_json,bundle_hash,captured_by) values(" + ":snapshot,:project,:guide,'v1','1','{}'::json,:digest,'migration-test')" + ), + {**ids, "digest": digest}, + ) + for table, trigger in ( + ("project_guides", "guide_mutation_product_custody"), + ("guide_source_snapshots", "source_snapshot_product_custody"), + ): + await connection.execute( + text(f"alter table {table} enable trigger {trigger}") + ) + await connection.execute( + text( + "insert into submission_policy_mutation_idempotency_records(" + "id,actor_profile_id,identity_link_id,action_id,idempotency_key," + "request_digest,resource_context_digest,resource_context_json," + "operation_id,project_id,guide_id,source_snapshot_id,policy_id," + "setup_generation,status) values(:id,:profile,:link," + "'project.submission_artifact_policy.create',:key,:digest,:digest," + "'{\"guide_version\":\"v1\"}'::json,:operation,:project,:guide," + ":snapshot,:policy,1,'pending')" + ), + { + **ids, + "id": replay_id, + "key": idempotency_key, + "operation": operation_id, + "digest": digest, + }, + ) + with pytest.raises(IntegrityError): + async with connection.begin_nested(): + await connection.execute( + text( + "insert into submission_policy_mutation_idempotency_records(" + "id,actor_profile_id,identity_link_id,action_id,idempotency_key," + "request_digest,resource_context_digest,resource_context_json," + "operation_id,project_id,guide_id,source_snapshot_id,policy_id," + "setup_generation,status) values(:id,:profile,:link," + "'project.submission_artifact_policy.update',:key,:digest,:digest," + "'{\"guide_version\":\"v1\"}'::json,:operation,:project," + ":guide,:snapshot,:policy,1,'pending')" + ), + { + **ids, + "id": uuid4(), + "key": idempotency_key, + "operation": uuid4(), + "digest": digest, + }, + ) + with pytest.raises(DBAPIError, match="invalid submission-policy replay mutation"): + async with connection.begin_nested(): + await connection.execute( + text( + "update submission_policy_mutation_idempotency_records " + "set setup_generation=2 where id=:id" + ), + {"id": replay_id}, + ) + with pytest.raises(DBAPIError, match="cannot be deleted"): + async with connection.begin_nested(): + await connection.execute( + text( + "delete from submission_policy_mutation_idempotency_records " + "where id=:id" + ), + {"id": replay_id}, + ) + with pytest.raises(DBAPIError, match="cannot be truncated"): + async with connection.begin_nested(): + await connection.execute( + text("truncate submission_policy_mutation_idempotency_records") + ) + with pytest.raises( + DBAPIError, match="submission-policy creation evidence mismatch" + ): + async with connection.begin_nested(): + await connection.execute( + text( + "insert into audit_events(" + "id,entity_type,entity_id,event_type,actor_id,actor_roles," + "claim_snapshot,auth_source,is_dev_auth,event_payload,event_domain," + "event_version,actor_ref_kind,request_id,correlation_id," + "matched_grant_id,permission_id,action_id,reason,denial_code," + "project_id,resource_type,resource_id,after_facts) values(" + ":decision,'authorization_decision',:decision," + "'SensitiveAuthorizationAllowed',:profile,'[]'::json,'{}'::json," + "'local_authority',false,'{}'::json,'authority',1,'actor_profile'," + ":request,:correlation,:grant_text," + "'project.effective_policy.manage'," + "'project.submission_artifact_policy.create'," + "'authorization_evaluation',null,:project," + "'project_submission_artifact_policy_mutation',:policy," + "cast(:after_facts as json))" + ), + { + **ids, + "decision": decision_id, + "request": str(uuid4()), + "correlation": str(uuid4()), + "grant_text": str(grant_id), + "after_facts": json.dumps( + {"allowed": True, "resource_context_digest": digest} + ), + }, + ) + await connection.execute( + text( + "insert into submission_artifact_policies(" + "id,project_id,guide_id,guide_version,source_snapshot_id," + "source_snapshot_hash,policy_version,lifecycle_status,policy_body," + "policy_hash,derivation_source,source_material_refs,created_by," + "created_by_actor_profile_id,created_via_identity_link_id," + "created_by_admin_role_grant_id,creation_scope_type," + "creation_scope_project_id,creation_action_id," + "creation_decision_event_id) values(:policy,:project,:guide,'v1'," + ":snapshot,:digest,'v1','draft','{}'::json,:digest,'test'," + "'[]'::json,'test',:profile,:link,:grant,'project',:project," + "'project.submission_artifact_policy.create',:decision)" + ), + { + **ids, + "grant": grant_id, + "decision": decision_id, + "digest": digest, + }, + ) + await connection.execute( + text( + "update submission_policy_mutation_idempotency_records set " + "status='committed',response_json='{}'::json," + "committed_policy_id=:policy,committed_at=now() where id=:id" + ), + {**ids, "id": replay_id}, + ) + finally: + await engine.dispose() + + async def reset_schema() -> None: + engine = create_async_engine(isolated_database_env) + try: + async with engine.begin() as connection: + await connection.execute(text("drop schema public cascade")) + await connection.execute(text("create schema public")) + finally: + await engine.dispose() + + with migration_lock(): + try: + command.upgrade(config, HEAD_REVISION) + asyncio.run(seed_pending()) + with pytest.raises( + RuntimeError, + match="cannot downgrade submission-policy authority with evidence", + ): + command.downgrade(config, "0056_review_lease_preference") + assert asyncio.run(_current_revision(isolated_database_env)) == HEAD_REVISION + finally: + asyncio.run(reset_schema()) + command.upgrade(config, "head") + + +def test_submission_policy_authority_audit_evidence_blocks_downgrade( + isolated_database_env: str, + migration_lock, +) -> None: + """Exact submission-policy AUTH evidence independently prevents vocabulary loss.""" + config = _alembic_config() + event_id, policy_id, project_id = str(uuid4()), str(uuid4()), str(uuid4()) + + async def seed_evidence() -> None: + engine = create_async_engine(isolated_database_env) + try: + async with engine.begin() as connection: + await connection.execute( + text( + "insert into audit_events(" + "id,entity_type,entity_id,event_type,actor_id,actor_roles,claim_snapshot," + "auth_source,is_dev_auth,event_payload,event_domain,event_version," + "actor_ref_kind,request_id,correlation_id,permission_id,action_id,reason," + "denial_code,project_id,resource_type,resource_id,after_facts) values(" + ":id,'authorization_decision',:id,'SensitiveAuthorizationDenied'," + "'workstream:system:bootstrap','[]'::json,'{}'::json,'local_authority'," + "false,'{}'::json,'authority',1,'system_principal',:request,:correlation," + "'project.effective_policy.manage'," + "'project.submission_artifact_policy.create'," + "'authorization_evaluation','permission_not_granted',:project," + "'project_submission_artifact_policy_mutation',:policy," + "'{\"allowed\": false}'::json)" + ), + { + "id": event_id, + "request": str(uuid4()), + "correlation": str(uuid4()), + "project": project_id, + "policy": policy_id, + }, + ) + finally: + await engine.dispose() + + async def reset_schema() -> None: + engine = create_async_engine(isolated_database_env) + try: + async with engine.begin() as connection: + await connection.execute(text("drop schema public cascade")) + await connection.execute(text("create schema public")) + finally: + await engine.dispose() + + with migration_lock(): + try: + command.upgrade(config, HEAD_REVISION) + asyncio.run(seed_evidence()) + with pytest.raises( + RuntimeError, + match="cannot downgrade submission-policy authority with evidence", + ): + command.downgrade(config, "0056_review_lease_preference") + assert asyncio.run(_current_revision(isolated_database_env)) == HEAD_REVISION + finally: + asyncio.run(reset_schema()) + command.upgrade(config, "head") + + def test_0054_guide_sufficiency_authority_safe_empty_downgrade_and_reupgrade( isolated_database_env: str, migration_lock, diff --git a/backend/tests/test_authorization.py b/backend/tests/test_authorization.py index b8540d217..0894c2915 100644 --- a/backend/tests/test_authorization.py +++ b/backend/tests/test_authorization.py @@ -54,6 +54,7 @@ AuthorityAuditEventInput, AuthorityEventType, ) +from app.modules.audit import schemas as audit_schemas_module from app.modules.audit.service import AuditService from app.modules.actors.models import ActorIdentityLink, ActorProfile from app.modules.actors.service import ActorService, ResolvedActor @@ -166,6 +167,7 @@ from app.modules.authorization.prepared import ( PreparedAuthorizationHandle, PreparedAuthorizationService, + _submission_policy_binding_matches, ) from app.modules.authorization.repository import AdminAuthorizationRepository from app.modules.authorization.admin_service import ( @@ -221,6 +223,7 @@ ProjectSetupServiceCustodyContext, ProjectSetupRunMutationResourceContext, ProjectSubmissionArtifactPolicyMutationResourceContext, + SubmissionPolicyCompilationContext, ProjectPolicyReadResourceContext, ProjectActiveGuideReadResourceContext, ProjectReadResourceContext, @@ -2278,6 +2281,8 @@ def test_project_mutation_resources_and_prepared_scopes_are_closed() -> None: action_id: ProjectSubmissionArtifactPolicyMutationResourceContext( resource_type="project_submission_artifact_policy_mutation", resource_id=submission_policy_id, + operation_id=operation_id, + request_digest=DIGEST, scope_project_id=project_id, guide_id=guide_id, guide_version="1", @@ -2286,9 +2291,34 @@ def test_project_mutation_resources_and_prepared_scopes_are_closed() -> None: target_kind=target_kind, execution_kind="setup_service" if target_kind == "derive" else "human", policy_id=submission_policy_id, + policy_version="1", policy_generation=1, + 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, 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 + ), + compilation=( + SubmissionPolicyCompilationContext( + compiler_version="v1", + bundle_schema_version="v1", + catalogue_id="workstream.default", + catalogue_version="v1", + catalogue_schema_version="v1", + catalogue_manifest_sha256=DIGEST, + ordered_entry_identities=("archive.identity@v1",), + ordered_entry_configuration_hashes=(DIGEST,), + disabled_catalogue_entry_ids=(), + disabled_catalogue_config_digest=DIGEST, + compiled_bundle_hash=DIGEST, + effective_plan_hash=DIGEST, + ) + if target_kind == "approve" + else None + ), setup_service_custody=( setup_custody_by_step["submission_artifact_policy"] if target_kind == "derive" @@ -5943,11 +5973,53 @@ async def lock_request_actor(self, identity_link_id, actor_profile_id): authorization, facts, # type: ignore[arg-type] ) + caller_input = PreparedAuthorizationInput(idempotency_key=uuid4(), request_value={}) + scope = PreparedAuthorityScope(kind=PreparedAuthorityScopeKind.SYSTEM) + if action_id is ActionId.PROJECT_SUBMISSION_ARTIFACT_POLICY_DERIVE: + 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, + guide_id=guide_id, + source_snapshot_id=snapshot_id, + setup_generation=1, + expected_step="submission_artifact_policy", + task_id=uuid4(), + correlation_id=uuid4(), + stale_output_digest=DIGEST, + ) + resource = ProjectSubmissionArtifactPolicyMutationResourceContext( + resource_type="project_submission_artifact_policy_mutation", + resource_id=policy_id, + operation_id=operation_id, + request_digest=DIGEST, + scope_project_id=project_id, + guide_id=guide_id, + guide_version="1", + source_snapshot_id=snapshot_id, + source_snapshot_hash=DIGEST, + target_kind="derive", + execution_kind="setup_service", + policy_id=policy_id, + policy_version="1", + policy_generation=1, + setup_generation=1, + stale_output_digest=DIGEST, + setup_service_custody=custody, + ) + caller_input = PreparedAuthorizationInput( + idempotency_key=uuid4(), request_value=resource.model_dump(mode="json") + ) + scope = PreparedAuthorityScope( + kind=PreparedAuthorityScopeKind.PROJECT, project_id=project_id + ) with pytest.raises(PreparedAuthorizationUnsupported) as exc_info: await prepared.prepare( action_id, - PreparedAuthorizationInput(idempotency_key=uuid4(), request_value={}), - PreparedAuthorityScope(kind=PreparedAuthorityScopeKind.SYSTEM), + caller_input, + scope, ) assert exc_info.value.denial_code is AuthorizationDenialCode.ACTION_UNAVAILABLE assert facts.calls == 0 @@ -5955,6 +6027,108 @@ async def lock_request_actor(self, identity_link_id, actor_profile_id): assert evidence.events == [] +@pytest.mark.asyncio +async def test_submission_artifact_policy_prepared_binding_requires_exact_final_facts( + 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) + ) + resource = ProjectSubmissionArtifactPolicyMutationResourceContext( + resource_type="project_submission_artifact_policy_mutation", + resource_id=policy_id, + operation_id=operation_id, + request_digest=DIGEST, + scope_project_id=project_id, + guide_id=guide_id, + guide_version="1", + source_snapshot_id=snapshot_id, + source_snapshot_hash=DIGEST, + target_kind="approve", + execution_kind="human", + policy_id=policy_id, + policy_version="1", + policy_generation=1, + policy_status="draft", + policy_digest=DIGEST, + setup_generation=1, + effective_output_digest=DIGEST, + compiled_pre_submit_output_digest=DIGEST, + compilation=SubmissionPolicyCompilationContext( + compiler_version="v1", + bundle_schema_version="v1", + catalogue_id="workstream.default", + catalogue_version="v1", + catalogue_schema_version="v1", + catalogue_manifest_sha256=DIGEST, + ordered_entry_identities=("archive.identity@v1",), + ordered_entry_configuration_hashes=(DIGEST,), + disabled_catalogue_entry_ids=(), + disabled_catalogue_config_digest=DIGEST, + compiled_bundle_hash=DIGEST, + effective_plan_hash=DIGEST, + ), + ) + context = _runtime_context() + session = _PreparedTestSession() + authorization, evidence = _runtime_service(context, session=session) + repository = _PreparedAdminFacts(context) + authorization._admin = repository # type: ignore[assignment] + prepared = PreparedAuthorizationService( + session, # type: ignore[arg-type] + context, + authorization, + repository, + ) + binding = prepared._binding( + ActionId.PROJECT_SUBMISSION_ARTIFACT_POLICY_APPROVE, + PreparedAuthorizationInput( + idempotency_key=uuid4(), request_value=resource.model_dump(mode="json") + ), + 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"} + ) + } + ) + assert not _submission_policy_binding_matches(binding, changed) + decision = AuthorizationDecision( + decision_id=uuid4(), + action_id=ActionId.PROJECT_SUBMISSION_ARTIFACT_POLICY_APPROVE, + permission_id=PermissionId.PROJECT_EFFECTIVE_POLICY_MANAGE, + allowed=True, + denial_code=None, + resource_type=resource.resource_type, + resource_id=resource.resource_id, + resource_context_digest=authorization_resource_digest(resource), + matched_authority_kind=MatchedAuthorityKind.ADMIN_ROLE_GRANT, + matched_grant_id=uuid4(), + matched_scope_project_id=project_id, + revalidated=True, + request_id=uuid4(), + correlation_id=uuid4(), + ) + active_actions = dict(ACTION_BY_ID) + active_actions[decision.action_id] = replace( + active_actions[decision.action_id], availability=ActionAvailability.ACTIVE + ) + monkeypatch.setattr(audit_schemas_module, "ACTION_BY_ID", active_actions) + await authorization._stage_decision(decision, context.actor_profile_id, resource) + assert evidence.events[0].resource_type == resource.resource_type + assert evidence.events[0].resource_id == str(policy_id) + assert evidence.events[0].project_id == str(project_id) + assert evidence.events[0].after_facts["resource_context_digest"] == ( + authorization_resource_digest(resource) + ) + + @pytest.mark.parametrize( ("action_id", "owning_identity"), tuple( @@ -5990,11 +6164,54 @@ async def test_project_setup_service_matrix_wrong_identity_denies_before_availab facts, ) + caller_input = PreparedAuthorizationInput(idempotency_key=uuid4(), request_value={}) + scope = PreparedAuthorityScope(kind=PreparedAuthorityScopeKind.SYSTEM) + if action_id is ActionId.PROJECT_SUBMISSION_ARTIFACT_POLICY_DERIVE: + 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, + guide_id=guide_id, + source_snapshot_id=snapshot_id, + setup_generation=1, + expected_step="submission_artifact_policy", + task_id=uuid4(), + correlation_id=uuid4(), + stale_output_digest=DIGEST, + ) + resource = ProjectSubmissionArtifactPolicyMutationResourceContext( + resource_type="project_submission_artifact_policy_mutation", + resource_id=policy_id, + operation_id=operation_id, + request_digest=DIGEST, + scope_project_id=project_id, + guide_id=guide_id, + guide_version="1", + source_snapshot_id=snapshot_id, + source_snapshot_hash=DIGEST, + target_kind="derive", + execution_kind="setup_service", + policy_id=policy_id, + policy_version="1", + policy_generation=1, + setup_generation=1, + stale_output_digest=DIGEST, + setup_service_custody=custody, + ) + caller_input = PreparedAuthorizationInput( + idempotency_key=uuid4(), request_value=resource.model_dump(mode="json") + ) + scope = PreparedAuthorityScope( + kind=PreparedAuthorityScopeKind.PROJECT, project_id=project_id + ) + with pytest.raises(PreparedAuthorizationUnsupported) as exc_info: await prepared.prepare( action_id, - PreparedAuthorizationInput(idempotency_key=uuid4(), request_value={}), - PreparedAuthorityScope(kind=PreparedAuthorityScopeKind.SYSTEM), + caller_input, + scope, ) assert exc_info.value.denial_code is AuthorizationDenialCode.PERMISSION_NOT_GRANTED diff --git a/backend/tests/test_projects.py b/backend/tests/test_projects.py index 49c157517..9df3e3d7f 100644 --- a/backend/tests/test_projects.py +++ b/backend/tests/test_projects.py @@ -2,6 +2,7 @@ import asyncio from copy import deepcopy +from dataclasses import replace import hashlib import inspect import json @@ -20,6 +21,7 @@ from sqlalchemy import event, func, select, text, update from sqlalchemy.dialects import postgresql from sqlalchemy.exc import DBAPIError, IntegrityError +from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine from fastapi import HTTPException from sqlalchemy.schema import CreateIndex @@ -88,6 +90,13 @@ from app.modules.projects.sufficiency_mutation_repository import ( GuideSufficiencyMutationReplayRepository, ) +from app.modules.projects.submission_policy_mutation_repository import ( + SubmissionPolicyMutationReplayRepository, +) +from app.modules.projects.submission_policy_mutation_service import ( + SubmissionPolicyMutationService, + SubmissionPolicyReplayFacts, +) from app.modules.tasks.models import AuditEvent from app.modules.authorization.models import ( AdminRoleGrant, @@ -139,6 +148,8 @@ AuthorizationDenialCode, MatchedAuthorityKind, PreparedAuthorizationUnsupported, + ProjectSetupServiceCustodyContext, + ProjectSubmissionArtifactPolicyMutationResourceContext, ) from app.core.permissions import PermissionDenied from app.modules.projects.service import ( @@ -9357,7 +9368,7 @@ async def get(self, *_: object): async def missing(*_: object): return None - repository.find = missing # type: ignore[method-assign] + repository._find_namespace = missing # type: ignore[method-assign] values = { "actor_profile_id": str(uuid4()), "identity_link_id": str(uuid4()), @@ -9389,6 +9400,362 @@ async def missing(*_: object): ) +async def test_submission_artifact_policy_replay_repository_classifies_exact_states() -> None: + """Pending, committed, and mismatched reservations remain distinct and fail closed.""" + + class Session: + scalar_result: object = None + get_result: object = None + + async def scalar(self, _: object): + return self.scalar_result + + async def get(self, *_: object): + return self.get_result + + session = Session() + repository = SubmissionPolicyMutationReplayRepository(session) # type: ignore[arg-type] + facts = { + "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": sha256_hash("request"), + "resource_context_digest": sha256_hash("resource"), + "resource_context_json": {"resource_type": "submission-policy"}, + "operation_id": uuid4(), + "project_id": str(uuid4()), + "guide_id": str(uuid4()), + "source_snapshot_id": str(uuid4()), + "policy_id": str(uuid4()), + "setup_run_id": None, + "setup_generation": 1, + "setup_task_id": None, + "correlation_id": None, + } + + async def missing(**_: object): + return None + + repository._find_namespace = missing # type: ignore[method-assign] + with pytest.raises(ProjectRepositoryIntegrityError, match="reservation disappeared"): + await repository.reserve(**facts) # type: ignore[arg-type] + + pending = SimpleNamespace(id=uuid4(), status="pending", **facts) + + async def find_pending(**_: object): + return pending + + repository._find_namespace = find_pending # type: ignore[method-assign] + state, record = await repository.reserve(**facts) # type: ignore[arg-type] + assert (state, record) == ("pending", pending) + + pending.status = "committed" + state, record = await repository.reserve(**facts) # type: ignore[arg-type] + assert (state, record) == ("replayed", pending) + + pending.request_digest = sha256_hash("different") + state, record = await repository.reserve(**facts) # type: ignore[arg-type] + assert (state, record) == ("mismatch", pending) + pending.request_digest = facts["request_digest"] + changed_namespace = {**facts, "idempotency_key": uuid4()} + state, record = await repository.reserve(**changed_namespace) # type: ignore[arg-type] + assert (state, record) == ("mismatch", pending) + + claimed_id = uuid4() + claimed = SimpleNamespace(id=claimed_id, status="pending", **facts) + session.scalar_result = claimed_id + session.get_result = claimed + state, record = await repository.reserve(**facts) # type: ignore[arg-type] + assert (state, record) == ("claimed", claimed) + + session.scalar_result = claimed + assert await repository.find_by_operation(facts["operation_id"]) is claimed + + session.scalar_result = None + with pytest.raises(ProjectRepositoryIntegrityError, match="invalid.*completion"): + await repository.complete( + facts["operation_id"], + actor_profile_id=facts["actor_profile_id"], + identity_link_id=facts["identity_link_id"], + service_identity=facts["service_identity"], + action_id=facts["action_id"], + idempotency_key=facts["idempotency_key"], + request_digest=facts["request_digest"], + resource_context_digest=facts["resource_context_digest"], + setup_run_id=facts["setup_run_id"], + setup_generation=facts["setup_generation"], + setup_task_id=facts["setup_task_id"], + correlation_id=facts["correlation_id"], + response_json={"id": str(uuid4())}, + committed_policy_id=facts["policy_id"], + ) + session.scalar_result = facts["operation_id"] + await repository.complete( + facts["operation_id"], + actor_profile_id=facts["actor_profile_id"], + identity_link_id=facts["identity_link_id"], + service_identity=facts["service_identity"], + action_id=facts["action_id"], + idempotency_key=facts["idempotency_key"], + request_digest=facts["request_digest"], + resource_context_digest=facts["resource_context_digest"], + setup_run_id=facts["setup_run_id"], + setup_generation=facts["setup_generation"], + setup_task_id=facts["setup_task_id"], + correlation_id=facts["correlation_id"], + response_json={"id": facts["policy_id"]}, + committed_policy_id=facts["policy_id"], + ) + + +async def test_submission_artifact_policy_authority_service_is_flush_only() -> None: + """The foundation delegates within one root transaction and never owns commit.""" + + class Transaction: + is_active = True + + class SyncSession: + transaction: object = Transaction() + + def get_transaction(self): + return self.transaction + + class Session: + sync_session = SyncSession() + nested = False + commits = 0 + rollbacks = 0 + + def in_nested_transaction(self): + return self.nested + + async def commit(self): + self.commits += 1 + + async def rollback(self): + self.rollbacks += 1 + + session = Session() + service = SubmissionPolicyMutationService(session) # type: ignore[arg-type] + calls: list[tuple[str, object]] = [] + record = SimpleNamespace(id=uuid4()) + + class Replay: + async def reserve(self, **values: object): + calls.append(("reserve", values)) + return "claimed", record + + 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) + ) + request_digest = sha256_hash("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="1", + source_snapshot_id=snapshot_id, + source_snapshot_hash=sha256_hash("snapshot"), + target_kind="create", + execution_kind="human", + policy_id=policy_id, + policy_version="1", + policy_generation=1, + setup_generation=1, + ) + 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, + ) + + assert await service.reserve_replay(facts) == ("claimed", record) + await service.complete_replay( + facts, + response_json={"id": "policy"}, + committed_policy_id=facts.policy_id, + ) + assert [name for name, _ in calls] == ["reserve", "complete"] + assert (session.commits, session.rollbacks) == (0, 0) + + mismatched = replace(facts, request_digest=sha256_hash("mismatched-request")) + with pytest.raises(ValueError, match="do not match resource context"): + await service.reserve_replay(mismatched) + + setup_run_id, task_id, correlation_id = uuid4(), uuid4(), uuid4() + custody = ProjectSetupServiceCustodyContext( + setup_run_id=setup_run_id, + scope_project_id=project_id, + guide_id=guide_id, + source_snapshot_id=snapshot_id, + setup_generation=1, + expected_step="submission_artifact_policy", + task_id=task_id, + correlation_id=correlation_id, + stale_output_digest=sha256_hash("stale"), + ) + derive_resource = resource.model_copy( + update={ + "target_kind": "derive", + "execution_kind": "setup_service", + "stale_output_digest": custody.stale_output_digest, + "setup_service_custody": custody, + } + ) + service_facts = replace( + facts, + service_identity="workstream.project.setup", + action_id=ActionId.PROJECT_SUBMISSION_ARTIFACT_POLICY_DERIVE.value, + idempotency_key=None, + resource_context=derive_resource, + setup_run_id=str(setup_run_id), + setup_task_id=task_id, + correlation_id=correlation_id, + ) + for changed in ( + replace(service_facts, setup_run_id=str(uuid4())), + replace(service_facts, setup_task_id=uuid4()), + replace(service_facts, correlation_id=uuid4()), + ): + with pytest.raises(ValueError, match="service replay custody is invalid"): + await service.reserve_replay(changed) + + session.nested = True + with pytest.raises(RuntimeError, match="one root transaction"): + await service.reserve_replay(facts) + session.nested = False + session.sync_session.transaction = None + with pytest.raises(RuntimeError, match="one root transaction"): + await service.reserve_replay(facts) + + +async def test_submission_artifact_policy_replay_postgres_converges_exact_reservations( + isolated_database_env: str, +) -> None: + """The real partial index makes concurrent exact human reservations converge.""" + engine = create_async_engine(isolated_database_env) + ids = {name: str(uuid4()) for name in ("actor", "link", "project", "guide", "snapshot")} + digest = sha256_hash("submission-policy-replay") + try: + async with engine.begin() as connection: + await connection.execute( + text("alter table projects disable trigger project_creation_custody") + ) + await connection.execute( + text( + "insert into projects(id,name,slug,status) values(" + ":project,'Replay project',:slug,'draft')" + ), + {**ids, "slug": f"replay-{ids['project']}"}, + ) + await connection.execute( + text("alter table projects enable trigger project_creation_custody") + ) + await connection.execute( + text( + "insert into actor_profiles(id,actor_kind,status,provisioning_method," + "created_by) values(:actor,'human','active','automatic_first_access',:actor)" + ), + ids, + ) + await connection.execute( + text( + "insert into actor_identity_links(id,actor_profile_id,issuer,subject," + "subject_kind,status,linked_by,last_verified_at) values(:link,:actor," + "'https://identity.test',:actor,'human','active',:actor,clock_timestamp())" + ), + ids, + ) + for table, trigger in ( + ("project_guides", "guide_mutation_product_custody"), + ("guide_source_snapshots", "source_snapshot_product_custody"), + ): + await connection.execute(text(f"alter table {table} disable trigger {trigger}")) + await connection.execute( + text( + "insert into project_guides(id,project_id,version,status,content_markdown," + "created_by) values(:guide,:project,'v1','draft','# guide','test')" + ), + ids, + ) + await connection.execute( + text( + "insert into guide_source_snapshots(id,project_id,guide_id,guide_version," + "manifest_schema_version,manifest_json,bundle_hash,captured_by) values(" + ":snapshot,:project,:guide,'v1','1','{}'::json,:digest,'test')" + ), + {**ids, "digest": digest}, + ) + for table, trigger in ( + ("project_guides", "guide_mutation_product_custody"), + ("guide_source_snapshots", "source_snapshot_product_custody"), + ): + await connection.execute(text(f"alter table {table} enable trigger {trigger}")) + + operation_id, policy_id, key = uuid4(), str(uuid4()), uuid4() + values = { + "actor_profile_id": ids["actor"], + "identity_link_id": ids["link"], + "service_identity": None, + "action_id": ActionId.PROJECT_SUBMISSION_ARTIFACT_POLICY_CREATE.value, + "idempotency_key": key, + "request_digest": digest, + "resource_context_digest": digest, + "resource_context_json": {"guide_version": "v1"}, + "operation_id": operation_id, + "project_id": ids["project"], + "guide_id": ids["guide"], + "source_snapshot_id": ids["snapshot"], + "policy_id": policy_id, + "setup_run_id": None, + "setup_generation": 1, + "setup_task_id": None, + "correlation_id": None, + } + factory = async_sessionmaker(engine, expire_on_commit=False) + first = factory() + await first.begin() + first_result = await SubmissionPolicyMutationReplayRepository(first).reserve(**values) + assert first_result[0] == "claimed" + + async def reserve_second(): + async with factory() as second: + async with second.begin(): + return await SubmissionPolicyMutationReplayRepository(second).reserve(**values) + + competing = asyncio.create_task(reserve_second()) + await asyncio.sleep(0.05) + await first.commit() + second_result = await asyncio.wait_for(competing, timeout=5) + assert second_result[0] == "pending" + assert second_result[1].operation_id == operation_id + await first.close() + finally: + await engine.dispose() + + async def test_sufficiency_lineage_and_target_guards_fail_closed() -> None: """Reject missing, replaced, and non-draft lineage before authorization consumption.""" diff --git a/docs/operations_authorization_service.md b/docs/operations_authorization_service.md index 1bf7066d6..6f25abb3d 100644 --- a/docs/operations_authorization_service.md +++ b/docs/operations_authorization_service.md @@ -1356,6 +1356,19 @@ 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 +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 +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. + ## Draft review and revision policy authorization The guide-bound review-policy and revision-policy `PUT` routes require a UUID diff --git a/docs/spec_authorization_service.md b/docs/spec_authorization_service.md index 9d8c8c74c..6c545dd7a 100644 --- a/docs/spec_authorization_service.md +++ b/docs/spec_authorization_service.md @@ -1103,6 +1103,21 @@ manual Project Manager drafts; 12F3 owns automatic fixed 12F4 owns Project Manager approval plus the atomic effective/pre-submit chain. 12G and the final setup-service cutover depend on merged 12F4. +The 12F1 foundation binds each future submission-policy handle to the exact +project/guide/source lineage, mutation target, operation and request digests, +policy generation, actor/link and grant-or-fixed-service custody, and current +root transaction. Approval also binds the immutable default-catalogue manifest, +ordered and disabled entry configuration digests, compiler/bundle schema, and +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 +authorization audit event, including denied evidence, also blocks downgrade so +the admitted evidence vocabulary is never removed while referenced. + Migration `0041_project_mutation_evidence` extends only the closed audit action-to-permission evidence constraint. It follows ART migration `0040_guide_materialization`, adds no permission, and refuses downgrade after From 1a729aba40c7533f503b3f9399387449e11a269d Mon Sep 17 00:00:00 2001 From: Abiorh001 Date: Wed, 5 Aug 2026 13:33:25 +0100 Subject: [PATCH 02/10] test(auth): register submission replay schema --- ...WS-AUTH-001-12F1-submission-policy-authority-foundation.md | 1 + backend/tests/conftest.py | 4 +++- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/chunks/WS-AUTH-001-12F1-submission-policy-authority-foundation.md b/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/chunks/WS-AUTH-001-12F1-submission-policy-authority-foundation.md index 63ddb95f2..b58e7ec70 100644 --- a/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/chunks/WS-AUTH-001-12F1-submission-policy-authority-foundation.md +++ b/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/chunks/WS-AUTH-001-12F1-submission-policy-authority-foundation.md @@ -24,6 +24,7 @@ backend/alembic/versions/_submission_policy_authority.py backend/tests/test_authorization.py backend/tests/test_projects.py backend/tests/test_alembic.py +backend/tests/conftest.py .github/workflows/backend.yml docs/spec_authorization_service.md docs/operations_authorization_service.md diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index 9ca06ff7b..f61176fe2 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -21,7 +21,7 @@ from scripts.run_isolated_tests import LOOPBACK, NAME_RE, ROLE_RE DDL_LOCK_DIRECTORY = Path("/tmp") -EXPECTED_PUBLIC_SCHEMA_SHA256 = "f89ce8bc78ed5089544b32eeb96489c54f25355a924aecd598a8f15317ec8122" +EXPECTED_PUBLIC_SCHEMA_SHA256 = "562108f0c0bb3f14b319242f843a76935a8a26b6e28ced56f74882ab88208603" PROTECTED_TEST_TABLES = ( "actor_profile_migration_state", "alembic_version", @@ -90,6 +90,7 @@ "review_admission_idempotency_records", "review_leases", "review_queue_entries", + "submission_policy_mutation_idempotency_records", "submission_artifact_policies", "submissions", "task_assignments", @@ -118,6 +119,7 @@ "review_queue_entries", "review_policies", "revision_policies", + "submission_policy_mutation_idempotency_records", ) TestDatabaseReset = Callable[..., Awaitable[None]] DatabaseLock = Callable[[], AbstractContextManager[None]] From a3f01ac207d0d9674ecdd20e822f9f448b09feba Mon Sep 17 00:00:00 2001 From: Abiorh001 Date: Wed, 5 Aug 2026 13:59:10 +0100 Subject: [PATCH 03/10] fix(auth): close submission policy review gaps --- .../WS-AUTH-001-12F1-pr-trust-bundle.md | 6 +- .../0057_submission_policy_authority.py | 24 +++- backend/app/modules/authorization/kernel.py | 7 + backend/app/modules/projects/models.py | 15 +- .../submission_policy_mutation_repository.py | 59 +++++--- .../submission_policy_mutation_service.py | 12 +- backend/tests/conftest.py | 2 +- backend/tests/test_authorization.py | 135 ++++++++---------- backend/tests/test_projects.py | 58 +++++++- 9 files changed, 206 insertions(+), 112 deletions(-) diff --git a/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/reviews/WS-AUTH-001-12F1-pr-trust-bundle.md b/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/reviews/WS-AUTH-001-12F1-pr-trust-bundle.md index 9b914dec2..c82db6958 100644 --- a/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/reviews/WS-AUTH-001-12F1-pr-trust-bundle.md +++ b/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/reviews/WS-AUTH-001-12F1-pr-trust-bundle.md @@ -56,8 +56,10 @@ delegated to the hosted Backend matrix rather than the user's slow local host. - [x] Completion uses operation UUID plus the complete immutable namespace. - [x] Product provenance, replay completion, and allowed evidence are linked by deferred database custody. -- [x] Historical null provenance survives upgrade and empty roundtrip. -- [x] Pending replay and admitted audit evidence block downgrade. +- [ ] Historical null provenance survives upgrade and empty roundtrip (hosted + Backend result pending on the corrected exact head). +- [ ] Pending replay and admitted audit evidence block downgrade (hosted + Backend result pending on the corrected exact head). - [x] New subsystem files have a non-weakened hosted 90 percent coverage gate. ## Internal review diff --git a/backend/alembic/versions/0057_submission_policy_authority.py b/backend/alembic/versions/0057_submission_policy_authority.py index 165a8cc09..1a5e4c9f4 100644 --- a/backend/alembic/versions/0057_submission_policy_authority.py +++ b/backend/alembic/versions/0057_submission_policy_authority.py @@ -124,7 +124,7 @@ def upgrade() -> None: "and creation_scope_type is null and creation_scope_project_id is null " "and creation_action_id is null and creation_decision_event_id is null) or " "(created_by_actor_profile_id is not null and created_via_identity_link_id is not null " - "and creation_scope_project_id is not null and creation_decision_event_id is not null " + "and creation_scope_project_id=project_id and creation_decision_event_id is not null " "and creation_action_id in ('project.submission_artifact_policy.create'," "'project.submission_artifact_policy.derive'," "'project.submission_artifact_policy.update') and " @@ -145,7 +145,7 @@ def upgrade() -> None: "(approved_by_actor_profile_id is not null and approved_via_identity_link_id is not null " "and approved_by_admin_role_grant_id is not null " "and approval_scope_type in ('system','project') " - "and approval_scope_project_id is not null " + "and approval_scope_project_id=project_id " "and approval_action_id='project.submission_artifact_policy.approve' " "and approval_decision_event_id is not null)", ) @@ -157,7 +157,7 @@ def upgrade() -> None: "(created_by_actor_profile_id is not null and created_via_identity_link_id is not null " "and created_by_admin_role_grant_id is not null " "and creation_scope_type in ('system','project') " - "and creation_scope_project_id is not null " + "and creation_scope_project_id=project_id " "and creation_action_id='project.submission_artifact_policy.approve' " "and creation_decision_event_id is not null)" ) @@ -273,6 +273,13 @@ def upgrade() -> None: unique=True, postgresql_where=sa.text("service_identity is not null"), ) + op.create_index( + "uq_submission_policy_committed_policy_action", + "submission_policy_mutation_idempotency_records", + ["committed_policy_id", "action_id"], + unique=True, + postgresql_where=sa.text("status='committed'"), + ) op.execute( """ create function reject_submission_policy_replay_mutation() returns trigger @@ -641,6 +648,14 @@ def upgrade() -> None: def downgrade() -> None: """Remove the inactive foundation only when no replay/provenance exists.""" connection = op.get_bind() + connection.execute(sa.text("lock table audit_events in access exclusive mode")) + for table in ( + "submission_policy_mutation_idempotency_records", + "submission_artifact_policies", + "effective_project_submission_artifact_policies", + "pre_submit_checker_policies", + ): + connection.execute(sa.text(f"lock table {table} in share row exclusive mode")) replay_count = connection.execute( sa.text("select count(*) from submission_policy_mutation_idempotency_records") ).scalar_one() @@ -717,7 +732,7 @@ def downgrade() -> None: ("submission_artifact_policies", "ck_submission_policy_approval_authority_shape"), ("submission_artifact_policies", "ck_submission_policy_creation_authority_shape"), ): - op.drop_constraint(constraint, table, type_="check") + op.drop_constraint(op.f(f"ck_{table}_{constraint}"), table, type_="check") for table, prefix, columns, approval in ( ("pre_submit_checker_policies", "pre_submit_policy", _APPROVAL_OUTPUT_COLUMNS, False), @@ -745,5 +760,4 @@ def downgrade() -> None: op.drop_constraint(f"fk_{prefix}_{stem}_{suffix}", table, type_="foreignkey") for name, _column_type in reversed(columns): op.drop_column(table, name) - op.execute("lock table audit_events in access exclusive mode") _rewrite_audit_resource(add=False) diff --git a/backend/app/modules/authorization/kernel.py b/backend/app/modules/authorization/kernel.py index 13c621ee0..565f5fe7e 100644 --- a/backend/app/modules/authorization/kernel.py +++ b/backend/app/modules/authorization/kernel.py @@ -682,6 +682,13 @@ async def _complete_prepared_denial( } and isinstance(resource_context, ProjectPolicyMutationPrepareDenialResourceContext) ) + or ( + action_id in _SUBMISSION_POLICY_MUTATIONS + and isinstance( + resource_context, + ProjectSubmissionArtifactPolicyMutationResourceContext, + ) + ) ) if not supported: raise TypeError("unsupported prepared denial") diff --git a/backend/app/modules/projects/models.py b/backend/app/modules/projects/models.py index 757ac22cd..3899c46ef 100644 --- a/backend/app/modules/projects/models.py +++ b/backend/app/modules/projects/models.py @@ -248,6 +248,13 @@ class SubmissionPolicyMutationIdempotencyRecord(Base): unique=True, postgresql_where=text("service_identity is not null"), ), + Index( + "uq_submission_policy_committed_policy_action", + "committed_policy_id", + "action_id", + unique=True, + postgresql_where=text("status='committed'"), + ), UniqueConstraint("operation_id", name="uq_submission_policy_operation_identity"), CheckConstraint( "action_id in ('project.submission_artifact_policy.create'," @@ -1285,7 +1292,7 @@ class SubmissionArtifactPolicy(Base): "and creation_scope_type is null and creation_scope_project_id is null " "and creation_action_id is null and creation_decision_event_id is null) or " "(created_by_actor_profile_id is not null and created_via_identity_link_id is not null " - "and creation_scope_project_id is not null and creation_decision_event_id is not null " + "and creation_scope_project_id=project_id and creation_decision_event_id is not null " "and creation_action_id in ('project.submission_artifact_policy.create'," "'project.submission_artifact_policy.derive'," "'project.submission_artifact_policy.update') and " @@ -1305,7 +1312,7 @@ class SubmissionArtifactPolicy(Base): "(approved_by_actor_profile_id is not null and approved_via_identity_link_id is not null " "and approved_by_admin_role_grant_id is not null " "and approval_scope_type in ('system','project') " - "and approval_scope_project_id is not null " + "and approval_scope_project_id=project_id " "and approval_action_id='project.submission_artifact_policy.approve' " "and approval_decision_event_id is not null)", name="ck_submission_policy_approval_authority_shape", @@ -1415,7 +1422,7 @@ class EffectiveProjectSubmissionArtifactPolicy(Base): "(created_by_actor_profile_id is not null and created_via_identity_link_id is not null " "and created_by_admin_role_grant_id is not null " "and creation_scope_type in ('system','project') " - "and creation_scope_project_id is not null " + "and creation_scope_project_id=project_id " "and creation_action_id='project.submission_artifact_policy.approve' " "and creation_decision_event_id is not null)", name="ck_effective_submission_policy_authority_shape", @@ -1512,7 +1519,7 @@ class PreSubmitCheckerPolicy(Base): "(created_by_actor_profile_id is not null and created_via_identity_link_id is not null " "and created_by_admin_role_grant_id is not null " "and creation_scope_type in ('system','project') " - "and creation_scope_project_id is not null " + "and creation_scope_project_id=project_id " "and creation_action_id='project.submission_artifact_policy.approve' " "and creation_decision_event_id is not null)", name="ck_pre_submit_policy_authority_shape", diff --git a/backend/app/modules/projects/submission_policy_mutation_repository.py b/backend/app/modules/projects/submission_policy_mutation_repository.py index b1662a12e..75f7ed7b9 100644 --- a/backend/app/modules/projects/submission_policy_mutation_repository.py +++ b/backend/app/modules/projects/submission_policy_mutation_repository.py @@ -9,11 +9,20 @@ from sqlalchemy import and_, select, update from sqlalchemy.dialects.postgresql import insert from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm.attributes import InstrumentedAttribute +from sqlalchemy.sql.elements import ColumnElement from app.modules.projects.models import SubmissionPolicyMutationIdempotencyRecord from app.modules.projects.repository import ProjectRepositoryIntegrityError +def _matches( + column: InstrumentedAttribute, value: object +) -> ColumnElement[bool]: + """Return exact null-safe equality for one nullable mapped column.""" + return column.is_(None) if value is None else column == value + + class SubmissionPolicyMutationReplayRepository: """Own submission-policy replay rows in the caller transaction only.""" @@ -46,15 +55,24 @@ async def _find_namespace( namespace = ( and_( SubmissionPolicyMutationIdempotencyRecord.service_identity.is_(None), - SubmissionPolicyMutationIdempotencyRecord.idempotency_key == idempotency_key, + _matches( + SubmissionPolicyMutationIdempotencyRecord.idempotency_key, + idempotency_key, + ), ) if service_identity is None else and_( SubmissionPolicyMutationIdempotencyRecord.service_identity == service_identity, SubmissionPolicyMutationIdempotencyRecord.setup_run_id == setup_run_id, SubmissionPolicyMutationIdempotencyRecord.setup_generation == setup_generation, - SubmissionPolicyMutationIdempotencyRecord.setup_task_id == setup_task_id, - SubmissionPolicyMutationIdempotencyRecord.correlation_id == correlation_id, + _matches( + SubmissionPolicyMutationIdempotencyRecord.setup_task_id, + setup_task_id, + ), + _matches( + SubmissionPolicyMutationIdempotencyRecord.correlation_id, + correlation_id, + ), SubmissionPolicyMutationIdempotencyRecord.action_id == action_id, ) ) @@ -196,26 +214,31 @@ async def complete( SubmissionPolicyMutationIdempotencyRecord.operation_id == operation_id, SubmissionPolicyMutationIdempotencyRecord.actor_profile_id == actor_profile_id, SubmissionPolicyMutationIdempotencyRecord.identity_link_id == identity_link_id, - SubmissionPolicyMutationIdempotencyRecord.service_identity - .is_(None) if service_identity is None else - SubmissionPolicyMutationIdempotencyRecord.service_identity == service_identity, + _matches( + SubmissionPolicyMutationIdempotencyRecord.service_identity, + service_identity, + ), SubmissionPolicyMutationIdempotencyRecord.action_id == action_id, - SubmissionPolicyMutationIdempotencyRecord.idempotency_key - .is_(None) if idempotency_key is None else - SubmissionPolicyMutationIdempotencyRecord.idempotency_key == idempotency_key, + _matches( + SubmissionPolicyMutationIdempotencyRecord.idempotency_key, + idempotency_key, + ), SubmissionPolicyMutationIdempotencyRecord.request_digest == request_digest, SubmissionPolicyMutationIdempotencyRecord.resource_context_digest == resource_context_digest, - SubmissionPolicyMutationIdempotencyRecord.setup_run_id - .is_(None) if setup_run_id is None else - SubmissionPolicyMutationIdempotencyRecord.setup_run_id == setup_run_id, + _matches( + SubmissionPolicyMutationIdempotencyRecord.setup_run_id, + setup_run_id, + ), SubmissionPolicyMutationIdempotencyRecord.setup_generation == setup_generation, - SubmissionPolicyMutationIdempotencyRecord.setup_task_id - .is_(None) if setup_task_id is None else - SubmissionPolicyMutationIdempotencyRecord.setup_task_id == setup_task_id, - SubmissionPolicyMutationIdempotencyRecord.correlation_id - .is_(None) if correlation_id is None else - SubmissionPolicyMutationIdempotencyRecord.correlation_id == correlation_id, + _matches( + SubmissionPolicyMutationIdempotencyRecord.setup_task_id, + setup_task_id, + ), + _matches( + SubmissionPolicyMutationIdempotencyRecord.correlation_id, + correlation_id, + ), SubmissionPolicyMutationIdempotencyRecord.status == "pending", ) .values( diff --git a/backend/app/modules/projects/submission_policy_mutation_service.py b/backend/app/modules/projects/submission_policy_mutation_service.py index eec5267bb..b98e8289e 100644 --- a/backend/app/modules/projects/submission_policy_mutation_service.py +++ b/backend/app/modules/projects/submission_policy_mutation_service.py @@ -3,16 +3,19 @@ from __future__ import annotations from dataclasses import dataclass +from typing import Literal from uuid import UUID from sqlalchemy.ext.asyncio import AsyncSession +from app.modules.actors.service_identities import ServiceIdentity from app.modules.authorization.runtime import ( PROJECT_SUBMISSION_POLICY_TARGET_KIND_BY_ACTION, ProjectSubmissionArtifactPolicyMutationResourceContext, authorization_resource_digest, ) from app.modules.authorization.catalogue import ActionId +from app.modules.projects.models import SubmissionPolicyMutationIdempotencyRecord from app.modules.projects.submission_policy_mutation_repository import ( SubmissionPolicyMutationReplayRepository, ) @@ -78,7 +81,7 @@ def _replay_values(facts: SubmissionPolicyReplayFacts) -> dict[str, object]: custody = resource.setup_service_custody if resource.execution_kind == "setup_service": if ( - facts.service_identity != "workstream.project.setup" + facts.service_identity != ServiceIdentity.PROJECT_SETUP.value or facts.idempotency_key is not None or custody is None or facts.setup_run_id != str(custody.setup_run_id) @@ -116,7 +119,12 @@ def _replay_values(facts: SubmissionPolicyReplayFacts) -> dict[str, object]: "correlation_id": facts.correlation_id, } - async def reserve_replay(self, facts: SubmissionPolicyReplayFacts): + async def reserve_replay( + self, facts: SubmissionPolicyReplayFacts + ) -> tuple[ + Literal["claimed", "mismatch", "pending", "replayed"], + SubmissionPolicyMutationIdempotencyRecord, + ]: """Reserve replay custody while leaving transaction ownership to the caller.""" self._require_root_transaction() return await self._replay.reserve(**self._replay_values(facts)) diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index f61176fe2..05bb91d02 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -21,7 +21,7 @@ from scripts.run_isolated_tests import LOOPBACK, NAME_RE, ROLE_RE DDL_LOCK_DIRECTORY = Path("/tmp") -EXPECTED_PUBLIC_SCHEMA_SHA256 = "562108f0c0bb3f14b319242f843a76935a8a26b6e28ced56f74882ab88208603" +EXPECTED_PUBLIC_SCHEMA_SHA256 = "c6fb2f08b641c76c511a66d3cd25c5939378aaa23358639ef8b9e7338b57737a" PROTECTED_TEST_TABLES = ( "actor_profile_migration_state", "alembic_version", diff --git a/backend/tests/test_authorization.py b/backend/tests/test_authorization.py index 0894c2915..58c8e2bd5 100644 --- a/backend/tests/test_authorization.py +++ b/backend/tests/test_authorization.py @@ -428,6 +428,53 @@ def test_authorization_read_cursor_round_trip_and_query_binding() -> None: codec.decode(cursor, query_digest=replay_digest) +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) + ) + custody = ProjectSetupServiceCustodyContext( + setup_run_id=uuid4(), + scope_project_id=project_id, + guide_id=guide_id, + source_snapshot_id=snapshot_id, + setup_generation=1, + expected_step="submission_artifact_policy", + task_id=uuid4(), + correlation_id=uuid4(), + stale_output_digest=DIGEST, + ) + resource = ProjectSubmissionArtifactPolicyMutationResourceContext( + resource_type="project_submission_artifact_policy_mutation", + resource_id=policy_id, + operation_id=operation_id, + request_digest=DIGEST, + scope_project_id=project_id, + guide_id=guide_id, + guide_version="1", + source_snapshot_id=snapshot_id, + source_snapshot_hash=DIGEST, + target_kind="derive", + execution_kind="setup_service", + policy_id=policy_id, + policy_version="1", + policy_generation=1, + setup_generation=1, + stale_output_digest=DIGEST, + setup_service_custody=custody, + ) + return ( + PreparedAuthorizationInput( + idempotency_key=uuid4(), request_value=resource.model_dump(mode="json") + ), + PreparedAuthorityScope( + kind=PreparedAuthorityScopeKind.PROJECT, project_id=project_id + ), + ) + + @pytest.mark.parametrize( "value", ["", "=", "a===", "*", "a" * 513], @@ -5976,45 +6023,7 @@ async def lock_request_actor(self, identity_link_id, actor_profile_id): caller_input = PreparedAuthorizationInput(idempotency_key=uuid4(), request_value={}) scope = PreparedAuthorityScope(kind=PreparedAuthorityScopeKind.SYSTEM) if action_id is ActionId.PROJECT_SUBMISSION_ARTIFACT_POLICY_DERIVE: - 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, - guide_id=guide_id, - source_snapshot_id=snapshot_id, - setup_generation=1, - expected_step="submission_artifact_policy", - task_id=uuid4(), - correlation_id=uuid4(), - stale_output_digest=DIGEST, - ) - resource = ProjectSubmissionArtifactPolicyMutationResourceContext( - resource_type="project_submission_artifact_policy_mutation", - resource_id=policy_id, - operation_id=operation_id, - request_digest=DIGEST, - scope_project_id=project_id, - guide_id=guide_id, - guide_version="1", - source_snapshot_id=snapshot_id, - source_snapshot_hash=DIGEST, - target_kind="derive", - execution_kind="setup_service", - policy_id=policy_id, - policy_version="1", - policy_generation=1, - setup_generation=1, - stale_output_digest=DIGEST, - setup_service_custody=custody, - ) - caller_input = PreparedAuthorizationInput( - idempotency_key=uuid4(), request_value=resource.model_dump(mode="json") - ) - scope = PreparedAuthorityScope( - kind=PreparedAuthorityScopeKind.PROJECT, project_id=project_id - ) + caller_input, scope = _submission_policy_derive_prepare_inputs() with pytest.raises(PreparedAuthorizationUnsupported) as exc_info: await prepared.prepare( action_id, @@ -6127,6 +6136,14 @@ async def test_submission_artifact_policy_prepared_binding_requires_exact_final_ assert evidence.events[0].after_facts["resource_context_digest"] == ( authorization_resource_digest(resource) ) + with pytest.raises(AuthorizationDenied) as denied: + await authorization._complete_prepared_denial( + prepared._consumer_token, + ActionId.PROJECT_SUBMISSION_ARTIFACT_POLICY_APPROVE, + resource, + AuthorizationDenialCode.RESOURCE_GUARD_DENIED, + ) + assert denied.value.decision.denial_code is AuthorizationDenialCode.RESOURCE_GUARD_DENIED @pytest.mark.parametrize( @@ -6167,45 +6184,7 @@ async def test_project_setup_service_matrix_wrong_identity_denies_before_availab caller_input = PreparedAuthorizationInput(idempotency_key=uuid4(), request_value={}) scope = PreparedAuthorityScope(kind=PreparedAuthorityScopeKind.SYSTEM) if action_id is ActionId.PROJECT_SUBMISSION_ARTIFACT_POLICY_DERIVE: - 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, - guide_id=guide_id, - source_snapshot_id=snapshot_id, - setup_generation=1, - expected_step="submission_artifact_policy", - task_id=uuid4(), - correlation_id=uuid4(), - stale_output_digest=DIGEST, - ) - resource = ProjectSubmissionArtifactPolicyMutationResourceContext( - resource_type="project_submission_artifact_policy_mutation", - resource_id=policy_id, - operation_id=operation_id, - request_digest=DIGEST, - scope_project_id=project_id, - guide_id=guide_id, - guide_version="1", - source_snapshot_id=snapshot_id, - source_snapshot_hash=DIGEST, - target_kind="derive", - execution_kind="setup_service", - policy_id=policy_id, - policy_version="1", - policy_generation=1, - setup_generation=1, - stale_output_digest=DIGEST, - setup_service_custody=custody, - ) - caller_input = PreparedAuthorizationInput( - idempotency_key=uuid4(), request_value=resource.model_dump(mode="json") - ) - scope = PreparedAuthorityScope( - kind=PreparedAuthorityScopeKind.PROJECT, project_id=project_id - ) + caller_input, scope = _submission_policy_derive_prepare_inputs() with pytest.raises(PreparedAuthorizationUnsupported) as exc_info: await prepared.prepare( diff --git a/backend/tests/test_projects.py b/backend/tests/test_projects.py index 9df3e3d7f..d43a37a44 100644 --- a/backend/tests/test_projects.py +++ b/backend/tests/test_projects.py @@ -9368,7 +9368,7 @@ async def get(self, *_: object): async def missing(*_: object): return None - repository._find_namespace = missing # type: ignore[method-assign] + repository.find = missing # type: ignore[method-assign] values = { "actor_profile_id": str(uuid4()), "identity_link_id": str(uuid4()), @@ -9470,6 +9470,43 @@ async def find_pending(**_: object): state, record = await repository.reserve(**facts) # type: ignore[arg-type] assert (state, record) == ("claimed", claimed) + session.get_result = None + with pytest.raises(ProjectRepositoryIntegrityError, match="reservation disappeared"): + await repository.reserve(**facts) # type: ignore[arg-type] + session.get_result = claimed + + captured: list[object] = [] + + async def capture(statement: object): + captured.append(statement) + return None + + session.scalar = capture # type: ignore[method-assign] + repository._find_namespace = ( + SubmissionPolicyMutationReplayRepository._find_namespace.__get__(repository) + ) # type: ignore[method-assign] + await repository._find_namespace( + actor_profile_id=facts["actor_profile_id"], + idempotency_key=None, + service_identity="workstream.project.setup", + setup_run_id=str(uuid4()), + setup_generation=1, + setup_task_id=uuid4(), + correlation_id=uuid4(), + action_id=ActionId.PROJECT_SUBMISSION_ARTIFACT_POLICY_DERIVE.value, + ) + service_lookup = str(captured[-1]) + for column in ( + "service_identity", + "setup_run_id", + "setup_generation", + "setup_task_id", + "correlation_id", + "action_id", + ): + assert column in service_lookup + + session.scalar = Session.scalar.__get__(session) # type: ignore[method-assign] session.scalar_result = claimed assert await repository.find_by_operation(facts["operation_id"]) is claimed @@ -9634,6 +9671,7 @@ async def complete(self, value: object, **completion: object): setup_task_id=task_id, correlation_id=correlation_id, ) + assert await service.reserve_replay(service_facts) == ("claimed", record) for changed in ( replace(service_facts, setup_run_id=str(uuid4())), replace(service_facts, setup_task_id=uuid4()), @@ -9646,6 +9684,9 @@ async def complete(self, value: object, **completion: object): with pytest.raises(RuntimeError, match="one root transaction"): await service.reserve_replay(facts) session.nested = False + session.sync_session.transaction = SimpleNamespace(is_active=False) + with pytest.raises(RuntimeError, match="one root transaction"): + await service.reserve_replay(facts) session.sync_session.transaction = None with pytest.raises(RuntimeError, match="one root transaction"): await service.reserve_replay(facts) @@ -9746,7 +9787,20 @@ async def reserve_second(): return await SubmissionPolicyMutationReplayRepository(second).reserve(**values) competing = asyncio.create_task(reserve_second()) - await asyncio.sleep(0.05) + async with engine.connect() as observer: + for _ in range(200): + waiting = await observer.scalar( + text( + "select count(*) from pg_stat_activity where " + "wait_event_type='Lock' and state='active' and " + "query ilike '%submission_policy_mutation_idempotency_records%'" + ) + ) + if waiting: + break + await asyncio.sleep(0.05) + else: + raise AssertionError("competing reservation never blocked") await first.commit() second_result = await asyncio.wait_for(competing, timeout=5) assert second_result[0] == "pending" From 9d6da9a3bb5927301939ebf77b31a7eead347345 Mon Sep 17 00:00:00 2001 From: Abiorh001 Date: Wed, 5 Aug 2026 14:16:10 +0100 Subject: [PATCH 04/10] test(auth): align exact-head migration expectations --- ...-submission-policy-authority-foundation.md | 1 + backend/tests/test_authorization.py | 77 ++++++++++++++++++- .../tests/test_review_lease_persistence.py | 8 +- 3 files changed, 81 insertions(+), 5 deletions(-) diff --git a/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/chunks/WS-AUTH-001-12F1-submission-policy-authority-foundation.md b/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/chunks/WS-AUTH-001-12F1-submission-policy-authority-foundation.md index b58e7ec70..6972800ba 100644 --- a/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/chunks/WS-AUTH-001-12F1-submission-policy-authority-foundation.md +++ b/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/chunks/WS-AUTH-001-12F1-submission-policy-authority-foundation.md @@ -25,6 +25,7 @@ backend/tests/test_authorization.py backend/tests/test_projects.py backend/tests/test_alembic.py backend/tests/conftest.py +backend/tests/test_review_lease_persistence.py .github/workflows/backend.yml docs/spec_authorization_service.md docs/operations_authorization_service.md diff --git a/backend/tests/test_authorization.py b/backend/tests/test_authorization.py index 58c8e2bd5..00a4d56b8 100644 --- a/backend/tests/test_authorization.py +++ b/backend/tests/test_authorization.py @@ -475,6 +475,65 @@ def _submission_policy_derive_prepare_inputs() -> tuple[ ) +def _submission_policy_human_prepare_inputs( + action_id: ActionId, project_id: UUID +) -> tuple[PreparedAuthorizationInput, PreparedAuthorityScope]: + """Build exact human submission-policy input for planned-action tests.""" + guide_id, snapshot_id, policy_id, operation_id = (uuid4() for _ in range(4)) + target_kind = { + ActionId.PROJECT_SUBMISSION_ARTIFACT_POLICY_CREATE: "create", + ActionId.PROJECT_SUBMISSION_ARTIFACT_POLICY_UPDATE: "update", + ActionId.PROJECT_SUBMISSION_ARTIFACT_POLICY_APPROVE: "approve", + }[action_id] + values: dict[str, object] = { + "resource_type": "project_submission_artifact_policy_mutation", + "resource_id": policy_id, + "operation_id": operation_id, + "request_digest": DIGEST, + "scope_project_id": project_id, + "guide_id": guide_id, + "guide_version": "1", + "source_snapshot_id": snapshot_id, + "source_snapshot_hash": DIGEST, + "target_kind": target_kind, + "execution_kind": "human", + "policy_id": policy_id, + "policy_version": "1", + "policy_generation": 1, + "setup_generation": 1, + } + if target_kind in {"update", "approve"}: + values.update(policy_status="draft", policy_digest=DIGEST) + if target_kind == "approve": + values.update( + effective_output_digest=DIGEST, + compiled_pre_submit_output_digest=DIGEST, + compilation=SubmissionPolicyCompilationContext( + compiler_version="v1", + bundle_schema_version="v1", + catalogue_id="workstream.default", + catalogue_version="v1", + catalogue_schema_version="v1", + catalogue_manifest_sha256=DIGEST, + ordered_entry_identities=("archive.identity@v1",), + ordered_entry_configuration_hashes=(DIGEST,), + disabled_catalogue_entry_ids=(), + disabled_catalogue_config_digest=DIGEST, + compiled_bundle_hash=DIGEST, + effective_plan_hash=DIGEST, + ), + ) + resource = ProjectSubmissionArtifactPolicyMutationResourceContext.model_validate(values) + return ( + PreparedAuthorizationInput( + idempotency_key=uuid4(), request_value=resource.model_dump(mode="json") + ), + PreparedAuthorityScope( + kind=PreparedAuthorityScopeKind.PROJECT, project_id=project_id + ), + ) + + @pytest.mark.parametrize( "value", ["", "=", "a===", "*", "a" * 513], @@ -3652,14 +3711,26 @@ 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={} + ) scope = PreparedAuthorityScope( - kind=PreparedAuthorityScopeKind.PROJECT, - project_id=project_id, + kind=PreparedAuthorityScopeKind.PROJECT, project_id=project_id ) + if action_id is ActionId.PROJECT_SUBMISSION_ARTIFACT_POLICY_DERIVE: + caller_input, scope = _submission_policy_derive_prepare_inputs() + elif action_id in { + ActionId.PROJECT_SUBMISSION_ARTIFACT_POLICY_CREATE, + ActionId.PROJECT_SUBMISSION_ARTIFACT_POLICY_UPDATE, + ActionId.PROJECT_SUBMISSION_ARTIFACT_POLICY_APPROVE, + }: + caller_input, scope = _submission_policy_human_prepare_inputs( + action_id, project_id + ) with pytest.raises(PreparedAuthorizationUnsupported) as exc_info: await prepared.prepare( action_id, - PreparedAuthorizationInput(idempotency_key=uuid4(), request_value={}), + caller_input, scope, ) assert exc_info.value.denial_code is AuthorizationDenialCode.ACTION_UNAVAILABLE diff --git a/backend/tests/test_review_lease_persistence.py b/backend/tests/test_review_lease_persistence.py index 0a655e32e..c25b98272 100644 --- a/backend/tests/test_review_lease_persistence.py +++ b/backend/tests/test_review_lease_persistence.py @@ -529,6 +529,9 @@ 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") + ) await db_session.dispose_engine() backend_root = Path(__file__).resolve().parents[1] @@ -543,8 +546,9 @@ def downgrade() -> None: 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")) == ( - "0056_review_lease_preference" + assert ( + await session.scalar(text("select version_num from alembic_version")) + == starting_revision ) assert await session.get(ReviewLease, value.id) is not None From 1f4df5323cc9424b13a9e1601511f6a6aed781eb Mon Sep 17 00:00:00 2001 From: Abiorh001 Date: Wed, 5 Aug 2026 14:30:03 +0100 Subject: [PATCH 05/10] test(auth): scope planned mutation assertion --- backend/tests/test_alembic.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/backend/tests/test_alembic.py b/backend/tests/test_alembic.py index 0b64115ff..3e80ad859 100644 --- a/backend/tests/test_alembic.py +++ b/backend/tests/test_alembic.py @@ -151,9 +151,13 @@ async def _submission_policy_authority_shape(database_url: str) -> dict[str, obj definition.availability.value, ) for definition in ACTION_DEFINITIONS - if definition.action_id.value.startswith( - "project.submission_artifact_policy." - ) + if definition.action_id + in { + ActionId.PROJECT_SUBMISSION_ARTIFACT_POLICY_CREATE, + ActionId.PROJECT_SUBMISSION_ARTIFACT_POLICY_DERIVE, + ActionId.PROJECT_SUBMISSION_ARTIFACT_POLICY_UPDATE, + ActionId.PROJECT_SUBMISSION_ARTIFACT_POLICY_APPROVE, + } ) return { "columns": columns, From 10f77e639f9109b2296ad87bf54a67ebef592c2f Mon Sep 17 00:00:00 2001 From: Abiorh001 Date: Wed, 5 Aug 2026 15:05:27 +0100 Subject: [PATCH 06/10] fix(auth): enforce exact replay context immutability --- .../0057_submission_policy_authority.py | 4 +- backend/tests/conftest.py | 2 +- backend/tests/test_alembic.py | 50 +++++++++++++++++-- 3 files changed, 48 insertions(+), 8 deletions(-) diff --git a/backend/alembic/versions/0057_submission_policy_authority.py b/backend/alembic/versions/0057_submission_policy_authority.py index 1a5e4c9f4..7d675d24b 100644 --- a/backend/alembic/versions/0057_submission_policy_authority.py +++ b/backend/alembic/versions/0057_submission_policy_authority.py @@ -291,14 +291,14 @@ def upgrade() -> None: if old.status <> 'pending' or new.status <> 'committed' or (new.id,new.actor_profile_id,new.identity_link_id,new.service_identity, new.action_id,new.idempotency_key,new.request_digest, - new.resource_context_digest,new.resource_context_json,new.operation_id, + new.resource_context_digest,new.resource_context_json::text,new.operation_id, new.project_id,new.guide_id,new.source_snapshot_id,new.policy_id, new.setup_run_id,new.setup_generation,new.setup_task_id, new.correlation_id,new.created_at) is distinct from (old.id,old.actor_profile_id,old.identity_link_id,old.service_identity, old.action_id,old.idempotency_key,old.request_digest, - old.resource_context_digest,old.resource_context_json,old.operation_id, + old.resource_context_digest,old.resource_context_json::text,old.operation_id, old.project_id,old.guide_id,old.source_snapshot_id,old.policy_id, old.setup_run_id,old.setup_generation,old.setup_task_id, old.correlation_id,old.created_at) diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index 05bb91d02..8a0f727aa 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -21,7 +21,7 @@ from scripts.run_isolated_tests import LOOPBACK, NAME_RE, ROLE_RE DDL_LOCK_DIRECTORY = Path("/tmp") -EXPECTED_PUBLIC_SCHEMA_SHA256 = "c6fb2f08b641c76c511a66d3cd25c5939378aaa23358639ef8b9e7338b57737a" +EXPECTED_PUBLIC_SCHEMA_SHA256 = "317e6cb6bfc0188dc3466f969e9d96e57749fe11f30d5d1e9692ea2a26a70f88" PROTECTED_TEST_TABLES = ( "actor_profile_migration_state", "alembic_version", diff --git a/backend/tests/test_alembic.py b/backend/tests/test_alembic.py index 3e80ad859..ade6c3b3c 100644 --- a/backend/tests/test_alembic.py +++ b/backend/tests/test_alembic.py @@ -291,7 +291,7 @@ def test_submission_policy_authority_pending_replay_blocks_downgrade( for name in ("profile", "link", "project", "guide", "snapshot", "policy") } replay_id, operation_id, idempotency_key = uuid4(), uuid4(), uuid4() - grant_id, decision_id = uuid4(), str(uuid4()) + bootstrap_grant_id, grant_id, decision_id = uuid4(), uuid4(), str(uuid4()) digest = f"sha256:{'c' * 64}" async def seed_pending() -> None: @@ -315,12 +315,33 @@ async def seed_pending() -> None: await connection.execute( text( "insert into admin_role_grants(" - "id,target_actor_profile_id,role,scope_type,scope_project_id,status," - "version,granted_by_system_principal,grant_reason) values(" - ":grant,:profile,'project_manager','project',:project,'active',1," + "id,target_actor_profile_id,role,scope_type,status,version," + "granted_by_system_principal,grant_reason) values(" + ":bootstrap_grant,:profile,'access_administrator','system','active',1," "'workstream:system:bootstrap','0057 custody proof')" ), - {**ids, "grant": grant_id}, + {**ids, "bootstrap_grant": bootstrap_grant_id}, + ) + await connection.execute( + text( + "update authority_control set bootstrap_completed=true," + "bootstrap_grant_id=:bootstrap_grant,version=1 where id=1" + ), + {"bootstrap_grant": bootstrap_grant_id}, + ) + await connection.execute( + text( + "insert into admin_role_grants(" + "id,target_actor_profile_id,role,scope_type,scope_project_id,status," + "version,granted_by_actor_profile_id,granted_by_admin_role_grant_id," + "grant_reason) values(:grant,:profile,'project_manager','project'," + ":project,'active',1,:profile,:bootstrap_grant,'0057 custody proof')" + ), + { + **ids, + "bootstrap_grant": bootstrap_grant_id, + "grant": grant_id, + }, ) await connection.execute( text( @@ -409,6 +430,16 @@ async def seed_pending() -> None: ), {"id": replay_id}, ) + with pytest.raises(DBAPIError, match="invalid submission-policy replay mutation"): + async with connection.begin_nested(): + await connection.execute( + text( + "update submission_policy_mutation_idempotency_records set " + "resource_context_json='{\"guide_version\": \"v1\"}'::json " + "where id=:id" + ), + {"id": replay_id}, + ) with pytest.raises(DBAPIError, match="cannot be deleted"): async with connection.begin_nested(): await connection.execute( @@ -418,11 +449,17 @@ async def seed_pending() -> None: ), {"id": replay_id}, ) + await connection.execute( + text("set constraints submission_policy_replay_custody immediate") + ) with pytest.raises(DBAPIError, match="cannot be truncated"): async with connection.begin_nested(): await connection.execute( text("truncate submission_policy_mutation_idempotency_records") ) + await connection.execute( + text("set constraints submission_policy_replay_custody deferred") + ) with pytest.raises( DBAPIError, match="submission-policy creation evidence mismatch" ): @@ -485,6 +522,9 @@ async def seed_pending() -> None: ), {**ids, "id": replay_id}, ) + await connection.execute( + text("set constraints submission_policy_creation_custody immediate") + ) finally: await engine.dispose() From 2a205a367f9e2b456441c4275a2bbe598581f8a5 Mon Sep 17 00:00:00 2001 From: Abiorh001 Date: Wed, 5 Aug 2026 15:21:21 +0100 Subject: [PATCH 07/10] test(auth): cover service actor self-read denial --- backend/tests/test_authorization.py | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/backend/tests/test_authorization.py b/backend/tests/test_authorization.py index 00a4d56b8..25dd829bc 100644 --- a/backend/tests/test_authorization.py +++ b/backend/tests/test_authorization.py @@ -9557,6 +9557,25 @@ async def test_authorization_kernel_allows_only_exact_actor_self_actions() -> No assert evidence.events[0].after_facts == {"allowed": True} +async def test_authorization_kernel_denies_actor_self_action_to_service_actor() -> None: + context = _runtime_context(actor_kind=ActorKind.SERVICE) + service, evidence = _runtime_service(context) + resource = ActorSelfResourceContext( + resource_type="actor_profile", + resource_id=context.actor_profile_id, + requested_fields=(), + ) + + with pytest.raises(AuthorizationDenied) as exc_info: + await service.require(ActionId.ACTOR_PROFILE_READ_SELF, resource) + + assert ( + exc_info.value.decision.denial_code + is AuthorizationDenialCode.PERMISSION_NOT_GRANTED + ) + assert evidence.events[0].after_facts == {"allowed": False} + + @pytest.mark.parametrize( ("action", "resource", "expected"), [ From ed5f4566d76d1d6319c132dda9df91706d78cde0 Mon Sep 17 00:00:00 2001 From: Abiorh001 Date: Wed, 5 Aug 2026 15:36:29 +0100 Subject: [PATCH 08/10] test(auth): cover prelocked authority constructor guard --- backend/tests/test_authorization.py | 24 +++++------------------- 1 file changed, 5 insertions(+), 19 deletions(-) diff --git a/backend/tests/test_authorization.py b/backend/tests/test_authorization.py index 25dd829bc..846e58f31 100644 --- a/backend/tests/test_authorization.py +++ b/backend/tests/test_authorization.py @@ -5151,6 +5151,11 @@ async def test_prepared_handle_rejects_forgery_copy_serialization_and_nested_roo await prepared.consume(handle, ActionId.ACTOR_PROFILE_UPDATE_SELF, caller_input, resource) +def test_prelocked_authority_rejects_external_construction() -> None: + with pytest.raises(TypeError, match="prelocked authority is internal"): + authorization_kernel._PrelockedAuthority() + + @pytest.mark.asyncio async def test_prepared_admin_consume_reuses_exact_locked_grant_without_requery(): context = _runtime_context() @@ -9557,25 +9562,6 @@ async def test_authorization_kernel_allows_only_exact_actor_self_actions() -> No assert evidence.events[0].after_facts == {"allowed": True} -async def test_authorization_kernel_denies_actor_self_action_to_service_actor() -> None: - context = _runtime_context(actor_kind=ActorKind.SERVICE) - service, evidence = _runtime_service(context) - resource = ActorSelfResourceContext( - resource_type="actor_profile", - resource_id=context.actor_profile_id, - requested_fields=(), - ) - - with pytest.raises(AuthorizationDenied) as exc_info: - await service.require(ActionId.ACTOR_PROFILE_READ_SELF, resource) - - assert ( - exc_info.value.decision.denial_code - is AuthorizationDenialCode.PERMISSION_NOT_GRANTED - ) - assert evidence.events[0].after_facts == {"allowed": False} - - @pytest.mark.parametrize( ("action", "resource", "expected"), [ From 87401b823e57e11575ceb510e9b75f25198b5bb2 Mon Sep 17 00:00:00 2001 From: Abiorh001 Date: Wed, 5 Aug 2026 16:07:15 +0100 Subject: [PATCH 09/10] fix(auth): require complete policy provenance --- .../0057_submission_policy_authority.py | 66 +++++- backend/app/modules/projects/models.py | 12 +- backend/tests/conftest.py | 2 +- backend/tests/test_alembic.py | 195 ++++++++++++++++-- 4 files changed, 247 insertions(+), 28 deletions(-) diff --git a/backend/alembic/versions/0057_submission_policy_authority.py b/backend/alembic/versions/0057_submission_policy_authority.py index 7d675d24b..eca90cbf1 100644 --- a/backend/alembic/versions/0057_submission_policy_authority.py +++ b/backend/alembic/versions/0057_submission_policy_authority.py @@ -124,6 +124,8 @@ def upgrade() -> None: "and creation_scope_type is null and creation_scope_project_id is null " "and creation_action_id is null and creation_decision_event_id is null) or " "(created_by_actor_profile_id is not null and created_via_identity_link_id is not null " + "and creation_scope_type is not null and creation_action_id is not null " + "and creation_scope_project_id is not null " "and creation_scope_project_id=project_id and creation_decision_event_id is not null " "and creation_action_id in ('project.submission_artifact_policy.create'," "'project.submission_artifact_policy.derive'," @@ -131,6 +133,7 @@ def upgrade() -> None: "((created_by_admin_role_grant_id is not null and created_by_service_identity is null " "and creation_scope_type in ('system','project')) or " "(created_by_admin_role_grant_id is null " + "and created_by_service_identity is not null " "and created_by_service_identity='workstream.project.setup' " "and creation_scope_type='service' " "and creation_action_id='project.submission_artifact_policy.derive')))", @@ -144,7 +147,9 @@ def upgrade() -> None: "and approval_decision_event_id is null) or " "(approved_by_actor_profile_id is not null and approved_via_identity_link_id is not null " "and approved_by_admin_role_grant_id is not null " + "and approval_scope_type is not null and approval_action_id is not null " "and approval_scope_type in ('system','project') " + "and approval_scope_project_id is not null " "and approval_scope_project_id=project_id " "and approval_action_id='project.submission_artifact_policy.approve' " "and approval_decision_event_id is not null)", @@ -156,7 +161,9 @@ def upgrade() -> None: "and creation_decision_event_id is null) or " "(created_by_actor_profile_id is not null and created_via_identity_link_id is not null " "and created_by_admin_role_grant_id is not null " + "and creation_scope_type is not null and creation_action_id is not null " "and creation_scope_type in ('system','project') " + "and creation_scope_project_id is not null " "and creation_scope_project_id=project_id " "and creation_action_id='project.submission_artifact_policy.approve' " "and creation_decision_event_id is not null)" @@ -228,7 +235,8 @@ def upgrade() -> None: sa.CheckConstraint( "(service_identity is null and idempotency_key is not null " "and setup_run_id is null and setup_task_id is null and correlation_id is null) or " - "(service_identity='workstream.project.setup' and idempotency_key is null " + "(service_identity is not null " + "and service_identity='workstream.project.setup' and idempotency_key is null " "and action_id='project.submission_artifact_policy.derive' " "and setup_run_id is not null and setup_task_id is not null " "and correlation_id is not null)", @@ -421,7 +429,19 @@ def upgrade() -> None: declare reservation submission_policy_mutation_idempotency_records%rowtype; evidence audit_events%rowtype; begin - if new.creation_action_id is null then return null; end if; + if new.creation_action_id is null then + if new.created_by_actor_profile_id is not null + or new.created_via_identity_link_id is not null + or new.created_by_admin_role_grant_id is not null + or new.created_by_service_identity is not null + or new.creation_scope_type is not null + or new.creation_scope_project_id is not null + or new.creation_decision_event_id is not null then + raise exception 'partial submission-policy creation provenance' + using errcode='23514'; + end if; + return null; + end if; select * into reservation from submission_policy_mutation_idempotency_records where committed_policy_id=new.id and action_id=new.creation_action_id and status='committed'; @@ -554,6 +574,22 @@ def upgrade() -> None: end if; elsif tg_table_name='submission_artifact_policies' then if new.creation_action_id is null and new.approval_action_id is null then + if new.created_by_actor_profile_id is not null + or new.created_via_identity_link_id is not null + or new.created_by_admin_role_grant_id is not null + or new.created_by_service_identity is not null + or new.creation_scope_type is not null + or new.creation_scope_project_id is not null + or new.creation_decision_event_id is not null + or new.approved_by_actor_profile_id is not null + or new.approved_via_identity_link_id is not null + or new.approved_by_admin_role_grant_id is not null + or new.approval_scope_type is not null + or new.approval_scope_project_id is not null + or new.approval_decision_event_id is not null then + raise exception 'partial submission-policy provenance' + using errcode='23514'; + end if; return null; end if; if new.approval_action_id is not null then @@ -578,7 +614,18 @@ def upgrade() -> None: end if; product_project:=new.project_id; product_id:=new.id; elsif tg_table_name='effective_project_submission_artifact_policies' then - if new.creation_action_id is null then return null; end if; + if new.creation_action_id is null then + if new.created_by_actor_profile_id is not null + or new.created_via_identity_link_id is not null + or new.created_by_admin_role_grant_id is not null + or new.creation_scope_type is not null + or new.creation_scope_project_id is not null + or new.creation_decision_event_id is not null then + raise exception 'partial effective-policy provenance' + using errcode='23514'; + end if; + return null; + end if; select * into reservation from submission_policy_mutation_idempotency_records where committed_effective_policy_id=new.id and status='committed'; actor_id:=new.created_by_actor_profile_id; @@ -588,7 +635,18 @@ def upgrade() -> None: decision_id:=new.creation_decision_event_id; product_project:=new.project_id; product_id:=reservation.committed_policy_id; else - if new.creation_action_id is null then return null; end if; + if new.creation_action_id is null then + if new.created_by_actor_profile_id is not null + or new.created_via_identity_link_id is not null + or new.created_by_admin_role_grant_id is not null + or new.creation_scope_type is not null + or new.creation_scope_project_id is not null + or new.creation_decision_event_id is not null then + raise exception 'partial pre-submit-policy provenance' + using errcode='23514'; + end if; + return null; + end if; select * into reservation from submission_policy_mutation_idempotency_records where committed_pre_submit_policy_id=new.id and status='committed'; actor_id:=new.created_by_actor_profile_id; diff --git a/backend/app/modules/projects/models.py b/backend/app/modules/projects/models.py index 3899c46ef..5cb33113b 100644 --- a/backend/app/modules/projects/models.py +++ b/backend/app/modules/projects/models.py @@ -272,7 +272,8 @@ class SubmissionPolicyMutationIdempotencyRecord(Base): CheckConstraint( "(service_identity is null and idempotency_key is not null " "and setup_run_id is null and setup_task_id is null and correlation_id is null) or " - "(service_identity = 'workstream.project.setup' and idempotency_key is null " + "(service_identity is not null " + "and service_identity = 'workstream.project.setup' and idempotency_key is null " "and action_id = 'project.submission_artifact_policy.derive' " "and setup_run_id is not null and setup_task_id is not null " "and correlation_id is not null)", @@ -1292,6 +1293,8 @@ class SubmissionArtifactPolicy(Base): "and creation_scope_type is null and creation_scope_project_id is null " "and creation_action_id is null and creation_decision_event_id is null) or " "(created_by_actor_profile_id is not null and created_via_identity_link_id is not null " + "and creation_scope_type is not null and creation_action_id is not null " + "and creation_scope_project_id is not null " "and creation_scope_project_id=project_id and creation_decision_event_id is not null " "and creation_action_id in ('project.submission_artifact_policy.create'," "'project.submission_artifact_policy.derive'," @@ -1299,6 +1302,7 @@ class SubmissionArtifactPolicy(Base): "((created_by_admin_role_grant_id is not null and created_by_service_identity is null " "and creation_scope_type in ('system','project')) or " "(created_by_admin_role_grant_id is null " + "and created_by_service_identity is not null " "and created_by_service_identity='workstream.project.setup' " "and creation_scope_type='service' " "and creation_action_id='project.submission_artifact_policy.derive')))", @@ -1311,7 +1315,9 @@ class SubmissionArtifactPolicy(Base): "and approval_decision_event_id is null) or " "(approved_by_actor_profile_id is not null and approved_via_identity_link_id is not null " "and approved_by_admin_role_grant_id is not null " + "and approval_scope_type is not null and approval_action_id is not null " "and approval_scope_type in ('system','project') " + "and approval_scope_project_id is not null " "and approval_scope_project_id=project_id " "and approval_action_id='project.submission_artifact_policy.approve' " "and approval_decision_event_id is not null)", @@ -1421,7 +1427,9 @@ class EffectiveProjectSubmissionArtifactPolicy(Base): "and creation_decision_event_id is null) or " "(created_by_actor_profile_id is not null and created_via_identity_link_id is not null " "and created_by_admin_role_grant_id is not null " + "and creation_scope_type is not null and creation_action_id is not null " "and creation_scope_type in ('system','project') " + "and creation_scope_project_id is not null " "and creation_scope_project_id=project_id " "and creation_action_id='project.submission_artifact_policy.approve' " "and creation_decision_event_id is not null)", @@ -1518,7 +1526,9 @@ class PreSubmitCheckerPolicy(Base): "and creation_decision_event_id is null) or " "(created_by_actor_profile_id is not null and created_via_identity_link_id is not null " "and created_by_admin_role_grant_id is not null " + "and creation_scope_type is not null and creation_action_id is not null " "and creation_scope_type in ('system','project') " + "and creation_scope_project_id is not null " "and creation_scope_project_id=project_id " "and creation_action_id='project.submission_artifact_policy.approve' " "and creation_decision_event_id is not null)", diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index 8a0f727aa..582d359a6 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -21,7 +21,7 @@ from scripts.run_isolated_tests import LOOPBACK, NAME_RE, ROLE_RE DDL_LOCK_DIRECTORY = Path("/tmp") -EXPECTED_PUBLIC_SCHEMA_SHA256 = "317e6cb6bfc0188dc3466f969e9d96e57749fe11f30d5d1e9692ea2a26a70f88" +EXPECTED_PUBLIC_SCHEMA_SHA256 = "923a64584cd1580759012c2303a14146f881ae5e69d021ce99b0648c08fc765b" PROTECTED_TEST_TABLES = ( "actor_profile_migration_state", "alembic_version", diff --git a/backend/tests/test_alembic.py b/backend/tests/test_alembic.py index ade6c3b3c..62ee2087b 100644 --- a/backend/tests/test_alembic.py +++ b/backend/tests/test_alembic.py @@ -257,9 +257,7 @@ def test_submission_policy_authority_safe_empty_roundtrip( command.upgrade(config, HEAD_REVISION) upgraded = asyncio.run(_submission_policy_authority_shape(isolated_database_env)) historical = asyncio.run( - _historical_submission_policy_authority( - isolated_database_env, ids["policy"] - ) + _historical_submission_policy_authority(isolated_database_env, ids["policy"]) ) command.downgrade(config, "0056_review_lease_preference") restored = asyncio.run(_submission_policy_authority_shape(isolated_database_env)) @@ -287,17 +285,72 @@ def test_submission_policy_authority_pending_replay_blocks_downgrade( """Even an uncommitted replay reservation is durable authority custody.""" config = _alembic_config() ids = { - name: str(uuid4()) - for name in ("profile", "link", "project", "guide", "snapshot", "policy") + name: str(uuid4()) for name in ("profile", "link", "project", "guide", "snapshot", "policy") } replay_id, operation_id, idempotency_key = uuid4(), uuid4(), uuid4() - bootstrap_grant_id, grant_id, decision_id = uuid4(), uuid4(), str(uuid4()) + bootstrap_grant_id, grant_id = uuid4(), uuid4() + decision_id, null_scope_decision_id = str(uuid4()), str(uuid4()) digest = f"sha256:{'c' * 64}" async def seed_pending() -> None: engine = create_async_engine(isolated_database_env) try: async with engine.begin() as connection: + constraint_rows = ( + ( + await connection.execute( + text( + "select c.relname as table_name, pg_get_constraintdef(k.oid) as definition " + "from pg_constraint k join pg_class c on c.oid=k.conrelid " + "where k.contype='c' and c.relname in (" + "'submission_artifact_policies'," + "'submission_policy_mutation_idempotency_records'," + "'effective_project_submission_artifact_policies'," + "'pre_submit_checker_policies')" + ) + ) + ) + .mappings() + .all() + ) + definitions = { + table: " ".join( + row["definition"].lower() + for row in constraint_rows + if row["table_name"] == table + ) + for table in ( + "submission_artifact_policies", + "submission_policy_mutation_idempotency_records", + "effective_project_submission_artifact_policies", + "pre_submit_checker_policies", + ) + } + submission_definition = definitions["submission_artifact_policies"] + for fragment in ( + "creation_scope_type is not null", + "creation_scope_project_id is not null", + "creation_action_id is not null", + "approval_scope_type is not null", + "approval_scope_project_id is not null", + "approval_action_id is not null", + "created_by_service_identity is not null", + ): + assert fragment in submission_definition + assert ( + "service_identity is not null" + in definitions["submission_policy_mutation_idempotency_records"] + ) + for table in ( + "effective_project_submission_artifact_policies", + "pre_submit_checker_policies", + ): + for fragment in ( + "creation_scope_type is not null", + "creation_scope_project_id is not null", + "creation_action_id is not null", + ): + assert fragment in definitions[table] await insert_historical_project( connection, project_id=ids["project"], @@ -356,9 +409,7 @@ async def seed_pending() -> None: ("project_guides", "guide_mutation_product_custody"), ("guide_source_snapshots", "source_snapshot_product_custody"), ): - await connection.execute( - text(f"alter table {table} disable trigger {trigger}") - ) + await connection.execute(text(f"alter table {table} disable trigger {trigger}")) await connection.execute( text( "insert into project_guides(id,project_id,version,status,content_markdown," @@ -378,9 +429,7 @@ async def seed_pending() -> None: ("project_guides", "guide_mutation_product_custody"), ("guide_source_snapshots", "source_snapshot_product_custody"), ): - await connection.execute( - text(f"alter table {table} enable trigger {trigger}") - ) + await connection.execute(text(f"alter table {table} enable trigger {trigger}")) await connection.execute( text( "insert into submission_policy_mutation_idempotency_records(" @@ -389,7 +438,7 @@ async def seed_pending() -> None: "operation_id,project_id,guide_id,source_snapshot_id,policy_id," "setup_generation,status) values(:id,:profile,:link," "'project.submission_artifact_policy.create',:key,:digest,:digest," - "'{\"guide_version\":\"v1\"}'::json,:operation,:project,:guide," + '\'{"guide_version":"v1"}\'::json,:operation,:project,:guide,' ":snapshot,:policy,1,'pending')" ), { @@ -400,6 +449,110 @@ async def seed_pending() -> None: "digest": digest, }, ) + for scope_type, scope_project, action_id, grant, service in ( + ( + "project", + None, + "project.submission_artifact_policy.create", + grant_id, + None, + ), + ( + None, + ids["project"], + "project.submission_artifact_policy.create", + grant_id, + None, + ), + ("project", ids["project"], None, grant_id, None), + ( + "service", + ids["project"], + "project.submission_artifact_policy.derive", + None, + None, + ), + ): + with pytest.raises( + IntegrityError, + match="ck_submission_artifact_policies_ck_submission_policy_cr", + ): + async with connection.begin_nested(): + await connection.execute( + text( + "insert into audit_events(" + "id,entity_type,entity_id,event_type,actor_id,actor_roles," + "claim_snapshot,auth_source,is_dev_auth,event_payload,event_domain," + "event_version,actor_ref_kind,request_id,correlation_id," + "matched_grant_id,permission_id,action_id,reason,denial_code," + "project_id,resource_type,resource_id,after_facts) values(" + ":decision,'authorization_decision',:decision," + "'SensitiveAuthorizationAllowed',:profile,'[]'::json,'{}'::json," + "'local_authority',false,'{}'::json,'authority',1,'actor_profile'," + ":request,:correlation,:grant_text," + "'project.effective_policy.manage'," + "'project.submission_artifact_policy.create'," + "'authorization_evaluation',null,:project," + "'project_submission_artifact_policy_mutation',:policy," + "cast(:after_facts as json))" + ), + { + **ids, + "decision": null_scope_decision_id, + "request": str(uuid4()), + "correlation": str(uuid4()), + "grant_text": str(grant_id), + "after_facts": json.dumps( + {"allowed": True, "resource_context_digest": digest} + ), + }, + ) + await connection.execute( + text( + "insert into submission_artifact_policies(" + "id,project_id,guide_id,guide_version,source_snapshot_id," + "source_snapshot_hash,policy_version,lifecycle_status,policy_body," + "policy_hash,derivation_source,source_material_refs,created_by," + "created_by_actor_profile_id,created_via_identity_link_id," + "created_by_admin_role_grant_id,created_by_service_identity," + "creation_scope_type," + "creation_scope_project_id,creation_action_id," + "creation_decision_event_id) values(:policy,:project,:guide,'v1'," + ":snapshot,:digest,'v1','draft','{}'::json,:digest,'test'," + "'[]'::json,'test',:profile,:link,:grant,:service,:scope_type," + ":scope_project,:action_id,:decision)" + ), + { + **ids, + "grant": grant, + "service": service, + "decision": null_scope_decision_id, + "digest": digest, + "scope_type": scope_type, + "scope_project": scope_project, + "action_id": action_id, + }, + ) + with pytest.raises(IntegrityError): + async with connection.begin_nested(): + await connection.execute( + text( + "insert into submission_policy_mutation_idempotency_records(" + "id,actor_profile_id,identity_link_id,service_identity,action_id," + "idempotency_key,request_digest,resource_context_digest," + "resource_context_json,operation_id,project_id,guide_id," + "source_snapshot_id,policy_id,setup_generation,status) values(" + ":id,null,null,null,'project.submission_artifact_policy.derive'," + 'null,:digest,:digest,\'{"guide_version":"v1"}\'::json,' + ":operation,:project,:guide,:snapshot,:policy,1,'pending')" + ), + { + **ids, + "id": uuid4(), + "operation": uuid4(), + "digest": digest, + }, + ) with pytest.raises(IntegrityError): async with connection.begin_nested(): await connection.execute( @@ -410,7 +563,7 @@ async def seed_pending() -> None: "operation_id,project_id,guide_id,source_snapshot_id,policy_id," "setup_generation,status) values(:id,:profile,:link," "'project.submission_artifact_policy.update',:key,:digest,:digest," - "'{\"guide_version\":\"v1\"}'::json,:operation,:project," + '\'{"guide_version":"v1"}\'::json,:operation,:project,' ":guide,:snapshot,:policy,1,'pending')" ), { @@ -435,7 +588,7 @@ async def seed_pending() -> None: await connection.execute( text( "update submission_policy_mutation_idempotency_records set " - "resource_context_json='{\"guide_version\": \"v1\"}'::json " + 'resource_context_json=\'{"guide_version": "v1"}\'::json ' "where id=:id" ), {"id": replay_id}, @@ -664,9 +817,7 @@ async def exercise() -> None: ("project_setup_runs", "source_setup_run_custody"), ) for table, trigger in custody_triggers: - await connection.execute( - text(f"alter table {table} disable trigger {trigger}") - ) + await connection.execute(text(f"alter table {table} disable trigger {trigger}")) statements = ( "insert into actor_profiles(id,actor_kind,status,provisioning_method," "service_identity,created_by) values(:profile,'service','active'," @@ -691,9 +842,7 @@ async def exercise() -> None: for statement in statements: await connection.execute(text(statement), {**ids, "digest": digest}) for table, trigger in custody_triggers: - await connection.execute( - text(f"alter table {table} enable trigger {trigger}") - ) + await connection.execute(text(f"alter table {table} enable trigger {trigger}")) values = { "id": replay_id, "profile": ids["profile"], @@ -743,7 +892,9 @@ async def exercise() -> None: ), {"id": replay_id, "response": json.dumps({"changed": True})}, ) - with pytest.raises(DBAPIError, match="guide sufficiency replay rows are append-only"): + with pytest.raises( + DBAPIError, match="guide sufficiency replay rows are append-only" + ): async with connection.begin_nested(): await connection.execute( text("truncate guide_sufficiency_mutation_idempotency_records") From ed7441208f3f20510ac710c2beeb1519bc42e7b6 Mon Sep 17 00:00:00 2001 From: Abiorh001 Date: Wed, 5 Aug 2026 16:26:58 +0100 Subject: [PATCH 10/10] test(auth): cover policy denial binding --- backend/tests/test_authorization.py | 36 +++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/backend/tests/test_authorization.py b/backend/tests/test_authorization.py index 846e58f31..705be24b2 100644 --- a/backend/tests/test_authorization.py +++ b/backend/tests/test_authorization.py @@ -167,6 +167,8 @@ from app.modules.authorization.prepared import ( PreparedAuthorizationHandle, PreparedAuthorizationService, + _PreparedAuthorizationBinding, + _policy_mutation_denial_binding_matches, _submission_policy_binding_matches, ) from app.modules.authorization.repository import AdminAuthorizationRepository @@ -218,6 +220,7 @@ ProjectGuideSourceSnapshotMutationResourceContext, ProjectGuideSufficiencyMutationResourceContext, ProjectPostSubmitCheckerPolicyMutationResourceContext, + ProjectPolicyMutationPrepareDenialResourceContext, ProjectReviewPolicyMutationResourceContext, ProjectRevisionPolicyMutationResourceContext, ProjectSetupServiceCustodyContext, @@ -6275,6 +6278,39 @@ async def test_project_setup_service_matrix_wrong_identity_denies_before_availab assert evidence.events == [] +def test_policy_mutation_denial_binding_requires_exact_kind_and_selectors() -> None: + """A bounded policy denial remains tied to its requested policy kind and selectors.""" + project_id, guide_id = uuid4(), uuid4() + binding = _PreparedAuthorizationBinding( + action_id=ActionId.PROJECT_REVIEW_POLICY_UPDATE, + actor_ref_kind=ActorReferenceKind.ACTOR_PROFILE, + actor_ref=uuid4(), + scope=PreparedAuthorityScope( + kind=PreparedAuthorityScopeKind.PROJECT, + project_id=project_id, + ), + idempotency_key=uuid4(), + request_digest=DIGEST, + policy_mutation_project_id=project_id, + policy_mutation_guide_id=guide_id, + policy_mutation_request_digest=DIGEST, + ) + resource = ProjectPolicyMutationPrepareDenialResourceContext( + resource_type="project_policy_mutation_request", + resource_id=guide_id, + scope_project_id=project_id, + requested_guide_id=guide_id, + requested_policy_kind="review", + request_digest=DIGEST, + ) + + assert _policy_mutation_denial_binding_matches(binding, resource) + assert not _policy_mutation_denial_binding_matches( + binding, + resource.model_copy(update={"requested_policy_kind": "revision"}), + ) + + @pytest.mark.asyncio async def test_prepared_rejects_unsupported_scope_missing_grant_and_inactive_root(): context = _runtime_context()