diff --git a/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/chunks/WS-AUTH-001-11A-project-read-catalogue-foundation.md b/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/chunks/WS-AUTH-001-11A-project-read-catalogue-foundation.md index f740e726..475759cb 100644 --- a/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/chunks/WS-AUTH-001-11A-project-read-catalogue-foundation.md +++ b/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/chunks/WS-AUTH-001-11A-project-read-catalogue-foundation.md @@ -2,7 +2,9 @@ ## Status -Proposed and inactive. Requires a separate signed explicit start. +Active for implementation under the repository's simple engineering loop. +The user approved resuming this bounded AUTH-owned change after reconciling it +with current `main`. ## Goal @@ -20,12 +22,13 @@ backend/app/modules/authorization/** backend/app/modules/audit/** backend/alembic/versions/0035_project_read_action_evidence.py backend/tests/test_authorization.py +backend/tests/test_auth.py backend/tests/test_alembic.py +backend/tests/conftest.py docs/operations_authorization_service.md docs/operations_roles_permissions.md docs/spec_authorization_service.md .agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/** -.agent-loop/merge-intents/WS-AUTH-001-11A.json ``` ## Not allowed @@ -71,7 +74,7 @@ compatibility aliases or fallback paths (cd backend && WORKSTREAM_DATABASE_URL= .venv/bin/alembic upgrade head) (cd backend && WORKSTREAM_DATABASE_URL= .venv/bin/alembic downgrade -1) (cd backend && WORKSTREAM_DATABASE_URL= .venv/bin/alembic upgrade head) -python3 scripts/test_agent_gates.py +python3 -m scripts.test_lightweight_agent_gates git diff --check ``` diff --git a/backend/alembic/versions/0035_project_read_action_evidence.py b/backend/alembic/versions/0035_project_read_action_evidence.py new file mode 100644 index 00000000..f269faf1 --- /dev/null +++ b/backend/alembic/versions/0035_project_read_action_evidence.py @@ -0,0 +1,139 @@ +"""register project-read permissions and action evidence + +Revision ID: 0035_project_read_evidence +Revises: 0034_project_role_issue_evidence +Create Date: 2026-07-26 +""" + +from __future__ import annotations + +from alembic import op +import sqlalchemy as sa + + +revision = "0035_project_read_evidence" +down_revision = "0034_project_role_issue_evidence" +branch_labels = depends_on = None + +_PERMISSIONS = ( + "project.setup_diagnostic.read", + "project.effective_policy.read", +) +_ACTIONS = ( + ("project.read", "project.read"), + ("actor.authorization_context.read", "actor.profile.read_self"), + ("project.setup_run.read", _PERMISSIONS[0]), + ("project.guide_sufficiency_report.list", _PERMISSIONS[0]), + ("project.guide_sufficiency_report.read", _PERMISSIONS[0]), + ("project.submission_artifact_policy.list", _PERMISSIONS[1]), + ("project.submission_artifact_policy.read", _PERMISSIONS[1]), + ("project.post_submit_checker_policy_setup.read", _PERMISSIONS[1]), + ("project.effective_submission_artifact_policy.read", _PERMISSIONS[1]), + ("project.pre_submit_checker_policy.read", _PERMISSIONS[1]), + ("project.active_guide.read", "project.read"), +) + + +def _definition(name: str) -> str: + return ( + op.get_bind() + .execute( + sa.text( + "select pg_get_constraintdef(oid) from pg_constraint " + "where conrelid='audit_events'::regclass and conname=:name" + ), + {"name": f"ck_audit_events_{name}"}, + ) + .scalar_one() + ) + + +def _replace(name: str, definition: str) -> None: + op.drop_constraint(name, "audit_events", type_="check") + op.execute(f"alter table audit_events add constraint ck_audit_events_{name} {definition}") + + +def _permission_tokens(*, add: bool) -> None: + marker = "('project.role_grant.manage'::character varying)::text" + addition = ", " + ", ".join( + f"('{permission}'::character varying)::text" for permission in _PERMISSIONS + ) + for name in ("authority_registries", "authority_privacy_bounds"): + definition = _definition(name) + if add: + if definition.count(marker) < 1 or any(value in definition for value in _PERMISSIONS): + raise RuntimeError(f"unexpected {name} permission registry definition") + definition = definition.replace(marker, marker + addition) + else: + if definition.count(addition) < 1: + raise RuntimeError(f"unexpected {name} permission registry definition") + definition = definition.replace(addition, "") + _replace(name, definition) + + +def _action_pairs(*, add: bool) -> None: + name = "authorization_action_evidence" + definition = _definition(name) + marker = ( + "(((action_id)::text = 'project_role_grant.revoke'::text) AND " + "((permission_id)::text = 'project.role_grant.manage'::text))" + ) + additions = " OR ".join( + f"(((action_id)::text = '{action}'::text) AND ((permission_id)::text = '{permission}'::text))" + for action, permission in _ACTIONS + ) + suffix = " OR " + additions + if add: + if definition.count(marker) != 2 or any(action in definition for action, _ in _ACTIONS): + raise RuntimeError("unexpected authorization action registry definition") + definition = definition.replace(marker, marker + suffix) + else: + if definition.count(suffix) != 2: + raise RuntimeError("unexpected authorization action registry definition") + definition = definition.replace(suffix, "") + _replace(name, definition) + + +def _action_permission_tokens(*, add: bool) -> None: + name = "authorization_action_evidence" + definition = _definition(name) + marker = "('review.queue.override'::character varying)::text" + addition = ", " + ", ".join( + f"('{permission}'::character varying)::text" for permission in _PERMISSIONS + ) + if add: + if definition.count(marker) != 1 or addition in definition: + raise RuntimeError("unexpected authorization permission registry definition") + definition = definition.replace(marker, marker + addition) + else: + if definition.count(addition) != 1: + raise RuntimeError("unexpected authorization permission registry definition") + definition = definition.replace(addition, "") + _replace(name, definition) + + +def upgrade() -> None: + """Add availability-neutral project-read registry parity.""" + _permission_tokens(add=True) + _action_pairs(add=True) + _action_permission_tokens(add=True) + + +def downgrade() -> None: + """Remove project-read parity only when no forward evidence exists.""" + bind = op.get_bind() + bind.execute(sa.text("lock table audit_events in access exclusive mode")) + blocked = bind.execute( + sa.text( + "select exists(select 1 from audit_events where " + "action_id = any(:actions) or permission_id = any(:permissions) or " + "(target_ref_kind='permission_registry' and target_ref_id = any(:permissions)) or " + "(invalidation_target_kind='permission_registry' and invalidation_target_ref = any(:permissions)))" + ), + {"actions": [action for action, _ in _ACTIONS], "permissions": list(_PERMISSIONS)}, + ).scalar_one() + if blocked: + raise RuntimeError("cannot downgrade non-empty project-read action evidence") + _action_permission_tokens(add=False) + _action_pairs(add=False) + _permission_tokens(add=False) diff --git a/backend/app/modules/authorization/admin_schemas.py b/backend/app/modules/authorization/admin_schemas.py index fb979d85..80924308 100644 --- a/backend/app/modules/authorization/admin_schemas.py +++ b/backend/app/modules/authorization/admin_schemas.py @@ -35,7 +35,7 @@ class PermissionDefinitionsResponse(BaseModel): model_config = _STRICT items: tuple[PermissionDefinitionResponse, ...] - total: Literal[74] + total: Literal[76] class AdminRoleDefinitionResponse(BaseModel): diff --git a/backend/app/modules/authorization/admin_service.py b/backend/app/modules/authorization/admin_service.py index 9fde4308..46d9f633 100644 --- a/backend/app/modules/authorization/admin_service.py +++ b/backend/app/modules/authorization/admin_service.py @@ -94,7 +94,7 @@ def permission_definitions() -> PermissionDefinitionsResponse: PermissionDefinitionResponse(permission_id=permission) for permission in sorted(PermissionId, key=lambda value: value.value) ), - total=74, + total=len(PermissionId), ) @staticmethod diff --git a/backend/app/modules/authorization/catalogue.py b/backend/app/modules/authorization/catalogue.py index 78615aba..eecc4ba8 100644 --- a/backend/app/modules/authorization/catalogue.py +++ b/backend/app/modules/authorization/catalogue.py @@ -28,6 +28,8 @@ class PermissionId(StrEnum): ADMIN_ROLE_REVOKE = "admin_role.revoke" PROJECT_CREATE = "project.create" PROJECT_READ = "project.read" + PROJECT_SETUP_DIAGNOSTIC_READ = "project.setup_diagnostic.read" + PROJECT_EFFECTIVE_POLICY_READ = "project.effective_policy.read" PROJECT_UPDATE = "project.update" PROJECT_ARCHIVE = "project.archive" PROJECT_GUIDE_MANAGE = "project.guide.manage" @@ -115,6 +117,19 @@ class ActionId(StrEnum): PROJECT_ROLE_GRANT_READ = "project_role_grant.read" PROJECT_ROLE_GRANT_ISSUE = "project_role_grant.issue" PROJECT_ROLE_GRANT_REVOKE = "project_role_grant.revoke" + PROJECT_READ = "project.read" + ACTOR_AUTHORIZATION_CONTEXT_READ = "actor.authorization_context.read" + PROJECT_SETUP_RUN_READ = "project.setup_run.read" + PROJECT_GUIDE_SUFFICIENCY_REPORT_LIST = "project.guide_sufficiency_report.list" + PROJECT_GUIDE_SUFFICIENCY_REPORT_READ = "project.guide_sufficiency_report.read" + PROJECT_SUBMISSION_ARTIFACT_POLICY_LIST = "project.submission_artifact_policy.list" + PROJECT_SUBMISSION_ARTIFACT_POLICY_READ = "project.submission_artifact_policy.read" + PROJECT_POST_SUBMIT_CHECKER_POLICY_SETUP_READ = "project.post_submit_checker_policy_setup.read" + PROJECT_EFFECTIVE_SUBMISSION_ARTIFACT_POLICY_READ = ( + "project.effective_submission_artifact_policy.read" + ) + PROJECT_PRE_SUBMIT_CHECKER_POLICY_READ = "project.pre_submit_checker_policy.read" + PROJECT_ACTIVE_GUIDE_READ = "project.active_guide.read" OPERATIONS_TASK_START_OVERRIDE = "operations.task.start_override" OPERATIONS_SUBMISSION_GATE_REPAIR = "operations.submission_gate.repair" OPERATIONS_CHECKER_RETRY = "operations.checker.retry" @@ -179,6 +194,9 @@ class ActionOwner(StrEnum): AUTH_09D_B = "WS-AUTH-001-09D-B" AUTH_10B = "WS-AUTH-001-10B" AUTH_10C = "WS-AUTH-001-10C" + AUTH_11B = "WS-AUTH-001-11B" + AUTH_11C1 = "WS-AUTH-001-11C1" + AUTH_11C2 = "WS-AUTH-001-11C2" AUTH_13 = "WS-AUTH-001-13" AUTH_14 = "WS-AUTH-001-14" AUTH_REV_05 = "WS-AUTH-001-REV-05" @@ -341,6 +359,57 @@ def _active( PermissionId.PROJECT_ROLE_GRANT_MANAGE, ActionOwner.AUTH_10C, ), + _planned(ActionId.PROJECT_READ, PermissionId.PROJECT_READ, ActionOwner.AUTH_11B), + _planned( + ActionId.ACTOR_AUTHORIZATION_CONTEXT_READ, + PermissionId.ACTOR_PROFILE_READ_SELF, + ActionOwner.AUTH_11B, + ), + _planned( + ActionId.PROJECT_SETUP_RUN_READ, + PermissionId.PROJECT_SETUP_DIAGNOSTIC_READ, + ActionOwner.AUTH_11C1, + ), + _planned( + ActionId.PROJECT_GUIDE_SUFFICIENCY_REPORT_LIST, + PermissionId.PROJECT_SETUP_DIAGNOSTIC_READ, + ActionOwner.AUTH_11C1, + ), + _planned( + ActionId.PROJECT_GUIDE_SUFFICIENCY_REPORT_READ, + PermissionId.PROJECT_SETUP_DIAGNOSTIC_READ, + ActionOwner.AUTH_11C1, + ), + _planned( + ActionId.PROJECT_SUBMISSION_ARTIFACT_POLICY_LIST, + PermissionId.PROJECT_EFFECTIVE_POLICY_READ, + ActionOwner.AUTH_11C1, + ), + _planned( + ActionId.PROJECT_SUBMISSION_ARTIFACT_POLICY_READ, + PermissionId.PROJECT_EFFECTIVE_POLICY_READ, + ActionOwner.AUTH_11C1, + ), + _planned( + ActionId.PROJECT_POST_SUBMIT_CHECKER_POLICY_SETUP_READ, + PermissionId.PROJECT_EFFECTIVE_POLICY_READ, + ActionOwner.AUTH_11C1, + ), + _planned( + ActionId.PROJECT_EFFECTIVE_SUBMISSION_ARTIFACT_POLICY_READ, + PermissionId.PROJECT_EFFECTIVE_POLICY_READ, + ActionOwner.AUTH_11C2, + ), + _planned( + ActionId.PROJECT_PRE_SUBMIT_CHECKER_POLICY_READ, + PermissionId.PROJECT_EFFECTIVE_POLICY_READ, + ActionOwner.AUTH_11C2, + ), + _planned( + ActionId.PROJECT_ACTIVE_GUIDE_READ, + PermissionId.PROJECT_READ, + ActionOwner.AUTH_11C2, + ), _planned( ActionId.OPERATIONS_TASK_START_OVERRIDE, PermissionId.OPERATIONS_TASK_START_OVERRIDE, @@ -563,6 +632,8 @@ def _active( ACTION_IDS = frozenset(ActionId) NEW_PERMISSION_IDS = frozenset( { + PermissionId.PROJECT_SETUP_DIAGNOSTIC_READ, + PermissionId.PROJECT_EFFECTIVE_POLICY_READ, PermissionId.OPERATIONS_TASK_START_OVERRIDE, PermissionId.OPERATIONS_SUBMISSION_GATE_REPAIR, PermissionId.OPERATIONS_CHECKER_RETRY, @@ -606,11 +677,11 @@ 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) != 74 or len(ACTION_IDS) != 70: + if len(PERMISSION_IDS) != 76 or len(ACTION_IDS) != 81: raise RuntimeError("authorization catalogue count mismatch") if len(indexed) != len(definitions) or set(indexed) != ACTION_IDS: raise RuntimeError("authorization action catalogue is incomplete") - if len(HISTORICAL_PERMISSION_IDS) != 49 or len(NEW_PERMISSION_IDS) != 25: + if len(HISTORICAL_PERMISSION_IDS) != 49 or len(NEW_PERMISSION_IDS) != 27: raise RuntimeError("authorization permission boundary mismatch") active_actions = { ActionId.ACTOR_PROFILE_READ_SELF, diff --git a/backend/app/modules/authorization/policy.py b/backend/app/modules/authorization/policy.py index eac2ce90..638d6de2 100644 --- a/backend/app/modules/authorization/policy.py +++ b/backend/app/modules/authorization/policy.py @@ -27,6 +27,8 @@ ), AdminRole.OPERATOR: ( PermissionId.PROJECT_READ, + PermissionId.PROJECT_SETUP_DIAGNOSTIC_READ, + PermissionId.PROJECT_EFFECTIVE_POLICY_READ, PermissionId.REVIEW_QUEUE_INSPECT, PermissionId.REVIEW_LEASE_FORCE_RELEASE, PermissionId.CONTRIBUTION_READ_PROJECT, @@ -51,6 +53,8 @@ AdminRole.PROJECT_MANAGER: ( PermissionId.PROJECT_CREATE, PermissionId.PROJECT_READ, + PermissionId.PROJECT_SETUP_DIAGNOSTIC_READ, + PermissionId.PROJECT_EFFECTIVE_POLICY_READ, PermissionId.PROJECT_UPDATE, PermissionId.PROJECT_ARCHIVE, PermissionId.PROJECT_GUIDE_MANAGE, @@ -78,6 +82,8 @@ PermissionId.ACTOR_IDENTITY_LINK_READ, PermissionId.ADMIN_ROLE_READ, PermissionId.PROJECT_READ, + PermissionId.PROJECT_SETUP_DIAGNOSTIC_READ, + PermissionId.PROJECT_EFFECTIVE_POLICY_READ, PermissionId.PROJECT_ROLE_GRANT_READ, PermissionId.REVIEW_QUEUE_INSPECT, PermissionId.REVIEW_CHAIN_READ, diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index 7d7ec476..a950d2e7 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -22,7 +22,7 @@ DDL_LOCK_DIRECTORY = Path("/tmp") EXPECTED_PUBLIC_SCHEMA_SHA256 = ( - "83831965c8001901db2dab8cfd9c8a83e27331d61e724381dbdce5a859bf0e9c" + "8853e81a2c3c2452dd236a5a691d568d9184379a78301172c096c8b33ef63890" ) PROTECTED_TEST_TABLES = ( "actor_profile_migration_state", diff --git a/backend/tests/test_alembic.py b/backend/tests/test_alembic.py index 221277e2..04339e22 100644 --- a/backend/tests/test_alembic.py +++ b/backend/tests/test_alembic.py @@ -234,23 +234,29 @@ def test_0034_project_role_issue_evidence_fact_shape_is_closed( (before, {**after, "role": mismatched_role}), ) for invalid_before, invalid_after in invalid: - assert asyncio.run( - _facts_are_safe_0034( - isolated_database_env, - invalid_before, - invalid_after, - project_id, + assert ( + asyncio.run( + _facts_are_safe_0034( + isolated_database_env, + invalid_before, + invalid_after, + project_id, + ) ) - ) is False + is False + ) for null_before, null_after in ((None, after), (before, None)): - assert asyncio.run( - _facts_are_safe_0034( - isolated_database_env, - null_before, - null_after, - project_id, + assert ( + asyncio.run( + _facts_are_safe_0034( + isolated_database_env, + null_before, + null_after, + project_id, + ) ) - ) is False + is False + ) assert asyncio.run( _facts_are_safe_0034( isolated_database_env, @@ -314,9 +320,7 @@ def test_0034_five_key_revoke_invalidation_requires_exact_linkage( records: list[str] = [] try: if invalid_form == "non_revoke": - fixture = asyncio.run( - _seed_pending_issue_cause_0034(isolated_database_env) - ) + fixture = asyncio.run(_seed_pending_issue_cause_0034(isolated_database_env)) records.append(fixture["record"]) cause = fixture else: @@ -336,12 +340,8 @@ def test_0034_five_key_revoke_invalidation_requires_exact_linkage( ) records.append(cause["record"]) - before_facts = _five_key_revoke_facts_0034( - fixture, effective=True - ) - after_facts = _five_key_revoke_facts_0034( - fixture, effective=False - ) + before_facts = _five_key_revoke_facts_0034(fixture, effective=True) + after_facts = _five_key_revoke_facts_0034(fixture, effective=False) if invalid_form == "mixed_facts": after_facts = {"effective": False} target_ref_kind: str | None = "project_role_grant" @@ -355,9 +355,7 @@ def test_0034_five_key_revoke_invalidation_requires_exact_linkage( elif invalid_form == "wrong_target_id": target_ref_id = str(uuid4()) before = asyncio.run( - _linked_revoke_fixture_state_0034( - isolated_database_env, records - ) + _linked_revoke_fixture_state_0034(isolated_database_env, records) ) with pytest.raises(IntegrityError) as rejected: @@ -373,24 +371,15 @@ def test_0034_five_key_revoke_invalidation_requires_exact_linkage( ) ) expected_sqlstate = "23503" if invalid_form == "orphan" else "23514" - assert ( - getattr(rejected.value.orig, "sqlstate", None) - == expected_sqlstate - ) + assert getattr(rejected.value.orig, "sqlstate", None) == expected_sqlstate assert ( asyncio.run( - _linked_revoke_fixture_state_0034( - isolated_database_env, records - ) + _linked_revoke_fixture_state_0034(isolated_database_env, records) ) == before ) finally: - asyncio.run( - _clear_linked_revoke_fixtures_0034( - isolated_database_env, records - ) - ) + asyncio.run(_clear_linked_revoke_fixtures_0034(isolated_database_env, records)) finally: command.downgrade(config, "base") @@ -426,9 +415,7 @@ def test_0034_downgrade_refuses_five_key_revoke_evidence_without_mutation( _linked_revoke_fixture_state_0034(isolated_database_env, records) ) assert admitted != before_insert - assert admitted["event_ids"] == tuple( - sorted((fixture["cause"], invalidation_id)) - ) + assert admitted["event_ids"] == tuple(sorted((fixture["cause"], invalidation_id))) assert admitted["event_count"] == 2 before_downgrade = { @@ -1629,7 +1616,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": "0034_project_role_issue_evidence", + "revision": "0035_project_read_evidence", "constraints": { "artifact_recovery_attempt_custody", "artifact_verification_lineage_custody", @@ -1683,6 +1670,69 @@ async def _artifact_recovery_schema(database_url: str) -> dict[str, object]: await engine.dispose() +def test_0035_project_read_action_evidence_round_trip( + isolated_database_env: str, migration_lock +) -> None: + """Prove all eleven planned pairs and both permissions round-trip exactly.""" + config = _alembic_config() + definitions = tuple( + definition + for definition in ACTION_DEFINITIONS + if definition.owner in {ActionOwner.AUTH_11B, ActionOwner.AUTH_11C1, ActionOwner.AUTH_11C2} + ) + assert len(definitions) == 11 + 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, "0034_project_role_issue_evidence") + command.upgrade(config, "head") + asyncio.run( + _assert_authorization_action_sql_pairs( + isolated_database_env, definitions=definitions + ) + ) + finally: + command.downgrade(config, "base") + + +def test_0035_project_read_action_evidence_refuses_nonempty_downgrade( + isolated_database_env: str, migration_lock +) -> None: + """A committed 11A audit row must block removal of its frozen vocabulary.""" + config = _alembic_config() + definition = next(item for item in ACTION_DEFINITIONS if item.owner is ActionOwner.AUTH_11B) + event_id = "" + with migration_lock(): + try: + command.downgrade(config, "base") + command.upgrade(config, "head") + 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-read action evidence" + ): + command.downgrade(config, "0034_project_role_issue_evidence") + assert asyncio.run(_current_revision(isolated_database_env)) == ( + "0035_project_read_evidence" + ) + finally: + asyncio.run( + _remove_authority_audit_fixture(isolated_database_env, event_id=event_id) + ) + command.downgrade(config, "base") + + def test_project_role_migration_constraints_and_immutable_history( isolated_database_env: str, migration_lock, @@ -1695,7 +1745,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": "0034_project_role_issue_evidence", + "revision": "0035_project_read_evidence", "role_count": 3, "invalid_availability": "23514", "duplicate_role": "23505", @@ -1907,7 +1957,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] == ( - "0034_project_role_issue_evidence", + "0035_project_read_evidence", True, True, ) @@ -1934,7 +1984,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] == ( - "0034_project_role_issue_evidence", + "0035_project_read_evidence", True, True, ) @@ -1962,7 +2012,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": "0034_project_role_issue_evidence", + "revision": "0035_project_read_evidence", "columns": { "aggregate_id", "aggregate_type", @@ -2022,7 +2072,7 @@ def test_outbox_migration_schema_and_downgrade_writer_guard( ) assert committed == "refused_after_commit" assert asyncio.run(_current_revision(isolated_database_env)) == ( - "0034_project_role_issue_evidence" + "0035_project_read_evidence" ) asyncio.run(_remove_outbox_migration_row(isolated_database_env, committed_project_id)) command.downgrade(config, "0028_artifact_admission") @@ -2474,6 +2524,9 @@ def test_authorization_action_evidence_constraints_and_guarded_downgrade( ActionOwner.AUTH_09D_B, ActionOwner.AUTH_10B, ActionOwner.AUTH_10C, + ActionOwner.AUTH_11B, + ActionOwner.AUTH_11C1, + ActionOwner.AUTH_11C2, } ), ) @@ -2628,6 +2681,9 @@ def test_bootstrap_admin_grant_schema_is_immutable_and_guarded( ActionOwner.AUTH_09D_B, ActionOwner.AUTH_10B, ActionOwner.AUTH_10C, + ActionOwner.AUTH_11B, + ActionOwner.AUTH_11C1, + ActionOwner.AUTH_11C2, } ), ) @@ -9615,8 +9671,7 @@ async def _install_legacy_project_role_blocker( else: await connection.execute( text( - "alter table audit_events disable trigger " - "audit_events_reject_update_delete" + "alter table audit_events disable trigger audit_events_reject_update_delete" ) ) assignments = [] @@ -9656,8 +9711,7 @@ async def _remove_legacy_project_role_blocker( async with engine.begin() as connection: await connection.execute( text( - "alter table audit_events disable trigger " - "audit_events_reject_update_delete" + "alter table audit_events disable trigger audit_events_reject_update_delete" ) ) await connection.execute( diff --git a/backend/tests/test_auth.py b/backend/tests/test_auth.py index bbc5860c..828c3ce1 100644 --- a/backend/tests/test_auth.py +++ b/backend/tests/test_auth.py @@ -2130,8 +2130,8 @@ async def assert_failed_admin_read(path: str) -> None: headers=admin_headers, ) assert permissions.status_code == definitions.status_code == 200 - assert permissions.json()["total"] == 74 - assert len(permissions.json()["items"]) == 74 + assert permissions.json()["total"] == 76 + assert len(permissions.json()["items"]) == 76 assert definitions.json()["total"] == 5 assert [item["role"] for item in definitions.json()["items"]] == [ "access_administrator", @@ -2433,7 +2433,7 @@ async def assert_failed_admin_read(path: str) -> None: ), ] assert [response.status_code for response in system_audit_reads] == [200] * 6 - assert system_audit_reads[0].json()["total"] == 74 + assert system_audit_reads[0].json()["total"] == 76 assert system_audit_reads[1].json()["total"] == 5 assert system_audit_reads[2].json()["total"] == 2 assert system_audit_reads[3].json()["total"] == 1 diff --git a/backend/tests/test_authorization.py b/backend/tests/test_authorization.py index ff916833..4eea7c48 100644 --- a/backend/tests/test_authorization.py +++ b/backend/tests/test_authorization.py @@ -236,9 +236,7 @@ def test_project_role_issue_advisory_key_contract_is_frozen_and_separated() -> N assert project_role_issue_lock_key(actor, project, "submitter") != project_role_issue_lock_key( project, actor, "submitter" ) - original = SimpleNamespace( - constraint_name="uq_project_role_grants_active_exact_role" - ) + original = SimpleNamespace(constraint_name="uq_project_role_grants_active_exact_role") error = IntegrityError("insert", {}, original) assert _constraint_name(error) == "uq_project_role_grants_active_exact_role" @@ -259,9 +257,7 @@ async def scalar(self, statement): actor = low if str(low) in values else high if entity is ActorProfile: self.calls.append(("profile", actor)) - return SimpleNamespace( - id=str(actor), actor_kind="human", status="active" - ) + return SimpleNamespace(id=str(actor), actor_kind="human", status="active") self.calls.append(("link", actor)) link_id = low_link if actor == low else high_link return SimpleNamespace(id=str(link_id), actor_profile_id=str(actor)) @@ -278,12 +274,10 @@ async def scalar(self, statement): ): session = RecordingSession() repository = AdminAuthorizationRepository(session) # type: ignore[arg-type] - locked_caller, target_eligible = ( - await repository.lock_project_role_issue_principals( - caller_actor_profile_id=caller, - caller_identity_link_id=caller_link, - target_actor_profile_id=target, - ) + locked_caller, target_eligible = await repository.lock_project_role_issue_principals( + caller_actor_profile_id=caller, + caller_identity_link_id=caller_link, + target_actor_profile_id=target, ) assert locked_caller is not None assert target_eligible is True @@ -814,7 +808,9 @@ async def forbidden_project_lookup(*_args, **_kwargs): app.dependency_overrides[enforce_admin_mutation_rate_limit] = fail_rate_first monkeypatch.setattr(ProjectRepository, "get_project", forbidden_project_lookup) - async with AsyncClient(transport=ASGITransport(app=app), base_url="http://testserver") as client: + async with AsyncClient( + transport=ASGITransport(app=app), base_url="http://testserver" + ) as client: response = await client.post( path(uuid4(), uuid4()), headers={"Idempotency-Key": str(uuid4())}, @@ -853,20 +849,118 @@ def _project_role_denial_decision( @pytest.mark.parametrize( ("operation", "hidden_case", "denial_code", "expected_status", "expected_code", "message"), [ - ("issue", "valid_target_no_manager", AuthorizationDenialCode.PERMISSION_NOT_GRANTED, 404, "resource_not_found", "Resource not found"), - ("issue", "missing_target_no_manager", AuthorizationDenialCode.PERMISSION_NOT_GRANTED, 404, "resource_not_found", "Resource not found"), - ("issue", "inactive_target_no_manager", AuthorizationDenialCode.PERMISSION_NOT_GRANTED, 404, "resource_not_found", "Resource not found"), - ("issue", "nonhuman_target_no_manager", AuthorizationDenialCode.PERMISSION_NOT_GRANTED, 404, "resource_not_found", "Resource not found"), - ("issue", "valid_target_cross_project_manager", AuthorizationDenialCode.SCOPE_NOT_AUTHORIZED, 404, "resource_not_found", "Resource not found"), - ("issue", "missing_target_manager", AuthorizationDenialCode.RESOURCE_GUARD_DENIED, 404, "resource_not_found", "Resource not found"), - ("issue", "inactive_target_manager", AuthorizationDenialCode.RESOURCE_GUARD_DENIED, 404, "resource_not_found", "Resource not found"), - ("issue", "nonhuman_target_manager", AuthorizationDenialCode.RESOURCE_GUARD_DENIED, 404, "resource_not_found", "Resource not found"), - ("issue", "unauthorized_target_manager", AuthorizationDenialCode.RESOURCE_GUARD_DENIED, 404, "resource_not_found", "Resource not found"), - ("issue", "self_target_manager", AuthorizationDenialCode.SELF_GRANT_FORBIDDEN, 403, "self_grant_forbidden", "Self grant is forbidden"), - ("revoke", "nonexistent_grant", AuthorizationDenialCode.GRANT_NOT_FOUND, 404, "resource_not_found", "Resource not found"), - ("revoke", "cross_project_grant", AuthorizationDenialCode.GRANT_NOT_FOUND, 404, "resource_not_found", "Resource not found"), - ("revoke", "self_owned_grant_no_manager", AuthorizationDenialCode.PERMISSION_NOT_GRANTED, 404, "resource_not_found", "Resource not found"), - ("revoke", "self_owned_grant_manager", AuthorizationDenialCode.SELF_ROLE_REVOKE_FORBIDDEN, 403, "self_role_revoke_forbidden", "Self role revocation is forbidden"), + ( + "issue", + "valid_target_no_manager", + AuthorizationDenialCode.PERMISSION_NOT_GRANTED, + 404, + "resource_not_found", + "Resource not found", + ), + ( + "issue", + "missing_target_no_manager", + AuthorizationDenialCode.PERMISSION_NOT_GRANTED, + 404, + "resource_not_found", + "Resource not found", + ), + ( + "issue", + "inactive_target_no_manager", + AuthorizationDenialCode.PERMISSION_NOT_GRANTED, + 404, + "resource_not_found", + "Resource not found", + ), + ( + "issue", + "nonhuman_target_no_manager", + AuthorizationDenialCode.PERMISSION_NOT_GRANTED, + 404, + "resource_not_found", + "Resource not found", + ), + ( + "issue", + "valid_target_cross_project_manager", + AuthorizationDenialCode.SCOPE_NOT_AUTHORIZED, + 404, + "resource_not_found", + "Resource not found", + ), + ( + "issue", + "missing_target_manager", + AuthorizationDenialCode.RESOURCE_GUARD_DENIED, + 404, + "resource_not_found", + "Resource not found", + ), + ( + "issue", + "inactive_target_manager", + AuthorizationDenialCode.RESOURCE_GUARD_DENIED, + 404, + "resource_not_found", + "Resource not found", + ), + ( + "issue", + "nonhuman_target_manager", + AuthorizationDenialCode.RESOURCE_GUARD_DENIED, + 404, + "resource_not_found", + "Resource not found", + ), + ( + "issue", + "unauthorized_target_manager", + AuthorizationDenialCode.RESOURCE_GUARD_DENIED, + 404, + "resource_not_found", + "Resource not found", + ), + ( + "issue", + "self_target_manager", + AuthorizationDenialCode.SELF_GRANT_FORBIDDEN, + 403, + "self_grant_forbidden", + "Self grant is forbidden", + ), + ( + "revoke", + "nonexistent_grant", + AuthorizationDenialCode.GRANT_NOT_FOUND, + 404, + "resource_not_found", + "Resource not found", + ), + ( + "revoke", + "cross_project_grant", + AuthorizationDenialCode.GRANT_NOT_FOUND, + 404, + "resource_not_found", + "Resource not found", + ), + ( + "revoke", + "self_owned_grant_no_manager", + AuthorizationDenialCode.PERMISSION_NOT_GRANTED, + 404, + "resource_not_found", + "Resource not found", + ), + ( + "revoke", + "self_owned_grant_manager", + AuthorizationDenialCode.SELF_ROLE_REVOKE_FORBIDDEN, + 403, + "self_role_revoke_forbidden", + "Self role revocation is forbidden", + ), ], ) async def test_project_role_mutation_routes_conceal_denials_and_preserve_self_guards_atomically( @@ -1586,7 +1680,8 @@ def test_closed_permission_and_action_catalogue_is_exact_and_non_executable() -> audit.read audit.export""".split() ) new_permissions = frozenset( - """operations.task.start_override operations.submission_gate.repair + """project.setup_diagnostic.read project.effective_policy.read + operations.task.start_override operations.submission_gate.repair operations.checker.retry artifact.binding.read artifact.replica.read artifact.receipt.read artifact.verification_job.read artifact.verification_job.retry artifact.recovery_attempt.read artifact.audit.read @@ -1641,11 +1736,49 @@ def test_closed_permission_and_action_catalogue_is_exact_and_non_executable() -> "project_role_grant.read": ("project.role_grant.read", "WS-AUTH-001-10B"), "project_role_grant.issue": ("project.role_grant.manage", "WS-AUTH-001-10C"), "project_role_grant.revoke": ("project.role_grant.manage", "WS-AUTH-001-10C"), + "project.read": ("project.read", "WS-AUTH-001-11B"), + "actor.authorization_context.read": ( + "actor.profile.read_self", + "WS-AUTH-001-11B", + ), + "project.setup_run.read": ( + "project.setup_diagnostic.read", + "WS-AUTH-001-11C1", + ), + "project.guide_sufficiency_report.list": ( + "project.setup_diagnostic.read", + "WS-AUTH-001-11C1", + ), + "project.guide_sufficiency_report.read": ( + "project.setup_diagnostic.read", + "WS-AUTH-001-11C1", + ), + "project.submission_artifact_policy.list": ( + "project.effective_policy.read", + "WS-AUTH-001-11C1", + ), + "project.submission_artifact_policy.read": ( + "project.effective_policy.read", + "WS-AUTH-001-11C1", + ), + "project.post_submit_checker_policy_setup.read": ( + "project.effective_policy.read", + "WS-AUTH-001-11C1", + ), + "project.effective_submission_artifact_policy.read": ( + "project.effective_policy.read", + "WS-AUTH-001-11C2", + ), + "project.pre_submit_checker_policy.read": ( + "project.effective_policy.read", + "WS-AUTH-001-11C2", + ), + "project.active_guide.read": ("project.read", "WS-AUTH-001-11C2"), } 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) == 70 + assert len(ACTION_IDS) == len(ACTION_DEFINITIONS) == len(ACTION_BY_ID) == 81 assert set(ACTION_BY_ID) == ACTION_IDS assert {definition.owner for definition in ACTION_DEFINITIONS} == set(ActionOwner) assert { @@ -1761,7 +1894,7 @@ def test_closed_permission_and_action_catalogue_is_exact_and_non_executable() -> definition.availability is ActionAvailability.PLANNED for definition in ACTION_DEFINITIONS ) - == 48 + == 59 ) assert resolve_executable_action(ActionId.ACTOR_PROFILE_READ_SELF).permission_id is ( PermissionId.ACTOR_PROFILE_READ_SELF @@ -1892,7 +2025,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 ( - "74 PermissionIds, 70 ActionIds, 22 active actions, and\n48 planned actions" in operations + "76 PermissionIds, 81 ActionIds, 22 active actions, and\n59 planned actions" in operations ) @@ -2004,7 +2137,8 @@ def test_administrative_role_policy_and_definition_responses_are_exact() -> None actor.profile.reactivate actor.profile.deactivate actor.identity_link.read actor.identity_link.revoke actor.identity_link.reactivate actor.service.provision admin_role.read admin_role.grant admin_role.revoke audit.read audit.export""".split(), - AdminRole.OPERATOR: """project.read review.queue.inspect review.lease.force_release + AdminRole.OPERATOR: """project.read project.setup_diagnostic.read + project.effective_policy.read review.queue.inspect review.lease.force_release contribution.read_project compensation.award.read operations.status.read operations.timer.run operations.reconcile.run operations.outbox.retry operations.projection.rebuild operations.task.start_override @@ -2012,7 +2146,8 @@ def test_administrative_role_policy_and_definition_responses_are_exact() -> None artifact.replica.read artifact.receipt.read artifact.verification_job.read artifact.verification_job.retry artifact.recovery_attempt.read artifact.audit.read audit.read""".split(), - AdminRole.PROJECT_MANAGER: """project.create project.read project.update project.archive + AdminRole.PROJECT_MANAGER: """project.create project.read project.setup_diagnostic.read + project.effective_policy.read project.update project.archive project.guide.manage project.effective_policy.manage project.task.manage project.review_policy.manage project.role_grant.read project.role_grant.manage review.queue.inspect contribution.read_project compensation.award.read @@ -2021,7 +2156,8 @@ def test_administrative_role_policy_and_definition_responses_are_exact() -> None compensation.policy.manage compensation.adapter_binding.manage compensation.award.read compensation.delivery.reconcile audit.read""".split(), AdminRole.AUDIT_AUTHORITY: """actor.profile.read_any actor.identity_link.read - admin_role.read project.read project.role_grant.read review.queue.inspect + admin_role.read project.read project.setup_diagnostic.read + project.effective_policy.read project.role_grant.read review.queue.inspect review.chain.read contribution.read_project compensation.award.read audit.read audit.export""".split(), } @@ -2045,7 +2181,7 @@ def test_administrative_role_policy_and_definition_responses_are_exact() -> None permission_response = AdminRoleGrantService.permission_definitions() role_response = AdminRoleGrantService.role_definitions() - assert permission_response.total == 74 + assert permission_response.total == 76 assert [item.permission_id.value for item in permission_response.items] == sorted( permission.value for permission in PermissionId ) @@ -2902,9 +3038,7 @@ def test_project_role_mutation_denials_share_resource_not_found_concealment( request_id=uuid4(), correlation_id=uuid4(), ) - translated = authorization_router._project_role_mutation_denial( - AuthorizationDenied(decision) - ) + translated = authorization_router._project_role_mutation_denial(AuthorizationDenied(decision)) assert translated.status_code == 404 assert translated.error_code == "resource_not_found" assert translated.error_message == "Resource not found" @@ -2915,6 +3049,17 @@ def test_project_role_read_permissions_separate_manager_and_auditor_authority() assert ( PermissionId.PROJECT_ROLE_GRANT_MANAGE in ADMIN_ROLE_PERMISSIONS[AdminRole.PROJECT_MANAGER] ) + + +def test_project_read_projection_permissions_have_exact_admin_role_matrix() -> None: + permissions = { + PermissionId.PROJECT_SETUP_DIAGNOSTIC_READ, + PermissionId.PROJECT_EFFECTIVE_POLICY_READ, + } + for role in (AdminRole.PROJECT_MANAGER, AdminRole.OPERATOR, AdminRole.AUDIT_AUTHORITY): + assert permissions <= set(ADMIN_ROLE_PERMISSIONS[role]) + for role in (AdminRole.FINANCE_AUTHORITY, AdminRole.ACCESS_ADMINISTRATOR): + assert permissions.isdisjoint(ADMIN_ROLE_PERMISSIONS[role]) assert PermissionId.PROJECT_ROLE_GRANT_READ in ADMIN_ROLE_PERMISSIONS[AdminRole.AUDIT_AUTHORITY] assert ( PermissionId.PROJECT_ROLE_GRANT_MANAGE @@ -7311,6 +7456,213 @@ async def authorization_factory(authorization_database_env: str): await engine.dispose() +@pytest.mark.asyncio +async def test_project_read_permissions_have_postgresql_role_scope_matrix( + authorization_factory, +) -> None: + """Prove persisted grants confer only the reviewed read projections.""" + now = datetime.now(UTC) + project_id, other_project_id = uuid4(), uuid4() + bootstrap_actor_id, bootstrap_link_id, bootstrap_grant_id = uuid4(), uuid4(), uuid4() + role_cases = ( + (AdminRole.OPERATOR, AdminScope.SYSTEM, None, True), + (AdminRole.PROJECT_MANAGER, AdminScope.PROJECT, project_id, True), + (AdminRole.AUDIT_AUTHORITY, AdminScope.PROJECT, project_id, True), + (AdminRole.FINANCE_AUTHORITY, AdminScope.PROJECT, project_id, False), + ) + actor_cases = [(role, uuid4(), uuid4(), uuid4()) for role, *_rest in role_cases] + contributor_cases = [(role, uuid4(), uuid4(), uuid4(), uuid4()) for role in ProjectRole] + + async with authorization_factory() as session: + session.add_all( + [ + Project( + id=str(project_id), + name="AUTH-11A role matrix", + slug=f"auth-11a-role-matrix-{project_id}", + status="draft", + ), + Project( + id=str(other_project_id), + name="AUTH-11A other project", + slug=f"auth-11a-other-project-{other_project_id}", + status="draft", + ), + ActorProfile( + id=str(bootstrap_actor_id), + actor_kind="human", + status="active", + provisioning_method="automatic_first_access", + created_by=str(bootstrap_actor_id), + ), + ActorIdentityLink( + id=str(bootstrap_link_id), + actor_profile_id=str(bootstrap_actor_id), + issuer="https://identity.flowresearch.tech", + subject=f"auth-11a-bootstrap-{bootstrap_actor_id}", + subject_kind="human", + status="active", + linked_by=str(bootstrap_actor_id), + last_verified_at=now, + ), + ] + ) + for _role, actor_id, link_id, _grant_id in actor_cases: + session.add_all( + [ + ActorProfile( + id=str(actor_id), + actor_kind="human", + status="active", + provisioning_method="automatic_first_access", + created_by=str(actor_id), + ), + ActorIdentityLink( + id=str(link_id), + actor_profile_id=str(actor_id), + issuer="https://identity.flowresearch.tech", + subject=f"auth-11a-admin-{actor_id}", + subject_kind="human", + status="active", + linked_by=str(actor_id), + last_verified_at=now, + ), + ] + ) + for _role, actor_id, link_id, _snapshot_id, _grant_id in contributor_cases: + session.add_all( + [ + ActorProfile( + id=str(actor_id), + actor_kind="human", + status="active", + provisioning_method="automatic_first_access", + created_by=str(actor_id), + ), + ActorIdentityLink( + id=str(link_id), + actor_profile_id=str(actor_id), + issuer="https://identity.flowresearch.tech", + subject=f"auth-11a-contributor-{actor_id}", + subject_kind="human", + status="active", + linked_by=str(actor_id), + last_verified_at=now, + ), + ] + ) + session.add( + AdminRoleGrant( + id=bootstrap_grant_id, + target_actor_profile_id=str(bootstrap_actor_id), + role=AdminRole.ACCESS_ADMINISTRATOR.value, + scope_type=AdminScope.SYSTEM.value, + status="active", + version=1, + granted_by_system_principal="workstream:system:bootstrap", + grant_reason="AUTH-11A PostgreSQL bootstrap proof", + granted_at=now, + ) + ) + await session.flush() + await session.execute( + text( + "update authority_control set bootstrap_completed=true, version=1, " + "bootstrap_grant_id=:grant_id, updated_at=clock_timestamp() where id=1" + ), + {"grant_id": str(bootstrap_grant_id)}, + ) + for (role, scope, scope_project_id, _allowed), ( + _case_role, + actor_id, + _link_id, + grant_id, + ) in zip(role_cases, actor_cases, strict=True): + session.add( + AdminRoleGrant( + id=grant_id, + target_actor_profile_id=str(actor_id), + role=role.value, + scope_type=scope.value, + scope_project_id=(str(scope_project_id) if scope_project_id else None), + status="active", + version=1, + granted_by_actor_profile_id=str(bootstrap_actor_id), + granted_by_admin_role_grant_id=bootstrap_grant_id, + grant_reason=f"AUTH-11A PostgreSQL {role.value} proof", + granted_at=now, + ) + ) + for role, actor_id, _link_id, snapshot_id, grant_id in contributor_cases: + session.add( + ProjectRoleQualificationSnapshot( + id=snapshot_id, + project_id=str(project_id), + actor_profile_id=str(actor_id), + requested_role=role.value, + **_project_role_qualification(), + captured_by_actor_profile_id=str(bootstrap_actor_id), + captured_by_admin_role_grant_id=bootstrap_grant_id, + captured_at=now, + ) + ) + await session.flush() + for role, actor_id, _link_id, snapshot_id, grant_id in contributor_cases: + session.add( + ProjectRoleGrant( + id=grant_id, + project_id=str(project_id), + actor_profile_id=str(actor_id), + role=role.value, + qualification_snapshot_id=snapshot_id, + granted_by_actor_profile_id=str(bootstrap_actor_id), + granted_by_admin_role_grant_id=bootstrap_grant_id, + grant_reason=f"AUTH-11A {role.value} exclusion proof", + granted_at=now, + ) + ) + await session.commit() + + repository = AdminAuthorizationRepository(session) + permissions = ( + PermissionId.PROJECT_SETUP_DIAGNOSTIC_READ, + PermissionId.PROJECT_EFFECTIVE_POLICY_READ, + ) + for (_role, scope, _scope_project_id, allowed), ( + _case_role, + actor_id, + _link_id, + _grant_id, + ) in zip(role_cases, actor_cases, strict=True): + for permission in permissions: + grant = await repository.find_effective_grant( + actor_id, permission, scope_project_id=project_id + ) + assert (grant is not None) is allowed + if allowed and scope is AdminScope.PROJECT: + assert ( + await repository.find_effective_grant( + actor_id, permission, scope_project_id=other_project_id + ) + is None + ) + for _role, actor_id, _link_id, _snapshot_id, _grant_id in contributor_cases: + for permission in permissions: + assert ( + await repository.find_effective_grant( + actor_id, permission, scope_project_id=project_id + ) + is None + ) + for permission in permissions: + assert ( + await repository.find_effective_grant( + bootstrap_actor_id, permission, scope_project_id=project_id + ) + is None + ) + + @pytest.mark.asyncio async def test_authorization_locks_refresh_cached_actor_lifecycle_state( authorization_factory, @@ -8835,9 +9187,7 @@ async def test_project_role_issue_shared_completion_writes_ordered_zero_invalida actor_ref_kind=ActorReferenceKind.ACTOR_PROFILE, actor_ref=str(actor_id), operation=request.operation, - request_digest=canonical_json_hash( - request.model_dump(mode="json", exclude_none=True) - ), + request_digest=canonical_json_hash(request.model_dump(mode="json", exclude_none=True)), ) response = AuthorityResponseReference( resource_type=AuthorityResourceType.PROJECT_ROLE_GRANT, @@ -9109,7 +9459,11 @@ async def test_project_role_issue_postgresql_prep_binds_target_role_and_scope( monkeypatch, ) -> None: caller_id, caller_link_id, target_id, target_link_id, project_id = ( - uuid4(), uuid4(), uuid4(), uuid4(), uuid4() + uuid4(), + uuid4(), + uuid4(), + uuid4(), + uuid4(), ) manager_grant_id = uuid4() bootstrap_grant_id = uuid4() @@ -9117,12 +9471,59 @@ async def test_project_role_issue_postgresql_prep_binds_target_role_and_scope( async with authorization_factory() as session: session.add_all( [ - ActorProfile(id=str(caller_id), actor_kind="human", status="active", provisioning_method="automatic_first_access", created_by=str(caller_id)), - ActorIdentityLink(id=str(caller_link_id), actor_profile_id=str(caller_id), issuer="https://identity.flowresearch.tech", subject=f"auth10c-caller-{caller_id}", subject_kind="human", status="active", linked_by=str(caller_id), last_verified_at=now), - ActorProfile(id=str(target_id), actor_kind="human", status="active", provisioning_method="automatic_first_access", created_by=str(target_id)), - ActorIdentityLink(id=str(target_link_id), actor_profile_id=str(target_id), issuer="https://identity.flowresearch.tech", subject=f"auth10c-target-{target_id}", subject_kind="human", status="active", linked_by=str(target_id), last_verified_at=now), - Project(id=str(project_id), name="AUTH-10C PREP proof", slug=f"auth-10c-prep-{project_id}", status="draft"), - AdminRoleGrant(id=bootstrap_grant_id, target_actor_profile_id=str(caller_id), role="access_administrator", scope_type="system", scope_project_id=None, status="active", version=1, granted_by_actor_profile_id=None, granted_by_system_principal="workstream:system:bootstrap", granted_by_admin_role_grant_id=None, grant_reason="AUTH-10C PostgreSQL bootstrap proof"), + ActorProfile( + id=str(caller_id), + actor_kind="human", + status="active", + provisioning_method="automatic_first_access", + created_by=str(caller_id), + ), + ActorIdentityLink( + id=str(caller_link_id), + actor_profile_id=str(caller_id), + issuer="https://identity.flowresearch.tech", + subject=f"auth10c-caller-{caller_id}", + subject_kind="human", + status="active", + linked_by=str(caller_id), + last_verified_at=now, + ), + ActorProfile( + id=str(target_id), + actor_kind="human", + status="active", + provisioning_method="automatic_first_access", + created_by=str(target_id), + ), + ActorIdentityLink( + id=str(target_link_id), + actor_profile_id=str(target_id), + issuer="https://identity.flowresearch.tech", + subject=f"auth10c-target-{target_id}", + subject_kind="human", + status="active", + linked_by=str(target_id), + last_verified_at=now, + ), + Project( + id=str(project_id), + name="AUTH-10C PREP proof", + slug=f"auth-10c-prep-{project_id}", + status="draft", + ), + AdminRoleGrant( + id=bootstrap_grant_id, + target_actor_profile_id=str(caller_id), + role="access_administrator", + scope_type="system", + scope_project_id=None, + status="active", + version=1, + granted_by_actor_profile_id=None, + granted_by_system_principal="workstream:system:bootstrap", + granted_by_admin_role_grant_id=None, + grant_reason="AUTH-10C PostgreSQL bootstrap proof", + ), ] ) await session.flush() @@ -9135,10 +9536,30 @@ async def test_project_role_issue_postgresql_prep_binds_target_role_and_scope( ) await session.commit() session.add( - AdminRoleGrant(id=manager_grant_id, target_actor_profile_id=str(caller_id), role="project_manager", scope_type="project", scope_project_id=str(project_id), status="active", version=1, granted_by_actor_profile_id=str(caller_id), granted_by_system_principal=None, granted_by_admin_role_grant_id=bootstrap_grant_id, grant_reason="AUTH-10C PostgreSQL project-manager proof") + AdminRoleGrant( + id=manager_grant_id, + target_actor_profile_id=str(caller_id), + role="project_manager", + scope_type="project", + scope_project_id=str(project_id), + status="active", + version=1, + granted_by_actor_profile_id=str(caller_id), + granted_by_system_principal=None, + granted_by_admin_role_grant_id=bootstrap_grant_id, + grant_reason="AUTH-10C PostgreSQL project-manager proof", + ) ) await session.commit() - context = HumanAuthorizationContext(actor_profile_id=caller_id, actor_kind=ActorKind.HUMAN, actor_status=ActorStatus.ACTIVE, identity_link_id=caller_link_id, identity_link_status=IdentityLinkStatus.ACTIVE, request_id=uuid4(), correlation_id=uuid4()) + context = HumanAuthorizationContext( + actor_profile_id=caller_id, + actor_kind=ActorKind.HUMAN, + actor_status=ActorStatus.ACTIVE, + identity_link_id=caller_link_id, + identity_link_status=IdentityLinkStatus.ACTIVE, + request_id=uuid4(), + correlation_id=uuid4(), + ) repository = AdminAuthorizationRepository(session) authorization = AuthorizationService(session, context, admin_repository=repository) prepared = PreparedAuthorizationService(session, context, authorization, repository) @@ -9159,13 +9580,37 @@ async def test_project_role_issue_postgresql_prep_binds_target_role_and_scope( request=canonical_issue, ) assert isinstance(issue_reservation, ClaimedReservation) - caller_input = PreparedAuthorizationInput(idempotency_key=issue_key, request_value=canonical_issue.model_dump(mode="json")) - handle = await prepared.prepare(ActionId.PROJECT_ROLE_GRANT_ISSUE, caller_input, PreparedAuthorityScope(kind=PreparedAuthorityScopeKind.PROJECT, project_id=project_id, target_actor_profile_id=target_id, role=ProjectRole.SUBMITTER)) + caller_input = PreparedAuthorizationInput( + idempotency_key=issue_key, request_value=canonical_issue.model_dump(mode="json") + ) + handle = await prepared.prepare( + ActionId.PROJECT_ROLE_GRANT_ISSUE, + caller_input, + PreparedAuthorityScope( + kind=PreparedAuthorityScopeKind.PROJECT, + project_id=project_id, + target_actor_profile_id=target_id, + role=ProjectRole.SUBMITTER, + ), + ) assert await repository.lock_project(project_id) is not None - await repository.take_project_role_issue_lock(project_role_issue_lock_key(target_id, project_id, "submitter")) + await repository.take_project_role_issue_lock( + project_role_issue_lock_key(target_id, project_id, "submitter") + ) assert await repository.lock_eligible_human(target_id) is not None - issue_resource = ProjectRoleGrantIssueResourceContext(resource_type="project_role_grant", resource_id=project_id, scope_project_id=project_id, target_actor_profile_id=target_id, role=ProjectRole.SUBMITTER, project_status="draft", target_eligible=True, active_exact_role_exists=False) - decision = await prepared.consume(handle, ActionId.PROJECT_ROLE_GRANT_ISSUE, caller_input, issue_resource) + issue_resource = ProjectRoleGrantIssueResourceContext( + resource_type="project_role_grant", + resource_id=project_id, + scope_project_id=project_id, + target_actor_profile_id=target_id, + role=ProjectRole.SUBMITTER, + project_status="draft", + target_eligible=True, + active_exact_role_exists=False, + ) + decision = await prepared.consume( + handle, ActionId.PROJECT_ROLE_GRANT_ISSUE, caller_input, issue_resource + ) assert decision.allowed is True assert decision.matched_grant_id == manager_grant_id issue_service = ProjectRoleGrantMutationService(session) @@ -9238,9 +9683,7 @@ async def test_project_role_issue_postgresql_prep_binds_target_role_and_scope( ), ) project = await repository.lock_project(project_id) - row = await repository.lock_project_role_grant( - project_id=project_id, grant_id=issued.id - ) + row = await repository.lock_project_role_grant(project_id=project_id, grant_id=issued.id) assert project is not None and row is not None grant, _snapshot = row revoke_resource = ProjectRoleGrantRevokeResourceContext( @@ -9262,15 +9705,11 @@ async def test_project_role_issue_postgresql_prep_binds_target_role_and_scope( revoke_service = ProjectRoleGrantMutationService(session) for substituted_decision, substituted_resource in ( ( - revoke_decision.model_copy( - update={"action_id": ActionId.PROJECT_ROLE_GRANT_ISSUE} - ), + revoke_decision.model_copy(update={"action_id": ActionId.PROJECT_ROLE_GRANT_ISSUE}), revoke_resource, ), ( - revoke_decision.model_copy( - update={"permission_id": PermissionId.PROJECT_READ} - ), + revoke_decision.model_copy(update={"permission_id": PermissionId.PROJECT_READ}), revoke_resource, ), (revoke_decision.model_copy(update={"revalidated": False}), revoke_resource), @@ -9448,12 +9887,8 @@ async def capture_race_claim(service, **kwargs): race_claim_ids.append(reservation.claim.record_id) return reservation - monkeypatch.setattr( - AdminAuthorizationRepository, "find_active_project_role", race_find - ) - monkeypatch.setattr( - ProjectRoleGrantMutationService, "reserve", capture_race_claim - ) + monkeypatch.setattr(AdminAuthorizationRepository, "find_active_project_role", race_find) + monkeypatch.setattr(ProjectRoleGrantMutationService, "reserve", capture_race_claim) caller_profile = await session.get(ActorProfile, str(caller_id)) caller_link = await session.get(ActorIdentityLink, str(caller_link_id)) assert caller_profile is not None and caller_link is not None @@ -9507,8 +9942,7 @@ async def capture_race_claim(service, **kwargs): assert ( await clean.scalar( text( - "select count(*) from authority_idempotency_records " - "where idempotency_key=:key" + "select count(*) from authority_idempotency_records where idempotency_key=:key" ), {"key": str(race_key)}, ) @@ -9527,16 +9961,14 @@ async def capture_race_claim(service, **kwargs): }, ) ).all() - assert denial_rows.count( - ("SensitiveAuthorizationDenied", "project_role_grant_exists", None) - ) == 1 + assert ( + denial_rows.count(("SensitiveAuthorizationDenied", "project_role_grant_exists", None)) + == 1 + ) assert all(row[0] != "AuthorityInvalidationRequested" for row in denial_rows) assert ( await clean.scalar( - text( - "select count(*) from audit_events " - "where idempotency_reference=:claim" - ), + text("select count(*) from audit_events where idempotency_reference=:claim"), {"claim": str(race_claim_ids[0])}, ) == 0 @@ -9690,8 +10122,7 @@ async def capture_race_claim(service, **kwargs): assert ( await clean.scalar( text( - "select count(*) from authority_idempotency_records " - "where idempotency_key=:key" + "select count(*) from authority_idempotency_records where idempotency_key=:key" ), {"key": str(waiting_key)}, ) diff --git a/docs/operations_authorization_service.md b/docs/operations_authorization_service.md index 869f2945..0c153a25 100644 --- a/docs/operations_authorization_service.md +++ b/docs/operations_authorization_service.md @@ -666,19 +666,21 @@ For each chunk: was added or restored. 5. Run required internal reviewers and repair valid findings. 6. Publish one chunk-sized PR and stop for human merge approval. -7. Update post-merge memory before activating the next chunk. Do not cut over a resource family until its local actor, grant, permission, resource loader, lifecycle guards, negative tests, and evidence path exist. ### Catalogue And Action-Evidence Staging -The catalogue contains exactly 74 PermissionIds and 70 ActionIds after -AUTH-10A. The two AUTH-07B actor-self actions, seven AUTH-08 administrative +The catalogue contains exactly 76 PermissionIds and 81 ActionIds after +AUTH-11A. 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 other 53 entries remain planned -and non-executable. The target post-custody +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`; the other +59 entries 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 transfers assign all 25 ART rows to eight exact AUTH custodians and all 19 REV @@ -702,8 +704,15 @@ complete. Counts and mappings remain unchanged. The ART transfer adds no migrati 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 74 PermissionIds, 70 ActionIds, 22 active actions, and -48 planned actions after AUTH-10C activates the two project-role mutation rows. +Catalogue totals are 76 PermissionIds, 81 ActionIds, 22 active actions, and +59 planned actions after AUTH-11A registers project-read actions without +activating a route. + +AUTH-11A adds read-only `project.setup_diagnostic.read` and +`project.effective_policy.read`. Project Manager and Audit Authority receive +them at system or exact-project scope; Operator receives them at system scope. +Finance Authority and Access Administrator do not. The eleven AUTH-11 actions +remain planned under 11B, 11C1, or 11C2 and cannot produce allowed evidence. Four later REV registrations add exactly four planned and zero active actions, while the review-evidence binding registration adds exactly one planned and zero active @@ -719,7 +728,8 @@ actions. Planned actions can record bounded denial evidence but cannot record an allowed decision through the typed writer. The historical permission set remains exactly 49 values. The post-`0020` set -contains exactly 25 values, including `review.queue.override`; do not derive +contains exactly 27 values, including `review.queue.override`, +`project.setup_diagnostic.read`, and `project.effective_policy.read`; do not derive historical status from identifier prefixes. All submission/review rows remain planned. Initial and revision submission share `submission.create`, and no revision-specific permission or preparation action exists. diff --git a/docs/operations_roles_permissions.md b/docs/operations_roles_permissions.md index d36f8747..022eb7e9 100644 --- a/docs/operations_roles_permissions.md +++ b/docs/operations_roles_permissions.md @@ -28,6 +28,12 @@ product authority. Scopes are an outer request-class gate only. | Finance Authority | system or exact covered project | Contribution policy, compensation-adapter binding, and fulfillment observation under WS-CON-001. | | Audit Authority | system or exact covered project | Read-only evidence access and authorized export. | +Project setup diagnostics and effective-policy inspection use the distinct +read-only permissions `project.setup_diagnostic.read` and +`project.effective_policy.read`. They are assigned to Project Manager, +system-scoped Operator, and covered Audit Authority. They grant no mutation and +are not assigned to Finance Authority or Access Administrator. + Administrative grants do not imply contributor capability. Holding one does not permit claiming tasks, submitting work, or recording review decisions. diff --git a/docs/spec_authorization_service.md b/docs/spec_authorization_service.md index 7b95f154..029d1d21 100644 --- a/docs/spec_authorization_service.md +++ b/docs/spec_authorization_service.md @@ -150,6 +150,8 @@ admin_role.revoke project.create project.read +project.setup_diagnostic.read +project.effective_policy.read project.update project.archive project.guide.manage @@ -231,17 +233,20 @@ registration, hidden ART behavior/resource composition, then dedicated AUTH evaluator integration and activation. ART never writes availability. AUTH-12, AUTH-14, and AUTH-15 are not alternate artifact activation paths. -These are 74 approved `PermissionId` values. `ActionId` values are a separate +These are 76 approved `PermissionId` values. `ActionId` values are a separate closed registry layer and are not included in that permission count. AUTH-05A's typed and PostgreSQL audit registry accepts the exact historical 49. The three -approved Operator recovery identifiers, 21 artifact identifiers, and -`review.queue.override` are the exact 25 post-`0020` permissions. AUTH-07A adds +approved Operator recovery identifiers, 21 artifact identifiers, +`review.queue.override`, and the two AUTH-11A read-only project inspection +permissions are the exact 27 post-`0020` permissions. AUTH-07A and AUTH-11A add their matching typed/SQL audit parity without making them executable. -The closed action registry contains 70 rows after AUTH-10C: 22 active actions -and 48 planned rows. AUTH-10A added five project-role read/manage rows; +The closed action registry contains 81 rows after AUTH-11A: 22 active actions +and 59 planned rows. 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-08 adds seven active administrative definition, +the two reason-bound, idempotent project-role mutations. AUTH-11A adds eleven +planned project identity, context, setup, policy, and active-guide reads owned +by 11B, 11C1, and 11C2. AUTH-08 adds seven active administrative definition, grant-history, issue, revoke, and local-bootstrap actions without adding a permission. AUTH-09A adds eight planned actor, identity-link, and service provisioning actions without activating a route; AUTH-09B activates only @@ -262,7 +267,7 @@ The four proposed REV lifecycle actions and registry. Catalogue totals are derived from trusted `main` when each gate runs: REV registration adds exactly four planned and zero active actions, while the review-evidence registration adds exactly one planned and zero active action, in -either order. Both retain 74 PermissionIds and stay blocked until complete +either order. Both retain 76 PermissionIds and stay blocked until complete feature-owned typed and transaction manifests exist. AUTH-07B activates `actor.profile.read_self` and `actor.profile.update_self`.