From fc59b58cde0b1baa382bd80f6595b22f57c5375a Mon Sep 17 00:00:00 2001 From: Abiorh001 Date: Fri, 31 Jul 2026 06:59:40 +0100 Subject: [PATCH 01/16] docs(auth): repair guide mutation contract --- ...TH-001-12D-guide-draft-source-mutations.md | 229 +++++++++++++++--- 1 file changed, 196 insertions(+), 33 deletions(-) diff --git a/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/chunks/WS-AUTH-001-12D-guide-draft-source-mutations.md b/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/chunks/WS-AUTH-001-12D-guide-draft-source-mutations.md index d5cd6b9c..bcabe0dc 100644 --- a/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/chunks/WS-AUTH-001-12D-guide-draft-source-mutations.md +++ b/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/chunks/WS-AUTH-001-12D-guide-draft-source-mutations.md @@ -2,7 +2,8 @@ ## Status and prerequisite -Proposed and inactive after 12C. +Active planning after merged 12C. Application code remains blocked until this +repaired contract passes the required L1 pre-implementation reviews. ## Parent initiative @@ -18,66 +19,227 @@ covered Project Manager using exact project/guide lineage and PREP. These are human-owned draft metadata mutations adjacent to, but distinct from, ART byte ingestion and policy lifecycle mutations. -## Risk class +## Risk class and SLA -L1 - -## SLA - -P1 +L1 / P1. ## Allowed files ```text backend/app/modules/projects/models.py backend/app/modules/projects/authorization_reads.py +backend/app/modules/projects/guide_mutation_repository.py +backend/app/modules/projects/guide_mutation_router.py +backend/app/modules/projects/guide_mutation_service.py backend/app/modules/projects/repository.py backend/app/modules/projects/router.py backend/app/modules/projects/schemas.py backend/app/modules/projects/service.py backend/app/modules/projects/setup_queue.py +backend/app/modules/authorization/catalogue.py backend/app/modules/authorization/kernel.py backend/app/modules/authorization/prepared.py backend/app/modules/authorization/runtime.py +backend/app/modules/audit/schemas.py backend/app/api/deps/authorization.py -backend/alembic/versions/_guide_source_metadata_authority.py +backend/app/api/router.py +backend/alembic/versions/0045_guide_source_metadata_authority.py +.github/workflows/backend.yml backend/tests/test_authorization.py backend/tests/test_projects.py backend/tests/test_guide_artifacts.py backend/tests/test_alembic.py +backend/tests/test_api_controls.py +backend/tests/project_create_fixtures.py backend/scripts/api_contract_e2e.py docs/spec_authorization_service.md +docs/operations_authorization_service.md docs/spec_artifact_storage_service.md .agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/** ``` ## Not allowed changes -ART ingest/binding/read/provider behavior, sufficiency/policy/activation state, -review/revision/retired-economic/contribution policy mutation, or issuer-claim fallback. - -## Acceptance criteria - -- Exact-project Project Manager authority is required; system scope covers its - projects, project scope covers only the named project. -- Guide create and update hard-remove embedded review, revision, retired economic, and - contribution policy mutation fields; 12D2 becomes the only configuration - path and no compatibility route or payload alias remains. -- Final consume follows locks of project, draft guide, current source lineage, - and generation facts, before mutation/setup enqueue intent. -- Cross-project/guide/snapshot, active/superseded guide, stale generation, - revoked grant/link/profile, replay, copied handle, and transaction mismatch - fail before state or queue intent. -- Existing active ART ingest is unchanged and no provider access is granted. -- Project/guide/source rows record local actor/link/grant/scope/action and - decision-event provenance; historical rows remain nullable/readable. -- Changed authorization/project modules remain at least 90 percent covered and - the final pushed head SHA passes `Backend / test` and `Agent Gates`. +ART ingest/binding/read/provider behavior; sufficiency, submission/checker, +activation, review, revision, payment/economic, contribution, or compensation +policy mutation; project update/archive invention; issuer-claim or token-role +fallback; a second authorization protocol; or a prepared handle crossing commit, +Celery, serialization, or worker boundaries. + +## Action and product clean cut + +- Activate exactly `project.guide.create`, `project.guide.update`, and + `project.guide_source_snapshot.create`. Every 12D2/12E/12F/12G/12B2/12H + action remains planned. OpenAPI exposes exactly one marker for each 12D route. +- `ProjectGuideCreate.source_snapshot` is removed. Guide creation never creates a + snapshot or setup run. Source metadata uses only its separate route/action/PREP. +- Guide create/update hard-remove `review_policy`, `revision_policy`, and + `payment_policy` plus every retired economic, contribution, or compensation + alias. Review/revision returns only in 12D2. Economic/contribution/compensation + policy remains outside AUTH with no compatibility field, alias, route, or fallback. +- `content_markdown` remains guide source material. It may change only before the + first source snapshot exists. Once any snapshot captures the guide, content + changes fail before product state or replay commit; bounded non-source metadata + such as `change_summary` may still change on an exact draft guide. +- Existing active ART ingest remains hidden and unchanged. 12D performs no + `ArtifactStore`, binding, verification, provider, byte-read, or byte-write call. + +## Authority and PREP contract + +- Only an active human with an active exact identity link and effective Project + Manager admin grant carrying `project.guide.manage` may proceed. System scope + covers any project; project scope covers only its exact `scope_project_id`. + Other admin roles, contributor grants, services, token roles, and issuer claims deny. +- PREP uses the existing opaque handle. Its positive project-mutation branch + locks the exact actor/link and one effective Project Manager grant with system + or exact-project coverage. Final consume revalidates lifecycle, active grant, + action, scope, request digest, root transaction, and complete final resource facts. +- Unsupported preparation produces bounded denial evidence for the same exact + resource context. Full resource-context digests are persisted for all 12D decisions. +- Copied/replayed/wrong-action/wrong-resource handles, changed input, replaced or + nested transactions, cross-project/guide/snapshot selectors, stale lineage or + generation, and post-lock revocation fail before product state or queue intent. + +## Idempotency and transaction contract + +- Every route requires a UUID `Idempotency-Key`, validated before actor + first-access provisioning. The namespace is + `(actor_profile_id, action_id, idempotency_key)` and the canonical digest binds + route, validated body, actor, exact link, project, server operation id, target + resource id, and operation generation. +- A fresh reservation starts `pending`. Protected state, allowed decision + evidence, row provenance, exact serialized response, and the `committed` + transition commit atomically. The route owns commit/rollback; product services + never commit. +- Exact committed replay returns the recorded response without fresh PREP, + product writes, setup-run creation, or broker dispatch. Changed input returns + structured `idempotency_mismatch`; an in-flight request returns retryable + `idempotency_pending`; denial and ordinary failure roll back pending state. + Concurrent same-key consumption produces exactly one mutation. +- Dedicated 12D router/service/repository boundaries replace legacy + `require_any_role`, self-commit, and embedded-policy behavior. No compatibility + path remains in the legacy projects router/service. + +## Per-mutation lock and consume order + +- Guide create selects a nonpersistent, in-memory server-generated guide id at + generation 1, prepares authority, then locks the exact project and proves the + version absent; it + consumes exact project/new-guide/version/generation facts before insert. +- Guide update prepares authority, then locks exact project, exact guide, and + latest source lineage; proves the guide is a draft in the named project; + reserves `current_mutation_generation + 1`; and consumes current + version/status/generation plus predecessor snapshot id/hash before mutation. +- Source-snapshot create prepares authority, then locks exact project, draft + guide, latest predecessor snapshot, and source lineage; sanitizes and hashes + server-owned manifest facts; reserves a server-generated snapshot id and next + generation; and consumes exact guide/version/status, predecessor id/hash, new + snapshot id/hash/generation, and request digest before snapshot items or setup intent. + +## Provenance, migration, and queue custody + +- `project_guides`, `guide_source_snapshots`, and source-created + `project_setup_runs` record nullable historical actor-profile, exact identity + link, matched Project Manager grant, scope type/project, action, operation + generation, and allowed decision-event provenance. The idempotency ledger + retains every committed operation so later updates do not erase prior custody. +- Exact migration `0045_guide_source_metadata_authority` follows + `0044_project_create_authority`. It adds nullable historical columns, foreign + keys, action/scope/shape checks, the 12D replay ledger, immutable transition + guards, and deferrable triggers tying new guide/snapshot/setup participants to + exact allowed evidence and resource-context digest. Historical rows remain + readable and unbackfilled; new mutations cannot omit custody. Downgrade refuses + after any 12D evidence or attributed mutation. +- The setup-run row plus exact setup generation is the durable queue intent and + commits with snapshot, evidence, and replay state. Celery dispatch occurs only + after commit and never carries PREP. Broker failure records `enqueue_failed` on + that exact run for bounded recovery. HTTP replay never creates or dispatches a + second intent. + +## Required proof matrix + +- Success: system and exact-project Project Manager for all three actions, exact + action/grant/scope/provenance, route-owned transaction, and recorded response. +- Denial: wrong role, contributor/service, wrong scope, revoked/suspended/ + deactivated actor/link/grant, and every unavailable future action. +- Capability abuse: copied/replayed/wrong-action/wrong-resource handle, changed + input, replaced/nested transaction, cross-lineage, stale predecessor/generation, + and post-lock revocation. +- Replay/concurrency: missing/invalid key, exact replay, mismatch, pending, + concurrent same-key use, exact response, and no duplicate guide/snapshot/run/task. +- Policy clean cut: removed fields/aliases return 422 and write no policy row. +- Source immutability: content update succeeds before the first snapshot, fails + after snapshot capture without state/replay effects, and a bounded + `change_summary` update remains allowed on the exact draft. +- Queue: intent commits atomically; success records one task id; broker failure + records `enqueue_failed`; replay creates and dispatches nothing. +- ART: hidden ingest remains unchanged and no provider/byte operation is called. +- Migration: `0044 -> 0045 -> 0044 -> 0045`, fingerprint, historical survival, + unattributed/custody-mismatched refusal, and populated downgrade refusal. +- API: three action markers, success, concealed denial, replay/mismatch, + policy-field rejection, and unchanged hidden ART route. + +## Coverage and hosted gates + +- Every new or materially changed AUTH-12D boundary remains at least 90 percent + covered. Removal-only legacy aggregation modules remain under the unchanged + authorization-subsystem and repository-wide gates. +- The repository-wide 78 percent floor and every existing 90 percent gate remain + unchanged. Add a hosted per-file 90 percent gate for the three dedicated 12D + modules; never add `continue-on-error`, `|| true`, skips, or exclusions. +- Final pushed SHA must pass `Backend / test`, `Agent Gates`, required internal + reviewers, and external review triage. ## Verification commands -Before start, freeze exact isolated-runner, migration round-trip, coverage, -Ruff, API drill, stale contract/docs, link, and diff commands. +```bash +cd backend +uv run ruff check app/api/deps/authorization.py app/api/router.py \ + app/modules/audit/schemas.py app/modules/authorization/catalogue.py \ + app/modules/authorization/kernel.py app/modules/authorization/prepared.py \ + app/modules/authorization/runtime.py app/modules/projects/models.py \ + app/modules/projects/authorization_reads.py \ + app/modules/projects/guide_mutation_repository.py \ + app/modules/projects/guide_mutation_router.py \ + app/modules/projects/guide_mutation_service.py app/modules/projects/repository.py \ + app/modules/projects/router.py app/modules/projects/schemas.py \ + app/modules/projects/service.py app/modules/projects/setup_queue.py \ + tests/project_create_fixtures.py tests/test_authorization.py \ + tests/test_projects.py tests/test_guide_artifacts.py tests/test_alembic.py \ + tests/test_api_controls.py scripts/api_contract_e2e.py + +PYTEST_DISABLE_PLUGIN_AUTOLOAD=1 uv run python scripts/run_isolated_tests.py \ + --metadata-json .ci/auth12d.json --lane auth12d --timeout-seconds 1200 -- \ + uv run pytest -p pytest_asyncio.plugin -q tests/test_authorization.py \ + tests/test_projects.py tests/test_guide_artifacts.py tests/test_alembic.py \ + tests/test_api_controls.py -k 'guide_authority or guide_source_metadata or create_guide or update_guide or create_guide_source_snapshot or guide_source_metadata_authority' + +PYTEST_DISABLE_PLUGIN_AUTOLOAD=1 uv run python scripts/run_isolated_tests.py \ + --metadata-json .ci/auth12d-api.json --lane auth12d_api \ + --timeout-seconds 1500 -- uv run python scripts/api_contract_e2e.py + +uv run coverage erase +uv run coverage run --source=app -m pytest -q tests/test_authorization.py \ + tests/test_projects.py tests/test_guide_artifacts.py \ + -k 'guide_authority or guide_source_metadata or create_guide or update_guide or create_guide_source_snapshot' +for source in app/modules/projects/guide_mutation_repository.py \ + app/modules/projects/guide_mutation_router.py \ + app/modules/projects/guide_mutation_service.py +do + uv run coverage report --include="${source}" --precision=2 --fail-under=90 +done + +cd .. +python3 scripts/check_stale_authorization_docs.py +python3 scripts/check_stale_artifact_contracts.py +python3 scripts/check_stale_workstream_wording.py +python3 scripts/check_markdown_links.py +git diff --check +``` + +The isolated PostgreSQL commands require `WORKSTREAM_TEST_ADMIN_DATABASE_URL`. +The full repository suite and coverage run only in hosted GitHub `Backend / test`; +do not run that full suite locally. ## Required reviewers @@ -86,9 +248,10 @@ integrity, docs, reuse/dedup, and test delta. ## Human review focus -Exact lineage, policy-field clean cut, enqueue atomicity, and ART separation. +Exact lineage/generation, replay response custody, policy-field clean cut, +database-enforced provenance, durable setup intent/post-commit dispatch, and ART separation. ## Stop conditions -Stop if the route must mutate review/revision/retired-economic/contribution policy or -authorize byte/provider behavior. +Stop if implementation must mutate excluded policy/ART behavior, introduce a +second auth protocol, weaken CI, cross PREP over commit/Celery, or exceed this boundary. From 3a4ef8ae618ff9cc0f334ff54788b25d1d25ca1a Mon Sep 17 00:00:00 2001 From: Abiorh001 Date: Fri, 31 Jul 2026 10:32:19 +0100 Subject: [PATCH 02/16] feat(auth): authorize guide draft source mutations --- ...TH-001-12D-guide-draft-source-mutations.md | 28 +- .github/workflows/backend.yml | 13 + README.md | 4 +- .../0045_guide_source_metadata_authority.py | 504 ++++++++++ backend/app/api/deps/authorization.py | 6 + backend/app/api/router.py | 2 + .../app/modules/authorization/catalogue.py | 9 +- backend/app/modules/authorization/kernel.py | 138 ++- backend/app/modules/authorization/prepared.py | 105 +- backend/app/modules/authorization/runtime.py | 177 ++-- .../projects/guide_mutation_repository.py | 110 +++ .../modules/projects/guide_mutation_router.py | 156 +++ .../projects/guide_mutation_service.py | 503 ++++++++++ backend/app/modules/projects/models.py | 102 ++ backend/app/modules/projects/router.py | 86 -- backend/app/modules/projects/schemas.py | 9 +- backend/app/modules/projects/service.py | 614 ++++-------- backend/app/modules/projects/setup_queue.py | 53 + backend/scripts/api_contract_e2e.py | 121 ++- backend/scripts/week2_api_e2e.py | 3 + backend/tests/conftest.py | 5 +- backend/tests/project_create_fixtures.py | 46 + backend/tests/test_alembic.py | 414 +++++++- backend/tests/test_api_controls.py | 34 +- backend/tests/test_authorization.py | 154 ++- backend/tests/test_checkers.py | 26 +- backend/tests/test_projects.py | 911 ++++++++++++++---- backend/tests/test_tasks.py | 33 +- docs/operations_authorization_service.md | 30 + docs/spec_authorization_service.md | 28 +- docs/spec_chunk_3_project_guide_foundation.md | 4 +- 31 files changed, 3489 insertions(+), 939 deletions(-) create mode 100644 backend/alembic/versions/0045_guide_source_metadata_authority.py create mode 100644 backend/app/modules/projects/guide_mutation_repository.py create mode 100644 backend/app/modules/projects/guide_mutation_router.py create mode 100644 backend/app/modules/projects/guide_mutation_service.py diff --git a/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/chunks/WS-AUTH-001-12D-guide-draft-source-mutations.md b/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/chunks/WS-AUTH-001-12D-guide-draft-source-mutations.md index bcabe0dc..d5ce2405 100644 --- a/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/chunks/WS-AUTH-001-12D-guide-draft-source-mutations.md +++ b/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/chunks/WS-AUTH-001-12D-guide-draft-source-mutations.md @@ -2,8 +2,8 @@ ## Status and prerequisite -Active planning after merged 12C. Application code remains blocked until this -repaired contract passes the required L1 pre-implementation reviews. +Implementation in progress after merged 12C and completed L1 +pre-implementation review. ## Parent initiative @@ -51,7 +51,10 @@ backend/tests/test_guide_artifacts.py backend/tests/test_alembic.py backend/tests/test_api_controls.py backend/tests/project_create_fixtures.py +backend/tests/conftest.py backend/scripts/api_contract_e2e.py +README.md +docs/spec_chunk_3_project_guide_foundation.md docs/spec_authorization_service.md docs/operations_authorization_service.md docs/spec_artifact_storage_service.md @@ -61,10 +64,10 @@ docs/spec_artifact_storage_service.md ## Not allowed changes ART ingest/binding/read/provider behavior; sufficiency, submission/checker, -activation, review, revision, payment/economic, contribution, or compensation -policy mutation; project update/archive invention; issuer-claim or token-role +activation, review, revision, retired economic/configuration, contribution-record, +or payout-configuration mutation; project update/archive invention; issuer-claim or token-role fallback; a second authorization protocol; or a prepared handle crossing commit, -Celery, serialization, or worker boundaries. +Celery, serialization, or durable job boundaries. ## Action and product clean cut @@ -73,10 +76,10 @@ Celery, serialization, or worker boundaries. action remains planned. OpenAPI exposes exactly one marker for each 12D route. - `ProjectGuideCreate.source_snapshot` is removed. Guide creation never creates a snapshot or setup run. Source metadata uses only its separate route/action/PREP. -- Guide create/update hard-remove `review_policy`, `revision_policy`, and - `payment_policy` plus every retired economic, contribution, or compensation - alias. Review/revision returns only in 12D2. Economic/contribution/compensation - policy remains outside AUTH with no compatibility field, alias, route, or fallback. +- Guide create/update hard-remove `review_policy`, `revision_policy`, and every + retired payout/economic configuration field or alias. Review/revision returns + only in 12D2. Economic and contribution-record policy remains outside AUTH with + no compatibility field, alias, route, or fallback. - `content_markdown` remains guide source material. It may change only before the first source snapshot exists. Once any snapshot captures the guide, content changes fail before product state or replay commit; bounded non-source metadata @@ -117,7 +120,7 @@ Celery, serialization, or worker boundaries. `idempotency_pending`; denial and ordinary failure roll back pending state. Concurrent same-key consumption produces exactly one mutation. - Dedicated 12D router/service/repository boundaries replace legacy - `require_any_role`, self-commit, and embedded-policy behavior. No compatibility + request-role gating, self-commit, and embedded-policy behavior. No compatibility path remains in the legacy projects router/service. ## Per-mutation lock and consume order @@ -143,8 +146,9 @@ Celery, serialization, or worker boundaries. link, matched Project Manager grant, scope type/project, action, operation generation, and allowed decision-event provenance. The idempotency ledger retains every committed operation so later updates do not erase prior custody. -- Exact migration `0045_guide_source_metadata_authority` follows - `0044_project_create_authority`. It adds nullable historical columns, foreign +- Exact migration file `0045_guide_source_metadata_authority.py` (revision id + `0045_guide_metadata_authority`) follows `0044_project_create_authority`. It + adds nullable historical columns, foreign keys, action/scope/shape checks, the 12D replay ledger, immutable transition guards, and deferrable triggers tying new guide/snapshot/setup participants to exact allowed evidence and resource-context digest. Historical rows remain diff --git a/.github/workflows/backend.yml b/.github/workflows/backend.yml index 1e6b2a07..515c0a70 100644 --- a/.github/workflows/backend.yml +++ b/.github/workflows/backend.yml @@ -252,6 +252,19 @@ jobs: --cov-report=term-missing \ --cov-fail-under=90 + - name: Guide metadata mutation per-file coverage + working-directory: backend + shell: bash + run: | + set -euo pipefail + for source in \ + app/modules/projects/guide_mutation_repository.py \ + app/modules/projects/guide_mutation_router.py \ + app/modules/projects/guide_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/README.md b/README.md index 47277d2e..0a91686f 100644 --- a/README.md +++ b/README.md @@ -251,8 +251,8 @@ environment must include `OPENAI_API_KEY` and the approved model settings. Persisted sufficiency and derivation agent identity is Workstream-owned; runtime or provider-returned identity fields are not trusted as audit provenance. -Run the Celery worker before creating project guides that should automatically prepare -pre-submit policy, continue into post-submit policy derivation after setup +Run the Celery worker before creating guide-source snapshots that should automatically +prepare pre-submit policy, continue into post-submit policy derivation after setup submission artifact policy approval, and advance locked submissions through the automatic pre-review checker gate: diff --git a/backend/alembic/versions/0045_guide_source_metadata_authority.py b/backend/alembic/versions/0045_guide_source_metadata_authority.py new file mode 100644 index 00000000..14d984ae --- /dev/null +++ b/backend/alembic/versions/0045_guide_source_metadata_authority.py @@ -0,0 +1,504 @@ +"""activate transaction-bound guide source-metadata authority + +Revision ID: 0045_guide_metadata_authority +Revises: 0044_project_create_authority +Create Date: 2026-07-31 +""" + +from __future__ import annotations + +from alembic import op +import sqlalchemy as sa + + +revision = "0045_guide_metadata_authority" +down_revision = "0044_project_create_authority" +branch_labels = depends_on = None + +_ACTIONS = "'project.guide.create','project.guide.update','project.guide_source_snapshot.create'" + + +def _authority_columns(table: str, *, prefix: str) -> None: + metadata_prefix = { + "last_mutated": "last_mutation", + "created": "creation", + "authorized": "authorization", + }[prefix] + op.add_column(table, sa.Column(f"{prefix}_by_actor_profile_id", sa.String(36))) + op.add_column(table, sa.Column(f"{prefix}_via_identity_link_id", sa.String(36))) + op.add_column(table, sa.Column(f"{prefix}_by_admin_role_grant_id", sa.Uuid())) + op.add_column(table, sa.Column(f"{metadata_prefix}_scope_type", sa.String(16))) + op.add_column(table, sa.Column(f"{metadata_prefix}_scope_project_id", sa.String(36))) + op.add_column(table, sa.Column(f"{metadata_prefix}_action_id", sa.String(160))) + decision_column = ( + "last_authorization_decision_event_id" + if prefix == "last_mutated" + else "authorization_decision_event_id" + ) + op.add_column(table, sa.Column(decision_column, sa.String(36))) + for suffix, target, target_column in ( + ("actor", "actor_profiles", "id"), + ("identity_link", "actor_identity_links", "id"), + ("admin_grant", "admin_role_grants", "id"), + ("decision", "audit_events", "id"), + ): + column = { + "actor": f"{prefix}_by_actor_profile_id", + "identity_link": f"{prefix}_via_identity_link_id", + "admin_grant": f"{prefix}_by_admin_role_grant_id", + "decision": decision_column, + }[suffix] + op.create_foreign_key( + f"fk_{table}_{prefix}_{suffix}", table, target, [column], [target_column] + ) + + +def _drop_authority_columns(table: str, *, prefix: str) -> None: + metadata_prefix = { + "last_mutated": "last_mutation", + "created": "creation", + "authorized": "authorization", + }[prefix] + for suffix in ("decision", "admin_grant", "identity_link", "actor"): + op.drop_constraint(f"fk_{table}_{prefix}_{suffix}", table, type_="foreignkey") + decision_column = ( + "last_authorization_decision_event_id" + if prefix == "last_mutated" + else "authorization_decision_event_id" + ) + for column in ( + decision_column, + f"{metadata_prefix}_action_id", + f"{metadata_prefix}_scope_project_id", + f"{metadata_prefix}_scope_type", + f"{prefix}_by_admin_role_grant_id", + f"{prefix}_via_identity_link_id", + f"{prefix}_by_actor_profile_id", + ): + op.drop_column(table, column) + + +def upgrade() -> None: + """Install nullable history and mandatory custody for every new mutation.""" + _authority_columns("project_guides", prefix="last_mutated") + op.add_column("project_guides", sa.Column("mutation_generation", sa.Integer())) + op.create_check_constraint( + "guide_mutation_authority_shape", + "project_guides", + "(mutation_generation is null and last_mutated_by_actor_profile_id is null " + "and last_mutated_via_identity_link_id is null " + "and last_mutated_by_admin_role_grant_id is null " + "and last_mutation_scope_type is null and last_mutation_scope_project_id is null " + "and last_mutation_action_id is null and last_authorization_decision_event_id is null) or " + "(mutation_generation > 0 and last_mutated_by_actor_profile_id is not null " + "and last_mutated_via_identity_link_id is not null " + "and last_mutated_by_admin_role_grant_id is not null " + "and last_mutation_scope_type in ('system','project') " + "and ((last_mutation_scope_type='system' and last_mutation_scope_project_id is null) " + "or (last_mutation_scope_type='project' and last_mutation_scope_project_id=project_id)) " + f"and last_mutation_action_id in ({_ACTIONS}) " + "and last_authorization_decision_event_id is not null)", + ) + + _authority_columns("guide_source_snapshots", prefix="created") + op.add_column("guide_source_snapshots", sa.Column("creation_generation", sa.Integer())) + op.create_check_constraint( + "source_snapshot_creation_authority_shape", + "guide_source_snapshots", + "(creation_generation is null and 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 authorization_decision_event_id is null) or " + "(creation_generation > 0 and 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_type='system' and creation_scope_project_id is null) " + "or (creation_scope_type='project' and creation_scope_project_id=project_id)) " + "and creation_action_id='project.guide_source_snapshot.create' " + "and authorization_decision_event_id is not null)", + ) + + _authority_columns("project_setup_runs", prefix="authorized") + op.create_check_constraint( + "setup_run_authority_shape", + "project_setup_runs", + "(authorized_by_actor_profile_id is null and authorized_via_identity_link_id is null " + "and authorized_by_admin_role_grant_id is null and authorization_scope_type is null " + "and authorization_scope_project_id is null and authorization_action_id is null " + "and authorization_decision_event_id is null) or " + "(authorized_by_actor_profile_id is not null " + "and authorized_via_identity_link_id is not null " + "and authorized_by_admin_role_grant_id is not null " + "and authorization_scope_type in ('system','project') " + "and ((authorization_scope_type='system' and authorization_scope_project_id is null) " + "or (authorization_scope_type='project' and authorization_scope_project_id=project_id)) " + "and authorization_action_id='project.guide_source_snapshot.create' " + "and authorization_decision_event_id is not null)", + ) + + op.create_table( + "guide_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("action_id", sa.String(160), nullable=False), + sa.Column("idempotency_key", sa.Uuid(), nullable=False), + sa.Column("request_digest", sa.String(71), nullable=False), + sa.Column("resource_context_digest", sa.String(71), 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("resource_id", sa.String(36), nullable=False), + sa.Column("operation_generation", sa.Integer(), nullable=False), + sa.Column("status", sa.String(16), nullable=False), + sa.Column("response_json", sa.JSON()), + sa.Column("setup_run_id", sa.String(36), sa.ForeignKey("project_setup_runs.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( + "actor_profile_id", + "action_id", + "idempotency_key", + name="uq_guide_mutation_replay_namespace", + ), + sa.UniqueConstraint("operation_id", name="uq_guide_mutation_operation_identity"), + sa.CheckConstraint(f"action_id in ({_ACTIONS})", name="ck_guide_mutation_action"), + sa.CheckConstraint( + "request_digest ~ '^sha256:[0-9a-f]{64}$'", name="ck_guide_mutation_request_digest" + ), + sa.CheckConstraint( + "resource_context_digest ~ '^sha256:[0-9a-f]{64}$'", + name="ck_guide_mutation_resource_context_digest", + ), + sa.CheckConstraint("operation_generation > 0", name="ck_guide_mutation_generation"), + sa.CheckConstraint("status in ('pending','committed')", name="ck_guide_mutation_status"), + sa.CheckConstraint( + "(status='pending' and response_json is null and committed_at is null and setup_run_id is null) or " + "(status='committed' and response_json is not null and committed_at is not null)", + name="ck_guide_mutation_state_shape", + ), + ) + op.execute( + """ + create function guard_guide_mutation_idempotency() returns trigger + language plpgsql as $$ begin + if tg_op='INSERT' then + if new.status<>'pending' then raise exception 'guide mutation must begin pending' using errcode='23514'; end if; + return new; + elsif tg_op='DELETE' then + raise exception 'guide mutation custody is immutable' using errcode='55000'; + end if; + if new is not distinct from old then return new; end if; + if old.status<>'pending' or new.status<>'committed' + or (new.id,new.actor_profile_id,new.identity_link_id,new.action_id,new.idempotency_key, + new.request_digest,new.resource_context_digest,new.operation_id,new.project_id,new.resource_id, + new.operation_generation,new.created_at) + is distinct from + (old.id,old.actor_profile_id,old.identity_link_id,old.action_id,old.idempotency_key, + old.request_digest,old.resource_context_digest,old.operation_id,old.project_id,old.resource_id, + old.operation_generation,old.created_at) then + raise exception 'invalid guide mutation custody transition' using errcode='23514'; + end if; + return new; + end $$ + """ + ) + op.execute( + "create trigger guide_mutation_idempotency_guard before insert or update or delete " + "on guide_mutation_idempotency_records for each row execute function guard_guide_mutation_idempotency()" + ) + op.execute( + """ + create function reject_guide_mutation_idempotency_truncate() returns trigger + language plpgsql as $$ begin + raise exception 'guide mutation custody is immutable' using errcode='55000'; + end $$ + """ + ) + op.execute( + "create trigger guide_mutation_idempotency_reject_truncate before truncate " + "on guide_mutation_idempotency_records execute function reject_guide_mutation_idempotency_truncate()" + ) + op.execute( + """ + create function validate_guide_mutation_custody() returns trigger + language plpgsql as $$ + declare reservation guide_mutation_idempotency_records%rowtype; + evidence audit_events%rowtype; + actor_id text; link_id text; grant_id uuid; action_value text; + scope_type text; scope_project text; decision_id text; + product_project text; product_resource text; product_generation integer; + begin + if tg_table_name='guide_mutation_idempotency_records' then + select * into reservation from guide_mutation_idempotency_records where id=new.id; + if reservation.status<>'committed' then + raise exception 'pending guide mutation custody cannot commit' using errcode='23514'; + end if; + if reservation.action_id in ('project.guide.create','project.guide.update') then + select last_mutated_by_actor_profile_id,last_mutated_via_identity_link_id, + last_mutated_by_admin_role_grant_id,last_mutation_action_id, + last_mutation_scope_type,last_mutation_scope_project_id, + last_authorization_decision_event_id,project_id,id,mutation_generation + into actor_id,link_id,grant_id,action_value,scope_type,scope_project, + decision_id,product_project,product_resource,product_generation + from project_guides where id=reservation.resource_id; + else + select created_by_actor_profile_id,created_via_identity_link_id, + created_by_admin_role_grant_id,creation_action_id, + creation_scope_type,creation_scope_project_id, + authorization_decision_event_id,project_id,id,creation_generation + into actor_id,link_id,grant_id,action_value,scope_type,scope_project, + decision_id,product_project,product_resource,product_generation + from guide_source_snapshots where id=reservation.resource_id; + end if; + elsif tg_table_name='project_guides' then + if tg_op='UPDATE' + and (new.id,new.project_id,new.version) + is distinct from (old.id,old.project_id,old.version) then + raise exception 'guide identity and lineage are immutable' using errcode='23514'; + end if; + if tg_op='UPDATE' + and (new.status,new.approved_by,new.effective_at,new.superseded_at) + is distinct from + (old.status,old.approved_by,old.effective_at,old.superseded_at) then + raise exception 'guide lifecycle mutation requires activation authority' + using errcode='23514'; + end if; + if tg_op='UPDATE' + and (new.content_markdown is distinct from old.content_markdown + or new.change_summary is distinct from old.change_summary) + and (new.mutation_generation is not distinct from old.mutation_generation + or new.last_authorization_decision_event_id + is not distinct from old.last_authorization_decision_event_id) then + raise exception 'guide content mutation requires fresh custody' using errcode='23514'; + end if; + if new.mutation_generation is null then + if tg_op='INSERT' then + raise exception 'new guides require mutation authority' using errcode='23514'; + end if; + return null; + end if; + actor_id:=new.last_mutated_by_actor_profile_id; + link_id:=new.last_mutated_via_identity_link_id; + grant_id:=new.last_mutated_by_admin_role_grant_id; + action_value:=new.last_mutation_action_id; + scope_type:=new.last_mutation_scope_type; + scope_project:=new.last_mutation_scope_project_id; + decision_id:=new.last_authorization_decision_event_id; + product_project:=new.project_id; product_resource:=new.id; + product_generation:=new.mutation_generation; + select * into reservation from guide_mutation_idempotency_records + where resource_id=new.id and action_id=new.last_mutation_action_id + and operation_generation=new.mutation_generation and status='committed'; + elsif tg_table_name='guide_source_snapshots' then + if tg_op='UPDATE' + and (new.project_id,new.guide_id,new.guide_version, + new.manifest_schema_version,new.manifest_json::jsonb,new.bundle_hash,new.captured_by) + is distinct from + (old.project_id,old.guide_id,old.guide_version, + old.manifest_schema_version,old.manifest_json::jsonb,old.bundle_hash,old.captured_by) then + raise exception 'guide source snapshot content is immutable' using errcode='23514'; + end if; + if new.creation_generation is null then + raise exception 'new source snapshots require creation authority' using errcode='23514'; + end if; + 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; + action_value:=new.creation_action_id; + scope_type:=new.creation_scope_type; + scope_project:=new.creation_scope_project_id; + decision_id:=new.authorization_decision_event_id; + product_project:=new.project_id; product_resource:=new.id; + product_generation:=new.creation_generation; + select * into reservation from guide_mutation_idempotency_records + where resource_id=new.id and action_id='project.guide_source_snapshot.create' + and operation_generation=new.creation_generation and status='committed'; + else + if new.authorization_action_id is null then return null; end if; + actor_id:=new.authorized_by_actor_profile_id; + link_id:=new.authorized_via_identity_link_id; + grant_id:=new.authorized_by_admin_role_grant_id; + action_value:=new.authorization_action_id; + scope_type:=new.authorization_scope_type; + scope_project:=new.authorization_scope_project_id; + decision_id:=new.authorization_decision_event_id; + product_project:=new.project_id; product_resource:=new.source_snapshot_id; + select * into reservation from guide_mutation_idempotency_records + where setup_run_id=new.id and action_id='project.guide_source_snapshot.create' + and status='committed'; + product_generation:=reservation.operation_generation; + end if; + if reservation.id is null or product_resource 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.resource_id is distinct from product_resource + or reservation.operation_generation is distinct from product_generation + or scope_type not in ('system','project') + or (scope_type='project' and scope_project is distinct from product_project) + or (scope_type='system' and scope_project is not null) then + raise exception 'guide 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.guide.manage' + or evidence.action_id is distinct from action_value + or evidence.resource_type is distinct from 'project' + or evidence.resource_id is distinct from product_project + or evidence.target_ref_kind is distinct from 'project' + or evidence.target_ref_id is distinct from product_project + 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 'guide mutation evidence mismatch' using errcode='23514'; + end if; + return null; + end $$ + """ + ) + for name, table in ( + ( + "guide_mutation_product_custody", + "project_guides", + ), + ( + "source_snapshot_product_custody", + "guide_source_snapshots", + ), + ( + "source_setup_run_custody", + "project_setup_runs", + ), + ): + op.execute( + f"create constraint trigger {name} after insert or update on {table} " + "deferrable initially deferred for each row execute function validate_guide_mutation_custody()" + ) + op.execute( + "create constraint trigger guide_mutation_reservation_custody after insert or update " + "on guide_mutation_idempotency_records deferrable initially deferred for each row " + "execute function validate_guide_mutation_custody()" + ) + op.execute( + """ + create function reject_guide_source_snapshot_item_mutation() returns trigger + language plpgsql as $$ begin + raise exception 'guide source snapshot items are immutable' using errcode='23514'; + end $$ + """ + ) + op.execute( + "create trigger guide_source_snapshot_items_immutable before update or delete or truncate " + "on guide_source_snapshot_items for each statement " + "execute function reject_guide_source_snapshot_item_mutation()" + ) + op.execute( + """ + create function validate_guide_source_snapshot_items() returns trigger + language plpgsql as $$ + declare expected jsonb; actual jsonb; reservation guide_mutation_idempotency_records%rowtype; + begin + select jsonb_agg(item.value - 'content_excerpt' order by item.ordinality) + into expected + from guide_source_snapshots snapshot, + jsonb_array_elements(snapshot.manifest_json::jsonb->'items') + with ordinality as item(value, ordinality) + where snapshot.id=new.source_snapshot_id; + if expected is null then + raise exception 'guide source snapshot item parent is unavailable' using errcode='23514'; + end if; + select coalesce(jsonb_agg(jsonb_build_object( + 'source_kind',source_kind,'durable_ref',durable_ref, + 'ingestion_adapter',ingestion_adapter,'content_hash',content_hash, + 'content_cid',content_cid,'media_type',media_type) order by item_order),'[]'::jsonb) + into actual from guide_source_snapshot_items + where source_snapshot_id=new.source_snapshot_id; + if actual is distinct from expected then + raise exception 'guide source snapshot items do not match manifest' using errcode='23514'; + end if; + select r.* into reservation from guide_mutation_idempotency_records r + join guide_source_snapshots s on s.id=r.resource_id + where s.id=new.source_snapshot_id + and r.action_id='project.guide_source_snapshot.create' + and r.operation_generation=s.creation_generation and r.status='committed'; + if reservation.id is null then + raise exception 'guide source snapshot item custody mismatch' using errcode='23514'; + end if; + return null; + end $$ + """ + ) + op.execute( + "create constraint trigger guide_source_snapshot_items_custody after insert " + "on guide_source_snapshot_items deferrable initially deferred for each row " + "execute function validate_guide_source_snapshot_items()" + ) + + +def downgrade() -> None: + """Refuse removal once the authority seam has custody of any mutation.""" + bind = op.get_bind() + for table in ( + "guide_mutation_idempotency_records", + "project_guides", + "guide_source_snapshots", + "project_setup_runs", + ): + bind.execute(sa.text(f"lock table {table} in share row exclusive mode")) + used = bind.execute( + sa.text( + "select exists(select 1 from guide_mutation_idempotency_records) " + "or exists(select 1 from project_guides where mutation_generation is not null) " + "or exists(select 1 from guide_source_snapshots where creation_generation is not null) " + "or exists(select 1 from project_setup_runs where authorization_action_id is not null)" + ) + ).scalar_one() + if used: + raise RuntimeError("cannot downgrade used guide source-metadata authority") + op.execute("drop trigger guide_source_snapshot_items_custody on guide_source_snapshot_items") + op.execute("drop function validate_guide_source_snapshot_items()") + op.execute("drop trigger guide_source_snapshot_items_immutable on guide_source_snapshot_items") + op.execute("drop function reject_guide_source_snapshot_item_mutation()") + op.execute( + "drop trigger guide_mutation_reservation_custody on guide_mutation_idempotency_records" + ) + op.execute("drop trigger source_setup_run_custody on project_setup_runs") + op.execute("drop trigger source_snapshot_product_custody on guide_source_snapshots") + op.execute("drop trigger guide_mutation_product_custody on project_guides") + op.execute( + "drop trigger guide_mutation_idempotency_reject_truncate on guide_mutation_idempotency_records" + ) + op.execute( + "drop trigger guide_mutation_idempotency_guard on guide_mutation_idempotency_records" + ) + op.execute("drop function reject_guide_mutation_idempotency_truncate()") + op.execute("drop function guard_guide_mutation_idempotency()") + op.execute("drop function validate_guide_mutation_custody()") + op.drop_table("guide_mutation_idempotency_records") + op.drop_constraint("setup_run_authority_shape", "project_setup_runs", type_="check") + _drop_authority_columns("project_setup_runs", prefix="authorized") + op.drop_constraint( + "source_snapshot_creation_authority_shape", "guide_source_snapshots", type_="check" + ) + op.drop_column("guide_source_snapshots", "creation_generation") + _drop_authority_columns("guide_source_snapshots", prefix="created") + op.drop_constraint("guide_mutation_authority_shape", "project_guides", type_="check") + op.drop_column("project_guides", "mutation_generation") + _drop_authority_columns("project_guides", prefix="last_mutated") diff --git a/backend/app/api/deps/authorization.py b/backend/app/api/deps/authorization.py index 406d846c..9fa6cd75 100644 --- a/backend/app/api/deps/authorization.py +++ b/backend/app/api/deps/authorization.py @@ -3,6 +3,7 @@ from __future__ import annotations from collections.abc import AsyncIterator +import logging from typing import Annotated from uuid import UUID @@ -47,6 +48,8 @@ from app.modules.authorization.catalogue import ActionId from app.schemas.auth import AuthVerificationResult +logger = logging.getLogger(__name__) + def _authorization_context( resolved: ResolvedActor, @@ -106,6 +109,7 @@ async def get_authorization_actor( raise actor_registry_http_error(exc) from exc except SQLAlchemyError as exc: await session.rollback() + logger.exception("authorization actor resolution failed") raise actor_registry_unavailable_error() from exc @@ -209,6 +213,7 @@ async def get_authorization_service( raise actor_registry_unavailable_error() from exc except SQLAlchemyError as exc: await session.rollback() + logger.exception("prepared authorization transaction failed") raise actor_registry_unavailable_error() from exc except BaseException: await session.rollback() @@ -308,6 +313,7 @@ async def get_prepared_authorization_service( raise actor_registry_unavailable_error() from exc except SQLAlchemyError as exc: await session.rollback() + logger.exception("prepared authorization transaction failed") raise actor_registry_unavailable_error() from exc except BaseException: await session.rollback() diff --git a/backend/app/api/router.py b/backend/app/api/router.py index 6405de7c..0a891d92 100644 --- a/backend/app/api/router.py +++ b/backend/app/api/router.py @@ -11,6 +11,7 @@ from app.modules.authorization.router import router as authorization_router from app.modules.projects.router import router as projects_router from app.modules.projects.create_router import router as project_create_router +from app.modules.projects.guide_mutation_router import router as guide_mutation_router from app.modules.tasks.router import router as tasks_router api_router = APIRouter() @@ -20,6 +21,7 @@ api_router.include_router(actors_router, prefix="/api/v1") api_router.include_router(authorization_router, prefix="/api/v1") api_router.include_router(project_create_router, prefix="/api/v1") +api_router.include_router(guide_mutation_router, prefix="/api/v1") api_router.include_router(projects_router, prefix="/api/v1") api_router.include_router(tasks_router, prefix="/api/v1") api_router.include_router(checkers_router, prefix="/api/v1") diff --git a/backend/app/modules/authorization/catalogue.py b/backend/app/modules/authorization/catalogue.py index 8f81d20f..5bf373dd 100644 --- a/backend/app/modules/authorization/catalogue.py +++ b/backend/app/modules/authorization/catalogue.py @@ -435,17 +435,17 @@ def _active( ActionOwner.AUTH_11C2, ), _active(ActionId.PROJECT_CREATE, PermissionId.PROJECT_CREATE, ActionOwner.AUTH_12C), - _planned( + _active( ActionId.PROJECT_GUIDE_CREATE, PermissionId.PROJECT_GUIDE_MANAGE, ActionOwner.AUTH_12D, ), - _planned( + _active( ActionId.PROJECT_GUIDE_UPDATE, PermissionId.PROJECT_GUIDE_MANAGE, ActionOwner.AUTH_12D, ), - _planned( + _active( ActionId.PROJECT_GUIDE_SOURCE_SNAPSHOT_CREATE, PermissionId.PROJECT_GUIDE_MANAGE, ActionOwner.AUTH_12D, @@ -797,6 +797,9 @@ def _index_actions( ActionId.PROJECT_ROLE_GRANT_ISSUE, ActionId.PROJECT_ROLE_GRANT_REVOKE, ActionId.PROJECT_CREATE, + ActionId.PROJECT_GUIDE_CREATE, + ActionId.PROJECT_GUIDE_UPDATE, + ActionId.PROJECT_GUIDE_SOURCE_SNAPSHOT_CREATE, ActionId.PROJECT_READ, ActionId.ACTOR_AUTHORIZATION_CONTEXT_READ, ActionId.PROJECT_SETUP_RUN_READ, diff --git a/backend/app/modules/authorization/kernel.py b/backend/app/modules/authorization/kernel.py index 16cf64e6..25ace084 100644 --- a/backend/app/modules/authorization/kernel.py +++ b/backend/app/modules/authorization/kernel.py @@ -25,6 +25,7 @@ ) from app.modules.authorization.policy import ACTIVE_GUIDE_ADMIN_ROLES from app.modules.authorization.repository import AdminAuthorizationRepository +from app.modules.authorization.schemas import AdminRole from app.modules.authorization.runtime import ( PROJECT_DIAGNOSTIC_TARGET_KIND_BY_ACTION, PROJECT_GUIDE_TARGET_KIND_BY_ACTION, @@ -62,6 +63,7 @@ PermissionCatalogueResourceContext, ProjectContributorCandidateCollectionResourceContext, ProjectCreateResourceContext, + ProjectGuideMutationPrepareDenialResourceContext, ProjectReadResourceContext, ProjectDiagnosticReadResourceContext, ProjectPolicyReadResourceContext, @@ -88,6 +90,14 @@ Awaitable[HumanAuthorizationContext], ] +_GUIDE_METADATA_MUTATIONS = frozenset( + { + ActionId.PROJECT_GUIDE_CREATE, + ActionId.PROJECT_GUIDE_UPDATE, + ActionId.PROJECT_GUIDE_SOURCE_SNAPSHOT_CREATE, + } +) + ServiceContextRevalidator = Callable[ [ServiceAuthorizationContext, ActionId], Awaitable[ServiceAuthorizationContext | None], @@ -239,6 +249,7 @@ class _PrelockedAuthority: "_frozen", "issuer", "matched_grant_id", + "matched_grant_scope_project_id", "matched_grant_status", "permission_id", "scope_project_id", @@ -260,6 +271,7 @@ def __init__( action_id: ActionId, scope_project_id: UUID | None, matched_grant_id: UUID | None, + matched_grant_scope_project_id: UUID | None, matched_grant_status: str | None, permission_id: PermissionId, artifact_resource_type: str | None = None, @@ -271,6 +283,9 @@ def __init__( object.__setattr__(self, "action_id", action_id) object.__setattr__(self, "scope_project_id", scope_project_id) object.__setattr__(self, "matched_grant_id", matched_grant_id) + object.__setattr__( + self, "matched_grant_scope_project_id", matched_grant_scope_project_id + ) object.__setattr__(self, "matched_grant_status", matched_grant_status) object.__setattr__(self, "permission_id", permission_id) object.__setattr__(self, "artifact_resource_type", artifact_resource_type) @@ -387,6 +402,7 @@ async def _prepare_prelocked( action_id=action_id, scope_project_id=None, matched_grant_id=None, + matched_grant_scope_project_id=None, matched_grant_status=None, permission_id=action.permission_id, artifact_resource_type=scope.artifact_resource_type, @@ -455,15 +471,35 @@ async def _prepare_prelocked( raise PreparedAuthorizationUnsupported( AuthorizationDenialCode.PERMISSION_NOT_GRANTED ) + elif action_id in _GUIDE_METADATA_MUTATIONS: + if ( + not isinstance(context, HumanAuthorizationContext) + or scope.kind is not PreparedAuthorityScopeKind.PROJECT + or scope.project_id is None + ): + raise PreparedAuthorizationUnsupported(AuthorizationDenialCode.SCOPE_NOT_AUTHORIZED) + locked = await self._admin.lock_request_actor( + context.identity_link_id, context.actor_profile_id + ) + context = self._locked_human_context(locked, context) + grant = await self._admin.find_effective_grant( + context.actor_profile_id, + action.permission_id, + scope_project_id=scope.project_id, + for_update=True, + allowed_roles=frozenset({AdminRole.PROJECT_MANAGER}), + ) + if grant is None: + raise PreparedAuthorizationUnsupported( + AuthorizationDenialCode.PERMISSION_NOT_GRANTED + ) elif action_id is ActionId.PROJECT_CREATE: if not isinstance(context, HumanAuthorizationContext): raise PreparedAuthorizationUnsupported( AuthorizationDenialCode.PERMISSION_NOT_GRANTED ) if scope.kind is not PreparedAuthorityScopeKind.SYSTEM: - raise PreparedAuthorizationUnsupported( - AuthorizationDenialCode.SCOPE_NOT_AUTHORIZED - ) + raise PreparedAuthorizationUnsupported(AuthorizationDenialCode.SCOPE_NOT_AUTHORIZED) locked = await self._admin.lock_request_actor( context.identity_link_id, context.actor_profile_id ) @@ -515,6 +551,11 @@ async def _prepare_prelocked( action_id=action_id, scope_project_id=scope.project_id, matched_grant_id=UUID(str(grant.id)) if grant is not None else None, + matched_grant_scope_project_id=( + UUID(str(grant.scope_project_id)) + if grant is not None and getattr(grant, "scope_project_id", None) is not None + else None + ), matched_grant_status=grant.status if grant is not None else None, permission_id=action.permission_id, ) @@ -580,9 +621,14 @@ async def _complete_prepared_denial( """Persist one exact prepare-time denial without issuing a capability.""" self._validate_prepared_consumer(consumer_token) action = ACTION_BY_ID.get(action_id) - if action_id is not ActionId.PROJECT_CREATE or not isinstance( - resource_context, ProjectCreateResourceContext - ): + supported = ( + action_id is ActionId.PROJECT_CREATE + and isinstance(resource_context, ProjectCreateResourceContext) + ) or ( + action_id in _GUIDE_METADATA_MUTATIONS + and isinstance(resource_context, ProjectGuideMutationPrepareDenialResourceContext) + ) + if not supported: raise TypeError("unsupported prepared denial") await self._complete_decision( action=action, @@ -902,19 +948,40 @@ async def _require_prelocked( matched_kind = MatchedAuthorityKind.ADMIN_ROLE_GRANT matched_grant_id = authority.matched_grant_id matched_project_id = authority.scope_project_id - elif action_id is ActionId.PROJECT_CREATE: + elif action_id in _GUIDE_METADATA_MUTATIONS: denial = self._lifecycle_denial(context) + expected = PROJECT_MUTATION_RESOURCE_BY_ACTION.get(action_id) if denial is None and action.availability is not ActionAvailability.ACTIVE: denial = AuthorizationDenialCode.ACTION_UNAVAILABLE - if denial is None and not isinstance( - resource_context, ProjectCreateResourceContext + if denial is None and (expected is None or not isinstance(resource_context, expected)): + denial = AuthorizationDenialCode.RESOURCE_GUARD_DENIED + if denial is None and (resource_context.scope_project_id != authority.scope_project_id): + denial = AuthorizationDenialCode.SCOPE_NOT_AUTHORIZED + guide_kind = PROJECT_GUIDE_TARGET_KIND_BY_ACTION.get(action_id) + if ( + denial is None + and guide_kind is not None + and (resource_context.target_kind != guide_kind) ): denial = AuthorizationDenialCode.RESOURCE_GUARD_DENIED + if denial is None and ( + authority.matched_grant_id is None or authority.matched_grant_status != "active" + ): + denial = AuthorizationDenialCode.PERMISSION_NOT_GRANTED + if denial is None: + matched_kind = MatchedAuthorityKind.ADMIN_ROLE_GRANT + matched_grant_id = authority.matched_grant_id + matched_project_id = authority.matched_grant_scope_project_id + elif action_id is ActionId.PROJECT_CREATE: + denial = self._lifecycle_denial(context) + if denial is None and action.availability is not ActionAvailability.ACTIVE: + denial = AuthorizationDenialCode.ACTION_UNAVAILABLE + if denial is None and not isinstance(resource_context, ProjectCreateResourceContext): + denial = AuthorizationDenialCode.RESOURCE_GUARD_DENIED if denial is None and authority.scope_project_id is not None: denial = AuthorizationDenialCode.SCOPE_NOT_AUTHORIZED if denial is None and ( - authority.matched_grant_id is None - or authority.matched_grant_status != "active" + authority.matched_grant_id is None or authority.matched_grant_status != "active" ): denial = AuthorizationDenialCode.PERMISSION_NOT_GRANTED if denial is None: @@ -1248,9 +1315,7 @@ async def restage_denial(self, decision: AuthorizationDecision) -> None: resource_context = self._pending_denial_resource_context if resource_context is None: raise TypeError("missing authorization denial resource context") - await self._stage_decision( - decision, self._context.actor_profile_id, resource_context - ) + await self._stage_decision(decision, self._context.actor_profile_id, resource_context) self._pending_denial = None self._pending_denial_resource_context = None @@ -1333,6 +1398,11 @@ async def _stage_decision( audit_resource_id = None target_ref_kind = None target_ref_id = None + audit_project_id = ( + str(decision.matched_scope_project_id) + if decision.matched_scope_project_id is not None + else None + ) if target_is_actor: audit_resource_type = "actor_profile" elif decision.resource_type in {"actor_identity_link", "admin_role_grant"}: @@ -1342,20 +1412,28 @@ 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 _GUIDE_METADATA_MUTATIONS: + if resource_context is not None: + project_id = self._resource_project_id(resource_context) + if project_id is not None: + audit_project_id = str(project_id) + audit_resource_type = "project" + audit_resource_id = str(project_id) + target_ref_kind = "project" + target_ref_id = str(project_id) after_facts: dict[str, object] = {"allowed": decision.allowed} - if ( - decision.resource_type - in { - "artifact_put_attempt", - "artifact_verification_job", - "artifact_pending_work", - "project_diagnostic", - "project_policy_read", - "project_active_guide_read", - } - or decision.action_id - in {ActionId.ARTIFACT_GUIDE_SOURCE_INGEST, ActionId.PROJECT_CREATE} - ): + if decision.resource_type in { + "artifact_put_attempt", + "artifact_verification_job", + "artifact_pending_work", + "project_diagnostic", + "project_policy_read", + "project_active_guide_read", + } or decision.action_id in { + ActionId.ARTIFACT_GUIDE_SOURCE_INGEST, + ActionId.PROJECT_CREATE, + *_GUIDE_METADATA_MUTATIONS, + }: after_facts["resource_context_digest"] = decision.resource_context_digest try: await self._audit.add_authority_event( @@ -1383,11 +1461,7 @@ async def _stage_decision( ), permission_id=decision.permission_id, action_id=decision.action_id, - project_id=( - str(decision.matched_scope_project_id) - if decision.matched_scope_project_id is not None - else None - ), + project_id=audit_project_id, resource_type=audit_resource_type, resource_id=( audit_resource_id diff --git a/backend/app/modules/authorization/prepared.py b/backend/app/modules/authorization/prepared.py index ee0a24ac..24e93e4f 100644 --- a/backend/app/modules/authorization/prepared.py +++ b/backend/app/modules/authorization/prepared.py @@ -34,6 +34,9 @@ PreparedAuthorityScopeKind, PROJECT_MUTATION_RESOURCE_BY_ACTION, ProjectCreateResourceContext, + ProjectGuideMutationResourceContext, + ProjectGuideMutationPrepareDenialResourceContext, + ProjectGuideSourceSnapshotMutationResourceContext, ) @@ -74,6 +77,10 @@ class _PreparedAuthorizationBinding: project_create_operation_id: UUID | None = None project_create_project_id: UUID | None = None project_create_generation: int | None = None + guide_mutation_project_id: UUID | None = None + guide_mutation_guide_id: UUID | None = None + guide_mutation_target_resource_id: UUID | None = None + guide_mutation_operation_id: UUID | None = None @dataclass(slots=True) @@ -101,6 +108,20 @@ def _project_create_binding_matches( ) +def _guide_mutation_binding_matches( + binding: _PreparedAuthorizationBinding, + resource: ProjectGuideMutationResourceContext + | ProjectGuideSourceSnapshotMutationResourceContext, +) -> bool: + """Return whether final guide lineage matches the prepared route selectors.""" + return ( + binding.guide_mutation_project_id == resource.scope_project_id + and binding.guide_mutation_guide_id == resource.guide_id + and binding.guide_mutation_target_resource_id == resource.resource_id + and binding.guide_mutation_operation_id == resource.operation_id + ) + + _CONSUMED = _Consumed() @@ -175,6 +196,11 @@ async def consume( _project_create_binding_matches(issuance.binding, final_resource_context) ): raise PreparedAuthorizationHandleInvalid("invalid prepared authorization handle") + if isinstance( + final_resource_context, + (ProjectGuideMutationResourceContext, ProjectGuideSourceSnapshotMutationResourceContext), + ) and not _guide_mutation_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, @@ -191,17 +217,16 @@ async def deny_unsupported( denial: PreparedAuthorizationUnsupported, ) -> NoReturn: """Evidence an exact prepare-time denial without issuing a handle.""" - binding = self._binding( - action_id, - caller_input, - PreparedAuthorityScope(kind=PreparedAuthorityScopeKind.SYSTEM), + scope = ( + PreparedAuthorityScope(kind=PreparedAuthorityScopeKind.SYSTEM) + if isinstance(final_resource_context, ProjectCreateResourceContext) + else self._scope_from_resource(action_id, final_resource_context) ) - if not isinstance( - final_resource_context, ProjectCreateResourceContext - ) or not _project_create_binding_matches(binding, final_resource_context): - raise PreparedAuthorizationHandleInvalid( - "invalid prepared authorization handle" - ) + binding = self._binding(action_id, caller_input, scope) + if isinstance(final_resource_context, ProjectCreateResourceContext) and not ( + _project_create_binding_matches(binding, final_resource_context) + ): + raise PreparedAuthorizationHandleInvalid("invalid prepared authorization handle") await self._authorization._complete_prepared_denial( self._consumer_token, action_id, @@ -215,9 +240,7 @@ def close(self) -> None: for issuance in self._issued.values(): if isinstance(issuance, _Issuance): self._authorization._discard_prelocked(issuance.authority) - self._authorization._unregister_prepared_consumer( - self._consumer_token, self - ) + self._authorization._unregister_prepared_consumer(self._consumer_token, self) self._closed = True self._issued.clear() @@ -247,6 +270,8 @@ def _binding( ) -> _PreparedAuthorizationBinding: operation_id = project_id = None operation_generation = None + guide_mutation_project_id = guide_mutation_guide_id = None + guide_mutation_target_resource_id = guide_mutation_operation_id = None if action_id is ActionId.PROJECT_CREATE: try: operation_id = UUID(str(caller_input.request_value["operation_id"])) @@ -260,6 +285,43 @@ def _binding( type(operation_generation) is not int or operation_generation < 1 or operation_id == project_id + ): + raise PreparedAuthorizationHandleInvalid("invalid prepared authorization handle") + if action_id in { + ActionId.PROJECT_GUIDE_CREATE, + ActionId.PROJECT_GUIDE_UPDATE, + ActionId.PROJECT_GUIDE_SOURCE_SNAPSHOT_CREATE, + }: + try: + guide_mutation_project_id = UUID( + str(caller_input.request_value["project_id"]) + ) + raw_guide_id = caller_input.request_value.get("guide_id") + guide_mutation_guide_id = ( + UUID(str(raw_guide_id)) if raw_guide_id is not None else None + ) + guide_mutation_target_resource_id = UUID( + str(caller_input.request_value["target_resource_id"]) + ) + guide_mutation_operation_id = UUID( + str(caller_input.request_value["operation_id"]) + ) + except (KeyError, TypeError, ValueError) as exc: + raise PreparedAuthorizationHandleInvalid( + "invalid prepared authorization handle" + ) from exc + if ( + guide_mutation_guide_id is None + or guide_mutation_target_resource_id is None + or guide_mutation_operation_id is None + or ( + action_id is ActionId.PROJECT_GUIDE_SOURCE_SNAPSHOT_CREATE + and guide_mutation_target_resource_id == guide_mutation_guide_id + ) + or ( + action_id is not ActionId.PROJECT_GUIDE_SOURCE_SNAPSHOT_CREATE + and guide_mutation_target_resource_id != guide_mutation_guide_id + ) ): raise PreparedAuthorizationHandleInvalid( "invalid prepared authorization handle" @@ -279,6 +341,10 @@ def _binding( project_create_operation_id=operation_id, project_create_project_id=project_id, project_create_generation=operation_generation, + guide_mutation_project_id=guide_mutation_project_id, + guide_mutation_guide_id=guide_mutation_guide_id, + guide_mutation_target_resource_id=guide_mutation_target_resource_id, + guide_mutation_operation_id=guide_mutation_operation_id, ) @staticmethod @@ -317,6 +383,15 @@ def _scope_from_resource( grant_id=grant_id, ) expected_project_mutation = PROJECT_MUTATION_RESOURCE_BY_ACTION.get(action_id) + if action_id in { + ActionId.PROJECT_GUIDE_CREATE, + ActionId.PROJECT_GUIDE_UPDATE, + ActionId.PROJECT_GUIDE_SOURCE_SNAPSHOT_CREATE, + } and isinstance(resource, ProjectGuideMutationPrepareDenialResourceContext): + return PreparedAuthorityScope( + kind=PreparedAuthorityScopeKind.PROJECT, + project_id=resource.scope_project_id, + ) if expected_project_mutation is not None and isinstance( resource, expected_project_mutation ): @@ -331,9 +406,7 @@ def _scope_from_resource( ActionId.ARTIFACT_VERIFICATION_EXECUTE: ArtifactVerificationJobResourceContext, ActionId.ARTIFACT_PENDING_WORK_SCAN: ArtifactPendingWorkResourceContext, }.get(action_id) - if artifact_resource_type is not None and isinstance( - resource, artifact_resource_type - ): + if artifact_resource_type is not None and isinstance(resource, artifact_resource_type): return PreparedAuthorityScope( kind=PreparedAuthorityScopeKind.ARTIFACT_INTERNAL, artifact_resource_type=resource.resource_type, diff --git a/backend/app/modules/authorization/runtime.py b/backend/app/modules/authorization/runtime.py index e8a20203..18bdd176 100644 --- a/backend/app/modules/authorization/runtime.py +++ b/backend/app/modules/authorization/runtime.py @@ -472,12 +472,15 @@ class ProjectGuideMutationResourceContext(BaseModel): resource_type: Literal["project_guide_mutation"] resource_id: UUID + operation_id: UUID scope_project_id: UUID guide_id: UUID target_kind: Literal["create", "update"] guide_exists: bool guide_status: str | None = None guide_version: str | None = None + predecessor_snapshot_id: UUID | None = None + predecessor_snapshot_hash: str | None = Field(default=None, pattern=r"^sha256:[0-9a-f]{64}$") operation_generation: int = Field(ge=1) @model_validator(mode="after") @@ -489,6 +492,32 @@ def require_guide_identity(self): raise ValueError("guide mutation lifecycle facts are inconsistent") if self.guide_exists != (self.target_kind == "update"): raise ValueError("guide mutation operation and existence are inconsistent") + if (self.predecessor_snapshot_id is None) != (self.predecessor_snapshot_hash is None): + raise ValueError("guide mutation predecessor facts must be bound together") + if self.target_kind == "create" and self.predecessor_snapshot_id is not None: + raise ValueError("guide creation cannot bind predecessor source lineage") + return self + + +class ProjectGuideMutationPrepareDenialResourceContext(BaseModel): + """Requested guide target used only to evidence a prepare-time denial.""" + + model_config = _STRICT_FROZEN + + resource_type: Literal["project_guide_mutation_request"] + resource_id: UUID + scope_project_id: UUID + requested_guide_id: UUID | None = None + requested_target_kind: Literal["guide_create", "guide_update", "source_snapshot_create"] + + @model_validator(mode="after") + def require_requested_target(self): + """Bind creates to the project and existing-guide requests to a guide id.""" + if self.requested_target_kind == "guide_create": + if self.resource_id != self.scope_project_id or self.requested_guide_id is not None: + raise ValueError("guide-create denial must identify only the project") + elif self.requested_guide_id is None or self.resource_id != self.requested_guide_id: + raise ValueError("guide mutation denial must identify the requested guide") return self @@ -499,15 +528,15 @@ class ProjectGuideSourceSnapshotMutationResourceContext(BaseModel): resource_type: Literal["project_guide_source_snapshot_mutation"] resource_id: UUID + operation_id: UUID scope_project_id: UUID guide_id: UUID guide_version: str guide_status: str source_snapshot_id: UUID + source_snapshot_hash: str = Field(pattern=r"^sha256:[0-9a-f]{64}$") predecessor_snapshot_id: UUID | None = None - predecessor_snapshot_hash: str | None = Field( - default=None, pattern=r"^sha256:[0-9a-f]{64}$" - ) + predecessor_snapshot_hash: str | None = Field(default=None, pattern=r"^sha256:[0-9a-f]{64}$") operation_generation: int = Field(ge=1) @model_validator(mode="after") @@ -805,70 +834,80 @@ def require_activation_identity(self): return self -PROJECT_MUTATION_RESOURCE_BY_ACTION = MappingProxyType({ - ActionId.PROJECT_CREATE: ProjectCreateResourceContext, - ActionId.PROJECT_GUIDE_CREATE: ProjectGuideMutationResourceContext, - ActionId.PROJECT_GUIDE_UPDATE: ProjectGuideMutationResourceContext, - ActionId.PROJECT_GUIDE_SOURCE_SNAPSHOT_CREATE: ( - ProjectGuideSourceSnapshotMutationResourceContext - ), - ActionId.PROJECT_REVIEW_POLICY_UPDATE: ProjectReviewPolicyMutationResourceContext, - ActionId.PROJECT_REVISION_POLICY_UPDATE: ProjectRevisionPolicyMutationResourceContext, - ActionId.PROJECT_GUIDE_SUFFICIENCY_REPORT_CREATE: ( - ProjectGuideSufficiencyMutationResourceContext - ), - ActionId.PROJECT_GUIDE_SUFFICIENCY_RUN: ProjectGuideSufficiencyMutationResourceContext, - ActionId.PROJECT_GUIDE_SUFFICIENCY_WARNINGS_ACKNOWLEDGE: ( - ProjectGuideSufficiencyMutationResourceContext - ), - ActionId.PROJECT_SUBMISSION_ARTIFACT_POLICY_CREATE: ( - ProjectSubmissionArtifactPolicyMutationResourceContext - ), - ActionId.PROJECT_SUBMISSION_ARTIFACT_POLICY_DERIVE: ( - ProjectSubmissionArtifactPolicyMutationResourceContext - ), - ActionId.PROJECT_SUBMISSION_ARTIFACT_POLICY_UPDATE: ( - ProjectSubmissionArtifactPolicyMutationResourceContext - ), - ActionId.PROJECT_SUBMISSION_ARTIFACT_POLICY_APPROVE: ( - ProjectSubmissionArtifactPolicyMutationResourceContext - ), - ActionId.PROJECT_POST_SUBMIT_CHECKER_POLICY_APPROVE: ( - ProjectPostSubmitCheckerPolicyMutationResourceContext - ), - ActionId.PROJECT_POST_SUBMIT_CHECKER_POLICY_CORRECTION_REQUEST: ( - ProjectPostSubmitCheckerPolicyMutationResourceContext - ), - ActionId.PROJECT_POST_SUBMIT_CHECKER_POLICY_DERIVE: ( - ProjectPostSubmitCheckerPolicyMutationResourceContext - ), - ActionId.PROJECT_SETUP_RUN_UPDATE: ProjectSetupRunMutationResourceContext, - ActionId.PROJECT_GUIDE_ACTIVATE: ProjectGuideActivationResourceContext, -}) - -PROJECT_SUFFICIENCY_TARGET_KIND_BY_ACTION = MappingProxyType({ - ActionId.PROJECT_GUIDE_SUFFICIENCY_REPORT_CREATE: "report", - ActionId.PROJECT_GUIDE_SUFFICIENCY_RUN: "run", - ActionId.PROJECT_GUIDE_SUFFICIENCY_WARNINGS_ACKNOWLEDGE: "warning_acknowledgement", -}) - -PROJECT_GUIDE_TARGET_KIND_BY_ACTION = MappingProxyType({ - ActionId.PROJECT_GUIDE_CREATE: "create", - ActionId.PROJECT_GUIDE_UPDATE: "update", -}) - -PROJECT_SUBMISSION_POLICY_TARGET_KIND_BY_ACTION = MappingProxyType({ - ActionId.PROJECT_SUBMISSION_ARTIFACT_POLICY_CREATE: "create", - ActionId.PROJECT_SUBMISSION_ARTIFACT_POLICY_DERIVE: "derive", - ActionId.PROJECT_SUBMISSION_ARTIFACT_POLICY_UPDATE: "update", - ActionId.PROJECT_SUBMISSION_ARTIFACT_POLICY_APPROVE: "approve", -}) - -PROJECT_POST_SUBMIT_POLICY_TARGET_KIND_BY_ACTION = MappingProxyType({ - ActionId.PROJECT_POST_SUBMIT_CHECKER_POLICY_APPROVE: "approve", - ActionId.PROJECT_POST_SUBMIT_CHECKER_POLICY_CORRECTION_REQUEST: "correction_request", - ActionId.PROJECT_POST_SUBMIT_CHECKER_POLICY_DERIVE: "derive", -}) +PROJECT_MUTATION_RESOURCE_BY_ACTION = MappingProxyType( + { + ActionId.PROJECT_CREATE: ProjectCreateResourceContext, + ActionId.PROJECT_GUIDE_CREATE: ProjectGuideMutationResourceContext, + ActionId.PROJECT_GUIDE_UPDATE: ProjectGuideMutationResourceContext, + ActionId.PROJECT_GUIDE_SOURCE_SNAPSHOT_CREATE: ( + ProjectGuideSourceSnapshotMutationResourceContext + ), + ActionId.PROJECT_REVIEW_POLICY_UPDATE: ProjectReviewPolicyMutationResourceContext, + ActionId.PROJECT_REVISION_POLICY_UPDATE: ProjectRevisionPolicyMutationResourceContext, + ActionId.PROJECT_GUIDE_SUFFICIENCY_REPORT_CREATE: ( + ProjectGuideSufficiencyMutationResourceContext + ), + ActionId.PROJECT_GUIDE_SUFFICIENCY_RUN: ProjectGuideSufficiencyMutationResourceContext, + ActionId.PROJECT_GUIDE_SUFFICIENCY_WARNINGS_ACKNOWLEDGE: ( + ProjectGuideSufficiencyMutationResourceContext + ), + ActionId.PROJECT_SUBMISSION_ARTIFACT_POLICY_CREATE: ( + ProjectSubmissionArtifactPolicyMutationResourceContext + ), + ActionId.PROJECT_SUBMISSION_ARTIFACT_POLICY_DERIVE: ( + ProjectSubmissionArtifactPolicyMutationResourceContext + ), + ActionId.PROJECT_SUBMISSION_ARTIFACT_POLICY_UPDATE: ( + ProjectSubmissionArtifactPolicyMutationResourceContext + ), + ActionId.PROJECT_SUBMISSION_ARTIFACT_POLICY_APPROVE: ( + ProjectSubmissionArtifactPolicyMutationResourceContext + ), + ActionId.PROJECT_POST_SUBMIT_CHECKER_POLICY_APPROVE: ( + ProjectPostSubmitCheckerPolicyMutationResourceContext + ), + ActionId.PROJECT_POST_SUBMIT_CHECKER_POLICY_CORRECTION_REQUEST: ( + ProjectPostSubmitCheckerPolicyMutationResourceContext + ), + ActionId.PROJECT_POST_SUBMIT_CHECKER_POLICY_DERIVE: ( + ProjectPostSubmitCheckerPolicyMutationResourceContext + ), + ActionId.PROJECT_SETUP_RUN_UPDATE: ProjectSetupRunMutationResourceContext, + ActionId.PROJECT_GUIDE_ACTIVATE: ProjectGuideActivationResourceContext, + } +) + +PROJECT_SUFFICIENCY_TARGET_KIND_BY_ACTION = MappingProxyType( + { + ActionId.PROJECT_GUIDE_SUFFICIENCY_REPORT_CREATE: "report", + ActionId.PROJECT_GUIDE_SUFFICIENCY_RUN: "run", + ActionId.PROJECT_GUIDE_SUFFICIENCY_WARNINGS_ACKNOWLEDGE: "warning_acknowledgement", + } +) + +PROJECT_GUIDE_TARGET_KIND_BY_ACTION = MappingProxyType( + { + ActionId.PROJECT_GUIDE_CREATE: "create", + ActionId.PROJECT_GUIDE_UPDATE: "update", + } +) + +PROJECT_SUBMISSION_POLICY_TARGET_KIND_BY_ACTION = MappingProxyType( + { + ActionId.PROJECT_SUBMISSION_ARTIFACT_POLICY_CREATE: "create", + ActionId.PROJECT_SUBMISSION_ARTIFACT_POLICY_DERIVE: "derive", + ActionId.PROJECT_SUBMISSION_ARTIFACT_POLICY_UPDATE: "update", + ActionId.PROJECT_SUBMISSION_ARTIFACT_POLICY_APPROVE: "approve", + } +) + +PROJECT_POST_SUBMIT_POLICY_TARGET_KIND_BY_ACTION = MappingProxyType( + { + ActionId.PROJECT_POST_SUBMIT_CHECKER_POLICY_APPROVE: "approve", + ActionId.PROJECT_POST_SUBMIT_CHECKER_POLICY_CORRECTION_REQUEST: "correction_request", + ActionId.PROJECT_POST_SUBMIT_CHECKER_POLICY_DERIVE: "derive", + } +) class ActorAuthorizationContextResourceContext(BaseModel): @@ -1183,6 +1222,7 @@ def bind_page_size(self): | ProjectActiveGuideReadResourceContext | ProjectCreateResourceContext | ProjectGuideMutationResourceContext + | ProjectGuideMutationPrepareDenialResourceContext | ProjectGuideSourceSnapshotMutationResourceContext | ProjectReviewPolicyMutationResourceContext | ProjectRevisionPolicyMutationResourceContext @@ -1276,6 +1316,9 @@ class AuthorizationDecision(BaseModel): "project_policy_read", "project_active_guide_read", "project_create", + "project_guide_mutation", + "project_guide_source_snapshot_mutation", + "project_guide_mutation_request", "actor_identity_link", "system", "permission_catalogue", diff --git a/backend/app/modules/projects/guide_mutation_repository.py b/backend/app/modules/projects/guide_mutation_repository.py new file mode 100644 index 00000000..d955a465 --- /dev/null +++ b/backend/app/modules/projects/guide_mutation_repository.py @@ -0,0 +1,110 @@ +"""Persistence for idempotent guide-metadata mutations.""" + +from __future__ import annotations + +from datetime import UTC, datetime +from uuid import UUID, uuid4 + +from sqlalchemy import select, update +from sqlalchemy.dialects.postgresql import insert +from sqlalchemy.ext.asyncio import AsyncSession + +from app.modules.projects.models import GuideMutationIdempotencyRecord +from app.modules.projects.repository import ProjectRepositoryIntegrityError + + +class GuideMutationRepository: + """Own guide-mutation replay reservations in the caller transaction.""" + + def __init__(self, session: AsyncSession) -> None: + self._session = session + + async def find( + self, actor_profile_id: str, action_id: str, idempotency_key: UUID + ) -> GuideMutationIdempotencyRecord | None: + """Load an existing replay record without creating product state.""" + return await self._session.scalar( + select(GuideMutationIdempotencyRecord).where( + GuideMutationIdempotencyRecord.actor_profile_id == actor_profile_id, + GuideMutationIdempotencyRecord.action_id == action_id, + GuideMutationIdempotencyRecord.idempotency_key == idempotency_key, + ) + ) + + async def reserve( + self, + *, + actor_profile_id: str, + identity_link_id: str, + action_id: str, + idempotency_key: UUID, + request_digest: str, + resource_context_digest: str, + operation_id: UUID, + project_id: str, + resource_id: str, + operation_generation: int, + ) -> tuple[str, GuideMutationIdempotencyRecord]: + """Claim or lock one actor/action replay namespace.""" + values = { + "id": uuid4(), + "actor_profile_id": actor_profile_id, + "identity_link_id": identity_link_id, + "action_id": action_id, + "idempotency_key": idempotency_key, + "request_digest": request_digest, + "resource_context_digest": resource_context_digest, + "operation_id": operation_id, + "project_id": project_id, + "resource_id": resource_id, + "operation_generation": operation_generation, + "status": "pending", + } + record_id = await self._session.scalar( + insert(GuideMutationIdempotencyRecord) + .values(**values) + .on_conflict_do_update( + index_elements=[ + GuideMutationIdempotencyRecord.actor_profile_id, + GuideMutationIdempotencyRecord.action_id, + GuideMutationIdempotencyRecord.idempotency_key, + ], + set_={"id": GuideMutationIdempotencyRecord.id}, + ) + .returning(GuideMutationIdempotencyRecord.id) + ) + if record_id is None: + raise ProjectRepositoryIntegrityError("guide mutation reservation disappeared") + record = await self._session.get(GuideMutationIdempotencyRecord, record_id) + if record is None: + raise ProjectRepositoryIntegrityError("guide mutation reservation disappeared") + if record_id == values["id"]: + return "claimed", record + if record.identity_link_id != identity_link_id or record.request_digest != request_digest: + return "mismatch", record + return ("replayed" if record.status == "committed" else "pending"), record + + async def complete( + self, + record: GuideMutationIdempotencyRecord, + *, + response_json: dict, + setup_run_id: str | None = None, + ) -> None: + """Complete one reservation with its exact replay response.""" + completed = await self._session.scalar( + update(GuideMutationIdempotencyRecord) + .where( + GuideMutationIdempotencyRecord.id == record.id, + GuideMutationIdempotencyRecord.status == "pending", + ) + .values( + status="committed", + response_json=response_json, + setup_run_id=setup_run_id, + committed_at=datetime.now(UTC), + ) + .returning(GuideMutationIdempotencyRecord.id) + ) + if completed is None: + raise ProjectRepositoryIntegrityError("invalid guide mutation completion") diff --git a/backend/app/modules/projects/guide_mutation_router.py b/backend/app/modules/projects/guide_mutation_router.py new file mode 100644 index 00000000..6c482db8 --- /dev/null +++ b/backend/app/modules/projects/guide_mutation_router.py @@ -0,0 +1,156 @@ +"""FastAPI boundary for authorized guide metadata mutations.""" + +from __future__ import annotations + +from typing import Annotated +from uuid import UUID + +from fastapi import APIRouter, Depends, Header, HTTPException +from sqlalchemy.ext.asyncio import AsyncSession + +from app.api.deps.authorization import get_authorization_actor, get_prepared_authorization_service +from app.core.api_controls import StructuredHTTPException +from app.db.session import get_db_session +from app.modules.actors.service import ResolvedActor +from app.modules.authorization.catalogue import ActionId +from app.modules.authorization.prepared import PreparedAuthorizationService +from app.modules.projects.guide_mutation_service import ( + GuideMutationIdempotencyConflict, + GuideMutationService, +) +from app.modules.projects.schemas import ( + GuideSourceSnapshotCreate, + GuideSourceSnapshotResponse, + ProjectGuideCreate, + ProjectGuideResponse, + ProjectGuideUpdate, +) +from app.modules.projects.service import ProjectServiceError +from app.modules.projects.setup_queue import dispatch_pre_submit_setup_pipeline_after_commit + +router = APIRouter(prefix="/projects", tags=["projects"]) + + +def require_guide_mutation_key( + idempotency_key: Annotated[str, Header(alias="Idempotency-Key")], +) -> UUID: + """Validate replay custody before actor provisioning.""" + try: + return UUID(idempotency_key) + except ValueError as exc: + raise StructuredHTTPException( + status_code=422, + detail="Idempotency-Key must be a UUID", + error_code="validation_error", + error_message="Idempotency-Key must be a UUID", + ) from exc + + +async def guide_authorization( + key: Annotated[UUID, Depends(require_guide_mutation_key)], + resolved: Annotated[ResolvedActor, Depends(get_authorization_actor)], + prepared: Annotated[PreparedAuthorizationService, Depends(get_prepared_authorization_service)], +): + return key, resolved, prepared + + +def _error(exc: ProjectServiceError): + if isinstance(exc, GuideMutationIdempotencyConflict): + code = str(exc) + return StructuredHTTPException( + status_code=409, + detail=code, + error_code=code, + error_message="Idempotency key does not match" + if code == "idempotency_mismatch" + else "Guide mutation is already in progress", + retryable=code == "idempotency_pending", + ) + return HTTPException(status_code=exc.status_code, detail=str(exc)) + + +async def _finish(session, outcome): + await (session.rollback() if outcome.replayed else session.commit()) + if outcome.setup_run_id and not outcome.replayed: + snapshot = outcome.response + await dispatch_pre_submit_setup_pipeline_after_commit( + session, + project_id=snapshot.project_id, + guide_id=snapshot.guide_id, + source_snapshot_id=snapshot.id, + setup_run_id=outcome.setup_run_id, + ) + return outcome.response + + +@router.post( + "/{project_id}/guides", + response_model=ProjectGuideResponse, + status_code=201, + openapi_extra={"x-workstream-action-id": ActionId.PROJECT_GUIDE_CREATE.value}, +) +async def create_guide( + project_id: UUID, + payload: ProjectGuideCreate, + authorization: Annotated[tuple, Depends(guide_authorization)], + session: Annotated[AsyncSession, Depends(get_db_session)], +): + key, resolved, prepared = authorization + try: + return await _finish( + session, + await GuideMutationService(session).create_guide( + resolved, prepared, key, project_id, payload + ), + ) + except ProjectServiceError as exc: + raise _error(exc) from exc + + +@router.patch( + "/{project_id}/guides/{guide_id}", + response_model=ProjectGuideResponse, + openapi_extra={"x-workstream-action-id": ActionId.PROJECT_GUIDE_UPDATE.value}, +) +async def update_guide( + project_id: UUID, + guide_id: UUID, + payload: ProjectGuideUpdate, + authorization: Annotated[tuple, Depends(guide_authorization)], + session: Annotated[AsyncSession, Depends(get_db_session)], +): + key, resolved, prepared = authorization + try: + return await _finish( + session, + await GuideMutationService(session).update_guide( + resolved, prepared, key, project_id, guide_id, payload + ), + ) + except ProjectServiceError as exc: + raise _error(exc) from exc + + +@router.post( + "/{project_id}/guides/{guide_id}/source-snapshots", + response_model=GuideSourceSnapshotResponse, + status_code=201, + openapi_extra={"x-workstream-action-id": ActionId.PROJECT_GUIDE_SOURCE_SNAPSHOT_CREATE.value}, +) +async def create_snapshot( + project_id: UUID, + guide_id: UUID, + payload: GuideSourceSnapshotCreate, + authorization: Annotated[tuple, Depends(guide_authorization)], + session: Annotated[AsyncSession, Depends(get_db_session)], +): + key, resolved, prepared = authorization + try: + return await _finish( + session, + await GuideMutationService(session).create_snapshot( + resolved, prepared, key, project_id, guide_id, payload + ), + ) + except ProjectServiceError as exc: + raise _error(exc) from exc diff --git a/backend/app/modules/projects/guide_mutation_service.py b/backend/app/modules/projects/guide_mutation_service.py new file mode 100644 index 00000000..32489106 --- /dev/null +++ b/backend/app/modules/projects/guide_mutation_service.py @@ -0,0 +1,503 @@ +"""Authorized guide and source-metadata mutation orchestration.""" + +from __future__ import annotations + +from dataclasses import dataclass +from uuid import UUID, uuid4 + +from app.core.config import get_settings +from app.core.hashing import canonical_json_hash +from app.modules.actors.service import ResolvedActor +from app.modules.authorization.catalogue import ActionId +from app.modules.authorization.runtime import ( + MatchedAuthorityKind, + PreparedAuthorizationInput, + PreparedAuthorizationUnsupported, + PreparedAuthorityScope, + PreparedAuthorityScopeKind, + ProjectGuideMutationResourceContext, + ProjectGuideMutationPrepareDenialResourceContext, + ProjectGuideSourceSnapshotMutationResourceContext, +) +from app.modules.projects.guide_mutation_repository import GuideMutationRepository +from app.modules.projects.models import ( + GuideSourceSnapshot, + ProjectGuide, + ProjectSetupRun, +) +from app.modules.projects.repository import ProjectRepository +from app.modules.projects.schemas import ( + GuideSourceSnapshotCreate, + GuideSourceSnapshotItemResponse, + GuideSourceSnapshotResponse, + ProjectGuideCreate, + ProjectGuideResponse, + ProjectGuideUpdate, +) +from app.modules.projects.service import ( + GUIDE_SOURCE_SNAPSHOT_SCHEMA_VERSION, + GuideEditBlocked, + GuideNotFound, + GuideVersionConflict, + ProjectNotFound, + ProjectServiceError, + PolicySetupBlocked, + build_guide_source_snapshot_manifest, + build_guide_source_snapshot_items, +) + + +class GuideMutationIdempotencyConflict(ProjectServiceError): + """One replay key was reused with incompatible guide-mutation state.""" + + status_code = 409 + + +@dataclass(frozen=True, slots=True) +class GuideMutationOutcome: + """Route-owned transaction result and optional post-commit dispatch facts.""" + + response: ProjectGuideResponse | GuideSourceSnapshotResponse + replayed: bool + setup_run_id: str | None = None + + +class GuideMutationService: + """Consume exact Project Manager authority before guide metadata writes.""" + + def __init__(self, session) -> None: + self._session = session + self._repo = ProjectRepository(session) + self._replay = GuideMutationRepository(session) + + @staticmethod + def _input( + action: ActionId, + route: str, + resolved: ResolvedActor, + key: UUID, + body, + *, + project_id: UUID, + guide_id: UUID | None = None, + target_resource_id: UUID, + operation_id: UUID, + ) -> tuple[PreparedAuthorizationInput, str]: + replay_request = { + "action_id": action.value, + "route": route, + "actor_profile_id": resolved.profile.id, + "identity_link_id": resolved.identity_link.id, + "idempotency_key": str(key), + "project_id": str(project_id), + "guide_id": str(guide_id) if guide_id is not None else None, + "body": body.model_dump(mode="json", exclude_none=True), + } + digest = canonical_json_hash( + {"domain": "workstream.guide_mutation.idempotency.v1", **replay_request} + ) + request = { + **replay_request, + "guide_id": str(guide_id or target_resource_id), + "target_resource_id": str(target_resource_id), + "operation_id": str(operation_id), + } + return PreparedAuthorizationInput(idempotency_key=key, request_value=request), digest + + async def _existing(self, resolved, action, key, digest, response_type): + record = await self._replay.find(resolved.profile.id, action.value, key) + if record is None: + return None + if record.identity_link_id != resolved.identity_link.id or record.request_digest != digest: + raise GuideMutationIdempotencyConflict("idempotency_mismatch") + if record.status != "committed" or record.response_json is None: + raise GuideMutationIdempotencyConflict("idempotency_pending") + return GuideMutationOutcome(response_type.model_validate(record.response_json), True) + + @staticmethod + def _reservation_outcome(disposition, record, response_type): + """Return an exact concurrent replay or raise the bounded conflict.""" + if disposition == "claimed": + return None + if disposition == "mismatch": + raise GuideMutationIdempotencyConflict("idempotency_mismatch") + if disposition == "pending" or record.response_json is None: + raise GuideMutationIdempotencyConflict("idempotency_pending") + return GuideMutationOutcome( + response=response_type.model_validate(record.response_json), + replayed=True, + ) + + @staticmethod + def _prove(decision, project_id: UUID) -> None: + if ( + decision.matched_authority_kind is not MatchedAuthorityKind.ADMIN_ROLE_GRANT + or decision.matched_grant_id is None + or decision.matched_scope_project_id not in {None, project_id} + ): + raise RuntimeError("guide mutation unexpectedly lacked Project Manager authority") + + async def _prepare( + self, + prepared, + action: ActionId, + caller: PreparedAuthorizationInput, + project_id: UUID, + *, + guide_id: UUID | None, + target_kind: str, + ): + """Prepare authority or persist one bounded denial without product locks.""" + try: + return await prepared.prepare( + action, + caller, + PreparedAuthorityScope( + kind=PreparedAuthorityScopeKind.PROJECT, + project_id=project_id, + ), + ) + except PreparedAuthorizationUnsupported as exc: + denial_resource = ProjectGuideMutationPrepareDenialResourceContext( + resource_type="project_guide_mutation_request", + resource_id=guide_id or project_id, + scope_project_id=project_id, + requested_guide_id=guide_id, + requested_target_kind=target_kind, + ) + await prepared.deny_unsupported(action, caller, denial_resource, exc) + + async def create_guide( + self, resolved, prepared, key: UUID, project_id: UUID, payload: ProjectGuideCreate + ) -> GuideMutationOutcome: + action = ActionId.PROJECT_GUIDE_CREATE + guide_id, operation_id = uuid4(), uuid4() + caller, digest = self._input( + action, + "POST /api/v1/projects/{project_id}/guides", + resolved, + key, + payload, + project_id=project_id, + target_resource_id=guide_id, + operation_id=operation_id, + ) + existing = await self._existing(resolved, action, key, digest, ProjectGuideResponse) + if existing: + return existing + handle = await self._prepare( + prepared, + action, + caller, + project_id, + guide_id=None, + target_kind="guide_create", + ) + project = await self._repo.get_project(str(project_id), for_update=True) + if project is None: + raise ProjectNotFound("project not found") + # A concurrent exact replay can miss the optimistic lookup and then wait + # on this project lock. Re-read the ledger after the lock so the winner's + # committed response takes precedence over the natural version conflict. + existing = await self._existing( + resolved, action, key, digest, ProjectGuideResponse + ) + if existing: + return existing + if await self._repo.get_guide_by_version(str(project_id), payload.version): + raise GuideVersionConflict("guide version already exists for project") + resource = ProjectGuideMutationResourceContext( + resource_type="project_guide_mutation", + resource_id=guide_id, + operation_id=operation_id, + scope_project_id=project_id, + guide_id=guide_id, + target_kind="create", + guide_exists=False, + operation_generation=1, + ) + decision = await prepared.consume(handle, action, caller, resource) + self._prove(decision, project_id) + disposition, replay = await self._replay.reserve( + actor_profile_id=resolved.profile.id, + identity_link_id=resolved.identity_link.id, + action_id=action.value, + idempotency_key=key, + request_digest=digest, + resource_context_digest=decision.resource_context_digest, + operation_id=operation_id, + project_id=str(project_id), + resource_id=str(guide_id), + operation_generation=1, + ) + concurrent = self._reservation_outcome(disposition, replay, ProjectGuideResponse) + if concurrent is not None: + return concurrent + guide = ProjectGuide( + id=str(guide_id), + project_id=str(project_id), + version=payload.version, + status="draft", + content_markdown=payload.content_markdown, + change_summary=payload.change_summary, + created_by=resolved.profile.id, + mutation_generation=1, + last_mutated_by_actor_profile_id=resolved.profile.id, + last_mutated_via_identity_link_id=resolved.identity_link.id, + last_mutated_by_admin_role_grant_id=decision.matched_grant_id, + last_mutation_scope_type="system" + if decision.matched_scope_project_id is None + else "project", + last_mutation_scope_project_id=str(decision.matched_scope_project_id) + if decision.matched_scope_project_id + else None, + last_mutation_action_id=action.value, + last_authorization_decision_event_id=str(decision.decision_id), + ) + await self._repo.add_guide(guide) + response = ProjectGuideResponse.model_validate(guide) + await self._replay.complete(replay, response_json=response.model_dump(mode="json")) + return GuideMutationOutcome(response, False) + + async def create_snapshot( + self, + resolved, + prepared, + key: UUID, + project_id: UUID, + guide_id: UUID, + payload: GuideSourceSnapshotCreate, + ) -> GuideMutationOutcome: + action = ActionId.PROJECT_GUIDE_SOURCE_SNAPSHOT_CREATE + snapshot_id, operation_id = uuid4(), uuid4() + caller, digest = self._input( + action, + "POST /api/v1/projects/{project_id}/guides/{guide_id}/source-snapshots", + resolved, + key, + payload, + project_id=project_id, + guide_id=guide_id, + target_resource_id=snapshot_id, + operation_id=operation_id, + ) + existing = await self._existing(resolved, action, key, digest, GuideSourceSnapshotResponse) + if existing: + return existing + handle = await self._prepare( + prepared, + action, + caller, + project_id, + guide_id=guide_id, + target_kind="source_snapshot_create", + ) + project = await self._repo.get_project(str(project_id), for_update=True) + guide = await self._repo.lock_project_guide(str(guide_id)) + if project is None: + raise ProjectNotFound("project not found") + if guide is None or guide.project_id != str(project_id): + raise GuideNotFound("guide not found") + if guide.status != "draft": + raise GuideEditBlocked("only draft guides can receive source snapshots") + predecessor = await self._repo.lock_latest_guide_source_snapshot( + str(project_id), guide.id, guide.version + ) + manifest, sanitized = build_guide_source_snapshot_manifest(payload, guide) + try: + snapshot_hash = canonical_json_hash(manifest) + except ValueError: + raise PolicySetupBlocked( + "canonical JSON cannot contain non-finite numbers" + ) from None + generation = (predecessor.creation_generation or 0) + 1 if predecessor else 1 + resource = ProjectGuideSourceSnapshotMutationResourceContext( + resource_type="project_guide_source_snapshot_mutation", + resource_id=snapshot_id, + operation_id=operation_id, + scope_project_id=project_id, + guide_id=guide_id, + guide_version=guide.version, + guide_status=guide.status, + source_snapshot_id=snapshot_id, + source_snapshot_hash=snapshot_hash, + predecessor_snapshot_id=UUID(predecessor.id) if predecessor else None, + predecessor_snapshot_hash=predecessor.bundle_hash if predecessor else None, + operation_generation=generation, + ) + decision = await prepared.consume(handle, action, caller, resource) + self._prove(decision, project_id) + disposition, replay = await self._replay.reserve( + actor_profile_id=resolved.profile.id, + identity_link_id=resolved.identity_link.id, + action_id=action.value, + idempotency_key=key, + request_digest=digest, + resource_context_digest=decision.resource_context_digest, + operation_id=operation_id, + project_id=str(project_id), + resource_id=str(snapshot_id), + operation_generation=generation, + ) + concurrent = self._reservation_outcome(disposition, replay, GuideSourceSnapshotResponse) + if concurrent is not None: + return concurrent + provenance = dict( + created_by_actor_profile_id=resolved.profile.id, + created_via_identity_link_id=resolved.identity_link.id, + created_by_admin_role_grant_id=decision.matched_grant_id, + creation_scope_type="system" + if decision.matched_scope_project_id is None + else "project", + creation_scope_project_id=str(decision.matched_scope_project_id) + if decision.matched_scope_project_id + else None, + creation_action_id=action.value, + authorization_decision_event_id=str(decision.decision_id), + ) + snapshot = GuideSourceSnapshot( + id=str(snapshot_id), + project_id=str(project_id), + guide_id=guide.id, + guide_version=guide.version, + manifest_schema_version=GUIDE_SOURCE_SNAPSHOT_SCHEMA_VERSION, + manifest_json=manifest, + bundle_hash=snapshot_hash, + captured_by=resolved.profile.id, + creation_generation=generation, + **provenance, + ) + items = build_guide_source_snapshot_items(snapshot.id, sanitized) + await self._repo.add_guide_source_snapshot(snapshot, items) + setup_run = None + if get_settings().project_setup_pipeline_autostart: + setup_generation = await self._repo.next_project_setup_generation(guide.id) + setup_run = ProjectSetupRun( + id=str(uuid4()), + project_id=guide.project_id, + guide_id=guide.id, + guide_version=guide.version, + source_snapshot_id=snapshot.id, + source_snapshot_hash=snapshot.bundle_hash, + setup_generation=setup_generation, + status="queued", + current_step="queued", + created_by=resolved.profile.id, + authorized_by_actor_profile_id=resolved.profile.id, + authorized_via_identity_link_id=resolved.identity_link.id, + authorized_by_admin_role_grant_id=decision.matched_grant_id, + authorization_scope_type=provenance["creation_scope_type"], + authorization_scope_project_id=provenance["creation_scope_project_id"], + authorization_action_id=action.value, + authorization_decision_event_id=str(decision.decision_id), + ) + await self._repo.add_project_setup_run(setup_run) + response = GuideSourceSnapshotResponse.model_validate(snapshot) + response.items = [GuideSourceSnapshotItemResponse.model_validate(item) for item in items] + await self._replay.complete( + replay, + response_json=response.model_dump(mode="json"), + setup_run_id=setup_run.id if setup_run else None, + ) + return GuideMutationOutcome(response, False, setup_run.id if setup_run else None) + + async def update_guide( + self, + resolved, + prepared, + key: UUID, + project_id: UUID, + guide_id: UUID, + payload: ProjectGuideUpdate, + ) -> GuideMutationOutcome: + action = ActionId.PROJECT_GUIDE_UPDATE + operation_id = uuid4() + caller, digest = self._input( + action, + "PATCH /api/v1/projects/{project_id}/guides/{guide_id}", + resolved, + key, + payload, + project_id=project_id, + guide_id=guide_id, + target_resource_id=guide_id, + operation_id=operation_id, + ) + existing = await self._existing(resolved, action, key, digest, ProjectGuideResponse) + if existing: + return existing + handle = await self._prepare( + prepared, + action, + caller, + project_id, + guide_id=guide_id, + target_kind="guide_update", + ) + project = await self._repo.get_project(str(project_id), for_update=True) + guide = await self._repo.lock_project_guide(str(guide_id)) + if project is None: + raise ProjectNotFound("project not found") + if guide is None or guide.project_id != str(project_id): + raise GuideNotFound("guide not found") + if guide.status != "draft": + raise GuideEditBlocked("only draft guides can be edited") + predecessor = await self._repo.lock_latest_guide_source_snapshot( + str(project_id), guide.id, guide.version + ) + changes = payload.model_dump(exclude_unset=True) + if predecessor is not None and "content_markdown" in changes: + raise GuideEditBlocked( + "guide source material cannot change after a source snapshot exists" + ) + generation = (guide.mutation_generation or 0) + 1 + resource = ProjectGuideMutationResourceContext( + resource_type="project_guide_mutation", + resource_id=guide_id, + operation_id=operation_id, + scope_project_id=project_id, + guide_id=guide_id, + target_kind="update", + guide_exists=True, + guide_status=guide.status, + guide_version=guide.version, + predecessor_snapshot_id=UUID(predecessor.id) if predecessor else None, + predecessor_snapshot_hash=predecessor.bundle_hash if predecessor else None, + operation_generation=generation, + ) + decision = await prepared.consume(handle, action, caller, resource) + self._prove(decision, project_id) + disposition, replay = await self._replay.reserve( + actor_profile_id=resolved.profile.id, + identity_link_id=resolved.identity_link.id, + action_id=action.value, + idempotency_key=key, + request_digest=digest, + resource_context_digest=decision.resource_context_digest, + operation_id=operation_id, + project_id=str(project_id), + resource_id=guide.id, + operation_generation=generation, + ) + concurrent = self._reservation_outcome(disposition, replay, ProjectGuideResponse) + if concurrent is not None: + return concurrent + for field, value in changes.items(): + setattr(guide, field, value) + guide.mutation_generation = generation + guide.last_mutated_by_actor_profile_id = resolved.profile.id + guide.last_mutated_via_identity_link_id = resolved.identity_link.id + guide.last_mutated_by_admin_role_grant_id = decision.matched_grant_id + guide.last_mutation_scope_type = ( + "system" if decision.matched_scope_project_id is None else "project" + ) + guide.last_mutation_scope_project_id = ( + str(decision.matched_scope_project_id) if decision.matched_scope_project_id else None + ) + guide.last_mutation_action_id = action.value + guide.last_authorization_decision_event_id = str(decision.decision_id) + await self._session.flush() + await self._session.refresh(guide) + response = ProjectGuideResponse.model_validate(guide) + await self._replay.complete(replay, response_json=response.model_dump(mode="json")) + return GuideMutationOutcome(response, False) diff --git a/backend/app/modules/projects/models.py b/backend/app/modules/projects/models.py index 6cecf626..7f795fab 100644 --- a/backend/app/modules/projects/models.py +++ b/backend/app/modules/projects/models.py @@ -120,6 +120,61 @@ class ProjectCreateIdempotencyRecord(Base): committed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) +class GuideMutationIdempotencyRecord(Base): + """Replay custody for one authorized guide-metadata mutation.""" + + __tablename__ = "guide_mutation_idempotency_records" + __table_args__ = ( + UniqueConstraint( + "actor_profile_id", + "action_id", + "idempotency_key", + name="uq_guide_mutation_replay_namespace", + ), + UniqueConstraint("operation_id", name="uq_guide_mutation_operation_identity"), + CheckConstraint( + "action_id in ('project.guide.create','project.guide.update'," + "'project.guide_source_snapshot.create')", + name="ck_guide_mutation_action", + ), + CheckConstraint( + "request_digest ~ '^sha256:[0-9a-f]{64}$'", + name="ck_guide_mutation_request_digest", + ), + CheckConstraint( + "resource_context_digest ~ '^sha256:[0-9a-f]{64}$'", + name="ck_guide_mutation_resource_context_digest", + ), + CheckConstraint("operation_generation > 0", name="ck_guide_mutation_generation"), + CheckConstraint( + "status in ('pending','committed')", name="ck_guide_mutation_status" + ), + CheckConstraint( + "(status='pending' and response_json is null and committed_at is null " + "and setup_run_id is null) or " + "(status='committed' and response_json is not null and committed_at is not null)", + name="ck_guide_mutation_state_shape", + ), + ) + + id: Mapped[UUID] = mapped_column(Uuid(), primary_key=True) + actor_profile_id: Mapped[str] = mapped_column(ForeignKey("actor_profiles.id")) + identity_link_id: Mapped[str] = mapped_column(ForeignKey("actor_identity_links.id")) + action_id: Mapped[str] = mapped_column(String(160), nullable=False) + idempotency_key: Mapped[UUID] = mapped_column(Uuid(), nullable=False) + request_digest: Mapped[str] = mapped_column(String(71), nullable=False) + resource_context_digest: Mapped[str] = mapped_column(String(71), nullable=False) + operation_id: Mapped[UUID] = mapped_column(Uuid(), nullable=False) + project_id: Mapped[str] = mapped_column(ForeignKey("projects.id"), nullable=False) + resource_id: Mapped[str] = mapped_column(String(36), nullable=False) + operation_generation: Mapped[int] = mapped_column(Integer, nullable=False) + status: Mapped[str] = mapped_column(String(16), nullable=False, default="pending") + response_json: Mapped[dict | None] = mapped_column(JSON) + setup_run_id: Mapped[str | None] = mapped_column(ForeignKey("project_setup_runs.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 ProjectGuide(Base): """Versioned human-facing project guide material.""" @@ -143,6 +198,22 @@ class ProjectGuide(Base): approved_by: Mapped[str | None] = mapped_column(String(100)) effective_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) created_by: Mapped[str] = mapped_column(String(100), nullable=False) + mutation_generation: Mapped[int | None] = mapped_column(Integer) + last_mutated_by_actor_profile_id: Mapped[str | None] = mapped_column( + ForeignKey("actor_profiles.id") + ) + last_mutated_via_identity_link_id: Mapped[str | None] = mapped_column( + ForeignKey("actor_identity_links.id") + ) + last_mutated_by_admin_role_grant_id: Mapped[UUID | None] = mapped_column( + Uuid(), ForeignKey("admin_role_grants.id") + ) + last_mutation_scope_type: Mapped[str | None] = mapped_column(String(16)) + last_mutation_scope_project_id: Mapped[str | None] = mapped_column(String(36)) + last_mutation_action_id: Mapped[str | None] = mapped_column(String(160)) + last_authorization_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), @@ -390,6 +461,22 @@ class GuideSourceSnapshot(Base): manifest_json: Mapped[dict] = mapped_column(JSON, nullable=False) bundle_hash: Mapped[str] = mapped_column(String(71), nullable=False, index=True) captured_by: Mapped[str] = mapped_column(String(100), nullable=False) + creation_generation: Mapped[int | None] = mapped_column(Integer) + 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(String(36)) + creation_action_id: Mapped[str | None] = mapped_column(String(160)) + authorization_decision_event_id: Mapped[str | None] = mapped_column( + ForeignKey("audit_events.id") + ) captured_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) @@ -529,6 +616,21 @@ class ProjectSetupRun(Base): error_code: Mapped[str | None] = mapped_column(String(100)) error_summary: Mapped[str | None] = mapped_column(Text) created_by: Mapped[str] = mapped_column(String(100), nullable=False) + authorized_by_actor_profile_id: Mapped[str | None] = mapped_column( + ForeignKey("actor_profiles.id") + ) + authorized_via_identity_link_id: Mapped[str | None] = mapped_column( + ForeignKey("actor_identity_links.id") + ) + authorized_by_admin_role_grant_id: Mapped[UUID | None] = mapped_column( + Uuid(), ForeignKey("admin_role_grants.id") + ) + authorization_scope_type: Mapped[str | None] = mapped_column(String(16)) + authorization_scope_project_id: Mapped[str | None] = mapped_column(String(36)) + authorization_action_id: Mapped[str | None] = mapped_column(String(160)) + authorization_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), diff --git a/backend/app/modules/projects/router.py b/backend/app/modules/projects/router.py index 6cfd3999..c2c15da3 100644 --- a/backend/app/modules/projects/router.py +++ b/backend/app/modules/projects/router.py @@ -24,20 +24,14 @@ from app.modules.artifacts.service import ArtifactAdmissionRelationshipError from app.modules.authorization.runtime import AuthorizationContext from app.modules.projects.schemas import ( - ActiveGuideResponse, ActiveGuideReadResponse, EffectiveProjectSubmissionArtifactPolicyResponse, - GuideSourceSnapshotCreate, GuideArtifactIngestResponse, - GuideSourceSnapshotResponse, GuideSufficiencyAcknowledgement, GuideSufficiencyReportCreate, GuideSufficiencyReportResponse, PreSubmitCheckerPolicySummaryResponse, ContributorProjectResponse, - ProjectGuideCreate, - ProjectGuideResponse, - ProjectGuideUpdate, ProjectResponse, ProjectSetupRunResponse, PostSubmitCheckerPolicyApproval, @@ -135,70 +129,6 @@ async def get_project( raise project_http_error(exc) from exc -@router.post("/{project_id}/guides", response_model=ProjectGuideResponse, status_code=201) -async def create_guide( - project_id: str, - payload: ProjectGuideCreate, - actor: Annotated[ActorContext, Depends(get_registered_actor)], - session: Annotated[AsyncSession, Depends(get_db_session)], -) -> ProjectGuideResponse: - """Create a draft guide and enqueue automatic pre-submit setup.""" - try: - return await ProjectService(session).create_guide(actor, project_id, payload) - except PermissionDenied as exc: - raise permission_http_error(exc) from exc - except ProjectServiceError as exc: - raise project_http_error(exc) from exc - - -@router.patch("/{project_id}/guides/{guide_id}", response_model=ProjectGuideResponse) -async def update_guide( - project_id: str, - guide_id: str, - payload: ProjectGuideUpdate, - actor: Annotated[ActorContext, Depends(get_registered_actor)], - session: Annotated[AsyncSession, Depends(get_db_session)], -) -> ProjectGuideResponse: - """Update a draft guide and optional review, revision, or payment policies.""" - try: - return await ProjectService(session).update_draft_guide( - actor, - project_id, - guide_id, - payload, - ) - except PermissionDenied as exc: - raise permission_http_error(exc) from exc - except ProjectServiceError as exc: - raise project_http_error(exc) from exc - - -@router.post( - "/{project_id}/guides/{guide_id}/source-snapshots", - response_model=GuideSourceSnapshotResponse, - status_code=201, -) -async def create_guide_source_snapshot( - project_id: str, - guide_id: str, - payload: GuideSourceSnapshotCreate, - actor: Annotated[ActorContext, Depends(get_registered_actor)], - session: Annotated[AsyncSession, Depends(get_db_session)], -) -> GuideSourceSnapshotResponse: - """Create an immutable source-material snapshot for a draft guide.""" - try: - return await ProjectService(session).create_guide_source_snapshot( - actor, - project_id, - guide_id, - payload, - ) - except PermissionDenied as exc: - raise permission_http_error(exc) from exc - except ProjectServiceError as exc: - raise project_http_error(exc) from exc - - @router.post( "/{project_id}/guides/{guide_id}/source-snapshots/{source_snapshot_id}/items/" "{source_item_id}/artifact", @@ -719,22 +649,6 @@ async def request_post_submit_checker_policy_correction( raise project_http_error(exc) from exc -@router.post("/{project_id}/guides/{guide_id}/activate", response_model=ActiveGuideResponse) -async def activate_guide( - project_id: str, - guide_id: str, - actor: Annotated[ActorContext, Depends(get_registered_actor)], - session: Annotated[AsyncSession, Depends(get_db_session)], -) -> ActiveGuideResponse: - """Activate a complete draft guide for a project.""" - try: - return await ProjectService(session).activate_guide(actor, project_id, guide_id) - except PermissionDenied as exc: - raise permission_http_error(exc) from exc - except ProjectServiceError as exc: - raise project_http_error(exc) from exc - - @router.get( "/{project_id}/active-guide", response_model=ActiveGuideReadResponse, diff --git a/backend/app/modules/projects/schemas.py b/backend/app/modules/projects/schemas.py index 3840e9f3..8913ccb8 100644 --- a/backend/app/modules/projects/schemas.py +++ b/backend/app/modules/projects/schemas.py @@ -449,17 +449,13 @@ class ContributorProjectResponse(BaseModel): class ProjectGuideCreate(BaseModel): - """Request schema for guide material plus optional activation policies.""" + """Request schema for draft guide material only.""" model_config = ConfigDict(extra="forbid") version: str content_markdown: str change_summary: str | None = None - source_snapshot: GuideSourceSnapshotCreate | None = None - review_policy: ReviewPolicyInput | None = None - revision_policy: RevisionPolicyInput | None = None - payment_policy: PaymentPolicyInput | None = None class ProjectGuideUpdate(BaseModel): @@ -469,9 +465,6 @@ class ProjectGuideUpdate(BaseModel): content_markdown: str | None = None change_summary: str | None = None - review_policy: ReviewPolicyInput | None = None - revision_policy: RevisionPolicyInput | None = None - payment_policy: PaymentPolicyInput | None = None class ProjectGuideResponse(BaseModel): diff --git a/backend/app/modules/projects/service.py b/backend/app/modules/projects/service.py index 8173e1a3..5d0eb560 100644 --- a/backend/app/modules/projects/service.py +++ b/backend/app/modules/projects/service.py @@ -63,8 +63,8 @@ from app.modules.projects.repository import ProjectRepository, ProjectRepositoryIntegrityError from app.modules.projects.setup_queue import ( ProjectSetupQueueError, + dispatch_pre_submit_setup_pipeline_after_commit, enqueue_post_submit_setup_continuation, - enqueue_pre_submit_setup_pipeline, ) from app.modules.projects.schemas import ( ActiveGuideResponse, @@ -86,9 +86,7 @@ PostSubmitCheckerPolicySetupResponse, PostSubmitCheckerPolicySetupSummaryResponse, ContributorProjectResponse, - ProjectGuideCreate, ProjectGuideResponse, - ProjectGuideUpdate, ProjectResponse, ProjectSetupRunResponse, RevisionPolicyInput, @@ -492,181 +490,6 @@ def project_identity_response( return ContributorProjectResponse.model_validate(project) return ProjectResponse.model_validate(project) - async def create_guide( - self, - actor: ActorContext, - project_id: str, - payload: ProjectGuideCreate, - ) -> ProjectGuideResponse: - """Create a draft guide and optional policy records for one project. - - Args: - actor: Verified Flow actor context for the current request. - project_id: Project that owns the guide. - payload: Guide content plus optional post-submit, review, revision, and payment policies. - - Returns: - Created draft guide response. - - Raises: - PermissionDenied: If the actor cannot manage project setup. - ProjectNotFound: If the parent project is unknown. - GuideVersionConflict: If the project already has the requested guide version. - """ - require_any_role(actor, PROJECT_SETUP_ROLES) - project = await self._repo.get_project(project_id) - if project is None: - raise ProjectNotFound("project not found") - guide = ProjectGuide( - id=str(uuid4()), - project_id=project_id, - version=payload.version, - status="draft", - content_markdown=payload.content_markdown, - change_summary=payload.change_summary, - created_by=actor.actor_id, - ) - source_snapshot_payload = payload.source_snapshot - source_snapshot: GuideSourceSnapshot | None = None - setup_run: ProjectSetupRun | None = None - try: - guide = await self._repo.add_guide(guide) - except IntegrityError as exc: - await self._session.rollback() - raise GuideVersionConflict("guide version already exists for project") from exc - await self._upsert_optional_policies(project_id, payload.version, payload) - if ( - source_snapshot_payload is not None - or get_settings().project_setup_pipeline_autostart - ): - source_snapshot = await self._create_guide_source_snapshot_model( - actor, - project_id, - guide, - source_snapshot_payload or GuideSourceSnapshotCreate(), - ) - if get_settings().project_setup_pipeline_autostart: - setup_run = await self._create_project_setup_run_model( - actor, - guide, - source_snapshot, - ) - await self._session.commit() - await self._session.refresh(guide) - if source_snapshot is not None and setup_run is not None: - await self._enqueue_pre_submit_setup_pipeline_after_commit( - project_id=project_id, - guide_id=guide.id, - source_snapshot_id=source_snapshot.id, - setup_run_id=setup_run.id, - ) - return ProjectGuideResponse.model_validate(guide) - - async def update_draft_guide( - self, - actor: ActorContext, - project_id: str, - guide_id: str, - payload: ProjectGuideUpdate, - ) -> ProjectGuideResponse: - """Apply edits to a draft guide and its optional policy records. - - Args: - actor: Verified Flow actor context for the current request. - project_id: Project that owns the guide. - guide_id: Draft guide to update. - payload: Partial guide and policy fields to apply. - - Returns: - Updated draft guide response. - - Raises: - PermissionDenied: If the actor cannot manage project setup. - GuideNotFound: If the guide is missing or outside the project. - GuideEditBlocked: If the guide is no longer a draft. - """ - require_any_role(actor, PROJECT_SETUP_ROLES) - guide = await self._lock_project_guide_for_setup(project_id, guide_id) - if guide.status != "draft": - raise GuideEditBlocked("only draft guides can be edited") - - changes = payload.model_dump(exclude_unset=True) - if GUIDE_SOURCE_MATERIAL_FIELDS.intersection(changes): - snapshots = await self._repo.list_guide_source_snapshots( - project_id, - guide.id, - guide.version, - ) - if snapshots: - raise GuideEditBlocked( - "guide source material cannot change after a source snapshot exists" - ) - for field, value in changes.items(): - if field in { - "review_policy", - "revision_policy", - "payment_policy", - }: - continue - setattr(guide, field, value) - await self._upsert_optional_policies(project_id, guide.version, payload) - await self._session.commit() - await self._session.refresh(guide) - return ProjectGuideResponse.model_validate(guide) - - async def create_guide_source_snapshot( - self, - actor: ActorContext, - project_id: str, - guide_id: str, - payload: GuideSourceSnapshotCreate, - ) -> GuideSourceSnapshotResponse: - """Create an immutable source-material snapshot for a draft guide. - - Args: - actor: Verified Flow actor context for the current request. - project_id: Project that owns the guide. - guide_id: Draft guide receiving the source snapshot. - payload: Source items to sanitize, canonicalize, and hash. - - Returns: - Created guide-source snapshot response. - - Raises: - PermissionDenied: If the actor cannot manage project setup. - GuideNotFound: If the guide is missing or outside the project. - GuideEditBlocked: If the guide is not a draft. - SourceSnapshotInvalid: If source refs or hashes are unsafe. - """ - require_any_role(actor, PROJECT_SETUP_ROLES) - guide = await self._lock_project_guide_for_setup(project_id, guide_id) - if guide.status != "draft": - raise GuideEditBlocked("only draft guides can receive source snapshots") - snapshot = await self._create_guide_source_snapshot_model( - actor, - project_id, - guide, - payload, - ) - setup_run: ProjectSetupRun | None = None - if get_settings().project_setup_pipeline_autostart: - setup_run = await self._create_project_setup_run_model(actor, guide, snapshot) - try: - await self._session.commit() - except IntegrityError as exc: - await self._session.rollback() - raise PolicySetupConflict( - "guide source snapshot conflicted with concurrent setup; retry" - ) from exc - if setup_run is not None: - await self._enqueue_pre_submit_setup_pipeline_after_commit( - project_id=project_id, - guide_id=guide.id, - source_snapshot_id=snapshot.id, - setup_run_id=setup_run.id, - ) - return await self._source_snapshot_response(snapshot) - async def approve_current_post_submit_checker_policy( self, actor: ActorContext, @@ -1946,32 +1769,6 @@ async def _lock_project_guide_for_setup( raise GuideNotFound("guide not found") return guide - async def _upsert_optional_policies( - self, - project_id: str, - guide_version: str, - payload: ProjectGuideCreate | ProjectGuideUpdate, - ) -> None: - """Create or replace policy records supplied with guide payloads. - - Args: - project_id: Project that owns the policies. - guide_version: Guide version the policies apply to. - payload: Guide create or update payload carrying optional policies. - """ - if payload.review_policy is not None: - await self._repo.upsert_review_policy( - self._review_policy_model(project_id, guide_version, payload.review_policy) - ) - if payload.revision_policy is not None: - await self._repo.upsert_revision_policy( - self._revision_policy_model(project_id, guide_version, payload.revision_policy) - ) - if payload.payment_policy is not None: - await self._repo.upsert_payment_policy( - self._payment_policy_model(project_id, guide_version, payload.payment_policy) - ) - async def _get_snapshot_for_guide( self, project_id: str, @@ -2019,7 +1816,7 @@ async def _create_guide_source_snapshot_model( Returns: Persisted source snapshot pending transaction commit. """ - manifest, sanitized_items = self._build_source_snapshot_manifest(payload, guide) + manifest, sanitized_items = build_guide_source_snapshot_manifest(payload, guide) bundle_hash = self._hash_canonical_json(manifest) snapshot = GuideSourceSnapshot( id=str(uuid4()), @@ -2031,20 +1828,7 @@ async def _create_guide_source_snapshot_model( bundle_hash=bundle_hash, captured_by=actor.actor_id, ) - item_models = [ - GuideSourceSnapshotItem( - id=str(uuid4()), - source_snapshot_id=snapshot.id, - item_order=index, - source_kind=item["source_kind"], - durable_ref=item["durable_ref"], - ingestion_adapter=item["ingestion_adapter"], - content_hash=item["content_hash"], - content_cid=item.get("content_cid"), - media_type=item.get("media_type"), - ) - for index, item in enumerate(sanitized_items) - ] + item_models = build_guide_source_snapshot_items(snapshot.id, sanitized_items) return await self._repo.add_guide_source_snapshot(snapshot, item_models) async def _create_project_setup_run_model( @@ -2092,40 +1876,13 @@ async def _enqueue_pre_submit_setup_pipeline_after_commit( Returns: Celery task id when enqueue succeeds; otherwise ``None``. """ - try: - task_id = await asyncio.to_thread( - enqueue_pre_submit_setup_pipeline, - project_id=project_id, - guide_id=guide_id, - source_snapshot_id=source_snapshot_id, - setup_run_id=setup_run_id, - ) - except ProjectSetupQueueError as exc: - safe_summary = self._safe_project_setup_error_summary(str(exc)) - logger.warning( - "project setup pipeline enqueue failed after commit", - extra={ - "project_id": project_id, - "guide_id": guide_id, - "source_snapshot_id": source_snapshot_id, - "setup_run_id": setup_run_id, - "error_code": exc.__class__.__name__, - "error_summary": safe_summary, - }, - ) - await self.update_project_setup_run_status( - setup_run_id, - status="enqueue_failed", - current_step="enqueue", - error_code=exc.__class__.__name__, - error_summary=safe_summary, - ) - return None - setup_run = await self._repo.get_project_setup_run(setup_run_id) - if setup_run is not None: - setup_run.celery_task_id = task_id - await self._session.commit() - return task_id + return await dispatch_pre_submit_setup_pipeline_after_commit( + self._session, + project_id=project_id, + guide_id=guide_id, + source_snapshot_id=source_snapshot_id, + setup_run_id=setup_run_id, + ) async def _enqueue_post_submit_setup_continuation_after_commit( self, @@ -3100,89 +2857,9 @@ def fail() -> None: if manifest_row_items != row_items: fail() - def _build_source_snapshot_manifest( - self, - payload: GuideSourceSnapshotCreate, - guide: ProjectGuide, - ) -> tuple[dict[str, Any], list[dict[str, Any]]]: - """Sanitize source items and build a canonical manifest. - - Args: - payload: Raw source snapshot request. - guide: Current draft guide whose source material is included. - - Returns: - Canonical manifest and deterministic source item dictionaries. - - Raises: - SourceSnapshotInvalid: If any source item is unsafe or duplicated. - """ - guide_item = self._guide_material_snapshot_item(guide) - normalized_items = [guide_item] - seen_refs: set[tuple[str, str]] = { - (guide_item["source_kind"], guide_item["durable_ref"]) - } - for item in payload.items: - source_kind = self._safe_source_token(item.source_kind, "source kind") - ingestion_adapter = self._safe_source_token( - item.ingestion_adapter, - "ingestion adapter", - ) - durable_ref = self._sanitize_durable_source_ref(item.durable_ref) - self._require_sha256_hash(item.content_hash, "source item content hash") - content_cid = self._sanitize_content_cid(item.content_cid) - duplicate_key = (source_kind, durable_ref) - if duplicate_key in seen_refs: - raise SourceSnapshotInvalid("duplicate source item durable reference") - seen_refs.add(duplicate_key) - normalized_items.append( - { - "source_kind": source_kind, - "durable_ref": durable_ref, - "ingestion_adapter": ingestion_adapter, - "content_hash": item.content_hash, - "content_cid": content_cid, - "media_type": item.media_type, - "content_excerpt": item.content_excerpt, - } - ) - - sorted_items = sorted( - normalized_items, - key=lambda item: ( - item["source_kind"], - item["durable_ref"], - item["content_hash"], - ), - ) - manifest = { - "schema_version": GUIDE_SOURCE_SNAPSHOT_SCHEMA_VERSION, - "items": sorted_items, - } - return manifest, sorted_items - - def _guide_material_snapshot_item(self, guide: ProjectGuide) -> dict[str, Any]: - """Build the server-owned source item for the current guide body.""" - guide_material = { - field: getattr(guide, field) - for field in sorted(GUIDE_SOURCE_MATERIAL_FIELDS) - } - return { - "source_kind": "project_guide", - "durable_ref": f"inline:/guides/{guide.id}/{guide.version}", - "ingestion_adapter": "workstream_project_guide", - "content_hash": self._hash_canonical_json(guide_material), - "content_cid": None, - "media_type": "application/json", - "content_excerpt": None, - } - def _safe_source_token(self, value: str, label: str) -> str: """Validate a source token field used in durable policy records.""" - normalized = value.strip().lower() - if not SAFE_TOKEN_PATTERN.fullmatch(normalized): - raise SourceSnapshotInvalid(f"invalid {label}") - return normalized + return _guide_source_token(value, label) def _sanitize_durable_source_ref(self, durable_ref: str) -> str: """Reject unsafe durable source refs and return a canonical ref. @@ -3191,104 +2868,11 @@ def _sanitize_durable_source_ref(self, durable_ref: str) -> str: Query strings, fragments, credentials, signed URL material, local paths, and token-bearing values are rejected before persistence. """ - raw_ref = durable_ref.strip() - decoded_ref = self._decode_percent_encoded_ref(raw_ref) - if "\\" in raw_ref or "\\" in decoded_ref: - raise SourceSnapshotInvalid("durable source refs cannot contain local path separators") - parsed = urlparse(raw_ref) - decoded_parsed = urlparse(decoded_ref) - if not parsed.scheme: - raise SourceSnapshotInvalid("durable source refs must use an approved scheme") - scheme = parsed.scheme.lower() - if scheme not in ALLOWED_SOURCE_REF_SCHEMES: - raise SourceSnapshotInvalid("durable source ref scheme is not approved") - if decoded_parsed.scheme and decoded_parsed.scheme.lower() != scheme: - raise SourceSnapshotInvalid("durable source refs cannot contain encoded locators") - if decoded_parsed.netloc and decoded_parsed.netloc != parsed.netloc: - raise SourceSnapshotInvalid("durable source refs cannot contain encoded locators") - if scheme in OPAQUE_SOURCE_REF_SCHEMES and (parsed.netloc or parsed.path.startswith("//")): - raise SourceSnapshotInvalid("durable source refs cannot contain network share authority") - if scheme in OPAQUE_SOURCE_REF_SCHEMES and ( - decoded_parsed.netloc or decoded_parsed.path.startswith("//") - ): - raise SourceSnapshotInvalid("durable source refs cannot contain network share authority") - if parsed.username or parsed.password or "@" in parsed.netloc: - raise SourceSnapshotInvalid("durable source refs cannot contain credentials") - if ";" in raw_ref or ";" in decoded_ref: - raise SourceSnapshotInvalid( - "durable source refs cannot contain query, fragment, or path parameters" - ) - if ( - parsed.query - or parsed.fragment - or parsed.params - or decoded_parsed.query - or decoded_parsed.fragment - or decoded_parsed.params - ): - raise SourceSnapshotInvalid( - "durable source refs cannot contain query, fragment, or path parameters" - ) - if SECRET_REF_PATTERN.search(raw_ref) or SECRET_REF_PATTERN.search(decoded_ref): - raise SourceSnapshotInvalid("durable source refs cannot contain credential material") - decoded_path = self._decode_percent_encoded_ref(parsed.path or "") - if "?" in decoded_path or "#" in decoded_path or ";" in decoded_path: - raise SourceSnapshotInvalid("durable source refs cannot contain encoded locators") - path_segments = [segment for segment in decoded_path.split("/") if segment] - if any(segment in {".", ".."} for segment in path_segments): - raise SourceSnapshotInvalid("durable source refs cannot contain path traversal") - if decoded_path.startswith(("~", "/tmp", "/home", "/Users", "/var", "/etc")) or re.match( - r"^/?[A-Za-z]:/", - decoded_path, - ): - raise SourceSnapshotInvalid("durable source refs cannot be local filesystem paths") - if self._matches_forbidden_artifact(decoded_path, DEFAULT_FORBIDDEN_ARTIFACT_PATTERNS): - raise SourceSnapshotInvalid("durable source refs cannot contain credential material") - if scheme in OPAQUE_SOURCE_REF_SCHEMES: - self._validate_opaque_source_ref_path(decoded_path) - if scheme in {"http", "https"} and not parsed.netloc: - raise SourceSnapshotInvalid("http source refs require a host") - netloc = parsed.netloc.lower() - path = parsed.path or "" - return f"{scheme}://{netloc}{path}" if netloc else f"{scheme}:{path}" - - def _decode_percent_encoded_ref(self, value: str) -> str: - """Decode percent-encoded source refs until stable or fail closed.""" - decoded = value - for _ in range(5): - next_decoded = unquote(decoded) - if next_decoded == decoded: - return decoded - decoded = next_decoded - raise SourceSnapshotInvalid("durable source refs cannot contain nested encoded locators") - - def _validate_opaque_source_ref_path(self, decoded_path: str) -> None: - """Require opaque durable refs to use approved virtual namespaces.""" - segments = [segment for segment in decoded_path.split("/") if segment] - if not decoded_path.startswith("/") or len(segments) < 2: - raise SourceSnapshotInvalid( - "opaque durable source refs must use an approved virtual namespace" - ) - if segments[0] not in OPAQUE_SOURCE_REF_NAMESPACES: - raise SourceSnapshotInvalid( - "opaque durable source refs must use an approved virtual namespace" - ) + return _guide_source_durable_ref(durable_ref) def _sanitize_content_cid(self, content_cid: str | None) -> str | None: """Validate optional immutable content identifiers before persistence.""" - if content_cid is None: - return None - normalized = content_cid.strip() - parsed = urlparse(normalized) - if parsed.query or parsed.fragment or parsed.username or parsed.password: - raise SourceSnapshotInvalid("content CID cannot contain credentials or locators") - if SECRET_REF_PATTERN.search(normalized): - raise SourceSnapshotInvalid("content CID cannot contain credential material") - if normalized.startswith(("/", "\\", "~")) or parsed.scheme in {"file"}: - raise SourceSnapshotInvalid("content CID cannot be a local filesystem path") - if not CONTENT_CID_PATTERN.fullmatch(normalized): - raise SourceSnapshotInvalid("content CID must be an approved opaque identifier") - return normalized + return _guide_source_content_cid(content_cid) def _require_sha256_hash(self, value: str, label: str) -> None: """Validate platform hash shape.""" @@ -4276,3 +3860,175 @@ async def _active_response( revision_policy, ), ) + + +def build_guide_source_snapshot_manifest( + payload: GuideSourceSnapshotCreate, + guide: ProjectGuide, +) -> tuple[dict[str, Any], list[dict[str, Any]]]: + """Compose one canonical guide-source manifest without service state.""" + guide_material = { + field: getattr(guide, field) for field in sorted(GUIDE_SOURCE_MATERIAL_FIELDS) + } + normalized_items = [ + { + "source_kind": "project_guide", + "durable_ref": f"inline:/guides/{guide.id}/{guide.version}", + "ingestion_adapter": "workstream_project_guide", + "content_hash": canonical_json_hash(guide_material), + "content_cid": None, + "media_type": "application/json", + "content_excerpt": None, + } + ] + seen_refs = {("project_guide", normalized_items[0]["durable_ref"])} + for item in payload.items: + source_kind = _guide_source_token(item.source_kind, "source kind") + ingestion_adapter = _guide_source_token( + item.ingestion_adapter, "ingestion adapter" + ) + durable_ref = _guide_source_durable_ref(item.durable_ref) + if not HASH_PATTERN.fullmatch(item.content_hash): + raise PolicySetupBlocked( + "source item content hash must be sha256:<64 lowercase hex>" + ) + content_cid = _guide_source_content_cid(item.content_cid) + duplicate_key = (source_kind, durable_ref) + if duplicate_key in seen_refs: + raise SourceSnapshotInvalid("duplicate source item durable reference") + seen_refs.add(duplicate_key) + normalized_items.append( + { + "source_kind": source_kind, + "durable_ref": durable_ref, + "ingestion_adapter": ingestion_adapter, + "content_hash": item.content_hash, + "content_cid": content_cid, + "media_type": item.media_type, + "content_excerpt": item.content_excerpt, + } + ) + sorted_items = sorted( + normalized_items, + key=lambda item: (item["source_kind"], item["durable_ref"], item["content_hash"]), + ) + return { + "schema_version": GUIDE_SOURCE_SNAPSHOT_SCHEMA_VERSION, + "items": sorted_items, + }, sorted_items + + +def _guide_source_token(value: str, label: str) -> str: + normalized = value.strip().lower() + if not SAFE_TOKEN_PATTERN.fullmatch(normalized): + raise SourceSnapshotInvalid(f"invalid {label}") + return normalized + + +def _guide_source_durable_ref(durable_ref: str) -> str: + raw_ref = durable_ref.strip() + decoded_ref = _decode_guide_source_ref(raw_ref) + if "\\" in raw_ref or "\\" in decoded_ref: + raise SourceSnapshotInvalid("durable source refs cannot contain local path separators") + parsed, decoded_parsed = urlparse(raw_ref), urlparse(decoded_ref) + if not parsed.scheme or parsed.scheme.lower() not in ALLOWED_SOURCE_REF_SCHEMES: + raise SourceSnapshotInvalid("durable source ref scheme is not approved") + scheme = parsed.scheme.lower() + if decoded_parsed.scheme and decoded_parsed.scheme.lower() != scheme: + raise SourceSnapshotInvalid("durable source refs cannot contain encoded locators") + if decoded_parsed.netloc and decoded_parsed.netloc != parsed.netloc: + raise SourceSnapshotInvalid("durable source refs cannot contain encoded locators") + if scheme in OPAQUE_SOURCE_REF_SCHEMES and ( + parsed.netloc + or parsed.path.startswith("//") + or decoded_parsed.netloc + or decoded_parsed.path.startswith("//") + ): + raise SourceSnapshotInvalid("durable source refs cannot contain network share authority") + if parsed.username or parsed.password or "@" in parsed.netloc: + raise SourceSnapshotInvalid("durable source refs cannot contain credentials") + if ( + ";" in raw_ref + or ";" in decoded_ref + or parsed.query + or parsed.fragment + or parsed.params + or decoded_parsed.query + or decoded_parsed.fragment + or decoded_parsed.params + ): + raise SourceSnapshotInvalid( + "durable source refs cannot contain query, fragment, or path parameters" + ) + if SECRET_REF_PATTERN.search(raw_ref) or SECRET_REF_PATTERN.search(decoded_ref): + raise SourceSnapshotInvalid("durable source refs cannot contain credential material") + decoded_path = _decode_guide_source_ref(parsed.path or "") + if any(segment in {".", ".."} for segment in decoded_path.split("/") if segment): + raise SourceSnapshotInvalid("durable source refs cannot contain path traversal") + if decoded_path.startswith(("~", "/tmp", "/home", "/Users", "/var", "/etc")) or re.match( + r"^/?[A-Za-z]:/", decoded_path + ): + raise SourceSnapshotInvalid("durable source refs cannot be local filesystem paths") + if SECRET_ARTIFACT_NAME_PATTERN.search(decoded_path): + raise SourceSnapshotInvalid("durable source refs cannot contain credential material") + if scheme in OPAQUE_SOURCE_REF_SCHEMES: + segments = [segment for segment in decoded_path.split("/") if segment] + if ( + not decoded_path.startswith("/") + or len(segments) < 2 + or segments[0] not in OPAQUE_SOURCE_REF_NAMESPACES + ): + raise SourceSnapshotInvalid( + "opaque durable source refs must use an approved virtual namespace" + ) + if scheme in {"http", "https"} and not parsed.netloc: + raise SourceSnapshotInvalid("http source refs require a host") + netloc, path = parsed.netloc.lower(), parsed.path or "" + return f"{scheme}://{netloc}{path}" if netloc else f"{scheme}:{path}" + + +def _decode_guide_source_ref(value: str) -> str: + decoded = value + for _ in range(5): + next_decoded = unquote(decoded) + if next_decoded == decoded: + return decoded + decoded = next_decoded + raise SourceSnapshotInvalid("durable source refs cannot contain nested encoded locators") + + +def _guide_source_content_cid(content_cid: str | None) -> str | None: + if content_cid is None: + return None + normalized = content_cid.strip() + parsed = urlparse(normalized) + if parsed.query or parsed.fragment or parsed.username or parsed.password: + raise SourceSnapshotInvalid("content CID cannot contain credentials or locators") + if SECRET_REF_PATTERN.search(normalized): + raise SourceSnapshotInvalid("content CID cannot contain credential material") + if normalized.startswith(("/", "\\", "~")) or parsed.scheme == "file": + raise SourceSnapshotInvalid("content CID cannot be a local filesystem path") + if not CONTENT_CID_PATTERN.fullmatch(normalized): + raise SourceSnapshotInvalid("content CID must be an approved opaque identifier") + return normalized + + +def build_guide_source_snapshot_items( + snapshot_id: str, + items: list[dict[str, Any]], +) -> list[GuideSourceSnapshotItem]: + """Build deterministic source-item rows shared by all snapshot writers.""" + return [ + GuideSourceSnapshotItem( + id=str(uuid4()), + source_snapshot_id=snapshot_id, + item_order=index, + source_kind=item["source_kind"], + durable_ref=item["durable_ref"], + ingestion_adapter=item["ingestion_adapter"], + content_hash=item["content_hash"], + content_cid=item.get("content_cid"), + media_type=item.get("media_type"), + ) + for index, item in enumerate(items) + ] diff --git a/backend/app/modules/projects/setup_queue.py b/backend/app/modules/projects/setup_queue.py index 8675f95a..7de61ce9 100644 --- a/backend/app/modules/projects/setup_queue.py +++ b/backend/app/modules/projects/setup_queue.py @@ -2,12 +2,18 @@ from __future__ import annotations +import asyncio +import logging + from celery.exceptions import CeleryError from kombu.exceptions import KombuError +from sqlalchemy.ext.asyncio import AsyncSession from app.workers.errors import CeleryConfigurationError from app.workers.task_settings import sync_task_settings +logger = logging.getLogger(__name__) + class ProjectSetupQueueError(RuntimeError): """Raised when Workstream cannot enqueue project setup automation.""" @@ -46,6 +52,53 @@ def enqueue_pre_submit_setup_pipeline( return result.id +async def dispatch_pre_submit_setup_pipeline_after_commit( + session: AsyncSession, + *, + project_id: str, + guide_id: str, + source_snapshot_id: str, + setup_run_id: str, +) -> str | None: + """Dispatch one committed setup intent and record its bounded outcome.""" + from app.modules.projects.repository import ProjectRepository + + repository = ProjectRepository(session) + try: + task_id = await asyncio.to_thread( + enqueue_pre_submit_setup_pipeline, + project_id=project_id, + guide_id=guide_id, + source_snapshot_id=source_snapshot_id, + setup_run_id=setup_run_id, + ) + except ProjectSetupQueueError as exc: + logger.warning( + "project setup pipeline enqueue failed after commit", + extra={ + "project_id": project_id, + "guide_id": guide_id, + "source_snapshot_id": source_snapshot_id, + "setup_run_id": setup_run_id, + "error_code": exc.__class__.__name__, + "error_summary": "project setup failed", + }, + ) + setup_run = await repository.get_project_setup_run(setup_run_id) + if setup_run is not None: + setup_run.status = "enqueue_failed" + setup_run.current_step = "enqueue" + setup_run.error_code = exc.__class__.__name__ + setup_run.error_summary = "project setup failed" + await session.commit() + return None + setup_run = await repository.get_project_setup_run(setup_run_id) + if setup_run is not None: + setup_run.celery_task_id = task_id + await session.commit() + return task_id + + def enqueue_post_submit_setup_continuation( *, project_id: str, diff --git a/backend/scripts/api_contract_e2e.py b/backend/scripts/api_contract_e2e.py index 7261175f..11870167 100644 --- a/backend/scripts/api_contract_e2e.py +++ b/backend/scripts/api_contract_e2e.py @@ -30,14 +30,19 @@ rate_key_digest, ) from app.modules.projects.models import ( + PaymentPolicy, PostSubmitCheckerPolicy, PreSubmitCheckerPolicy, ProjectSetupRun, + ReviewPolicy, + RevisionPolicy, ) from app.modules.projects.post_submit_policy import ( build_project_post_submit_checker_spec, compile_project_post_submit_checker_spec, ) +from app.modules.projects.service import ProjectService +from app.schemas.auth import ActorContext from run_isolated_tests import NAME_RE as DERIVED_DATABASE_NAME from bootstrap_access_administrator import _run as run_admin_bootstrap @@ -51,6 +56,35 @@ "check_confidentiality_attestation", "check_low_quality_generated_artifacts", } + + +async def seed_active_guide_for_pre_12h_e2e(project_id: str, guide_id: str) -> dict: + """Seed downstream active state while the public activation route is unavailable.""" + actor = ActorContext( + actor_id="project-manager-subject", + external_subject="project-manager-subject", + external_issuer=DEFAULT_FLOW_ISSUER, + roles=("project_manager",), + claim_snapshot={}, + auth_source="flow_jwt", + is_dev_auth=False, + ) + async with db_session.get_session_factory()() as session: + database_name = await session.scalar(text("select current_database()")) + if DERIVED_DATABASE_NAME.fullmatch(str(database_name)) is None: + raise RuntimeError("pre-12H activation seed requires an isolated E2E database") + await session.execute( + text("alter table project_guides disable trigger guide_mutation_product_custody") + ) + await session.commit() + try: + result = await ProjectService(session).activate_guide(actor, project_id, guide_id) + return result.model_dump(mode="json") + finally: + await session.execute( + text("alter table project_guides enable trigger guide_mutation_product_custody") + ) + await session.commit() DEFAULT_FLOW_ISSUER = "https://auth.flow.local/e2e" DEFAULT_FLOW_AUDIENCE = "workstream-api" LOCAL_DATABASE_HOSTS = {"localhost", "127.0.0.1", "::1"} @@ -357,6 +391,7 @@ async def request_json( token: str | None = None, payload: dict | None = None, expected_status: int = 200, + idempotency_key: str | None = None, ) -> dict | list: """Call one API endpoint and assert its status. @@ -367,6 +402,7 @@ async def request_json( token: Optional Flow bearer token. payload: Optional JSON payload. expected_status: Expected HTTP status code. + idempotency_key: Optional UUID replay key for mutation boundaries. Returns: Parsed JSON response body. @@ -380,6 +416,8 @@ async def request_json( headers.update( {"X-Request-ID": request_id, "X-Correlation-ID": correlation_id} ) + if idempotency_key is not None: + headers["Idempotency-Key"] = idempotency_key response = await client.request( method, path, @@ -580,30 +618,54 @@ def guide_payload(run_id: str) -> dict: "data." ), "change_summary": "Initial real API guide", - "review_policy": { - "requires_second_review": False, - "allowed_decisions": ["accept", "needs_revision", "reject"], - "minimum_finding_fields": ["issue", "required_fix"], - "sla_hours": 24, - }, - "revision_policy": { - "max_revision_rounds": 7, - "revision_deadline_hours": 48, - "auto_reject_after_limit": True, - "allowed_resubmission_states": ["needs_revision"], - "reviewer_reassignment_rule": "same reviewer preferred", - }, - "payment_policy": { - "base_amount": "25.00", - "currency": "USD", - "payout_type": "fixed", - "revision_payment_rule": "none", - "rejection_payment_rule": "none", - "accepted_payment_rule": "pay base amount", - }, } +async def seed_pending_policy_boundaries(project_id: str, guide_version: str) -> None: + """Seed policies whose clean-cut authorization routes arrive after AUTH-12D. + + The E2E flow must keep proving the downstream lifecycle while guide create no + longer accepts embedded policy writes. These direct fixtures are removed as + each dedicated policy boundary is activated. + """ + async with db_session.get_session_factory()() as session: + session.add_all( + [ + ReviewPolicy( + id=str(uuid4()), + project_id=project_id, + guide_version=guide_version, + requires_second_review=False, + allowed_decisions=["accept", "needs_revision", "reject"], + minimum_finding_fields=["issue", "required_fix"], + sla_hours=24, + ), + RevisionPolicy( + id=str(uuid4()), + project_id=project_id, + guide_version=guide_version, + max_revision_rounds=7, + revision_deadline_hours=48, + auto_reject_after_limit=True, + allowed_resubmission_states=["needs_revision"], + reviewer_reassignment_rule="same reviewer preferred", + ), + PaymentPolicy( + id=str(uuid4()), + project_id=project_id, + guide_version=guide_version, + base_amount="25.00", + currency="USD", + payout_type="fixed", + revision_payment_rule="none", + rejection_payment_rule="none", + accepted_payment_rule="pay base amount", + ), + ] + ) + await session.commit() + + def sha256_token(seed: str) -> str: """Return a platform-shaped sha256 token for deterministic E2E fixtures. @@ -683,7 +745,7 @@ async def create_policy_bundle_for_guide( client, "POST", f"/api/v1/projects/{project_id}/guides/{guide_id}/source-snapshots", - manager_token, + diagnostic_reader_token, { "items": [ { @@ -696,6 +758,7 @@ async def create_policy_bundle_for_guide( ] }, 201, + idempotency_key=str(uuid4()), ) report = await request_json( client, @@ -1364,16 +1427,19 @@ async def exercise_api_contract(base_url: str, env: dict[str, str]) -> None: client, "POST", f"/api/v1/projects/{project['id']}/guides", - manager_token, + project_reader_token, guide_payload(run_id), 201, + idempotency_key=str(uuid4()), ) + await seed_pending_policy_boundaries(project["id"], guide["version"]) patched_guide = await request_json( client, "PATCH", f"/api/v1/projects/{project['id']}/guides/{guide['id']}", - manager_token, + project_reader_token, {"change_summary": "Patched before activation through real API"}, + idempotency_key=str(uuid4()), ) assert patched_guide["change_summary"] == "Patched before activation through real API" await create_policy_bundle_for_guide( @@ -1385,12 +1451,14 @@ async def exercise_api_contract(base_url: str, env: dict[str, str]) -> None: guide["id"], run_id, ) - active = await request_json( + await request_json( client, "POST", f"/api/v1/projects/{project['id']}/guides/{guide['id']}/activate", manager_token, + expected_status=404, ) + active = await seed_active_guide_for_pre_12h_e2e(project["id"], guide["id"]) assert active["guide"]["version"] == "v1" active_actor_context = await request_json( client, @@ -1407,9 +1475,10 @@ async def exercise_api_contract(base_url: str, env: dict[str, str]) -> None: client, "PATCH", f"/api/v1/projects/{project['id']}/guides/{guide['id']}", - manager_token, + project_reader_token, {"change_summary": "Illegal active guide edit"}, 409, + idempotency_key=str(uuid4()), ) await request_json( client, diff --git a/backend/scripts/week2_api_e2e.py b/backend/scripts/week2_api_e2e.py index 687ed4c8..3a677d40 100644 --- a/backend/scripts/week2_api_e2e.py +++ b/backend/scripts/week2_api_e2e.py @@ -28,6 +28,7 @@ issue_flow_token, project_root, request_json, + seed_active_guide_for_pre_12h_e2e, sha256_token, wait_for_health, ) @@ -367,7 +368,9 @@ async def create_project_with_guide( "POST", f"/api/v1/projects/{project['id']}/guides/{guide['id']}/activate", manager_token, + expected_status=404, ) + await seed_active_guide_for_pre_12h_e2e(project["id"], guide["id"]) return project diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index 00b85c7d..0480bd2c 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 = "defa8baad0019605b4bb512808a04b554c0f6ce585842fe60a9e3291d1d9bb79" +EXPECTED_PUBLIC_SCHEMA_SHA256 = "782291544912ec1c0ed793c3874f399e8708eb3f5dda051a21c121ee47a507ca" PROTECTED_TEST_TABLES = ( "actor_profile_migration_state", "alembic_version", @@ -62,6 +62,8 @@ "guide_source_extraction_retry_budgets", "guide_source_extraction_usages", "guide_source_format_classifications", + "guide_mutation_idempotency_records", + "guide_source_snapshot_items", "guide_source_snapshot_items", "guide_source_snapshots", "guide_sufficiency_reports", @@ -88,6 +90,7 @@ "audit_events", "authority_control", "authority_idempotency_records", + "guide_mutation_idempotency_records", "outbox_events", "project_create_idempotency_records", "project_role_grants", diff --git a/backend/tests/project_create_fixtures.py b/backend/tests/project_create_fixtures.py index 045321d7..43ff82fa 100644 --- a/backend/tests/project_create_fixtures.py +++ b/backend/tests/project_create_fixtures.py @@ -3,6 +3,7 @@ from datetime import UTC, datetime from uuid import UUID, uuid4 +from httpx import Response from sqlalchemy import select, text from sqlalchemy.ext.asyncio import AsyncConnection, AsyncSession @@ -21,6 +22,51 @@ authorization_resource_digest, ) from app.modules.projects.models import Project, ProjectCreateIdempotencyRecord +from app.modules.projects.service import ProjectService, ProjectServiceError +from app.schemas.auth import ActorContext + + +async def activate_guide_for_downstream_test( + session_factory, + *, + project_id: str, + guide_id: str, +) -> Response: + """Seed the pre-12H active-guide prerequisite without exposing an API route. + + AUTH-12D deliberately removes the legacy activation endpoint. Downstream + subsystem tests still need active historical state until AUTH-12H installs + the authorized activation mutation, so this fixture exercises the existing + product validation while explicitly suspending only the new custody trigger. + """ + actor = ActorContext( + actor_id="project-manager-subject", + external_subject="project-manager-subject", + external_issuer="flow-test", + roles=("project_manager",), + claim_snapshot={}, + auth_source="dev_mock", + is_dev_auth=True, + ) + async with session_factory() as session: + database_name = await session.scalar(text("select current_database()")) + if not str(database_name).startswith("workstream_test_"): + raise RuntimeError("downstream activation fixture requires an isolated test database") + await session.execute( + text("alter table project_guides disable trigger guide_mutation_product_custody") + ) + await session.commit() + try: + result = await ProjectService(session).activate_guide(actor, project_id, guide_id) + return Response(status_code=200, json=result.model_dump(mode="json")) + except ProjectServiceError as exc: + await session.rollback() + return Response(status_code=exc.status_code, json={"detail": str(exc)}) + finally: + await session.execute( + text("alter table project_guides enable trigger guide_mutation_product_custody") + ) + await session.commit() async def grant_system_project_manager( diff --git a/backend/tests/test_alembic.py b/backend/tests/test_alembic.py index ab6fd155..2f3a8a5f 100644 --- a/backend/tests/test_alembic.py +++ b/backend/tests/test_alembic.py @@ -10,7 +10,7 @@ import re import threading import time -from uuid import NAMESPACE_URL, uuid4, uuid5 +from uuid import NAMESPACE_URL, UUID, uuid4, uuid5 import pytest from alembic import command @@ -22,18 +22,35 @@ protected_mapping_roots as frozen_protected_mapping_roots, validate_mapping_path as validate_frozen_mapping_path, ) -from sqlalchemy import text +from sqlalchemy import select, text from sqlalchemy.exc import DBAPIError, IntegrityError from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine from app.adapters.auth.dev import actor_id_from_external_identity +from app.core.hashing import canonical_json_hash +from app.modules.audit.schemas import ( + ActorReferenceKind, + AuthorityAuditEventInput, + AuthorityEventType, +) +from app.modules.audit.service import AuditService from app.modules.authorization.catalogue import ( ACTION_DEFINITIONS, HISTORICAL_PERMISSION_IDS, NEW_PERMISSION_IDS, + ActionId, ActionOwner, PermissionId, ) +from app.modules.authorization.runtime import ( + ProjectGuideMutationResourceContext, + authorization_resource_digest, +) +from app.modules.projects.models import ( + GuideMutationIdempotencyRecord, + ProjectCreateIdempotencyRecord, + ProjectGuide, +) from project_create_fixtures import insert_historical_project, seed_authorized_project from app.modules.actors.legacy_classification import ( @@ -56,7 +73,7 @@ snapshot_existing_service_rows, ) -HEAD_REVISION = "0044_project_create_authority" +HEAD_REVISION = "0045_guide_metadata_authority" pytestmark = pytest.mark.postgres_schema_contract @@ -2143,19 +2160,13 @@ def test_0043_project_setup_service_refuses_in_use_downgrade( match="cannot downgrade project setup service identity", ): command.downgrade(config, "0042_guide_extraction") - assert asyncio.run(_current_revision(isolated_database_env)) == ( - HEAD_REVISION - ) - asyncio.run( - _remove_fixed_service_actor(isolated_database_env, actor_profile_id) - ) + assert asyncio.run(_current_revision(isolated_database_env)) == (HEAD_REVISION) + asyncio.run(_remove_fixed_service_actor(isolated_database_env, actor_profile_id)) actor_profile_id = "" command.downgrade(config, "0042_guide_extraction") finally: if actor_profile_id: - asyncio.run( - _remove_fixed_service_actor(isolated_database_env, actor_profile_id) - ) + asyncio.run(_remove_fixed_service_actor(isolated_database_env, actor_profile_id)) command.downgrade(config, "base") @@ -2180,13 +2191,16 @@ async def _project_create_authority_schema_state(database_url: str) -> tuple[boo ) ) ) - privacy = await connection.scalar( - text( - "select pg_get_constraintdef(oid) from pg_constraint " - "where conrelid='audit_events'::regclass " - "and conname='ck_audit_events_authority_privacy_bounds'" + privacy = ( + await connection.scalar( + text( + "select pg_get_constraintdef(oid) from pg_constraint " + "where conrelid='audit_events'::regclass " + "and conname='ck_audit_events_authority_privacy_bounds'" + ) ) - ) or "" + or "" + ) return ( table_exists, provenance_exists, @@ -2205,17 +2219,23 @@ def test_0044_project_create_authority_round_trip( try: command.downgrade(config, "base") command.upgrade(config, "0043_project_setup_service") - assert asyncio.run( - _project_create_authority_schema_state(isolated_database_env) - ) == (False, False, False) + assert asyncio.run(_project_create_authority_schema_state(isolated_database_env)) == ( + False, + False, + False, + ) command.upgrade(config, "head") - assert asyncio.run( - _project_create_authority_schema_state(isolated_database_env) - ) == (True, True, True) + assert asyncio.run(_project_create_authority_schema_state(isolated_database_env)) == ( + True, + True, + True, + ) command.downgrade(config, "0043_project_setup_service") - assert asyncio.run( - _project_create_authority_schema_state(isolated_database_env) - ) == (False, False, False) + assert asyncio.run(_project_create_authority_schema_state(isolated_database_env)) == ( + False, + False, + False, + ) command.upgrade(config, "head") finally: command.downgrade(config, "base") @@ -2241,9 +2261,7 @@ async def _assert_0044_rejects_new_unattributed_project(database_url: str) -> No await engine.dispose() -def test_0044_rejects_new_unattributed_project( - isolated_database_env: str, migration_lock -) -> None: +def test_0044_rejects_new_unattributed_project(isolated_database_env: str, migration_lock) -> None: """Historical null provenance survives, but new null-provenance rows deny.""" config = _alembic_config() historical_id = str(uuid4()) @@ -2268,9 +2286,7 @@ async def seed_historical() -> None: asyncio.run(seed_historical()) command.upgrade(config, "head") - asyncio.run( - _assert_0044_rejects_new_unattributed_project(isolated_database_env) - ) + asyncio.run(_assert_0044_rejects_new_unattributed_project(isolated_database_env)) async def remove_historical() -> None: engine = create_async_engine(isolated_database_env) @@ -2330,6 +2346,338 @@ async def reset_schema() -> None: asyncio.run(reset_schema()) +async def _guide_metadata_authority_schema_state( + database_url: str, +) -> tuple[bool, bool, bool, bool]: + engine = create_async_engine(database_url) + try: + async with engine.connect() as connection: + return ( + bool( + await connection.scalar( + text( + "select to_regclass('public.guide_mutation_idempotency_records') " + "is not null" + ) + ) + ), + bool( + await connection.scalar( + text( + "select exists(select 1 from information_schema.columns " + "where table_schema='public' and table_name='project_guides' " + "and column_name='mutation_generation')" + ) + ) + ), + bool( + await connection.scalar( + text( + "select exists(select 1 from information_schema.columns " + "where table_schema='public' and table_name='guide_source_snapshots' " + "and column_name='creation_generation')" + ) + ) + ), + bool( + await connection.scalar( + text( + "select exists(select 1 from pg_trigger " + "where tgname='guide_mutation_reservation_custody')" + ) + ) + ), + ) + finally: + await engine.dispose() + + +def test_0045_guide_source_metadata_authority_round_trip( + isolated_database_env: str, migration_lock +) -> None: + """0045 installs and exactly removes the guide-mutation custody seam.""" + config = _alembic_config() + with migration_lock(): + try: + command.downgrade(config, "base") + command.upgrade(config, "0044_project_create_authority") + assert asyncio.run(_guide_metadata_authority_schema_state(isolated_database_env)) == ( + False, + False, + False, + False, + ) + command.upgrade(config, "head") + assert asyncio.run(_guide_metadata_authority_schema_state(isolated_database_env)) == ( + True, + True, + True, + True, + ) + command.downgrade(config, "0044_project_create_authority") + assert asyncio.run(_guide_metadata_authority_schema_state(isolated_database_env)) == ( + False, + False, + False, + False, + ) + command.upgrade(config, "head") + finally: + command.downgrade(config, "base") + + +def test_0045_preserves_historical_guide_rows(isolated_database_env: str, migration_lock) -> None: + """Pre-0045 guide rows remain readable with explicitly null custody.""" + config = _alembic_config() + project_id, guide_id, snapshot_id, setup_run_id = (str(uuid4()) for _ in range(4)) + snapshot_hash = "sha256:" + "0" * 64 + + async def seed_and_read(*, seed: bool) -> tuple | None: + engine = create_async_engine(isolated_database_env) + try: + if seed: + factory = async_sessionmaker(engine, expire_on_commit=False) + async with factory() as session: + await insert_historical_project( + session, + project_id=project_id, + name="Historical guide project", + slug=f"historical-guide-{uuid4()}", + ) + await session.execute( + text( + "insert into project_guides " + "(id,project_id,version,status,content_markdown,created_by) " + "values (:id,:project_id,'v1','draft','# Historical','legacy')" + ), + {"id": guide_id, "project_id": project_id}, + ) + await session.execute( + text( + "insert into guide_source_snapshots " + "(id,project_id,guide_id,guide_version,manifest_schema_version," + "manifest_json,bundle_hash,captured_by) values " + "(:id,:project_id,:guide_id,'v1','guide_source_snapshot.v1'," + "cast(:manifest as jsonb),:hash,'legacy')" + ), + { + "id": snapshot_id, + "project_id": project_id, + "guide_id": guide_id, + "manifest": '{"schema_version":"guide_source_snapshot.v1","items":[]}', + "hash": snapshot_hash, + }, + ) + await session.execute( + text( + "insert into project_setup_runs " + "(id,project_id,guide_id,guide_version,source_snapshot_id," + "source_snapshot_hash,setup_generation,status,current_step,created_by) " + "values (:id,:project_id,:guide_id,'v1',:snapshot_id,:hash,1," + "'queued','queued','legacy')" + ), + { + "id": setup_run_id, + "project_id": project_id, + "guide_id": guide_id, + "snapshot_id": snapshot_id, + "hash": snapshot_hash, + }, + ) + await session.commit() + return None + async with engine.connect() as connection: + guide_result = await connection.execute( + text( + "select mutation_generation,last_mutated_by_actor_profile_id," + "last_authorization_decision_event_id from project_guides where id=:id" + ), + {"id": guide_id}, + ) + snapshot_result = await connection.execute( + text( + "select creation_generation,created_by_actor_profile_id," + "authorization_decision_event_id from guide_source_snapshots where id=:id" + ), + {"id": snapshot_id}, + ) + setup_result = await connection.execute( + text( + "select authorized_by_actor_profile_id,authorized_via_identity_link_id," + "authorization_decision_event_id from project_setup_runs where id=:id" + ), + {"id": setup_run_id}, + ) + return (*guide_result.one(), *snapshot_result.one(), *setup_result.one()) + 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.downgrade(config, "base") + command.upgrade(config, "0044_project_create_authority") + asyncio.run(seed_and_read(seed=True)) + command.upgrade(config, "head") + assert asyncio.run(seed_and_read(seed=False)) == (None,) * 9 + command.downgrade(config, "0044_project_create_authority") + command.upgrade(config, "head") + assert asyncio.run(seed_and_read(seed=False)) == (None,) * 9 + finally: + asyncio.run(reset_schema()) + + +def test_0045_refuses_populated_guide_authority_downgrade( + isolated_database_env: str, migration_lock +) -> None: + """Committed 12D custody prevents destructive downgrade.""" + config = _alembic_config() + + async def seed() -> None: + engine = create_async_engine(isolated_database_env) + try: + factory = async_sessionmaker(engine, expire_on_commit=False) + async with factory() as session: + project_id, guide_id = str(uuid4()), str(uuid4()) + await seed_authorized_project( + session, + project_id=project_id, + name="Guide downgrade custody", + slug=f"guide-downgrade-{uuid4()}", + ) + project_record = await session.scalar( + select(ProjectCreateIdempotencyRecord).where( + ProjectCreateIdempotencyRecord.project_id == project_id + ) + ) + assert project_record is not None + operation_id, decision_id = uuid4(), uuid4() + resource = ProjectGuideMutationResourceContext( + resource_type="project_guide_mutation", + resource_id=UUID(guide_id), + operation_id=operation_id, + scope_project_id=UUID(project_id), + guide_id=UUID(guide_id), + target_kind="create", + guide_exists=False, + operation_generation=1, + ) + resource_digest = authorization_resource_digest(resource) + audit_row = ( + await session.execute( + text( + "select id,matched_grant_id::uuid from audit_events " + "where action_id='project.create' and target_ref_id=:project_id" + ), + {"project_id": project_id}, + ) + ).one() + grant_id = audit_row[1] + assert grant_id is not None + await AuditService(session).add_authority_event( + AuthorityAuditEventInput( + event_id=decision_id, + event_type=AuthorityEventType.SENSITIVE_AUTHORIZATION_ALLOWED, + entity_type="authorization_decision", + entity_id=str(decision_id), + actor_ref_kind=ActorReferenceKind.ACTOR_PROFILE, + actor_ref=project_record.actor_profile_id, + request_id=uuid4(), + correlation_id=uuid4(), + matched_grant_id=str(grant_id), + permission_id=PermissionId.PROJECT_GUIDE_MANAGE, + action_id=ActionId.PROJECT_GUIDE_CREATE, + project_id=project_id, + resource_type="project", + resource_id=project_id, + target_ref_kind="project", + target_ref_id=project_id, + reason="authorization_evaluation", + after_facts={ + "allowed": True, + "resource_context_digest": resource_digest, + }, + ) + ) + reservation = GuideMutationIdempotencyRecord( + id=uuid4(), + actor_profile_id=project_record.actor_profile_id, + identity_link_id=project_record.identity_link_id, + action_id=ActionId.PROJECT_GUIDE_CREATE.value, + idempotency_key=uuid4(), + request_digest=canonical_json_hash( + {"domain": "workstream.test.guide_create", "guide_id": guide_id} + ), + resource_context_digest=resource_digest, + operation_id=operation_id, + project_id=project_id, + resource_id=guide_id, + operation_generation=1, + status="pending", + ) + session.add(reservation) + session.add( + ProjectGuide( + id=guide_id, + project_id=project_id, + version="v1", + status="draft", + content_markdown="# Custodied", + created_by=project_record.actor_profile_id, + mutation_generation=1, + last_mutated_by_actor_profile_id=project_record.actor_profile_id, + last_mutated_via_identity_link_id=project_record.identity_link_id, + last_mutated_by_admin_role_grant_id=grant_id, + last_mutation_scope_type="system", + last_mutation_action_id=ActionId.PROJECT_GUIDE_CREATE.value, + last_authorization_decision_event_id=str(decision_id), + ) + ) + await session.flush() + reservation.status = "committed" + reservation.response_json = {"id": guide_id} + reservation.committed_at = datetime.now(UTC) + await session.commit() + with pytest.raises(DBAPIError, match="activation authority"): + await session.execute( + text("update project_guides set status='active' where id=:id"), + {"id": guide_id}, + ) + await session.commit() + await session.rollback() + 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.downgrade(config, "base") + command.upgrade(config, "head") + asyncio.run(seed()) + with pytest.raises( + RuntimeError, match="cannot downgrade used guide source-metadata authority" + ): + command.downgrade(config, "0044_project_create_authority") + finally: + asyncio.run(reset_schema()) + + def test_0036_art_auth_catalogue_round_trip(isolated_database_env: str, migration_lock) -> None: """Prove the three replacement pairs and review permission round-trip exactly.""" config = _alembic_config() diff --git a/backend/tests/test_api_controls.py b/backend/tests/test_api_controls.py index 1ae131da..11716779 100644 --- a/backend/tests/test_api_controls.py +++ b/backend/tests/test_api_controls.py @@ -439,13 +439,13 @@ def test_openapi_documents_request_error_and_response_context() -> None: for method, operation in path_item.items() if method in methods and operation.get("security") ) - assert len(route_inventory) == 77 + assert len(route_inventory) == 76 assert sha256("\n".join(route_inventory).encode()).hexdigest() == ( - "e9d2f9074f61f3d464142515193514674e87f905d75c0b4d97fee6a7ea99f1fa" + "1df58dc46d473ed04a9c26b2b90899ed471fa9aaedced43b4f1e2dc477cac350" ) - assert len(protected_inventory) == 75 + assert len(protected_inventory) == 74 assert sha256("\n".join(protected_inventory).encode()).hexdigest() == ( - "b005b2516a43e90437c0035301b56f2100024a2ca3d770fb7025b6bab8ca7378" + "8c629cd977bb0eedf99d5ea73f05708f6549f635974998b31abdd9a25636ad76" ) assert set(schema["paths"]["/health"]["get"]["responses"]) == {"200", "400", "500"} assert {"401", "403", "503"} <= set( @@ -511,6 +511,11 @@ def test_openapi_documents_request_error_and_response_context() -> None: ), "GET /api/v1/projects/{project_id}": "project.read", "POST /api/v1/projects": "project.create", + "POST /api/v1/projects/{project_id}/guides": "project.guide.create", + "PATCH /api/v1/projects/{project_id}/guides/{guide_id}": "project.guide.update", + "POST /api/v1/projects/{project_id}/guides/{guide_id}/source-snapshots": ( + "project.guide_source_snapshot.create" + ), "GET /api/v1/projects/{project_id}/guides/{guide_id}/setup-runs/latest": ( "project.setup_run.read" ), @@ -593,11 +598,22 @@ def test_openapi_documents_request_error_and_response_context() -> None: } assert schema["components"]["schemas"][schema_name]["additionalProperties"] is False assert not any("bootstrap" in path for path in schema["paths"]) - assert {"404", "409"} <= set( - schema["paths"]["/api/v1/projects/{project_id}/guides/{guide_id}/activate"][ - "post" - ]["responses"] - ) + assert "/api/v1/projects/{project_id}/guides/{guide_id}/activate" not in schema["paths"] + for path, method in ( + ("/api/v1/projects/{project_id}/guides", "post"), + ("/api/v1/projects/{project_id}/guides/{guide_id}", "patch"), + ( + "/api/v1/projects/{project_id}/guides/{guide_id}/source-snapshots", + "post", + ), + ): + idempotency_parameters = [ + parameter + for parameter in schema["paths"][path][method]["parameters"] + if parameter["name"] == "Idempotency-Key" + ] + assert len(idempotency_parameters) == 1 + assert idempotency_parameters[0]["required"] is True for path_item in schema["paths"].values(): for method, operation in path_item.items(): if method not in {"get", "put", "post", "delete", "options", "head", "patch"}: diff --git a/backend/tests/test_authorization.py b/backend/tests/test_authorization.py index 4579c779..e1c4d8f5 100644 --- a/backend/tests/test_authorization.py +++ b/backend/tests/test_authorization.py @@ -1943,6 +1943,9 @@ def test_closed_permission_and_action_catalogue_is_exact_and_non_executable() -> ActionId.PROJECT_ROLE_GRANT_ISSUE, ActionId.PROJECT_ROLE_GRANT_REVOKE, ActionId.PROJECT_CREATE, + ActionId.PROJECT_GUIDE_CREATE, + ActionId.PROJECT_GUIDE_UPDATE, + ActionId.PROJECT_GUIDE_SOURCE_SNAPSHOT_CREATE, ActionId.PROJECT_READ, ActionId.ACTOR_AUTHORIZATION_CONTEXT_READ, ActionId.PROJECT_SETUP_RUN_READ, @@ -2041,14 +2044,14 @@ def test_closed_permission_and_action_catalogue_is_exact_and_non_executable() -> definition.availability is ActionAvailability.ACTIVE for definition in ACTION_DEFINITIONS ) - == 38 + == 41 ) assert ( sum( definition.availability is ActionAvailability.PLANNED for definition in ACTION_DEFINITIONS ) - == 58 + == 55 ) assert resolve_executable_action(ActionId.ACTOR_PROFILE_READ_SELF).permission_id is ( PermissionId.ACTOR_PROFILE_READ_SELF @@ -2093,6 +2096,7 @@ def test_project_mutation_resources_and_prepared_scopes_are_closed() -> None: ActionId.PROJECT_GUIDE_CREATE: ProjectGuideMutationResourceContext( resource_type="project_guide_mutation", resource_id=guide_id, + operation_id=operation_id, scope_project_id=project_id, guide_id=guide_id, target_kind="create", @@ -2102,6 +2106,7 @@ def test_project_mutation_resources_and_prepared_scopes_are_closed() -> None: ActionId.PROJECT_GUIDE_UPDATE: ProjectGuideMutationResourceContext( resource_type="project_guide_mutation", resource_id=guide_id, + operation_id=operation_id, scope_project_id=project_id, guide_id=guide_id, target_kind="update", @@ -2114,11 +2119,13 @@ def test_project_mutation_resources_and_prepared_scopes_are_closed() -> None: source_resource = ProjectGuideSourceSnapshotMutationResourceContext( resource_type="project_guide_source_snapshot_mutation", resource_id=snapshot_id, + operation_id=operation_id, scope_project_id=project_id, guide_id=guide_id, guide_version="1", guide_status="draft", source_snapshot_id=snapshot_id, + source_snapshot_hash=DIGEST, operation_generation=1, ) review_resource = ProjectReviewPolicyMutationResourceContext( @@ -2332,6 +2339,7 @@ def test_project_mutation_resources_and_prepared_scopes_are_closed() -> None: ProjectGuideMutationResourceContext( resource_type="project_guide_mutation", resource_id=uuid4(), + operation_id=uuid4(), scope_project_id=project_id, guide_id=guide_id, target_kind="update", @@ -3453,7 +3461,12 @@ def __getattr__(self, name: str): ) project_id = uuid4() for action_id in PROJECT_MUTATION_RESOURCE_BY_ACTION: - if action_id is ActionId.PROJECT_CREATE: + if action_id in { + ActionId.PROJECT_CREATE, + ActionId.PROJECT_GUIDE_CREATE, + ActionId.PROJECT_GUIDE_UPDATE, + ActionId.PROJECT_GUIDE_SOURCE_SNAPSHOT_CREATE, + }: continue scope = PreparedAuthorityScope( kind=PreparedAuthorityScopeKind.PROJECT, @@ -3506,6 +3519,141 @@ async def find_effective_grant( return self.grant +class _GuideMutationAuthorityFacts: + def __init__(self, context: HumanAuthorizationContext, *, grant=None) -> None: + self.context = context + self.grant = grant + + async def lock_request_actor(self, identity_link_id, actor_profile_id): + assert identity_link_id == self.context.identity_link_id + assert actor_profile_id == self.context.actor_profile_id + return ( + SimpleNamespace( + id=str(identity_link_id), + actor_profile_id=str(actor_profile_id), + status="active", + ), + SimpleNamespace( + id=str(actor_profile_id), actor_kind="human", status="active" + ), + ) + + async def find_effective_grant( + self, + actor_profile_id, + permission_id, + *, + scope_project_id, + for_update, + allowed_roles, + ): + assert actor_profile_id == self.context.actor_profile_id + assert permission_id is PermissionId.PROJECT_GUIDE_MANAGE + assert scope_project_id is not None + assert for_update is True + assert allowed_roles == frozenset({AdminRole.PROJECT_MANAGER}) + return self.grant + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "action_id,target_kind,resource_type", + [ + (ActionId.PROJECT_GUIDE_CREATE, "create", "guide"), + (ActionId.PROJECT_GUIDE_UPDATE, "update", "guide"), + ( + ActionId.PROJECT_GUIDE_SOURCE_SNAPSHOT_CREATE, + "snapshot", + "snapshot", + ), + ], +) +async def test_guide_source_metadata_authority_uses_exact_single_use_project_handle( + action_id: ActionId, + target_kind: str, + resource_type: str, +) -> None: + context = _runtime_context() + assert isinstance(context, HumanAuthorizationContext) + session = _PreparedTestSession() + project_id, guide_id, resource_id, grant_id = (uuid4() for _ in range(4)) + facts = _GuideMutationAuthorityFacts( + context, grant=SimpleNamespace(id=grant_id, status="active") + ) + authorization, evidence = _runtime_service( + context, session=session, admin_repository=facts + ) + prepared = PreparedAuthorizationService( + session, context, authorization, facts # type: ignore[arg-type] + ) + operation_id = uuid4() + target_resource_id = resource_id if resource_type == "snapshot" else guide_id + caller = PreparedAuthorizationInput( + idempotency_key=uuid4(), + request_value={ + "project_id": str(project_id), + "guide_id": str(guide_id), + "target_resource_id": str(target_resource_id), + "operation_id": str(operation_id), + }, + ) + scope = PreparedAuthorityScope( + kind=PreparedAuthorityScopeKind.PROJECT, + project_id=project_id, + ) + handle = await prepared.prepare(action_id, caller, scope) + if resource_type == "snapshot": + resource = ProjectGuideSourceSnapshotMutationResourceContext( + resource_type="project_guide_source_snapshot_mutation", + resource_id=resource_id, + operation_id=operation_id, + scope_project_id=project_id, + guide_id=guide_id, + guide_version="v1", + guide_status="draft", + source_snapshot_id=resource_id, + source_snapshot_hash=DIGEST, + operation_generation=1, + ) + else: + resource = ProjectGuideMutationResourceContext( + resource_type="project_guide_mutation", + resource_id=guide_id, + operation_id=operation_id, + scope_project_id=project_id, + guide_id=guide_id, + target_kind=target_kind, + guide_exists=target_kind == "update", + guide_status="draft" if target_kind == "update" else None, + guide_version="v1" if target_kind == "update" else None, + operation_generation=1, + ) + if resource_type == "snapshot": + wrong_id = uuid4() + wrong_resource = resource.model_copy( + update={"resource_id": wrong_id, "source_snapshot_id": wrong_id} + ) + else: + wrong_id = uuid4() + wrong_resource = resource.model_copy( + update={"resource_id": wrong_id, "guide_id": wrong_id} + ) + with pytest.raises(PreparedAuthorizationHandleInvalid): + await prepared.consume(handle, action_id, caller, wrong_resource) + decision = await prepared.consume(handle, action_id, caller, resource) + assert decision.allowed is True + assert decision.matched_grant_id == grant_id + assert decision.matched_scope_project_id is None + assert evidence.events[0].resource_type == "project" + assert evidence.events[0].resource_id == str(project_id) + assert evidence.events[0].after_facts == { + "allowed": True, + "resource_context_digest": decision.resource_context_digest, + } + with pytest.raises(PreparedAuthorizationHandleInvalid): + await prepared.consume(handle, action_id, caller, resource) + + @pytest.mark.asyncio async def test_project_create_prepared_authority_is_system_scoped_and_evidenced() -> None: context = _runtime_context() diff --git a/backend/tests/test_checkers.py b/backend/tests/test_checkers.py index a43fc24e..c804f5ab 100644 --- a/backend/tests/test_checkers.py +++ b/backend/tests/test_checkers.py @@ -56,7 +56,10 @@ seed_worker_profile, set_dev_actor, ) -from project_create_fixtures import grant_system_project_manager +from project_create_fixtures import ( + activate_guide_for_downstream_test, + grant_system_project_manager, +) @pytest.fixture @@ -1113,9 +1116,10 @@ async def create_checker_trial_project( guide_response.json()["id"], post_submit_required_checkers=required_checkers, ) - activation_response = await client.post( - f"/api/v1/projects/{project['id']}/guides/{guide_response.json()['id']}/activate", - headers=auth_headers(), + activation_response = await activate_guide_for_downstream_test( + db_session.get_session_factory(), + project_id=project["id"], + guide_id=guide_response.json()["id"], ) assert activation_response.status_code == 200, activation_response.text return project @@ -1811,9 +1815,10 @@ async def test_chunk8_default_blocking_checker_survives_omitted_blocking_severit post_submit_required_checkers=["check_policy_context_present"], post_submit_blocking_severities=None, ) - activation_response = await checker_client.post( - f"/api/v1/projects/{project['id']}/guides/{guide_response.json()['id']}/activate", - headers=auth_headers(), + activation_response = await activate_guide_for_downstream_test( + db_session.get_session_factory(), + project_id=project["id"], + guide_id=guide_response.json()["id"], ) assert activation_response.status_code == 200, activation_response.text started_task = await create_started_task(checker_client, project["id"], monkeypatch) @@ -2183,9 +2188,10 @@ async def test_chunk8_task_setup_blocked_takes_priority_over_worker_revision( guide_response.json()["id"], post_submit_required_checkers=["check_acceptance_criteria_present"], ) - activation_response = await checker_client.post( - f"/api/v1/projects/{project['id']}/guides/{guide_response.json()['id']}/activate", - headers=auth_headers(), + activation_response = await activate_guide_for_downstream_test( + db_session.get_session_factory(), + project_id=project["id"], + guide_id=guide_response.json()["id"], ) assert activation_response.status_code == 200, activation_response.text started_task = await create_started_task(checker_client, project["id"], monkeypatch) diff --git a/backend/tests/test_projects.py b/backend/tests/test_projects.py index 774f67e8..fd50daed 100644 --- a/backend/tests/test_projects.py +++ b/backend/tests/test_projects.py @@ -13,7 +13,7 @@ import pytest # type: ignore[import-not-found] from httpx import ASGITransport, AsyncClient -from sqlalchemy import event, func, select, update +from sqlalchemy import event, func, select, text, update from sqlalchemy.dialects import postgresql from sqlalchemy.exc import IntegrityError from fastapi import HTTPException @@ -45,6 +45,7 @@ ) from app.modules.projects.models import ( EffectiveProjectSubmissionArtifactPolicy, + GuideMutationIdempotencyRecord, GuideSourceSnapshot, GuideSourceSnapshotItem, GuideSufficiencyReport, @@ -69,6 +70,7 @@ from app.modules.authorization.repository import AdminAuthorizationRepository from app.modules.authorization.catalogue import ActionId from app.modules.projects import service as project_service_module +from app.modules.projects import setup_queue as project_setup_queue_module from app.modules.projects.authorization_reads import ( authorize_project_active_guide_read, authorize_project_diagnostic_read, @@ -104,7 +106,10 @@ ProjectServiceError, StaleProjectSetupContinuation, ) -from project_create_fixtures import seed_historical_project +from project_create_fixtures import ( + activate_guide_for_downstream_test, + seed_historical_project, +) from app.modules.projects.post_submit_policy import ( @@ -1118,7 +1123,10 @@ async def project_client(project_database_env: str) -> AsyncIterator[AsyncClient def auth_headers(token: str = "project-token") -> dict[str, str]: - return {"Authorization": f"Bearer {token}"} + return { + "Authorization": f"Bearer {token}", + "Idempotency-Key": str(uuid4()), + } async def ensure_access_administrator_bootstrap() -> tuple[UUID, UUID, UUID]: @@ -1574,8 +1582,6 @@ def test_policy_models_do_not_enforce_mutable_current_uniqueness() -> None: def test_setup_mutations_use_locked_guide_helper() -> None: locked_methods = [ - "update_draft_guide", - "create_guide_source_snapshot", "create_guide_sufficiency_report", "acknowledge_guide_sufficiency_warnings", "create_submission_artifact_policy", @@ -1804,27 +1810,6 @@ def complete_guide_payload(version: str = "v1") -> dict: "context." ), "change_summary": f"Initial {version}", - "review_policy": { - "requires_second_review": False, - "allowed_decisions": ["accept", "needs_revision", "reject"], - "minimum_finding_fields": ["issue", "required_fix"], - "sla_hours": 24, - }, - "revision_policy": { - "max_revision_rounds": 7, - "revision_deadline_hours": 48, - "auto_reject_after_limit": True, - "allowed_resubmission_states": ["needs_revision"], - "reviewer_reassignment_rule": "same reviewer preferred", - }, - "payment_policy": { - "base_amount": "25.00", - "currency": "USD", - "payout_type": "fixed", - "revision_payment_rule": "none", - "rejection_payment_rule": "none", - "accepted_payment_rule": "pay base amount", - }, } @@ -2012,17 +1997,87 @@ async def test_project_create_concurrent_exact_replay_commits_once( async def create_guide(client: AsyncClient, project_id: str, payload: dict) -> dict: + request_payload = dict(payload) + source_snapshot = request_payload.pop("source_snapshot", None) + review_policy = request_payload.pop("review_policy", "default") + revision_policy = request_payload.pop("revision_policy", "default") + payment_policy = request_payload.pop("payment_policy", "default") response = await client.post( f"/api/v1/projects/{project_id}/guides", headers=auth_headers(), - json=payload, + json=request_payload, ) assert response.status_code == 201, response.text + guide = response.json() + async with db_session.get_session_factory()() as session: + if review_policy is not None: + values = ( + review_policy + if isinstance(review_policy, dict) + else { + "requires_second_review": False, + "allowed_decisions": ["accept", "needs_revision", "reject"], + "minimum_finding_fields": ["issue", "required_fix"], + "sla_hours": 24, + } + ) + session.add( + ReviewPolicy( + id=str(uuid4()), + project_id=project_id, + guide_version=guide["version"], + **values, + ) + ) + if revision_policy is not None: + values = ( + revision_policy + if isinstance(revision_policy, dict) + else { + "max_revision_rounds": 7, + "revision_deadline_hours": 48, + "auto_reject_after_limit": True, + "allowed_resubmission_states": ["needs_revision"], + "reviewer_reassignment_rule": "same reviewer preferred", + } + ) + session.add( + RevisionPolicy( + id=str(uuid4()), + project_id=project_id, + guide_version=guide["version"], + **values, + ) + ) + if payment_policy is not None: + values = ( + payment_policy + if isinstance(payment_policy, dict) + else { + "base_amount": "25.00", + "currency": "USD", + "payout_type": "fixed", + "revision_payment_rule": "none", + "rejection_payment_rule": "none", + "accepted_payment_rule": "pay base amount", + } + ) + session.add( + PaymentPolicy( + id=str(uuid4()), + project_id=project_id, + guide_version=guide["version"], + **values, + ) + ) + await session.commit() await add_project_manager_admin_grant(project_id) - return response.json() + if source_snapshot is not None: + await create_source_snapshot(client, project_id, guide["id"], source_snapshot) + return guide -async def test_create_guide_autostart_enqueues_without_inline_agent_execution( +async def test_create_guide_never_enqueues_setup_or_runs_agents( project_client: AsyncClient, monkeypatch: pytest.MonkeyPatch, ) -> None: @@ -2073,7 +2128,7 @@ def capture_enqueue( lambda: FailingRuntime(), ) monkeypatch.setattr( - project_service_module, + project_setup_queue_module, "enqueue_pre_submit_setup_pipeline", capture_enqueue, ) @@ -2081,10 +2136,8 @@ def capture_enqueue( project = await create_project(project_client) guide = await create_guide(project_client, project["id"], complete_guide_payload()) - assert len(enqueued) == 1 - assert enqueued[0]["project_id"] == project["id"] - assert enqueued[0]["guide_id"] == guide["id"] - assert enqueued[0]["setup_run_id"] + assert guide["project_id"] == project["id"] + assert enqueued == [] async with db_session.get_session_factory()() as session: snapshots = ( await session.scalars( @@ -2105,18 +2158,12 @@ def capture_enqueue( ).all() setup_runs = ( await session.scalars( - select(ProjectSetupRun).where( - ProjectSetupRun.guide_id == guide["id"], - ProjectSetupRun.source_snapshot_id == snapshots[0].id, - ) + select(ProjectSetupRun).where(ProjectSetupRun.guide_id == guide["id"]) ) ).all() - assert len(snapshots) == 1 - assert enqueued[0]["source_snapshot_id"] == snapshots[0].id - assert len(setup_runs) == 1 - assert enqueued[0]["setup_run_id"] == setup_runs[0].id - assert setup_runs[0].celery_task_id == "captured-task-id" + assert snapshots == [] + assert setup_runs == [] assert reports == [] assert policies == [] @@ -2310,7 +2357,7 @@ async def test_project_identity_and_context_follow_exact_grant_and_lifecycle( assert denied.status_code == 404 -async def test_create_guide_returns_created_when_post_commit_enqueue_fails( +async def test_create_source_snapshot_marks_setup_run_when_post_commit_enqueue_fails( project_client: AsyncClient, monkeypatch: pytest.MonkeyPatch, ) -> None: @@ -2331,32 +2378,38 @@ def enqueue_failure( monkeypatch.setenv("WORKSTREAM_CELERY_TASK_ALWAYS_EAGER", "false") get_settings.cache_clear() monkeypatch.setattr( - project_service_module, + project_setup_queue_module, "enqueue_pre_submit_setup_pipeline", enqueue_failure, ) + guide = await create_guide(project_client, project["id"], complete_guide_payload()) response = await project_client.post( - f"/api/v1/projects/{project['id']}/guides", + f"/api/v1/projects/{project['id']}/guides/{guide['id']}/source-snapshots", headers=auth_headers(), - json=complete_guide_payload(), + json=source_snapshot_payload(), ) assert response.status_code == 201, response.text - guide = response.json() + created_snapshot = response.json() async with db_session.get_session_factory()() as session: persisted_guide = await session.scalar( select(ProjectGuide).where(ProjectGuide.id == guide["id"]) ) - snapshot = await session.scalar( - select(GuideSourceSnapshot).where(GuideSourceSnapshot.guide_id == guide["id"]) + snapshot = await session.get(GuideSourceSnapshot, created_snapshot["id"]) + setup_run = await session.scalar( + select(ProjectSetupRun).where( + ProjectSetupRun.source_snapshot_id == created_snapshot["id"] + ) ) assert persisted_guide is not None assert snapshot is not None + assert setup_run is not None + assert setup_run.status == "enqueue_failed" -async def test_create_guide_autostart_runs_celery_pipeline_to_draft_policy( +async def test_create_source_snapshot_autostart_runs_celery_pipeline_to_draft_policy( project_client: AsyncClient, monkeypatch: pytest.MonkeyPatch, deterministic_project_agent_runtime: None, @@ -2367,6 +2420,7 @@ async def test_create_guide_autostart_runs_celery_pipeline_to_draft_policy( project = await create_project(project_client) guide = await create_guide(project_client, project["id"], complete_guide_payload()) + await create_source_snapshot(project_client, project["id"], guide["id"]) async with db_session.get_session_factory()() as session: snapshot = await session.scalar( @@ -2401,7 +2455,7 @@ async def test_create_guide_autostart_runs_celery_pipeline_to_draft_policy( assert pre_submit_checker_policy is None -async def test_create_guide_autostart_stops_before_derivation_when_sufficiency_blocks( +async def test_create_source_snapshot_autostart_stops_before_derivation_when_sufficiency_blocks( project_client: AsyncClient, monkeypatch: pytest.MonkeyPatch, deterministic_project_agent_runtime: None, @@ -2414,6 +2468,7 @@ async def test_create_guide_autostart_stops_before_derivation_when_sufficiency_b blocked_payload = complete_guide_payload() blocked_payload["content_markdown"] = "Too thin." guide = await create_guide(project_client, project["id"], blocked_payload) + await create_source_snapshot(project_client, project["id"], guide["id"]) async with db_session.get_session_factory()() as session: report = await session.scalar( @@ -2459,7 +2514,7 @@ def capture_enqueue( monkeypatch.setenv("WORKSTREAM_PROJECT_SETUP_PIPELINE_AUTOSTART", "true") get_settings.cache_clear() monkeypatch.setattr( - project_service_module, + project_setup_queue_module, "enqueue_pre_submit_setup_pipeline", capture_enqueue, ) @@ -2512,7 +2567,7 @@ def enqueue_failure( monkeypatch.setenv("WORKSTREAM_PROJECT_SETUP_PIPELINE_AUTOSTART", "true") get_settings.cache_clear() monkeypatch.setattr( - project_service_module, + project_setup_queue_module, "enqueue_pre_submit_setup_pipeline", enqueue_failure, ) @@ -2618,6 +2673,431 @@ async def create_source_snapshot( return response.json() +async def test_guide_source_metadata_authority_records_exact_provenance_and_replays( + project_client: AsyncClient, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """All three 12D mutations retain exact authority and replay custody.""" + monkeypatch.setenv("WORKSTREAM_PROJECT_SETUP_PIPELINE_AUTOSTART", "false") + get_settings.cache_clear() + project = await create_project(project_client) + create_key = str(uuid4()) + create_headers = auth_headers() | {"Idempotency-Key": create_key} + payload = complete_guide_payload() + + created = await project_client.post( + f"/api/v1/projects/{project['id']}/guides", + headers=create_headers, + json=payload, + ) + replayed = await project_client.post( + f"/api/v1/projects/{project['id']}/guides", + headers=create_headers, + json=payload, + ) + assert created.status_code == replayed.status_code == 201 + assert replayed.json() == created.json() + guide = created.json() + + update_key = str(uuid4()) + updated = await project_client.patch( + f"/api/v1/projects/{project['id']}/guides/{guide['id']}", + headers=auth_headers() | {"Idempotency-Key": update_key}, + json={"content_markdown": f"{payload['content_markdown']}\n\nExpanded."}, + ) + assert updated.status_code == 200, updated.text + assert updated.json()["content_markdown"].endswith("Expanded.") + + snapshot_key = str(uuid4()) + snapshot_headers = auth_headers() | {"Idempotency-Key": snapshot_key} + snapshot_payload = source_snapshot_payload() + snapshotted = await project_client.post( + f"/api/v1/projects/{project['id']}/guides/{guide['id']}/source-snapshots", + headers=snapshot_headers, + json=snapshot_payload, + ) + snapshot_replay = await project_client.post( + f"/api/v1/projects/{project['id']}/guides/{guide['id']}/source-snapshots", + headers=snapshot_headers, + json=snapshot_payload, + ) + assert snapshotted.status_code == 201, snapshotted.text + assert snapshot_replay.status_code == 201, snapshot_replay.text + assert snapshot_replay.json() == snapshotted.json() + + blocked = await project_client.patch( + f"/api/v1/projects/{project['id']}/guides/{guide['id']}", + headers=auth_headers(), + json={"content_markdown": "replacement source"}, + ) + metadata_update = await project_client.patch( + f"/api/v1/projects/{project['id']}/guides/{guide['id']}", + headers=auth_headers(), + json={"change_summary": "Clarified without replacing source"}, + ) + assert blocked.status_code == 409 + assert metadata_update.status_code == 200, metadata_update.text + + async with db_session.get_session_factory()() as session: + persisted_guide = await session.get(ProjectGuide, guide["id"]) + persisted_snapshot = await session.get( + GuideSourceSnapshot, snapshotted.json()["id"] + ) + records = ( + await session.scalars( + select(GuideMutationIdempotencyRecord).where( + GuideMutationIdempotencyRecord.project_id == project["id"] + ) + ) + ).all() + assert persisted_guide is not None + assert persisted_snapshot is not None + assert persisted_guide.last_mutation_action_id == "project.guide.update" + assert persisted_guide.last_mutation_scope_type == "system" + assert persisted_guide.last_mutation_scope_project_id is None + assert persisted_guide.last_authorization_decision_event_id is not None + assert ( + persisted_snapshot.creation_action_id + == "project.guide_source_snapshot.create" + ) + assert persisted_snapshot.authorization_decision_event_id is not None + assert len(records) == 4 + assert all(record.status == "committed" for record in records) + assert sum(record.action_id == "project.guide.create" for record in records) == 1 + assert ( + sum( + record.action_id == "project.guide_source_snapshot.create" + for record in records + ) + == 1 + ) + + +async def test_guide_source_metadata_authority_rejects_removed_fields_and_bad_replay( + project_client: AsyncClient, +) -> None: + """The clean-cut schema and exact replay digest both fail closed.""" + project = await create_project(project_client) + for retired_field in ("review_policy", "revision_policy", "payment_policy"): + rejected = await project_client.post( + f"/api/v1/projects/{project['id']}/guides", + headers=auth_headers(), + json=complete_guide_payload() | {retired_field: {}}, + ) + assert rejected.status_code == 422 + assert retired_field in rejected.text + + key = str(uuid4()) + headers = auth_headers() | {"Idempotency-Key": key} + first = await project_client.post( + f"/api/v1/projects/{project['id']}/guides", + headers=headers, + json=complete_guide_payload(), + ) + mismatch = await project_client.post( + f"/api/v1/projects/{project['id']}/guides", + headers=headers, + json=complete_guide_payload("v2"), + ) + assert first.status_code == 201, first.text + assert mismatch.status_code == 409 + assert mismatch.json()["error"]["code"] == "idempotency_mismatch" + + +async def test_guide_source_metadata_authority_validates_key_before_actor_provisioning( + project_database_env: str, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Missing and malformed replay custody cannot create actor identity state.""" + subject = f"guide-key-rejected-{uuid4()}" + monkeypatch.setenv("WORKSTREAM_DEV_AUTH_SUBJECT", subject) + get_settings.cache_clear() + app = create_app() + async with AsyncClient( + transport=ASGITransport(app=app), base_url="http://testserver" + ) as client: + project_id, guide_id = uuid4(), uuid4() + requests = ( + ("post", f"/api/v1/projects/{project_id}/guides", complete_guide_payload()), + ( + "patch", + f"/api/v1/projects/{project_id}/guides/{guide_id}", + {"change_summary": "must fail before actor provisioning"}, + ), + ( + "post", + f"/api/v1/projects/{project_id}/guides/{guide_id}/source-snapshots", + source_snapshot_payload(), + ), + ) + for method, path, payload in requests: + for headers in ( + {"Authorization": "Bearer project-token"}, + { + "Authorization": "Bearer project-token", + "Idempotency-Key": "not-a-uuid", + }, + ): + response = await client.request(method, path, headers=headers, json=payload) + assert response.status_code == 422 + + async with db_session.get_session_factory()() as session: + assert await session.scalar( + select(ActorIdentityLink).where(ActorIdentityLink.subject == subject) + ) is None + + +async def test_create_guide_source_metadata_concurrent_replay_commits_once( + project_client: AsyncClient, +) -> None: + """Two simultaneous exact requests converge on one guide and response.""" + project = await create_project(project_client) + headers = auth_headers() | {"Idempotency-Key": str(uuid4())} + payload = complete_guide_payload() + + first, second = await asyncio.gather( + project_client.post( + f"/api/v1/projects/{project['id']}/guides", + headers=headers, + json=payload, + ), + project_client.post( + f"/api/v1/projects/{project['id']}/guides", + headers=headers, + json=payload, + ), + ) + assert first.status_code == second.status_code == 201 + assert first.json() == second.json() + async with db_session.get_session_factory()() as session: + assert await session.scalar( + select(func.count()).select_from(ProjectGuide).where( + ProjectGuide.project_id == project["id"], + ProjectGuide.version == payload["version"], + ) + ) == 1 + assert await session.scalar( + select(func.count()).select_from(GuideMutationIdempotencyRecord).where( + GuideMutationIdempotencyRecord.project_id == project["id"], + GuideMutationIdempotencyRecord.action_id == "project.guide.create", + ) + ) == 1 + + +async def test_guide_source_metadata_authority_enforces_exact_project_scope( + project_client: AsyncClient, +) -> None: + """A project-scoped Project Manager grant cannot cross into another project.""" + allowed_project = await create_project(project_client, name="Guide scope allowed") + denied_project = await create_project(project_client, name="Guide scope denied") + await revoke_system_project_manager_for_default_actor() + grant_id = await add_project_manager_admin_grant(allowed_project["id"]) + + allowed = await project_client.post( + f"/api/v1/projects/{allowed_project['id']}/guides", + headers=auth_headers(), + json=complete_guide_payload(), + ) + denied = await project_client.post( + f"/api/v1/projects/{denied_project['id']}/guides", + headers=auth_headers(), + json=complete_guide_payload(), + ) + assert allowed.status_code == 201, allowed.text + assert denied.status_code == 403 + + async with db_session.get_session_factory()() as session: + guide = await session.get(ProjectGuide, allowed.json()["id"]) + assert guide is not None + assert guide.last_mutated_by_admin_role_grant_id == grant_id + assert guide.last_mutation_scope_type == "project" + assert guide.last_mutation_scope_project_id == allowed_project["id"] + assert await session.scalar( + select(func.count()).select_from(ProjectGuide).where( + ProjectGuide.project_id == denied_project["id"] + ) + ) == 0 + denial = await session.scalar( + select(AuditEvent).where( + AuditEvent.action_id == "project.guide.create", + AuditEvent.event_type == "SensitiveAuthorizationDenied", + AuditEvent.target_ref_id == denied_project["id"], + ) + ) + assert denial is not None + assert denial.denial_code == "permission_not_granted" + + +async def test_guide_source_metadata_replay_cannot_cross_project_or_guide( + project_client: AsyncClient, +) -> None: + """The same actor/action/key/body never replays across route selectors.""" + first_project = await create_project(project_client, name="Replay first") + second_project = await create_project(project_client, name="Replay second") + create_key = str(uuid4()) + create_headers = auth_headers() | {"Idempotency-Key": create_key} + first_guide_response = await project_client.post( + f"/api/v1/projects/{first_project['id']}/guides", + headers=create_headers, + json=complete_guide_payload(), + ) + crossed_create = await project_client.post( + f"/api/v1/projects/{second_project['id']}/guides", + headers=create_headers, + json=complete_guide_payload(), + ) + assert first_guide_response.status_code == 201 + assert crossed_create.status_code == 409 + assert crossed_create.json()["error"]["code"] == "idempotency_mismatch" + + first_guide = first_guide_response.json() + second_guide = await create_guide( + project_client, second_project["id"], complete_guide_payload("v2") + ) + update_key = str(uuid4()) + update_headers = auth_headers() | {"Idempotency-Key": update_key} + update_body = {"change_summary": "Selector-bound update"} + first_update = await project_client.patch( + f"/api/v1/projects/{first_project['id']}/guides/{first_guide['id']}", + headers=update_headers, + json=update_body, + ) + crossed_update = await project_client.patch( + f"/api/v1/projects/{second_project['id']}/guides/{second_guide['id']}", + headers=update_headers, + json=update_body, + ) + assert first_update.status_code == 200 + assert crossed_update.status_code == 409 + assert crossed_update.json()["error"]["code"] == "idempotency_mismatch" + + snapshot_key = str(uuid4()) + snapshot_headers = auth_headers() | {"Idempotency-Key": snapshot_key} + snapshot_body = source_snapshot_payload() + first_snapshot = await project_client.post( + f"/api/v1/projects/{first_project['id']}/guides/{first_guide['id']}" + "/source-snapshots", + headers=snapshot_headers, + json=snapshot_body, + ) + crossed_snapshot = await project_client.post( + f"/api/v1/projects/{second_project['id']}/guides/{second_guide['id']}" + "/source-snapshots", + headers=snapshot_headers, + json=snapshot_body, + ) + assert first_snapshot.status_code == 201 + assert crossed_snapshot.status_code == 409 + assert crossed_snapshot.json()["error"]["code"] == "idempotency_mismatch" + + +async def test_guide_source_metadata_snapshot_replay_does_not_redispatch( + project_client: AsyncClient, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """An exact snapshot replay returns custody without another run or task.""" + dispatched: list[dict[str, str]] = [] + + def capture_dispatch(**facts: str) -> str: + dispatched.append(facts) + return "auth12d-one-task" + + monkeypatch.setenv("WORKSTREAM_PROJECT_SETUP_PIPELINE_AUTOSTART", "true") + get_settings.cache_clear() + monkeypatch.setattr( + project_setup_queue_module, + "enqueue_pre_submit_setup_pipeline", + capture_dispatch, + ) + project = await create_project(project_client) + guide = await create_guide(project_client, project["id"], complete_guide_payload()) + headers = auth_headers() | {"Idempotency-Key": str(uuid4())} + payload = source_snapshot_payload() + first = await project_client.post( + f"/api/v1/projects/{project['id']}/guides/{guide['id']}/source-snapshots", + headers=headers, + json=payload, + ) + replay = await project_client.post( + f"/api/v1/projects/{project['id']}/guides/{guide['id']}/source-snapshots", + headers=headers, + json=payload, + ) + assert first.status_code == replay.status_code == 201 + assert replay.json() == first.json() + assert len(dispatched) == 1 + async with db_session.get_session_factory()() as session: + runs = ( + await session.scalars( + select(ProjectSetupRun).where( + ProjectSetupRun.source_snapshot_id == first.json()["id"] + ) + ) + ).all() + assert len(runs) == 1 + assert runs[0].celery_task_id == "auth12d-one-task" + + +async def test_guide_source_metadata_database_rejects_unattributed_and_mismatched_custody( + project_client: AsyncClient, +) -> None: + """Deferred 0045 guards reject missing or borrowed authorization evidence.""" + project = await create_project(project_client) + async with db_session.get_session_factory()() as session: + session.add( + ProjectGuide( + id=str(uuid4()), + project_id=project["id"], + version="unattributed", + status="draft", + content_markdown="# Missing custody", + change_summary=None, + created_by=str(uuid4()), + ) + ) + with pytest.raises(IntegrityError): + await session.commit() + await session.rollback() + + guide = await create_guide(project_client, project["id"], complete_guide_payload()) + async with db_session.get_session_factory()() as session: + persisted = await session.get(ProjectGuide, guide["id"]) + assert persisted is not None + persisted.content_markdown = "# Changed without fresh custody" + with pytest.raises(IntegrityError): + await session.commit() + await session.rollback() + + async with db_session.get_session_factory()() as session: + persisted = await session.get(ProjectGuide, guide["id"]) + assert persisted is not None + persisted.version = "stale-lineage-rewrite" + with pytest.raises(IntegrityError, match="identity and lineage are immutable"): + await session.commit() + await session.rollback() + + snapshot = await create_source_snapshot(project_client, project["id"], guide["id"]) + async with db_session.get_session_factory()() as session: + persisted_snapshot = await session.get(GuideSourceSnapshot, snapshot["id"]) + assert persisted_snapshot is not None + persisted_snapshot.created_via_identity_link_id = str(uuid4()) + with pytest.raises(IntegrityError): + await session.commit() + await session.rollback() + + async with db_session.get_session_factory()() as session: + persisted = await session.get(ProjectGuide, guide["id"]) + borrowed = await session.scalar( + select(AuditEvent).where(AuditEvent.action_id == "project.create") + ) + assert persisted is not None + assert borrowed is not None + persisted.last_authorization_decision_event_id = borrowed.id + with pytest.raises(IntegrityError): + await session.commit() + await session.rollback() + + async def create_sufficiency_report( client: AsyncClient, project_id: str, @@ -4080,9 +4560,10 @@ async def derive_post_submit_checker_policy( ) await session.commit() - activation = await project_client.post( - f"/api/v1/projects/{project['id']}/guides/{guide['id']}/activate", - headers=auth_headers(), + activation = await activate_guide_for_downstream_test( + db_session.get_session_factory(), + project_id=project["id"], + guide_id=guide["id"], ) assert activation.status_code == 422 @@ -4366,7 +4847,9 @@ def fail_enqueue(**_: object) -> str: "broker rejected https://storage.flow.test/signed?token=secret" ) - monkeypatch.setattr(project_service_module, "enqueue_pre_submit_setup_pipeline", fail_enqueue) + monkeypatch.setattr( + project_setup_queue_module, "enqueue_pre_submit_setup_pipeline", fail_enqueue + ) project = await create_project(project_client) guide = await create_guide( project_client, @@ -5202,21 +5685,8 @@ async def test_sufficiency_report_rejects_snapshot_manifest_hash_drift( persisted = await session.get(GuideSourceSnapshot, snapshot["id"]) assert persisted is not None persisted.manifest_json = {**persisted.manifest_json, "tampered": True} - await session.commit() - - response = await project_client.post( - f"/api/v1/projects/{project['id']}/guides/{guide['id']}/sufficiency-reports", - headers=auth_headers(), - json={ - "source_snapshot_id": snapshot["id"], - "status": "passed", - "findings": [], - "summary": "Looks sufficient.", - }, - ) - - assert response.status_code == 422 - assert "integrity" in response.json()["detail"] + with pytest.raises(IntegrityError, match="source snapshot content is immutable"): + await session.commit() async def test_submission_policy_rejects_snapshot_item_drift( @@ -5240,20 +5710,41 @@ async def test_submission_policy_rejects_snapshot_item_drift( ) assert item is not None item.content_hash = sha256_hash("tampered-source-item") - await session.commit() + with pytest.raises(IntegrityError, match="snapshot items are immutable"): + await session.commit() - response = await project_client.post( - f"/api/v1/projects/{project['id']}/guides/{guide['id']}/submission-artifact-policies", - headers=auth_headers(), - json={ - "source_snapshot_id": snapshot["id"], - "policy_version": "v1", - "policy_body": project_submission_artifact_policy_body(), - }, - ) + async with db_session.get_session_factory()() as session: + with pytest.raises(IntegrityError, match="items do not match manifest"): + await session.execute( + text( + "insert into guide_source_snapshot_items " + "(id,source_snapshot_id,item_order,source_kind,durable_ref," + "ingestion_adapter,content_hash,content_cid,media_type) " + "values (:id,:snapshot_id,999,'external_document'," + "'https://docs.flow.test/appended','manual',:content_hash,null,'text/plain')" + ), + { + "id": str(uuid4()), + "snapshot_id": snapshot["id"], + "content_hash": sha256_hash("unauthorized-append"), + }, + ) + await session.commit() - assert response.status_code == 422 - assert "integrity" in response.json()["detail"] + async with db_session.get_session_factory()() as session: + with pytest.raises(IntegrityError, match="snapshot items are immutable"): + await session.execute(text("truncate guide_source_snapshot_items")) + + async with db_session.get_session_factory()() as session: + item = await session.scalar( + select(GuideSourceSnapshotItem).where( + GuideSourceSnapshotItem.source_snapshot_id == snapshot["id"] + ) + ) + assert item is not None + await session.delete(item) + with pytest.raises(IntegrityError, match="snapshot items are immutable"): + await session.commit() async def test_snapshot_freshness_fails_closed_when_captured_at_ties( @@ -6566,9 +7057,10 @@ async def test_activation_revalidates_agent_derived_policy_provenance( persisted.derivation_agent_version = "provider-v0" await session.commit() - response = await project_client.post( - f"/api/v1/projects/{project['id']}/guides/{guide['id']}/activate", - headers=auth_headers(), + response = await activate_guide_for_downstream_test( + db_session.get_session_factory(), + project_id=project["id"], + guide_id=guide["id"], ) assert response.status_code == 422 @@ -7218,13 +7710,20 @@ async def test_material_guide_edit_after_source_snapshot_is_blocked( assert "source material" in response.json()["detail"] -async def test_policy_context_edit_after_source_snapshot_is_allowed( +async def test_removed_payment_policy_edit_after_source_snapshot_is_rejected( project_client: AsyncClient, ) -> None: project = await create_project(project_client) guide = await create_guide(project_client, project["id"], complete_guide_payload()) await create_source_snapshot(project_client, project["id"], guide["id"]) - payment_policy = complete_guide_payload()["payment_policy"] + payment_policy = { + "base_amount": "25.00", + "currency": "USD", + "payout_type": "fixed", + "revision_payment_rule": "none", + "rejection_payment_rule": "none", + "accepted_payment_rule": "pay base amount", + } payment_policy["base_amount"] = "100.00" response = await project_client.patch( @@ -7233,7 +7732,8 @@ async def test_policy_context_edit_after_source_snapshot_is_allowed( json={"payment_policy": payment_policy}, ) - assert response.status_code == 200, response.text + assert response.status_code == 422 + assert "payment_policy" in response.text async def test_activation_rejects_policy_bound_to_stale_source_snapshot( @@ -7265,9 +7765,10 @@ async def test_activation_rejects_policy_bound_to_stale_source_snapshot( ) assert newer_response.status_code == 201, newer_response.text - response = await project_client.post( - f"/api/v1/projects/{project['id']}/guides/{guide['id']}/activate", - headers=auth_headers(), + response = await activate_guide_for_downstream_test( + db_session.get_session_factory(), + project_id=project["id"], + guide_id=guide["id"], ) assert response.status_code == 422 @@ -7316,9 +7817,10 @@ async def test_draft_policy_cannot_be_approved_after_guide_activation( pre_submit_checker_policy=pre_submit_checker_policy, ) await approve_post_submit_checker_policy(project_client, project["id"], guide["id"]) - activation = await project_client.post( - f"/api/v1/projects/{project['id']}/guides/{guide['id']}/activate", - headers=auth_headers(), + activation = await activate_guide_for_downstream_test( + db_session.get_session_factory(), + project_id=project["id"], + guide_id=guide["id"], ) assert activation.status_code == 200, activation.text @@ -7862,9 +8364,10 @@ async def test_sufficiency_warnings_require_acknowledgement( ) await approve_post_submit_checker_policy(project_client, project["id"], guide["id"]) - activated = await project_client.post( - f"/api/v1/projects/{project['id']}/guides/{guide['id']}/activate", - headers=auth_headers(), + activated = await activate_guide_for_downstream_test( + db_session.get_session_factory(), + project_id=project["id"], + guide_id=guide["id"], ) assert activated.status_code == 200, activated.text @@ -7945,9 +8448,10 @@ async def test_activation_revalidates_sufficiency_warning_acknowledgement_proven persisted.warnings_acknowledged_by_role = None await session.commit() - response = await project_client.post( - f"/api/v1/projects/{project['id']}/guides/{guide['id']}/activate", - headers=auth_headers(), + response = await activate_guide_for_downstream_test( + db_session.get_session_factory(), + project_id=project["id"], + guide_id=guide["id"], ) assert response.status_code == 422 @@ -8012,9 +8516,10 @@ async def test_activation_requires_submission_artifact_policy(project_client: As snapshot = await create_source_snapshot(project_client, project["id"], guide["id"]) await create_sufficiency_report(project_client, project["id"], guide["id"], snapshot["id"]) - response = await project_client.post( - f"/api/v1/projects/{project['id']}/guides/{guide['id']}/activate", - headers=auth_headers(), + response = await activate_guide_for_downstream_test( + db_session.get_session_factory(), + project_id=project["id"], + guide_id=guide["id"], ) assert response.status_code == 422 @@ -8028,9 +8533,10 @@ async def test_activation_uses_policy_bundle_without_guide_owned_artifact_fields guide = await create_guide(project_client, project["id"], complete_guide_payload()) await create_approved_policy_bundle(project_client, project["id"], guide["id"]) - response = await project_client.post( - f"/api/v1/projects/{project['id']}/guides/{guide['id']}/activate", - headers=auth_headers(), + response = await activate_guide_for_downstream_test( + db_session.get_session_factory(), + project_id=project["id"], + guide_id=guide["id"], ) assert response.status_code == 200, response.text @@ -8048,9 +8554,10 @@ async def test_activation_requires_generated_post_submit_setup_output( compile_post_submit_checker=False, ) - response = await project_client.post( - f"/api/v1/projects/{project['id']}/guides/{guide['id']}/activate", - headers=auth_headers(), + response = await activate_guide_for_downstream_test( + db_session.get_session_factory(), + project_id=project["id"], + guide_id=guide["id"], ) assert response.status_code == 422 @@ -8069,9 +8576,10 @@ async def test_activation_rejects_compiled_post_submit_checker_policy_before_app approve_post_submit_checker=False, ) - response = await project_client.post( - f"/api/v1/projects/{project['id']}/guides/{guide['id']}/activate", - headers=auth_headers(), + response = await activate_guide_for_downstream_test( + db_session.get_session_factory(), + project_id=project["id"], + guide_id=guide["id"], ) assert response.status_code == 422 @@ -8411,9 +8919,10 @@ async def derive_post_submit_checker_policy( assert setup_visibility.status_code == 200 assert setup_visibility.json()["correction_history"] == [] - activation = await project_client.post( - f"/api/v1/projects/{project['id']}/guides/{guide['id']}/activate", - headers=auth_headers(), + activation = await activate_guide_for_downstream_test( + db_session.get_session_factory(), + project_id=project["id"], + guide_id=guide["id"], ) assert activation.status_code == 422 @@ -8550,13 +9059,13 @@ async def test_guide_payload_rejects_manual_post_submit_checker_policy( async def test_activation_requires_review_policy(project_client: AsyncClient) -> None: project = await create_project(project_client) payload = complete_guide_payload() - payload["review_policy"] = None guide = await create_guide(project_client, project["id"], payload) await create_approved_policy_bundle(project_client, project["id"], guide["id"]) - response = await project_client.post( - f"/api/v1/projects/{project['id']}/guides/{guide['id']}/activate", - headers=auth_headers(), + response = await activate_guide_for_downstream_test( + db_session.get_session_factory(), + project_id=project["id"], + guide_id=guide["id"], ) assert response.status_code == 422 @@ -8566,13 +9075,13 @@ async def test_activation_requires_review_policy(project_client: AsyncClient) -> async def test_activation_requires_payment_policy(project_client: AsyncClient) -> None: project = await create_project(project_client) payload = complete_guide_payload() - payload["payment_policy"] = None guide = await create_guide(project_client, project["id"], payload) await create_approved_policy_bundle(project_client, project["id"], guide["id"]) - response = await project_client.post( - f"/api/v1/projects/{project['id']}/guides/{guide['id']}/activate", - headers=auth_headers(), + response = await activate_guide_for_downstream_test( + db_session.get_session_factory(), + project_id=project["id"], + guide_id=guide["id"], ) assert response.status_code == 422 @@ -8582,13 +9091,13 @@ async def test_activation_requires_payment_policy(project_client: AsyncClient) - async def test_activation_requires_revision_policy(project_client: AsyncClient) -> None: project = await create_project(project_client) payload = complete_guide_payload() - payload["revision_policy"] = None guide = await create_guide(project_client, project["id"], payload) await create_approved_policy_bundle(project_client, project["id"], guide["id"]) - response = await project_client.post( - f"/api/v1/projects/{project['id']}/guides/{guide['id']}/activate", - headers=auth_headers(), + response = await activate_guide_for_downstream_test( + db_session.get_session_factory(), + project_id=project["id"], + guide_id=guide["id"], ) assert response.status_code == 422 @@ -8598,7 +9107,12 @@ async def test_activation_requires_revision_policy(project_client: AsyncClient) async def test_review_policy_rejects_invalid_decision_names(project_client: AsyncClient) -> None: project = await create_project(project_client) payload = complete_guide_payload() - payload["review_policy"]["allowed_decisions"] = ["accept", "hold"] + payload["review_policy"] = { + "requires_second_review": False, + "allowed_decisions": ["accept", "hold"], + "minimum_finding_fields": ["issue", "required_fix"], + "sla_hours": 24, + } response = await project_client.post( f"/api/v1/projects/{project['id']}/guides", @@ -8608,7 +9122,7 @@ async def test_review_policy_rejects_invalid_decision_names(project_client: Asyn assert response.status_code == 422 detail = response.json()["detail"][0] - assert "allowed_decisions" in detail["loc"] + assert "review_policy" in detail["loc"] assert detail["input"] == "redacted" assert "hold" not in response.text @@ -8616,39 +9130,53 @@ async def test_review_policy_rejects_invalid_decision_names(project_client: Asyn async def test_activation_requires_complete_payment_policy(project_client: AsyncClient) -> None: project = await create_project(project_client) payload = complete_guide_payload() - payload["payment_policy"]["accepted_payment_rule"] = None - guide = await create_guide(project_client, project["id"], payload) - await create_approved_policy_bundle(project_client, project["id"], guide["id"]) - + payload["payment_policy"] = { + "base_amount": "25.00", + "currency": "USD", + "payout_type": "fixed", + "revision_payment_rule": "none", + "rejection_payment_rule": "none", + "accepted_payment_rule": None, + } response = await project_client.post( - f"/api/v1/projects/{project['id']}/guides/{guide['id']}/activate", + f"/api/v1/projects/{project['id']}/guides", headers=auth_headers(), + json=payload, ) assert response.status_code == 422 - assert "payment policy is incomplete" in response.json()["detail"] + assert "payment_policy" in response.text async def test_activation_requires_complete_revision_policy(project_client: AsyncClient) -> None: project = await create_project(project_client) payload = complete_guide_payload() - payload["revision_policy"]["allowed_resubmission_states"] = [] - guide = await create_guide(project_client, project["id"], payload) - await create_approved_policy_bundle(project_client, project["id"], guide["id"]) - + payload["revision_policy"] = { + "max_revision_rounds": 7, + "revision_deadline_hours": 48, + "auto_reject_after_limit": True, + "allowed_resubmission_states": [], + "reviewer_reassignment_rule": "same reviewer preferred", + } response = await project_client.post( - f"/api/v1/projects/{project['id']}/guides/{guide['id']}/activate", + f"/api/v1/projects/{project['id']}/guides", headers=auth_headers(), + json=payload, ) assert response.status_code == 422 - assert "revision policy is incomplete" in response.json()["detail"] + assert "revision_policy" in response.text async def test_revision_policy_requires_deadline(project_client: AsyncClient) -> None: project = await create_project(project_client) payload = complete_guide_payload() - del payload["revision_policy"]["revision_deadline_hours"] + payload["revision_policy"] = { + "max_revision_rounds": 7, + "auto_reject_after_limit": True, + "allowed_resubmission_states": ["needs_revision"], + "reviewer_reassignment_rule": "same reviewer preferred", + } response = await project_client.post( f"/api/v1/projects/{project['id']}/guides", @@ -8658,7 +9186,7 @@ async def test_revision_policy_requires_deadline(project_client: AsyncClient) -> assert response.status_code == 422 detail = response.json()["detail"][0] - assert "revision_deadline_hours" in detail["loc"] + assert "revision_policy" in detail["loc"] async def test_guide_update_rejects_manual_post_submit_checker_policy( @@ -8687,17 +9215,21 @@ async def test_activation_rejects_unsupported_revision_resubmission_states( ) -> None: project = await create_project(project_client) payload = complete_guide_payload() - payload["revision_policy"]["allowed_resubmission_states"] = ["random_state"] - guide = await create_guide(project_client, project["id"], payload) - await create_approved_policy_bundle(project_client, project["id"], guide["id"]) - + payload["revision_policy"] = { + "max_revision_rounds": 7, + "revision_deadline_hours": 48, + "auto_reject_after_limit": True, + "allowed_resubmission_states": ["random_state"], + "reviewer_reassignment_rule": "same reviewer preferred", + } response = await project_client.post( - f"/api/v1/projects/{project['id']}/guides/{guide['id']}/activate", + f"/api/v1/projects/{project['id']}/guides", headers=auth_headers(), + json=payload, ) assert response.status_code == 422 - assert "invalid resubmission states" in response.json()["detail"] + assert "revision_policy" in response.text async def test_activation_rejects_pending_pre_submit_checker_policy( @@ -8712,9 +9244,10 @@ async def test_activation_rejects_pending_pre_submit_checker_policy( compile_pre_submit_checker=False, ) - response = await project_client.post( - f"/api/v1/projects/{project['id']}/guides/{guide['id']}/activate", - headers=auth_headers(), + response = await activate_guide_for_downstream_test( + db_session.get_session_factory(), + project_id=project["id"], + guide_id=guide["id"], ) assert response.status_code == 422 @@ -8777,9 +9310,10 @@ async def test_activation_rejects_mismatched_submission_policy_body_hash( } await session.commit() - response = await project_client.post( - f"/api/v1/projects/{project['id']}/guides/{guide['id']}/activate", - headers=auth_headers(), + response = await activate_guide_for_downstream_test( + db_session.get_session_factory(), + project_id=project["id"], + guide_id=guide["id"], ) assert response.status_code == 422 @@ -8793,9 +9327,10 @@ async def test_active_guide_read_rejects_mismatched_effective_policy_body_hash( await add_project_manager_admin_grant(project["id"]) guide = await create_guide(project_client, project["id"], complete_guide_payload()) bundle = await create_approved_policy_bundle(project_client, project["id"], guide["id"]) - activation = await project_client.post( - f"/api/v1/projects/{project['id']}/guides/{guide['id']}/activate", - headers=auth_headers(), + activation = await activate_guide_for_downstream_test( + db_session.get_session_factory(), + project_id=project["id"], + guide_id=guide["id"], ) assert activation.status_code == 200, activation.text async with db_session.get_session_factory()() as session: @@ -8826,9 +9361,10 @@ async def test_active_guide_read_revalidates_policy_context( await add_project_manager_admin_grant(project["id"]) guide = await create_guide(project_client, project["id"], complete_guide_payload()) bundle = await create_approved_policy_bundle(project_client, project["id"], guide["id"]) - activation = await project_client.post( - f"/api/v1/projects/{project['id']}/guides/{guide['id']}/activate", - headers=auth_headers(), + activation = await activate_guide_for_downstream_test( + db_session.get_session_factory(), + project_id=project["id"], + guide_id=guide["id"], ) assert activation.status_code == 200, activation.text @@ -8857,9 +9393,10 @@ async def test_guide_activation_and_active_guide_retrieval(project_client: Async guide = await create_guide(project_client, project["id"], complete_guide_payload()) bundle = await create_approved_policy_bundle(project_client, project["id"], guide["id"]) - activation = await project_client.post( - f"/api/v1/projects/{project['id']}/guides/{guide['id']}/activate", - headers=auth_headers(), + activation = await activate_guide_for_downstream_test( + db_session.get_session_factory(), + project_id=project["id"], + guide_id=guide["id"], ) active = await project_client.get( f"/api/v1/projects/{project['id']}/active-guide", @@ -8986,9 +9523,10 @@ async def test_draft_guide_edit_and_active_guide_edit_block(project_client: Asyn assert draft_update.json()["content_markdown"] == "# Updated draft" await create_approved_policy_bundle(project_client, project["id"], guide["id"]) - activation = await project_client.post( - f"/api/v1/projects/{project['id']}/guides/{guide['id']}/activate", - headers=auth_headers(), + activation = await activate_guide_for_downstream_test( + db_session.get_session_factory(), + project_id=project["id"], + guide_id=guide["id"], ) assert activation.status_code == 200, activation.text @@ -9006,17 +9544,19 @@ async def test_new_active_guide_supersedes_prior_without_mutating_content( project = await create_project(project_client) first = await create_guide(project_client, project["id"], complete_guide_payload("v1")) await create_approved_policy_bundle(project_client, project["id"], first["id"]) - first_activation = await project_client.post( - f"/api/v1/projects/{project['id']}/guides/{first['id']}/activate", - headers=auth_headers(), + first_activation = await activate_guide_for_downstream_test( + db_session.get_session_factory(), + project_id=project["id"], + guide_id=first["id"], ) assert first_activation.status_code == 200, first_activation.text second = await create_guide(project_client, project["id"], complete_guide_payload("v2")) await create_approved_policy_bundle(project_client, project["id"], second["id"]) - second_activation = await project_client.post( - f"/api/v1/projects/{project['id']}/guides/{second['id']}/activate", - headers=auth_headers(), + second_activation = await activate_guide_for_downstream_test( + db_session.get_session_factory(), + project_id=project["id"], + guide_id=second["id"], ) assert second_activation.status_code == 200, second_activation.text @@ -9077,9 +9617,10 @@ async def test_activation_conflict_returns_conflict_response( project = await create_project(project_client) first = await create_guide(project_client, project["id"], complete_guide_payload("v1")) await create_approved_policy_bundle(project_client, project["id"], first["id"]) - first_activation = await project_client.post( - f"/api/v1/projects/{project['id']}/guides/{first['id']}/activate", - headers=auth_headers(), + first_activation = await activate_guide_for_downstream_test( + db_session.get_session_factory(), + project_id=project["id"], + guide_id=first["id"], ) assert first_activation.status_code == 200, first_activation.text @@ -9091,9 +9632,10 @@ async def hide_active_guides(self: ProjectRepository, project_id: str) -> list[P monkeypatch.setattr(ProjectRepository, "list_active_guides", hide_active_guides) - response = await project_client.post( - f"/api/v1/projects/{project['id']}/guides/{second['id']}/activate", - headers=auth_headers(), + response = await activate_guide_for_downstream_test( + db_session.get_session_factory(), + project_id=project["id"], + guide_id=second["id"], ) assert response.status_code == 409 @@ -9139,7 +9681,10 @@ async def test_project_create_requires_valid_idempotency_before_actor_provisioni async with AsyncClient( transport=ASGITransport(app=app), base_url="http://testserver" ) as client: - for headers in (auth_headers(), auth_headers() | {"Idempotency-Key": "invalid"}): + for headers in ( + {"Authorization": "Bearer project-token"}, + {"Authorization": "Bearer project-token", "Idempotency-Key": "invalid"}, + ): response = await client.post( "/api/v1/projects", headers=headers, diff --git a/backend/tests/test_tasks.py b/backend/tests/test_tasks.py index 9c0c74f8..816ee5e6 100644 --- a/backend/tests/test_tasks.py +++ b/backend/tests/test_tasks.py @@ -69,7 +69,10 @@ TaskAssignment, WorkstreamTask, ) -from project_create_fixtures import grant_system_project_manager +from project_create_fixtures import ( + activate_guide_for_downstream_test, + grant_system_project_manager, +) from app.modules.tasks.repository import TaskRepository from app.modules.tasks.schemas import SubmissionCreate, TaskCreate from app.modules.tasks.service import ( @@ -1110,9 +1113,10 @@ async def create_active_project(client: AsyncClient) -> dict: guide = guide_response.json() await create_policy_bundle_for_guide(client, project["id"], guide["id"]) - activation_response = await client.post( - f"/api/v1/projects/{project['id']}/guides/{guide['id']}/activate", - headers=auth_headers(), + activation_response = await activate_guide_for_downstream_test( + db_session.get_session_factory(), + project_id=project["id"], + guide_id=guide["id"], ) assert activation_response.status_code == 200, activation_response.text return project @@ -2943,9 +2947,10 @@ async def test_task_context_apis_use_v1_locked_requirements_after_v2_activation( guide_v2.json()["id"], policy_v2, ) - activate_v2 = await task_client.post( - f"/api/v1/projects/{project['id']}/guides/{guide_v2.json()['id']}/activate", - headers=auth_headers(), + activate_v2 = await activate_guide_for_downstream_test( + db_session.get_session_factory(), + project_id=project["id"], + guide_id=guide_v2.json()["id"], ) assert activate_v2.status_code == 200, activate_v2.text assert activate_v2.json()["guide"]["version"] == "v2" @@ -4754,9 +4759,10 @@ async def test_submission_uses_task_locked_context_after_new_guide_activation( ) assert guide_v2.status_code == 201, guide_v2.text await create_policy_bundle_for_guide(task_client, project["id"], guide_v2.json()["id"]) - activate_v2 = await task_client.post( - f"/api/v1/projects/{project['id']}/guides/{guide_v2.json()['id']}/activate", - headers=auth_headers(), + activate_v2 = await activate_guide_for_downstream_test( + db_session.get_session_factory(), + project_id=project["id"], + guide_id=guide_v2.json()["id"], ) assert activate_v2.status_code == 200, activate_v2.text assert activate_v2.json()["guide"]["version"] == "v2" @@ -5086,9 +5092,10 @@ async def test_database_blocks_task_locked_context_mutation_after_submission( ) assert guide_v2.status_code == 201, guide_v2.text await create_policy_bundle_for_guide(task_client, project["id"], guide_v2.json()["id"]) - activate_v2 = await task_client.post( - f"/api/v1/projects/{project['id']}/guides/{guide_v2.json()['id']}/activate", - headers=auth_headers(), + activate_v2 = await activate_guide_for_downstream_test( + db_session.get_session_factory(), + project_id=project["id"], + guide_id=guide_v2.json()["id"], ) assert activate_v2.status_code == 200, activate_v2.text diff --git a/docs/operations_authorization_service.md b/docs/operations_authorization_service.md index 9c55a47a..5241c7e5 100644 --- a/docs/operations_authorization_service.md +++ b/docs/operations_authorization_service.md @@ -1290,3 +1290,33 @@ project write fails, the reservation rolls back with the transaction. Do not repair this path by inserting a project without the complete creation provenance columns or by granting project-scoped authority; project-scoped Project Manager grants cannot create projects. + +## Draft guide and source-metadata authorization + +`POST /api/v1/projects/{project_id}/guides`, its draft-guide `PATCH`, and the +source-snapshot metadata `POST` each require a UUID `Idempotency-Key` and an +active system or exact-project Project Manager grant. A 403 for an existing +project is expected when that local grant is absent, revoked, stale, or scoped +to another project; do not restore access from token roles or issuer claims. +Use the request/correlation IDs to inspect the bounded denial event. + +Guide creation must not create source snapshots, policy rows, or setup runs. +Source-snapshot creation is the separate boundary that may atomically commit +one setup-run queue intent. Celery receives identifiers only after commit; a +prepared authorization handle must never appear in task arguments, logs, or +serialized state. If broker dispatch fails, inspect the exact setup run for +`enqueue_failed` and use its bounded recovery path. Retrying the original HTTP +request returns its recorded response and must not dispatch again. + +Source markdown can be corrected only until the first snapshot exists. After +capture, source changes correctly return 409 while bounded draft metadata may +still be updated. Embedded review, revision, retired payout/economic, and +contribution-record configuration fields correctly return 422; do not reintroduce a +compatibility payload or direct product-service authorization path. + +Migration `0045_guide_metadata_authority` leaves pre-existing guide, source +snapshot, and setup-run rows readable with null authorization provenance; it +does not invent or backfill historical custody. Every new covered mutation must +commit its complete replay, decision, and row provenance atomically. Once any +12D custody or attributed mutation exists, downgrade is intentionally refused; +operators must not delete authority evidence merely to force rollback. diff --git a/docs/spec_authorization_service.md b/docs/spec_authorization_service.md index c7c34ebc..4e369a25 100644 --- a/docs/spec_authorization_service.md +++ b/docs/spec_authorization_service.md @@ -926,7 +926,8 @@ conceal. Contributor guide requirements remain on task work-context and submission-requirements surfaces. AUTH-12A registered the complete project-mutation vocabulary below as planned -and unavailable. AUTH-12C now activates only `project.create`; every remaining +and unavailable. AUTH-12C activates `project.create`, and AUTH-12D activates +the three draft guide/source-metadata actions identified below; every remaining row stays planned. A planned action fails with `action_unavailable` before a prepared handle or allowed decision evidence can exist. `project.create` alone derives system scope; every other action derives the exact project from its @@ -951,12 +952,31 @@ committed custody chain, then returns the original response without new PREP or allowed evidence. Later grant revocation denies new or changed creation requests but does not rewrite an already committed idempotent response. +Guide create, guide update, and source-snapshot metadata create require an +active human with an effective system-scoped or exact-project Project Manager +grant carrying `project.guide.manage`. Each route requires a UUID +`Idempotency-Key` before actor first-access provisioning and consumes one opaque, +transaction-bound PREP handle after locking the exact project, draft guide, and +current source lineage. Guide create produces only a draft guide. Snapshot +creation separately records the sanitized source manifest and may commit one +setup-run queue intent; broker dispatch happens only after commit and never +carries the prepared handle. + +Guide create/update no longer accept embedded review, revision, retired +payout/economic, or contribution-record configuration fields. Guide source markdown +may change before the first source snapshot, becomes immutable after capture, +and bounded metadata such as `change_summary` remains editable while the guide +is draft. Exact committed retries return the recorded response without another +mutation, setup run, or dispatch. Changed, concurrent-pending, cross-project, +stale-lineage, revoked, wrong-action, wrong-resource, or wrong-transaction use +fails closed with no product write. + | ActionId | PermissionId | Activation owner | |---|---|---| | `project.create` (active) | `project.create` | `WS-AUTH-001-12C` | -| `project.guide.create` | `project.guide.manage` | `WS-AUTH-001-12D` | -| `project.guide.update` | `project.guide.manage` | `WS-AUTH-001-12D` | -| `project.guide_source_snapshot.create` | `project.guide.manage` | `WS-AUTH-001-12D` | +| `project.guide.create` (active) | `project.guide.manage` | `WS-AUTH-001-12D` | +| `project.guide.update` (active) | `project.guide.manage` | `WS-AUTH-001-12D` | +| `project.guide_source_snapshot.create` (active) | `project.guide.manage` | `WS-AUTH-001-12D` | | `project.review_policy.update` | `project.review_policy.manage` | `WS-AUTH-001-12D2` | | `project.revision_policy.update` | `project.review_policy.manage` | `WS-AUTH-001-12D2` | | `project.guide_sufficiency_report.create` | `project.guide.manage` | `WS-AUTH-001-12E` | diff --git a/docs/spec_chunk_3_project_guide_foundation.md b/docs/spec_chunk_3_project_guide_foundation.md index 232a4929..8d8657bc 100644 --- a/docs/spec_chunk_3_project_guide_foundation.md +++ b/docs/spec_chunk_3_project_guide_foundation.md @@ -145,8 +145,8 @@ Adds protected v1 routes: These routes require an actor role allowed to manage project setup. Normal project setup does not depend on manually calling the sufficiency or -derivation routes. Creating a guide or a later guide-source snapshot enqueues -the Celery project setup pipeline, which runs guide sufficiency first and only +derivation routes. Creating guide-source snapshot metadata enqueues the Celery +project setup pipeline; guide creation alone does not. The pipeline runs guide sufficiency first and only continues to submission artifact policy derivation when sufficiency is not blocked. From 0972eac58c4024486c756ae26b10457a7545e02a Mon Sep 17 00:00:00 2001 From: Abiorh001 Date: Fri, 31 Jul 2026 11:41:38 +0100 Subject: [PATCH 03/16] fix(auth): close guide mutation custody gaps --- ...TH-001-12D-guide-draft-source-mutations.md | 7 +++- ...S-AUTH-001-12D-internal-review-evidence.md | 33 +++++++++++++++ .../WS-AUTH-001-12D-pr-trust-bundle.md | 42 +++++++++++++++++++ .../0045_guide_source_metadata_authority.py | 35 ++++++++++------ backend/app/api/deps/authorization.py | 22 ++++++++++ .../modules/projects/guide_mutation_router.py | 40 ++++++++++++++++-- backend/scripts/api_contract_e2e.py | 8 +++- backend/tests/conftest.py | 4 +- backend/tests/project_create_fixtures.py | 6 +++ backend/tests/test_projects.py | 2 +- docs/spec_chunk_3_project_guide_foundation.md | 4 +- 11 files changed, 179 insertions(+), 24 deletions(-) create mode 100644 .agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/reviews/WS-AUTH-001-12D-internal-review-evidence.md create mode 100644 .agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/reviews/WS-AUTH-001-12D-pr-trust-bundle.md diff --git a/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/chunks/WS-AUTH-001-12D-guide-draft-source-mutations.md b/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/chunks/WS-AUTH-001-12D-guide-draft-source-mutations.md index d5ce2405..e4c21eb7 100644 --- a/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/chunks/WS-AUTH-001-12D-guide-draft-source-mutations.md +++ b/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/chunks/WS-AUTH-001-12D-guide-draft-source-mutations.md @@ -2,8 +2,8 @@ ## Status and prerequisite -Implementation in progress after merged 12C and completed L1 -pre-implementation review. +Implemented and internally reviewed; awaiting hosted CI, external review, and +human merge approval. ## Parent initiative @@ -50,9 +50,12 @@ backend/tests/test_projects.py backend/tests/test_guide_artifacts.py backend/tests/test_alembic.py backend/tests/test_api_controls.py +backend/tests/test_checkers.py backend/tests/project_create_fixtures.py backend/tests/conftest.py +backend/tests/test_tasks.py backend/scripts/api_contract_e2e.py +backend/scripts/week2_api_e2e.py README.md docs/spec_chunk_3_project_guide_foundation.md docs/spec_authorization_service.md diff --git a/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/reviews/WS-AUTH-001-12D-internal-review-evidence.md b/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/reviews/WS-AUTH-001-12D-internal-review-evidence.md new file mode 100644 index 00000000..a8814c4b --- /dev/null +++ b/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/reviews/WS-AUTH-001-12D-internal-review-evidence.md @@ -0,0 +1,33 @@ +# WS-AUTH-001-12D Internal Review Evidence + +## Scope reviewed + +Draft guide create/update and guide-source snapshot metadata creation through +the existing PREP protocol, migration 0045 custody, clean-cut activation-route +removal, and temporary isolated-test downstream activation seeding. + +## Deterministic evidence + +- Ruff and `git diff --check`: pass. +- Markdown links, stale Workstream wording, and stale authorization docs: pass. +- Focused PostgreSQL AUTH-12D lane: 20 passed. +- Exact key-before-provisioning and project-scope regression: 2 passed. +- Snapshot header/item and lineage custody regression: covered in the focused lane. +- Real API contract E2E: pass, including public activation-route 404. +- Hosted full-suite and per-file 90 percent coverage gates: pending pushed SHA. + +## Reviewer results + +| Track | Result | Material resolution | +| --- | --- | --- | +| Architecture | PASS | Synced current main, preserved ART gates, bounded temporary test seed, and made guide lineage immutable. | +| Security | PASS | Key-gated actor/PREP chain; exact binding; immediate lifecycle guard; full snapshot-item custody. | +| QA | PASS | Reset guards, append/update/delete/truncate proof, activation cutover, and E2E expectations reconciled. | +| Product/ops | PASS | Activation is explicitly deferred to AUTH-12H; only three 12D actions are exposed. | +| Senior engineering | PASS with low risk | Shared actor/PREP helpers preserve structured denial handling; isolated seed restores both triggers. | +| CI integrity | PASS with low risk | Existing floors remain; three new modules receive hosted per-file 90 percent gates. | +| Docs | PASS | Link and stale-wording gates pass; guide/setup wording matches the cutover. | +| Reuse/dedup | PASS with low risk | Manifest, item construction, and validation use shared implementations. | +| Test delta | PASS with low risk | No skips or weakened assertions; missing/malformed key and migration round-trip proof restored. | + +All reviewer sessions completed. No Critical, High, or Medium finding remains open. diff --git a/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/reviews/WS-AUTH-001-12D-pr-trust-bundle.md b/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/reviews/WS-AUTH-001-12D-pr-trust-bundle.md new file mode 100644 index 00000000..86933e8a --- /dev/null +++ b/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/reviews/WS-AUTH-001-12D-pr-trust-bundle.md @@ -0,0 +1,42 @@ +# WS-AUTH-001-12D PR Trust Bundle + +## Intent + +Activate exactly `project.guide.create`, `project.guide.update`, and +`project.guide_source_snapshot.create` for a system-scoped or exact-project +Project Manager. Keep ART byte operations, policy mutation, and guide activation +outside this chunk. + +## Design and scope + +- Dedicated guide mutation router, service, and repository use the existing + opaque, transaction-bound PREP protocol. +- Required UUID idempotency is validated before actor first-access provisioning. +- Prepared requests and final decisions bind actor, identity link, exact grant, + action, project, guide, target resource, operation, transaction, generation, + and current source lineage. +- Migration 0045 records atomic replay/evidence/product custody and protects + guide identity/lifecycle plus the complete snapshot manifest/item set. +- The legacy activation route is absent until AUTH-12H. Downstream tests seed + historical active state only inside derived isolated databases. + +## Proof + +- Focused PostgreSQL lane: 20 passed. +- Key ordering and exact project scope: 2 passed. +- Real API contract E2E: passed. +- Ruff, diff check, Markdown links, and stale wording/docs: passed. +- Internal architecture, security, QA, product, senior, CI, docs, reuse, and + test-delta tracks: passed; low risks are documented in the review evidence. +- GitHub full-suite, repository 78 percent, AUTH 90 percent, and new per-file + 90 percent gates remain required on the final pushed SHA. + +## Remaining risk and human review focus + +- Confirm only the three intended actions moved from planned to active. +- Inspect migration trigger ordering, snapshot-item append protection, and the + shortened Alembic revision id documented beside the exact migration filename. +- Inspect the key-gated actor/PREP dependency graph and denial restaging. +- Confirm the temporary activation seed is test/E2E-only and isolated-database guarded. + +Chunk complete locally. Await hosted CI, external review, and human merge. diff --git a/backend/alembic/versions/0045_guide_source_metadata_authority.py b/backend/alembic/versions/0045_guide_source_metadata_authority.py index 14d984ae..0b4cd342 100644 --- a/backend/alembic/versions/0045_guide_source_metadata_authority.py +++ b/backend/alembic/versions/0045_guide_source_metadata_authority.py @@ -228,6 +228,27 @@ def upgrade() -> None: "create trigger guide_mutation_idempotency_reject_truncate before truncate " "on guide_mutation_idempotency_records execute function reject_guide_mutation_idempotency_truncate()" ) + op.execute( + """ + create function guard_guide_lineage_and_lifecycle() returns trigger + language plpgsql as $$ begin + if (new.id,new.project_id,new.version) + is distinct from (old.id,old.project_id,old.version) then + raise exception 'guide identity and lineage are immutable' using errcode='23514'; + end if; + if (new.status,new.approved_by,new.effective_at,new.superseded_at) + is distinct from (old.status,old.approved_by,old.effective_at,old.superseded_at) then + raise exception 'guide lifecycle mutation requires activation authority' + using errcode='23514'; + end if; + return new; + end $$ + """ + ) + op.execute( + "create trigger guide_lineage_lifecycle_guard before update on project_guides " + "for each row execute function guard_guide_lineage_and_lifecycle()" + ) op.execute( """ create function validate_guide_mutation_custody() returns trigger @@ -261,18 +282,6 @@ def upgrade() -> None: from guide_source_snapshots where id=reservation.resource_id; end if; elsif tg_table_name='project_guides' then - if tg_op='UPDATE' - and (new.id,new.project_id,new.version) - is distinct from (old.id,old.project_id,old.version) then - raise exception 'guide identity and lineage are immutable' using errcode='23514'; - end if; - if tg_op='UPDATE' - and (new.status,new.approved_by,new.effective_at,new.superseded_at) - is distinct from - (old.status,old.approved_by,old.effective_at,old.superseded_at) then - raise exception 'guide lifecycle mutation requires activation authority' - using errcode='23514'; - end if; if tg_op='UPDATE' and (new.content_markdown is distinct from old.content_markdown or new.change_summary is distinct from old.change_summary) @@ -482,6 +491,7 @@ def downgrade() -> None: op.execute("drop trigger source_setup_run_custody on project_setup_runs") op.execute("drop trigger source_snapshot_product_custody on guide_source_snapshots") op.execute("drop trigger guide_mutation_product_custody on project_guides") + op.execute("drop trigger guide_lineage_lifecycle_guard on project_guides") op.execute( "drop trigger guide_mutation_idempotency_reject_truncate on guide_mutation_idempotency_records" ) @@ -491,6 +501,7 @@ def downgrade() -> None: op.execute("drop function reject_guide_mutation_idempotency_truncate()") op.execute("drop function guard_guide_mutation_idempotency()") op.execute("drop function validate_guide_mutation_custody()") + op.execute("drop function guard_guide_lineage_and_lifecycle()") op.drop_table("guide_mutation_idempotency_records") op.drop_constraint("setup_run_authority_shape", "project_setup_runs", type_="check") _drop_authority_columns("project_setup_runs", prefix="authorized") diff --git a/backend/app/api/deps/authorization.py b/backend/app/api/deps/authorization.py index 9fa6cd75..eba1b619 100644 --- a/backend/app/api/deps/authorization.py +++ b/backend/app/api/deps/authorization.py @@ -3,6 +3,7 @@ from __future__ import annotations from collections.abc import AsyncIterator +from contextlib import asynccontextmanager import logging from typing import Annotated from uuid import UUID @@ -81,6 +82,16 @@ async def get_authorization_actor( rate_control: Annotated[RateControlService, Depends(get_rate_control_service)], ) -> ResolvedActor: """Resolve an exact human or fixed-service target before kernel lifecycle denial.""" + return await resolve_authorization_actor(request, result, session, rate_control) + + +async def resolve_authorization_actor( + request: Request, + result: AuthVerificationResult, + session: AsyncSession, + rate_control: RateControlService, +) -> ResolvedActor: + """Resolve an actor after any route-specific pre-provisioning prerequisites.""" if result.token.subject_kind not in {"human", "service"}: raise actor_registry_http_error(UnsupportedSubjectKind("Unsupported subject kind")) service = ActorService(session) @@ -285,6 +296,17 @@ async def get_prepared_authorization_service( session: Annotated[AsyncSession, Depends(get_db_session)], ) -> AsyncIterator[PreparedAuthorizationService]: """Compose one request-local prepared service without taking commit ownership.""" + async with prepared_authorization_service(request, resolved, session) as service: + yield service + + +@asynccontextmanager +async def prepared_authorization_service( + request: Request, + resolved: ResolvedActor, + session: AsyncSession, +) -> AsyncIterator[PreparedAuthorizationService]: + """Yield PREP around an actor resolved by the caller's dependency chain.""" request_id, correlation_id = (UUID(value) for value in request_ids(request)) context = _authorization_context(resolved, request_id, correlation_id) repository = AdminAuthorizationRepository(session) diff --git a/backend/app/modules/projects/guide_mutation_router.py b/backend/app/modules/projects/guide_mutation_router.py index 6c482db8..c87e08a3 100644 --- a/backend/app/modules/projects/guide_mutation_router.py +++ b/backend/app/modules/projects/guide_mutation_router.py @@ -5,13 +5,19 @@ from typing import Annotated from uuid import UUID -from fastapi import APIRouter, Depends, Header, HTTPException +from fastapi import APIRouter, Depends, Header, HTTPException, Request from sqlalchemy.ext.asyncio import AsyncSession -from app.api.deps.authorization import get_authorization_actor, get_prepared_authorization_service +from app.api.deps.auth import get_auth_verification_result +from app.api.deps.authorization import ( + prepared_authorization_service, + resolve_authorization_actor, +) +from app.api.deps.rate_controls import get_rate_control_service from app.core.api_controls import StructuredHTTPException from app.db.session import get_db_session from app.modules.actors.service import ResolvedActor +from app.modules.api_controls.service import RateControlService from app.modules.authorization.catalogue import ActionId from app.modules.authorization.prepared import PreparedAuthorizationService from app.modules.projects.guide_mutation_service import ( @@ -27,6 +33,7 @@ ) from app.modules.projects.service import ProjectServiceError from app.modules.projects.setup_queue import dispatch_pre_submit_setup_pipeline_after_commit +from app.schemas.auth import AuthVerificationResult router = APIRouter(prefix="/projects", tags=["projects"]) @@ -46,10 +53,35 @@ def require_guide_mutation_key( ) from exc +async def guide_authorization_actor( + key: Annotated[UUID, Depends(require_guide_mutation_key)], + request: Request, + result: Annotated[AuthVerificationResult, Depends(get_auth_verification_result)], + session: Annotated[AsyncSession, Depends(get_db_session)], + rate_control: Annotated[RateControlService, Depends(get_rate_control_service)], +) -> ResolvedActor: + """Provision an actor only after the replay key is present and valid.""" + del key + return await resolve_authorization_actor(request, result, session, rate_control) + + +async def get_guide_prepared_authorization_service( + request: Request, + resolved: Annotated[ResolvedActor, Depends(guide_authorization_actor)], + session: Annotated[AsyncSession, Depends(get_db_session)], +): + """Compose PREP only from the key-gated guide actor dependency.""" + async with prepared_authorization_service(request, resolved, session) as service: + yield service + + async def guide_authorization( key: Annotated[UUID, Depends(require_guide_mutation_key)], - resolved: Annotated[ResolvedActor, Depends(get_authorization_actor)], - prepared: Annotated[PreparedAuthorizationService, Depends(get_prepared_authorization_service)], + resolved: Annotated[ResolvedActor, Depends(guide_authorization_actor)], + prepared: Annotated[ + PreparedAuthorizationService, + Depends(get_guide_prepared_authorization_service), + ], ): return key, resolved, prepared diff --git a/backend/scripts/api_contract_e2e.py b/backend/scripts/api_contract_e2e.py index 11870167..2af656c5 100644 --- a/backend/scripts/api_contract_e2e.py +++ b/backend/scripts/api_contract_e2e.py @@ -66,7 +66,7 @@ async def seed_active_guide_for_pre_12h_e2e(project_id: str, guide_id: str) -> d external_issuer=DEFAULT_FLOW_ISSUER, roles=("project_manager",), claim_snapshot={}, - auth_source="flow_jwt", + auth_source="flow", is_dev_auth=False, ) async with db_session.get_session_factory()() as session: @@ -76,11 +76,17 @@ async def seed_active_guide_for_pre_12h_e2e(project_id: str, guide_id: str) -> d await session.execute( text("alter table project_guides disable trigger guide_mutation_product_custody") ) + await session.execute( + text("alter table project_guides disable trigger guide_lineage_lifecycle_guard") + ) await session.commit() try: result = await ProjectService(session).activate_guide(actor, project_id, guide_id) return result.model_dump(mode="json") finally: + await session.execute( + text("alter table project_guides enable trigger guide_lineage_lifecycle_guard") + ) await session.execute( text("alter table project_guides enable trigger guide_mutation_product_custody") ) diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index 0480bd2c..d8b3f9a9 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 = "782291544912ec1c0ed793c3874f399e8708eb3f5dda051a21c121ee47a507ca" +EXPECTED_PUBLIC_SCHEMA_SHA256 = "e5e55ca12f13d860d2c9f374376b8bd4c0c981103ee242246fa0389b0a5b5cb7" PROTECTED_TEST_TABLES = ( "actor_profile_migration_state", "alembic_version", @@ -64,7 +64,6 @@ "guide_source_format_classifications", "guide_mutation_idempotency_records", "guide_source_snapshot_items", - "guide_source_snapshot_items", "guide_source_snapshots", "guide_sufficiency_reports", "legacy_actor_identities", @@ -91,6 +90,7 @@ "authority_control", "authority_idempotency_records", "guide_mutation_idempotency_records", + "guide_source_snapshot_items", "outbox_events", "project_create_idempotency_records", "project_role_grants", diff --git a/backend/tests/project_create_fixtures.py b/backend/tests/project_create_fixtures.py index 43ff82fa..a91a5643 100644 --- a/backend/tests/project_create_fixtures.py +++ b/backend/tests/project_create_fixtures.py @@ -55,6 +55,9 @@ async def activate_guide_for_downstream_test( await session.execute( text("alter table project_guides disable trigger guide_mutation_product_custody") ) + await session.execute( + text("alter table project_guides disable trigger guide_lineage_lifecycle_guard") + ) await session.commit() try: result = await ProjectService(session).activate_guide(actor, project_id, guide_id) @@ -63,6 +66,9 @@ async def activate_guide_for_downstream_test( await session.rollback() return Response(status_code=exc.status_code, json={"detail": str(exc)}) finally: + await session.execute( + text("alter table project_guides enable trigger guide_lineage_lifecycle_guard") + ) await session.execute( text("alter table project_guides enable trigger guide_mutation_product_custody") ) diff --git a/backend/tests/test_projects.py b/backend/tests/test_projects.py index fd50daed..3d7c7187 100644 --- a/backend/tests/test_projects.py +++ b/backend/tests/test_projects.py @@ -5733,7 +5733,7 @@ async def test_submission_policy_rejects_snapshot_item_drift( async with db_session.get_session_factory()() as session: with pytest.raises(IntegrityError, match="snapshot items are immutable"): - await session.execute(text("truncate guide_source_snapshot_items")) + await session.execute(text("truncate guide_source_snapshot_items cascade")) async with db_session.get_session_factory()() as session: item = await session.scalar( diff --git a/docs/spec_chunk_3_project_guide_foundation.md b/docs/spec_chunk_3_project_guide_foundation.md index 8d8657bc..9f1fb775 100644 --- a/docs/spec_chunk_3_project_guide_foundation.md +++ b/docs/spec_chunk_3_project_guide_foundation.md @@ -176,8 +176,8 @@ project-scoped `PreSubmitCheckerPolicy` contract with lifecycle status same approval path. Guide activation fails unless the compiled project pre-submit checker policy exists. -`POST /activate` returns the lifecycle activation result with the full setup -bundle: +Guide activation is unavailable until AUTH-12H installs its prepared mutation +boundary. The historical activation bundle will contain: - `guide_source_snapshot` - `guide_sufficiency_report` From a5cca2f6f8186c97dacc8dd3f7e3fec3b8e00433 Mon Sep 17 00:00:00 2001 From: Abiorh001 Date: Fri, 31 Jul 2026 12:24:07 +0100 Subject: [PATCH 04/16] fix(auth): close guide mutation review gaps --- ...S-AUTH-001-12D-external-review-response.md | 41 +++++++++ ...S-AUTH-001-12D-internal-review-evidence.md | 16 ++++ .../WS-AUTH-001-12D-pr-trust-bundle.md | 17 +++- backend/app/api/deps/authorization.py | 2 +- .../projects/guide_mutation_service.py | 2 +- backend/app/modules/projects/service.py | 87 ------------------- backend/scripts/api_contract_e2e.py | 1 + backend/tests/project_create_fixtures.py | 5 +- backend/tests/test_authorization.py | 13 ++- backend/tests/test_projects.py | 19 +++- backend/tests/test_tasks.py | 66 +++++++++----- 11 files changed, 153 insertions(+), 116 deletions(-) create mode 100644 .agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/reviews/WS-AUTH-001-12D-external-review-response.md diff --git a/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/reviews/WS-AUTH-001-12D-external-review-response.md b/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/reviews/WS-AUTH-001-12D-external-review-response.md new file mode 100644 index 00000000..5038bc96 --- /dev/null +++ b/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/reviews/WS-AUTH-001-12D-external-review-response.md @@ -0,0 +1,41 @@ +# WS-AUTH-001-12D External Review Response + +## Comments addressed + +- Major: preserve explicit `null` values in the guide-update idempotency digest + while omitting unset fields, matching the actual update semantics. Added a + regression proving explicit null and omission cannot replay under one key. +- Major: roll back failed activation transactions before restoring both + test-only guide triggers in the downstream fixture and API E2E seed. +- Minor: distinguish ordinary authorization-decision transaction failures from + prepared-authorization transaction failures in logs. +- Minor: document both triggers suspended by the isolated downstream seed and + correct the source-snapshot broker-failure test wording. +- Test completeness: exercise both system-scoped and exact-project Project + Manager grants for all three guide mutation actions. +- Reuse: remove the unreachable legacy snapshot/setup-run creation and dispatch + helper chain from `ProjectService`. +- Hosted CI: update downstream task/checker construction to use required UUID + idempotency and the clean-cut guide request, with lifecycle policies seeded + independently as test prerequisites. + +## Comments deferred + +None. + +## Human decisions needed + +None. The user retains merge authority for PR #232. + +## Commands rerun + +- Ruff on all corrective files: passed. +- Isolated PostgreSQL correction lane covering downstream task/checker setup, + explicit-null replay, and system/project grant paths: 9 passed. +- Git diff check: passed. +- GitHub full backend suite and coverage: required again on the corrected SHA. + +## Remaining risks + +- Hosted semantic lanes and coverage must pass on the corrected exact head. +- CodeRabbit must re-review the corrected exact head with no unresolved findings. diff --git a/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/reviews/WS-AUTH-001-12D-internal-review-evidence.md b/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/reviews/WS-AUTH-001-12D-internal-review-evidence.md index a8814c4b..4cc4f856 100644 --- a/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/reviews/WS-AUTH-001-12D-internal-review-evidence.md +++ b/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/reviews/WS-AUTH-001-12D-internal-review-evidence.md @@ -31,3 +31,19 @@ removal, and temporary isolated-test downstream activation seeding. | Test delta | PASS with low risk | No skips or weakened assertions; missing/malformed key and migration round-trip proof restored. | All reviewer sessions completed. No Critical, High, or Medium finding remains open. + +## Corrective-delta re-review + +After the first hosted run and CodeRabbit review, architecture, security, QA, +product/ops, senior engineering, and CI integrity re-reviewed the corrective +delta. All six tracks passed with no blocking finding. They confirmed: + +- explicit null and omitted guide-update fields have distinct replay digests; +- trigger restoration rolls back first and remains isolated-test-only; +- downstream policy prerequisites are independent from the clean-cut guide API; +- system and exact-project Project Manager grant paths are both covered; +- removing the dead `ProjectService` snapshot/setup helper chain leaves setup + dispatch in its intended mutation-router boundary; and +- no CI or coverage gate was weakened. + +All corrective re-review sessions completed. diff --git a/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/reviews/WS-AUTH-001-12D-pr-trust-bundle.md b/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/reviews/WS-AUTH-001-12D-pr-trust-bundle.md index 86933e8a..e71d4ca1 100644 --- a/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/reviews/WS-AUTH-001-12D-pr-trust-bundle.md +++ b/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/reviews/WS-AUTH-001-12D-pr-trust-bundle.md @@ -25,6 +25,9 @@ outside this chunk. - Focused PostgreSQL lane: 20 passed. - Key ordering and exact project scope: 2 passed. - Real API contract E2E: passed. +- Hosted CI failure was traced to downstream task/checker fixtures retaining the + retired guide request shape. The corrected isolated PostgreSQL regression lane + passed 9 tests across downstream setup, replay, and grant-scope paths. - Ruff, diff check, Markdown links, and stale wording/docs: passed. - Internal architecture, security, QA, product, senior, CI, docs, reuse, and test-delta tracks: passed; low risks are documented in the review evidence. @@ -39,4 +42,16 @@ outside this chunk. - Inspect the key-gated actor/PREP dependency graph and denial restaging. - Confirm the temporary activation seed is test/E2E-only and isolated-database guarded. -Chunk complete locally. Await hosted CI, external review, and human merge. +## External review response + +- CodeRabbit's explicit-null idempotency finding was fixed with + `exclude_unset=True` and a replay-mismatch regression. +- Trigger restoration now rolls back first in both isolated activation helpers. +- System and exact-project Project Manager paths are both covered for all three + actions. +- Dead legacy snapshot/setup helpers were removed; downstream fixtures use the + clean-cut guide contract and seed independent lifecycle policy prerequisites. +- The complete response is recorded in + `WS-AUTH-001-12D-external-review-response.md`. + +Chunk complete locally. Await corrected-head hosted CI, CodeRabbit, and human merge. diff --git a/backend/app/api/deps/authorization.py b/backend/app/api/deps/authorization.py index eba1b619..cf78d2c0 100644 --- a/backend/app/api/deps/authorization.py +++ b/backend/app/api/deps/authorization.py @@ -224,7 +224,7 @@ async def get_authorization_service( raise actor_registry_unavailable_error() from exc except SQLAlchemyError as exc: await session.rollback() - logger.exception("prepared authorization transaction failed") + logger.exception("authorization decision transaction failed") raise actor_registry_unavailable_error() from exc except BaseException: await session.rollback() diff --git a/backend/app/modules/projects/guide_mutation_service.py b/backend/app/modules/projects/guide_mutation_service.py index 32489106..62b4de67 100644 --- a/backend/app/modules/projects/guide_mutation_service.py +++ b/backend/app/modules/projects/guide_mutation_service.py @@ -91,7 +91,7 @@ def _input( "idempotency_key": str(key), "project_id": str(project_id), "guide_id": str(guide_id) if guide_id is not None else None, - "body": body.model_dump(mode="json", exclude_none=True), + "body": body.model_dump(mode="json", exclude_unset=True), } digest = canonical_json_hash( {"domain": "workstream.guide_mutation.idempotency.v1", **replay_request} diff --git a/backend/app/modules/projects/service.py b/backend/app/modules/projects/service.py index 5d0eb560..e299c783 100644 --- a/backend/app/modules/projects/service.py +++ b/backend/app/modules/projects/service.py @@ -63,7 +63,6 @@ from app.modules.projects.repository import ProjectRepository, ProjectRepositoryIntegrityError from app.modules.projects.setup_queue import ( ProjectSetupQueueError, - dispatch_pre_submit_setup_pipeline_after_commit, enqueue_post_submit_setup_continuation, ) from app.modules.projects.schemas import ( @@ -1798,92 +1797,6 @@ async def _get_snapshot_for_guide( raise SourceSnapshotNotFound("guide source snapshot not found") return snapshot - async def _create_guide_source_snapshot_model( - self, - actor: ActorContext, - project_id: str, - guide: ProjectGuide, - payload: GuideSourceSnapshotCreate, - ) -> GuideSourceSnapshot: - """Persist a guide-source snapshot model without committing. - - Args: - actor: Actor responsible for capturing the source material. - project_id: Project that owns the guide. - guide: Draft guide whose material is snapshotted. - payload: Additional source items to include with the guide body. - - Returns: - Persisted source snapshot pending transaction commit. - """ - manifest, sanitized_items = build_guide_source_snapshot_manifest(payload, guide) - bundle_hash = self._hash_canonical_json(manifest) - snapshot = GuideSourceSnapshot( - id=str(uuid4()), - project_id=project_id, - guide_id=guide.id, - guide_version=guide.version, - manifest_schema_version=GUIDE_SOURCE_SNAPSHOT_SCHEMA_VERSION, - manifest_json=manifest, - bundle_hash=bundle_hash, - captured_by=actor.actor_id, - ) - item_models = build_guide_source_snapshot_items(snapshot.id, sanitized_items) - return await self._repo.add_guide_source_snapshot(snapshot, item_models) - - async def _create_project_setup_run_model( - self, - actor: ActorContext, - guide: ProjectGuide, - snapshot: GuideSourceSnapshot, - ) -> ProjectSetupRun: - """Create a queued setup-run ledger row without committing.""" - setup_run = ProjectSetupRun( - id=str(uuid4()), - project_id=guide.project_id, - guide_id=guide.id, - guide_version=guide.version, - source_snapshot_id=snapshot.id, - source_snapshot_hash=snapshot.bundle_hash, - setup_generation=await self._repo.next_project_setup_generation(guide.id), - status="queued", - current_step="queued", - created_by=actor.actor_id, - ) - return await self._repo.add_project_setup_run(setup_run) - - async def _enqueue_pre_submit_setup_pipeline_after_commit( - self, - *, - project_id: str, - guide_id: str, - source_snapshot_id: str, - setup_run_id: str, - ) -> str | None: - """Enqueue automatic pre-submit setup for an immutable source snapshot. - - This is called only after the project setup record is already committed. - If the broker fails at this point, the write remains successful and the - operator can retry setup from the persisted snapshot instead of receiving - a false 503 for a durable create. - - Args: - project_id: Project that owns the guide. - guide_id: Guide whose snapshot should be processed. - source_snapshot_id: Immutable source snapshot to process. - setup_run_id: Ledger row that should receive the Celery task id. - - Returns: - Celery task id when enqueue succeeds; otherwise ``None``. - """ - return await dispatch_pre_submit_setup_pipeline_after_commit( - self._session, - project_id=project_id, - guide_id=guide_id, - source_snapshot_id=source_snapshot_id, - setup_run_id=setup_run_id, - ) - async def _enqueue_post_submit_setup_continuation_after_commit( self, *, diff --git a/backend/scripts/api_contract_e2e.py b/backend/scripts/api_contract_e2e.py index 2af656c5..623af029 100644 --- a/backend/scripts/api_contract_e2e.py +++ b/backend/scripts/api_contract_e2e.py @@ -84,6 +84,7 @@ async def seed_active_guide_for_pre_12h_e2e(project_id: str, guide_id: str) -> d result = await ProjectService(session).activate_guide(actor, project_id, guide_id) return result.model_dump(mode="json") finally: + await session.rollback() await session.execute( text("alter table project_guides enable trigger guide_lineage_lifecycle_guard") ) diff --git a/backend/tests/project_create_fixtures.py b/backend/tests/project_create_fixtures.py index a91a5643..50c09de7 100644 --- a/backend/tests/project_create_fixtures.py +++ b/backend/tests/project_create_fixtures.py @@ -37,7 +37,9 @@ async def activate_guide_for_downstream_test( AUTH-12D deliberately removes the legacy activation endpoint. Downstream subsystem tests still need active historical state until AUTH-12H installs the authorized activation mutation, so this fixture exercises the existing - product validation while explicitly suspending only the new custody trigger. + product validation while explicitly suspending the + ``guide_mutation_product_custody`` and ``guide_lineage_lifecycle_guard`` + triggers. """ actor = ActorContext( actor_id="project-manager-subject", @@ -66,6 +68,7 @@ async def activate_guide_for_downstream_test( await session.rollback() return Response(status_code=exc.status_code, json={"detail": str(exc)}) finally: + await session.rollback() await session.execute( text("alter table project_guides enable trigger guide_lineage_lifecycle_guard") ) diff --git a/backend/tests/test_authorization.py b/backend/tests/test_authorization.py index e1c4d8f5..d5e26dc8 100644 --- a/backend/tests/test_authorization.py +++ b/backend/tests/test_authorization.py @@ -3568,17 +3568,24 @@ async def find_effective_grant( ), ], ) +@pytest.mark.parametrize("grant_scope", ["system", "project"]) async def test_guide_source_metadata_authority_uses_exact_single_use_project_handle( action_id: ActionId, target_kind: str, resource_type: str, + grant_scope: str, ) -> None: context = _runtime_context() assert isinstance(context, HumanAuthorizationContext) session = _PreparedTestSession() project_id, guide_id, resource_id, grant_id = (uuid4() for _ in range(4)) facts = _GuideMutationAuthorityFacts( - context, grant=SimpleNamespace(id=grant_id, status="active") + context, + grant=SimpleNamespace( + id=grant_id, + status="active", + scope_project_id=None if grant_scope == "system" else project_id, + ), ) authorization, evidence = _runtime_service( context, session=session, admin_repository=facts @@ -3643,7 +3650,9 @@ async def test_guide_source_metadata_authority_uses_exact_single_use_project_han decision = await prepared.consume(handle, action_id, caller, resource) assert decision.allowed is True assert decision.matched_grant_id == grant_id - assert decision.matched_scope_project_id is None + assert decision.matched_scope_project_id == ( + None if grant_scope == "system" else project_id + ) assert evidence.events[0].resource_type == "project" assert evidence.events[0].resource_id == str(project_id) assert evidence.events[0].after_facts == { diff --git a/backend/tests/test_projects.py b/backend/tests/test_projects.py index 3d7c7187..fbeeb0a1 100644 --- a/backend/tests/test_projects.py +++ b/backend/tests/test_projects.py @@ -2361,7 +2361,7 @@ async def test_create_source_snapshot_marks_setup_run_when_post_commit_enqueue_f project_client: AsyncClient, monkeypatch: pytest.MonkeyPatch, ) -> None: - """A late broker failure cannot turn a durable guide create into a false 503.""" + """A late broker failure cannot turn a durable snapshot create into a false 503.""" project = await create_project(project_client) def enqueue_failure( @@ -2803,6 +2803,23 @@ async def test_guide_source_metadata_authority_rejects_removed_fields_and_bad_re assert mismatch.status_code == 409 assert mismatch.json()["error"]["code"] == "idempotency_mismatch" + guide = first.json() + update_key = str(uuid4()) + update_headers = auth_headers() | {"Idempotency-Key": update_key} + explicit_null = await project_client.patch( + f"/api/v1/projects/{project['id']}/guides/{guide['id']}", + headers=update_headers, + json={"change_summary": None}, + ) + omitted_field = await project_client.patch( + f"/api/v1/projects/{project['id']}/guides/{guide['id']}", + headers=update_headers, + json={}, + ) + assert explicit_null.status_code == 200, explicit_null.text + assert omitted_field.status_code == 409 + assert omitted_field.json()["error"]["code"] == "idempotency_mismatch" + async def test_guide_source_metadata_authority_validates_key_before_actor_provisioning( project_database_env: str, diff --git a/backend/tests/test_tasks.py b/backend/tests/test_tasks.py index 816ee5e6..3d2cc5b9 100644 --- a/backend/tests/test_tasks.py +++ b/backend/tests/test_tasks.py @@ -52,6 +52,7 @@ PaymentPolicy, PostSubmitCheckerPolicy, PreSubmitCheckerPolicy, + ProjectGuide, ProjectSetupRun, ReviewPolicy, RevisionPolicy, @@ -636,7 +637,10 @@ def alembic_config() -> Config: def auth_headers(token: str = "task-token") -> dict[str, str]: - return {"Authorization": f"Bearer {token}"} + return { + "Authorization": f"Bearer {token}", + "Idempotency-Key": str(uuid4()), + } _DEFAULT_DEV_ACTOR_FIELD = object() @@ -709,27 +713,6 @@ def complete_guide_payload(version: str = "v1") -> dict: "submission intake while the guide gives human review context." ), "change_summary": f"Initial {version}", - "review_policy": { - "requires_second_review": False, - "allowed_decisions": ["accept", "needs_revision", "reject"], - "minimum_finding_fields": ["issue", "required_fix"], - "sla_hours": 24, - }, - "revision_policy": { - "max_revision_rounds": 7, - "revision_deadline_hours": 48, - "auto_reject_after_limit": True, - "allowed_resubmission_states": ["needs_revision"], - "reviewer_reassignment_rule": "same reviewer preferred", - }, - "payment_policy": { - "base_amount": "25.00", - "currency": "USD", - "payout_type": "fixed", - "revision_payment_rule": "none", - "rejection_payment_rule": "none", - "accepted_payment_rule": "pay base amount", - }, } @@ -971,6 +954,45 @@ async def create_policy_bundle_for_guide( post_submit_warning_checkers: list[str] | None = None, post_submit_blocking_severities: list[str] | None = None, ) -> dict: + async with db_session.get_session_factory()() as session: + guide = await session.get(ProjectGuide, guide_id) + assert guide is not None + session.add_all( + [ + ReviewPolicy( + id=str(uuid4()), + project_id=project_id, + guide_version=guide.version, + requires_second_review=False, + allowed_decisions=["accept", "needs_revision", "reject"], + minimum_finding_fields=["issue", "required_fix"], + sla_hours=24, + ), + RevisionPolicy( + id=str(uuid4()), + project_id=project_id, + guide_version=guide.version, + max_revision_rounds=7, + revision_deadline_hours=48, + auto_reject_after_limit=True, + allowed_resubmission_states=["needs_revision"], + reviewer_reassignment_rule="same reviewer preferred", + ), + PaymentPolicy( + id=str(uuid4()), + project_id=project_id, + guide_version=guide.version, + base_amount="25.00", + currency="USD", + payout_type="fixed", + revision_payment_rule="none", + rejection_payment_rule="none", + accepted_payment_rule="pay base amount", + ), + ] + ) + await session.commit() + snapshot_response = await client.post( f"/api/v1/projects/{project_id}/guides/{guide_id}/source-snapshots", headers=auth_headers(), From 38f6a0caada5f00d3964304d3989003ad7460e84 Mon Sep 17 00:00:00 2001 From: Abiorh001 Date: Fri, 31 Jul 2026 13:02:51 +0100 Subject: [PATCH 05/16] fix(auth): align downstream guide custody --- ...S-AUTH-001-12D-external-review-response.md | 13 ++++++- ...S-AUTH-001-12D-internal-review-evidence.md | 6 +++ .../WS-AUTH-001-12D-pr-trust-bundle.md | 7 ++++ backend/app/modules/projects/service.py | 12 +++--- backend/scripts/api_contract_e2e.py | 38 +++++++++++++------ backend/scripts/week2_api_e2e.py | 17 ++++++++- backend/tests/project_create_fixtures.py | 26 ++++++++----- backend/tests/test_projects.py | 35 ++++++++--------- backend/tests/test_tasks.py | 4 ++ 9 files changed, 112 insertions(+), 46 deletions(-) diff --git a/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/reviews/WS-AUTH-001-12D-external-review-response.md b/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/reviews/WS-AUTH-001-12D-external-review-response.md index 5038bc96..c51a7b30 100644 --- a/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/reviews/WS-AUTH-001-12D-external-review-response.md +++ b/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/reviews/WS-AUTH-001-12D-external-review-response.md @@ -18,6 +18,14 @@ - Hosted CI: update downstream task/checker construction to use required UUID idempotency and the clean-cut guide request, with lifecycle policies seeded independently as test prerequisites. +- Second hosted CI: preserve secret-looking durable-ref rejection in the shared + manifest builder; use the canonical actor-profile ID in isolated activation + seeds; install exact-project authority before revoking system authority; and + replace stale legacy-manifest mutation expectations with database-immutability + proof. +- Final internal review: reject suffixed `.npmrc`/`.pypirc` refs and thread the + exact run-specific Flow subject and issuer through both API E2E activation + seeds. ## Comments deferred @@ -32,10 +40,13 @@ None. The user retains merge authority for PR #232. - Ruff on all corrective files: passed. - Isolated PostgreSQL correction lane covering downstream task/checker setup, explicit-null replay, and system/project grant paths: 9 passed. +- The exact second-run regression selection completed 36 tests without a failure + before the intentionally stopped local run; the complete semantic lanes remain + assigned to GitHub because local execution is prohibitively slow. - Git diff check: passed. - GitHub full backend suite and coverage: required again on the corrected SHA. ## Remaining risks -- Hosted semantic lanes and coverage must pass on the corrected exact head. +- Hosted semantic lanes and coverage must pass on the next corrected exact head. - CodeRabbit must re-review the corrected exact head with no unresolved findings. diff --git a/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/reviews/WS-AUTH-001-12D-internal-review-evidence.md b/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/reviews/WS-AUTH-001-12D-internal-review-evidence.md index 4cc4f856..a1a6e4c9 100644 --- a/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/reviews/WS-AUTH-001-12D-internal-review-evidence.md +++ b/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/reviews/WS-AUTH-001-12D-internal-review-evidence.md @@ -47,3 +47,9 @@ delta. All six tracks passed with no blocking finding. They confirmed: - no CI or coverage gate was weakened. All corrective re-review sessions completed. + +After the second hosted failure, architecture passed the final delta. Security +and QA each found and then verified closure of one blocking E2E edge case: +suffixed package credential refs now fail closed, and activation seeds resolve +the exact run-specific Flow subject plus issuer to its canonical actor profile. +Both final tracks passed, and all sessions completed. diff --git a/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/reviews/WS-AUTH-001-12D-pr-trust-bundle.md b/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/reviews/WS-AUTH-001-12D-pr-trust-bundle.md index e71d4ca1..7b20ea11 100644 --- a/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/reviews/WS-AUTH-001-12D-pr-trust-bundle.md +++ b/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/reviews/WS-AUTH-001-12D-pr-trust-bundle.md @@ -28,6 +28,11 @@ outside this chunk. - Hosted CI failure was traced to downstream task/checker fixtures retaining the retired guide request shape. The corrected isolated PostgreSQL regression lane passed 9 tests across downstream setup, replay, and grant-scope paths. +- A second hosted run exposed remaining stale tests plus a shared durable-ref + validation regression. The repair restores secret-reference rejection, uses + canonical actor IDs in isolated activation seeds, and proves immutable + snapshots reject legacy rewrites. Its exact local selection completed 36 tests + without a failure before local execution was stopped for machine cost. - Ruff, diff check, Markdown links, and stale wording/docs: passed. - Internal architecture, security, QA, product, senior, CI, docs, reuse, and test-delta tracks: passed; low risks are documented in the review evidence. @@ -51,6 +56,8 @@ outside this chunk. actions. - Dead legacy snapshot/setup helpers were removed; downstream fixtures use the clean-cut guide contract and seed independent lifecycle policy prerequisites. +- Suffixed package-credential refs are rejected, and both API E2E flows bind the + isolated activation seed to the exact admitted Flow subject and issuer. - The complete response is recorded in `WS-AUTH-001-12D-external-review-response.md`. diff --git a/backend/app/modules/projects/service.py b/backend/app/modules/projects/service.py index e299c783..8ed037ac 100644 --- a/backend/app/modules/projects/service.py +++ b/backend/app/modules/projects/service.py @@ -131,9 +131,9 @@ r")" ) SECRET_ARTIFACT_NAME_PATTERN = re.compile( - r"(^|[._/\-])(" - r"\.env[^/]*|" - r"\.npmrc|\.pypirc|" + r"(^|/)(" + r"[^/]*\.env[^/]*|" + r"\.npmrc[^/]*|\.pypirc[^/]*|" r"id_(rsa|dsa|ecdsa|ed25519)(\.[^/]*)?|" r"private[_\-]?key[^/]*|" r"api[_\-]?key[^/]*|" @@ -141,8 +141,10 @@ r"secret[^/]*|" r"credential[^/]*|" r"token[^/]*|" - r"service[_\-]?account[^/]*" - r")($|[._/\-])", + r"service[_\-]?account[^/]*|" + r"[^/]*\.pem" + r")($|/)|" + r"(^|/)(private|api|access)/key(\.[^/]*)?($|/)", re.IGNORECASE, ) diff --git a/backend/scripts/api_contract_e2e.py b/backend/scripts/api_contract_e2e.py index 623af029..1c9312c0 100644 --- a/backend/scripts/api_contract_e2e.py +++ b/backend/scripts/api_contract_e2e.py @@ -24,6 +24,7 @@ from sqlalchemy import select, text from app.db import session as db_session +from app.modules.actors.models import ActorIdentityLink from app.modules.api_controls.service import ( FIRST_ACCESS_SCOPE, RateControlService, @@ -58,21 +59,34 @@ } -async def seed_active_guide_for_pre_12h_e2e(project_id: str, guide_id: str) -> dict: +async def seed_active_guide_for_pre_12h_e2e( + project_id: str, + guide_id: str, + manager_subject: str, + manager_issuer: str, +) -> dict: """Seed downstream active state while the public activation route is unavailable.""" - actor = ActorContext( - actor_id="project-manager-subject", - external_subject="project-manager-subject", - external_issuer=DEFAULT_FLOW_ISSUER, - roles=("project_manager",), - claim_snapshot={}, - auth_source="flow", - is_dev_auth=False, - ) async with db_session.get_session_factory()() as session: database_name = await session.scalar(text("select current_database()")) if DERIVED_DATABASE_NAME.fullmatch(str(database_name)) is None: raise RuntimeError("pre-12H activation seed requires an isolated E2E database") + link = await session.scalar( + select(ActorIdentityLink).where( + ActorIdentityLink.issuer == manager_issuer, + ActorIdentityLink.subject == manager_subject, + ) + ) + if link is None: + raise RuntimeError("pre-12H activation seed requires an admitted actor") + actor = ActorContext( + actor_id=str(link.actor_profile_id), + external_subject=link.subject, + external_issuer=link.issuer, + roles=("project_manager",), + claim_snapshot={}, + auth_source="flow", + is_dev_auth=False, + ) await session.execute( text("alter table project_guides disable trigger guide_mutation_product_custody") ) @@ -1465,7 +1479,9 @@ async def exercise_api_contract(base_url: str, env: dict[str, str]) -> None: manager_token, expected_status=404, ) - active = await seed_active_guide_for_pre_12h_e2e(project["id"], guide["id"]) + active = await seed_active_guide_for_pre_12h_e2e( + project["id"], guide["id"], manager_subject, flow_issuer + ) assert active["guide"]["version"] == "v1" active_actor_context = await request_json( client, diff --git a/backend/scripts/week2_api_e2e.py b/backend/scripts/week2_api_e2e.py index 3a677d40..8d61d581 100644 --- a/backend/scripts/week2_api_e2e.py +++ b/backend/scripts/week2_api_e2e.py @@ -318,6 +318,7 @@ async def create_project_with_guide( client: httpx.AsyncClient, manager_token: str, manager_subject: str, + manager_issuer: str, run_id: str, suffix: str, required_checkers: list[str] | None = None, @@ -327,6 +328,8 @@ async def create_project_with_guide( Args: client: Real HTTP client. manager_token: Project manager Flow token. + manager_subject: External subject admitted by the manager token. + manager_issuer: External issuer that admitted the manager subject. run_id: Unique test run id. suffix: Scenario suffix used in the project slug. required_checkers: Optional checker policy override. @@ -370,7 +373,9 @@ async def create_project_with_guide( manager_token, expected_status=404, ) - await seed_active_guide_for_pre_12h_e2e(project["id"], guide["id"]) + await seed_active_guide_for_pre_12h_e2e( + project["id"], guide["id"], manager_subject, manager_issuer + ) return project @@ -1077,6 +1082,7 @@ async def exercise_week2_api(base_url: str, env: dict[str, str]) -> None: client, manager_token, manager_subject, + flow_issuer, run_id, "clean", ) @@ -1206,6 +1212,7 @@ async def exercise_week2_api(base_url: str, env: dict[str, str]) -> None: client, manager_token, manager_subject, + flow_issuer, run_id, "trusted-retry", ) @@ -1325,6 +1332,7 @@ async def exercise_week2_api(base_url: str, env: dict[str, str]) -> None: client, manager_token, manager_subject, + flow_issuer, run_id, "revision", ) @@ -1409,6 +1417,7 @@ async def exercise_week2_api(base_url: str, env: dict[str, str]) -> None: client, manager_token, manager_subject, + flow_issuer, run_id, "no-evidence", ) @@ -1474,6 +1483,7 @@ async def exercise_week2_api(base_url: str, env: dict[str, str]) -> None: client, manager_token, manager_subject, + flow_issuer, run_id, "integrity", ) @@ -1538,6 +1548,7 @@ async def exercise_week2_api(base_url: str, env: dict[str, str]) -> None: client, manager_token, manager_subject, + flow_issuer, run_id, "attestation", ) @@ -1595,6 +1606,7 @@ async def exercise_week2_api(base_url: str, env: dict[str, str]) -> None: client, manager_token, manager_subject, + flow_issuer, run_id, "warning", ) @@ -1638,6 +1650,7 @@ async def exercise_week2_api(base_url: str, env: dict[str, str]) -> None: client, manager_token, manager_subject, + flow_issuer, run_id, "checker-revision", required_checkers=["check_low_quality_generated_artifacts"], @@ -1945,6 +1958,7 @@ async def exercise_week2_api(base_url: str, env: dict[str, str]) -> None: client, manager_token, manager_subject, + flow_issuer, run_id, "forbidden", ) @@ -2009,6 +2023,7 @@ async def exercise_week2_api(base_url: str, env: dict[str, str]) -> None: client, manager_token, manager_subject, + flow_issuer, run_id, "setup", required_checkers=["check_acceptance_criteria_present"], diff --git a/backend/tests/project_create_fixtures.py b/backend/tests/project_create_fixtures.py index 50c09de7..6a8a221f 100644 --- a/backend/tests/project_create_fixtures.py +++ b/backend/tests/project_create_fixtures.py @@ -41,19 +41,27 @@ async def activate_guide_for_downstream_test( ``guide_mutation_product_custody`` and ``guide_lineage_lifecycle_guard`` triggers. """ - actor = ActorContext( - actor_id="project-manager-subject", - external_subject="project-manager-subject", - external_issuer="flow-test", - roles=("project_manager",), - claim_snapshot={}, - auth_source="dev_mock", - is_dev_auth=True, - ) async with session_factory() as session: database_name = await session.scalar(text("select current_database()")) if not str(database_name).startswith("workstream_test_"): raise RuntimeError("downstream activation fixture requires an isolated test database") + link = await session.scalar( + select(ActorIdentityLink).where( + ActorIdentityLink.issuer == "flow-test", + ActorIdentityLink.subject == "project-manager-subject", + ) + ) + if link is None: + raise RuntimeError("downstream activation fixture requires an admitted actor") + actor = ActorContext( + actor_id=str(link.actor_profile_id), + external_subject=link.subject, + external_issuer=link.issuer, + roles=("project_manager",), + claim_snapshot={}, + auth_source="dev_mock", + is_dev_auth=True, + ) await session.execute( text("alter table project_guides disable trigger guide_mutation_product_custody") ) diff --git a/backend/tests/test_projects.py b/backend/tests/test_projects.py index fbeeb0a1..8d941e4a 100644 --- a/backend/tests/test_projects.py +++ b/backend/tests/test_projects.py @@ -4888,9 +4888,7 @@ def fail_enqueue(**_: object) -> str: assert body["current_step"] == "enqueue" assert body["celery_task_id"] is None assert body["error_code"] == "ProjectSetupQueueError" - assert body["error_summary"] == ( - "project setup failed; inspect server logs with the setup run id" - ) + assert body["error_summary"] == "project setup failed" assert "token" not in body["error_summary"] assert "https://" not in body["error_summary"] @@ -5181,6 +5179,7 @@ async def test_project_setup_visibility_apis_require_active_local_grant( get_settings.cache_clear() project = await create_project(project_client) other_project = await create_project(project_client, name="Wrong Scope") + await add_project_manager_admin_grant(project["id"]) await revoke_system_project_manager_for_default_actor() guide = await create_guide( project_client, @@ -5574,6 +5573,8 @@ async def test_source_snapshot_rejects_unsafe_refs(project_client: AsyncClient) ("https://docs.flow.test/outputs/prod.env", "credential material"), ("https://docs.flow.test/keys/id_rsa", "credential material"), ("https://docs.flow.test/keys/deploy.pem", "credential material"), + ("https://docs.flow.test/.npmrc.bak", "credential material"), + ("https://docs.flow.test/.pypirc.old", "credential material"), ("s3://bucket/private/key.pem", "credential material"), ("s3://bucket/access/key/guide.md", "credential material"), ("s3://bucket/api/key/guide.md", "credential material"), @@ -6129,9 +6130,8 @@ async def derive_submission_artifact_policy( ) -async def test_source_snapshot_integrity_accepts_v1_manifest_without_content_excerpt( +async def test_source_snapshot_manifest_cannot_be_rewritten_for_legacy_shape( project_client: AsyncClient, - deterministic_project_agent_runtime: None, ) -> None: project = await create_project(project_client) guide = await create_guide(project_client, project["id"], complete_guide_payload()) @@ -6142,20 +6142,14 @@ async def test_source_snapshot_integrity_accepts_v1_manifest_without_content_exc manifest = json.loads(json.dumps(persisted.manifest_json)) for item in manifest["items"]: item.pop("content_excerpt", None) - await session.execute( - update(GuideSourceSnapshot) - .where(GuideSourceSnapshot.id == snapshot["id"]) - .values(manifest_json=manifest, bundle_hash=canonical_json_hash(manifest)) - ) - await session.commit() - - response = await project_client.post( - f"/api/v1/projects/{project['id']}/guides/{guide['id']}/source-snapshots/" - f"{snapshot['id']}/run-sufficiency-agent", - headers=auth_headers(), - ) - - assert response.status_code == 201, response.text + with pytest.raises(IntegrityError): + await session.execute( + update(GuideSourceSnapshot) + .where(GuideSourceSnapshot.id == snapshot["id"]) + .values(manifest_json=manifest, bundle_hash=canonical_json_hash(manifest)) + ) + await session.commit() + await session.rollback() def test_project_agent_factory_requires_openai_agent_sdk_model( @@ -9076,6 +9070,7 @@ async def test_guide_payload_rejects_manual_post_submit_checker_policy( async def test_activation_requires_review_policy(project_client: AsyncClient) -> None: project = await create_project(project_client) payload = complete_guide_payload() + payload["review_policy"] = None guide = await create_guide(project_client, project["id"], payload) await create_approved_policy_bundle(project_client, project["id"], guide["id"]) @@ -9092,6 +9087,7 @@ async def test_activation_requires_review_policy(project_client: AsyncClient) -> async def test_activation_requires_payment_policy(project_client: AsyncClient) -> None: project = await create_project(project_client) payload = complete_guide_payload() + payload["payment_policy"] = None guide = await create_guide(project_client, project["id"], payload) await create_approved_policy_bundle(project_client, project["id"], guide["id"]) @@ -9108,6 +9104,7 @@ async def test_activation_requires_payment_policy(project_client: AsyncClient) - async def test_activation_requires_revision_policy(project_client: AsyncClient) -> None: project = await create_project(project_client) payload = complete_guide_payload() + payload["revision_policy"] = None guide = await create_guide(project_client, project["id"], payload) await create_approved_policy_bundle(project_client, project["id"], guide["id"]) diff --git a/backend/tests/test_tasks.py b/backend/tests/test_tasks.py index 3d2cc5b9..5e2ced51 100644 --- a/backend/tests/test_tasks.py +++ b/backend/tests/test_tasks.py @@ -2812,6 +2812,10 @@ async def test_task_context_apis_fail_closed_on_stale_locked_context_rows( ) assert snapshot is not None snapshot.manifest_json = {**snapshot.manifest_json, "tampered": True} + with pytest.raises(IntegrityError): + await session.commit() + await session.rollback() + return elif mutation == "effective_policy_body": effective_policy = await session.get( EffectiveProjectSubmissionArtifactPolicy, From 4dbbc70160162baca39b699278999eb706163ac7 Mon Sep 17 00:00:00 2001 From: Abiorh001 Date: Fri, 31 Jul 2026 13:38:00 +0100 Subject: [PATCH 06/16] fix(auth): align historical guide fixtures --- ...TH-001-12D-guide-draft-source-mutations.md | 1 + ...S-AUTH-001-12D-external-review-response.md | 7 + ...S-AUTH-001-12D-internal-review-evidence.md | 14 ++ backend/tests/project_create_fixtures.py | 52 +++++ backend/tests/test_guide_bindings.py | 220 +++++++++++------- 5 files changed, 205 insertions(+), 89 deletions(-) diff --git a/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/chunks/WS-AUTH-001-12D-guide-draft-source-mutations.md b/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/chunks/WS-AUTH-001-12D-guide-draft-source-mutations.md index e4c21eb7..df691eec 100644 --- a/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/chunks/WS-AUTH-001-12D-guide-draft-source-mutations.md +++ b/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/chunks/WS-AUTH-001-12D-guide-draft-source-mutations.md @@ -48,6 +48,7 @@ backend/alembic/versions/0045_guide_source_metadata_authority.py backend/tests/test_authorization.py backend/tests/test_projects.py backend/tests/test_guide_artifacts.py +backend/tests/test_guide_bindings.py backend/tests/test_alembic.py backend/tests/test_api_controls.py backend/tests/test_checkers.py diff --git a/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/reviews/WS-AUTH-001-12D-external-review-response.md b/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/reviews/WS-AUTH-001-12D-external-review-response.md index c51a7b30..3bbb1c07 100644 --- a/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/reviews/WS-AUTH-001-12D-external-review-response.md +++ b/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/reviews/WS-AUTH-001-12D-external-review-response.md @@ -26,6 +26,9 @@ - Final internal review: reject suffixed `.npmrc`/`.pypirc` refs and thread the exact run-specific Flow subject and issuer through both API E2E activation seeds. +- Third hosted CI: construct pre-0045 ART guide-binding lineage only through an + isolated-test, fixed-allowlist custody suspension and restore migration 0045 + before current ORM models are used after the extraction-migration downgrade. ## Comments deferred @@ -44,6 +47,10 @@ None. The user retains merge authority for PR #232. before the intentionally stopped local run; the complete semantic lanes remain assigned to GitHub because local execution is prohibitively slow. - Git diff check: passed. +- Focused isolated PostgreSQL historical guide-binding proof: 1 passed. +- Focused isolated PostgreSQL downgrade/restore extraction proof: 1 passed. +- Exact stale-generation, next-generation, and post-read drift regressions: + 3 passed in a runner-owned isolated PostgreSQL database. - GitHub full backend suite and coverage: required again on the corrected SHA. ## Remaining risks diff --git a/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/reviews/WS-AUTH-001-12D-internal-review-evidence.md b/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/reviews/WS-AUTH-001-12D-internal-review-evidence.md index a1a6e4c9..55c74263 100644 --- a/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/reviews/WS-AUTH-001-12D-internal-review-evidence.md +++ b/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/reviews/WS-AUTH-001-12D-internal-review-evidence.md @@ -53,3 +53,17 @@ and QA each found and then verified closure of one blocking E2E edge case: suffixed package credential refs now fail closed, and activation seeds resolve the exact run-specific Flow subject plus issuer to its canonical actor profile. Both final tracks passed, and all sessions completed. + +After the third hosted run exposed ART guide-binding fixtures that constructed +pre-0045 lineage against the current custody schema, architecture, security, +and QA reviewed the bounded corrective delta. The chunk contract now explicitly +includes `backend/tests/test_guide_bindings.py` for that downstream regression +repair. Focused isolated-database proof covers both historical lineage setup and +the migration downgrade/current-model boundary; production custody triggers +remain unchanged. + +Security's blocking review finding tightened the fixture from a database-name +prefix check to the runner-owned `workstream_test_<12 hex>` and matching +`workstream_role_<12 hex>` pair. QA's blocking finding identified three later +setup-generation inserts; all now use the same bounded fixture. Security passed +the correction, and the exact three QA regressions pass in an isolated database. diff --git a/backend/tests/project_create_fixtures.py b/backend/tests/project_create_fixtures.py index 6a8a221f..fa75698e 100644 --- a/backend/tests/project_create_fixtures.py +++ b/backend/tests/project_create_fixtures.py @@ -1,5 +1,9 @@ """Test-only construction of historical and currently attributed projects.""" +from contextlib import asynccontextmanager +from collections.abc import AsyncIterator, Sequence +import re + from datetime import UTC, datetime from uuid import UUID, uuid4 @@ -26,6 +30,54 @@ from app.schemas.auth import ActorContext +_ISOLATED_DATABASE_RE = re.compile(r"workstream_test_([a-f0-9]{12})") +_ISOLATED_ROLE_RE = re.compile(r"workstream_role_([a-f0-9]{12})") + + +@asynccontextmanager +async def suspend_historical_product_custody( + session: AsyncSession, + *, + table: str, + triggers: Sequence[str], +) -> AsyncIterator[None]: + """Suspend named custody triggers only inside an isolated test database.""" + allowed = { + "project_guides": { + "guide_mutation_product_custody", + "guide_lineage_lifecycle_guard", + }, + "guide_source_snapshots": {"source_snapshot_product_custody"}, + "guide_source_snapshot_items": {"guide_source_snapshot_items_custody"}, + "project_setup_runs": {"source_setup_run_custody"}, + } + if table not in allowed or not triggers or not set(triggers) <= allowed[table]: + raise RuntimeError("unsupported historical custody suspension") + database_name, database_role = ( + await session.execute(text("select current_database(), current_user")) + ).one() + database_match = _ISOLATED_DATABASE_RE.fullmatch(str(database_name)) + role_match = _ISOLATED_ROLE_RE.fullmatch(str(database_role)) + if ( + database_match is None + or role_match is None + or database_match.group(1) != role_match.group(1) + ): + raise RuntimeError("historical custody suspension requires an isolated test database") + try: + for trigger in triggers: + await session.execute(text(f"alter table {table} disable trigger {trigger}")) + yield + for trigger in reversed(triggers): + await session.execute(text(f"alter table {table} enable trigger {trigger}")) + except BaseException: + await session.rollback() + for trigger in reversed(triggers): + await session.execute(text(f"alter table {table} enable trigger {trigger}")) + await session.commit() + raise + + async def activate_guide_for_downstream_test( session_factory, *, diff --git a/backend/tests/test_guide_bindings.py b/backend/tests/test_guide_bindings.py index ad95097d..1fddadf9 100644 --- a/backend/tests/test_guide_bindings.py +++ b/backend/tests/test_guide_bindings.py @@ -85,7 +85,7 @@ ProjectGuide, ProjectSetupRun, ) -from project_create_fixtures import seed_historical_project +from project_create_fixtures import seed_historical_project, suspend_historical_product_custody class _AllowBindingAuthority: @@ -254,58 +254,79 @@ async def _seed_binding_lineage( status="draft", ) await session.flush() - session.add( - ProjectGuide( - id=str(ids["guide"]), - project_id=str(ids["project"]), - version="v1", - status="draft", - content_markdown="# Guide", - created_by="test", + async with suspend_historical_product_custody( + session, + table="project_guides", + triggers=("guide_mutation_product_custody",), + ): + session.add( + ProjectGuide( + id=str(ids["guide"]), + project_id=str(ids["project"]), + version="v1", + status="draft", + content_markdown="# Guide", + created_by="test", + ) ) - ) - await session.flush() + await session.flush() snapshot_hash = canonical_json_hash({"item": str(ids["item"])}) - session.add( - GuideSourceSnapshot( - id=str(ids["snapshot"]), - project_id=str(ids["project"]), - guide_id=str(ids["guide"]), - guide_version="v1", - manifest_schema_version="v1", - manifest_json={"item": str(ids["item"])}, - bundle_hash=snapshot_hash, - captured_by=str(ids["actor"]), + async with suspend_historical_product_custody( + session, + table="guide_source_snapshots", + triggers=("source_snapshot_product_custody",), + ): + session.add( + GuideSourceSnapshot( + id=str(ids["snapshot"]), + project_id=str(ids["project"]), + guide_id=str(ids["guide"]), + guide_version="v1", + manifest_schema_version="v1", + manifest_json={"item": str(ids["item"])}, + bundle_hash=snapshot_hash, + captured_by=str(ids["actor"]), + ) ) - ) - await session.flush() - session.add( - GuideSourceSnapshotItem( - id=str(ids["item"]), - source_snapshot_id=str(ids["snapshot"]), - item_order=0, - source_kind="file", - durable_ref="guide.pdf", - ingestion_adapter="pdf", - content_hash="caller-metadata-is-not-authority", - media_type=media_type, + await session.flush() + async with suspend_historical_product_custody( + session, + table="guide_source_snapshot_items", + triggers=("guide_source_snapshot_items_custody",), + ): + session.add( + GuideSourceSnapshotItem( + id=str(ids["item"]), + source_snapshot_id=str(ids["snapshot"]), + item_order=0, + source_kind="file", + durable_ref="guide.pdf", + ingestion_adapter="pdf", + content_hash="caller-metadata-is-not-authority", + media_type=media_type, + ) ) - ) - session.add( - ProjectSetupRun( - id=str(ids["run"]), - project_id=str(ids["project"]), - guide_id=str(ids["guide"]), - guide_version="v1", - source_snapshot_id=str(ids["snapshot"]), - source_snapshot_hash=snapshot_hash, - setup_generation=1, - status="queued", - current_step="queued", - created_by="test", + await session.flush() + async with suspend_historical_product_custody( + session, + table="project_setup_runs", + triggers=("source_setup_run_custody",), + ): + session.add( + ProjectSetupRun( + id=str(ids["run"]), + project_id=str(ids["project"]), + guide_id=str(ids["guide"]), + guide_version="v1", + source_snapshot_id=str(ids["snapshot"]), + source_snapshot_hash=snapshot_hash, + setup_generation=1, + status="queued", + current_step="queued", + created_by="test", + ) ) - ) - await session.flush() + await session.flush() session.add( GuideSourceArtifactIngest( id=str(uuid4()), @@ -457,6 +478,7 @@ async def test_extraction_publishes_deterministic_content_and_exact_usage( ) with migration_lock(): await asyncio.to_thread(command.downgrade, config, "0042_guide_extraction") + await asyncio.to_thread(command.upgrade, config, "head") payload = b'{"z":2,"a":1}' digest = "sha256:" + hashlib.sha256(payload).hexdigest() engine = create_async_engine(isolated_database_env) @@ -1140,20 +1162,28 @@ async def test_post_read_lineage_drift_records_stale_incident( async def advance_setup_generation() -> None: async with factory() as session, session.begin(): - session.add( - ProjectSetupRun( - id=str(uuid4()), - project_id=str(ids["project"]), - guide_id=str(ids["guide"]), - guide_version="v1", - source_snapshot_id=str(ids["snapshot"]), - source_snapshot_hash=canonical_json_hash({"item": str(ids["item"])}), - setup_generation=2, - status="queued", - current_step="queued", - created_by="test", + async with suspend_historical_product_custody( + session, + table="project_setup_runs", + triggers=("source_setup_run_custody",), + ): + session.add( + ProjectSetupRun( + id=str(uuid4()), + project_id=str(ids["project"]), + guide_id=str(ids["guide"]), + guide_version="v1", + source_snapshot_id=str(ids["snapshot"]), + source_snapshot_hash=canonical_json_hash( + {"item": str(ids["item"])} + ), + setup_generation=2, + status="queued", + current_step="queued", + created_by="test", + ) ) - ) + await session.flush() service = ArtifactMaterializationService( factory, @@ -1474,20 +1504,26 @@ async def test_next_generation_explicitly_supersedes_prior_binding( async with factory() as session, session.begin(): snapshot = await session.get(GuideSourceSnapshot, str(ids["snapshot"])) assert snapshot is not None - session.add( - ProjectSetupRun( - id=str(second_run_id), - project_id=str(ids["project"]), - guide_id=str(ids["guide"]), - guide_version="v1", - source_snapshot_id=str(ids["snapshot"]), - source_snapshot_hash=snapshot.bundle_hash, - setup_generation=2, - status="queued", - current_step="queued", - created_by="test", + async with suspend_historical_product_custody( + session, + table="project_setup_runs", + triggers=("source_setup_run_custody",), + ): + session.add( + ProjectSetupRun( + id=str(second_run_id), + project_id=str(ids["project"]), + guide_id=str(ids["guide"]), + guide_version="v1", + source_snapshot_id=str(ids["snapshot"]), + source_snapshot_hash=snapshot.bundle_hash, + setup_generation=2, + status="queued", + current_step="queued", + created_by="test", + ) ) - ) + await session.flush() second_authority = _AllowBindingAuthority() async with factory() as session, session.begin(): second = await GuideSourceBindingService(session, second_authority).bind_guide_source( @@ -1678,22 +1714,28 @@ async def test_binding_fails_closed_before_authority_or_effect( verification_receipt=failure != "status_only", ) if failure == "stale_generation": - session.add( - ProjectSetupRun( - id=str(uuid4()), - project_id=str(ids["project"]), - guide_id=str(ids["guide"]), - guide_version="v1", - source_snapshot_id=str(ids["snapshot"]), - source_snapshot_hash=( - await session.get(GuideSourceSnapshot, str(ids["snapshot"])) - ).bundle_hash, - setup_generation=2, - status="queued", - current_step="queued", - created_by="test", + async with suspend_historical_product_custody( + session, + table="project_setup_runs", + triggers=("source_setup_run_custody",), + ): + session.add( + ProjectSetupRun( + id=str(uuid4()), + project_id=str(ids["project"]), + guide_id=str(ids["guide"]), + guide_version="v1", + source_snapshot_id=str(ids["snapshot"]), + source_snapshot_hash=( + await session.get(GuideSourceSnapshot, str(ids["snapshot"])) + ).bundle_hash, + setup_generation=2, + status="queued", + current_step="queued", + created_by="test", + ) ) - ) + await session.flush() await session.commit() authority = _AllowBindingAuthority() request = _request( From f7da14558a39c27ab1821ab738824d7cf7e9e3b4 Mon Sep 17 00:00:00 2001 From: Abiorh001 Date: Fri, 31 Jul 2026 13:58:57 +0100 Subject: [PATCH 07/16] fix(auth): reconcile shared guide fixtures --- ...TH-001-12D-guide-draft-source-mutations.md | 4 + ...S-AUTH-001-12D-external-review-response.md | 7 + ...S-AUTH-001-12D-internal-review-evidence.md | 9 + backend/tests/test_artifact_admission.py | 194 ++++++++++++------ backend/tests/test_artifact_recovery.py | 68 +++--- backend/tests/test_audit.py | 3 + backend/tests/test_auth.py | 8 +- 7 files changed, 202 insertions(+), 91 deletions(-) diff --git a/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/chunks/WS-AUTH-001-12D-guide-draft-source-mutations.md b/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/chunks/WS-AUTH-001-12D-guide-draft-source-mutations.md index df691eec..24ec43ad 100644 --- a/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/chunks/WS-AUTH-001-12D-guide-draft-source-mutations.md +++ b/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/chunks/WS-AUTH-001-12D-guide-draft-source-mutations.md @@ -49,6 +49,10 @@ backend/tests/test_authorization.py backend/tests/test_projects.py backend/tests/test_guide_artifacts.py backend/tests/test_guide_bindings.py +backend/tests/test_artifact_admission.py +backend/tests/test_artifact_recovery.py +backend/tests/test_audit.py +backend/tests/test_auth.py backend/tests/test_alembic.py backend/tests/test_api_controls.py backend/tests/test_checkers.py diff --git a/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/reviews/WS-AUTH-001-12D-external-review-response.md b/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/reviews/WS-AUTH-001-12D-external-review-response.md index 3bbb1c07..e5ff75bb 100644 --- a/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/reviews/WS-AUTH-001-12D-external-review-response.md +++ b/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/reviews/WS-AUTH-001-12D-external-review-response.md @@ -29,6 +29,10 @@ - Third hosted CI: construct pre-0045 ART guide-binding lineage only through an isolated-test, fixed-allowlist custody suspension and restore migration 0045 before current ORM models are used after the extraction-migration downgrade. +- Fourth hosted CI: apply the same explicit historical-lineage boundary to the + shared ART admission/recovery fixtures, seed the intentionally old 0028 schema + without current-model columns, and update exact active-action/dependency + assertions for the AUTH-12D cutover. ## Comments deferred @@ -51,6 +55,9 @@ None. The user retains merge authority for PR #232. - Focused isolated PostgreSQL downgrade/restore extraction proof: 1 passed. - Exact stale-generation, next-generation, and post-read drift regressions: 3 passed in a runner-owned isolated PostgreSQL database. +- Representative ART guide admission, checker, 0028 migration, and recovery + regressions: 4 passed in a runner-owned isolated PostgreSQL database. +- Active-action and dependency allowlist assertions: 2 passed. - GitHub full backend suite and coverage: required again on the corrected SHA. ## Remaining risks diff --git a/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/reviews/WS-AUTH-001-12D-internal-review-evidence.md b/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/reviews/WS-AUTH-001-12D-internal-review-evidence.md index 55c74263..6a3c4b41 100644 --- a/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/reviews/WS-AUTH-001-12D-internal-review-evidence.md +++ b/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/reviews/WS-AUTH-001-12D-internal-review-evidence.md @@ -67,3 +67,12 @@ prefix check to the runner-owned `workstream_test_<12 hex>` and matching `workstream_role_<12 hex>` pair. QA's blocking finding identified three later setup-generation inserts; all now use the same bounded fixture. Security passed the correction, and the exact three QA regressions pass in an isolated database. + +The fourth hosted run exposed the same historical-lineage assumption in shared +ART admission and recovery fixtures, plus one migration-0028 test that used the +current ORM while intentionally holding the old schema. Those fixtures now use +the strict runner-owned suspension; the migration test uses only columns that +exist at 0028. The newly active action set and guide-router dependency are also +represented in their static contract assertions. Four representative database +regressions and both static assertions pass. Architecture, security, and QA +re-reviewed the expanded correction and all passed with no open finding. diff --git a/backend/tests/test_artifact_admission.py b/backend/tests/test_artifact_admission.py index aefa9016..3c1ec828 100644 --- a/backend/tests/test_artifact_admission.py +++ b/backend/tests/test_artifact_admission.py @@ -102,7 +102,7 @@ RevisionPolicy, SubmissionArtifactPolicy, ) -from project_create_fixtures import seed_historical_project +from project_create_fixtures import seed_historical_project, suspend_historical_product_custody from app.modules.tasks.models import AuditEvent, Submission, WorkstreamTask from tests.artifact_store_helpers import ( artifact_admission_limit_settings, @@ -283,42 +283,58 @@ async def _seed_guide( slug=f"admission-{project_id}", ) await session.flush() - session.add( - ProjectGuide( - id=guide_id, - project_id=project_id, - version="v1", - status="draft", - content_markdown="# Guide", - created_by="test", + async with suspend_historical_product_custody( + session, + table="project_guides", + triggers=("guide_mutation_product_custody",), + ): + session.add( + ProjectGuide( + id=guide_id, + project_id=project_id, + version="v1", + status="draft", + content_markdown="# Guide", + created_by="test", + ) ) - ) - await session.flush() - session.add( - GuideSourceSnapshot( - id=snapshot_id, - project_id=project_id, - guide_id=guide_id, - guide_version="v1", - manifest_schema_version="v1", - manifest_json={"items": [item_id]}, - bundle_hash=canonical_json_hash({"items": [item_id]}), - captured_by=captured_by, + await session.flush() + async with suspend_historical_product_custody( + session, + table="guide_source_snapshots", + triggers=("source_snapshot_product_custody",), + ): + session.add( + GuideSourceSnapshot( + id=snapshot_id, + project_id=project_id, + guide_id=guide_id, + guide_version="v1", + manifest_schema_version="v1", + manifest_json={"items": [item_id]}, + bundle_hash=canonical_json_hash({"items": [item_id]}), + captured_by=captured_by, + ) ) - ) - await session.flush() - session.add( - GuideSourceSnapshotItem( - id=item_id, - source_snapshot_id=snapshot_id, - item_order=0, - source_kind="inline", - durable_ref="guide.md", - ingestion_adapter="inline", - content_hash=content_hash, - media_type=media_type, + await session.flush() + async with suspend_historical_product_custody( + session, + table="guide_source_snapshot_items", + triggers=("guide_source_snapshot_items_custody",), + ): + session.add( + GuideSourceSnapshotItem( + id=item_id, + source_snapshot_id=snapshot_id, + item_order=0, + source_kind="inline", + durable_ref="guide.md", + ingestion_adapter="inline", + content_hash=content_hash, + media_type=media_type, + ) ) - ) + await session.flush() await session.commit() return project_id, item_id @@ -435,32 +451,42 @@ async def _seed_checker_output_relationships(session) -> tuple[str, str, str]: slug=f"checker-{project_id}", ) await session.flush() - session.add( - ProjectGuide( - id=guide_id, - project_id=project_id, - version=guide_version, - status="active", - content_markdown="# Checker guide", - approved_by="setup-actor", - effective_at=now, - created_by="setup-actor", + async with suspend_historical_product_custody( + session, + table="project_guides", + triggers=("guide_mutation_product_custody",), + ): + session.add( + ProjectGuide( + id=guide_id, + project_id=project_id, + version=guide_version, + status="active", + content_markdown="# Checker guide", + approved_by="setup-actor", + effective_at=now, + created_by="setup-actor", + ) ) - ) - await session.flush() - session.add( - GuideSourceSnapshot( - id=snapshot_id, - project_id=project_id, - guide_id=guide_id, - guide_version=guide_version, - manifest_schema_version="v1", - manifest_json={"items": []}, - bundle_hash=snapshot_hash, - captured_by="setup-actor", + await session.flush() + async with suspend_historical_product_custody( + session, + table="guide_source_snapshots", + triggers=("source_snapshot_product_custody",), + ): + session.add( + GuideSourceSnapshot( + id=snapshot_id, + project_id=project_id, + guide_id=guide_id, + guide_version=guide_version, + manifest_schema_version="v1", + manifest_json={"items": []}, + bundle_hash=snapshot_hash, + captured_by="setup-actor", + ) ) - ) - await session.flush() + await session.flush() session.add( SubmissionArtifactPolicy( id=submission_policy_id, @@ -4365,11 +4391,53 @@ async def seed_attempt_only() -> None: factory = async_sessionmaker(engine, expire_on_commit=False) async with factory() as session: context = _context() - project_id, item_id = await _seed_guide( - session, - context=context, - content_hash="sha256:" + "b" * 64, - media_type="text/markdown", + await _seed_human_actor(session, context) + project_id, guide_id, snapshot_id, item_id = ( + str(uuid4()) for _ in range(4) + ) + await session.execute( + text( + "insert into projects (id,name,slug,status) values " + "(:id,'Admission migration',:slug,'draft')" + ), + {"id": project_id, "slug": f"admission-migration-{project_id}"}, + ) + await session.execute( + text( + "insert into project_guides " + "(id,project_id,version,status,content_markdown,created_by) values " + "(:id,:project_id,'v1','draft','# Guide','test')" + ), + {"id": guide_id, "project_id": project_id}, + ) + await session.execute( + text( + "insert into guide_source_snapshots " + "(id,project_id,guide_id,guide_version,manifest_schema_version," + "manifest_json,bundle_hash,captured_by) values " + "(:id,:project_id,:guide_id,'v1','v1',:manifest,:bundle_hash,:actor)" + ), + { + "id": snapshot_id, + "project_id": project_id, + "guide_id": guide_id, + "manifest": '{"items": []}', + "bundle_hash": canonical_json_hash({"items": []}), + "actor": str(context.actor_profile_id), + }, + ) + await session.execute( + text( + "insert into guide_source_snapshot_items " + "(id,source_snapshot_id,item_order,source_kind,durable_ref," + "ingestion_adapter,content_hash,media_type) values " + "(:id,:snapshot_id,0,'inline','guide.md','inline',:hash,'text/markdown')" + ), + { + "id": item_id, + "snapshot_id": snapshot_id, + "hash": "sha256:" + "b" * 64, + }, ) namespace_fingerprint = "sha256:" + "c" * 64 await session.execute( diff --git a/backend/tests/test_artifact_recovery.py b/backend/tests/test_artifact_recovery.py index 61e0de55..aa17e419 100644 --- a/backend/tests/test_artifact_recovery.py +++ b/backend/tests/test_artifact_recovery.py @@ -62,7 +62,7 @@ Project, ProjectGuide, ) -from project_create_fixtures import seed_historical_project +from project_create_fixtures import seed_historical_project, suspend_historical_product_custody from app.modules.tasks.models import AuditEvent, WorkstreamTask from tests.artifact_store_helpers import artifact_admission_limit_settings, minted_source @@ -302,32 +302,46 @@ async def _exhausted_guide_job(session, settings, tmp_path, context): assert project is not None project_id = project.id guide_id, snapshot_id, item_id = (str(uuid4()) for _ in range(3)) - session.add( - ProjectGuide( - id=guide_id, - project_id=project_id, - version="v1", - status="draft", - content_markdown="# Guide", - created_by="test", + async with suspend_historical_product_custody( + session, + table="project_guides", + triggers=("guide_mutation_product_custody",), + ): + session.add( + ProjectGuide( + id=guide_id, + project_id=project_id, + version="v1", + status="draft", + content_markdown="# Guide", + created_by="test", + ) ) - ) - await session.flush() - session.add( - GuideSourceSnapshot( - id=snapshot_id, - project_id=project_id, - guide_id=guide_id, - guide_version="v1", - manifest_schema_version="v1", - manifest_json={"items": [item_id]}, - bundle_hash=canonical_json_hash({"items": [item_id]}), - captured_by=str(context.actor_profile_id), + await session.flush() + async with suspend_historical_product_custody( + session, + table="guide_source_snapshots", + triggers=("source_snapshot_product_custody",), + ): + session.add( + GuideSourceSnapshot( + id=snapshot_id, + project_id=project_id, + guide_id=guide_id, + guide_version="v1", + manifest_schema_version="v1", + manifest_json={"items": [item_id]}, + bundle_hash=canonical_json_hash({"items": [item_id]}), + captured_by=str(context.actor_profile_id), + ) ) - ) - await session.flush() - session.add( - GuideSourceSnapshotItem( + await session.flush() + async with suspend_historical_product_custody( + session, + table="guide_source_snapshot_items", + triggers=("guide_source_snapshot_items_custody",), + ): + session.add(GuideSourceSnapshotItem( id=item_id, source_snapshot_id=snapshot_id, item_order=0, @@ -336,8 +350,8 @@ async def _exhausted_guide_job(session, settings, tmp_path, context): ingestion_adapter="inline", content_hash=source.commitment.sha256, media_type=source.commitment.media_type, - ) - ) + )) + await session.flush() await session.commit() prepared = _AllowGuidePreparedAuthorization(context.actor_profile_id) admission = await ArtifactAdmissionService(session, settings, namespace).admit( diff --git a/backend/tests/test_audit.py b/backend/tests/test_audit.py index e028a169..69691eb8 100644 --- a/backend/tests/test_audit.py +++ b/backend/tests/test_audit.py @@ -186,6 +186,9 @@ def test_action_aware_audit_input_enforces_mapping_and_action_availability() -> ActionId.PROJECT_PRE_SUBMIT_CHECKER_POLICY_READ, ActionId.PROJECT_ACTIVE_GUIDE_READ, ActionId.PROJECT_CREATE, + ActionId.PROJECT_GUIDE_CREATE, + ActionId.PROJECT_GUIDE_UPDATE, + ActionId.PROJECT_GUIDE_SOURCE_SNAPSHOT_CREATE, ActionId.PROJECT_READ, ActionId.PROJECT_ROLE_GRANT_LIST, ActionId.PROJECT_ROLE_GRANT_READ, diff --git a/backend/tests/test_auth.py b/backend/tests/test_auth.py index 780358a7..a4c1ba53 100644 --- a/backend/tests/test_auth.py +++ b/backend/tests/test_auth.py @@ -1426,7 +1426,12 @@ def test_legacy_compatibility_dependency_has_fixed_consumer_allowlist() -> None: } assert { path for path, source in sources.items() if "get_auth_verification_result" in source - } == {"api/deps/api_controls.py", "api/deps/auth.py", "api/deps/authorization.py"} + } == { + "api/deps/api_controls.py", + "api/deps/auth.py", + "api/deps/authorization.py", + "modules/projects/guide_mutation_router.py", + } assert {path for path, source in sources.items() if "AuthVerificationResult" in source} == { "adapters/auth/dev.py", "adapters/auth/flow.py", @@ -1436,6 +1441,7 @@ def test_legacy_compatibility_dependency_has_fixed_consumer_allowlist() -> None: "api/deps/rate_controls.py", "core/auth.py", "interfaces/auth.py", + "modules/projects/guide_mutation_router.py", "schemas/auth.py", } assert { From 271341d997e7a3e69c4e1708027a46ff2565ed0c Mon Sep 17 00:00:00 2001 From: Abiorh001 Date: Fri, 31 Jul 2026 14:28:17 +0100 Subject: [PATCH 08/16] fix(auth): preserve strict guide source boundaries --- ...S-AUTH-001-12D-external-review-response.md | 12 ++++++ ...S-AUTH-001-12D-internal-review-evidence.md | 15 ++++++++ backend/app/modules/projects/service.py | 9 +++-- backend/tests/project_create_fixtures.py | 30 +++++---------- backend/tests/test_artifact_admission.py | 26 +++++++++---- backend/tests/test_projects.py | 37 +++++++++++++++++++ 6 files changed, 98 insertions(+), 31 deletions(-) diff --git a/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/reviews/WS-AUTH-001-12D-external-review-response.md b/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/reviews/WS-AUTH-001-12D-external-review-response.md index e5ff75bb..d04e0d3c 100644 --- a/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/reviews/WS-AUTH-001-12D-external-review-response.md +++ b/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/reviews/WS-AUTH-001-12D-external-review-response.md @@ -33,6 +33,15 @@ shared ART admission/recovery fixtures, seed the intentionally old 0028 schema without current-model columns, and update exact active-action/dependency assertions for the AUTH-12D cutover. +- Fresh CodeRabbit review: reuse the strict runner-owned trigger suspension in + the downstream activation fixture, and require secret/token/credential name + boundaries so benign words such as `secretary` are not over-blocked. +- Product re-review extended those word boundaries to guide durable refs, with + benign source names admitted while the full credential/local-ref denial + matrix remains fail-closed. +- Fifth hosted CI: distinguish lock-based lineage protection from the stronger + immediate guide/item immutability guards, while preserving the post-lock + stale-lineage assertion through the isolated historical fixture. ## Comments deferred @@ -58,6 +67,9 @@ None. The user retains merge authority for PR #232. - Representative ART guide admission, checker, 0028 migration, and recovery regressions: 4 passed in a runner-owned isolated PostgreSQL database. - Active-action and dependency allowlist assertions: 2 passed. +- Exact guide-lineage lock/immutability regression: 1 passed. +- Guide-source credential/local-ref denial matrix: 46 passed; benign + secretary/tokenizer/credentialing source refs: 3 passed. - GitHub full backend suite and coverage: required again on the corrected SHA. ## Remaining risks diff --git a/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/reviews/WS-AUTH-001-12D-internal-review-evidence.md b/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/reviews/WS-AUTH-001-12D-internal-review-evidence.md index 6a3c4b41..973f162e 100644 --- a/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/reviews/WS-AUTH-001-12D-internal-review-evidence.md +++ b/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/reviews/WS-AUTH-001-12D-internal-review-evidence.md @@ -76,3 +76,18 @@ exist at 0028. The newly active action set and guide-router dependency are also represented in their static contract assertions. Four representative database regressions and both static assertions pass. Architecture, security, and QA re-reviewed the expanded correction and all passed with no open finding. + +The fifth hosted run left one stale lock-only assertion: 1,862 shared-foundation +tests passed, while immutable guide/item mutations denied before the expected +lock timeout. The correction now accepts only the two safe outcomes appropriate +to each row (held lock or the named immutable/lifecycle guard) and uses the +strict isolated fixture for the final historical status transition. The exact +regression passes locally. Fresh CodeRabbit review also identified and prompted +closure of the remaining weak isolation check and over-broad secret-name prefix. + +Product review then found the same prefix issue in the earlier durable-ref +scanner. Whole-token boundaries now apply there as well. The complete focused +source-ref proof passes: 46 unsafe credential/local refs deny and three benign +secretary/tokenizer/credentialing refs are accepted. Product re-review passed; +security and QA also passed the final CodeRabbit/hosted-CI correction with no +open finding. diff --git a/backend/app/modules/projects/service.py b/backend/app/modules/projects/service.py index 8ed037ac..c9ceb9c8 100644 --- a/backend/app/modules/projects/service.py +++ b/backend/app/modules/projects/service.py @@ -114,7 +114,8 @@ SAFE_TOKEN_PATTERN = re.compile(r"^[a-z][a-z0-9_]{1,63}$") SAFE_PUBLIC_SUMMARY_LABEL_PATTERN = re.compile(r"^[a-z0-9][a-z0-9 _-]{0,79}$") SECRET_REF_PATTERN = re.compile( - r"(x-amz-|signature|credential|access[_-]?key|secret|token|password|private[_-]?key)", + r"(x-amz-|access[_-]?key|private[_-]?key|" + r"(? None: + project = await create_project(project_client) + guide = await create_guide(project_client, project["id"], complete_guide_payload()) + + snapshot = await create_source_snapshot( + project_client, + project["id"], + guide["id"], + payload=source_snapshot_payload(durable_ref=durable_ref), + ) + + assert durable_ref in {item["durable_ref"] for item in snapshot["items"]} + + @pytest.mark.parametrize( ("durable_ref", "expected_detail"), [ @@ -8142,6 +8167,18 @@ async def test_submission_artifact_policy_rejects_forbidden_required_artifacts( assert "forbidden artifacts" in response.json()["detail"] +@pytest.mark.parametrize( + "artifact_path", + ["outputs/secretary.txt", "outputs/tokenizer.py", "outputs/credentialing.py"], +) +def test_submission_artifact_policy_allows_non_secret_keyword_prefixes( + artifact_path: str, +) -> None: + service = ProjectService(None) # type: ignore[arg-type] + + assert not service._matches_forbidden_artifact(artifact_path, []) + + @pytest.mark.parametrize( ("policy_body", "expected_detail"), [ From 61973819435558d19ea9d9f7d53fa9fe78dec68b Mon Sep 17 00:00:00 2001 From: Abiorh001 Date: Fri, 31 Jul 2026 14:54:57 +0100 Subject: [PATCH 09/16] test(auth): isolate guide ingest migration replay --- .../reviews/WS-AUTH-001-12D-external-review-response.md | 4 ++++ .../reviews/WS-AUTH-001-12D-internal-review-evidence.md | 8 ++++++++ .../reviews/WS-AUTH-001-12D-pr-trust-bundle.md | 3 +++ backend/tests/test_alembic.py | 4 ++-- 4 files changed, 17 insertions(+), 2 deletions(-) diff --git a/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/reviews/WS-AUTH-001-12D-external-review-response.md b/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/reviews/WS-AUTH-001-12D-external-review-response.md index d04e0d3c..2d9e2527 100644 --- a/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/reviews/WS-AUTH-001-12D-external-review-response.md +++ b/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/reviews/WS-AUTH-001-12D-external-review-response.md @@ -42,6 +42,9 @@ - Fifth hosted CI: distinguish lock-based lineage protection from the stronger immediate guide/item immutability guards, while preserving the post-lock stale-lineage assertion through the isolated historical fixture. +- Sixth hosted CI: keep the migration-0038 populated replay test at its stated + 0038 schema boundary instead of seeding historical rows under the active + 0045 production mutation triggers. ## Comments deferred @@ -70,6 +73,7 @@ None. The user retains merge authority for PR #232. - Exact guide-lineage lock/immutability regression: 1 passed. - Guide-source credential/local-ref denial matrix: 46 passed; benign secretary/tokenizer/credentialing source refs: 3 passed. +- Exact migration-0038 populated downgrade/replay regression: 1 passed. - GitHub full backend suite and coverage: required again on the corrected SHA. ## Remaining risks diff --git a/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/reviews/WS-AUTH-001-12D-internal-review-evidence.md b/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/reviews/WS-AUTH-001-12D-internal-review-evidence.md index 973f162e..41baa607 100644 --- a/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/reviews/WS-AUTH-001-12D-internal-review-evidence.md +++ b/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/reviews/WS-AUTH-001-12D-internal-review-evidence.md @@ -91,3 +91,11 @@ source-ref proof passes: 46 unsafe credential/local refs deny and three benign secretary/tokenizer/credentialing refs are accepted. Product re-review passed; security and QA also passed the final CodeRabbit/hosted-CI correction with no open finding. + +The sixth hosted run completed every semantic lane except one migration replay +test. That test documented migration 0038 but upgraded to head before creating +0038-era rows, causing the intentionally strict 0045 guide mutation trigger to +deny the seed. Setup and replay verification now remain at revision 0038; the +production trigger is unchanged, and the exact migration regression passes. +Architecture and QA re-reviewed this final narrow delta and passed it with no +finding; all reviewer sessions completed. diff --git a/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/reviews/WS-AUTH-001-12D-pr-trust-bundle.md b/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/reviews/WS-AUTH-001-12D-pr-trust-bundle.md index 7b20ea11..c1b6cfa2 100644 --- a/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/reviews/WS-AUTH-001-12D-pr-trust-bundle.md +++ b/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/reviews/WS-AUTH-001-12D-pr-trust-bundle.md @@ -34,6 +34,9 @@ outside this chunk. snapshots reject legacy rewrites. Its exact local selection completed 36 tests without a failure before local execution was stopped for machine cost. - Ruff, diff check, Markdown links, and stale wording/docs: passed. +- The final schema-lane failure was an old migration-0038 replay test running + against head; its setup and replay checks are now pinned to 0038, and the + exact isolated regression passes without weakening 0045 custody. - Internal architecture, security, QA, product, senior, CI, docs, reuse, and test-delta tracks: passed; low risks are documented in the review evidence. - GitHub full-suite, repository 78 percent, AUTH 90 percent, and new per-file diff --git a/backend/tests/test_alembic.py b/backend/tests/test_alembic.py index 2f3a8a5f..8468e1cc 100644 --- a/backend/tests/test_alembic.py +++ b/backend/tests/test_alembic.py @@ -1701,7 +1701,7 @@ def test_guide_source_artifact_ingest_schema_and_replay( with migration_lock(): try: command.downgrade(config, "base") - command.upgrade(config, "head") + command.upgrade(config, "0038_guide_source_ingest") assert "guide_source_artifact_ingests" in asyncio.run( _fetch_table_names(isolated_database_env) ) @@ -1716,7 +1716,7 @@ def test_guide_source_artifact_ingest_schema_and_replay( assert "guide_source_artifact_ingests" not in asyncio.run( _fetch_table_names(isolated_database_env) ) - command.upgrade(config, "head") + command.upgrade(config, "0038_guide_source_ingest") assert "guide_source_artifact_ingests" in asyncio.run( _fetch_table_names(isolated_database_env) ) From da2ec16b42f50e58a340f0ad84564ff0a15ac50b Mon Sep 17 00:00:00 2001 From: Abiorh001 Date: Fri, 31 Jul 2026 15:20:00 +0100 Subject: [PATCH 10/16] test(auth): cover prepared dependency failures --- ...S-AUTH-001-12D-external-review-response.md | 4 ++ ...S-AUTH-001-12D-internal-review-evidence.md | 9 ++++ .../WS-AUTH-001-12D-pr-trust-bundle.md | 3 ++ backend/tests/test_authorization.py | 46 +++++++++++++++++++ 4 files changed, 62 insertions(+) diff --git a/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/reviews/WS-AUTH-001-12D-external-review-response.md b/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/reviews/WS-AUTH-001-12D-external-review-response.md index 2d9e2527..8647f14d 100644 --- a/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/reviews/WS-AUTH-001-12D-external-review-response.md +++ b/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/reviews/WS-AUTH-001-12D-external-review-response.md @@ -45,6 +45,9 @@ - Sixth hosted CI: keep the migration-0038 populated replay test at its stated 0038 schema boundary instead of seeding historical rows under the active 0045 production mutation triggers. +- Seventh hosted CI: add direct rollback, public-unavailable response, handle + closure, and cancellation propagation coverage for every PREP dependency + transaction-failure branch; the 90 percent per-file gate is unchanged. ## Comments deferred @@ -74,6 +77,7 @@ None. The user retains merge authority for PR #232. - Guide-source credential/local-ref denial matrix: 46 passed; benign secretary/tokenizer/credentialing source refs: 3 passed. - Exact migration-0038 populated downgrade/replay regression: 1 passed. +- Exact PREP transaction-failure regression: 3 passed. - GitHub full backend suite and coverage: required again on the corrected SHA. ## Remaining risks diff --git a/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/reviews/WS-AUTH-001-12D-internal-review-evidence.md b/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/reviews/WS-AUTH-001-12D-internal-review-evidence.md index 41baa607..5d57f04f 100644 --- a/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/reviews/WS-AUTH-001-12D-internal-review-evidence.md +++ b/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/reviews/WS-AUTH-001-12D-internal-review-evidence.md @@ -99,3 +99,12 @@ deny the seed. Setup and replay verification now remain at revision 0038; the production trigger is unchanged, and the exact migration regression passes. Architecture and QA re-reviewed this final narrow delta and passed it with no finding; all reviewer sessions completed. + +The seventh hosted run passed every semantic lane and then found the shared +authorization dependency at 88.44 percent in the existing project-cutover +per-file gate. Direct regressions now cover authorization-evidence failure, +database failure, and cancellation across the PREP context manager, including +rollback and opaque-handle closure. The 90 percent gate remains unchanged. +QA passed the failure-semantics proof, and CI integrity passed with only the +expected low risk that the new exact head still requires hosted verification. +Both review sessions completed with no required fix. diff --git a/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/reviews/WS-AUTH-001-12D-pr-trust-bundle.md b/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/reviews/WS-AUTH-001-12D-pr-trust-bundle.md index c1b6cfa2..af9f9519 100644 --- a/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/reviews/WS-AUTH-001-12D-pr-trust-bundle.md +++ b/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/reviews/WS-AUTH-001-12D-pr-trust-bundle.md @@ -37,6 +37,9 @@ outside this chunk. - The final schema-lane failure was an old migration-0038 replay test running against head; its setup and replay checks are now pinned to 0038, and the exact isolated regression passes without weakening 0045 custody. +- All semantic lanes then passed; explicit PREP failure-path regressions close + the remaining shared-dependency per-file coverage gap without lowering its + 90 percent threshold. - Internal architecture, security, QA, product, senior, CI, docs, reuse, and test-delta tracks: passed; low risks are documented in the review evidence. - GitHub full-suite, repository 78 percent, AUTH 90 percent, and new per-file diff --git a/backend/tests/test_authorization.py b/backend/tests/test_authorization.py index d5e26dc8..6c1a44b8 100644 --- a/backend/tests/test_authorization.py +++ b/backend/tests/test_authorization.py @@ -8130,6 +8130,52 @@ async def reject_test_denial(_decision): assert persistence_failure_service._closed is True +@pytest.mark.parametrize( + ("failure", "expected_status"), + [ + (AuthorizationEvidenceUnavailable("injected evidence failure"), 503), + (SQLAlchemyError("injected database failure"), 503), + (asyncio.CancelledError(), None), + ], +) +async def test_prepared_dependency_rolls_back_every_transaction_failure( + failure: BaseException, + expected_status: int | None, +) -> None: + class Session: + rollback_count = 0 + + async def rollback(self) -> None: + self.rollback_count += 1 + + def in_transaction(self) -> bool: + return False + + resolved = SimpleNamespace( + profile=SimpleNamespace(id=str(uuid4()), actor_kind="human", status="active"), + identity_link=SimpleNamespace(id=str(uuid4()), status="active"), + ) + request = Request({"type": "http", "method": "GET", "path": "/", "headers": []}) + session = Session() + dependency = get_prepared_authorization_service( + request, + resolved, # type: ignore[arg-type] + session, # type: ignore[arg-type] + ) + service = await anext(dependency) + + if expected_status is None: + with pytest.raises(asyncio.CancelledError): + await dependency.athrow(failure) # type: ignore[attr-defined] + else: + with pytest.raises(StructuredHTTPException) as exc_info: + await dependency.athrow(failure) # type: ignore[attr-defined] + assert exc_info.value.status_code == expected_status + + assert session.rollback_count == 1 + assert service._closed is True + + async def test_authorization_dependency_admits_service_without_human_rate_control( monkeypatch: pytest.MonkeyPatch, ) -> None: From 994a29d8d74e000cead4b6d75534cf4fb9623608 Mon Sep 17 00:00:00 2001 From: Abiorh001 Date: Fri, 31 Jul 2026 15:46:05 +0100 Subject: [PATCH 11/16] test(auth): prove guide preparation denials --- ...S-AUTH-001-12D-external-review-response.md | 4 ++ ...S-AUTH-001-12D-internal-review-evidence.md | 9 +++ .../WS-AUTH-001-12D-pr-trust-bundle.md | 3 + backend/tests/test_authorization.py | 62 +++++++++++++++++++ 4 files changed, 78 insertions(+) diff --git a/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/reviews/WS-AUTH-001-12D-external-review-response.md b/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/reviews/WS-AUTH-001-12D-external-review-response.md index 8647f14d..744bedb2 100644 --- a/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/reviews/WS-AUTH-001-12D-external-review-response.md +++ b/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/reviews/WS-AUTH-001-12D-external-review-response.md @@ -48,6 +48,9 @@ - Seventh hosted CI: add direct rollback, public-unavailable response, handle closure, and cancellation propagation coverage for every PREP dependency transaction-failure branch; the 90 percent per-file gate is unchanged. +- Eighth hosted CI: prove guide-metadata PREP denies system scope, absent + Project Manager authority, and service actors before handle issuance; this + closes the one-statement kernel coverage gap without weakening the gate. ## Comments deferred @@ -78,6 +81,7 @@ None. The user retains merge authority for PR #232. secretary/tokenizer/credentialing source refs: 3 passed. - Exact migration-0038 populated downgrade/replay regression: 1 passed. - Exact PREP transaction-failure regression: 3 passed. +- Exact guide-metadata PREP denial regression: 1 passed. - GitHub full backend suite and coverage: required again on the corrected SHA. ## Remaining risks diff --git a/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/reviews/WS-AUTH-001-12D-internal-review-evidence.md b/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/reviews/WS-AUTH-001-12D-internal-review-evidence.md index 5d57f04f..9ca86075 100644 --- a/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/reviews/WS-AUTH-001-12D-internal-review-evidence.md +++ b/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/reviews/WS-AUTH-001-12D-internal-review-evidence.md @@ -108,3 +108,12 @@ rollback and opaque-handle closure. The 90 percent gate remains unchanged. QA passed the failure-semantics proof, and CI integrity passed with only the expected low risk that the new exact head still requires hosted verification. Both review sessions completed with no required fix. + +The eighth hosted run confirmed the PREP dependency at 91.84 percent, then +exposed the authorization kernel at 89.90 percent—one statement below its +existing floor. A direct guide-metadata preparation regression now proves wrong +scope, missing Project Manager authority, and service identity all deny before +handle issuance. No production or CI code changed. +QA and CI integrity passed this delta with low hosted-verification risk. QA's +only strengthening suggestion was applied: both human and service denial paths +now explicitly assert that no prepared handle was issued. All sessions closed. diff --git a/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/reviews/WS-AUTH-001-12D-pr-trust-bundle.md b/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/reviews/WS-AUTH-001-12D-pr-trust-bundle.md index af9f9519..b68722f7 100644 --- a/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/reviews/WS-AUTH-001-12D-pr-trust-bundle.md +++ b/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/reviews/WS-AUTH-001-12D-pr-trust-bundle.md @@ -40,6 +40,9 @@ outside this chunk. - All semantic lanes then passed; explicit PREP failure-path regressions close the remaining shared-dependency per-file coverage gap without lowering its 90 percent threshold. +- The following hosted run confirmed that file at 91.84 percent and exposed a + one-statement kernel gap; guide PREP scope/grant/service denials now have + direct regression proof while the kernel's 90 percent gate remains intact. - Internal architecture, security, QA, product, senior, CI, docs, reuse, and test-delta tracks: passed; low risks are documented in the review evidence. - GitHub full-suite, repository 78 percent, AUTH 90 percent, and new per-file diff --git a/backend/tests/test_authorization.py b/backend/tests/test_authorization.py index 6c1a44b8..8696fdc4 100644 --- a/backend/tests/test_authorization.py +++ b/backend/tests/test_authorization.py @@ -3663,6 +3663,68 @@ async def test_guide_source_metadata_authority_uses_exact_single_use_project_han await prepared.consume(handle, action_id, caller, resource) +@pytest.mark.asyncio +async def test_guide_metadata_preparation_denies_wrong_scope_missing_grant_and_service() -> None: + context = _runtime_context() + assert isinstance(context, HumanAuthorizationContext) + session = _PreparedTestSession() + facts = _GuideMutationAuthorityFacts(context) + authorization, evidence = _runtime_service( + context, session=session, admin_repository=facts + ) + prepared = PreparedAuthorizationService( + session, context, authorization, facts # type: ignore[arg-type] + ) + project_id = uuid4() + guide_id = uuid4() + caller = PreparedAuthorizationInput( + idempotency_key=uuid4(), + request_value={ + "project_id": str(project_id), + "guide_id": str(guide_id), + "target_resource_id": str(guide_id), + "operation_id": str(uuid4()), + }, + ) + + with pytest.raises(PreparedAuthorizationUnsupported) as wrong_scope: + await prepared.prepare( + ActionId.PROJECT_GUIDE_CREATE, + caller, + PreparedAuthorityScope(kind=PreparedAuthorityScopeKind.SYSTEM), + ) + assert wrong_scope.value.denial_code is AuthorizationDenialCode.SCOPE_NOT_AUTHORIZED + + project_scope = PreparedAuthorityScope( + kind=PreparedAuthorityScopeKind.PROJECT, + project_id=project_id, + ) + with pytest.raises(PreparedAuthorizationUnsupported) as missing_grant: + await prepared.prepare(ActionId.PROJECT_GUIDE_CREATE, caller, project_scope) + assert missing_grant.value.denial_code is AuthorizationDenialCode.PERMISSION_NOT_GRANTED + assert prepared._issued == {} + assert evidence.events == [] + + service_context = _runtime_context(actor_kind=ActorKind.SERVICE) + service_authorization, _ = _runtime_service( + service_context, session=session, admin_repository=object() + ) + service_prepared = PreparedAuthorizationService( + session, + service_context, + service_authorization, + service_authorization._admin, + ) + with pytest.raises(PreparedAuthorizationUnsupported) as service_denial: + await service_prepared.prepare( + ActionId.PROJECT_GUIDE_CREATE, + caller, + project_scope, + ) + assert service_denial.value.denial_code is AuthorizationDenialCode.PERMISSION_NOT_GRANTED + assert service_prepared._issued == {} + + @pytest.mark.asyncio async def test_project_create_prepared_authority_is_system_scoped_and_evidenced() -> None: context = _runtime_context() From df7fb5fc9c174999273ae22e4a09cd079bc67fa1 Mon Sep 17 00:00:00 2001 From: Abiorh001 Date: Fri, 31 Jul 2026 16:10:04 +0100 Subject: [PATCH 12/16] test(auth): cover guide mutation repository --- ...S-AUTH-001-12D-external-review-response.md | 4 + ...S-AUTH-001-12D-internal-review-evidence.md | 9 ++ .../WS-AUTH-001-12D-pr-trust-bundle.md | 3 + backend/tests/test_projects.py | 97 +++++++++++++++++++ 4 files changed, 113 insertions(+) diff --git a/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/reviews/WS-AUTH-001-12D-external-review-response.md b/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/reviews/WS-AUTH-001-12D-external-review-response.md index 744bedb2..498c070d 100644 --- a/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/reviews/WS-AUTH-001-12D-external-review-response.md +++ b/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/reviews/WS-AUTH-001-12D-external-review-response.md @@ -51,6 +51,9 @@ - Eighth hosted CI: prove guide-metadata PREP denies system scope, absent Project Manager authority, and service actors before handle issuance; this closes the one-statement kernel coverage gap without weakening the gate. +- Ninth hosted CI: directly prove guide-mutation replay lookup and every + existing-reservation classification, plus fail-closed insert/load/completion + custody disappearance; the repository's 90 percent gate is unchanged. ## Comments deferred @@ -82,6 +85,7 @@ None. The user retains merge authority for PR #232. - Exact migration-0038 populated downgrade/replay regression: 1 passed. - Exact PREP transaction-failure regression: 3 passed. - Exact guide-metadata PREP denial regression: 1 passed. +- Exact guide-mutation repository regression: 8 passed. - GitHub full backend suite and coverage: required again on the corrected SHA. ## Remaining risks diff --git a/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/reviews/WS-AUTH-001-12D-internal-review-evidence.md b/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/reviews/WS-AUTH-001-12D-internal-review-evidence.md index 9ca86075..485c6801 100644 --- a/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/reviews/WS-AUTH-001-12D-internal-review-evidence.md +++ b/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/reviews/WS-AUTH-001-12D-internal-review-evidence.md @@ -117,3 +117,12 @@ handle issuance. No production or CI code changed. QA and CI integrity passed this delta with low hosted-verification risk. QA's only strengthening suggestion was applied: both human and service denial paths now explicitly assert that no prepared handle was issued. All sessions closed. + +The ninth hosted run advanced through the repaired shared dependency and kernel +gates, then reported the new guide-mutation repository at 60 percent. Direct +repository tests now cover replay lookup, identity/digest mismatch, pending and +committed replay classification, and fail-closed insert/load/complete custody +loss. The production repository and 90 percent gate are unchanged. +QA required the lookup mock to return the real row shape and requested direct +claimed-reservation proof. Both were added; QA then passed, CI integrity passed +with only hosted-verification risk, and all sessions closed. diff --git a/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/reviews/WS-AUTH-001-12D-pr-trust-bundle.md b/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/reviews/WS-AUTH-001-12D-pr-trust-bundle.md index b68722f7..1842656d 100644 --- a/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/reviews/WS-AUTH-001-12D-pr-trust-bundle.md +++ b/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/reviews/WS-AUTH-001-12D-pr-trust-bundle.md @@ -43,6 +43,9 @@ outside this chunk. - The following hosted run confirmed that file at 91.84 percent and exposed a one-statement kernel gap; guide PREP scope/grant/service denials now have direct regression proof while the kernel's 90 percent gate remains intact. +- The next hosted run passed those repaired gates and exposed the new replay + repository's untested fail-closed branches; eight direct repository cases now + cover classification and custody-loss behavior without changing production. - Internal architecture, security, QA, product, senior, CI, docs, reuse, and test-delta tracks: passed; low risks are documented in the review evidence. - GitHub full-suite, repository 78 percent, AUTH 90 percent, and new per-file diff --git a/backend/tests/test_projects.py b/backend/tests/test_projects.py index 9797cd8a..f64f57c0 100644 --- a/backend/tests/test_projects.py +++ b/backend/tests/test_projects.py @@ -8,6 +8,7 @@ import types from collections.abc import AsyncIterator, Iterator from datetime import UTC, datetime +from types import SimpleNamespace from typing import Any, cast from uuid import UUID, uuid4 @@ -60,6 +61,7 @@ ReviewPolicy, SubmissionArtifactPolicy, ) +from app.modules.projects.guide_mutation_repository import GuideMutationRepository from app.modules.tasks.models import AuditEvent from app.modules.authorization.models import ( AdminRoleGrant, @@ -2743,6 +2745,7 @@ async def test_guide_source_metadata_authority_records_exact_provenance_and_repl persisted_snapshot = await session.get( GuideSourceSnapshot, snapshotted.json()["id"] ) + records = ( await session.scalars( select(GuideMutationIdempotencyRecord).where( @@ -2773,6 +2776,100 @@ async def test_guide_source_metadata_authority_records_exact_provenance_and_repl ) +@pytest.mark.parametrize( + ("identity_matches", "digest_matches", "status", "expected"), + [ + (False, True, "pending", "mismatch"), + (True, False, "pending", "mismatch"), + (True, True, "pending", "pending"), + (True, True, "committed", "replayed"), + (True, True, "pending", "claimed"), + ], +) +async def test_guide_mutation_repository_classifies_existing_reservations( + identity_matches: bool, + digest_matches: bool, + status: str, + expected: str, +) -> None: + record_id = uuid4() + identity_link_id = str(uuid4()) + request_digest = "sha256:" + "a" * 64 + record = SimpleNamespace( + id=record_id, + identity_link_id=identity_link_id if identity_matches else str(uuid4()), + request_digest=request_digest if digest_matches else "sha256:" + "b" * 64, + status=status, + ) + + class Session: + scalar_calls = 0 + + async def scalar(self, statement): + self.scalar_calls += 1 + if self.scalar_calls == 1: + return record + if expected == "claimed": + return statement.compile().params["id"] + return record_id + + async def get(self, _model, selected_id): + if expected != "claimed": + assert selected_id == record_id + return record + + repository = GuideMutationRepository(Session()) # type: ignore[arg-type] + assert await repository.find(str(uuid4()), "project.guide.create", uuid4()) is record + result, selected = await repository.reserve( + actor_profile_id=str(uuid4()), + identity_link_id=identity_link_id, + action_id="project.guide.create", + idempotency_key=uuid4(), + request_digest=request_digest, + resource_context_digest="sha256:" + "c" * 64, + operation_id=uuid4(), + project_id=str(uuid4()), + resource_id=str(uuid4()), + operation_generation=1, + ) + + assert result == expected + assert selected is record + + +@pytest.mark.parametrize("missing_stage", ["insert", "load", "complete"]) +async def test_guide_mutation_repository_fails_closed_when_custody_disappears( + missing_stage: str, +) -> None: + record_id = uuid4() + + class Session: + async def scalar(self, _statement): + return None if missing_stage in {"insert", "complete"} else record_id + + async def get(self, _model, _selected_id): + return None + + repository = GuideMutationRepository(Session()) # type: ignore[arg-type] + with pytest.raises(ProjectRepositoryIntegrityError): + if missing_stage == "complete": + await repository.complete( + SimpleNamespace(id=record_id), # type: ignore[arg-type] + response_json={}, + ) + else: + await repository.reserve( + actor_profile_id=str(uuid4()), + identity_link_id=str(uuid4()), + action_id="project.guide.create", + idempotency_key=uuid4(), + request_digest="sha256:" + "a" * 64, + resource_context_digest="sha256:" + "b" * 64, + operation_id=uuid4(), + project_id=str(uuid4()), + resource_id=str(uuid4()), + operation_generation=1, + ) async def test_guide_source_metadata_authority_rejects_removed_fields_and_bad_replay( project_client: AsyncClient, ) -> None: From 2f6194029a85b59e197a1af8569d1685e7a6b4cc Mon Sep 17 00:00:00 2001 From: Abiorh001 Date: Fri, 31 Jul 2026 16:33:40 +0100 Subject: [PATCH 13/16] test(auth): cover guide router composition --- ...S-AUTH-001-12D-external-review-response.md | 4 ++ ...S-AUTH-001-12D-internal-review-evidence.md | 9 ++++ .../WS-AUTH-001-12D-pr-trust-bundle.md | 3 ++ backend/tests/test_projects.py | 54 +++++++++++++++++++ 4 files changed, 70 insertions(+) diff --git a/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/reviews/WS-AUTH-001-12D-external-review-response.md b/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/reviews/WS-AUTH-001-12D-external-review-response.md index 498c070d..783a6f9d 100644 --- a/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/reviews/WS-AUTH-001-12D-external-review-response.md +++ b/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/reviews/WS-AUTH-001-12D-external-review-response.md @@ -54,6 +54,9 @@ - Ninth hosted CI: directly prove guide-mutation replay lookup and every existing-reservation classification, plus fail-closed insert/load/completion custody disappearance; the repository's 90 percent gate is unchanged. +- Tenth hosted retry: directly prove the guide router preserves key-gated actor + resolution, request-local PREP composition, and the exact authority tuple; + the router's 90 percent gate is unchanged. ## Comments deferred @@ -86,6 +89,7 @@ None. The user retains merge authority for PR #232. - Exact PREP transaction-failure regression: 3 passed. - Exact guide-metadata PREP denial regression: 1 passed. - Exact guide-mutation repository regression: 8 passed. +- Exact guide-router composition regression: 1 passed. - GitHub full backend suite and coverage: required again on the corrected SHA. ## Remaining risks diff --git a/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/reviews/WS-AUTH-001-12D-internal-review-evidence.md b/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/reviews/WS-AUTH-001-12D-internal-review-evidence.md index 485c6801..a62c2f54 100644 --- a/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/reviews/WS-AUTH-001-12D-internal-review-evidence.md +++ b/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/reviews/WS-AUTH-001-12D-internal-review-evidence.md @@ -126,3 +126,12 @@ loss. The production repository and 90 percent gate are unchanged. QA required the lookup mock to return the real row shape and requested direct claimed-reservation proof. Both were added; QA then passed, CI integrity passed with only hosted-verification risk, and all sessions closed. + +The first repository-corrected run was interrupted by an unrelated asyncpg +isolated-runner concurrency failure. Its exact-head retry passed the repository +at 100 percent, then reported the guide router at 84.62 percent. A direct +composition regression now proves valid-key actor resolution, request-local +PREP lifetime, and exact authority tuple propagation without changing runtime. +QA and CI integrity passed with only hosted-verification risk; QA's cleanup +suggestion was applied by explicitly observing PREP context exit. All sessions +closed. diff --git a/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/reviews/WS-AUTH-001-12D-pr-trust-bundle.md b/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/reviews/WS-AUTH-001-12D-pr-trust-bundle.md index 1842656d..1b4aedef 100644 --- a/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/reviews/WS-AUTH-001-12D-pr-trust-bundle.md +++ b/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/reviews/WS-AUTH-001-12D-pr-trust-bundle.md @@ -46,6 +46,9 @@ outside this chunk. - The next hosted run passed those repaired gates and exposed the new replay repository's untested fail-closed branches; eight direct repository cases now cover classification and custody-loss behavior without changing production. +- The repository then passed at 100 percent; a focused router composition test + covers its remaining key-gated actor/PREP dependency paths without changing + the router or its 90 percent gate. - Internal architecture, security, QA, product, senior, CI, docs, reuse, and test-delta tracks: passed; low risks are documented in the review evidence. - GitHub full-suite, repository 78 percent, AUTH 90 percent, and new per-file diff --git a/backend/tests/test_projects.py b/backend/tests/test_projects.py index f64f57c0..c0bdff45 100644 --- a/backend/tests/test_projects.py +++ b/backend/tests/test_projects.py @@ -7,6 +7,7 @@ import sys import types from collections.abc import AsyncIterator, Iterator +from contextlib import asynccontextmanager from datetime import UTC, datetime from types import SimpleNamespace from typing import Any, cast @@ -72,6 +73,7 @@ from app.modules.authorization.repository import AdminAuthorizationRepository from app.modules.authorization.catalogue import ActionId from app.modules.projects import service as project_service_module +from app.modules.projects import guide_mutation_router as guide_mutation_router_module from app.modules.projects import setup_queue as project_setup_queue_module from app.modules.projects.authorization_reads import ( authorize_project_active_guide_read, @@ -2870,6 +2872,58 @@ async def get(self, _model, _selected_id): resource_id=str(uuid4()), operation_generation=1, ) + + +async def test_guide_mutation_router_composes_only_key_gated_authority( + monkeypatch: pytest.MonkeyPatch, +) -> None: + key = uuid4() + request = object() + result = object() + session = object() + rate_control = object() + resolved = object() + prepared = object() + calls: list[tuple] = [] + + async def resolve(current_request, current_result, current_session, current_rate): + calls.append((current_request, current_result, current_session, current_rate)) + return resolved + + @asynccontextmanager + async def prepared_context(current_request, current_resolved, current_session): + calls.append((current_request, current_resolved, current_session)) + try: + yield prepared + finally: + calls.append(("prepared_closed",)) + + monkeypatch.setattr(guide_mutation_router_module, "resolve_authorization_actor", resolve) + monkeypatch.setattr( + guide_mutation_router_module, + "prepared_authorization_service", + prepared_context, + ) + + assert ( + await guide_mutation_router_module.guide_authorization_actor( + key, request, result, session, rate_control # type: ignore[arg-type] + ) + is resolved + ) + dependency = guide_mutation_router_module.get_guide_prepared_authorization_service( + request, resolved, session # type: ignore[arg-type] + ) + assert await anext(dependency) is prepared + await dependency.aclose() + assert await guide_mutation_router_module.guide_authorization( + key, resolved, prepared # type: ignore[arg-type] + ) == (key, resolved, prepared) + assert calls == [ + (request, result, session, rate_control), + (request, resolved, session), + ("prepared_closed",), + ] async def test_guide_source_metadata_authority_rejects_removed_fields_and_bad_replay( project_client: AsyncClient, ) -> None: From b9a41d64f053234c8c61ca677de3f8411ec3c27e Mon Sep 17 00:00:00 2001 From: Abiorh001 Date: Fri, 31 Jul 2026 16:54:28 +0100 Subject: [PATCH 14/16] test(auth): cover guide router boundaries --- ...S-AUTH-001-12D-external-review-response.md | 4 + ...S-AUTH-001-12D-internal-review-evidence.md | 8 ++ .../WS-AUTH-001-12D-pr-trust-bundle.md | 2 + backend/tests/test_projects.py | 87 +++++++++++++++++++ 4 files changed, 101 insertions(+) diff --git a/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/reviews/WS-AUTH-001-12D-external-review-response.md b/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/reviews/WS-AUTH-001-12D-external-review-response.md index 783a6f9d..297e65a9 100644 --- a/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/reviews/WS-AUTH-001-12D-external-review-response.md +++ b/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/reviews/WS-AUTH-001-12D-external-review-response.md @@ -57,6 +57,9 @@ - Tenth hosted retry: directly prove the guide router preserves key-gated actor resolution, request-local PREP composition, and the exact authority tuple; the router's 90 percent gate is unchanged. +- Eleventh hosted CI: directly prove bounded pending/mismatch/ordinary error + translation and transaction-finish commit, rollback, dispatch, and replay + suppression behavior; the router's 90 percent gate remains unchanged. ## Comments deferred @@ -90,6 +93,7 @@ None. The user retains merge authority for PR #232. - Exact guide-metadata PREP denial regression: 1 passed. - Exact guide-mutation repository regression: 8 passed. - Exact guide-router composition regression: 1 passed. +- Exact guide-router boundary regression: 3 passed. - GitHub full backend suite and coverage: required again on the corrected SHA. ## Remaining risks diff --git a/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/reviews/WS-AUTH-001-12D-internal-review-evidence.md b/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/reviews/WS-AUTH-001-12D-internal-review-evidence.md index a62c2f54..7fdd42a8 100644 --- a/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/reviews/WS-AUTH-001-12D-internal-review-evidence.md +++ b/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/reviews/WS-AUTH-001-12D-internal-review-evidence.md @@ -135,3 +135,11 @@ PREP lifetime, and exact authority tuple propagation without changing runtime. QA and CI integrity passed with only hosted-verification risk; QA's cleanup suggestion was applied by explicitly observing PREP context exit. All sessions closed. + +The next hosted run showed the composition path had already been exercised and +left the router at 84.62 percent. Direct boundary tests now prove pending versus +mismatch retry semantics, ordinary service-error translation, commit versus +rollback, setup dispatch after commit, and dispatch suppression on replay. +QA and CI integrity passed with hosted-verification risk only. QA's suggested +counter-assertions now explicitly exclude rollback on commit and extra commit +on replay; all sessions closed. diff --git a/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/reviews/WS-AUTH-001-12D-pr-trust-bundle.md b/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/reviews/WS-AUTH-001-12D-pr-trust-bundle.md index 1b4aedef..97960cf9 100644 --- a/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/reviews/WS-AUTH-001-12D-pr-trust-bundle.md +++ b/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/reviews/WS-AUTH-001-12D-pr-trust-bundle.md @@ -49,6 +49,8 @@ outside this chunk. - The repository then passed at 100 percent; a focused router composition test covers its remaining key-gated actor/PREP dependency paths without changing the router or its 90 percent gate. +- Direct router boundary tests additionally cover bounded conflict translation + and commit/rollback/dispatch/replay behavior; runtime and CI remain unchanged. - Internal architecture, security, QA, product, senior, CI, docs, reuse, and test-delta tracks: passed; low risks are documented in the review evidence. - GitHub full-suite, repository 78 percent, AUTH 90 percent, and new per-file diff --git a/backend/tests/test_projects.py b/backend/tests/test_projects.py index c0bdff45..8bf8f19e 100644 --- a/backend/tests/test_projects.py +++ b/backend/tests/test_projects.py @@ -105,6 +105,7 @@ SUBMISSION_ARTIFACT_POLICY_DERIVATION_AGENT_VERSION, GuideActivationBlocked, PolicySetupBlocked, + ProjectNotFound, ProjectSetupQueueError, ProjectService, ProjectServiceError, @@ -2924,6 +2925,92 @@ async def prepared_context(current_request, current_resolved, current_session): (request, resolved, session), ("prepared_closed",), ] + + +def test_guide_mutation_router_translates_bounded_service_errors() -> None: + pending = guide_mutation_router_module._error( + guide_mutation_router_module.GuideMutationIdempotencyConflict( + "idempotency_pending" + ) + ) + mismatch = guide_mutation_router_module._error( + guide_mutation_router_module.GuideMutationIdempotencyConflict( + "idempotency_mismatch" + ) + ) + missing = guide_mutation_router_module._error(ProjectNotFound("project not found")) + + assert pending.status_code == 409 + assert pending.retryable is True + assert pending.error_message == "Guide mutation is already in progress" + assert mismatch.status_code == 409 + assert mismatch.retryable is False + assert mismatch.error_message == "Idempotency key does not match" + assert missing.status_code == 404 + assert missing.detail == "project not found" + + +async def test_guide_mutation_router_finishes_commit_dispatch_and_replay( + monkeypatch: pytest.MonkeyPatch, +) -> None: + class Session: + commit_count = 0 + rollback_count = 0 + + async def commit(self): + self.commit_count += 1 + + async def rollback(self): + self.rollback_count += 1 + + dispatched: list[dict] = [] + + async def dispatch(_session, **facts): + dispatched.append(facts) + + monkeypatch.setattr( + guide_mutation_router_module, + "dispatch_pre_submit_setup_pipeline_after_commit", + dispatch, + ) + response = SimpleNamespace( + project_id="project-1", + guide_id="guide-1", + id="snapshot-1", + ) + session = Session() + assert ( + await guide_mutation_router_module._finish( + session, + SimpleNamespace( + replayed=False, + setup_run_id="setup-1", + response=response, + ), + ) + is response + ) + assert session.commit_count == 1 + assert session.rollback_count == 0 + assert dispatched == [ + { + "project_id": "project-1", + "guide_id": "guide-1", + "source_snapshot_id": "snapshot-1", + "setup_run_id": "setup-1", + } + ] + + assert ( + await guide_mutation_router_module._finish( + session, + SimpleNamespace(replayed=True, setup_run_id="setup-1", response=response), + ) + is response + ) + assert session.rollback_count == 1 + assert session.commit_count == 1 + assert len(dispatched) == 1 async def test_guide_source_metadata_authority_rejects_removed_fields_and_bad_replay( project_client: AsyncClient, ) -> None: From cef30d95688d833be71afd21903b90286c2e1a7c Mon Sep 17 00:00:00 2001 From: Abiorh001 Date: Fri, 31 Jul 2026 17:27:04 +0100 Subject: [PATCH 15/16] test(auth): cover guide mutation service --- ...S-AUTH-001-12D-external-review-response.md | 5 + ...S-AUTH-001-12D-internal-review-evidence.md | 12 + .../WS-AUTH-001-12D-pr-trust-bundle.md | 3 + backend/tests/test_projects.py | 323 +++++++++++++++++- 4 files changed, 341 insertions(+), 2 deletions(-) diff --git a/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/reviews/WS-AUTH-001-12D-external-review-response.md b/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/reviews/WS-AUTH-001-12D-external-review-response.md index 297e65a9..e5dab029 100644 --- a/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/reviews/WS-AUTH-001-12D-external-review-response.md +++ b/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/reviews/WS-AUTH-001-12D-external-review-response.md @@ -60,6 +60,9 @@ - Eleventh hosted CI: directly prove bounded pending/mismatch/ordinary error translation and transaction-finish commit, rollback, dispatch, and replay suppression behavior; the router's 90 percent gate remains unchanged. +- Twelfth hosted CI: execute all three guide service mutations through their + real orchestration, including setup-run creation, and prove replay, + reservation, authority, and unsupported-PREP edges fail closed. ## Comments deferred @@ -94,6 +97,8 @@ None. The user retains merge authority for PR #232. - Exact guide-mutation repository regression: 8 passed. - Exact guide-router composition regression: 1 passed. - Exact guide-router boundary regression: 3 passed. +- Exact guide-mutation service regression: 2 passed at 91.33 percent per-file + coverage using Python's system-monitoring tracer. - GitHub full backend suite and coverage: required again on the corrected SHA. ## Remaining risks diff --git a/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/reviews/WS-AUTH-001-12D-internal-review-evidence.md b/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/reviews/WS-AUTH-001-12D-internal-review-evidence.md index 7fdd42a8..f029cb7d 100644 --- a/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/reviews/WS-AUTH-001-12D-internal-review-evidence.md +++ b/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/reviews/WS-AUTH-001-12D-internal-review-evidence.md @@ -143,3 +143,15 @@ rollback, setup dispatch after commit, and dispatch suppression on replay. QA and CI integrity passed with hosted-verification risk only. QA's suggested counter-assertions now explicitly exclude rollback on commit and extra commit on replay; all sessions closed. + +The following hosted run passed repository and router at 100 and 90.77 percent, +then reported the guide service at 34.10 percent. Two direct service tests now +execute create, update, and snapshot orchestration, setup-run creation, cached +replay, mismatch/pending classification, authority proof, and unsupported PREP +denial. They independently pass the service's unchanged 90 percent floor at +91.33 percent. The local coverage C tracer crashed during collection, so this +proof used Python's system-monitoring tracer; plain pytest also passed. +QA caught an accidental shared-fixture autostart change made while constructing +the local setup-run proof. It was reverted immediately; autostart is enabled +only through the direct test's service-module settings seam. QA then passed, +CI integrity passed with hosted-verification risk only, and all sessions closed. diff --git a/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/reviews/WS-AUTH-001-12D-pr-trust-bundle.md b/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/reviews/WS-AUTH-001-12D-pr-trust-bundle.md index 97960cf9..65109777 100644 --- a/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/reviews/WS-AUTH-001-12D-pr-trust-bundle.md +++ b/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/reviews/WS-AUTH-001-12D-pr-trust-bundle.md @@ -51,6 +51,9 @@ outside this chunk. the router or its 90 percent gate. - Direct router boundary tests additionally cover bounded conflict translation and commit/rollback/dispatch/replay behavior; runtime and CI remain unchanged. +- Direct service tests cover all three authorized mutation orchestrations and + fail-closed replay/PREP edges, independently reaching 91.33 percent without + changing production or the hosted threshold. - Internal architecture, security, QA, product, senior, CI, docs, reuse, and test-delta tracks: passed; low risks are documented in the review evidence. - GitHub full-suite, repository 78 percent, AUTH 90 percent, and new per-file diff --git a/backend/tests/test_projects.py b/backend/tests/test_projects.py index 8bf8f19e..2d62e8ac 100644 --- a/backend/tests/test_projects.py +++ b/backend/tests/test_projects.py @@ -74,6 +74,7 @@ from app.modules.authorization.catalogue import ActionId from app.modules.projects import service as project_service_module from app.modules.projects import guide_mutation_router as guide_mutation_router_module +from app.modules.projects import guide_mutation_service as guide_mutation_service_module from app.modules.projects import setup_queue as project_setup_queue_module from app.modules.projects.authorization_reads import ( authorize_project_active_guide_read, @@ -91,9 +92,20 @@ ProjectCreateOutcome, ProjectCreateService, ) +from app.modules.projects.guide_mutation_service import GuideMutationService from app.modules.projects.repository import ProjectRepository, ProjectRepositoryIntegrityError -from app.modules.projects.schemas import ProjectCreate, ProjectResponse -from app.modules.authorization.runtime import MatchedAuthorityKind +from app.modules.projects.schemas import ( + GuideSourceSnapshotCreate, + ProjectCreate, + ProjectGuideCreate, + ProjectGuideUpdate, + ProjectResponse, +) +from app.modules.authorization.runtime import ( + AuthorizationDenialCode, + MatchedAuthorityKind, + PreparedAuthorizationUnsupported, +) from app.core.permissions import PermissionDenied from app.modules.projects.service import ( GUIDE_SOURCE_MATERIAL_FIELDS, @@ -3011,6 +3023,313 @@ async def dispatch(_session, **facts): assert session.rollback_count == 1 assert session.commit_count == 1 assert len(dispatched) == 1 + + +async def test_guide_mutation_service_executes_all_three_authorized_happy_paths( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr( + guide_mutation_service_module, + "get_settings", + lambda: SimpleNamespace(project_setup_pipeline_autostart=True), + ) + project_id = uuid4() + actor_id, link_id, grant_id = (uuid4() for _ in range(3)) + resolved = SimpleNamespace( + profile=SimpleNamespace(id=str(actor_id)), + identity_link=SimpleNamespace(id=str(link_id)), + ) + + class Session: + flush_count = 0 + refresh_count = 0 + + async def flush(self): + self.flush_count += 1 + + async def refresh(self, _record): + self.refresh_count += 1 + + class Repository: + project = SimpleNamespace(id=str(project_id)) + guide = None + snapshot = None + items = None + setup_run = None + + async def get_project(self, selected_id, *, for_update=False): + assert selected_id == str(project_id) + assert for_update is True + return self.project + + async def get_guide_by_version(self, selected_project_id, _version): + assert selected_project_id == str(project_id) + return None + + async def add_guide(self, guide): + guide.created_at = datetime.now(UTC) + guide.updated_at = guide.created_at + self.guide = guide + + async def lock_project_guide(self, selected_id): + assert self.guide is not None + assert selected_id == self.guide.id + return self.guide + + async def lock_latest_guide_source_snapshot(self, *_args): + return None + + async def add_guide_source_snapshot(self, snapshot, items): + snapshot.captured_at = datetime.now(UTC) + for item in items: + item.created_at = snapshot.captured_at + self.snapshot = snapshot + self.items = items + + async def next_project_setup_generation(self, selected_guide_id): + assert self.guide is not None + assert selected_guide_id == self.guide.id + return 1 + + async def add_project_setup_run(self, setup_run): + self.setup_run = setup_run + + class Replay: + completed: list[tuple] = [] + + async def find(self, *_args): + return None + + async def reserve(self, **_facts): + return "claimed", SimpleNamespace(response_json=None) + + async def complete(self, record, **facts): + self.completed.append((record, facts)) + + class Prepared: + prepare_count = 0 + consume_count = 0 + + async def prepare(self, *_args): + self.prepare_count += 1 + return object() + + async def consume(self, _handle, _action, _caller, resource): + self.consume_count += 1 + return SimpleNamespace( + matched_authority_kind=MatchedAuthorityKind.ADMIN_ROLE_GRANT, + matched_grant_id=grant_id, + matched_scope_project_id=project_id, + resource_context_digest=canonical_json_hash(resource.model_dump(mode="json")), + decision_id=uuid4(), + ) + + session = Session() + repository = Repository() + replay = Replay() + prepared = Prepared() + service = GuideMutationService(session) + service._repo = repository # type: ignore[assignment] + service._replay = replay # type: ignore[assignment] + + created = await service.create_guide( + resolved, + prepared, + uuid4(), + project_id, + ProjectGuideCreate.model_validate(complete_guide_payload()), + ) + guide_id = UUID(created.response.id) + updated = await service.update_guide( + resolved, + prepared, + uuid4(), + project_id, + guide_id, + ProjectGuideUpdate(change_summary="Clarified"), + ) + snapshotted = await service.create_snapshot( + resolved, + prepared, + uuid4(), + project_id, + guide_id, + GuideSourceSnapshotCreate.model_validate(source_snapshot_payload()), + ) + + assert created.replayed is updated.replayed is snapshotted.replayed is False + assert updated.response.change_summary == "Clarified" + assert snapshotted.response.guide_id == str(guide_id) + assert snapshotted.response.items + assert snapshotted.setup_run_id == repository.setup_run.id + assert prepared.prepare_count == prepared.consume_count == 3 + assert len(replay.completed) == 3 + assert session.flush_count == session.refresh_count == 1 + assert repository.snapshot is not None + assert repository.items + + +async def test_guide_mutation_service_fails_closed_for_replay_and_authority_edges() -> None: + actor_id, link_id, project_id = (uuid4() for _ in range(3)) + resolved = SimpleNamespace( + profile=SimpleNamespace(id=str(actor_id)), + identity_link=SimpleNamespace(id=str(link_id)), + ) + + class ResponseType: + @classmethod + def model_validate(cls, value): + return ("validated", value) + + class Replay: + record = None + + async def find(self, *_args): + return self.record + + replay = Replay() + service = GuideMutationService(object()) + service._replay = replay # type: ignore[assignment] + replay.record = SimpleNamespace( + identity_link_id=str(uuid4()), + request_digest="digest", + status="committed", + response_json={"id": "response"}, + ) + with pytest.raises( + guide_mutation_router_module.GuideMutationIdempotencyConflict, + match="idempotency_mismatch", + ): + await service._existing( + resolved, ActionId.PROJECT_GUIDE_CREATE, uuid4(), "digest", ResponseType + ) + + replay.record = SimpleNamespace( + identity_link_id=str(link_id), + request_digest="digest", + status="pending", + response_json=None, + ) + with pytest.raises( + guide_mutation_router_module.GuideMutationIdempotencyConflict, + match="idempotency_pending", + ): + await service._existing( + resolved, ActionId.PROJECT_GUIDE_CREATE, uuid4(), "digest", ResponseType + ) + + replay.record.status = "committed" + replay.record.response_json = {"id": "response"} + existing = await service._existing( + resolved, ActionId.PROJECT_GUIDE_CREATE, uuid4(), "digest", ResponseType + ) + assert existing.response == ("validated", {"id": "response"}) + assert existing.replayed is True + + cached = SimpleNamespace(replayed=True) + + async def cached_existing(*_args): + return cached + + cached_service = GuideMutationService(object()) + cached_service._existing = cached_existing # type: ignore[method-assign] + guide_id = uuid4() + assert ( + await cached_service.create_guide( + resolved, + object(), + uuid4(), + project_id, + ProjectGuideCreate.model_validate(complete_guide_payload()), + ) + is cached + ) + assert ( + await cached_service.update_guide( + resolved, + object(), + uuid4(), + project_id, + guide_id, + ProjectGuideUpdate(change_summary="cached"), + ) + is cached + ) + assert ( + await cached_service.create_snapshot( + resolved, + object(), + uuid4(), + project_id, + guide_id, + GuideSourceSnapshotCreate.model_validate(source_snapshot_payload()), + ) + is cached + ) + + record = SimpleNamespace(response_json={"id": "response"}) + with pytest.raises( + guide_mutation_router_module.GuideMutationIdempotencyConflict, + match="idempotency_mismatch", + ): + service._reservation_outcome("mismatch", record, ResponseType) + with pytest.raises( + guide_mutation_router_module.GuideMutationIdempotencyConflict, + match="idempotency_pending", + ): + service._reservation_outcome("pending", record, ResponseType) + replayed = service._reservation_outcome("replayed", record, ResponseType) + assert replayed.response == ("validated", {"id": "response"}) + assert replayed.replayed is True + + with pytest.raises(RuntimeError, match="lacked Project Manager authority"): + service._prove( + SimpleNamespace( + matched_authority_kind=None, + matched_grant_id=None, + matched_scope_project_id=None, + ), + project_id, + ) + + class Prepared: + denied = None + + async def prepare(self, *_args): + raise PreparedAuthorizationUnsupported( + AuthorizationDenialCode.PERMISSION_NOT_GRANTED + ) + + async def deny_unsupported(self, *args): + self.denied = args + + prepared = Prepared() + caller, _ = service._input( + ActionId.PROJECT_GUIDE_CREATE, + "POST /api/v1/projects/{project_id}/guides", + resolved, + uuid4(), + ProjectGuideCreate.model_validate(complete_guide_payload()), + project_id=project_id, + target_resource_id=uuid4(), + operation_id=uuid4(), + ) + assert ( + await service._prepare( + prepared, + ActionId.PROJECT_GUIDE_CREATE, + caller, + project_id, + guide_id=None, + target_kind="guide_create", + ) + is None + ) + assert prepared.denied is not None + denial_resource = prepared.denied[2] + assert denial_resource.scope_project_id == project_id + assert denial_resource.requested_guide_id is None + assert denial_resource.requested_target_kind == "guide_create" async def test_guide_source_metadata_authority_rejects_removed_fields_and_bad_replay( project_client: AsyncClient, ) -> None: From 438bf7396c917f18f98e58cdfb204c009ce8aa68 Mon Sep 17 00:00:00 2001 From: Abiorh001 Date: Fri, 31 Jul 2026 17:52:52 +0100 Subject: [PATCH 16/16] test(auth): address guide review feedback --- ...S-AUTH-001-12D-external-review-response.md | 5 ++ ...S-AUTH-001-12D-internal-review-evidence.md | 9 ++ .../WS-AUTH-001-12D-pr-trust-bundle.md | 2 + backend/tests/test_projects.py | 87 ++++++++++++++----- 4 files changed, 79 insertions(+), 24 deletions(-) diff --git a/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/reviews/WS-AUTH-001-12D-external-review-response.md b/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/reviews/WS-AUTH-001-12D-external-review-response.md index e5dab029..190e13de 100644 --- a/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/reviews/WS-AUTH-001-12D-external-review-response.md +++ b/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/reviews/WS-AUTH-001-12D-external-review-response.md @@ -63,6 +63,9 @@ - Twelfth hosted CI: execute all three guide service mutations through their real orchestration, including setup-run creation, and prove replay, reservation, authority, and unsupported-PREP edges fail closed. +- Fresh CodeRabbit test review: make replay capture state instance-local, model + insert victory independently from the expected classification, and split the + broad fail-closed service test into five focused regressions. ## Comments deferred @@ -99,6 +102,8 @@ None. The user retains merge authority for PR #232. - Exact guide-router boundary regression: 3 passed. - Exact guide-mutation service regression: 2 passed at 91.33 percent per-file coverage using Python's system-monitoring tracer. +- CodeRabbit-corrected focused service/repository selection: 11 passed; the six + service tests retain 91.33 percent coverage. - GitHub full backend suite and coverage: required again on the corrected SHA. ## Remaining risks diff --git a/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/reviews/WS-AUTH-001-12D-internal-review-evidence.md b/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/reviews/WS-AUTH-001-12D-internal-review-evidence.md index f029cb7d..20f7b7c2 100644 --- a/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/reviews/WS-AUTH-001-12D-internal-review-evidence.md +++ b/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/reviews/WS-AUTH-001-12D-internal-review-evidence.md @@ -155,3 +155,12 @@ QA caught an accidental shared-fixture autostart change made while constructing the local setup-run proof. It was reverted immediately; autostart is enabled only through the direct test's service-module settings seam. QA then passed, CI integrity passed with hosted-verification risk only, and all sessions closed. + +Fresh CodeRabbit review found three maintainability issues in the new tests. +Replay capture is now instance-local, insert victory is an explicit fake input +rather than inferred from the expected result, and the broad service edge test +is split into focused replay, cache, reservation, authority, and PREP-denial +tests. The corrected selection passes and preserves 91.33 percent service +coverage. +QA passed the corrected test structure, and CI integrity passed with only the +expected exact-head hosted-verification risk. All reviewer sessions closed. diff --git a/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/reviews/WS-AUTH-001-12D-pr-trust-bundle.md b/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/reviews/WS-AUTH-001-12D-pr-trust-bundle.md index 65109777..a91d33b6 100644 --- a/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/reviews/WS-AUTH-001-12D-pr-trust-bundle.md +++ b/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/reviews/WS-AUTH-001-12D-pr-trust-bundle.md @@ -54,6 +54,8 @@ outside this chunk. - Direct service tests cover all three authorized mutation orchestrations and fail-closed replay/PREP edges, independently reaching 91.33 percent without changing production or the hosted threshold. +- CodeRabbit's three test-maintainability comments are resolved without losing + coverage or combining unrelated failure attribution. - Internal architecture, security, QA, product, senior, CI, docs, reuse, and test-delta tracks: passed; low risks are documented in the review evidence. - GitHub full-suite, repository 78 percent, AUTH 90 percent, and new per-file diff --git a/backend/tests/test_projects.py b/backend/tests/test_projects.py index 2d62e8ac..d32ddaa8 100644 --- a/backend/tests/test_projects.py +++ b/backend/tests/test_projects.py @@ -2792,19 +2792,20 @@ async def test_guide_source_metadata_authority_records_exact_provenance_and_repl @pytest.mark.parametrize( - ("identity_matches", "digest_matches", "status", "expected"), + ("identity_matches", "digest_matches", "status", "insert_wins", "expected"), [ - (False, True, "pending", "mismatch"), - (True, False, "pending", "mismatch"), - (True, True, "pending", "pending"), - (True, True, "committed", "replayed"), - (True, True, "pending", "claimed"), + (False, True, "pending", False, "mismatch"), + (True, False, "pending", False, "mismatch"), + (True, True, "pending", False, "pending"), + (True, True, "committed", False, "replayed"), + (True, True, "pending", True, "claimed"), ], ) async def test_guide_mutation_repository_classifies_existing_reservations( identity_matches: bool, digest_matches: bool, status: str, + insert_wins: bool, expected: str, ) -> None: record_id = uuid4() @@ -2824,12 +2825,12 @@ async def scalar(self, statement): self.scalar_calls += 1 if self.scalar_calls == 1: return record - if expected == "claimed": + if insert_wins: return statement.compile().params["id"] return record_id async def get(self, _model, selected_id): - if expected != "claimed": + if not insert_wins: assert selected_id == record_id return record @@ -3095,7 +3096,8 @@ async def add_project_setup_run(self, setup_run): self.setup_run = setup_run class Replay: - completed: list[tuple] = [] + def __init__(self): + self.completed: list[tuple] = [] async def find(self, *_args): return None @@ -3169,20 +3171,22 @@ async def consume(self, _handle, _action, _caller, resource): assert repository.items -async def test_guide_mutation_service_fails_closed_for_replay_and_authority_edges() -> None: +class _GuideMutationTestResponse: + @classmethod + def model_validate(cls, value): + return ("validated", value) + + +def _guide_mutation_edge_subject(): actor_id, link_id, project_id = (uuid4() for _ in range(3)) resolved = SimpleNamespace( profile=SimpleNamespace(id=str(actor_id)), identity_link=SimpleNamespace(id=str(link_id)), ) - class ResponseType: - @classmethod - def model_validate(cls, value): - return ("validated", value) - class Replay: - record = None + def __init__(self): + self.record = None async def find(self, *_args): return self.record @@ -3190,6 +3194,11 @@ async def find(self, *_args): replay = Replay() service = GuideMutationService(object()) service._replay = replay # type: ignore[assignment] + return resolved, project_id, replay, service + + +async def test_guide_mutation_service_classifies_existing_replay() -> None: + resolved, _project_id, replay, service = _guide_mutation_edge_subject() replay.record = SimpleNamespace( identity_link_id=str(uuid4()), request_digest="digest", @@ -3201,11 +3210,15 @@ async def find(self, *_args): match="idempotency_mismatch", ): await service._existing( - resolved, ActionId.PROJECT_GUIDE_CREATE, uuid4(), "digest", ResponseType + resolved, + ActionId.PROJECT_GUIDE_CREATE, + uuid4(), + "digest", + _GuideMutationTestResponse, ) replay.record = SimpleNamespace( - identity_link_id=str(link_id), + identity_link_id=resolved.identity_link.id, request_digest="digest", status="pending", response_json=None, @@ -3215,17 +3228,28 @@ async def find(self, *_args): match="idempotency_pending", ): await service._existing( - resolved, ActionId.PROJECT_GUIDE_CREATE, uuid4(), "digest", ResponseType + resolved, + ActionId.PROJECT_GUIDE_CREATE, + uuid4(), + "digest", + _GuideMutationTestResponse, ) replay.record.status = "committed" replay.record.response_json = {"id": "response"} existing = await service._existing( - resolved, ActionId.PROJECT_GUIDE_CREATE, uuid4(), "digest", ResponseType + resolved, + ActionId.PROJECT_GUIDE_CREATE, + uuid4(), + "digest", + _GuideMutationTestResponse, ) assert existing.response == ("validated", {"id": "response"}) assert existing.replayed is True + +async def test_guide_mutation_service_short_circuits_cached_operations() -> None: + resolved, project_id, _replay, _service = _guide_mutation_edge_subject() cached = SimpleNamespace(replayed=True) async def cached_existing(*_args): @@ -3267,21 +3291,29 @@ async def cached_existing(*_args): is cached ) + +def test_guide_mutation_service_classifies_reservation_outcomes() -> None: + _resolved, _project_id, _replay, service = _guide_mutation_edge_subject() record = SimpleNamespace(response_json={"id": "response"}) with pytest.raises( guide_mutation_router_module.GuideMutationIdempotencyConflict, match="idempotency_mismatch", ): - service._reservation_outcome("mismatch", record, ResponseType) + service._reservation_outcome("mismatch", record, _GuideMutationTestResponse) with pytest.raises( guide_mutation_router_module.GuideMutationIdempotencyConflict, match="idempotency_pending", ): - service._reservation_outcome("pending", record, ResponseType) - replayed = service._reservation_outcome("replayed", record, ResponseType) + service._reservation_outcome("pending", record, _GuideMutationTestResponse) + replayed = service._reservation_outcome( + "replayed", record, _GuideMutationTestResponse + ) assert replayed.response == ("validated", {"id": "response"}) assert replayed.replayed is True + +def test_guide_mutation_service_rejects_invalid_authority_proof() -> None: + _resolved, project_id, _replay, service = _guide_mutation_edge_subject() with pytest.raises(RuntimeError, match="lacked Project Manager authority"): service._prove( SimpleNamespace( @@ -3292,8 +3324,13 @@ async def cached_existing(*_args): project_id, ) + +async def test_guide_mutation_service_composes_unsupported_prepare_denial() -> None: + resolved, project_id, _replay, service = _guide_mutation_edge_subject() + class Prepared: - denied = None + def __init__(self): + self.denied = None async def prepare(self, *_args): raise PreparedAuthorizationUnsupported( @@ -3330,6 +3367,8 @@ async def deny_unsupported(self, *args): assert denial_resource.scope_project_id == project_id assert denial_resource.requested_guide_id is None assert denial_resource.requested_target_kind == "guide_create" + + async def test_guide_source_metadata_authority_rejects_removed_fields_and_bad_replay( project_client: AsyncClient, ) -> None: