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..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 @@ -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 @@ -30,18 +32,39 @@ 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 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/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 +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 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/tests/test_outbox.py backend/scripts/api_contract_e2e.py docs/spec_authorization_service.md docs/operations_authorization_service.md @@ -58,10 +81,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 +104,114 @@ 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. -- Every changed authorization/project module remains at least 90 percent - covered. Final pushed head SHA passes `Backend / test` and `Agent Gates`. +- 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. `ProjectCreateService.create` + must neither authorize from token roles nor commit independently. +- 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 -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 \ + 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 +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,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/*,*/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 \ + --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 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 new file mode 100644 index 000000000..adcd3b51f --- /dev/null +++ b/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/reviews/WS-AUTH-001-12C-external-review-response.md @@ -0,0 +1,94 @@ +# 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. +- 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. +- 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. +- 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. +- 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. +- 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. +- 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. +- 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-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 + +Hosted full-suite execution and exact per-file coverage remain authoritative. diff --git a/.github/workflows/backend.yml b/.github/workflows/backend.yml index c5faf9a0f..1e6b2a074 100644 --- a/.github/workflows/backend.yml +++ b/.github/workflows/backend.yml @@ -221,6 +221,27 @@ 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/db/errors.py \ + app/modules/projects/models.py \ + app/modules/projects/create_repository.py \ + app/modules/projects/create_router.py \ + app/modules/projects/create_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..34dd3b640 --- /dev/null +++ b/backend/alembic/versions/0044_project_create_authority.py @@ -0,0 +1,336 @@ +"""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( + "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 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, + 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 + 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 is distinct from 'authority' + or evidence.event_type is distinct from 'SensitiveAuthorizationAllowed' + or evidence.denial_code is not null + or evidence.actor_ref_kind is distinct from 'actor_profile' + or evidence.actor_id is distinct from 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; + 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("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/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/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/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..ee0a24acb 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) @@ -85,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() @@ -155,6 +171,10 @@ 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 not ( + _project_create_binding_matches(issuance.binding, final_resource_context) + ): + raise PreparedAuthorizationHandleInvalid("invalid prepared authorization handle") self._issued[handle] = _CONSUMED return await self._authorization._require_prelocked( self._consumer_token, @@ -163,6 +183,33 @@ 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 not _project_create_binding_matches(binding, final_resource_context): + raise PreparedAuthorizationHandleInvalid( + "invalid prepared authorization handle" + ) + await self._authorization._complete_prepared_denial( + self._consumer_token, + action_id, + 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 +245,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 +276,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/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/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/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/create_router.py b/backend/app/modules/projects/create_router.py new file mode 100644 index 000000000..318658c79 --- /dev/null +++ b/backend/app/modules/projects/create_router.py @@ -0,0 +1,120 @@ +"""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.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 +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 = integrity_constraint_name(exc) + 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/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/router.py b/backend/app/modules/projects/router.py index b384e19a5..6cfd3999e 100644 --- a/backend/app/modules/projects/router.py +++ b/backend/app/modules/projects/router.py @@ -34,7 +34,6 @@ GuideSufficiencyReportCreate, GuideSufficiencyReportResponse, PreSubmitCheckerPolicySummaryResponse, - ProjectCreate, ContributorProjectResponse, ProjectGuideCreate, ProjectGuideResponse, @@ -92,21 +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) -async def create_project( - payload: ProjectCreate, - actor: Annotated[ActorContext, Depends(get_registered_actor)], - session: Annotated[AsyncSession, Depends(get_db_session)], -) -> ProjectResponse: - """Create a draft project shell for future guide versions.""" - try: - return await ProjectService(session).create_project(actor, payload) - except PermissionDenied as exc: - raise permission_http_error(exc) from exc - except ProjectServiceError as exc: - raise project_http_error(exc) from exc - - @router.get( "/{project_id}", response_model=ProjectResponse | ContributorProjectResponse, diff --git a/backend/app/modules/projects/service.py b/backend/app/modules/projects/service.py index 35692e7bf..8173e1a3a 100644 --- a/backend/app/modules/projects/service.py +++ b/backend/app/modules/projects/service.py @@ -85,7 +85,6 @@ PostSubmitCheckerPolicyResponse, PostSubmitCheckerPolicySetupResponse, PostSubmitCheckerPolicySetupSummaryResponse, - ProjectCreate, ContributorProjectResponse, ProjectGuideCreate, ProjectGuideResponse, @@ -473,32 +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, actor: ActorContext, payload: ProjectCreate) -> ProjectResponse: - """Create a draft project record after project setup authorization. - - Args: - actor: Verified Flow actor context for the current request. - payload: Validated project creation fields. - - Returns: - Created project response. - - Raises: - PermissionDenied: If the actor cannot manage project setup. - """ - require_any_role(actor, PROJECT_SETUP_ROLES) - project = Project( - id=str(uuid4()), - name=payload.name, - slug=payload.slug, - description=payload.description, - status="draft", - ) - project = await self._repo.add_project(project) - await self._session.commit() - await self._session.refresh(project) - return ProjectResponse.model_validate(project) - 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/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..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 = "5654f83859a318a6f8205215d72934a9b115a155f15d407d54f4200c394b8e3e" +EXPECTED_PUBLIC_SCHEMA_SHA256 = "defa8baad0019605b4bb512808a04b554c0f6ce585842fe60a9e3291d1d9bb79" 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..045321d73 --- /dev/null +++ b/backend/tests/project_create_fixtures.py @@ -0,0 +1,270 @@ +"""Test-only construction of historical and currently attributed projects.""" + +from datetime import UTC, datetime +from uuid import UUID, uuid4 + +from sqlalchemy import select, text +from sqlalchemy.ext.asyncio import AsyncConnection, AsyncSession + +from app.modules.actors.models import ActorIdentityLink, ActorProfile +from app.core.hashing import canonical_json_hash +from app.modules.audit.schemas import ( + ActorReferenceKind, + AuthorityAuditEventInput, + AuthorityEventType, +) +from app.modules.audit.service import AuditService +from app.modules.authorization.catalogue import 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 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_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=bootstrap_actor.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_historical_project( + session: AsyncSession, + *, + project_id: str, + name: str, + slug: str, + status: str = "draft", +) -> None: + """Stage a pre-0044 project without manufacturing current authority evidence.""" + 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 connection.execute( + text("alter table projects disable trigger project_creation_custody") + ) + try: + await connection.execute( + text( + "insert into projects (id, name, slug, status) " + "values (:id, :name, :slug, :status)" + ), + {"id": project_id, "name": name, "slug": slug, "status": status}, + ) + finally: + if has_cutover: + await connection.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()), + 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() + link, grant = await grant_system_project_manager( + session, + issuer=link.issuer, + subject=link.subject, + ) + operation_id = uuid4() + decision_id = uuid4() + 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() + 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), + ) + ) + reservation.status = "committed" + reservation.committed_at = datetime.now(UTC) + await session.flush() diff --git a/backend/tests/test_alembic.py b/backend/tests/test_alembic.py index ed7584525..ab6fd155d 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 insert_historical_project, 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 @@ -1829,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 " @@ -1842,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) " @@ -2156,6 +2159,177 @@ 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'" + ) + ) or "" + 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.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 transaction.commit() + if transaction.is_active: + await transaction.rollback() + 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() @@ -5689,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 " @@ -11135,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( 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..aefa9016a 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_historical_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_historical_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_historical_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_historical_project( + session, + project_id=project_id, + name="Checker project", + slug=f"checker-{project_id}", + ) await session.flush() session.add( ProjectGuide( @@ -3655,15 +3658,15 @@ 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_historical_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 + authority_audit_count = await _count(session, AuditEvent) await session.commit() with pytest.raises( @@ -3682,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_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 36c6132cc..61e0de55c 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_historical_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_historical_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..e028a1691 100644 --- a/backend/tests/test_audit.py +++ b/backend/tests/test_audit.py @@ -185,6 +185,7 @@ def test_action_aware_audit_input_enforces_mapping_and_action_availability() -> 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_ROLE_GRANT_LIST, ActionId.PROJECT_ROLE_GRANT_READ, @@ -237,6 +238,27 @@ def test_action_aware_audit_input_enforces_mapping_and_action_availability() -> ) +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 c7d075f8d..780358a7a 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_historical_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_historical_project( + session, + project_id=str(project_one), + name="AUTH-08 scope one", + slug=f"auth08-scope-one-{project_one}", + ) + await seed_historical_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..4579c7790 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_historical_project from app.modules.authorization.pagination import ( AuthorizationReadCursorCodec, InvalidPaginationCursor, @@ -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 @@ -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,13 +3453,11 @@ 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 - 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( @@ -3479,6 +3470,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.""" @@ -7706,10 +7866,14 @@ 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 + 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 @@ -7718,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( @@ -7726,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] @@ -7738,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( @@ -7745,7 +7910,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, @@ -7771,7 +7945,32 @@ def in_transaction(self): AuthorizationDenied(denied) ) assert denial_session.rollback_count == 1 - assert denial_session.commit_count == 0 + 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( @@ -8814,18 +9013,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 +9097,18 @@ async def test_project_read_permissions_have_postgresql_role_scope_matrix( ), {"grant_id": str(bootstrap_grant_id)}, ) + 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_historical_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 +11042,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 +11066,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_historical_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_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_guide_bindings.py b/backend/tests/test_guide_bindings.py index 3e4e3e032..2a7e7a29c 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_historical_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_historical_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_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: diff --git a/backend/tests/test_projects.py b/backend/tests/test_projects.py index d36fe88a7..774f67e8c 100644 --- a/backend/tests/test_projects.py +++ b/backend/tests/test_projects.py @@ -13,9 +13,10 @@ 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 fastapi import HTTPException from sqlalchemy.schema import CreateIndex from app.core.config import get_settings @@ -51,12 +52,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, @@ -71,7 +74,21 @@ authorize_project_diagnostic_read, 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, + get_project_create_authorization, + require_project_create_idempotency_key, +) +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, @@ -87,6 +104,7 @@ ProjectServiceError, StaleProjectSetupContinuation, ) +from project_create_fixtures import seed_historical_project from app.modules.projects.post_submit_policy import ( @@ -485,6 +503,377 @@ 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())) + + +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, + ) + + +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( + 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", @@ -699,6 +1088,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 +1286,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 +1332,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_historical_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 +1832,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, @@ -1428,12 +1843,40 @@ 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: 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 +1903,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", @@ -1631,6 +2182,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) @@ -1670,6 +2222,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()) @@ -2493,7 +3046,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 +4611,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", @@ -4127,6 +4680,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"], @@ -4179,7 +4734,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"] ) @@ -4226,7 +4780,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 +9105,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 +9120,180 @@ 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.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/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 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` |