From fc3d2bef82821b4440245fda67c223f44c0f800b Mon Sep 17 00:00:00 2001 From: Abiorh001 Date: Wed, 29 Jul 2026 23:54:01 +0100 Subject: [PATCH 1/6] feat(auth): register project mutation authorization contracts --- .../CHUNK_MAP.md | 2 +- .../STATUS.md | 14 +- ...AUTH-001-12A-project-mutation-catalogue.md | 42 +- .../0041_project_mutation_action_evidence.py | 127 +++++ .../app/modules/authorization/catalogue.py | 118 ++++- backend/app/modules/authorization/kernel.py | 18 + backend/app/modules/authorization/prepared.py | 12 + backend/app/modules/authorization/runtime.py | 414 ++++++++++++++++ backend/tests/test_alembic.py | 152 +++++- backend/tests/test_authorization.py | 461 +++++++++++++++++- docs/operations_authorization_service.md | 14 +- docs/spec_authorization_service.md | 37 +- 12 files changed, 1379 insertions(+), 32 deletions(-) create mode 100644 backend/alembic/versions/0041_project_mutation_action_evidence.py diff --git a/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/CHUNK_MAP.md b/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/CHUNK_MAP.md index a29ae52b8..c24f7d258 100644 --- a/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/CHUNK_MAP.md +++ b/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/CHUNK_MAP.md @@ -57,7 +57,7 @@ stopped. | `WS-AUTH-001-11C1` | Project Setup Diagnostic Read Cutover | L1 | Merged in PR #216 as `2965a9f9` | | `WS-AUTH-001-11C2` | Effective Policy And Active Guide Read Cutover | L1 | Merged in PR #221 as `3fc323d7` | | `WS-AUTH-001-12` | Project Mutation Cutover Planning Parent | L1 | Split before runtime implementation after failed L1 review | -| `WS-AUTH-001-12A` | Project Mutation Catalogue And PREP Foundation | L1 | Proposed after planning merge; ART-owned `0040` prerequisite satisfied | +| `WS-AUTH-001-12A` | Project Mutation Catalogue And PREP Foundation | L1 | In progress; ART-owned `0040` prerequisite satisfied, AUTH `0041` allocated | | `WS-AUTH-001-12B` | Fixed Project Setup Service Foundation | L1 | Proposed after 12A; zero activation | | `WS-AUTH-001-12B2` | Project Setup Service Runtime Cutover | L1 | Proposed after 12E, 12F, and 12G | | `WS-AUTH-001-12C` | Project Creation Cutover | L1 | Proposed after 12B | diff --git a/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/STATUS.md b/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/STATUS.md index aa167ef0f..551937e89 100644 --- a/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/STATUS.md +++ b/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/STATUS.md @@ -120,13 +120,17 @@ setup-run binding findings were repaired. ## Active implementation chunk -None. `WS-AUTH-001-11C2` merged through PR #221 as `3fc323d7` after Backend, -Agent Gates, and required internal review passed. AUTH-12 is planning-only -until its repaired split passes review and a child receives a separate start. +`WS-AUTH-001-12A`; the user started the bounded catalogue, typed resource, +PREP-scope, and PostgreSQL evidence foundation on 2026-07-29. All eighteen new +actions remain planned and unavailable; no product mutation is activated. +Initial architecture and product review found missing setup-service custody and +operation-kind bindings. Those defects were repaired, focused proof passes, +and all nine required internal reviewer tracks now pass. Hosted Backend, +Agent Gates, and external review remain before merge readiness. ## Current review branch -`codex/ws-auth-001-12-project-mutation-cutover` (planning repair). +`codex/ws-auth-001-12a-project-mutation-catalogue`. ## Chunk status @@ -168,7 +172,7 @@ until its repaired split passes review and a child receives a separate start. | `WS-AUTH-001-11C1` | Merged | `codex/ws-auth-001-11c1-setup-diagnostic-reads` | #216 | Setup-diagnostic read hard cutover merged as `2965a9f9` on 2026-07-28. | | `WS-AUTH-001-11C2` | Merged | `codex/ws-auth-001-11c2-effective-policy-active-guide-reads` | #221 | Effective-policy and active-guide read cutover merged as `3fc323d7` on 2026-07-29. | | `WS-AUTH-001-12` | Planning repair | `codex/ws-auth-001-12-project-mutation-cutover` | - | Combined runtime contract rejected; planning parent split into 12A-12H plus 12B2/12D2 before code. | -| `WS-AUTH-001-12A` | Proposed | - | - | Planned action/evidence/PREP foundation; ART-owned migration `0040` is merged, so only planning merge and a separate start remain before allocation. | +| `WS-AUTH-001-12A` | In progress | `codex/ws-auth-001-12a-project-mutation-catalogue` | - | Exact 18-action planned catalogue, typed resource/PREP scope, and migration `0041`; zero activation. | | `WS-AUTH-001-12B` | Proposed | - | - | Fixed project-setup service identity and planned matrix only; zero activation. | | `WS-AUTH-001-12B2` | Proposed | - | - | Final Celery call-graph cutover after exact product actions activate. | | `WS-AUTH-001-12C` | Proposed | - | - | System-scoped project creation cutover. | diff --git a/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/chunks/WS-AUTH-001-12A-project-mutation-catalogue.md b/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/chunks/WS-AUTH-001-12A-project-mutation-catalogue.md index 7fe2fe5ae..c339d3a69 100644 --- a/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/chunks/WS-AUTH-001-12A-project-mutation-catalogue.md +++ b/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/chunks/WS-AUTH-001-12A-project-mutation-catalogue.md @@ -2,9 +2,9 @@ ## Status and prerequisite -Proposed and inactive. AUTH-12 planning must be merged; ART-owned `0040` is -merged on trusted main. -the exact AUTH migration revision is frozen from the resulting trusted main. +Implementation and internal review complete; externally inactive. AUTH-12 planning is merged; ART-owned `0040` is +merged on trusted main. The exact AUTH migration revision is frozen as +`0041_project_mutation_evidence` from that trusted head. ## Parent initiative @@ -41,6 +41,7 @@ backend/alembic/versions/_project_mutation_action_ev backend/tests/test_authorization.py backend/tests/test_alembic.py docs/spec_authorization_service.md +docs/operations_authorization_service.md .agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/** ``` @@ -63,6 +64,12 @@ project-table provenance columns, ART behavior, or token-role compatibility. - PREP scope derivation is explicit for system project creation and exact project resources. Handles remain opaque, request/session/transaction bound, non-copyable, non-serializable, and single-use. +- The catalogue contains exactly 96 actions after this chunk: 37 active and 59 + planned. All eighteen new actions remain planned, fail with + `action_unavailable` before handle issuance, and produce no allowed evidence. +- `project.create` is the only new system-scoped action. Every other new action + derives exact project scope from its final typed resource context and rejects + partial or cross-project lineage. - The migration follows merged ART-owned `0040_guide_materialization`; `0040` is not duplicated or edited. - Upgrade, downgrade, re-upgrade, typed/SQL parity, and zero-active-delta tests @@ -70,10 +77,31 @@ project-table provenance columns, ART behavior, or token-role compatibility. ## Verification commands -Before start, freeze the actual revision after merged ART `0040` and the exact -isolated-runner, 90% authorization coverage, migration round-trip, Ruff, stale -authorization docs, Markdown-link, and diff commands. Final pushed head SHA -must pass `Backend / test` and `Agent Gates`. +```bash +cd backend +.venv/bin/python -m ruff check \ + app/modules/authorization/catalogue.py \ + app/modules/authorization/runtime.py \ + app/modules/authorization/kernel.py \ + app/modules/authorization/prepared.py \ + app/modules/audit/schemas.py \ + alembic/versions/0041_project_mutation_action_evidence.py \ + tests/test_authorization.py tests/test_alembic.py +PYTEST_DISABLE_PLUGIN_AUTOLOAD=1 .venv/bin/python scripts/run_isolated_tests.py \ + --metadata-json .ci/auth12a.json --lane auth12a --timeout-seconds 1200 -- \ + .venv/bin/python -m pytest -p pytest_asyncio.plugin -p pytest_cov.plugin -q \ + tests/test_authorization.py tests/test_alembic.py \ + -k 'project_mutation or 0041_project_mutation' +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`; the hosted +Backend gate owns fresh full-suite coverage and isolated PostgreSQL migration +proof. ## Required reviewers diff --git a/backend/alembic/versions/0041_project_mutation_action_evidence.py b/backend/alembic/versions/0041_project_mutation_action_evidence.py new file mode 100644 index 000000000..5fbe242a4 --- /dev/null +++ b/backend/alembic/versions/0041_project_mutation_action_evidence.py @@ -0,0 +1,127 @@ +"""register planned project-mutation action evidence + +Revision ID: 0041_project_mutation_evidence +Revises: 0040_guide_materialization +Create Date: 2026-07-29 +""" + +from __future__ import annotations + +from alembic import op +import sqlalchemy as sa + + +revision = "0041_project_mutation_evidence" +down_revision = "0040_guide_materialization" +branch_labels = depends_on = None + +_ACTIONS = ( + ("project.create", "project.create"), + ("project.guide.create", "project.guide.manage"), + ("project.guide.update", "project.guide.manage"), + ("project.guide_source_snapshot.create", "project.guide.manage"), + ("project.review_policy.update", "project.review_policy.manage"), + ("project.revision_policy.update", "project.review_policy.manage"), + ("project.guide_sufficiency_report.create", "project.guide.manage"), + ("project.guide_sufficiency.run", "project.guide.manage"), + ("project.guide_sufficiency.warnings.acknowledge", "project.guide.manage"), + ("project.submission_artifact_policy.create", "project.effective_policy.manage"), + ("project.submission_artifact_policy.derive", "project.effective_policy.manage"), + ("project.submission_artifact_policy.update", "project.effective_policy.manage"), + ("project.submission_artifact_policy.approve", "project.effective_policy.manage"), + ("project.post_submit_checker_policy.approve", "project.effective_policy.manage"), + ( + "project.post_submit_checker_policy.correction.request", + "project.effective_policy.manage", + ), + ("project.post_submit_checker_policy.derive", "project.effective_policy.manage"), + ("project.setup_run.update", "project.guide.manage"), + ("project.guide.activate", "project.guide.manage"), +) + + +def _definition() -> str: + return ( + op.get_bind() + .execute( + sa.text( + "select pg_get_constraintdef(oid) from pg_constraint " + "where conrelid='audit_events'::regclass " + "and conname='ck_audit_events_authorization_action_evidence'" + ) + ) + .scalar_one() + ) + + +def _replace(definition: str) -> None: + op.drop_constraint( + "ck_audit_events_authorization_action_evidence", + "audit_events", + type_="check", + ) + op.execute( + "alter table audit_events add constraint " + f"ck_audit_events_authorization_action_evidence {definition}" + ) + + +def _pair_token(action: str, permission: str) -> str: + return ( + f"(((action_id)::text = '{action}'::text) AND " + f"((permission_id)::text = '{permission}'::text))" + ) + + +def _rewrite(*, add: bool) -> None: + definition = _definition() + additions = " OR " + " OR ".join(_pair_token(*pair) for pair in _ACTIONS) + marker = _pair_token("project.active_guide.read", "project.read") + if add: + if definition.count(marker) != 2 or any( + _pair_token(*pair) in definition for pair in _ACTIONS + ): + raise RuntimeError("unexpected project-mutation action registry definition") + definition = definition.replace(marker, marker + additions) + else: + if definition.count(additions) != 2: + raise RuntimeError("unexpected project-mutation action registry definition") + definition = definition.replace(additions, "") + _replace(definition) + + +def _lock_evidence() -> None: + bind = op.get_bind() + bind.execute(sa.text("lock table authority_idempotency_records in share row exclusive mode")) + bind.execute(sa.text("lock table audit_events in access exclusive mode")) + + +def _has_evidence() -> bool: + actions = [action for action, _ in _ACTIONS] + return bool( + op.get_bind() + .execute( + sa.text( + "select exists(select 1 from audit_events where action_id = any(:actions)) or " + "exists(select 1 from authority_idempotency_records record " + "join audit_events event on event.idempotency_reference=record.id " + "where event.action_id = any(:actions))" + ), + {"actions": actions}, + ) + .scalar_one() + ) + + +def upgrade() -> None: + """Register the eighteen project-mutation pairs without activating them.""" + _lock_evidence() + _rewrite(add=True) + + +def downgrade() -> None: + """Remove project-mutation pairs only when no forward evidence exists.""" + _lock_evidence() + if _has_evidence(): + raise RuntimeError("cannot downgrade non-empty project-mutation action evidence") + _rewrite(add=False) diff --git a/backend/app/modules/authorization/catalogue.py b/backend/app/modules/authorization/catalogue.py index e4866264e..a72441ea5 100644 --- a/backend/app/modules/authorization/catalogue.py +++ b/backend/app/modules/authorization/catalogue.py @@ -125,6 +125,28 @@ class ActionId(StrEnum): ) PROJECT_PRE_SUBMIT_CHECKER_POLICY_READ = "project.pre_submit_checker_policy.read" PROJECT_ACTIVE_GUIDE_READ = "project.active_guide.read" + PROJECT_CREATE = "project.create" + PROJECT_GUIDE_CREATE = "project.guide.create" + PROJECT_GUIDE_UPDATE = "project.guide.update" + PROJECT_GUIDE_SOURCE_SNAPSHOT_CREATE = "project.guide_source_snapshot.create" + PROJECT_REVIEW_POLICY_UPDATE = "project.review_policy.update" + PROJECT_REVISION_POLICY_UPDATE = "project.revision_policy.update" + PROJECT_GUIDE_SUFFICIENCY_REPORT_CREATE = "project.guide_sufficiency_report.create" + PROJECT_GUIDE_SUFFICIENCY_RUN = "project.guide_sufficiency.run" + PROJECT_GUIDE_SUFFICIENCY_WARNINGS_ACKNOWLEDGE = ( + "project.guide_sufficiency.warnings.acknowledge" + ) + PROJECT_SUBMISSION_ARTIFACT_POLICY_CREATE = "project.submission_artifact_policy.create" + PROJECT_SUBMISSION_ARTIFACT_POLICY_DERIVE = "project.submission_artifact_policy.derive" + PROJECT_SUBMISSION_ARTIFACT_POLICY_UPDATE = "project.submission_artifact_policy.update" + PROJECT_SUBMISSION_ARTIFACT_POLICY_APPROVE = "project.submission_artifact_policy.approve" + PROJECT_POST_SUBMIT_CHECKER_POLICY_APPROVE = "project.post_submit_checker_policy.approve" + PROJECT_POST_SUBMIT_CHECKER_POLICY_CORRECTION_REQUEST = ( + "project.post_submit_checker_policy.correction.request" + ) + PROJECT_POST_SUBMIT_CHECKER_POLICY_DERIVE = "project.post_submit_checker_policy.derive" + PROJECT_SETUP_RUN_UPDATE = "project.setup_run.update" + PROJECT_GUIDE_ACTIVATE = "project.guide.activate" OPERATIONS_TASK_START_OVERRIDE = "operations.task.start_override" OPERATIONS_SUBMISSION_GATE_REPAIR = "operations.submission_gate.repair" OPERATIONS_CHECKER_RETRY = "operations.checker.retry" @@ -189,6 +211,14 @@ class ActionOwner(StrEnum): AUTH_11B = "WS-AUTH-001-11B" AUTH_11C1 = "WS-AUTH-001-11C1" AUTH_11C2 = "WS-AUTH-001-11C2" + AUTH_12B2 = "WS-AUTH-001-12B2" + AUTH_12C = "WS-AUTH-001-12C" + AUTH_12D = "WS-AUTH-001-12D" + AUTH_12D2 = "WS-AUTH-001-12D2" + AUTH_12E = "WS-AUTH-001-12E" + AUTH_12F = "WS-AUTH-001-12F" + AUTH_12G = "WS-AUTH-001-12G" + AUTH_12H = "WS-AUTH-001-12H" AUTH_13 = "WS-AUTH-001-13" AUTH_14 = "WS-AUTH-001-14" AUTH_REV_05 = "WS-AUTH-001-REV-05" @@ -404,6 +434,92 @@ def _active( PermissionId.PROJECT_READ, ActionOwner.AUTH_11C2, ), + _planned(ActionId.PROJECT_CREATE, PermissionId.PROJECT_CREATE, ActionOwner.AUTH_12C), + _planned( + ActionId.PROJECT_GUIDE_CREATE, + PermissionId.PROJECT_GUIDE_MANAGE, + ActionOwner.AUTH_12D, + ), + _planned( + ActionId.PROJECT_GUIDE_UPDATE, + PermissionId.PROJECT_GUIDE_MANAGE, + ActionOwner.AUTH_12D, + ), + _planned( + ActionId.PROJECT_GUIDE_SOURCE_SNAPSHOT_CREATE, + PermissionId.PROJECT_GUIDE_MANAGE, + ActionOwner.AUTH_12D, + ), + _planned( + ActionId.PROJECT_REVIEW_POLICY_UPDATE, + PermissionId.PROJECT_REVIEW_POLICY_MANAGE, + ActionOwner.AUTH_12D2, + ), + _planned( + ActionId.PROJECT_REVISION_POLICY_UPDATE, + PermissionId.PROJECT_REVIEW_POLICY_MANAGE, + ActionOwner.AUTH_12D2, + ), + _planned( + ActionId.PROJECT_GUIDE_SUFFICIENCY_REPORT_CREATE, + PermissionId.PROJECT_GUIDE_MANAGE, + ActionOwner.AUTH_12E, + ), + _planned( + ActionId.PROJECT_GUIDE_SUFFICIENCY_RUN, + PermissionId.PROJECT_GUIDE_MANAGE, + ActionOwner.AUTH_12E, + ), + _planned( + ActionId.PROJECT_GUIDE_SUFFICIENCY_WARNINGS_ACKNOWLEDGE, + PermissionId.PROJECT_GUIDE_MANAGE, + ActionOwner.AUTH_12E, + ), + _planned( + ActionId.PROJECT_SUBMISSION_ARTIFACT_POLICY_CREATE, + PermissionId.PROJECT_EFFECTIVE_POLICY_MANAGE, + ActionOwner.AUTH_12F, + ), + _planned( + ActionId.PROJECT_SUBMISSION_ARTIFACT_POLICY_DERIVE, + PermissionId.PROJECT_EFFECTIVE_POLICY_MANAGE, + ActionOwner.AUTH_12F, + ), + _planned( + ActionId.PROJECT_SUBMISSION_ARTIFACT_POLICY_UPDATE, + PermissionId.PROJECT_EFFECTIVE_POLICY_MANAGE, + ActionOwner.AUTH_12F, + ), + _planned( + ActionId.PROJECT_SUBMISSION_ARTIFACT_POLICY_APPROVE, + PermissionId.PROJECT_EFFECTIVE_POLICY_MANAGE, + ActionOwner.AUTH_12F, + ), + _planned( + ActionId.PROJECT_POST_SUBMIT_CHECKER_POLICY_APPROVE, + PermissionId.PROJECT_EFFECTIVE_POLICY_MANAGE, + ActionOwner.AUTH_12G, + ), + _planned( + ActionId.PROJECT_POST_SUBMIT_CHECKER_POLICY_CORRECTION_REQUEST, + PermissionId.PROJECT_EFFECTIVE_POLICY_MANAGE, + ActionOwner.AUTH_12G, + ), + _planned( + ActionId.PROJECT_POST_SUBMIT_CHECKER_POLICY_DERIVE, + PermissionId.PROJECT_EFFECTIVE_POLICY_MANAGE, + ActionOwner.AUTH_12G, + ), + _planned( + ActionId.PROJECT_SETUP_RUN_UPDATE, + PermissionId.PROJECT_GUIDE_MANAGE, + ActionOwner.AUTH_12B2, + ), + _planned( + ActionId.PROJECT_GUIDE_ACTIVATE, + PermissionId.PROJECT_GUIDE_MANAGE, + ActionOwner.AUTH_12H, + ), _planned( ActionId.OPERATIONS_TASK_START_OVERRIDE, PermissionId.OPERATIONS_TASK_START_OVERRIDE, @@ -651,7 +767,7 @@ def _index_actions( ): raise RuntimeError("authorization action catalogue contains an invalid row") indexed = {definition.action_id: definition for definition in definitions} - if len(PERMISSION_IDS) != 71 or len(ACTION_IDS) != 78: + if len(PERMISSION_IDS) != 71 or len(ACTION_IDS) != 96: raise RuntimeError("authorization catalogue count mismatch") if len(indexed) != len(definitions) or set(indexed) != ACTION_IDS: raise RuntimeError("authorization action catalogue is incomplete") diff --git a/backend/app/modules/authorization/kernel.py b/backend/app/modules/authorization/kernel.py index 37f380c06..7fb6f9d73 100644 --- a/backend/app/modules/authorization/kernel.py +++ b/backend/app/modules/authorization/kernel.py @@ -25,7 +25,12 @@ from app.modules.authorization.repository import AdminAuthorizationRepository from app.modules.authorization.runtime import ( PROJECT_DIAGNOSTIC_TARGET_KIND_BY_ACTION, + PROJECT_GUIDE_TARGET_KIND_BY_ACTION, + PROJECT_MUTATION_RESOURCE_BY_ACTION, + PROJECT_POST_SUBMIT_POLICY_TARGET_KIND_BY_ACTION, PROJECT_POLICY_READ_TARGET_KIND_BY_ACTION, + PROJECT_SUBMISSION_POLICY_TARGET_KIND_BY_ACTION, + PROJECT_SUFFICIENCY_TARGET_KIND_BY_ACTION, ActorAdminRoleGrantHistoryResourceContext, ActorAuthorizationContextResourceContext, ActorIdentityLinkAdminReadResourceContext, @@ -1105,6 +1110,7 @@ def _admin_resource_matches( ), ActionId.PROJECT_PRE_SUBMIT_CHECKER_POLICY_READ: ProjectPolicyReadResourceContext, ActionId.PROJECT_ACTIVE_GUIDE_READ: ProjectActiveGuideReadResourceContext, + **PROJECT_MUTATION_RESOURCE_BY_ACTION, }.get(action_id) if expected is None or not isinstance(resource, expected): return False @@ -1114,6 +1120,18 @@ def _admin_resource_matches( policy_kind = PROJECT_POLICY_READ_TARGET_KIND_BY_ACTION.get(action_id) if policy_kind is not None and resource.target_kind != policy_kind: return False + sufficiency_kind = PROJECT_SUFFICIENCY_TARGET_KIND_BY_ACTION.get(action_id) + if sufficiency_kind is not None and resource.target_kind != sufficiency_kind: + return False + guide_kind = PROJECT_GUIDE_TARGET_KIND_BY_ACTION.get(action_id) + if guide_kind is not None and resource.target_kind != guide_kind: + return False + submission_policy_kind = PROJECT_SUBMISSION_POLICY_TARGET_KIND_BY_ACTION.get(action_id) + if submission_policy_kind is not None and resource.target_kind != submission_policy_kind: + return False + post_submit_policy_kind = PROJECT_POST_SUBMIT_POLICY_TARGET_KIND_BY_ACTION.get(action_id) + if post_submit_policy_kind is not None and resource.target_kind != post_submit_policy_kind: + return False transition = { ActionId.ACTOR_PROFILE_SUSPEND: "suspend", ActionId.ACTOR_PROFILE_REACTIVATE: "reactivate", diff --git a/backend/app/modules/authorization/prepared.py b/backend/app/modules/authorization/prepared.py index a94ebe9c9..b6f2bb4b5 100644 --- a/backend/app/modules/authorization/prepared.py +++ b/backend/app/modules/authorization/prepared.py @@ -31,6 +31,8 @@ PreparedAuthorizationInput, PreparedAuthorityScope, PreparedAuthorityScopeKind, + PROJECT_MUTATION_RESOURCE_BY_ACTION, + ProjectCreateResourceContext, ) @@ -245,6 +247,16 @@ def _scope_from_resource( role=role, grant_id=grant_id, ) + expected_project_mutation = PROJECT_MUTATION_RESOURCE_BY_ACTION.get(action_id) + if expected_project_mutation is not None and isinstance( + resource, expected_project_mutation + ): + if isinstance(resource, ProjectCreateResourceContext): + return PreparedAuthorityScope(kind=PreparedAuthorityScopeKind.SYSTEM) + return PreparedAuthorityScope( + kind=PreparedAuthorityScopeKind.PROJECT, + project_id=resource.scope_project_id, + ) artifact_resource_type = { ActionId.ARTIFACT_PUT_ATTEMPT_RESOLVE: ArtifactPutAttemptResourceContext, ActionId.ARTIFACT_VERIFICATION_EXECUTE: ArtifactVerificationJobResourceContext, diff --git a/backend/app/modules/authorization/runtime.py b/backend/app/modules/authorization/runtime.py index 2989e8b31..21acdcca6 100644 --- a/backend/app/modules/authorization/runtime.py +++ b/backend/app/modules/authorization/runtime.py @@ -446,6 +446,410 @@ def require_active_bundle_shape(self): return self +class ProjectCreateResourceContext(BaseModel): + """Server-owned facts for one system-scoped project creation.""" + + model_config = _STRICT_FROZEN + + resource_type: Literal["project_create"] + resource_id: UUID + requested_project_id: UUID + operation_generation: int = Field(ge=1) + + @model_validator(mode="after") + def require_operation_identity(self): + """Keep the idempotent operation distinct from the future project.""" + if self.resource_id == self.requested_project_id: + raise ValueError("project creation operation must not impersonate project identity") + return self + + +class ProjectGuideMutationResourceContext(BaseModel): + """Canonical draft-guide facts for create or update.""" + + model_config = _STRICT_FROZEN + + resource_type: Literal["project_guide_mutation"] + resource_id: UUID + scope_project_id: UUID + guide_id: UUID + target_kind: Literal["create", "update"] + guide_exists: bool + guide_status: str | None = None + guide_version: str | None = None + operation_generation: int = Field(ge=1) + + @model_validator(mode="after") + def require_guide_identity(self): + """Reject cross-resource and partial guide lineage.""" + if self.resource_id != self.guide_id: + raise ValueError("guide mutation resource must match guide") + if self.guide_exists != (self.guide_status is not None and self.guide_version is not None): + raise ValueError("guide mutation lifecycle facts are inconsistent") + if self.guide_exists != (self.target_kind == "update"): + raise ValueError("guide mutation operation and existence are inconsistent") + return self + + +class ProjectGuideSourceSnapshotMutationResourceContext(BaseModel): + """Canonical guide and source-snapshot lineage for snapshot creation.""" + + model_config = _STRICT_FROZEN + + resource_type: Literal["project_guide_source_snapshot_mutation"] + resource_id: UUID + scope_project_id: UUID + guide_id: UUID + guide_version: str + guide_status: str + source_snapshot_id: UUID + predecessor_snapshot_id: UUID | None = None + predecessor_snapshot_hash: str | None = Field( + default=None, pattern=r"^sha256:[0-9a-f]{64}$" + ) + operation_generation: int = Field(ge=1) + + @model_validator(mode="after") + def require_snapshot_identity(self): + """Reject copied snapshot selectors and partial predecessor facts.""" + if self.resource_id != self.source_snapshot_id: + raise ValueError("source snapshot resource must match snapshot") + if (self.predecessor_snapshot_id is None) != (self.predecessor_snapshot_hash is None): + raise ValueError("source snapshot predecessor facts must be bound together") + return self + + +class ProjectReviewPolicyMutationResourceContext(BaseModel): + """Canonical guide-bound review-policy mutation facts.""" + + model_config = _STRICT_FROZEN + + resource_type: Literal["project_review_policy_mutation"] + resource_id: UUID + scope_project_id: UUID + guide_id: UUID + guide_version: str + review_policy_id: UUID + policy_generation: int = Field(ge=1) + current_policy_digest: str | None = Field(default=None, pattern=r"^sha256:[0-9a-f]{64}$") + + @model_validator(mode="after") + def require_review_policy_identity(self): + """Bind the resource selector to the review policy only.""" + if self.resource_id != self.review_policy_id: + raise ValueError("review policy resource must match policy") + return self + + +class ProjectRevisionPolicyMutationResourceContext(BaseModel): + """Canonical guide-bound revision-policy mutation facts.""" + + model_config = _STRICT_FROZEN + + resource_type: Literal["project_revision_policy_mutation"] + resource_id: UUID + scope_project_id: UUID + guide_id: UUID + guide_version: str + revision_policy_id: UUID + policy_generation: int = Field(ge=1) + current_policy_digest: str | None = Field(default=None, pattern=r"^sha256:[0-9a-f]{64}$") + + @model_validator(mode="after") + def require_revision_policy_identity(self): + """Bind the resource selector to the revision policy only.""" + if self.resource_id != self.revision_policy_id: + raise ValueError("revision policy resource must match policy") + return self + + +class ProjectSetupServiceCustodyContext(BaseModel): + """Locked setup-run custody required by one fixed-service product effect.""" + + model_config = _STRICT_FROZEN + + setup_run_id: UUID + scope_project_id: UUID + guide_id: UUID + source_snapshot_id: UUID + setup_generation: int = Field(ge=1) + expected_step: Literal[ + "guide_sufficiency", + "submission_artifact_policy", + "post_submit_policy", + ] + task_id: UUID + correlation_id: UUID + stale_output_digest: str = Field(pattern=r"^sha256:[0-9a-f]{64}$") + + +class ProjectGuideSufficiencyMutationResourceContext(BaseModel): + """Canonical snapshot and report facts for sufficiency mutations.""" + + model_config = _STRICT_FROZEN + + resource_type: Literal["project_guide_sufficiency_mutation"] + resource_id: UUID + scope_project_id: UUID + guide_id: UUID + guide_version: str + source_snapshot_id: UUID + source_snapshot_hash: str = Field(pattern=r"^sha256:[0-9a-f]{64}$") + target_kind: Literal["report", "run", "warning_acknowledgement"] + execution_kind: Literal["human", "setup_service"] + sufficiency_report_id: UUID | None = None + setup_generation: int = Field(ge=1) + stale_output_digest: str | None = Field(default=None, pattern=r"^sha256:[0-9a-f]{64}$") + setup_service_custody: ProjectSetupServiceCustodyContext | None = None + + @model_validator(mode="after") + def require_sufficiency_identity(self): + """Require report identity only for report-bound operations.""" + report_bound = self.target_kind in {"report", "warning_acknowledgement"} + if report_bound != (self.sufficiency_report_id is not None): + raise ValueError("sufficiency report facts do not match target kind") + expected = self.sufficiency_report_id or self.source_snapshot_id + if self.resource_id != expected: + raise ValueError("sufficiency resource does not match target") + service_execution = self.execution_kind == "setup_service" + if service_execution != (self.setup_service_custody is not None): + raise ValueError("sufficiency service execution requires exact setup custody") + if service_execution: + if self.target_kind != "run": + raise ValueError("only a sufficiency run may use setup-service authority") + if self.setup_service_custody.expected_step != "guide_sufficiency": + raise ValueError("sufficiency setup-service step is inconsistent") + if self.setup_service_custody.setup_generation != self.setup_generation: + raise ValueError("sufficiency setup generation is inconsistent") + if self.setup_service_custody.stale_output_digest != self.stale_output_digest: + raise ValueError("sufficiency stale output is inconsistent") + if ( + self.setup_service_custody.scope_project_id != self.scope_project_id + or self.setup_service_custody.guide_id != self.guide_id + or self.setup_service_custody.source_snapshot_id != self.source_snapshot_id + ): + raise ValueError("sufficiency setup lineage is inconsistent") + return self + + +class ProjectSubmissionArtifactPolicyMutationResourceContext(BaseModel): + """Canonical submission-artifact policy lineage for one mutation.""" + + model_config = _STRICT_FROZEN + + resource_type: Literal["project_submission_artifact_policy_mutation"] + resource_id: UUID + scope_project_id: UUID + guide_id: UUID + guide_version: str + source_snapshot_id: UUID + source_snapshot_hash: str = Field(pattern=r"^sha256:[0-9a-f]{64}$") + target_kind: Literal["create", "derive", "update", "approve"] + execution_kind: Literal["human", "setup_service"] + policy_id: UUID + policy_generation: int = Field(ge=1) + setup_generation: int = Field(ge=1) + policy_status: str | None = None + policy_digest: str | None = Field(default=None, pattern=r"^sha256:[0-9a-f]{64}$") + stale_output_digest: str | None = Field(default=None, pattern=r"^sha256:[0-9a-f]{64}$") + effective_output_digest: str | None = Field(default=None, pattern=r"^sha256:[0-9a-f]{64}$") + compiled_pre_submit_output_digest: str | None = Field( + default=None, pattern=r"^sha256:[0-9a-f]{64}$" + ) + setup_service_custody: ProjectSetupServiceCustodyContext | None = None + + @model_validator(mode="after") + def require_submission_policy_identity(self): + """Reject cross-policy selectors and partial current-policy facts.""" + if self.resource_id != self.policy_id: + raise ValueError("submission policy resource must match policy") + if (self.policy_status is None) != (self.policy_digest is None): + raise ValueError("submission policy status and digest must be bound together") + service_execution = self.execution_kind == "setup_service" + if service_execution != (self.setup_service_custody is not None): + raise ValueError("policy service execution requires exact setup custody") + if service_execution != (self.target_kind == "derive"): + raise ValueError("policy derivation requires setup-service authority") + if service_execution: + if self.setup_service_custody.expected_step != "submission_artifact_policy": + raise ValueError("submission policy setup-service step is inconsistent") + if self.setup_service_custody.setup_generation != self.setup_generation: + raise ValueError("submission policy setup generation is inconsistent") + if self.setup_service_custody.stale_output_digest != self.stale_output_digest: + raise ValueError("submission policy stale output is inconsistent") + if ( + self.setup_service_custody.scope_project_id != self.scope_project_id + or self.setup_service_custody.guide_id != self.guide_id + or self.setup_service_custody.source_snapshot_id != self.source_snapshot_id + ): + raise ValueError("submission policy setup lineage is inconsistent") + return self + + +class ProjectPostSubmitCheckerPolicyMutationResourceContext(BaseModel): + """Canonical post-submit checker-policy lineage for one mutation.""" + + model_config = _STRICT_FROZEN + + resource_type: Literal["project_post_submit_checker_policy_mutation"] + resource_id: UUID + scope_project_id: UUID + guide_id: UUID + guide_version: str + source_snapshot_id: UUID + source_snapshot_hash: str = Field(pattern=r"^sha256:[0-9a-f]{64}$") + target_kind: Literal["approve", "correction_request", "derive"] + execution_kind: Literal["human", "setup_service"] + checker_policy_id: UUID + setup_generation: int = Field(ge=1) + lifecycle_status: str + compiled_policy_digest: str = Field(pattern=r"^sha256:[0-9a-f]{64}$") + setup_service_custody: ProjectSetupServiceCustodyContext | None = None + + @model_validator(mode="after") + def require_checker_policy_identity(self): + """Bind the resource selector to the checker policy only.""" + if self.resource_id != self.checker_policy_id: + raise ValueError("checker policy resource must match policy") + service_execution = self.execution_kind == "setup_service" + if service_execution != (self.setup_service_custody is not None): + raise ValueError("checker service execution requires exact setup custody") + if service_execution != (self.target_kind == "derive"): + raise ValueError("checker derivation requires setup-service authority") + if service_execution: + if self.setup_service_custody.expected_step != "post_submit_policy": + raise ValueError("checker policy setup-service step is inconsistent") + if self.setup_service_custody.setup_generation != self.setup_generation: + raise ValueError("checker policy setup generation is inconsistent") + if self.setup_service_custody.stale_output_digest != self.compiled_policy_digest: + raise ValueError("checker policy stale output is inconsistent") + if ( + self.setup_service_custody.scope_project_id != self.scope_project_id + or self.setup_service_custody.guide_id != self.guide_id + or self.setup_service_custody.source_snapshot_id != self.source_snapshot_id + ): + raise ValueError("checker policy setup lineage is inconsistent") + return self + + +class ProjectSetupRunMutationResourceContext(BaseModel): + """Canonical setup-run step custody for one ledger mutation.""" + + model_config = _STRICT_FROZEN + + resource_type: Literal["project_setup_run_mutation"] + resource_id: UUID + scope_project_id: UUID + guide_id: UUID + setup_run_id: UUID + setup_generation: int = Field(ge=1) + expected_step: Literal["guide_sufficiency", "submission_artifact_policy", "post_submit_policy"] + task_id: UUID + correlation_id: UUID + stale_output_digest: str = Field(pattern=r"^sha256:[0-9a-f]{64}$") + + @model_validator(mode="after") + def require_setup_run_identity(self): + """Bind setup ledger authority to the exact active run.""" + if self.resource_id != self.setup_run_id: + raise ValueError("setup-run resource must match run") + return self + + +class ProjectGuideActivationResourceContext(BaseModel): + """Complete guide and active-bundle identity for terminal activation.""" + + model_config = _STRICT_FROZEN + + resource_type: Literal["project_guide_activation"] + resource_id: UUID + scope_project_id: UUID + guide_id: UUID + guide_version: str + source_snapshot_id: UUID + sufficiency_report_id: UUID + submission_artifact_policy_id: UUID + pre_submit_checker_policy_id: UUID + post_submit_checker_policy_id: UUID + review_policy_id: UUID + revision_policy_id: UUID + active_bundle_digest: str = Field(pattern=r"^sha256:[0-9a-f]{64}$") + activation_generation: int = Field(ge=1) + + @model_validator(mode="after") + def require_activation_identity(self): + """Bind terminal activation to the selected guide.""" + if self.resource_id != self.guide_id: + raise ValueError("guide activation resource must match guide") + return self + + +PROJECT_MUTATION_RESOURCE_BY_ACTION = { + ActionId.PROJECT_CREATE: ProjectCreateResourceContext, + ActionId.PROJECT_GUIDE_CREATE: ProjectGuideMutationResourceContext, + ActionId.PROJECT_GUIDE_UPDATE: ProjectGuideMutationResourceContext, + ActionId.PROJECT_GUIDE_SOURCE_SNAPSHOT_CREATE: ( + ProjectGuideSourceSnapshotMutationResourceContext + ), + ActionId.PROJECT_REVIEW_POLICY_UPDATE: ProjectReviewPolicyMutationResourceContext, + ActionId.PROJECT_REVISION_POLICY_UPDATE: ProjectRevisionPolicyMutationResourceContext, + ActionId.PROJECT_GUIDE_SUFFICIENCY_REPORT_CREATE: ( + ProjectGuideSufficiencyMutationResourceContext + ), + ActionId.PROJECT_GUIDE_SUFFICIENCY_RUN: ProjectGuideSufficiencyMutationResourceContext, + ActionId.PROJECT_GUIDE_SUFFICIENCY_WARNINGS_ACKNOWLEDGE: ( + ProjectGuideSufficiencyMutationResourceContext + ), + ActionId.PROJECT_SUBMISSION_ARTIFACT_POLICY_CREATE: ( + ProjectSubmissionArtifactPolicyMutationResourceContext + ), + ActionId.PROJECT_SUBMISSION_ARTIFACT_POLICY_DERIVE: ( + ProjectSubmissionArtifactPolicyMutationResourceContext + ), + ActionId.PROJECT_SUBMISSION_ARTIFACT_POLICY_UPDATE: ( + ProjectSubmissionArtifactPolicyMutationResourceContext + ), + ActionId.PROJECT_SUBMISSION_ARTIFACT_POLICY_APPROVE: ( + ProjectSubmissionArtifactPolicyMutationResourceContext + ), + ActionId.PROJECT_POST_SUBMIT_CHECKER_POLICY_APPROVE: ( + ProjectPostSubmitCheckerPolicyMutationResourceContext + ), + ActionId.PROJECT_POST_SUBMIT_CHECKER_POLICY_CORRECTION_REQUEST: ( + ProjectPostSubmitCheckerPolicyMutationResourceContext + ), + ActionId.PROJECT_POST_SUBMIT_CHECKER_POLICY_DERIVE: ( + ProjectPostSubmitCheckerPolicyMutationResourceContext + ), + ActionId.PROJECT_SETUP_RUN_UPDATE: ProjectSetupRunMutationResourceContext, + ActionId.PROJECT_GUIDE_ACTIVATE: ProjectGuideActivationResourceContext, +} + +PROJECT_SUFFICIENCY_TARGET_KIND_BY_ACTION = { + ActionId.PROJECT_GUIDE_SUFFICIENCY_REPORT_CREATE: "report", + ActionId.PROJECT_GUIDE_SUFFICIENCY_RUN: "run", + ActionId.PROJECT_GUIDE_SUFFICIENCY_WARNINGS_ACKNOWLEDGE: "warning_acknowledgement", +} + +PROJECT_GUIDE_TARGET_KIND_BY_ACTION = { + ActionId.PROJECT_GUIDE_CREATE: "create", + ActionId.PROJECT_GUIDE_UPDATE: "update", +} + +PROJECT_SUBMISSION_POLICY_TARGET_KIND_BY_ACTION = { + ActionId.PROJECT_SUBMISSION_ARTIFACT_POLICY_CREATE: "create", + ActionId.PROJECT_SUBMISSION_ARTIFACT_POLICY_DERIVE: "derive", + ActionId.PROJECT_SUBMISSION_ARTIFACT_POLICY_UPDATE: "update", + ActionId.PROJECT_SUBMISSION_ARTIFACT_POLICY_APPROVE: "approve", +} + +PROJECT_POST_SUBMIT_POLICY_TARGET_KIND_BY_ACTION = { + ActionId.PROJECT_POST_SUBMIT_CHECKER_POLICY_APPROVE: "approve", + ActionId.PROJECT_POST_SUBMIT_CHECKER_POLICY_CORRECTION_REQUEST: "correction_request", + ActionId.PROJECT_POST_SUBMIT_CHECKER_POLICY_DERIVE: "derive", +} + + class ActorAuthorizationContextResourceContext(BaseModel): """Self-owned selector for authority projected onto one project.""" @@ -756,6 +1160,16 @@ def bind_page_size(self): | ProjectDiagnosticReadResourceContext | ProjectPolicyReadResourceContext | ProjectActiveGuideReadResourceContext + | ProjectCreateResourceContext + | ProjectGuideMutationResourceContext + | ProjectGuideSourceSnapshotMutationResourceContext + | ProjectReviewPolicyMutationResourceContext + | ProjectRevisionPolicyMutationResourceContext + | ProjectGuideSufficiencyMutationResourceContext + | ProjectSubmissionArtifactPolicyMutationResourceContext + | ProjectPostSubmitCheckerPolicyMutationResourceContext + | ProjectSetupRunMutationResourceContext + | ProjectGuideActivationResourceContext | ActorAuthorizationContextResourceContext | ActorProfileAdminReadResourceContext | ActorIdentityLinkAdminReadResourceContext diff --git a/backend/tests/test_alembic.py b/backend/tests/test_alembic.py index 26a62f0cc..b56a7feb2 100644 --- a/backend/tests/test_alembic.py +++ b/backend/tests/test_alembic.py @@ -1629,7 +1629,7 @@ def test_artifact_recovery_schema_and_empty_downgrade( command.downgrade(config, "base") command.upgrade(config, "head") assert asyncio.run(_artifact_recovery_schema(isolated_database_env)) == { - "revision": "0040_guide_materialization", + "revision": "0041_project_mutation_evidence", "constraints": { "artifact_recovery_attempt_custody", "artifact_verification_lineage_custody", @@ -1953,13 +1953,149 @@ def test_0035_project_read_action_evidence_refuses_nonempty_downgrade( ): command.downgrade(config, "0034_project_role_issue_evidence") assert asyncio.run(_current_revision(isolated_database_env)) == ( - "0040_guide_materialization" + "0041_project_mutation_evidence" ) finally: asyncio.run(_remove_authority_audit_fixture(isolated_database_env, event_id=event_id)) command.downgrade(config, "base") +def test_0041_project_mutation_action_evidence_round_trip( + isolated_database_env: str, migration_lock +) -> None: + """Prove all eighteen planned action pairs round-trip without new permissions.""" + config = _alembic_config() + definitions = tuple( + definition + for definition in ACTION_DEFINITIONS + if definition.owner + in { + ActionOwner.AUTH_12B2, + ActionOwner.AUTH_12C, + ActionOwner.AUTH_12D, + ActionOwner.AUTH_12D2, + ActionOwner.AUTH_12E, + ActionOwner.AUTH_12F, + ActionOwner.AUTH_12G, + ActionOwner.AUTH_12H, + } + ) + assert len(definitions) == 18 + assert {definition.permission_id for definition in definitions} <= set(PermissionId) + with migration_lock(): + try: + command.downgrade(config, "base") + command.upgrade(config, "head") + asyncio.run( + _assert_authorization_action_sql_pairs( + isolated_database_env, definitions=definitions + ) + ) + command.downgrade(config, "0040_guide_materialization") + command.upgrade(config, "head") + asyncio.run( + _assert_authorization_action_sql_pairs( + isolated_database_env, definitions=definitions + ) + ) + finally: + command.downgrade(config, "base") + + +def test_0041_project_mutation_action_evidence_refuses_downgrade( + isolated_database_env: str, migration_lock +) -> None: + """Committed direct and idempotency-linked evidence must preserve vocabulary.""" + config = _alembic_config() + definitions = tuple( + item + for item in ACTION_DEFINITIONS + if item.owner + in { + ActionOwner.AUTH_12B2, + ActionOwner.AUTH_12C, + ActionOwner.AUTH_12D, + ActionOwner.AUTH_12D2, + ActionOwner.AUTH_12E, + ActionOwner.AUTH_12F, + ActionOwner.AUTH_12G, + ActionOwner.AUTH_12H, + } + ) + assert len(definitions) == 18 + event_id = "" + linked_event_id = "" + record_id = str(uuid4()) + actor_id = str(uuid4()) + target_id = str(uuid4()) + with migration_lock(): + try: + command.downgrade(config, "base") + command.upgrade(config, "head") + for definition in definitions: + event_id = asyncio.run( + _insert_authorization_action_event_for( + isolated_database_env, + definition.action_id.value, + definition.permission_id.value, + ) + ) + with pytest.raises( + RuntimeError, + match="cannot downgrade non-empty project-mutation action evidence", + ): + command.downgrade(config, "0040_guide_materialization") + asyncio.run( + _remove_authority_audit_fixture( + isolated_database_env, event_id=event_id + ) + ) + event_id = "" + + definition = definitions[-1] + asyncio.run( + _insert_committed_authority_idempotency( + isolated_database_env, record_id, actor_id, target_id + ) + ) + linked_event_id = asyncio.run( + _insert_linked_authorization_action_event( + isolated_database_env, + record_id=record_id, + actor_id=actor_id, + action_id=definition.action_id.value, + permission_id=definition.permission_id.value, + ) + ) + with pytest.raises( + RuntimeError, + match="cannot downgrade non-empty project-mutation action evidence", + ): + command.downgrade(config, "0040_guide_materialization") + assert asyncio.run(_current_revision(isolated_database_env)) == ( + "0041_project_mutation_evidence" + ) + finally: + if event_id: + asyncio.run( + _remove_authority_audit_fixture( + isolated_database_env, event_id=event_id + ) + ) + if linked_event_id: + asyncio.run( + _remove_authority_audit_fixture( + isolated_database_env, event_id=linked_event_id + ) + ) + asyncio.run( + _remove_authority_idempotency_fixture( + isolated_database_env, record_id, orphan_event=None + ) + ) + command.downgrade(config, "base") + + 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() @@ -2080,7 +2216,7 @@ def test_0036_art_auth_catalogue_refuses_obsolete_evidence( record_id = "" command.upgrade(config, "head") assert asyncio.run(_current_revision(isolated_database_env)) == ( - "0040_guide_materialization" + "0041_project_mutation_evidence" ) finally: for event_id in reversed(event_ids): @@ -2477,7 +2613,7 @@ def test_project_role_migration_constraints_and_immutable_history( command.upgrade(config, "head") result = asyncio.run(_exercise_project_role_migration(isolated_database_env)) assert result == { - "revision": "0040_guide_materialization", + "revision": "0041_project_mutation_evidence", "role_count": 3, "invalid_availability": "23514", "duplicate_role": "23505", @@ -2689,7 +2825,7 @@ def test_project_role_downgrade_refuses_each_reserved_evidence_predicate( ): command.downgrade(config, "0030_artifact_verification") assert asyncio.run(_project_role_refusal_state(isolated_database_env))[:3] == ( - "0040_guide_materialization", + "0041_project_mutation_evidence", True, True, ) @@ -2716,7 +2852,7 @@ def test_project_role_downgrade_refuses_each_reserved_evidence_predicate( ): command.downgrade(config, "0030_artifact_verification") assert asyncio.run(_project_role_refusal_state(isolated_database_env))[:3] == ( - "0040_guide_materialization", + "0041_project_mutation_evidence", True, True, ) @@ -2744,7 +2880,7 @@ def test_outbox_migration_schema_and_downgrade_writer_guard( command.upgrade(config, "head") schema = asyncio.run(_outbox_schema(isolated_database_env)) assert schema == { - "revision": "0040_guide_materialization", + "revision": "0041_project_mutation_evidence", "columns": { "aggregate_id", "aggregate_type", @@ -2804,7 +2940,7 @@ def test_outbox_migration_schema_and_downgrade_writer_guard( ) assert committed == "refused_after_commit" assert asyncio.run(_current_revision(isolated_database_env)) == ( - "0040_guide_materialization" + "0041_project_mutation_evidence" ) asyncio.run(_remove_outbox_migration_row(isolated_database_env, committed_project_id)) command.downgrade(config, "0028_artifact_admission") diff --git a/backend/tests/test_authorization.py b/backend/tests/test_authorization.py index 8ab0a495c..162d79e06 100644 --- a/backend/tests/test_authorization.py +++ b/backend/tests/test_authorization.py @@ -195,6 +195,18 @@ PermissionCatalogueResourceContext, ProjectContributorCandidateCollectionResourceContext, ProjectDiagnosticReadResourceContext, + PROJECT_MUTATION_RESOURCE_BY_ACTION, + ProjectCreateResourceContext, + ProjectGuideActivationResourceContext, + ProjectGuideMutationResourceContext, + ProjectGuideSourceSnapshotMutationResourceContext, + ProjectGuideSufficiencyMutationResourceContext, + ProjectPostSubmitCheckerPolicyMutationResourceContext, + ProjectReviewPolicyMutationResourceContext, + ProjectRevisionPolicyMutationResourceContext, + ProjectSetupServiceCustodyContext, + ProjectSetupRunMutationResourceContext, + ProjectSubmissionArtifactPolicyMutationResourceContext, ProjectPolicyReadResourceContext, ProjectActiveGuideReadResourceContext, ProjectReadResourceContext, @@ -1839,11 +1851,68 @@ def test_closed_permission_and_action_catalogue_is_exact_and_non_executable() -> "WS-AUTH-001-11C2", ), "project.active_guide.read": ("project.read", "WS-AUTH-001-11C2"), + "project.create": ("project.create", "WS-AUTH-001-12C"), + "project.guide.create": ("project.guide.manage", "WS-AUTH-001-12D"), + "project.guide.update": ("project.guide.manage", "WS-AUTH-001-12D"), + "project.guide_source_snapshot.create": ( + "project.guide.manage", + "WS-AUTH-001-12D", + ), + "project.review_policy.update": ( + "project.review_policy.manage", + "WS-AUTH-001-12D2", + ), + "project.revision_policy.update": ( + "project.review_policy.manage", + "WS-AUTH-001-12D2", + ), + "project.guide_sufficiency_report.create": ( + "project.guide.manage", + "WS-AUTH-001-12E", + ), + "project.guide_sufficiency.run": ( + "project.guide.manage", + "WS-AUTH-001-12E", + ), + "project.guide_sufficiency.warnings.acknowledge": ( + "project.guide.manage", + "WS-AUTH-001-12E", + ), + "project.submission_artifact_policy.create": ( + "project.effective_policy.manage", + "WS-AUTH-001-12F", + ), + "project.submission_artifact_policy.derive": ( + "project.effective_policy.manage", + "WS-AUTH-001-12F", + ), + "project.submission_artifact_policy.update": ( + "project.effective_policy.manage", + "WS-AUTH-001-12F", + ), + "project.submission_artifact_policy.approve": ( + "project.effective_policy.manage", + "WS-AUTH-001-12F", + ), + "project.post_submit_checker_policy.approve": ( + "project.effective_policy.manage", + "WS-AUTH-001-12G", + ), + "project.post_submit_checker_policy.correction.request": ( + "project.effective_policy.manage", + "WS-AUTH-001-12G", + ), + "project.post_submit_checker_policy.derive": ( + "project.effective_policy.manage", + "WS-AUTH-001-12G", + ), + "project.setup_run.update": ("project.guide.manage", "WS-AUTH-001-12B2"), + "project.guide.activate": ("project.guide.manage", "WS-AUTH-001-12H"), } assert {item.value for item in HISTORICAL_PERMISSION_IDS} == historical_permissions assert {item.value for item in NEW_PERMISSION_IDS} == new_permissions assert {item.value for item in PERMISSION_IDS} == historical_permissions | new_permissions - assert len(ACTION_IDS) == len(ACTION_DEFINITIONS) == len(ACTION_BY_ID) == 78 + assert len(ACTION_IDS) == len(ACTION_DEFINITIONS) == len(ACTION_BY_ID) == 96 assert set(ACTION_BY_ID) == ACTION_IDS assert {definition.owner for definition in ACTION_DEFINITIONS} == set(ActionOwner) assert { @@ -1978,7 +2047,7 @@ def test_closed_permission_and_action_catalogue_is_exact_and_non_executable() -> definition.availability is ActionAvailability.PLANNED for definition in ACTION_DEFINITIONS ) - == 41 + == 59 ) assert resolve_executable_action(ActionId.ACTOR_PROFILE_READ_SELF).permission_id is ( PermissionId.ACTOR_PROFILE_READ_SELF @@ -1989,6 +2058,349 @@ def test_closed_permission_and_action_catalogue_is_exact_and_non_executable() -> ACTION_BY_ID[ActionId.ACTOR_PROFILE_READ_SELF] = ACTION_DEFINITIONS[0] +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) + ) + 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 = { + step: ProjectSetupServiceCustodyContext( + setup_run_id=setup_run_id, + scope_project_id=project_id, + guide_id=guide_id, + source_snapshot_id=snapshot_id, + setup_generation=1, + expected_step=step, + task_id=setup_task_id, + correlation_id=setup_correlation_id, + stale_output_digest=DIGEST, + ) + for step in ( + "guide_sufficiency", + "submission_artifact_policy", + "post_submit_policy", + ) + } + create_resource = ProjectCreateResourceContext( + resource_type="project_create", + resource_id=operation_id, + requested_project_id=requested_project_id, + operation_generation=1, + ) + guide_resources = { + ActionId.PROJECT_GUIDE_CREATE: ProjectGuideMutationResourceContext( + resource_type="project_guide_mutation", + resource_id=guide_id, + scope_project_id=project_id, + guide_id=guide_id, + target_kind="create", + guide_exists=False, + operation_generation=1, + ), + ActionId.PROJECT_GUIDE_UPDATE: ProjectGuideMutationResourceContext( + resource_type="project_guide_mutation", + resource_id=guide_id, + scope_project_id=project_id, + guide_id=guide_id, + target_kind="update", + guide_exists=True, + guide_status="draft", + guide_version="1", + operation_generation=1, + ), + } + source_resource = ProjectGuideSourceSnapshotMutationResourceContext( + resource_type="project_guide_source_snapshot_mutation", + resource_id=snapshot_id, + scope_project_id=project_id, + guide_id=guide_id, + guide_version="1", + guide_status="draft", + source_snapshot_id=snapshot_id, + operation_generation=1, + ) + review_resource = ProjectReviewPolicyMutationResourceContext( + resource_type="project_review_policy_mutation", + resource_id=review_id, + scope_project_id=project_id, + guide_id=guide_id, + guide_version="1", + review_policy_id=review_id, + policy_generation=1, + ) + revision_resource = ProjectRevisionPolicyMutationResourceContext( + resource_type="project_revision_policy_mutation", + resource_id=revision_id, + scope_project_id=project_id, + guide_id=guide_id, + guide_version="1", + revision_policy_id=revision_id, + policy_generation=1, + ) + sufficiency_resources = { + ActionId.PROJECT_GUIDE_SUFFICIENCY_REPORT_CREATE: ( + ProjectGuideSufficiencyMutationResourceContext( + resource_type="project_guide_sufficiency_mutation", + resource_id=report_id, + scope_project_id=project_id, + guide_id=guide_id, + guide_version="1", + source_snapshot_id=snapshot_id, + source_snapshot_hash=DIGEST, + target_kind="report", + execution_kind="human", + sufficiency_report_id=report_id, + setup_generation=1, + ) + ), + ActionId.PROJECT_GUIDE_SUFFICIENCY_RUN: ( + ProjectGuideSufficiencyMutationResourceContext( + resource_type="project_guide_sufficiency_mutation", + resource_id=snapshot_id, + scope_project_id=project_id, + guide_id=guide_id, + guide_version="1", + source_snapshot_id=snapshot_id, + source_snapshot_hash=DIGEST, + target_kind="run", + execution_kind="setup_service", + setup_generation=1, + stale_output_digest=DIGEST, + setup_service_custody=setup_custody_by_step["guide_sufficiency"], + ) + ), + ActionId.PROJECT_GUIDE_SUFFICIENCY_WARNINGS_ACKNOWLEDGE: ( + ProjectGuideSufficiencyMutationResourceContext( + resource_type="project_guide_sufficiency_mutation", + resource_id=report_id, + scope_project_id=project_id, + guide_id=guide_id, + guide_version="1", + source_snapshot_id=snapshot_id, + source_snapshot_hash=DIGEST, + target_kind="warning_acknowledgement", + execution_kind="human", + sufficiency_report_id=report_id, + setup_generation=1, + ) + ), + } + submission_resources = { + action_id: ProjectSubmissionArtifactPolicyMutationResourceContext( + resource_type="project_submission_artifact_policy_mutation", + resource_id=submission_policy_id, + scope_project_id=project_id, + guide_id=guide_id, + guide_version="1", + source_snapshot_id=snapshot_id, + source_snapshot_hash=DIGEST, + target_kind=target_kind, + execution_kind="setup_service" if target_kind == "derive" else "human", + policy_id=submission_policy_id, + policy_generation=1, + setup_generation=1, + stale_output_digest=DIGEST if target_kind == "derive" else None, + setup_service_custody=( + setup_custody_by_step["submission_artifact_policy"] + if target_kind == "derive" + else None + ), + ) + for action_id, target_kind in ( + (ActionId.PROJECT_SUBMISSION_ARTIFACT_POLICY_CREATE, "create"), + (ActionId.PROJECT_SUBMISSION_ARTIFACT_POLICY_DERIVE, "derive"), + (ActionId.PROJECT_SUBMISSION_ARTIFACT_POLICY_UPDATE, "update"), + (ActionId.PROJECT_SUBMISSION_ARTIFACT_POLICY_APPROVE, "approve"), + ) + } + checker_resources = { + action_id: ProjectPostSubmitCheckerPolicyMutationResourceContext( + resource_type="project_post_submit_checker_policy_mutation", + resource_id=checker_policy_id, + scope_project_id=project_id, + guide_id=guide_id, + guide_version="1", + source_snapshot_id=snapshot_id, + source_snapshot_hash=DIGEST, + target_kind=target_kind, + execution_kind="setup_service" if target_kind == "derive" else "human", + checker_policy_id=checker_policy_id, + setup_generation=1, + lifecycle_status="draft", + compiled_policy_digest=DIGEST, + setup_service_custody=( + setup_custody_by_step["post_submit_policy"] + if target_kind == "derive" + else None + ), + ) + for action_id, target_kind in ( + (ActionId.PROJECT_POST_SUBMIT_CHECKER_POLICY_APPROVE, "approve"), + ( + ActionId.PROJECT_POST_SUBMIT_CHECKER_POLICY_CORRECTION_REQUEST, + "correction_request", + ), + (ActionId.PROJECT_POST_SUBMIT_CHECKER_POLICY_DERIVE, "derive"), + ) + } + setup_resource = ProjectSetupRunMutationResourceContext( + resource_type="project_setup_run_mutation", + resource_id=setup_run_id, + scope_project_id=project_id, + guide_id=guide_id, + setup_run_id=setup_run_id, + setup_generation=1, + expected_step="guide_sufficiency", + task_id=uuid4(), + correlation_id=uuid4(), + stale_output_digest=DIGEST, + ) + activation_resource = ProjectGuideActivationResourceContext( + resource_type="project_guide_activation", + resource_id=guide_id, + scope_project_id=project_id, + guide_id=guide_id, + guide_version="1", + source_snapshot_id=snapshot_id, + sufficiency_report_id=report_id, + submission_artifact_policy_id=submission_policy_id, + pre_submit_checker_policy_id=uuid4(), + post_submit_checker_policy_id=checker_policy_id, + review_policy_id=review_id, + revision_policy_id=revision_id, + active_bundle_digest=DIGEST, + activation_generation=1, + ) + resources = { + ActionId.PROJECT_CREATE: create_resource, + **guide_resources, + ActionId.PROJECT_GUIDE_SOURCE_SNAPSHOT_CREATE: source_resource, + ActionId.PROJECT_REVIEW_POLICY_UPDATE: review_resource, + ActionId.PROJECT_REVISION_POLICY_UPDATE: revision_resource, + **sufficiency_resources, + **submission_resources, + **checker_resources, + ActionId.PROJECT_SETUP_RUN_UPDATE: setup_resource, + ActionId.PROJECT_GUIDE_ACTIVATE: activation_resource, + } + assert set(resources) == set(PROJECT_MUTATION_RESOURCE_BY_ACTION) + for action_id, resource in resources.items(): + assert AuthorizationService._admin_resource_matches(action_id, resource) + scope = PreparedAuthorizationService._scope_from_resource(None, action_id, resource) + if action_id is ActionId.PROJECT_CREATE: + assert scope == PreparedAuthorityScope(kind=PreparedAuthorityScopeKind.SYSTEM) + else: + assert scope == PreparedAuthorityScope( + kind=PreparedAuthorityScopeKind.PROJECT, + project_id=project_id, + ) + assert not AuthorizationService._admin_resource_matches( + ActionId.PROJECT_REVISION_POLICY_UPDATE, + review_resource, + ) + assert not AuthorizationService._admin_resource_matches( + ActionId.PROJECT_GUIDE_UPDATE, + guide_resources[ActionId.PROJECT_GUIDE_CREATE], + ) + assert not AuthorizationService._admin_resource_matches( + ActionId.PROJECT_GUIDE_SUFFICIENCY_REPORT_CREATE, + sufficiency_resources[ActionId.PROJECT_GUIDE_SUFFICIENCY_RUN], + ) + assert not AuthorizationService._admin_resource_matches( + ActionId.PROJECT_SUBMISSION_ARTIFACT_POLICY_APPROVE, + submission_resources[ActionId.PROJECT_SUBMISSION_ARTIFACT_POLICY_DERIVE], + ) + assert not AuthorizationService._admin_resource_matches( + ActionId.PROJECT_POST_SUBMIT_CHECKER_POLICY_APPROVE, + checker_resources[ActionId.PROJECT_POST_SUBMIT_CHECKER_POLICY_DERIVE], + ) + human_sufficiency_run = sufficiency_resources[ + ActionId.PROJECT_GUIDE_SUFFICIENCY_RUN + ].model_copy( + update={ + "execution_kind": "human", + "setup_service_custody": None, + } + ) + assert AuthorizationService._admin_resource_matches( + ActionId.PROJECT_GUIDE_SUFFICIENCY_RUN, + ProjectGuideSufficiencyMutationResourceContext.model_validate( + human_sufficiency_run.model_dump() + ), + ) + with pytest.raises(ValidationError): + ProjectGuideMutationResourceContext( + resource_type="project_guide_mutation", + resource_id=uuid4(), + scope_project_id=project_id, + guide_id=guide_id, + target_kind="update", + guide_exists=True, + guide_status="draft", + guide_version="1", + operation_generation=1, + ) + for context_type, service_resource in ( + ( + ProjectGuideSufficiencyMutationResourceContext, + sufficiency_resources[ActionId.PROJECT_GUIDE_SUFFICIENCY_RUN], + ), + ( + ProjectSubmissionArtifactPolicyMutationResourceContext, + submission_resources[ActionId.PROJECT_SUBMISSION_ARTIFACT_POLICY_DERIVE], + ), + ( + ProjectPostSubmitCheckerPolicyMutationResourceContext, + checker_resources[ActionId.PROJECT_POST_SUBMIT_CHECKER_POLICY_DERIVE], + ), + ): + missing_custody = service_resource.model_dump() + missing_custody["setup_service_custody"] = None + 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() + human_derive["execution_kind"] = "human" + human_derive["setup_service_custody"] = None + with pytest.raises( + ValidationError, match="derivation requires setup-service authority" + ): + context_type.model_validate(human_derive) + wrong_lineage = service_resource.model_dump() + wrong_lineage["setup_service_custody"]["scope_project_id"] = uuid4() + with pytest.raises(ValidationError, match="setup lineage is inconsistent"): + context_type.model_validate(wrong_lineage) + wrong_generation = service_resource.model_dump() + wrong_generation["setup_service_custody"]["setup_generation"] = 2 + with pytest.raises(ValidationError, match="setup generation is inconsistent"): + context_type.model_validate(wrong_generation) + wrong_step = service_resource.model_dump() + wrong_step["setup_service_custody"]["expected_step"] = "post_submit_policy" + if context_type is ProjectPostSubmitCheckerPolicyMutationResourceContext: + wrong_step["setup_service_custody"]["expected_step"] = "guide_sufficiency" + 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 + ) + with pytest.raises(ValidationError, match="stale output is inconsistent"): + context_type.model_validate(wrong_stale_output) + changed_task = service_resource.model_copy( + update={ + "setup_service_custody": service_resource.setup_service_custody.model_copy( + update={"task_id": uuid4(), "correlation_id": uuid4()} + ) + } + ) + assert changed_task != service_resource + + def test_obsolete_artifact_upload_authority_is_historical_only() -> None: """Reject obsolete upload authority outside exact immutable/deletion evidence.""" repository_root = Path(__file__).resolve().parents[2] @@ -2172,7 +2584,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, 78 ActionIds, 37 active actions, and\n41 planned actions" in operations + "71 PermissionIds, 96 ActionIds, 37 active actions, and\n59 planned actions" in operations ) @@ -2988,6 +3400,49 @@ def in_nested_transaction(self) -> bool: return self.nested +@pytest.mark.asyncio +async def test_project_mutation_actions_cannot_issue_prepared_handles_while_planned() -> None: + """Deny every 12A action before actor locks, evidence, or handle issuance.""" + context = _runtime_context() + session = _PreparedTestSession() + + class UnexpectedFacts: + def __getattr__(self, name: str): + raise AssertionError(f"planned project mutation reached {name}") + + facts = UnexpectedFacts() + authorization, evidence = _runtime_service( + context, + session=session, + admin_repository=facts, + ) + prepared = PreparedAuthorizationService( + session, # type: ignore[arg-type] + context, + authorization, + facts, # type: ignore[arg-type] + ) + project_id = uuid4() + for action_id in PROJECT_MUTATION_RESOURCE_BY_ACTION: + 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, + ) + with pytest.raises(PreparedAuthorizationUnsupported) as exc_info: + await prepared.prepare( + action_id, + PreparedAuthorizationInput(idempotency_key=uuid4(), request_value={}), + scope, + ) + assert exc_info.value.denial_code is AuthorizationDenialCode.ACTION_UNAVAILABLE + assert prepared._issued == {} + assert evidence.events == [] + + class _ProjectReadAuthorityFacts: """Minimal grant repository used by project-read kernel tests.""" diff --git a/docs/operations_authorization_service.md b/docs/operations_authorization_service.md index 0a5ab05c8..c9935a0bb 100644 --- a/docs/operations_authorization_service.md +++ b/docs/operations_authorization_service.md @@ -672,16 +672,17 @@ resource loader, lifecycle guards, negative tests, and evidence path exist. ### Catalogue And Action-Evidence Staging -The catalogue contains exactly 71 PermissionIds and 78 ActionIds after -WS-XINT-002-01. The two AUTH-07B actor-self actions, seven AUTH-08 administrative +WS-XINT-002-01 left the catalogue at exactly 71 PermissionIds and 78 ActionIds. +The two AUTH-07B actor-self actions, seven AUTH-08 administrative actions, `actor.service.provision`, `actor.profile.read`, `actor.identity_link.read`, the three profile lifecycle actions, and the two identity-link lifecycle actions are active. The remaining five active actions are the AUTH-10B reads `project.contributor_candidate.list`, `project_role_grant.list`, and `project_role_grant.read`, plus the AUTH-10C mutations `project_role_grant.issue` and `project_role_grant.revoke`. WS-XINT-002-03 -also activates only the fixed verifier, pending-work scanner, and put resolver; -the other 53 entries remain planned and non-executable. The target post-custody +also activates only the fixed verifier, pending-work scanner, and put resolver. +Actions not named by a completed activation chunk remain planned and +non-executable. The target post-custody invariant is that planned runtime entries contain only action, permission, exact AUTH activation owner, and availability. The availability-neutral custody reconciliation assigns all 22 ART rows to ten exact activation custodians and all 19 REV @@ -708,8 +709,9 @@ reconciliation uses migration `0036`. The REV transfer adds no migration. The ART transfer does not grant Operator authority; its `OPERATOR` suffix denotes only future activation custody, and verification retry remains independently gated from read/status actions. -Catalogue totals are 71 PermissionIds, 78 ActionIds, 37 active actions, and -41 planned actions after AUTH-11C2 activates three current effective-policy and +Catalogue totals are 71 PermissionIds, 96 ActionIds, 37 active actions, and +59 planned actions after AUTH-12A registers eighteen project-mutation actions +without activation. AUTH-11C2 activates three current effective-policy and active-guide reads in addition to AUTH-11C1's six diagnostic reads. The exact route mapping is in `docs/spec_authorization_service.md`. WS-XINT-002-04A activates only guide-source ingest; the other 18 ART actions remain planned, including every diff --git a/docs/spec_authorization_service.md b/docs/spec_authorization_service.md index e22f22540..f2ca6081f 100644 --- a/docs/spec_authorization_service.md +++ b/docs/spec_authorization_service.md @@ -238,7 +238,9 @@ WS-XINT-002-01 add their matching typed/SQL audit parity without making them executable. The closed action registry contains 78 rows after AUTH-11C2: 37 active actions -and 41 planned rows. AUTH-10A added five project-role read/manage rows; +and 41 planned rows before AUTH-12A. AUTH-12A adds eighteen planned +project-mutation rows, producing 96 total actions: 37 active and 59 planned. +AUTH-10A added five project-role read/manage rows; AUTH-10B owns and activates the three reads, while AUTH-10C owns and activates the two reason-bound, idempotent project-role mutations. AUTH-11A adds eleven project identity and actor-context read rows: two are active under 11B, three @@ -915,6 +917,39 @@ 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 | +|---|---|---| +| `project.create` | `project.create` | `WS-AUTH-001-12C` | +| `project.guide.create` | `project.guide.manage` | `WS-AUTH-001-12D` | +| `project.guide.update` | `project.guide.manage` | `WS-AUTH-001-12D` | +| `project.guide_source_snapshot.create` | `project.guide.manage` | `WS-AUTH-001-12D` | +| `project.review_policy.update` | `project.review_policy.manage` | `WS-AUTH-001-12D2` | +| `project.revision_policy.update` | `project.review_policy.manage` | `WS-AUTH-001-12D2` | +| `project.guide_sufficiency_report.create` | `project.guide.manage` | `WS-AUTH-001-12E` | +| `project.guide_sufficiency.run` | `project.guide.manage` | `WS-AUTH-001-12E` | +| `project.guide_sufficiency.warnings.acknowledge` | `project.guide.manage` | `WS-AUTH-001-12E` | +| `project.submission_artifact_policy.create` | `project.effective_policy.manage` | `WS-AUTH-001-12F` | +| `project.submission_artifact_policy.derive` | `project.effective_policy.manage` | `WS-AUTH-001-12F` | +| `project.submission_artifact_policy.update` | `project.effective_policy.manage` | `WS-AUTH-001-12F` | +| `project.submission_artifact_policy.approve` | `project.effective_policy.manage` | `WS-AUTH-001-12F` | +| `project.post_submit_checker_policy.approve` | `project.effective_policy.manage` | `WS-AUTH-001-12G` | +| `project.post_submit_checker_policy.correction.request` | `project.effective_policy.manage` | `WS-AUTH-001-12G` | +| `project.post_submit_checker_policy.derive` | `project.effective_policy.manage` | `WS-AUTH-001-12G` | +| `project.setup_run.update` | `project.guide.manage` | `WS-AUTH-001-12B2` | +| `project.guide.activate` | `project.guide.manage` | `WS-AUTH-001-12H` | + +Migration `0041_project_mutation_evidence` extends only the closed audit +action-to-permission evidence constraint. It follows ART migration +`0040_guide_materialization`, adds no permission, and refuses downgrade after +direct or idempotency-linked evidence uses any new action. + The two collection routes return and transactionally bind at most the newest 100 canonical rows in deterministic newest-first order. Older retained records remain available only through their exact individually authorized read route. From 36678997e9aca3caaff80d3996366c294c47e27e Mon Sep 17 00:00:00 2001 From: Abiorh001 Date: Thu, 30 Jul 2026 00:41:59 +0100 Subject: [PATCH 2/6] fix(auth): use canonical Alembic constraint name --- .../alembic/versions/0041_project_mutation_action_evidence.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/alembic/versions/0041_project_mutation_action_evidence.py b/backend/alembic/versions/0041_project_mutation_action_evidence.py index 5fbe242a4..de24bddc6 100644 --- a/backend/alembic/versions/0041_project_mutation_action_evidence.py +++ b/backend/alembic/versions/0041_project_mutation_action_evidence.py @@ -56,7 +56,7 @@ def _definition() -> str: def _replace(definition: str) -> None: op.drop_constraint( - "ck_audit_events_authorization_action_evidence", + "authorization_action_evidence", "audit_events", type_="check", ) From 62e877ed240cc6910d6152aa5dddd5b572ba9fab Mon Sep 17 00:00:00 2001 From: Abiorh001 Date: Thu, 30 Jul 2026 00:46:15 +0100 Subject: [PATCH 3/6] test(auth): bound migration custody proof --- backend/tests/test_alembic.py | 34 ++++++++++++++++------------------ 1 file changed, 16 insertions(+), 18 deletions(-) diff --git a/backend/tests/test_alembic.py b/backend/tests/test_alembic.py index b56a7feb2..c3a7f2c92 100644 --- a/backend/tests/test_alembic.py +++ b/backend/tests/test_alembic.py @@ -2032,25 +2032,23 @@ def test_0041_project_mutation_action_evidence_refuses_downgrade( try: command.downgrade(config, "base") command.upgrade(config, "head") - for definition in definitions: - event_id = asyncio.run( - _insert_authorization_action_event_for( - isolated_database_env, - definition.action_id.value, - definition.permission_id.value, - ) - ) - with pytest.raises( - RuntimeError, - match="cannot downgrade non-empty project-mutation action evidence", - ): - command.downgrade(config, "0040_guide_materialization") - asyncio.run( - _remove_authority_audit_fixture( - isolated_database_env, event_id=event_id - ) + definition = definitions[0] + event_id = asyncio.run( + _insert_authorization_action_event_for( + isolated_database_env, + definition.action_id.value, + definition.permission_id.value, ) - event_id = "" + ) + with pytest.raises( + RuntimeError, + match="cannot downgrade non-empty project-mutation action evidence", + ): + command.downgrade(config, "0040_guide_materialization") + asyncio.run( + _remove_authority_audit_fixture(isolated_database_env, event_id=event_id) + ) + event_id = "" definition = definitions[-1] asyncio.run( From 8cb6eab93a5782a06ca25410bc031615ab66f687 Mon Sep 17 00:00:00 2001 From: Abiorh001 Date: Thu, 30 Jul 2026 00:52:25 +0100 Subject: [PATCH 4/6] test(auth): refresh 0041 schema fingerprint --- .../chunks/WS-AUTH-001-12A-project-mutation-catalogue.md | 1 + backend/tests/conftest.py | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/chunks/WS-AUTH-001-12A-project-mutation-catalogue.md b/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/chunks/WS-AUTH-001-12A-project-mutation-catalogue.md index c339d3a69..9c0785d17 100644 --- a/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/chunks/WS-AUTH-001-12A-project-mutation-catalogue.md +++ b/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/chunks/WS-AUTH-001-12A-project-mutation-catalogue.md @@ -40,6 +40,7 @@ backend/app/modules/audit/schemas.py backend/alembic/versions/_project_mutation_action_evidence.py backend/tests/test_authorization.py backend/tests/test_alembic.py +backend/tests/conftest.py docs/spec_authorization_service.md docs/operations_authorization_service.md .agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/** diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index 9d203e19d..5a6c311e9 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 = "d5b9373e7bcf9afbae4f8bef066c952a0b83146d90020c32d73d543d35c99c66" +EXPECTED_PUBLIC_SCHEMA_SHA256 = "9d6cb126924b2ddd0d805d0bdc21feb55f2827608a26c26e0ecb80072880447a" PROTECTED_TEST_TABLES = ( "actor_profile_migration_state", "alembic_version", From 232996c36a599823465da5b2e4bbd55393330208 Mon Sep 17 00:00:00 2001 From: Abiorh001 Date: Thu, 30 Jul 2026 01:07:20 +0100 Subject: [PATCH 5/6] test(auth): preserve historical action boundaries --- backend/tests/test_alembic.py | 37 ++++++++++++++--------------------- 1 file changed, 15 insertions(+), 22 deletions(-) diff --git a/backend/tests/test_alembic.py b/backend/tests/test_alembic.py index c3a7f2c92..3cfb5cbca 100644 --- a/backend/tests/test_alembic.py +++ b/backend/tests/test_alembic.py @@ -68,6 +68,17 @@ ) ) +_PROJECT_MUTATION_OWNERS = { + ActionOwner.AUTH_12B2, + ActionOwner.AUTH_12C, + ActionOwner.AUTH_12D, + ActionOwner.AUTH_12D2, + ActionOwner.AUTH_12E, + ActionOwner.AUTH_12F, + ActionOwner.AUTH_12G, + ActionOwner.AUTH_12H, +} + def _alembic_config() -> Config: project_root = Path(__file__).resolve().parents[1] @@ -1968,17 +1979,7 @@ def test_0041_project_mutation_action_evidence_round_trip( definitions = tuple( definition for definition in ACTION_DEFINITIONS - if definition.owner - in { - ActionOwner.AUTH_12B2, - ActionOwner.AUTH_12C, - ActionOwner.AUTH_12D, - ActionOwner.AUTH_12D2, - ActionOwner.AUTH_12E, - ActionOwner.AUTH_12F, - ActionOwner.AUTH_12G, - ActionOwner.AUTH_12H, - } + if definition.owner in _PROJECT_MUTATION_OWNERS ) assert len(definitions) == 18 assert {definition.permission_id for definition in definitions} <= set(PermissionId) @@ -2010,17 +2011,7 @@ def test_0041_project_mutation_action_evidence_refuses_downgrade( definitions = tuple( item for item in ACTION_DEFINITIONS - if item.owner - in { - ActionOwner.AUTH_12B2, - ActionOwner.AUTH_12C, - ActionOwner.AUTH_12D, - ActionOwner.AUTH_12D2, - ActionOwner.AUTH_12E, - ActionOwner.AUTH_12F, - ActionOwner.AUTH_12G, - ActionOwner.AUTH_12H, - } + if item.owner in _PROJECT_MUTATION_OWNERS ) assert len(definitions) == 18 event_id = "" @@ -3393,6 +3384,7 @@ def test_authorization_action_evidence_constraints_and_guarded_downgrade( ActionOwner.AUTH_11B, ActionOwner.AUTH_11C1, ActionOwner.AUTH_11C2, + *_PROJECT_MUTATION_OWNERS, ActionOwner.XINT_002_05A, ActionOwner.XINT_002_07, } @@ -3552,6 +3544,7 @@ def test_bootstrap_admin_grant_schema_is_immutable_and_guarded( ActionOwner.AUTH_11B, ActionOwner.AUTH_11C1, ActionOwner.AUTH_11C2, + *_PROJECT_MUTATION_OWNERS, ActionOwner.XINT_002_05A, ActionOwner.XINT_002_07, } From 76ef37130bbcd1a02fbffa29f291d50c6cfd7d9f Mon Sep 17 00:00:00 2001 From: Abiorh001 Date: Thu, 30 Jul 2026 01:34:21 +0100 Subject: [PATCH 6/6] fix(auth): address project mutation review findings --- ...AUTH-001-12A-project-mutation-catalogue.md | 4 +- ...S-AUTH-001-12A-external-review-response.md | 51 ++++++++ .../0041_project_mutation_action_evidence.py | 2 + backend/app/modules/authorization/kernel.py | 79 ++++++------ backend/app/modules/authorization/prepared.py | 2 +- backend/app/modules/authorization/runtime.py | 113 +++++++++++------- backend/tests/test_alembic.py | 7 +- backend/tests/test_authorization.py | 2 +- 8 files changed, 172 insertions(+), 88 deletions(-) create mode 100644 .agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/reviews/WS-AUTH-001-12A-external-review-response.md diff --git a/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/chunks/WS-AUTH-001-12A-project-mutation-catalogue.md b/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/chunks/WS-AUTH-001-12A-project-mutation-catalogue.md index 9c0785d17..d4e14a66a 100644 --- a/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/chunks/WS-AUTH-001-12A-project-mutation-catalogue.md +++ b/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/chunks/WS-AUTH-001-12A-project-mutation-catalogue.md @@ -37,7 +37,7 @@ backend/app/modules/authorization/runtime.py backend/app/modules/authorization/kernel.py backend/app/modules/authorization/prepared.py backend/app/modules/audit/schemas.py -backend/alembic/versions/_project_mutation_action_evidence.py +backend/alembic/versions/0041_project_mutation_action_evidence.py backend/tests/test_authorization.py backend/tests/test_alembic.py backend/tests/conftest.py @@ -87,7 +87,7 @@ cd backend app/modules/authorization/prepared.py \ app/modules/audit/schemas.py \ alembic/versions/0041_project_mutation_action_evidence.py \ - tests/test_authorization.py tests/test_alembic.py + tests/test_authorization.py tests/test_alembic.py tests/conftest.py PYTEST_DISABLE_PLUGIN_AUTOLOAD=1 .venv/bin/python scripts/run_isolated_tests.py \ --metadata-json .ci/auth12a.json --lane auth12a --timeout-seconds 1200 -- \ .venv/bin/python -m pytest -p pytest_asyncio.plugin -p pytest_cov.plugin -q \ diff --git a/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/reviews/WS-AUTH-001-12A-external-review-response.md b/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/reviews/WS-AUTH-001-12A-external-review-response.md new file mode 100644 index 000000000..a30dd34c9 --- /dev/null +++ b/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/reviews/WS-AUTH-001-12A-external-review-response.md @@ -0,0 +1,51 @@ +# WS-AUTH-001-12A External Review Response + +External review: CodeRabbit review on PR #226 at head `232996c3` + +## Comments addressed + +- Replaced the migration placeholder in the allowed-file boundary with the + exact implemented `0041_project_mutation_action_evidence.py` path. The + revision identifier remains the separately frozen + `0041_project_mutation_evidence` value. +- Added `tests/conftest.py` to the chunk's Ruff verification command so every + changed Python file is covered by the declared lint proof. +- Replaced the tautological `PermissionId` membership assertion with the exact + four pre-existing permissions used by the eighteen project-mutation actions. +- Documented the migration's intentional dependency on PostgreSQL constraint + rendering and its fail-closed drift guards. +- Made the project-mutation resource and target-kind maps immutable. +- Hoisted the static admin action-to-resource map out of the authorization hot + path and froze it. +- Made resource-to-PREP scope derivation explicitly static, removing the test's + `None` receiver workaround. +- Consolidated repeated setup-service custody checks without changing their + validation order or error wording. + +## Comments deferred + +None. All three actionable comments and five collapsed nitpicks were addressed. + +## Human decisions needed + +None. + +## Commands rerun + +- `git diff --check` +- Ruff over `tests/test_alembic.py` and `tests/conftest.py` +- Focused authorization tests: `2 passed, 366 deselected` +- The local isolated PostgreSQL runner refused the configured admin database + with `unsafe_admin_database`; this safety guard was not bypassed, and hosted + CI owns the isolated migration proof. +- Stale authorization wording, stale Workstream wording, and Markdown-link + checks + +Hosted `Backend / test` and `Agent Gates` are pending on the corrective pushed +head and remain required before merge readiness. + +## Remaining risks + +The actions remain planned and externally inactive. Runtime activation and +mutation-service integration remain owned by their separately bounded child +chunks. diff --git a/backend/alembic/versions/0041_project_mutation_action_evidence.py b/backend/alembic/versions/0041_project_mutation_action_evidence.py index de24bddc6..d295763fa 100644 --- a/backend/alembic/versions/0041_project_mutation_action_evidence.py +++ b/backend/alembic/versions/0041_project_mutation_action_evidence.py @@ -67,6 +67,8 @@ def _replace(definition: str) -> None: def _pair_token(action: str, permission: str) -> str: + # Keep this byte-for-byte aligned with PostgreSQL's pg_get_constraintdef + # rendering; _rewrite's marker-count guards fail closed if that format drifts. return ( f"(((action_id)::text = '{action}'::text) AND " f"((permission_id)::text = '{permission}'::text))" diff --git a/backend/app/modules/authorization/kernel.py b/backend/app/modules/authorization/kernel.py index 7fb6f9d73..23b5b045b 100644 --- a/backend/app/modules/authorization/kernel.py +++ b/backend/app/modules/authorization/kernel.py @@ -3,6 +3,7 @@ from __future__ import annotations from collections.abc import Awaitable, Callable +from types import MappingProxyType from uuid import UUID, uuid4 from sqlalchemy.exc import SQLAlchemyError @@ -168,6 +169,46 @@ ), } +_ADMIN_EXPECTED_RESOURCES = MappingProxyType( + { + ActionId.AUTHORIZATION_PERMISSION_CATALOGUE_READ: PermissionCatalogueResourceContext, + ActionId.AUTHORIZATION_ADMIN_ROLE_DEFINITIONS_READ: AdminRoleDefinitionsResourceContext, + ActionId.ADMIN_ROLE_GRANT_LIST: AdminRoleGrantCollectionResourceContext, + ActionId.ACTOR_ADMIN_ROLE_GRANT_HISTORY_READ: ActorAdminRoleGrantHistoryResourceContext, + ActionId.ADMIN_ROLE_GRANT_ISSUE: AdminRoleGrantIssueResourceContext, + ActionId.ADMIN_ROLE_GRANT_REVOKE: AdminRoleGrantResourceContext, + ActionId.ACTOR_SERVICE_PROVISION: ServiceActorProvisionResourceContext, + ActionId.ACTOR_PROFILE_READ: ActorProfileAdminReadResourceContext, + ActionId.ACTOR_IDENTITY_LINK_READ: ActorIdentityLinkAdminReadResourceContext, + ActionId.ACTOR_PROFILE_SUSPEND: ActorProfileLifecycleResourceContext, + ActionId.ACTOR_PROFILE_REACTIVATE: ActorProfileLifecycleResourceContext, + ActionId.ACTOR_PROFILE_DEACTIVATE: ActorProfileLifecycleResourceContext, + ActionId.ACTOR_IDENTITY_LINK_REVOKE: ActorIdentityLinkLifecycleResourceContext, + ActionId.ACTOR_IDENTITY_LINK_REACTIVATE: ActorIdentityLinkLifecycleResourceContext, + ActionId.PROJECT_CONTRIBUTOR_CANDIDATE_LIST: ( + ProjectContributorCandidateCollectionResourceContext + ), + ActionId.PROJECT_ROLE_GRANT_LIST: ProjectRoleGrantCollectionResourceContext, + ActionId.PROJECT_ROLE_GRANT_READ: ProjectRoleGrantReadResourceContext, + ActionId.PROJECT_ROLE_GRANT_ISSUE: ProjectRoleGrantIssueResourceContext, + ActionId.PROJECT_ROLE_GRANT_REVOKE: ProjectRoleGrantRevokeResourceContext, + ActionId.PROJECT_SETUP_RUN_READ: ProjectDiagnosticReadResourceContext, + ActionId.PROJECT_GUIDE_SUFFICIENCY_REPORT_LIST: ProjectDiagnosticReadResourceContext, + ActionId.PROJECT_GUIDE_SUFFICIENCY_REPORT_READ: ProjectDiagnosticReadResourceContext, + ActionId.PROJECT_SUBMISSION_ARTIFACT_POLICY_LIST: ProjectDiagnosticReadResourceContext, + ActionId.PROJECT_SUBMISSION_ARTIFACT_POLICY_READ: ProjectDiagnosticReadResourceContext, + ActionId.PROJECT_POST_SUBMIT_CHECKER_POLICY_SETUP_READ: ( + ProjectDiagnosticReadResourceContext + ), + ActionId.PROJECT_EFFECTIVE_SUBMISSION_ARTIFACT_POLICY_READ: ( + ProjectPolicyReadResourceContext + ), + ActionId.PROJECT_PRE_SUBMIT_CHECKER_POLICY_READ: ProjectPolicyReadResourceContext, + ActionId.PROJECT_ACTIVE_GUIDE_READ: ProjectActiveGuideReadResourceContext, + **PROJECT_MUTATION_RESOURCE_BY_ACTION, + } +) + def project_action_available_for_status(action_id: ActionId, project_status: str) -> bool: """Apply project-only lifecycle guards shared by decisions and projections.""" @@ -1075,43 +1116,7 @@ def _admin_resource_matches( action_id: ActionId, resource: AuthorizationResourceContext, ) -> bool: - expected = { - ActionId.AUTHORIZATION_PERMISSION_CATALOGUE_READ: PermissionCatalogueResourceContext, - ActionId.AUTHORIZATION_ADMIN_ROLE_DEFINITIONS_READ: AdminRoleDefinitionsResourceContext, - ActionId.ADMIN_ROLE_GRANT_LIST: AdminRoleGrantCollectionResourceContext, - ActionId.ACTOR_ADMIN_ROLE_GRANT_HISTORY_READ: ActorAdminRoleGrantHistoryResourceContext, - ActionId.ADMIN_ROLE_GRANT_ISSUE: AdminRoleGrantIssueResourceContext, - ActionId.ADMIN_ROLE_GRANT_REVOKE: AdminRoleGrantResourceContext, - ActionId.ACTOR_SERVICE_PROVISION: ServiceActorProvisionResourceContext, - ActionId.ACTOR_PROFILE_READ: ActorProfileAdminReadResourceContext, - ActionId.ACTOR_IDENTITY_LINK_READ: ActorIdentityLinkAdminReadResourceContext, - ActionId.ACTOR_PROFILE_SUSPEND: ActorProfileLifecycleResourceContext, - ActionId.ACTOR_PROFILE_REACTIVATE: ActorProfileLifecycleResourceContext, - ActionId.ACTOR_PROFILE_DEACTIVATE: ActorProfileLifecycleResourceContext, - ActionId.ACTOR_IDENTITY_LINK_REVOKE: ActorIdentityLinkLifecycleResourceContext, - ActionId.ACTOR_IDENTITY_LINK_REACTIVATE: ActorIdentityLinkLifecycleResourceContext, - ActionId.PROJECT_CONTRIBUTOR_CANDIDATE_LIST: ( - ProjectContributorCandidateCollectionResourceContext - ), - ActionId.PROJECT_ROLE_GRANT_LIST: ProjectRoleGrantCollectionResourceContext, - ActionId.PROJECT_ROLE_GRANT_READ: ProjectRoleGrantReadResourceContext, - ActionId.PROJECT_ROLE_GRANT_ISSUE: ProjectRoleGrantIssueResourceContext, - ActionId.PROJECT_ROLE_GRANT_REVOKE: ProjectRoleGrantRevokeResourceContext, - ActionId.PROJECT_SETUP_RUN_READ: ProjectDiagnosticReadResourceContext, - ActionId.PROJECT_GUIDE_SUFFICIENCY_REPORT_LIST: ProjectDiagnosticReadResourceContext, - ActionId.PROJECT_GUIDE_SUFFICIENCY_REPORT_READ: ProjectDiagnosticReadResourceContext, - ActionId.PROJECT_SUBMISSION_ARTIFACT_POLICY_LIST: ProjectDiagnosticReadResourceContext, - ActionId.PROJECT_SUBMISSION_ARTIFACT_POLICY_READ: ProjectDiagnosticReadResourceContext, - ActionId.PROJECT_POST_SUBMIT_CHECKER_POLICY_SETUP_READ: ( - ProjectDiagnosticReadResourceContext - ), - ActionId.PROJECT_EFFECTIVE_SUBMISSION_ARTIFACT_POLICY_READ: ( - ProjectPolicyReadResourceContext - ), - ActionId.PROJECT_PRE_SUBMIT_CHECKER_POLICY_READ: ProjectPolicyReadResourceContext, - ActionId.PROJECT_ACTIVE_GUIDE_READ: ProjectActiveGuideReadResourceContext, - **PROJECT_MUTATION_RESOURCE_BY_ACTION, - }.get(action_id) + expected = _ADMIN_EXPECTED_RESOURCES.get(action_id) if expected is None or not isinstance(resource, expected): return False diagnostic_kind = PROJECT_DIAGNOSTIC_TARGET_KIND_BY_ACTION.get(action_id) diff --git a/backend/app/modules/authorization/prepared.py b/backend/app/modules/authorization/prepared.py index b6f2bb4b5..97b1b3c7e 100644 --- a/backend/app/modules/authorization/prepared.py +++ b/backend/app/modules/authorization/prepared.py @@ -212,8 +212,8 @@ def _binding( ), ) + @staticmethod def _scope_from_resource( - self, action_id: ActionId, resource: AuthorizationResourceContext, ) -> PreparedAuthorityScope: diff --git a/backend/app/modules/authorization/runtime.py b/backend/app/modules/authorization/runtime.py index 21acdcca6..51a35b7c2 100644 --- a/backend/app/modules/authorization/runtime.py +++ b/backend/app/modules/authorization/runtime.py @@ -2,6 +2,7 @@ from __future__ import annotations +from types import MappingProxyType from enum import StrEnum from typing import Literal from uuid import NAMESPACE_URL, UUID, uuid5 @@ -583,6 +584,32 @@ class ProjectSetupServiceCustodyContext(BaseModel): stale_output_digest: str = Field(pattern=r"^sha256:[0-9a-f]{64}$") +def _require_setup_custody( + custody: ProjectSetupServiceCustodyContext, + *, + label: str, + expected_step: str, + setup_generation: int, + stale_output_digest: str | None, + scope_project_id: UUID, + guide_id: UUID, + source_snapshot_id: UUID, +) -> None: + """Reject setup-service custody that does not match the protected lineage.""" + if custody.expected_step != expected_step: + raise ValueError(f"{label} setup-service step is inconsistent") + if custody.setup_generation != setup_generation: + raise ValueError(f"{label} setup generation is inconsistent") + if custody.stale_output_digest != stale_output_digest: + raise ValueError(f"{label} stale output is inconsistent") + if ( + custody.scope_project_id != scope_project_id + or custody.guide_id != guide_id + or custody.source_snapshot_id != source_snapshot_id + ): + raise ValueError(f"{label} setup lineage is inconsistent") + + class ProjectGuideSufficiencyMutationResourceContext(BaseModel): """Canonical snapshot and report facts for sufficiency mutations.""" @@ -617,18 +644,16 @@ def require_sufficiency_identity(self): if service_execution: if self.target_kind != "run": raise ValueError("only a sufficiency run may use setup-service authority") - if self.setup_service_custody.expected_step != "guide_sufficiency": - raise ValueError("sufficiency setup-service step is inconsistent") - if self.setup_service_custody.setup_generation != self.setup_generation: - raise ValueError("sufficiency setup generation is inconsistent") - if self.setup_service_custody.stale_output_digest != self.stale_output_digest: - raise ValueError("sufficiency stale output is inconsistent") - if ( - self.setup_service_custody.scope_project_id != self.scope_project_id - or self.setup_service_custody.guide_id != self.guide_id - or self.setup_service_custody.source_snapshot_id != self.source_snapshot_id - ): - raise ValueError("sufficiency setup lineage is inconsistent") + _require_setup_custody( + self.setup_service_custody, + label="sufficiency", + expected_step="guide_sufficiency", + setup_generation=self.setup_generation, + stale_output_digest=self.stale_output_digest, + scope_project_id=self.scope_project_id, + guide_id=self.guide_id, + source_snapshot_id=self.source_snapshot_id, + ) return self @@ -671,18 +696,16 @@ def require_submission_policy_identity(self): if service_execution != (self.target_kind == "derive"): raise ValueError("policy derivation requires setup-service authority") if service_execution: - if self.setup_service_custody.expected_step != "submission_artifact_policy": - raise ValueError("submission policy setup-service step is inconsistent") - if self.setup_service_custody.setup_generation != self.setup_generation: - raise ValueError("submission policy setup generation is inconsistent") - if self.setup_service_custody.stale_output_digest != self.stale_output_digest: - raise ValueError("submission policy stale output is inconsistent") - if ( - self.setup_service_custody.scope_project_id != self.scope_project_id - or self.setup_service_custody.guide_id != self.guide_id - or self.setup_service_custody.source_snapshot_id != self.source_snapshot_id - ): - raise ValueError("submission policy setup lineage is inconsistent") + _require_setup_custody( + self.setup_service_custody, + label="submission policy", + expected_step="submission_artifact_policy", + setup_generation=self.setup_generation, + stale_output_digest=self.stale_output_digest, + scope_project_id=self.scope_project_id, + guide_id=self.guide_id, + source_snapshot_id=self.source_snapshot_id, + ) return self @@ -717,18 +740,16 @@ def require_checker_policy_identity(self): if service_execution != (self.target_kind == "derive"): raise ValueError("checker derivation requires setup-service authority") if service_execution: - if self.setup_service_custody.expected_step != "post_submit_policy": - raise ValueError("checker policy setup-service step is inconsistent") - if self.setup_service_custody.setup_generation != self.setup_generation: - raise ValueError("checker policy setup generation is inconsistent") - if self.setup_service_custody.stale_output_digest != self.compiled_policy_digest: - raise ValueError("checker policy stale output is inconsistent") - if ( - self.setup_service_custody.scope_project_id != self.scope_project_id - or self.setup_service_custody.guide_id != self.guide_id - or self.setup_service_custody.source_snapshot_id != self.source_snapshot_id - ): - raise ValueError("checker policy setup lineage is inconsistent") + _require_setup_custody( + self.setup_service_custody, + label="checker policy", + expected_step="post_submit_policy", + setup_generation=self.setup_generation, + stale_output_digest=self.compiled_policy_digest, + scope_project_id=self.scope_project_id, + guide_id=self.guide_id, + source_snapshot_id=self.source_snapshot_id, + ) return self @@ -784,7 +805,7 @@ def require_activation_identity(self): return self -PROJECT_MUTATION_RESOURCE_BY_ACTION = { +PROJECT_MUTATION_RESOURCE_BY_ACTION = MappingProxyType({ ActionId.PROJECT_CREATE: ProjectCreateResourceContext, ActionId.PROJECT_GUIDE_CREATE: ProjectGuideMutationResourceContext, ActionId.PROJECT_GUIDE_UPDATE: ProjectGuideMutationResourceContext, @@ -823,31 +844,31 @@ def require_activation_identity(self): ), ActionId.PROJECT_SETUP_RUN_UPDATE: ProjectSetupRunMutationResourceContext, ActionId.PROJECT_GUIDE_ACTIVATE: ProjectGuideActivationResourceContext, -} +}) -PROJECT_SUFFICIENCY_TARGET_KIND_BY_ACTION = { +PROJECT_SUFFICIENCY_TARGET_KIND_BY_ACTION = MappingProxyType({ ActionId.PROJECT_GUIDE_SUFFICIENCY_REPORT_CREATE: "report", ActionId.PROJECT_GUIDE_SUFFICIENCY_RUN: "run", ActionId.PROJECT_GUIDE_SUFFICIENCY_WARNINGS_ACKNOWLEDGE: "warning_acknowledgement", -} +}) -PROJECT_GUIDE_TARGET_KIND_BY_ACTION = { +PROJECT_GUIDE_TARGET_KIND_BY_ACTION = MappingProxyType({ ActionId.PROJECT_GUIDE_CREATE: "create", ActionId.PROJECT_GUIDE_UPDATE: "update", -} +}) -PROJECT_SUBMISSION_POLICY_TARGET_KIND_BY_ACTION = { +PROJECT_SUBMISSION_POLICY_TARGET_KIND_BY_ACTION = MappingProxyType({ ActionId.PROJECT_SUBMISSION_ARTIFACT_POLICY_CREATE: "create", ActionId.PROJECT_SUBMISSION_ARTIFACT_POLICY_DERIVE: "derive", ActionId.PROJECT_SUBMISSION_ARTIFACT_POLICY_UPDATE: "update", ActionId.PROJECT_SUBMISSION_ARTIFACT_POLICY_APPROVE: "approve", -} +}) -PROJECT_POST_SUBMIT_POLICY_TARGET_KIND_BY_ACTION = { +PROJECT_POST_SUBMIT_POLICY_TARGET_KIND_BY_ACTION = MappingProxyType({ ActionId.PROJECT_POST_SUBMIT_CHECKER_POLICY_APPROVE: "approve", ActionId.PROJECT_POST_SUBMIT_CHECKER_POLICY_CORRECTION_REQUEST: "correction_request", ActionId.PROJECT_POST_SUBMIT_CHECKER_POLICY_DERIVE: "derive", -} +}) class ActorAuthorizationContextResourceContext(BaseModel): diff --git a/backend/tests/test_alembic.py b/backend/tests/test_alembic.py index 3cfb5cbca..de3b06b76 100644 --- a/backend/tests/test_alembic.py +++ b/backend/tests/test_alembic.py @@ -1982,7 +1982,12 @@ def test_0041_project_mutation_action_evidence_round_trip( if definition.owner in _PROJECT_MUTATION_OWNERS ) assert len(definitions) == 18 - assert {definition.permission_id for definition in definitions} <= set(PermissionId) + assert {definition.permission_id for definition in definitions} == { + PermissionId.PROJECT_CREATE, + PermissionId.PROJECT_GUIDE_MANAGE, + PermissionId.PROJECT_REVIEW_POLICY_MANAGE, + PermissionId.PROJECT_EFFECTIVE_POLICY_MANAGE, + } with migration_lock(): try: command.downgrade(config, "base") diff --git a/backend/tests/test_authorization.py b/backend/tests/test_authorization.py index 162d79e06..a3dd83beb 100644 --- a/backend/tests/test_authorization.py +++ b/backend/tests/test_authorization.py @@ -2289,7 +2289,7 @@ def test_project_mutation_resources_and_prepared_scopes_are_closed() -> None: assert set(resources) == set(PROJECT_MUTATION_RESOURCE_BY_ACTION) for action_id, resource in resources.items(): assert AuthorizationService._admin_resource_matches(action_id, resource) - scope = PreparedAuthorizationService._scope_from_resource(None, action_id, resource) + scope = PreparedAuthorizationService._scope_from_resource(action_id, resource) if action_id is ActionId.PROJECT_CREATE: assert scope == PreparedAuthorityScope(kind=PreparedAuthorityScopeKind.SYSTEM) else: