From 4745d37d30c5f33f1cace93510bbcab5401403d8 Mon Sep 17 00:00:00 2001 From: Abiorh001 Date: Thu, 23 Jul 2026 15:24:44 +0100 Subject: [PATCH 01/10] docs(auth): activate and harden AUTH-10C contract --- ...TH-001-10C-project-role-grant-mutations.md | 155 ++++++++++++++++-- 1 file changed, 139 insertions(+), 16 deletions(-) diff --git a/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/chunks/WS-AUTH-001-10C-project-role-grant-mutations.md b/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/chunks/WS-AUTH-001-10C-project-role-grant-mutations.md index cbdce300..69c0fd7e 100644 --- a/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/chunks/WS-AUTH-001-10C-project-role-grant-mutations.md +++ b/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/chunks/WS-AUTH-001-10C-project-role-grant-mutations.md @@ -6,8 +6,11 @@ ## Status and prerequisite -Proposed and inactive. Start only after 10B merges, signed memory names 10C, -and a fresh explicit start event activates this exact child. +Active for implementation. AUTH-10B2 merged through PR #178 as +`73b457925b02301587b83d01ced0adb66319d134`. Signed automation run +`30014637065` activated this exact child on protected main +`bcf1292e1a591e3e84bf8ee212ee7191d80741fa`. Chat and local branch state are +not authority; the signed automation projection is the canonical start proof. ## Goal @@ -36,6 +39,7 @@ backend/app/modules/projects/repository.py backend/app/api/router.py backend/tests/test_actors.py backend/tests/test_authorization.py +backend/tests/test_api_controls.py backend/tests/test_projects.py backend/scripts/api_contract_e2e.py backend/scripts/auth_api_e2e.py @@ -74,18 +78,71 @@ authority. ## Exact lock and transaction order -PREP first locks `AuthorityControl(id=1)` as the shared authorization-order -barrier used by administrative lifecycle mutations. It then accepts the caller -plus the known target human principal for issuance, sorts distinct ActorProfile -IDs lexically, and locks each profile then its exact active link. It finally -locks the caller's one deterministic covered Project Manager AdminRoleGrant. -The barrier is ordering coordination, not final-admin permission or safety. +The route consumes the existing admin-mutation rate dependency exactly once, +opens one root transaction, and reserves or locks the idempotency record before +any other mutable row. Reservation outcome is internal only: mismatch and replay +never branch to a response until PREP and current project authority have been +revalidated, so authority and concealment win over key-existence disclosure. +Fresh, replay, and mismatch paths use the same ordering. + +PREP then locks `AuthorityControl(id=1)` as the shared authorization-order +barrier used by administrative lifecycle mutations. Issue uses a target-aware +prepared input containing the canonical target ActorProfile ID in addition to +the existing project scope and full request digest. PREP sorts the distinct +caller and target ActorProfile UUID strings lexically, locks each profile and +then its selected exact link immediately after that profile, and finally locks +the caller's one deterministic covered Project Manager AdminRoleGrant. The +caller's link is the authenticated exact link; the target link is the unique +active exact link selected deterministically for issue. Missing, inactive, or +nonhuman target facts are retained as a fail-closed prepared outcome rather +than raised immediately: PREP still locks and revalidates the caller's current +authority and the route still locks/revalidates the project before choosing the +public result. An unauthorized or concealed-project caller therefore observes +the same status, code, body, and mutation-free outcome for valid, missing, +inactive, agent, Space, or service targets, without a target-specific early +timing or response branch. All competing profile, link, and +administrative-grant mutations retain `AuthorityControl` for the transaction +lifetime. The barrier is ordering coordination, not final-admin permission or +safety. Revoke has no client-trusted target: PREP binds caller, project, grant +ID, idempotency key, and request digest; the locked grant supplies target facts +only during final resource recomposition. Revoke does not require the stored +target profile or identity link to remain active; a target profile row may be +locked only for deterministic concurrency or audit integrity, never as a +revocation admission gate. + +The opaque handle binds action, caller/link, target when issue, project, role +when issue, grant ID when revoke, idempotency key, and full canonical request +digest. Consume rejects target, scope, role, grant, digest, session, +transaction, action, handle-owner, reuse, or lifecycle substitution. Forged, +copied, serialized, cross-service, cross-session, and already-consumed handles +fail closed. After PREP returns, issue locks canonical project, then takes one transaction advisory key for `(actor_profile_id, project_id, requested_role)` to serialize absence, then reloads the target/scope facts and active exact-role selector. -Revoke locks canonical project then the exact grant. Consume recomposes every -fact and evaluates once; repositories flush only. The route commits once. +Revoke locks canonical project then the exact path-project grant. Consume +recomposes every fact and evaluates once; repositories flush only. The route +commits once. + +The issue advisory key is a domain-separated deterministic PostgreSQL signed +`int8`. Its frozen input encoding is the compact UTF-8 JSON array +`["workstream.project_role_grant.issue.v1","","",""]` +with no insignificant whitespace and JSON escaping defined by the repository's +canonical serializer. SHA-256 hashes those exact bytes; the first eight digest +bytes are decoded big-endian as a two's-complement signed `int64`. Tests freeze +a known vector, exact encoding, UUID and role separation, stability, and signed +range. +The partial unique active-exact-role index remains the final backstop; a +uniqueness race becomes `project_role_grant_exists` without a second snapshot, +success event, or grant. + +The complete order is: rate consumption -> root transaction -> idempotency +reserve/record lock -> AuthorityControl -> lexical caller/target profile and +exact-link locks -> deterministic covered Project Manager grant -> project -> +issue advisory key or exact revoke grant -> canonical fact reload -> consume +exactly once -> snapshot/grant/idempotency/audit/invalidation flush -> one +route-owned commit. Rollback and denial-evidence restaging never invert this +order or disclose reservation outcome before authorization. Crossed tests prove two managers targeting one another, issue versus target profile/link lifecycle, and issue versus caller-grant revocation cannot deadlock @@ -95,8 +152,13 @@ because all paths share the barrier and compatible principal order. Issue requires `Idempotency-Key: ` and a strict body containing exactly `target_actor_profile_id`, `role`, `qualification`, and `reason`. -`qualification` contains the two exact availability objects and reference lists -defined by 10A. Revoke requires the same header and exactly `reason`. Reasons +`qualification` is identifier-free and contains only the two exact availability +objects and reference lists defined by 10A; the server composes path project, +body target, and body role into `ProjectRoleQualificationSnapshotInput`. The +full canonical bounded qualification value participates in the issue request +digest, idempotency equality, PREP binding, and snapshot validation. Changing +any qualification field with the same key is `idempotency_mismatch`. Revoke +requires the same header and exactly `reason`. Reasons are 1..500 UTF-8 bytes, equal their Python `str.strip()` result, and contain no Unicode control character. Issue returns HTTP 201 with exactly `{id, qualification_snapshot_id, project_id, actor_profile_id, role, status: @@ -117,14 +179,40 @@ same-key/same-body revoke replay reauthorizes and returns the prior canonical HTTP 200 revoked response; a new-key revoke of an already-revoked grant returns HTTP 409 `project_role_grant_already_revoked`. +Replay never trusts stored response metadata alone. Active issue replay locks +and reloads the canonical path project, grant, and snapshot and requires the +exact project/target/role tuple, snapshot ownership, `status=active`, and +`version=1`. Revoked, missing, or mismatched state becomes +`project_role_grant_replay_state_changed` only after current authority and +project concealment pass. Revoke replay locks and reloads the exact +path-project grant and original snapshot and requires `status=revoked`, +`version=2`, and unchanged ownership before returning the prior HTTP 200 shape. + +The shared `AuthorityMutationService` remains the sole completion path. It is +extended to validate and atomically append an ordered tuple of success events +and an optional typed invalidation projection before completing idempotency. +Issue writes exactly `ProjectRoleQualificationSnapshotCaptured` followed by +`ProjectRoleGrantIssued` and creates no invalidation. Revoke writes exactly +`ProjectRoleGrantRevoked` followed by one linked +`AuthorityInvalidationRequested` containing grant, actor, project, role, +cause-event, and the role-specific future-obligation projection. No parallel +completion writer is permitted. + ## Acceptance criteria - Issue permits draft, active, and paused projects; terminal/archived, nonexistent, and unauthorized projects deny without target disclosure. - Revoke remains available for every existing project state so authority can always be removed. -- Target is a different active human with active link. Agent, Space, service, - self-grant, and self-revoke deny stably before disclosure. +- Issue requires a different active canonical human target with its unique + active link. Agent, Space, service, and self-grant deny stably after current + caller/project authorization and concealment have been resolved. +- Revoke derives target identity and role only from the locked exact + path-project grant. After current covered-manager authorization, self-role + revoke returns HTTP 403 `self_role_revoke_forbidden`; an unauthorized caller + cannot learn self-ownership and receives the same concealed HTTP 404 as other + hidden grants. Target suspension or a revoked/missing target identity link + never blocks revocation, so granted authority cannot become irremovable. - Issue creates one exact-role snapshot, one manual grant, `ProjectRoleQualificationSnapshotCaptured`, and `ProjectRoleGrantIssued` in one transaction. It never changes another role. @@ -145,7 +233,9 @@ HTTP 409 `project_role_grant_already_revoked`. invalidation, flush, and commit leave no orphan row or partial evidence. - PostgreSQL tests cover identical-role issue, different-role issue, crossed managers, revoke versus regrant, revoke versus authorization, replay after - authority loss, timeout, and cancellation. + authority loss, timeout, and cancellation. PostgreSQL and API tests also + prove a covered manager can revoke an active grant after the target profile + is suspended and after its identity link is revoked. - Live API proof uses the same unexpired manager token to issue, read the active grant through 10B, revoke, read the historical revoked grant, and prove a same-key/same-body revoke replay returns the prior HTTP 200 response after @@ -157,11 +247,44 @@ HTTP 409 `project_role_grant_already_revoked`. issue replay changes to that exact closed conflict. Contributor-capability denial remains explicitly deferred to AUTH-11/13 where a real consumer action exists. +- Cross-project revoke (`project_id=A`, grant from B), nonexistent grant, and + unauthorized revoke share concealed 404 and create no mutation, success + audit, invalidation, or disclosure. Unauthorized issue with and without any + manager grant cannot distinguish valid, nonexistent, inactive, agent, Space, + service, or otherwise unauthorized targets: exact public status, code, and + body match and no mutation/evidence is written. Unauthorized revoke cannot + distinguish self-owned, cross-project, or nonexistent grants; only an + authorized covered manager receives the declared self-role 403. +- PostgreSQL concurrency tests use independent sessions, deterministic + synchronization that observes held and waiting locks, bounded + `asyncio.wait_for` plus statement/lock timeouts, and clean-retry proof. + Cancellation while waiting and after each staged flush, plus injected + route-commit failure, leaves no pending idempotency record, snapshot, grant, + audit, or invalidation; the route explicitly rolls back/closes and a clean + retry succeeds. +- Existing 10B read, cursor, concealment, rate-order, exact OpenAPI inventory, + and protected-action assertions remain intact. Only the two AUTH-10C rows + move from planned to active; owner and permission remain unchanged. The two + operations carry exact action IDs, consume the admin-mutation rate dependency + exactly once, and never attach authorization-read rate consumption. +- Hosted `backend/scripts/api_contract_e2e.py` provisions a distinct canonical + human target and covered project-scoped Project Manager through existing + public APIs with unexpired tokens and no direct database edits. The same + manager token proves issue -> 10B active read -> revoke -> 10B historical + read -> same-key revoke replay 200 -> new-key revoke 409 -> old issue replay + 409, plus concealed denial after manager authority loss. `auth_api_e2e.py` + remains complementary local proof, not a substitute for the hosted drill. +- Regenerate exact combined route/protected-operation counts and SHA-256 + inventories from current main after adding the two routes; never guess or + replace exact hashes with count-only assertions. No migration, configuration, + workflow, pytest marker, skip/xfail, command, or coverage-threshold change is + allowed. GitHub must pass all shards, hosted E2E, Agent Gates, the 78 percent + repository floor, and the 90 percent authorization-subsystem floor. ## Verification commands ```bash -(cd backend && .venv/bin/python -m ruff check app/modules/actors app/modules/authorization app/modules/projects tests/test_actors.py tests/test_authorization.py tests/test_projects.py scripts/auth_api_e2e.py) +(cd backend && .venv/bin/python -m ruff check app/modules/actors app/modules/authorization app/modules/projects tests/test_actors.py tests/test_authorization.py tests/test_api_controls.py tests/test_projects.py scripts/auth_api_e2e.py scripts/api_contract_e2e.py) (cd backend && WORKSTREAM_TEST_ADMIN_DATABASE_URL= .venv/bin/python scripts/run_isolated_tests.py --metadata-json --timeout-seconds 300 -- .venv/bin/python -m pytest -q tests/test_actors.py tests/test_authorization.py tests/test_projects.py -k 'project_role_grant') (cd backend && WORKSTREAM_DATABASE_URL= .venv/bin/python scripts/auth_api_e2e.py) python3 scripts/check_stale_authorization_docs.py From eb8b29623ad2c12dc2a39ae40cbbc65f4718bb47 Mon Sep 17 00:00:00 2001 From: Abiorh001 Date: Thu, 23 Jul 2026 15:52:38 +0100 Subject: [PATCH 02/10] feat(auth): add project role grant mutations --- .../merge-intents/WS-AUTH-001-10C.json | 9 + .../app/modules/authorization/catalogue.py | 26 +- backend/app/modules/authorization/kernel.py | 85 +++--- .../authorization/project_role_schemas.py | 40 +++ .../authorization/project_role_service.py | 271 ++++++++++++++++++ .../app/modules/authorization/repository.py | 64 ++++- backend/app/modules/authorization/router.py | 213 +++++++++++++- backend/app/modules/authorization/runtime.py | 57 +++- backend/app/modules/authorization/schemas.py | 20 +- backend/app/modules/authorization/service.py | 125 ++++---- backend/scripts/api_contract_e2e.py | 148 +++++----- backend/tests/test_api_controls.py | 29 +- backend/tests/test_authorization.py | 182 ++++++++---- docs/operations_authorization_service.md | 4 +- docs/spec_authorization_service.md | 7 +- 15 files changed, 1001 insertions(+), 279 deletions(-) create mode 100644 .agent-loop/merge-intents/WS-AUTH-001-10C.json create mode 100644 backend/app/modules/authorization/project_role_schemas.py create mode 100644 backend/app/modules/authorization/project_role_service.py diff --git a/.agent-loop/merge-intents/WS-AUTH-001-10C.json b/.agent-loop/merge-intents/WS-AUTH-001-10C.json new file mode 100644 index 00000000..cfef9a9a --- /dev/null +++ b/.agent-loop/merge-intents/WS-AUTH-001-10C.json @@ -0,0 +1,9 @@ +{ + "chunk_id": "WS-AUTH-001-10C", + "chunk_title": "Project Role Grant Mutations", + "initiative_id": "WS-AUTH-001", + "next_chunk_id": "WS-AUTH-001-11", + "next_chunk_title": "Project Identity, Guide, Source, And Visibility Cutover", + "next_requires_explicit_start": true, + "schema_version": 2 +} diff --git a/backend/app/modules/authorization/catalogue.py b/backend/app/modules/authorization/catalogue.py index 337167b3..78615aba 100644 --- a/backend/app/modules/authorization/catalogue.py +++ b/backend/app/modules/authorization/catalogue.py @@ -331,12 +331,12 @@ def _active( PermissionId.PROJECT_ROLE_GRANT_READ, ActionOwner.AUTH_10B, ), - _planned( + _active( ActionId.PROJECT_ROLE_GRANT_ISSUE, PermissionId.PROJECT_ROLE_GRANT_MANAGE, ActionOwner.AUTH_10C, ), - _planned( + _active( ActionId.PROJECT_ROLE_GRANT_REVOKE, PermissionId.PROJECT_ROLE_GRANT_MANAGE, ActionOwner.AUTH_10C, @@ -633,6 +633,8 @@ def _index_actions( ActionId.PROJECT_CONTRIBUTOR_CANDIDATE_LIST, ActionId.PROJECT_ROLE_GRANT_LIST, ActionId.PROJECT_ROLE_GRANT_READ, + ActionId.PROJECT_ROLE_GRANT_ISSUE, + ActionId.PROJECT_ROLE_GRANT_REVOKE, } if { definition.action_id @@ -663,18 +665,14 @@ def _index_actions( ActionId.ARTIFACT_CHECKER_OUTPUT_BINDING_CREATE, } ), - ServiceIdentity.ARTIFACT_GUIDE_READER: frozenset( - {ActionId.ARTIFACT_GUIDE_SOURCE_READ} - ), + ServiceIdentity.ARTIFACT_GUIDE_READER: frozenset({ActionId.ARTIFACT_GUIDE_SOURCE_READ}), ServiceIdentity.ARTIFACT_MATERIALIZER: frozenset( { ActionId.ARTIFACT_PRE_SUBMIT_CHECKER_INPUT_MATERIALIZE, ActionId.ARTIFACT_POST_SUBMIT_CHECKER_INPUT_MATERIALIZE, } ), - ServiceIdentity.ARTIFACT_CHECKER_OUTPUT: frozenset( - {ActionId.ARTIFACT_CHECKER_OUTPUT_WRITE} - ), + ServiceIdentity.ARTIFACT_CHECKER_OUTPUT: frozenset({ActionId.ARTIFACT_CHECKER_OUTPUT_WRITE}), } @@ -683,12 +681,8 @@ def _index_service_actions( ) -> MappingProxyType[ServiceIdentity, frozenset[ActionId]]: """Validate the exact fixed service matrix and return an immutable view.""" expected_rows = { - ServiceIdentity.ARTIFACT_VERIFIER: frozenset( - {ActionId.ARTIFACT_VERIFICATION_EXECUTE} - ), - ServiceIdentity.ARTIFACT_PUT_RESOLVER: frozenset( - {ActionId.ARTIFACT_PUT_ATTEMPT_RESOLVE} - ), + ServiceIdentity.ARTIFACT_VERIFIER: frozenset({ActionId.ARTIFACT_VERIFICATION_EXECUTE}), + ServiceIdentity.ARTIFACT_PUT_RESOLVER: frozenset({ActionId.ARTIFACT_PUT_ATTEMPT_RESOLVE}), ServiceIdentity.ARTIFACT_SCHEDULER: frozenset( {ActionId.ARTIFACT_PENDING_WORK_SCAN, ActionId.ARTIFACT_UPLOAD_SESSION_EXPIRE} ), @@ -699,9 +693,7 @@ def _index_service_actions( ActionId.ARTIFACT_CHECKER_OUTPUT_BINDING_CREATE, } ), - ServiceIdentity.ARTIFACT_GUIDE_READER: frozenset( - {ActionId.ARTIFACT_GUIDE_SOURCE_READ} - ), + ServiceIdentity.ARTIFACT_GUIDE_READER: frozenset({ActionId.ARTIFACT_GUIDE_SOURCE_READ}), ServiceIdentity.ARTIFACT_MATERIALIZER: frozenset( { ActionId.ARTIFACT_PRE_SUBMIT_CHECKER_INPUT_MATERIALIZE, diff --git a/backend/app/modules/authorization/kernel.py b/backend/app/modules/authorization/kernel.py index accde975..900ce8e2 100644 --- a/backend/app/modules/authorization/kernel.py +++ b/backend/app/modules/authorization/kernel.py @@ -47,7 +47,9 @@ PermissionCatalogueResourceContext, ProjectContributorCandidateCollectionResourceContext, ProjectRoleGrantCollectionResourceContext, + ProjectRoleGrantIssueResourceContext, ProjectRoleGrantReadResourceContext, + ProjectRoleGrantRevokeResourceContext, PreparedAuthorizationUnsupported, PreparedAuthorityScope, PreparedAuthorityScopeKind, @@ -81,6 +83,8 @@ ActionId.ACTOR_PROFILE_DEACTIVATE, ActionId.ACTOR_IDENTITY_LINK_REVOKE, ActionId.ACTOR_IDENTITY_LINK_REACTIVATE, + ActionId.PROJECT_ROLE_GRANT_ISSUE, + ActionId.PROJECT_ROLE_GRANT_REVOKE, ActionId.PROJECT_CONTRIBUTOR_CANDIDATE_LIST, ActionId.PROJECT_ROLE_GRANT_LIST, ActionId.PROJECT_ROLE_GRANT_READ, @@ -263,16 +267,13 @@ async def _prepare_prelocked( PreparedAuthorityScopeKind.SYSTEM, PreparedAuthorityScopeKind.PROJECT, }: - raise PreparedAuthorizationUnsupported( - AuthorizationDenialCode.SCOPE_NOT_AUTHORIZED - ) - if ( - scope.kind is PreparedAuthorityScopeKind.PROJECT - and action_id is not ActionId.ADMIN_ROLE_GRANT_ISSUE - ): - raise PreparedAuthorizationUnsupported( - AuthorizationDenialCode.SCOPE_NOT_AUTHORIZED - ) + raise PreparedAuthorizationUnsupported(AuthorizationDenialCode.SCOPE_NOT_AUTHORIZED) + if scope.kind is PreparedAuthorityScopeKind.PROJECT and action_id not in { + ActionId.ADMIN_ROLE_GRANT_ISSUE, + ActionId.PROJECT_ROLE_GRANT_ISSUE, + ActionId.PROJECT_ROLE_GRANT_REVOKE, + }: + raise PreparedAuthorizationUnsupported(AuthorizationDenialCode.SCOPE_NOT_AUTHORIZED) await self._admin.lock_control() locked = await self._admin.lock_request_actor( context.identity_link_id, context.actor_profile_id @@ -313,18 +314,14 @@ async def _prepare_prelocked( @staticmethod def _locked_human_context(locked, original) -> HumanAuthorizationContext: if locked is None: - raise PreparedAuthorizationUnsupported( - AuthorizationDenialCode.IDENTITY_LINK_REVOKED - ) + raise PreparedAuthorizationUnsupported(AuthorizationDenialCode.IDENTITY_LINK_REVOKED) link, profile = locked if ( profile.id != str(original.actor_profile_id) or link.id != str(original.identity_link_id) or link.actor_profile_id != profile.id ): - raise PreparedAuthorizationUnsupported( - AuthorizationDenialCode.PERMISSION_NOT_GRANTED - ) + raise PreparedAuthorizationUnsupported(AuthorizationDenialCode.PERMISSION_NOT_GRANTED) return HumanAuthorizationContext( actor_profile_id=UUID(profile.id), actor_kind=ActorKind(profile.actor_kind), @@ -338,9 +335,7 @@ def _locked_human_context(locked, original) -> HumanAuthorizationContext: @staticmethod def _locked_service_context(locked, original) -> ServiceAuthorizationContext: if locked is None: - raise PreparedAuthorizationUnsupported( - AuthorizationDenialCode.IDENTITY_LINK_REVOKED - ) + raise PreparedAuthorizationUnsupported(AuthorizationDenialCode.IDENTITY_LINK_REVOKED) link, profile = locked if ( profile.id != str(original.actor_profile_id) @@ -349,9 +344,7 @@ def _locked_service_context(locked, original) -> ServiceAuthorizationContext: or profile.actor_kind != ActorKind.SERVICE.value or profile.service_identity != original.service_identity.value ): - raise PreparedAuthorizationUnsupported( - AuthorizationDenialCode.PERMISSION_NOT_GRANTED - ) + raise PreparedAuthorizationUnsupported(AuthorizationDenialCode.PERMISSION_NOT_GRANTED) return ServiceAuthorizationContext( actor_profile_id=UUID(profile.id), actor_kind=ActorKind.SERVICE, @@ -469,8 +462,7 @@ async def _require_prelocked( if denial is None and final_scope != authority.scope_project_id: denial = AuthorizationDenialCode.SCOPE_NOT_AUTHORIZED if denial is None and ( - authority.matched_grant_id is None - or authority.matched_grant_status != "active" + authority.matched_grant_id is None or authority.matched_grant_status != "active" ): denial = AuthorizationDenialCode.PERMISSION_NOT_GRANTED if denial is None: @@ -553,8 +545,7 @@ async def _service_denial( return lifecycle, refreshed, True if ( refreshed.service_identity is not context.service_identity - or action.action_id - not in SERVICE_ACTIONS_BY_IDENTITY[refreshed.service_identity] + or action.action_id not in SERVICE_ACTIONS_BY_IDENTITY[refreshed.service_identity] or ACTION_BY_ID[action.action_id].availability is not ActionAvailability.ACTIVE ): return AuthorizationDenialCode.PERMISSION_NOT_GRANTED, refreshed, True @@ -663,24 +654,28 @@ async def _admin_guard( return AuthorizationDenialCode.GRANT_NOT_FOUND if grant.target_actor_profile_id == str(context.actor_profile_id): return AuthorizationDenialCode.SELF_ROLE_REVOKE_FORBIDDEN + elif isinstance(resource, ProjectRoleGrantIssueResourceContext): + if resource.target_actor_profile_id == context.actor_profile_id: + return AuthorizationDenialCode.SELF_GRANT_FORBIDDEN + if resource.project_status not in {"draft", "active", "paused"}: + return AuthorizationDenialCode.RESOURCE_GUARD_DENIED + if not resource.target_eligible: + return AuthorizationDenialCode.ACTOR_NOT_FOUND + elif isinstance(resource, ProjectRoleGrantRevokeResourceContext): + if resource.actor_profile_id == context.actor_profile_id: + return AuthorizationDenialCode.SELF_ROLE_REVOKE_FORBIDDEN elif isinstance(resource, ActorProfileLifecycleResourceContext): - if ( - resource.resource_id == context.actor_profile_id - and resource.transition in {"suspend", "deactivate"} - ): + if resource.resource_id == context.actor_profile_id and resource.transition in { + "suspend", + "deactivate", + }: return AuthorizationDenialCode.RESOURCE_GUARD_DENIED if await self._admin.lock_actor_lifecycle_target(resource.resource_id) is None: return AuthorizationDenialCode.ACTOR_NOT_FOUND elif isinstance(resource, ActorIdentityLinkLifecycleResourceContext): - if ( - resource.resource_id == context.identity_link_id - and resource.transition == "revoke" - ): + if resource.resource_id == context.identity_link_id and resource.transition == "revoke": return AuthorizationDenialCode.RESOURCE_GUARD_DENIED - if ( - await self._admin.lock_identity_link_lifecycle_target(resource.resource_id) - is None - ): + if await self._admin.lock_identity_link_lifecycle_target(resource.resource_id) is None: return AuthorizationDenialCode.RESOURCE_NOT_FOUND elif isinstance( resource, @@ -724,6 +719,8 @@ def _admin_resource_matches( ), ActionId.PROJECT_ROLE_GRANT_LIST: ProjectRoleGrantCollectionResourceContext, ActionId.PROJECT_ROLE_GRANT_READ: ProjectRoleGrantReadResourceContext, + ActionId.PROJECT_ROLE_GRANT_ISSUE: ProjectRoleGrantIssueResourceContext, + ActionId.PROJECT_ROLE_GRANT_REVOKE: ProjectRoleGrantRevokeResourceContext, }.get(action_id) if expected is None or not isinstance(resource, expected): return False @@ -801,10 +798,14 @@ def _denial( return AuthorizationDenialCode.RESOURCE_GUARD_DENIED if context.actor_kind is not ActorKind.HUMAN: return AuthorizationDenialCode.PERMISSION_NOT_GRANTED - if action.action_id in { - ActionId.ACTOR_PROFILE_READ_SELF, - ActionId.ACTOR_PROFILE_UPDATE_SELF, - } and not revalidated: + if ( + action.action_id + in { + ActionId.ACTOR_PROFILE_READ_SELF, + ActionId.ACTOR_PROFILE_UPDATE_SELF, + } + and not revalidated + ): return AuthorizationDenialCode.RESOURCE_GUARD_DENIED return None diff --git a/backend/app/modules/authorization/project_role_schemas.py b/backend/app/modules/authorization/project_role_schemas.py new file mode 100644 index 00000000..8aa213ff --- /dev/null +++ b/backend/app/modules/authorization/project_role_schemas.py @@ -0,0 +1,40 @@ +"""Strict public contracts for independent project-role mutations.""" + +from typing import Annotated, Literal +from uuid import UUID + +from pydantic import BaseModel, ConfigDict, Field + +from app.modules.authorization.schemas import ( + ProjectRole, + ProjectRoleQualificationEvidence, +) + +_STRICT = ConfigDict(extra="forbid", strict=True) + + +class ProjectRoleGrantIssueBody(BaseModel): + model_config = _STRICT + + target_actor_profile_id: UUID + role: ProjectRole + qualification: ProjectRoleQualificationEvidence + reason: Annotated[str, Field(min_length=1, max_length=500)] + + +class ProjectRoleGrantRevokeBody(BaseModel): + model_config = _STRICT + + reason: Annotated[str, Field(min_length=1, max_length=500)] + + +class ProjectRoleGrantMutationResponse(BaseModel): + model_config = ConfigDict(extra="forbid", frozen=True) + + id: UUID + qualification_snapshot_id: UUID + project_id: UUID + actor_profile_id: UUID + role: ProjectRole + status: Literal["active", "revoked"] + version: Literal[1, 2] diff --git a/backend/app/modules/authorization/project_role_service.py b/backend/app/modules/authorization/project_role_service.py new file mode 100644 index 00000000..4316dcbe --- /dev/null +++ b/backend/app/modules/authorization/project_role_service.py @@ -0,0 +1,271 @@ +"""Prepared, idempotent project-role grant mutations.""" + +from __future__ import annotations + +import hashlib +import json +from uuid import UUID, uuid4 + +from sqlalchemy.sql import func +from sqlalchemy.ext.asyncio import AsyncSession + +from app.modules.audit.schemas import ( + ActorReferenceKind, + AuthorityAuditEventInput, + AuthorityEventType, +) +from app.modules.authorization.catalogue import PermissionId +from app.modules.authorization.models import ProjectRoleGrant, ProjectRoleQualificationSnapshot +from app.modules.authorization.project_role_schemas import ProjectRoleGrantMutationResponse +from app.modules.authorization.repository import AdminAuthorizationRepository +from app.modules.authorization.runtime import AuthorizationDecision +from app.modules.authorization.schemas import ( + AuthorityClaimHandle, + AuthorityInvalidationContext, + AuthorityReservationResult, + AuthorityResourceType, + AuthorityResponseReference, + ProjectRoleGrantIssueRequest, + ProjectRoleGrantRevokeRequest, + ProjectRole, + derive_reason_digest, +) +from app.modules.authorization.service import AuthorityMutationService + + +class ProjectRoleGrantConflict(RuntimeError): + pass + + +def project_role_issue_lock_key(actor_id: UUID, project_id: UUID, role: str) -> int: + encoded = json.dumps( + [ + "workstream.project_role_grant.issue.v1", + str(actor_id), + str(project_id), + role, + ], + ensure_ascii=False, + separators=(",", ":"), + ).encode() + return int.from_bytes(hashlib.sha256(encoded).digest()[:8], "big", signed=True) + + +def _facts(grant: ProjectRoleGrant) -> dict[str, object]: + return { + "status": grant.status, + "role": grant.role, + "scope_type": "project", + "scope_id": grant.project_id, + "effective": grant.status == "active", + } + + +def _response(grant: ProjectRoleGrant) -> ProjectRoleGrantMutationResponse: + return ProjectRoleGrantMutationResponse( + id=grant.id, + qualification_snapshot_id=grant.qualification_snapshot_id, + project_id=UUID(grant.project_id), + actor_profile_id=UUID(grant.actor_profile_id), + role=ProjectRole(grant.role), + status=grant.status, + version=grant.version, + ) + + +class ProjectRoleGrantMutationService: + def __init__(self, session: AsyncSession) -> None: + self._session = session + self.repository = AdminAuthorizationRepository(session) + self._mutation = AuthorityMutationService(session) + + async def reserve( + self, + *, + key: UUID, + actor_profile_id: UUID, + request: ProjectRoleGrantIssueRequest | ProjectRoleGrantRevokeRequest, + ) -> AuthorityReservationResult: + return await self._mutation.reserve( + idempotency_key=key, + actor_ref_kind=ActorReferenceKind.ACTOR_PROFILE, + actor_ref=str(actor_profile_id), + request=request.model_dump(), + ) + + async def complete_issue( + self, + *, + claim: AuthorityClaimHandle, + request: ProjectRoleGrantIssueRequest, + decision: AuthorizationDecision, + actor_profile_id: UUID, + reason: str, + ) -> ProjectRoleGrantMutationResponse: + if ( + request.reason_digest != derive_reason_digest(reason) + or decision.matched_grant_id is None + ): + raise TypeError("project-role issue requires exact matched authority") + await self.repository.take_project_role_issue_lock( + project_role_issue_lock_key( + request.target_actor_id, request.project_id, request.role.value + ) + ) + if ( + await self.repository.find_active_project_role( + project_id=request.project_id, + actor_profile_id=request.target_actor_id, + role=request.role.value, + ) + is not None + ): + raise ProjectRoleGrantConflict("project_role_grant_exists") + evidence = request.qualification + snapshot = await self.repository.add_project_role_snapshot( + ProjectRoleQualificationSnapshot( + id=uuid4(), + project_id=str(request.project_id), + actor_profile_id=str(request.target_actor_id), + requested_role=request.role.value, + skills_snapshot=evidence.skills_snapshot.model_dump(mode="json"), + reputation_snapshot=evidence.reputation_snapshot.model_dump(mode="json"), + prior_project_work_refs=[str(value) for value in evidence.prior_project_work_refs], + external_expertise_refs=list(evidence.external_expertise_refs), + captured_by_actor_profile_id=str(actor_profile_id), + captured_by_admin_role_grant_id=decision.matched_grant_id, + ) + ) + grant = await self.repository.add_project_role_grant( + ProjectRoleGrant( + id=uuid4(), + project_id=str(request.project_id), + actor_profile_id=str(request.target_actor_id), + role=request.role.value, + status="active", + version=1, + grant_method="manual", + qualification_snapshot_id=snapshot.id, + granted_by_actor_profile_id=str(actor_profile_id), + granted_by_admin_role_grant_id=decision.matched_grant_id, + grant_reason=reason, + ) + ) + common = dict( + actor_ref_kind=ActorReferenceKind.ACTOR_PROFILE, + actor_ref=str(actor_profile_id), + request_id=decision.request_id, + correlation_id=decision.correlation_id, + target_actor_ref_kind=ActorReferenceKind.ACTOR_PROFILE, + target_actor_ref=str(request.target_actor_id), + matched_grant_id=str(decision.matched_grant_id), + permission_id=PermissionId.PROJECT_ROLE_GRANT_MANAGE, + project_id=str(request.project_id), + idempotency_reference=claim.record_id, + ) + await self._mutation.complete( + claim=claim, + request=request.model_dump(), + response=AuthorityResponseReference( + resource_type=AuthorityResourceType.PROJECT_ROLE_GRANT, + resource_id=grant.id, + version=1, + http_status=201, + ), + success=( + AuthorityAuditEventInput( + event_id=uuid4(), + event_type=AuthorityEventType.PROJECT_ROLE_QUALIFICATION_CAPTURED, + entity_type="qualification_snapshot", + entity_id=str(snapshot.id), + resource_type="qualification_snapshot", + resource_id=str(snapshot.id), + target_ref_kind="qualification_snapshot", + target_ref_id=str(snapshot.id), + reason="qualification_evidence_captured", + after_facts={"status": "captured"}, + **common, + ), + AuthorityAuditEventInput( + event_id=uuid4(), + event_type=AuthorityEventType.PROJECT_ROLE_GRANT_ISSUED, + entity_type="project_role_grant", + entity_id=str(grant.id), + resource_type="project_role_grant", + resource_id=str(grant.id), + target_ref_kind="project_role_grant", + target_ref_id=str(grant.id), + reason="authority_assignment", + after_facts=_facts(grant), + **common, + ), + ), + invalidation=None, + ) + return _response(grant) + + async def complete_revoke( + self, + *, + claim: AuthorityClaimHandle, + request: ProjectRoleGrantRevokeRequest, + decision: AuthorizationDecision, + actor_profile_id: UUID, + reason: str, + grant: ProjectRoleGrant, + ) -> ProjectRoleGrantMutationResponse: + if ( + request.reason_digest != derive_reason_digest(reason) + or decision.matched_grant_id is None + ): + raise TypeError("project-role revoke requires exact matched authority") + if grant.status != "active": + raise ProjectRoleGrantConflict("project_role_grant_already_revoked") + before = _facts(grant) + grant.status = "revoked" + grant.version = 2 + grant.revoked_by_actor_profile_id = str(actor_profile_id) + grant.revoked_by_admin_role_grant_id = decision.matched_grant_id + grant.revoked_reason = reason + grant.revoked_at = func.clock_timestamp() + await self._session.flush() + await self._session.refresh(grant) + await self._mutation.complete( + claim=claim, + request=request.model_dump(), + response=AuthorityResponseReference( + resource_type=AuthorityResourceType.PROJECT_ROLE_GRANT, + resource_id=grant.id, + version=2, + http_status=200, + ), + success=AuthorityAuditEventInput( + event_id=uuid4(), + event_type=AuthorityEventType.PROJECT_ROLE_GRANT_REVOKED, + entity_type="project_role_grant", + entity_id=str(grant.id), + actor_ref_kind=ActorReferenceKind.ACTOR_PROFILE, + actor_ref=str(actor_profile_id), + request_id=decision.request_id, + correlation_id=decision.correlation_id, + target_actor_ref_kind=ActorReferenceKind.ACTOR_PROFILE, + target_actor_ref=grant.actor_profile_id, + matched_grant_id=str(decision.matched_grant_id), + permission_id=PermissionId.PROJECT_ROLE_GRANT_MANAGE, + project_id=grant.project_id, + resource_type="project_role_grant", + resource_id=str(grant.id), + target_ref_kind="project_role_grant", + target_ref_id=str(grant.id), + reason="authority_revocation", + idempotency_reference=claim.record_id, + before_facts=before, + after_facts=_facts(grant), + ), + invalidation=AuthorityInvalidationContext( + event_id=uuid4(), + request_id=decision.request_id, + correlation_id=decision.correlation_id, + ), + ) + return _response(grant) diff --git a/backend/app/modules/authorization/repository.py b/backend/app/modules/authorization/repository.py index cb3ea99a..5e1619a6 100644 --- a/backend/app/modules/authorization/repository.py +++ b/backend/app/modules/authorization/repository.py @@ -5,7 +5,7 @@ from datetime import datetime from uuid import UUID, uuid4 -from sqlalchemy import and_, case, exists, func, or_, select, update +from sqlalchemy import and_, case, exists, func, or_, select, text, update from sqlalchemy.dialects.postgresql import insert from sqlalchemy.ext.asyncio import AsyncSession @@ -58,8 +58,7 @@ async def list_project_role_grants( select(ProjectRoleGrant, ProjectRoleQualificationSnapshot) .join( ProjectRoleQualificationSnapshot, - ProjectRoleQualificationSnapshot.id - == ProjectRoleGrant.qualification_snapshot_id, + ProjectRoleQualificationSnapshot.id == ProjectRoleGrant.qualification_snapshot_id, ) .where(ProjectRoleGrant.project_id == str(project_id)) ) @@ -108,6 +107,60 @@ async def get_project_role_grant( ).one_or_none() return tuple(row) if row is not None else None + async def lock_project_role_grant( + self, *, project_id: UUID, grant_id: UUID + ) -> tuple[ProjectRoleGrant, ProjectRoleQualificationSnapshot] | None: + """Lock one grant and snapshot only through the exact path project.""" + row = ( + await self._session.execute( + select(ProjectRoleGrant, ProjectRoleQualificationSnapshot) + .join( + ProjectRoleQualificationSnapshot, + ProjectRoleQualificationSnapshot.id + == ProjectRoleGrant.qualification_snapshot_id, + ) + .where( + ProjectRoleGrant.project_id == str(project_id), + ProjectRoleGrant.id == grant_id, + ) + .with_for_update(of=(ProjectRoleGrant, ProjectRoleQualificationSnapshot)) + .execution_options(populate_existing=True) + ) + ).one_or_none() + return tuple(row) if row is not None else None + + async def lock_project(self, project_id: UUID): + return await self._projects.get_project(str(project_id), for_update=True) + + async def take_project_role_issue_lock(self, key: int) -> None: + await self._session.execute(text("select pg_advisory_xact_lock(:key)"), {"key": key}) + + async def find_active_project_role( + self, *, project_id: UUID, actor_profile_id: UUID, role: str + ) -> ProjectRoleGrant | None: + return await self._session.scalar( + select(ProjectRoleGrant).where( + ProjectRoleGrant.project_id == str(project_id), + ProjectRoleGrant.actor_profile_id == str(actor_profile_id), + ProjectRoleGrant.role == role, + ProjectRoleGrant.status == "active", + ) + ) + + async def add_project_role_snapshot( + self, snapshot: ProjectRoleQualificationSnapshot + ) -> ProjectRoleQualificationSnapshot: + self._session.add(snapshot) + await self._session.flush() + await self._session.refresh(snapshot) + return snapshot + + async def add_project_role_grant(self, grant: ProjectRoleGrant) -> ProjectRoleGrant: + self._session.add(grant) + await self._session.flush() + await self._session.refresh(grant) + return grant + async def lock_control(self) -> AuthorityControl: """Lock the irreversible singleton before any administrative mutation.""" control = await self._session.scalar( @@ -370,10 +423,7 @@ async def has_effective_permission_any_scope( async def project_exists(self, project_id: UUID, *, for_update: bool = False) -> bool: """Resolve an exact project from Workstream-owned records.""" - return ( - await self._projects.get_project(str(project_id), for_update=for_update) - is not None - ) + return await self._projects.get_project(str(project_id), for_update=for_update) is not None async def get_grant( self, diff --git a/backend/app/modules/authorization/router.py b/backend/app/modules/authorization/router.py index 578ffa28..da813648 100644 --- a/backend/app/modules/authorization/router.py +++ b/backend/app/modules/authorization/router.py @@ -17,6 +17,7 @@ enforce_human_authorization_read, get_authorization_actor, get_authorization_service, + get_prepared_authorization_service, ) from app.core.api_controls import ApiErrorResponse, StructuredHTTPException from app.core.config import decode_pagination_cursor_hmac_secret @@ -50,6 +51,16 @@ ProjectRoleReadResourceNotFound, ProjectRoleReadService, ) +from app.modules.authorization.prepared import PreparedAuthorizationService +from app.modules.authorization.project_role_schemas import ( + ProjectRoleGrantIssueBody, + ProjectRoleGrantMutationResponse, + ProjectRoleGrantRevokeBody, +) +from app.modules.authorization.project_role_service import ( + ProjectRoleGrantConflict, + ProjectRoleGrantMutationService, +) from app.modules.authorization.repository import AdminAuthorizationRepository from app.modules.authorization.lifecycle_schemas import ( ActorLifecycleBody, @@ -75,6 +86,12 @@ AdminRoleGrantIssueResourceContext, AdminRoleGrantResourceContext, PermissionCatalogueResourceContext, + PreparedAuthorizationInput, + PreparedAuthorizationUnsupported, + PreparedAuthorityScope, + PreparedAuthorityScopeKind, + ProjectRoleGrantIssueResourceContext, + ProjectRoleGrantRevokeResourceContext, ServiceActorProvisionResourceContext, ) from app.modules.authorization.schemas import ( @@ -93,6 +110,8 @@ ProjectRole, ProjectRoleGrantListResponse, ProjectRoleGrantRead, + ProjectRoleGrantIssueRequest, + ProjectRoleGrantRevokeRequest, derive_reason_digest, derive_service_identity_digest, ) @@ -707,9 +726,7 @@ async def revoke_actor_identity_link( response_model=IdentityLinkLifecycleMutationResponse, dependencies=[Depends(enforce_admin_mutation_rate_limit)], responses=_LIFECYCLE_CONFLICT_RESPONSE, - openapi_extra={ - "x-workstream-action-id": ActionId.ACTOR_IDENTITY_LINK_REACTIVATE.value - }, + openapi_extra={"x-workstream-action-id": ActionId.ACTOR_IDENTITY_LINK_REACTIVATE.value}, ) async def reactivate_actor_identity_link( identity_link_id: UUID, @@ -1177,13 +1194,197 @@ async def _canonical_project(session: AsyncSession, project_id: UUID): return project +def _project_role_response(grant) -> ProjectRoleGrantMutationResponse: + return ProjectRoleGrantMutationResponse( + id=grant.id, + qualification_snapshot_id=grant.qualification_snapshot_id, + project_id=UUID(grant.project_id), + actor_profile_id=UUID(grant.actor_profile_id), + role=grant.role, + status=grant.status, + version=grant.version, + ) + + +@router.post( + "/projects/{project_id}/role-grants", + response_model=ProjectRoleGrantMutationResponse, + status_code=status.HTTP_201_CREATED, + dependencies=[Depends(enforce_admin_mutation_rate_limit)], + openapi_extra={"x-workstream-action-id": ActionId.PROJECT_ROLE_GRANT_ISSUE.value}, +) +async def issue_project_role_grant( + project_id: UUID, + payload: ProjectRoleGrantIssueBody, + idempotency_key: Annotated[UUID, Header(alias="Idempotency-Key")], + resolved: Annotated[ResolvedActor, Depends(get_authorization_actor)], + prepared: Annotated[PreparedAuthorizationService, Depends(get_prepared_authorization_service)], + session: Annotated[AsyncSession, Depends(get_db_session)], +) -> ProjectRoleGrantMutationResponse: + canonical = ProjectRoleGrantIssueRequest( + operation=AuthorityOperation.PROJECT_ROLE_GRANT_ISSUE, + project_id=project_id, + target_actor_id=payload.target_actor_profile_id, + role=payload.role, + qualification=payload.qualification, + reason_digest=derive_reason_digest(payload.reason), + ) + actor_id = UUID(resolved.profile.id) + service = ProjectRoleGrantMutationService(session) + reservation = await service.reserve( + key=idempotency_key, actor_profile_id=actor_id, request=canonical + ) + prepared_input = PreparedAuthorizationInput( + idempotency_key=idempotency_key, + request_value=canonical.model_dump(mode="json"), + ) + try: + handle = await prepared.prepare( + ActionId.PROJECT_ROLE_GRANT_ISSUE, + prepared_input, + PreparedAuthorityScope(kind=PreparedAuthorityScopeKind.PROJECT, project_id=project_id), + ) + except PreparedAuthorizationUnsupported as exc: + raise _project_role_resource_not_found() from exc + project = await service.repository.lock_project(project_id) + if project is None: + raise _project_role_resource_not_found() + target_eligible = ( + await service.repository.lock_eligible_human(payload.target_actor_profile_id) is not None + ) + decision = await prepared.consume( + handle, + ActionId.PROJECT_ROLE_GRANT_ISSUE, + prepared_input, + ProjectRoleGrantIssueResourceContext( + resource_type="project_role_grant_issue", + resource_id=project_id, + scope_project_id=project_id, + target_actor_profile_id=payload.target_actor_profile_id, + role=payload.role, + project_status=project.status, + target_eligible=target_eligible, + ), + ) + if reservation.outcome == "mismatch": + raise _domain_error(409, "idempotency_mismatch", "Idempotency key does not match") + if reservation.outcome == "replay": + row = await service.repository.lock_project_role_grant( + project_id=project_id, grant_id=reservation.response.resource_id + ) + if row is None or row[0].status != "active" or row[0].version != 1: + raise _domain_error( + 409, + "project_role_grant_replay_state_changed", + "Project role grant replay state changed", + ) + await session.commit() + return _project_role_response(row[0]) + try: + response = await service.complete_issue( + claim=reservation.claim, + request=canonical, + decision=decision, + actor_profile_id=actor_id, + reason=payload.reason, + ) + await session.commit() + return response + except ProjectRoleGrantConflict as exc: + await session.rollback() + raise _domain_error(409, str(exc), "Project role grant already exists") from exc + + +@router.post( + "/projects/{project_id}/role-grants/{grant_id}/revoke", + response_model=ProjectRoleGrantMutationResponse, + dependencies=[Depends(enforce_admin_mutation_rate_limit)], + openapi_extra={"x-workstream-action-id": ActionId.PROJECT_ROLE_GRANT_REVOKE.value}, +) +async def revoke_project_role_grant( + project_id: UUID, + grant_id: UUID, + payload: ProjectRoleGrantRevokeBody, + idempotency_key: Annotated[UUID, Header(alias="Idempotency-Key")], + resolved: Annotated[ResolvedActor, Depends(get_authorization_actor)], + prepared: Annotated[PreparedAuthorizationService, Depends(get_prepared_authorization_service)], + session: Annotated[AsyncSession, Depends(get_db_session)], +) -> ProjectRoleGrantMutationResponse: + canonical = ProjectRoleGrantRevokeRequest( + operation=AuthorityOperation.PROJECT_ROLE_GRANT_REVOKE, + project_id=project_id, + grant_id=grant_id, + reason_digest=derive_reason_digest(payload.reason), + ) + actor_id = UUID(resolved.profile.id) + service = ProjectRoleGrantMutationService(session) + reservation = await service.reserve( + key=idempotency_key, actor_profile_id=actor_id, request=canonical + ) + prepared_input = PreparedAuthorizationInput( + idempotency_key=idempotency_key, + request_value=canonical.model_dump(mode="json"), + ) + try: + handle = await prepared.prepare( + ActionId.PROJECT_ROLE_GRANT_REVOKE, + prepared_input, + PreparedAuthorityScope(kind=PreparedAuthorityScopeKind.PROJECT, project_id=project_id), + ) + except PreparedAuthorizationUnsupported as exc: + raise _project_role_resource_not_found() from exc + project = await service.repository.lock_project(project_id) + row = await service.repository.lock_project_role_grant(project_id=project_id, grant_id=grant_id) + if project is None or row is None: + raise _project_role_resource_not_found() + grant, _snapshot = row + decision = await prepared.consume( + handle, + ActionId.PROJECT_ROLE_GRANT_REVOKE, + prepared_input, + ProjectRoleGrantRevokeResourceContext( + resource_type="project_role_grant_revoke", + resource_id=grant_id, + scope_project_id=project_id, + actor_profile_id=UUID(grant.actor_profile_id), + role=ProjectRole(grant.role), + project_status=project.status, + status=grant.status, + version=grant.version, + ), + ) + if reservation.outcome == "mismatch": + raise _domain_error(409, "idempotency_mismatch", "Idempotency key does not match") + if reservation.outcome == "replay": + if grant.status != "revoked" or grant.version != 2: + raise _domain_error( + 409, + "project_role_grant_replay_state_changed", + "Project role grant replay state changed", + ) + await session.commit() + return _project_role_response(grant) + try: + response = await service.complete_revoke( + claim=reservation.claim, + request=canonical, + decision=decision, + actor_profile_id=actor_id, + reason=payload.reason, + grant=grant, + ) + await session.commit() + return response + except ProjectRoleGrantConflict as exc: + await session.rollback() + raise _domain_error(409, str(exc), "Project role grant is already revoked") from exc + + @router.get( "/projects/{project_id}/contributor-candidates", response_model=ContributorCandidateListResponse, dependencies=[Depends(enforce_human_authorization_read)], - openapi_extra={ - "x-workstream-action-id": ActionId.PROJECT_CONTRIBUTOR_CANDIDATE_LIST.value - }, + openapi_extra={"x-workstream-action-id": ActionId.PROJECT_CONTRIBUTOR_CANDIDATE_LIST.value}, ) async def list_project_contributor_candidates( project_id: UUID, diff --git a/backend/app/modules/authorization/runtime.py b/backend/app/modules/authorization/runtime.py index 51d179c5..e29de706 100644 --- a/backend/app/modules/authorization/runtime.py +++ b/backend/app/modules/authorization/runtime.py @@ -11,7 +11,7 @@ from app.core.hashing import canonical_json_hash from app.modules.actors.service_identities import ServiceIdentity from app.modules.authorization.catalogue import ActionId, PermissionId -from app.modules.authorization.schemas import AdminRole, AdminScope +from app.modules.authorization.schemas import AdminRole, AdminScope, ProjectRole _STRICT_FROZEN = ConfigDict(extra="forbid", frozen=True, strict=True) @@ -91,17 +91,21 @@ class PreparedAuthorityScope(BaseModel): def validate_selector(self): """Require exactly the identifier owned by the selected scope kind.""" valid = ( - self.kind is PreparedAuthorityScopeKind.ACTOR_SELF - and self.actor_profile_id is not None - and self.project_id is None - ) or ( - self.kind is PreparedAuthorityScopeKind.SYSTEM - and self.actor_profile_id is None - and self.project_id is None - ) or ( - self.kind is PreparedAuthorityScopeKind.PROJECT - and self.actor_profile_id is None - and self.project_id is not None + ( + self.kind is PreparedAuthorityScopeKind.ACTOR_SELF + and self.actor_profile_id is not None + and self.project_id is None + ) + or ( + self.kind is PreparedAuthorityScopeKind.SYSTEM + and self.actor_profile_id is None + and self.project_id is None + ) + or ( + self.kind is PreparedAuthorityScopeKind.PROJECT + and self.actor_profile_id is None + and self.project_id is not None + ) ) if not valid: raise ValueError("invalid prepared authority scope") @@ -329,6 +333,33 @@ class ProjectRoleGrantReadResourceContext(BaseModel): project_status: Literal["draft", "active", "paused", "archived"] +class ProjectRoleGrantIssueResourceContext(BaseModel): + """Server-composed facts for issuing one exact independent role.""" + + model_config = _STRICT_FROZEN + resource_type: Literal["project_role_grant_issue"] + resource_id: UUID + scope_project_id: UUID + target_actor_profile_id: UUID + role: ProjectRole + project_status: Literal["draft", "active", "paused", "archived"] + target_eligible: bool + + +class ProjectRoleGrantRevokeResourceContext(BaseModel): + """Locked exact-project grant facts used for revocation.""" + + model_config = _STRICT_FROZEN + resource_type: Literal["project_role_grant_revoke"] + resource_id: UUID + scope_project_id: UUID + actor_profile_id: UUID + role: ProjectRole + project_status: Literal["draft", "active", "paused", "archived"] + status: Literal["active", "revoked"] + version: Literal[1, 2] + + AuthorizationResourceContext = ( ActorSelfResourceContext | ActorProfileAdminReadResourceContext @@ -346,6 +377,8 @@ class ProjectRoleGrantReadResourceContext(BaseModel): | ProjectContributorCandidateCollectionResourceContext | ProjectRoleGrantCollectionResourceContext | ProjectRoleGrantReadResourceContext + | ProjectRoleGrantIssueResourceContext + | ProjectRoleGrantRevokeResourceContext ) diff --git a/backend/app/modules/authorization/schemas.py b/backend/app/modules/authorization/schemas.py index f7c017bc..7ab356e2 100644 --- a/backend/app/modules/authorization/schemas.py +++ b/backend/app/modules/authorization/schemas.py @@ -193,6 +193,23 @@ def reject_url_references(self) -> Self: return self +class ProjectRoleQualificationEvidence(BaseModel): + """Identifier-free qualification facts supplied for one role decision.""" + + model_config = _MODEL_CONFIG + + skills_snapshot: QualificationAvailabilitySnapshot + reputation_snapshot: QualificationAvailabilitySnapshot + prior_project_work_refs: Annotated[list[UUID], Field(max_length=20)] + external_expertise_refs: Annotated[list[ReferenceToken], Field(max_length=20)] + + @model_validator(mode="after") + def reject_url_references(self) -> Self: + if any("://" in reference for reference in self.external_expertise_refs): + raise ValueError("invalid qualification reference") + return self + + Digest = Annotated[str, Field(pattern=r"^sha256:[0-9a-f]{64}$")] @@ -284,6 +301,7 @@ class ProjectRoleGrantIssueRequest(CanonicalAuthorityRequest): project_id: UUID target_actor_id: UUID role: ProjectRole + qualification: ProjectRoleQualificationEvidence reason_digest: Digest @@ -481,7 +499,7 @@ class AuthorityCompletionResult(BaseModel): response: AuthorityResponseReference success_event_id: UUID - invalidation_event_id: UUID + invalidation_event_id: UUID | None = None class PendingAuthorityReservationError(RuntimeError): diff --git a/backend/app/modules/authorization/service.py b/backend/app/modules/authorization/service.py index 02e861a1..0d66fd6f 100644 --- a/backend/app/modules/authorization/service.py +++ b/backend/app/modules/authorization/service.py @@ -75,16 +75,17 @@ class _OperationEvidence: ), AuthorityOperation.PROJECT_ROLE_GRANT_ISSUE: _OperationEvidence( "project.role_grant.manage", - None, + ActionId.PROJECT_ROLE_GRANT_ISSUE, AuthorityResourceType.PROJECT_ROLE_GRANT, 201, ( + AuthorityEventType.PROJECT_ROLE_QUALIFICATION_CAPTURED, AuthorityEventType.PROJECT_ROLE_GRANT_ISSUED, ), ), AuthorityOperation.PROJECT_ROLE_GRANT_REVOKE: _OperationEvidence( "project.role_grant.manage", - None, + ActionId.PROJECT_ROLE_GRANT_REVOKE, AuthorityResourceType.PROJECT_ROLE_GRANT, 200, (AuthorityEventType.PROJECT_ROLE_GRANT_REVOKED,), @@ -174,37 +175,52 @@ async def complete( claim: AuthorityClaimHandle, request: object, response: AuthorityResponseReference, - success: AuthorityAuditEventInput, - invalidation: AuthorityInvalidationContext, + success: AuthorityAuditEventInput | tuple[AuthorityAuditEventInput, ...], + invalidation: AuthorityInvalidationContext | None, ) -> AuthorityCompletionResult: """Flush one mapped success/invalidation pair, then complete its claim.""" mutation = parse_authority_request(request) spec = _EVIDENCE[claim.operation] expected_digest = canonical_json_hash(mutation.model_dump(mode="json", exclude_none=True)) + successes = success if isinstance(success, tuple) else (success,) + if not successes: + raise TypeError("invalid authority completion input") + primary = successes[-1] allowed_events = spec.events valid = ( mutation.operation == claim.operation and expected_digest == claim.request_digest - and success.event_type in allowed_events - and success.actor_ref_kind == claim.actor_ref_kind - and success.actor_ref == claim.actor_ref - and success.idempotency_reference == claim.record_id - and success.permission_id == spec.permission - and success.entity_type == spec.resource_type.value - and success.entity_id == str(response.resource_id) - and success.resource_type == spec.resource_type.value - and success.resource_id == str(response.resource_id) - and success.target_ref_kind == spec.resource_type.value - and success.target_ref_id == str(response.resource_id) + and tuple(item.event_type for item in successes) == allowed_events + and all(item.actor_ref_kind == claim.actor_ref_kind for item in successes) + and all(item.actor_ref == claim.actor_ref for item in successes) + and all(item.idempotency_reference == claim.record_id for item in successes) + and all(item.permission_id == spec.permission for item in successes) + and primary.entity_type == spec.resource_type.value + and primary.entity_id == str(response.resource_id) + and primary.resource_type == spec.resource_type.value + and primary.resource_id == str(response.resource_id) + and primary.target_ref_kind == spec.resource_type.value + and primary.target_ref_id == str(response.resource_id) and response.resource_type == spec.resource_type and response.http_status == spec.http_status - and _request_matches_success(mutation, response, success) - and success.request_id == invalidation.request_id - and success.correlation_id == invalidation.correlation_id + and _request_matches_success(mutation, response, primary) + and all(item.request_id == primary.request_id for item in successes) + and all(item.correlation_id == primary.correlation_id for item in successes) + and ( + invalidation is None + or ( + primary.request_id == invalidation.request_id + and primary.correlation_id == invalidation.correlation_id + ) + ) ) if not valid: raise TypeError("invalid authority completion input") - stored_success = await self._audit.add_authority_event(success) + stored_success = None + for success_event in successes: + stored_success = await self._audit.add_authority_event(success_event) + if stored_success is None: + raise TypeError("invalid authority completion input") admin_mutation = isinstance( mutation, (AdminRoleGrantIssueRequest, AdminRoleGrantRevokeRequest), @@ -214,18 +230,22 @@ async def complete( (ActorIdentityLinkRevokeRequest, ActorIdentityLinkReactivateRequest), ) invalidation_target_kind = spec.resource_type.value - invalidation_target_ref = success.resource_id - invalidation_resource_type = success.resource_type - invalidation_resource_id = success.resource_id + invalidation_target_ref = primary.resource_id + invalidation_resource_type = primary.resource_type + invalidation_resource_id = primary.resource_id before_facts = {"effective": True} after_facts = {"effective": False} - if admin_mutation or identity_link_mutation: - if success.target_actor_ref is None: + if ( + admin_mutation + or identity_link_mutation + or isinstance(mutation, ProjectRoleGrantRevokeRequest) + ): + if primary.target_actor_ref is None: raise TypeError("projected authority success requires target actor") invalidation_target_kind = AuthorityResourceType.ACTOR_PROFILE.value - invalidation_target_ref = success.target_actor_ref + invalidation_target_ref = primary.target_actor_ref invalidation_resource_type = AuthorityResourceType.ACTOR_PROFILE.value - invalidation_resource_id = success.target_actor_ref + invalidation_resource_id = primary.target_actor_ref if isinstance( mutation, (AdminRoleGrantIssueRequest, ActorIdentityLinkReactivateRequest), @@ -235,33 +255,34 @@ async def complete( elif isinstance(mutation, ActorProfileReactivateRequest): before_facts = {"effective": False} after_facts = {"effective": True} - invalidation_input = AuthorityAuditEventInput( - event_id=invalidation.event_id, - event_type=AuthorityEventType.AUTHORITY_INVALIDATION_REQUESTED, - entity_type="authority_invalidation", - entity_id=str(invalidation.event_id), - actor_ref_kind=claim.actor_ref_kind, - actor_ref=claim.actor_ref, - request_id=invalidation.request_id, - correlation_id=invalidation.correlation_id, - permission_id=spec.permission, - project_id=success.project_id, - resource_type=invalidation_resource_type, - resource_id=invalidation_resource_id, - reason="authority_state_changed", - idempotency_reference=claim.record_id, - invalidation_cause_event_id=UUID(stored_success.id), - invalidation_target_kind=invalidation_target_kind, - invalidation_target_ref=invalidation_target_ref, - before_facts=before_facts, - after_facts=after_facts, - ) - await self._audit.add_authority_event(invalidation_input) + if invalidation is not None: + invalidation_input = AuthorityAuditEventInput( + event_id=invalidation.event_id, + event_type=AuthorityEventType.AUTHORITY_INVALIDATION_REQUESTED, + entity_type="authority_invalidation", + entity_id=str(invalidation.event_id), + actor_ref_kind=claim.actor_ref_kind, + actor_ref=claim.actor_ref, + request_id=invalidation.request_id, + correlation_id=invalidation.correlation_id, + permission_id=spec.permission, + project_id=primary.project_id, + resource_type=invalidation_resource_type, + resource_id=invalidation_resource_id, + reason="authority_state_changed", + idempotency_reference=claim.record_id, + invalidation_cause_event_id=UUID(stored_success.id), + invalidation_target_kind=invalidation_target_kind, + invalidation_target_ref=invalidation_target_ref, + before_facts=before_facts, + after_facts=after_facts, + ) + await self._audit.add_authority_event(invalidation_input) await self._repository.complete(claim, response) return AuthorityCompletionResult( response=response, - success_event_id=success.event_id, - invalidation_event_id=invalidation.event_id, + success_event_id=primary.event_id, + invalidation_event_id=invalidation.event_id if invalidation is not None else None, ) async def record_mismatch_denial( @@ -285,9 +306,7 @@ async def record_mismatch_denial( actor_ref=actor_ref, request_id=context.request_id, correlation_id=context.correlation_id, - matched_grant_id=( - str(context.matched_grant_id) if context.matched_grant_id else None - ), + matched_grant_id=(str(context.matched_grant_id) if context.matched_grant_id else None), permission_id=spec.permission, action_id=spec.action, project_id=_request_project_id(mutation), diff --git a/backend/scripts/api_contract_e2e.py b/backend/scripts/api_contract_e2e.py index 60dfc298..03beccdd 100644 --- a/backend/scripts/api_contract_e2e.py +++ b/backend/scripts/api_contract_e2e.py @@ -21,7 +21,7 @@ from alembic import command from alembic.config import Config from pydantic import SecretStr -from sqlalchemy import select, text +from sqlalchemy import text from app.db import session as db_session from app.modules.api_controls.service import ( @@ -29,11 +29,6 @@ RateControlService, rate_key_digest, ) -from app.modules.authorization.models import ( - AdminRoleGrant, - ProjectRoleGrant, - ProjectRoleQualificationSnapshot, -) from app.modules.projects.models import PostSubmitCheckerPolicy, ProjectSetupRun from app.modules.projects.post_submit_policy import ( build_project_post_submit_checker_spec, @@ -289,65 +284,6 @@ async def exercise_rate_control_contract(env: dict[str, str]) -> None: await session.commit() -async def seed_project_role_read_contract( - *, - project_id: str, - actor_profile_id: str, - manager_actor_profile_id: str, -) -> str: - """Seed one bounded read fixture until AUTH-10C owns public mutations.""" - snapshot_id = uuid4() - grant_id = uuid4() - async with db_session.get_session_factory()() as session: - admin_grant_id = await session.scalar( - select(AdminRoleGrant.id).where( - AdminRoleGrant.target_actor_profile_id == manager_actor_profile_id, - AdminRoleGrant.status == "active", - ) - ) - assert admin_grant_id is not None - session.add( - ProjectRoleQualificationSnapshot( - id=snapshot_id, - project_id=project_id, - actor_profile_id=actor_profile_id, - requested_role="submitter", - skills_snapshot={ - "availability": "available", - "reference_ids": ["skill:api-contract"], - "unavailable_reason": None, - }, - reputation_snapshot={ - "availability": "unavailable", - "reference_ids": [], - "unavailable_reason": "no_record", - }, - prior_project_work_refs=[], - external_expertise_refs=[], - captured_by_actor_profile_id=manager_actor_profile_id, - captured_by_admin_role_grant_id=admin_grant_id, - ) - ) - await session.flush() - session.add( - ProjectRoleGrant( - id=grant_id, - project_id=project_id, - actor_profile_id=actor_profile_id, - role="submitter", - status="active", - version=1, - grant_method="manual", - qualification_snapshot_id=snapshot_id, - granted_by_actor_profile_id=manager_actor_profile_id, - granted_by_admin_role_grant_id=admin_grant_id, - grant_reason="API contract read fixture", - ) - ) - await session.commit() - return str(grant_id) - - def start_api_server(port: int, env: dict[str, str]) -> tuple[subprocess.Popen, Path]: """Start a real uvicorn server process. @@ -1061,13 +997,12 @@ async def exercise_api_contract(base_url: str, env: dict[str, str]) -> None: ), } assert not any("authorization-context" in path for path in openapi["paths"]) - assert not any( - operation.get("x-workstream-action-id") - in {"project_role_grant.issue", "project_role_grant.revoke"} - for path_item in openapi["paths"].values() - for method, operation in path_item.items() - if method in {"get", "post", "put", "patch", "delete"} - ) + assert openapi["paths"]["/api/v1/projects/{project_id}/role-grants"]["post"][ + "x-workstream-action-id" + ] == "project_role_grant.issue" + assert openapi["paths"][ + "/api/v1/projects/{project_id}/role-grants/{grant_id}/revoke" + ]["post"]["x-workstream-action-id"] == "project_role_grant.revoke" await request_json(client, "GET", "/api/v1/auth/me", expected_status=401) await request_json(client, "GET", "/api/v1/auth/me", invalid_token, expected_status=401) await request_json(client, "GET", "/api/v1/auth/me", wrong_issuer_token, expected_status=401) @@ -1507,11 +1442,33 @@ async def exercise_api_contract(base_url: str, env: dict[str, str]) -> None: assert worker_profile["external_issuer"] == flow_issuer assert worker_profile["status"] == "active" assert set(worker_profile["skill_tags"]) == {"stem", "proofs"} - role_grant_id = await seed_project_role_read_contract( - project_id=project["id"], - actor_profile_id=canonical_actor["actor_profile_id"], - manager_actor_profile_id=manager_profile["actor_profile_id"], + role_issue_key = str(uuid4()) + role_issue_body = { + "target_actor_profile_id": canonical_actor["actor_profile_id"], + "role": "submitter", + "qualification": { + "skills_snapshot": { + "availability": "available", + "reference_ids": ["skill:api-contract"], + "unavailable_reason": None, + }, + "reputation_snapshot": { + "availability": "unavailable", + "reference_ids": [], + "unavailable_reason": "no_record", + }, + "prior_project_work_refs": [], + "external_expertise_refs": [], + }, + "reason": "Real API independent submitter authority", + } + issued_role = await client.post( + f"/api/v1/projects/{project['id']}/role-grants", + headers=auth_headers(manager_token) | {"Idempotency-Key": role_issue_key}, + json=role_issue_body, ) + assert issued_role.status_code == 201, issued_role.text + role_grant_id = issued_role.json()["id"] candidates = await request_json( client, "GET", @@ -1553,6 +1510,45 @@ async def exercise_api_contract(base_url: str, env: dict[str, str]) -> None: assert grant["revoked_by_actor_profile_id"] is None assert grant["revoked_at"] is None assert grant["revoked_reason"] is None + role_revoke_key = str(uuid4()) + role_revoke_body = {"reason": "Real API submitter authority removal"} + revoked_role = await client.post( + f"/api/v1/projects/{project['id']}/role-grants/{role_grant_id}/revoke", + headers=auth_headers(manager_token) | {"Idempotency-Key": role_revoke_key}, + json=role_revoke_body, + ) + assert revoked_role.status_code == 200, revoked_role.text + assert revoked_role.json()["status"] == "revoked" + historical_role = await request_json( + client, + "GET", + f"/api/v1/projects/{project['id']}/role-grants/{role_grant_id}", + project_reader_token, + ) + assert historical_role["status"] == "revoked" + revoke_replay = await client.post( + f"/api/v1/projects/{project['id']}/role-grants/{role_grant_id}/revoke", + headers=auth_headers(manager_token) | {"Idempotency-Key": role_revoke_key}, + json=role_revoke_body, + ) + assert revoke_replay.status_code == 200, revoke_replay.text + second_revoke = await client.post( + f"/api/v1/projects/{project['id']}/role-grants/{role_grant_id}/revoke", + headers=auth_headers(manager_token) | {"Idempotency-Key": str(uuid4())}, + json=role_revoke_body, + ) + assert second_revoke.status_code == 409, second_revoke.text + assert second_revoke.json()["error"]["code"] == "project_role_grant_already_revoked" + issue_after_revoke = await client.post( + f"/api/v1/projects/{project['id']}/role-grants", + headers=auth_headers(manager_token) | {"Idempotency-Key": role_issue_key}, + json=role_issue_body, + ) + assert issue_after_revoke.status_code == 409, issue_after_revoke.text + assert ( + issue_after_revoke.json()["error"]["code"] + == "project_role_grant_replay_state_changed" + ) await request_json(client, "GET", f"/api/v1/tasks/{task['id']}", worker_token) ready_work_context = await request_json( client, diff --git a/backend/tests/test_api_controls.py b/backend/tests/test_api_controls.py index 9f8121fe..8ba829c5 100644 --- a/backend/tests/test_api_controls.py +++ b/backend/tests/test_api_controls.py @@ -439,13 +439,13 @@ def test_openapi_documents_request_error_and_response_context() -> None: for method, operation in path_item.items() if method in methods and operation.get("security") ) - assert len(route_inventory) == 74 + assert len(route_inventory) == 76 assert sha256("\n".join(route_inventory).encode()).hexdigest() == ( - "f0076e4145d8fe68365912a6e5bc047dece3ae2ac1e69619018b5119a9710c09" + "c8f9852035446ea59b0e929b1bd8c8cfc7df5bf838ceb544c04e899f90169318" ) - assert len(protected_inventory) == 72 + assert len(protected_inventory) == 74 assert sha256("\n".join(protected_inventory).encode()).hexdigest() == ( - "b05ebbca27a538958af6c122403ccf3997ab08326943150f6c763ce9c321eec5" + "9278d0183ffb87947ee4857e0325483ba7bf07feac0c38a88432840b10c2b0c3" ) assert set(schema["paths"]["/health"]["get"]["responses"]) == {"200", "400", "500"} assert {"401", "403", "503"} <= set( @@ -502,6 +502,10 @@ def test_openapi_documents_request_error_and_response_context() -> None: "GET /api/v1/projects/{project_id}/role-grants/{grant_id}": ( "project_role_grant.read" ), + "POST /api/v1/projects/{project_id}/role-grants": "project_role_grant.issue", + "POST /api/v1/projects/{project_id}/role-grants/{grant_id}/revoke": ( + "project_role_grant.revoke" + ), } project_read_shapes = { "/api/v1/projects/{project_id}/contributor-candidates": ( @@ -517,13 +521,16 @@ def test_openapi_documents_request_error_and_response_context() -> None: } assert schema["components"]["schemas"][schema_name]["additionalProperties"] is False assert not any("authorization-context" in path for path in schema["paths"]) - assert not any( - operation.get("x-workstream-action-id") - in {"project_role_grant.issue", "project_role_grant.revoke"} - for path_item in schema["paths"].values() - for method, operation in path_item.items() - if method in methods - ) + for path in ( + "/api/v1/projects/{project_id}/role-grants", + "/api/v1/projects/{project_id}/role-grants/{grant_id}/revoke", + ): + operation = schema["paths"][path]["post"] + assert operation["responses"]["201" if path.endswith("role-grants") else "200"][ + "content" + ]["application/json"]["schema"] == { + "$ref": "#/components/schemas/ProjectRoleGrantMutationResponse" + } for path, schema_name in ( ("/api/v1/actors/{actor_profile_id}", "ActorProfileAdminResponse"), ( diff --git a/backend/tests/test_authorization.py b/backend/tests/test_authorization.py index e33acb22..6f0d49a0 100644 --- a/backend/tests/test_authorization.py +++ b/backend/tests/test_authorization.py @@ -126,6 +126,7 @@ parse_authority_request, ) from app.modules.authorization.service import AuthorityMutationService +from app.modules.authorization.project_role_service import project_role_issue_lock_key from app.modules.authorization.kernel import AuthorizationService from app.modules.authorization.prepared import ( PreparedAuthorizationHandle, @@ -183,6 +184,38 @@ DIGEST = "sha256:" + "a" * 64 +def _project_role_qualification() -> dict[str, object]: + return { + "skills_snapshot": { + "availability": "available", + "reference_ids": ["skill:opaque"], + "unavailable_reason": None, + }, + "reputation_snapshot": { + "availability": "unavailable", + "reference_ids": [], + "unavailable_reason": "no_record", + }, + "prior_project_work_refs": [], + "external_expertise_refs": [], + } + + +def test_project_role_issue_advisory_key_contract_is_frozen_and_separated() -> None: + actor = UUID("00000000-0000-0000-0000-000000000001") + project = UUID("00000000-0000-0000-0000-000000000002") + assert project_role_issue_lock_key(actor, project, "submitter") == -7801444014257588548 + values = { + project_role_issue_lock_key(actor, project, role) + for role in ("submitter", "reviewer", "adjudicator") + } + assert len(values) == 3 + assert all(-(2**63) <= value < 2**63 for value in values) + assert project_role_issue_lock_key(actor, project, "submitter") != project_role_issue_lock_key( + project, actor, "submitter" + ) + + def test_authorization_read_cursor_round_trip_and_query_binding() -> None: codec = AuthorizationReadCursorCodec(bytes(range(32))) project_id = UUID("00000000-0000-4000-8000-000000000001") @@ -251,12 +284,18 @@ def _signed_cursor_envelope(envelope: dict, *, secret: bytes = bytes(range(32))) sort_keys=True, separators=(",", ":"), ).encode() - envelope["s"] = base64.urlsafe_b64encode( - hmac.new(secret, payload_bytes, hashlib.sha256).digest() - ).decode().rstrip("=") - return base64.urlsafe_b64encode( - json.dumps(envelope, sort_keys=True, separators=(",", ":")).encode() - ).decode().rstrip("=") + envelope["s"] = ( + base64.urlsafe_b64encode(hmac.new(secret, payload_bytes, hashlib.sha256).digest()) + .decode() + .rstrip("=") + ) + return ( + base64.urlsafe_b64encode( + json.dumps(envelope, sort_keys=True, separators=(",", ":")).encode() + ) + .decode() + .rstrip("=") + ) @pytest.mark.parametrize( @@ -323,19 +362,23 @@ def test_authorization_read_cursor_rejects_oversized_decoded_value() -> None: def test_authorization_read_cursor_rejects_missing_envelope_and_duplicate_keys() -> None: - missing_signature = base64.urlsafe_b64encode( - json.dumps( - { - "p": { - "v": 1, - "q": DIGEST, - "ts": "2026-07-22T00:00:00.000000Z", - "id": "00000000-0000-4000-8000-000000000002", - } - }, - separators=(",", ":"), - ).encode() - ).decode().rstrip("=") + missing_signature = ( + base64.urlsafe_b64encode( + json.dumps( + { + "p": { + "v": 1, + "q": DIGEST, + "ts": "2026-07-22T00:00:00.000000Z", + "id": "00000000-0000-4000-8000-000000000002", + } + }, + separators=(",", ":"), + ).encode() + ) + .decode() + .rstrip("=") + ) duplicate = base64.urlsafe_b64encode(b'{"p":{},"p":{},"s":"x"}').decode().rstrip("=") codec = AuthorizationReadCursorCodec(bytes(range(32))) for value in (missing_signature, duplicate): @@ -572,12 +615,11 @@ async def forbidden_project_lookup(*_args, **_kwargs): response = await client.get(f"/api/v1/projects/{uuid4()}/role-grants") assert response.status_code == 404 - assert response.json()["error"]["code"] == ( - "project_authorization_resource_not_found" - ) + assert response.json()["error"]["code"] == ("project_authorization_resource_not_found") assert consumptions == 1 assert lookups == 0 + ART_CUSTODY_EXPECTATIONS = { "artifact.binding.read": ( "artifact.binding.read", @@ -903,6 +945,8 @@ def test_closed_permission_and_action_catalogue_is_exact_and_non_executable() -> ActionId.PROJECT_CONTRIBUTOR_CANDIDATE_LIST, ActionId.PROJECT_ROLE_GRANT_LIST, ActionId.PROJECT_ROLE_GRANT_READ, + ActionId.PROJECT_ROLE_GRANT_ISSUE, + ActionId.PROJECT_ROLE_GRANT_REVOKE, } assert { definition.action_id.value: ( @@ -977,14 +1021,20 @@ def test_closed_permission_and_action_catalogue_is_exact_and_non_executable() -> "review.revision_obligation.close", "review.lifecycle.activation.manage", }.isdisjoint(action.value for action in ACTION_IDS) - assert sum( - definition.availability is ActionAvailability.ACTIVE - for definition in ACTION_DEFINITIONS - ) == 20 - assert sum( - definition.availability is ActionAvailability.PLANNED - for definition in ACTION_DEFINITIONS - ) == 50 + assert ( + sum( + definition.availability is ActionAvailability.ACTIVE + for definition in ACTION_DEFINITIONS + ) + == 22 + ) + assert ( + sum( + definition.availability is ActionAvailability.PLANNED + for definition in ACTION_DEFINITIONS + ) + == 48 + ) assert resolve_executable_action(ActionId.ACTOR_PROFILE_READ_SELF).permission_id is ( PermissionId.ACTOR_PROFILE_READ_SELF ) @@ -1113,7 +1163,9 @@ def test_art_custody_documentation_matches_the_independent_catalogue_fixture() - assert "The ART transfer adds no migration" in operations assert "does not grant Operator" in operations assert "verification retry remains independently gated" in operations - assert "74 PermissionIds, 70 ActionIds, 20 active actions, and\n50 planned actions" in operations + assert ( + "74 PermissionIds, 70 ActionIds, 22 active actions, and\n48 planned actions" in operations + ) def test_rev_custody_documentation_matches_the_independent_catalogue_fixture() -> None: @@ -2089,18 +2141,15 @@ def test_project_role_read_denials_share_one_public_concealment( def test_project_role_read_permissions_separate_manager_and_auditor_authority() -> None: - assert PermissionId.PROJECT_ROLE_GRANT_READ in ADMIN_ROLE_PERMISSIONS[ - AdminRole.PROJECT_MANAGER - ] - assert PermissionId.PROJECT_ROLE_GRANT_MANAGE in ADMIN_ROLE_PERMISSIONS[ - AdminRole.PROJECT_MANAGER - ] - assert PermissionId.PROJECT_ROLE_GRANT_READ in ADMIN_ROLE_PERMISSIONS[ - AdminRole.AUDIT_AUTHORITY - ] - assert PermissionId.PROJECT_ROLE_GRANT_MANAGE not in ADMIN_ROLE_PERMISSIONS[ - AdminRole.AUDIT_AUTHORITY - ] + assert PermissionId.PROJECT_ROLE_GRANT_READ in ADMIN_ROLE_PERMISSIONS[AdminRole.PROJECT_MANAGER] + assert ( + PermissionId.PROJECT_ROLE_GRANT_MANAGE in ADMIN_ROLE_PERMISSIONS[AdminRole.PROJECT_MANAGER] + ) + assert PermissionId.PROJECT_ROLE_GRANT_READ in ADMIN_ROLE_PERMISSIONS[AdminRole.AUDIT_AUTHORITY] + assert ( + PermissionId.PROJECT_ROLE_GRANT_MANAGE + not in ADMIN_ROLE_PERMISSIONS[AdminRole.AUDIT_AUTHORITY] + ) @pytest.mark.asyncio @@ -6767,6 +6816,7 @@ def _operation_success( } elif isinstance(request, ProjectRoleGrantRevokeRequest): project_id = request.project_id + target_actor = response.resource_id before_facts = { "status": "active", "role": "submitter", @@ -6988,6 +7038,7 @@ async def test_completion_rejects_resource_and_project_not_bound_to_request( project_id=uuid4(), target_actor_id=uuid4(), role=ProjectRole.SUBMITTER, + qualification=_project_role_qualification(), reason_digest=DIGEST, ) wrong_project = project_request.model_copy(update={"project_id": uuid4()}) @@ -7575,6 +7626,7 @@ def test_every_operation_has_one_strict_canonical_request_variant() -> None: project_id=project, target_actor_id=actor, role=ProjectRole.ADJUDICATOR, + qualification=_project_role_qualification(), reason_digest=DIGEST, ), ProjectRoleGrantRevokeRequest( @@ -7735,6 +7787,7 @@ async def test_project_role_and_all_operation_mappings_commit_one_linked_pair( project_id=project, target_actor_id=actor, role=ProjectRole.SUBMITTER, + qualification=_project_role_qualification(), reason_digest=DIGEST, ), ProjectRoleGrantRevokeRequest( @@ -7839,16 +7892,36 @@ async def test_project_role_and_all_operation_mappings_commit_one_linked_pair( if request.operation in admin_operations: assert success.matched_grant_id == str(admin_authorizer_grant_id) assert success.matched_grant_id != str(response.resource_id) + success_input = success + invalidation = AuthorityInvalidationContext( + event_id=uuid4(), + request_id=success.request_id, + correlation_id=success.correlation_id, + ) + if request.operation is AuthorityOperation.PROJECT_ROLE_GRANT_ISSUE: + qualification_id = uuid4() + qualification_event = success.model_copy( + update={ + "event_id": uuid4(), + "event_type": AuthorityEventType.PROJECT_ROLE_QUALIFICATION_CAPTURED, + "entity_type": "qualification_snapshot", + "entity_id": str(qualification_id), + "resource_type": "qualification_snapshot", + "resource_id": str(qualification_id), + "target_ref_kind": "qualification_snapshot", + "target_ref_id": str(qualification_id), + "reason": "qualification_evidence_captured", + "after_facts": {"status": "captured"}, + } + ) + success_input = (qualification_event, success) + invalidation = None completed = await service.complete( claim=claim, request=request.model_dump(), response=response, - success=success, - invalidation=AuthorityInvalidationContext( - event_id=uuid4(), - request_id=success.request_id, - correlation_id=success.correlation_id, - ), + success=success_input, + invalidation=invalidation, ) assert completed.response == response expected_pairs[claim.record_id] = success @@ -7871,6 +7944,15 @@ async def test_project_role_and_all_operation_mappings_commit_one_linked_pair( pair = [row for row in rows if row.idempotency_reference == record_id] assert len(pair) == 2 success_row = next(row for row in pair if row.event_type == success.event_type.value) + if success.event_type is AuthorityEventType.PROJECT_ROLE_GRANT_ISSUED: + qualification_row = next( + row + for row in pair + if row.event_type + == AuthorityEventType.PROJECT_ROLE_QUALIFICATION_CAPTURED.value + ) + assert qualification_row.invalidation_cause_event_id is None + continue invalidation_row = next( row for row in pair if row.event_type == "AuthorityInvalidationRequested" ) @@ -7886,6 +7968,7 @@ async def test_project_role_and_all_operation_mappings_commit_one_linked_pair( AuthorityEventType.ADMIN_ROLE_GRANT_REVOKED, AuthorityEventType.ACTOR_IDENTITY_LINK_REVOKED, AuthorityEventType.ACTOR_IDENTITY_LINK_REACTIVATED, + AuthorityEventType.PROJECT_ROLE_GRANT_REVOKED, } else success.resource_id ) @@ -7915,6 +7998,7 @@ async def test_issue_mismatch_derives_project_and_omits_nonexistent_grant_resour project_id=project, target_actor_id=uuid4(), role=ProjectRole.REVIEWER, + qualification=_project_role_qualification(), reason_digest=DIGEST, ) context = AuthorityMismatchContext(event_id=uuid4(), request_id=uuid4(), correlation_id=uuid4()) diff --git a/docs/operations_authorization_service.md b/docs/operations_authorization_service.md index 705cbb76..f922ed82 100644 --- a/docs/operations_authorization_service.md +++ b/docs/operations_authorization_service.md @@ -703,8 +703,8 @@ 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, 20 active actions, and -50 planned actions after AUTH-10B2 activates the three project-role read rows. +Catalogue totals are 74 PermissionIds, 70 ActionIds, 22 active actions, and +48 planned actions after AUTH-10C activates the two project-role mutation rows. 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 diff --git a/docs/spec_authorization_service.md b/docs/spec_authorization_service.md index cf566d2b..43360098 100644 --- a/docs/spec_authorization_service.md +++ b/docs/spec_authorization_service.md @@ -238,9 +238,10 @@ approved Operator recovery identifiers, 21 artifact identifiers, and `review.queue.override` are the exact 25 post-`0020` permissions. AUTH-07A adds their matching typed/SQL audit parity without making them executable. -The closed action registry contains 70 rows after AUTH-10B2: 20 active actions -and 50 planned rows. AUTH-10A added five project-role read/manage rows, -owned by AUTH-10B and AUTH-10C. AUTH-08 adds seven active administrative definition, +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; +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, 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 From 2fea7b6c62f1c8e55340508d5114e5ae1a4263cf Mon Sep 17 00:00:00 2001 From: Abiorh001 Date: Thu, 23 Jul 2026 16:12:06 +0100 Subject: [PATCH 03/10] fix(auth): close AUTH-10C review gaps --- backend/app/modules/authorization/kernel.py | 25 +++- backend/app/modules/authorization/prepared.py | 11 ++ .../authorization/project_role_schemas.py | 22 +++- .../authorization/project_role_service.py | 85 +++++++++++--- .../app/modules/authorization/repository.py | 51 +++++++++ backend/app/modules/authorization/router.py | 107 ++++++++++++++++-- backend/app/modules/authorization/runtime.py | 14 +++ backend/app/modules/authorization/schemas.py | 12 +- backend/app/modules/authorization/service.py | 26 ++++- backend/scripts/api_contract_e2e.py | 63 ++++++++++- backend/tests/test_authorization.py | 28 +++++ docs/operations_authorization_service.md | 10 +- docs/spec_authorization_service.md | 12 +- 13 files changed, 417 insertions(+), 49 deletions(-) diff --git a/backend/app/modules/authorization/kernel.py b/backend/app/modules/authorization/kernel.py index 900ce8e2..9fbcf9ad 100644 --- a/backend/app/modules/authorization/kernel.py +++ b/backend/app/modules/authorization/kernel.py @@ -106,6 +106,8 @@ ActionId.ACTOR_PROFILE_DEACTIVATE, ActionId.ACTOR_IDENTITY_LINK_REVOKE, ActionId.ACTOR_IDENTITY_LINK_REACTIVATE, + ActionId.PROJECT_ROLE_GRANT_ISSUE, + ActionId.PROJECT_ROLE_GRANT_REVOKE, } ) @@ -275,10 +277,27 @@ async def _prepare_prelocked( }: raise PreparedAuthorizationUnsupported(AuthorizationDenialCode.SCOPE_NOT_AUTHORIZED) await self._admin.lock_control() - locked = await self._admin.lock_request_actor( - context.identity_link_id, context.actor_profile_id - ) + if action_id is ActionId.PROJECT_ROLE_GRANT_ISSUE: + if scope.target_actor_profile_id is None or scope.role is None: + raise PreparedAuthorizationUnsupported( + AuthorizationDenialCode.RESOURCE_GUARD_DENIED + ) + locked, _target_eligible = ( + await self._admin.lock_project_role_issue_principals( + caller_actor_profile_id=context.actor_profile_id, + caller_identity_link_id=context.identity_link_id, + target_actor_profile_id=scope.target_actor_profile_id, + ) + ) + else: + locked = await self._admin.lock_request_actor( + context.identity_link_id, context.actor_profile_id + ) context = self._locked_human_context(locked, context) + if action_id is ActionId.PROJECT_ROLE_GRANT_REVOKE and scope.grant_id is None: + raise PreparedAuthorizationUnsupported( + AuthorizationDenialCode.RESOURCE_GUARD_DENIED + ) grant = await self._admin.find_effective_grant( context.actor_profile_id, action.permission_id, diff --git a/backend/app/modules/authorization/prepared.py b/backend/app/modules/authorization/prepared.py index 268df94f..c553e46d 100644 --- a/backend/app/modules/authorization/prepared.py +++ b/backend/app/modules/authorization/prepared.py @@ -222,6 +222,14 @@ def _scope_from_resource( action_id, resource ): project_id = AuthorizationService._resource_project_id(resource) + target_actor_profile_id = None + role = None + grant_id = None + if action_id is ActionId.PROJECT_ROLE_GRANT_ISSUE: + target_actor_profile_id = resource.target_actor_profile_id + role = resource.role + elif action_id is ActionId.PROJECT_ROLE_GRANT_REVOKE: + grant_id = resource.resource_id return PreparedAuthorityScope( kind=( PreparedAuthorityScopeKind.PROJECT @@ -229,5 +237,8 @@ def _scope_from_resource( else PreparedAuthorityScopeKind.SYSTEM ), project_id=project_id, + target_actor_profile_id=target_actor_profile_id, + role=role, + grant_id=grant_id, ) raise PreparedAuthorizationHandleInvalid("invalid prepared authorization handle") diff --git a/backend/app/modules/authorization/project_role_schemas.py b/backend/app/modules/authorization/project_role_schemas.py index 8aa213ff..53e24da7 100644 --- a/backend/app/modules/authorization/project_role_schemas.py +++ b/backend/app/modules/authorization/project_role_schemas.py @@ -1,16 +1,30 @@ """Strict public contracts for independent project-role mutations.""" from typing import Annotated, Literal +import unicodedata from uuid import UUID -from pydantic import BaseModel, ConfigDict, Field +from pydantic import AfterValidator, BaseModel, ConfigDict, Field from app.modules.authorization.schemas import ( ProjectRole, ProjectRoleQualificationEvidence, ) -_STRICT = ConfigDict(extra="forbid", strict=True) +_STRICT = ConfigDict(extra="forbid") + + +def _reason(value: str) -> str: + if ( + value != value.strip() + or not 1 <= len(value.encode("utf-8")) <= 500 + or any(unicodedata.category(character).startswith("C") for character in value) + ): + raise ValueError("invalid project-role mutation reason") + return value + + +Reason = Annotated[str, Field(min_length=1), AfterValidator(_reason)] class ProjectRoleGrantIssueBody(BaseModel): @@ -19,13 +33,13 @@ class ProjectRoleGrantIssueBody(BaseModel): target_actor_profile_id: UUID role: ProjectRole qualification: ProjectRoleQualificationEvidence - reason: Annotated[str, Field(min_length=1, max_length=500)] + reason: Reason class ProjectRoleGrantRevokeBody(BaseModel): model_config = _STRICT - reason: Annotated[str, Field(min_length=1, max_length=500)] + reason: Reason class ProjectRoleGrantMutationResponse(BaseModel): diff --git a/backend/app/modules/authorization/project_role_service.py b/backend/app/modules/authorization/project_role_service.py index 4316dcbe..ded39466 100644 --- a/backend/app/modules/authorization/project_role_service.py +++ b/backend/app/modules/authorization/project_role_service.py @@ -14,7 +14,8 @@ AuthorityAuditEventInput, AuthorityEventType, ) -from app.modules.authorization.catalogue import PermissionId +from app.modules.audit.service import AuditService +from app.modules.authorization.catalogue import ActionId, PermissionId from app.modules.authorization.models import ProjectRoleGrant, ProjectRoleQualificationSnapshot from app.modules.authorization.project_role_schemas import ProjectRoleGrantMutationResponse from app.modules.authorization.repository import AdminAuthorizationRepository @@ -22,6 +23,7 @@ from app.modules.authorization.schemas import ( AuthorityClaimHandle, AuthorityInvalidationContext, + AuthorityMismatchContext, AuthorityReservationResult, AuthorityResourceType, AuthorityResponseReference, @@ -34,7 +36,10 @@ class ProjectRoleGrantConflict(RuntimeError): - pass + def __init__(self, code: str, grant_id: UUID) -> None: + self.code = code + self.grant_id = grant_id + super().__init__(code) def project_role_issue_lock_key(actor_id: UUID, project_id: UUID, role: str) -> int: @@ -78,6 +83,7 @@ def __init__(self, session: AsyncSession) -> None: self._session = session self.repository = AdminAuthorizationRepository(session) self._mutation = AuthorityMutationService(session) + self._audit = AuditService(session) async def reserve( self, @@ -93,6 +99,58 @@ async def reserve( request=request.model_dump(), ) + async def record_mismatch( + self, + *, + actor_profile_id: UUID, + request: ProjectRoleGrantIssueRequest | ProjectRoleGrantRevokeRequest, + decision: AuthorizationDecision, + ) -> None: + await self._mutation.record_mismatch_denial( + actor_ref_kind=ActorReferenceKind.ACTOR_PROFILE, + actor_ref=str(actor_profile_id), + request=request.model_dump(), + context=AuthorityMismatchContext( + event_id=uuid4(), + request_id=decision.request_id, + correlation_id=decision.correlation_id, + matched_grant_id=decision.matched_grant_id, + ), + ) + + async def record_conflict( + self, + *, + actor_profile_id: UUID, + project_id: UUID, + grant_id: UUID, + decision: AuthorizationDecision, + code: str, + action_id: ActionId, + ) -> None: + event_id = uuid4() + await self._audit.add_authority_event( + AuthorityAuditEventInput( + event_id=event_id, + event_type=AuthorityEventType.SENSITIVE_AUTHORIZATION_DENIED, + entity_type="authorization_decision", + entity_id=str(event_id), + actor_ref_kind=ActorReferenceKind.ACTOR_PROFILE, + actor_ref=str(actor_profile_id), + request_id=decision.request_id, + correlation_id=decision.correlation_id, + matched_grant_id=str(decision.matched_grant_id), + permission_id=PermissionId.PROJECT_ROLE_GRANT_MANAGE, + action_id=action_id, + project_id=str(project_id), + resource_type="project_role_grant", + resource_id=str(grant_id), + reason="authorization_evaluation", + denial_code=code, + after_facts={"allowed": False}, + ) + ) + async def complete_issue( self, *, @@ -107,20 +165,13 @@ async def complete_issue( or decision.matched_grant_id is None ): raise TypeError("project-role issue requires exact matched authority") - await self.repository.take_project_role_issue_lock( - project_role_issue_lock_key( - request.target_actor_id, request.project_id, request.role.value - ) + duplicate = await self.repository.find_active_project_role( + project_id=request.project_id, + actor_profile_id=request.target_actor_id, + role=request.role.value, ) - if ( - await self.repository.find_active_project_role( - project_id=request.project_id, - actor_profile_id=request.target_actor_id, - role=request.role.value, - ) - is not None - ): - raise ProjectRoleGrantConflict("project_role_grant_exists") + if duplicate is not None: + raise ProjectRoleGrantConflict("project_role_grant_exists", duplicate.id) evidence = request.qualification snapshot = await self.repository.add_project_role_snapshot( ProjectRoleQualificationSnapshot( @@ -220,7 +271,7 @@ async def complete_revoke( ): raise TypeError("project-role revoke requires exact matched authority") if grant.status != "active": - raise ProjectRoleGrantConflict("project_role_grant_already_revoked") + raise ProjectRoleGrantConflict("project_role_grant_already_revoked", grant.id) before = _facts(grant) grant.status = "revoked" grant.version = 2 @@ -266,6 +317,8 @@ async def complete_revoke( event_id=uuid4(), request_id=decision.request_id, correlation_id=decision.correlation_id, + target_ref_kind=AuthorityResourceType.PROJECT_ROLE_GRANT, + target_ref_id=grant.id, ), ) return _response(grant) diff --git a/backend/app/modules/authorization/repository.py b/backend/app/modules/authorization/repository.py index 5e1619a6..ac7359f8 100644 --- a/backend/app/modules/authorization/repository.py +++ b/backend/app/modules/authorization/repository.py @@ -194,6 +194,57 @@ async def lock_request_actor( return None return link, profile + async def lock_project_role_issue_principals( + self, + *, + caller_actor_profile_id: UUID, + caller_identity_link_id: UUID, + target_actor_profile_id: UUID, + ) -> tuple[tuple[ActorIdentityLink, ActorProfile] | None, bool]: + """Lock caller and issue target in one lexical profile/link order.""" + caller = None + target_eligible = False + for actor_profile_id in sorted( + {caller_actor_profile_id, target_actor_profile_id}, key=str + ): + profile = await self._session.scalar( + select(ActorProfile) + .where(ActorProfile.id == str(actor_profile_id)) + .with_for_update() + .execution_options(populate_existing=True) + ) + if profile is None: + continue + if actor_profile_id == caller_actor_profile_id: + link = await self._session.scalar( + select(ActorIdentityLink) + .where( + ActorIdentityLink.id == str(caller_identity_link_id), + ActorIdentityLink.actor_profile_id == str(actor_profile_id), + ) + .with_for_update() + .execution_options(populate_existing=True) + ) + caller = (link, profile) if link is not None else None + else: + link = await self._session.scalar( + select(ActorIdentityLink) + .where( + ActorIdentityLink.actor_profile_id == str(actor_profile_id), + ActorIdentityLink.status == "active", + ) + .order_by(ActorIdentityLink.id) + .limit(1) + .with_for_update() + .execution_options(populate_existing=True) + ) + target_eligible = ( + profile.actor_kind == "human" + and profile.status == "active" + and link is not None + ) + return caller, target_eligible + async def lock_actor_self( self, actor_profile_id: UUID, diff --git a/backend/app/modules/authorization/router.py b/backend/app/modules/authorization/router.py index da813648..96350910 100644 --- a/backend/app/modules/authorization/router.py +++ b/backend/app/modules/authorization/router.py @@ -60,6 +60,7 @@ from app.modules.authorization.project_role_service import ( ProjectRoleGrantConflict, ProjectRoleGrantMutationService, + project_role_issue_lock_key, ) from app.modules.authorization.repository import AdminAuthorizationRepository from app.modules.authorization.lifecycle_schemas import ( @@ -1242,7 +1243,12 @@ async def issue_project_role_grant( handle = await prepared.prepare( ActionId.PROJECT_ROLE_GRANT_ISSUE, prepared_input, - PreparedAuthorityScope(kind=PreparedAuthorityScopeKind.PROJECT, project_id=project_id), + PreparedAuthorityScope( + kind=PreparedAuthorityScopeKind.PROJECT, + project_id=project_id, + target_actor_profile_id=payload.target_actor_profile_id, + role=payload.role, + ), ) except PreparedAuthorizationUnsupported as exc: raise _project_role_resource_not_found() from exc @@ -1252,6 +1258,13 @@ async def issue_project_role_grant( target_eligible = ( await service.repository.lock_eligible_human(payload.target_actor_profile_id) is not None ) + await service.repository.take_project_role_issue_lock( + project_role_issue_lock_key( + payload.target_actor_profile_id, + project_id, + payload.role.value, + ) + ) decision = await prepared.consume( handle, ActionId.PROJECT_ROLE_GRANT_ISSUE, @@ -1267,18 +1280,42 @@ async def issue_project_role_grant( ), ) if reservation.outcome == "mismatch": + await session.rollback() + await _database_call( + session, + service.record_mismatch( + actor_profile_id=actor_id, + request=canonical, + decision=decision, + ), + ) + await _commit_or_unavailable(session) raise _domain_error(409, "idempotency_mismatch", "Idempotency key does not match") if reservation.outcome == "replay": row = await service.repository.lock_project_role_grant( project_id=project_id, grant_id=reservation.response.resource_id ) - if row is None or row[0].status != "active" or row[0].version != 1: + if ( + reservation.response.resource_type.value != "project_role_grant" + or reservation.response.http_status != 201 + or reservation.response.version != 1 + or row is None + or row[0].status != "active" + or row[0].version != 1 + or row[0].project_id != str(project_id) + or row[0].actor_profile_id != str(payload.target_actor_profile_id) + or row[0].role != payload.role.value + or row[1].id != row[0].qualification_snapshot_id + or row[1].project_id != row[0].project_id + or row[1].actor_profile_id != row[0].actor_profile_id + or row[1].requested_role != row[0].role + ): raise _domain_error( 409, "project_role_grant_replay_state_changed", "Project role grant replay state changed", ) - await session.commit() + await _commit_or_unavailable(session) return _project_role_response(row[0]) try: response = await service.complete_issue( @@ -1288,11 +1325,23 @@ async def issue_project_role_grant( actor_profile_id=actor_id, reason=payload.reason, ) - await session.commit() + await _commit_or_unavailable(session) return response except ProjectRoleGrantConflict as exc: await session.rollback() - raise _domain_error(409, str(exc), "Project role grant already exists") from exc + await _database_call( + session, + service.record_conflict( + actor_profile_id=actor_id, + project_id=project_id, + grant_id=exc.grant_id, + decision=decision, + code=exc.code, + action_id=ActionId.PROJECT_ROLE_GRANT_ISSUE, + ), + ) + await _commit_or_unavailable(session) + raise _domain_error(409, exc.code, "Project role grant already exists") from exc @router.post( @@ -1329,7 +1378,11 @@ async def revoke_project_role_grant( handle = await prepared.prepare( ActionId.PROJECT_ROLE_GRANT_REVOKE, prepared_input, - PreparedAuthorityScope(kind=PreparedAuthorityScopeKind.PROJECT, project_id=project_id), + PreparedAuthorityScope( + kind=PreparedAuthorityScopeKind.PROJECT, + project_id=project_id, + grant_id=grant_id, + ), ) except PreparedAuthorizationUnsupported as exc: raise _project_role_resource_not_found() from exc @@ -1354,15 +1407,37 @@ async def revoke_project_role_grant( ), ) if reservation.outcome == "mismatch": + await session.rollback() + await _database_call( + session, + service.record_mismatch( + actor_profile_id=actor_id, + request=canonical, + decision=decision, + ), + ) + await _commit_or_unavailable(session) raise _domain_error(409, "idempotency_mismatch", "Idempotency key does not match") if reservation.outcome == "replay": - if grant.status != "revoked" or grant.version != 2: + if ( + reservation.response.resource_type.value != "project_role_grant" + or reservation.response.resource_id != grant_id + or reservation.response.http_status != 200 + or reservation.response.version != 2 + or grant.status != "revoked" + or grant.version != 2 + or grant.project_id != str(project_id) + or _snapshot.id != grant.qualification_snapshot_id + or _snapshot.project_id != grant.project_id + or _snapshot.actor_profile_id != grant.actor_profile_id + or _snapshot.requested_role != grant.role + ): raise _domain_error( 409, "project_role_grant_replay_state_changed", "Project role grant replay state changed", ) - await session.commit() + await _commit_or_unavailable(session) return _project_role_response(grant) try: response = await service.complete_revoke( @@ -1373,11 +1448,23 @@ async def revoke_project_role_grant( reason=payload.reason, grant=grant, ) - await session.commit() + await _commit_or_unavailable(session) return response except ProjectRoleGrantConflict as exc: await session.rollback() - raise _domain_error(409, str(exc), "Project role grant is already revoked") from exc + await _database_call( + session, + service.record_conflict( + actor_profile_id=actor_id, + project_id=project_id, + grant_id=exc.grant_id, + decision=decision, + code=exc.code, + action_id=ActionId.PROJECT_ROLE_GRANT_REVOKE, + ), + ) + await _commit_or_unavailable(session) + raise _domain_error(409, exc.code, "Project role grant is already revoked") from exc @router.get( diff --git a/backend/app/modules/authorization/runtime.py b/backend/app/modules/authorization/runtime.py index e29de706..e6ef8728 100644 --- a/backend/app/modules/authorization/runtime.py +++ b/backend/app/modules/authorization/runtime.py @@ -86,6 +86,9 @@ class PreparedAuthorityScope(BaseModel): kind: PreparedAuthorityScopeKind actor_profile_id: UUID | None = None project_id: UUID | None = None + target_actor_profile_id: UUID | None = None + role: ProjectRole | None = None + grant_id: UUID | None = None @model_validator(mode="after") def validate_selector(self): @@ -95,16 +98,27 @@ def validate_selector(self): self.kind is PreparedAuthorityScopeKind.ACTOR_SELF and self.actor_profile_id is not None and self.project_id is None + and self.target_actor_profile_id is None + and self.role is None + and self.grant_id is None ) or ( self.kind is PreparedAuthorityScopeKind.SYSTEM and self.actor_profile_id is None and self.project_id is None + and self.target_actor_profile_id is None + and self.role is None + and self.grant_id is None ) or ( self.kind is PreparedAuthorityScopeKind.PROJECT and self.actor_profile_id is None and self.project_id is not None + and not ( + self.target_actor_profile_id is not None + and self.grant_id is not None + ) + and ((self.target_actor_profile_id is None) == (self.role is None)) ) ) if not valid: diff --git a/backend/app/modules/authorization/schemas.py b/backend/app/modules/authorization/schemas.py index 7ab356e2..53b015bc 100644 --- a/backend/app/modules/authorization/schemas.py +++ b/backend/app/modules/authorization/schemas.py @@ -153,7 +153,7 @@ class QualificationUnavailableReason(StrEnum): class QualificationAvailabilitySnapshot(BaseModel): """Privacy-bounded availability and opaque-reference evidence.""" - model_config = _MODEL_CONFIG + model_config = ConfigDict(extra="forbid", frozen=True, hide_input_in_errors=True) availability: QualificationAvailability reference_ids: Annotated[list[ReferenceToken], Field(max_length=20)] @@ -196,7 +196,7 @@ def reject_url_references(self) -> Self: class ProjectRoleQualificationEvidence(BaseModel): """Identifier-free qualification facts supplied for one role decision.""" - model_config = _MODEL_CONFIG + model_config = ConfigDict(extra="forbid", frozen=True, hide_input_in_errors=True) skills_snapshot: QualificationAvailabilitySnapshot reputation_snapshot: QualificationAvailabilitySnapshot @@ -479,6 +479,14 @@ class AuthorityInvalidationContext(BaseModel): event_id: UUID request_id: UUID correlation_id: UUID + target_ref_kind: AuthorityResourceType | None = None + target_ref_id: UUID | None = None + + @model_validator(mode="after") + def validate_target(self): + if (self.target_ref_kind is None) != (self.target_ref_id is None): + raise ValueError("invalid authority invalidation target") + return self class AuthorityMismatchContext(BaseModel): diff --git a/backend/app/modules/authorization/service.py b/backend/app/modules/authorization/service.py index 0d66fd6f..19a3809d 100644 --- a/backend/app/modules/authorization/service.py +++ b/backend/app/modules/authorization/service.py @@ -206,6 +206,8 @@ async def complete( and _request_matches_success(mutation, response, primary) and all(item.request_id == primary.request_id for item in successes) and all(item.correlation_id == primary.correlation_id for item in successes) + and (invalidation is None) + == isinstance(mutation, ProjectRoleGrantIssueRequest) and ( invalidation is None or ( @@ -268,12 +270,22 @@ async def complete( permission_id=spec.permission, project_id=primary.project_id, resource_type=invalidation_resource_type, - resource_id=invalidation_resource_id, + resource_id=invalidation_resource_id, + target_ref_kind=( + invalidation.target_ref_kind.value + if invalidation.target_ref_kind is not None + else None + ), + target_ref_id=( + str(invalidation.target_ref_id) + if invalidation.target_ref_id is not None + else None + ), reason="authority_state_changed", idempotency_reference=claim.record_id, invalidation_cause_event_id=UUID(stored_success.id), invalidation_target_kind=invalidation_target_kind, - invalidation_target_ref=invalidation_target_ref, + invalidation_target_ref=invalidation_target_ref, before_facts=before_facts, after_facts=after_facts, ) @@ -373,13 +385,19 @@ def _request_matches_success( success.target_actor_ref_kind == ActorReferenceKind.ACTOR_PROFILE and success.target_actor_ref == str(request.target_actor_id) and success.project_id == str(request.project_id) - and success.matched_grant_id is None + and success.matched_grant_id is not None and facts.get("role") == request.role.value and facts.get("scope_type") == "project" and facts.get("scope_id") == str(request.project_id) ) if isinstance(request, ProjectRoleGrantRevokeRequest): - return resource_id == request.grant_id and success.project_id == str(request.project_id) + return ( + resource_id == request.grant_id + and success.project_id == str(request.project_id) + and success.matched_grant_id is not None + and success.target_actor_ref_kind == ActorReferenceKind.ACTOR_PROFILE + and success.target_actor_ref is not None + ) if isinstance( request, (ActorProfileSuspendRequest, ActorProfileReactivateRequest, ActorProfileDeactivateRequest), diff --git a/backend/scripts/api_contract_e2e.py b/backend/scripts/api_contract_e2e.py index 03beccdd..09df3f19 100644 --- a/backend/scripts/api_contract_e2e.py +++ b/backend/scripts/api_contract_e2e.py @@ -1464,7 +1464,7 @@ async def exercise_api_contract(base_url: str, env: dict[str, str]) -> None: } issued_role = await client.post( f"/api/v1/projects/{project['id']}/role-grants", - headers=auth_headers(manager_token) | {"Idempotency-Key": role_issue_key}, + headers=auth_headers(project_reader_token) | {"Idempotency-Key": role_issue_key}, json=role_issue_body, ) assert issued_role.status_code == 201, issued_role.text @@ -1510,15 +1510,27 @@ async def exercise_api_contract(base_url: str, env: dict[str, str]) -> None: assert grant["revoked_by_actor_profile_id"] is None assert grant["revoked_at"] is None assert grant["revoked_reason"] is None + suspended_role_target = await client.post( + f"/api/v1/actors/{canonical_actor['actor_profile_id']}/suspend", + headers=auth_headers(manager_token) | {"Idempotency-Key": str(uuid4())}, + json={"reason": "Prove suspended targets cannot retain irremovable authority"}, + ) + assert suspended_role_target.status_code == 200, suspended_role_target.text role_revoke_key = str(uuid4()) role_revoke_body = {"reason": "Real API submitter authority removal"} revoked_role = await client.post( f"/api/v1/projects/{project['id']}/role-grants/{role_grant_id}/revoke", - headers=auth_headers(manager_token) | {"Idempotency-Key": role_revoke_key}, + headers=auth_headers(project_reader_token) | {"Idempotency-Key": role_revoke_key}, json=role_revoke_body, ) assert revoked_role.status_code == 200, revoked_role.text assert revoked_role.json()["status"] == "revoked" + reactivated_role_target = await client.post( + f"/api/v1/actors/{canonical_actor['actor_profile_id']}/reactivate", + headers=auth_headers(manager_token) | {"Idempotency-Key": str(uuid4())}, + json={"reason": "Restore API contract contributor after revocation proof"}, + ) + assert reactivated_role_target.status_code == 200, reactivated_role_target.text historical_role = await request_json( client, "GET", @@ -1528,20 +1540,20 @@ async def exercise_api_contract(base_url: str, env: dict[str, str]) -> None: assert historical_role["status"] == "revoked" revoke_replay = await client.post( f"/api/v1/projects/{project['id']}/role-grants/{role_grant_id}/revoke", - headers=auth_headers(manager_token) | {"Idempotency-Key": role_revoke_key}, + headers=auth_headers(project_reader_token) | {"Idempotency-Key": role_revoke_key}, json=role_revoke_body, ) assert revoke_replay.status_code == 200, revoke_replay.text second_revoke = await client.post( f"/api/v1/projects/{project['id']}/role-grants/{role_grant_id}/revoke", - headers=auth_headers(manager_token) | {"Idempotency-Key": str(uuid4())}, + headers=auth_headers(project_reader_token) | {"Idempotency-Key": str(uuid4())}, json=role_revoke_body, ) assert second_revoke.status_code == 409, second_revoke.text assert second_revoke.json()["error"]["code"] == "project_role_grant_already_revoked" issue_after_revoke = await client.post( f"/api/v1/projects/{project['id']}/role-grants", - headers=auth_headers(manager_token) | {"Idempotency-Key": role_issue_key}, + headers=auth_headers(project_reader_token) | {"Idempotency-Key": role_issue_key}, json=role_issue_body, ) assert issue_after_revoke.status_code == 409, issue_after_revoke.text @@ -1549,6 +1561,47 @@ async def exercise_api_contract(base_url: str, env: dict[str, str]) -> None: issue_after_revoke.json()["error"]["code"] == "project_role_grant_replay_state_changed" ) + link_case_body = role_issue_body | { + "role": "reviewer", + "reason": "Prove link lifecycle cannot make authority irremovable", + } + link_case_issue = await client.post( + f"/api/v1/projects/{project['id']}/role-grants", + headers=auth_headers(project_reader_token) | {"Idempotency-Key": str(uuid4())}, + json=link_case_body, + ) + assert link_case_issue.status_code == 201, link_case_issue.text + revoked_target_link = await client.post( + f"/api/v1/actor-identity-links/{worker_admin_link['id']}/revoke", + headers=auth_headers(manager_token) | {"Idempotency-Key": str(uuid4())}, + json={"reason": "Prove revoked-link grant removal"}, + ) + assert revoked_target_link.status_code == 200, revoked_target_link.text + link_case_revoke = await client.post( + f"/api/v1/projects/{project['id']}/role-grants/" + f"{link_case_issue.json()['id']}/revoke", + headers=auth_headers(project_reader_token) | {"Idempotency-Key": str(uuid4())}, + json={"reason": "Remove reviewer authority after target link revocation"}, + ) + assert link_case_revoke.status_code == 200, link_case_revoke.text + repaired_target_link = await client.post( + f"/api/v1/actor-identity-links/{worker_admin_link['id']}/reactivate", + headers=auth_headers(manager_token) | {"Idempotency-Key": str(uuid4())}, + json={"reason": "Restore API contract contributor identity link"}, + ) + assert repaired_target_link.status_code == 200, repaired_target_link.text + removed_project_manager = await client.post( + f"/api/v1/admin-role-grants/{project_manager_grant.json()['resource_id']}/revoke", + headers=auth_headers(manager_token) | {"Idempotency-Key": str(uuid4())}, + json={"reason": "Prove mutation replay reauthorizes current project authority"}, + ) + assert removed_project_manager.status_code == 200, removed_project_manager.text + concealed_replay = await client.post( + f"/api/v1/projects/{project['id']}/role-grants/{role_grant_id}/revoke", + headers=auth_headers(project_reader_token) | {"Idempotency-Key": role_revoke_key}, + json=role_revoke_body, + ) + assert concealed_replay.status_code == 404, concealed_replay.text await request_json(client, "GET", f"/api/v1/tasks/{task['id']}", worker_token) ready_work_context = await request_json( client, diff --git a/backend/tests/test_authorization.py b/backend/tests/test_authorization.py index 6f0d49a0..eb7db1a5 100644 --- a/backend/tests/test_authorization.py +++ b/backend/tests/test_authorization.py @@ -127,6 +127,10 @@ ) from app.modules.authorization.service import AuthorityMutationService from app.modules.authorization.project_role_service import project_role_issue_lock_key +from app.modules.authorization.project_role_schemas import ( + ProjectRoleGrantIssueBody, + ProjectRoleGrantRevokeBody, +) from app.modules.authorization.kernel import AuthorizationService from app.modules.authorization.prepared import ( PreparedAuthorizationHandle, @@ -216,6 +220,22 @@ def test_project_role_issue_advisory_key_contract_is_frozen_and_separated() -> N ) +def test_project_role_public_reason_and_qualification_contract_is_strict() -> None: + payload = { + "target_actor_profile_id": str(uuid4()), + "role": "submitter", + "qualification": _project_role_qualification(), + "reason": "Bounded authority assignment", + } + assert ProjectRoleGrantIssueBody.model_validate(payload).role is ProjectRole.SUBMITTER + assert ProjectRoleGrantRevokeBody.model_validate({"reason": "Bounded removal"}).reason == ( + "Bounded removal" + ) + for reason in (" padded", "padded ", "control\x00", "é" * 251): + with pytest.raises(ValidationError): + ProjectRoleGrantIssueBody.model_validate(payload | {"reason": reason}) + + def test_authorization_read_cursor_round_trip_and_query_binding() -> None: codec = AuthorizationReadCursorCodec(bytes(range(32))) project_id = UUID("00000000-0000-4000-8000-000000000001") @@ -6805,8 +6825,11 @@ def _operation_success( } after_facts = before_facts | {"status": "revoked", "effective": False} elif isinstance(request, ProjectRoleGrantIssueRequest): + if admin_authorizer_grant_id is None: + raise AssertionError("project role issue proof requires authorizing manager grant") project_id = request.project_id target_actor = request.target_actor_id + matched_grant = admin_authorizer_grant_id after_facts = { "status": "active", "role": request.role.value, @@ -6815,8 +6838,11 @@ def _operation_success( "effective": True, } elif isinstance(request, ProjectRoleGrantRevokeRequest): + if admin_authorizer_grant_id is None: + raise AssertionError("project role revoke proof requires authorizing manager grant") project_id = request.project_id target_actor = response.resource_id + matched_grant = admin_authorizer_grant_id before_facts = { "status": "active", "role": "submitter", @@ -7842,6 +7868,8 @@ async def test_project_role_and_all_operation_mappings_commit_one_linked_pair( admin_operations = { AuthorityOperation.ADMIN_ROLE_GRANT_ISSUE, AuthorityOperation.ADMIN_ROLE_GRANT_REVOKE, + AuthorityOperation.PROJECT_ROLE_GRANT_ISSUE, + AuthorityOperation.PROJECT_ROLE_GRANT_REVOKE, } expected_pairs = {} async with authorization_factory() as session: diff --git a/docs/operations_authorization_service.md b/docs/operations_authorization_service.md index f922ed82..66861abc 100644 --- a/docs/operations_authorization_service.md +++ b/docs/operations_authorization_service.md @@ -1129,8 +1129,14 @@ log the key, a cursor, or distinctions hidden by the shared 404. Authorization read exhaustion returns 429 with `Retry-After`, and unavailable rate/evidence persistence returns retryable 503 before private row lookup. -This read activation adds no migration and changes no PREP, project-role grant -issue/revoke, or other mutation behavior. +AUTH-10C adds no migration. Its project-role mutations use the admin-mutation +rate control, exact-project PREP, UUID idempotency keys, stable replay/conflict +responses, and fail-closed retryable 503 handling for persistence failures. +Operators may retry the same key and body after a 503; they must not invent a +new key until the committed state is known. A revoked or suspended target may +still have an active grant revoked. Issue writes snapshot-captured then +grant-issued evidence without invalidation; revoke writes grant-revoked then a +linked authority invalidation targeting the affected actor and exact grant. ## Authority Audit Custody diff --git a/docs/spec_authorization_service.md b/docs/spec_authorization_service.md index 43360098..658c314d 100644 --- a/docs/spec_authorization_service.md +++ b/docs/spec_authorization_service.md @@ -809,9 +809,15 @@ issue/revoke APIs, and local bootstrap command. AUTH-09C activates exact actor and identity-link reads for effective system Access Administrator or Audit Authority grants. AUTH-09D-A activates the three profile lifecycle routes for effective system Access Administrators only. AUTH-09D-B activates exact -identity-link revoke and reactivate for the same authority; the project-role -route family remains planned. Project-scoped -`GET /api/v1/actors/me/authorization-context` begins in AUTH-10 after +identity-link revoke and reactivate for the same authority. AUTH-10B activates +the concealed project-role reads, and AUTH-10C activates exact-role issue and +revoke mutations for covered Project Managers. Those mutations use +`Idempotency-Key`, transaction-bound PREP, immutable qualification snapshots, +canonical replay validation, and one route-owned commit. Issue requires a +different active human with an active identity link; revoke remains available +after target suspension or identity-link revocation so authority cannot become +irremovable. Project-scoped +`GET /api/v1/actors/me/authorization-context` begins in AUTH-11 after exact-project grant and canonical project capability composition exists. `WS-AUTH-001-CONTRIBUTOR-FOUNDATION` adds no permission or authorization path. From 2fe3806b25b8c7e168f4dce30d005e93511cb949 Mon Sep 17 00:00:00 2001 From: Abiorh001 Date: Thu, 23 Jul 2026 16:21:04 +0100 Subject: [PATCH 04/10] fix(auth): harden mutation ordering and invalidation proof --- ...TH-001-10C-project-role-grant-mutations.md | 1 + backend/app/modules/audit/schemas.py | 39 +++- .../authorization/project_role_service.py | 6 + backend/app/modules/authorization/router.py | 184 +++++++++++------- backend/app/modules/authorization/runtime.py | 1 + backend/app/modules/authorization/schemas.py | 13 ++ backend/app/modules/authorization/service.py | 15 ++ backend/scripts/api_contract_e2e.py | 1 + backend/tests/test_authorization.py | 147 +++++++++++++- 9 files changed, 335 insertions(+), 72 deletions(-) diff --git a/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/chunks/WS-AUTH-001-10C-project-role-grant-mutations.md b/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/chunks/WS-AUTH-001-10C-project-role-grant-mutations.md index 69c0fd7e..035e9d5a 100644 --- a/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/chunks/WS-AUTH-001-10C-project-role-grant-mutations.md +++ b/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/chunks/WS-AUTH-001-10C-project-role-grant-mutations.md @@ -35,6 +35,7 @@ P1 ```text backend/app/modules/actors/repository.py backend/app/modules/authorization/** +backend/app/modules/audit/schemas.py backend/app/modules/projects/repository.py backend/app/api/router.py backend/tests/test_actors.py diff --git a/backend/app/modules/audit/schemas.py b/backend/app/modules/audit/schemas.py index 06dedf64..e15af310 100644 --- a/backend/app/modules/audit/schemas.py +++ b/backend/app/modules/audit/schemas.py @@ -60,6 +60,9 @@ "provisioning_method": frozenset({"automatic_first_access", "manual_service_provisioning"}), "role": _ADMIN_ROLES | _PROJECT_ROLES, "scope_type": frozenset({"system", "project"}), + "future_obligation": frozenset( + {"auth13_assignment", "rev_reviewer_obligation", "none"} + ), } @@ -201,9 +204,30 @@ def _event_facts_valid(event: AuthorityEventType, before: dict[str, object] | No if event in exact: return (before, after) == exact[event] if event == AuthorityEventType.AUTHORITY_INVALIDATION_REQUESTED: - return (before, after) in ( + if (before, after) in ( ({"effective": True}, {"effective": False}), ({"effective": False}, {"effective": True}), + ): + return True + if before is None or after is None: + return False + expected_obligation = { + "submitter": "auth13_assignment", + "reviewer": "rev_reviewer_obligation", + "adjudicator": "none", + }.get(before.get("role")) + keys = {"effective", "role", "scope_type", "scope_id", "future_obligation"} + return ( + set(before) == keys + and set(after) == keys + and before["effective"] is True + and after["effective"] is False + and before["scope_type"] == after["scope_type"] == "project" + and before["role"] == after["role"] + and before["scope_id"] == after["scope_id"] + and before["future_obligation"] + == after["future_obligation"] + == expected_obligation ) if event == AuthorityEventType.ACTOR_IDENTITY_LINKED: return before is None and after in ({"status": "active", "subject_kind": "human"}, {"status": "active", "subject_kind": "service"}) @@ -404,7 +428,18 @@ def validate_shape(self) -> Self: if restoration else ({"effective": True}, {"effective": False}) ) - if (before, after) != expected_direction: + projected_project_role = ( + self.permission_id is PermissionId.PROJECT_ROLE_GRANT_MANAGE + and before is not None + and after is not None + and before.get("effective") is True + and after.get("effective") is False + and before.get("role") in _PROJECT_ROLES + and before.get("role") == after.get("role") + and before.get("future_obligation") + == after.get("future_obligation") + ) + if (before, after) != expected_direction and not projected_project_role: raise ValueError("invalid authority invalidation direction") if self.permission_id in { PermissionId.ADMIN_ROLE_GRANT, diff --git a/backend/app/modules/authorization/project_role_service.py b/backend/app/modules/authorization/project_role_service.py index ded39466..87e7af9d 100644 --- a/backend/app/modules/authorization/project_role_service.py +++ b/backend/app/modules/authorization/project_role_service.py @@ -319,6 +319,12 @@ async def complete_revoke( correlation_id=decision.correlation_id, target_ref_kind=AuthorityResourceType.PROJECT_ROLE_GRANT, target_ref_id=grant.id, + project_role=ProjectRole(grant.role), + future_obligation={ + ProjectRole.SUBMITTER: "auth13_assignment", + ProjectRole.REVIEWER: "rev_reviewer_obligation", + ProjectRole.ADJUDICATOR: "none", + }[ProjectRole(grant.role)], ), ) return _response(grant) diff --git a/backend/app/modules/authorization/router.py b/backend/app/modules/authorization/router.py index 96350910..534d0456 100644 --- a/backend/app/modules/authorization/router.py +++ b/backend/app/modules/authorization/router.py @@ -1232,51 +1232,74 @@ async def issue_project_role_grant( ) actor_id = UUID(resolved.profile.id) service = ProjectRoleGrantMutationService(session) - reservation = await service.reserve( - key=idempotency_key, actor_profile_id=actor_id, request=canonical + reservation = await _database_call( + session, + service.reserve(key=idempotency_key, actor_profile_id=actor_id, request=canonical), ) prepared_input = PreparedAuthorizationInput( idempotency_key=idempotency_key, request_value=canonical.model_dump(mode="json"), ) try: - handle = await prepared.prepare( - ActionId.PROJECT_ROLE_GRANT_ISSUE, - prepared_input, - PreparedAuthorityScope( - kind=PreparedAuthorityScopeKind.PROJECT, - project_id=project_id, - target_actor_profile_id=payload.target_actor_profile_id, - role=payload.role, + handle = await _database_call( + session, + prepared.prepare( + ActionId.PROJECT_ROLE_GRANT_ISSUE, + prepared_input, + PreparedAuthorityScope( + kind=PreparedAuthorityScopeKind.PROJECT, + project_id=project_id, + target_actor_profile_id=payload.target_actor_profile_id, + role=payload.role, + ), ), ) except PreparedAuthorizationUnsupported as exc: raise _project_role_resource_not_found() from exc - project = await service.repository.lock_project(project_id) + project = await _database_call(session, service.repository.lock_project(project_id)) if project is None: raise _project_role_resource_not_found() - target_eligible = ( - await service.repository.lock_eligible_human(payload.target_actor_profile_id) is not None + await _database_call( + session, + service.repository.take_project_role_issue_lock( + project_role_issue_lock_key( + payload.target_actor_profile_id, + project_id, + payload.role.value, + ) + ), ) - await service.repository.take_project_role_issue_lock( - project_role_issue_lock_key( - payload.target_actor_profile_id, - project_id, - payload.role.value, + target_eligible = ( + await _database_call( + session, + service.repository.lock_eligible_human(payload.target_actor_profile_id), ) + is not None ) - decision = await prepared.consume( - handle, - ActionId.PROJECT_ROLE_GRANT_ISSUE, - prepared_input, - ProjectRoleGrantIssueResourceContext( - resource_type="project_role_grant_issue", - resource_id=project_id, - scope_project_id=project_id, - target_actor_profile_id=payload.target_actor_profile_id, - role=payload.role, - project_status=project.status, - target_eligible=target_eligible, + active_exact_role = await _database_call( + session, + service.repository.find_active_project_role( + project_id=project_id, + actor_profile_id=payload.target_actor_profile_id, + role=payload.role.value, + ), + ) + decision = await _database_call( + session, + prepared.consume( + handle, + ActionId.PROJECT_ROLE_GRANT_ISSUE, + prepared_input, + ProjectRoleGrantIssueResourceContext( + resource_type="project_role_grant_issue", + resource_id=project_id, + scope_project_id=project_id, + target_actor_profile_id=payload.target_actor_profile_id, + role=payload.role, + project_status=project.status, + target_eligible=target_eligible, + active_exact_role_exists=active_exact_role is not None, + ), ), ) if reservation.outcome == "mismatch": @@ -1292,8 +1315,11 @@ async def issue_project_role_grant( await _commit_or_unavailable(session) raise _domain_error(409, "idempotency_mismatch", "Idempotency key does not match") if reservation.outcome == "replay": - row = await service.repository.lock_project_role_grant( - project_id=project_id, grant_id=reservation.response.resource_id + row = await _database_call( + session, + service.repository.lock_project_role_grant( + project_id=project_id, grant_id=reservation.response.resource_id + ), ) if ( reservation.response.resource_type.value != "project_role_grant" @@ -1318,12 +1344,19 @@ async def issue_project_role_grant( await _commit_or_unavailable(session) return _project_role_response(row[0]) try: - response = await service.complete_issue( - claim=reservation.claim, - request=canonical, - decision=decision, - actor_profile_id=actor_id, - reason=payload.reason, + if active_exact_role is not None: + raise ProjectRoleGrantConflict( + "project_role_grant_exists", active_exact_role.id + ) + response = await _database_call( + session, + service.complete_issue( + claim=reservation.claim, + request=canonical, + decision=decision, + actor_profile_id=actor_id, + reason=payload.reason, + ), ) await _commit_or_unavailable(session) return response @@ -1367,43 +1400,53 @@ async def revoke_project_role_grant( ) actor_id = UUID(resolved.profile.id) service = ProjectRoleGrantMutationService(session) - reservation = await service.reserve( - key=idempotency_key, actor_profile_id=actor_id, request=canonical + reservation = await _database_call( + session, + service.reserve(key=idempotency_key, actor_profile_id=actor_id, request=canonical), ) prepared_input = PreparedAuthorizationInput( idempotency_key=idempotency_key, request_value=canonical.model_dump(mode="json"), ) try: - handle = await prepared.prepare( - ActionId.PROJECT_ROLE_GRANT_REVOKE, - prepared_input, - PreparedAuthorityScope( - kind=PreparedAuthorityScopeKind.PROJECT, - project_id=project_id, - grant_id=grant_id, + handle = await _database_call( + session, + prepared.prepare( + ActionId.PROJECT_ROLE_GRANT_REVOKE, + prepared_input, + PreparedAuthorityScope( + kind=PreparedAuthorityScopeKind.PROJECT, + project_id=project_id, + grant_id=grant_id, + ), ), ) except PreparedAuthorizationUnsupported as exc: raise _project_role_resource_not_found() from exc - project = await service.repository.lock_project(project_id) - row = await service.repository.lock_project_role_grant(project_id=project_id, grant_id=grant_id) + project = await _database_call(session, service.repository.lock_project(project_id)) + row = await _database_call( + session, + service.repository.lock_project_role_grant(project_id=project_id, grant_id=grant_id), + ) if project is None or row is None: raise _project_role_resource_not_found() grant, _snapshot = row - decision = await prepared.consume( - handle, - ActionId.PROJECT_ROLE_GRANT_REVOKE, - prepared_input, - ProjectRoleGrantRevokeResourceContext( - resource_type="project_role_grant_revoke", - resource_id=grant_id, - scope_project_id=project_id, - actor_profile_id=UUID(grant.actor_profile_id), - role=ProjectRole(grant.role), - project_status=project.status, - status=grant.status, - version=grant.version, + decision = await _database_call( + session, + prepared.consume( + handle, + ActionId.PROJECT_ROLE_GRANT_REVOKE, + prepared_input, + ProjectRoleGrantRevokeResourceContext( + resource_type="project_role_grant_revoke", + resource_id=grant_id, + scope_project_id=project_id, + actor_profile_id=UUID(grant.actor_profile_id), + role=ProjectRole(grant.role), + project_status=project.status, + status=grant.status, + version=grant.version, + ), ), ) if reservation.outcome == "mismatch": @@ -1440,13 +1483,16 @@ async def revoke_project_role_grant( await _commit_or_unavailable(session) return _project_role_response(grant) try: - response = await service.complete_revoke( - claim=reservation.claim, - request=canonical, - decision=decision, - actor_profile_id=actor_id, - reason=payload.reason, - grant=grant, + response = await _database_call( + session, + service.complete_revoke( + claim=reservation.claim, + request=canonical, + decision=decision, + actor_profile_id=actor_id, + reason=payload.reason, + grant=grant, + ), ) await _commit_or_unavailable(session) return response diff --git a/backend/app/modules/authorization/runtime.py b/backend/app/modules/authorization/runtime.py index e6ef8728..83e028db 100644 --- a/backend/app/modules/authorization/runtime.py +++ b/backend/app/modules/authorization/runtime.py @@ -358,6 +358,7 @@ class ProjectRoleGrantIssueResourceContext(BaseModel): role: ProjectRole project_status: Literal["draft", "active", "paused", "archived"] target_eligible: bool + active_exact_role_exists: bool class ProjectRoleGrantRevokeResourceContext(BaseModel): diff --git a/backend/app/modules/authorization/schemas.py b/backend/app/modules/authorization/schemas.py index 53b015bc..a56c451a 100644 --- a/backend/app/modules/authorization/schemas.py +++ b/backend/app/modules/authorization/schemas.py @@ -481,11 +481,24 @@ class AuthorityInvalidationContext(BaseModel): correlation_id: UUID target_ref_kind: AuthorityResourceType | None = None target_ref_id: UUID | None = None + project_role: ProjectRole | None = None + future_obligation: Literal[ + "auth13_assignment", "rev_reviewer_obligation", "none" + ] | None = None @model_validator(mode="after") def validate_target(self): if (self.target_ref_kind is None) != (self.target_ref_id is None): raise ValueError("invalid authority invalidation target") + expected = { + ProjectRole.SUBMITTER: "auth13_assignment", + ProjectRole.REVIEWER: "rev_reviewer_obligation", + ProjectRole.ADJUDICATOR: "none", + }.get(self.project_role) + if (self.project_role is None) != (self.future_obligation is None): + raise ValueError("invalid authority invalidation projection") + if expected is not None and self.future_obligation != expected: + raise ValueError("invalid authority invalidation projection") return self diff --git a/backend/app/modules/authorization/service.py b/backend/app/modules/authorization/service.py index 19a3809d..945961e7 100644 --- a/backend/app/modules/authorization/service.py +++ b/backend/app/modules/authorization/service.py @@ -257,6 +257,21 @@ async def complete( elif isinstance(mutation, ActorProfileReactivateRequest): before_facts = {"effective": False} after_facts = {"effective": True} + if isinstance(mutation, ProjectRoleGrantRevokeRequest): + if ( + invalidation is None + or invalidation.project_role is None + or invalidation.future_obligation is None + ): + raise TypeError("project-role revoke requires invalidation projection") + projection = { + "role": invalidation.project_role.value, + "scope_type": "project", + "scope_id": str(mutation.project_id), + "future_obligation": invalidation.future_obligation, + } + before_facts = {"effective": True, **projection} + after_facts = {"effective": False, **projection} if invalidation is not None: invalidation_input = AuthorityAuditEventInput( event_id=invalidation.event_id, diff --git a/backend/scripts/api_contract_e2e.py b/backend/scripts/api_contract_e2e.py index 09df3f19..a2e33343 100644 --- a/backend/scripts/api_contract_e2e.py +++ b/backend/scripts/api_contract_e2e.py @@ -1602,6 +1602,7 @@ async def exercise_api_contract(base_url: str, env: dict[str, str]) -> None: json=role_revoke_body, ) assert concealed_replay.status_code == 404, concealed_replay.text + assert concealed_replay.json()["error"] == missing_grant["error"] await request_json(client, "GET", f"/api/v1/tasks/{task['id']}", worker_token) ready_work_context = await request_json( client, diff --git a/backend/tests/test_authorization.py b/backend/tests/test_authorization.py index eb7db1a5..0c72aff4 100644 --- a/backend/tests/test_authorization.py +++ b/backend/tests/test_authorization.py @@ -37,7 +37,10 @@ get_authorization_service, get_prepared_authorization_service, ) -from app.api.deps.api_controls import enforce_authorization_read_rate_limit +from app.api.deps.api_controls import ( + enforce_admin_mutation_rate_limit, + enforce_authorization_read_rate_limit, +) from app.api.deps.auth import get_auth_verification_result from app.core.api_controls import StructuredHTTPException from app.core.config import Settings, get_settings @@ -236,6 +239,64 @@ def test_project_role_public_reason_and_qualification_contract_is_strict() -> No ProjectRoleGrantIssueBody.model_validate(payload | {"reason": reason}) +def test_project_role_invalidation_projection_is_closed_per_role() -> None: + project_id = uuid4() + mappings = { + ProjectRole.SUBMITTER: "auth13_assignment", + ProjectRole.REVIEWER: "rev_reviewer_obligation", + ProjectRole.ADJUDICATOR: "none", + } + for role, obligation in mappings.items(): + context = AuthorityInvalidationContext( + event_id=uuid4(), + request_id=uuid4(), + correlation_id=uuid4(), + target_ref_kind=AuthorityResourceType.PROJECT_ROLE_GRANT, + target_ref_id=uuid4(), + project_role=role, + future_obligation=obligation, + ) + projection = { + "role": role.value, + "scope_type": "project", + "scope_id": str(project_id), + "future_obligation": obligation, + } + event_id = uuid4() + event = AuthorityAuditEventInput( + event_id=event_id, + event_type=AuthorityEventType.AUTHORITY_INVALIDATION_REQUESTED, + entity_type="authority_invalidation", + entity_id=str(event_id), + actor_ref_kind=ActorReferenceKind.ACTOR_PROFILE, + actor_ref=str(uuid4()), + request_id=context.request_id, + correlation_id=context.correlation_id, + permission_id=PermissionId.PROJECT_ROLE_GRANT_MANAGE, + project_id=str(project_id), + resource_type="actor_profile", + resource_id=str(uuid4()), + target_ref_kind="project_role_grant", + target_ref_id=str(context.target_ref_id), + reason="authority_state_changed", + idempotency_reference=uuid4(), + invalidation_cause_event_id=uuid4(), + invalidation_target_kind="actor_profile", + invalidation_target_ref=str(uuid4()), + before_facts={"effective": True, **projection}, + after_facts={"effective": False, **projection}, + ) + assert event.after_facts["future_obligation"] == obligation + with pytest.raises(ValidationError): + AuthorityInvalidationContext( + event_id=uuid4(), + request_id=uuid4(), + correlation_id=uuid4(), + project_role=ProjectRole.SUBMITTER, + future_obligation="none", + ) + + def test_authorization_read_cursor_round_trip_and_query_binding() -> None: codec = AuthorizationReadCursorCodec(bytes(range(32))) project_id = UUID("00000000-0000-4000-8000-000000000001") @@ -640,6 +701,60 @@ async def forbidden_project_lookup(*_args, **_kwargs): assert lookups == 0 +@pytest.mark.asyncio +@pytest.mark.parametrize( + ("path", "payload"), + [ + ( + lambda project, _grant: f"/api/v1/projects/{project}/role-grants", + lambda: { + "target_actor_profile_id": str(uuid4()), + "role": "submitter", + "qualification": _project_role_qualification(), + "reason": "Bounded assignment", + }, + ), + ( + lambda project, grant: f"/api/v1/projects/{project}/role-grants/{grant}/revoke", + lambda: {"reason": "Bounded removal"}, + ), + ], +) +async def test_project_role_mutation_rate_failure_precedes_private_work( + monkeypatch: pytest.MonkeyPatch, + path, + payload, +) -> None: + app = create_app(Settings(environment="test")) + calls = 0 + + async def fail_rate_first() -> None: + nonlocal calls + calls += 1 + raise StructuredHTTPException( + status_code=503, + detail="rate persistence unavailable", + error_code="service_unavailable", + error_message="rate persistence unavailable", + retryable=True, + ) + + async def forbidden_project_lookup(*_args, **_kwargs): + raise AssertionError("private mutation work must not run") + + 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: + response = await client.post( + path(uuid4(), uuid4()), + headers={"Idempotency-Key": str(uuid4())}, + json=payload(), + ) + assert response.status_code == 503 + assert response.json()["error"]["retryable"] is True + assert calls == 1 + + ART_CUSTODY_EXPECTATIONS = { "artifact.binding.read": ( "artifact.binding.read", @@ -7925,6 +8040,26 @@ async def test_project_role_and_all_operation_mappings_commit_one_linked_pair( event_id=uuid4(), request_id=success.request_id, correlation_id=success.correlation_id, + target_ref_kind=( + AuthorityResourceType.PROJECT_ROLE_GRANT + if request.operation is AuthorityOperation.PROJECT_ROLE_GRANT_REVOKE + else None + ), + target_ref_id=( + response.resource_id + if request.operation is AuthorityOperation.PROJECT_ROLE_GRANT_REVOKE + else None + ), + project_role=( + ProjectRole.SUBMITTER + if request.operation is AuthorityOperation.PROJECT_ROLE_GRANT_REVOKE + else None + ), + future_obligation=( + "auth13_assignment" + if request.operation is AuthorityOperation.PROJECT_ROLE_GRANT_REVOKE + else None + ), ) if request.operation is AuthorityOperation.PROJECT_ROLE_GRANT_ISSUE: qualification_id = uuid4() @@ -8011,7 +8146,17 @@ async def test_project_role_and_all_operation_mappings_commit_one_linked_pair( } else {"effective": True} ) + if success.event_type is AuthorityEventType.PROJECT_ROLE_GRANT_REVOKED: + expected_before = { + "effective": True, + "role": "submitter", + "scope_type": "project", + "scope_id": success.project_id, + "future_obligation": "auth13_assignment", + } expected_after = {"effective": not expected_before["effective"]} + if success.event_type is AuthorityEventType.PROJECT_ROLE_GRANT_REVOKED: + expected_after = expected_before | {"effective": False} assert invalidation_row.before_facts == expected_before assert invalidation_row.after_facts == expected_after From 6c182007fbe4ca7f7e4a933273eb2566c6987f2a Mon Sep 17 00:00:00 2001 From: Abiorh001 Date: Thu, 23 Jul 2026 16:28:35 +0100 Subject: [PATCH 05/10] fix(auth): map unique races and cancellation safely --- .../authorization/project_role_service.py | 43 +++++++++----- backend/app/modules/authorization/router.py | 24 +++++++- backend/tests/test_authorization.py | 56 ++++++++++++++++++- 3 files changed, 105 insertions(+), 18 deletions(-) diff --git a/backend/app/modules/authorization/project_role_service.py b/backend/app/modules/authorization/project_role_service.py index 87e7af9d..306b17cd 100644 --- a/backend/app/modules/authorization/project_role_service.py +++ b/backend/app/modules/authorization/project_role_service.py @@ -7,6 +7,7 @@ from uuid import UUID, uuid4 from sqlalchemy.sql import func +from sqlalchemy.exc import IntegrityError from sqlalchemy.ext.asyncio import AsyncSession from app.modules.audit.schemas import ( @@ -36,7 +37,7 @@ class ProjectRoleGrantConflict(RuntimeError): - def __init__(self, code: str, grant_id: UUID) -> None: + def __init__(self, code: str, grant_id: UUID | None) -> None: self.code = code self.grant_id = grant_id super().__init__(code) @@ -56,6 +57,13 @@ def project_role_issue_lock_key(actor_id: UUID, project_id: UUID, role: str) -> return int.from_bytes(hashlib.sha256(encoded).digest()[:8], "big", signed=True) +def _constraint_name(exc: IntegrityError) -> str | None: + original = exc.orig + return getattr(original, "constraint_name", None) or getattr( + getattr(original, "diag", None), "constraint_name", None + ) + + def _facts(grant: ProjectRoleGrant) -> dict[str, object]: return { "status": grant.status, @@ -187,21 +195,26 @@ async def complete_issue( captured_by_admin_role_grant_id=decision.matched_grant_id, ) ) - grant = await self.repository.add_project_role_grant( - ProjectRoleGrant( - id=uuid4(), - project_id=str(request.project_id), - actor_profile_id=str(request.target_actor_id), - role=request.role.value, - status="active", - version=1, - grant_method="manual", - qualification_snapshot_id=snapshot.id, - granted_by_actor_profile_id=str(actor_profile_id), - granted_by_admin_role_grant_id=decision.matched_grant_id, - grant_reason=reason, + try: + grant = await self.repository.add_project_role_grant( + ProjectRoleGrant( + id=uuid4(), + project_id=str(request.project_id), + actor_profile_id=str(request.target_actor_id), + role=request.role.value, + status="active", + version=1, + grant_method="manual", + qualification_snapshot_id=snapshot.id, + granted_by_actor_profile_id=str(actor_profile_id), + granted_by_admin_role_grant_id=decision.matched_grant_id, + grant_reason=reason, + ) ) - ) + except IntegrityError as exc: + if _constraint_name(exc) == "uq_project_role_grants_active_exact_role": + raise ProjectRoleGrantConflict("project_role_grant_exists", None) from exc + raise common = dict( actor_ref_kind=ActorReferenceKind.ACTOR_PROFILE, actor_ref=str(actor_profile_id), diff --git a/backend/app/modules/authorization/router.py b/backend/app/modules/authorization/router.py index 534d0456..0369c269 100644 --- a/backend/app/modules/authorization/router.py +++ b/backend/app/modules/authorization/router.py @@ -2,6 +2,7 @@ from __future__ import annotations +import asyncio from collections.abc import Awaitable from typing import Annotated, Literal, TypeVar from uuid import UUID @@ -272,6 +273,9 @@ def _configured_issuer(verifier: AuthVerifier) -> str: async def _commit_or_unavailable(session: AsyncSession) -> None: try: await session.commit() + except asyncio.CancelledError: + await session.rollback() + raise except SQLAlchemyError as exc: await session.rollback() raise service_unavailable_error() from exc @@ -281,6 +285,9 @@ async def _database_call(session: AsyncSession, operation: Awaitable[T]) -> T: """Map feature-owned SQL failures without relabeling authorization evidence errors.""" try: return await operation + except asyncio.CancelledError: + await session.rollback() + raise except SQLAlchemyError as exc: await session.rollback() raise service_unavailable_error() from exc @@ -1362,12 +1369,25 @@ async def issue_project_role_grant( return response except ProjectRoleGrantConflict as exc: await session.rollback() + conflict_grant_id = exc.grant_id + if conflict_grant_id is None: + conflict = await _database_call( + session, + service.repository.find_active_project_role( + project_id=project_id, + actor_profile_id=payload.target_actor_profile_id, + role=payload.role.value, + ), + ) + if conflict is None: + raise service_unavailable_error() from exc + conflict_grant_id = conflict.id await _database_call( session, service.record_conflict( actor_profile_id=actor_id, project_id=project_id, - grant_id=exc.grant_id, + grant_id=conflict_grant_id, decision=decision, code=exc.code, action_id=ActionId.PROJECT_ROLE_GRANT_ISSUE, @@ -1498,6 +1518,8 @@ async def revoke_project_role_grant( return response except ProjectRoleGrantConflict as exc: await session.rollback() + if exc.grant_id is None: + raise service_unavailable_error() from exc await _database_call( session, service.record_conflict( diff --git a/backend/tests/test_authorization.py b/backend/tests/test_authorization.py index 0c72aff4..38de94f6 100644 --- a/backend/tests/test_authorization.py +++ b/backend/tests/test_authorization.py @@ -72,6 +72,7 @@ IdentityLinkLifecycleService, ) from app.modules.authorization.models import AdminRoleGrant +from app.modules.projects.models import Project from app.modules.authorization.pagination import ( AuthorizationReadCursorCodec, InvalidPaginationCursor, @@ -129,7 +130,10 @@ parse_authority_request, ) from app.modules.authorization.service import AuthorityMutationService -from app.modules.authorization.project_role_service import project_role_issue_lock_key +from app.modules.authorization.project_role_service import ( + _constraint_name, + project_role_issue_lock_key, +) from app.modules.authorization.project_role_schemas import ( ProjectRoleGrantIssueBody, ProjectRoleGrantRevokeBody, @@ -176,6 +180,7 @@ PermissionCatalogueResourceContext, ProjectContributorCandidateCollectionResourceContext, ProjectRoleGrantCollectionResourceContext, + ProjectRoleGrantIssueResourceContext, ProjectRoleGrantReadResourceContext, ServiceActorProvisionResourceContext, ServiceAuthorizationContext, @@ -221,6 +226,11 @@ 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" + ) + error = IntegrityError("insert", {}, original) + assert _constraint_name(error) == "uq_project_role_grants_active_exact_role" def test_project_role_public_reason_and_qualification_contract_is_strict() -> None: @@ -1692,18 +1702,23 @@ async def rollback(self) -> None: async def failed_operation() -> None: raise SQLAlchemyError("query failed") + async def cancelled_operation() -> None: + raise asyncio.CancelledError + session = Session() with pytest.raises(StructuredHTTPException) as commit_failure: await authorization_router._commit_or_unavailable(session) with pytest.raises(StructuredHTTPException) as query_failure: await authorization_router._database_call(session, failed_operation()) + with pytest.raises(asyncio.CancelledError): + await authorization_router._database_call(session, cancelled_operation()) for failure in (commit_failure.value, query_failure.value): assert failure.status_code == 503 assert failure.error_code == "service_unavailable" assert failure.retryable is True assert session.commits == 1 - assert session.rollbacks == 2 + assert session.rollbacks == 3 @pytest.mark.parametrize( @@ -8358,3 +8373,40 @@ async def test_same_key_is_isolated_independently_by_actor_and_reference_kind( await first.rollback() await second.rollback() await third.rollback() + + +@pytest.mark.asyncio +async def test_project_role_issue_postgresql_prep_binds_target_role_and_scope( + authorization_factory, +) -> None: + caller_id, caller_link_id, target_id, target_link_id, project_id = ( + uuid4(), uuid4(), uuid4(), uuid4(), uuid4() + ) + manager_grant_id = uuid4() + now = datetime.now(UTC) + 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=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=None, grant_reason="AUTH-10C PostgreSQL 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()) + repository = AdminAuthorizationRepository(session) + authorization = AuthorizationService(session, context, admin_repository=repository) + prepared = PreparedAuthorizationService(session, context, authorization, repository) + await session.begin() + caller_input = PreparedAuthorizationInput(idempotency_key=uuid4(), request_value={"target": str(target_id), "role": "submitter"}) + 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")) + assert await repository.lock_eligible_human(target_id) is not None + decision = await prepared.consume(handle, ActionId.PROJECT_ROLE_GRANT_ISSUE, caller_input, ProjectRoleGrantIssueResourceContext(resource_type="project_role_grant_issue", 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)) + assert decision.allowed is True + assert decision.matched_grant_id == manager_grant_id + await session.rollback() From 31682f80add8949d81b6565eb7d6f873e7fa770b Mon Sep 17 00:00:00 2001 From: Abiorh001 Date: Thu, 23 Jul 2026 16:33:07 +0100 Subject: [PATCH 06/10] test(auth): prove project role mutation persistence and waiting rollback --- backend/tests/test_authorization.py | 187 +++++++++++++++++++++++++++- 1 file changed, 185 insertions(+), 2 deletions(-) diff --git a/backend/tests/test_authorization.py b/backend/tests/test_authorization.py index 38de94f6..901a9d3c 100644 --- a/backend/tests/test_authorization.py +++ b/backend/tests/test_authorization.py @@ -132,6 +132,7 @@ from app.modules.authorization.service import AuthorityMutationService from app.modules.authorization.project_role_service import ( _constraint_name, + ProjectRoleGrantMutationService, project_role_issue_lock_key, ) from app.modules.authorization.project_role_schemas import ( @@ -182,6 +183,7 @@ ProjectRoleGrantCollectionResourceContext, ProjectRoleGrantIssueResourceContext, ProjectRoleGrantReadResourceContext, + ProjectRoleGrantRevokeResourceContext, ServiceActorProvisionResourceContext, ServiceAuthorizationContext, SystemResourceContext, @@ -8392,7 +8394,7 @@ async def test_project_role_issue_postgresql_prep_binds_target_role_and_scope( 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=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=None, grant_reason="AUTH-10C PostgreSQL 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=None, granted_by_system_principal="workstream:system:bootstrap", granted_by_admin_role_grant_id=None, grant_reason="AUTH-10C PostgreSQL proof"), ] ) await session.commit() @@ -8400,8 +8402,24 @@ async def test_project_role_issue_postgresql_prep_binds_target_role_and_scope( repository = AdminAuthorizationRepository(session) authorization = AuthorizationService(session, context, admin_repository=repository) prepared = PreparedAuthorizationService(session, context, authorization, repository) + issue_reason = "AUTH-10C PostgreSQL issue proof" + canonical_issue = ProjectRoleGrantIssueRequest( + operation=AuthorityOperation.PROJECT_ROLE_GRANT_ISSUE, + project_id=project_id, + target_actor_id=target_id, + role=ProjectRole.SUBMITTER, + qualification=_project_role_qualification(), + reason_digest=derive_reason_digest(issue_reason), + ) + issue_key = uuid4() await session.begin() - caller_input = PreparedAuthorizationInput(idempotency_key=uuid4(), request_value={"target": str(target_id), "role": "submitter"}) + issue_reservation = await ProjectRoleGrantMutationService(session).reserve( + key=issue_key, + actor_profile_id=caller_id, + 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)) 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")) @@ -8409,4 +8427,169 @@ async def test_project_role_issue_postgresql_prep_binds_target_role_and_scope( decision = await prepared.consume(handle, ActionId.PROJECT_ROLE_GRANT_ISSUE, caller_input, ProjectRoleGrantIssueResourceContext(resource_type="project_role_grant_issue", 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)) assert decision.allowed is True assert decision.matched_grant_id == manager_grant_id + issued = await ProjectRoleGrantMutationService(session).complete_issue( + claim=issue_reservation.claim, + request=canonical_issue, + decision=decision, + actor_profile_id=caller_id, + reason=issue_reason, + ) + assert issued.status == "active" + await session.commit() + await session.execute( + text("update actor_profiles set status='suspended' where id=:id"), + {"id": str(target_id)}, + ) + await session.execute( + text("update actor_identity_links set status='revoked' where id=:id"), + {"id": str(target_link_id)}, + ) + await session.commit() + revoke_reason = "AUTH-10C lifecycle-independent revoke proof" + canonical_revoke = ProjectRoleGrantRevokeRequest( + operation=AuthorityOperation.PROJECT_ROLE_GRANT_REVOKE, + project_id=project_id, + grant_id=issued.id, + reason_digest=derive_reason_digest(revoke_reason), + ) + revoke_key = uuid4() + revoke_reservation = await ProjectRoleGrantMutationService(session).reserve( + key=revoke_key, + actor_profile_id=caller_id, + request=canonical_revoke, + ) + assert isinstance(revoke_reservation, ClaimedReservation) + revoke_input = PreparedAuthorizationInput( + idempotency_key=revoke_key, + request_value=canonical_revoke.model_dump(mode="json"), + ) + revoke_handle = await prepared.prepare( + ActionId.PROJECT_ROLE_GRANT_REVOKE, + revoke_input, + PreparedAuthorityScope( + kind=PreparedAuthorityScopeKind.PROJECT, + project_id=project_id, + grant_id=issued.id, + ), + ) + project = await repository.lock_project(project_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_decision = await prepared.consume( + revoke_handle, + ActionId.PROJECT_ROLE_GRANT_REVOKE, + revoke_input, + ProjectRoleGrantRevokeResourceContext( + resource_type="project_role_grant_revoke", + resource_id=issued.id, + scope_project_id=project_id, + actor_profile_id=target_id, + role=ProjectRole.SUBMITTER, + project_status="draft", + status="active", + version=1, + ), + ) + revoked = await ProjectRoleGrantMutationService(session).complete_revoke( + claim=revoke_reservation.claim, + request=canonical_revoke, + decision=revoke_decision, + actor_profile_id=caller_id, + reason=revoke_reason, + grant=grant, + ) + assert revoked.status == "revoked" + await session.commit() + assert ( + await session.scalar( + text( + "select count(*) from audit_events where idempotency_reference in (:issue, :revoke)" + ), + { + "issue": str(issue_reservation.claim.record_id), + "revoke": str(revoke_reservation.claim.record_id), + }, + ) + == 4 + ) await session.rollback() + prepared.close() + + canonical = ProjectRoleGrantIssueRequest( + operation=AuthorityOperation.PROJECT_ROLE_GRANT_ISSUE, + project_id=project_id, + target_actor_id=target_id, + role=ProjectRole.SUBMITTER, + qualification=_project_role_qualification(), + reason_digest=derive_reason_digest("AUTH-10C concurrency proof"), + ) + waiting_key = uuid4() + async with authorization_factory() as locker, authorization_factory() as waiter: + locker_repository = AdminAuthorizationRepository(locker) + locker_authorization = AuthorizationService( + locker, context, admin_repository=locker_repository + ) + locker_prepared = PreparedAuthorizationService( + locker, context, locker_authorization, locker_repository + ) + waiter_repository = AdminAuthorizationRepository(waiter) + waiter_authorization = AuthorizationService( + waiter, context, admin_repository=waiter_repository + ) + waiter_prepared = PreparedAuthorizationService( + waiter, context, waiter_authorization, waiter_repository + ) + scope = PreparedAuthorityScope( + kind=PreparedAuthorityScopeKind.PROJECT, + project_id=project_id, + target_actor_profile_id=target_id, + role=ProjectRole.SUBMITTER, + ) + await locker.begin() + await locker_prepared.prepare( + ActionId.PROJECT_ROLE_GRANT_ISSUE, + PreparedAuthorizationInput( + idempotency_key=uuid4(), request_value=canonical.model_dump(mode="json") + ), + scope, + ) + await waiter.begin() + await ProjectRoleGrantMutationService(waiter).reserve( + key=waiting_key, + actor_profile_id=caller_id, + request=canonical, + ) + wait_task = asyncio.create_task( + waiter_prepared.prepare( + ActionId.PROJECT_ROLE_GRANT_ISSUE, + PreparedAuthorizationInput( + idempotency_key=waiting_key, + request_value=canonical.model_dump(mode="json"), + ), + scope, + ) + ) + with pytest.raises(TimeoutError): + await asyncio.wait_for(asyncio.shield(wait_task), timeout=0.2) + wait_task.cancel() + with pytest.raises(asyncio.CancelledError): + await wait_task + await waiter.rollback() + await locker.rollback() + waiter_prepared.close() + locker_prepared.close() + async with authorization_factory() as clean: + assert ( + await clean.scalar( + text( + "select count(*) from authority_idempotency_records " + "where idempotency_key=:key" + ), + {"key": str(waiting_key)}, + ) + == 0 + ) + await clean.rollback() From 0d05b7096eb7a2cf7c68a1770c0b35f07d5b55df Mon Sep 17 00:00:00 2001 From: Abiorh001 Date: Thu, 23 Jul 2026 16:39:13 +0100 Subject: [PATCH 07/10] test(auth): prove unique-race and cancellation recovery --- backend/tests/test_authorization.py | 353 ++++++++++++++++++++++++++-- 1 file changed, 337 insertions(+), 16 deletions(-) diff --git a/backend/tests/test_authorization.py b/backend/tests/test_authorization.py index 901a9d3c..2968b549 100644 --- a/backend/tests/test_authorization.py +++ b/backend/tests/test_authorization.py @@ -71,7 +71,11 @@ IdentityLinkLifecycleConflict, IdentityLinkLifecycleService, ) -from app.modules.authorization.models import AdminRoleGrant +from app.modules.authorization.models import ( + AdminRoleGrant, + ProjectRoleGrant, + ProjectRoleQualificationSnapshot, +) from app.modules.projects.models import Project from app.modules.authorization.pagination import ( AuthorizationReadCursorCodec, @@ -235,6 +239,53 @@ def test_project_role_issue_advisory_key_contract_is_frozen_and_separated() -> N assert _constraint_name(error) == "uq_project_role_grants_active_exact_role" +@pytest.mark.asyncio +async def test_project_role_issue_crossed_principals_use_one_lexical_lock_order() -> None: + low = UUID("00000000-0000-0000-0000-000000000001") + high = UUID("ffffffff-ffff-ffff-ffff-ffffffffffff") + low_link, high_link = uuid4(), uuid4() + + class RecordingSession: + def __init__(self) -> None: + self.calls: list[tuple[str, UUID]] = [] + + async def scalar(self, statement): + entity = statement.column_descriptions[0]["entity"] + values = set(statement.compile().params.values()) + 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" + ) + 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)) + + expected = [ + ("profile", low), + ("link", low), + ("profile", high), + ("link", high), + ] + for caller, caller_link, target in ( + (low, low_link, high), + (high, high_link, low), + ): + 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, + ) + ) + assert locked_caller is not None + assert target_eligible is True + assert session.calls == expected + + def test_project_role_public_reason_and_qualification_contract_is_strict() -> None: payload = { "target_actor_profile_id": str(uuid4()), @@ -8379,7 +8430,9 @@ async def test_same_key_is_isolated_independently_by_actor_and_reference_kind( @pytest.mark.asyncio async def test_project_role_issue_postgresql_prep_binds_target_role_and_scope( + authorization_database_env: str, authorization_factory, + monkeypatch, ) -> None: caller_id, caller_link_id, target_id, target_link_id, project_id = ( uuid4(), uuid4(), uuid4(), uuid4(), uuid4() @@ -8437,12 +8490,18 @@ async def test_project_role_issue_postgresql_prep_binds_target_role_and_scope( assert issued.status == "active" await session.commit() await session.execute( - text("update actor_profiles set status='suspended' where id=:id"), - {"id": str(target_id)}, + text( + "update actor_profiles set status='suspended', suspended_by=:by, " + "suspended_at=clock_timestamp(), suspension_reason=:reason where id=:id" + ), + {"id": str(target_id), "by": str(caller_id), "reason": "AUTH-10C proof"}, ) await session.execute( - text("update actor_identity_links set status='revoked' where id=:id"), - {"id": str(target_link_id)}, + text( + "update actor_identity_links set status='revoked', revoked_by=:by, " + "revoked_at=clock_timestamp(), revoked_reason=:reason where id=:id" + ), + {"id": str(target_link_id), "by": str(caller_id), "reason": "AUTH-10C proof"}, ) await session.commit() revoke_reason = "AUTH-10C lifecycle-independent revoke proof" @@ -8515,9 +8574,183 @@ async def test_project_role_issue_postgresql_prep_binds_target_role_and_scope( ) == 4 ) - await session.rollback() + + await session.execute( + text( + "update actor_profiles set status='active', suspended_by=null, " + "suspended_at=null, suspension_reason=null, reactivated_by=:by, " + "reactivated_at=clock_timestamp(), reactivation_reason=:reason where id=:id" + ), + { + "id": str(target_id), + "by": str(caller_id), + "reason": "AUTH-10C continuation proof", + }, + ) + await session.execute( + text( + "update actor_identity_links set status='active', revoked_by=null, " + "revoked_at=null, revoked_reason=null, reactivated_by=:by, " + "reactivated_at=clock_timestamp(), reactivation_reason=:reason where id=:id" + ), + { + "id": str(target_link_id), + "by": str(caller_id), + "reason": "AUTH-10C continuation proof", + }, + ) + qualification = _project_role_qualification() + existing_snapshot = ProjectRoleQualificationSnapshot( + id=uuid4(), + project_id=str(project_id), + actor_profile_id=str(target_id), + requested_role="reviewer", + skills_snapshot=qualification["skills_snapshot"], + reputation_snapshot=qualification["reputation_snapshot"], + prior_project_work_refs=[], + external_expertise_refs=[], + captured_by_actor_profile_id=str(caller_id), + captured_by_admin_role_grant_id=manager_grant_id, + ) + session.add(existing_snapshot) + session.add( + ProjectRoleGrant( + id=uuid4(), + project_id=str(project_id), + actor_profile_id=str(target_id), + role="reviewer", + status="active", + version=1, + grant_method="manual", + qualification_snapshot_id=existing_snapshot.id, + granted_by_actor_profile_id=str(caller_id), + granted_by_admin_role_grant_id=manager_grant_id, + grant_reason="AUTH-10C unique-index winner", + ) + ) + await session.commit() + + race_reason = "AUTH-10C real unique-index loser" + race_payload = ProjectRoleGrantIssueBody( + target_actor_profile_id=target_id, + role=ProjectRole.REVIEWER, + qualification=_project_role_qualification(), + reason=race_reason, + ) + race_key = uuid4() + original_find = AdminAuthorizationRepository.find_active_project_role + original_reserve = ProjectRoleGrantMutationService.reserve + find_calls = 0 + race_claim_ids: list[UUID] = [] + + async def race_find(repository, **kwargs): + nonlocal find_calls + find_calls += 1 + if find_calls <= 2: + return None + return await original_find(repository, **kwargs) + + async def capture_race_claim(service, **kwargs): + reservation = await original_reserve(service, **kwargs) + assert isinstance(reservation, ClaimedReservation) + 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 + ) + 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 + with pytest.raises(StructuredHTTPException) as conflict: + await authorization_router.issue_project_role_grant( + project_id=project_id, + payload=race_payload, + idempotency_key=race_key, + resolved=ResolvedActor(caller_profile, caller_link), + prepared=prepared, + session=session, + ) + assert conflict.value.status_code == 409 + assert conflict.value.error_code == "project_role_grant_exists" + assert find_calls == 3 + assert len(race_claim_ids) == 1 + monkeypatch.setattr( + AdminAuthorizationRepository, + "find_active_project_role", + original_find, + ) + monkeypatch.setattr( + ProjectRoleGrantMutationService, + "reserve", + original_reserve, + ) prepared.close() + async with authorization_factory() as clean: + assert ( + await clean.scalar( + text( + "select count(*) from project_role_grants where project_id=:project " + "and actor_profile_id=:actor and role='reviewer' and status='active'" + ), + {"project": str(project_id), "actor": str(target_id)}, + ) + == 1 + ) + assert ( + await clean.scalar( + text( + "select count(*) from project_role_qualification_snapshots " + "where project_id=:project and actor_profile_id=:actor " + "and requested_role='reviewer'" + ), + {"project": str(project_id), "actor": str(target_id)}, + ) + == 1 + ) + assert ( + await clean.scalar( + text( + "select count(*) from authority_idempotency_records " + "where idempotency_key=:key" + ), + {"key": str(race_key)}, + ) + == 0 + ) + denial_rows = ( + await clean.execute( + text( + "select event_type, denial_code, idempotency_reference from audit_events " + "where request_id=:request and correlation_id=:correlation " + "and action_id='project_role_grant.issue'" + ), + { + "request": str(context.request_id), + "correlation": str(context.correlation_id), + }, + ) + ).all() + 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" + ), + {"claim": str(race_claim_ids[0])}, + ) + == 0 + ) + await clean.rollback() + canonical = ProjectRoleGrantIssueRequest( operation=AuthorityOperation.PROJECT_ROLE_GRANT_ISSUE, project_id=project_id, @@ -8562,23 +8795,100 @@ async def test_project_role_issue_postgresql_prep_binds_target_role_and_scope( actor_profile_id=caller_id, request=canonical, ) + waiter_pid = await waiter.scalar(text("select pg_backend_pid()")) wait_task = asyncio.create_task( - waiter_prepared.prepare( - ActionId.PROJECT_ROLE_GRANT_ISSUE, - PreparedAuthorizationInput( - idempotency_key=waiting_key, - request_value=canonical.model_dump(mode="json"), + authorization_router._database_call( + waiter, + waiter_prepared.prepare( + ActionId.PROJECT_ROLE_GRANT_ISSUE, + PreparedAuthorizationInput( + idempotency_key=waiting_key, + request_value=canonical.model_dump(mode="json"), + ), + scope, ), - scope, ) ) - with pytest.raises(TimeoutError): - await asyncio.wait_for(asyncio.shield(wait_task), timeout=0.2) + observer = create_async_engine(authorization_database_env) + try: + async with observer.connect() as connection: + for _ in range(5000): + waiting = await connection.scalar( + text( + "select exists(select 1 from pg_stat_activity where " + "pid=:pid and wait_event_type='Lock')" + ), + {"pid": waiter_pid}, + ) + if waiting: + break + await asyncio.sleep(0) + else: + raise AssertionError("PREP contender never waited on its database lock") + finally: + await observer.dispose() wait_task.cancel() with pytest.raises(asyncio.CancelledError): await wait_task - await waiter.rollback() await locker.rollback() + + retry_reservation = await ProjectRoleGrantMutationService(waiter).reserve( + key=waiting_key, + actor_profile_id=caller_id, + request=canonical, + ) + assert isinstance(retry_reservation, ClaimedReservation) + retry_input = PreparedAuthorizationInput( + idempotency_key=waiting_key, + request_value=canonical.model_dump(mode="json"), + ) + retry_handle = await authorization_router._database_call( + waiter, + waiter_prepared.prepare( + ActionId.PROJECT_ROLE_GRANT_ISSUE, + retry_input, + scope, + ), + ) + waiter_repository = AdminAuthorizationRepository(waiter) + project = await waiter_repository.lock_project(project_id) + assert project is not None + await waiter_repository.take_project_role_issue_lock( + project_role_issue_lock_key(target_id, project_id, "submitter") + ) + assert await waiter_repository.lock_eligible_human(target_id) is not None + assert ( + await waiter_repository.find_active_project_role( + project_id=project_id, + actor_profile_id=target_id, + role="submitter", + ) + is None + ) + retry_decision = await waiter_prepared.consume( + retry_handle, + ActionId.PROJECT_ROLE_GRANT_ISSUE, + retry_input, + ProjectRoleGrantIssueResourceContext( + resource_type="project_role_grant_issue", + resource_id=project_id, + scope_project_id=project_id, + target_actor_profile_id=target_id, + role=ProjectRole.SUBMITTER, + project_status=project.status, + target_eligible=True, + active_exact_role_exists=False, + ), + ) + retried = await ProjectRoleGrantMutationService(waiter).complete_issue( + claim=retry_reservation.claim, + request=canonical, + decision=retry_decision, + actor_profile_id=caller_id, + reason="AUTH-10C concurrency proof", + ) + await waiter.commit() + assert retried.status == "active" waiter_prepared.close() locker_prepared.close() async with authorization_factory() as clean: @@ -8590,6 +8900,17 @@ async def test_project_role_issue_postgresql_prep_binds_target_role_and_scope( ), {"key": str(waiting_key)}, ) - == 0 + == 1 + ) + assert ( + await clean.scalar( + text( + "select count(*) from audit_events where " + "idempotency_reference=(select id from authority_idempotency_records " + "where idempotency_key=:key)" + ), + {"key": str(waiting_key)}, + ) + == 2 ) await clean.rollback() From 9081161b8a4eee3d6cdfd1087b96e061ab25f5c8 Mon Sep 17 00:00:00 2001 From: Abiorh001 Date: Thu, 23 Jul 2026 16:57:38 +0100 Subject: [PATCH 08/10] docs(auth): record AUTH-10C review evidence --- .agent-loop/REVIEW_LOG.md | 14 ++++ ...S-AUTH-001-10C-internal-review-evidence.md | 69 +++++++++++++++++++ .../WS-AUTH-001-10C-pr-trust-bundle.md | 49 +++++++++++++ 3 files changed, 132 insertions(+) create mode 100644 .agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/reviews/WS-AUTH-001-10C-internal-review-evidence.md create mode 100644 .agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/reviews/WS-AUTH-001-10C-pr-trust-bundle.md diff --git a/.agent-loop/REVIEW_LOG.md b/.agent-loop/REVIEW_LOG.md index 1d2a5b15..458849c3 100644 --- a/.agent-loop/REVIEW_LOG.md +++ b/.agent-loop/REVIEW_LOG.md @@ -2844,3 +2844,17 @@ inline finding. Its unexplained 37.61 percent docstring warning is non-actionable because the unchanged repository gate independently passes the same head at 87.6 percent against the 80 percent floor. Human review and explicit approval of PR #178 remain. + +## 2026-07-23 - WS-AUTH-001-10C Internal Implementation Review + +AUTH-10C exact implementation SHA +`0d05b7096eb7a2cf7c68a1770c0b35f07d5b55df` activates the two +Project-Manager-only project-role mutations with PREP-bound lexical principal +locking, deterministic absence serialization, strict replay, immutable +qualification evidence, lifecycle-independent revoke, and typed invalidation. +The repair loop closed real named-index fallback and residue, lifecycle +attribution, database-observed cancellation rollback, committed same-key retry, +and crossed-principal ordering proof. Senior engineering, architecture, +reuse/dedup, security/auth, QA/test, test delta, product/ops, docs, and CI +integrity all pass with no open finding. GitHub full CI/coverage, hosted API E2E, +CodeRabbit, and human review remain. diff --git a/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/reviews/WS-AUTH-001-10C-internal-review-evidence.md b/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/reviews/WS-AUTH-001-10C-internal-review-evidence.md new file mode 100644 index 00000000..de8065e8 --- /dev/null +++ b/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/reviews/WS-AUTH-001-10C-internal-review-evidence.md @@ -0,0 +1,69 @@ +# Internal Review Evidence: WS-AUTH-001-10C + +## Chunk And Reviewed Code + +`WS-AUTH-001-10C` — Project Role Grant Mutations. Risk: L1 authorization, +concurrency, and audit. Trusted-main start base is +`bcf1292e1a591e3e84bf8ee212ee7191d80741fa`; signed start run is +`30014637065`. + +Reviewed code SHA: `0d05b7096eb7a2cf7c68a1770c0b35f07d5b55df` +Reviewed at: 2026-07-23T16:55:00+01:00 +Reviewer run IDs: /root/auth10b1_final_core, /root/auth10b1_final_security_qa, /root/auth10b1_final_ops_docs_ci + +## Implemented Contract + +- Activates exactly the covered-Project-Manager issue and revoke actions and + their two strict `/api/v1/projects/.../role-grants` mutation routes. +- Binds idempotency, PREP, current caller/target/project/grant facts, lexical + principal locks, deterministic absence serialization, and one route commit. +- Persists one immutable qualification snapshot per issued exact role; revoke + remains possible after target suspension or identity-link revocation. +- Appends ordered typed success evidence and the revoke-only future-obligation + invalidation projection through the shared authority mutation completion path. +- Adds no migration, schema revision, worker, assignment, review-reconciliation, + automated grant, replacement-role, or authority-substitution behavior. + +## Deterministic Evidence + +```text +Focused project-role schema/advisory/rate/invalidation/cancellation tests — passed +Crossed-principal lexical profile/link order in both directions — passed +Python compile of authorization tests — passed +Ruff on changed authorization tests and implementation — passed +git diff --check — passed +Markdown links and stale wording scans — passed +``` + +The PostgreSQL proof covers durable issue/revoke evidence, revocation after +target lifecycle loss, the real named partial-unique-index fallback through the +public route, loser residue, observed database lock waiting, production +cancellation rollback, and a completed same-key retry. It runs in GitHub with +the repository database fixture. The repository-wide suite and coverage remain +GitHub-owned because this workstation's full run takes approximately four hours. + +## Integrity And Review + +No test was deleted, skipped, xfailed, or weakened. The exact OpenAPI inventory +is 76 routes with hash +`c8f9852035446ea59b0e929b1bd8c8cfc7df5bf838ceb544c04e899f90169318` +and 74 protected operations with hash +`9278d0183ffb87947ee4857e0325483ba7bf07feac0c38a88432840b10c2b0c3`. +No workflow, dependency, shard, command, threshold, or coverage setting changed. + +| Reviewer | Result | Blocking findings | +|---|---|---| +| Senior engineering | PASS | none | +| Architecture | PASS | none | +| Reuse/dedup | PASS | none | +| Security/auth | PASS | none | +| QA/test | PASS | none | +| Test delta | PASS | none | +| Product/ops | PASS | none | +| Docs | PASS | none | +| CI integrity | PASS | none | + +The repair loop closed lifecycle attribution, real unique-index fallback, +complete residue, database-observed cancellation, committed retry, and lexical +crossed-principal proof gaps. Open reviewer and sub-agent sessions: none. + diff --git a/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/reviews/WS-AUTH-001-10C-pr-trust-bundle.md b/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/reviews/WS-AUTH-001-10C-pr-trust-bundle.md new file mode 100644 index 00000000..f9212392 --- /dev/null +++ b/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/reviews/WS-AUTH-001-10C-pr-trust-bundle.md @@ -0,0 +1,49 @@ +# PR Trust Bundle: WS-AUTH-001-10C + +## Chunk And Goal + +`WS-AUTH-001-10C` adds PREP-bound, idempotent, auditable issue and revoke +mutations for independent exact-project contributor roles. Risk is L1 +authorization, concurrency, and audit integrity. + +## Change, Design, And Scope + +- A covered Project Manager can issue submitter, reviewer, or adjudicator for + one exact project and revoke one exact stored grant. +- Issue captures a bounded immutable qualification snapshot. Revoke derives the + target and role only from the locked grant and emits the closed typed + future-obligation invalidation projection. +- Rate consumption, idempotency, the shared authority barrier, lexical + profile/link locking, PM authority, project, advisory/grant locking, final + fact recomposition, consume, evidence, and commit use the contract order. +- Replay reauthorizes and reloads canonical ownership/state. Conflict and + concealment paths do not disclose private target or reservation facts. +- Hosted E2E uses only public APIs for issue, active read, revoke, history, + replay/state-change conflicts, lifecycle-independent revoke, and authority loss. + +All changed files are contract-allowed. There is no migration, durable schema +change, frontend work, automated role conversion, task assignment, review +reconciliation, or successor implementation. + +## Proof And Review + +Focused lint, compile, contract, ordering, schema, rate, invalidation, and +cancellation checks pass locally. PostgreSQL evidence exercises the real named +partial unique index, public fallback, full loser rollback, database-observed +lock wait, production cancellation rollback, and committed clean retry. The +GitHub Backend workflow must run the full shards, hosted API E2E, repository-wide +78 percent floor, and authorization-subsystem 90 percent floor. + +All nine internal tracks pass reviewed implementation SHA +`0d05b7096eb7a2cf7c68a1770c0b35f07d5b55df` with no open finding. + +## External Review, Risk, And Human Focus + +GitHub CI, Agent Gates, CodeRabbit, and human review remain after publication. +Human review should focus on transaction/lock order, target concealment, +Project-Manager-only authority, replay reauthorization, partial-unique fallback, +revocation availability after lifecycle loss, and audit/invalidation atomicity. +The user retains merge ownership; do not merge without explicit approval of the +specific PR. The declared successor `WS-AUTH-001-11` requires a separate signed +explicit start and must not begin automatically. + From 833dd6e5352e1774ffd8c669346c65e61e3e7ac1 Mon Sep 17 00:00:00 2001 From: Abiorh001 Date: Thu, 23 Jul 2026 17:01:12 +0100 Subject: [PATCH 09/10] docs(auth): fix AUTH-10C evidence hygiene --- .../reviews/WS-AUTH-001-10C-internal-review-evidence.md | 1 - .../reviews/WS-AUTH-001-10C-pr-trust-bundle.md | 1 - 2 files changed, 2 deletions(-) diff --git a/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/reviews/WS-AUTH-001-10C-internal-review-evidence.md b/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/reviews/WS-AUTH-001-10C-internal-review-evidence.md index de8065e8..cf2461e5 100644 --- a/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/reviews/WS-AUTH-001-10C-internal-review-evidence.md +++ b/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/reviews/WS-AUTH-001-10C-internal-review-evidence.md @@ -66,4 +66,3 @@ No workflow, dependency, shard, command, threshold, or coverage setting changed. The repair loop closed lifecycle attribution, real unique-index fallback, complete residue, database-observed cancellation, committed retry, and lexical crossed-principal proof gaps. Open reviewer and sub-agent sessions: none. - diff --git a/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/reviews/WS-AUTH-001-10C-pr-trust-bundle.md b/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/reviews/WS-AUTH-001-10C-pr-trust-bundle.md index f9212392..f2f9e956 100644 --- a/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/reviews/WS-AUTH-001-10C-pr-trust-bundle.md +++ b/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/reviews/WS-AUTH-001-10C-pr-trust-bundle.md @@ -46,4 +46,3 @@ revocation availability after lifecycle loss, and audit/invalidation atomicity. The user retains merge ownership; do not merge without explicit approval of the specific PR. The declared successor `WS-AUTH-001-11` requires a separate signed explicit start and must not begin automatically. - From cbe6cc189fcb2e3341eeb2d715b9b1c3b228646b Mon Sep 17 00:00:00 2001 From: Abiorh001 Date: Thu, 23 Jul 2026 17:02:44 +0100 Subject: [PATCH 10/10] docs(auth): bind AUTH-10C exact review head --- .../reviews/WS-AUTH-001-10C-internal-review-evidence.md | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/reviews/WS-AUTH-001-10C-internal-review-evidence.md b/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/reviews/WS-AUTH-001-10C-internal-review-evidence.md index cf2461e5..f96c1dba 100644 --- a/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/reviews/WS-AUTH-001-10C-internal-review-evidence.md +++ b/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/reviews/WS-AUTH-001-10C-internal-review-evidence.md @@ -7,8 +7,8 @@ concurrency, and audit. Trusted-main start base is `bcf1292e1a591e3e84bf8ee212ee7191d80741fa`; signed start run is `30014637065`. -Reviewed code SHA: `0d05b7096eb7a2cf7c68a1770c0b35f07d5b55df` -Reviewed at: 2026-07-23T16:55:00+01:00 +Reviewed code SHA: `833dd6e5352e1774ffd8c669346c65e61e3e7ac1` +Reviewed at: 2026-07-23T16:05:00Z Reviewer run IDs: /root/auth10b1_final_core, /root/auth10b1_final_security_qa, /root/auth10b1_final_ops_docs_ci ## Implemented Contract @@ -65,4 +65,7 @@ No workflow, dependency, shard, command, threshold, or coverage setting changed. The repair loop closed lifecycle attribution, real unique-index fallback, complete residue, database-observed cancellation, committed retry, and lexical -crossed-principal proof gaps. Open reviewer and sub-agent sessions: none. +crossed-principal proof gaps. + +Open sub-agent sessions: none +Valid findings addressed: yes