diff --git a/.ci/behavior-ownership/partition.v1.json b/.ci/behavior-ownership/partition.v1.json index 090db847a..d87147ca7 100644 --- a/.ci/behavior-ownership/partition.v1.json +++ b/.ci/behavior-ownership/partition.v1.json @@ -196,6 +196,10 @@ "group": "shared", "target": "backend/app/api/deps/post_policy.py" }, + { + "group": "lifecycle", + "target": "backend/app/api/deps/project_catalogue.py" + }, { "group": "lifecycle", "target": "backend/app/api/deps/project_guide_reads.py" @@ -268,6 +272,10 @@ "group": "shared", "target": "backend/app/api/routes/post_policy.py" }, + { + "group": "lifecycle", + "target": "backend/app/api/routes/project_catalogue.py" + }, { "group": "lifecycle", "target": "backend/app/api/routes/project_guide_reads.py" @@ -684,6 +692,10 @@ "group": "auth", "target": "backend/app/modules/authorization/api/post_policy.py" }, + { + "group": "auth", + "target": "backend/app/modules/authorization/api/project_catalogue.py" + }, { "group": "auth", "target": "backend/app/modules/authorization/api/project_guide_compilation.py" @@ -872,6 +884,10 @@ "group": "auth", "target": "backend/app/modules/authorization/domain/prepared_submission_policy.py" }, + { + "group": "auth", + "target": "backend/app/modules/authorization/domain/project_catalogue.py" + }, { "group": "auth", "target": "backend/app/modules/authorization/domain/project_create.py" @@ -1036,6 +1052,10 @@ "group": "auth", "target": "backend/app/modules/authorization/prepared_submission_replay.py" }, + { + "group": "auth", + "target": "backend/app/modules/authorization/project_catalogue.py" + }, { "group": "auth", "target": "backend/app/modules/authorization/project_role_schemas.py" @@ -2349,7 +2369,7 @@ "target": "backend/scripts/validate_test_lane_evidence.py" } ], - "authority_digest": "bcb6024819d3bbda45e7f8fbfd0dba07ea4a26981348253daca0c5d9d9ec90db", + "authority_digest": "38802d2eaf89cd8ce3ec68743f7c8a4868771d69fb99544400dd16559a269098", "protected_base_commit": "7676ce4347db0c9694962a9b587a20765e16eac6", "schema": "workstream.behavior-ownership-partition.v1" } diff --git a/.commitrail/initiatives/WS-CLI-001/OVERVIEW.md b/.commitrail/initiatives/WS-CLI-001/OVERVIEW.md index c86b8a670..5bfc6aa10 100644 --- a/.commitrail/initiatives/WS-CLI-001/OVERVIEW.md +++ b/.commitrail/initiatives/WS-CLI-001/OVERVIEW.md @@ -45,6 +45,8 @@ and eligible warning acknowledgement through existing public POSTs; [WS-CLI-001-41](WS-CLI-001-41.md), explicit manual artifact-policy draft creation and exact-predecessor hash-guarded successor through public writes; + [WS-CLI-001-46](WS-CLI-001-46.md), a separate minimal system project-reader + catalogue and `project list` command, without widening contributor membership; [PILOT-13](../../changes/pilot13-assigned-task-guide-documents.md), assigned-task locked-guide listing/download; [PILOT-02](../../changes/pilot02-bulk-transitions-cli.md), explicit CSV conversion, source custody, atomic import, batch status and whole-batch screen/release. @@ -68,6 +70,12 @@ client gaps and the optional TUI remain separate work. Binary distribution's workflow is prepared by -44; a protected release run and public-asset audit remain operational work. +[WS-CLI-001-46](WS-CLI-001-46.md) adds `GET /api/v1/projects` and +`workstream project list`: current system Project Manager, Operator, Finance +and Audit project-reader grants can discover bounded ID/name/status pages. +`me projects` remains exact-grant membership; catalogue reads neither confer +guide/management access nor implement the optional TUI. + The Operator commands `operations task list`, `operations task locked-context` and `operations task start` use three existing public routes. Their restricted read projections remain observations; the backend rechecks the distinct @@ -330,7 +338,8 @@ CLIs. Keep the package independent of backend and MCP runtime dependencies. identity, authority or backend policy. 3. **WS-CLI-001-03:** Inspect a known project alongside its current authority, through the public project read. Preserve administrative/contributor response - boundaries; do not invent a public project-list contract. + boundaries. The separate system-reader catalogue arrives in WS-CLI-001-46; + contributor membership stays exact-grant scoped. 4. **WS-CLI-001-04:** Browse a manager task page and open its exact project/task detail, passing opaque continuation unchanged without automatic pagination. 5. **WS-CLI-001-05:** Discover one ready-task page and inspect contributor diff --git a/.commitrail/initiatives/WS-CLI-001/WS-CLI-001-46.md b/.commitrail/initiatives/WS-CLI-001/WS-CLI-001-46.md new file mode 100644 index 000000000..2b8dfee41 --- /dev/null +++ b/.commitrail/initiatives/WS-CLI-001/WS-CLI-001-46.md @@ -0,0 +1,110 @@ +# WS-CLI-001-46 — System-covered project catalogue + +- Initiative: `WS-CLI-001` +- Durable disposition: `Complete` +- Issue: [#663](https://github.com/Flow-Research/workstream/issues/663) +- Intended merge outcome: A minimal public project catalogue and explicit CLI page read let holders of a current system-wide Project Manager, Operator, Finance Authority, or Audit Authority grant find projects by name without treating exact-project membership as global discovery. + +## Intent + +`GET /api/v1/actors/me/projects` currently lists only active exact-project grants. `actor.project.list` is an actor-self action; a system grant adds effective roles to listed rows but enumerates no projects (`docs/spec_authorization_service.md`). The separate exact-project `project.read` action permits a system-wide Project Manager, Operator, Finance Authority, or Audit Authority to inspect a known project. Access Administrator has no `project.read` permission. Requiring a UUID obtained out of band makes the intended administrative terminal picker unusable. The human decision for this chunk is to permit those four existing system-wide project-reader roles, but no others, to enumerate only project ID, name and status. + +## Bounded change + +1. Add one `project.catalogue.list` AUTH action mapped to existing `project.read`, with an exact actor-owned collection context, a fresh active-human/system-scope-only grant check restricted to the four named roles, grant locking through the canonical AUTH repository, and decision/audit provenance. Extend the existing closed audit action/permission CHECK with that exact pair in one narrow migration; it otherwise rejects even denied decision evidence. Deny services, Access Administrator alone, exact-project-only grants and contributor-only grants. Do not modify `actor.project.list`. +2. Extend the PROJECTS-owned summary reader/public port with a separate bounded project-ID keyset page over all projects containing only ID, name and status. ID order is stable enumeration, not creation, commit or business order. Do not materialize every project ID or join guide, task, source, actor or payment rows. Use the existing snapshot-read and signed cursor conventions to bind actor, action, query and limit; the cursor position discloses only the already-public ID. Authorize before cursor parsing on every page. New `GET /api/v1/projects` returns only that page and an opaque `next_cursor`. +3. Add `workstream project list [--limit N] [--cursor TOKEN]`, using the existing typed public client and stable text/`--output json`/structured error conventions. No TUI runtime is part of this PR. Update the canonical authorization spec, CLI README and affected roadmap/initiative navigation for the intended merged outcome. + +### Allowed files + +- AUTH catalogue, typed context/port, kernel/read dispatch, focused evaluator, repository and their tests under `backend/app/modules/authorization/`, `backend/app/adapters/auth/__init__.py` and `backend/tests/authorization/`. +- One next Alembic revision under `backend/alembic/versions/` solely admitting `project.catalogue.list`/`project.read` to the closed `audit_events` action/permission CHECK, its migration test, the linear-head guard in `backend/alembic/env.py`, the exact revision graph test in `backend/tests/test_alembic.py`, and the checked public-schema fingerprint in `backend/tests/conftest.py` after comparing the actual object change. Reconcile catalogue revision `0061_project_catalogue_read` to current main's `0060_audit_restore_path` as predecessor; no parallel head or replacement of retained facts, audit validator bodies or their pinned safe search paths. +- PROJECTS public summary port/reader and focused tests under `backend/app/modules/projects/` and `backend/tests/contributor_reads/` or `backend/tests/project_reads/`. +- One public route/composition under `backend/app/api/`, router wiring and API-control tests under `backend/tests/`, including the exact production rate-dependency inventory in `backend/tests/test_api_rate_controls.py`. +- `backend/scripts/test_lane_catalogue.py` and its exact-inventory test only to assign the new project API and migration proof to existing hosted lanes; no lane count, runner, limit or gate-behavior change. +- `.ci/behavior-ownership/partition.v1.json`, `backend/scripts/behavior_ownership.py`, and `backend/tests/test_behavior_ownership.py` only to register the five new eligible API/AUTH catalogue modules in the existing exact target-to-group partition. Retain the protected-base commit, all prior assignments, digest/custody validation, and fail-closed rejection of neighboring targets. +- `cli/internal/api/` and `cli/internal/command/` project-list client/command, plus focused CLI integration tests and `cli/README.md`. +- `mcp_server/contracts/authorization_context_get.json` only to reconcile the existing selected OpenAPI `ActionId` schema after this action is added. The existing authorization-context response validator accepts that new enum value; no MCP tool, route or operation is added. +- `docs/spec_authorization_service.md`, the system-catalogue row in `docs/operations_roles_permissions.md`, affected `docs/roadmap_status.md` sections, this record, `.commitrail/initiatives/WS-CLI-001/OVERVIEW.md`, and `.commitrail/INDEX.md` only for the new durable boundary. + +### Prohibited changes + +No change to contributor/exact-grant `me projects`, existing project detail authorization, role permission definitions, project lifecycle, guide disclosure, task search, reviewer workflow, compensation/payment, login/token storage, TUI behavior, CI gate behavior, or unrelated catalogue operations. No duplicate project resolver, all-project materialization, fallback authority path, compatibility alias, automatic pagination, or merge/release. + +## Acceptance criteria + +- Each of the four named *system-scoped* roles independently obtains pages of only project ID, name and `draft|active|paused|archived` status, including a project lacking an exact grant. Same-project overlaps do not duplicate rows; page size is 1–100 with stable ID order and a bounded response. The cursor envelope must not expose creation timestamps or any other undisclosed project fact. +- A system Access Administrator alone, exact-project Project Manager/Finance/Audit, Submitter/Reviewer and unrelated actor cannot enumerate; an existing exact project grant remains visible only through unchanged `me projects`. Absent/denied cases never reveal private project facts or substitute an empty success. +- Every page starts from current actor and system grant authority; cursor tampering, cross-actor/limit/action reuse is refused, and revocation committed before a later page removes catalogue access. A concurrent revoke cannot commit until an already-authorized response releases its locked grant; an in-flight response may still arrive after that later commit. AUTH decision and evidence identify the matched system grant. +- The PROJECTS read uses one bounded SQL page of three columns in primary-key order, not a full project-ID set. Demonstrate real PostgreSQL storage and actor/resource isolation, grant revocation/concurrent-session locking, and no duplicate/gap pagination. The primary-key keyset avoids a catalogue-wide sort; production-scale latency is unmeasured and not claimed. A projection/response validation failure must roll back its AUTH decision evidence. The new audit CHECK must accept the exact pair and reject a crossed pair by direct SQL while retaining the valid row. +- CLI text escapes untrusted terminal controls, JSON preserves validated public values, failures retain stable bounded structured stderr and no partial stdout. Existing `workstream me projects`, `project show/access`, and agent CLI output remain unchanged. +- Run focused backend/CLI tests, applicable full suites and public API drill, the selected MCP/OpenAPI contract comparison, stale-wording scan, Markdown-link check, roadmap/INDEX reconciliation, and reviewer tracks on a clean candidate. Hosted backend lanes must select both new modules, and the separate CLI public-API lane must supply the built binary for the native drill; no test may silently skip when that binary is absent. +- The canonical behavior-ownership partition contains each new eligible catalogue module exactly once in its deterministic group, validates against protected `main`, and still rejects an unapproved neighboring target. Do not weaken partition completeness, digest or transition custody to make preflight pass. + +## Evidence + +Focused proof lives in `backend/tests/project_catalogue/test_public.py`, +`backend/tests/migrations/test_project_catalogue_read.py`, and +`cli/tests/integration/test_project_catalogue_http.py`, and the existing +`cli/tests/integration/test_admin_inspection_public_api.py` journey. It uses separately +stored actors and grants; the fixture's default manager is already system +authorized and is not a negative control. Two independently authorized readers +exercise cross-actor cursor binding. Existing canonical nonhuman/inactive +identity tests cover the shared read gate. The catalogue cases prove exact-only +and Access Administrator denials, revoked system authority, a grant-lock race, +and rollback of invalid projection evidence. The existing CLI public-API journey +now runs the catalogue command with its built binary, system Audit Authority +and two real projects against the live API and PostgreSQL, avoiding a second +database/server setup in the hosted 15-minute job. The CLI HTTP suite checks +process output against hostile responses. The HMAC cursor is +integrity-protected but its Base64 payload is not confidential; it contains +only the already-disclosed project ID as position. + +The isolated PostgreSQL 16 public-API module passes as one settled run (11 +cases): four distinct system roles, exact-grant, contributor and Access +Administrator denials, signed pagination with action binding and ID-only +payload, revocation, a concurrent grant-lock race, and invalid-projection +rollback. The separate shadow-schema migration regression covers crossed-pair +direct SQL and role search-path cleanup. +The migration chain preserves main's `0060_audit_restore_path` unchanged before +catalogue revision `0061_project_catalogue_read`. Retained upgrades from both +`0059_neutral_guide_runtime` and `0060_audit_restore_path` check audit validator +identity/body custody and safe function-local search paths; the catalogue-only +upgrade must leave the complete already-pinned validator custody unchanged. +The PostgreSQL 16 schema-object comparison confirms `0059 -> 0060` changes only +the two audit helper function definitions and `0060 -> 0061` changes only the +closed `audit_events` action/permission CHECK. Main's reviewed fingerprint and +the earlier catalogue-only fingerprint both reproduce from those exact object +sets; the combined fingerprint is derived from the actual merged schema. +AUTH catalogue and API-control inventories, Go tests/build/module verification, +CLI process tests, Ruff, Markdown links and stale-wording checks pass. The +exact production rate-dependency inventory includes the new catalogue route +under the existing authorization-read limiter; its full route/dependency +equality assertion is retained, not replaced with a catalogue-only assertion. +The existing MCP authorization-context selected fragment was reconciled only for +the added `ActionId`: the actual backend OpenAPI comparison passes all 29 +cases, and the MCP non-integration suite passes 599 cases with two existing +skips. The existing MCP response validator now accepts that shared enum value; +no MCP tool, route or operation was added. These +are local source/binary proofs; hosted CI and independent review belong to the +draft PR. The initial real-API 503 from an omitted closed AUTH decision-resource +type was caught by this module and fixed without weakening error normalization. + +Hosted preflight exposed the five new eligible catalogue Python modules missing +from the behavior-ownership partition. The exact-target repair retains the +protected-base commit and every prior assignment, adds only those five modules +in the deterministic auth/lifecycle groups, and recalculates the canonical +digest. Local partition validation, the full ownership catalogue validator, +and all 167 ownership tests pass; a neighboring module remains rejected. + +## Risk and review routing + +- Risk class: `L1` — new cross-project enumeration under administrative authority and a public API/CLI contract. No delivery SLA supplied. +- Focused plan review precedes code. Implementation reviewers: `security`, `architecture`, `qa`, `documentation`, `product_ops` for workspace/role semantics, and `ci_integrity` for the exact hosted-lane ownership change; add `reuse_dedup` if the owner extension creates duplicate selection logic. The lead runs shared checks once, then freezes a clean candidate. +- Human review focus: confirm only the four approved system-wide `project.read` roles enumerate and that ID/name/status is the entire catalogue disclosure; inspect AUTH grant-lock/audit proof, real PostgreSQL negative paths, bounded paging, and unmodified contributor membership semantics. + +## Alternatives and remaining risks + +Extending `me projects` with a system mode was rejected: exact-grant membership and system-wide catalogue selection have different permission/audit and disclosure contracts; reusing its `active_guide` field would over-disclose to system Finance. `GET /api/v1/projects` is the natural separate read on the existing PROJECTS owner and does not establish a second resolver. Client-side UUID entry remains available but does not solve discovery. A future TUI consumes the public read; it is not delivered here. + +Potential risks are grant/actor race handling, cursor substitution, row projection drift, and unmeasured production-scale catalogue latency. The project-ID primary key supplies the page order without an added index; no delivery-time performance guarantee is claimed. The policy decision is resolved by the user; the human merge decision remains separate. diff --git a/backend/alembic/env.py b/backend/alembic/env.py index 9c7542979..234edda18 100644 --- a/backend/alembic/env.py +++ b/backend/alembic/env.py @@ -21,7 +21,7 @@ target_metadata = Base.metadata _BASELINE_REVISION = "0001_uuid7_v01" -_CURRENT_HEAD_REVISION = "0060_audit_restore_path" +_CURRENT_HEAD_REVISION = "0061_project_catalogue_read" _RECREATE_GUIDANCE = ( "Workstream v0.1 requires a fresh database; recreate this database before " "running the 0001_uuid7_v01 migration" @@ -113,6 +113,7 @@ def do_run_migrations(connection: Connection) -> None: ("0057_roles_anywhere_custody",), ("0058_compensation_unit_create",), ("0059_neutral_guide_runtime",), + ("0060_audit_restore_path",), (_CURRENT_HEAD_REVISION,), ): raise RuntimeError(_RECREATE_GUIDANCE) diff --git a/backend/alembic/versions/0061_project_catalogue_read.py b/backend/alembic/versions/0061_project_catalogue_read.py new file mode 100644 index 000000000..5cff2e879 --- /dev/null +++ b/backend/alembic/versions/0061_project_catalogue_read.py @@ -0,0 +1,50 @@ +"""Admit exact system project catalogue decisions to the closed audit vocabulary.""" + +from alembic import op +from sqlalchemy import text + + +revision = "0061_project_catalogue_read" +down_revision = "0060_audit_restore_path" +branch_labels = None +depends_on = None + +_NAME = "ck_audit_events_authorization_action_evidence" +_ANCHOR = ( + "(((action_id)::text = 'contribution.read_self'::text) AND " + "((permission_id)::text = 'contribution.read_self'::text))" +) +_ADDITION = ( + " OR (((action_id)::text = 'project.catalogue.list'::text) AND " + "((permission_id)::text = 'project.read'::text))" +) + + +def upgrade(): + """Extend only the public audit constraint, preserving every retained event.""" + op.execute("SET LOCAL search_path=pg_catalog,public,pg_temp") + op.execute("LOCK TABLE public.audit_events IN ACCESS EXCLUSIVE MODE") + definition = ( + op.get_bind() + .execute( + text( + "SELECT pg_catalog.pg_get_constraintdef(oid) FROM pg_catalog.pg_constraint " + "WHERE conrelid='public.audit_events'::regclass AND conname=:name" + ), + {"name": _NAME}, + ) + .scalar_one() + ) + if definition.count(_ANCHOR) != 2 or "project.catalogue.list" in definition: + raise RuntimeError("project catalogue audit action constraint shape changed") + op.execute("ALTER TABLE public.audit_events DROP CONSTRAINT " + _NAME) + op.execute( + "ALTER TABLE public.audit_events ADD CONSTRAINT " + + _NAME + + " " + + definition.replace(_ANCHOR, _ANCHOR + _ADDITION) + ) + + +def downgrade(): + raise RuntimeError("Workstream v0.1 migrations cannot be downgraded; recreate the database") diff --git a/backend/app/adapters/auth/__init__.py b/backend/app/adapters/auth/__init__.py index 45ec97b82..130377837 100644 --- a/backend/app/adapters/auth/__init__.py +++ b/backend/app/adapters/auth/__init__.py @@ -376,6 +376,12 @@ def contributor_read_authorization(kernel, session: AsyncSession, secret): return ContributorReadAuthorization(kernel, AdminAuthorizationRepository(session), secret) +def project_catalogue_authorization(kernel, secret): + """Compose the bounded system-project read through the request kernel.""" + from app.modules.authorization.project_catalogue import ProjectCatalogueAuthorization + return ProjectCatalogueAuthorization(kernel, secret) + + def project_management_read_authorization(kernel, secret): """Compose the project-wide Project Manager and Finance read adapter over the request kernel.""" from app.modules.authorization.management_read import ProjectManagementReadAuthorization diff --git a/backend/app/api/deps/authorization.py b/backend/app/api/deps/authorization.py index e08ff6a56..7d873243b 100644 --- a/backend/app/api/deps/authorization.py +++ b/backend/app/api/deps/authorization.py @@ -333,6 +333,7 @@ def authorization_http_error(exc: AuthorizationDenied) -> StructuredHTTPExceptio ActionId.PROJECT_ROLE_GRANT_LIST, ActionId.PROJECT_ROLE_GRANT_READ, ActionId.PROJECT_READ, + ActionId.PROJECT_CATALOGUE_LIST, ActionId.ACTOR_AUTHORIZATION_CONTEXT_READ, ActionId.PROJECT_SETUP_RUN_READ, ActionId.PROJECT_GUIDE_SUFFICIENCY_REPORT_LIST, diff --git a/backend/app/api/deps/project_catalogue.py b/backend/app/api/deps/project_catalogue.py new file mode 100644 index 000000000..e673b3350 --- /dev/null +++ b/backend/app/api/deps/project_catalogue.py @@ -0,0 +1,134 @@ +"""Compose one system-authorized project identity page without guide disclosure.""" + +from typing import Annotated +from uuid import UUID + +from fastapi import Depends, Request +from pydantic import BaseModel, ConfigDict +from sqlalchemy.exc import SQLAlchemyError +from sqlalchemy.ext.asyncio import AsyncSession + +from app.api.deps.authorization import get_authorization_actor_identity, get_authorization_service +from app.api.deps.snapshot_reads import run_snapshot_read +from app.core.api_controls import StructuredHTTPException +from app.db.session import get_db_session +from app.modules.authorization.api import ActorIdentityFacts +from app.modules.authorization.api.project_catalogue import ( + ProjectCatalogueAuthorizationPort, + ProjectCatalogueCursorInvalid, + ProjectCatalogueRequest, + ProjectCatalogueUnavailable, +) +from app.modules.projects.api.project_summaries import ProjectCatalogueReadPort, ProjectStatus + + +_FROZEN = ConfigDict(extra="forbid", frozen=True) + + +class ProjectCatalogueItem(BaseModel): + """The complete public row: no guide, grant, actor or management facts.""" + + model_config = _FROZEN + project_id: UUID + name: str + status: ProjectStatus + + +class ProjectCataloguePage(BaseModel): + """A bounded minimal identity page and optional signed continuation.""" + + model_config = _FROZEN + items: tuple[ProjectCatalogueItem, ...] + next_cursor: str | None + + +def _cursor_invalid() -> StructuredHTTPException: + """Expose one bounded error for malformed or substituted cursors.""" + return StructuredHTTPException( + status_code=422, + detail="Invalid project catalogue cursor", + error_code="project_catalogue_cursor_invalid", + error_message="Invalid project catalogue cursor", + ) + + +def _unavailable() -> StructuredHTTPException: + """Normalize infrastructure and projection failures without private facts.""" + return StructuredHTTPException( + status_code=503, + detail="Project catalogue unavailable", + error_code="project_catalogue_unavailable", + error_message="Project catalogue unavailable", + retryable=True, + ) + + +class ProjectCatalogueReadOperation: + """AUTH decision and PROJECTS page share one repeatable-read response transaction.""" + + def __init__( + self, + session: AsyncSession, + actor_profile_id: UUID, + authority: ProjectCatalogueAuthorizationPort, + projects: ProjectCatalogueReadPort, + ) -> None: + """Bind request-local public owners to the response transaction.""" + self._session, self._actor_id = session, actor_profile_id + self._authority, self._projects = authority, projects + + async def list(self, *, limit: int, cursor: str | None) -> ProjectCataloguePage: + """Commit AUTH evidence only with a validated minimal project page.""" + request = ProjectCatalogueRequest( + actor_profile_id=self._actor_id, limit=limit, cursor=cursor + ) + + async def read(grant): + """Project only authorized public columns before the snapshot commits.""" + rows, more = await self._projects.read_project_catalogue( + after=grant.position, limit=limit + ) + last = rows[-1] if rows else None + return ProjectCataloguePage( + items=tuple( + ProjectCatalogueItem( + project_id=row.project_id, + name=row.name, + status=row.status, + ) + for row in rows + ), + next_cursor=( + self._authority.encode(request, last.project_id) if more and last else None + ), + ) + + return await run_snapshot_read( + self._session, + lambda: self._authority.authorize(request), + read, + cursor_invalid=ProjectCatalogueCursorInvalid, + invalid_cursor=_cursor_invalid, + faults=(ProjectCatalogueUnavailable, SQLAlchemyError, ValueError), + unavailable=_unavailable, + ) + + +async def get_project_catalogue_reads( + request: Request, + session: Annotated[AsyncSession, Depends(get_db_session)], + actor: Annotated[ActorIdentityFacts, Depends(get_authorization_actor_identity)], + authority: Annotated[object, Depends(get_authorization_service)], +) -> ProjectCatalogueReadOperation: + """Wire public AUTH and PROJECTS ports at the API composition root.""" + from app.adapters.auth import project_catalogue_authorization + from app.adapters.projects import project_summary_reader + + return ProjectCatalogueReadOperation( + session, + actor.actor_profile_id, + project_catalogue_authorization( + authority, request.app.state.settings.pagination_cursor_hmac_secret + ), + project_summary_reader(session), + ) diff --git a/backend/app/api/router.py b/backend/app/api/router.py index d07dae4a2..f91558ad3 100644 --- a/backend/app/api/router.py +++ b/backend/app/api/router.py @@ -11,6 +11,7 @@ from app.api.routes.project_guide_reads import router as project_guide_reads_router from app.api.routes.project_preparation_reads import router as project_preparation_reads_router from app.api.routes.project_work_reads import router as project_work_reads_router +from app.api.routes.project_catalogue import router as project_catalogue_router from fastapi import APIRouter @@ -32,6 +33,7 @@ api_router.include_router(health_router, prefix="/api/v1") api_router.include_router(actors_router, prefix="/api/v1") api_router.include_router(contributor_reads_router, prefix="/api/v1") +api_router.include_router(project_catalogue_router, prefix="/api/v1") api_router.include_router(authorization_router, prefix="/api/v1") api_router.include_router(project_create_router, prefix="/api/v1") api_router.include_router(guide_mutation_router, prefix="/api/v1") diff --git a/backend/app/api/routes/project_catalogue.py b/backend/app/api/routes/project_catalogue.py new file mode 100644 index 000000000..f2ad781fa --- /dev/null +++ b/backend/app/api/routes/project_catalogue.py @@ -0,0 +1,28 @@ +"""Minimal system-covered project discovery; exact membership remains separate.""" + +from typing import Annotated + +from fastapi import APIRouter, Depends, Query + +from app.api.deps.authorization import enforce_human_authorization_read +from app.api.deps.project_catalogue import ( + ProjectCataloguePage, ProjectCatalogueReadOperation, get_project_catalogue_reads, +) +from app.core.api_controls import ApiErrorResponse + +router = APIRouter(dependencies=[Depends(enforce_human_authorization_read)]) +Reads = Annotated[ProjectCatalogueReadOperation, Depends(get_project_catalogue_reads)] +PageLimit = Annotated[int, Query(ge=1, le=100)] +PageCursor = Annotated[str | None, Query(min_length=1, max_length=512)] +_ERRORS = {status: {"model": ApiErrorResponse} for status in (404, 422, 503)} + + +@router.get( + "/projects", response_model=ProjectCataloguePage, tags=["projects"], + responses=_ERRORS, openapi_extra={"x-workstream-action-id": "project.catalogue.list"}, +) +async def list_system_covered_projects( + reads: Reads, limit: PageLimit = 50, cursor: PageCursor = None, +) -> ProjectCataloguePage: + """One current system Project Reader's ID/name/status page, with no guide facts.""" + return await reads.list(limit=limit, cursor=cursor) diff --git a/backend/app/modules/authorization/api/project_catalogue.py b/backend/app/modules/authorization/api/project_catalogue.py new file mode 100644 index 000000000..b238205c4 --- /dev/null +++ b/backend/app/modules/authorization/api/project_catalogue.py @@ -0,0 +1,54 @@ +"""Current system-covered authority for a bounded project identity page.""" + +from dataclasses import dataclass +from typing import Protocol +from uuid import UUID + + +@dataclass(frozen=True, slots=True) +class ProjectCatalogueRequest: + """One actor-bound page request with bounded cursor and page size.""" + + actor_profile_id: UUID + limit: int + cursor: str | None + + def __post_init__(self) -> None: + """Reject malformed requests before authority or cursor processing.""" + if ( + not isinstance(self.actor_profile_id, UUID) + or not isinstance(self.limit, int) + or not 1 <= self.limit <= 100 + or ( + self.cursor is not None + and (not isinstance(self.cursor, str) or not 1 <= len(self.cursor) <= 512) + ) + ): + raise ValueError("invalid project catalogue request") + + +@dataclass(frozen=True, slots=True) +class ProjectCatalogueGrant: + """An authorized page boundary, not reusable authority for another read.""" + + position: UUID | None + + +class ProjectCatalogueCursorInvalid(ValueError): + """A malformed, forged or substituted continuation.""" + + +class ProjectCatalogueUnavailable(RuntimeError): + """Required signed-cursor configuration is unavailable.""" + + +class ProjectCatalogueAuthorizationPort(Protocol): + """Authorize each current request and bind its public ID continuation.""" + + async def authorize(self, request: ProjectCatalogueRequest) -> ProjectCatalogueGrant: + """Lock current system-reader authority before parsing a continuation.""" + ... + + def encode(self, request: ProjectCatalogueRequest, position: UUID) -> str: + """Sign a disclosed ID for this actor, action, ordering and page size.""" + ... diff --git a/backend/app/modules/authorization/catalogue.py b/backend/app/modules/authorization/catalogue.py index e7c08a212..8db392126 100644 --- a/backend/app/modules/authorization/catalogue.py +++ b/backend/app/modules/authorization/catalogue.py @@ -272,6 +272,7 @@ class ActionId(StrEnum): CONTRIBUTION_READ_SELF = "contribution.read_self" CONTRIBUTION_READ_PROJECT = "contribution.read_project" ACTOR_PROJECT_LIST = "actor.project.list" + PROJECT_CATALOGUE_LIST = "project.catalogue.list" ACTOR_ASSIGNMENT_LIST = "actor.assignment.list" ACTOR_SUBMISSION_LIST = "actor.submission.list" ACTOR_WORK_SUMMARY_READ = "actor.work_summary.read" @@ -343,6 +344,7 @@ class ActionOwner(StrEnum): PILOT_10 = "PILOT-10" PILOT_READ_2 = "PILOT-READ-2" PILOT_READ_3 = "PILOT-READ-3" + CLI_46 = "WS-CLI-001-46" AUTH_07B = "WS-AUTH-001-07B" AUTH_08 = "WS-AUTH-001-08" @@ -445,6 +447,7 @@ def _active( ACTION_DEFINITIONS = ( _active(ActionId.ACTOR_PROJECT_LIST, PermissionId.ACTOR_PROFILE_READ_SELF, ActionOwner.PILOT_READ_2), + _active(ActionId.PROJECT_CATALOGUE_LIST, PermissionId.PROJECT_READ, ActionOwner.CLI_46), *( _active(action, PermissionId.SUBMISSION_READ_OWN, ActionOwner.PILOT_READ_2) for action in sorted(CONTRIBUTOR_WORK_READ_ACTIONS) @@ -818,7 +821,7 @@ def _active( def _require_catalogue_counts() -> None: """Keep the closed action inventory and permission boundary exact.""" - if len(PERMISSION_IDS) != 82 or len(ACTION_IDS) != 177: + if len(PERMISSION_IDS) != 82 or len(ACTION_IDS) != 178: raise RuntimeError("authorization catalogue count mismatch") if len(HISTORICAL_PERMISSION_IDS) != 49 or len(NEW_PERMISSION_IDS) != 33: raise RuntimeError("authorization permission boundary mismatch") @@ -919,6 +922,7 @@ def _validate_action_definitions(definitions: tuple[ActionDefinition, ...]) -> N ActionId.PROJECT_SUBMISSION_ARTIFACT_POLICY_DERIVE, ActionId.PROJECT_SUBMISSION_ARTIFACT_POLICY_UPDATE, ActionId.PROJECT_READ, + ActionId.PROJECT_CATALOGUE_LIST, ActionId.ACTOR_AUTHORIZATION_CONTEXT_READ, ActionId.PROJECT_SETUP_RUN_READ, ActionId.PROJECT_GUIDE_SUFFICIENCY_REPORT_LIST, diff --git a/backend/app/modules/authorization/domain/audit.py b/backend/app/modules/authorization/domain/audit.py index baf976876..a13ddb074 100644 --- a/backend/app/modules/authorization/domain/audit.py +++ b/backend/app/modules/authorization/domain/audit.py @@ -53,6 +53,7 @@ "task_import_source", "task_import_batch", "actor_project_collection", + "project_catalogue", "actor_assignment_collection", "actor_submission_collection", "actor_work_summary", @@ -141,6 +142,7 @@ "task_import_source", "task_import_batch", "actor_project_collection", + "project_catalogue", "actor_assignment_collection", "actor_submission_collection", "actor_work_summary", diff --git a/backend/app/modules/authorization/domain/project_catalogue.py b/backend/app/modules/authorization/domain/project_catalogue.py new file mode 100644 index 000000000..de4cd6b45 --- /dev/null +++ b/backend/app/modules/authorization/domain/project_catalogue.py @@ -0,0 +1,68 @@ +"""One current system-wide project reader, never a contributor grant list.""" + +from typing import Literal +from uuid import UUID + +from pydantic import BaseModel, ConfigDict, Field, ValidationError, model_validator + +from app.modules.authorization.catalogue import ActionAvailability, ActionId +from app.modules.authorization.domain.audit import AuthorizationDenialCode, MatchedAuthorityKind +from app.modules.authorization.schemas import AdminRole + + +class ProjectCatalogueResourceContext(BaseModel): + """Bind the page query to the caller, without selecting another actor.""" + + model_config = ConfigDict(extra="forbid", frozen=True, strict=True, revalidate_instances="always") + resource_type: Literal["project_catalogue"] = "project_catalogue" + resource_id: UUID + actor_profile_id: UUID + request_digest: str = Field(pattern=r"^sha256:[0-9a-f]{64}$") + + @model_validator(mode="after") + def exact_actor(self): + if self.resource_id != self.actor_profile_id: + raise ValueError("project catalogue differs from caller") + return self + + +SYSTEM_PROJECT_READ_ROLES = frozenset({ + AdminRole.PROJECT_MANAGER, AdminRole.OPERATOR, + AdminRole.FINANCE_AUTHORITY, AdminRole.AUDIT_AUTHORITY, +}) + + +async def project_catalogue_read_denial(action, resource, context, repository, refresh, lifecycle): + """Hold the actor and matched system grant until the identity page commits.""" + from app.modules.authorization.runtime import HumanAuthorizationContext, PreparedAuthorizationUnsupported + + refused = AuthorizationDenialCode.RESOURCE_GUARD_DENIED + if ( + action.action_id is not ActionId.PROJECT_CATALOGUE_LIST + or not isinstance(context, HumanAuthorizationContext) + or type(resource) is not ProjectCatalogueResourceContext + ): + return refused, context, None, None, None, False + try: + resource = ProjectCatalogueResourceContext.model_validate(resource) + except ValidationError: + return refused, context, None, None, None, False + if resource.actor_profile_id != context.actor_profile_id: + return refused, context, None, None, None, False + if action.availability is not ActionAvailability.ACTIVE: + return AuthorizationDenialCode.ACTION_UNAVAILABLE, context, None, None, None, False + locked = await repository.lock_request_actor(context.identity_link_id, context.actor_profile_id) + try: + context = refresh(locked, context) + except PreparedAuthorizationUnsupported as exc: + return exc.denial_code, context, None, None, None, True + denial = lifecycle(context) + if denial is not None: + return denial, context, None, None, None, True + grant = await repository.find_effective_grant( + context.actor_profile_id, action.permission_id, scope_project_id=None, + system_scope_only=True, for_update=True, allowed_roles=SYSTEM_PROJECT_READ_ROLES, + ) + if grant is None: + return AuthorizationDenialCode.PERMISSION_NOT_GRANTED, context, None, None, None, True + return None, context, MatchedAuthorityKind.ADMIN_ROLE_GRANT, UUID(str(grant.id)), None, True diff --git a/backend/app/modules/authorization/domain/read_dispatch.py b/backend/app/modules/authorization/domain/read_dispatch.py index 2d3ef562b..7fdbc3a33 100644 --- a/backend/app/modules/authorization/domain/read_dispatch.py +++ b/backend/app/modules/authorization/domain/read_dispatch.py @@ -19,6 +19,7 @@ from app.modules.authorization.domain.contributor_reads import contributor_read_denial from app.modules.authorization.domain.management_reads import management_read_denial from app.modules.authorization.domain.project_reads import project_read_denial +from app.modules.authorization.domain.project_catalogue import project_catalogue_read_denial from app.modules.authorization.domain.submission_history import history_read_denial from app.modules.authorization.domain.task_queues import TASK_QUEUE_ACTIONS, queue_read_denial @@ -27,6 +28,7 @@ | CONTRIBUTOR_READ_ACTIONS | CHECKER_RETRY_READ_ACTIONS | POST_SUBMIT_DELIVERY_READ_ACTIONS | PROJECT_MANAGEMENT_READ_ACTIONS | {ActionId.PROJECT_READ} + | {ActionId.PROJECT_CATALOGUE_LIST} ) @@ -34,6 +36,8 @@ async def read_denial(action, resource, context, repository, refresh, lifecycle, """Evaluate one closed read; project reads keep their actor revalidation callback.""" if action.action_id is ActionId.PROJECT_READ: return await project_read_denial(action, resource, context, repository, revalidate_self, lifecycle) + if action.action_id is ActionId.PROJECT_CATALOGUE_LIST: + return await project_catalogue_read_denial(action, resource, context, repository, refresh, lifecycle) if action.action_id in PROJECT_MANAGEMENT_READ_ACTIONS: evaluator = management_read_denial elif action.action_id in HISTORY_READ_ACTIONS: diff --git a/backend/app/modules/authorization/pagination.py b/backend/app/modules/authorization/pagination.py index 176bbd915..b987c1d96 100644 --- a/backend/app/modules/authorization/pagination.py +++ b/backend/app/modules/authorization/pagination.py @@ -58,6 +58,37 @@ def validate_signature(cls, value: str) -> str: return value +class _IdCursorPayload(BaseModel): + """A signed position containing only the already-disclosed record ID.""" + + model_config = _STRICT + + v: Literal[1] + q: str + id: str + + @field_validator("q") + @classmethod + def validate_digest(cls, value: str) -> str: + if re.fullmatch(r"sha256:[0-9a-f]{64}", value) is None: + raise ValueError("invalid cursor") + return value + + +class _IdCursorEnvelope(BaseModel): + model_config = _STRICT + + p: _IdCursorPayload + s: str + + @field_validator("s") + @classmethod + def validate_signature(cls, value: str) -> str: + if len(value) != 43 or _BASE64URL.fullmatch(value) is None: + raise ValueError("invalid cursor") + return value + + def authorization_read_query_digest( *, action_id: ActionId, @@ -128,6 +159,52 @@ def decode(self, value: str, *, query_digest: str) -> tuple[datetime, UUID]: raise InvalidPaginationCursor("invalid cursor") from None +class AuthorizationIdCursorCodec: + """HMAC-bound ID keyset for a read whose ID is already public.""" + + def __init__(self, secret: bytes) -> None: + if len(secret) != 32: + raise ValueError("invalid pagination cursor HMAC secret") + self._secret = bytes(secret) + + def encode(self, *, query_digest: str, resource_id: UUID) -> str: + payload = _IdCursorPayload(v=1, q=query_digest, id=str(resource_id)) + payload_bytes = _canonical_json(payload.model_dump(mode="json")) + signature = hmac.new(self._secret, payload_bytes, hashlib.sha256).digest() + return _encode_base64url( + _canonical_json( + { + "p": payload.model_dump(mode="json"), + "s": _encode_base64url(signature), + } + ) + ) + + def decode(self, value: str, *, query_digest: str) -> UUID: + try: + decoded = _decode_base64url(value) + if len(decoded) > _MAX_DECODED_BYTES: + raise InvalidPaginationCursor("invalid cursor") + envelope = _IdCursorEnvelope.model_validate( + json.loads(decoded, object_pairs_hook=_reject_duplicate_keys), + ) + payload_bytes = _canonical_json(envelope.p.model_dump(mode="json")) + signature = _decode_base64url(envelope.s) + expected = hmac.new(self._secret, payload_bytes, hashlib.sha256).digest() + if not hmac.compare_digest(expected, signature): + raise InvalidPaginationCursor("invalid cursor") + if not hmac.compare_digest(envelope.p.q, query_digest): + raise InvalidPaginationCursor("invalid cursor") + resource_id = UUID(envelope.p.id) + if str(resource_id) != envelope.p.id: + raise InvalidPaginationCursor("invalid cursor") + return resource_id + except InvalidPaginationCursor: + raise + except (ValueError, TypeError, UnicodeDecodeError, json.JSONDecodeError, binascii.Error): + raise InvalidPaginationCursor("invalid cursor") from None + + def _canonical_json(value: dict[str, Any]) -> bytes: return json.dumps( value, diff --git a/backend/app/modules/authorization/project_catalogue.py b/backend/app/modules/authorization/project_catalogue.py new file mode 100644 index 000000000..391b778ca --- /dev/null +++ b/backend/app/modules/authorization/project_catalogue.py @@ -0,0 +1,77 @@ +"""Request-local AUTH decision and signed cursor for project catalogue pages.""" + +from hashlib import sha256 + +from app.core.config import decode_pagination_cursor_hmac_secret +from app.core.hashing import canonical_json_hash +from app.modules.authorization.api.project_catalogue import ( + ProjectCatalogueCursorInvalid, + ProjectCatalogueGrant, + ProjectCatalogueRequest, + ProjectCatalogueUnavailable, +) +from app.modules.authorization.catalogue import ActionId +from app.modules.authorization.domain.project_catalogue import ProjectCatalogueResourceContext +from app.modules.authorization.kernel import AuthorizationService +from app.modules.authorization.pagination import AuthorizationIdCursorCodec, InvalidPaginationCursor + + +class ProjectCatalogueAuthorization: + """Use the canonical kernel for each page before decoding a continuation.""" + + def __init__(self, kernel: AuthorizationService, secret) -> None: + """Reuse the canonical kernel and configured cursor-signing secret.""" + self._kernel, self._secret = kernel, secret + + @staticmethod + def _query(request: ProjectCatalogueRequest) -> str: + """Bind the cursor to the exact actor, action and ID-keyset request.""" + return canonical_json_hash( + { + "action_id": ActionId.PROJECT_CATALOGUE_LIST.value, + "actor_profile_id": str(request.actor_profile_id), + "limit": request.limit, + "order": "id_asc", + } + ) + + def _codec(self) -> AuthorizationIdCursorCodec: + """Fail closed when no valid cursor-signing configuration is available.""" + if self._secret is None: + raise ProjectCatalogueUnavailable("Project catalogue unavailable") + return AuthorizationIdCursorCodec(decode_pagination_cursor_hmac_secret(self._secret)) + + async def authorize(self, request: ProjectCatalogueRequest) -> ProjectCatalogueGrant: + """Require fresh locked authority before decoding the submitted cursor.""" + codec = self._codec() + query_digest = self._query(request) + await self._kernel.require( + ActionId.PROJECT_CATALOGUE_LIST, + ProjectCatalogueResourceContext( + resource_id=request.actor_profile_id, + actor_profile_id=request.actor_profile_id, + request_digest=canonical_json_hash( + { + "query_digest": query_digest, + "presented_cursor_digest": ( + "sha256:" + sha256(request.cursor.encode()).hexdigest() + if request.cursor is not None + else None + ), + } + ), + ), + ) + if request.cursor is None: + return ProjectCatalogueGrant(None) + try: + return ProjectCatalogueGrant(codec.decode(request.cursor, query_digest=query_digest)) + except InvalidPaginationCursor as exc: + raise ProjectCatalogueCursorInvalid("Invalid project catalogue cursor") from exc + + def encode(self, request: ProjectCatalogueRequest, position) -> str: + """Publish only an already-disclosed ID in the signed query envelope.""" + return self._codec().encode( + query_digest=self._query(request), + resource_id=position, + ) diff --git a/backend/app/modules/authorization/runtime.py b/backend/app/modules/authorization/runtime.py index bc7764625..cb3dbf81e 100644 --- a/backend/app/modules/authorization/runtime.py +++ b/backend/app/modules/authorization/runtime.py @@ -29,6 +29,7 @@ checker_registry, checker_retry, checker_retry_reads, contribution_reads, contributor_reads, management_reads, submission_history, task_authority, task_queues, post_submit_deliveries, + project_catalogue, ) from app.modules.authorization.domain.guide_mutations import ( ProjectGuideMutationResourceContext, ProjectGuideMutationPrepareDenialResourceContext, @@ -1063,6 +1064,7 @@ class PreSubmitCheckerInputResourceContext(PreSubmitCheckerInputPreparationConte AuthorizationResourceContext = ( ReviewLifecycleActivationContract | contribution_reads.ContributionSelfReadResourceContext | contribution_reads.ContributionProjectReadResourceContext | contributor_reads.ActorProjectListResourceContext | contributor_reads.ContributorWorkReadResourceContext + | project_catalogue.ProjectCatalogueResourceContext | management_reads.ProjectManagementReadResourceContext | submission_history.HistoryReadResourceContext | task_queues.QueueReadResourceContext | checker_retry_reads.CheckerRetrySourceReadResourceContext diff --git a/backend/app/modules/projects/api/project_summaries.py b/backend/app/modules/projects/api/project_summaries.py index 7bf970bac..29d52e142 100644 --- a/backend/app/modules/projects/api/project_summaries.py +++ b/backend/app/modules/projects/api/project_summaries.py @@ -25,9 +25,33 @@ class ProjectSummaryFacts: active_guide: ActiveGuideIdentity | None +@dataclass(frozen=True, slots=True) +class ProjectCatalogueFacts: + """Only project identity and lifecycle status.""" + + project_id: UUID + name: str + status: ProjectStatus + + class ProjectSummaryReadPort(Protocol): """Page a bounded selected project set by ``(created_at, id)``.""" async def read_project_summaries( - self, project_ids: frozenset[UUID], *, after: tuple[datetime, UUID] | None, limit: int, + self, + project_ids: frozenset[UUID], + *, + after: tuple[datetime, UUID] | None, + limit: int, ) -> tuple[tuple[ProjectSummaryFacts, ...], bool]: ... + + +class ProjectCatalogueReadPort(Protocol): + """Page all project identities after AUTH locks a system reader grant.""" + + async def read_project_catalogue( + self, + *, + after: UUID | None, + limit: int, + ) -> tuple[tuple[ProjectCatalogueFacts, ...], bool]: ... diff --git a/backend/app/modules/projects/project_summaries.py b/backend/app/modules/projects/project_summaries.py index a94ebd45a..69d866cdb 100644 --- a/backend/app/modules/projects/project_summaries.py +++ b/backend/app/modules/projects/project_summaries.py @@ -5,7 +5,11 @@ from sqlalchemy import and_, select, tuple_ from sqlalchemy.ext.asyncio import AsyncSession -from app.modules.projects.api.project_summaries import ActiveGuideIdentity, ProjectSummaryFacts +from app.modules.projects.api.project_summaries import ( + ActiveGuideIdentity, + ProjectCatalogueFacts, + ProjectSummaryFacts, +) from app.modules.projects.models import Project, ProjectGuide @@ -15,26 +19,65 @@ class ProjectSummaryReader: def __init__(self, session: AsyncSession) -> None: self._session = session + async def read_project_catalogue(self, *, after, limit): + """One bounded all-project identity page; never join guide or actor rows.""" + statement = select(Project.id, Project.name, Project.status) + if after is not None: + statement = statement.where(Project.id > str(after)) + rows = ( + await self._session.execute( + statement.order_by(Project.id).limit(limit + 1), + ) + ).all() + return tuple( + ProjectCatalogueFacts( + project_id=UUID(str(row.id)), + name=row.name, + status=row.status, + ) + for row in rows[:limit] + ), len(rows) > limit + async def read_project_summaries(self, project_ids, *, after, limit): if not project_ids: return (), False - statement = select( - Project.id, Project.name, Project.status, Project.created_at, - ProjectGuide.id.label("guide_id"), ProjectGuide.version, - ).outerjoin(ProjectGuide, and_( - ProjectGuide.project_id == Project.id, ProjectGuide.status == "active", - )).where(Project.id.in_([str(item) for item in project_ids])) + statement = ( + select( + Project.id, + Project.name, + Project.status, + Project.created_at, + ProjectGuide.id.label("guide_id"), + ProjectGuide.version, + ) + .outerjoin( + ProjectGuide, + and_( + ProjectGuide.project_id == Project.id, + ProjectGuide.status == "active", + ), + ) + .where(Project.id.in_([str(item) for item in project_ids])) + ) if after is not None: - statement = statement.where(tuple_(Project.created_at, Project.id) > (after[0], str(after[1]))) - rows = (await self._session.execute( - statement.order_by(Project.created_at, Project.id).limit(limit + 1), - )).all() + statement = statement.where( + tuple_(Project.created_at, Project.id) > (after[0], str(after[1])) + ) + rows = ( + await self._session.execute( + statement.order_by(Project.created_at, Project.id).limit(limit + 1), + ) + ).all() return tuple( ProjectSummaryFacts( - project_id=UUID(str(row.id)), name=row.name, status=row.status, created_at=row.created_at, + project_id=UUID(str(row.id)), + name=row.name, + status=row.status, + created_at=row.created_at, active_guide=( ActiveGuideIdentity(UUID(str(row.guide_id)), row.version) - if row.guide_id is not None else None + if row.guide_id is not None + else None ), ) for row in rows[:limit] diff --git a/backend/scripts/behavior_ownership.py b/backend/scripts/behavior_ownership.py index 63ad00af9..a974221f1 100644 --- a/backend/scripts/behavior_ownership.py +++ b/backend/scripts/behavior_ownership.py @@ -865,6 +865,13 @@ "backend/app/modules/tasks/task_batch_transition_router.py", "backend/app/modules/tasks/task_batch_transitions.py", }) +PROJECT_CATALOGUE_TARGETS = frozenset({ + "backend/app/api/deps/project_catalogue.py", + "backend/app/api/routes/project_catalogue.py", + "backend/app/modules/authorization/api/project_catalogue.py", + "backend/app/modules/authorization/domain/project_catalogue.py", + "backend/app/modules/authorization/project_catalogue.py", +}) class BehaviorOwnershipError(RuntimeError): @@ -1134,6 +1141,7 @@ def _validate_additive_partition_transition( | PILOT_02A_IMPORT_SOURCE_TARGETS | PILOT_02B_ATOMIC_IMPORT_TARGETS | PILOT_02C_BULK_TRANSITION_TARGETS + | PROJECT_CATALOGUE_TARGETS ) expected_additions = (approved_additions & additions) - set(trusted_targets) if POL_03A_DECLARATIVE_MODEL_TARGET in additions: diff --git a/backend/scripts/test_lane_catalogue.py b/backend/scripts/test_lane_catalogue.py index 50edefb94..e24aafe05 100644 --- a/backend/scripts/test_lane_catalogue.py +++ b/backend/scripts/test_lane_catalogue.py @@ -304,6 +304,7 @@ class TestLane: PROJECT_MODULES = ( # Project-bound batch custody stays in the project semantic family. + "tests/project_catalogue/test_public.py", "tests/tasks/test_task_import_batch_contract.py", "tests/tasks/test_task_import_batch_bindings.py", "tests/tasks/test_task_import_batches.py", @@ -718,6 +719,7 @@ class TestLane: "schema_contracts", ( SCHEMA_MODULE, + "tests/migrations/test_project_catalogue_read.py", "tests/migrations/test_contribution_read_audit.py", "tests/migrations/test_contribution_export_snapshots.py", "tests/authorization/post_submit/test_receipt_custody.py", diff --git a/backend/tests/authorization/catalogue_fixtures.py b/backend/tests/authorization/catalogue_fixtures.py index 0909ed172..c52b812c2 100644 --- a/backend/tests/authorization/catalogue_fixtures.py +++ b/backend/tests/authorization/catalogue_fixtures.py @@ -267,6 +267,7 @@ expected = { "actor.project.list": ("actor.profile.read_self", "PILOT-READ-2"), + "project.catalogue.list": ("project.read", "WS-CLI-001-46"), **dict.fromkeys(( "actor.assignment.list", "actor.submission.list", "actor.work_summary.read", "task.submission_bundle_preparation.list", "task.submission_bundle_preparation.read", @@ -489,6 +490,7 @@ "contribution.read_self", "contribution.read_project", "actor.project.list", + "project.catalogue.list", "actor.assignment.list", "actor.submission.list", "actor.work_summary.read", diff --git a/backend/tests/authorization/setup_finalization/test_catalogue.py b/backend/tests/authorization/setup_finalization/test_catalogue.py index bf4d6bfc4..a5eb34e94 100644 --- a/backend/tests/authorization/setup_finalization/test_catalogue.py +++ b/backend/tests/authorization/setup_finalization/test_catalogue.py @@ -84,6 +84,7 @@ ActionId.PROJECT_ROLE_GRANT_ISSUE, ActionId.PROJECT_ROLE_GRANT_REVOKE, ActionId.PROJECT_CREATE, + ActionId.PROJECT_CATALOGUE_LIST, ActionId.PROJECT_GUIDE_CREATE, ActionId.PROJECT_GUIDE_UPDATE, ActionId.PROJECT_GUIDE_SOURCE_SNAPSHOT_CREATE, diff --git a/backend/tests/authorization/test_catalogue.py b/backend/tests/authorization/test_catalogue.py index fd8717f35..e85b7ea4f 100644 --- a/backend/tests/authorization/test_catalogue.py +++ b/backend/tests/authorization/test_catalogue.py @@ -26,7 +26,7 @@ def test_closed_permission_and_action_catalogue_is_exact_and_non_executable() -> assert {item.value for item in HISTORICAL_PERMISSION_IDS} == historical_permissions assert {item.value for item in NEW_PERMISSION_IDS} == new_permissions assert {item.value for item in PERMISSION_IDS} == historical_permissions | new_permissions - assert len(ACTION_IDS) == len(ACTION_DEFINITIONS) == len(ACTION_BY_ID) == 177 + assert len(ACTION_IDS) == len(ACTION_DEFINITIONS) == len(ACTION_BY_ID) == 178 assert set(ACTION_BY_ID) == ACTION_IDS assert {definition.owner for definition in ACTION_DEFINITIONS} == set(ActionOwner) assert { @@ -105,7 +105,7 @@ def test_closed_permission_and_action_catalogue_is_exact_and_non_executable() -> } assert all(not owner.value.startswith("WS-REV-") for owner in ActionOwner) assert Counter(definition.availability for definition in ACTION_DEFINITIONS) == { - ActionAvailability.ACTIVE: 151, + ActionAvailability.ACTIVE: 152, ActionAvailability.PLANNED: 26, } assert resolve_executable_action(ActionId.ACTOR_PROFILE_READ_SELF).permission_id is PermissionId.ACTOR_PROFILE_READ_SELF diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index dcb0a4fec..d470f9b6f 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -23,7 +23,7 @@ DDL_LOCK_DIRECTORY = Path("/tmp") # Match the PostgreSQL 16 engine used by Backend CI. Catalog identity rendering # differs across major versions; regenerate only after comparing actual objects. -EXPECTED_PUBLIC_SCHEMA_SHA256 = "d7bc9a50fce73530b15ee47937bf3aa1263e6c34629699185223c951cc336e8e" +EXPECTED_PUBLIC_SCHEMA_SHA256 = "4551d00ae48c338e60cdb55d6cf213461450fe67078a00f28862f956e70b0a3a" PROTECTED_TEST_TABLES = ( "actor_profile_migration_state", "alembic_version", diff --git a/backend/tests/migrations/test_project_catalogue_read.py b/backend/tests/migrations/test_project_catalogue_read.py new file mode 100644 index 000000000..f8eb2056e --- /dev/null +++ b/backend/tests/migrations/test_project_catalogue_read.py @@ -0,0 +1,143 @@ +"""The catalogue action extends only the public audit CHECK under hostile search_path.""" + +import asyncio + +from alembic import command +import asyncpg +import pytest + +from app.core.identifiers import new_record_id +from app.db import session as db_session +from tests.conftest import _drop_test_database_schema +from tests.migration_fixtures import _config + + +pytestmark = pytest.mark.postgres_schema_contract +NAME = "ck_audit_events_authorization_action_evidence" + + +async def _prepare_shadow(url): + connection = await asyncpg.connect(url.replace("+asyncpg", "")) + try: + role, database = await connection.fetchrow("select current_user, current_database()") + assert role.startswith("workstream_role_") and database.startswith("workstream_test_") + await connection.execute("create schema shadow") + await connection.execute( + "create table shadow.audit_events (like public.audit_events including constraints)" + ) + before_public = await connection.fetchval( + "select pg_get_constraintdef(oid) from pg_constraint " + "where conrelid='public.audit_events'::regclass and conname=$1", + NAME, + ) + before_shadow = await connection.fetchval( + "select pg_get_constraintdef(oid) from pg_constraint " + "where conrelid='shadow.audit_events'::regclass and conname=$1", + NAME, + ) + assert before_public == before_shadow + await connection.execute( + f'alter role "{role}" in database "{database}" set search_path=shadow,public' + ) + return before_public, before_shadow + finally: + await connection.close() + + +async def _definitions(url): + connection = await asyncpg.connect(url.replace("+asyncpg", "")) + try: + return tuple( + [ + await connection.fetchval( + "select pg_get_constraintdef(oid) from pg_constraint " + "where conrelid=$1::regclass and conname=$2", + table, + NAME, + ) + for table in ("public.audit_events", "shadow.audit_events") + ] + ) + finally: + await connection.close() + + +async def _restore_role_search_path(url): + connection = await asyncpg.connect(url.replace("+asyncpg", "")) + try: + role, database = await connection.fetchrow("select current_user, current_database()") + assert role.startswith("workstream_role_") and database.startswith("workstream_test_") + await connection.execute(f'alter role "{role}" in database "{database}" reset search_path') + finally: + await connection.close() + + +async def _assert_closed_pair(url): + connection = await asyncpg.connect(url.replace("+asyncpg", "")) + try: + valid_id = str(new_record_id()) + await connection.execute( + """insert into public.audit_events( + id,entity_type,entity_id,event_type,actor_id,actor_roles,claim_snapshot, + auth_source,is_dev_auth,event_payload,event_domain,event_version,actor_ref_kind, + request_id,correlation_id,permission_id,action_id,reason,project_id,resource_type, + resource_id,after_facts,matched_grant_id,target_ref_kind,target_ref_id,denial_code) + values($1,'authorization_decision',$1,'SensitiveAuthorizationDenied',$2,'[]','{}', + 'local_authority',false,'{}','authority',1,'actor_profile',$3,$4, + 'project.read','project.catalogue.list','authorization_evaluation', + null,null,null,jsonb_build_object('allowed',false,'resource_context_digest', + 'sha256:'||repeat('a',64)),null,null,null,'permission_not_granted')""", + valid_id, + str(new_record_id()), + str(new_record_id()), + str(new_record_id()), + ) + crossed_id = str(new_record_id()) + with pytest.raises(asyncpg.CheckViolationError) as error: + await connection.execute( + """insert into public.audit_events select + (jsonb_populate_record(null::public.audit_events, + to_jsonb(value) || jsonb_build_object( + 'id',$2::text,'entity_id',$2::text, + 'permission_id','project.create'))).* + from public.audit_events value where value.id=$1::uuid""", + valid_id, + crossed_id, + ) + assert error.value.constraint_name == NAME + assert ( + await connection.fetchval( + "select count(*) from public.audit_events where id=$1::uuid", + valid_id, + ) + == 1 + ) + assert ( + await connection.fetchval( + "select count(*) from public.audit_events where id=$1::uuid", + crossed_id, + ) + == 0 + ) + finally: + await connection.close() + + +def test_upgrade_qualifies_public_constraint_under_shadowed_search_path( + isolated_database_env, + migration_lock, +): + with migration_lock(): + asyncio.run(db_session.dispose_engine()) + asyncio.run(_drop_test_database_schema(isolated_database_env)) + command.upgrade(_config(), "0060_audit_restore_path") + before_public, before_shadow = asyncio.run(_prepare_shadow(isolated_database_env)) + try: + command.upgrade(_config(), "0061_project_catalogue_read") + after_public, after_shadow = asyncio.run(_definitions(isolated_database_env)) + assert before_public.count("project.catalogue.list") == 0 + assert after_public.count("project.catalogue.list") == 2 + assert after_shadow == before_shadow + asyncio.run(_assert_closed_pair(isolated_database_env)) + finally: + asyncio.run(_restore_role_search_path(isolated_database_env)) diff --git a/backend/tests/project_catalogue/test_public.py b/backend/tests/project_catalogue/test_public.py new file mode 100644 index 000000000..6eba8aba3 --- /dev/null +++ b/backend/tests/project_catalogue/test_public.py @@ -0,0 +1,351 @@ +"""Public project catalogue proofs against current PostgreSQL authority.""" + +import asyncio +import base64 +import json +from types import SimpleNamespace +from uuid import UUID + +import pytest +from sqlalchemy import select + +from app.core.config import decode_pagination_cursor_hmac_secret, get_settings +from app.core.hashing import canonical_json_hash +from app.db import session as db_session +from app.modules.authorization.models import AdminRoleGrant +from app.modules.authorization.pagination import AuthorizationIdCursorCodec +from app.modules.projects.project_summaries import ProjectSummaryReader +from app.modules.tasks.models import AuditEvent +from tests.project_create_fixtures import grant_fixture_admin_role +from tests.test_tasks import actor_id, auth_headers, set_dev_actor + + +pytest_plugins = ("tests.test_tasks",) + + +PATH = "/api/v1/projects" +MANAGER = "project-manager-subject" + + +async def _project(client, slug): + response = await client.post( + PATH, + headers=auth_headers(), + json={ + "name": f"Catalogue {slug}", + "slug": slug, + "description": "Catalogue fixture", + }, + ) + assert response.status_code == 201, response.text + return response.json() + + +async def _admit_and_grant(client, monkeypatch, subject, role, *, scope="system", project_id=None): + set_dev_actor(monkeypatch, roles="", subject=subject) + admission = await client.get("/api/v1/actors/me", headers=auth_headers()) + assert admission.status_code == 200, admission.text + async with db_session.get_session_factory()() as session, session.begin(): + return await grant_fixture_admin_role( + session, + await actor_id(subject), + role=role, + scope=scope, + project_id=project_id, + ) + + +@pytest.mark.parametrize( + "role", ["project_manager", "operator", "finance_authority", "audit_authority"] +) +async def test_each_existing_system_project_reader_sees_only_minimal_identity( + task_client, + monkeypatch, + role, +): + first = await _project(task_client, f"catalogue-first-{role.replace('_', '-')}") + second = await _project(task_client, f"catalogue-second-{role.replace('_', '-')}") + subject = f"catalogue-{role}" + grant = await _admit_and_grant(task_client, monkeypatch, subject, role) + response = await task_client.get(PATH, headers=auth_headers()) + assert response.status_code == 200, response.text + page = response.json() + assert page["next_cursor"] is None + assert {row["project_id"] for row in page["items"]} == {first["id"], second["id"]} + assert all(set(row) == {"project_id", "name", "status"} for row in page["items"]) + reader_id = await actor_id(subject) + async with db_session.get_session_factory()() as session: + decisions = ( + await session.scalars( + select(AuditEvent).where( + AuditEvent.actor_id == reader_id, + AuditEvent.action_id == "project.catalogue.list", + ) + ) + ).all() + assert len(decisions) == 1 + assert decisions[0].event_type == "SensitiveAuthorizationAllowed" + assert decisions[0].permission_id == "project.read" + assert decisions[0].matched_grant_id == str(grant.id) + assert decisions[0].resource_type is None and decisions[0].resource_id is None + assert decisions[0].project_id is None + assert decisions[0].after_facts["resource_context_digest"].startswith("sha256:") + own = await task_client.get("/api/v1/actors/me/projects", headers=auth_headers()) + assert own.status_code == 200 and own.json()["items"] == [] + if role == "project_manager": + await _admit_and_grant( + task_client, + monkeypatch, + subject, + role, + scope="project", + project_id=first["id"], + ) + overlap = await task_client.get(PATH, headers=auth_headers()) + assert overlap.status_code == 200 + assert [row["project_id"] for row in overlap.json()["items"]] == sorted( + [first["id"], second["id"]] + ) + own = await task_client.get("/api/v1/actors/me/projects", headers=auth_headers()) + assert [row["project_id"] for row in own.json()["items"]] == [first["id"]] + + +async def test_exact_project_grant_and_access_administrator_alone_do_not_enumerate( + task_client, + monkeypatch, +): + project = await _project(task_client, "catalogue-exact-negative") + for role, scope in (("project_manager", "project"), ("access_administrator", "system")): + await _admit_and_grant( + task_client, + monkeypatch, + f"catalogue-negative-{role}", + role, + scope=scope, + project_id=project["id"] if scope == "project" else None, + ) + if scope == "project": + own = await task_client.get("/api/v1/actors/me/projects", headers=auth_headers()) + assert own.status_code == 200, own.text + assert [item["project_id"] for item in own.json()["items"]] == [project["id"]] + response = await task_client.get(PATH, headers=auth_headers()) + assert response.status_code == 404, response.text + assert response.json()["error"]["code"] == "project_authorization_resource_not_found" + + +@pytest.mark.parametrize("role", ["submitter", "reviewer", None]) +async def test_contributor_or_unrelated_actor_cannot_enumerate(task_client, monkeypatch, role): + project = await _project(task_client, f"catalogue-contributor-{role or 'none'}") + subject = f"catalogue-contributor-{role or 'none'}" + set_dev_actor(monkeypatch, roles="viewer", subject=subject) + admission = await task_client.get("/api/v1/actors/me", headers=auth_headers()) + assert admission.status_code == 200 + if role is not None: + set_dev_actor(monkeypatch, roles="project_manager", subject=MANAGER) + issued = await task_client.post( + f"/api/v1/projects/{project['id']}/role-grants", + headers=auth_headers(), + json={ + "target_actor_profile_id": admission.json()["actor_profile_id"], + "role": role, + "qualification": { + "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": [], + }, + "reason": "Exact contributor role is not system catalogue authority", + }, + ) + assert issued.status_code == 201, issued.text + set_dev_actor(monkeypatch, roles="viewer", subject=subject) + response = await task_client.get(PATH, headers=auth_headers()) + assert response.status_code == 404, response.text + assert response.json()["error"]["code"] == "project_authorization_resource_not_found" + + +async def test_page_cursor_is_actor_and_query_bound_and_authority_is_refreshed( + task_client, + monkeypatch, +): + projects = [await _project(task_client, f"catalogue-page-{index}") for index in range(3)] + first = await task_client.get(PATH, params={"limit": 1}, headers=auth_headers()) + assert first.status_code == 200, first.text + cursor = first.json()["next_cursor"] + assert cursor is not None + ids = [first.json()["items"][0]["project_id"]] + while cursor is not None: + response = await task_client.get( + PATH, params={"limit": 1, "cursor": cursor}, headers=auth_headers() + ) + assert response.status_code == 200, response.text + ids.extend(row["project_id"] for row in response.json()["items"]) + cursor = response.json()["next_cursor"] + assert ids == sorted(row["id"] for row in projects) + envelope = json.loads( + base64.urlsafe_b64decode( + first.json()["next_cursor"] + "=" * (-len(first.json()["next_cursor"]) % 4), + ) + ) + assert set(envelope) == {"p", "s"} + assert set(envelope["p"]) == {"v", "q", "id"} + assert envelope["p"]["id"] == ids[0] + assert "created_at" not in json.dumps(envelope) + + other_action_digest = canonical_json_hash( + { + "action_id": "project.read", + "actor_profile_id": str(await actor_id(MANAGER)), + "limit": 1, + "order": "id_asc", + } + ) + secret = get_settings().pagination_cursor_hmac_secret + assert secret is not None + foreign_action_cursor = AuthorizationIdCursorCodec( + decode_pagination_cursor_hmac_secret(secret), + ).encode(query_digest=other_action_digest, resource_id=UUID(ids[0])) + + async def forbidden_read(self, *, after, limit): + raise AssertionError("cursor decoding must precede project reads") + + with monkeypatch.context() as patched: + patched.setattr(ProjectSummaryReader, "read_project_catalogue", forbidden_read) + wrong_action = await task_client.get( + PATH, + params={"limit": 1, "cursor": foreign_action_cursor}, + headers=auth_headers(), + ) + assert wrong_action.status_code == 422 + assert wrong_action.json()["error"]["code"] == "project_catalogue_cursor_invalid" + bad_limit = await task_client.get( + PATH, params={"limit": 2, "cursor": first.json()["next_cursor"]}, headers=auth_headers() + ) + assert bad_limit.status_code == 422 + assert bad_limit.json()["error"]["code"] == "project_catalogue_cursor_invalid" + tampered = first.json()["next_cursor"] + middle = len(tampered) // 2 + tampered = ( + tampered[:middle] + ("A" if tampered[middle] != "A" else "B") + tampered[middle + 1 :] + ) + forged = await task_client.get( + PATH, params={"limit": 1, "cursor": tampered}, headers=auth_headers() + ) + assert forged.status_code == 422 + + await _admit_and_grant(task_client, monkeypatch, "catalogue-second-reader", "audit_authority") + other = await task_client.get( + PATH, params={"limit": 1, "cursor": first.json()["next_cursor"]}, headers=auth_headers() + ) + assert other.status_code == 422 + set_dev_actor(monkeypatch, roles="", subject=MANAGER) + async with db_session.get_session_factory()() as session, session.begin(): + grant = await session.scalar( + select(AdminRoleGrant).where( + AdminRoleGrant.target_actor_profile_id == await actor_id(MANAGER), + AdminRoleGrant.role == "project_manager", + AdminRoleGrant.scope_type == "system", + AdminRoleGrant.status == "active", + ) + ) + assert grant is not None + grant.status = "revoked" + grant.version = 2 + grant.revoked_by_actor_profile_id = grant.granted_by_actor_profile_id + grant.revoked_by_admin_role_grant_id = grant.granted_by_admin_role_grant_id + grant.revoked_reason = "Catalogue test revocation" + from datetime import UTC, datetime + + grant.revoked_at = datetime.now(UTC) + refused = await task_client.get( + PATH, params={"limit": 1, "cursor": first.json()["next_cursor"]}, headers=auth_headers() + ) + assert refused.status_code == 404, refused.text + + +async def test_in_flight_page_holds_matched_grant_until_response_transaction_commits( + task_client, + monkeypatch, +): + await _project(task_client, "catalogue-locking") + manager_id = await actor_id(MANAGER) + async with db_session.get_session_factory()() as session: + grant_id = await session.scalar( + select(AdminRoleGrant.id).where( + AdminRoleGrant.target_actor_profile_id == manager_id, + AdminRoleGrant.role == "project_manager", + AdminRoleGrant.scope_type == "system", + AdminRoleGrant.status == "active", + ) + ) + assert grant_id is not None + entered, release = asyncio.Event(), asyncio.Event() + original = ProjectSummaryReader.read_project_catalogue + + async def held_read(self, *, after, limit): + entered.set() + await release.wait() + return await original(self, after=after, limit=limit) + + monkeypatch.setattr(ProjectSummaryReader, "read_project_catalogue", held_read) + first = asyncio.create_task(task_client.get(PATH, headers=auth_headers())) + await asyncio.wait_for(entered.wait(), 20) + + async def revoke(): + async with db_session.get_session_factory()() as session, session.begin(): + grant = await session.get(AdminRoleGrant, grant_id, with_for_update=True) + assert grant is not None + grant.status = "revoked" + grant.version = 2 + grant.revoked_by_actor_profile_id = grant.granted_by_actor_profile_id + grant.revoked_by_admin_role_grant_id = grant.granted_by_admin_role_grant_id + grant.revoked_reason = "Catalogue lock regression" + from datetime import UTC, datetime + + grant.revoked_at = datetime.now(UTC) + + revocation = asyncio.create_task(revoke()) + try: + await asyncio.sleep(0.2) + assert not revocation.done(), "revocation committed before the read released its grant" + finally: + release.set() + assert (await asyncio.wait_for(first, 20)).status_code == 200 + await asyncio.wait_for(revocation, 20) + refused = await task_client.get(PATH, headers=auth_headers()) + assert refused.status_code == 404 + + +async def test_invalid_owner_projection_rolls_back_authorization_evidence(task_client, monkeypatch): + project = await _project(task_client, "catalogue-invalid-projection") + + async def invalid_read(self, *, after, limit): + return ( + SimpleNamespace( + project_id=UUID(project["id"]), + name="Invalid projection", + status="not-a-project-status", + ), + ), False + + monkeypatch.setattr(ProjectSummaryReader, "read_project_catalogue", invalid_read) + response = await task_client.get(PATH, headers=auth_headers()) + assert response.status_code == 503 + assert response.json()["error"]["code"] == "project_catalogue_unavailable" + async with db_session.get_session_factory()() as session: + count = ( + await session.scalars( + select(AuditEvent.id).where( + AuditEvent.action_id == "project.catalogue.list", + ) + ) + ).all() + assert count == [] diff --git a/backend/tests/test_alembic.py b/backend/tests/test_alembic.py index eb7e25634..669546ecb 100644 --- a/backend/tests/test_alembic.py +++ b/backend/tests/test_alembic.py @@ -354,6 +354,7 @@ def test_v01_graph_has_one_root_and_head() -> None: assert [revision.revision for revision in revisions] == [ HEAD_REVISION, + "0060_audit_restore_path", "0059_neutral_guide_runtime", "0058_compensation_unit_create", "0057_roles_anywhere_custody", @@ -568,6 +569,7 @@ def test_repeated_upgrade_head_preserves_current_database( "0057_roles_anywhere_custody", "0058_compensation_unit_create", "0059_neutral_guide_runtime", + "0060_audit_restore_path", ], ) def test_preceding_head_upgrades_to_current_without_recreation( @@ -600,7 +602,7 @@ def test_preceding_head_upgrades_to_current_without_recreation( assert before["versions"] == [installed_revision] validators = ( asyncio.run(_audit_validator_custody(isolated_database_env)) - if installed_revision == "0059_neutral_guide_runtime" + if installed_revision in ("0059_neutral_guide_runtime", "0060_audit_restore_path") else None ) command.upgrade(config, HEAD_REVISION) @@ -608,6 +610,8 @@ def test_preceding_head_upgrades_to_current_without_recreation( updated = asyncio.run(_audit_validator_custody(isolated_database_env)) assert [row[:-1] for row in updated] == [row[:-1] for row in validators] assert all(row[-1] == ["search_path=pg_catalog, public, pg_temp"] for row in updated) + if installed_revision == "0060_audit_restore_path": + assert updated == validators after = asyncio.run(_database_snapshot(isolated_database_env)) command.upgrade(config, "head") repeated = asyncio.run(_database_snapshot(isolated_database_env)) diff --git a/backend/tests/test_api_controls.py b/backend/tests/test_api_controls.py index ccfe71b91..463bb7702 100644 --- a/backend/tests/test_api_controls.py +++ b/backend/tests/test_api_controls.py @@ -519,6 +519,7 @@ def test_openapi_documents_request_error_and_response_context() -> None: new_manager_reads |= contributor_reads.keys() project = "/api/v1/projects/{project_id}" project_reads = { + "GET /api/v1/projects": "project.catalogue.list", f"GET {project}/guides": "project.guide.list", f"GET {project}/guides/{{guide_id}}": "project.guide.read", f"GET {project}/guides/{{guide_id}}/activation-readiness": "project.guide.activation_readiness.read", @@ -633,13 +634,13 @@ def test_openapi_documents_request_error_and_response_context() -> None: assert repair_operation["x-workstream-action-id"] == "project.task.revision.repair" assert repair_operation["security"] new_manager_reads.add(revision_repair) - assert len(route_inventory) == 129 + assert len(route_inventory) == 130 retained_routes = sorted(set(route_inventory) - proposal_routes - post_policy_routes - queue_routes - new_manager_reads - guide_read - contribution_reads.keys() - public_entry_actions.keys() - registry_actions.keys() - recovery_actions.keys()) retained_protected = sorted(set(protected_inventory) - proposal_routes - post_policy_routes - queue_routes - new_manager_reads - guide_read - contribution_reads.keys() - public_entry_actions.keys() - registry_actions.keys() - recovery_actions.keys()) assert sha256("\n".join(retained_routes).encode()).hexdigest() == ( "793237012e9256d308dd5d1e9c1a65e41381cf6306888fdd175dc9794b118444" ) - assert len(protected_inventory) == 127 + assert len(protected_inventory) == 128 assert sha256("\n".join(retained_protected).encode()).hexdigest() == ( "0953ae6392b81a4a7ba7c9f6b7fccc17768f34cd1f958572fdc553cd204ec82e" ) diff --git a/backend/tests/test_api_rate_controls.py b/backend/tests/test_api_rate_controls.py index f5d0ea43c..88fa3fea1 100644 --- a/backend/tests/test_api_rate_controls.py +++ b/backend/tests/test_api_rate_controls.py @@ -888,8 +888,8 @@ async def unavailable() -> None: enforce_admin_mutation_rate_limit, enforce_authorization_read_rate_limit, } -# These 67 bindings are the explicit unchanged production baseline from main. -# Registry and recovery routes extend it through their owner-specific maps below. +# These bindings form the exact production inventory; registry and recovery routes +# extend it through their owner-specific maps below. ESTABLISHED_ADMIN_MUTATION_RATE_ROUTES = { ("POST", "/actor-identity-links/{identity_link_id}/reactivate"), ("POST", "/actor-identity-links/{identity_link_id}/revoke"), @@ -913,6 +913,7 @@ async def unavailable() -> None: ("GET", "/audit/projects/{project_id}/tasks/{task_id}/locked-context"), ("GET", "/operations/projects/{project_id}/tasks"), ("GET", "/operations/projects/{project_id}/tasks/{task_id}/locked-context"), + ("GET", "/projects"), ("GET", "/projects/{project_id}"), ("GET", "/projects/{project_id}/active-guide"), ("GET", "/projects/{project_id}/contribution-policies/current"), diff --git a/backend/tests/test_behavior_ownership.py b/backend/tests/test_behavior_ownership.py index 438615f4b..e86674ea3 100644 --- a/backend/tests/test_behavior_ownership.py +++ b/backend/tests/test_behavior_ownership.py @@ -1697,6 +1697,27 @@ def test_partition_accepts_only_exact_task_project_authority_targets() -> None: ) +def test_partition_accepts_only_exact_project_catalogue_targets() -> None: + expected = frozenset({ + "backend/app/api/deps/project_catalogue.py", + "backend/app/api/routes/project_catalogue.py", + "backend/app/modules/authorization/api/project_catalogue.py", + "backend/app/modules/authorization/domain/project_catalogue.py", + "backend/app/modules/authorization/project_catalogue.py", + }) + assert ownership.PROJECT_CATALOGUE_TARGETS == expected + retained = "backend/app/core/config.py" + trusted = _partition([retained]) + ownership._validate_additive_partition_transition( + _partition(sorted({retained, *expected})), trusted + ) + with pytest.raises(ownership.BehaviorOwnershipError, match="untrusted_partition_change"): + ownership._validate_additive_partition_transition( + _partition(sorted({retained, *expected, "backend/app/api/routes/project_catalogue_admin.py"})), + trusted, + ) + + def test_partition_accepts_only_exact_cp05_authorization_targets() -> None: """CP05 registers five named targets without admitting another AUTH owner.""" expected = frozenset({ diff --git a/backend/tests/test_ci_lane_catalogue.py b/backend/tests/test_ci_lane_catalogue.py index 521825ed4..9f539a262 100644 --- a/backend/tests/test_ci_lane_catalogue.py +++ b/backend/tests/test_ci_lane_catalogue.py @@ -216,6 +216,7 @@ def test_expanded_lane_families_keep_exact_semantic_owners() -> None: assert schema_static_contracts.isdisjoint(partitioned_modules) assert { "tests/test_alembic.py", + "tests/migrations/test_project_catalogue_read.py", "tests/migrations/test_contribution_export_snapshots.py", "tests/migrations/test_contribution_read_audit.py", "tests/test_database_reset.py", @@ -271,8 +272,8 @@ def test_semantic_family_membership_has_an_independent_fingerprint() -> None: "c8a0538c251f77fc1893ed8aa76feff8fa2134cf8e9b5a049f32fbabd5226277", ), "project": ( - 212, - "11225020885fa570a1faf96c0cf37a604a0f9ec17e799794fa52a1b189f87283", + 213, + "3c2477009349137e7aefd268294a811c16ee8b2ffff23d5aa513f4b479b1889d", ), "task": ( 95, @@ -344,9 +345,9 @@ def test_project_lane_ownership_has_an_independent_fingerprint() -> None: ) + "\n" ) - assert len(catalogue.PROJECT_PARTITIONED_MODULES) == 212 + assert len(catalogue.PROJECT_PARTITIONED_MODULES) == 213 assert hashlib.sha256(canonical.encode()).hexdigest() == ( - "ef5d13776cc1686b98538ee01b496f420391c9c4574f0e6017b9bd395a2da32f" + "41e0285a424449b5d639407cfd4ca5a4ac1065baa8057198417274d310adc3a8" ) diff --git a/cli/README.md b/cli/README.md index d4ea7e483..c8bf0d22e 100644 --- a/cli/README.md +++ b/cli/README.md @@ -32,6 +32,7 @@ acknowledgements; neither approves policy nor activates a guide. | `workstream contributions [--project UUID]` | `GET /api/v1/actors/me/contributions` | | `workstream project contributions PROJECT_ID` | `GET /api/v1/projects/PROJECT_ID/contributions/export` | | `workstream project access PROJECT_ID` | `GET /api/v1/actors/me/authorization-context?project_id=PROJECT_ID` | +| `workstream project list [--limit N] [--cursor TOKEN]` | `GET /api/v1/projects` | | `workstream project show PROJECT_ID` | `GET /api/v1/projects/PROJECT_ID` | | `workstream project create --name TEXT --slug TEXT --idempotency-key UUID` | `POST /api/v1/projects` | | `workstream project guide create PROJECT_ID --input FILE --idempotency-key UUID` | `POST /api/v1/projects/PROJECT_ID/guides` | @@ -147,6 +148,33 @@ authorization and lifecycle decisions. Reading a profile can admit a first-time caller and update server-owned last-seen/audit data. Project access is a current snapshot; each later API operation must independently authorize the caller. +## Read the system-covered project catalogue + +```sh +workstream project list +workstream project list --limit 10 --output json +workstream project list --limit 10 --cursor PREVIOUS_NEXT_CURSOR --output json +``` + +This separate catalogue requires a current system-wide Project Manager, +Operator, Finance Authority, or Audit Authority grant. Access Administrator +alone, exact-project grants, and contributor grants do not authorize it. +The backend checks current authority on every page; the CLI does not infer +authority from a role, cached result, or cursor. + +Each invocation requests one page with `--limit` 1–100 (default 50) and never +follows continuation automatically. JSON preserves the validated public +`{"items":[{"project_id":"…","name":"…","status":"draft"}],"next_cursor":null}` +shape. Rows contain no roles, guide, task, or payment details. Text prints +Project, Name, and Status for each row, then Next cursor; terminal controls +are escaped. Pass the opaque cursor unchanged with the same limit; null ends +continuation. Invalid, substituted, or stale-authority continuations fail +through the existing structured error output, without partial stdout. + +`workstream me projects` still lists exact-grant projects and is not global +discovery. `project show` and `project access` remain exact-project reads. +Catalogue visibility is an observation, not permission for a later operation. + ## Read and export contribution evidence ```sh diff --git a/cli/internal/api/project_catalogue.go b/cli/internal/api/project_catalogue.go new file mode 100644 index 000000000..4c781f77c --- /dev/null +++ b/cli/internal/api/project_catalogue.go @@ -0,0 +1,48 @@ +package api + +import ( + "context" + "encoding/json" +) + +// ProjectCatalogueEntry is discovery metadata, not project membership or authority. +type ProjectCatalogueEntry struct { + ProjectID string `json:"project_id"` + Name string `json:"name"` + Status string `json:"status"` +} + +type ProjectCataloguePage struct { + Items []ProjectCatalogueEntry `json:"items"` + NextCursor *string `json:"next_cursor"` +} + +// ProjectCatalogue reads one page under current backend system authority. +func (c *Client) ProjectCatalogue(ctx context.Context, limit int, cursor *string) (Result[ProjectCataloguePage], error) { + var result Result[ProjectCataloguePage] + query, err := taskPageQuery(limit, cursor) + if err != nil { + return result, err + } + raw, fields, items, err := c.readPage(ctx, "/api/v1/projects", query, limit) + if err != nil { + return result, err + } + page := ProjectCataloguePage{Items: make([]ProjectCatalogueEntry, 0, len(items))} + if json.Unmarshal(fields["next_cursor"], &page.NextCursor) != nil { + return result, &Failure{Code: "invalid_api_response"} + } + seen := make(map[[16]byte]bool, len(items)) + for _, item := range items { + var project ProjectCatalogueEntry + _, decodeErr := contextObject(item, &project, []string{"project_id", "name", "status"}, nil) + identity, valid := uuidIdentity(project.ProjectID) + if decodeErr != nil || !valid || seen[identity] || project.Name == "" || + !oneOf(project.Status, "draft", "active", "paused", "archived") { + return result, &Failure{Code: "invalid_api_response"} + } + seen[identity] = true + page.Items = append(page.Items, project) + } + return Result[ProjectCataloguePage]{Raw: raw, Value: page}, nil +} diff --git a/cli/internal/command/command.go b/cli/internal/command/command.go index 6a41930b9..3c4295e60 100644 --- a/cli/internal/command/command.go +++ b/cli/internal/command/command.go @@ -110,6 +110,7 @@ func Run(args []string, stdout, stderr io.Writer, getenv environment, version st addAdminInspection(root, client, &output, stdout) project := &cobra.Command{Use: "project", Short: "Public project operations"} + addProjectCatalogue(project, client, &output, stdout) addTaskReads(project, client, &output, stdout) addTaskBatch(project, client, &output, stdout) addProjectCreate(project, client, &output, stdout) diff --git a/cli/internal/command/project_catalogue.go b/cli/internal/command/project_catalogue.go new file mode 100644 index 000000000..d863948bc --- /dev/null +++ b/cli/internal/command/project_catalogue.go @@ -0,0 +1,37 @@ +package command + +import ( + "fmt" + "io" + + "github.com/Flow-Research/workstream/cli/internal/api" + "github.com/spf13/cobra" +) + +func addProjectCatalogue(project *cobra.Command, client func() (*api.Client, error), output *string, stdout io.Writer) { + var page pageFlags + cmd := &cobra.Command{ + Use: "list", Short: "List one minimal project catalogue page under system authority", Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + apiClient, err := client() + if err != nil { + return err + } + query := page.query(cmd) + result, err := apiClient.ProjectCatalogue(cmd.Context(), query.Limit, query.Cursor) + if err != nil || *output == "json" { + return jsonOrError(stdout, result.Raw, err) + } + for _, item := range result.Value.Items { + if _, err := fmt.Fprintf(stdout, "Project: %s\nName: %s\nStatus: %s\n", + safeText(item.ProjectID), safeText(item.Name), safeText(item.Status)); err != nil { + return err + } + } + _, err = fmt.Fprintf(stdout, "Next cursor: %s\n", optional(result.Value.NextCursor)) + return err + }, + } + page.bind(cmd, 50) + project.AddCommand(cmd) +} diff --git a/cli/tests/integration/test_admin_inspection_public_api.py b/cli/tests/integration/test_admin_inspection_public_api.py index 17ad2c0d3..321661f1c 100644 --- a/cli/tests/integration/test_admin_inspection_public_api.py +++ b/cli/tests/integration/test_admin_inspection_public_api.py @@ -141,6 +141,36 @@ async def issue( system_grant = await issue("auditor", "audit_authority") project = str(await create_project("CLI inspection scope")) foreign = str(await create_project("CLI inspection foreign")) + catalogue = cli( + origin, + tokens["auditor"], + "project", + "list", + "--limit", + "1", + "-o", + "json", + ) + assert catalogue.returncode == 0 and catalogue.stderr == "", ( + catalogue.stderr + ) + catalogue_page = json.loads(catalogue.stdout) + direct_catalogue = await direct.get( + "/api/v1/projects", + headers=headers("auditor"), + params={"limit": 1}, + ) + assert direct_catalogue.status_code == 200, direct_catalogue.text + assert catalogue_page == direct_catalogue.json() + assert len(catalogue_page["items"]) == 1 + assert catalogue_page["items"][0] == { + "project_id": min(project, foreign), + "name": "CLI inspection scope" + if project < foreign + else "CLI inspection foreign", + "status": "draft", + } + assert catalogue_page["next_cursor"] scoped_grant = await issue("scoped", "audit_authority", project) target_grant = await issue("target", "project_manager", project) target = profiles["target"] diff --git a/cli/tests/integration/test_project_catalogue_http.py b/cli/tests/integration/test_project_catalogue_http.py new file mode 100644 index 000000000..e65e0ae02 --- /dev/null +++ b/cli/tests/integration/test_project_catalogue_http.py @@ -0,0 +1,111 @@ +"""Built-binary proof of one-page, minimal system project catalogue reads.""" + +import json +from urllib.parse import parse_qs, urlsplit + +from test_contributor_task_mutations import KEY, canonical_error +from test_http_boundary import PROJECT, TOKEN, assert_failure, http_fixture + + +def invoke(cli, origin, *flags, token=TOKEN, output="json"): + return cli(origin, token, "-o", output, "project", "list", *flags) + + +def page(items, cursor=None): + return {"items": items, "next_cursor": cursor} + + +def test_catalogue_reads_one_public_page_with_only_minimal_rows(cli): + first = {"project_id": PROJECT, "name": "Project é\n\x1b[31m", "status": "draft"} + second = {"project_id": KEY, "name": "Second", "status": "archived"} + cursor = "opaque+/=é&next=not-another-parameter" + with http_fixture() as (origin, response, requests): + value = page([first, second], cursor) + response["body"] = json.dumps(value, ensure_ascii=False).encode() + result = invoke(cli, origin) + assert result.returncode == 0 and result.stderr == "", result.stderr + assert json.loads(result.stdout) == value + assert requests == [("GET", "/api/v1/projects?limit=50", "Bearer " + TOKEN)] + + response["body"] = json.dumps(page([second])).encode() + result = invoke(cli, origin, "--limit", "1", "--cursor", cursor) + assert result.returncode == 0 and result.stderr == "", result.stderr + assert json.loads(result.stdout) == page([second]) + assert len(requests) == 2 # No implicit traversal. + request = urlsplit(requests[-1][1]) + assert request.path == "/api/v1/projects" + assert parse_qs(request.query) == {"limit": ["1"], "cursor": [cursor]} + + response["body"] = json.dumps(value).encode() + result = invoke(cli, origin, output="text") + assert result.returncode == 0 and result.stderr == "", result.stderr + assert result.stdout == ( + f"Project: {PROJECT}\nName: Project é\\u000A\\u001B[31m\nStatus: draft\n" + f"Project: {KEY}\nName: Second\nStatus: archived\n" + f"Next cursor: {cursor}\n" + ) + assert "\x1b" not in result.stdout + + +def test_catalogue_rejects_invalid_page_flags_before_http(cli): + with http_fixture() as (origin, _response, requests): + for flags in ( + ("--limit", "0"), + ("--limit", "101"), + ("--limit", "not-a-number"), + ("--cursor", ""), + ("--cursor", "é" * 513), + ("--cursor", b"\xff"), + ("--project-id", PROJECT), + ("unexpected-positional",), + ): + assert_failure(invoke(cli, origin, *flags), "invalid_arguments", 2) + assert requests == [] + + +def test_catalogue_rejects_malformed_success_without_partial_stdout(cli): + row = {"project_id": PROJECT, "name": "Valid", "status": "active"} + with http_fixture() as (origin, response, _requests): + malformed = ( + None, + [], + {"items": [row]}, + {"items": None, "next_cursor": None}, + page([row | {"active_guide": "private"}]), + page([{key: value for key, value in row.items() if key != "name"}]), + page([row | {"project_id": None}]), + page([row | {"project_id": "bad"}]), + page([row | {"name": ""}]), + page([row | {"status": "unknown"}]), + page([row, row | {"project_id": PROJECT.replace("-", "")}]), + page([], "next"), + page([row], ""), + page([row], "x" * 513), + page([row]) | {"total": 1}, + page([row | {"name": TOKEN}]), + ) + for value in malformed: + response["body"] = json.dumps(value).encode() + assert_failure(invoke(cli, origin), "invalid_api_response") + response["body"] = json.dumps(page([row, row | {"project_id": KEY}])).encode() + assert_failure(invoke(cli, origin, "--limit", "1"), "invalid_api_response") + response["body"] = b'{"items":[],"next_cursor":null,"items":[]}' + assert_failure(invoke(cli, origin), "invalid_api_response") + + +def test_catalogue_preserves_backend_denial_and_cursor_error(cli): + with http_fixture() as (origin, response, requests): + response.update( + status=403, + body=json.dumps(canonical_error("project_catalogue_forbidden")).encode(), + ) + result = invoke(cli, origin) + assert_failure(result, "project_catalogue_forbidden") + assert len(requests) == 1 + + response.update( + status=400, body=json.dumps(canonical_error("invalid_cursor")).encode() + ) + result = invoke(cli, origin, "--cursor", "tampered") + assert_failure(result, "invalid_cursor") + assert len(requests) == 2 diff --git a/docs/operations_roles_permissions.md b/docs/operations_roles_permissions.md index 8d3c37a20..d483d58db 100644 --- a/docs/operations_roles_permissions.md +++ b/docs/operations_roles_permissions.md @@ -81,6 +81,7 @@ or the exact project; own means record-level ownership still applies. | Actor/link administration | system | no | no | no | minimal read covered | no | no | | Administrative grants | system | no | no | no | history read covered | no | no | | Project create | no | no | system only | no | no | no | no | +| Project catalogue (ID/name/status only) | no | system only | system only | system only | system only | no | no | | Project identity read | no | system, full identity | covered, full identity | covered, full identity | covered, full identity | exact project minimal | exact project minimal | | Project, guide, submission/checker, review, and revision configuration | no | recovery-only where registered | covered | no | no | no | no | | Contribution policy and compensation-adapter binding | no | no | no | covered | no | no | no | @@ -109,6 +110,16 @@ Authority, or Audit Authority grant. An exact-project Submitter or Reviewer sees only id, name, and status. Access Administrator alone is concealed because that role has no `project.read` permission. +`GET /api/v1/projects` is a separate system project catalogue. Only a current +system-scoped Project Manager, Operator, Finance Authority or Audit Authority +grant permits the bounded ID/name/status page. Access Administrator alone, +exact-project-only grants and contributor grants do not enumerate. The +catalogue reveals no guide, role, task, payment or other project-management +facts; in particular it does not give Finance Authority guide access. +`GET /api/v1/actors/me/projects` remains exact-grant membership, not this +system catalogue. Catalogue ID order is stable pagination, not creation or +business order. + `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 diff --git a/docs/roadmap_status.md b/docs/roadmap_status.md index 4c9113575..c025f1f32 100644 --- a/docs/roadmap_status.md +++ b/docs/roadmap_status.md @@ -212,7 +212,11 @@ authority. ARCH-03C5 adds exact-authorized Contributor/Manager detail and requirements. ARCH-03C6 adds separate exact-authorized locked-context reads. ARCH-03C7 adds bounded public Audit Authority history access. WS-CLI-001-34 adds explicit CLI consumption of -those two audit reads without changing their authority. PILOT-02A provides project-bound canonical JSON +those two audit reads without changing their authority. WS-CLI-001-46 adds +`GET /api/v1/projects` and `workstream project list` for a minimal ID/name/status +page under current system Project Manager, Operator, Finance or Audit project-read +authority. It leaves exact-grant contributor discovery unchanged and supplies +no TUI runtime. PILOT-02A provides project-bound canonical JSON source declaration, upload, status and verified download using ART custody and current PM authority, with public-route PostgreSQL/MinIO custody, replay, rollback and recovery proof. PILOT-02B adds public atomic JSON-to-DRAFT import @@ -388,9 +392,10 @@ acknowledge eligible warnings under fresh Project Manager authority and caller- owned replay keys. They do not produce an authoritative setup result, approve a policy or activate a guide; the separate CLI manual artifact-policy writes only append retained drafts after authoritative setup and never authorize activation. -No public project-list -route is invented; `GET /actors/me/projects` lists only the caller's own -exact-grant projects. +The separate public `GET /api/v1/projects` and `workstream project list` return +only bounded ID/name/status pages for the four current system project-reader +roles. `GET /api/v1/actors/me/projects` still lists only the caller's exact-grant +projects; neither route discloses guide content by catalogue authority alone. The CLI also exposes the eight existing administrative actor, identity-link, fixed-service provision and grant POSTs as deliberate keyed commands under the single `admin` root. Backend authorization and lifecycle guards remain decisive; @@ -514,6 +519,7 @@ cannot be reused as post-submission review-gate evidence. See the | --- | --- | --- | --- | | Identity and actor resolution | **Live foundation** | Flow-token verification; canonical ActorProfile and ActorIdentityLink; human/service separation; lifecycle controls; canonical `/actors/me` self-read with duplicate `/auth/me` removed; suspension denies both self-read and self-update; CLI privileged exact-actor/profile and identity-link reads and explicit separate profile/link lifecycle writes remain distinct from self-service and backend guards | Final end-to-end operational and conformance proof | | Authorization kernel | **Live foundation** | Closed action/permission catalogues; deny-by-default evaluation; grants; exact-project, privacy-safe candidate read now consumed by the CLI; fixed services; rate controls; opaque transaction-bound PREP; atomic decision evidence; CLI scope-filtered administrative grant/catalogue reads and explicit keyed grant issue/revoke consume existing public APIs without local authority decisions | Activate only the remaining owner-proven TASK, checker, REV, and CON boundaries; remove obsolete authority after replacement paths are live | +| System project catalogue | **Bounded public API and CLI read** | WS-CLI-001-46 adds `GET /api/v1/projects` and `workstream project list` for current system Project Manager, Operator, Finance or Audit `project.read` grants; each signed page exposes only ID, name and status. `me projects` retains exact-grant membership semantics. | TUI runtime, deployed CLI and hosted conformance remain separate | | Project Guide source custody | **Live foundation** | Guide creation declares documents and task examples; public document upload; immutable internal metadata snapshots; draft guide and review/revision policy writes with fresh-authorized exact replay of original receipts; exact run-scoped setup reads and assigned-task locked-original reads; S3-backed originals and isolated agent document inspection; exact document generation through separate manager approvals and public exact-authorized CP07 activation; Project Manager guide list, guide detail, activation readiness dry run and verified original read-back (PILOT-READ-3); the CLI exposes existing administrative active-chain and retained sufficiency/artifact-policy reads, keyed human sufficiency writes, and separate manual artifact-policy draft create/update with backend-owned policy decisions | Public original upload, task examples, Celery setup and separate approvals are connected locally with recorded inference; prove configured live inference and readers in the intended environment; manual drafts are not compiled activation proposals; deployed CLI remains separate | | Unified Project Guide compilation | **Live setup and manager proposal operations** | Committed original-document readiness dispatches one immutable attempt through Celery; complete result and crash/recovery custody; distinct pre/post proposals; typed per-requirement classification and checker specifications; deterministic sufficiency and submission-artifact-policy projections; immutable authorized setup finalization; public exact manager review, pre-submit approval and manual correction dispatch; automatic deterministic post-policy derivation, public complete policy read, separate approval and shared correction custody. Neutral read/search tools support statically registered OpenAI Agents SDK and OmniCoreAgent adapters in mutually exclusive inference images, while provider-free `manager_authored` compiles a manager-uploaded rules document with no inference or provider resources ([runtime initiative](../.commitrail/initiatives/WS-AGENT-001/OVERVIEW.md), [manager-authored record](../.commitrail/changes/manager-authored-guide-runtime.md)). Capability gaps preserve a warning-ready review package but block activation; retained pre-specification results keep exact hashes and reads. Exact Omni SDK, mutually exclusive release-image, parser-isolation and real-MinIO proofs pass. Both inference adapters pass the same original-guide public API, durable Celery, real-parser and MinIO path; the Omni run stopped at a warning-ready manager draft with no bindings, approval or activation. The Terminal Systems handoff now has a project-owned image for its two PRE rules, with local rule and networkless-container proof. | Retain the exact external provider/account and selected-image AWS evidence; implement the eight Terminal Systems POST rules and their runtime dependencies; register the required images, obtain approved bindings and qualify the broader hosted Flow contribution journey. Original-guide qualification and local checker proof are not deployed operation or full guide-to-contribution proof. | | Contribution policy administration | **Public Finance policy workflow; binding administration internal** | Internal Finance Authority adapter-binding lifecycle; public ContributionPolicy discovery/read/create/update/publish/retire with exact Finance Authority and recoverable draft selectors, and a Finance list of every version (PILOT-READ-3); immutable operation and event history; internal exact selected-version validation; CP07 binding with live exact-project manager authority of the selected published version to the active guide generation | The bounded local public drill passed; policy publication alone does not create a contribution, and hosted operation remains unproven | @@ -662,6 +668,9 @@ cannot be reused as post-submission review-gate evidence. See the [CLI project inspection](../.commitrail/initiatives/WS-CLI-001/WS-CLI-001-03.md) adds the existing public project read, preserving server-selected full/minimal fields and concealed foreign/revoked/suspended denials. + [CLI system project catalogue](../.commitrail/initiatives/WS-CLI-001/WS-CLI-001-46.md) + adds a separate bounded ID/name/status page for the four current system + project-reader roles; exact-grant contributor discovery is unchanged. [CLI administrative inspection](../.commitrail/initiatives/WS-CLI-001/WS-CLI-001-28.md) adds six explicit public privileged reads: exact actor/profile and one identity link, system/project-scoped grant pages, and permission/role @@ -860,9 +869,12 @@ cannot be reused as post-submission review-gate evidence. See the contributor-visible change summary. Live human decision composition remains later work. Built-binary HTTP integration and isolated real-API proof accompany - the package. Further public commands and the optional TUI remain planned; - binary-release automation is prepared, while protected first publication - remains outstanding. CLI reads do not complete unfinished product lifecycle work. + the package. [WS-CLI-001-46](../.commitrail/initiatives/WS-CLI-001/WS-CLI-001-46.md) + adds minimal system project-reader discovery through one public API page and + `project list`, without changing exact-grant `me projects`. Further public + commands and the optional TUI remain planned; binary-release automation is + prepared, while protected first publication remains outstanding. CLI reads + do not complete unfinished product lifecycle work. The public-client drill targets currently usable APIs only; hidden and unfinished lifecycle routes are not completion targets. Draft-guide policy probes additionally cover optional fields, conditional headers and exact @@ -1284,6 +1296,7 @@ Delivered foundations (bounded local journey proven; no deployment claim) public manager activation context + exact guide activation/binding WS-CLI-001-38 keyed human guide sufficiency diagnostic + warning acknowledgement WS-CLI-001-41 keyed manual artifact-policy draft create + hash-guarded successor + WS-CLI-001-46 current system project-reader catalogue -> CLI project list (TUI separate) WS-CLI-001-44 native CLI release proof + gated manual publisher (not yet published) task/assignment/Submission lineage + public ZIP intake/admission-backed creation ARCH-03D exact approved historical guide through durable intake handoff diff --git a/docs/spec_authorization_service.md b/docs/spec_authorization_service.md index 7c36380c5..97a7e5ab3 100644 --- a/docs/spec_authorization_service.md +++ b/docs/spec_authorization_service.md @@ -141,6 +141,22 @@ response. Each decision digest binds the caller, resource, normalized filters, limit, order and a hash of any presented cursor; signed cursors bind the same query. +WS-CLI-001-46 adds a separate `project.catalogue.list` action for +`GET /api/v1/projects`, mapped to existing `project.read`. An active human +must hold a current *system-scoped* Project Manager, Operator, Finance +Authority, or Audit Authority grant; the evaluator locks the actor and +matched grant and records that exact authority. An Access Administrator +alone, a project-scoped grant, and a contributor grant do not enumerate. +Each 1–100-row page contains only project ID, name and status, ordered by +project ID. This is stable enumeration, not creation, commit or business order; +UUIDv7 timestamps establish none of those facts. The signed cursor contains +only the already-disclosed ID as its position and binds actor, action and page +limit; +every continuation rechecks current authority before cursor decoding and +shares one response transaction with the bounded PROJECTS read. This does +not widen `actor.project.list` or disclose a guide, grant, task or Finance +binding through the catalogue. + PILOT-READ-3 activates ten project-wide reads through one closed evaluator. `project.guide.list`, `project.guide.read`, `project.guide.activation_readiness.read` and `project.guide.document.read` diff --git a/mcp_server/contracts/authorization_context_get.json b/mcp_server/contracts/authorization_context_get.json index feb640d90..38f175aac 100644 --- a/mcp_server/contracts/authorization_context_get.json +++ b/mcp_server/contracts/authorization_context_get.json @@ -1,5 +1,5 @@ { - "canonical_sha256": "5972b939c5c83b6790b6025d18e08623199cb1ad288851d62f5ba34bedae7061", + "canonical_sha256": "068aa5a75c3e505eca21e1dc08b10e4fc5e6f02e43adce7765e9a7ae692c8d36", "components": { "schemas": { "ActionId": { @@ -167,6 +167,7 @@ "contribution.read_self", "contribution.read_project", "actor.project.list", + "project.catalogue.list", "actor.assignment.list", "actor.submission.list", "actor.work_summary.read", @@ -568,7 +569,7 @@ "schema_version": 1, "source": { "action_id": "actor.authorization_context.read", - "backend_git_sha": "5f2e1c97f1de1145bb61717fbcd2b414adfb7d92", + "backend_git_sha": "6208a1f8152df8de299fa6dd1a6802d9454c9d44", "method": "GET", "openapi_version": "3.1.0", "path": "/api/v1/actors/me/authorization-context"