diff --git a/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/ACTIVATION_CUSTODY.md b/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/ACTIVATION_CUSTODY.md index 810d93290..88641b251 100644 --- a/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/ACTIVATION_CUSTODY.md +++ b/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/ACTIVATION_CUSTODY.md @@ -3,9 +3,10 @@ The final v0.1 ART catalogue reconciliation, PREP extension, and activation waves are superseded prospectively by `../WS-XINT-002-art-auth-end-to-end/`. The counts immediately below are the -trusted pre-reconciliation entry evidence; WS-XINT-002-01 replaces them with -the live 71/78/22/56 catalogue recorded in the ART custody section without -changing runtime availability. +trusted pre-reconciliation entry evidence; at its merge, WS-XINT-002-01 +replaced them with the then-live 71/78/22/56 catalogue recorded in the ART +custody section without changing runtime availability. Subsequent AUTH chunks +have advanced the current catalogue to 71/96/37/59. The pre-reconciliation baseline is trusted `main` commit `2fb322bd2249a5fe9d3fa706dc63f033074e38ce`: 76 PermissionIds, 81 ActionIds, 22 active actions, and 59 planned actions. Older counts below are explicitly @@ -62,8 +63,8 @@ remains planned and cannot be activated by read/status proof. The historical transfer added no migration because owner and availability are typed metadata. WS-XINT-002-01 reconciles PostgreSQL parity through migration `0036`; the live catalogue has -71 PermissionIds, 78 ActionIds, 22 active actions, and 56 planned actions, with -seven fixed-service identities and twelve matrix memberships. +71 PermissionIds, 96 ActionIds, 37 active actions, and 59 planned actions, with +eight fixed-service identities and sixteen matrix memberships. ## REV custody transfer 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 c24f7d258..c896f7270 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,8 +57,8 @@ 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 | 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-12A` | Project Mutation Catalogue And PREP Foundation | L1 | Merged as PR #226 with AUTH `0041`; zero activation | +| `WS-AUTH-001-12B` | Fixed Project Setup Service Foundation | L1 | Internal review complete; hosted checks pending; identity/matrix registration only, 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 | | `WS-AUTH-001-12D` | Draft Guide And Source Metadata Cutover | L1 | Proposed after 12C | 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 551937e89..ebf6771db 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 @@ -172,8 +172,8 @@ Agent Gates, and external review remain before merge readiness. | `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` | 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-12A` | Merged | `codex/ws-auth-001-12a-project-mutation-catalogue` | #226 | Exact 18-action planned catalogue, typed resource/PREP scope, and migration `0041`; merged as `64dd9c98` with zero activation. | +| `WS-AUTH-001-12B` | Internal review complete; hosted checks pending | `codex/ws-auth-001-12b-project-setup-service` | - | Fixed project-setup service identity and planned matrix only; zero activation and no actor/link seed. | | `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. | | `WS-AUTH-001-12D` | Proposed | - | - | Draft guide and source metadata mutation cutover. | diff --git a/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/chunks/WS-AUTH-001-12B-project-setup-service.md b/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/chunks/WS-AUTH-001-12B-project-setup-service.md index 83624e3c6..a993662ea 100644 --- a/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/chunks/WS-AUTH-001-12B-project-setup-service.md +++ b/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/chunks/WS-AUTH-001-12B-project-setup-service.md @@ -2,8 +2,11 @@ ## Status and prerequisite -Proposed and inactive after 12A. This child provisions planned matrix facts and -must activate no action or Celery call path. +Implementation and required internal review are complete from merged 12A at +trusted main `64dd9c98`; hosted PR checks remain pending. This child registers +planned matrix facts and activates no action or Celery call path. Its exact +migration is `0043_project_setup_service` after merged ART migration +`0042_guide_extraction`. ## Parent initiative @@ -11,10 +14,13 @@ must activate no action or Celery call path. ## Goal -Provision one exact project-setup service identity and planned memberships for +Register one exact project-setup service identity and planned memberships for `project.guide_sufficiency.run`, `project.submission_artifact_policy.derive`, `project.post_submit_checker_policy.derive`, and `project.setup_run.update`. -Activate none of them here. +Activate none of them here. Registration makes the closed identity available +to the existing controlled service-actor provisioning route only after an +administrator supplies an exact issuer and subject; this chunk seeds no actor +profile or identity link. ## Why this chunk exists @@ -33,21 +39,15 @@ P1 ```text backend/app/modules/actors/models.py -backend/app/modules/actors/repository.py backend/app/modules/actors/service_identities.py +backend/app/modules/actors/service_identity_migration.py backend/app/modules/authorization/catalogue.py -backend/app/modules/authorization/kernel.py -backend/app/modules/authorization/prepared.py -backend/app/modules/authorization/runtime.py -backend/app/modules/authorization/service_actor_service.py -backend/app/modules/projects/repository.py -backend/app/modules/projects/service.py -backend/app/modules/projects/setup_queue.py -backend/alembic/versions/_project_setup_service.py +backend/alembic/versions/0043_project_setup_service.py +backend/tests/test_actor_migration_tools.py backend/tests/test_authorization.py -backend/tests/test_projects.py backend/tests/test_alembic.py -backend/scripts/api_contract_e2e.py +backend/tests/test_auth.py +backend/tests/conftest.py docs/spec_authorization_service.md docs/operations_authorization_service.md .agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/** @@ -57,33 +57,100 @@ docs/operations_authorization_service.md Human route activation, Celery cutover, any action activation, generic setup-service authority, serialized prepared handles, ART/provider behavior, -checker execution, or review/contribution behavior. +checker execution, or review/contribution behavior. Do not change +`authorization/kernel.py`, `authorization/prepared.py`, +`authorization/runtime.py`, `authorization/service_actor_service.py`, any +project module, the setup queue, or the current Celery setup worker module. ## Acceptance criteria -- Immutable identity `workstream.project.setup` has exactly the four parent actions and - no human/admin/project grants. +- Immutable identity `workstream.project.setup` is registered as the eighth + closed service identity and has exactly the four parent actions. Static + membership grants no human/admin/project role, and migration `0043` seeds no + ActorProfile, ActorIdentityLink, admin grant, or project grant. - All four memberships remain planned and unavailable; the existing Celery call graph is not switched in this foundation. -- Exact setup run, project, guide, snapshot, generation, effective policy, and - pre-submit checker facts are locked/recomposed as applicable before consume. -- Wrong service/action/project/guide/snapshot/run/generation, stale output, - replay, revocation, copied handle, and transaction/session mismatch deny - before durable mutation or external continuation. +- Own-action attempts fail with `action_unavailable` before actor/resource + locks, handle issuance, or allowed evidence. Every other fixed identity fails + each of these four actions with `permission_not_granted`; the project-setup + identity likewise fails every action owned by another fixed identity. +- Live lock/recomposition, final PREP consumption, stale-output, replay, + revocation, copied-handle, session/transaction, and external-continuation + proof remains owned by 12E, 12F, 12G, and 12B2 after their exact actions + activate. This foundation proves those paths are unreachable while planned. - The fabricated legacy setup actor remains unchanged until 12B2; this foundation makes no Celery call-graph or runtime-principal change. - Matrix tests prove the identity has only these four actions and all-pairs cross-service denial. Later 12E/12F/12G own product action activation; 12B2 alone owns the final Celery call-graph cutover and setup-run writes. +- The frozen revision-0023 seven-identity migration contract is not edited. + Its operator mapping tool consumes that frozen contract rather than the live + registry. `0043` alone expands the current database constraint, round-trips + cleanly, and refuses downgrade while a project-setup ActorProfile exists. +- Specification and operations docs list the eighth identity and its exact + four planned/unavailable actions while preserving explicitly historical + seven-identity AUTH-09A wording. - Every changed authorization/project/setup-service module remains at least 90 percent covered. Final pushed head SHA passes `Backend / test` and `Agent Gates`. ## Verification commands -Before start, freeze the exact isolated-runner command, coverage includes, -Ruff, migration round-trip, fixed-service all-pairs denial proof, stale docs, -links, and diff commands. +```bash +cd backend +install -d -m 700 .ci +.venv/bin/python -m ruff check \ + app/modules/actors/models.py \ + app/modules/actors/service_identities.py \ + app/modules/actors/service_identity_migration.py \ + app/modules/authorization/catalogue.py \ + alembic/versions/0043_project_setup_service.py \ + tests/test_actor_migration_tools.py tests/test_authorization.py \ + tests/test_alembic.py tests/test_auth.py tests/conftest.py +.venv/bin/python -m py_compile \ + app/modules/actors/models.py \ + app/modules/actors/service_identities.py \ + app/modules/actors/service_identity_migration.py \ + app/modules/authorization/catalogue.py \ + alembic/versions/0043_project_setup_service.py \ + tests/test_actor_migration_tools.py tests/test_authorization.py \ + tests/test_alembic.py tests/test_auth.py tests/conftest.py +PYTEST_DISABLE_PLUGIN_AUTOLOAD=1 .venv/bin/python scripts/run_isolated_tests.py \ + --metadata-json .ci/auth12b.json --lane auth12b --timeout-seconds 1200 -- \ + .venv/bin/python -m pytest -p pytest_asyncio.plugin -p pytest_cov.plugin -q \ + tests/test_actor_migration_tools.py tests/test_authorization.py tests/test_alembic.py \ + tests/test_auth.py \ + -k 'project_setup_service or controlled_service_actor_provisioning_includes_project_setup or fixed_service_action_matrix or 0043_project_setup or service_identity_migration_contract' +PYTEST_DISABLE_PLUGIN_AUTOLOAD=1 .venv/bin/python scripts/run_isolated_tests.py \ + --metadata-json .ci/auth12b-migration-coverage.json \ + --lane auth12b_migration_coverage --timeout-seconds 1200 -- sh -c \ + '.venv/bin/coverage erase && \ + .venv/bin/coverage run --include="*/alembic/versions/0043_project_setup_service.py" \ + -m pytest -p pytest_asyncio.plugin -q tests/test_alembic.py \ + -k "0043_project_setup" && \ + .venv/bin/coverage report \ + --include="*/alembic/versions/0043_project_setup_service.py" \ + --show-missing --fail-under=90' +PYTEST_DISABLE_PLUGIN_AUTOLOAD=1 .venv/bin/python -m pytest \ + -p pytest_asyncio.plugin -p pytest_cov.plugin -q tests/test_actor_migration_tools.py \ + --cov=app.modules.actors.service_identity_migration \ + --cov-report=term-missing --cov-fail-under=90 +PYTEST_DISABLE_PLUGIN_AUTOLOAD=1 .venv/bin/python -m pytest \ + -p pytest_asyncio.plugin -p pytest_cov.plugin -q tests/test_authorization.py \ + -k 'project_setup_service or fixed_service_action_matrix' \ + --cov=app.modules.actors.service_identities \ + --cov=app.modules.actors.models \ + --cov=app.modules.authorization.catalogue \ + --cov-report=term-missing --cov-fail-under=90 +cd .. +python3 scripts/check_stale_authorization_docs.py +python3 scripts/check_stale_workstream_wording.py +python3 scripts/check_markdown_links.py +git diff --check +``` + +Final pushed head SHA must pass `Backend / test` and `Agent Gates`; hosted +Backend owns fresh full-suite coverage and isolated PostgreSQL migration proof. ## Required reviewers diff --git a/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/chunks/WS-AUTH-001-12B2-project-setup-service-cutover.md b/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/chunks/WS-AUTH-001-12B2-project-setup-service-cutover.md index 3019cea8f..b1de2841b 100644 --- a/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/chunks/WS-AUTH-001-12B2-project-setup-service-cutover.md +++ b/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/chunks/WS-AUTH-001-12B2-project-setup-service-cutover.md @@ -16,7 +16,7 @@ invoke is active for that identity. ## Why this chunk exists -Provisioning the service early avoids invented authority, but switching the +Registering the service identity early avoids invented authority, but switching the call graph early would duplicate or bypass sufficiency and policy provenance. ## Risk class diff --git a/backend/alembic/versions/0043_project_setup_service.py b/backend/alembic/versions/0043_project_setup_service.py new file mode 100644 index 000000000..7e48630b7 --- /dev/null +++ b/backend/alembic/versions/0043_project_setup_service.py @@ -0,0 +1,62 @@ +"""register the fixed project-setup service identity + +Revision ID: 0043_project_setup_service +Revises: 0042_guide_extraction +Create Date: 2026-07-30 +""" + +from __future__ import annotations + +from alembic import op +import sqlalchemy as sa + + +revision = "0043_project_setup_service" +down_revision = "0042_guide_extraction" +branch_labels = depends_on = None + +_HISTORICAL_IDENTITIES = ( + "workstream.artifact.verifier", + "workstream.artifact.put_resolver", + "workstream.artifact.scheduler", + "workstream.artifact.binding", + "workstream.artifact.guide_reader", + "workstream.artifact.materializer", + "workstream.artifact.checker_output", +) +_PROJECT_SETUP_IDENTITY = "workstream.project.setup" + + +def _tokens(values: tuple[str, ...]) -> str: + return ",".join(f"'{value}'" for value in values) + + +def _replace_identity_constraint(values: tuple[str, ...]) -> None: + op.drop_constraint("kind_service_identity", "actor_profiles", type_="check") + op.create_check_constraint( + "kind_service_identity", + "actor_profiles", + "(actor_kind='human' and service_identity is null) or " + f"(actor_kind='service' and service_identity in ({_tokens(values)}))", + ) + + +def upgrade() -> None: + """Admit the eighth closed identity without creating a service actor.""" + op.get_bind().execute(sa.text("lock table actor_profiles in access exclusive mode")) + _replace_identity_constraint((*_HISTORICAL_IDENTITIES, _PROJECT_SETUP_IDENTITY)) + + +def downgrade() -> None: + """Restore the seven-identity constraint only when the new identity is unused.""" + bind = op.get_bind() + bind.execute(sa.text("lock table actor_profiles in access exclusive mode")) + in_use = bind.execute( + sa.text( + "select exists(select 1 from actor_profiles where service_identity=:identity)" + ), + {"identity": _PROJECT_SETUP_IDENTITY}, + ).scalar_one() + if in_use: + raise RuntimeError("cannot downgrade project setup service identity") + _replace_identity_constraint(_HISTORICAL_IDENTITIES) diff --git a/backend/app/modules/actors/service_identities.py b/backend/app/modules/actors/service_identities.py index 9e8ebdf15..749175b32 100644 --- a/backend/app/modules/actors/service_identities.py +++ b/backend/app/modules/actors/service_identities.py @@ -16,6 +16,7 @@ class ServiceIdentity(StrEnum): ARTIFACT_GUIDE_READER = "workstream.artifact.guide_reader" ARTIFACT_MATERIALIZER = "workstream.artifact.materializer" ARTIFACT_CHECKER_OUTPUT = "workstream.artifact.checker_output" + PROJECT_SETUP = "workstream.project.setup" SERVICE_IDENTITIES = frozenset(ServiceIdentity) diff --git a/backend/app/modules/actors/service_identity_migration.py b/backend/app/modules/actors/service_identity_migration.py index c90f9b947..028edf2a7 100644 --- a/backend/app/modules/actors/service_identity_migration.py +++ b/backend/app/modules/actors/service_identity_migration.py @@ -19,7 +19,7 @@ from sqlalchemy.ext.asyncio import AsyncConnection, AsyncEngine from app.modules.actors.legacy_classification import database_binding_identifier -from app.modules.actors.service_identities import SERVICE_IDENTITIES, ServiceIdentity +from migration_contracts.service_identity_0023 import SERVICE_IDENTITIES, ServiceIdentity MAPPING_FILE_ENV = "WORKSTREAM_SERVICE_ACTOR_IDENTITY_MAPPING_FILE" MAX_MAPPING_FILE_BYTES = 64 * 1024 diff --git a/backend/app/modules/authorization/catalogue.py b/backend/app/modules/authorization/catalogue.py index a72441ea5..ddf37dcae 100644 --- a/backend/app/modules/authorization/catalogue.py +++ b/backend/app/modules/authorization/catalogue.py @@ -849,6 +849,14 @@ def _index_actions( } ), ServiceIdentity.ARTIFACT_CHECKER_OUTPUT: frozenset({ActionId.ARTIFACT_CHECKER_OUTPUT_WRITE}), + ServiceIdentity.PROJECT_SETUP: frozenset( + { + ActionId.PROJECT_GUIDE_SUFFICIENCY_RUN, + ActionId.PROJECT_SUBMISSION_ARTIFACT_POLICY_DERIVE, + ActionId.PROJECT_POST_SUBMIT_CHECKER_POLICY_DERIVE, + ActionId.PROJECT_SETUP_RUN_UPDATE, + } + ), } @@ -879,6 +887,14 @@ def _index_service_actions( ServiceIdentity.ARTIFACT_CHECKER_OUTPUT: frozenset( {ActionId.ARTIFACT_CHECKER_OUTPUT_WRITE} ), + ServiceIdentity.PROJECT_SETUP: frozenset( + { + ActionId.PROJECT_GUIDE_SUFFICIENCY_RUN, + ActionId.PROJECT_SUBMISSION_ARTIFACT_POLICY_DERIVE, + ActionId.PROJECT_POST_SUBMIT_CHECKER_POLICY_DERIVE, + ActionId.PROJECT_SETUP_RUN_UPDATE, + } + ), } expected_metadata = { ActionId.ARTIFACT_VERIFICATION_EXECUTE: ( @@ -929,6 +945,22 @@ def _index_service_actions( PermissionId.ARTIFACT_BINDING_CREATE, ActionOwner.XINT_002_07, ), + ActionId.PROJECT_GUIDE_SUFFICIENCY_RUN: ( + PermissionId.PROJECT_GUIDE_MANAGE, + ActionOwner.AUTH_12E, + ), + ActionId.PROJECT_SUBMISSION_ARTIFACT_POLICY_DERIVE: ( + PermissionId.PROJECT_EFFECTIVE_POLICY_MANAGE, + ActionOwner.AUTH_12F, + ), + ActionId.PROJECT_POST_SUBMIT_CHECKER_POLICY_DERIVE: ( + PermissionId.PROJECT_EFFECTIVE_POLICY_MANAGE, + ActionOwner.AUTH_12G, + ), + ActionId.PROJECT_SETUP_RUN_UPDATE: ( + PermissionId.PROJECT_GUIDE_MANAGE, + ActionOwner.AUTH_12B2, + ), } if set(rows) != SERVICE_IDENTITIES: raise RuntimeError("service action matrix identity mismatch") diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index 4d91281cc..22b49af0e 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 = "649ad4f05aa677cc72a8f4cffe04291ed8f41fd6f4be0f1bc4aac6809ca96491" +EXPECTED_PUBLIC_SCHEMA_SHA256 = "5654f83859a318a6f8205215d72934a9b115a155f15d407d54f4200c394b8e3e" PROTECTED_TEST_TABLES = ( "actor_profile_migration_state", "alembic_version", diff --git a/backend/tests/test_actor_migration_tools.py b/backend/tests/test_actor_migration_tools.py index 33cdc1c8d..60222861a 100644 --- a/backend/tests/test_actor_migration_tools.py +++ b/backend/tests/test_actor_migration_tools.py @@ -14,7 +14,6 @@ from app.modules.actors import service_identity_migration as identity_migration from app.modules.actors.legacy_classification import database_binding_identifier -from app.modules.actors.service_identities import SERVICE_IDENTITY_VALUES, ServiceIdentity from app.modules.actors.service_identity_migration import ( MAPPING_FILE_ENV, MAX_MAPPINGS, @@ -36,6 +35,10 @@ validate_mapping_path, verify_envelope, ) +from migration_contracts.service_identity_0023 import ( + SERVICE_IDENTITY_VALUES, + ServiceIdentity, +) from scripts import service_actor_identity_mapping as mapping_cli ISSUER = "https://identity.example.test" @@ -99,7 +102,7 @@ def write_private_json(path: Path, value: object) -> None: os.chmod(path, 0o600) -def test_fixed_service_identity_registry_is_exact() -> None: +def test_service_identity_migration_contract_registry_is_exact() -> None: assert SERVICE_IDENTITY_VALUES == ( "workstream.artifact.verifier", "workstream.artifact.put_resolver", diff --git a/backend/tests/test_alembic.py b/backend/tests/test_alembic.py index 72c8b9578..ed7584525 100644 --- a/backend/tests/test_alembic.py +++ b/backend/tests/test_alembic.py @@ -55,7 +55,7 @@ snapshot_existing_service_rows, ) -HEAD_REVISION = "0042_guide_extraction" +HEAD_REVISION = "0043_project_setup_service" pytestmark = pytest.mark.postgres_schema_contract @@ -103,7 +103,19 @@ def test_service_identity_migration_contract_is_frozen_from_application_modules( assert "app.modules" not in contract_source assert "repository_root=MIGRATION_REPOSITORY_ROOT" in revision_source assert "REPOSITORY_ROOT" not in contract_source - assert FROZEN_SERVICE_IDENTITY_VALUES == tuple(identity.value for identity in ServiceIdentity) + assert FROZEN_SERVICE_IDENTITY_VALUES == ( + "workstream.artifact.verifier", + "workstream.artifact.put_resolver", + "workstream.artifact.scheduler", + "workstream.artifact.binding", + "workstream.artifact.guide_reader", + "workstream.artifact.materializer", + "workstream.artifact.checker_output", + ) + assert tuple(identity.value for identity in ServiceIdentity) == ( + *FROZEN_SERVICE_IDENTITY_VALUES, + "workstream.project.setup", + ) def test_frozen_mapping_path_custody_is_independent_of_install_location( @@ -2078,6 +2090,72 @@ def test_0041_project_mutation_action_evidence_refuses_downgrade( command.downgrade(config, "base") +def test_0043_project_setup_service_round_trip_and_seeds_no_authority( + isolated_database_env: str, migration_lock +) -> None: + """0043 alone admits the eighth identity without creating actor authority.""" + config = _alembic_config() + with migration_lock(): + try: + command.downgrade(config, "base") + command.upgrade(config, "0042_guide_extraction") + prior = asyncio.run(_project_setup_service_state(isolated_database_env)) + assert not prior["constraint_admits_identity"] + assert prior["authority_rows"] == (0, 0, 0, 0) + + command.upgrade(config, "head") + upgraded = asyncio.run(_project_setup_service_state(isolated_database_env)) + assert upgraded["constraint_admits_identity"] + assert upgraded["authority_rows"] == (0, 0, 0, 0) + + command.downgrade(config, "0042_guide_extraction") + restored = asyncio.run(_project_setup_service_state(isolated_database_env)) + assert not restored["constraint_admits_identity"] + assert restored["authority_rows"] == (0, 0, 0, 0) + + command.upgrade(config, "head") + assert asyncio.run(_project_setup_service_state(isolated_database_env)) == upgraded + finally: + command.downgrade(config, "base") + + +def test_0043_project_setup_service_refuses_in_use_downgrade( + isolated_database_env: str, migration_lock +) -> None: + """An exact project-setup profile prevents removal of its closed identity.""" + config = _alembic_config() + actor_profile_id = str(uuid4()) + with migration_lock(): + try: + command.downgrade(config, "base") + command.upgrade(config, "head") + asyncio.run( + _insert_project_setup_service_actor( + isolated_database_env, + actor_profile_id=actor_profile_id, + ) + ) + with pytest.raises( + RuntimeError, + match="cannot downgrade project setup service identity", + ): + command.downgrade(config, "0042_guide_extraction") + assert asyncio.run(_current_revision(isolated_database_env)) == ( + HEAD_REVISION + ) + asyncio.run( + _remove_fixed_service_actor(isolated_database_env, actor_profile_id) + ) + actor_profile_id = "" + command.downgrade(config, "0042_guide_extraction") + finally: + if actor_profile_id: + asyncio.run( + _remove_fixed_service_actor(isolated_database_env, actor_profile_id) + ) + 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() @@ -9675,6 +9753,84 @@ async def _remove_fixed_service_actor(database_url: str, actor_profile_id: str) await engine.dispose() +async def _project_setup_service_state(database_url: str) -> dict[str, object]: + engine = create_async_engine(database_url) + try: + async with engine.connect() as connection: + definition = await connection.scalar( + text( + "select pg_get_constraintdef(oid) from pg_constraint " + "where conrelid='actor_profiles'::regclass " + "and conname='ck_actor_profiles_kind_service_identity'" + ) + ) + counts = tuple( + ( + await connection.execute( + text( + "select " + "(select count(*) from actor_profiles where service_identity=:identity)," + "(select count(*) from actor_identity_links as links join " + "actor_profiles as profiles on profiles.id=links.actor_profile_id " + "where profiles.service_identity=:identity)," + "(select count(*) from admin_role_grants as grants join " + "actor_profiles as profiles on profiles.id=grants.target_actor_profile_id " + "where profiles.service_identity=:identity)," + "(select count(*) from project_role_grants as grants join " + "actor_profiles as profiles on profiles.id=grants.actor_profile_id " + "where profiles.service_identity=:identity)" + ), + {"identity": ServiceIdentity.PROJECT_SETUP.value}, + ) + ).one() + ) + return { + "constraint_admits_identity": ( + ServiceIdentity.PROJECT_SETUP.value in str(definition) + ), + "authority_rows": counts, + } + finally: + await engine.dispose() + + +async def _insert_project_setup_service_actor( + database_url: str, + *, + actor_profile_id: str, +) -> None: + engine = create_async_engine(database_url) + try: + async with engine.begin() as connection: + await connection.execute( + text( + "insert into actor_profiles " + "(id,actor_kind,status,provisioning_method,service_identity,created_by) " + "values (:id,'service','active','manual_service_provisioning'," + ":identity,:id)" + ), + { + "id": actor_profile_id, + "identity": ServiceIdentity.PROJECT_SETUP.value, + }, + ) + await connection.execute( + text( + "insert into actor_identity_links " + "(id,actor_profile_id,issuer,subject,subject_kind,status,linked_by) " + "values (:link,:actor,'https://identity.test',:subject,'service'," + "'active',:actor)" + ), + { + "link": str(uuid4()), + "actor": actor_profile_id, + "subject": ServiceIdentity.PROJECT_SETUP.value, + }, + ) + finally: + await engine.dispose() + + async def _seed_contributor_prior_head( database_url: str, *, diff --git a/backend/tests/test_auth.py b/backend/tests/test_auth.py index 92ccfec3b..c7d075f8d 100644 --- a/backend/tests/test_auth.py +++ b/backend/tests/test_auth.py @@ -54,7 +54,11 @@ from app.modules.actors.models import ActorIdentityLink, ActorProfile from app.modules.actors.repository import ActorRepository from app.modules.actors.service_identities import ServiceIdentity -from app.modules.authorization.models import AdminRoleGrant, AuthorityIdempotencyRecord +from app.modules.authorization.models import ( + AdminRoleGrant, + AuthorityIdempotencyRecord, + ProjectRoleGrant, +) from app.modules.authorization.catalogue import ActionId from app.modules.authorization.repository import ( AdminAuthorizationRepository, @@ -3531,7 +3535,7 @@ async def wait_for_transition_lock( pause_kind = None -async def test_controlled_service_actor_provisioning_is_atomic_private_and_concurrent( +async def test_controlled_service_actor_provisioning_includes_project_setup_and_is_atomic( auth_database_env: str, rsa_signing_material: tuple[rsa.RSAPrivateKey, dict[str, Any]], monkeypatch: pytest.MonkeyPatch, @@ -4027,6 +4031,31 @@ async def fail_service_commit(session: AsyncSession) -> None: ) assert commit_retried.status_code == 201, commit_retried.text + setup_subject = "Opaque-Service-Subject/ProjectSetup:01" + setup_payload = payload | { + "service_identity": ServiceIdentity.PROJECT_SETUP.value, + "subject": setup_subject, + } + setup_created = await client.post( + "/api/v1/service-actors", + headers={**admin_headers, "Idempotency-Key": str(uuid4())}, + json=setup_payload, + ) + assert setup_created.status_code == 201, setup_created.text + assert setup_created.json()["service_identity"] == ServiceIdentity.PROJECT_SETUP.value + setup_state = await service_state(ServiceIdentity.PROJECT_SETUP) + assert setup_state is not None + assert setup_state[1:4] == ("service", "active", "manual_service_provisioning") + assert setup_state[5] is None + assert setup_state[6:11] == ( + settings.token_issuer, + setup_subject, + "service", + "active", + str(admin_id), + ) + assert setup_state[11] is None + class CanonicalIssuerUnavailable: async def verify(self, token: str): return await verifier.verify(token) @@ -4079,6 +4108,7 @@ def canonical_issuer(self) -> str: shared_subject, failure_payload["subject"], commit_payload["subject"], + setup_subject, } } assert all(value not in body for value in sensitive_values for body in observed_response_bodies) @@ -4127,10 +4157,23 @@ def canonical_issuer(self) -> str: ) or 0 ) + service_project_grants = int( + await session.scalar( + select(func.count()) + .select_from(ProjectRoleGrant) + .where( + ProjectRoleGrant.actor_profile_id.in_( + [profile.id for profile in service_profiles] + ) + ) + ) + or 0 + ) assert service_profiles assert all(profile.last_seen_at is None for profile in service_profiles) assert pending == 0 assert service_grants == 0 + assert service_project_grants == 0 assert service_events assert all(event.action_id is None for event in service_events) assert all(event.permission_id == "actor.service.provision" for event in service_events) diff --git a/backend/tests/test_authorization.py b/backend/tests/test_authorization.py index a3dd83beb..123566252 100644 --- a/backend/tests/test_authorization.py +++ b/backend/tests/test_authorization.py @@ -2468,13 +2468,49 @@ def test_fixed_service_action_matrix_and_activation_are_exact_and_immutable() -> "artifact.review_packet.materialize", }, ServiceIdentity.ARTIFACT_CHECKER_OUTPUT: {"artifact.checker_output.write"}, + ServiceIdentity.PROJECT_SETUP: { + "project.guide_sufficiency.run", + "project.submission_artifact_policy.derive", + "project.post_submit_checker_policy.derive", + "project.setup_run.update", + }, } assert set(SERVICE_ACTIONS_BY_IDENTITY) == SERVICE_IDENTITIES assert { identity: {action.value for action in actions} for identity, actions in SERVICE_ACTIONS_BY_IDENTITY.items() } == expected - assert sum(map(len, SERVICE_ACTIONS_BY_IDENTITY.values())) == 12 + assert sum(map(len, SERVICE_ACTIONS_BY_IDENTITY.values())) == 16 + project_setup_actions = SERVICE_ACTIONS_BY_IDENTITY[ServiceIdentity.PROJECT_SETUP] + assert { + action: ( + ACTION_BY_ID[action].permission_id, + ACTION_BY_ID[action].owner, + ACTION_BY_ID[action].availability, + ) + for action in project_setup_actions + } == { + ActionId.PROJECT_GUIDE_SUFFICIENCY_RUN: ( + PermissionId.PROJECT_GUIDE_MANAGE, + ActionOwner.AUTH_12E, + ActionAvailability.PLANNED, + ), + ActionId.PROJECT_SUBMISSION_ARTIFACT_POLICY_DERIVE: ( + PermissionId.PROJECT_EFFECTIVE_POLICY_MANAGE, + ActionOwner.AUTH_12F, + ActionAvailability.PLANNED, + ), + ActionId.PROJECT_POST_SUBMIT_CHECKER_POLICY_DERIVE: ( + PermissionId.PROJECT_EFFECTIVE_POLICY_MANAGE, + ActionOwner.AUTH_12G, + ActionAvailability.PLANNED, + ), + ActionId.PROJECT_SETUP_RUN_UPDATE: ( + PermissionId.PROJECT_GUIDE_MANAGE, + ActionOwner.AUTH_12B2, + ActionAvailability.PLANNED, + ), + } active_internal = { ActionId.ARTIFACT_VERIFICATION_EXECUTE, ActionId.ARTIFACT_PUT_ATTEMPT_RESOLVE, @@ -4722,23 +4758,16 @@ async def lock_request_actor(self, identity_link_id, actor_profile_id): ("action_id", "service_identity"), tuple( ( - definition.action_id, - next( - ( - identity - for identity, actions in SERVICE_ACTIONS_BY_IDENTITY.items() - if definition.action_id in actions - ), - None, - ), + action_id, + identity, ) - for definition in ACTION_DEFINITIONS - if definition.availability is ActionAvailability.PLANNED - and definition.action_id.value.startswith("artifact.") + for identity, actions in SERVICE_ACTIONS_BY_IDENTITY.items() + for action_id in actions + if ACTION_BY_ID[action_id].availability is ActionAvailability.PLANNED ), ) @pytest.mark.asyncio -async def test_prepared_issues_no_handle_or_evidence_for_every_planned_art_action( +async def test_project_setup_service_matrix_issues_no_handle_for_planned_actions( action_id: ActionId, service_identity: ServiceIdentity | None, ): @@ -4796,15 +4825,11 @@ async def lock_request_actor(self, identity_link_id, actor_profile_id): (action_id, identity) for identity, actions in SERVICE_ACTIONS_BY_IDENTITY.items() for action_id in actions - if action_id.value.startswith("artifact.") - and next( - definition for definition in ACTION_DEFINITIONS if definition.action_id is action_id - ).availability - is ActionAvailability.PLANNED + if ACTION_BY_ID[action_id].availability is ActionAvailability.PLANNED ), ) @pytest.mark.asyncio -async def test_prepared_wrong_fixed_service_denies_before_planned_availability( +async def test_project_setup_service_matrix_wrong_identity_denies_before_availability( action_id: ActionId, owning_identity: ServiceIdentity, ): diff --git a/docs/operations_authorization_service.md b/docs/operations_authorization_service.md index c9935a0bb..202e9965f 100644 --- a/docs/operations_authorization_service.md +++ b/docs/operations_authorization_service.md @@ -877,6 +877,20 @@ manifest followed by AUTH-owned enum/constraint/matrix, provisioning, admission, and cross-service denial proof. Do not create a shared review service or a database service-grant table. +AUTH-12B extends the current live registry to an eighth identity, +`workstream.project.setup`, with exactly four static memberships: +`project.guide_sufficiency.run`, +`project.submission_artifact_policy.derive`, +`project.post_submit_checker_policy.derive`, and `project.setup_run.update`. +All four remain planned and unavailable. Migration +`0043_project_setup_service` seeds no profile, link, AdminRoleGrant, or +ProjectRoleGrant. It takes an `ACCESS EXCLUSIVE` lock on `actor_profiles` while +replacing the closed service-identity constraint, and downgrade refuses once a +`workstream.project.setup` ActorProfile exists. An Access Administrator may use the existing controlled +service-actor provisioning route only when the deployment supplies the exact +issuer and opaque subject; that actor still has no executable setup action +until the owning later activation chunks merge. + Fixed-service admission is request-local. Resolve only the verified issuer and opaque subject through the exact stored link and active service profile; never accept a service identity, action, permission, or matrix row from request or diff --git a/docs/spec_authorization_service.md b/docs/spec_authorization_service.md index f2ca6081f..37338376c 100644 --- a/docs/spec_authorization_service.md +++ b/docs/spec_authorization_service.md @@ -464,10 +464,10 @@ remain planned and unavailable, and add no migration. | `artifact.review_packet.materialize` | `artifact.review_packet.materialize` | fixed materializer service | exact active lease and Submission packet | `WS-XINT-002-07` | | `artifact.review_evidence.binding.create` | `artifact.binding.create` | fixed binding service | exact verified review evidence slot | `WS-XINT-002-07` | -The fixed internal service identities and their complete artifact action sets -are also closed: +The fixed internal service identities and their complete action sets are also +closed: -| Service identity | Allowed artifact actions | +| Service identity | Allowed actions | |---|---| | `workstream.artifact.verifier` | `artifact.verification.execute` | | `workstream.artifact.put_resolver` | `artifact.put_attempt.resolve` | @@ -476,6 +476,14 @@ are also closed: | `workstream.artifact.guide_reader` | `artifact.guide_source.read` | | `workstream.artifact.materializer` | `artifact.pre_submit.checker_input.materialize`, `artifact.post_submit.checker_input.materialize`, `artifact.review_packet.materialize` | | `workstream.artifact.checker_output` | `artifact.checker_output.write` | +| `workstream.project.setup` | `project.guide_sufficiency.run`, `project.submission_artifact_policy.derive`, `project.post_submit_checker_policy.derive`, `project.setup_run.update` | + +`workstream.project.setup` is the eighth current fixed identity. All four of +its actions remain planned and unavailable in AUTH-12B. Registration makes the +identity selectable by the existing controlled provisioning route but creates +no ActorProfile, ActorIdentityLink, role, grant, or executable authority by +itself; migration `0043_project_setup_service` only expands the closed database +identity constraint. AUTH-09B lets a system Access Administrator bind an exact configured-issuer subject with no leading or trailing whitespace to one of these fixed identities @@ -491,7 +499,7 @@ dedicated AUTH activation custodian integrates the evaluator and changes only the exact action to active. Composition startup proves registry, service actor, matrix row, action, and PermissionId parity and fails closed on missing or extra matrix membership. Negative authorization tests prove each service identity is -denied every artifact action outside its row. Human authorization remains +denied every fixed-service action outside its row. Human authorization remains attached to the initiating product command; an internal service identity never inherits a human grant or role.