diff --git a/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/CHUNK_MAP.md b/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/CHUNK_MAP.md index 9863a7c1..a24cdc47 100644 --- a/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/CHUNK_MAP.md +++ b/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/CHUNK_MAP.md @@ -53,8 +53,8 @@ stopped. | `WS-AUTH-001-10C` | Project Role Grant Mutations | L1 | Proposed after 10B2 | | `WS-AUTH-001-11` | Project Read Cutover Planning Parent | L1 | Signed start run `30167274426`; planning split authored, no runtime implementation | | `WS-AUTH-001-11A` | Project Read Catalogue And Projection Foundation | L1 | Merged in PR #208; migration `0035`, no active surface | -| `WS-AUTH-001-11B` | Project Identity And Actor Context Cutover | L1 | Implementation after merged 11A | -| `WS-AUTH-001-11C1` | Project Setup Diagnostic Read Cutover | L1 | Proposed after 11B | +| `WS-AUTH-001-11B` | Project Identity And Actor Context Cutover | L1 | Merged in PR #214 as `033654ac` | +| `WS-AUTH-001-11C1` | Project Setup Diagnostic Read Cutover | L1 | Active bounded implementation after passed L1 plan review | | `WS-AUTH-001-11C2` | Effective Policy And Active Guide Read Cutover | L1 | Proposed after 11C1 | | `WS-AUTH-001-12` | Project Policy And Setup Mutation Cutover | L1 | Proposed | | `WS-AUTH-001-13` | Task Management And Assignment Cutover | L1 | Proposed | diff --git a/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/STATUS.md b/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/STATUS.md index 2ad143fe..3272bbdf 100644 --- a/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/STATUS.md +++ b/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/STATUS.md @@ -111,16 +111,16 @@ or consumer feature action is active. ## Active planning chunk -None. +None; `WS-AUTH-001-11C1` passed required L1 preimplementation review. ## Active implementation chunk -None. `WS-AUTH-001-09E` merged through PR #157 as `42a89b2d` without changing -feature action availability. +`WS-AUTH-001-11C1`; bounded implementation and deterministic evidence are in +progress on `codex/ws-auth-001-11c1-setup-diagnostic-reads`. ## Current review branch -None. +`codex/ws-auth-001-11c1-setup-diagnostic-reads` (implementation evidence). ## Chunk status @@ -158,8 +158,8 @@ None. | `WS-AUTH-001-10` | Proposed | - | - | Project contributor grants. | | `WS-AUTH-001-11` | Planning split authored | `codex/ws-auth-001-11-project-read-cutover` | - | Exact hard-cutover inventory split into 11A, 11B, 11C1, and 11C2; signed automation remains the live-state authority. | | `WS-AUTH-001-11A` | Merged | `codex/ws-auth-001-11a-project-read-catalogue` | #208 | Registered the project-read catalogue and migration `0035`; no activation. | -| `WS-AUTH-001-11B` | Implementation | `codex/ws-auth-001-11b-project-identity-context` | - | Project identity and self authorization-context hard cutover. | -| `WS-AUTH-001-11C1` | Proposed | - | - | Setup and draft diagnostic read cutover. | +| `WS-AUTH-001-11B` | Merged | `codex/ws-auth-001-11b-project-identity-context` | #214 | Project identity and self authorization-context hard cutover; merged as `033654ac`. | +| `WS-AUTH-001-11C1` | Implementation | `codex/ws-auth-001-11c1-setup-diagnostic-reads` | - | Started 2026-07-28; repaired contract passed all six required L1 plan-review tracks. | | `WS-AUTH-001-11C2` | Proposed | - | - | Effective policy and active-guide read cutover. | | `WS-AUTH-001-12` | Proposed | - | - | Project policy/setup mutation cutover. | | `WS-AUTH-001-13` | Proposed | - | - | Task management and assignment cutover. | diff --git a/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/chunks/WS-AUTH-001-11C1-project-setup-diagnostic-reads.md b/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/chunks/WS-AUTH-001-11C1-project-setup-diagnostic-reads.md index ef0a2e0c..2e428734 100644 --- a/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/chunks/WS-AUTH-001-11C1-project-setup-diagnostic-reads.md +++ b/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/chunks/WS-AUTH-001-11C1-project-setup-diagnostic-reads.md @@ -2,25 +2,50 @@ ## Status -Proposed and inactive after 11B. Requires a separate signed explicit start. +Started by the user on 2026-07-28. The repaired contract passed architecture, +security, product/ops, QA/test, CI-integrity, and senior-engineering L1 plan +review; bounded implementation and deterministic evidence are in progress. ## Goal -Hard-cut the six setup and draft diagnostic GET surfaces assigned by AUTH-11 -to scoped local administrative grants as the sole product-authority source. +Hard-cut exactly six project-guide setup and draft diagnostic GET surfaces to +scoped local administrative grants as their sole product-authority source. + +## Exact route, action, and permission inventory + +Paths below are router-relative; every public path has the canonical `/api/v1` +prefix. + +| Route | Action | Permission | +|---|---|---| +| `GET /projects/{project_id}/guides/{guide_id}/setup-runs/latest` | `project.setup_run.read` | `PROJECT_SETUP_DIAGNOSTIC_READ` | +| `GET /projects/{project_id}/guides/{guide_id}/sufficiency-reports` | `project.guide_sufficiency_report.list` | `PROJECT_SETUP_DIAGNOSTIC_READ` | +| `GET /projects/{project_id}/guides/{guide_id}/sufficiency-reports/{report_id}` | `project.guide_sufficiency_report.read` | `PROJECT_SETUP_DIAGNOSTIC_READ` | +| `GET /projects/{project_id}/guides/{guide_id}/submission-artifact-policies` | `project.submission_artifact_policy.list` | `PROJECT_EFFECTIVE_POLICY_READ` | +| `GET /projects/{project_id}/guides/{guide_id}/submission-artifact-policies/{policy_id}` | `project.submission_artifact_policy.read` | `PROJECT_EFFECTIVE_POLICY_READ` | +| `GET /projects/{project_id}/guides/{guide_id}/post-submit-checker-policy/setup` | `project.post_submit_checker_policy_setup.read` | `PROJECT_EFFECTIVE_POLICY_READ` | ## Risk and SLA -L1 / P1 +L1 / P1. Authorization or concealment failure can disclose private project +configuration or silently retain obsolete token-role authority. ## Allowed files ```text -backend/app/modules/projects/** -backend/app/modules/authorization/** -backend/app/modules/audit/** -backend/tests/test_projects.py +backend/app/api/deps/authorization.py +backend/app/modules/authorization/catalogue.py +backend/app/modules/authorization/kernel.py +backend/app/modules/authorization/repository.py +backend/app/modules/authorization/runtime.py +backend/app/modules/authorization/read_service.py +backend/app/modules/projects/authorization_reads.py +backend/app/modules/projects/repository.py +backend/app/modules/projects/router.py +backend/app/modules/projects/service.py +backend/tests/test_api_controls.py backend/tests/test_authorization.py +backend/tests/test_projects.py backend/scripts/api_contract_e2e.py .github/workflows/backend.yml docs/operations_authorization_service.md @@ -28,65 +53,121 @@ docs/operations_roles_permissions.md docs/operations_project_operating_manual.md docs/spec_authorization_service.md .agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/** -.agent-loop/merge-intents/WS-AUTH-001-11C1.json ``` +An allowed file may change only for this chunk's six actions and their shared, +narrow read-authority machinery. No database migration is permitted. + ## Not allowed ```text project or policy mutation -project identity, actor-context, effective-policy, or active-guide routes +project identity or actor-context routes +the 11C2 effective submission-artifact-policy, pre-submit-checker-policy, + or active-guide routes contributor access to setup diagnostics -token-role fallback or dual authorization +token-role fallback, dual authorization, or compatibility paths +generic artifact download authority +database schema or migration changes ``` +## Design boundary + +- Every route uses `Depends(enforce_human_authorization_read)` before any + project, guide, or child lookup. Rate control and authentication retain their + established 429/503 and 401 behavior before private lookup. Non-human callers + and authorization denials use the existing centralized concealed response. +- One narrow application-layer composer resolves canonical project, guide, and + child facts through `ProjectRepository`; AUTH does not gain a parallel + project repository. The six routes must not duplicate bespoke authorization + flows. +- New strict typed resource context(s) bind the action to `project_id`, + `guide_id`, guide version, target kind, target identifier where applicable, + existence and ownership facts, and the source snapshot identifier/digest + where the projection has one. The six actions receive explicit kernel + context mappings. Decision/audit evidence includes the action, permission, + matched grant identifier and scope, denial code, and exact resource-context + digest. +- After router/composition authorization, migrated service reads are + authorization-neutral. They do not accept `ActorContext` and do not call + the legacy role helper or inspect issuer role claims. +- Transaction-local revalidation locks the canonical actor profile, exact + identity link, and matched administrative grant (`for_update=True`) through + disclosure/commit. A revoked link/grant or changed binding cannot pass on a + stale decision. +- Historical setup runs, reports, and draft/superseded policies remain readable + diagnostics when the exact row is canonically bound to the requested + project, guide, guide version, and source snapshot. Supersession alone does + not conceal history. The `latest` setup route and checker-setup projection + bind the exact canonical row selected in the transaction and deny if those + facts change before projection. + ## Acceptance criteria -- Activate exactly the six 11C1 actions listed in the parent contract. -- Canonical project and child-resource ownership are resolved before disclosure; - unauthorized, nonexistent, and cross-project resources share the action-aware - concealed public response. -- Same-project cross-guide identifiers and report/policy identifiers bound to a - different guide are denied with that same concealed response. -- Setup/sufficiency actions require `PROJECT_SETUP_DIAGNOSTIC_READ`; policy - and checker-setup actions require `PROJECT_EFFECTIVE_POLICY_READ`. Covered - Project Manager and Audit Authority grants and system Operator grants allow - their read-only projections. Finance Authority, Access Administrator, and - contributor grants deny. No read permission implies a mutation permission. -- ProjectRepository remains persistence owner and returns domain records; the - application layer composes authorization context without a parallel project - repository in AUTH. -- Every migrated route declares exactly one primary action and uses local - grants as its sole product-authority source. -- Per-action scope, child-binding, concealed-denial, audit, invalidation, and - live API contract tests pass, including positive Project Manager/Operator/ - Audit and negative Finance/Access Administrator/contributor cases. +- Activate exactly the six inventory actions and no other planned action. +- Canonical project, guide, and child ownership is resolved before disclosure. + Unauthorized, nonexistent, cross-project, cross-guide, wrong-child-binding, + and stale-context requests share the action-aware concealed public response. +- Same-project/different-guide, same-guide/different-version or snapshot, and + copied child identifiers cannot disclose data. +- Project Manager, scoped Audit Authority, and system Operator grants allow + their exact read-only projections. Finance Authority, Access Administrator, + contributor grants, wrong-scope grants, non-human callers, revoked grants, + revoked links, and suspended/deactivated actors deny. +- Admission-order tests prove that rate exhaustion preserves canonical 429 with + retry metadata, rate-store failure preserves canonical 503, missing/invalid + bearer authentication preserves canonical 401 (and a verifier outage + preserves its canonical 503), and a verified non-human subject receives concealed 404, + all before any project, guide, or child lookup. +- Read permissions grant no mutation authority, and mutation permissions do not + imply diagnostic read authority. +- Every migrated route declares exactly one primary action in the canonical + OpenAPI inventory and uses local grants as its sole product-authority source. +- Concurrent revocation or replacement cannot disclose through a previously + resolved actor, identity link, grant, guide, child, version, or snapshot. +- Per-action allow/deny evidence is persisted with the exact context digest and + no N+1 authorization lookup is introduced. - Authorization spec, role matrix, project operating manual, and authorization - operations docs match the six action mappings, read-only projections, - concealment behavior, and removal of token-role authority. + operations docs match the mappings, projections, concealment, history + semantics, and removal of token-role authority. ## Verification +Local deterministic checks are focused; the full suite runs only in GitHub +Actions because the local machine is too slow for the four-hour suite. + ```bash (cd backend && .venv/bin/python -m ruff check app tests scripts) -(cd backend && WORKSTREAM_DATABASE_URL= .venv/bin/python -m pytest -q tests/test_authorization.py tests/test_projects.py --cov=app.modules.authorization --cov-report=term-missing --cov-fail-under=90) +(cd backend && WORKSTREAM_DATABASE_URL= .venv/bin/python -m pytest -q tests/test_authorization.py tests/test_projects.py tests/test_api_controls.py) +(cd backend && WORKSTREAM_DATABASE_URL= .venv/bin/python -m pytest -q tests/test_projects.py --cov=app.modules.projects.authorization_reads --cov-branch --cov-report=term-missing --cov-fail-under=90) +(cd backend && WORKSTREAM_DATABASE_URL= .venv/bin/python -m pytest -q tests/test_authorization.py --cov=app.modules.authorization --cov-branch --cov-report=term-missing --cov-fail-under=90) (cd backend && WORKSTREAM_DATABASE_URL= .venv/bin/python scripts/api_contract_e2e.py) -python3 scripts/test_agent_gates.py +python3 -m unittest -v scripts.test_lightweight_agent_gates +python3 scripts/check_stale_workstream_wording.py +python3 scripts/check_stale_authorization_docs.py +python3 scripts/check_markdown_links.py git diff --check ``` -Hosted `Backend / test` is mandatory before merge and must preserve the full -semantic lanes, API E2E, repository-wide 78 percent floor, and applicable -authorization subsystem 90 percent floor. New project-read branches require -focused behavior coverage; the pre-existing broad project subsystem remains -under the repository-wide floor until its dedicated coverage uplift. +Before completion, scan changed docs for stale terminology and broken Markdown +links. Hosted `Backend / test` is mandatory before merge and must preserve all +semantic lanes, API E2E, the repository-wide 78 percent floor, the applicable +authorization 90 percent floor, and an additive branch-aware 90 percent gate +for the narrow new project authorization-read composer. The existing broad +legacy project subsystem is not falsely presented as a 90 percent gate. ## Required reviewers -Senior engineering, QA/test, security/auth, product/ops, architecture, CI -integrity, docs, reuse/dedup, and test delta. +Preimplementation: senior engineering, QA/test, security/auth, product/ops, +architecture, and CI integrity. + +Implementation: senior engineering, QA/test, security/auth, product/ops, +architecture, CI integrity, docs, reuse/dedup, and test delta. ## Stop conditions -Stop if a diagnostic surface cannot be concealed without changing its mutation -lifecycle or if any route would retain token-role authority. +Stop and repair before proceeding if a route cannot use centralized +concealment, exact typed child binding, transaction-local revalidation, or a +single local-grant authority path; or if implementation requires a migration, +mutation behavior change, 11C2 route, compatibility fallback, weakened CI, or +cannot prove the narrow new composer at 90 percent branch coverage. diff --git a/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/reviews/WS-AUTH-001-11C1-external-review-response.md b/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/reviews/WS-AUTH-001-11C1-external-review-response.md new file mode 100644 index 00000000..7083214c --- /dev/null +++ b/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/reviews/WS-AUTH-001-11C1-external-review-response.md @@ -0,0 +1,60 @@ +# WS-AUTH-001-11C1 External Review Response + +## Comments addressed + +- Corrected the verifier-outage sentence in the chunk contract. +- Wrapped the three repository joins identified by CodeRabbit. +- Bounded each diagnostic collection lock and response to the newest 100 rows, + preserving deterministic newest-first ordering and matching the established + retained-diagnostic cap. +- Strengthened `ProjectDiagnosticReadResourceContext`: snapshot hashes must be + canonical SHA-256 digests, and existing non-collection targets must carry the + paired snapshot identifier and hash. Collections remain bound by their exact + ordered row-set digest. +- Narrowed the setup-run branch before accessing setup-run-only fields. +- Replaced the fixture's implicit submitter grant with an explicit, idempotent + Access Administrator bootstrap helper. +- Updated the two stale full-suite active-action expectations reported by the + first hosted Backend run. +- Updated the stale real-API E2E Project Manager action projection reported by + the second hosted Backend run; the production response already contained the + correct six newly active actions. +- Split the E2E policy-bundle helper's legacy mutation token from its locally + granted diagnostic-reader token after the third hosted run correctly denied + issuer-role-only access to the migrated GET routes. + +## Comments deferred or rejected + +- The post-authorization `target is None` guard remains a sanitized invariant + failure. Replacing it with a concealed 404 would hide a kernel defect if AUTH + ever allowed facts declaring `target_exists=False`; the normal missing-target + path is already concealed by the authorization dependency before this guard. +- The six reads remain serialized over actor, identity-link, and matched-grant + rows. That locking is required by the approved anti-stale and concurrent- + revocation contract. Removing it based only on a speculative contention note + would weaken the security property. The bounded child collection limits the + newly identified unbounded-lock risk. +- CodeRabbit's generic PR-description and docstring-coverage warnings do not + identify a missing public contract or undocumented production callable. The + PR links the repository trust bundle; production additions carry docstrings, + and repository CI—not the bot's heuristic—owns coverage thresholds. + +## Human decisions needed + +None. Human merge approval remains required after exact-head hosted checks pass. + +## Commands rerun + +- `uv run ruff check app tests scripts` +- Focused audit, authorization-context, diagnostic resource validation, + bounded-lock SQL, and six-action composer tests: 13 passed. +- Focused live PostgreSQL diagnostic-route proof could not start without + `WORKSTREAM_TEST_DATABASE_URL`; the corresponding hosted lane remains required. +- Hosted Backend, Agent Gates, and CodeRabbit: required again on the final head. + +## Remaining risks + +- A diagnostic list intentionally returns at most the newest 100 records; an + older exact record remains addressable through its individually authorized + read route. +- Hosted semantic lanes, API E2E, and coverage gates must pass on the final head. diff --git a/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/reviews/WS-AUTH-001-11C1-pr-trust-bundle.md b/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/reviews/WS-AUTH-001-11C1-pr-trust-bundle.md new file mode 100644 index 00000000..9a4cff1e --- /dev/null +++ b/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/reviews/WS-AUTH-001-11C1-pr-trust-bundle.md @@ -0,0 +1,131 @@ +# PR Trust Bundle: WS-AUTH-001-11C1 + +## Chunk + +`WS-AUTH-001-11C1` - Project Setup Diagnostic Read Cutover (L1/P1). + +## Goal And Human-Approved Intent + +Hard-cut exactly six project-guide diagnostic GET routes from issuer role +claims to scoped local administrative grants, without a migration, mutation +change, compatibility path, or 11C2 activation. The user started this chunk on +2026-07-28 after AUTH-11B merged. + +## What Changed And Why + +- Activated the six contract-owned ActionIds and preserved all three 11C2 + actions as planned. +- Added one project-side diagnostic composer used by all six routes. +- Added strict action/target-kind, project, guide/version, child/collection, + source-snapshot, and binding-digest authorization facts. +- Locked canonical project, guide, selected diagnostic rows, post-submit + policy, current actor/identity link, and matched grant through projection and + commit. +- Deleted the six obsolete service entry points that accepted `ActorContext` + and performed legacy role checks. +- Added centralized concealed denial, OpenAPI action declarations, persisted + decision-context digest evidence, docs, tests, and an additive hosted 90% + branch-coverage gate for the new composer. + +## Design Chosen + +`ProjectRepository` remains the only project persistence owner. A narrow +application composer loads and locks feature facts, AUTH evaluates one strict +resource context, and authorization-neutral response projection follows in the +same transaction. Historical diagnostic rows remain readable only when their +complete guide-version and source-snapshot binding is canonical. + +Rejected alternatives: retaining token-role fallback, duplicating six route +flows, adding an AUTH project repository, using an unbound project-only +context, or weakening the broad legacy project coverage boundary. + +## Scope And Product Behavior + +Allowed: covered Project Manager, scoped Audit Authority, and system Operator. +Denied with concealed 404: Finance Authority, Access Administrator, +contributors, wrong project scope, revoked grant/link, inactive actor, +non-human caller, missing/cross-project/cross-guide/stale child, and target-kind +mismatch. Authentication and rate controls retain canonical 401/503 and +429/503 responses before private lookup. Read permission grants no mutation +authority. + +## Acceptance Evidence And Test Delta + +- Six route/action OpenAPI declarations and closed-catalogue counts. +- Kernel allow/deny, target-kind, revalidation, and persisted digest evidence. +- Six-action composer unit matrix, missing/cross-binding failures, and locked + post-submit policy binding. +- Live database proof for all six routes, cross-project/cross-guide child + concealment, Project Manager/Operator/Audit allow, Finance/Access + Administrator/contributor/wrong-scope/revoked denial. +- Admission-order proof for 429, 503, 401, and verified non-human 404 before + project lookup. +- No tests removed, skipped, or weakened; legacy role checks remain only on + the non-11C1 mutation routes. + +## Checks Run + +- Ruff over `app`, `tests`, and `scripts`: pass. +- Focused API controls and authorization tests: 28 pass on final repaired code. +- Admission/API controls focused set: 48 pass. +- Six-action composer branch coverage: 94.68% (required 90%). +- Three focused live PostgreSQL route flows: 3 pass in 117.91 seconds. +- Agent Gates: 6 pass. +- Stale Workstream wording: pass. +- Stale authorization docs: pass. +- Markdown links: pass. +- `git diff --cached --check`: pass. +- Local API E2E attempt: not a pass; the slow local machine timed out reading + `/openapi.json` after both health probes passed. Hosted API E2E and the full + suite remain mandatory on the exact PR head. + +## CI Integrity + +No gate was weakened. Hosted Backend retains semantic lanes, API E2E, global +78%, authorization 90%, and all existing subsystem gates. The new composer +uses a separate coverage data file and a branch-aware 90% focused gate, so it +does not erase or replace combined full-suite coverage. + +## Reviewer Results + +Preimplementation architecture, security, product/ops, QA, CI-integrity, and +senior-engineering reviews passed after contract repair. Exact implementation +architecture, security, product/ops, senior-engineering, CI-integrity, docs, +reuse/dedup, and test-delta reviews pass. QA passes with the condition that +hosted Backend/API E2E evidence be recorded before completion. + +The CodeRabbit correction received focused architecture and security passes; +QA and test-delta passed with only the low residual risk that the 100-row cap is +proved at compiled-SQL rather than a 101-row live route fixture. Exact SQL shape, +ordering, lock target, and cap are asserted, and hosted PostgreSQL lanes remain +mandatory. + +## External Review And Remaining Risk + +The first hosted Backend run found two stale explicit active-action test +expectations; both were corrected without weakening exact equality. CodeRabbit's +valid findings were addressed with canonical snapshot validation, bounded +newest-first collection locks, setup-run type narrowing, an explicit fixture +bootstrap helper, repository formatting, and corrected contract wording. The +invariant-failure and authority-serialization suggestions were rejected because +they would hide a kernel defect or weaken the approved concurrent-revocation +boundary. Full rationale is recorded in the 11C1 external-review response. +Final-head Backend, Agent Gates, and CodeRabbit evidence remains mandatory. + +The next hosted run passed semantic lanes, migrations, health/OpenAPI/auth +probes, project creation, and the authorization-context request, then found one +stale real-API E2E expected action list. That exact list was updated with the six +11C1 actions; production behavior required no correction. + +The following hosted run advanced through that assertion and correctly denied +the policy-bundle helper's issuer-role-only token on a migrated diagnostic GET. +The E2E helper now uses its separately provisioned local Project Manager token +for the four 11C1 diagnostic reads while retaining the legacy manager token only +for not-yet-migrated setup mutations. + +## Follow-Up And Human Review Focus + +AUTH-11C2 remains separate and unstarted. Review the canonical snapshot joins, +post-submit policy binding, concealed response boundary, role matrix, and the +additive coverage step. The human owns merge approval; the agent must not merge +this PR without explicit approval for that PR. diff --git a/.github/workflows/backend.yml b/.github/workflows/backend.yml index 6a670884..66c892ba 100644 --- a/.github/workflows/backend.yml +++ b/.github/workflows/backend.yml @@ -221,6 +221,16 @@ jobs: working-directory: backend run: coverage report --include='app/modules/authorization/*' --precision=2 --fail-under=90 + - name: Project authorization-read composer coverage + working-directory: backend + run: | + COVERAGE_FILE=.coverage-project-auth-read pytest -q tests/test_projects.py \ + -k 'project_diagnostic_read_composer' \ + --cov=app.modules.projects.authorization_reads \ + --cov-branch \ + --cov-report=term-missing \ + --cov-fail-under=90 + - name: API controls coverage working-directory: backend run: >- diff --git a/backend/app/api/deps/authorization.py b/backend/app/api/deps/authorization.py index 78373e8b..02d2e855 100644 --- a/backend/app/api/deps/authorization.py +++ b/backend/app/api/deps/authorization.py @@ -40,6 +40,7 @@ HumanAuthorizationContext, IdentityLinkStatus, ProjectReadResourceContext, + ProjectDiagnosticReadResourceContext, ServiceAuthorizationContext, ) from app.modules.actors.service_identities import ServiceIdentity @@ -129,6 +130,12 @@ def authorization_http_error(exc: AuthorizationDenied) -> StructuredHTTPExceptio ActionId.PROJECT_ROLE_GRANT_READ, ActionId.PROJECT_READ, ActionId.ACTOR_AUTHORIZATION_CONTEXT_READ, + ActionId.PROJECT_SETUP_RUN_READ, + ActionId.PROJECT_GUIDE_SUFFICIENCY_REPORT_LIST, + ActionId.PROJECT_GUIDE_SUFFICIENCY_REPORT_READ, + ActionId.PROJECT_SUBMISSION_ARTIFACT_POLICY_LIST, + ActionId.PROJECT_SUBMISSION_ARTIFACT_POLICY_READ, + ActionId.PROJECT_POST_SUBMIT_CHECKER_POLICY_SETUP_READ, } if exc.decision.action_id in concealed_project_reads: return StructuredHTTPException( @@ -173,9 +180,7 @@ async def get_authorization_service( ) -> AsyncIterator[AuthorizationService]: """Yield one service and own final decision transaction cleanup.""" request_id, correlation_id = (UUID(value) for value in request_ids(request)) - service = _compose_authorization_service( - resolved, session, request_id, correlation_id - ) + service = _compose_authorization_service(resolved, session, request_id, correlation_id) actor_service = ActorService(session) context = _authorization_context(resolved, request_id, correlation_id) @@ -226,12 +231,16 @@ async def revalidate_actor_self( ActorSelfResourceContext | ActorAuthorizationContextResourceContext | ProjectReadResourceContext + | ProjectDiagnosticReadResourceContext ), ) -> AuthorizationContext: """Rebuild actor state from exact rows locked in the caller transaction.""" - if isinstance( - resource, (ActorSelfResourceContext, ActorAuthorizationContextResourceContext) - ) and resource.resource_id != context.actor_profile_id: + if ( + isinstance( + resource, (ActorSelfResourceContext, ActorAuthorizationContextResourceContext) + ) + and resource.resource_id != context.actor_profile_id + ): return context locked = await actor_service.lock_actor_self_for_authorization(resolved) return _authorization_context(locked, request_id, correlation_id) diff --git a/backend/app/modules/authorization/catalogue.py b/backend/app/modules/authorization/catalogue.py index 9e5b95ff..1002b24e 100644 --- a/backend/app/modules/authorization/catalogue.py +++ b/backend/app/modules/authorization/catalogue.py @@ -358,32 +358,32 @@ def _active( PermissionId.ACTOR_PROFILE_READ_SELF, ActionOwner.AUTH_11B, ), - _planned( + _active( ActionId.PROJECT_SETUP_RUN_READ, PermissionId.PROJECT_SETUP_DIAGNOSTIC_READ, ActionOwner.AUTH_11C1, ), - _planned( + _active( ActionId.PROJECT_GUIDE_SUFFICIENCY_REPORT_LIST, PermissionId.PROJECT_SETUP_DIAGNOSTIC_READ, ActionOwner.AUTH_11C1, ), - _planned( + _active( ActionId.PROJECT_GUIDE_SUFFICIENCY_REPORT_READ, PermissionId.PROJECT_SETUP_DIAGNOSTIC_READ, ActionOwner.AUTH_11C1, ), - _planned( + _active( ActionId.PROJECT_SUBMISSION_ARTIFACT_POLICY_LIST, PermissionId.PROJECT_EFFECTIVE_POLICY_READ, ActionOwner.AUTH_11C1, ), - _planned( + _active( ActionId.PROJECT_SUBMISSION_ARTIFACT_POLICY_READ, PermissionId.PROJECT_EFFECTIVE_POLICY_READ, ActionOwner.AUTH_11C1, ), - _planned( + _active( ActionId.PROJECT_POST_SUBMIT_CHECKER_POLICY_SETUP_READ, PermissionId.PROJECT_EFFECTIVE_POLICY_READ, ActionOwner.AUTH_11C1, @@ -681,6 +681,12 @@ def _index_actions( ActionId.PROJECT_ROLE_GRANT_REVOKE, ActionId.PROJECT_READ, ActionId.ACTOR_AUTHORIZATION_CONTEXT_READ, + ActionId.PROJECT_SETUP_RUN_READ, + ActionId.PROJECT_GUIDE_SUFFICIENCY_REPORT_LIST, + ActionId.PROJECT_GUIDE_SUFFICIENCY_REPORT_READ, + ActionId.PROJECT_SUBMISSION_ARTIFACT_POLICY_LIST, + ActionId.PROJECT_SUBMISSION_ARTIFACT_POLICY_READ, + ActionId.PROJECT_POST_SUBMIT_CHECKER_POLICY_SETUP_READ, ActionId.ARTIFACT_VERIFICATION_EXECUTE, ActionId.ARTIFACT_PENDING_WORK_SCAN, ActionId.ARTIFACT_PUT_ATTEMPT_RESOLVE, diff --git a/backend/app/modules/authorization/kernel.py b/backend/app/modules/authorization/kernel.py index 0c3cac70..42d1d12e 100644 --- a/backend/app/modules/authorization/kernel.py +++ b/backend/app/modules/authorization/kernel.py @@ -23,6 +23,7 @@ ) from app.modules.authorization.repository import AdminAuthorizationRepository from app.modules.authorization.runtime import ( + PROJECT_DIAGNOSTIC_TARGET_KIND_BY_ACTION, ActorAdminRoleGrantHistoryResourceContext, ActorAuthorizationContextResourceContext, ActorIdentityLinkAdminReadResourceContext, @@ -51,6 +52,7 @@ PermissionCatalogueResourceContext, ProjectContributorCandidateCollectionResourceContext, ProjectReadResourceContext, + ProjectDiagnosticReadResourceContext, ProjectRoleGrantCollectionResourceContext, ProjectRoleGrantIssueResourceContext, ProjectRoleGrantReadResourceContext, @@ -66,7 +68,9 @@ ContextRevalidator = Callable[ [ HumanAuthorizationContext, - ActorSelfResourceContext | ActorAuthorizationContextResourceContext | ProjectReadResourceContext, + ActorSelfResourceContext + | ActorAuthorizationContextResourceContext + | ProjectReadResourceContext, ], Awaitable[HumanAuthorizationContext], ] @@ -97,12 +101,24 @@ ActionId.PROJECT_CONTRIBUTOR_CANDIDATE_LIST, ActionId.PROJECT_ROLE_GRANT_LIST, ActionId.PROJECT_ROLE_GRANT_READ, + ActionId.PROJECT_SETUP_RUN_READ, + ActionId.PROJECT_GUIDE_SUFFICIENCY_REPORT_LIST, + ActionId.PROJECT_GUIDE_SUFFICIENCY_REPORT_READ, + ActionId.PROJECT_SUBMISSION_ARTIFACT_POLICY_LIST, + ActionId.PROJECT_SUBMISSION_ARTIFACT_POLICY_READ, + ActionId.PROJECT_POST_SUBMIT_CHECKER_POLICY_SETUP_READ, } ) _SERIALIZED_ADMIN_READS = frozenset( { ActionId.ACTOR_PROFILE_READ, ActionId.ACTOR_IDENTITY_LINK_READ, + ActionId.PROJECT_SETUP_RUN_READ, + ActionId.PROJECT_GUIDE_SUFFICIENCY_REPORT_LIST, + ActionId.PROJECT_GUIDE_SUFFICIENCY_REPORT_READ, + ActionId.PROJECT_SUBMISSION_ARTIFACT_POLICY_LIST, + ActionId.PROJECT_SUBMISSION_ARTIFACT_POLICY_READ, + ActionId.PROJECT_POST_SUBMIT_CHECKER_POLICY_SETUP_READ, } ) _ADMIN_MUTATIONS = frozenset( @@ -121,7 +137,10 @@ ) _ARTIFACT_INTERNAL_RESOURCES = { - ActionId.ARTIFACT_PUT_ATTEMPT_RESOLVE: ("artifact_put_attempt", ArtifactPutAttemptResourceContext), + ActionId.ARTIFACT_PUT_ATTEMPT_RESOLVE: ( + "artifact_put_attempt", + ArtifactPutAttemptResourceContext, + ), ActionId.ARTIFACT_VERIFICATION_EXECUTE: ( "artifact_verification_job", ArtifactVerificationJobResourceContext, @@ -344,12 +363,10 @@ async def _prepare_prelocked( 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, - ) + 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( @@ -708,7 +725,10 @@ async def _require_prelocked( expected_resource = _ARTIFACT_INTERNAL_RESOURCES.get(action_id) if denial is None and action.availability is not ActionAvailability.ACTIVE: denial = AuthorizationDenialCode.ACTION_UNAVAILABLE - if denial is None and action_id not in SERVICE_ACTIONS_BY_IDENTITY[context.service_identity]: + if ( + denial is None + and action_id not in SERVICE_ACTIONS_BY_IDENTITY[context.service_identity] + ): denial = AuthorizationDenialCode.PERMISSION_NOT_GRANTED if denial is None and ( expected_resource is None @@ -968,6 +988,9 @@ async def _admin_guard( elif isinstance(resource, ProjectContributorCandidateCollectionResourceContext): if not project_action_available_for_status(action_id, resource.project_status): return AuthorizationDenialCode.RESOURCE_GUARD_DENIED + elif isinstance(resource, ProjectDiagnosticReadResourceContext): + if not (resource.project_exists and resource.guide_exists and resource.target_exists): + return AuthorizationDenialCode.RESOURCE_NOT_FOUND return None @staticmethod @@ -997,9 +1020,20 @@ def _admin_resource_matches( ActionId.PROJECT_ROLE_GRANT_READ: ProjectRoleGrantReadResourceContext, ActionId.PROJECT_ROLE_GRANT_ISSUE: ProjectRoleGrantIssueResourceContext, ActionId.PROJECT_ROLE_GRANT_REVOKE: ProjectRoleGrantRevokeResourceContext, + ActionId.PROJECT_SETUP_RUN_READ: ProjectDiagnosticReadResourceContext, + ActionId.PROJECT_GUIDE_SUFFICIENCY_REPORT_LIST: ProjectDiagnosticReadResourceContext, + ActionId.PROJECT_GUIDE_SUFFICIENCY_REPORT_READ: ProjectDiagnosticReadResourceContext, + ActionId.PROJECT_SUBMISSION_ARTIFACT_POLICY_LIST: ProjectDiagnosticReadResourceContext, + ActionId.PROJECT_SUBMISSION_ARTIFACT_POLICY_READ: ProjectDiagnosticReadResourceContext, + ActionId.PROJECT_POST_SUBMIT_CHECKER_POLICY_SETUP_READ: ( + ProjectDiagnosticReadResourceContext + ), }.get(action_id) if expected is None or not isinstance(resource, expected): return False + diagnostic_kind = PROJECT_DIAGNOSTIC_TARGET_KIND_BY_ACTION.get(action_id) + if diagnostic_kind is not None and resource.target_kind != diagnostic_kind: + return False transition = { ActionId.ACTOR_PROFILE_SUSPEND: "suspend", ActionId.ACTOR_PROFILE_REACTIVATE: "reactivate", @@ -1121,6 +1155,7 @@ async def _stage_decision( "artifact_put_attempt", "artifact_verification_job", "artifact_pending_work", + "project_diagnostic", }: after_facts["resource_context_digest"] = decision.resource_context_digest try: diff --git a/backend/app/modules/authorization/runtime.py b/backend/app/modules/authorization/runtime.py index 7aff25f3..7dcf66e3 100644 --- a/backend/app/modules/authorization/runtime.py +++ b/backend/app/modules/authorization/runtime.py @@ -15,6 +15,17 @@ _STRICT_FROZEN = ConfigDict(extra="forbid", frozen=True, strict=True) +PROJECT_DIAGNOSTIC_TARGET_KIND_BY_ACTION = { + ActionId.PROJECT_SETUP_RUN_READ: "setup_run", + ActionId.PROJECT_GUIDE_SUFFICIENCY_REPORT_LIST: "sufficiency_report_collection", + ActionId.PROJECT_GUIDE_SUFFICIENCY_REPORT_READ: "sufficiency_report", + ActionId.PROJECT_SUBMISSION_ARTIFACT_POLICY_LIST: "submission_artifact_policy_collection", + ActionId.PROJECT_SUBMISSION_ARTIFACT_POLICY_READ: "submission_artifact_policy", + ActionId.PROJECT_POST_SUBMIT_CHECKER_POLICY_SETUP_READ: ( + "post_submit_checker_policy_setup" + ), +} + class ActorKind(StrEnum): """Canonical actor kinds visible to authorization.""" @@ -90,11 +101,14 @@ class PreparedAuthorityScope(BaseModel): target_actor_profile_id: UUID | None = None role: ProjectRole | None = None grant_id: UUID | None = None - artifact_resource_type: Literal[ - "artifact_put_attempt", - "artifact_verification_job", - "artifact_pending_work", - ] | None = None + artifact_resource_type: ( + Literal[ + "artifact_put_attempt", + "artifact_verification_job", + "artifact_pending_work", + ] + | None + ) = None artifact_resource_id: UUID | Literal["workstream:artifact_pending_work"] | None = None @model_validator(mode="after") @@ -125,10 +139,7 @@ def validate_selector(self): 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 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)) and self.artifact_resource_type is None and self.artifact_resource_id is None @@ -219,6 +230,57 @@ def require_exact_project(self): return self +class ProjectDiagnosticReadResourceContext(BaseModel): + """Canonical project-guide diagnostic facts for one bounded read.""" + + model_config = _STRICT_FROZEN + + resource_type: Literal["project_diagnostic"] + resource_id: UUID + scope_project_id: UUID + guide_id: UUID + guide_version: str | None + target_kind: Literal[ + "setup_run", + "sufficiency_report_collection", + "sufficiency_report", + "submission_artifact_policy_collection", + "submission_artifact_policy", + "post_submit_checker_policy_setup", + ] + project_exists: bool + guide_exists: bool + target_exists: bool + target_binding_digest: str | None = Field(default=None, pattern=r"^sha256:[0-9a-f]{64}$") + source_snapshot_id: UUID | None = None + source_snapshot_hash: str | None = Field( + default=None, pattern=r"^sha256:[0-9a-f]{64}$" + ) + + @model_validator(mode="after") + def require_canonical_shape(self): + """Reject fabricated child facts and partially bound snapshots.""" + if self.guide_exists and not self.project_exists: + raise ValueError("guide cannot exist without its project") + if self.target_exists and not self.guide_exists: + raise ValueError("diagnostic target cannot exist without its guide") + if self.guide_exists != (self.guide_version is not None): + raise ValueError("guide existence and version are inconsistent") + if (self.source_snapshot_id is None) != (self.source_snapshot_hash is None): + raise ValueError("source snapshot id and hash must be bound together") + if not self.target_exists and self.source_snapshot_id is not None: + raise ValueError("missing diagnostic target cannot carry snapshot facts") + if ( + self.target_exists + and not self.target_kind.endswith("_collection") + and self.source_snapshot_id is None + ): + raise ValueError("existing diagnostic target requires snapshot facts") + if self.target_exists != (self.target_binding_digest is not None): + raise ValueError("target existence and binding digest are inconsistent") + return self + + class ActorAuthorizationContextResourceContext(BaseModel): """Self-owned selector for authority projected onto one project.""" @@ -500,6 +562,7 @@ def bind_page_size(self): AuthorizationResourceContext = ( ActorSelfResourceContext | ProjectReadResourceContext + | ProjectDiagnosticReadResourceContext | ActorAuthorizationContextResourceContext | ActorProfileAdminReadResourceContext | ActorIdentityLinkAdminReadResourceContext @@ -580,6 +643,7 @@ class AuthorizationDecision(BaseModel): "actor_profile", "actor_authorization_context", "project", + "project_diagnostic", "actor_identity_link", "system", "permission_catalogue", diff --git a/backend/app/modules/projects/authorization_reads.py b/backend/app/modules/projects/authorization_reads.py new file mode 100644 index 00000000..86fd6f6c --- /dev/null +++ b/backend/app/modules/projects/authorization_reads.py @@ -0,0 +1,171 @@ +"""Canonical composition for project setup diagnostic authorization reads.""" + +from __future__ import annotations + +from collections.abc import Sequence +from typing import TypeAlias +from uuid import UUID + +from app.core.hashing import canonical_json_hash +from app.modules.authorization.catalogue import ActionId +from app.modules.authorization.kernel import AuthorizationService +from app.modules.authorization.runtime import ( + PROJECT_DIAGNOSTIC_TARGET_KIND_BY_ACTION, + ProjectDiagnosticReadResourceContext, + authorization_resource_selector_id, +) +from app.modules.projects.models import ( + GuideSufficiencyReport, + PostSubmitCheckerPolicy, + ProjectSetupRun, + SubmissionArtifactPolicy, +) +from app.modules.projects.repository import ProjectRepository + +DiagnosticRecord: TypeAlias = ProjectSetupRun | GuideSufficiencyReport | SubmissionArtifactPolicy +DiagnosticResult: TypeAlias = ( + DiagnosticRecord + | Sequence[DiagnosticRecord] + | tuple[ProjectSetupRun, PostSubmitCheckerPolicy | None] +) + +async def authorize_project_diagnostic_read( + *, + authorization: AuthorizationService, + repository: ProjectRepository, + action_id: ActionId, + project_id: str, + guide_id: str, + target_id: str | None = None, +) -> DiagnosticResult: + """Lock canonical facts, require exact scoped authority, and return them.""" + if action_id not in PROJECT_DIAGNOSTIC_TARGET_KIND_BY_ACTION: + raise ValueError("unsupported project diagnostic read action") + + project = await repository.get_project(project_id, for_update=True) + guide = await repository.lock_project_guide(guide_id) if project is not None else None + if guide is not None and guide.project_id != project_id: + guide = None + + target: DiagnosticResult | None = None + post_submit_policy = None + if guide is not None: + if action_id in { + ActionId.PROJECT_SETUP_RUN_READ, + ActionId.PROJECT_POST_SUBMIT_CHECKER_POLICY_SETUP_READ, + }: + setup_run = await repository.lock_latest_project_setup_run( + project_id, guide_id, guide.version + ) + target = setup_run + if ( + action_id is ActionId.PROJECT_POST_SUBMIT_CHECKER_POLICY_SETUP_READ + and setup_run is not None + and setup_run.output_post_submit_checker_policy_id is not None + ): + post_submit_policy = await repository.lock_post_submit_checker_policy( + setup_run.output_post_submit_checker_policy_id + ) + if post_submit_policy is None or any( + getattr(post_submit_policy, field) != getattr(setup_run, field) + for field in ( + "project_id", + "guide_id", + "guide_version", + "source_snapshot_id", + "source_snapshot_hash", + ) + ): + target = None + post_submit_policy = None + elif action_id is ActionId.PROJECT_GUIDE_SUFFICIENCY_REPORT_LIST: + target = await repository.lock_guide_sufficiency_reports( + project_id, guide_id, guide.version + ) + elif action_id is ActionId.PROJECT_GUIDE_SUFFICIENCY_REPORT_READ and target_id: + target = await repository.lock_guide_sufficiency_report( + target_id, project_id, guide_id, guide.version + ) + elif action_id is ActionId.PROJECT_SUBMISSION_ARTIFACT_POLICY_LIST: + target = await repository.lock_submission_artifact_policies( + project_id, guide_id, guide.version + ) + elif action_id is ActionId.PROJECT_SUBMISSION_ARTIFACT_POLICY_READ and target_id: + target = await repository.lock_submission_artifact_policy_diagnostic( + target_id, project_id, guide_id, guide.version + ) + + is_collection = action_id in { + ActionId.PROJECT_GUIDE_SUFFICIENCY_REPORT_LIST, + ActionId.PROJECT_SUBMISSION_ARTIFACT_POLICY_LIST, + } + target_exists = guide is not None and (is_collection or target is not None) + record = None if target is None or is_collection else target + if record is not None and (record.project_id != project_id or record.guide_id != guide_id): + target_exists = False + record = None + target = None + + records = list(target) if is_collection and target is not None else ([record] if record else []) + if post_submit_policy is not None: + records.append(post_submit_policy) + target_binding_digest = ( + canonical_json_hash( + [ + { + "id": item.id, + "project_id": item.project_id, + "guide_id": item.guide_id, + "guide_version": item.guide_version, + "source_snapshot_id": item.source_snapshot_id, + "source_snapshot_hash": item.source_snapshot_hash, + } + for item in records + ] + ) + if target_exists + else None + ) + + project_uuid = ( + UUID(project.id) + if project is not None + else authorization_resource_selector_id("project", project_id) + ) + guide_uuid = ( + UUID(guide.id) + if guide is not None + else authorization_resource_selector_id("project_guide", guide_id) + ) + if record is not None: + resource_id = UUID(record.id) + elif is_collection and guide is not None: + resource_id = guide_uuid + else: + resource_id = authorization_resource_selector_id( + PROJECT_DIAGNOSTIC_TARGET_KIND_BY_ACTION[action_id], + target_id or f"{project_id}:{guide_id}", + ) + + await authorization.require( + action_id, + ProjectDiagnosticReadResourceContext( + resource_type="project_diagnostic", + resource_id=resource_id, + scope_project_id=project_uuid, + guide_id=guide_uuid, + guide_version=guide.version if guide is not None else None, + target_kind=PROJECT_DIAGNOSTIC_TARGET_KIND_BY_ACTION[action_id], + project_exists=project is not None, + guide_exists=guide is not None, + target_exists=target_exists, + target_binding_digest=target_binding_digest, + source_snapshot_id=(UUID(record.source_snapshot_id) if record is not None else None), + source_snapshot_hash=(record.source_snapshot_hash if record is not None else None), + ), + ) + if target is None: + raise RuntimeError("missing diagnostic authorization unexpectedly allowed") + if action_id is ActionId.PROJECT_POST_SUBMIT_CHECKER_POLICY_SETUP_READ: + return (target, post_submit_policy) + return target diff --git a/backend/app/modules/projects/repository.py b/backend/app/modules/projects/repository.py index a35556c1..f6f74d78 100644 --- a/backend/app/modules/projects/repository.py +++ b/backend/app/modules/projects/repository.py @@ -122,9 +122,7 @@ async def get_guide(self, guide_id: str) -> ProjectGuide | None: async def lock_project_guide(self, guide_id: str) -> ProjectGuide | None: """Load one project guide with a transactional row lock.""" result = await self._session.execute( - select(ProjectGuide) - .where(ProjectGuide.id == guide_id) - .with_for_update() + select(ProjectGuide).where(ProjectGuide.id == guide_id).with_for_update() ) return result.scalar_one_or_none() @@ -295,9 +293,7 @@ async def get_project_setup_run(self, setup_run_id: str) -> ProjectSetupRun | No async def lock_project_setup_run(self, setup_run_id: str) -> ProjectSetupRun | None: """Load one project setup run with a transactional row lock.""" result = await self._session.execute( - select(ProjectSetupRun) - .where(ProjectSetupRun.id == setup_run_id) - .with_for_update() + select(ProjectSetupRun).where(ProjectSetupRun.id == setup_run_id).with_for_update() ) return result.scalar_one_or_none() @@ -309,10 +305,41 @@ async def get_latest_project_setup_run( """Load the latest setup run for one project guide.""" result = await self._session.execute( select(ProjectSetupRun) - .join(GuideSourceSnapshot, ProjectSetupRun.source_snapshot_id == GuideSourceSnapshot.id) + .join( + GuideSourceSnapshot, + ProjectSetupRun.source_snapshot_id == GuideSourceSnapshot.id, + ) + .where( + ProjectSetupRun.project_id == project_id, + ProjectSetupRun.guide_id == guide_id, + ) + .order_by( + GuideSourceSnapshot.captured_at.desc(), + ProjectSetupRun.created_at.desc(), + ProjectSetupRun.id.desc(), + ) + .limit(1) + ) + return result.scalar_one_or_none() + + async def lock_latest_project_setup_run( + self, project_id: str, guide_id: str, guide_version: str + ) -> ProjectSetupRun | None: + """Lock the latest setup-run row selected for diagnostic projection.""" + result = await self._session.execute( + select(ProjectSetupRun) + .join( + GuideSourceSnapshot, + ProjectSetupRun.source_snapshot_id == GuideSourceSnapshot.id, + ) .where( ProjectSetupRun.project_id == project_id, ProjectSetupRun.guide_id == guide_id, + ProjectSetupRun.guide_version == guide_version, + GuideSourceSnapshot.project_id == ProjectSetupRun.project_id, + GuideSourceSnapshot.guide_id == ProjectSetupRun.guide_id, + GuideSourceSnapshot.guide_version == ProjectSetupRun.guide_version, + GuideSourceSnapshot.bundle_hash == ProjectSetupRun.source_snapshot_hash, ) .order_by( GuideSourceSnapshot.captured_at.desc(), @@ -320,6 +347,7 @@ async def get_latest_project_setup_run( ProjectSetupRun.id.desc(), ) .limit(1) + .with_for_update(of=ProjectSetupRun) ) return result.scalar_one_or_none() @@ -347,6 +375,30 @@ async def get_guide_sufficiency_report( """Load one guide sufficiency report by primary key.""" return await self._session.get(GuideSufficiencyReport, report_id) + async def lock_guide_sufficiency_report( + self, report_id: str, project_id: str, guide_id: str, guide_version: str + ) -> GuideSufficiencyReport | None: + """Lock one sufficiency report for authorization and projection.""" + result = await self._session.execute( + select(GuideSufficiencyReport) + .join( + GuideSourceSnapshot, + GuideSufficiencyReport.source_snapshot_id == GuideSourceSnapshot.id, + ) + .where( + GuideSufficiencyReport.id == report_id, + GuideSufficiencyReport.project_id == project_id, + GuideSufficiencyReport.guide_id == guide_id, + GuideSufficiencyReport.guide_version == guide_version, + GuideSourceSnapshot.project_id == GuideSufficiencyReport.project_id, + GuideSourceSnapshot.guide_id == GuideSufficiencyReport.guide_id, + GuideSourceSnapshot.guide_version == GuideSufficiencyReport.guide_version, + GuideSourceSnapshot.bundle_hash == GuideSufficiencyReport.source_snapshot_hash, + ) + .with_for_update(of=GuideSufficiencyReport) + ) + return result.scalar_one_or_none() + async def list_guide_sufficiency_reports( self, project_id: str, @@ -363,6 +415,31 @@ async def list_guide_sufficiency_reports( ) return result.scalars().all() + async def lock_guide_sufficiency_reports( + self, project_id: str, guide_id: str, guide_version: str + ) -> Sequence[GuideSufficiencyReport]: + """Lock the bounded report collection used by a diagnostic list.""" + result = await self._session.execute( + select(GuideSufficiencyReport) + .join( + GuideSourceSnapshot, + GuideSufficiencyReport.source_snapshot_id == GuideSourceSnapshot.id, + ) + .where( + GuideSufficiencyReport.project_id == project_id, + GuideSufficiencyReport.guide_id == guide_id, + GuideSufficiencyReport.guide_version == guide_version, + GuideSourceSnapshot.project_id == GuideSufficiencyReport.project_id, + GuideSourceSnapshot.guide_id == GuideSufficiencyReport.guide_id, + GuideSourceSnapshot.guide_version == GuideSufficiencyReport.guide_version, + GuideSourceSnapshot.bundle_hash == GuideSufficiencyReport.source_snapshot_hash, + ) + .order_by(GuideSufficiencyReport.created_at.desc(), GuideSufficiencyReport.id.desc()) + .limit(100) + .with_for_update(of=GuideSufficiencyReport) + ) + return result.scalars().all() + async def get_sufficiency_report_for_snapshot( self, snapshot_id: str, @@ -418,6 +495,33 @@ async def list_submission_artifact_policies( ) return result.scalars().all() + async def lock_submission_artifact_policies( + self, project_id: str, guide_id: str, guide_version: str + ) -> Sequence[SubmissionArtifactPolicy]: + """Lock the bounded draft-policy collection used by a diagnostic list.""" + result = await self._session.execute( + select(SubmissionArtifactPolicy) + .join( + GuideSourceSnapshot, + SubmissionArtifactPolicy.source_snapshot_id == GuideSourceSnapshot.id, + ) + .where( + SubmissionArtifactPolicy.project_id == project_id, + SubmissionArtifactPolicy.guide_id == guide_id, + SubmissionArtifactPolicy.guide_version == guide_version, + GuideSourceSnapshot.project_id == SubmissionArtifactPolicy.project_id, + GuideSourceSnapshot.guide_id == SubmissionArtifactPolicy.guide_id, + GuideSourceSnapshot.guide_version == SubmissionArtifactPolicy.guide_version, + GuideSourceSnapshot.bundle_hash == SubmissionArtifactPolicy.source_snapshot_hash, + ) + .order_by( + SubmissionArtifactPolicy.created_at.desc(), SubmissionArtifactPolicy.id.desc() + ) + .limit(100) + .with_for_update(of=SubmissionArtifactPolicy) + ) + return result.scalars().all() + async def get_agent_derived_submission_artifact_policy_for_snapshot( self, project_id: str, @@ -455,6 +559,30 @@ async def lock_submission_artifact_policy( ) return result.scalar_one_or_none() + async def lock_submission_artifact_policy_diagnostic( + self, policy_id: str, project_id: str, guide_id: str, guide_version: str + ) -> SubmissionArtifactPolicy | None: + """Lock one policy only when its complete snapshot binding is canonical.""" + result = await self._session.execute( + select(SubmissionArtifactPolicy) + .join( + GuideSourceSnapshot, + SubmissionArtifactPolicy.source_snapshot_id == GuideSourceSnapshot.id, + ) + .where( + SubmissionArtifactPolicy.id == policy_id, + SubmissionArtifactPolicy.project_id == project_id, + SubmissionArtifactPolicy.guide_id == guide_id, + SubmissionArtifactPolicy.guide_version == guide_version, + GuideSourceSnapshot.project_id == SubmissionArtifactPolicy.project_id, + GuideSourceSnapshot.guide_id == SubmissionArtifactPolicy.guide_id, + GuideSourceSnapshot.guide_version == SubmissionArtifactPolicy.guide_version, + GuideSourceSnapshot.bundle_hash == SubmissionArtifactPolicy.source_snapshot_hash, + ) + .with_for_update(of=SubmissionArtifactPolicy) + ) + return result.scalar_one_or_none() + async def get_approved_submission_artifact_policy( self, project_id: str, @@ -566,9 +694,7 @@ async def get_pre_submit_checker_policy_for_effective_policy( result = await self._session.execute( select(PreSubmitCheckerPolicy).where( PreSubmitCheckerPolicy.effective_policy_id == effective_policy_id, - PreSubmitCheckerPolicy.lifecycle_status.in_( - ["pending_compilation", "compiled"] - ), + PreSubmitCheckerPolicy.lifecycle_status.in_(["pending_compilation", "compiled"]), ) ) rows = result.scalars().all() @@ -597,9 +723,7 @@ async def get_current_pre_submit_checker_policy( select(PreSubmitCheckerPolicy).where( PreSubmitCheckerPolicy.project_id == project_id, PreSubmitCheckerPolicy.guide_version == guide_version, - PreSubmitCheckerPolicy.lifecycle_status.in_( - ["pending_compilation", "compiled"] - ), + PreSubmitCheckerPolicy.lifecycle_status.in_(["pending_compilation", "compiled"]), ) ) return self._resolve_current_append_only_row( diff --git a/backend/app/modules/projects/router.py b/backend/app/modules/projects/router.py index b26fe4ed..9c68b8c9 100644 --- a/backend/app/modules/projects/router.py +++ b/backend/app/modules/projects/router.py @@ -49,6 +49,8 @@ SubmissionArtifactPolicyUpdate, ) from app.modules.projects.service import ProjectService, ProjectServiceError +from app.modules.projects.authorization_reads import authorize_project_diagnostic_read +from app.modules.projects.repository import ProjectRepository from app.modules.authorization.catalogue import ActionId from app.modules.authorization.kernel import AuthorizationService from app.modules.authorization.runtime import ( @@ -260,72 +262,78 @@ async def ingest_guide_source_artifact( @router.get( "/{project_id}/guides/{guide_id}/setup-runs/latest", response_model=ProjectSetupRunResponse, + openapi_extra={"x-workstream-action-id": ActionId.PROJECT_SETUP_RUN_READ.value}, + dependencies=[Depends(enforce_human_authorization_read)], ) async def get_latest_project_setup_run( project_id: str, guide_id: str, - actor: Annotated[ActorContext, Depends(get_registered_actor)], + authorization: Annotated[AuthorizationService, Depends(get_authorization_service)], session: Annotated[AsyncSession, Depends(get_db_session)], ) -> ProjectSetupRunResponse: """Return the latest automatic setup run for one project guide.""" - try: - return await ProjectService(session).get_latest_project_setup_run( - actor, - project_id, - guide_id, - ) - except PermissionDenied as exc: - raise permission_http_error(exc) from exc - except ProjectServiceError as exc: - raise project_http_error(exc) from exc + run = await authorize_project_diagnostic_read( + authorization=authorization, + repository=ProjectRepository(session), + action_id=ActionId.PROJECT_SETUP_RUN_READ, + project_id=project_id, + guide_id=guide_id, + ) + response = ProjectSetupRunResponse.model_validate(run) + await session.commit() + return response @router.get( "/{project_id}/guides/{guide_id}/sufficiency-reports", response_model=list[GuideSufficiencyReportResponse], + openapi_extra={"x-workstream-action-id": ActionId.PROJECT_GUIDE_SUFFICIENCY_REPORT_LIST.value}, + dependencies=[Depends(enforce_human_authorization_read)], ) async def list_guide_sufficiency_reports( project_id: str, guide_id: str, - actor: Annotated[ActorContext, Depends(get_registered_actor)], + authorization: Annotated[AuthorizationService, Depends(get_authorization_service)], session: Annotated[AsyncSession, Depends(get_db_session)], ) -> list[GuideSufficiencyReportResponse]: """List guide sufficiency reports for one project guide.""" - try: - return await ProjectService(session).list_guide_sufficiency_reports( - actor, - project_id, - guide_id, - ) - except PermissionDenied as exc: - raise permission_http_error(exc) from exc - except ProjectServiceError as exc: - raise project_http_error(exc) from exc + reports = await authorize_project_diagnostic_read( + authorization=authorization, + repository=ProjectRepository(session), + action_id=ActionId.PROJECT_GUIDE_SUFFICIENCY_REPORT_LIST, + project_id=project_id, + guide_id=guide_id, + ) + response = [GuideSufficiencyReportResponse.model_validate(report) for report in reports] + await session.commit() + return response @router.get( "/{project_id}/guides/{guide_id}/sufficiency-reports/{report_id}", response_model=GuideSufficiencyReportResponse, + openapi_extra={"x-workstream-action-id": ActionId.PROJECT_GUIDE_SUFFICIENCY_REPORT_READ.value}, + dependencies=[Depends(enforce_human_authorization_read)], ) async def get_guide_sufficiency_report( project_id: str, guide_id: str, report_id: str, - actor: Annotated[ActorContext, Depends(get_registered_actor)], + authorization: Annotated[AuthorizationService, Depends(get_authorization_service)], session: Annotated[AsyncSession, Depends(get_db_session)], ) -> GuideSufficiencyReportResponse: """Return one guide sufficiency report for one project guide.""" - try: - return await ProjectService(session).get_guide_sufficiency_report( - actor, - project_id, - guide_id, - report_id, - ) - except PermissionDenied as exc: - raise permission_http_error(exc) from exc - except ProjectServiceError as exc: - raise project_http_error(exc) from exc + report = await authorize_project_diagnostic_read( + authorization=authorization, + repository=ProjectRepository(session), + action_id=ActionId.PROJECT_GUIDE_SUFFICIENCY_REPORT_READ, + project_id=project_id, + guide_id=guide_id, + target_id=report_id, + ) + response = GuideSufficiencyReportResponse.model_validate(report) + await session.commit() + return response @router.post( @@ -357,49 +365,57 @@ async def create_guide_sufficiency_report( @router.get( "/{project_id}/guides/{guide_id}/submission-artifact-policies", response_model=list[SubmissionArtifactPolicyResponse], + openapi_extra={ + "x-workstream-action-id": ActionId.PROJECT_SUBMISSION_ARTIFACT_POLICY_LIST.value + }, + dependencies=[Depends(enforce_human_authorization_read)], ) async def list_submission_artifact_policies( project_id: str, guide_id: str, - actor: Annotated[ActorContext, Depends(get_registered_actor)], + authorization: Annotated[AuthorizationService, Depends(get_authorization_service)], session: Annotated[AsyncSession, Depends(get_db_session)], ) -> list[SubmissionArtifactPolicyResponse]: """List submission artifact policies for one project guide.""" - try: - return await ProjectService(session).list_submission_artifact_policies( - actor, - project_id, - guide_id, - ) - except PermissionDenied as exc: - raise permission_http_error(exc) from exc - except ProjectServiceError as exc: - raise project_http_error(exc) from exc + policies = await authorize_project_diagnostic_read( + authorization=authorization, + repository=ProjectRepository(session), + action_id=ActionId.PROJECT_SUBMISSION_ARTIFACT_POLICY_LIST, + project_id=project_id, + guide_id=guide_id, + ) + response = [SubmissionArtifactPolicyResponse.model_validate(policy) for policy in policies] + await session.commit() + return response @router.get( "/{project_id}/guides/{guide_id}/submission-artifact-policies/{policy_id}", response_model=SubmissionArtifactPolicyResponse, + openapi_extra={ + "x-workstream-action-id": ActionId.PROJECT_SUBMISSION_ARTIFACT_POLICY_READ.value + }, + dependencies=[Depends(enforce_human_authorization_read)], ) async def get_submission_artifact_policy( project_id: str, guide_id: str, policy_id: str, - actor: Annotated[ActorContext, Depends(get_registered_actor)], + authorization: Annotated[AuthorizationService, Depends(get_authorization_service)], session: Annotated[AsyncSession, Depends(get_db_session)], ) -> SubmissionArtifactPolicyResponse: """Return one submission artifact policy for one project guide.""" - try: - return await ProjectService(session).get_submission_artifact_policy( - actor, - project_id, - guide_id, - policy_id, - ) - except PermissionDenied as exc: - raise permission_http_error(exc) from exc - except ProjectServiceError as exc: - raise project_http_error(exc) from exc + policy = await authorize_project_diagnostic_read( + authorization=authorization, + repository=ProjectRepository(session), + action_id=ActionId.PROJECT_SUBMISSION_ARTIFACT_POLICY_READ, + project_id=project_id, + guide_id=guide_id, + target_id=policy_id, + ) + response = SubmissionArtifactPolicyResponse.model_validate(policy) + await session.commit() + return response @router.post( @@ -630,24 +646,28 @@ async def get_current_pre_submit_checker_policy( @router.get( "/{project_id}/guides/{guide_id}/post-submit-checker-policy/setup", response_model=PostSubmitCheckerPolicySetupResponse, + openapi_extra={ + "x-workstream-action-id": ActionId.PROJECT_POST_SUBMIT_CHECKER_POLICY_SETUP_READ.value + }, + dependencies=[Depends(enforce_human_authorization_read)], ) async def get_current_post_submit_checker_policy_setup( project_id: str, guide_id: str, - actor: Annotated[ActorContext, Depends(get_registered_actor)], + authorization: Annotated[AuthorizationService, Depends(get_authorization_service)], session: Annotated[AsyncSession, Depends(get_db_session)], ) -> PostSubmitCheckerPolicySetupResponse: """Return current generated post-submit checker setup status.""" - try: - return await ProjectService(session).get_current_post_submit_checker_policy_setup( - actor, - project_id, - guide_id, - ) - except PermissionDenied as exc: - raise permission_http_error(exc) from exc - except ProjectServiceError as exc: - raise project_http_error(exc) from exc + run, policy = await authorize_project_diagnostic_read( + authorization=authorization, + repository=ProjectRepository(session), + action_id=ActionId.PROJECT_POST_SUBMIT_CHECKER_POLICY_SETUP_READ, + project_id=project_id, + guide_id=guide_id, + ) + response = await ProjectService(session).post_submit_policy_setup_response(run, policy) + await session.commit() + return response @router.post( diff --git a/backend/app/modules/projects/service.py b/backend/app/modules/projects/service.py index 5c472bb7..9e551606 100644 --- a/backend/app/modules/projects/service.py +++ b/backend/app/modules/projects/service.py @@ -693,74 +693,6 @@ async def create_guide_source_snapshot( ) return await self._source_snapshot_response(snapshot) - async def get_latest_project_setup_run( - self, - actor: ActorContext, - project_id: str, - guide_id: str, - ) -> ProjectSetupRunResponse: - """Return the latest automatic setup run for one project guide.""" - require_any_role(actor, PROJECT_SETUP_ROLES) - guide = await self._get_project_guide(project_id, guide_id) - setup_run = await self._repo.get_latest_project_setup_run(project_id, guide.id) - if setup_run is None: - raise ProjectSetupRunNotFound("project setup run not found") - return ProjectSetupRunResponse.model_validate(setup_run) - - async def list_guide_sufficiency_reports( - self, - actor: ActorContext, - project_id: str, - guide_id: str, - ) -> list[GuideSufficiencyReportResponse]: - """List guide sufficiency reports for one project guide.""" - require_any_role(actor, PROJECT_SETUP_ROLES) - guide = await self._get_project_guide(project_id, guide_id) - reports = await self._repo.list_guide_sufficiency_reports(project_id, guide.id) - return [GuideSufficiencyReportResponse.model_validate(report) for report in reports] - - async def get_guide_sufficiency_report( - self, - actor: ActorContext, - project_id: str, - guide_id: str, - report_id: str, - ) -> GuideSufficiencyReportResponse: - """Return one guide sufficiency report scoped to a project guide.""" - require_any_role(actor, PROJECT_SETUP_ROLES) - guide = await self._get_project_guide(project_id, guide_id) - report = await self._repo.get_guide_sufficiency_report(report_id) - if report is None or report.project_id != project_id or report.guide_id != guide.id: - raise SufficiencyReportNotFound("guide sufficiency report not found") - return GuideSufficiencyReportResponse.model_validate(report) - - async def list_submission_artifact_policies( - self, - actor: ActorContext, - project_id: str, - guide_id: str, - ) -> list[SubmissionArtifactPolicyResponse]: - """List submission artifact policies for one project guide.""" - require_any_role(actor, PROJECT_SETUP_ROLES) - guide = await self._get_project_guide(project_id, guide_id) - policies = await self._repo.list_submission_artifact_policies(project_id, guide.id) - return [SubmissionArtifactPolicyResponse.model_validate(policy) for policy in policies] - - async def get_submission_artifact_policy( - self, - actor: ActorContext, - project_id: str, - guide_id: str, - policy_id: str, - ) -> SubmissionArtifactPolicyResponse: - """Return one submission artifact policy scoped to a project guide.""" - require_any_role(actor, PROJECT_SETUP_ROLES) - guide = await self._get_project_guide(project_id, guide_id) - policy = await self._repo.get_submission_artifact_policy(policy_id) - if policy is None or policy.project_id != project_id or policy.guide_id != guide.id: - raise SubmissionArtifactPolicyNotFound("submission artifact policy not found") - return SubmissionArtifactPolicyResponse.model_validate(policy) - async def get_current_effective_submission_artifact_policy( self, actor: ActorContext, @@ -834,21 +766,6 @@ async def get_current_pre_submit_checker_policy( raise PreSubmitCheckerPolicyNotFound("pre-submit checker policy not found") return PreSubmitCheckerPolicySummaryResponse.model_validate(policy) - async def get_current_post_submit_checker_policy_setup( - self, - actor: ActorContext, - project_id: str, - guide_id: str, - ) -> PostSubmitCheckerPolicySetupResponse: - """Return the current generated post-submit setup status for operators.""" - require_any_role(actor, PROJECT_SETUP_ROLES) - guide = await self._get_project_guide(project_id, guide_id) - setup_run = await self._repo.get_latest_project_setup_run(project_id, guide.id) - if setup_run is None: - raise ProjectSetupRunNotFound("project setup run not found") - policy = await self._post_submit_policy_from_setup_run(setup_run) - return await self._post_submit_policy_setup_response(setup_run, policy) - async def approve_current_post_submit_checker_policy( self, actor: ActorContext, @@ -2841,6 +2758,14 @@ async def _post_submit_policy_setup_response( correction_history=await self._post_submit_policy_correction_history(setup_run), ) + async def post_submit_policy_setup_response( + self, + setup_run: ProjectSetupRun, + policy: PostSubmitCheckerPolicy | None, + ) -> PostSubmitCheckerPolicySetupResponse: + """Project one already-authorized and transaction-locked setup result.""" + return await self._post_submit_policy_setup_response(setup_run, policy) + async def _post_submit_policy_correction_history( self, setup_run: ProjectSetupRun, diff --git a/backend/scripts/api_contract_e2e.py b/backend/scripts/api_contract_e2e.py index 5fca5626..be603879 100644 --- a/backend/scripts/api_contract_e2e.py +++ b/backend/scripts/api_contract_e2e.py @@ -652,6 +652,7 @@ def submission_artifact_policy_body() -> dict: async def create_policy_bundle_for_guide( client: httpx.AsyncClient, manager_token: str, + diagnostic_reader_token: str, manager_subject: str, project_id: str, guide_id: str, @@ -666,6 +667,7 @@ async def create_policy_bundle_for_guide( Args: client: HTTP client pointed at the running API. manager_token: Flow token with project manager role. + diagnostic_reader_token: Token bound to the local Project Manager grant. project_id: Project id that owns the guide. guide_id: Guide id to bind. run_id: Unique run id used for deterministic source hashes. @@ -708,7 +710,7 @@ async def create_policy_bundle_for_guide( client, "GET", f"/api/v1/projects/{project_id}/guides/{guide_id}/sufficiency-reports", - manager_token, + diagnostic_reader_token, ) ensure(isinstance(reports, list), "sufficiency report list did not return a list") ensure(len(reports) == 1, f"expected one sufficiency report, got {len(reports)}") @@ -717,7 +719,7 @@ async def create_policy_bundle_for_guide( client, "GET", f"/api/v1/projects/{project_id}/guides/{guide_id}/sufficiency-reports/{report['id']}", - manager_token, + diagnostic_reader_token, ) policy = await request_json( client, @@ -735,7 +737,7 @@ async def create_policy_bundle_for_guide( client, "GET", f"/api/v1/projects/{project_id}/guides/{guide_id}/submission-artifact-policies", - manager_token, + diagnostic_reader_token, ) ensure(isinstance(policies, list), "submission artifact policy list did not return a list") ensure(len(policies) == 1, f"expected one submission artifact policy, got {len(policies)}") @@ -744,7 +746,7 @@ async def create_policy_bundle_for_guide( client, "GET", f"/api/v1/projects/{project_id}/guides/{guide_id}/submission-artifact-policies/{policy['id']}", - manager_token, + diagnostic_reader_token, ) effective_policy = await request_json( client, @@ -1284,7 +1286,13 @@ async def exercise_api_contract(base_url: str, env: dict[str, str]) -> None: assert actor_context["project_roles"] == [] assert actor_context["effective_action_ids"] == [ "project.contributor_candidate.list", + "project.guide_sufficiency_report.list", + "project.guide_sufficiency_report.read", + "project.post_submit_checker_policy_setup.read", "project.read", + "project.setup_run.read", + "project.submission_artifact_policy.list", + "project.submission_artifact_policy.read", "project_role_grant.issue", "project_role_grant.list", "project_role_grant.read", @@ -1340,6 +1348,7 @@ async def exercise_api_contract(base_url: str, env: dict[str, str]) -> None: await create_policy_bundle_for_guide( client, manager_token, + project_reader_token, manager_subject, project["id"], guide["id"], diff --git a/backend/tests/test_api_controls.py b/backend/tests/test_api_controls.py index 16ad3e30..d8a6f839 100644 --- a/backend/tests/test_api_controls.py +++ b/backend/tests/test_api_controls.py @@ -510,6 +510,24 @@ def test_openapi_documents_request_error_and_response_context() -> None: "project_role_grant.revoke" ), "GET /api/v1/projects/{project_id}": "project.read", + "GET /api/v1/projects/{project_id}/guides/{guide_id}/setup-runs/latest": ( + "project.setup_run.read" + ), + "GET /api/v1/projects/{project_id}/guides/{guide_id}/sufficiency-reports": ( + "project.guide_sufficiency_report.list" + ), + "GET /api/v1/projects/{project_id}/guides/{guide_id}/sufficiency-reports/{report_id}": ( + "project.guide_sufficiency_report.read" + ), + "GET /api/v1/projects/{project_id}/guides/{guide_id}/submission-artifact-policies": ( + "project.submission_artifact_policy.list" + ), + "GET /api/v1/projects/{project_id}/guides/{guide_id}/submission-artifact-policies/{policy_id}": ( + "project.submission_artifact_policy.read" + ), + "GET /api/v1/projects/{project_id}/guides/{guide_id}/post-submit-checker-policy/setup": ( + "project.post_submit_checker_policy_setup.read" + ), } project_read_shapes = { "/api/v1/projects/{project_id}/contributor-candidates": ( diff --git a/backend/tests/test_audit.py b/backend/tests/test_audit.py index 08025c2c..86f9c214 100644 --- a/backend/tests/test_audit.py +++ b/backend/tests/test_audit.py @@ -179,11 +179,17 @@ def test_action_aware_audit_input_enforces_mapping_and_action_availability() -> ActionId.ACTOR_IDENTITY_LINK_REACTIVATE, ActionId.ACTOR_AUTHORIZATION_CONTEXT_READ, ActionId.PROJECT_CONTRIBUTOR_CANDIDATE_LIST, + ActionId.PROJECT_GUIDE_SUFFICIENCY_REPORT_LIST, + ActionId.PROJECT_GUIDE_SUFFICIENCY_REPORT_READ, + ActionId.PROJECT_POST_SUBMIT_CHECKER_POLICY_SETUP_READ, ActionId.PROJECT_READ, ActionId.PROJECT_ROLE_GRANT_LIST, ActionId.PROJECT_ROLE_GRANT_READ, ActionId.PROJECT_ROLE_GRANT_ISSUE, ActionId.PROJECT_ROLE_GRANT_REVOKE, + ActionId.PROJECT_SETUP_RUN_READ, + ActionId.PROJECT_SUBMISSION_ARTIFACT_POLICY_LIST, + ActionId.PROJECT_SUBMISSION_ARTIFACT_POLICY_READ, ActionId.ARTIFACT_VERIFICATION_EXECUTE, ActionId.ARTIFACT_PENDING_WORK_SCAN, ActionId.ARTIFACT_PUT_ATTEMPT_RESOLVE, diff --git a/backend/tests/test_authorization.py b/backend/tests/test_authorization.py index 723991c6..c6a65f69 100644 --- a/backend/tests/test_authorization.py +++ b/backend/tests/test_authorization.py @@ -193,6 +193,7 @@ MatchedAuthorityKind, PermissionCatalogueResourceContext, ProjectContributorCandidateCollectionResourceContext, + ProjectDiagnosticReadResourceContext, ProjectReadResourceContext, ProjectRoleGrantCollectionResourceContext, ProjectRoleGrantIssueResourceContext, @@ -691,6 +692,12 @@ async def list_contributor_candidates(self, *, cursor, **_kwargs): "/api/v1/projects/{project_id}/role-grants", "/api/v1/projects/{project_id}", "/api/v1/actors/me/authorization-context?project_id={project_id}", + "/api/v1/projects/{project_id}/guides/{guide_id}/setup-runs/latest", + "/api/v1/projects/{project_id}/guides/{guide_id}/sufficiency-reports", + "/api/v1/projects/{project_id}/guides/{guide_id}/sufficiency-reports/{report_id}", + "/api/v1/projects/{project_id}/guides/{guide_id}/submission-artifact-policies", + "/api/v1/projects/{project_id}/guides/{guide_id}/submission-artifact-policies/{policy_id}", + "/api/v1/projects/{project_id}/guides/{guide_id}/post-submit-checker-policy/setup", ), ) async def test_authorization_read_rate_failure_precedes_project_lookup( @@ -731,7 +738,9 @@ async def verified_human(): base_url="http://testserver", ) as client: response = await client.get( - path.format(project_id=uuid4()), + path.format( + project_id=uuid4(), guide_id=uuid4(), report_id=uuid4(), policy_id=uuid4() + ), headers={"Authorization": "Bearer test"}, ) @@ -776,6 +785,12 @@ async def forbidden_project_lookup(*_args, **_kwargs): f"/api/v1/projects/{uuid4()}/role-grants", f"/api/v1/projects/{uuid4()}", f"/api/v1/actors/me/authorization-context?project_id={uuid4()}", + f"/api/v1/projects/{uuid4()}/guides/{uuid4()}/setup-runs/latest", + f"/api/v1/projects/{uuid4()}/guides/{uuid4()}/sufficiency-reports", + f"/api/v1/projects/{uuid4()}/guides/{uuid4()}/sufficiency-reports/{uuid4()}", + f"/api/v1/projects/{uuid4()}/guides/{uuid4()}/submission-artifact-policies", + f"/api/v1/projects/{uuid4()}/guides/{uuid4()}/submission-artifact-policies/{uuid4()}", + f"/api/v1/projects/{uuid4()}/guides/{uuid4()}/post-submit-checker-policy/setup", ): response = await client.get(path) assert response.status_code == 404 @@ -783,10 +798,53 @@ async def forbidden_project_lookup(*_args, **_kwargs): "project_authorization_resource_not_found" ) - assert consumptions == 3 + assert consumptions == 9 assert lookups == 0 +@pytest.mark.asyncio +async def test_diagnostic_authentication_failure_precedes_private_lookup( + monkeypatch: pytest.MonkeyPatch, +) -> None: + app = create_app(Settings(environment="test")) + lookups = 0 + + async def consume_once() -> None: + return None + + async def invalid_bearer(): + raise StructuredHTTPException( + status_code=401, + detail="Invalid authentication credentials", + error_code="invalid_authentication", + error_message="Invalid authentication credentials", + ) + + async def forbidden_project_lookup(*_args, **_kwargs): + nonlocal lookups + lookups += 1 + raise AssertionError("project lookup must not run") + + app.dependency_overrides[enforce_authorization_read_rate_limit] = consume_once + app.dependency_overrides[get_auth_verification_result] = invalid_bearer + monkeypatch.setattr(ProjectRepository, "get_project", forbidden_project_lookup) + project_id, guide_id = uuid4(), uuid4() + paths = ( + f"/api/v1/projects/{project_id}/guides/{guide_id}/setup-runs/latest", + f"/api/v1/projects/{project_id}/guides/{guide_id}/sufficiency-reports", + f"/api/v1/projects/{project_id}/guides/{guide_id}/sufficiency-reports/{uuid4()}", + f"/api/v1/projects/{project_id}/guides/{guide_id}/submission-artifact-policies", + f"/api/v1/projects/{project_id}/guides/{guide_id}/submission-artifact-policies/{uuid4()}", + f"/api/v1/projects/{project_id}/guides/{guide_id}/post-submit-checker-policy/setup", + ) + async with AsyncClient( + transport=ASGITransport(app=app), base_url="http://testserver" + ) as client: + responses = [await client.get(path) for path in paths] + assert [response.status_code for response in responses] == [401] * 6 + assert lookups == 0 + + @pytest.mark.asyncio @pytest.mark.parametrize( ("path", "payload"), @@ -1816,6 +1874,12 @@ def test_closed_permission_and_action_catalogue_is_exact_and_non_executable() -> ActionId.PROJECT_ROLE_GRANT_REVOKE, ActionId.PROJECT_READ, ActionId.ACTOR_AUTHORIZATION_CONTEXT_READ, + ActionId.PROJECT_SETUP_RUN_READ, + ActionId.PROJECT_GUIDE_SUFFICIENCY_REPORT_LIST, + ActionId.PROJECT_GUIDE_SUFFICIENCY_REPORT_READ, + ActionId.PROJECT_SUBMISSION_ARTIFACT_POLICY_LIST, + ActionId.PROJECT_SUBMISSION_ARTIFACT_POLICY_READ, + ActionId.PROJECT_POST_SUBMIT_CHECKER_POLICY_SETUP_READ, ActionId.ARTIFACT_VERIFICATION_EXECUTE, ActionId.ARTIFACT_PENDING_WORK_SCAN, ActionId.ARTIFACT_PUT_ATTEMPT_RESOLVE, @@ -1900,14 +1964,14 @@ def test_closed_permission_and_action_catalogue_is_exact_and_non_executable() -> definition.availability is ActionAvailability.ACTIVE for definition in ACTION_DEFINITIONS ) - == 27 + == 33 ) assert ( sum( definition.availability is ActionAvailability.PLANNED for definition in ACTION_DEFINITIONS ) - == 51 + == 45 ) assert resolve_executable_action(ActionId.ACTOR_PROFILE_READ_SELF).permission_id is ( PermissionId.ACTOR_PROFILE_READ_SELF @@ -2100,7 +2164,7 @@ def test_art_custody_documentation_matches_the_independent_catalogue_fixture() - assert "does not grant Operator" in operations assert "verification retry remains independently gated" in operations assert ( - "71 PermissionIds, 78 ActionIds, 27 active actions, and\n51 planned actions" in operations + "71 PermissionIds, 78 ActionIds, 33 active actions, and\n45 planned actions" in operations ) @@ -2928,6 +2992,20 @@ async def find_effective_grant(self, *_args, **_kwargs): async def find_active_project_role_any(self, **_kwargs): return self.project_grant + async def lock_request_actor(self, identity_link_id, actor_profile_id): + return ( + SimpleNamespace( + id=str(identity_link_id), + actor_profile_id=str(actor_profile_id), + status="active", + ), + SimpleNamespace( + id=str(actor_profile_id), + actor_kind="human", + status="active", + ), + ) + async def has_effective_permission_any_scope(self, *_args, **_kwargs): return False @@ -2996,6 +3074,87 @@ async def test_project_read_kernel_prefers_admin_and_records_project_role_author assert len(evidence.events) == 1 +@pytest.mark.asyncio +async def test_project_diagnostic_read_requires_exact_active_admin_grant_and_child() -> None: + context = _runtime_context() + project_id = uuid4() + grant = SimpleNamespace(id=uuid4()) + resource = ProjectDiagnosticReadResourceContext( + resource_type="project_diagnostic", + resource_id=uuid4(), + scope_project_id=project_id, + guide_id=uuid4(), + guide_version="v1", + target_kind="sufficiency_report", + project_exists=True, + guide_exists=True, + target_exists=True, + target_binding_digest=f"sha256:{'b' * 64}", + source_snapshot_id=uuid4(), + source_snapshot_hash=f"sha256:{'a' * 64}", + ) + with pytest.raises(ValidationError, match="source_snapshot_hash"): + ProjectDiagnosticReadResourceContext( + **resource.model_dump(exclude={"source_snapshot_hash"}), + source_snapshot_hash="malformed", + ) + with pytest.raises(ValidationError, match="requires snapshot facts"): + ProjectDiagnosticReadResourceContext( + **resource.model_dump(exclude={"source_snapshot_id", "source_snapshot_hash"}) + ) + collection = ProjectDiagnosticReadResourceContext( + **resource.model_dump( + exclude={"source_snapshot_id", "source_snapshot_hash", "target_kind"}, + ), + target_kind="sufficiency_report_collection", + ) + assert collection.source_snapshot_id is None + service, evidence = _runtime_service( + context, + admin_repository=_ProjectReadAuthorityFacts(admin_grant=grant), + ) + decision = await service.require(ActionId.PROJECT_GUIDE_SUFFICIENCY_REPORT_READ, resource) + assert decision.matched_authority_kind is MatchedAuthorityKind.ADMIN_ROLE_GRANT + assert decision.matched_grant_id == grant.id + assert decision.matched_scope_project_id == project_id + assert decision.revalidated is True + assert decision.resource_context_digest == authorization_resource_digest(resource) + assert len(evidence.events) == 1 + assert evidence.events[0].after_facts["resource_context_digest"] == ( + decision.resource_context_digest + ) + + service, _ = _runtime_service( + context, + admin_repository=_ProjectReadAuthorityFacts(admin_grant=grant), + ) + with pytest.raises(AuthorizationDenied) as wrong_kind: + await service.require( + ActionId.PROJECT_GUIDE_SUFFICIENCY_REPORT_READ, + resource.model_copy(update={"target_kind": "submission_artifact_policy"}), + ) + assert wrong_kind.value.decision.denial_code is AuthorizationDenialCode.RESOURCE_GUARD_DENIED + + missing = resource.model_copy( + update={ + "target_exists": False, + "target_binding_digest": None, + "source_snapshot_id": None, + "source_snapshot_hash": None, + } + ) + service, denied_evidence = _runtime_service( + context, + admin_repository=_ProjectReadAuthorityFacts(admin_grant=grant), + ) + with pytest.raises(AuthorizationDenied) as exc_info: + await service.require(ActionId.PROJECT_GUIDE_SUFFICIENCY_REPORT_READ, missing) + assert exc_info.value.decision.denial_code is AuthorizationDenialCode.RESOURCE_NOT_FOUND + assert denied_evidence.events[0].after_facts["resource_context_digest"] == ( + exc_info.value.decision.resource_context_digest + ) + + @pytest.mark.asyncio async def test_actor_authorization_context_is_self_only_and_revalidated() -> None: context = _runtime_context() @@ -3047,7 +3206,13 @@ async def test_context_projection_excludes_planned_and_unrelated_actions() -> No assert response.project_roles == ("reviewer", "submitter") assert response.effective_action_ids == ( ActionId.PROJECT_CONTRIBUTOR_CANDIDATE_LIST, + ActionId.PROJECT_GUIDE_SUFFICIENCY_REPORT_LIST, + ActionId.PROJECT_GUIDE_SUFFICIENCY_REPORT_READ, + ActionId.PROJECT_POST_SUBMIT_CHECKER_POLICY_SETUP_READ, ActionId.PROJECT_READ, + ActionId.PROJECT_SETUP_RUN_READ, + ActionId.PROJECT_SUBMISSION_ARTIFACT_POLICY_LIST, + ActionId.PROJECT_SUBMISSION_ARTIFACT_POLICY_READ, ActionId.PROJECT_ROLE_GRANT_ISSUE, ActionId.PROJECT_ROLE_GRANT_LIST, ActionId.PROJECT_ROLE_GRANT_READ, @@ -3059,7 +3224,13 @@ async def test_context_projection_excludes_planned_and_unrelated_actions() -> No project_selector_id=project_id, ) assert archived.effective_action_ids == ( + ActionId.PROJECT_GUIDE_SUFFICIENCY_REPORT_LIST, + ActionId.PROJECT_GUIDE_SUFFICIENCY_REPORT_READ, + ActionId.PROJECT_POST_SUBMIT_CHECKER_POLICY_SETUP_READ, ActionId.PROJECT_READ, + ActionId.PROJECT_SETUP_RUN_READ, + ActionId.PROJECT_SUBMISSION_ARTIFACT_POLICY_LIST, + ActionId.PROJECT_SUBMISSION_ARTIFACT_POLICY_READ, ActionId.PROJECT_ROLE_GRANT_LIST, ActionId.PROJECT_ROLE_GRANT_READ, ActionId.PROJECT_ROLE_GRANT_REVOKE, diff --git a/backend/tests/test_projects.py b/backend/tests/test_projects.py index c2d134d8..e04b857a 100644 --- a/backend/tests/test_projects.py +++ b/backend/tests/test_projects.py @@ -64,7 +64,9 @@ ProjectRoleQualificationSnapshot, ) from app.modules.authorization.repository import AdminAuthorizationRepository +from app.modules.authorization.catalogue import ActionId from app.modules.projects import service as project_service_module +from app.modules.projects.authorization_reads import authorize_project_diagnostic_read from app.modules.projects.repository import ProjectRepository, ProjectRepositoryIntegrityError from app.modules.projects.service import ( GUIDE_SOURCE_MATERIAL_FIELDS, @@ -88,6 +90,243 @@ ) +class _DiagnosticAuthorization: + def __init__(self) -> None: + self.calls: list[tuple[ActionId, Any]] = [] + + async def require(self, action_id: ActionId, resource: Any) -> None: + self.calls.append((action_id, resource)) + + +class _DiagnosticRepository: + def __init__(self, *, project_id: str, guide_id: str, target: Any) -> None: + self.project = types.SimpleNamespace(id=project_id) + self.guide = types.SimpleNamespace(id=guide_id, project_id=project_id, version="v1") + self.target = target + self.post_policy = None + + async def get_project(self, _project_id: str, *, for_update: bool = False) -> Any: + assert for_update is True + return self.project + + async def lock_project_guide(self, _guide_id: str) -> Any: + return self.guide + + async def lock_latest_project_setup_run(self, *_args: Any) -> Any: + return self.target + + async def lock_guide_sufficiency_reports(self, *_args: Any) -> list[Any]: + return [self.target] + + async def lock_guide_sufficiency_report(self, *_args: Any) -> Any: + return self.target + + async def lock_submission_artifact_policies(self, *_args: Any) -> list[Any]: + return [self.target] + + async def lock_submission_artifact_policy(self, *_args: Any) -> Any: + return self.target + + async def lock_submission_artifact_policy_diagnostic(self, *_args: Any) -> Any: + return self.target + + async def lock_post_submit_checker_policy(self, *_args: Any) -> Any: + return self.post_policy + + +class _DiagnosticStatementCaptureSession: + def __init__(self) -> None: + self.statements: list[Any] = [] + + async def execute(self, statement: Any) -> Any: + self.statements.append(statement) + return types.SimpleNamespace( + scalars=lambda: types.SimpleNamespace(all=lambda: []) + ) + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "method_name,locked_table", + [ + ("lock_guide_sufficiency_reports", "guide_sufficiency_reports"), + ("lock_submission_artifact_policies", "submission_artifact_policies"), + ], +) +async def test_project_diagnostic_collection_locks_are_bounded( + method_name: str, locked_table: str +) -> None: + session = _DiagnosticStatementCaptureSession() + repository = ProjectRepository(cast(Any, session)) + + await getattr(repository, method_name)(str(uuid4()), str(uuid4()), "v1") + + assert len(session.statements) == 1 + compiled = str( + session.statements[0].compile( + dialect=postgresql.dialect(), compile_kwargs={"literal_binds": True} + ) + ) + assert "LIMIT 100" in compiled + assert f"FOR UPDATE OF {locked_table}" in compiled + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "action_id,target_kind,is_collection", + [ + (ActionId.PROJECT_SETUP_RUN_READ, "setup_run", False), + ( + ActionId.PROJECT_GUIDE_SUFFICIENCY_REPORT_LIST, + "sufficiency_report_collection", + True, + ), + (ActionId.PROJECT_GUIDE_SUFFICIENCY_REPORT_READ, "sufficiency_report", False), + ( + ActionId.PROJECT_SUBMISSION_ARTIFACT_POLICY_LIST, + "submission_artifact_policy_collection", + True, + ), + ( + ActionId.PROJECT_SUBMISSION_ARTIFACT_POLICY_READ, + "submission_artifact_policy", + False, + ), + ( + ActionId.PROJECT_POST_SUBMIT_CHECKER_POLICY_SETUP_READ, + "post_submit_checker_policy_setup", + False, + ), + ], +) +async def test_project_diagnostic_read_composer_binds_each_action( + action_id: ActionId, target_kind: str, is_collection: bool +) -> None: + project_id, guide_id, target_id, snapshot_id = (str(uuid4()) for _ in range(4)) + target = types.SimpleNamespace( + id=target_id, + project_id=project_id, + guide_id=guide_id, + guide_version="v1", + source_snapshot_id=snapshot_id, + source_snapshot_hash=f"sha256:{'a' * 64}", + output_post_submit_checker_policy_id=None, + ) + repository = _DiagnosticRepository( + project_id=project_id, guide_id=guide_id, target=target + ) + authorization = _DiagnosticAuthorization() + + result = await authorize_project_diagnostic_read( + authorization=cast(Any, authorization), + repository=cast(Any, repository), + action_id=action_id, + project_id=project_id, + guide_id=guide_id, + target_id=target_id, + ) + + expected = ( + (target, None) + if action_id is ActionId.PROJECT_POST_SUBMIT_CHECKER_POLICY_SETUP_READ + else ([target] if is_collection else target) + ) + assert result == expected + assert len(authorization.calls) == 1 + called_action, context = authorization.calls[0] + assert called_action is action_id + assert context.target_kind == target_kind + assert context.target_exists is True + assert context.target_binding_digest.startswith("sha256:") + + +@pytest.mark.asyncio +async def test_project_diagnostic_read_composer_fails_closed_for_invalid_or_missing() -> None: + project_id, guide_id = str(uuid4()), str(uuid4()) + repository = _DiagnosticRepository(project_id=project_id, guide_id=guide_id, target=None) + authorization = _DiagnosticAuthorization() + with pytest.raises(ValueError, match="unsupported"): + await authorize_project_diagnostic_read( + authorization=cast(Any, authorization), + repository=cast(Any, repository), + action_id=ActionId.PROJECT_READ, + project_id=project_id, + guide_id=guide_id, + ) + with pytest.raises(RuntimeError, match="unexpectedly allowed"): + await authorize_project_diagnostic_read( + authorization=cast(Any, authorization), + repository=cast(Any, repository), + action_id=ActionId.PROJECT_SETUP_RUN_READ, + project_id=project_id, + guide_id=guide_id, + ) + assert authorization.calls[-1][1].target_exists is False + + repository.project = None + with pytest.raises(RuntimeError, match="unexpectedly allowed"): + await authorize_project_diagnostic_read( + authorization=cast(Any, authorization), + repository=cast(Any, repository), + action_id=ActionId.PROJECT_SETUP_RUN_READ, + project_id=project_id, + guide_id=guide_id, + ) + repository.project = types.SimpleNamespace(id=project_id) + repository.guide = types.SimpleNamespace( + id=guide_id, project_id=str(uuid4()), version="v1" + ) + with pytest.raises(RuntimeError, match="unexpectedly allowed"): + await authorize_project_diagnostic_read( + authorization=cast(Any, authorization), + repository=cast(Any, repository), + action_id=ActionId.PROJECT_SETUP_RUN_READ, + project_id=project_id, + guide_id=guide_id, + ) + + +@pytest.mark.asyncio +async def test_project_diagnostic_read_composer_locks_post_submit_policy_binding() -> None: + project_id, guide_id, run_id, policy_id, snapshot_id = (str(uuid4()) for _ in range(5)) + shared = { + "project_id": project_id, + "guide_id": guide_id, + "guide_version": "v1", + "source_snapshot_id": snapshot_id, + "source_snapshot_hash": f"sha256:{'c' * 64}", + } + run = types.SimpleNamespace( + id=run_id, output_post_submit_checker_policy_id=policy_id, **shared + ) + policy = types.SimpleNamespace(id=policy_id, **shared) + repository = _DiagnosticRepository(project_id=project_id, guide_id=guide_id, target=run) + repository.post_policy = policy + authorization = _DiagnosticAuthorization() + + result = await authorize_project_diagnostic_read( + authorization=cast(Any, authorization), + repository=cast(Any, repository), + action_id=ActionId.PROJECT_POST_SUBMIT_CHECKER_POLICY_SETUP_READ, + project_id=project_id, + guide_id=guide_id, + ) + assert result == (run, policy) + assert authorization.calls[-1][1].target_binding_digest.startswith("sha256:") + + repository.post_policy = types.SimpleNamespace( + id=policy_id, **{**shared, "guide_version": "stale"} + ) + with pytest.raises(RuntimeError, match="unexpectedly allowed"): + await authorize_project_diagnostic_read( + authorization=cast(Any, authorization), + repository=cast(Any, repository), + action_id=ActionId.PROJECT_POST_SUBMIT_CHECKER_POLICY_SETUP_READ, + project_id=project_id, + guide_id=guide_id, + ) + + @pytest.fixture def project_database_env( monkeypatch: pytest.MonkeyPatch, @@ -127,9 +366,8 @@ def auth_headers(token: str = "project-token") -> dict[str, str]: return {"Authorization": f"Bearer {token}"} -async def add_project_role_for_default_actor(project_id: str, role: str) -> tuple[UUID, str]: - """Insert reviewed local-grant fixtures for project identity route tests.""" - now = datetime.now(UTC) +async def ensure_access_administrator_bootstrap() -> tuple[UUID, UUID, UUID]: + """Return the default actor and its idempotent bootstrap grant.""" async with db_session.get_session_factory()() as session: link = await session.scalar( select(ActorIdentityLink).where( @@ -138,32 +376,108 @@ async def add_project_role_for_default_actor(project_id: str, role: str) -> tupl ) ) assert link is not None - actor_id = link.actor_profile_id - admin_grant = await session.scalar( + grant = await session.scalar( select(AdminRoleGrant).where( - AdminRoleGrant.target_actor_profile_id == actor_id, + AdminRoleGrant.target_actor_profile_id == link.actor_profile_id, AdminRoleGrant.role == "access_administrator", + AdminRoleGrant.status == "active", ) ) - if admin_grant is None: - admin_grant = AdminRoleGrant( + if grant is None: + grant = AdminRoleGrant( id=uuid4(), - target_actor_profile_id=actor_id, + target_actor_profile_id=link.actor_profile_id, role="access_administrator", scope_type="system", scope_project_id=None, status="active", version=1, granted_by_system_principal="workstream:system:bootstrap", - grant_reason="AUTH-11B route fixture", + grant_reason="AUTH route fixture", ) - session.add(admin_grant) + session.add(grant) control = await session.get(AuthorityControl, 1) assert control is not None control.bootstrap_completed = True - control.bootstrap_grant_id = admin_grant.id + control.bootstrap_grant_id = grant.id control.version = 1 - await session.flush() + await session.commit() + return link.actor_profile_id, link.id, grant.id + + +async def add_project_manager_admin_grant(project_id: str) -> UUID: + """Grant the default registered human exact project diagnostic authority.""" + async with db_session.get_session_factory()() as session: + existing = await session.scalar( + select(AdminRoleGrant).where( + AdminRoleGrant.role == "project_manager", + AdminRoleGrant.scope_project_id == project_id, + AdminRoleGrant.status == "active", + ) + ) + if existing is not None: + return existing.id + actor_id, _, grantor_id = await ensure_access_administrator_bootstrap() + async with db_session.get_session_factory()() as session: + grant = AdminRoleGrant( + id=uuid4(), + target_actor_profile_id=actor_id, + role="project_manager", + scope_type="project", + scope_project_id=project_id, + status="active", + version=1, + granted_by_actor_profile_id=actor_id, + granted_by_admin_role_grant_id=grantor_id, + grant_reason="AUTH-11C1 diagnostic read fixture", + ) + session.add(grant) + await session.commit() + return grant.id + + +async def add_local_admin_role_for_default_actor( + role: str, *, project_id: str | None +) -> UUID: + """Add one valid local administrative grant through the fixture grantor.""" + actor_id, _, grantor_id = await ensure_access_administrator_bootstrap() + async with db_session.get_session_factory()() as session: + grant = AdminRoleGrant( + id=uuid4(), + target_actor_profile_id=actor_id, + role=role, + scope_type="project" if project_id is not None else "system", + scope_project_id=project_id, + status="active", + version=1, + granted_by_actor_profile_id=actor_id, + granted_by_admin_role_grant_id=grantor_id, + grant_reason=f"AUTH-11C1 {role} route fixture", + ) + session.add(grant) + await session.commit() + return grant.id + + +async def revoke_local_admin_role(grant_id: UUID) -> None: + """Revoke one fixture grant with complete provenance.""" + async with db_session.get_session_factory()() as session: + grant = await session.get(AdminRoleGrant, grant_id) + assert grant is not None + grant.status = "revoked" + grant.version = 2 + grant.revoked_by_actor_profile_id = grant.target_actor_profile_id + grant.revoked_by_admin_role_grant_id = grant.granted_by_admin_role_grant_id + grant.revoked_reason = "AUTH-11C1 role matrix proof" + grant.revoked_at = datetime.now(UTC) + await session.commit() + + +async def add_project_role_for_default_actor(project_id: str, role: str) -> tuple[UUID, str]: + """Insert reviewed local-grant fixtures for project identity route tests.""" + now = datetime.now(UTC) + actor_id, link_id, admin_grant_id = await ensure_access_administrator_bootstrap() + async with db_session.get_session_factory()() as session: snapshot = ProjectRoleQualificationSnapshot( id=uuid4(), project_id=project_id, @@ -182,7 +496,7 @@ async def add_project_role_for_default_actor(project_id: str, role: str) -> tupl prior_project_work_refs=[], external_expertise_refs=[], captured_by_actor_profile_id=actor_id, - captured_by_admin_role_grant_id=admin_grant.id, + captured_by_admin_role_grant_id=admin_grant_id, captured_at=now, ) session.add(snapshot) @@ -197,13 +511,13 @@ async def add_project_role_for_default_actor(project_id: str, role: str) -> tupl grant_method="manual", qualification_snapshot_id=snapshot.id, granted_by_actor_profile_id=actor_id, - granted_by_admin_role_grant_id=admin_grant.id, + granted_by_admin_role_grant_id=admin_grant_id, grant_reason="AUTH-11B route fixture", granted_at=now, ) session.add(grant) await session.commit() - return grant.id, str(link.id) + return grant.id, str(link_id) @pytest.mark.asyncio @@ -813,6 +1127,7 @@ async def create_guide(client: AsyncClient, project_id: str, payload: dict) -> d json=payload, ) assert response.status_code == 201, response.text + await add_project_manager_admin_grant(project_id) return response.json() @@ -1017,9 +1332,7 @@ async def test_project_identity_and_context_follow_exact_grant_and_lifecycle( other = await create_project(project_client, name="Other project") grant_id, link_id = await add_project_role_for_default_actor(project["id"], "submitter") - identity = await project_client.get( - f"/api/v1/projects/{project['id']}", headers=auth_headers() - ) + identity = await project_client.get(f"/api/v1/projects/{project['id']}", headers=auth_headers()) assert identity.status_code == 200, identity.text assert identity.json() == { "id": project["id"], @@ -1746,6 +2059,7 @@ async def test_project_setup_visibility_apis_show_automatic_setup_outputs( monkeypatch.setenv("WORKSTREAM_CELERY_TASK_ALWAYS_EAGER", "true") get_settings.cache_clear() project = await create_project(project_client) + await add_project_manager_admin_grant(project["id"]) guide = await create_guide( project_client, project["id"], @@ -1853,6 +2167,7 @@ async def test_project_setup_visibility_apis_show_automatic_setup_outputs( ) assert second_project_response.status_code == 201, second_project_response.text second_project = second_project_response.json() + await add_project_manager_admin_grant(second_project["id"]) second_guide = await create_guide( project_client, second_project["id"], @@ -3473,7 +3788,7 @@ async def test_project_setup_run_rejects_cross_context_worker_updates( ) -async def test_project_setup_visibility_apis_require_project_setup_role( +async def test_project_setup_visibility_apis_require_active_local_grant( project_client: AsyncClient, monkeypatch: pytest.MonkeyPatch, deterministic_project_agent_runtime: None, @@ -3505,9 +3820,6 @@ async def test_project_setup_visibility_apis_require_project_setup_role( f"/api/v1/projects/{project['id']}/guides/{guide['id']}/submission-artifact-policies", f"/api/v1/projects/{project['id']}/guides/{guide['id']}/submission-artifact-policies/" f"{setup_run['output_submission_artifact_policy_id']}", - f"/api/v1/projects/{project['id']}/guides/{guide['id']}/" - "effective-submission-artifact-policy", - f"/api/v1/projects/{project['id']}/guides/{guide['id']}/pre-submit-checker-policy", f"/api/v1/projects/{project['id']}/guides/{guide['id']}/post-submit-checker-policy/setup", ] monkeypatch.setenv("WORKSTREAM_DEV_AUTH_ROLES", "admin") @@ -3515,25 +3827,61 @@ async def test_project_setup_visibility_apis_require_project_setup_role( admin_responses = [ await project_client.get(endpoint, headers=auth_headers()) for endpoint in endpoints ] - assert [response.status_code for response in admin_responses] == [ - 200, - 200, - 200, - 200, - 200, - 404, - 404, - 200, + assert [response.status_code for response in admin_responses] == [200] * len(endpoints) + + async with db_session.get_session_factory()() as session: + grant = await session.scalar( + select(AdminRoleGrant).where( + AdminRoleGrant.role == "project_manager", + AdminRoleGrant.scope_project_id == project["id"], + AdminRoleGrant.status == "active", + ) + ) + assert grant is not None + grant.status = "revoked" + grant.version = 2 + grant.revoked_by_actor_profile_id = grant.target_actor_profile_id + grant.revoked_by_admin_role_grant_id = grant.granted_by_admin_role_grant_id + grant.revoked_reason = "AUTH-11C1 revocation proof" + grant.revoked_at = datetime.now(UTC) + await session.commit() + + denied = [await project_client.get(endpoint, headers=auth_headers()) for endpoint in endpoints] + assert [response.status_code for response in denied] == [404] * len(endpoints) + + other_project = await create_project(project_client, name="Wrong Scope") + wrong_scope_grant = await add_local_admin_role_for_default_actor( + "project_manager", project_id=other_project["id"] + ) + wrong_scope = [ + await project_client.get(endpoint, headers=auth_headers()) for endpoint in endpoints ] + assert [response.status_code for response in wrong_scope] == [404] * len(endpoints) + await revoke_local_admin_role(wrong_scope_grant) - for role in ("worker", "reviewer", "finance", "auditor"): - monkeypatch.setenv("WORKSTREAM_DEV_AUTH_ROLES", role) - get_settings.cache_clear() - responses = [ - await project_client.get(endpoint, headers=auth_headers()) for endpoint in endpoints - ] + operator_grant = await add_local_admin_role_for_default_actor( + "operator", project_id=None + ) + operator = [ + await project_client.get(endpoint, headers=auth_headers()) for endpoint in endpoints + ] + assert [response.status_code for response in operator] == [200] * len(endpoints) + await revoke_local_admin_role(operator_grant) - assert [response.status_code for response in responses] == [403] * len(endpoints) + audit_grant = await add_local_admin_role_for_default_actor( + "audit_authority", project_id=project["id"] + ) + audit = [await project_client.get(endpoint, headers=auth_headers()) for endpoint in endpoints] + assert [response.status_code for response in audit] == [200] * len(endpoints) + await revoke_local_admin_role(audit_grant) + + await add_local_admin_role_for_default_actor( + "finance_authority", project_id=project["id"] + ) + finance = [ + await project_client.get(endpoint, headers=auth_headers()) for endpoint in endpoints + ] + assert [response.status_code for response in finance] == [404] * len(endpoints) async def test_project_can_be_created(project_client: AsyncClient) -> None: @@ -7187,7 +7535,7 @@ async def derive_post_submit_checker_policy( assert "post-submit checker policy" in activation.json()["detail"] -async def test_post_submit_checker_policy_setup_apis_require_setup_role( +async def test_post_submit_checker_policy_mutations_still_require_legacy_setup_role( project_client: AsyncClient, monkeypatch: pytest.MonkeyPatch, ) -> None: @@ -7199,13 +7547,13 @@ async def test_post_submit_checker_policy_setup_apis_require_setup_role( guide["id"], approve_post_submit_checker=False, ) + diagnostic = await project_client.get( + f"/api/v1/projects/{project['id']}/guides/{guide['id']}/" + "post-submit-checker-policy/setup", + headers=auth_headers(), + ) + assert diagnostic.status_code == 200, diagnostic.text endpoints = [ - ( - "get", - f"/api/v1/projects/{project['id']}/guides/{guide['id']}/" - "post-submit-checker-policy/setup", - None, - ), ( "post", f"/api/v1/projects/{project['id']}/guides/{guide['id']}/" @@ -7224,17 +7572,11 @@ async def test_post_submit_checker_policy_setup_apis_require_setup_role( monkeypatch.setenv("WORKSTREAM_DEV_AUTH_ROLES", role) get_settings.cache_clear() for method, endpoint, payload in endpoints: - if payload is None: - response = await getattr(project_client, method)( - endpoint, - headers=auth_headers(), - ) - else: - response = await getattr(project_client, method)( - endpoint, - headers=auth_headers(), - json=payload, - ) + response = await getattr(project_client, method)( + endpoint, + headers=auth_headers(), + json=payload, + ) assert response.status_code == 403 diff --git a/docs/operations_authorization_service.md b/docs/operations_authorization_service.md index 75ceaf12..6f9a4a30 100644 --- a/docs/operations_authorization_service.md +++ b/docs/operations_authorization_service.md @@ -708,9 +708,10 @@ reconciliation uses migration `0036`. The REV transfer adds no migration. The ART transfer does not grant Operator authority; its `OPERATOR` suffix denotes only future activation custody, and verification retry remains independently gated from read/status actions. -Catalogue totals are 71 PermissionIds, 78 ActionIds, 27 active actions, and -51 planned actions after AUTH-11B activates project identity and actor context -actions. The other 19 ART actions remain planned, including every Operator +Catalogue totals are 71 PermissionIds, 78 ActionIds, 33 active actions, and +45 planned actions after AUTH-11C1 activates three project setup-diagnostic and +three draft/effective-policy diagnostic read actions. The exact route mapping +is in `docs/spec_authorization_service.md`. The other 19 ART actions remain planned, including every Operator artifact action. Migration `0037` keeps each allowed or denied internal ART decision bound to the exact privacy-bounded resource-context digest in append-only audit facts. @@ -718,8 +719,10 @@ the exact privacy-bounded resource-context digest in append-only audit facts. AUTH-11A adds read-only `project.setup_diagnostic.read` and `project.effective_policy.read`. Project Manager and Audit Authority receive them at system or exact-project scope; Operator receives them at system scope. -Finance Authority and Access Administrator do not. The eleven AUTH-11 actions -remain planned under 11B, 11C1, or 11C2 and cannot produce allowed evidence. +Finance Authority and Access Administrator do not. The two AUTH-11B +identity/context actions and six AUTH-11C1 diagnostic-read actions are active; +the three AUTH-11C2 effective-policy/active-guide actions remain planned and +cannot produce allowed evidence. Four later REV registrations add exactly four planned and zero active actions. Review-evidence binding is already registered planned and unavailable under `WS-XINT-002-07`; it remains non-operational until exact feature proof and a @@ -1027,6 +1030,16 @@ records which grant class authorized the decision. The context response is a derived read model, not an authority token: it contains no grant ids or identity-link fields and never advertises planned or unrelated actions. +The six AUTH-11C1 diagnostic GET routes use the same rate-first human-read +admission and concealed authorization response. They lock the exact project, +guide/version, selected child or collection, source snapshot, current actor and +identity link, and matched administrative grant through projection/commit. +Only a covered Project Manager, scoped Audit Authority, or system Operator may +read them. Finance Authority, Access Administrator, contributor, non-human, +revoked, wrong-scope, and cross-guide/cross-project requests do not disclose +whether the target exists. Authentication and rate-control failures retain +their canonical 401/503 and 429/503 behavior before private lookup. + ## Recovery Permission Inventory | Operation | Authority | Required controls | diff --git a/docs/operations_project_operating_manual.md b/docs/operations_project_operating_manual.md index 877ef24f..1317c19e 100644 --- a/docs/operations_project_operating_manual.md +++ b/docs/operations_project_operating_manual.md @@ -68,9 +68,11 @@ generated by Workstream setup and exposed as generated setup output. Project setup operators inspect setup state through API endpoints, not direct database queries. Project identity and the caller's self-authorization context -follow the exact local-grant rules below. The remaining setup endpoints require the -registered covered Project Manager permission or an explicitly authorized -Operator/Audit projection: +follow the exact local-grant rules below. The three setup-diagnostic and three +draft/effective-policy diagnostic GET endpoints are active under AUTH-11C1 and require a covered Project Manager, +scoped Audit Authority, or system Operator grant. They bind the exact project, +guide/version, child, and source snapshot. Issuer role claims and contributor +grants are non-authoritative for these reads: - `GET /api/v1/projects/{project_id}` provides canonical project identity. Eligible administrative grants receive the full identity fields; an active @@ -83,10 +85,17 @@ Operator/Audit projection: - `GET /api/v1/projects/{project_id}/guides/{guide_id}/sufficiency-reports/{report_id}` - `GET /api/v1/projects/{project_id}/guides/{guide_id}/submission-artifact-policies` - `GET /api/v1/projects/{project_id}/guides/{guide_id}/submission-artifact-policies/{policy_id}` +- `GET /api/v1/projects/{project_id}/guides/{guide_id}/post-submit-checker-policy/setup` + +The two collection routes return the newest 100 canonical rows. Use the exact +single-record route for an older retained report or policy identifier. + +The remaining policy and mutation endpoints follow their separately owned +activation chunks: + - `POST /api/v1/projects/{project_id}/guides/{guide_id}/submission-artifact-policies/{policy_id}/approve` - `GET /api/v1/projects/{project_id}/guides/{guide_id}/effective-submission-artifact-policy` - `GET /api/v1/projects/{project_id}/guides/{guide_id}/pre-submit-checker-policy` -- `GET /api/v1/projects/{project_id}/guides/{guide_id}/post-submit-checker-policy/setup` - `POST /api/v1/projects/{project_id}/guides/{guide_id}/post-submit-checker-policy/approve` - `POST /api/v1/projects/{project_id}/guides/{guide_id}/post-submit-checker-policy/request-correction` diff --git a/docs/spec_authorization_service.md b/docs/spec_authorization_service.md index d3e6b776..e37f8c7c 100644 --- a/docs/spec_authorization_service.md +++ b/docs/spec_authorization_service.md @@ -237,12 +237,14 @@ permissions are the exact 22 post-`0020` permissions. AUTH-07A, AUTH-11A, and WS-XINT-002-01 add their matching typed/SQL audit parity without making them executable. -The closed action registry contains 78 rows after AUTH-11B: 27 active actions -and 51 planned rows. AUTH-10A added five project-role read/manage rows; +The closed action registry contains 78 rows after AUTH-11C1: 33 active actions +and 45 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-11A adds eleven -project identity and actor-context read rows: two are active under 11B and nine -setup, policy, and active-guide reads remain planned under 11C1 and 11C2. +project identity and actor-context read rows: two are active under 11B, three +setup-diagnostic and three draft/effective-policy diagnostic reads are active +under 11C1, and three effective-policy and active-guide reads remain planned +under 11C2. AUTH-08 adds seven active administrative definition, grant-history, issue, revoke, and local-bootstrap actions without adding a @@ -853,6 +855,29 @@ The self context lists effective role names and active route-backed project actions; it exposes no grant ids, identity-link data, planned actions, or unrelated system authority. +AUTH-11C1 activates the six setup-run, sufficiency-report, draft submission +artifact policy, and post-submit checker setup GET actions. Each route resolves +and locks the canonical project, guide/version, exact child or collection, and +source-snapshot facts before requiring a covered Project Manager, scoped Audit +Authority, or system Operator grant. Missing, cross-project, cross-guide, +revoked, and stale bindings share the concealed project-read response. +Issuer-provided role metadata is excluded from product decisions; contributor grants do not +cover these diagnostics, and these read permissions provide no mutation +authority. + +| AUTH-11C1 public GET route | ActionId | PermissionId | +|---|---|---| +| `/api/v1/projects/{project_id}/guides/{guide_id}/setup-runs/latest` | `project.setup_run.read` | `project.setup_diagnostic.read` | +| `/api/v1/projects/{project_id}/guides/{guide_id}/sufficiency-reports` | `project.guide_sufficiency_report.list` | `project.setup_diagnostic.read` | +| `/api/v1/projects/{project_id}/guides/{guide_id}/sufficiency-reports/{report_id}` | `project.guide_sufficiency_report.read` | `project.setup_diagnostic.read` | +| `/api/v1/projects/{project_id}/guides/{guide_id}/submission-artifact-policies` | `project.submission_artifact_policy.list` | `project.effective_policy.read` | +| `/api/v1/projects/{project_id}/guides/{guide_id}/submission-artifact-policies/{policy_id}` | `project.submission_artifact_policy.read` | `project.effective_policy.read` | +| `/api/v1/projects/{project_id}/guides/{guide_id}/post-submit-checker-policy/setup` | `project.post_submit_checker_policy_setup.read` | `project.effective_policy.read` | + +The two collection routes return and transactionally bind at most the newest +100 canonical rows in deterministic newest-first order. Older retained records +remain available only through their exact individually authorized read route. + `WS-AUTH-001-CONTRIBUTOR-FOUNDATION` adds no permission or authorization path. It clean-cuts TaskAssignment and Submission attribution to `contributor_id`, binds both fields to canonical human ActorProfiles in PostgreSQL, and exposes