Skip to content
Merged
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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
Expand Down Expand Up @@ -71,7 +74,7 @@ compatibility aliases or fallback paths
(cd backend && WORKSTREAM_DATABASE_URL=<isolated-test-db> .venv/bin/alembic upgrade head)
(cd backend && WORKSTREAM_DATABASE_URL=<isolated-test-db> .venv/bin/alembic downgrade -1)
(cd backend && WORKSTREAM_DATABASE_URL=<isolated-test-db> .venv/bin/alembic upgrade head)
python3 scripts/test_agent_gates.py
python3 -m scripts.test_lightweight_agent_gates
git diff --check
```

Expand Down
139 changes: 139 additions & 0 deletions backend/alembic/versions/0035_project_read_action_evidence.py
Original file line number Diff line number Diff line change
@@ -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)
2 changes: 1 addition & 1 deletion backend/app/modules/authorization/admin_schemas.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ class PermissionDefinitionsResponse(BaseModel):

model_config = _STRICT
items: tuple[PermissionDefinitionResponse, ...]
total: Literal[74]
total: Literal[76]


class AdminRoleDefinitionResponse(BaseModel):
Expand Down
2 changes: 1 addition & 1 deletion backend/app/modules/authorization/admin_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
75 changes: 73 additions & 2 deletions backend/app/modules/authorization/catalogue.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
6 changes: 6 additions & 0 deletions backend/app/modules/authorization/policy.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
2 changes: 1 addition & 1 deletion backend/tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@

DDL_LOCK_DIRECTORY = Path("/tmp")
EXPECTED_PUBLIC_SCHEMA_SHA256 = (
"83831965c8001901db2dab8cfd9c8a83e27331d61e724381dbdce5a859bf0e9c"
"8853e81a2c3c2452dd236a5a691d568d9184379a78301172c096c8b33ef63890"
)
PROTECTED_TEST_TABLES = (
"actor_profile_migration_state",
Expand Down
Loading
Loading