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 8a1c05cf..9863a7c1 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 @@ -52,8 +52,8 @@ stopped. | `WS-AUTH-001-10B2` | Privacy-Safe Project Role Grant Reads | L1 | Proposed after 10B1 | | `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 | Proposed successor; migration `0035`, no active surface | -| `WS-AUTH-001-11B` | Project Identity And Actor Context Cutover | L1 | Proposed after 11A | +| `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-11C2` | Effective Policy And Active Guide Read Cutover | L1 | Proposed after 11C1 | | `WS-AUTH-001-12` | Project Policy And Setup Mutation 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 ad326c31..2ad143fe 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 @@ -157,8 +157,8 @@ None. | `WS-AUTH-001-PREP` | Merged | `codex/ws-auth-001-prep` | #162 | Merged as `c559d556` on 2026-07-21; adds no feature consumer or activation. | | `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` | Proposed | - | - | Project-read action catalogue and migration `0035`; no activation. | -| `WS-AUTH-001-11B` | Proposed | - | - | Project identity and self authorization-context cutover. | +| `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-11C2` | Proposed | - | - | Effective policy and active-guide read cutover. | | `WS-AUTH-001-12` | Proposed | - | - | Project policy/setup mutation cutover. | @@ -168,8 +168,9 @@ None. | `WS-AUTH-001-16` | Proposed | - | - | Conformance and live proof. | Feature-gated registration and activation chunks are enumerated in -`CHUNK_MAP.md` and `ACTIVATION_CUSTODY.md`. They remain inactive until exact -merged feature manifests and separate human starts exist. +`CHUNK_MAP.md` and `ACTIVATION_CUSTODY.md`. Actions remain inactive until their +bounded implementation and required evidence are reviewed and human-merged; +planning artifacts do not activate or lock work. ## Blockers diff --git a/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/chunks/WS-AUTH-001-11B-project-identity-actor-context.md b/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/chunks/WS-AUTH-001-11B-project-identity-actor-context.md index dd4dce74..77cbdd9d 100644 --- a/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/chunks/WS-AUTH-001-11B-project-identity-actor-context.md +++ b/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/chunks/WS-AUTH-001-11B-project-identity-actor-context.md @@ -2,7 +2,9 @@ ## Status -Proposed and inactive after 11A. Requires a separate signed explicit start. +Authorized for implementation after AUTH-11A merged in PR #208. Work follows +the repository's simple engineering loop; planning artifacts do not activate or +lock execution. ## Goal @@ -17,23 +19,35 @@ L1 / P1 ## Allowed files ```text -backend/app/modules/projects/** +backend/app/modules/projects/router.py +backend/app/modules/projects/schemas.py +backend/app/modules/projects/service.py +backend/app/modules/projects/repository.py +backend/app/api/deps/authorization.py backend/app/api/routes/auth.py -backend/app/api/router.py -backend/app/modules/actors/** -backend/app/modules/authorization/** -backend/app/modules/audit/** +backend/app/modules/actors/schemas.py +backend/app/modules/actors/service.py +backend/app/modules/authorization/catalogue.py +backend/app/modules/authorization/kernel.py +backend/app/modules/authorization/read_service.py +backend/app/modules/authorization/repository.py +backend/app/modules/authorization/runtime.py backend/tests/test_projects.py backend/tests/test_auth.py +backend/tests/test_actors.py backend/tests/test_authorization.py +backend/tests/test_api_controls.py backend/scripts/api_contract_e2e.py .github/workflows/backend.yml docs/operations_authorization_service.md 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-11B.json +.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/STATUS.md +.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/CHUNK_MAP.md +.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/chunks/WS-AUTH-001-11B-project-identity-actor-context.md +.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/reviews/WS-AUTH-001-11B-internal-review-evidence.md +.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/reviews/WS-AUTH-001-11B-pr-trust-bundle.md ``` ## Not allowed @@ -50,15 +64,55 @@ project collection/list API - Activate only `project.read` and `actor.authorization_context.read`. - Resolve the canonical project before authorization; unknown, cross-project, unauthorized, inactive-grant, and out-of-scope requests are concealed by the - action-aware public denial contract. + action-aware public denial contract. Both 11B actions use the shared denial + translator and preserve their authorization evidence before returning the + identical public not-found envelope. - Eligible scoped admin grants receive the registered project identity view. + This means Operator system scope and effective system/project-scoped Project + Manager, Finance Authority, and Audit Authority grants. Access Administrator + alone is denied because its registered permission set excludes + `project.read`. All four eligible admin roles share the existing registered + `ProjectResponse` fields: id, name, slug, description, lifecycle status, + created_at, and updated_at; 11B does not expose setup, guide, policy, task, + submission, compensation, or reputation data through this route. Active exact-project submitter, reviewer, and adjudicator grants independently - receive only the minimal project identity projection. + receive only the minimal project identity projection: project id, name, and + lifecycle status. Contributor responses omit slug, description, and + timestamps. A caller with both kinds of authority receives the registered + admin projection; response selection is server-owned. +- Admin and contributor projections use distinct strict response schemas; the + minimal contributor projection cannot validate or serialize the omitted + admin-only fields. - The context response is self-only, exact-project, derived from current local grants and action availability, and cannot advertise planned/inactive actions. + A caller with no effective local authority for that project receives the same + concealed not-found response rather than an empty context that confirms the + project exists. + It returns the caller actor-profile id and status, the exact project id, + effective active admin-role names and independent project-role names for that + project, and the sorted active action ids those grants currently permit. It + exposes neither grant ids nor identity-link data. Action ids are evaluated + for the exact canonical project target and limited to active, route-backed + project actions currently executable there; system, actor-administration, + and unrelated project actions are excluded. +- Contributor project visibility and context derivation revalidate the current + human ActorProfile and exact ActorIdentityLink in the request transaction; + suspension, deactivation, or link revocation denies a stale bearer token even + while a project-role grant remains active. - Routers declare one primary ActionId and target; policy stays in the kernel or feature policy layer. Both routes use local grants as their sole product- authority source after cutover. +- `project.read` uses one typed exact-project resource context and the central + authorization decision/evidence path. Allowed decisions distinguish a + matched admin-role grant from a matched project-role grant and persist the + matched grant plus project scope; no service-layer boolean role check may + bypass or duplicate the kernel. +- `actor.authorization_context.read` uses a distinct typed self-plus-project + resource context. An AUTH-owned read projection service derives its strict + response from canonical grant and catalogue state; actor service remains + limited to actor-profile behavior and the kernel is not used as a read model. + Capability derivation does not call `require` once per candidate action or + stage synthetic per-action decision events. - Exact-project allow, cross-project deny, role revocation independence, minimal-field, concealed-not-found, audit, invalidation, and live API tests pass. @@ -70,16 +124,23 @@ project collection/list API ```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_auth.py tests/test_projects.py --cov=app.modules.authorization --cov=app.modules.actors --cov=app.modules.projects --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_auth.py tests/test_actors.py tests/test_projects.py tests/test_api_controls.py --cov=app.modules.authorization --cov=app.modules.actors --cov=app.modules.projects --cov-report=term-missing) (cd backend && WORKSTREAM_DATABASE_URL= .venv/bin/python scripts/api_contract_e2e.py) -python3 scripts/test_agent_gates.py +python3 scripts/check_stale_workstream_wording.py +python3 scripts/check_stale_authorization_docs.py +python3 scripts/check_markdown_links.py +python3 -m unittest -v scripts.test_lightweight_agent_gates 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 actor/authorization subsystem 90 percent floors. This child adds a protected -`app/modules/projects/*` 90 percent coverage report to that hosted gate. +authorization-boundary test delta for every new project-read path; it does not +claim that the pre-existing broad project subsystem has reached the dedicated +90 percent uplift milestone. The full suite and coverage run in GitHub Actions; +local verification is limited to focused tests, lint, API contract, and +deterministic repository checks. ## Required reviewers 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 cfc2f5f0..ef0a2e0c 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 @@ -69,7 +69,7 @@ token-role fallback or dual authorization ```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=app.modules.projects --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 --cov=app.modules.authorization --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 git diff --check @@ -77,8 +77,9 @@ 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. The protected -`app/modules/projects/*` 90 percent report introduced by 11B remains mandatory. +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. ## Required reviewers diff --git a/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/chunks/WS-AUTH-001-11C2-effective-policy-active-guide-reads.md b/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/chunks/WS-AUTH-001-11C2-effective-policy-active-guide-reads.md index 84bb0caa..bbffec40 100644 --- a/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/chunks/WS-AUTH-001-11C2-effective-policy-active-guide-reads.md +++ b/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/chunks/WS-AUTH-001-11C2-effective-policy-active-guide-reads.md @@ -67,7 +67,7 @@ token-role fallback or dual authorization ```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=app.modules.projects --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 --cov=app.modules.authorization --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 git diff --check @@ -75,8 +75,9 @@ 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. The protected -`app/modules/projects/*` 90 percent report introduced by 11B remains mandatory. +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. ## Required reviewers diff --git a/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/reviews/WS-AUTH-001-11B-internal-review-evidence.md b/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/reviews/WS-AUTH-001-11B-internal-review-evidence.md new file mode 100644 index 00000000..fa04bada --- /dev/null +++ b/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/reviews/WS-AUTH-001-11B-internal-review-evidence.md @@ -0,0 +1,48 @@ +# WS-AUTH-001-11B Internal Review Evidence + +## Scope + +Project identity and self authorization-context hard cutover to current local +grants. Review covered the complete branch diff against the corrected 11B +contract. + +## Plan review + +| Track | Result | Resolution | +|---|---|---| +| Architecture | PASS | AUTH owns the context projection; project and contributor response schemas remain distinct. | +| Security/auth | PASS | Exact actor/link revalidation, project scope, concealment, and route/action/evidence binding are explicit. | +| QA/test | PASS | Role matrix, denial cases, API controls, E2E, and focused project-read coverage are required. | +| Product/ops | PASS | Admin and contributor projections and role precedence are explicit. | +| Senior engineering | PASS | Exact manifests and the central decision/evidence path are explicit. | +| CI integrity | PASS WITH CONDITIONS | Preserve all existing lanes and floors; prove every new project-read branch without misrepresenting legacy project coverage. | + +## Implementation review + +| Track | Final result | Findings resolved | +|---|---|---| +| Architecture | PASS WITH LOW RISKS | Missing-project decisions now use AUTH evidence; unrelated roles are filtered; project lifecycle projection shares the kernel guard. | +| Security/auth | PASS | Both reads use human/rate admission; decisions retain matched grant/project evidence; matched grants remain locked through projection. | +| QA/test | PASS WITH LOW RISKS | Added contributor, cross-project, revocation, suspension, link-revocation, rate, nonhuman, missing-project, and archived-project proof. | +| Product/ops | PASS | Context includes active AUTH-10B/10C/11B project actions and excludes planned 11C actions. | +| Senior engineering | PASS WITH LOW RISKS | Restored scope-specific denial evidence and public service exports. | +| CI integrity | PASS WITH LOW RISKS | No established gate weakening; hosted full-suite, global 78%, and actor/authorization 90% floors remain required. | + +## Local evidence + +- `ruff check app tests scripts`: passed. +- Focused kernel, context projection, rate-admission, and nonhuman tests: passed. +- `tests/test_api_controls.py`: 27 passed. +- Catalogue activation test: passed. +- Python compile and docstring gate: passed. +- Stale wording, stale authorization docs, Markdown links, lightweight agent + gates, and `git diff --check`: passed. +- Hosted Backend run `30360376315` passed the database-backed route tests, four + semantic lanes, independent custody validation, API E2E, full-suite 78% + coverage floor, and every established subsystem coverage floor. + +## Residual review focus + +The manual `_PROJECT_CONTEXT_ACTIONS` inventory must be updated by future +project-action activation chunks. A later catalogue metadata grouping may +replace it; no additional abstraction is required for 11B. diff --git a/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/reviews/WS-AUTH-001-11B-pr-trust-bundle.md b/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/reviews/WS-AUTH-001-11B-pr-trust-bundle.md new file mode 100644 index 00000000..c7e84fa5 --- /dev/null +++ b/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/reviews/WS-AUTH-001-11B-pr-trust-bundle.md @@ -0,0 +1,116 @@ +# WS-AUTH-001-11B PR Trust Bundle + +## Chunk + +`WS-AUTH-001-11B` - Project Identity And Actor Context Cutover (L1/P1). + +## Goal + +Make project identity and the caller's project authorization context depend +only on canonical Workstream grants, with no token-role fallback. + +## Human-approved intent + +The user approved resuming AUTH-11 while ART proceeds independently and required +each critical authorization chunk to be re-reviewed before implementation. + +## What changed and why + +- Activated only `project.read` and `actor.authorization_context.read`. +- Hard-cut `GET /api/v1/projects/{project_id}` to local admin/project grants. +- Added `GET /api/v1/actors/me/authorization-context?project_id=...`. +- Added distinct full-admin and minimal-contributor project schemas. +- Added typed project/self-project resource contexts, project-role matched + authority evidence, scope-aware denials, and missing-project evidence. +- Added human read admission, durable rate control, concealment, and current + actor/link revalidation to both reads. +- Added an AUTH-owned context projection of active, route-backed project actions + with shared project-lifecycle guards. +- Added focused route, projection, denial, lifecycle, and API E2E coverage for + every new 11B project-read branch. + +These changes remove issuer token roles from project-read authority and prevent +the context route from becoming a project-existence or capability oracle. + +## Design chosen + +Routers resolve the canonical project, the kernel owns authorization and audit +evidence, project service owns response projection, and AUTH read service owns +the context read model. Missing selectors use a bounded AUTH selector solely for +denial evidence. Matched grants are locked through context projection. + +## Alternatives rejected + +- Token-role fallback or dual authorization. +- Empty authorization contexts for projects the caller cannot access. +- Repeated per-action `require()` calls that would create synthetic audit events. +- One project schema that could accidentally leak admin-only fields. +- Route-local concealed errors without central denial evidence. + +## Scope control + +No project mutations, grant mutations, setup/policy/guide read cutovers, +collection endpoint, new migration, or compatibility alias was added. AUTH-11C +actions remain planned. + +## Product behavior + +Operator, covered Project Manager, Finance Authority, and Audit Authority grants +receive the registered full project identity. Exact-project Submitter, Reviewer, +or Adjudicator grants receive id, name, and status only. Access Administrator +alone and token roles are denied. The context route returns only relevant role +names and active project actions for the exact project. + +## Acceptance criteria proof and test delta + +Tests cover catalogue activation, admin/project-role evidence, admin precedence, +minimal projection, cross-project and revoked-grant denial, suspended actor, +revoked exact link, missing-project evidence, rate-before-lookup, nonhuman +concealment, planned-action exclusion, archived lifecycle filtering, OpenAPI, +and real API E2E expectations. Existing token-role project-read success was +intentionally changed to concealed denial. + +## Tests and checks run + +Local focused tests, Ruff, OpenAPI controls, compile/docstring checks, stale +wording and authorization scans, Markdown links, lightweight agent gates, and +`git diff --check` passed. Full database-backed semantic lanes, API E2E, and +coverage run in GitHub Actions because the local machine is intentionally not +used for the multi-hour full suite. + +## CI integrity + +The existing four semantic lanes, API E2E, repository-wide 78% floor, actor 90% +floor, and authorization 90% floor are unchanged. The pre-existing project +subsystem remains governed by the repository-wide floor pending its dedicated +coverage uplift. No skipped failure, threshold reduction, or bypass was +introduced. + +## Reviewer results + +Architecture, security, QA, product/ops, senior engineering, and CI integrity +all passed after their findings were fixed. Detailed evidence is in +`WS-AUTH-001-11B-internal-review-evidence.md`. + +## External review + +PR #214 Agent Gates and Backend passed on the final implementation tree; +Backend run `30360376315` passed the four semantic lanes, independent custody +validation, real API E2E, full-suite 78% coverage floor, and every established +subsystem coverage floor. All CodeRabbit findings were fixed or answered with +contract-backed rationale, and all review threads are resolved. + +## Remaining risks and follow-up + +Hosted Backend must prove the database-backed route test, API E2E, full suite, +and all coverage floors. Future project-action activation must update the +explicit context-action inventory. AUTH-11C1 and 11C2 remain separate chunks. + +## Human review focus + +Review exact grant precedence, minimal contributor fields, missing/cross-project +concealment with evidence, matched-grant locking, and context action filtering. + +## Human merge ownership + +Only the user may approve and merge this PR. diff --git a/backend/app/api/deps/authorization.py b/backend/app/api/deps/authorization.py index 27f2a2a0..78373e8b 100644 --- a/backend/app/api/deps/authorization.py +++ b/backend/app/api/deps/authorization.py @@ -31,6 +31,7 @@ from app.modules.authorization.repository import AdminAuthorizationRepository from app.modules.authorization.runtime import ( ActorKind, + ActorAuthorizationContextResourceContext, ActorSelfResourceContext, ActorStatus, AuthorizationContext, @@ -38,6 +39,7 @@ AuthorizationEvidenceUnavailable, HumanAuthorizationContext, IdentityLinkStatus, + ProjectReadResourceContext, ServiceAuthorizationContext, ) from app.modules.actors.service_identities import ServiceIdentity @@ -125,6 +127,8 @@ def authorization_http_error(exc: AuthorizationDenied) -> StructuredHTTPExceptio ActionId.PROJECT_CONTRIBUTOR_CANDIDATE_LIST, ActionId.PROJECT_ROLE_GRANT_LIST, ActionId.PROJECT_ROLE_GRANT_READ, + ActionId.PROJECT_READ, + ActionId.ACTOR_AUTHORIZATION_CONTEXT_READ, } if exc.decision.action_id in concealed_project_reads: return StructuredHTTPException( @@ -218,10 +222,16 @@ def _compose_authorization_service( async def revalidate_actor_self( context: AuthorizationContext, - resource: ActorSelfResourceContext, + resource: ( + ActorSelfResourceContext + | ActorAuthorizationContextResourceContext + | ProjectReadResourceContext + ), ) -> AuthorizationContext: """Rebuild actor state from exact rows locked in the caller transaction.""" - if 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/api/routes/auth.py b/backend/app/api/routes/auth.py index 3b59016a..563cdf33 100644 --- a/backend/app/api/routes/auth.py +++ b/backend/app/api/routes/auth.py @@ -5,7 +5,7 @@ from typing import Annotated from uuid import UUID -from fastapi import APIRouter, Depends +from fastapi import APIRouter, Depends, Query from sqlalchemy.exc import SQLAlchemyError from sqlalchemy.ext.asyncio import AsyncSession @@ -14,13 +14,24 @@ actor_registry_unavailable_error, get_registered_actor, ) -from app.api.deps.authorization import get_authorization_actor, get_authorization_service +from app.api.deps.authorization import ( + enforce_human_authorization_read, + get_authorization_actor, + get_authorization_service, +) from app.db.session import get_db_session -from app.modules.actors.schemas import ActorProfileSelfResponse, ActorProfileUpdateRequest +from app.modules.actors.schemas import ( + ActorAuthorizationContextResponse, + ActorProfileSelfResponse, + ActorProfileUpdateRequest, +) from app.modules.actors.service import ActorRegistryError, ActorService, ResolvedActor from app.modules.authorization.admin_service import AdminRoleGrantService from app.modules.authorization.catalogue import ActionId from app.modules.authorization.kernel import AuthorizationService +from app.modules.authorization.read_service import ActorAuthorizationContextReadService +from app.modules.authorization.runtime import authorization_resource_selector_id +from app.modules.projects.service import ProjectService from app.schemas.auth import ActorContext, ActorResponse router = APIRouter(prefix="/auth", tags=["auth"]) @@ -73,6 +84,45 @@ async def read_current_actor_profile( raise actor_registry_unavailable_error() from exc +@actors_router.get( + "/me/authorization-context", + response_model=ActorAuthorizationContextResponse, + openapi_extra={ + "x-workstream-action-id": ActionId.ACTOR_AUTHORIZATION_CONTEXT_READ.value + }, + dependencies=[Depends(enforce_human_authorization_read)], +) +async def read_current_actor_authorization_context( + project_id: Annotated[str, Query(min_length=1, max_length=100)], + resolved: Annotated[ResolvedActor, Depends(get_authorization_actor)], + authorization: Annotated[AuthorizationService, Depends(get_authorization_service)], + session: Annotated[AsyncSession, Depends(get_db_session)], +) -> ActorAuthorizationContextResponse: + """Return the caller's effective authority for one canonical project.""" + try: + projects = ProjectService(session) + project = await projects.find_project(project_id) + selector_id = ( + UUID(project.id) + if project is not None + else authorization_resource_selector_id("project", project_id) + ) + service = ActorAuthorizationContextReadService.from_session(authorization, session) + response = await service.read( + resolved=resolved, + project=project, + project_selector_id=selector_id, + ) + await session.commit() + return response + except ActorRegistryError as exc: + await session.rollback() + raise actor_registry_http_error(exc) from exc + except SQLAlchemyError as exc: + await session.rollback() + raise actor_registry_unavailable_error() from exc + + @actors_router.patch( "/me", response_model=ActorProfileSelfResponse, diff --git a/backend/app/modules/actors/schemas.py b/backend/app/modules/actors/schemas.py index 3206ad4c..cc4d6263 100644 --- a/backend/app/modules/actors/schemas.py +++ b/backend/app/modules/actors/schemas.py @@ -9,6 +9,7 @@ from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator from app.modules.actors.service_identities import ServiceIdentity +from app.modules.authorization.catalogue import ActionId def normalize_skill_tags(value: list[str]) -> list[str]: @@ -70,6 +71,19 @@ class ActorProfileSelfResponse(BaseModel): last_seen_at: datetime | None +class ActorAuthorizationContextResponse(BaseModel): + """Self-only effective authority projected onto one canonical project.""" + + model_config = ConfigDict(extra="forbid") + + actor_profile_id: UUID + status: Literal["active", "suspended", "deactivated"] + project_id: UUID + admin_roles: tuple[str, ...] + project_roles: tuple[Literal["submitter", "reviewer", "adjudicator"], ...] + effective_action_ids: tuple[ActionId, ...] + + class ActorProfileAdminResponse(BaseModel): """Privacy-bounded administrative view of one canonical actor.""" diff --git a/backend/app/modules/authorization/catalogue.py b/backend/app/modules/authorization/catalogue.py index 160e6b56..9e5b95ff 100644 --- a/backend/app/modules/authorization/catalogue.py +++ b/backend/app/modules/authorization/catalogue.py @@ -352,8 +352,8 @@ def _active( PermissionId.PROJECT_ROLE_GRANT_MANAGE, ActionOwner.AUTH_10C, ), - _planned(ActionId.PROJECT_READ, PermissionId.PROJECT_READ, ActionOwner.AUTH_11B), - _planned( + _active(ActionId.PROJECT_READ, PermissionId.PROJECT_READ, ActionOwner.AUTH_11B), + _active( ActionId.ACTOR_AUTHORIZATION_CONTEXT_READ, PermissionId.ACTOR_PROFILE_READ_SELF, ActionOwner.AUTH_11B, @@ -679,6 +679,8 @@ def _index_actions( ActionId.PROJECT_ROLE_GRANT_READ, ActionId.PROJECT_ROLE_GRANT_ISSUE, ActionId.PROJECT_ROLE_GRANT_REVOKE, + ActionId.PROJECT_READ, + ActionId.ACTOR_AUTHORIZATION_CONTEXT_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 c18d2600..0c3cac70 100644 --- a/backend/app/modules/authorization/kernel.py +++ b/backend/app/modules/authorization/kernel.py @@ -24,6 +24,7 @@ from app.modules.authorization.repository import AdminAuthorizationRepository from app.modules.authorization.runtime import ( ActorAdminRoleGrantHistoryResourceContext, + ActorAuthorizationContextResourceContext, ActorIdentityLinkAdminReadResourceContext, ActorIdentityLinkLifecycleResourceContext, ActorKind, @@ -49,6 +50,7 @@ MatchedAuthorityKind, PermissionCatalogueResourceContext, ProjectContributorCandidateCollectionResourceContext, + ProjectReadResourceContext, ProjectRoleGrantCollectionResourceContext, ProjectRoleGrantIssueResourceContext, ProjectRoleGrantReadResourceContext, @@ -62,7 +64,11 @@ ) ContextRevalidator = Callable[ - [HumanAuthorizationContext, ActorSelfResourceContext], Awaitable[HumanAuthorizationContext] + [ + HumanAuthorizationContext, + ActorSelfResourceContext | ActorAuthorizationContextResourceContext | ProjectReadResourceContext, + ], + Awaitable[HumanAuthorizationContext], ] ServiceContextRevalidator = Callable[ [ServiceAuthorizationContext, ActionId], @@ -127,6 +133,16 @@ } +def project_action_available_for_status(action_id: ActionId, project_status: str) -> bool: + """Apply project-only lifecycle guards shared by decisions and projections.""" + if action_id in { + ActionId.PROJECT_CONTRIBUTOR_CANDIDATE_LIST, + ActionId.PROJECT_ROLE_GRANT_ISSUE, + }: + return project_status in {"draft", "active", "paused"} + return True + + class _PrelockedAuthority: """AUTH-private authority facts locked by the prepared protocol.""" @@ -455,6 +471,31 @@ async def require( ) = await self._admin_denial(action, resource_context, context) if denial is None: matched_kind = MatchedAuthorityKind.ADMIN_ROLE_GRANT + elif action is not None and action.action_id is ActionId.PROJECT_READ: + ( + denial, + context, + matched_kind, + matched_grant_id, + matched_project_id, + revalidated, + ) = await self._project_read_denial(action, resource_context, context) + elif action is not None and action.action_id is ActionId.ACTOR_AUTHORIZATION_CONTEXT_READ: + if ( + isinstance(context, HumanAuthorizationContext) + and isinstance(resource_context, ActorAuthorizationContextResourceContext) + and self._revalidate_actor_self is not None + ): + context = await self._revalidate_actor_self(context, resource_context) + revalidated = True + ( + denial, + matched_kind, + matched_grant_id, + matched_project_id, + ) = await self._authorization_context_denial( + action, resource_context, context, revalidated + ) else: if ( action is not None @@ -482,6 +523,154 @@ async def require( revalidated=revalidated, ) + async def _project_read_denial( + self, + action, + resource: AuthorizationResourceContext, + context: AuthorizationContext, + ) -> tuple[ + AuthorizationDenialCode | None, + AuthorizationContext, + MatchedAuthorityKind | None, + UUID | None, + UUID | None, + bool, + ]: + """Authorize one canonical project through admin or contributor grants.""" + if not isinstance(context, HumanAuthorizationContext) or not isinstance( + resource, ProjectReadResourceContext + ): + return AuthorizationDenialCode.RESOURCE_GUARD_DENIED, context, None, None, None, False + if action.availability is not ActionAvailability.ACTIVE: + return AuthorizationDenialCode.ACTION_UNAVAILABLE, context, None, None, None, False + if self._revalidate_actor_self is None: + return AuthorizationDenialCode.RESOURCE_GUARD_DENIED, context, None, None, None, False + context = await self._revalidate_actor_self(context, resource) + lifecycle = self._lifecycle_denial(context) + if lifecycle is not None: + return lifecycle, context, None, None, None, True + if not resource.project_exists: + return ( + AuthorizationDenialCode.RESOURCE_NOT_FOUND, + context, + None, + None, + None, + True, + ) + # Keep the matched grant stable through response projection and commit so a + # concurrent revoke cannot authorize a stale read from this transaction. + admin_grant = await self._admin.find_effective_grant( + context.actor_profile_id, + action.permission_id, + scope_project_id=resource.scope_project_id, + system_scope_only=False, + for_update=True, + ) + if admin_grant is not None: + return ( + None, + context, + MatchedAuthorityKind.ADMIN_ROLE_GRANT, + admin_grant.id, + resource.scope_project_id, + True, + ) + project_grant = await self._admin.find_active_project_role_any( + project_id=resource.scope_project_id, + actor_profile_id=context.actor_profile_id, + for_update=True, + ) + if project_grant is not None: + return ( + None, + context, + MatchedAuthorityKind.PROJECT_ROLE_GRANT, + project_grant.id, + resource.scope_project_id, + True, + ) + out_of_scope = await self._admin.has_effective_permission_any_scope( + context.actor_profile_id, action.permission_id + ) or await self._admin.has_active_project_role_any_project(context.actor_profile_id) + return ( + AuthorizationDenialCode.SCOPE_NOT_AUTHORIZED + if out_of_scope + else AuthorizationDenialCode.PERMISSION_NOT_GRANTED, + context, + None, + None, + None, + True, + ) + + async def _authorization_context_denial( + self, + action, + resource: AuthorizationResourceContext, + context: AuthorizationContext, + revalidated: bool, + ) -> tuple[ + AuthorizationDenialCode | None, + MatchedAuthorityKind | None, + UUID | None, + UUID | None, + ]: + """Authorize a caller-owned context projection for one exact project.""" + if ( + not isinstance(context, HumanAuthorizationContext) + or not isinstance(resource, ActorAuthorizationContextResourceContext) + or resource.resource_id != context.actor_profile_id + or not revalidated + ): + return AuthorizationDenialCode.RESOURCE_GUARD_DENIED, None, None, None + if action.availability is not ActionAvailability.ACTIVE: + return AuthorizationDenialCode.ACTION_UNAVAILABLE, None, None, None + lifecycle = self._lifecycle_denial(context) + if lifecycle is not None: + return lifecycle, None, None, None + if not resource.project_exists: + return AuthorizationDenialCode.RESOURCE_NOT_FOUND, None, None, None + # The context projection reuses these rows after authorization; lock the + # matched grant until commit so its advertised authority cannot go stale. + admin_grant = await self._admin.find_effective_grant( + context.actor_profile_id, + PermissionId.PROJECT_READ, + scope_project_id=resource.scope_project_id, + system_scope_only=False, + for_update=True, + ) + if admin_grant is not None: + return ( + None, + MatchedAuthorityKind.ADMIN_ROLE_GRANT, + admin_grant.id, + resource.scope_project_id, + ) + project_grant = await self._admin.find_active_project_role_any( + project_id=resource.scope_project_id, + actor_profile_id=context.actor_profile_id, + for_update=True, + ) + if project_grant is None: + out_of_scope = await self._admin.has_effective_permission_any_scope( + context.actor_profile_id, PermissionId.PROJECT_READ + ) or await self._admin.has_active_project_role_any_project(context.actor_profile_id) + return ( + AuthorizationDenialCode.SCOPE_NOT_AUTHORIZED + if out_of_scope + else AuthorizationDenialCode.PERMISSION_NOT_GRANTED, + None, + None, + None, + ) + return ( + None, + MatchedAuthorityKind.PROJECT_ROLE_GRANT, + project_grant.id, + resource.scope_project_id, + ) + async def _require_prelocked( self, consumer_token: object, @@ -744,7 +933,7 @@ async def _admin_guard( elif isinstance(resource, ProjectRoleGrantIssueResourceContext): if resource.target_actor_profile_id == context.actor_profile_id: return AuthorizationDenialCode.SELF_GRANT_FORBIDDEN - if resource.project_status not in {"draft", "active", "paused"}: + if not project_action_available_for_status(action_id, resource.project_status): return AuthorizationDenialCode.RESOURCE_GUARD_DENIED if not resource.target_eligible: return AuthorizationDenialCode.ACTOR_NOT_FOUND @@ -777,7 +966,7 @@ async def _admin_guard( ) and not await self._admin.actor_exists(resource.resource_id): return AuthorizationDenialCode.ACTOR_NOT_FOUND elif isinstance(resource, ProjectContributorCandidateCollectionResourceContext): - if resource.project_status not in {"draft", "active", "paused"}: + if not project_action_available_for_status(action_id, resource.project_status): return AuthorizationDenialCode.RESOURCE_GUARD_DENIED return None diff --git a/backend/app/modules/authorization/read_service.py b/backend/app/modules/authorization/read_service.py index d81a3010..5a196471 100644 --- a/backend/app/modules/authorization/read_service.py +++ b/backend/app/modules/authorization/read_service.py @@ -4,9 +4,16 @@ from uuid import UUID +from sqlalchemy.ext.asyncio import AsyncSession + from app.modules.actors.repository import ActorRepository -from app.modules.authorization.catalogue import ActionId -from app.modules.authorization.kernel import AuthorizationService +from app.modules.actors.schemas import ActorAuthorizationContextResponse +from app.modules.actors.service import ResolvedActor +from app.modules.authorization.catalogue import ACTION_BY_ID, ActionAvailability, ActionId +from app.modules.authorization.kernel import ( + AuthorizationService, + project_action_available_for_status, +) from app.modules.authorization.models import ( ProjectRoleGrant, ProjectRoleQualificationSnapshot, @@ -18,11 +25,13 @@ ) from app.modules.authorization.repository import AdminAuthorizationRepository from app.modules.authorization.runtime import ( + ActorAuthorizationContextResourceContext, ProjectContributorCandidateCollectionResourceContext, ProjectRoleGrantCollectionResourceContext, ProjectRoleGrantReadResourceContext, ) from app.modules.authorization.schemas import ( + AdminRole, ContributorCandidateListResponse, ContributorCandidateRead, ProjectRole, @@ -32,12 +41,116 @@ QualificationAvailabilitySnapshot, ) from app.modules.projects.models import Project +from app.modules.authorization.policy import permissions_for + + +_PROJECT_CONTEXT_ACTIONS = ( + ActionId.PROJECT_CONTRIBUTOR_CANDIDATE_LIST, + ActionId.PROJECT_ROLE_GRANT_LIST, + ActionId.PROJECT_ROLE_GRANT_READ, + ActionId.PROJECT_ROLE_GRANT_ISSUE, + ActionId.PROJECT_ROLE_GRANT_REVOKE, + ActionId.PROJECT_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.PROJECT_EFFECTIVE_SUBMISSION_ARTIFACT_POLICY_READ, + ActionId.PROJECT_PRE_SUBMIT_CHECKER_POLICY_READ, + ActionId.PROJECT_ACTIVE_GUIDE_READ, +) class ProjectRoleReadResourceNotFound(LookupError): """One concealed absence for project or project-role grant reads.""" +class ActorAuthorizationContextReadService: + """Build one bounded self authority projection from canonical local grants.""" + + def __init__( + self, + authorization: AuthorizationService, + grants: AdminAuthorizationRepository, + ) -> None: + self._authorization = authorization + self._grants = grants + + @classmethod + def from_session( + cls, + authorization: AuthorizationService, + session: AsyncSession, + ) -> ActorAuthorizationContextReadService: + """Compose the read service without exposing persistence at the API boundary.""" + return cls(authorization, AdminAuthorizationRepository(session)) + + async def read( + self, + *, + resolved: ResolvedActor, + project: Project | None, + project_selector_id: UUID, + ) -> ActorAuthorizationContextResponse: + """Authorize self access and derive effective exact-project actions.""" + actor_profile_id = UUID(resolved.profile.id) + project_id = UUID(project.id) if project is not None else project_selector_id + await self._authorization.require( + ActionId.ACTOR_AUTHORIZATION_CONTEXT_READ, + ActorAuthorizationContextResourceContext( + resource_type="actor_authorization_context", + resource_id=actor_profile_id, + scope_project_id=project_id, + project_exists=project is not None, + project_status=project.status if project is not None else None, + ), + ) + if project is None: + raise RuntimeError("missing project authorization unexpectedly allowed") + admin_roles = await self._grants.effective_admin_roles_for_project( + project_id=project_id, + actor_profile_id=actor_profile_id, + ) + project_roles = await self._grants.active_project_roles_for_actor( + project_id=project_id, + actor_profile_id=actor_profile_id, + ) + eligible_admin_roles = tuple( + role_name + for role_name in admin_roles + if any( + ACTION_BY_ID[action_id].availability is ActionAvailability.ACTIVE + and ACTION_BY_ID[action_id].permission_id + in permissions_for(AdminRole(role_name)) + for action_id in _PROJECT_CONTEXT_ACTIONS + ) + ) + admin_permissions = { + permission + for role_name in eligible_admin_roles + for permission in permissions_for(AdminRole(role_name)) + } + effective_actions = { + action_id + for action_id in _PROJECT_CONTEXT_ACTIONS + if ACTION_BY_ID[action_id].availability is ActionAvailability.ACTIVE + and ACTION_BY_ID[action_id].permission_id in admin_permissions + and project_action_available_for_status(action_id, project.status) + } + if project_roles: + effective_actions.add(ActionId.PROJECT_READ) + return ActorAuthorizationContextResponse( + actor_profile_id=actor_profile_id, + status=resolved.profile.status, + project_id=project_id, + admin_roles=eligible_admin_roles, + project_roles=project_roles, + effective_action_ids=tuple(sorted(effective_actions, key=str)), + ) + + class ProjectRoleReadService: """Authorize before decoding cursors and querying private read rows.""" @@ -223,6 +336,7 @@ def _grant_read( __all__ = [ + "ActorAuthorizationContextReadService", "InvalidPaginationCursor", "ProjectRoleReadResourceNotFound", "ProjectRoleReadService", diff --git a/backend/app/modules/authorization/repository.py b/backend/app/modules/authorization/repository.py index ac7359f8..bd472b3b 100644 --- a/backend/app/modules/authorization/repository.py +++ b/backend/app/modules/authorization/repository.py @@ -147,6 +147,85 @@ async def find_active_project_role( ) ) + async def find_active_project_role_any( + self, *, project_id: UUID, actor_profile_id: UUID, for_update: bool = False + ) -> ProjectRoleGrant | None: + """Return one deterministic active exact-project contributor grant.""" + query = ( + select(ProjectRoleGrant) + .where( + ProjectRoleGrant.project_id == str(project_id), + ProjectRoleGrant.actor_profile_id == str(actor_profile_id), + ProjectRoleGrant.status == "active", + ) + .order_by(ProjectRoleGrant.role, ProjectRoleGrant.id) + .limit(1) + ) + if for_update: + query = query.with_for_update(of=ProjectRoleGrant) + return await self._session.scalar(query) + + async def active_project_roles_for_actor( + self, *, project_id: UUID, actor_profile_id: UUID + ) -> tuple[str, ...]: + """Project sorted active contributor role names for one exact project.""" + roles = await self._session.scalars( + select(ProjectRoleGrant.role) + .where( + ProjectRoleGrant.project_id == str(project_id), + ProjectRoleGrant.actor_profile_id == str(actor_profile_id), + ProjectRoleGrant.status == "active", + ) + .distinct() + .order_by(ProjectRoleGrant.role) + ) + return tuple(roles.all()) + + async def has_active_project_role_any_project(self, actor_profile_id: UUID) -> bool: + """Return whether the actor has contributor authority on any project.""" + return ( + await self._session.scalar( + select(ProjectRoleGrant.id) + .where( + ProjectRoleGrant.actor_profile_id == str(actor_profile_id), + ProjectRoleGrant.status == "active", + ) + .limit(1) + ) + is not None + ) + + async def effective_admin_roles_for_project( + self, *, project_id: UUID, actor_profile_id: UUID + ) -> tuple[str, ...]: + """Project sorted effective admin role names for one exact project.""" + roles = await self._session.scalars( + select(AdminRoleGrant.role) + .join(ActorProfile, ActorProfile.id == AdminRoleGrant.target_actor_profile_id) + .where( + AdminRoleGrant.target_actor_profile_id == str(actor_profile_id), + AdminRoleGrant.status == "active", + ActorProfile.actor_kind == "human", + ActorProfile.status == "active", + exists( + select(ActorIdentityLink.id).where( + ActorIdentityLink.actor_profile_id == ActorProfile.id, + ActorIdentityLink.status == "active", + ) + ), + or_( + AdminRoleGrant.scope_type == AdminScope.SYSTEM.value, + and_( + AdminRoleGrant.scope_type == AdminScope.PROJECT.value, + AdminRoleGrant.scope_project_id == str(project_id), + ), + ), + ) + .distinct() + .order_by(AdminRoleGrant.role) + ) + return tuple(roles.all()) + async def add_project_role_snapshot( self, snapshot: ProjectRoleQualificationSnapshot ) -> ProjectRoleQualificationSnapshot: diff --git a/backend/app/modules/authorization/runtime.py b/backend/app/modules/authorization/runtime.py index b485592d..7aff25f3 100644 --- a/backend/app/modules/authorization/runtime.py +++ b/backend/app/modules/authorization/runtime.py @@ -4,7 +4,7 @@ from enum import StrEnum from typing import Literal -from uuid import UUID +from uuid import NAMESPACE_URL, UUID, uuid5 from pydantic import BaseModel, ConfigDict, Field, JsonValue, model_validator @@ -198,6 +198,46 @@ def require_unique_fields(self): return self +class ProjectReadResourceContext(BaseModel): + """Canonical project facts for one project identity read.""" + + model_config = _STRICT_FROZEN + + resource_type: Literal["project"] + resource_id: UUID + scope_project_id: UUID + project_exists: bool = True + project_status: str | None + + @model_validator(mode="after") + def require_exact_project(self): + """Bind the resource and authority scope to one project.""" + if self.resource_id != self.scope_project_id: + raise ValueError("project read scope must match resource") + if self.project_exists != (self.project_status is not None): + raise ValueError("project existence and status are inconsistent") + return self + + +class ActorAuthorizationContextResourceContext(BaseModel): + """Self-owned selector for authority projected onto one project.""" + + model_config = _STRICT_FROZEN + + resource_type: Literal["actor_authorization_context"] + resource_id: UUID + scope_project_id: UUID + project_exists: bool = True + project_status: str | None + + @model_validator(mode="after") + def require_project_existence_shape(self): + """Keep missing-project selectors free of fabricated lifecycle facts.""" + if self.project_exists != (self.project_status is not None): + raise ValueError("project existence and status are inconsistent") + return self + + class ActorProfileAdminReadResourceContext(BaseModel): """Server-composed selector for one administrative actor-profile read.""" @@ -459,6 +499,8 @@ def bind_page_size(self): AuthorizationResourceContext = ( ActorSelfResourceContext + | ProjectReadResourceContext + | ActorAuthorizationContextResourceContext | ActorProfileAdminReadResourceContext | ActorIdentityLinkAdminReadResourceContext | ActorProfileLifecycleResourceContext @@ -489,6 +531,14 @@ def authorization_resource_digest(resource: AuthorizationResourceContext) -> str ) +def authorization_resource_selector_id(resource_type: str, raw_id: str) -> UUID: + """Return a bounded UUID selector for missing-resource decision evidence.""" + try: + return UUID(raw_id) + except (TypeError, ValueError, AttributeError): + return uuid5(NAMESPACE_URL, f"workstream:{resource_type}-selector:{raw_id}") + + class AuthorizationDenialCode(StrEnum): """Closed internal authorization outcomes.""" @@ -512,6 +562,7 @@ class MatchedAuthorityKind(StrEnum): ACTOR_SELF = "actor_self" ADMIN_ROLE_GRANT = "admin_role_grant" + PROJECT_ROLE_GRANT = "project_role_grant" FIXED_SERVICE = "fixed_service" @@ -527,6 +578,8 @@ class AuthorizationDecision(BaseModel): denial_code: AuthorizationDenialCode | None resource_type: Literal[ "actor_profile", + "actor_authorization_context", + "project", "actor_identity_link", "system", "permission_catalogue", @@ -579,6 +632,9 @@ def validate_outcome(self): elif self.matched_authority_kind is MatchedAuthorityKind.ADMIN_ROLE_GRANT: if self.matched_grant_id is None: raise ValueError("grant decisions require matched grant") + elif self.matched_authority_kind is MatchedAuthorityKind.PROJECT_ROLE_GRANT: + if self.matched_grant_id is None or self.matched_scope_project_id is None: + raise ValueError("project-role decisions require matched grant and scope") elif self.matched_authority_kind is MatchedAuthorityKind.FIXED_SERVICE: if self.matched_grant_id is not None or self.matched_scope_project_id is not None: raise ValueError("fixed-service decisions cannot carry grant scope") diff --git a/backend/app/modules/projects/router.py b/backend/app/modules/projects/router.py index 4b86e8b0..0207e15c 100644 --- a/backend/app/modules/projects/router.py +++ b/backend/app/modules/projects/router.py @@ -3,11 +3,16 @@ from __future__ import annotations from typing import Annotated +from uuid import UUID from fastapi import APIRouter, Depends, HTTPException, Response, status from sqlalchemy.ext.asyncio import AsyncSession from app.api.deps.auth import get_registered_actor +from app.api.deps.authorization import ( + enforce_human_authorization_read, + get_authorization_service, +) from app.core.permissions import PermissionDenied from app.db.session import get_db_session from app.modules.projects.schemas import ( @@ -20,6 +25,7 @@ GuideSufficiencyReportResponse, PreSubmitCheckerPolicySummaryResponse, ProjectCreate, + ContributorProjectResponse, ProjectGuideCreate, ProjectGuideResponse, ProjectGuideUpdate, @@ -34,6 +40,13 @@ SubmissionArtifactPolicyUpdate, ) from app.modules.projects.service import ProjectService, ProjectServiceError +from app.modules.authorization.catalogue import ActionId +from app.modules.authorization.kernel import AuthorizationService +from app.modules.authorization.runtime import ( + MatchedAuthorityKind, + ProjectReadResourceContext, + authorization_resource_selector_id, +) from app.schemas.auth import ActorContext router = APIRouter(prefix="/projects", tags=["projects"]) @@ -78,17 +91,46 @@ async def create_project( raise project_http_error(exc) from exc -@router.get("/{project_id}", response_model=ProjectResponse) +@router.get( + "/{project_id}", + response_model=ProjectResponse | ContributorProjectResponse, + openapi_extra={"x-workstream-action-id": ActionId.PROJECT_READ.value}, + dependencies=[Depends(enforce_human_authorization_read)], +) async def get_project( project_id: str, - actor: Annotated[ActorContext, Depends(get_registered_actor)], + authorization: Annotated[AuthorizationService, Depends(get_authorization_service)], session: Annotated[AsyncSession, Depends(get_db_session)], -) -> ProjectResponse: +) -> ProjectResponse | ContributorProjectResponse: """Return one project by id.""" try: - return await ProjectService(session).get_project(actor, project_id) - except PermissionDenied as exc: - raise permission_http_error(exc) from exc + service = ProjectService(session) + project = await service.find_project(project_id) + project_uuid = ( + UUID(project.id) + if project is not None + else authorization_resource_selector_id("project", project_id) + ) + decision = await authorization.require( + ActionId.PROJECT_READ, + ProjectReadResourceContext( + resource_type="project", + resource_id=project_uuid, + scope_project_id=project_uuid, + project_exists=project is not None, + project_status=project.status if project is not None else None, + ), + ) + if project is None: + raise RuntimeError("missing project authorization unexpectedly allowed") + response = service.project_identity_response( + project, + contributor_only=( + decision.matched_authority_kind is MatchedAuthorityKind.PROJECT_ROLE_GRANT + ), + ) + await session.commit() + return response except ProjectServiceError as exc: raise project_http_error(exc) from exc diff --git a/backend/app/modules/projects/schemas.py b/backend/app/modules/projects/schemas.py index 433234ad..ffbbc4ef 100644 --- a/backend/app/modules/projects/schemas.py +++ b/backend/app/modules/projects/schemas.py @@ -423,6 +423,16 @@ class ProjectResponse(BaseModel): updated_at: datetime +class ContributorProjectResponse(BaseModel): + """Minimal project identity visible through an exact contributor grant.""" + + model_config = ConfigDict(extra="forbid", from_attributes=True) + + id: str + name: str + status: str + + class ProjectGuideCreate(BaseModel): """Request schema for guide material plus optional activation policies.""" diff --git a/backend/app/modules/projects/service.py b/backend/app/modules/projects/service.py index 4fbbb9a7..5c472bb7 100644 --- a/backend/app/modules/projects/service.py +++ b/backend/app/modules/projects/service.py @@ -85,6 +85,7 @@ PostSubmitCheckerPolicySetupSummaryResponse, PreSubmitCheckerPolicySummaryResponse, ProjectCreate, + ContributorProjectResponse, ProjectGuideCreate, ProjectGuideResponse, ProjectGuideUpdate, @@ -497,24 +498,24 @@ async def create_project(self, actor: ActorContext, payload: ProjectCreate) -> P await self._session.refresh(project) return ProjectResponse.model_validate(project) - async def get_project(self, actor: ActorContext, project_id: str) -> ProjectResponse: - """Return one project visible to project setup operators. - - Args: - actor: Verified Flow actor context for the current request. - project_id: Project identifier to load. - - Returns: - Matching project response. - - Raises: - PermissionDenied: If the actor cannot manage project setup. - ProjectNotFound: If the project id is unknown. - """ - require_any_role(actor, PROJECT_SETUP_ROLES) + async def resolve_project(self, project_id: str) -> Project: + """Resolve one canonical project before authorization.""" project = await self._repo.get_project(project_id) if project is None: raise ProjectNotFound("project not found") + return project + + async def find_project(self, project_id: str) -> Project | None: + """Return one canonical project without inventing an absence response.""" + return await self._repo.get_project(project_id) + + @staticmethod + def project_identity_response( + project: Project, *, contributor_only: bool + ) -> ProjectResponse | ContributorProjectResponse: + """Select the server-owned project identity disclosure shape.""" + if contributor_only: + return ContributorProjectResponse.model_validate(project) return ProjectResponse.model_validate(project) async def create_guide( diff --git a/backend/scripts/api_contract_e2e.py b/backend/scripts/api_contract_e2e.py index c5a6f4b1..5fca5626 100644 --- a/backend/scripts/api_contract_e2e.py +++ b/backend/scripts/api_contract_e2e.py @@ -985,6 +985,8 @@ async def exercise_api_contract(base_url: str, env: dict[str, str]) -> None: "/api/v1/projects/{project_id}/contributor-candidates", "/api/v1/projects/{project_id}/role-grants", "/api/v1/projects/{project_id}/role-grants/{grant_id}", + "/api/v1/projects/{project_id}", + "/api/v1/actors/me/authorization-context", } } assert read_actions == { @@ -995,8 +997,11 @@ async def exercise_api_contract(base_url: str, env: dict[str, str]) -> None: "/api/v1/projects/{project_id}/role-grants/{grant_id}": ( "project_role_grant.read" ), + "/api/v1/projects/{project_id}": "project.read", + "/api/v1/actors/me/authorization-context": ( + "actor.authorization_context.read" + ), } - assert not any("authorization-context" in path for path in openapi["paths"]) assert openapi["paths"]["/api/v1/projects/{project_id}/role-grants"]["post"][ "x-workstream-action-id" ] == "project_role_grant.issue" @@ -1242,7 +1247,13 @@ async def exercise_api_contract(base_url: str, env: dict[str, str]) -> None: }, 201, ) - await request_json(client, "GET", f"/api/v1/projects/{project['id']}", manager_token) + await request_json( + client, + "GET", + f"/api/v1/projects/{project['id']}", + manager_token, + expected_status=404, + ) project_manager_grant = await client.post( "/api/v1/admin-role-grants", headers=auth_headers(manager_token) | {"Idempotency-Key": str(uuid4())}, @@ -1255,6 +1266,30 @@ async def exercise_api_contract(base_url: str, env: dict[str, str]) -> None: }, ) assert project_manager_grant.status_code == 201, project_manager_grant.text + project_identity = await request_json( + client, + "GET", + f"/api/v1/projects/{project['id']}", + project_reader_token, + ) + assert project_identity == project + actor_context = await request_json( + client, + "GET", + f"/api/v1/actors/me/authorization-context?project_id={project['id']}", + project_reader_token, + ) + assert actor_context["project_id"] == project["id"] + assert actor_context["admin_roles"] == ["project_manager"] + assert actor_context["project_roles"] == [] + assert actor_context["effective_action_ids"] == [ + "project.contributor_candidate.list", + "project.read", + "project_role_grant.issue", + "project_role_grant.list", + "project_role_grant.read", + "project_role_grant.revoke", + ] candidates = await request_json( client, "GET", diff --git a/backend/tests/test_api_controls.py b/backend/tests/test_api_controls.py index 8ba829c5..16ad3e30 100644 --- a/backend/tests/test_api_controls.py +++ b/backend/tests/test_api_controls.py @@ -439,13 +439,13 @@ def test_openapi_documents_request_error_and_response_context() -> None: for method, operation in path_item.items() if method in methods and operation.get("security") ) - assert len(route_inventory) == 76 + assert len(route_inventory) == 77 assert sha256("\n".join(route_inventory).encode()).hexdigest() == ( - "c8f9852035446ea59b0e929b1bd8c8cfc7df5bf838ceb544c04e899f90169318" + "e9d2f9074f61f3d464142515193514674e87f905d75c0b4d97fee6a7ea99f1fa" ) - assert len(protected_inventory) == 74 + assert len(protected_inventory) == 75 assert sha256("\n".join(protected_inventory).encode()).hexdigest() == ( - "9278d0183ffb87947ee4857e0325483ba7bf07feac0c38a88432840b10c2b0c3" + "b005b2516a43e90437c0035301b56f2100024a2ca3d770fb7025b6bab8ca7378" ) assert set(schema["paths"]["/health"]["get"]["responses"]) == {"200", "400", "500"} assert {"401", "403", "503"} <= set( @@ -473,6 +473,9 @@ def test_openapi_documents_request_error_and_response_context() -> None: assert action_declarations == { "GET /api/v1/actors/me": "actor.profile.read_self", "PATCH /api/v1/actors/me": "actor.profile.update_self", + "GET /api/v1/actors/me/authorization-context": ( + "actor.authorization_context.read" + ), "GET /api/v1/actors/{actor_profile_id}": "actor.profile.read", "GET /api/v1/actors/{actor_profile_id}/identity-links": "actor.identity_link.read", "POST /api/v1/actors/{actor_profile_id}/suspend": "actor.profile.suspend", @@ -506,6 +509,7 @@ def test_openapi_documents_request_error_and_response_context() -> None: "POST /api/v1/projects/{project_id}/role-grants/{grant_id}/revoke": ( "project_role_grant.revoke" ), + "GET /api/v1/projects/{project_id}": "project.read", } project_read_shapes = { "/api/v1/projects/{project_id}/contributor-candidates": ( @@ -520,7 +524,16 @@ def test_openapi_documents_request_error_and_response_context() -> None: "$ref": f"#/components/schemas/{schema_name}" } assert schema["components"]["schemas"][schema_name]["additionalProperties"] is False - assert not any("authorization-context" in path for path in schema["paths"]) + context_operation = schema["paths"]["/api/v1/actors/me/authorization-context"]["get"] + assert context_operation["responses"]["200"]["content"]["application/json"]["schema"] == { + "$ref": "#/components/schemas/ActorAuthorizationContextResponse" + } + assert ( + schema["components"]["schemas"]["ActorAuthorizationContextResponse"][ + "additionalProperties" + ] + is False + ) for path in ( "/api/v1/projects/{project_id}/role-grants", "/api/v1/projects/{project_id}/role-grants/{grant_id}/revoke", diff --git a/backend/tests/test_audit.py b/backend/tests/test_audit.py index 02dce2ac..08025c2c 100644 --- a/backend/tests/test_audit.py +++ b/backend/tests/test_audit.py @@ -177,7 +177,9 @@ def test_action_aware_audit_input_enforces_mapping_and_action_availability() -> ActionId.ACTOR_PROFILE_DEACTIVATE, ActionId.ACTOR_IDENTITY_LINK_REVOKE, ActionId.ACTOR_IDENTITY_LINK_REACTIVATE, + ActionId.ACTOR_AUTHORIZATION_CONTEXT_READ, ActionId.PROJECT_CONTRIBUTOR_CANDIDATE_LIST, + ActionId.PROJECT_READ, ActionId.PROJECT_ROLE_GRANT_LIST, ActionId.PROJECT_ROLE_GRANT_READ, ActionId.PROJECT_ROLE_GRANT_ISSUE, diff --git a/backend/tests/test_authorization.py b/backend/tests/test_authorization.py index 0613f040..723991c6 100644 --- a/backend/tests/test_authorization.py +++ b/backend/tests/test_authorization.py @@ -84,7 +84,10 @@ InvalidPaginationCursor, authorization_read_query_digest, ) -from app.modules.authorization.read_service import ProjectRoleReadService +from app.modules.authorization.read_service import ( + ActorAuthorizationContextReadService, + ProjectRoleReadService, +) from app.modules.authorization.catalogue import ( ACTION_BY_ID, ACTION_DEFINITIONS, @@ -162,6 +165,7 @@ from app.modules.authorization.policy import ADMIN_ROLE_PERMISSIONS, ADMIN_ROLE_SCOPES from app.modules.authorization.runtime import ( ActorAdminRoleGrantHistoryResourceContext, + ActorAuthorizationContextResourceContext, ActorIdentityLinkAdminReadResourceContext, ActorIdentityLinkLifecycleResourceContext, ActorKind, @@ -189,6 +193,7 @@ MatchedAuthorityKind, PermissionCatalogueResourceContext, ProjectContributorCandidateCollectionResourceContext, + ProjectReadResourceContext, ProjectRoleGrantCollectionResourceContext, ProjectRoleGrantIssueResourceContext, ProjectRoleGrantReadResourceContext, @@ -680,9 +685,18 @@ async def list_contributor_candidates(self, *, cursor, **_kwargs): @pytest.mark.asyncio @pytest.mark.parametrize("status_code", [429, 503]) +@pytest.mark.parametrize( + "path", + ( + "/api/v1/projects/{project_id}/role-grants", + "/api/v1/projects/{project_id}", + "/api/v1/actors/me/authorization-context?project_id={project_id}", + ), +) async def test_authorization_read_rate_failure_precedes_project_lookup( monkeypatch: pytest.MonkeyPatch, status_code: int, + path: str, ) -> None: app = create_app(Settings(environment="test")) lookups = 0 @@ -717,7 +731,7 @@ async def verified_human(): base_url="http://testserver", ) as client: response = await client.get( - f"/api/v1/projects/{uuid4()}/role-grants", + path.format(project_id=uuid4()), headers={"Authorization": "Bearer test"}, ) @@ -758,11 +772,18 @@ async def forbidden_project_lookup(*_args, **_kwargs): transport=ASGITransport(app=app), base_url="http://testserver", ) as client: - response = await client.get(f"/api/v1/projects/{uuid4()}/role-grants") + for path in ( + f"/api/v1/projects/{uuid4()}/role-grants", + f"/api/v1/projects/{uuid4()}", + f"/api/v1/actors/me/authorization-context?project_id={uuid4()}", + ): + response = await client.get(path) + assert response.status_code == 404 + assert response.json()["error"]["code"] == ( + "project_authorization_resource_not_found" + ) - assert response.status_code == 404 - assert response.json()["error"]["code"] == ("project_authorization_resource_not_found") - assert consumptions == 1 + assert consumptions == 3 assert lookups == 0 @@ -1793,6 +1814,8 @@ def test_closed_permission_and_action_catalogue_is_exact_and_non_executable() -> ActionId.PROJECT_ROLE_GRANT_READ, ActionId.PROJECT_ROLE_GRANT_ISSUE, ActionId.PROJECT_ROLE_GRANT_REVOKE, + ActionId.PROJECT_READ, + ActionId.ACTOR_AUTHORIZATION_CONTEXT_READ, ActionId.ARTIFACT_VERIFICATION_EXECUTE, ActionId.ARTIFACT_PENDING_WORK_SCAN, ActionId.ARTIFACT_PUT_ATTEMPT_RESOLVE, @@ -1877,14 +1900,14 @@ def test_closed_permission_and_action_catalogue_is_exact_and_non_executable() -> definition.availability is ActionAvailability.ACTIVE for definition in ACTION_DEFINITIONS ) - == 25 + == 27 ) assert ( sum( definition.availability is ActionAvailability.PLANNED for definition in ACTION_DEFINITIONS ) - == 53 + == 51 ) assert resolve_executable_action(ActionId.ACTOR_PROFILE_READ_SELF).permission_id is ( PermissionId.ACTOR_PROFILE_READ_SELF @@ -2077,7 +2100,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, 25 active actions, and\n53 planned actions" in operations + "71 PermissionIds, 78 ActionIds, 27 active actions, and\n51 planned actions" in operations ) @@ -2892,6 +2915,157 @@ def in_nested_transaction(self) -> bool: return self.nested +class _ProjectReadAuthorityFacts: + """Minimal grant repository used by project-read kernel tests.""" + + def __init__(self, *, admin_grant=None, project_grant=None) -> None: + self.admin_grant = admin_grant + self.project_grant = project_grant + + async def find_effective_grant(self, *_args, **_kwargs): + return self.admin_grant + + async def find_active_project_role_any(self, **_kwargs): + return self.project_grant + + async def has_effective_permission_any_scope(self, *_args, **_kwargs): + return False + + async def has_active_project_role_any_project(self, *_args, **_kwargs): + return False + + +class _ContextProjectionFacts: + async def effective_admin_roles_for_project(self, **_kwargs): + return ("access_administrator", "project_manager") + + async def active_project_roles_for_actor(self, **_kwargs): + return ("reviewer", "submitter") + + +class _ContextProjectionAuthorization: + def __init__(self) -> None: + self.calls = [] + + async def require(self, action_id, resource): + self.calls.append((action_id, resource)) + + +@pytest.mark.asyncio +async def test_project_read_kernel_prefers_admin_and_records_project_role_authority() -> None: + context = _runtime_context() + project_id = uuid4() + resource = ProjectReadResourceContext( + resource_type="project", + resource_id=project_id, + scope_project_id=project_id, + project_status="active", + ) + admin_grant = SimpleNamespace(id=uuid4()) + service, _ = _runtime_service( + context, + admin_repository=_ProjectReadAuthorityFacts( + admin_grant=admin_grant, + project_grant=SimpleNamespace(id=uuid4()), + ), + ) + decision = await service.require(ActionId.PROJECT_READ, resource) + assert decision.matched_authority_kind is MatchedAuthorityKind.ADMIN_ROLE_GRANT + assert decision.matched_grant_id == admin_grant.id + assert decision.matched_scope_project_id == project_id + assert decision.revalidated is True + + project_grant = SimpleNamespace(id=uuid4()) + service, _ = _runtime_service( + context, + admin_repository=_ProjectReadAuthorityFacts(project_grant=project_grant), + ) + decision = await service.require(ActionId.PROJECT_READ, resource) + assert decision.matched_authority_kind is MatchedAuthorityKind.PROJECT_ROLE_GRANT + assert decision.matched_grant_id == project_grant.id + assert decision.matched_scope_project_id == project_id + + missing = resource.model_copy(update={"project_exists": False, "project_status": None}) + service, evidence = _runtime_service( + context, + admin_repository=_ProjectReadAuthorityFacts(admin_grant=admin_grant), + ) + with pytest.raises(AuthorizationDenied) as exc_info: + await service.require(ActionId.PROJECT_READ, missing) + assert exc_info.value.decision.denial_code is AuthorizationDenialCode.RESOURCE_NOT_FOUND + assert len(evidence.events) == 1 + + +@pytest.mark.asyncio +async def test_actor_authorization_context_is_self_only_and_revalidated() -> None: + context = _runtime_context() + resource = ActorAuthorizationContextResourceContext( + resource_type="actor_authorization_context", + resource_id=context.actor_profile_id, + scope_project_id=uuid4(), + project_status="active", + ) + service, _ = _runtime_service( + context, + admin_repository=_ProjectReadAuthorityFacts(project_grant=SimpleNamespace(id=uuid4())), + ) + decision = await service.require(ActionId.ACTOR_AUTHORIZATION_CONTEXT_READ, resource) + assert decision.matched_authority_kind is MatchedAuthorityKind.PROJECT_ROLE_GRANT + assert decision.matched_scope_project_id == resource.scope_project_id + assert decision.revalidated is True + + service, _ = _runtime_service(context, admin_repository=_ProjectReadAuthorityFacts()) + with pytest.raises(AuthorizationDenied) as exc_info: + await service.require(ActionId.ACTOR_AUTHORIZATION_CONTEXT_READ, resource) + assert exc_info.value.decision.denial_code is AuthorizationDenialCode.PERMISSION_NOT_GRANTED + + service, _ = _runtime_service(context, admin_repository=_ProjectReadAuthorityFacts()) + with pytest.raises(AuthorizationDenied) as exc_info: + await service.require( + ActionId.ACTOR_AUTHORIZATION_CONTEXT_READ, + resource.model_copy(update={"resource_id": uuid4()}), + ) + assert exc_info.value.decision.denial_code is AuthorizationDenialCode.RESOURCE_GUARD_DENIED + + +@pytest.mark.asyncio +async def test_context_projection_excludes_planned_and_unrelated_actions() -> None: + actor_id, project_id = uuid4(), uuid4() + authorization = _ContextProjectionAuthorization() + service = ActorAuthorizationContextReadService( + authorization, # type: ignore[arg-type] + _ContextProjectionFacts(), # type: ignore[arg-type] + ) + response = await service.read( + resolved=SimpleNamespace(profile=SimpleNamespace(id=str(actor_id), status="active")), + project=SimpleNamespace(id=str(project_id), status="active"), + project_selector_id=project_id, + ) + assert len(authorization.calls) == 1 + assert authorization.calls[0][0] is ActionId.ACTOR_AUTHORIZATION_CONTEXT_READ + assert response.admin_roles == ("project_manager",) + assert response.project_roles == ("reviewer", "submitter") + assert response.effective_action_ids == ( + ActionId.PROJECT_CONTRIBUTOR_CANDIDATE_LIST, + ActionId.PROJECT_READ, + ActionId.PROJECT_ROLE_GRANT_ISSUE, + ActionId.PROJECT_ROLE_GRANT_LIST, + ActionId.PROJECT_ROLE_GRANT_READ, + ActionId.PROJECT_ROLE_GRANT_REVOKE, + ) + archived = await service.read( + resolved=SimpleNamespace(profile=SimpleNamespace(id=str(actor_id), status="active")), + project=SimpleNamespace(id=str(project_id), status="archived"), + project_selector_id=project_id, + ) + assert archived.effective_action_ids == ( + ActionId.PROJECT_READ, + ActionId.PROJECT_ROLE_GRANT_LIST, + ActionId.PROJECT_ROLE_GRANT_READ, + ActionId.PROJECT_ROLE_GRANT_REVOKE, + ) + + class _PreparedActorFacts: def __init__(self, context: HumanAuthorizationContext) -> None: self.context = context diff --git a/backend/tests/test_projects.py b/backend/tests/test_projects.py index 777f5203..c2d134d8 100644 --- a/backend/tests/test_projects.py +++ b/backend/tests/test_projects.py @@ -9,7 +9,7 @@ from collections.abc import AsyncIterator, Iterator from datetime import UTC, datetime from typing import Any, cast -from uuid import uuid4 +from uuid import UUID, uuid4 import pytest # type: ignore[import-not-found] from httpx import ASGITransport, AsyncClient @@ -127,6 +127,85 @@ 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 with db_session.get_session_factory()() as session: + link = await session.scalar( + select(ActorIdentityLink).where( + ActorIdentityLink.issuer == "flow-test", + ActorIdentityLink.subject == "project-manager-subject", + ) + ) + assert link is not None + actor_id = link.actor_profile_id + admin_grant = await session.scalar( + select(AdminRoleGrant).where( + AdminRoleGrant.target_actor_profile_id == actor_id, + AdminRoleGrant.role == "access_administrator", + ) + ) + if admin_grant is None: + admin_grant = AdminRoleGrant( + id=uuid4(), + target_actor_profile_id=actor_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", + ) + session.add(admin_grant) + control = await session.get(AuthorityControl, 1) + assert control is not None + control.bootstrap_completed = True + control.bootstrap_grant_id = admin_grant.id + control.version = 1 + await session.flush() + snapshot = ProjectRoleQualificationSnapshot( + id=uuid4(), + project_id=project_id, + actor_profile_id=actor_id, + requested_role=role, + skills_snapshot={ + "availability": "unavailable", + "reference_ids": [], + "unavailable_reason": "no_record", + }, + reputation_snapshot={ + "availability": "unavailable", + "reference_ids": [], + "unavailable_reason": "no_record", + }, + prior_project_work_refs=[], + external_expertise_refs=[], + captured_by_actor_profile_id=actor_id, + captured_by_admin_role_grant_id=admin_grant.id, + captured_at=now, + ) + session.add(snapshot) + await session.flush() + grant = ProjectRoleGrant( + id=uuid4(), + project_id=project_id, + actor_profile_id=actor_id, + role=role, + status="active", + version=1, + grant_method="manual", + qualification_snapshot_id=snapshot.id, + granted_by_actor_profile_id=actor_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) + + @pytest.mark.asyncio async def test_project_role_grant_repository_filters_and_uses_strict_keyset( project_database_env: str, @@ -680,13 +759,14 @@ def complete_guide_payload(version: str = "v1") -> dict: } -async def create_project(client: AsyncClient) -> dict: +async def create_project(client: AsyncClient, *, name: str = "STEM Eval") -> dict: + slug = f"{name.lower().replace(' ', '-')}-{uuid4()}" response = await client.post( "/api/v1/projects", headers=auth_headers(), json={ - "name": "STEM Eval", - "slug": "stem-eval", + "name": name, + "slug": slug, "description": "Internal STEM evaluation tasks", }, ) @@ -890,11 +970,11 @@ def test_project_setup_queue_syncs_all_setup_task_settings( get_settings.cache_clear() -async def test_get_project_does_not_require_project_setup_queue( +async def test_get_project_rejects_token_role_when_setup_queue_is_unavailable( project_client: AsyncClient, monkeypatch: pytest.MonkeyPatch, ) -> None: - """Read paths stay available even when automatic setup cannot enqueue work.""" + """A token role cannot authorize project identity under any queue state.""" project = await create_project(project_client) monkeypatch.setenv("WORKSTREAM_PROJECT_SETUP_PIPELINE_AUTOSTART", "true") monkeypatch.setenv("WORKSTREAM_CELERY_TASK_ALWAYS_EAGER", "false") @@ -906,8 +986,122 @@ async def test_get_project_does_not_require_project_setup_queue( headers=auth_headers(), ) - assert response.status_code == 200, response.text - assert response.json()["id"] == project["id"] + assert response.status_code == 404, response.text + assert response.json()["error"]["code"] == "project_authorization_resource_not_found" + + +def test_project_identity_projection_is_structurally_minimal_for_contributors() -> None: + """Contributor selection cannot serialize admin-only project fields.""" + project = Project( + id=str(uuid4()), + name="Projection proof", + slug="projection-proof", + description="Admin-only project description", + status="active", + ) + contributor = ProjectService.project_identity_response(project, contributor_only=True) + assert contributor.model_dump() == { + "id": project.id, + "name": "Projection proof", + "status": "active", + } + assert "slug" not in contributor.model_dump() + assert "description" not in contributor.model_dump() + + +async def test_project_identity_and_context_follow_exact_grant_and_lifecycle( + project_client: AsyncClient, +) -> None: + """Live routes conceal cross-project, revoked, suspended, and revoked-link access.""" + project = await create_project(project_client, name="Visible project") + 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() + ) + assert identity.status_code == 200, identity.text + assert identity.json() == { + "id": project["id"], + "name": "Visible project", + "status": "draft", + } + context = await project_client.get( + f"/api/v1/actors/me/authorization-context?project_id={project['id']}", + headers=auth_headers(), + ) + assert context.status_code == 200, context.text + assert context.json()["project_roles"] == ["submitter"] + assert context.json()["admin_roles"] == [] + assert context.json()["effective_action_ids"] == ["project.read"] + + for path in ( + f"/api/v1/projects/{other['id']}", + f"/api/v1/actors/me/authorization-context?project_id={other['id']}", + ): + denied = await project_client.get(path, headers=auth_headers()) + assert denied.status_code == 404 + assert denied.json()["error"]["code"] == "project_authorization_resource_not_found" + + now = datetime.now(UTC) + async with db_session.get_session_factory()() as session: + grant = await session.get(ProjectRoleGrant, grant_id) + assert grant is not None + grant.status = "revoked" + grant.version = 2 + grant.revoked_by_actor_profile_id = grant.actor_profile_id + grant.revoked_by_admin_role_grant_id = grant.granted_by_admin_role_grant_id + grant.revoked_reason = "AUTH-11B revocation proof" + grant.revoked_at = now + await session.commit() + for path in ( + f"/api/v1/projects/{project['id']}", + f"/api/v1/actors/me/authorization-context?project_id={project['id']}", + ): + denied = await project_client.get(path, headers=auth_headers()) + assert denied.status_code == 404 + + await add_project_role_for_default_actor(project["id"], "reviewer") + async with db_session.get_session_factory()() as session: + link = await session.get(ActorIdentityLink, link_id) + assert link is not None + profile = await session.get(ActorProfile, link.actor_profile_id) + assert profile is not None + profile.status = "suspended" + profile.suspended_by = profile.id + profile.suspended_at = now + profile.suspension_reason = "AUTH-11B stale actor proof" + await session.commit() + for path in ( + f"/api/v1/projects/{project['id']}", + f"/api/v1/actors/me/authorization-context?project_id={project['id']}", + ): + denied = await project_client.get(path, headers=auth_headers()) + assert denied.status_code == 404 + + async with db_session.get_session_factory()() as session: + link = await session.get(ActorIdentityLink, link_id) + assert link is not None + profile = await session.get(ActorProfile, link.actor_profile_id) + assert profile is not None + profile.status = "active" + profile.suspended_by = None + profile.suspended_at = None + profile.suspension_reason = None + profile.reactivated_by = profile.id + profile.reactivated_at = now + profile.reactivation_reason = "AUTH-11B stale link proof setup" + link.status = "revoked" + link.revoked_by = profile.id + link.revoked_at = now + link.revoked_reason = "AUTH-11B stale link proof" + await session.commit() + for path in ( + f"/api/v1/projects/{project['id']}", + f"/api/v1/actors/me/authorization-context?project_id={project['id']}", + ): + denied = await project_client.get(path, headers=auth_headers()) + assert denied.status_code == 404 async def test_create_guide_returns_created_when_post_commit_enqueue_fails( diff --git a/docs/operations_authorization_service.md b/docs/operations_authorization_service.md index c28f319e..75ceaf12 100644 --- a/docs/operations_authorization_service.md +++ b/docs/operations_authorization_service.md @@ -708,8 +708,8 @@ 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, 25 active actions, and -53 planned actions after WS-XINT-002-03 activates the three internal ART service +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 artifact action. Migration `0037` keeps each allowed or denied internal ART decision bound to @@ -1019,6 +1019,14 @@ Stop rollout when: Do not restore deleted authority through direct SQL or re-enable any obsolete token-role path. +Project identity and self authorization-context reads are active under +AUTH-11B. Both use a canonical project target and revalidate the current human +profile and exact identity link. `project.read` accepts either an effective +covered administrative grant or an active exact-project contributor grant and +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. + ## Recovery Permission Inventory | Operation | Authority | Required controls | diff --git a/docs/operations_project_operating_manual.md b/docs/operations_project_operating_manual.md index 0115915e..877ef24f 100644 --- a/docs/operations_project_operating_manual.md +++ b/docs/operations_project_operating_manual.md @@ -66,10 +66,18 @@ activated as separate guide-policy context. Contribution policy is project-level and independently published by its owning authority. Checker policy records are generated by Workstream setup and exposed as generated setup output. -Project setup operators inspect setup state through API reads, not direct -database queries. These endpoints require the registered covered Project -Manager permission or an explicitly authorized Operator/Audit projection: - +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: + +- `GET /api/v1/projects/{project_id}` provides canonical project identity. + Eligible administrative grants receive the full identity fields; an active + exact-project Submitter, Reviewer, or Adjudicator grant receives only id, + name, and status. +- `GET /api/v1/actors/me/authorization-context?project_id=...` reports only the + caller's current local roles and active route-backed actions for that project. - `GET /api/v1/projects/{project_id}/guides/{guide_id}/setup-runs/latest` - `GET /api/v1/projects/{project_id}/guides/{guide_id}/sufficiency-reports` - `GET /api/v1/projects/{project_id}/guides/{guide_id}/sufficiency-reports/{report_id}` diff --git a/docs/operations_roles_permissions.md b/docs/operations_roles_permissions.md index 022eb7e9..6c325fd6 100644 --- a/docs/operations_roles_permissions.md +++ b/docs/operations_roles_permissions.md @@ -61,7 +61,7 @@ or the exact project; own means record-level ownership still applies. | Actor/link administration | system | no | no | no | minimal read covered | no | no | no | | Administrative grants | system | no | no | no | history read covered | no | no | no | | Project create | no | no | system only | no | no | no | no | no | -| Project read | authority-only | system operational | covered | covered finance projection | covered audit projection | exact project minimal | exact project minimal | exact project minimal | +| Project identity read | no | system, full identity | covered, full identity | covered, full identity | covered, full identity | exact project minimal | exact project minimal | exact project minimal | | Project, guide, submission/checker, review, and revision configuration | no | recovery-only where registered | covered | no | no | no | no | no | | Contribution policy and compensation-adapter binding | no | no | no | covered | no | no | no | no | | Project contributor grants | no | no | covered | no | read covered | no | no | no | @@ -79,9 +79,18 @@ through three independent exact-project grants. No grant adds administrative capability, and every ownership, assignment, no-self-review, separation-of-duties, and lifecycle guard applies to the selected action. -Access Administrator's authority-only project view means the minimum resource -identity necessary to administer grants; it is not general project-management -visibility. +`GET /api/v1/projects/{project_id}` returns id, name, slug, description, +status, and timestamps to an effective Operator, Project Manager, Finance +Authority, or Audit Authority grant. An exact-project Submitter, Reviewer, or +Adjudicator sees only id, name, and status. Access Administrator alone is +concealed because that role has no `project.read` permission. + +`GET /api/v1/actors/me/authorization-context?project_id=...` is self-only. It +returns effective role names and active route-backed project actions for the +exact canonical project, never grant identifiers, identity-link data, planned +actions, or unrelated system authority. A caller without local authority for +the project receives the concealed not-found response. Token roles do not +affect either read. ## Separation Rules diff --git a/docs/spec_authorization_service.md b/docs/spec_authorization_service.md index 2096e42f..d3e6b776 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 WS-XINT-002-03: 25 active -actions and 53 planned rows. AUTH-10A added five project-role read/manage rows; +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; 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 -planned project identity, context, setup, policy, and active-guide reads owned -by 11B, 11C1, and 11C2. AUTH-08 adds seven active administrative definition, +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. +AUTH-08 adds seven +active administrative definition, grant-history, issue, revoke, and local-bootstrap actions without adding a permission. AUTH-09A adds eight planned actor, identity-link, and service provisioning actions without activating a route; AUTH-09B activates only @@ -841,9 +843,15 @@ revoke mutations for covered Project Managers. Those mutations use canonical replay validation, and one route-owned commit. Issue requires a different active human with an active identity link; revoke remains available after target suspension or identity-link revocation so authority cannot become -irremovable. Project-scoped -`GET /api/v1/actors/me/authorization-context` begins in AUTH-11 after -exact-project grant and canonical project capability composition exists. +irremovable. AUTH-11B activates `GET /api/v1/projects/{project_id}` and +project-scoped `GET /api/v1/actors/me/authorization-context?project_id=...`. +Both resolve the canonical project and use current local grants only. Project +identity returns the registered full identity projection to effective Operator, +Project Manager, Finance Authority, or Audit Authority grants, and only id, +name, and status to exact-project Submitter, Reviewer, or Adjudicator grants. +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. `WS-AUTH-001-CONTRIBUTOR-FOUNDATION` adds no permission or authorization path. It clean-cuts TaskAssignment and Submission attribution to `contributor_id`,