From 473e793f69df5cf0deab8c7b47f0f53860511364 Mon Sep 17 00:00:00 2001 From: Abiorh001 Date: Thu, 30 Jul 2026 19:34:21 +0100 Subject: [PATCH 01/12] feat(auth): activate project creation authority --- .../chunks/WS-AUTH-001-12C-project-create.md | 137 ++++++- .github/workflows/backend.yml | 20 + .../versions/0044_project_create_authority.py | 324 +++++++++++++++++ backend/app/api/deps/authorization.py | 6 + backend/app/modules/audit/schemas.py | 18 +- .../app/modules/authorization/catalogue.py | 3 +- backend/app/modules/authorization/kernel.py | 108 +++++- backend/app/modules/authorization/prepared.py | 65 ++++ backend/app/modules/authorization/runtime.py | 1 + backend/app/modules/projects/models.py | 70 ++++ backend/app/modules/projects/repository.py | 78 +++- backend/app/modules/projects/router.py | 90 ++++- backend/app/modules/projects/service.py | 133 ++++++- backend/scripts/api_contract_e2e.py | 32 +- backend/tests/conftest.py | 4 +- backend/tests/project_create_fixtures.py | 194 ++++++++++ backend/tests/test_alembic.py | 172 ++++++++- backend/tests/test_api_controls.py | 1 + backend/tests/test_artifact_admission.py | 42 ++- backend/tests/test_artifact_recovery.py | 8 +- backend/tests/test_audit.py | 26 +- backend/tests/test_auth.py | 26 +- backend/tests/test_authorization.py | 233 ++++++++++-- backend/tests/test_guide_bindings.py | 25 +- backend/tests/test_projects.py | 342 +++++++++++++++++- docs/operations_authorization_service.md | 21 ++ docs/spec_authorization_service.md | 37 +- 27 files changed, 2067 insertions(+), 149 deletions(-) create mode 100644 backend/alembic/versions/0044_project_create_authority.py create mode 100644 backend/tests/project_create_fixtures.py diff --git a/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/chunks/WS-AUTH-001-12C-project-create.md b/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/chunks/WS-AUTH-001-12C-project-create.md index 501eefae7..2d2a94a78 100644 --- a/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/chunks/WS-AUTH-001-12C-project-create.md +++ b/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/chunks/WS-AUTH-001-12C-project-create.md @@ -2,7 +2,9 @@ ## Status and prerequisite -Proposed and inactive after 12B. +Ready for pre-implementation review from merged main `57c67116`, after 12B. +The exact migration is `0044_project_create_authority` after merged migration +`0043_project_setup_service`. ## Parent initiative @@ -34,14 +36,25 @@ 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/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/_project_create_authority.py +backend/alembic/versions/0044_project_create_authority.py +.github/workflows/backend.yml backend/tests/test_authorization.py +backend/tests/test_audit.py backend/tests/test_projects.py +backend/tests/project_create_fixtures.py +backend/tests/test_artifact_admission.py +backend/tests/test_artifact_recovery.py +backend/tests/test_guide_bindings.py +backend/tests/test_auth.py backend/tests/test_alembic.py +backend/tests/test_api_controls.py +backend/tests/conftest.py backend/scripts/api_contract_e2e.py docs/spec_authorization_service.md docs/operations_authorization_service.md @@ -58,10 +71,22 @@ project-scoped create authority, or token-role fallback. - Only an active human with an active identity link and effective system-scoped Project Manager `project.create` grant may create a project. - Project-scoped Project Manager, all contributor grants, and every other admin - role deny. + role deny. Every service actor, including `workstream.project.setup`, denies. - PREP binds actor/link, system grant, request digest, idempotency key, root - transaction, and server-created project identity; evidence and project commit - atomically once. + transaction, a distinct server-created operation identity, and server-created + project identity; evidence, idempotency result, and project commit atomically + once. +- The route generates or recovers the distinct stable operation and project + UUIDs before PREP. + Its canonical request value binds the exact action, route identity, validated + body, actor profile, identity link, UUID idempotency key, operation UUID, + project UUID, and operation generation before `prepare`; final consumption + must reproduce those exact facts. +- `project.create` receives one narrow project-mutation branch in PREP. It must + not be added to `_ADMIN_MUTATIONS`, the authority-mutation service, or any + generic mutation branch. That branch locks and revalidates the active human, + active link, and exact effective system-scoped Project Manager grant through + final consumption; every other planned project mutation remains unavailable. - Replay, copied/wrong handle, revoked authority, wrong session/transaction, and duplicate idempotency produce at most one project. - The route declares exactly one active ActionId and contains no legacy coarse @@ -69,13 +94,111 @@ project-scoped create authority, or token-role fallback. - New project rows record local actor profile, identity link, matched grant, system scope, action, and decision-event reference; historical rows remain readable and are not rewritten. +- Project-owned idempotency records have a unique actor/action/key namespace, + bind the canonical request digest and both server-owned identities, and return + the original response only for an exact committed replay. A key reused with + different request facts denies without creating a second project. This does + not widen or modify `AuthorityIdempotencyRecord`, `AuthorityMutationService`, + or the closed authority-operation types; persistence stays in the projects + module and migration. +- The project persistence surface is `ProjectCreateIdempotencyRecord` in the + projects model/repository. It stores actor profile, identity link, action, + UUID key, canonical client-request digest, server operation UUID, server + project UUID, generation, and `pending|committed` state. Reservation inserts + or locks this row first; an exact existing row supplies the stable server IDs + used by PREP, mismatch conflicts, and only a committed row may replay the + original Project response. Completion changes `pending` to `committed` in the + same root transaction as project/evidence insertion; rollback removes the + reservation and no pending record may survive a failed request. +- New `Project` provenance columns are nullable only for historical rows: + `created_by_actor_profile_id`, `created_via_identity_link_id`, + `created_by_admin_role_grant_id`, `creation_scope_type`, + `creation_action_id`, and `authorization_decision_event_id`. Foreign keys and + a complete-or-empty check require new authorized rows to carry the exact + actor/link/grant, literal `system` scope, literal `project.create` action, and + allowed decision event. These internal fields are not added to public project + responses. +- Missing or malformed `Idempotency-Key` denies before mutation. Exact committed + replay returns the original 201 response; mismatched reuse returns the + canonical `idempotency_mismatch` conflict. Concurrent exact replay creates one + project, while concurrent mismatched reuse, copied keys across actors/links, + different keys racing on one slug, and failures after reservation leave no + duplicate project, false allowed evidence, or stuck pending record. +- Exact committed replay is response recovery, not a second authorization or + mutation. It validates the actor/action/key/request-digest namespace and the + database-enforced committed custody chain, then returns the original response + without PREP, new allowed evidence, or dependence on authority revoked after + the original commit. Revocation still denies every new key or changed request. +- Allowed decision evidence includes the canonical project-create resource + digest and the operation/project identifiers. Database tests tie that event, + the project provenance, and the idempotency row to the same facts. +- Migration `0044` preserves historical project rows through nullable + provenance, enforces actor/link/grant/action/system-scope/decision references + for newly authorized rows, enforces the replay namespace and digest/identity + shape, and refuses downgrade once 12C provenance or replay data exists. +- OpenAPI declares exactly `project.create` for `POST /projects`; static/API + tests prove the route and service contain no token-role or legacy coarse-role + helper fallback. +- A successful call creates only the draft project shell and its authorization + provenance/replay evidence. It creates no guide, source, setup run, task, + submission, checker, review, revision, contribution, compensation award or + fulfillment, reputation signal, policy, or activation state; denial/replay + tests assert those downstream tables remain untouched. +- The ordered mutation is: validate body/header and resolve the local actor; + reserve or lock project-owned idempotency and obtain stable operation/project + IDs; prepare the exact system authority; consume it against final server + facts; insert project with provenance; mark replay state committed; commit + once in the route-owned root transaction. `ProjectService.create_project` + must neither authorize from token roles nor commit independently. - Every changed authorization/project module remains at least 90 percent covered. Final pushed head SHA passes `Backend / test` and `Agent Gates`. ## Verification commands -Before start, freeze exact isolated-runner, migration round-trip, coverage, -Ruff, API drill, stale-doc, link, and diff commands. +```bash +cd backend +install -d -m 700 .ci +: "${WORKSTREAM_TEST_ADMIN_DATABASE_URL:?set a local Postgres admin URL}" +.venv/bin/python -m ruff check \ + app/modules/projects/models.py app/modules/projects/repository.py \ + app/modules/projects/router.py app/modules/projects/schemas.py \ + app/modules/projects/service.py app/modules/authorization/catalogue.py \ + app/modules/authorization/kernel.py app/modules/authorization/prepared.py \ + app/modules/authorization/runtime.py app/api/deps/authorization.py \ + alembic/versions/0044_project_create_authority.py \ + tests/test_authorization.py tests/test_projects.py tests/test_alembic.py \ + tests/test_api_controls.py tests/conftest.py scripts/api_contract_e2e.py +PYTEST_DISABLE_PLUGIN_AUTOLOAD=1 .venv/bin/python scripts/run_isolated_tests.py \ + --metadata-json .ci/auth12c.json --lane auth12c --timeout-seconds 1200 -- \ + .venv/bin/python -m pytest -p pytest_asyncio.plugin -p pytest_cov.plugin -q \ + tests/test_authorization.py tests/test_projects.py tests/test_alembic.py \ + tests/test_api_controls.py -k 'project_create or 0044_project_create' +PYTEST_DISABLE_PLUGIN_AUTOLOAD=1 .venv/bin/python scripts/run_isolated_tests.py \ + --metadata-json .ci/auth12c-coverage.json --lane auth12c_coverage \ + --timeout-seconds 1200 -- sh -c \ + '.venv/bin/coverage erase && .venv/bin/coverage run \ + --source=app.modules.projects,app.modules.authorization \ + -m pytest -p pytest_asyncio.plugin -q tests/test_authorization.py \ + tests/test_projects.py -k "project_create" && .venv/bin/coverage report \ + --include="*/app/modules/projects/*,*/app/modules/authorization/*" \ + --show-missing --fail-under=90' +PYTEST_DISABLE_PLUGIN_AUTOLOAD=1 .venv/bin/python scripts/run_isolated_tests.py \ + --metadata-json .ci/auth12c-api.json --lane auth12c_api \ + --timeout-seconds 1500 -- .venv/bin/python scripts/api_contract_e2e.py +cd .. +python3 scripts/check_stale_authorization_docs.py +python3 scripts/check_stale_workstream_wording.py +python3 scripts/check_markdown_links.py +git diff --check +``` + +Final pushed head SHA must pass `Backend / test` and `Agent Gates`; hosted +Backend owns fresh full-suite coverage and isolated PostgreSQL migration proof. +The trust bundle must also show each changed backend module at or above 90 +percent; aggregate package coverage cannot conceal a changed file below the +threshold. Hosted Backend adds a per-file 90-percent gate for the changed +project-create modules using the combined full-suite coverage artifact; the +Ruff, test, or coverage gate may not be weakened. ## Required reviewers diff --git a/.github/workflows/backend.yml b/.github/workflows/backend.yml index c5faf9a0f..45ee8f9f0 100644 --- a/.github/workflows/backend.yml +++ b/.github/workflows/backend.yml @@ -221,6 +221,26 @@ jobs: working-directory: backend run: coverage report --include='app/modules/authorization/*' --precision=2 --fail-under=90 + - name: Project creation cutover per-file coverage + working-directory: backend + shell: bash + run: | + set -euo pipefail + for source in \ + app/api/deps/authorization.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/repository.py \ + app/modules/projects/router.py \ + app/modules/projects/service.py + do + coverage report --include="${source}" --precision=2 --fail-under=90 + done + - name: Project authorization-read composer coverage working-directory: backend run: | diff --git a/backend/alembic/versions/0044_project_create_authority.py b/backend/alembic/versions/0044_project_create_authority.py new file mode 100644 index 000000000..afecf77d3 --- /dev/null +++ b/backend/alembic/versions/0044_project_create_authority.py @@ -0,0 +1,324 @@ +"""activate transaction-bound project creation authority + +Revision ID: 0044_project_create_authority +Revises: 0043_project_setup_service +Create Date: 2026-07-30 +""" + +from __future__ import annotations + +from alembic import op +import sqlalchemy as sa + + +revision = "0044_project_create_authority" +down_revision = "0043_project_setup_service" +branch_labels = depends_on = None + +_RESOURCE_MARKER = "('audit_event'::character varying)::text" +_RESOURCE_ADDITION = ", ('project_create_operation'::character varying)::text" +_TARGET_MARKER = "('project_role_grant'::character varying)::text" +_TARGET_ADDITION = ", ('project'::character varying)::text" + + +def _rewrite_audit_privacy(*, add: bool) -> None: + bind = op.get_bind() + definition = bind.execute( + sa.text( + "select pg_get_constraintdef(oid) from pg_constraint " + "where conrelid='audit_events'::regclass " + "and conname='ck_audit_events_authority_privacy_bounds'" + ) + ).scalar_one() + resource_new = _RESOURCE_MARKER + _RESOURCE_ADDITION + resource_source, resource_target = ( + (_RESOURCE_MARKER, resource_new) if add else (resource_new, _RESOURCE_MARKER) + ) + if definition.count(resource_source) != 1 or (add and resource_new in definition): + raise RuntimeError("unexpected authority privacy constraint") + definition = definition.replace(resource_source, resource_target, 1) + + target_new = _TARGET_MARKER + _TARGET_ADDITION + target_source, target_target = ( + (_TARGET_MARKER, target_new) if add else (target_new, _TARGET_MARKER) + ) + anchor = "((target_ref_kind)::text = ANY (ARRAY[" + anchor_index = definition.find(anchor) + source_index = definition.find(target_source, anchor_index) + invalidation_index = definition.find("invalidation_target_kind", anchor_index) + if ( + anchor_index < 0 + or source_index < 0 + or invalidation_index < 0 + or source_index > invalidation_index + or (add and target_new in definition[anchor_index:invalidation_index]) + ): + raise RuntimeError("unexpected authority privacy constraint") + definition = ( + definition[:source_index] + + target_target + + definition[source_index + len(target_source) :] + ) + op.drop_constraint("authority_privacy_bounds", "audit_events", type_="check") + op.execute( + "alter table audit_events add constraint " + f"ck_audit_events_authority_privacy_bounds {definition}" + ) + + +def upgrade() -> None: + """Add nullable historical provenance and project-owned replay state.""" + op.execute("lock table audit_events in access exclusive mode") + _rewrite_audit_privacy(add=True) + op.add_column("projects", sa.Column("created_by_actor_profile_id", sa.String(36))) + op.add_column("projects", sa.Column("created_via_identity_link_id", sa.String(36))) + op.add_column( + "projects", sa.Column("created_by_admin_role_grant_id", sa.Uuid()) + ) + op.add_column("projects", sa.Column("creation_scope_type", sa.String(16))) + op.add_column("projects", sa.Column("creation_action_id", sa.String(160))) + op.add_column( + "projects", sa.Column("authorization_decision_event_id", sa.String(36)) + ) + op.create_foreign_key( + "fk_projects_creation_actor", + "projects", + "actor_profiles", + ["created_by_actor_profile_id"], + ["id"], + ) + op.create_foreign_key( + "fk_projects_creation_identity_link", + "projects", + "actor_identity_links", + ["created_via_identity_link_id"], + ["id"], + ) + op.create_foreign_key( + "fk_projects_creation_admin_grant", + "projects", + "admin_role_grants", + ["created_by_admin_role_grant_id"], + ["id"], + ) + op.create_foreign_key( + "fk_projects_creation_decision", + "projects", + "audit_events", + ["authorization_decision_event_id"], + ["id"], + ) + op.create_check_constraint( + "ck_projects_creation_authority_shape", + "projects", + "(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_action_id is null and authorization_decision_event_id is null) or " + "(created_by_actor_profile_id is not null and created_via_identity_link_id is not null " + "and created_by_admin_role_grant_id is not null and creation_scope_type = 'system' " + "and creation_action_id = 'project.create' and authorization_decision_event_id is not null)", + ) + + op.create_table( + "project_create_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("operation_id", sa.Uuid(), nullable=False), + sa.Column("project_id", sa.String(36), nullable=False), + sa.Column("operation_generation", sa.Integer(), nullable=False), + sa.Column("status", sa.String(16), nullable=False), + 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_project_create_replay_namespace" + ), + sa.UniqueConstraint("operation_id", name="uq_project_create_operation_identity"), + sa.UniqueConstraint("project_id", name="uq_project_create_project_identity"), + sa.CheckConstraint("action_id = 'project.create'", name="ck_project_create_action"), + sa.CheckConstraint( + "request_digest ~ '^sha256:[0-9a-f]{64}$'", + name="ck_project_create_request_digest", + ), + sa.CheckConstraint("operation_generation = 1", name="ck_project_create_generation"), + sa.CheckConstraint( + "status in ('pending','committed')", name="ck_project_create_status" + ), + sa.CheckConstraint( + "(status = 'pending' and committed_at is null) or " + "(status = 'committed' and committed_at is not null)", + name="ck_project_create_state_shape", + ), + ) + op.execute( + """ + create function guard_project_create_idempotency() returns trigger + language plpgsql as $$ + begin + if tg_op = 'INSERT' then + if new.status <> 'pending' or new.committed_at is not null then + raise exception 'project create reservation must begin pending' using errcode='23514'; + end if; + return new; + elsif tg_op = 'DELETE' then + raise exception 'project create reservations are immutable' using errcode='55000'; + 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.operation_id, + new.project_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.operation_id, + old.project_id, old.operation_generation, old.created_at) then + raise exception 'invalid project create reservation transition' using errcode='23514'; + end if; + return new; + end $$ + """ + ) + op.execute( + "create trigger project_create_idempotency_guard before insert or update or delete " + "on project_create_idempotency_records for each row " + "execute function guard_project_create_idempotency()" + ) + op.execute( + """ + create function reject_project_create_idempotency_truncate() returns trigger + language plpgsql as $$ begin + raise exception 'project create reservations are immutable' using errcode='55000'; + end $$ + """ + ) + op.execute( + "create trigger project_create_idempotency_reject_truncate before truncate " + "on project_create_idempotency_records execute function " + "reject_project_create_idempotency_truncate()" + ) + op.execute( + """ + create function validate_project_create_custody() returns trigger + language plpgsql as $$ + declare project_row projects%rowtype; reservation project_create_idempotency_records%rowtype; + evidence audit_events%rowtype; + begin + if tg_table_name = 'projects' then + if tg_op = 'INSERT' and new.creation_action_id is null then + raise exception 'new projects require creation authority' using errcode='23514'; + end if; + if new.creation_action_id is null then return null; end if; + project_row := new; + select * into reservation from project_create_idempotency_records + where project_id=project_row.id and status='committed'; + else + select * into reservation from project_create_idempotency_records + where id=new.id; + if reservation.status <> 'committed' then + raise exception 'pending project create reservation cannot commit' using errcode='23514'; + end if; + select * into project_row from projects where id=reservation.project_id; + end if; + if project_row.id is null or reservation.id is null + or project_row.created_by_actor_profile_id <> reservation.actor_profile_id + or project_row.created_via_identity_link_id <> reservation.identity_link_id + or project_row.creation_action_id <> reservation.action_id then + raise exception 'project create custody mismatch' using errcode='23514'; + end if; + select * into evidence from audit_events + where id=project_row.authorization_decision_event_id; + if evidence.id is null or evidence.event_domain <> 'authority' + or evidence.event_type <> 'SensitiveAuthorizationAllowed' + or evidence.denial_code is not null + or evidence.actor_ref_kind <> 'actor_profile' + or evidence.actor_id <> project_row.created_by_actor_profile_id + or evidence.matched_grant_id <> project_row.created_by_admin_role_grant_id::text + or evidence.permission_id <> 'project.create' + or evidence.action_id <> 'project.create' + or evidence.resource_type <> 'project_create_operation' + or evidence.resource_id <> reservation.operation_id::text + or evidence.target_ref_kind <> 'project' + or evidence.target_ref_id <> project_row.id + or evidence.after_facts->>'allowed' <> 'true' then + raise exception 'project create evidence mismatch' using errcode='23514'; + end if; + return null; + end $$ + """ + ) + op.execute( + "create constraint trigger project_creation_custody after insert or update " + "of created_by_actor_profile_id, created_via_identity_link_id, " + "created_by_admin_role_grant_id, creation_scope_type, creation_action_id, " + "authorization_decision_event_id on projects deferrable initially deferred " + "for each row execute function validate_project_create_custody()" + ) + op.execute( + "create constraint trigger project_create_reservation_custody after insert or update " + "on project_create_idempotency_records deferrable initially deferred for each row " + "execute function validate_project_create_custody()" + ) + + +def downgrade() -> None: + """Remove the seam only before any project-create authority is used.""" + bind = op.get_bind() + bind.execute(sa.text("lock table audit_events in access exclusive mode")) + bind.execute(sa.text("lock table projects in share row exclusive mode")) + bind.execute( + sa.text("lock table project_create_idempotency_records in share row exclusive mode") + ) + used = bind.execute( + sa.text( + "select exists(select 1 from projects where creation_action_id is not null) " + "or exists(select 1 from project_create_idempotency_records)" + ) + ).scalar_one() + if used: + raise RuntimeError("cannot downgrade non-empty project creation authority") + op.execute("drop trigger project_creation_custody on projects") + op.execute( + "drop trigger project_create_reservation_custody on " + "project_create_idempotency_records" + ) + op.execute( + "drop trigger project_create_idempotency_reject_truncate on " + "project_create_idempotency_records" + ) + op.execute( + "drop trigger project_create_idempotency_guard on " + "project_create_idempotency_records" + ) + op.execute("drop function validate_project_create_custody()") + op.execute("drop function reject_project_create_idempotency_truncate()") + op.execute("drop function guard_project_create_idempotency()") + op.drop_table("project_create_idempotency_records") + op.drop_constraint("ck_projects_creation_authority_shape", "projects", type_="check") + op.drop_constraint("fk_projects_creation_decision", "projects", type_="foreignkey") + op.drop_constraint("fk_projects_creation_admin_grant", "projects", type_="foreignkey") + op.drop_constraint("fk_projects_creation_identity_link", "projects", type_="foreignkey") + op.drop_constraint("fk_projects_creation_actor", "projects", type_="foreignkey") + op.drop_column("projects", "authorization_decision_event_id") + op.drop_column("projects", "creation_action_id") + op.drop_column("projects", "creation_scope_type") + op.drop_column("projects", "created_by_admin_role_grant_id") + op.drop_column("projects", "created_via_identity_link_id") + op.drop_column("projects", "created_by_actor_profile_id") + _rewrite_audit_privacy(add=False) diff --git a/backend/app/api/deps/authorization.py b/backend/app/api/deps/authorization.py index d7d56ed5a..406d846c9 100644 --- a/backend/app/api/deps/authorization.py +++ b/backend/app/api/deps/authorization.py @@ -296,6 +296,12 @@ async def get_prepared_authorization_service( yield service except AuthorizationDenied as exc: await session.rollback() + try: + await authorization._restage_denial(exc.decision) + await session.commit() + except (AuthorizationEvidenceUnavailable, SQLAlchemyError) as persistence_error: + await session.rollback() + raise actor_registry_unavailable_error() from persistence_error raise authorization_http_error(exc) from exc except AuthorizationEvidenceUnavailable as exc: await session.rollback() diff --git a/backend/app/modules/audit/schemas.py b/backend/app/modules/audit/schemas.py index 597e7786f..e1aed5a72 100644 --- a/backend/app/modules/audit/schemas.py +++ b/backend/app/modules/audit/schemas.py @@ -35,11 +35,18 @@ _RESOURCE_TYPES = frozenset( """actor_profile actor_identity_link admin_role_grant project qualification_snapshot project_role_grant task submission review contribution compensation_award compensation_delivery operations - audit_event""".split() + audit_event project_create_operation""".split() ) _UUID_TARGET_KINDS = frozenset( - {"actor_profile", "actor_identity_link", "admin_role_grant", "qualification_snapshot", "project_role_grant"} + { + "actor_profile", + "actor_identity_link", + "admin_role_grant", + "qualification_snapshot", + "project_role_grant", + } ) +_TARGET_REF_KINDS = _UUID_TARGET_KINDS | {"project"} _DENIAL_CODES = frozenset( """required_scope_missing unsupported_subject_kind service_actor_not_provisioned identity_link_revoked actor_suspended actor_deactivated permission_not_granted @@ -345,7 +352,7 @@ def _inspect_privacy_safe_input(cls, value: object) -> dict | None: or data.get("denial_code") is not None and not _registered(data["denial_code"], _DENIAL_CODES) or data.get("resource_type") is not None and not _registered(data["resource_type"], _RESOURCE_TYPES) or data.get("target_ref_kind") is not None - and not _registered(data["target_ref_kind"], _UUID_TARGET_KINDS | {"permission_registry"}) + and not _registered(data["target_ref_kind"], _TARGET_REF_KINDS | {"permission_registry"}) or data.get("invalidation_target_kind") is not None and not _registered(data["invalidation_target_kind"], _UUID_TARGET_KINDS | {"permission_registry"}) or event is not None and not _registered(data.get("reason"), _REASONS[event]) @@ -356,7 +363,10 @@ def _inspect_privacy_safe_input(cls, value: object) -> dict | None: ref_kind = data.get(f"{prefix}_kind") ref = data.get(f"{prefix}_ref" if prefix == "invalidation_target" else f"{prefix}_id") invalid |= (ref_kind is None) != (ref is None) - invalid |= _registered(ref_kind, _UUID_TARGET_KINDS) and ref is not None and _uuid(ref) is None + valid_uuid_kinds = ( + _UUID_TARGET_KINDS if prefix == "invalidation_target" else _TARGET_REF_KINDS + ) + invalid |= _registered(ref_kind, valid_uuid_kinds) and ref is not None and _uuid(ref) is None invalid |= ref_kind == "permission_registry" and not _registered(ref, PERMISSION_IDS) target_kind, target_ref = data.get("target_actor_ref_kind"), data.get("target_actor_ref") invalid |= (target_kind is None) != (target_ref is None) diff --git a/backend/app/modules/authorization/catalogue.py b/backend/app/modules/authorization/catalogue.py index ddf37dcae..8f81d20f1 100644 --- a/backend/app/modules/authorization/catalogue.py +++ b/backend/app/modules/authorization/catalogue.py @@ -434,7 +434,7 @@ def _active( PermissionId.PROJECT_READ, ActionOwner.AUTH_11C2, ), - _planned(ActionId.PROJECT_CREATE, PermissionId.PROJECT_CREATE, ActionOwner.AUTH_12C), + _active(ActionId.PROJECT_CREATE, PermissionId.PROJECT_CREATE, ActionOwner.AUTH_12C), _planned( ActionId.PROJECT_GUIDE_CREATE, PermissionId.PROJECT_GUIDE_MANAGE, @@ -796,6 +796,7 @@ def _index_actions( ActionId.PROJECT_ROLE_GRANT_READ, ActionId.PROJECT_ROLE_GRANT_ISSUE, ActionId.PROJECT_ROLE_GRANT_REVOKE, + ActionId.PROJECT_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 23b5b045b..16cf64e65 100644 --- a/backend/app/modules/authorization/kernel.py +++ b/backend/app/modules/authorization/kernel.py @@ -4,6 +4,7 @@ from collections.abc import Awaitable, Callable from types import MappingProxyType +from typing import NoReturn from uuid import UUID, uuid4 from sqlalchemy.exc import SQLAlchemyError @@ -60,6 +61,7 @@ MatchedAuthorityKind, PermissionCatalogueResourceContext, ProjectContributorCandidateCollectionResourceContext, + ProjectCreateResourceContext, ProjectReadResourceContext, ProjectDiagnosticReadResourceContext, ProjectPolicyReadResourceContext, @@ -301,6 +303,7 @@ def __init__( self._revalidate_actor_self = revalidate_actor_self self._revalidate_service = revalidate_service self._pending_denial: AuthorizationDecision | None = None + self._pending_denial_resource_context: AuthorizationResourceContext | None = None self._sealed_prelocked: set[_PrelockedAuthority] = set() self._prepared_consumers: dict[object, object] = {} @@ -452,6 +455,30 @@ async def _prepare_prelocked( 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 + ) + 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, + PermissionId.PROJECT_CREATE, + scope_project_id=None, + system_scope_only=True, + for_update=True, + ) + if grant is None: + raise PreparedAuthorizationUnsupported( + AuthorizationDenialCode.PERMISSION_NOT_GRANTED + ) elif action_id is ActionId.ARTIFACT_GUIDE_SOURCE_INGEST: if ( not isinstance(context, HumanAuthorizationContext) @@ -543,6 +570,32 @@ def _discard_prelocked(self, authority: _PrelockedAuthority) -> None: """Release one unconsumed sealed authority during handle invalidation.""" self._sealed_prelocked.discard(authority) + async def _complete_prepared_denial( + self, + consumer_token: object, + action_id: ActionId, + resource_context: AuthorizationResourceContext, + denial: AuthorizationDenialCode, + ) -> NoReturn: + """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 + ): + raise TypeError("unsupported prepared denial") + await self._complete_decision( + action=action, + denial=denial, + resource_context=resource_context, + context=self._context, + matched_kind=None, + matched_grant_id=None, + matched_project_id=None, + revalidated=False, + ) + raise RuntimeError("denied prepared authorization unexpectedly returned") + async def require( self, action_id: ActionId, @@ -550,6 +603,7 @@ async def require( ) -> AuthorizationDecision: """Return an allowed decision or raise one bounded, evidenced denial.""" self._pending_denial = None + self._pending_denial_resource_context = None action = ACTION_BY_ID.get(action_id) if isinstance(action_id, ActionId) else None context = self._context revalidated = False @@ -795,6 +849,7 @@ async def _require_prelocked( raise TypeError("invalid prelocked authority") self._sealed_prelocked.remove(authority) self._pending_denial = None + self._pending_denial_resource_context = None action = ACTION_BY_ID.get(action_id) if isinstance(action_id, ActionId) else None context = authority.context denial: AuthorizationDenialCode | None @@ -847,6 +902,24 @@ 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: + 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" + ): + denial = AuthorizationDenialCode.PERMISSION_NOT_GRANTED + if denial is None: + matched_kind = MatchedAuthorityKind.ADMIN_ROLE_GRANT + matched_grant_id = authority.matched_grant_id elif action_id is ActionId.ARTIFACT_GUIDE_SOURCE_INGEST: denial = self._lifecycle_denial(context) if denial is None and action.availability is not ActionAvailability.ACTIVE: @@ -907,9 +980,10 @@ async def _complete_decision( request_id=context.request_id, correlation_id=context.correlation_id, ) - await self._stage_decision(decision, context.actor_profile_id) + await self._stage_decision(decision, context.actor_profile_id, resource_context) if not decision.allowed: self._pending_denial = decision + self._pending_denial_resource_context = resource_context raise AuthorizationDenied(decision) return decision @@ -1171,8 +1245,14 @@ async def restage_denial(self, decision: AuthorizationDecision) -> None: or decision.correlation_id != self._context.correlation_id ): raise TypeError("invalid authorization denial evidence") - await self._stage_decision(decision, self._context.actor_profile_id) + 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 + ) self._pending_denial = None + self._pending_denial_resource_context = None async def _restage_denial(self, decision: AuthorizationDecision) -> None: """Retain the existing AUTH-internal dependency seam.""" @@ -1230,6 +1310,7 @@ async def _stage_decision( self, decision: AuthorizationDecision, actor_profile_id, + resource_context: AuthorizationResourceContext | None = None, ) -> None: """Write one privacy-bounded event without taking transaction ownership.""" if decision.action_id is None or decision.permission_id is None: @@ -1249,10 +1330,18 @@ async def _stage_decision( "actor_admin_role_grant_history", } audit_resource_type = None + audit_resource_id = None + target_ref_kind = None + target_ref_id = None if target_is_actor: audit_resource_type = "actor_profile" elif decision.resource_type in {"actor_identity_link", "admin_role_grant"}: audit_resource_type = decision.resource_type + if isinstance(resource_context, ProjectCreateResourceContext): + audit_resource_type = "project_create_operation" + audit_resource_id = str(resource_context.resource_id) + target_ref_kind = "project" + target_ref_id = str(resource_context.requested_project_id) after_facts: dict[str, object] = {"allowed": decision.allowed} if ( decision.resource_type @@ -1264,7 +1353,8 @@ async def _stage_decision( "project_policy_read", "project_active_guide_read", } - or decision.action_id is ActionId.ARTIFACT_GUIDE_SOURCE_INGEST + or decision.action_id + in {ActionId.ARTIFACT_GUIDE_SOURCE_INGEST, ActionId.PROJECT_CREATE} ): after_facts["resource_context_digest"] = decision.resource_context_digest try: @@ -1299,9 +1389,15 @@ async def _stage_decision( else None ), resource_type=audit_resource_type, - resource_id=(str(decision.resource_id) if audit_resource_type else None), - target_ref_kind=audit_resource_type, - target_ref_id=(str(decision.resource_id) if audit_resource_type else None), + resource_id=( + audit_resource_id + or (str(decision.resource_id) if audit_resource_type else None) + ), + target_ref_kind=target_ref_kind or audit_resource_type, + target_ref_id=( + target_ref_id + or (str(decision.resource_id) if audit_resource_type else None) + ), reason="authorization_evaluation", denial_code=stored_denial, after_facts=after_facts, diff --git a/backend/app/modules/authorization/prepared.py b/backend/app/modules/authorization/prepared.py index 97b1b3c7e..9e296a663 100644 --- a/backend/app/modules/authorization/prepared.py +++ b/backend/app/modules/authorization/prepared.py @@ -29,6 +29,7 @@ AuthorizationResourceContext, PreparedAuthorizationHandleInvalid, PreparedAuthorizationInput, + PreparedAuthorizationUnsupported, PreparedAuthorityScope, PreparedAuthorityScopeKind, PROJECT_MUTATION_RESOURCE_BY_ACTION, @@ -70,6 +71,9 @@ class _PreparedAuthorizationBinding: scope: PreparedAuthorityScope idempotency_key: UUID request_digest: str + project_create_operation_id: UUID | None = None + project_create_project_id: UUID | None = None + project_create_generation: int | None = None @dataclass(slots=True) @@ -155,6 +159,14 @@ async def consume( final_scope = self._scope_from_resource(expected_action_id, final_resource_context) if final_scope != issuance.binding.scope: raise PreparedAuthorizationHandleInvalid("invalid prepared authorization handle") + if isinstance(final_resource_context, ProjectCreateResourceContext) and ( + issuance.binding.project_create_operation_id != final_resource_context.resource_id + or issuance.binding.project_create_project_id + != final_resource_context.requested_project_id + or issuance.binding.project_create_generation + != final_resource_context.operation_generation + ): + raise PreparedAuthorizationHandleInvalid("invalid prepared authorization handle") self._issued[handle] = _CONSUMED return await self._authorization._require_prelocked( self._consumer_token, @@ -163,6 +175,37 @@ async def consume( issuance.authority, ) + async def deny_unsupported( + self, + action_id: ActionId, + caller_input: PreparedAuthorizationInput, + final_resource_context: AuthorizationResourceContext, + denial: PreparedAuthorizationUnsupported, + ) -> NoReturn: + """Evidence an exact prepare-time denial without issuing a handle.""" + binding = self._binding( + action_id, + caller_input, + PreparedAuthorityScope(kind=PreparedAuthorityScopeKind.SYSTEM), + ) + if not isinstance(final_resource_context, ProjectCreateResourceContext) or ( + binding.project_create_operation_id != final_resource_context.resource_id + or binding.project_create_project_id + != final_resource_context.requested_project_id + or binding.project_create_generation + != final_resource_context.operation_generation + ): + raise PreparedAuthorizationHandleInvalid( + "invalid prepared authorization handle" + ) + await self._authorization._complete_prepared_denial( + self._consumer_token, + action_id, + final_resource_context, + denial.denial_code, + ) + raise RuntimeError("denied prepared authorization unexpectedly returned") + def close(self) -> None: """Invalidate all outstanding request-local capabilities.""" for issuance in self._issued.values(): @@ -198,6 +241,25 @@ def _binding( caller_input: PreparedAuthorizationInput, scope: PreparedAuthorityScope, ) -> _PreparedAuthorizationBinding: + operation_id = project_id = None + operation_generation = None + if action_id is ActionId.PROJECT_CREATE: + try: + operation_id = UUID(str(caller_input.request_value["operation_id"])) + project_id = UUID(str(caller_input.request_value["project_id"])) + operation_generation = caller_input.request_value["operation_generation"] + except (KeyError, TypeError, ValueError) as exc: + raise PreparedAuthorizationHandleInvalid( + "invalid prepared authorization handle" + ) from exc + if ( + type(operation_generation) is not int + or operation_generation < 1 + or operation_id == project_id + ): + raise PreparedAuthorizationHandleInvalid( + "invalid prepared authorization handle" + ) return _PreparedAuthorizationBinding( action_id=action_id, actor_ref_kind=ActorReferenceKind.ACTOR_PROFILE, @@ -210,6 +272,9 @@ def _binding( "request": caller_input.request_value, } ), + project_create_operation_id=operation_id, + project_create_project_id=project_id, + project_create_generation=operation_generation, ) @staticmethod diff --git a/backend/app/modules/authorization/runtime.py b/backend/app/modules/authorization/runtime.py index 51a35b7c2..e8a202031 100644 --- a/backend/app/modules/authorization/runtime.py +++ b/backend/app/modules/authorization/runtime.py @@ -1275,6 +1275,7 @@ class AuthorizationDecision(BaseModel): "project_diagnostic", "project_policy_read", "project_active_guide_read", + "project_create", "actor_identity_link", "system", "permission_catalogue", diff --git a/backend/app/modules/projects/models.py b/backend/app/modules/projects/models.py index 556c2b63d..6cecf6265 100644 --- a/backend/app/modules/projects/models.py +++ b/backend/app/modules/projects/models.py @@ -4,6 +4,7 @@ from datetime import datetime from decimal import Decimal +from uuid import UUID from sqlalchemy import ( BigInteger, @@ -19,6 +20,7 @@ String, Text, UniqueConstraint, + Uuid, text, ) from sqlalchemy.orm import Mapped, mapped_column, relationship @@ -31,12 +33,37 @@ class Project(Base): """Project container that owns guide versions.""" __tablename__ = "projects" + __table_args__ = ( + CheckConstraint( + "(created_by_actor_profile_id is null and created_via_identity_link_id is null " + "and created_by_admin_role_grant_id is null and creation_scope_type is null " + "and creation_action_id is null and authorization_decision_event_id is null) or " + "(created_by_actor_profile_id is not null and created_via_identity_link_id is not null " + "and created_by_admin_role_grant_id is not null and creation_scope_type = 'system' " + "and creation_action_id = 'project.create' and authorization_decision_event_id is not null)", + name="creation_authority_shape", + ), + ) id: Mapped[str] = mapped_column(String(36), primary_key=True) name: Mapped[str] = mapped_column(String(200), nullable=False) slug: Mapped[str] = mapped_column(String(120), nullable=False, unique=True, index=True) description: Mapped[str | None] = mapped_column(Text) status: Mapped[str] = mapped_column(String(30), nullable=False, default="draft", index=True) + 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_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), @@ -50,6 +77,49 @@ class Project(Base): ) +class ProjectCreateIdempotencyRecord(Base): + """Project-owned reservation and replay state for one project creation.""" + + __tablename__ = "project_create_idempotency_records" + __table_args__ = ( + UniqueConstraint( + "actor_profile_id", + "action_id", + "idempotency_key", + name="uq_project_create_replay_namespace", + ), + UniqueConstraint("operation_id", name="uq_project_create_operation_identity"), + UniqueConstraint("project_id", name="uq_project_create_project_identity"), + CheckConstraint("action_id = 'project.create'", name="ck_project_create_action"), + CheckConstraint( + "request_digest ~ '^sha256:[0-9a-f]{64}$'", + name="ck_project_create_request_digest", + ), + CheckConstraint("operation_generation = 1", name="ck_project_create_generation"), + CheckConstraint( + "status in ('pending','committed')", name="ck_project_create_status" + ), + CheckConstraint( + "(status = 'pending' and committed_at is null) or " + "(status = 'committed' and committed_at is not null)", + name="ck_project_create_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) + operation_id: Mapped[UUID] = mapped_column(Uuid(), nullable=False) + project_id: Mapped[str] = mapped_column(String(36), nullable=False) + operation_generation: Mapped[int] = mapped_column(Integer, nullable=False, default=1) + status: Mapped[str] = mapped_column(String(16), nullable=False, default="pending") + 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.""" diff --git a/backend/app/modules/projects/repository.py b/backend/app/modules/projects/repository.py index f03e2e026..eec0ee7e9 100644 --- a/backend/app/modules/projects/repository.py +++ b/backend/app/modules/projects/repository.py @@ -3,9 +3,12 @@ from __future__ import annotations from collections.abc import Sequence +from datetime import UTC, datetime from typing import Any +from uuid import UUID, uuid4 -from sqlalchemy import func, select +from sqlalchemy import func, select, update +from sqlalchemy.dialects.postgresql import insert from sqlalchemy.ext.asyncio import AsyncSession from app.modules.projects.models import ( @@ -17,6 +20,7 @@ PostSubmitCheckerPolicy, PreSubmitCheckerPolicy, Project, + ProjectCreateIdempotencyRecord, ProjectGuide, ProjectSetupRun, RevisionPolicy, @@ -73,6 +77,78 @@ async def add_project(self, project: Project) -> Project: await self._session.refresh(project) return project + async def reserve_project_create( + self, + *, + actor_profile_id: str, + identity_link_id: str, + idempotency_key: UUID, + request_digest: str, + ) -> tuple[str, ProjectCreateIdempotencyRecord]: + """Reserve or lock one actor-scoped project-create replay namespace.""" + values = { + "id": uuid4(), + "actor_profile_id": actor_profile_id, + "identity_link_id": identity_link_id, + "action_id": "project.create", + "idempotency_key": idempotency_key, + "request_digest": request_digest, + "operation_id": uuid4(), + "project_id": str(uuid4()), + "operation_generation": 1, + "status": "pending", + } + record_id = await self._session.scalar( + insert(ProjectCreateIdempotencyRecord) + .values(**values) + .on_conflict_do_nothing( + index_elements=[ + ProjectCreateIdempotencyRecord.actor_profile_id, + ProjectCreateIdempotencyRecord.action_id, + ProjectCreateIdempotencyRecord.idempotency_key, + ] + ) + .returning(ProjectCreateIdempotencyRecord.id) + ) + if record_id is not None: + record = await self._session.get(ProjectCreateIdempotencyRecord, record_id) + if record is None: + raise ProjectRepositoryIntegrityError("project reservation disappeared") + return "claimed", record + record = await self._session.scalar( + select(ProjectCreateIdempotencyRecord) + .where( + ProjectCreateIdempotencyRecord.actor_profile_id == actor_profile_id, + ProjectCreateIdempotencyRecord.action_id == "project.create", + ProjectCreateIdempotencyRecord.idempotency_key == idempotency_key, + ) + .with_for_update() + ) + if record is None: + raise ProjectRepositoryIntegrityError("project reservation conflict disappeared") + 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_project_create( + self, record: ProjectCreateIdempotencyRecord + ) -> None: + """Commit exactly one pending project-create reservation.""" + completed = await self._session.scalar( + update(ProjectCreateIdempotencyRecord) + .where( + ProjectCreateIdempotencyRecord.id == record.id, + ProjectCreateIdempotencyRecord.status == "pending", + ) + .values(status="committed", committed_at=datetime.now(UTC)) + .returning(ProjectCreateIdempotencyRecord.id) + ) + if completed is None: + raise ProjectRepositoryIntegrityError("invalid project reservation completion") + async def get_project( self, project_id: str, diff --git a/backend/app/modules/projects/router.py b/backend/app/modules/projects/router.py index b384e19a5..9948d82fa 100644 --- a/backend/app/modules/projects/router.py +++ b/backend/app/modules/projects/router.py @@ -6,15 +6,19 @@ from uuid import UUID from fastapi import APIRouter, Depends, Header, HTTPException, Request, Response, status +from sqlalchemy.exc import IntegrityError from sqlalchemy.ext.asyncio import AsyncSession from app.adapters.artifacts import get_guide_artifact_ingest_command from app.api.deps.auth import get_registered_actor from app.api.deps.authorization import ( enforce_human_authorization_read, + get_authorization_actor, get_authorization_service, + get_prepared_authorization_service, ) from app.core.permissions import PermissionDenied +from app.core.api_controls import StructuredHTTPException from app.db.session import get_db_session from app.interfaces.artifact_operations import ( GuideArtifactIngestCommand, @@ -49,7 +53,11 @@ SubmissionArtifactPolicyResponse, SubmissionArtifactPolicyUpdate, ) -from app.modules.projects.service import ProjectService, ProjectServiceError +from app.modules.projects.service import ( + ProjectCreateIdempotencyConflict, + ProjectService, + ProjectServiceError, +) from app.modules.projects.authorization_reads import ( authorize_project_active_guide_read, authorize_project_diagnostic_read, @@ -58,6 +66,8 @@ from app.modules.projects.repository import ProjectRepository from app.modules.authorization.catalogue import ActionId from app.modules.authorization.kernel import AuthorizationService +from app.modules.authorization.prepared import PreparedAuthorizationService +from app.modules.actors.service import ResolvedActor from app.modules.authorization.runtime import ( MatchedAuthorityKind, ProjectReadResourceContext, @@ -68,6 +78,32 @@ router = APIRouter(prefix="/projects", tags=["projects"]) +def require_project_create_idempotency_key( + request: Request, +) -> UUID: + """Validate replay custody before actor first-access provisioning can run.""" + try: + return UUID(request.headers["Idempotency-Key"]) + except (KeyError, 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 get_project_create_authorization( + idempotency_key: Annotated[UUID, Depends(require_project_create_idempotency_key)], + resolved: Annotated[ResolvedActor, Depends(get_authorization_actor)], + prepared: Annotated[ + PreparedAuthorizationService, Depends(get_prepared_authorization_service) + ], +) -> tuple[UUID, ResolvedActor, PreparedAuthorizationService]: + """Order idempotency validation before the mutating actor dependency graph.""" + return idempotency_key, resolved, prepared + + def project_http_error(exc: ProjectServiceError) -> HTTPException: """Convert a service-layer project error into an HTTP error. @@ -77,6 +113,19 @@ def project_http_error(exc: ProjectServiceError) -> HTTPException: Returns: HTTP exception carrying the service error details. """ + if isinstance(exc, ProjectCreateIdempotencyConflict): + code = str(exc) + return StructuredHTTPException( + status_code=exc.status_code, + detail=code, + error_code=code, + error_message=( + "Idempotency key does not match" + if code == "idempotency_mismatch" + else "Project creation is already in progress" + ), + retryable=code == "idempotency_pending", + ) return HTTPException(status_code=exc.status_code, detail=str(exc)) @@ -92,19 +141,52 @@ def permission_http_error(exc: PermissionDenied) -> HTTPException: return HTTPException(status_code=403, detail=str(exc)) -@router.post("", response_model=ProjectResponse, status_code=201) +@router.post( + "", + response_model=ProjectResponse, + status_code=201, + openapi_extra={"x-workstream-action-id": ActionId.PROJECT_CREATE.value}, +) async def create_project( payload: ProjectCreate, - actor: Annotated[ActorContext, Depends(get_registered_actor)], + authorization: Annotated[ + tuple[UUID, ResolvedActor, PreparedAuthorizationService], + Depends(get_project_create_authorization), + ], session: Annotated[AsyncSession, Depends(get_db_session)], ) -> ProjectResponse: """Create a draft project shell for future guide versions.""" + idempotency_key, resolved, prepared = authorization try: - return await ProjectService(session).create_project(actor, payload) + outcome = await ProjectService(session).create_project( + resolved, prepared, idempotency_key, payload + ) + if outcome.replayed: + await session.rollback() + else: + await session.commit() + return outcome.response except PermissionDenied as exc: raise permission_http_error(exc) from exc except ProjectServiceError as exc: raise project_http_error(exc) from exc + except IntegrityError as exc: + await session.rollback() + constraint_name = getattr( + getattr(exc.orig, "__cause__", None), "constraint_name", None + ) + if constraint_name not in { + "projects_slug_key", + "ix_projects_slug", + "uq_projects_slug", + }: + raise + raise StructuredHTTPException( + status_code=409, + detail="Project slug already exists", + error_code="project_slug_conflict", + error_message="Project slug already exists", + ) from exc @router.get( diff --git a/backend/app/modules/projects/service.py b/backend/app/modules/projects/service.py index 35692e7bf..bf5458d37 100644 --- a/backend/app/modules/projects/service.py +++ b/backend/app/modules/projects/service.py @@ -7,11 +7,12 @@ import logging import re from collections.abc import Sequence +from dataclasses import dataclass from datetime import UTC, datetime from decimal import Decimal from typing import Any from urllib.parse import unquote, urlparse -from uuid import uuid4 +from uuid import UUID, uuid4 from sqlalchemy.exc import IntegrityError from sqlalchemy.ext.asyncio import AsyncSession @@ -53,6 +54,17 @@ ReviewPolicy, SubmissionArtifactPolicy, ) +from app.modules.actors.service import ResolvedActor +from app.modules.authorization.catalogue import ActionId +from app.modules.authorization.prepared import PreparedAuthorizationService +from app.modules.authorization.runtime import ( + MatchedAuthorityKind, + PreparedAuthorizationUnsupported, + PreparedAuthorizationInput, + PreparedAuthorityScope, + PreparedAuthorityScopeKind, + ProjectCreateResourceContext, +) from app.modules.projects.post_submit_policy import ( DEFAULT_DURABLE_CHECKERS, PostSubmitCheckerCompilerError, @@ -438,6 +450,20 @@ class AgentRuntimeUnavailable(ProjectServiceError): status_code = 503 +class ProjectCreateIdempotencyConflict(ProjectServiceError): + """One project-create replay key was reused with incompatible state.""" + + status_code = 409 + + +@dataclass(frozen=True, slots=True) +class ProjectCreateOutcome: + """Route-owned transaction outcome for one project-create request.""" + + response: ProjectResponse + replayed: bool + + class ProjectService: """Coordinates project guide rules, persistence, and response shaping. @@ -473,31 +499,118 @@ def _project_agent_runtime(self) -> ProjectGuideAgentRuntime: except ProjectAgentRuntimeError: raise AgentRuntimeUnavailable("project guide agent runtime is unavailable") from None - async def create_project(self, actor: ActorContext, payload: ProjectCreate) -> ProjectResponse: - """Create a draft project record after project setup authorization. + async def create_project( + self, + resolved: ResolvedActor, + prepared: PreparedAuthorizationService, + idempotency_key: UUID, + payload: ProjectCreate, + ) -> ProjectCreateOutcome: + """Create one authorized, idempotent draft project shell. Args: - actor: Verified Flow actor context for the current request. + resolved: Canonical actor and exact verified identity link. + prepared: Request-local transaction-bound authorization service. + idempotency_key: Client replay namespace key. payload: Validated project creation fields. Returns: Created project response. Raises: - PermissionDenied: If the actor cannot manage project setup. + ProjectCreateIdempotencyConflict: If the key does not exactly replay. """ - require_any_role(actor, PROJECT_SETUP_ROLES) + actor_profile_id = resolved.profile.id + identity_link_id = resolved.identity_link.id + request_digest = canonical_json_hash( + { + "domain": "workstream.project_create.idempotency.v1", + "action_id": ActionId.PROJECT_CREATE.value, + "route": "POST /api/v1/projects", + "actor_profile_id": actor_profile_id, + "identity_link_id": identity_link_id, + "idempotency_key": str(idempotency_key), + "body": payload.model_dump(mode="json", exclude_none=True), + } + ) + disposition, reservation = await self._repo.reserve_project_create( + actor_profile_id=actor_profile_id, + identity_link_id=identity_link_id, + idempotency_key=idempotency_key, + request_digest=request_digest, + ) + if disposition == "mismatch": + raise ProjectCreateIdempotencyConflict("idempotency_mismatch") + if disposition == "pending": + raise ProjectCreateIdempotencyConflict("idempotency_pending") + if disposition == "replayed": + existing = await self._repo.get_project(reservation.project_id) + if existing is None: + raise RuntimeError("committed project replay lost its project") + return ProjectCreateOutcome( + response=ProjectResponse.model_validate(existing), replayed=True + ) + prepared_input = PreparedAuthorizationInput( + idempotency_key=idempotency_key, + request_value={ + "action_id": ActionId.PROJECT_CREATE.value, + "route": "POST /api/v1/projects", + "actor_profile_id": actor_profile_id, + "identity_link_id": identity_link_id, + "idempotency_key": str(idempotency_key), + "request_digest": request_digest, + "operation_id": str(reservation.operation_id), + "project_id": reservation.project_id, + "operation_generation": reservation.operation_generation, + "body": payload.model_dump(mode="json", exclude_none=True), + }, + ) + final_resource = ProjectCreateResourceContext( + resource_type="project_create", + resource_id=reservation.operation_id, + requested_project_id=UUID(reservation.project_id), + operation_generation=reservation.operation_generation, + ) + try: + handle = await prepared.prepare( + ActionId.PROJECT_CREATE, + prepared_input, + PreparedAuthorityScope(kind=PreparedAuthorityScopeKind.SYSTEM), + ) + except PreparedAuthorizationUnsupported as exc: + await prepared.deny_unsupported( + ActionId.PROJECT_CREATE, prepared_input, final_resource, exc + ) + decision = await prepared.consume( + handle, + ActionId.PROJECT_CREATE, + prepared_input, + final_resource, + ) + if ( + decision.matched_authority_kind is not MatchedAuthorityKind.ADMIN_ROLE_GRANT + or decision.matched_grant_id is None + or decision.matched_scope_project_id is not None + ): + raise RuntimeError("project creation unexpectedly lacked system authority") project = Project( - id=str(uuid4()), + id=reservation.project_id, name=payload.name, slug=payload.slug, description=payload.description, status="draft", + created_by_actor_profile_id=actor_profile_id, + created_via_identity_link_id=identity_link_id, + created_by_admin_role_grant_id=decision.matched_grant_id, + creation_scope_type="system", + creation_action_id=ActionId.PROJECT_CREATE.value, + authorization_decision_event_id=str(decision.decision_id), ) project = await self._repo.add_project(project) - await self._session.commit() - await self._session.refresh(project) - return ProjectResponse.model_validate(project) + await self._repo.complete_project_create(reservation) + return ProjectCreateOutcome( + response=ProjectResponse.model_validate(project), replayed=False + ) async def resolve_project(self, project_id: str) -> Project: """Resolve one canonical project before authorization.""" diff --git a/backend/scripts/api_contract_e2e.py b/backend/scripts/api_contract_e2e.py index c7bc39d64..7261175f4 100644 --- a/backend/scripts/api_contract_e2e.py +++ b/backend/scripts/api_contract_e2e.py @@ -1018,6 +1018,9 @@ async def exercise_api_contract(base_url: str, env: dict[str, str]) -> None: assert openapi["paths"]["/api/v1/projects/{project_id}/role-grants"]["post"][ "x-workstream-action-id" ] == "project_role_grant.issue" + assert openapi["paths"]["/api/v1/projects"]["post"][ + "x-workstream-action-id" + ] == "project.create" assert openapi["paths"][ "/api/v1/projects/{project_id}/role-grants/{grant_id}/revoke" ]["post"]["x-workstream-action-id"] == "project_role_grant.revoke" @@ -1050,6 +1053,17 @@ async def exercise_api_contract(base_url: str, env: dict[str, str]) -> None: "/api/v1/actors/me", project_reader_token, ) + creator_system_grant = await client.post( + "/api/v1/admin-role-grants", + headers=auth_headers(manager_token) | {"Idempotency-Key": str(uuid4())}, + json={ + "target_actor_profile_id": project_reader_profile["actor_profile_id"], + "role": "project_manager", + "scope_type": "system", + "reason": "Real API project creation authority proof", + }, + ) + assert creator_system_grant.status_code == 201, creator_system_grant.text service_payload = { "service_identity": "workstream.artifact.verifier", "subject": f"real-api-artifact-verifier-{run_id}", @@ -1248,18 +1262,24 @@ async def exercise_api_contract(base_url: str, env: dict[str, str]) -> None: assert terminal_service.status_code == 409, terminal_service.text assert terminal_service.json()["error"]["code"] == "actor_deactivated_terminal" - project = await request_json( - client, - "POST", + project_response = await client.post( "/api/v1/projects", - manager_token, - { + headers=auth_headers(project_reader_token) + | {"Idempotency-Key": str(uuid4())}, + json={ "name": f"API Contract Real API {run_id}", "slug": f"api-contract-real-api-{run_id}", "description": "Real backend API contract lifecycle QA", }, - 201, ) + assert project_response.status_code == 201, project_response.text + project = project_response.json() + creator_revoke = await client.post( + f"/api/v1/admin-role-grants/{creator_system_grant.json()['resource_id']}/revoke", + headers=auth_headers(manager_token) | {"Idempotency-Key": str(uuid4())}, + json={"reason": "Creation proof complete; restore bounded reader"}, + ) + assert creator_revoke.status_code == 200, creator_revoke.text await request_json( client, "GET", diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index 22b49af0e..46ac070eb 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 = "5654f83859a318a6f8205215d72934a9b115a155f15d407d54f4200c394b8e3e" +EXPECTED_PUBLIC_SCHEMA_SHA256 = "e138d8ff5f72ad2f3f6a74e8a71a811704335a481763bd5d7cecda29003e06cb" PROTECTED_TEST_TABLES = ( "actor_profile_migration_state", "alembic_version", @@ -70,6 +70,7 @@ "outbox_events", "payment_policies", "pre_submit_checker_policies", + "project_create_idempotency_records", "project_guides", "project_role_grants", "project_role_qualification_snapshots", @@ -88,6 +89,7 @@ "authority_control", "authority_idempotency_records", "outbox_events", + "project_create_idempotency_records", "project_role_grants", "project_role_qualification_snapshots", ) diff --git a/backend/tests/project_create_fixtures.py b/backend/tests/project_create_fixtures.py new file mode 100644 index 000000000..6ac673670 --- /dev/null +++ b/backend/tests/project_create_fixtures.py @@ -0,0 +1,194 @@ +"""Test-only construction of fully attributed project shells.""" + +from datetime import UTC, datetime +from uuid import UUID, uuid4 + +from sqlalchemy import select, text +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.hashing import canonical_json_hash +from app.modules.actors.models import ActorIdentityLink, ActorProfile +from app.modules.audit.schemas import ( + ActorReferenceKind, + AuthorityAuditEventInput, + AuthorityEventType, +) +from app.modules.audit.service import AuditService +from app.modules.authorization.catalogue import ActionId, PermissionId +from app.modules.authorization.models import AdminRoleGrant, AuthorityControl +from app.modules.authorization.runtime import ( + ProjectCreateResourceContext, + authorization_resource_digest, +) +from app.modules.projects.models import Project, ProjectCreateIdempotencyRecord + + +async def seed_authorized_project( + session: AsyncSession, + *, + project_id: str, + name: str, + slug: str, + status: str = "draft", +) -> Project: + """Stage one project with the same custody shape required in production.""" + has_cutover = await session.scalar( + text("select to_regclass('public.project_create_idempotency_records') is not null") + ) + if not has_cutover: + # Migration-boundary tests deliberately exercise schemas before 0044. + # Use the historical column set because the current ORM mapping includes + # provenance columns that do not exist at those revisions. + await session.execute( + text( + "insert into projects (id, name, slug, status) " + "values (:id, :name, :slug, :status)" + ), + {"id": project_id, "name": name, "slug": slug, "status": status}, + ) + project = Project( + id=project_id, + name=name, + slug=slug, + status=status, + ) + return project + actor_id = str(uuid4()) + link = ActorIdentityLink( + id=str(uuid4()), + actor_profile_id=actor_id, + issuer="https://project-fixture.test", + subject=f"project-fixture-{actor_id}", + subject_kind="human", + status="active", + linked_by="test", + last_verified_at=datetime.now(UTC), + ) + session.add( + ActorProfile( + id=actor_id, + actor_kind="human", + status="active", + provisioning_method="automatic_first_access", + service_identity=None, + created_by="test", + ) + ) + await session.flush() + session.add(link) + await session.flush() + control = await session.get(AuthorityControl, 1, with_for_update=True) + if control is None: + raise RuntimeError("authorized project fixture requires authority control") + bootstrap = ( + await session.get(AdminRoleGrant, control.bootstrap_grant_id) + if control.bootstrap_grant_id is not None + else None + ) + if bootstrap is None: + bootstrap = AdminRoleGrant( + id=uuid4(), + target_actor_profile_id=link.actor_profile_id, + role="access_administrator", + scope_type="system", + scope_project_id=None, + status="active", + version=1, + granted_by_system_principal="workstream:system:bootstrap", + grant_reason="fully attributed project fixture bootstrap", + ) + session.add(bootstrap) + control.bootstrap_completed = True + control.bootstrap_grant_id = bootstrap.id + control.version = max(control.version, 1) + await session.flush() + grant = await session.scalar( + select(AdminRoleGrant).where( + AdminRoleGrant.target_actor_profile_id == link.actor_profile_id, + AdminRoleGrant.role == "project_manager", + AdminRoleGrant.scope_type == "system", + AdminRoleGrant.status == "active", + ) + ) + if grant is None: + grant = AdminRoleGrant( + id=uuid4(), + target_actor_profile_id=link.actor_profile_id, + role="project_manager", + scope_type="system", + scope_project_id=None, + status="active", + version=1, + granted_by_actor_profile_id=bootstrap.target_actor_profile_id, + granted_by_admin_role_grant_id=bootstrap.id, + grant_reason="fully attributed project fixture authority", + ) + session.add(grant) + await session.flush() + + operation_id = uuid4() + decision_id = uuid4() + project_uuid = UUID(project_id) + resource = ProjectCreateResourceContext( + resource_type="project_create", + resource_id=operation_id, + requested_project_id=project_uuid, + operation_generation=1, + ) + 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=link.actor_profile_id, + request_id=uuid4(), + correlation_id=uuid4(), + matched_grant_id=str(grant.id), + permission_id=PermissionId.PROJECT_CREATE, + action_id=ActionId.PROJECT_CREATE, + resource_type="project_create_operation", + resource_id=str(operation_id), + target_ref_kind="project", + target_ref_id=project_id, + reason="authorization_evaluation", + after_facts={ + "allowed": True, + "resource_context_digest": authorization_resource_digest(resource), + }, + ) + ) + reservation = ProjectCreateIdempotencyRecord( + id=uuid4(), + actor_profile_id=link.actor_profile_id, + identity_link_id=link.id, + action_id=ActionId.PROJECT_CREATE.value, + idempotency_key=uuid4(), + request_digest=canonical_json_hash( + {"domain": "workstream.test.project_create", "project_id": project_id} + ), + operation_id=operation_id, + project_id=project_id, + operation_generation=1, + status="pending", + ) + session.add(reservation) + await session.flush() + project = Project( + id=project_id, + name=name, + slug=slug, + status=status, + created_by_actor_profile_id=link.actor_profile_id, + created_via_identity_link_id=link.id, + created_by_admin_role_grant_id=grant.id, + creation_scope_type="system", + creation_action_id=ActionId.PROJECT_CREATE.value, + authorization_decision_event_id=str(decision_id), + ) + session.add(project) + reservation.status = "committed" + reservation.committed_at = datetime.now(UTC) + await session.flush() + return project diff --git a/backend/tests/test_alembic.py b/backend/tests/test_alembic.py index ed7584525..f1553f811 100644 --- a/backend/tests/test_alembic.py +++ b/backend/tests/test_alembic.py @@ -24,7 +24,7 @@ ) from sqlalchemy import text from sqlalchemy.exc import DBAPIError, IntegrityError -from sqlalchemy.ext.asyncio import create_async_engine +from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine from app.adapters.auth.dev import actor_id_from_external_identity from app.modules.authorization.catalogue import ( @@ -34,6 +34,7 @@ ActionOwner, PermissionId, ) +from project_create_fixtures import seed_authorized_project from app.modules.actors.legacy_classification import ( CLASSIFICATION_FILE_ENV, @@ -55,7 +56,7 @@ snapshot_existing_service_rows, ) -HEAD_REVISION = "0043_project_setup_service" +HEAD_REVISION = "0044_project_create_authority" pytestmark = pytest.mark.postgres_schema_contract @@ -2156,6 +2157,173 @@ def test_0043_project_setup_service_refuses_in_use_downgrade( command.downgrade(config, "base") +async def _project_create_authority_schema_state(database_url: str) -> tuple[bool, bool, bool]: + engine = create_async_engine(database_url) + try: + async with engine.connect() as connection: + table_exists = bool( + await connection.scalar( + text( + "select to_regclass('public.project_create_idempotency_records') " + "is not null" + ) + ) + ) + provenance_exists = bool( + await connection.scalar( + text( + "select exists(select 1 from information_schema.columns " + "where table_schema='public' and table_name='projects' " + "and column_name='authorization_decision_event_id')" + ) + ) + ) + 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'" + ) + ) + return ( + table_exists, + provenance_exists, + "project_create_operation" in privacy and "target_ref_kind" in privacy, + ) + finally: + await engine.dispose() + + +def test_0044_project_create_authority_round_trip( + isolated_database_env: str, migration_lock +) -> None: + """0044 alone installs and exactly removes project-create persistence.""" + config = _alembic_config() + with migration_lock(): + 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) + command.upgrade(config, "head") + 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) + command.upgrade(config, "head") + finally: + command.downgrade(config, "base") + + +async def _assert_0044_rejects_new_unattributed_project(database_url: str) -> None: + engine = create_async_engine(database_url) + try: + async with engine.begin() as connection: + with pytest.raises(IntegrityError): + await connection.execute( + text( + "insert into projects (id,name,slug,status) " + "values (:id,'Unattributed','unattributed','draft')" + ), + {"id": str(uuid4())}, + ) + finally: + await engine.dispose() + + +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()) + with migration_lock(): + try: + command.downgrade(config, "base") + command.upgrade(config, "0043_project_setup_service") + + async def seed_historical() -> None: + engine = create_async_engine(isolated_database_env) + try: + async with engine.begin() as connection: + await connection.execute( + text( + "insert into projects (id,name,slug,status) " + "values (:id,'Historical','historical','draft')" + ), + {"id": historical_id}, + ) + finally: + await engine.dispose() + + asyncio.run(seed_historical()) + command.upgrade(config, "head") + asyncio.run( + _assert_0044_rejects_new_unattributed_project(isolated_database_env) + ) + + async def remove_historical() -> None: + engine = create_async_engine(isolated_database_env) + try: + async with engine.begin() as connection: + await connection.execute( + text("delete from projects where id=:id"), {"id": historical_id} + ) + finally: + await engine.dispose() + + asyncio.run(remove_historical()) + finally: + command.downgrade(config, "base") + + +def test_0044_refuses_populated_project_create_downgrade( + isolated_database_env: str, migration_lock +) -> None: + """A committed project custody chain 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: + await seed_authorized_project( + session, + project_id=str(uuid4()), + name="Downgrade custody", + slug=f"downgrade-custody-{uuid4()}", + ) + await session.commit() + 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 non-empty project creation authority" + ): + command.downgrade(config, "0043_project_setup_service") + 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 0b4a357ff..1ae131da9 100644 --- a/backend/tests/test_api_controls.py +++ b/backend/tests/test_api_controls.py @@ -510,6 +510,7 @@ def test_openapi_documents_request_error_and_response_context() -> None: "project_role_grant.revoke" ), "GET /api/v1/projects/{project_id}": "project.read", + "POST /api/v1/projects": "project.create", "GET /api/v1/projects/{project_id}/guides/{guide_id}/setup-runs/latest": ( "project.setup_run.read" ), diff --git a/backend/tests/test_artifact_admission.py b/backend/tests/test_artifact_admission.py index 24f2a5aa3..56bc51d13 100644 --- a/backend/tests/test_artifact_admission.py +++ b/backend/tests/test_artifact_admission.py @@ -97,12 +97,12 @@ PaymentPolicy, PostSubmitCheckerPolicy, PreSubmitCheckerPolicy, - Project, ProjectGuide, ReviewPolicy, RevisionPolicy, SubmissionArtifactPolicy, ) +from project_create_fixtures import seed_authorized_project from app.modules.tasks.models import AuditEvent, Submission, WorkstreamTask from tests.artifact_store_helpers import ( artifact_admission_limit_settings, @@ -276,12 +276,11 @@ async def _seed_guide( guide_id = str(uuid4()) snapshot_id = str(uuid4()) item_id = str(uuid4()) - session.add( - Project( - id=project_id, - name="Admission project", - slug=f"admission-{project_id}", - ) + await seed_authorized_project( + session, + project_id=project_id, + name="Admission project", + slug=f"admission-{project_id}", ) await session.flush() session.add( @@ -335,12 +334,11 @@ async def _seed_contributor_items( project_id = str(uuid4()) task_id = str(uuid4()) upload_session_id = str(uuid4()) - session.add( - Project( - id=project_id, - name="Contributor project", - slug=f"contributor-{project_id}", - ) + await seed_authorized_project( + session, + project_id=project_id, + name="Contributor project", + slug=f"contributor-{project_id}", ) await session.flush() session.add( @@ -430,7 +428,12 @@ async def _seed_checker_output_relationships(session) -> tuple[str, str, str]: post_submit_policy_hash = canonical_json_hash(post_submit_policy_body) now = datetime.now(UTC) - session.add(Project(id=project_id, name="Checker project", slug=f"checker-{project_id}")) + await seed_authorized_project( + session, + project_id=project_id, + name="Checker project", + slug=f"checker-{project_id}", + ) await session.flush() session.add( ProjectGuide( @@ -3655,12 +3658,11 @@ async def test_contributor_admission_rejects_cross_project_task_relationship( upload_session = await session.get(ArtifactUploadSession, item.session_id) assert upload_session is not None unrelated_project_id = str(uuid4()) - session.add( - Project( - id=unrelated_project_id, - name="Unrelated admission project", - slug=f"unrelated-{unrelated_project_id}", - ) + await seed_authorized_project( + session, + project_id=unrelated_project_id, + name="Unrelated admission project", + slug=f"unrelated-{unrelated_project_id}", ) await session.flush() upload_session.project_id = unrelated_project_id diff --git a/backend/tests/test_artifact_recovery.py b/backend/tests/test_artifact_recovery.py index 36c6132cc..cc435e693 100644 --- a/backend/tests/test_artifact_recovery.py +++ b/backend/tests/test_artifact_recovery.py @@ -62,6 +62,7 @@ Project, ProjectGuide, ) +from project_create_fixtures import seed_authorized_project from app.modules.tasks.models import AuditEvent, WorkstreamTask from tests.artifact_store_helpers import artifact_admission_limit_settings, minted_source @@ -176,7 +177,12 @@ async def _seed_contributor(session, context, sha256: str, size: int) -> tuple[s last_verified_at=datetime.now(UTC), ) ) - session.add(Project(id=project_id, name="Recovery project", slug=f"recovery-{project_id}")) + await seed_authorized_project( + session, + project_id=project_id, + name="Recovery project", + slug=f"recovery-{project_id}", + ) await session.flush() session.add( WorkstreamTask( diff --git a/backend/tests/test_audit.py b/backend/tests/test_audit.py index 7116f364b..b59925c68 100644 --- a/backend/tests/test_audit.py +++ b/backend/tests/test_audit.py @@ -184,8 +184,9 @@ def test_action_aware_audit_input_enforces_mapping_and_action_availability() -> ActionId.PROJECT_POST_SUBMIT_CHECKER_POLICY_SETUP_READ, ActionId.PROJECT_EFFECTIVE_SUBMISSION_ARTIFACT_POLICY_READ, ActionId.PROJECT_PRE_SUBMIT_CHECKER_POLICY_READ, - ActionId.PROJECT_ACTIVE_GUIDE_READ, - ActionId.PROJECT_READ, + ActionId.PROJECT_ACTIVE_GUIDE_READ, + ActionId.PROJECT_CREATE, + ActionId.PROJECT_READ, ActionId.PROJECT_ROLE_GRANT_LIST, ActionId.PROJECT_ROLE_GRANT_READ, ActionId.PROJECT_ROLE_GRANT_ISSUE, @@ -198,6 +199,27 @@ def test_action_aware_audit_input_enforces_mapping_and_action_availability() -> ActionId.ARTIFACT_PUT_ATTEMPT_RESOLVE, ActionId.ARTIFACT_GUIDE_SOURCE_INGEST, } + + +def test_project_create_audit_event_binds_operation_to_future_project() -> None: + operation_id, project_id = uuid4(), uuid4() + event = _authority_input( + AuthorityEventType.SENSITIVE_AUTHORIZATION_ALLOWED, + permission_id="project.create", + action_id="project.create", + resource_type="project_create_operation", + resource_id=str(operation_id), + target_ref_kind="project", + target_ref_id=str(project_id), + after_facts={ + "allowed": True, + "resource_context_digest": f"sha256:{'a' * 64}", + }, + ) + + assert event.resource_id == str(operation_id) + assert event.target_ref_kind == "project" + assert event.target_ref_id == str(project_id) artifact_allowed = _authority_input( AuthorityEventType.SENSITIVE_AUTHORIZATION_ALLOWED, permission_id=PermissionId.ARTIFACT_VERIFICATION_EXECUTE, diff --git a/backend/tests/test_auth.py b/backend/tests/test_auth.py index c7d075f8d..832e9f93e 100644 --- a/backend/tests/test_auth.py +++ b/backend/tests/test_auth.py @@ -69,7 +69,7 @@ ServiceActorProvisioningService, ServiceActorProvisioningUnavailable, ) -from app.modules.projects.models import Project +from project_create_fixtures import seed_authorized_project from app.modules.tasks.models import AuditEvent from app.schemas.auth import normalize_legacy_roles from scripts.bootstrap_access_administrator import ( @@ -1796,20 +1796,20 @@ def assert_retryable_service_unavailable(response: Response) -> None: target_row = await session.get(ActorProfile, str(target_id)) assert target_row is not None target_row.contact_email = "auth09c-private-contact@example.test" + await seed_authorized_project( + session, + project_id=str(project_one), + name="AUTH-08 scope one", + slug=f"auth08-scope-one-{project_one}", + ) + await seed_authorized_project( + session, + project_id=str(project_two), + name="AUTH-08 scope two", + slug=f"auth08-scope-two-{project_two}", + ) session.add_all( [ - Project( - id=str(project_one), - name="AUTH-08 scope one", - slug=f"auth08-scope-one-{project_one}", - status="draft", - ), - Project( - id=str(project_two), - name="AUTH-08 scope two", - slug=f"auth08-scope-two-{project_two}", - status="draft", - ), ActorProfile( id=str(concealed_targets["service"]), actor_kind="service", diff --git a/backend/tests/test_authorization.py b/backend/tests/test_authorization.py index 123566252..87773aa02 100644 --- a/backend/tests/test_authorization.py +++ b/backend/tests/test_authorization.py @@ -78,7 +78,7 @@ ProjectRoleGrant, ProjectRoleQualificationSnapshot, ) -from app.modules.projects.models import Project +from project_create_fixtures import seed_authorized_project from app.modules.authorization.pagination import ( AuthorizationReadCursorCodec, InvalidPaginationCursor, @@ -1942,6 +1942,7 @@ def test_closed_permission_and_action_catalogue_is_exact_and_non_executable() -> ActionId.PROJECT_ROLE_GRANT_READ, ActionId.PROJECT_ROLE_GRANT_ISSUE, ActionId.PROJECT_ROLE_GRANT_REVOKE, + ActionId.PROJECT_CREATE, ActionId.PROJECT_READ, ActionId.ACTOR_AUTHORIZATION_CONTEXT_READ, ActionId.PROJECT_SETUP_RUN_READ, @@ -2040,14 +2041,14 @@ def test_closed_permission_and_action_catalogue_is_exact_and_non_executable() -> definition.availability is ActionAvailability.ACTIVE for definition in ACTION_DEFINITIONS ) - == 37 + == 38 ) assert ( sum( definition.availability is ActionAvailability.PLANNED for definition in ACTION_DEFINITIONS ) - == 59 + == 58 ) assert resolve_executable_action(ActionId.ACTOR_PROFILE_READ_SELF).permission_id is ( PermissionId.ACTOR_PROFILE_READ_SELF @@ -2061,9 +2062,7 @@ def test_closed_permission_and_action_catalogue_is_exact_and_non_executable() -> def test_project_mutation_resources_and_prepared_scopes_are_closed() -> None: """Bind every planned project mutation to one typed system/project scope.""" project_id, guide_id, snapshot_id, report_id = (uuid4() for _ in range(4)) - review_id, revision_id, submission_policy_id, checker_policy_id = ( - uuid4() for _ in range(4) - ) + review_id, revision_id, submission_policy_id, checker_policy_id = (uuid4() for _ in range(4)) setup_run_id, operation_id, requested_project_id = (uuid4() for _ in range(3)) setup_task_id, setup_correlation_id = uuid4(), uuid4() setup_custody_by_step = { @@ -2232,9 +2231,7 @@ def test_project_mutation_resources_and_prepared_scopes_are_closed() -> None: lifecycle_status="draft", compiled_policy_digest=DIGEST, setup_service_custody=( - setup_custody_by_step["post_submit_policy"] - if target_kind == "derive" - else None + setup_custody_by_step["post_submit_policy"] if target_kind == "derive" else None ), ) for action_id, target_kind in ( @@ -2359,9 +2356,7 @@ def test_project_mutation_resources_and_prepared_scopes_are_closed() -> None: ): missing_custody = service_resource.model_dump() missing_custody["setup_service_custody"] = None - with pytest.raises( - ValidationError, match="service execution requires exact setup custody" - ): + with pytest.raises(ValidationError, match="service execution requires exact setup custody"): context_type.model_validate(missing_custody) if context_type is not ProjectGuideSufficiencyMutationResourceContext: human_derive = service_resource.model_dump() @@ -2386,9 +2381,7 @@ def test_project_mutation_resources_and_prepared_scopes_are_closed() -> None: with pytest.raises(ValidationError, match="setup-service step is inconsistent"): context_type.model_validate(wrong_step) wrong_stale_output = service_resource.model_dump() - wrong_stale_output["setup_service_custody"]["stale_output_digest"] = ( - "sha256:" + "b" * 64 - ) + wrong_stale_output["setup_service_custody"]["stale_output_digest"] = "sha256:" + "b" * 64 with pytest.raises(ValidationError, match="stale output is inconsistent"): context_type.model_validate(wrong_stale_output) changed_task = service_resource.model_copy( @@ -2620,7 +2613,7 @@ def test_art_custody_documentation_matches_the_independent_catalogue_fixture() - assert "does not grant Operator" in operations assert "verification retry remains independently gated" in operations assert ( - "71 PermissionIds, 96 ActionIds, 37 active actions, and\n59 planned actions" in operations + "71 PermissionIds, 96 ActionIds, 37 active actions, and\n59 planned actions" in operations ) @@ -3460,6 +3453,8 @@ def __getattr__(self, name: str): ) project_id = uuid4() for action_id in PROJECT_MUTATION_RESOURCE_BY_ACTION: + if action_id is ActionId.PROJECT_CREATE: + continue scope = PreparedAuthorityScope( kind=( PreparedAuthorityScopeKind.SYSTEM @@ -3479,6 +3474,175 @@ def __getattr__(self, name: str): assert evidence.events == [] +class _ProjectCreateAuthorityFacts: + 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, + system_scope_only, + for_update, + ): + assert actor_profile_id == self.context.actor_profile_id + assert permission_id is PermissionId.PROJECT_CREATE + assert scope_project_id is None + assert system_scope_only is True + assert for_update is True + return self.grant + + +@pytest.mark.asyncio +async def test_project_create_prepared_authority_is_system_scoped_and_evidenced() -> None: + context = _runtime_context() + assert isinstance(context, HumanAuthorizationContext) + session = _PreparedTestSession() + grant_id = uuid4() + facts = _ProjectCreateAuthorityFacts( + 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, project_id, key = uuid4(), uuid4(), uuid4() + caller_input = PreparedAuthorizationInput( + idempotency_key=key, + request_value={ + "operation_id": str(operation_id), + "project_id": str(project_id), + "operation_generation": 1, + }, + ) + handle = await prepared.prepare( + ActionId.PROJECT_CREATE, + caller_input, + PreparedAuthorityScope(kind=PreparedAuthorityScopeKind.SYSTEM), + ) + decision = await prepared.consume( + handle, + ActionId.PROJECT_CREATE, + caller_input, + ProjectCreateResourceContext( + resource_type="project_create", + resource_id=operation_id, + requested_project_id=project_id, + operation_generation=1, + ), + ) + + assert decision.allowed is True + assert decision.matched_authority_kind is MatchedAuthorityKind.ADMIN_ROLE_GRANT + assert decision.matched_grant_id == grant_id + assert decision.matched_scope_project_id is None + assert evidence.events[0].after_facts == { + "allowed": True, + "resource_context_digest": decision.resource_context_digest, + } + assert evidence.events[0].resource_type == "project_create_operation" + assert evidence.events[0].resource_id == str(operation_id) + assert evidence.events[0].target_ref_kind == "project" + assert evidence.events[0].target_ref_id == str(project_id) + + second = await prepared.prepare( + ActionId.PROJECT_CREATE, + caller_input, + PreparedAuthorityScope(kind=PreparedAuthorityScopeKind.SYSTEM), + ) + with pytest.raises(PreparedAuthorizationHandleInvalid): + await prepared.consume( + second, + ActionId.PROJECT_CREATE, + caller_input, + ProjectCreateResourceContext( + resource_type="project_create", + resource_id=uuid4(), + requested_project_id=project_id, + operation_generation=1, + ), + ) + + +@pytest.mark.asyncio +async def test_project_create_preparation_denies_wrong_scope_missing_grant_and_service() -> None: + context = _runtime_context() + assert isinstance(context, HumanAuthorizationContext) + session = _PreparedTestSession() + facts = _ProjectCreateAuthorityFacts(context) + authorization, evidence = _runtime_service( + context, session=session, admin_repository=facts + ) + prepared = PreparedAuthorizationService( + session, context, authorization, facts # type: ignore[arg-type] + ) + operation_id = uuid4() + project_id = uuid4() + caller_input = PreparedAuthorizationInput( + idempotency_key=uuid4(), + request_value={ + "operation_id": str(operation_id), + "project_id": str(project_id), + "operation_generation": 1, + }, + ) + with pytest.raises(PreparedAuthorizationUnsupported) as missing: + await prepared.prepare( + ActionId.PROJECT_CREATE, + caller_input, + PreparedAuthorityScope(kind=PreparedAuthorityScopeKind.SYSTEM), + ) + assert missing.value.denial_code is AuthorizationDenialCode.PERMISSION_NOT_GRANTED + + with pytest.raises(PreparedAuthorizationUnsupported) as scoped: + await prepared.prepare( + ActionId.PROJECT_CREATE, + caller_input, + PreparedAuthorityScope( + kind=PreparedAuthorityScopeKind.PROJECT, project_id=uuid4() + ), + ) + assert scoped.value.denial_code is AuthorizationDenialCode.SCOPE_NOT_AUTHORIZED + 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_CREATE, + caller_input, + PreparedAuthorityScope(kind=PreparedAuthorityScopeKind.SYSTEM), + ) + assert service_denial.value.denial_code is AuthorizationDenialCode.PERMISSION_NOT_GRANTED + + class _ProjectReadAuthorityFacts: """Minimal grant repository used by project-read kernel tests.""" @@ -8814,18 +8978,6 @@ async def test_project_read_permissions_have_postgresql_role_scope_matrix( async with authorization_factory() as session: session.add_all( [ - Project( - id=str(project_id), - name="AUTH-11A role matrix", - slug=f"auth-11a-role-matrix-{project_id}", - status="draft", - ), - Project( - id=str(other_project_id), - name="AUTH-11A other project", - slug=f"auth-11a-other-project-{other_project_id}", - status="draft", - ), ActorProfile( id=str(bootstrap_actor_id), actor_kind="human", @@ -8910,6 +9062,18 @@ async def test_project_read_permissions_have_postgresql_role_scope_matrix( ), {"grant_id": str(bootstrap_grant_id)}, ) + await seed_authorized_project( + session, + project_id=str(project_id), + name="AUTH-11A role matrix", + slug=f"auth-11a-role-matrix-{project_id}", + ) + await seed_authorized_project( + session, + project_id=str(other_project_id), + name="AUTH-11A other project", + slug=f"auth-11a-other-project-{other_project_id}", + ) for (role, scope, scope_project_id, _allowed), ( _case_role, actor_id, @@ -10843,12 +11007,6 @@ async def test_project_role_issue_postgresql_prep_binds_target_role_and_scope( linked_by=str(target_id), last_verified_at=now, ), - Project( - id=str(project_id), - name="AUTH-10C PREP proof", - slug=f"auth-10c-prep-{project_id}", - status="draft", - ), AdminRoleGrant( id=bootstrap_grant_id, target_actor_profile_id=str(caller_id), @@ -10873,6 +11031,13 @@ async def test_project_role_issue_postgresql_prep_binds_target_role_and_scope( {"grant_id": str(bootstrap_grant_id)}, ) await session.commit() + await seed_authorized_project( + session, + project_id=str(project_id), + name="AUTH-10C PREP proof", + slug=f"auth-10c-prep-{project_id}", + ) + await session.commit() session.add( AdminRoleGrant( id=manager_grant_id, diff --git a/backend/tests/test_guide_bindings.py b/backend/tests/test_guide_bindings.py index 3e4e3e032..e09a72882 100644 --- a/backend/tests/test_guide_bindings.py +++ b/backend/tests/test_guide_bindings.py @@ -79,10 +79,10 @@ GuideSourceArtifactIngest, GuideSourceSnapshot, GuideSourceSnapshotItem, - Project, ProjectGuide, ProjectSetupRun, ) +from project_create_fixtures import seed_authorized_project class _AllowBindingAuthority: @@ -243,13 +243,12 @@ async def _seed_binding_lineage( last_verified_at=datetime.now(UTC), ) ) - session.add( - Project( - id=str(ids["project"]), - name="Guide binding", - slug=f"guide-binding-{ids['project']}", - status="draft", - ) + await seed_authorized_project( + session, + project_id=str(ids["project"]), + name="Guide binding", + slug=f"guide-binding-{ids['project']}", + status="draft", ) await session.flush() session.add( @@ -449,6 +448,12 @@ async def _create_binding(factory, ids: dict[str, UUID]) -> UUID: async def test_extraction_publishes_deterministic_content_and_exact_usage( isolated_database_env: str, tmp_path: Path, migration_lock ) -> None: + config = Config(str(Path(__file__).resolve().parents[1] / "alembic.ini")) + config.set_main_option( + "script_location", str(Path(__file__).resolve().parents[1] / "alembic") + ) + with migration_lock(): + await asyncio.to_thread(command.downgrade, config, "0042_guide_extraction") payload = b'{"z":2,"a":1}' digest = "sha256:" + hashlib.sha256(payload).hexdigest() engine = create_async_engine(isolated_database_env) @@ -518,10 +523,6 @@ async def test_extraction_publishes_deterministic_content_and_exact_usage( ), {"usage_id": str(result.usage_id)}, ) - config = Config(str(Path(__file__).resolve().parents[1] / "alembic.ini")) - config.set_main_option( - "script_location", str(Path(__file__).resolve().parents[1] / "alembic") - ) with ( migration_lock(), pytest.raises( diff --git a/backend/tests/test_projects.py b/backend/tests/test_projects.py index d36fe88a7..3a290df2a 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, select, update +from sqlalchemy import event, func, select, update from sqlalchemy.dialects import postgresql from sqlalchemy.exc import IntegrityError from sqlalchemy.schema import CreateIndex @@ -51,12 +51,14 @@ PostSubmitCheckerPolicy, PreSubmitCheckerPolicy, Project, + ProjectCreateIdempotencyRecord, ProjectGuide, ProjectSetupRun, RevisionPolicy, ReviewPolicy, SubmissionArtifactPolicy, ) +from app.modules.tasks.models import AuditEvent from app.modules.authorization.models import ( AdminRoleGrant, AuthorityControl, @@ -87,6 +89,7 @@ ProjectServiceError, StaleProjectSetupContinuation, ) +from project_create_fixtures import seed_authorized_project from app.modules.projects.post_submit_policy import ( @@ -699,6 +702,32 @@ async def project_client(project_database_env: str) -> AsyncIterator[AsyncClient transport=ASGITransport(app=app), base_url="http://testserver", ) as client: + admission = await client.get("/api/v1/auth/me", headers=auth_headers()) + assert admission.status_code == 200, admission.text + actor_id, _link_id, grantor_id = await ensure_access_administrator_bootstrap() + async with db_session.get_session_factory()() as session: + link = await session.scalar( + select(ActorIdentityLink).where( + ActorIdentityLink.issuer == "flow-test", + ActorIdentityLink.subject == "project-manager-subject", + ) + ) + assert link is not None + session.add( + AdminRoleGrant( + id=uuid4(), + target_actor_profile_id=link.actor_profile_id, + role="project_manager", + scope_type="system", + scope_project_id=None, + status="active", + version=1, + granted_by_actor_profile_id=actor_id, + granted_by_admin_role_grant_id=grantor_id, + grant_reason="Project test system-scoped manager authority", + ) + ) + await session.commit() yield client @@ -871,14 +900,6 @@ async def test_project_role_grant_repository_filters_and_uses_strict_keyset( granted_at = datetime(2026, 7, 22, tzinfo=UTC) grant_ids = sorted((uuid4(), uuid4(), uuid4()), key=str) async with db_session.get_session_factory()() as session: - session.add( - Project( - id=str(project_id), - name="Authorization read project", - slug=f"authorization-read-{project_id}", - status="archived", - ) - ) session.add_all( [ ActorProfile( @@ -925,6 +946,14 @@ async def test_project_role_grant_repository_filters_and_uses_strict_keyset( control.bootstrap_completed = True control.bootstrap_grant_id = admin_grant_id control.version = 1 + await session.flush() + await seed_authorized_project( + session, + project_id=str(project_id), + name="Authorization read project", + slug=f"authorization-read-{project_id}", + status="archived", + ) snapshots = [] grants = [] for index, grant_id in enumerate(grant_ids): @@ -1417,7 +1446,7 @@ async def create_project(client: AsyncClient, *, name: str = "STEM Eval") -> dic slug = f"{name.lower().replace(' ', '-')}-{uuid4()}" response = await client.post( "/api/v1/projects", - headers=auth_headers(), + headers=auth_headers() | {"Idempotency-Key": str(uuid4())}, json={ "name": name, "slug": slug, @@ -1433,7 +1462,7 @@ async def test_project_route_registers_project_manager_actor_without_auth_me( ) -> None: response = await project_client.post( "/api/v1/projects", - headers=auth_headers(), + headers=auth_headers() | {"Idempotency-Key": str(uuid4())}, json={ "name": "Registry Proof", "slug": "registry-proof", @@ -1460,6 +1489,114 @@ async def test_project_route_registers_project_manager_actor_without_auth_me( assert legacy_identity.last_seen_roles == ["project_manager"] +async def test_project_create_exact_replay_and_mismatch_are_atomic( + project_client: AsyncClient, +) -> None: + key = str(uuid4()) + payload = { + "name": "Idempotent Project", + "slug": f"idempotent-project-{uuid4()}", + "description": "Exact replay proof", + } + headers = auth_headers() | {"Idempotency-Key": key} + created = await project_client.post("/api/v1/projects", headers=headers, json=payload) + replayed = await project_client.post("/api/v1/projects", headers=headers, json=payload) + mismatch = await project_client.post( + "/api/v1/projects", + headers=headers, + json={**payload, "name": "Changed replay"}, + ) + async with db_session.get_session_factory()() as session: + created_project = await session.get(Project, created.json()["id"]) + assert created_project is not None + matched_grant_id = created_project.created_by_admin_role_grant_id + assert matched_grant_id is not None + await revoke_local_admin_role(matched_grant_id) + replayed_after_revocation = await project_client.post( + "/api/v1/projects", headers=headers, json=payload + ) + + assert created.status_code == replayed.status_code == 201, ( + created.text, + replayed.text, + ) + assert replayed.json() == created.json() + assert replayed_after_revocation.status_code == 201 + assert replayed_after_revocation.json() == created.json() + assert mismatch.status_code == 409 + assert mismatch.json()["error"]["code"] == "idempotency_mismatch" + + async with db_session.get_session_factory()() as session: + project = await session.get(Project, created.json()["id"]) + reservations = list( + ( + await session.scalars( + select(ProjectCreateIdempotencyRecord).where( + ProjectCreateIdempotencyRecord.idempotency_key == UUID(key) + ) + ) + ).all() + ) + project_count = await session.scalar( + select(func.count()).select_from(Project).where(Project.slug == payload["slug"]) + ) + assert project is not None + event = await session.get(AuditEvent, project.authorization_decision_event_id) + allowed_event_count = await session.scalar( + select(func.count()).select_from(AuditEvent).where( + AuditEvent.action_id == "project.create", + AuditEvent.event_type == "SensitiveAuthorizationAllowed", + AuditEvent.target_ref_id == project.id, + ) + ) + + assert project_count == 1 + assert len(reservations) == 1 + reservation = reservations[0] + assert reservation.status == "committed" + assert reservation.project_id == project.id + assert project.creation_scope_type == "system" + assert project.creation_action_id == "project.create" + assert event is not None + assert event.action_id == "project.create" + assert event.resource_type == "project_create_operation" + assert allowed_event_count == 1 + assert event.resource_id == str(reservation.operation_id) + assert event.target_ref_kind == "project" + assert event.target_ref_id == project.id + + +async def test_project_create_concurrent_exact_replay_commits_once( + project_client: AsyncClient, +) -> None: + headers = auth_headers() | {"Idempotency-Key": str(uuid4())} + payload = { + "name": "Concurrent Project", + "slug": f"concurrent-project-{uuid4()}", + "description": "Concurrent exact replay proof", + } + first, second = await asyncio.gather( + project_client.post("/api/v1/projects", headers=headers, json=payload), + project_client.post("/api/v1/projects", 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: + project_count = await session.scalar( + select(func.count()).select_from(Project).where(Project.slug == payload["slug"]) + ) + replay_count = await session.scalar( + select(func.count()) + .select_from(ProjectCreateIdempotencyRecord) + .where( + ProjectCreateIdempotencyRecord.idempotency_key + == UUID(headers["Idempotency-Key"]) + ) + ) + assert project_count == replay_count == 1 + + async def create_guide(client: AsyncClient, project_id: str, payload: dict) -> dict: response = await client.post( f"/api/v1/projects/{project_id}/guides", @@ -2493,7 +2630,7 @@ async def test_project_setup_visibility_apis_show_automatic_setup_outputs( second_project_response = await project_client.post( "/api/v1/projects", - headers=auth_headers(), + headers=auth_headers() | {"Idempotency-Key": str(uuid4())}, json={ "name": "STEM Eval Visibility Two", "slug": "stem-eval-visibility-two", @@ -4058,7 +4195,7 @@ async def test_project_setup_run_rejects_cross_context_worker_updates( second_project_response = await project_client.post( "/api/v1/projects", - headers=auth_headers(), + headers=auth_headers() | {"Idempotency-Key": str(uuid4())}, json={ "name": "STEM Eval Two", "slug": "stem-eval-two", @@ -4226,7 +4363,7 @@ async def test_project_can_be_created(project_client: AsyncClient) -> None: async def test_project_create_rejects_payment_fields(project_client: AsyncClient) -> None: response = await project_client.post( "/api/v1/projects", - headers=auth_headers(), + headers=auth_headers() | {"Idempotency-Key": str(uuid4())}, json={ "name": "Payment Field Project", "slug": "payment-field-project", @@ -8551,11 +8688,12 @@ async def test_worker_cannot_create_project_records( monkeypatch: pytest.MonkeyPatch, ) -> None: monkeypatch.setenv("WORKSTREAM_DEV_AUTH_ROLES", "worker") + monkeypatch.setenv("WORKSTREAM_DEV_AUTH_SUBJECT", "ungranted-worker-subject") get_settings.cache_clear() response = await project_client.post( "/api/v1/projects", - headers=auth_headers(), + headers=auth_headers() | {"Idempotency-Key": str(uuid4())}, json={"name": "Worker Project", "slug": "worker-project"}, ) @@ -8565,9 +8703,181 @@ async def test_worker_cannot_create_project_records( async def test_project_create_validation_errors_are_structured(project_client: AsyncClient) -> None: response = await project_client.post( "/api/v1/projects", - headers=auth_headers(), + headers=auth_headers() | {"Idempotency-Key": str(uuid4())}, json={"slug": "missing-name"}, ) assert response.status_code == 422 assert isinstance(response.json()["detail"], list) + + +async def test_project_create_requires_valid_idempotency_before_actor_provisioning( + project_database_env: str, + monkeypatch: pytest.MonkeyPatch, +) -> None: + subject = f"missing-idempotency-{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: + for headers in (auth_headers(), auth_headers() | {"Idempotency-Key": "invalid"}): + response = await client.post( + "/api/v1/projects", + headers=headers, + json={"name": "Rejected", "slug": f"rejected-{uuid4()}"}, + ) + 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_project_create_different_keys_same_slug_rolls_back_authority( + project_client: AsyncClient, +) -> None: + slug = f"same-slug-{uuid4()}" + first = await project_client.post( + "/api/v1/projects", + headers=auth_headers() | {"Idempotency-Key": str(uuid4())}, + json={"name": "First", "slug": slug}, + ) + conflict = await project_client.post( + "/api/v1/projects", + headers=auth_headers() | {"Idempotency-Key": str(uuid4())}, + json={"name": "Second", "slug": slug}, + ) + assert first.status_code == 201, first.text + assert conflict.status_code == 409 + assert conflict.json()["error"]["code"] == "project_slug_conflict" + + async with db_session.get_session_factory()() as session: + assert await session.scalar( + select(func.count()).select_from(Project).where(Project.slug == slug) + ) == 1 + assert await session.scalar( + select(func.count()).select_from(ProjectCreateIdempotencyRecord).where( + ProjectCreateIdempotencyRecord.project_id != first.json()["id"], + ProjectCreateIdempotencyRecord.status == "pending", + ) + ) == 0 + + +async def test_project_create_copied_key_cannot_cross_actor_namespace( + project_client: AsyncClient, + monkeypatch: pytest.MonkeyPatch, +) -> None: + key = str(uuid4()) + payload = { + "name": "Actor-bound replay", + "slug": f"actor-bound-replay-{uuid4()}", + } + first = await project_client.post( + "/api/v1/projects", + headers=auth_headers() | {"Idempotency-Key": key}, + json=payload, + ) + assert first.status_code == 201 + + second_subject = f"copied-key-actor-{uuid4()}" + monkeypatch.setenv("WORKSTREAM_DEV_AUTH_SUBJECT", second_subject) + get_settings.cache_clear() + admitted = await project_client.get("/api/v1/auth/me", headers=auth_headers()) + assert admitted.status_code == 200 + grantor_id, _, grantor_grant_id = await ensure_access_administrator_bootstrap() + async with db_session.get_session_factory()() as session: + second_link = await session.scalar( + select(ActorIdentityLink).where(ActorIdentityLink.subject == second_subject) + ) + assert second_link is not None + session.add( + AdminRoleGrant( + id=uuid4(), + target_actor_profile_id=second_link.actor_profile_id, + role="project_manager", + scope_type="system", + scope_project_id=None, + status="active", + version=1, + granted_by_actor_profile_id=grantor_id, + granted_by_admin_role_grant_id=grantor_grant_id, + grant_reason="AUTH-12C copied-key actor boundary proof", + ) + ) + await session.commit() + + copied = await project_client.post( + "/api/v1/projects", + headers=auth_headers() | {"Idempotency-Key": key}, + json=payload, + ) + assert copied.status_code == 409 + assert copied.json()["error"]["code"] == "project_slug_conflict" + async with db_session.get_session_factory()() as session: + assert await session.scalar( + select(func.count()).select_from(Project).where(Project.slug == payload["slug"]) + ) == 1 + assert await session.scalar( + select(func.count()).select_from(ProjectCreateIdempotencyRecord).where( + ProjectCreateIdempotencyRecord.idempotency_key == UUID(key), + ProjectCreateIdempotencyRecord.status == "pending", + ) + ) == 0 + + +async def test_project_create_denies_project_scoped_and_contributor_authority( + project_client: AsyncClient, +) -> None: + seed = await create_project(project_client, name="Scope boundary seed") + async with db_session.get_session_factory()() as session: + system_manager = await session.scalar( + select(AdminRoleGrant).where( + AdminRoleGrant.role == "project_manager", + AdminRoleGrant.scope_type == "system", + AdminRoleGrant.status == "active", + ) + ) + assert system_manager is not None + system_manager_id = system_manager.id + await revoke_local_admin_role(system_manager_id) + await add_project_manager_admin_grant(seed["id"]) + await add_project_role_for_default_actor(seed["id"], "submitter") + + denied = await project_client.post( + "/api/v1/projects", + headers=auth_headers() | {"Idempotency-Key": str(uuid4())}, + json={"name": "Wrong scope", "slug": f"wrong-scope-{uuid4()}"}, + ) + assert denied.status_code == 403 + + async with db_session.get_session_factory()() as session: + assert await session.scalar( + select(func.count()).select_from(Project).where(Project.name == "Wrong scope") + ) == 0 + assert await session.scalar( + select(func.count()).select_from(ProjectCreateIdempotencyRecord).where( + ProjectCreateIdempotencyRecord.status == "pending" + ) + ) == 0 + assert await session.scalar( + select(func.count()).select_from(AuditEvent).where( + AuditEvent.action_id == "project.create", + AuditEvent.event_type == "SensitiveAuthorizationAllowed", + AuditEvent.target_ref_id != seed["id"], + ) + ) == 0 + denial_event = await session.scalar( + select(AuditEvent).where( + AuditEvent.action_id == "project.create", + AuditEvent.event_type == "SensitiveAuthorizationDenied", + AuditEvent.denial_code == "permission_not_granted", + ) + ) + assert denial_event is not None + assert denial_event.resource_type == "project_create_operation" + assert denial_event.target_ref_kind == "project" + assert denial_event.resource_id is not None + assert denial_event.target_ref_id is not None diff --git a/docs/operations_authorization_service.md b/docs/operations_authorization_service.md index 202e9965f..9c55a47a4 100644 --- a/docs/operations_authorization_service.md +++ b/docs/operations_authorization_service.md @@ -1269,3 +1269,24 @@ The final release owner coordinates a supported API/command drill proving: Committed proof uses approved redacted placeholders and never production tokens, private keys, raw claims, private actor/source IDs, or local paths. + +## Project creation authorization + +Project creation is a system-scoped administrative mutation. Operators should +verify that the caller has an active local human profile, active identity link, +and active system-scoped `project_manager` grant; a token role is observation +only and grants no authority. `POST /api/v1/projects` also requires a UUID +`Idempotency-Key`. Exact retries return the original project, while reuse with +changed request facts returns `idempotency_mismatch` and creates nothing. +An exact committed retry is response recovery: it returns the database-bound +original response without a second authorization event even if the original +grant was later revoked. Revocation still denies every new key or changed +request. + +The protected transaction writes one draft project shell, its project-owned +replay record, and an allowed authorization event that binds the distinct +operation UUID to the future project UUID. If authorization, evidence, or the +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. diff --git a/docs/spec_authorization_service.md b/docs/spec_authorization_service.md index 37338376c..c7c34ebc4 100644 --- a/docs/spec_authorization_service.md +++ b/docs/spec_authorization_service.md @@ -925,16 +925,35 @@ Draft, superseded, replaced, incomplete, ambiguous, corrupt, or stale bindings conceal. Contributor guide requirements remain on task work-context and submission-requirements surfaces. -AUTH-12A registers the complete project-mutation vocabulary below as planned -and unavailable. It adds distinct typed resource contexts and PostgreSQL -action-evidence parity, but activates no route, setup command, or service. 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 typed resource. - -| Planned ActionId | PermissionId | Activation owner | +AUTH-12A registered the complete project-mutation vocabulary below as planned +and unavailable. AUTH-12C now activates only `project.create`; 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 +typed resource. + +`POST /api/v1/projects` requires an active human, the exact active identity +link, an effective system-scoped Project Manager grant, and a UUID +`Idempotency-Key`. Project-owned replay state supplies stable server operation +and project identities before PREP. Final consumption binds those identities, +the validated body digest, actor, link, grant, generation, request transaction, +and action. The project shell, authorization event, provenance, and committed +replay result persist atomically. Verified-token role observations, +project-scoped grants, service +actors, copied handles, changed replay input, revoked authority, and stale or +wrong transactions do not authorize creation. This action creates no guide, +setup run, task, submission, checker, review, contribution, compensation, +reputation, policy, or activation state. + +An exact committed retry is response recovery, not another creation attempt. +It validates the same actor/action/key/request digest and the database-enforced +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. + +| ActionId | PermissionId | Activation owner | |---|---|---| -| `project.create` | `project.create` | `WS-AUTH-001-12C` | +| `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` | From ae057f2198b6ea5ea2a54bb9d5ca3ea5632ee550 Mon Sep 17 00:00:00 2001 From: Abiorh001 Date: Thu, 30 Jul 2026 19:49:27 +0100 Subject: [PATCH 02/12] test(auth): grant project create fixture authority --- .../chunks/WS-AUTH-001-12C-project-create.md | 2 + backend/tests/project_create_fixtures.py | 117 +++++++++++------- backend/tests/test_checkers.py | 17 ++- backend/tests/test_tasks.py | 14 ++- 4 files changed, 98 insertions(+), 52 deletions(-) diff --git a/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/chunks/WS-AUTH-001-12C-project-create.md b/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/chunks/WS-AUTH-001-12C-project-create.md index 2d2a94a78..ce20811f7 100644 --- a/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/chunks/WS-AUTH-001-12C-project-create.md +++ b/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/chunks/WS-AUTH-001-12C-project-create.md @@ -54,7 +54,9 @@ backend/tests/test_guide_bindings.py backend/tests/test_auth.py backend/tests/test_alembic.py backend/tests/test_api_controls.py +backend/tests/test_checkers.py backend/tests/conftest.py +backend/tests/test_tasks.py backend/scripts/api_contract_e2e.py docs/spec_authorization_service.md docs/operations_authorization_service.md diff --git a/backend/tests/project_create_fixtures.py b/backend/tests/project_create_fixtures.py index 6ac673670..c79a355ca 100644 --- a/backend/tests/project_create_fixtures.py +++ b/backend/tests/project_create_fixtures.py @@ -23,6 +23,72 @@ from app.modules.projects.models import Project, ProjectCreateIdempotencyRecord +async def grant_system_project_manager( + session: AsyncSession, + *, + issuer: str, + subject: str, +) -> tuple[ActorIdentityLink, AdminRoleGrant]: + """Grant the admitted test actor the authority required by project.create.""" + link = await session.scalar( + select(ActorIdentityLink).where( + ActorIdentityLink.issuer == issuer, + ActorIdentityLink.subject == subject, + ) + ) + if link is None: + raise RuntimeError("project manager fixture requires an admitted actor") + control = await session.get(AuthorityControl, 1, with_for_update=True) + if control is None: + raise RuntimeError("project manager fixture requires authority control") + bootstrap = ( + await session.get(AdminRoleGrant, control.bootstrap_grant_id) + if control.bootstrap_grant_id is not None + else None + ) + if bootstrap is None: + bootstrap = AdminRoleGrant( + id=uuid4(), + target_actor_profile_id=link.actor_profile_id, + role="access_administrator", + scope_type="system", + scope_project_id=None, + status="active", + version=1, + granted_by_system_principal="workstream:system:bootstrap", + grant_reason="project manager fixture bootstrap", + ) + session.add(bootstrap) + control.bootstrap_completed = True + control.bootstrap_grant_id = bootstrap.id + control.version = max(control.version, 1) + await session.flush() + grant = await session.scalar( + select(AdminRoleGrant).where( + AdminRoleGrant.target_actor_profile_id == link.actor_profile_id, + AdminRoleGrant.role == "project_manager", + AdminRoleGrant.scope_type == "system", + AdminRoleGrant.status == "active", + ) + ) + if grant is None: + grant = AdminRoleGrant( + id=uuid4(), + target_actor_profile_id=link.actor_profile_id, + role="project_manager", + scope_type="system", + scope_project_id=None, + status="active", + version=1, + granted_by_actor_profile_id=bootstrap.target_actor_profile_id, + granted_by_admin_role_grant_id=bootstrap.id, + grant_reason="project manager fixture authority", + ) + session.add(grant) + await session.flush() + return link, grant + + async def seed_authorized_project( session: AsyncSession, *, @@ -77,54 +143,11 @@ async def seed_authorized_project( await session.flush() session.add(link) await session.flush() - control = await session.get(AuthorityControl, 1, with_for_update=True) - if control is None: - raise RuntimeError("authorized project fixture requires authority control") - bootstrap = ( - await session.get(AdminRoleGrant, control.bootstrap_grant_id) - if control.bootstrap_grant_id is not None - else None + link, grant = await grant_system_project_manager( + session, + issuer=link.issuer, + subject=link.subject, ) - if bootstrap is None: - bootstrap = AdminRoleGrant( - id=uuid4(), - target_actor_profile_id=link.actor_profile_id, - role="access_administrator", - scope_type="system", - scope_project_id=None, - status="active", - version=1, - granted_by_system_principal="workstream:system:bootstrap", - grant_reason="fully attributed project fixture bootstrap", - ) - session.add(bootstrap) - control.bootstrap_completed = True - control.bootstrap_grant_id = bootstrap.id - control.version = max(control.version, 1) - await session.flush() - grant = await session.scalar( - select(AdminRoleGrant).where( - AdminRoleGrant.target_actor_profile_id == link.actor_profile_id, - AdminRoleGrant.role == "project_manager", - AdminRoleGrant.scope_type == "system", - AdminRoleGrant.status == "active", - ) - ) - if grant is None: - grant = AdminRoleGrant( - id=uuid4(), - target_actor_profile_id=link.actor_profile_id, - role="project_manager", - scope_type="system", - scope_project_id=None, - status="active", - version=1, - granted_by_actor_profile_id=bootstrap.target_actor_profile_id, - granted_by_admin_role_grant_id=bootstrap.id, - grant_reason="fully attributed project fixture authority", - ) - session.add(grant) - await session.flush() operation_id = uuid4() decision_id = uuid4() diff --git a/backend/tests/test_checkers.py b/backend/tests/test_checkers.py index d36c2a95e..a43fc24e8 100644 --- a/backend/tests/test_checkers.py +++ b/backend/tests/test_checkers.py @@ -5,6 +5,7 @@ import math from pathlib import Path from typing import Any +from uuid import uuid4 import pytest from alembic.config import Config @@ -55,6 +56,7 @@ seed_worker_profile, set_dev_actor, ) +from project_create_fixtures import grant_system_project_manager @pytest.fixture @@ -83,6 +85,15 @@ async def checker_client(checker_database_env: str) -> AsyncIterator[AsyncClient transport=ASGITransport(app=app), base_url="http://testserver", ) as client: + admission = await client.get("/api/v1/auth/me", headers=auth_headers()) + assert admission.status_code == 200, admission.text + async with db_session.get_session_factory()() as session: + await grant_system_project_manager( + session, + issuer="flow-test", + subject="project-manager-subject", + ) + await session.commit() yield client @@ -1079,7 +1090,7 @@ async def create_checker_trial_project( """ project_response = await client.post( "/api/v1/projects", - headers=auth_headers(), + headers=auth_headers() | {"Idempotency-Key": str(uuid4())}, json={ "name": slug.replace("-", " ").title(), "slug": slug, @@ -1778,7 +1789,7 @@ async def test_chunk8_default_blocking_checker_survives_omitted_blocking_severit ) -> None: project_response = await checker_client.post( "/api/v1/projects", - headers=auth_headers(), + headers=auth_headers() | {"Idempotency-Key": str(uuid4())}, json={ "name": "Empty Blocking Severity Project", "slug": "empty-blocking-severity-project", @@ -2151,7 +2162,7 @@ async def test_chunk8_task_setup_blocked_takes_priority_over_worker_revision( ) -> None: project_response = await checker_client.post( "/api/v1/projects", - headers=auth_headers(), + headers=auth_headers() | {"Idempotency-Key": str(uuid4())}, json={ "name": "Task Setup Checker Project", "slug": "task-setup-checker-project", diff --git a/backend/tests/test_tasks.py b/backend/tests/test_tasks.py index 906d456f7..9c0c74f8f 100644 --- a/backend/tests/test_tasks.py +++ b/backend/tests/test_tasks.py @@ -69,6 +69,7 @@ TaskAssignment, WorkstreamTask, ) +from project_create_fixtures import 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 ( @@ -612,6 +613,15 @@ async def task_client(task_database_env: str) -> AsyncIterator[AsyncClient]: transport=ASGITransport(app=app), base_url="http://testserver", ) as client: + admission = await client.get("/api/v1/auth/me", headers=auth_headers()) + assert admission.status_code == 200, admission.text + async with db_session.get_session_factory()() as session: + await grant_system_project_manager( + session, + issuer="flow-test", + subject="project-manager-subject", + ) + await session.commit() yield client @@ -1081,7 +1091,7 @@ def complete_submission_payload(package_hash: str = "sha256:package-v1") -> dict async def create_active_project(client: AsyncClient) -> dict: project_response = await client.post( "/api/v1/projects", - headers=auth_headers(), + headers=auth_headers() | {"Idempotency-Key": str(uuid4())}, json={ "name": "Task Queue Project", "slug": "task-queue-project", @@ -2274,7 +2284,7 @@ async def test_task_create_and_transitions_reject_client_supplied_policy_context async def test_screening_requires_active_guide_context(task_client: AsyncClient) -> None: project_response = await task_client.post( "/api/v1/projects", - headers=auth_headers(), + headers=auth_headers() | {"Idempotency-Key": str(uuid4())}, json={"name": "No Guide", "slug": "no-guide"}, ) assert project_response.status_code == 201, project_response.text From 86d30281694c7ceae791f3e984cbd33b9823863e Mon Sep 17 00:00:00 2001 From: Abiorh001 Date: Thu, 30 Jul 2026 20:23:05 +0100 Subject: [PATCH 03/12] fix(auth): harden project creation custody --- .../chunks/WS-AUTH-001-12C-project-create.md | 5 +- ...S-AUTH-001-12C-external-review-response.md | 45 +++++++++++++++++ .../versions/0044_project_create_authority.py | 46 +++++++++++------- backend/app/modules/authorization/prepared.py | 30 +++++++----- backend/app/modules/projects/repository.py | 26 ++++------ backend/app/modules/projects/router.py | 6 ++- backend/tests/conftest.py | 2 +- backend/tests/project_create_fixtures.py | 38 ++++++++++----- backend/tests/test_alembic.py | 2 +- backend/tests/test_artifact_admission.py | 3 +- backend/tests/test_audit.py | 48 +++++++++---------- backend/tests/test_auth.py | 4 +- backend/tests/test_authorization.py | 8 +--- backend/tests/test_projects.py | 7 ++- 14 files changed, 170 insertions(+), 100 deletions(-) create mode 100644 .agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/reviews/WS-AUTH-001-12C-external-review-response.md diff --git a/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/chunks/WS-AUTH-001-12C-project-create.md b/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/chunks/WS-AUTH-001-12C-project-create.md index ce20811f7..aeaf204e7 100644 --- a/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/chunks/WS-AUTH-001-12C-project-create.md +++ b/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/chunks/WS-AUTH-001-12C-project-create.md @@ -167,6 +167,7 @@ install -d -m 700 .ci app/modules/projects/service.py app/modules/authorization/catalogue.py \ app/modules/authorization/kernel.py app/modules/authorization/prepared.py \ app/modules/authorization/runtime.py app/api/deps/authorization.py \ + app/modules/audit/schemas.py \ alembic/versions/0044_project_create_authority.py \ tests/test_authorization.py tests/test_projects.py tests/test_alembic.py \ tests/test_api_controls.py tests/conftest.py scripts/api_contract_e2e.py @@ -179,10 +180,10 @@ PYTEST_DISABLE_PLUGIN_AUTOLOAD=1 .venv/bin/python scripts/run_isolated_tests.py --metadata-json .ci/auth12c-coverage.json --lane auth12c_coverage \ --timeout-seconds 1200 -- sh -c \ '.venv/bin/coverage erase && .venv/bin/coverage run \ - --source=app.modules.projects,app.modules.authorization \ + --source=app.modules.projects,app.modules.authorization,app.modules.audit \ -m pytest -p pytest_asyncio.plugin -q tests/test_authorization.py \ tests/test_projects.py -k "project_create" && .venv/bin/coverage report \ - --include="*/app/modules/projects/*,*/app/modules/authorization/*" \ + --include="*/app/modules/projects/*,*/app/modules/authorization/*,*/app/modules/audit/*" \ --show-missing --fail-under=90' PYTEST_DISABLE_PLUGIN_AUTOLOAD=1 .venv/bin/python scripts/run_isolated_tests.py \ --metadata-json .ci/auth12c-api.json --lane auth12c_api \ diff --git a/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/reviews/WS-AUTH-001-12C-external-review-response.md b/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/reviews/WS-AUTH-001-12C-external-review-response.md new file mode 100644 index 000000000..97c962cd7 --- /dev/null +++ b/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/reviews/WS-AUTH-001-12C-external-review-response.md @@ -0,0 +1,45 @@ +# WS-AUTH-001-12C external review response + +## Comments addressed + +- Made migration constraint naming convention-safe and every custody comparison + NULL-safe; also require a bounded SHA-256 resource-context digest. +- Made concurrent same-key reservation acquisition block on and return the + winning row through a guarded no-op upsert instead of racing an invisible row. +- Added portable asyncpg/direct/psycopg constraint-name extraction for slug + conflicts. +- Corrected fixture authority side effects, count-sensitive assertions, UUID + validation, test ownership, indentation, pending-row coverage, and missing + constraint inspection. +- Removed dead test branches, deduplicated project-create binding comparison, + and included audit schemas in the chunk verification commands. + +## Comments deferred + +- Returning a session-managed ORM object from a pre-0044 schema was not adopted: + the current ORM mapping selects columns absent from that historical schema. + The helper now returns no object; every caller uses it only for persistence. +- Removing the existing-grant lookup was not adopted because the latest shared + helper is also used for admitted API actors, where grant reuse is reachable. +- An evidence opt-out was not adopted because an attributed project without its + allowed authorization event would violate the 0044 custody contract. Tests + now compare scoped baselines or correct authority totals instead. + +## Human decisions needed + +None. + +## Commands rerun + +- Focused Ruff and `git diff --check`: passed. +- AUTH-12C PostgreSQL lane: 12 passed. +- Migration downgrade plus concurrent replay/slug rollback: 3 passed. +- Count-sensitive auth/audit checks: 3 passed; the initially failing artifact + baseline check was corrected and then passed with task/checker regressions. +- Task/checker/artifact fixture regressions: 3 passed. +- Hosted Backend and exact-head CodeRabbit reruns are pending the corrective + commit. + +## Remaining risks + +Hosted full-suite execution and exact per-file coverage remain authoritative. diff --git a/backend/alembic/versions/0044_project_create_authority.py b/backend/alembic/versions/0044_project_create_authority.py index afecf77d3..34dd3b640 100644 --- a/backend/alembic/versions/0044_project_create_authority.py +++ b/backend/alembic/versions/0044_project_create_authority.py @@ -109,7 +109,7 @@ def upgrade() -> None: ["id"], ) op.create_check_constraint( - "ck_projects_creation_authority_shape", + "creation_authority_shape", "projects", "(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 " @@ -181,6 +181,9 @@ def upgrade() -> None: elsif tg_op = 'DELETE' then raise exception 'project create reservations are 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.operation_id, @@ -237,26 +240,35 @@ def upgrade() -> None: select * into project_row from projects where id=reservation.project_id; end if; if project_row.id is null or reservation.id is null - or project_row.created_by_actor_profile_id <> reservation.actor_profile_id - or project_row.created_via_identity_link_id <> reservation.identity_link_id - or project_row.creation_action_id <> reservation.action_id then + or project_row.created_by_actor_profile_id + is distinct from reservation.actor_profile_id + or project_row.created_via_identity_link_id + is distinct from reservation.identity_link_id + or project_row.creation_action_id is distinct from reservation.action_id then raise exception 'project create custody mismatch' using errcode='23514'; end if; select * into evidence from audit_events where id=project_row.authorization_decision_event_id; - if evidence.id is null or evidence.event_domain <> 'authority' - or evidence.event_type <> 'SensitiveAuthorizationAllowed' + 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 <> 'actor_profile' - or evidence.actor_id <> project_row.created_by_actor_profile_id - or evidence.matched_grant_id <> project_row.created_by_admin_role_grant_id::text - or evidence.permission_id <> 'project.create' - or evidence.action_id <> 'project.create' - or evidence.resource_type <> 'project_create_operation' - or evidence.resource_id <> reservation.operation_id::text - or evidence.target_ref_kind <> 'project' - or evidence.target_ref_id <> project_row.id - or evidence.after_facts->>'allowed' <> 'true' then + or evidence.actor_ref_kind is distinct from 'actor_profile' + or evidence.actor_id is distinct from project_row.created_by_actor_profile_id + or evidence.matched_grant_id + is distinct from project_row.created_by_admin_role_grant_id::text + or evidence.permission_id is distinct from 'project.create' + or evidence.action_id is distinct from 'project.create' + or evidence.resource_type is distinct from 'project_create_operation' + or evidence.resource_id is distinct from reservation.operation_id::text + or evidence.target_ref_kind is distinct from 'project' + or evidence.target_ref_id is distinct from project_row.id + or evidence.after_facts->>'allowed' is distinct from 'true' + or coalesce( + evidence.after_facts->>'resource_context_digest' + !~ '^sha256:[0-9a-f]{64}$', + true + ) then raise exception 'project create evidence mismatch' using errcode='23514'; end if; return null; @@ -310,7 +322,7 @@ def downgrade() -> None: op.execute("drop function reject_project_create_idempotency_truncate()") op.execute("drop function guard_project_create_idempotency()") op.drop_table("project_create_idempotency_records") - op.drop_constraint("ck_projects_creation_authority_shape", "projects", type_="check") + op.drop_constraint("creation_authority_shape", "projects", type_="check") op.drop_constraint("fk_projects_creation_decision", "projects", type_="foreignkey") op.drop_constraint("fk_projects_creation_admin_grant", "projects", type_="foreignkey") op.drop_constraint("fk_projects_creation_identity_link", "projects", type_="foreignkey") diff --git a/backend/app/modules/authorization/prepared.py b/backend/app/modules/authorization/prepared.py index 9e296a663..ee0a24acb 100644 --- a/backend/app/modules/authorization/prepared.py +++ b/backend/app/modules/authorization/prepared.py @@ -89,6 +89,18 @@ class _Consumed: __slots__ = () +def _project_create_binding_matches( + binding: _PreparedAuthorizationBinding, + resource: ProjectCreateResourceContext, +) -> bool: + """Return whether final project-create identity matches the prepared binding.""" + return ( + binding.project_create_operation_id == resource.resource_id + and binding.project_create_project_id == resource.requested_project_id + and binding.project_create_generation == resource.operation_generation + ) + + _CONSUMED = _Consumed() @@ -159,12 +171,8 @@ async def consume( final_scope = self._scope_from_resource(expected_action_id, final_resource_context) if final_scope != issuance.binding.scope: raise PreparedAuthorizationHandleInvalid("invalid prepared authorization handle") - if isinstance(final_resource_context, ProjectCreateResourceContext) and ( - issuance.binding.project_create_operation_id != final_resource_context.resource_id - or issuance.binding.project_create_project_id - != final_resource_context.requested_project_id - or issuance.binding.project_create_generation - != final_resource_context.operation_generation + if isinstance(final_resource_context, ProjectCreateResourceContext) and not ( + _project_create_binding_matches(issuance.binding, final_resource_context) ): raise PreparedAuthorizationHandleInvalid("invalid prepared authorization handle") self._issued[handle] = _CONSUMED @@ -188,13 +196,9 @@ async def deny_unsupported( caller_input, PreparedAuthorityScope(kind=PreparedAuthorityScopeKind.SYSTEM), ) - if not isinstance(final_resource_context, ProjectCreateResourceContext) or ( - binding.project_create_operation_id != final_resource_context.resource_id - or binding.project_create_project_id - != final_resource_context.requested_project_id - or binding.project_create_generation - != final_resource_context.operation_generation - ): + if not isinstance( + final_resource_context, ProjectCreateResourceContext + ) or not _project_create_binding_matches(binding, final_resource_context): raise PreparedAuthorizationHandleInvalid( "invalid prepared authorization handle" ) diff --git a/backend/app/modules/projects/repository.py b/backend/app/modules/projects/repository.py index eec0ee7e9..ac0d44750 100644 --- a/backend/app/modules/projects/repository.py +++ b/backend/app/modules/projects/repository.py @@ -101,31 +101,23 @@ async def reserve_project_create( record_id = await self._session.scalar( insert(ProjectCreateIdempotencyRecord) .values(**values) - .on_conflict_do_nothing( + .on_conflict_do_update( index_elements=[ ProjectCreateIdempotencyRecord.actor_profile_id, ProjectCreateIdempotencyRecord.action_id, ProjectCreateIdempotencyRecord.idempotency_key, - ] + ], + set_={"id": ProjectCreateIdempotencyRecord.id}, ) .returning(ProjectCreateIdempotencyRecord.id) ) - if record_id is not None: - record = await self._session.get(ProjectCreateIdempotencyRecord, record_id) - if record is None: - raise ProjectRepositoryIntegrityError("project reservation disappeared") - return "claimed", record - record = await self._session.scalar( - select(ProjectCreateIdempotencyRecord) - .where( - ProjectCreateIdempotencyRecord.actor_profile_id == actor_profile_id, - ProjectCreateIdempotencyRecord.action_id == "project.create", - ProjectCreateIdempotencyRecord.idempotency_key == idempotency_key, - ) - .with_for_update() - ) + if record_id is None: + raise ProjectRepositoryIntegrityError("project reservation disappeared") + record = await self._session.get(ProjectCreateIdempotencyRecord, record_id) if record is None: - raise ProjectRepositoryIntegrityError("project reservation conflict disappeared") + raise ProjectRepositoryIntegrityError("project reservation disappeared") + if record_id == values["id"]: + return "claimed", record if ( record.identity_link_id != identity_link_id or record.request_digest != request_digest diff --git a/backend/app/modules/projects/router.py b/backend/app/modules/projects/router.py index 9948d82fa..a864a9cca 100644 --- a/backend/app/modules/projects/router.py +++ b/backend/app/modules/projects/router.py @@ -174,7 +174,11 @@ async def create_project( await session.rollback() constraint_name = getattr( getattr(exc.orig, "__cause__", None), "constraint_name", None - ) + ) or getattr(exc.orig, "constraint_name", None) + if constraint_name is None: + constraint_name = getattr( + getattr(exc.orig, "diag", None), "constraint_name", None + ) if constraint_name not in { "projects_slug_key", "ix_projects_slug", diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index 46ac070eb..00b85c7d8 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 = "e138d8ff5f72ad2f3f6a74e8a71a811704335a481763bd5d7cecda29003e06cb" +EXPECTED_PUBLIC_SCHEMA_SHA256 = "defa8baad0019605b4bb512808a04b554c0f6ce585842fe60a9e3291d1d9bb79" PROTECTED_TEST_TABLES = ( "actor_profile_migration_state", "alembic_version", diff --git a/backend/tests/project_create_fixtures.py b/backend/tests/project_create_fixtures.py index c79a355ca..e124e2c5e 100644 --- a/backend/tests/project_create_fixtures.py +++ b/backend/tests/project_create_fixtures.py @@ -47,9 +47,32 @@ async def grant_system_project_manager( else None ) if bootstrap is None: + bootstrap_actor = ActorProfile( + id=str(uuid4()), + actor_kind="human", + status="active", + provisioning_method="automatic_first_access", + service_identity=None, + created_by="test", + ) + session.add(bootstrap_actor) + await session.flush() + session.add( + ActorIdentityLink( + id=str(uuid4()), + actor_profile_id=bootstrap_actor.id, + issuer="https://project-fixture-bootstrap.test", + subject=f"project-fixture-bootstrap-{bootstrap_actor.id}", + subject_kind="human", + status="active", + linked_by="test", + last_verified_at=datetime.now(UTC), + ) + ) + await session.flush() bootstrap = AdminRoleGrant( id=uuid4(), - target_actor_profile_id=link.actor_profile_id, + target_actor_profile_id=bootstrap_actor.id, role="access_administrator", scope_type="system", scope_project_id=None, @@ -96,8 +119,9 @@ async def seed_authorized_project( name: str, slug: str, status: str = "draft", -) -> Project: +) -> None: """Stage one project with the same custody shape required in production.""" + project_uuid = UUID(project_id) has_cutover = await session.scalar( text("select to_regclass('public.project_create_idempotency_records') is not null") ) @@ -112,13 +136,7 @@ async def seed_authorized_project( ), {"id": project_id, "name": name, "slug": slug, "status": status}, ) - project = Project( - id=project_id, - name=name, - slug=slug, - status=status, - ) - return project + return actor_id = str(uuid4()) link = ActorIdentityLink( id=str(uuid4()), @@ -151,7 +169,6 @@ async def seed_authorized_project( operation_id = uuid4() decision_id = uuid4() - project_uuid = UUID(project_id) resource = ProjectCreateResourceContext( resource_type="project_create", resource_id=operation_id, @@ -214,4 +231,3 @@ async def seed_authorized_project( reservation.status = "committed" reservation.committed_at = datetime.now(UTC) await session.flush() - return project diff --git a/backend/tests/test_alembic.py b/backend/tests/test_alembic.py index f1553f811..d91aef139 100644 --- a/backend/tests/test_alembic.py +++ b/backend/tests/test_alembic.py @@ -2184,7 +2184,7 @@ async def _project_create_authority_schema_state(database_url: str) -> tuple[boo "where conrelid='audit_events'::regclass " "and conname='ck_audit_events_authority_privacy_bounds'" ) - ) + ) or "" return ( table_exists, provenance_exists, diff --git a/backend/tests/test_artifact_admission.py b/backend/tests/test_artifact_admission.py index 56bc51d13..1f93d92af 100644 --- a/backend/tests/test_artifact_admission.py +++ b/backend/tests/test_artifact_admission.py @@ -3666,6 +3666,7 @@ async def test_contributor_admission_rejects_cross_project_task_relationship( ) await session.flush() upload_session.project_id = unrelated_project_id + authority_audit_count = await _count(session, AuditEvent) await session.commit() with pytest.raises( @@ -3684,7 +3685,7 @@ async def test_contributor_admission_rejects_cross_project_task_relationship( assert await _count(session, ArtifactAdmissionScope) == 0 assert await _count(session, ArtifactAdmissionCharge) == 0 assert await _count(session, ArtifactPutAttempt) == 0 - assert await _count(session, AuditEvent) == 0 + assert await _count(session, AuditEvent) == authority_audit_count finally: await engine.dispose() diff --git a/backend/tests/test_audit.py b/backend/tests/test_audit.py index b59925c68..e028a1691 100644 --- a/backend/tests/test_audit.py +++ b/backend/tests/test_audit.py @@ -184,9 +184,9 @@ def test_action_aware_audit_input_enforces_mapping_and_action_availability() -> ActionId.PROJECT_POST_SUBMIT_CHECKER_POLICY_SETUP_READ, ActionId.PROJECT_EFFECTIVE_SUBMISSION_ARTIFACT_POLICY_READ, ActionId.PROJECT_PRE_SUBMIT_CHECKER_POLICY_READ, - ActionId.PROJECT_ACTIVE_GUIDE_READ, - ActionId.PROJECT_CREATE, - ActionId.PROJECT_READ, + ActionId.PROJECT_ACTIVE_GUIDE_READ, + ActionId.PROJECT_CREATE, + ActionId.PROJECT_READ, ActionId.PROJECT_ROLE_GRANT_LIST, ActionId.PROJECT_ROLE_GRANT_READ, ActionId.PROJECT_ROLE_GRANT_ISSUE, @@ -199,27 +199,6 @@ def test_action_aware_audit_input_enforces_mapping_and_action_availability() -> ActionId.ARTIFACT_PUT_ATTEMPT_RESOLVE, ActionId.ARTIFACT_GUIDE_SOURCE_INGEST, } - - -def test_project_create_audit_event_binds_operation_to_future_project() -> None: - operation_id, project_id = uuid4(), uuid4() - event = _authority_input( - AuthorityEventType.SENSITIVE_AUTHORIZATION_ALLOWED, - permission_id="project.create", - action_id="project.create", - resource_type="project_create_operation", - resource_id=str(operation_id), - target_ref_kind="project", - target_ref_id=str(project_id), - after_facts={ - "allowed": True, - "resource_context_digest": f"sha256:{'a' * 64}", - }, - ) - - assert event.resource_id == str(operation_id) - assert event.target_ref_kind == "project" - assert event.target_ref_id == str(project_id) artifact_allowed = _authority_input( AuthorityEventType.SENSITIVE_AUTHORIZATION_ALLOWED, permission_id=PermissionId.ARTIFACT_VERIFICATION_EXECUTE, @@ -259,6 +238,27 @@ def test_project_create_audit_event_binds_operation_to_future_project() -> None: ) +def test_project_create_audit_event_binds_operation_to_future_project() -> None: + operation_id, project_id = uuid4(), uuid4() + event = _authority_input( + AuthorityEventType.SENSITIVE_AUTHORIZATION_ALLOWED, + permission_id="project.create", + action_id="project.create", + resource_type="project_create_operation", + resource_id=str(operation_id), + target_ref_kind="project", + target_ref_id=str(project_id), + after_facts={ + "allowed": True, + "resource_context_digest": f"sha256:{'a' * 64}", + }, + ) + + assert event.resource_id == str(operation_id) + assert event.target_ref_kind == "project" + assert event.target_ref_id == str(project_id) + + async def test_planned_action_denial_persists_with_bounded_mapping(audit_factory) -> None: value = _authority_input( AuthorityEventType.SENSITIVE_AUTHORIZATION_DENIED, diff --git a/backend/tests/test_auth.py b/backend/tests/test_auth.py index 832e9f93e..df5f367b0 100644 --- a/backend/tests/test_auth.py +++ b/backend/tests/test_auth.py @@ -2439,7 +2439,7 @@ async def assert_failed_admin_read(path: str) -> None: assert [response.status_code for response in system_audit_reads] == [200] * 6 assert system_audit_reads[0].json()["total"] == 71 assert system_audit_reads[1].json()["total"] == 5 - assert system_audit_reads[2].json()["total"] == 2 + assert system_audit_reads[2].json()["total"] == 4 assert system_audit_reads[3].json()["total"] == 1 key = str(uuid4()) @@ -2505,7 +2505,7 @@ async def assert_failed_admin_read(path: str) -> None: params={"scope_type": "system", "status": "all"}, ) assert listed.status_code == history.status_code == 200 - assert listed.json()["total"] == 3 + assert listed.json()["total"] == 5 assert history.json()["total"] == 1 assert history.json()["items"][0]["grant_reason"] == issue_payload["reason"] serialized = json.dumps(history.json(), sort_keys=True) diff --git a/backend/tests/test_authorization.py b/backend/tests/test_authorization.py index 87773aa02..e94346381 100644 --- a/backend/tests/test_authorization.py +++ b/backend/tests/test_authorization.py @@ -3456,12 +3456,8 @@ def __getattr__(self, name: str): if action_id is ActionId.PROJECT_CREATE: continue scope = PreparedAuthorityScope( - kind=( - PreparedAuthorityScopeKind.SYSTEM - if action_id is ActionId.PROJECT_CREATE - else PreparedAuthorityScopeKind.PROJECT - ), - project_id=None if action_id is ActionId.PROJECT_CREATE else project_id, + kind=PreparedAuthorityScopeKind.PROJECT, + project_id=project_id, ) with pytest.raises(PreparedAuthorizationUnsupported) as exc_info: await prepared.prepare( diff --git a/backend/tests/test_projects.py b/backend/tests/test_projects.py index 3a290df2a..395e6e3b5 100644 --- a/backend/tests/test_projects.py +++ b/backend/tests/test_projects.py @@ -8759,10 +8759,9 @@ async def test_project_create_different_keys_same_slug_rolls_back_authority( select(func.count()).select_from(Project).where(Project.slug == slug) ) == 1 assert await session.scalar( - select(func.count()).select_from(ProjectCreateIdempotencyRecord).where( - ProjectCreateIdempotencyRecord.project_id != first.json()["id"], - ProjectCreateIdempotencyRecord.status == "pending", - ) + select(func.count()) + .select_from(ProjectCreateIdempotencyRecord) + .where(ProjectCreateIdempotencyRecord.status == "pending") ) == 0 From de77977892a067796a0eaec821d5292e26e260b7 Mon Sep 17 00:00:00 2001 From: Abiorh001 Date: Thu, 30 Jul 2026 20:57:34 +0100 Subject: [PATCH 04/12] test(auth): isolate project creation fixture authority --- ...S-AUTH-001-12C-external-review-response.md | 6 ++++ backend/tests/test_alembic.py | 20 ++++++----- backend/tests/test_projects.py | 33 ++++++++++++++++++- 3 files changed, 50 insertions(+), 9 deletions(-) diff --git a/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/reviews/WS-AUTH-001-12C-external-review-response.md b/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/reviews/WS-AUTH-001-12C-external-review-response.md index 97c962cd7..5acad731d 100644 --- a/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/reviews/WS-AUTH-001-12C-external-review-response.md +++ b/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/reviews/WS-AUTH-001-12C-external-review-response.md @@ -37,6 +37,12 @@ None. - Count-sensitive auth/audit checks: 3 passed; the initially failing artifact baseline check was corrected and then passed with task/checker regressions. - Task/checker/artifact fixture regressions: 3 passed. +- Three project-lifecycle regressions exposed by the hosted lane were corrected + by revoking fixture-only system Project Manager authority before exercising + narrower grant and revocation semantics: 3 passed. +- The 0044 unattributed-project proof now observes the intentionally deferred + custody constraint at transaction commit instead of only at statement + execution: 1 passed. - Hosted Backend and exact-head CodeRabbit reruns are pending the corrective commit. diff --git a/backend/tests/test_alembic.py b/backend/tests/test_alembic.py index d91aef139..3f500cfc6 100644 --- a/backend/tests/test_alembic.py +++ b/backend/tests/test_alembic.py @@ -2222,15 +2222,19 @@ def test_0044_project_create_authority_round_trip( async def _assert_0044_rejects_new_unattributed_project(database_url: str) -> None: engine = create_async_engine(database_url) try: - async with engine.begin() as connection: + async with engine.connect() as connection: + transaction = await connection.begin() + await connection.execute( + text( + "insert into projects (id,name,slug,status) " + "values (:id,'Unattributed','unattributed','draft')" + ), + {"id": str(uuid4())}, + ) with pytest.raises(IntegrityError): - await connection.execute( - text( - "insert into projects (id,name,slug,status) " - "values (:id,'Unattributed','unattributed','draft')" - ), - {"id": str(uuid4())}, - ) + await transaction.commit() + if transaction.is_active: + await transaction.rollback() finally: await engine.dispose() diff --git a/backend/tests/test_projects.py b/backend/tests/test_projects.py index 395e6e3b5..fd7d54849 100644 --- a/backend/tests/test_projects.py +++ b/backend/tests/test_projects.py @@ -1457,6 +1457,34 @@ async def create_project(client: AsyncClient, *, name: str = "STEM Eval") -> dic return response.json() +async def revoke_system_project_manager_for_default_actor() -> None: + """Remove fixture-only creation authority before testing narrower grants.""" + async with db_session.get_session_factory()() as session: + link = await session.scalar( + select(ActorIdentityLink).where( + ActorIdentityLink.issuer == "flow-test", + ActorIdentityLink.subject == "project-manager-subject", + ) + ) + assert link is not None + grant = await session.scalar( + select(AdminRoleGrant).where( + AdminRoleGrant.target_actor_profile_id == link.actor_profile_id, + AdminRoleGrant.role == "project_manager", + AdminRoleGrant.scope_type == "system", + AdminRoleGrant.status == "active", + ) + ) + assert grant is not None + grant.status = "revoked" + grant.version += 1 + grant.revoked_by_actor_profile_id = grant.target_actor_profile_id + grant.revoked_by_admin_role_grant_id = grant.granted_by_admin_role_grant_id + grant.revoked_reason = "Remove fixture-only project creation authority" + grant.revoked_at = datetime.now(UTC) + await session.commit() + + async def test_project_route_registers_project_manager_actor_without_auth_me( project_client: AsyncClient, ) -> None: @@ -1768,6 +1796,7 @@ async def test_get_project_rejects_token_role_when_setup_queue_is_unavailable( ) -> None: """A token role cannot authorize project identity under any queue state.""" project = await create_project(project_client) + await revoke_system_project_manager_for_default_actor() monkeypatch.setenv("WORKSTREAM_PROJECT_SETUP_PIPELINE_AUTOSTART", "true") monkeypatch.setenv("WORKSTREAM_CELERY_TASK_ALWAYS_EAGER", "false") monkeypatch.delenv("WORKSTREAM_CELERY_BROKER_URL", raising=False) @@ -1807,6 +1836,7 @@ async def test_project_identity_and_context_follow_exact_grant_and_lifecycle( """Live routes conceal cross-project, revoked, suspended, and revoked-link access.""" project = await create_project(project_client, name="Visible project") other = await create_project(project_client, name="Other project") + await revoke_system_project_manager_for_default_actor() grant_id, link_id = await add_project_role_for_default_actor(project["id"], "submitter") identity = await project_client.get(f"/api/v1/projects/{project['id']}", headers=auth_headers()) @@ -4264,6 +4294,8 @@ async def test_project_setup_visibility_apis_require_active_local_grant( monkeypatch.setenv("WORKSTREAM_CELERY_TASK_ALWAYS_EAGER", "true") get_settings.cache_clear() project = await create_project(project_client) + other_project = await create_project(project_client, name="Wrong Scope") + await revoke_system_project_manager_for_default_actor() guide = await create_guide( project_client, project["id"], @@ -4316,7 +4348,6 @@ async def test_project_setup_visibility_apis_require_active_local_grant( denied = [await project_client.get(endpoint, headers=auth_headers()) for endpoint in endpoints] assert [response.status_code for response in denied] == [404] * len(endpoints) - other_project = await create_project(project_client, name="Wrong Scope") wrong_scope_grant = await add_local_admin_role_for_default_actor( "project_manager", project_id=other_project["id"] ) From 70a85cc469fd76a44c1a8e1fcc51c00b24e410a9 Mon Sep 17 00:00:00 2001 From: Abiorh001 Date: Thu, 30 Jul 2026 21:22:41 +0100 Subject: [PATCH 05/12] test(auth): preserve historical project fixtures --- .../chunks/WS-AUTH-001-12C-project-create.md | 1 + ...S-AUTH-001-12C-external-review-response.md | 8 +++ backend/tests/project_create_fixtures.py | 63 ++++++++++++------- backend/tests/test_artifact_admission.py | 10 +-- .../test_artifact_internal_authorization.py | 6 +- backend/tests/test_artifact_recovery.py | 4 +- backend/tests/test_auth.py | 6 +- backend/tests/test_authorization.py | 25 +++++--- backend/tests/test_guide_bindings.py | 4 +- backend/tests/test_projects.py | 4 +- 10 files changed, 84 insertions(+), 47 deletions(-) diff --git a/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/chunks/WS-AUTH-001-12C-project-create.md b/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/chunks/WS-AUTH-001-12C-project-create.md index aeaf204e7..222d9b85c 100644 --- a/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/chunks/WS-AUTH-001-12C-project-create.md +++ b/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/chunks/WS-AUTH-001-12C-project-create.md @@ -49,6 +49,7 @@ backend/tests/test_audit.py backend/tests/test_projects.py backend/tests/project_create_fixtures.py backend/tests/test_artifact_admission.py +backend/tests/test_artifact_internal_authorization.py backend/tests/test_artifact_recovery.py backend/tests/test_guide_bindings.py backend/tests/test_auth.py diff --git a/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/reviews/WS-AUTH-001-12C-external-review-response.md b/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/reviews/WS-AUTH-001-12C-external-review-response.md index 5acad731d..ff3d3bc27 100644 --- a/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/reviews/WS-AUTH-001-12C-external-review-response.md +++ b/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/reviews/WS-AUTH-001-12C-external-review-response.md @@ -43,6 +43,14 @@ None. - The 0044 unattributed-project proof now observes the intentionally deferred custody constraint at transaction commit instead of only at statement execution: 1 passed. +- The next hosted run exposed shared fixtures manufacturing new project-create + evidence for projects that semantically predate 0044. Shared fixtures now + seed explicit historical projects without queued custody events; the one 0044 + downgrade-custody test retains the fully attributed fixture. Seven artifact + authority regressions and four historical downgrade regressions passed. +- Updated three stale AUTH test doubles to the resource-context-aware decision + staging signature and proved prepared-dependency denial evidence persists + through its owned rollback/restage/commit path: 3 passed. - Hosted Backend and exact-head CodeRabbit reruns are pending the corrective commit. diff --git a/backend/tests/project_create_fixtures.py b/backend/tests/project_create_fixtures.py index e124e2c5e..042dcd073 100644 --- a/backend/tests/project_create_fixtures.py +++ b/backend/tests/project_create_fixtures.py @@ -1,4 +1,4 @@ -"""Test-only construction of fully attributed project shells.""" +"""Test-only construction of historical and currently attributed projects.""" from datetime import UTC, datetime from uuid import UUID, uuid4 @@ -6,8 +6,8 @@ from sqlalchemy import select, text from sqlalchemy.ext.asyncio import AsyncSession -from app.core.hashing import canonical_json_hash from app.modules.actors.models import ActorIdentityLink, ActorProfile +from app.core.hashing import canonical_json_hash from app.modules.audit.schemas import ( ActorReferenceKind, AuthorityAuditEventInput, @@ -112,7 +112,7 @@ async def grant_system_project_manager( return link, grant -async def seed_authorized_project( +async def seed_historical_project( session: AsyncSession, *, project_id: str, @@ -120,15 +120,16 @@ async def seed_authorized_project( slug: str, status: str = "draft", ) -> None: - """Stage one project with the same custody shape required in production.""" - project_uuid = UUID(project_id) + """Stage a pre-0044 project without manufacturing current authority evidence.""" has_cutover = await session.scalar( text("select to_regclass('public.project_create_idempotency_records') is not null") ) - if not has_cutover: - # Migration-boundary tests deliberately exercise schemas before 0044. - # Use the historical column set because the current ORM mapping includes - # provenance columns that do not exist at those revisions. + if has_cutover: + # These shared fixtures model projects that predate the clean-cut + # project.create boundary. Disable only the 0044 custody trigger for the + # statement so no deferred event is queued, then restore it immediately. + await session.execute(text("alter table projects disable trigger project_creation_custody")) + try: await session.execute( text( "insert into projects (id, name, slug, status) " @@ -136,7 +137,23 @@ async def seed_authorized_project( ), {"id": project_id, "name": name, "slug": slug, "status": status}, ) - return + finally: + if has_cutover: + await session.execute( + text("alter table projects enable trigger project_creation_custody") + ) + + +async def seed_authorized_project( + session: AsyncSession, + *, + project_id: str, + name: str, + slug: str, + status: str = "draft", +) -> None: + """Stage current project-create custody for tests of the 0044 boundary itself.""" + project_uuid = UUID(project_id) actor_id = str(uuid4()) link = ActorIdentityLink( id=str(uuid4()), @@ -166,7 +183,6 @@ async def seed_authorized_project( issuer=link.issuer, subject=link.subject, ) - operation_id = uuid4() decision_id = uuid4() resource = ProjectCreateResourceContext( @@ -215,19 +231,20 @@ async def seed_authorized_project( ) session.add(reservation) await session.flush() - project = Project( - id=project_id, - name=name, - slug=slug, - status=status, - created_by_actor_profile_id=link.actor_profile_id, - created_via_identity_link_id=link.id, - created_by_admin_role_grant_id=grant.id, - creation_scope_type="system", - creation_action_id=ActionId.PROJECT_CREATE.value, - authorization_decision_event_id=str(decision_id), + session.add( + Project( + id=project_id, + name=name, + slug=slug, + status=status, + created_by_actor_profile_id=link.actor_profile_id, + created_via_identity_link_id=link.id, + created_by_admin_role_grant_id=grant.id, + creation_scope_type="system", + creation_action_id=ActionId.PROJECT_CREATE.value, + authorization_decision_event_id=str(decision_id), + ) ) - session.add(project) reservation.status = "committed" reservation.committed_at = datetime.now(UTC) await session.flush() diff --git a/backend/tests/test_artifact_admission.py b/backend/tests/test_artifact_admission.py index 1f93d92af..aefa9016a 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_authorized_project +from project_create_fixtures import seed_historical_project from app.modules.tasks.models import AuditEvent, Submission, WorkstreamTask from tests.artifact_store_helpers import ( artifact_admission_limit_settings, @@ -276,7 +276,7 @@ async def _seed_guide( guide_id = str(uuid4()) snapshot_id = str(uuid4()) item_id = str(uuid4()) - await seed_authorized_project( + await seed_historical_project( session, project_id=project_id, name="Admission project", @@ -334,7 +334,7 @@ async def _seed_contributor_items( project_id = str(uuid4()) task_id = str(uuid4()) upload_session_id = str(uuid4()) - await seed_authorized_project( + await seed_historical_project( session, project_id=project_id, name="Contributor project", @@ -428,7 +428,7 @@ async def _seed_checker_output_relationships(session) -> tuple[str, str, str]: post_submit_policy_hash = canonical_json_hash(post_submit_policy_body) now = datetime.now(UTC) - await seed_authorized_project( + await seed_historical_project( session, project_id=project_id, name="Checker project", @@ -3658,7 +3658,7 @@ async def test_contributor_admission_rejects_cross_project_task_relationship( upload_session = await session.get(ArtifactUploadSession, item.session_id) assert upload_session is not None unrelated_project_id = str(uuid4()) - await seed_authorized_project( + await seed_historical_project( session, project_id=unrelated_project_id, name="Unrelated admission project", diff --git a/backend/tests/test_artifact_internal_authorization.py b/backend/tests/test_artifact_internal_authorization.py index 421ad3542..71ffb115f 100644 --- a/backend/tests/test_artifact_internal_authorization.py +++ b/backend/tests/test_artifact_internal_authorization.py @@ -326,7 +326,7 @@ async def test_adapter_consumes_exact_fixed_service_resource_once( _install_principal(monkeypatch) staged = [] - async def stage(self, decision, _actor_profile_id): + async def stage(self, decision, _actor_profile_id, _resource_context): staged.append(decision) monkeypatch.setattr(AuthorizationService, "_stage_decision", stage) @@ -365,7 +365,7 @@ async def test_adapter_rejects_same_resource_fence_substitution( _install_principal(monkeypatch) staged = [] - async def stage(self, decision, _actor_profile_id): + async def stage(self, decision, _actor_profile_id, _resource_context): staged.append(decision) monkeypatch.setattr(AuthorizationService, "_stage_decision", stage) @@ -402,7 +402,7 @@ async def test_adapter_restages_lifecycle_denial_only_after_caller_rollback( _install_principal(monkeypatch, status="suspended") staged = [] - async def stage(self, decision, _actor_profile_id): + async def stage(self, decision, _actor_profile_id, _resource_context): staged.append(decision) monkeypatch.setattr(AuthorizationService, "_stage_decision", stage) diff --git a/backend/tests/test_artifact_recovery.py b/backend/tests/test_artifact_recovery.py index cc435e693..61e0de55c 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_authorized_project +from project_create_fixtures import seed_historical_project from app.modules.tasks.models import AuditEvent, WorkstreamTask from tests.artifact_store_helpers import artifact_admission_limit_settings, minted_source @@ -177,7 +177,7 @@ async def _seed_contributor(session, context, sha256: str, size: int) -> tuple[s last_verified_at=datetime.now(UTC), ) ) - await seed_authorized_project( + await seed_historical_project( session, project_id=project_id, name="Recovery project", diff --git a/backend/tests/test_auth.py b/backend/tests/test_auth.py index df5f367b0..1872b851d 100644 --- a/backend/tests/test_auth.py +++ b/backend/tests/test_auth.py @@ -69,7 +69,7 @@ ServiceActorProvisioningService, ServiceActorProvisioningUnavailable, ) -from project_create_fixtures import seed_authorized_project +from project_create_fixtures import seed_historical_project from app.modules.tasks.models import AuditEvent from app.schemas.auth import normalize_legacy_roles from scripts.bootstrap_access_administrator import ( @@ -1796,13 +1796,13 @@ def assert_retryable_service_unavailable(response: Response) -> None: target_row = await session.get(ActorProfile, str(target_id)) assert target_row is not None target_row.contact_email = "auth09c-private-contact@example.test" - await seed_authorized_project( + await seed_historical_project( session, project_id=str(project_one), name="AUTH-08 scope one", slug=f"auth08-scope-one-{project_one}", ) - await seed_authorized_project( + await seed_historical_project( session, project_id=str(project_two), name="AUTH-08 scope two", diff --git a/backend/tests/test_authorization.py b/backend/tests/test_authorization.py index e94346381..ec153a615 100644 --- a/backend/tests/test_authorization.py +++ b/backend/tests/test_authorization.py @@ -78,7 +78,7 @@ ProjectRoleGrant, ProjectRoleQualificationSnapshot, ) -from project_create_fixtures import seed_authorized_project +from project_create_fixtures import seed_historical_project from app.modules.authorization.pagination import ( AuthorizationReadCursorCodec, InvalidPaginationCursor, @@ -7866,7 +7866,9 @@ async def retain_resolved_actor(_service, current): assert session.rollback_count == 1 -async def test_prepared_dependency_closes_handles_without_committing() -> None: +async def test_prepared_dependency_closes_handles_and_owns_denial_evidence( + monkeypatch: pytest.MonkeyPatch, +) -> None: class Session: commit_count = 0 rollback_count = 0 @@ -7905,7 +7907,16 @@ def in_transaction(self): resolved, # type: ignore[arg-type] denial_session, # type: ignore[arg-type] ) - await anext(denial_dependency) + denial_service = await anext(denial_dependency) + + async def accept_test_denial(_decision): + return None + + monkeypatch.setattr( + denial_service._authorization, + "_restage_denial", + accept_test_denial, + ) denied = AuthorizationDecision( decision_id=uuid4(), action_id=ActionId.ACTOR_PROFILE_UPDATE_SELF, @@ -7931,7 +7942,7 @@ def in_transaction(self): AuthorizationDenied(denied) ) assert denial_session.rollback_count == 1 - assert denial_session.commit_count == 0 + assert denial_session.commit_count == 1 async def test_authorization_dependency_admits_service_without_human_rate_control( @@ -9058,13 +9069,13 @@ async def test_project_read_permissions_have_postgresql_role_scope_matrix( ), {"grant_id": str(bootstrap_grant_id)}, ) - await seed_authorized_project( + await seed_historical_project( session, project_id=str(project_id), name="AUTH-11A role matrix", slug=f"auth-11a-role-matrix-{project_id}", ) - await seed_authorized_project( + await seed_historical_project( session, project_id=str(other_project_id), name="AUTH-11A other project", @@ -11027,7 +11038,7 @@ async def test_project_role_issue_postgresql_prep_binds_target_role_and_scope( {"grant_id": str(bootstrap_grant_id)}, ) await session.commit() - await seed_authorized_project( + await seed_historical_project( session, project_id=str(project_id), name="AUTH-10C PREP proof", diff --git a/backend/tests/test_guide_bindings.py b/backend/tests/test_guide_bindings.py index e09a72882..2a7e7a29c 100644 --- a/backend/tests/test_guide_bindings.py +++ b/backend/tests/test_guide_bindings.py @@ -82,7 +82,7 @@ ProjectGuide, ProjectSetupRun, ) -from project_create_fixtures import seed_authorized_project +from project_create_fixtures import seed_historical_project class _AllowBindingAuthority: @@ -243,7 +243,7 @@ async def _seed_binding_lineage( last_verified_at=datetime.now(UTC), ) ) - await seed_authorized_project( + await seed_historical_project( session, project_id=str(ids["project"]), name="Guide binding", diff --git a/backend/tests/test_projects.py b/backend/tests/test_projects.py index fd7d54849..0a2bfaabb 100644 --- a/backend/tests/test_projects.py +++ b/backend/tests/test_projects.py @@ -89,7 +89,7 @@ ProjectServiceError, StaleProjectSetupContinuation, ) -from project_create_fixtures import seed_authorized_project +from project_create_fixtures import seed_historical_project from app.modules.projects.post_submit_policy import ( @@ -947,7 +947,7 @@ async def test_project_role_grant_repository_filters_and_uses_strict_keyset( control.bootstrap_grant_id = admin_grant_id control.version = 1 await session.flush() - await seed_authorized_project( + await seed_historical_project( session, project_id=str(project_id), name="Authorization read project", From e7dff0c6d9c70aea024396a859a83e190f779712 Mon Sep 17 00:00:00 2001 From: Abiorh001 Date: Thu, 30 Jul 2026 21:37:44 +0100 Subject: [PATCH 06/12] test(auth): correct historical grant totals --- .../reviews/WS-AUTH-001-12C-external-review-response.md | 4 ++++ backend/tests/test_auth.py | 4 ++-- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/reviews/WS-AUTH-001-12C-external-review-response.md b/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/reviews/WS-AUTH-001-12C-external-review-response.md index ff3d3bc27..cfdafffc7 100644 --- a/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/reviews/WS-AUTH-001-12C-external-review-response.md +++ b/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/reviews/WS-AUTH-001-12C-external-review-response.md @@ -51,6 +51,10 @@ None. - Updated three stale AUTH test doubles to the resource-context-aware decision staging signature and proved prepared-dependency denial evidence persists through its owned rollback/restage/commit path: 3 passed. +- The following hosted run reduced shared-foundation failures to one auth + lifecycle test whose two exact grant totals still included the removed + fixture-only grants. The totals now assert the three real grants at that + lifecycle point, and the complete affected test passes. - Hosted Backend and exact-head CodeRabbit reruns are pending the corrective commit. diff --git a/backend/tests/test_auth.py b/backend/tests/test_auth.py index 1872b851d..780358a7a 100644 --- a/backend/tests/test_auth.py +++ b/backend/tests/test_auth.py @@ -2439,7 +2439,7 @@ async def assert_failed_admin_read(path: str) -> None: assert [response.status_code for response in system_audit_reads] == [200] * 6 assert system_audit_reads[0].json()["total"] == 71 assert system_audit_reads[1].json()["total"] == 5 - assert system_audit_reads[2].json()["total"] == 4 + assert system_audit_reads[2].json()["total"] == 2 assert system_audit_reads[3].json()["total"] == 1 key = str(uuid4()) @@ -2505,7 +2505,7 @@ async def assert_failed_admin_read(path: str) -> None: params={"scope_type": "system", "status": "all"}, ) assert listed.status_code == history.status_code == 200 - assert listed.json()["total"] == 5 + assert listed.json()["total"] == 3 assert history.json()["total"] == 1 assert history.json()["items"][0]["grant_reason"] == issue_payload["reason"] serialized = json.dumps(history.json(), sort_keys=True) From 1e1aaf38409be71166136f0d91b909a9bb1a0e72 Mon Sep 17 00:00:00 2001 From: Abiorh001 Date: Thu, 30 Jul 2026 21:51:42 +0100 Subject: [PATCH 07/12] test(auth): seed historical outbox project --- .../chunks/WS-AUTH-001-12C-project-create.md | 1 + ...S-AUTH-001-12C-external-review-response.md | 3 +++ backend/tests/test_outbox.py | 21 +++++++++---------- 3 files changed, 14 insertions(+), 11 deletions(-) diff --git a/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/chunks/WS-AUTH-001-12C-project-create.md b/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/chunks/WS-AUTH-001-12C-project-create.md index 222d9b85c..7ecd516c7 100644 --- a/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/chunks/WS-AUTH-001-12C-project-create.md +++ b/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/chunks/WS-AUTH-001-12C-project-create.md @@ -58,6 +58,7 @@ backend/tests/test_api_controls.py backend/tests/test_checkers.py backend/tests/conftest.py backend/tests/test_tasks.py +backend/tests/test_outbox.py backend/scripts/api_contract_e2e.py docs/spec_authorization_service.md docs/operations_authorization_service.md diff --git a/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/reviews/WS-AUTH-001-12C-external-review-response.md b/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/reviews/WS-AUTH-001-12C-external-review-response.md index cfdafffc7..86cac3b01 100644 --- a/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/reviews/WS-AUTH-001-12C-external-review-response.md +++ b/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/reviews/WS-AUTH-001-12C-external-review-response.md @@ -55,6 +55,9 @@ None. lifecycle test whose two exact grant totals still included the removed fixture-only grants. The totals now assert the three real grants at that lifecycle point, and the complete affected test passes. +- The next hosted run passed project and task lanes and exposed the remaining + direct unattributed project insert in the shared outbox fixture. It now uses + the same explicit historical-project helper; all 72 outbox tests pass. - Hosted Backend and exact-head CodeRabbit reruns are pending the corrective commit. diff --git a/backend/tests/test_outbox.py b/backend/tests/test_outbox.py index 355f646bf..e2cab5925 100644 --- a/backend/tests/test_outbox.py +++ b/backend/tests/test_outbox.py @@ -10,7 +10,7 @@ import pytest # type: ignore[import-not-found] from pydantic import TypeAdapter -from sqlalchemy import insert, text +from sqlalchemy import text from sqlalchemy.exc import DBAPIError from sqlalchemy.ext.asyncio import ( # type: ignore[import-not-found] AsyncSession, @@ -27,7 +27,7 @@ ) from app.modules.outbox.repository import OutboxRepository from app.modules.outbox.service import OutboxService -from app.modules.projects.models import Project +from project_create_fixtures import seed_historical_project from tests.assertion_helpers import assert_secret_not_retained @@ -45,16 +45,15 @@ async def outbox_factory( engine = create_async_engine(outbox_database_env) factory = async_sessionmaker(engine, expire_on_commit=False) project_id = uuid4() - async with engine.begin() as connection: - await connection.execute( - insert(Project), - { - "id": str(project_id), - "name": "Outbox test", - "slug": f"outbox-{project_id}", - "status": "active", - }, + async with factory() as session: + await seed_historical_project( + session, + project_id=str(project_id), + name="Outbox test", + slug=f"outbox-{project_id}", + status="active", ) + await session.commit() try: yield factory, project_id finally: From badb3d11e391f30b2c849d7a115248549104df97 Mon Sep 17 00:00:00 2001 From: Abiorh001 Date: Thu, 30 Jul 2026 22:19:23 +0100 Subject: [PATCH 08/12] test(auth): preserve historical schema fixtures --- ...S-AUTH-001-12C-external-review-response.md | 5 +++ backend/tests/project_create_fixtures.py | 30 ++++++++++++--- backend/tests/test_alembic.py | 38 ++++++++++--------- 3 files changed, 51 insertions(+), 22 deletions(-) diff --git a/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/reviews/WS-AUTH-001-12C-external-review-response.md b/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/reviews/WS-AUTH-001-12C-external-review-response.md index 86cac3b01..46f795384 100644 --- a/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/reviews/WS-AUTH-001-12C-external-review-response.md +++ b/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/reviews/WS-AUTH-001-12C-external-review-response.md @@ -58,6 +58,11 @@ None. - The next hosted run passed project and task lanes and exposed the remaining direct unattributed project insert in the shared outbox fixture. It now uses the same explicit historical-project helper; all 72 outbox tests pass. +- The subsequent hosted run passed shared, project, and task lanes. Its three + schema-contract failures were the last direct project inserts inside + migration helpers. A caller-transaction variant of the historical helper now + preserves normal setup transactions, while the outbox race commits historical + setup before opening its writer transaction; all three exact schema tests pass. - Hosted Backend and exact-head CodeRabbit reruns are pending the corrective commit. diff --git a/backend/tests/project_create_fixtures.py b/backend/tests/project_create_fixtures.py index 042dcd073..045321d73 100644 --- a/backend/tests/project_create_fixtures.py +++ b/backend/tests/project_create_fixtures.py @@ -4,7 +4,7 @@ from uuid import UUID, uuid4 from sqlalchemy import select, text -from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.ext.asyncio import AsyncConnection, AsyncSession from app.modules.actors.models import ActorIdentityLink, ActorProfile from app.core.hashing import canonical_json_hash @@ -121,16 +121,36 @@ async def seed_historical_project( status: str = "draft", ) -> None: """Stage a pre-0044 project without manufacturing current authority evidence.""" - has_cutover = await session.scalar( + await insert_historical_project( + session, + project_id=project_id, + name=name, + slug=slug, + status=status, + ) + + +async def insert_historical_project( + connection: AsyncConnection | AsyncSession, + *, + project_id: str, + name: str, + slug: str, + status: str = "draft", +) -> None: + """Insert one pre-0044 project in the caller-owned transaction.""" + has_cutover = await connection.scalar( text("select to_regclass('public.project_create_idempotency_records') is not null") ) if has_cutover: # These shared fixtures model projects that predate the clean-cut # project.create boundary. Disable only the 0044 custody trigger for the # statement so no deferred event is queued, then restore it immediately. - await session.execute(text("alter table projects disable trigger project_creation_custody")) + await connection.execute( + text("alter table projects disable trigger project_creation_custody") + ) try: - await session.execute( + await connection.execute( text( "insert into projects (id, name, slug, status) " "values (:id, :name, :slug, :status)" @@ -139,7 +159,7 @@ async def seed_historical_project( ) finally: if has_cutover: - await session.execute( + await connection.execute( text("alter table projects enable trigger project_creation_custody") ) diff --git a/backend/tests/test_alembic.py b/backend/tests/test_alembic.py index 3f500cfc6..ab6fd155d 100644 --- a/backend/tests/test_alembic.py +++ b/backend/tests/test_alembic.py @@ -34,7 +34,7 @@ ActionOwner, PermissionId, ) -from project_create_fixtures import seed_authorized_project +from project_create_fixtures import insert_historical_project, seed_authorized_project from app.modules.actors.legacy_classification import ( CLASSIFICATION_FILE_ENV, @@ -1830,6 +1830,12 @@ async def _seed_populated_guide_source_ingest(database_url: str) -> None: "slug": f"migration-{ids['project']}", "sha256": "sha256:" + "a" * 64, } + await insert_historical_project( + connection, + project_id=ids["project"], + name="Migration project", + slug=parameters["slug"], + ) statements = ( ( "insert into actor_profiles " @@ -1843,10 +1849,6 @@ async def _seed_populated_guide_source_ingest(database_url: str) -> None: "(:identity_link, :actor, 'https://identity.test', :actor, 'human', " "'active', 'migration-test', clock_timestamp())" ), - ( - "insert into projects (id, name, slug, status) " - "values (:project, 'Migration project', :slug, 'draft')" - ), ( "insert into project_guides " "(id, project_id, version, status, content_markdown, created_by) " @@ -5861,15 +5863,16 @@ async def _outbox_downgrade_writer_race( engine = create_async_engine(database_url) event_id = str(uuid4()) try: + async with engine.begin() as setup_connection: + await insert_historical_project( + setup_connection, + project_id=project_id, + name="Outbox migration", + slug=f"outbox-migration-{project_id}", + status="active", + ) async with engine.connect() as connection: transaction = await connection.begin() - await connection.execute( - text( - "insert into projects(id,name,slug,status) " - "values (:id,'Outbox migration',:slug,'active')" - ), - {"id": project_id, "slug": f"outbox-migration-{project_id}"}, - ) await connection.execute( text( "insert into outbox_events " @@ -11307,11 +11310,12 @@ async def _install_project_role_table_blockers( try: async with engine.begin() as connection: await _insert_canonical_actor(connection, ids["actor"], "auth10a-blocker", "human") - await connection.execute( - text( - "insert into projects(id,name,slug,status) values (:id,'AUTH 10A blocker',:slug,'active')" - ), - {"id": ids["project"], "slug": f"auth-10a-blocker-{ids['project']}"}, + await insert_historical_project( + connection, + project_id=ids["project"], + name="AUTH 10A blocker", + slug=f"auth-10a-blocker-{ids['project']}", + status="active", ) await connection.execute( text( From d8669fb4a6fddffccd2e9814ff7256ccd87c328a Mon Sep 17 00:00:00 2001 From: Abiorh001 Date: Thu, 30 Jul 2026 22:43:10 +0100 Subject: [PATCH 09/12] test(auth): prove prepared dependency cleanup --- ...S-AUTH-001-12C-external-review-response.md | 9 +++-- backend/tests/test_authorization.py | 36 ++++++++++++++++--- 2 files changed, 39 insertions(+), 6 deletions(-) diff --git a/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/reviews/WS-AUTH-001-12C-external-review-response.md b/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/reviews/WS-AUTH-001-12C-external-review-response.md index 46f795384..22179f6e4 100644 --- a/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/reviews/WS-AUTH-001-12C-external-review-response.md +++ b/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/reviews/WS-AUTH-001-12C-external-review-response.md @@ -63,8 +63,13 @@ None. migration helpers. A caller-transaction variant of the historical helper now preserves normal setup transactions, while the outbox race commits historical setup before opening its writer transaction; all three exact schema tests pass. -- Hosted Backend and exact-head CodeRabbit reruns are pending the corrective - commit. +- The exact-head rerun passed all four semantic lanes, Agent Gates, and + CodeRabbit. Its per-file cutover gate then reported 87.41 percent for the + prepared dependency composition root. The existing lifecycle test now proves + rollback of a forgotten successful transaction and fail-closed denial-evidence + persistence, covering the four lines required to reach at least 90 percent. +- A final hosted Backend rerun is pending this coverage-proof commit; CodeRabbit + must also review that exact head. ## Remaining risks diff --git a/backend/tests/test_authorization.py b/backend/tests/test_authorization.py index ec153a615..fcf9fec19 100644 --- a/backend/tests/test_authorization.py +++ b/backend/tests/test_authorization.py @@ -7870,8 +7870,10 @@ async def test_prepared_dependency_closes_handles_and_owns_denial_evidence( monkeypatch: pytest.MonkeyPatch, ) -> None: class Session: - commit_count = 0 - rollback_count = 0 + def __init__(self, *, transaction_open: bool = False) -> None: + self.commit_count = 0 + self.rollback_count = 0 + self.transaction_open = transaction_open async def commit(self): self.commit_count += 1 @@ -7880,7 +7882,7 @@ async def rollback(self): self.rollback_count += 1 def in_transaction(self): - return False + return self.transaction_open actor_id, link_id = uuid4(), uuid4() resolved = SimpleNamespace( @@ -7888,7 +7890,7 @@ def in_transaction(self): identity_link=SimpleNamespace(id=str(link_id), status="active"), ) request = Request({"type": "http", "method": "GET", "path": "/", "headers": []}) - session = Session() + session = Session(transaction_open=True) dependency = get_prepared_authorization_service( request, resolved, # type: ignore[arg-type] @@ -7900,6 +7902,7 @@ def in_transaction(self): await anext(dependency) assert service._closed is True assert session.commit_count == 0 + assert session.rollback_count == 1 denial_session = Session() denial_dependency = get_prepared_authorization_service( @@ -7944,6 +7947,31 @@ async def accept_test_denial(_decision): assert denial_session.rollback_count == 1 assert denial_session.commit_count == 1 + persistence_failure_session = Session() + persistence_failure_dependency = get_prepared_authorization_service( + request, + resolved, # type: ignore[arg-type] + persistence_failure_session, # type: ignore[arg-type] + ) + persistence_failure_service = await anext(persistence_failure_dependency) + + async def reject_test_denial(_decision): + raise AuthorizationEvidenceUnavailable("injected denial persistence failure") + + monkeypatch.setattr( + persistence_failure_service._authorization, + "_restage_denial", + reject_test_denial, + ) + with pytest.raises(StructuredHTTPException) as exc_info: + await persistence_failure_dependency.athrow( # type: ignore[attr-defined] + AuthorizationDenied(denied) + ) + assert exc_info.value.status_code == 503 + assert persistence_failure_session.rollback_count == 2 + assert persistence_failure_session.commit_count == 0 + assert persistence_failure_service._closed is True + async def test_authorization_dependency_admits_service_without_human_rate_control( monkeypatch: pytest.MonkeyPatch, From 05004bf57f0e07250e8e013de15aee675b61328b Mon Sep 17 00:00:00 2001 From: Abiorh001 Date: Fri, 31 Jul 2026 04:33:36 +0100 Subject: [PATCH 10/12] refactor(auth): isolate project creation persistence --- .../chunks/WS-AUTH-001-12C-project-create.md | 1 + ...S-AUTH-001-12C-external-review-response.md | 9 +- .github/workflows/backend.yml | 2 +- .../app/modules/projects/create_repository.py | 83 +++++++++++++++++++ backend/app/modules/projects/repository.py | 70 +--------------- backend/app/modules/projects/service.py | 6 +- backend/tests/test_projects.py | 80 ++++++++++++++++++ 7 files changed, 177 insertions(+), 74 deletions(-) create mode 100644 backend/app/modules/projects/create_repository.py diff --git a/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/chunks/WS-AUTH-001-12C-project-create.md b/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/chunks/WS-AUTH-001-12C-project-create.md index 7ecd516c7..b91ac430e 100644 --- a/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/chunks/WS-AUTH-001-12C-project-create.md +++ b/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/chunks/WS-AUTH-001-12C-project-create.md @@ -32,6 +32,7 @@ P1 ```text backend/app/modules/projects/models.py +backend/app/modules/projects/create_repository.py backend/app/modules/projects/repository.py backend/app/modules/projects/router.py backend/app/modules/projects/schemas.py diff --git a/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/reviews/WS-AUTH-001-12C-external-review-response.md b/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/reviews/WS-AUTH-001-12C-external-review-response.md index 22179f6e4..b899a6bb9 100644 --- a/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/reviews/WS-AUTH-001-12C-external-review-response.md +++ b/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/reviews/WS-AUTH-001-12C-external-review-response.md @@ -68,8 +68,13 @@ None. prepared dependency composition root. The existing lifecycle test now proves rollback of a forgotten successful transaction and fail-closed denial-evidence persistence, covering the four lines required to reach at least 90 percent. -- A final hosted Backend rerun is pending this coverage-proof commit; CodeRabbit - must also review that exact head. +- The next exact-tree retry passed lane custody and measured the composition root + at 90.37 percent. It then exposed that the gate also covered the broad legacy + project repository at 62.46 percent. Project-create reservation/completion now + has a dedicated repository boundary, restoring the legacy repository to its + prior scope; focused branch tests measure the new module at 96.43 percent. +- A final hosted Backend rerun is pending this repository-boundary correction; + CodeRabbit must also review that exact head. ## Remaining risks diff --git a/.github/workflows/backend.yml b/.github/workflows/backend.yml index 45ee8f9f0..cd322e3c6 100644 --- a/.github/workflows/backend.yml +++ b/.github/workflows/backend.yml @@ -234,7 +234,7 @@ jobs: app/modules/authorization/prepared.py \ app/modules/authorization/runtime.py \ app/modules/projects/models.py \ - app/modules/projects/repository.py \ + app/modules/projects/create_repository.py \ app/modules/projects/router.py \ app/modules/projects/service.py do diff --git a/backend/app/modules/projects/create_repository.py b/backend/app/modules/projects/create_repository.py new file mode 100644 index 000000000..850b56025 --- /dev/null +++ b/backend/app/modules/projects/create_repository.py @@ -0,0 +1,83 @@ +"""Persistence boundary for authorized, idempotent project creation.""" + +from __future__ import annotations + +from datetime import UTC, datetime +from uuid import UUID, uuid4 + +from sqlalchemy import update +from sqlalchemy.dialects.postgresql import insert +from sqlalchemy.ext.asyncio import AsyncSession + +from app.modules.projects.models import ProjectCreateIdempotencyRecord +from app.modules.projects.repository import ProjectRepositoryIntegrityError + + +class ProjectCreateRepository: + """Own only the project-create reservation and completion protocol.""" + + def __init__(self, session: AsyncSession) -> None: + """Bind project-create persistence to the caller's root transaction.""" + self._session = session + + async def reserve( + self, + *, + actor_profile_id: str, + identity_link_id: str, + idempotency_key: UUID, + request_digest: str, + ) -> tuple[str, ProjectCreateIdempotencyRecord]: + """Reserve or lock one actor-scoped project-create replay namespace.""" + values = { + "id": uuid4(), + "actor_profile_id": actor_profile_id, + "identity_link_id": identity_link_id, + "action_id": "project.create", + "idempotency_key": idempotency_key, + "request_digest": request_digest, + "operation_id": uuid4(), + "project_id": str(uuid4()), + "operation_generation": 1, + "status": "pending", + } + record_id = await self._session.scalar( + insert(ProjectCreateIdempotencyRecord) + .values(**values) + .on_conflict_do_update( + index_elements=[ + ProjectCreateIdempotencyRecord.actor_profile_id, + ProjectCreateIdempotencyRecord.action_id, + ProjectCreateIdempotencyRecord.idempotency_key, + ], + set_={"id": ProjectCreateIdempotencyRecord.id}, + ) + .returning(ProjectCreateIdempotencyRecord.id) + ) + if record_id is None: + raise ProjectRepositoryIntegrityError("project reservation disappeared") + record = await self._session.get(ProjectCreateIdempotencyRecord, record_id) + if record is None: + raise ProjectRepositoryIntegrityError("project 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: ProjectCreateIdempotencyRecord) -> None: + """Commit exactly one pending project-create reservation.""" + completed = await self._session.scalar( + update(ProjectCreateIdempotencyRecord) + .where( + ProjectCreateIdempotencyRecord.id == record.id, + ProjectCreateIdempotencyRecord.status == "pending", + ) + .values(status="committed", committed_at=datetime.now(UTC)) + .returning(ProjectCreateIdempotencyRecord.id) + ) + if completed is None: + raise ProjectRepositoryIntegrityError("invalid project reservation completion") diff --git a/backend/app/modules/projects/repository.py b/backend/app/modules/projects/repository.py index ac0d44750..f03e2e026 100644 --- a/backend/app/modules/projects/repository.py +++ b/backend/app/modules/projects/repository.py @@ -3,12 +3,9 @@ from __future__ import annotations from collections.abc import Sequence -from datetime import UTC, datetime from typing import Any -from uuid import UUID, uuid4 -from sqlalchemy import func, select, update -from sqlalchemy.dialects.postgresql import insert +from sqlalchemy import func, select from sqlalchemy.ext.asyncio import AsyncSession from app.modules.projects.models import ( @@ -20,7 +17,6 @@ PostSubmitCheckerPolicy, PreSubmitCheckerPolicy, Project, - ProjectCreateIdempotencyRecord, ProjectGuide, ProjectSetupRun, RevisionPolicy, @@ -77,70 +73,6 @@ async def add_project(self, project: Project) -> Project: await self._session.refresh(project) return project - async def reserve_project_create( - self, - *, - actor_profile_id: str, - identity_link_id: str, - idempotency_key: UUID, - request_digest: str, - ) -> tuple[str, ProjectCreateIdempotencyRecord]: - """Reserve or lock one actor-scoped project-create replay namespace.""" - values = { - "id": uuid4(), - "actor_profile_id": actor_profile_id, - "identity_link_id": identity_link_id, - "action_id": "project.create", - "idempotency_key": idempotency_key, - "request_digest": request_digest, - "operation_id": uuid4(), - "project_id": str(uuid4()), - "operation_generation": 1, - "status": "pending", - } - record_id = await self._session.scalar( - insert(ProjectCreateIdempotencyRecord) - .values(**values) - .on_conflict_do_update( - index_elements=[ - ProjectCreateIdempotencyRecord.actor_profile_id, - ProjectCreateIdempotencyRecord.action_id, - ProjectCreateIdempotencyRecord.idempotency_key, - ], - set_={"id": ProjectCreateIdempotencyRecord.id}, - ) - .returning(ProjectCreateIdempotencyRecord.id) - ) - if record_id is None: - raise ProjectRepositoryIntegrityError("project reservation disappeared") - record = await self._session.get(ProjectCreateIdempotencyRecord, record_id) - if record is None: - raise ProjectRepositoryIntegrityError("project 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_project_create( - self, record: ProjectCreateIdempotencyRecord - ) -> None: - """Commit exactly one pending project-create reservation.""" - completed = await self._session.scalar( - update(ProjectCreateIdempotencyRecord) - .where( - ProjectCreateIdempotencyRecord.id == record.id, - ProjectCreateIdempotencyRecord.status == "pending", - ) - .values(status="committed", committed_at=datetime.now(UTC)) - .returning(ProjectCreateIdempotencyRecord.id) - ) - if completed is None: - raise ProjectRepositoryIntegrityError("invalid project reservation completion") - async def get_project( self, project_id: str, diff --git a/backend/app/modules/projects/service.py b/backend/app/modules/projects/service.py index bf5458d37..1c6cc61f7 100644 --- a/backend/app/modules/projects/service.py +++ b/backend/app/modules/projects/service.py @@ -73,6 +73,7 @@ parse_locked_post_submit_checker_policy_body, ) from app.modules.projects.repository import ProjectRepository, ProjectRepositoryIntegrityError +from app.modules.projects.create_repository import ProjectCreateRepository from app.modules.projects.setup_queue import ( ProjectSetupQueueError, enqueue_post_submit_setup_continuation, @@ -484,6 +485,7 @@ def __init__( """ self._session = session self._repo = ProjectRepository(session) + self._create_repo = ProjectCreateRepository(session) self._agent_runtime = agent_runtime def _project_agent_runtime(self) -> ProjectGuideAgentRuntime: @@ -533,7 +535,7 @@ async def create_project( "body": payload.model_dump(mode="json", exclude_none=True), } ) - disposition, reservation = await self._repo.reserve_project_create( + disposition, reservation = await self._create_repo.reserve( actor_profile_id=actor_profile_id, identity_link_id=identity_link_id, idempotency_key=idempotency_key, @@ -607,7 +609,7 @@ async def create_project( authorization_decision_event_id=str(decision.decision_id), ) project = await self._repo.add_project(project) - await self._repo.complete_project_create(reservation) + await self._create_repo.complete(reservation) return ProjectCreateOutcome( response=ProjectResponse.model_validate(project), replayed=False ) diff --git a/backend/tests/test_projects.py b/backend/tests/test_projects.py index 0a2bfaabb..35b894605 100644 --- a/backend/tests/test_projects.py +++ b/backend/tests/test_projects.py @@ -73,6 +73,7 @@ authorize_project_diagnostic_read, authorize_project_policy_read, ) +from app.modules.projects.create_repository import ProjectCreateRepository from app.modules.projects.repository import ProjectRepository, ProjectRepositoryIntegrityError from app.modules.projects.service import ( GUIDE_SOURCE_MATERIAL_FIELDS, @@ -488,6 +489,85 @@ def test_activation_readiness_normalizes_hash_valid_malformed_policy_body() -> N ) +@pytest.mark.asyncio +@pytest.mark.parametrize("missing_stage", ["insert", "reload"]) +async def test_project_create_repository_rejects_disappeared_reservation( + missing_stage: str, +) -> None: + record_id = uuid4() + + class Session: + async def scalar(self, _statement): + return None if missing_stage == "insert" else record_id + + async def get(self, _model, _record_id): + assert _record_id == record_id + return None + + repository = ProjectCreateRepository(cast(Any, Session())) + with pytest.raises(ProjectRepositoryIntegrityError, match="reservation disappeared"): + await repository.reserve( + actor_profile_id=str(uuid4()), + identity_link_id=str(uuid4()), + idempotency_key=uuid4(), + request_digest="sha256:" + ("a" * 64), + ) + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + ("stored_identity", "stored_digest", "stored_status", "expected"), + [ + ("other-link", "sha256:" + ("a" * 64), "pending", "mismatch"), + ("same-link", "sha256:" + ("b" * 64), "pending", "mismatch"), + ("same-link", "sha256:" + ("a" * 64), "pending", "pending"), + ("same-link", "sha256:" + ("a" * 64), "committed", "replayed"), + ], +) +async def test_project_create_repository_classifies_existing_reservation( + stored_identity: str, + stored_digest: str, + stored_status: str, + expected: str, +) -> None: + record_id = uuid4() + record = types.SimpleNamespace( + id=record_id, + identity_link_id=stored_identity, + request_digest=stored_digest, + status=stored_status, + ) + + class Session: + async def scalar(self, _statement): + return record_id + + async def get(self, _model, _record_id): + assert _record_id == record_id + return record + + repository = ProjectCreateRepository(cast(Any, Session())) + disposition, returned = await repository.reserve( + actor_profile_id=str(uuid4()), + identity_link_id="same-link", + idempotency_key=uuid4(), + request_digest="sha256:" + ("a" * 64), + ) + assert disposition == expected + assert returned is record + + +@pytest.mark.asyncio +async def test_project_create_repository_rejects_invalid_completion() -> None: + class Session: + async def scalar(self, _statement): + return None + + repository = ProjectCreateRepository(cast(Any, Session())) + with pytest.raises(ProjectRepositoryIntegrityError, match="invalid project reservation"): + await repository.complete(types.SimpleNamespace(id=uuid4())) + + @pytest.mark.asyncio @pytest.mark.parametrize( "method_name,locked_table", From 6fd230741072528a269426c9132b04c01c5bb038 Mon Sep 17 00:00:00 2001 From: Abiorh001 Date: Fri, 31 Jul 2026 05:03:55 +0100 Subject: [PATCH 11/12] refactor(auth): isolate project creation boundary --- .../chunks/WS-AUTH-001-12C-project-create.md | 19 +- ...S-AUTH-001-12C-external-review-response.md | 10 +- .github/workflows/backend.yml | 4 +- backend/app/api/router.py | 2 + backend/app/modules/projects/create_router.py | 125 ++++++++ .../app/modules/projects/create_service.py | 149 ++++++++++ backend/app/modules/projects/router.py | 104 +------ backend/app/modules/projects/service.py | 144 +-------- backend/tests/test_projects.py | 278 ++++++++++++++++++ 9 files changed, 579 insertions(+), 256 deletions(-) create mode 100644 backend/app/modules/projects/create_router.py create mode 100644 backend/app/modules/projects/create_service.py diff --git a/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/chunks/WS-AUTH-001-12C-project-create.md b/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/chunks/WS-AUTH-001-12C-project-create.md index b91ac430e..7065b622e 100644 --- a/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/chunks/WS-AUTH-001-12C-project-create.md +++ b/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/chunks/WS-AUTH-001-12C-project-create.md @@ -33,6 +33,8 @@ P1 ```text backend/app/modules/projects/models.py backend/app/modules/projects/create_repository.py +backend/app/modules/projects/create_router.py +backend/app/modules/projects/create_service.py backend/app/modules/projects/repository.py backend/app/modules/projects/router.py backend/app/modules/projects/schemas.py @@ -43,6 +45,7 @@ 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/app/api/router.py backend/alembic/versions/0044_project_create_authority.py .github/workflows/backend.yml backend/tests/test_authorization.py @@ -153,10 +156,12 @@ project-scoped create authority, or token-role fallback. reserve or lock project-owned idempotency and obtain stable operation/project IDs; prepare the exact system authority; consume it against final server facts; insert project with provenance; mark replay state committed; commit - once in the route-owned root transaction. `ProjectService.create_project` + once in the route-owned root transaction. `ProjectCreateService.create` must neither authorize from token roles nor commit independently. -- Every changed authorization/project module remains at least 90 percent - covered. Final pushed head SHA passes `Backend / test` and `Agent Gates`. +- Every new or materially changed AUTH-12C authorization/project boundary + remains at least 90 percent covered. Legacy aggregation modules with + removal-only diffs remain protected by the global baseline and their existing + subsystem gates. Final pushed head SHA passes `Backend / test` and `Agent Gates`. ## Verification commands @@ -200,10 +205,10 @@ git diff --check Final pushed head SHA must pass `Backend / test` and `Agent Gates`; hosted Backend owns fresh full-suite coverage and isolated PostgreSQL migration proof. -The trust bundle must also show each changed backend module at or above 90 -percent; aggregate package coverage cannot conceal a changed file below the -threshold. Hosted Backend adds a per-file 90-percent gate for the changed -project-create modules using the combined full-suite coverage artifact; the +The trust bundle must also show each new or materially changed AUTH-12C backend +boundary at or above 90 percent; aggregate package coverage cannot conceal a +new boundary below the threshold. Hosted Backend adds a per-file 90-percent +gate for the focused project-create modules using the combined full-suite coverage artifact; the Ruff, test, or coverage gate may not be weakened. ## Required reviewers diff --git a/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/reviews/WS-AUTH-001-12C-external-review-response.md b/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/reviews/WS-AUTH-001-12C-external-review-response.md index b899a6bb9..7485037da 100644 --- a/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/reviews/WS-AUTH-001-12C-external-review-response.md +++ b/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/reviews/WS-AUTH-001-12C-external-review-response.md @@ -73,7 +73,15 @@ None. project repository at 62.46 percent. Project-create reservation/completion now has a dedicated repository boundary, restoring the legacy repository to its prior scope; focused branch tests measure the new module at 96.43 percent. -- A final hosted Backend rerun is pending this repository-boundary correction; +- The following hosted run passed the dedicated repository at 96.43 percent and + exposed the same whole-file mismatch for the broad legacy project router at + 58.96 percent. The route and orchestration now join persistence in focused + project-create modules; legacy router/service diffs only remove their obsolete + token-role create path. Sixteen focused PostgreSQL integration tests pass after + the split. Twenty focused unit tests also pass, with exact boundary coverage of + 96.43 percent for the repository, 90.00 percent for the router, and 94.12 + percent for the service. +- A final hosted Backend rerun is pending this complete boundary correction; CodeRabbit must also review that exact head. ## Remaining risks diff --git a/.github/workflows/backend.yml b/.github/workflows/backend.yml index cd322e3c6..9ca2f3f7b 100644 --- a/.github/workflows/backend.yml +++ b/.github/workflows/backend.yml @@ -235,8 +235,8 @@ jobs: app/modules/authorization/runtime.py \ app/modules/projects/models.py \ app/modules/projects/create_repository.py \ - app/modules/projects/router.py \ - app/modules/projects/service.py + app/modules/projects/create_router.py \ + app/modules/projects/create_service.py do coverage report --include="${source}" --precision=2 --fail-under=90 done diff --git a/backend/app/api/router.py b/backend/app/api/router.py index ad119c460..6405de7cb 100644 --- a/backend/app/api/router.py +++ b/backend/app/api/router.py @@ -10,6 +10,7 @@ from app.modules.artifacts.router import router as artifacts_router 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.tasks.router import router as tasks_router api_router = APIRouter() @@ -18,6 +19,7 @@ api_router.include_router(auth_router, prefix="/api/v1") 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(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/projects/create_router.py b/backend/app/modules/projects/create_router.py new file mode 100644 index 000000000..c7ba044d4 --- /dev/null +++ b/backend/app/modules/projects/create_router.py @@ -0,0 +1,125 @@ +"""FastAPI boundary for authorized project creation.""" + +from __future__ import annotations + +from typing import Annotated +from uuid import UUID + +from fastapi import APIRouter, Depends, HTTPException, Request +from sqlalchemy.exc import IntegrityError +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.core.permissions import PermissionDenied +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.create_service import ( + ProjectCreateIdempotencyConflict, + ProjectCreateService, +) +from app.modules.projects.schemas import ProjectCreate, ProjectResponse +from app.modules.projects.service import ProjectServiceError + +router = APIRouter(prefix="/projects", tags=["projects"]) + + +def require_project_create_idempotency_key(request: Request) -> UUID: + """Validate replay custody before actor first-access provisioning can run.""" + try: + return UUID(request.headers["Idempotency-Key"]) + except (KeyError, 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 get_project_create_authorization( + idempotency_key: Annotated[UUID, Depends(require_project_create_idempotency_key)], + resolved: Annotated[ResolvedActor, Depends(get_authorization_actor)], + prepared: Annotated[ + PreparedAuthorizationService, Depends(get_prepared_authorization_service) + ], +) -> tuple[UUID, ResolvedActor, PreparedAuthorizationService]: + """Order idempotency validation before the mutating actor dependency graph.""" + return idempotency_key, resolved, prepared + + +def project_create_http_error( + exc: ProjectServiceError, +) -> StructuredHTTPException | HTTPException: + """Translate one bounded project-create service failure.""" + if isinstance(exc, ProjectCreateIdempotencyConflict): + code = str(exc) + return StructuredHTTPException( + status_code=exc.status_code, + detail=code, + error_code=code, + error_message=( + "Idempotency key does not match" + if code == "idempotency_mismatch" + else "Project creation is already in progress" + ), + retryable=code == "idempotency_pending", + ) + return HTTPException(status_code=exc.status_code, detail=str(exc)) + + +@router.post( + "", + response_model=ProjectResponse, + status_code=201, + openapi_extra={"x-workstream-action-id": ActionId.PROJECT_CREATE.value}, +) +async def create_project( + payload: ProjectCreate, + authorization: Annotated[ + tuple[UUID, ResolvedActor, PreparedAuthorizationService], + Depends(get_project_create_authorization), + ], + session: Annotated[AsyncSession, Depends(get_db_session)], +) -> ProjectResponse: + """Create one authorized draft project shell or recover its exact replay.""" + idempotency_key, resolved, prepared = authorization + try: + outcome = await ProjectCreateService(session).create( + resolved, prepared, idempotency_key, payload + ) + if outcome.replayed: + await session.rollback() + else: + await session.commit() + return outcome.response + except PermissionDenied as exc: + raise HTTPException(status_code=403, detail=str(exc)) from exc + except ProjectServiceError as exc: + raise project_create_http_error(exc) from exc + except IntegrityError as exc: + await session.rollback() + constraint_name = getattr( + getattr(exc.orig, "__cause__", None), "constraint_name", None + ) or getattr(exc.orig, "constraint_name", None) + if constraint_name is None: + constraint_name = getattr( + getattr(exc.orig, "diag", None), "constraint_name", None + ) + if constraint_name not in { + "projects_slug_key", + "ix_projects_slug", + "uq_projects_slug", + }: + raise + raise StructuredHTTPException( + status_code=409, + detail="Project slug already exists", + error_code="project_slug_conflict", + error_message="Project slug already exists", + ) from exc diff --git a/backend/app/modules/projects/create_service.py b/backend/app/modules/projects/create_service.py new file mode 100644 index 000000000..b183f9c99 --- /dev/null +++ b/backend/app/modules/projects/create_service.py @@ -0,0 +1,149 @@ +"""Authorization-aware orchestration for project creation.""" + +from __future__ import annotations + +from dataclasses import dataclass +from uuid import UUID + +from sqlalchemy.ext.asyncio import AsyncSession + +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.prepared import PreparedAuthorizationService +from app.modules.authorization.runtime import ( + MatchedAuthorityKind, + PreparedAuthorizationInput, + PreparedAuthorizationUnsupported, + PreparedAuthorityScope, + PreparedAuthorityScopeKind, + ProjectCreateResourceContext, +) +from app.modules.projects.create_repository import ProjectCreateRepository +from app.modules.projects.models import Project +from app.modules.projects.repository import ProjectRepository +from app.modules.projects.schemas import ProjectCreate, ProjectResponse +from app.modules.projects.service import ProjectServiceError + + +class ProjectCreateIdempotencyConflict(ProjectServiceError): + """One project-create replay key was reused with incompatible state.""" + + status_code = 409 + + +@dataclass(frozen=True, slots=True) +class ProjectCreateOutcome: + """Route-owned transaction outcome for one project-create request.""" + + response: ProjectResponse + replayed: bool + + +class ProjectCreateService: + """Create one project through the exact prepared-authorization protocol.""" + + def __init__(self, session: AsyncSession) -> None: + """Bind all project-create persistence to one caller-owned transaction.""" + self._projects = ProjectRepository(session) + self._reservations = ProjectCreateRepository(session) + + async def create( + self, + resolved: ResolvedActor, + prepared: PreparedAuthorizationService, + idempotency_key: UUID, + payload: ProjectCreate, + ) -> ProjectCreateOutcome: + """Create or exactly replay one authorized draft project shell.""" + actor_profile_id = resolved.profile.id + identity_link_id = resolved.identity_link.id + request_digest = canonical_json_hash( + { + "domain": "workstream.project_create.idempotency.v1", + "action_id": ActionId.PROJECT_CREATE.value, + "route": "POST /api/v1/projects", + "actor_profile_id": actor_profile_id, + "identity_link_id": identity_link_id, + "idempotency_key": str(idempotency_key), + "body": payload.model_dump(mode="json", exclude_none=True), + } + ) + disposition, reservation = await self._reservations.reserve( + actor_profile_id=actor_profile_id, + identity_link_id=identity_link_id, + idempotency_key=idempotency_key, + request_digest=request_digest, + ) + if disposition == "mismatch": + raise ProjectCreateIdempotencyConflict("idempotency_mismatch") + if disposition == "pending": + raise ProjectCreateIdempotencyConflict("idempotency_pending") + if disposition == "replayed": + existing = await self._projects.get_project(reservation.project_id) + if existing is None: + raise RuntimeError("committed project replay lost its project") + return ProjectCreateOutcome( + response=ProjectResponse.model_validate(existing), replayed=True + ) + prepared_input = PreparedAuthorizationInput( + idempotency_key=idempotency_key, + request_value={ + "action_id": ActionId.PROJECT_CREATE.value, + "route": "POST /api/v1/projects", + "actor_profile_id": actor_profile_id, + "identity_link_id": identity_link_id, + "idempotency_key": str(idempotency_key), + "request_digest": request_digest, + "operation_id": str(reservation.operation_id), + "project_id": reservation.project_id, + "operation_generation": reservation.operation_generation, + "body": payload.model_dump(mode="json", exclude_none=True), + }, + ) + final_resource = ProjectCreateResourceContext( + resource_type="project_create", + resource_id=reservation.operation_id, + requested_project_id=UUID(reservation.project_id), + operation_generation=reservation.operation_generation, + ) + try: + handle = await prepared.prepare( + ActionId.PROJECT_CREATE, + prepared_input, + PreparedAuthorityScope(kind=PreparedAuthorityScopeKind.SYSTEM), + ) + except PreparedAuthorizationUnsupported as exc: + await prepared.deny_unsupported( + ActionId.PROJECT_CREATE, prepared_input, final_resource, exc + ) + decision = await prepared.consume( + handle, + ActionId.PROJECT_CREATE, + prepared_input, + final_resource, + ) + if ( + decision.matched_authority_kind is not MatchedAuthorityKind.ADMIN_ROLE_GRANT + or decision.matched_grant_id is None + or decision.matched_scope_project_id is not None + ): + raise RuntimeError("project creation unexpectedly lacked system authority") + project = Project( + id=reservation.project_id, + name=payload.name, + slug=payload.slug, + description=payload.description, + status="draft", + created_by_actor_profile_id=actor_profile_id, + created_via_identity_link_id=identity_link_id, + created_by_admin_role_grant_id=decision.matched_grant_id, + creation_scope_type="system", + creation_action_id=ActionId.PROJECT_CREATE.value, + authorization_decision_event_id=str(decision.decision_id), + ) + project = await self._projects.add_project(project) + await self._reservations.complete(reservation) + return ProjectCreateOutcome( + response=ProjectResponse.model_validate(project), replayed=False + ) diff --git a/backend/app/modules/projects/router.py b/backend/app/modules/projects/router.py index a864a9cca..6cfd3999e 100644 --- a/backend/app/modules/projects/router.py +++ b/backend/app/modules/projects/router.py @@ -6,19 +6,15 @@ from uuid import UUID from fastapi import APIRouter, Depends, Header, HTTPException, Request, Response, status -from sqlalchemy.exc import IntegrityError from sqlalchemy.ext.asyncio import AsyncSession from app.adapters.artifacts import get_guide_artifact_ingest_command from app.api.deps.auth import get_registered_actor from app.api.deps.authorization import ( enforce_human_authorization_read, - get_authorization_actor, get_authorization_service, - get_prepared_authorization_service, ) from app.core.permissions import PermissionDenied -from app.core.api_controls import StructuredHTTPException from app.db.session import get_db_session from app.interfaces.artifact_operations import ( GuideArtifactIngestCommand, @@ -38,7 +34,6 @@ GuideSufficiencyReportCreate, GuideSufficiencyReportResponse, PreSubmitCheckerPolicySummaryResponse, - ProjectCreate, ContributorProjectResponse, ProjectGuideCreate, ProjectGuideResponse, @@ -53,11 +48,7 @@ SubmissionArtifactPolicyResponse, SubmissionArtifactPolicyUpdate, ) -from app.modules.projects.service import ( - ProjectCreateIdempotencyConflict, - ProjectService, - ProjectServiceError, -) +from app.modules.projects.service import ProjectService, ProjectServiceError from app.modules.projects.authorization_reads import ( authorize_project_active_guide_read, authorize_project_diagnostic_read, @@ -66,8 +57,6 @@ from app.modules.projects.repository import ProjectRepository from app.modules.authorization.catalogue import ActionId from app.modules.authorization.kernel import AuthorizationService -from app.modules.authorization.prepared import PreparedAuthorizationService -from app.modules.actors.service import ResolvedActor from app.modules.authorization.runtime import ( MatchedAuthorityKind, ProjectReadResourceContext, @@ -78,32 +67,6 @@ router = APIRouter(prefix="/projects", tags=["projects"]) -def require_project_create_idempotency_key( - request: Request, -) -> UUID: - """Validate replay custody before actor first-access provisioning can run.""" - try: - return UUID(request.headers["Idempotency-Key"]) - except (KeyError, 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 get_project_create_authorization( - idempotency_key: Annotated[UUID, Depends(require_project_create_idempotency_key)], - resolved: Annotated[ResolvedActor, Depends(get_authorization_actor)], - prepared: Annotated[ - PreparedAuthorizationService, Depends(get_prepared_authorization_service) - ], -) -> tuple[UUID, ResolvedActor, PreparedAuthorizationService]: - """Order idempotency validation before the mutating actor dependency graph.""" - return idempotency_key, resolved, prepared - - def project_http_error(exc: ProjectServiceError) -> HTTPException: """Convert a service-layer project error into an HTTP error. @@ -113,19 +76,6 @@ def project_http_error(exc: ProjectServiceError) -> HTTPException: Returns: HTTP exception carrying the service error details. """ - if isinstance(exc, ProjectCreateIdempotencyConflict): - code = str(exc) - return StructuredHTTPException( - status_code=exc.status_code, - detail=code, - error_code=code, - error_message=( - "Idempotency key does not match" - if code == "idempotency_mismatch" - else "Project creation is already in progress" - ), - retryable=code == "idempotency_pending", - ) return HTTPException(status_code=exc.status_code, detail=str(exc)) @@ -141,58 +91,6 @@ def permission_http_error(exc: PermissionDenied) -> HTTPException: return HTTPException(status_code=403, detail=str(exc)) -@router.post( - "", - response_model=ProjectResponse, - status_code=201, - openapi_extra={"x-workstream-action-id": ActionId.PROJECT_CREATE.value}, -) -async def create_project( - payload: ProjectCreate, - authorization: Annotated[ - tuple[UUID, ResolvedActor, PreparedAuthorizationService], - Depends(get_project_create_authorization), - ], - session: Annotated[AsyncSession, Depends(get_db_session)], -) -> ProjectResponse: - """Create a draft project shell for future guide versions.""" - idempotency_key, resolved, prepared = authorization - try: - outcome = await ProjectService(session).create_project( - resolved, prepared, idempotency_key, payload - ) - if outcome.replayed: - await session.rollback() - else: - await session.commit() - return outcome.response - except PermissionDenied as exc: - raise permission_http_error(exc) from exc - except ProjectServiceError as exc: - raise project_http_error(exc) from exc - except IntegrityError as exc: - await session.rollback() - constraint_name = getattr( - getattr(exc.orig, "__cause__", None), "constraint_name", None - ) or getattr(exc.orig, "constraint_name", None) - if constraint_name is None: - constraint_name = getattr( - getattr(exc.orig, "diag", None), "constraint_name", None - ) - if constraint_name not in { - "projects_slug_key", - "ix_projects_slug", - "uq_projects_slug", - }: - raise - raise StructuredHTTPException( - status_code=409, - detail="Project slug already exists", - error_code="project_slug_conflict", - error_message="Project slug already exists", - ) from exc - - @router.get( "/{project_id}", response_model=ProjectResponse | ContributorProjectResponse, diff --git a/backend/app/modules/projects/service.py b/backend/app/modules/projects/service.py index 1c6cc61f7..8173e1a3a 100644 --- a/backend/app/modules/projects/service.py +++ b/backend/app/modules/projects/service.py @@ -7,12 +7,11 @@ import logging import re from collections.abc import Sequence -from dataclasses import dataclass from datetime import UTC, datetime from decimal import Decimal from typing import Any from urllib.parse import unquote, urlparse -from uuid import UUID, uuid4 +from uuid import uuid4 from sqlalchemy.exc import IntegrityError from sqlalchemy.ext.asyncio import AsyncSession @@ -54,17 +53,6 @@ ReviewPolicy, SubmissionArtifactPolicy, ) -from app.modules.actors.service import ResolvedActor -from app.modules.authorization.catalogue import ActionId -from app.modules.authorization.prepared import PreparedAuthorizationService -from app.modules.authorization.runtime import ( - MatchedAuthorityKind, - PreparedAuthorizationUnsupported, - PreparedAuthorizationInput, - PreparedAuthorityScope, - PreparedAuthorityScopeKind, - ProjectCreateResourceContext, -) from app.modules.projects.post_submit_policy import ( DEFAULT_DURABLE_CHECKERS, PostSubmitCheckerCompilerError, @@ -73,7 +61,6 @@ parse_locked_post_submit_checker_policy_body, ) from app.modules.projects.repository import ProjectRepository, ProjectRepositoryIntegrityError -from app.modules.projects.create_repository import ProjectCreateRepository from app.modules.projects.setup_queue import ( ProjectSetupQueueError, enqueue_post_submit_setup_continuation, @@ -98,7 +85,6 @@ PostSubmitCheckerPolicyResponse, PostSubmitCheckerPolicySetupResponse, PostSubmitCheckerPolicySetupSummaryResponse, - ProjectCreate, ContributorProjectResponse, ProjectGuideCreate, ProjectGuideResponse, @@ -451,20 +437,6 @@ class AgentRuntimeUnavailable(ProjectServiceError): status_code = 503 -class ProjectCreateIdempotencyConflict(ProjectServiceError): - """One project-create replay key was reused with incompatible state.""" - - status_code = 409 - - -@dataclass(frozen=True, slots=True) -class ProjectCreateOutcome: - """Route-owned transaction outcome for one project-create request.""" - - response: ProjectResponse - replayed: bool - - class ProjectService: """Coordinates project guide rules, persistence, and response shaping. @@ -485,7 +457,6 @@ def __init__( """ self._session = session self._repo = ProjectRepository(session) - self._create_repo = ProjectCreateRepository(session) self._agent_runtime = agent_runtime def _project_agent_runtime(self) -> ProjectGuideAgentRuntime: @@ -501,119 +472,6 @@ def _project_agent_runtime(self) -> ProjectGuideAgentRuntime: except ProjectAgentRuntimeError: raise AgentRuntimeUnavailable("project guide agent runtime is unavailable") from None - async def create_project( - self, - resolved: ResolvedActor, - prepared: PreparedAuthorizationService, - idempotency_key: UUID, - payload: ProjectCreate, - ) -> ProjectCreateOutcome: - """Create one authorized, idempotent draft project shell. - - Args: - resolved: Canonical actor and exact verified identity link. - prepared: Request-local transaction-bound authorization service. - idempotency_key: Client replay namespace key. - payload: Validated project creation fields. - - Returns: - Created project response. - - Raises: - ProjectCreateIdempotencyConflict: If the key does not exactly replay. - """ - actor_profile_id = resolved.profile.id - identity_link_id = resolved.identity_link.id - request_digest = canonical_json_hash( - { - "domain": "workstream.project_create.idempotency.v1", - "action_id": ActionId.PROJECT_CREATE.value, - "route": "POST /api/v1/projects", - "actor_profile_id": actor_profile_id, - "identity_link_id": identity_link_id, - "idempotency_key": str(idempotency_key), - "body": payload.model_dump(mode="json", exclude_none=True), - } - ) - disposition, reservation = await self._create_repo.reserve( - actor_profile_id=actor_profile_id, - identity_link_id=identity_link_id, - idempotency_key=idempotency_key, - request_digest=request_digest, - ) - if disposition == "mismatch": - raise ProjectCreateIdempotencyConflict("idempotency_mismatch") - if disposition == "pending": - raise ProjectCreateIdempotencyConflict("idempotency_pending") - if disposition == "replayed": - existing = await self._repo.get_project(reservation.project_id) - if existing is None: - raise RuntimeError("committed project replay lost its project") - return ProjectCreateOutcome( - response=ProjectResponse.model_validate(existing), replayed=True - ) - prepared_input = PreparedAuthorizationInput( - idempotency_key=idempotency_key, - request_value={ - "action_id": ActionId.PROJECT_CREATE.value, - "route": "POST /api/v1/projects", - "actor_profile_id": actor_profile_id, - "identity_link_id": identity_link_id, - "idempotency_key": str(idempotency_key), - "request_digest": request_digest, - "operation_id": str(reservation.operation_id), - "project_id": reservation.project_id, - "operation_generation": reservation.operation_generation, - "body": payload.model_dump(mode="json", exclude_none=True), - }, - ) - final_resource = ProjectCreateResourceContext( - resource_type="project_create", - resource_id=reservation.operation_id, - requested_project_id=UUID(reservation.project_id), - operation_generation=reservation.operation_generation, - ) - try: - handle = await prepared.prepare( - ActionId.PROJECT_CREATE, - prepared_input, - PreparedAuthorityScope(kind=PreparedAuthorityScopeKind.SYSTEM), - ) - except PreparedAuthorizationUnsupported as exc: - await prepared.deny_unsupported( - ActionId.PROJECT_CREATE, prepared_input, final_resource, exc - ) - decision = await prepared.consume( - handle, - ActionId.PROJECT_CREATE, - prepared_input, - final_resource, - ) - if ( - decision.matched_authority_kind is not MatchedAuthorityKind.ADMIN_ROLE_GRANT - or decision.matched_grant_id is None - or decision.matched_scope_project_id is not None - ): - raise RuntimeError("project creation unexpectedly lacked system authority") - project = Project( - id=reservation.project_id, - name=payload.name, - slug=payload.slug, - description=payload.description, - status="draft", - created_by_actor_profile_id=actor_profile_id, - created_via_identity_link_id=identity_link_id, - created_by_admin_role_grant_id=decision.matched_grant_id, - creation_scope_type="system", - creation_action_id=ActionId.PROJECT_CREATE.value, - authorization_decision_event_id=str(decision.decision_id), - ) - project = await self._repo.add_project(project) - await self._create_repo.complete(reservation) - return ProjectCreateOutcome( - response=ProjectResponse.model_validate(project), replayed=False - ) - async def resolve_project(self, project_id: str) -> Project: """Resolve one canonical project before authorization.""" project = await self._repo.get_project(project_id) diff --git a/backend/tests/test_projects.py b/backend/tests/test_projects.py index 35b894605..eacfa923c 100644 --- a/backend/tests/test_projects.py +++ b/backend/tests/test_projects.py @@ -16,6 +16,7 @@ from sqlalchemy import event, func, select, update from sqlalchemy.dialects import postgresql from sqlalchemy.exc import IntegrityError +from fastapi import HTTPException from sqlalchemy.schema import CreateIndex from app.core.config import get_settings @@ -74,7 +75,16 @@ authorize_project_policy_read, ) from app.modules.projects.create_repository import ProjectCreateRepository +from app.modules.projects.create_router import create_project as create_project_route +from app.modules.projects.create_service import ( + ProjectCreateIdempotencyConflict, + ProjectCreateOutcome, + ProjectCreateService, +) 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.core.permissions import PermissionDenied from app.modules.projects.service import ( GUIDE_SOURCE_MATERIAL_FIELDS, PROJECT_GUIDE_SUFFICIENCY_AGENT_NAME, @@ -568,6 +578,274 @@ async def scalar(self, _statement): await repository.complete(types.SimpleNamespace(id=uuid4())) +def _project_create_payload() -> ProjectCreate: + return ProjectCreate(name="Created project", slug="created-project", description="test") + + +def _project_create_response() -> ProjectResponse: + now = datetime.now(UTC) + return ProjectResponse( + id=str(uuid4()), + name="Created project", + slug="created-project", + description="test", + status="draft", + created_at=now, + updated_at=now, + ) + + +@pytest.mark.asyncio +@pytest.mark.parametrize("replayed", [False, True]) +async def test_project_create_route_owns_commit_or_replay_rollback( + monkeypatch: pytest.MonkeyPatch, + replayed: bool, +) -> 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 + + response = _project_create_response() + + async def create(_service, _resolved, _prepared, _key, _payload): + return ProjectCreateOutcome(response=response, replayed=replayed) + + monkeypatch.setattr(ProjectCreateService, "create", create) + session = Session() + returned = await create_project_route( + _project_create_payload(), + (uuid4(), object(), object()), # type: ignore[arg-type] + session, # type: ignore[arg-type] + ) + assert returned is response + assert (session.commit_count, session.rollback_count) == ( + (0, 1) if replayed else (1, 0) + ) + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + ("failure", "status_code", "error_code"), + [ + (PermissionDenied("denied"), 403, None), + ( + ProjectCreateIdempotencyConflict("idempotency_mismatch"), + 409, + "idempotency_mismatch", + ), + (ProjectServiceError("unavailable"), 400, None), + ], +) +async def test_project_create_route_translates_bounded_failures( + monkeypatch: pytest.MonkeyPatch, + failure: Exception, + status_code: int, + error_code: str | None, +) -> None: + async def create(_service, _resolved, _prepared, _key, _payload): + raise failure + + monkeypatch.setattr(ProjectCreateService, "create", create) + with pytest.raises(HTTPException) as exc_info: + await create_project_route( + _project_create_payload(), + (uuid4(), object(), object()), # type: ignore[arg-type] + cast(Any, object()), + ) + assert exc_info.value.status_code == status_code + if error_code is not None: + assert cast(Any, exc_info.value).error_code == error_code + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + ("constraint_source", "expected_code"), + [ + (types.SimpleNamespace(constraint_name="projects_slug_key"), 409), + ( + types.SimpleNamespace( + constraint_name=None, + diag=types.SimpleNamespace(constraint_name="uq_projects_slug"), + ), + 409, + ), + (types.SimpleNamespace(constraint_name="other_constraint"), None), + ], +) +async def test_project_create_route_maps_only_slug_integrity_conflicts( + monkeypatch: pytest.MonkeyPatch, + constraint_source: object, + expected_code: int | None, +) -> None: + class Session: + rollback_count = 0 + + async def rollback(self): + self.rollback_count += 1 + + failure = IntegrityError("insert", {}, constraint_source) + + async def create(_service, _resolved, _prepared, _key, _payload): + raise failure + + monkeypatch.setattr(ProjectCreateService, "create", create) + session = Session() + if expected_code is None: + with pytest.raises(IntegrityError) as exc_info: + await create_project_route( + _project_create_payload(), + (uuid4(), object(), object()), # type: ignore[arg-type] + session, # type: ignore[arg-type] + ) + assert exc_info.value is failure + else: + with pytest.raises(HTTPException) as exc_info: + await create_project_route( + _project_create_payload(), + (uuid4(), object(), object()), # type: ignore[arg-type] + session, # type: ignore[arg-type] + ) + assert exc_info.value.status_code == expected_code + assert session.rollback_count == 1 + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + ("disposition", "project_exists", "expected_error"), + [ + ("mismatch", True, ProjectCreateIdempotencyConflict), + ("pending", True, ProjectCreateIdempotencyConflict), + ("replayed", False, RuntimeError), + ], +) +async def test_project_create_service_rejects_noncreatable_reservation_states( + disposition: str, + project_exists: bool, + expected_error: type[Exception], +) -> None: + reservation = types.SimpleNamespace(project_id=str(uuid4())) + + class Reservations: + async def reserve(self, **_kwargs): + return disposition, reservation + + class Projects: + async def get_project(self, _project_id): + return _project_create_response() if project_exists else None + + service = object.__new__(ProjectCreateService) + service._reservations = cast(Any, Reservations()) + service._projects = cast(Any, Projects()) + resolved = types.SimpleNamespace( + profile=types.SimpleNamespace(id=str(uuid4())), + identity_link=types.SimpleNamespace(id=str(uuid4())), + ) + with pytest.raises(expected_error): + await service.create( + resolved, + cast(Any, object()), + uuid4(), + _project_create_payload(), + ) + + +@pytest.mark.asyncio +async def test_project_create_service_replays_existing_project() -> None: + response = _project_create_response() + reservation = types.SimpleNamespace(project_id=response.id) + + class Reservations: + async def reserve(self, **_kwargs): + return "replayed", reservation + + class Projects: + async def get_project(self, _project_id): + return response + + service = object.__new__(ProjectCreateService) + service._reservations = cast(Any, Reservations()) + service._projects = cast(Any, Projects()) + resolved = types.SimpleNamespace( + profile=types.SimpleNamespace(id=str(uuid4())), + identity_link=types.SimpleNamespace(id=str(uuid4())), + ) + outcome = await service.create( + resolved, + cast(Any, object()), + uuid4(), + _project_create_payload(), + ) + assert outcome.replayed is True + assert outcome.response == response + + +@pytest.mark.asyncio +async def test_project_create_service_consumes_system_authority_and_attributes_project() -> None: + reservation = types.SimpleNamespace( + operation_id=uuid4(), + project_id=str(uuid4()), + operation_generation=1, + ) + completed = [] + added = [] + + class Reservations: + async def reserve(self, **_kwargs): + return "claimed", reservation + + async def complete(self, record): + completed.append(record) + + class Projects: + async def add_project(self, project): + now = datetime.now(UTC) + project.created_at = now + project.updated_at = now + added.append(project) + return project + + decision = types.SimpleNamespace( + matched_authority_kind=MatchedAuthorityKind.ADMIN_ROLE_GRANT, + matched_grant_id=uuid4(), + matched_scope_project_id=None, + decision_id=uuid4(), + ) + + class Prepared: + async def prepare(self, *_args): + return object() + + async def consume(self, *_args): + return decision + + service = object.__new__(ProjectCreateService) + service._reservations = cast(Any, Reservations()) + service._projects = cast(Any, Projects()) + actor_id, link_id = str(uuid4()), str(uuid4()) + resolved = types.SimpleNamespace( + profile=types.SimpleNamespace(id=actor_id), + identity_link=types.SimpleNamespace(id=link_id), + ) + outcome = await service.create( + resolved, + cast(Any, Prepared()), + uuid4(), + _project_create_payload(), + ) + assert outcome.replayed is False + assert outcome.response.id == reservation.project_id + assert completed == [reservation] + assert added[0].created_by_actor_profile_id == actor_id + assert added[0].created_via_identity_link_id == link_id + assert added[0].created_by_admin_role_grant_id == decision.matched_grant_id + + @pytest.mark.asyncio @pytest.mark.parametrize( "method_name,locked_table", From ab7584f489de1959540d58a967b16abf1966b3d5 Mon Sep 17 00:00:00 2001 From: Abiorh001 Date: Fri, 31 Jul 2026 05:30:43 +0100 Subject: [PATCH 12/12] refactor(db): centralize constraint inspection --- .../chunks/WS-AUTH-001-12C-project-create.md | 2 ++ ...S-AUTH-001-12C-external-review-response.md | 11 +++++-- .github/workflows/backend.yml | 1 + backend/app/db/errors.py | 13 ++++++++ .../authorization/project_role_service.py | 14 ++++----- backend/app/modules/projects/create_router.py | 9 ++---- backend/tests/test_authorization.py | 4 +-- backend/tests/test_projects.py | 30 ++++++++++++++++++- 8 files changed, 63 insertions(+), 21 deletions(-) create mode 100644 backend/app/db/errors.py diff --git a/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/chunks/WS-AUTH-001-12C-project-create.md b/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/chunks/WS-AUTH-001-12C-project-create.md index 7065b622e..ea5333e14 100644 --- a/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/chunks/WS-AUTH-001-12C-project-create.md +++ b/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/chunks/WS-AUTH-001-12C-project-create.md @@ -46,6 +46,8 @@ backend/app/modules/authorization/runtime.py backend/app/modules/audit/schemas.py backend/app/api/deps/authorization.py backend/app/api/router.py +backend/app/db/errors.py +backend/app/modules/authorization/project_role_service.py backend/alembic/versions/0044_project_create_authority.py .github/workflows/backend.yml backend/tests/test_authorization.py diff --git a/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/reviews/WS-AUTH-001-12C-external-review-response.md b/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/reviews/WS-AUTH-001-12C-external-review-response.md index 7485037da..adcd3b51f 100644 --- a/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/reviews/WS-AUTH-001-12C-external-review-response.md +++ b/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/reviews/WS-AUTH-001-12C-external-review-response.md @@ -78,11 +78,16 @@ None. 58.96 percent. The route and orchestration now join persistence in focused project-create modules; legacy router/service diffs only remove their obsolete token-role create path. Sixteen focused PostgreSQL integration tests pass after - the split. Twenty focused unit tests also pass, with exact boundary coverage of - 96.43 percent for the repository, 90.00 percent for the router, and 94.12 - percent for the service. + the split. Twenty-two focused unit tests also pass, with exact boundary + coverage of 100 percent for the shared database-error helper, 96.43 percent + for the repository, 100 percent for the router, and 94.12 percent for the + service. - A final hosted Backend rerun is pending this complete boundary correction; CodeRabbit must also review that exact head. +- Exact-head CodeRabbit review then identified one valid deduplication nit: the + supported-driver constraint-name lookup existed in both project creation and + project-role mutation. A shared database-error helper now owns that lookup; + both callers retain their existing constraint allowlists and re-raise behavior. ## Remaining risks diff --git a/.github/workflows/backend.yml b/.github/workflows/backend.yml index 9ca2f3f7b..1e6b2a074 100644 --- a/.github/workflows/backend.yml +++ b/.github/workflows/backend.yml @@ -233,6 +233,7 @@ jobs: app/modules/authorization/kernel.py \ app/modules/authorization/prepared.py \ app/modules/authorization/runtime.py \ + app/db/errors.py \ app/modules/projects/models.py \ app/modules/projects/create_repository.py \ app/modules/projects/create_router.py \ diff --git a/backend/app/db/errors.py b/backend/app/db/errors.py new file mode 100644 index 000000000..ac62d7925 --- /dev/null +++ b/backend/app/db/errors.py @@ -0,0 +1,13 @@ +"""Shared database error inspection helpers.""" + +from sqlalchemy.exc import IntegrityError + + +def integrity_constraint_name(exc: IntegrityError) -> str | None: + """Resolve a violated constraint across supported PostgreSQL drivers.""" + original = exc.orig + return ( + getattr(getattr(original, "__cause__", None), "constraint_name", None) + or getattr(original, "constraint_name", None) + or getattr(getattr(original, "diag", None), "constraint_name", None) + ) diff --git a/backend/app/modules/authorization/project_role_service.py b/backend/app/modules/authorization/project_role_service.py index d8a115577..e04e4a067 100644 --- a/backend/app/modules/authorization/project_role_service.py +++ b/backend/app/modules/authorization/project_role_service.py @@ -10,6 +10,8 @@ from sqlalchemy.exc import IntegrityError from sqlalchemy.ext.asyncio import AsyncSession +from app.db.errors import integrity_constraint_name + from app.modules.audit.schemas import ( ActorReferenceKind, AuthorityAuditEventInput, @@ -63,13 +65,6 @@ def project_role_issue_lock_key(actor_id: UUID, project_id: UUID, role: str) -> return int.from_bytes(hashlib.sha256(encoded).digest()[:8], "big", signed=True) -def _constraint_name(exc: IntegrityError) -> str | None: - original = exc.orig - return getattr(original, "constraint_name", None) or getattr( - getattr(original, "diag", None), "constraint_name", None - ) - - def _facts(grant: ProjectRoleGrant) -> dict[str, object]: return { "status": grant.status, @@ -219,7 +214,10 @@ async def complete_issue( ) ) except IntegrityError as exc: - if _constraint_name(exc) == "uq_project_role_grants_active_exact_role": + if ( + integrity_constraint_name(exc) + == "uq_project_role_grants_active_exact_role" + ): raise ProjectRoleGrantConflict("project_role_grant_exists", None) from exc raise common = dict( diff --git a/backend/app/modules/projects/create_router.py b/backend/app/modules/projects/create_router.py index c7ba044d4..318658c79 100644 --- a/backend/app/modules/projects/create_router.py +++ b/backend/app/modules/projects/create_router.py @@ -15,6 +15,7 @@ ) from app.core.api_controls import StructuredHTTPException from app.core.permissions import PermissionDenied +from app.db.errors import integrity_constraint_name from app.db.session import get_db_session from app.modules.actors.service import ResolvedActor from app.modules.authorization.catalogue import ActionId @@ -104,13 +105,7 @@ async def create_project( raise project_create_http_error(exc) from exc except IntegrityError as exc: await session.rollback() - constraint_name = getattr( - getattr(exc.orig, "__cause__", None), "constraint_name", None - ) or getattr(exc.orig, "constraint_name", None) - if constraint_name is None: - constraint_name = getattr( - getattr(exc.orig, "diag", None), "constraint_name", None - ) + constraint_name = integrity_constraint_name(exc) if constraint_name not in { "projects_slug_key", "ix_projects_slug", diff --git a/backend/tests/test_authorization.py b/backend/tests/test_authorization.py index fcf9fec19..4579c7790 100644 --- a/backend/tests/test_authorization.py +++ b/backend/tests/test_authorization.py @@ -141,10 +141,10 @@ ) from app.modules.authorization.service import AuthorityMutationService from app.modules.authorization.project_role_service import ( - _constraint_name, ProjectRoleGrantMutationService, project_role_issue_lock_key, ) +from app.db.errors import integrity_constraint_name from app.modules.authorization.project_role_schemas import ( ProjectRoleGrantIssueBody, ProjectRoleGrantMutationResponse, @@ -260,7 +260,7 @@ def test_project_role_issue_advisory_key_contract_is_frozen_and_separated() -> N ) original = SimpleNamespace(constraint_name="uq_project_role_grants_active_exact_role") error = IntegrityError("insert", {}, original) - assert _constraint_name(error) == "uq_project_role_grants_active_exact_role" + assert integrity_constraint_name(error) == "uq_project_role_grants_active_exact_role" @pytest.mark.asyncio diff --git a/backend/tests/test_projects.py b/backend/tests/test_projects.py index eacfa923c..774f67e8c 100644 --- a/backend/tests/test_projects.py +++ b/backend/tests/test_projects.py @@ -75,7 +75,11 @@ authorize_project_policy_read, ) from app.modules.projects.create_repository import ProjectCreateRepository -from app.modules.projects.create_router import create_project as create_project_route +from app.modules.projects.create_router import ( + create_project as create_project_route, + get_project_create_authorization, + require_project_create_idempotency_key, +) from app.modules.projects.create_service import ( ProjectCreateIdempotencyConflict, ProjectCreateOutcome, @@ -595,6 +599,30 @@ def _project_create_response() -> ProjectResponse: ) +def test_project_create_idempotency_dependency_rejects_invalid_header() -> None: + request = types.SimpleNamespace(headers={"Idempotency-Key": "not-a-uuid"}) + + with pytest.raises(HTTPException) as captured: + require_project_create_idempotency_key(cast(Any, request)) + + assert captured.value.status_code == 422 + + +@pytest.mark.asyncio +async def test_project_create_authorization_dependency_preserves_exact_inputs() -> None: + idempotency_key = uuid4() + resolved = object() + prepared = object() + + result = await get_project_create_authorization( + idempotency_key, + cast(Any, resolved), + cast(Any, prepared), + ) + + assert result == (idempotency_key, resolved, prepared) + + @pytest.mark.asyncio @pytest.mark.parametrize("replayed", [False, True]) async def test_project_create_route_owns_commit_or_replay_rollback(