From 058b083290e38fb690e9f8f43f7646ca31e1e3a8 Mon Sep 17 00:00:00 2001 From: Commitrail Probe Date: Fri, 23 Oct 2026 11:14:25 +0100 Subject: [PATCH 01/10] Add system-authorized project catalogue API and CLI read Expose one bounded ID/name/status page under existing system project-reader authority, preserve exact-grant contributor membership, and prove audit, pagination, revocation and native CLI behavior. --- .commitrail/INDEX.md | 2 +- .../initiatives/WS-CLI-001/OVERVIEW.md | 11 +- .../initiatives/WS-CLI-001/WS-CLI-001-46.md | 75 +++++ .../versions/0059_project_catalogue_read.py | 39 +++ backend/app/adapters/auth/__init__.py | 6 + backend/app/api/deps/authorization.py | 1 + backend/app/api/deps/project_catalogue.py | 101 +++++++ backend/app/api/router.py | 2 + backend/app/api/routes/project_catalogue.py | 28 ++ .../authorization/api/project_catalogue.py | 40 +++ .../app/modules/authorization/catalogue.py | 6 +- .../app/modules/authorization/domain/audit.py | 2 + .../authorization/domain/project_catalogue.py | 68 +++++ .../authorization/domain/read_dispatch.py | 4 + .../authorization/project_catalogue.py | 64 +++++ backend/app/modules/authorization/runtime.py | 2 + .../modules/projects/api/project_summaries.py | 18 ++ .../app/modules/projects/project_summaries.py | 19 +- .../tests/authorization/catalogue_fixtures.py | 2 + .../setup_finalization/test_catalogue.py | 1 + backend/tests/authorization/test_catalogue.py | 4 +- backend/tests/conftest.py | 2 +- .../migrations/test_project_catalogue_read.py | 77 ++++++ .../tests/project_catalogue/test_public.py | 259 ++++++++++++++++++ backend/tests/test_api_controls.py | 5 +- cli/README.md | 28 ++ cli/internal/api/project_catalogue.go | 48 ++++ cli/internal/command/command.go | 1 + cli/internal/command/project_catalogue.go | 37 +++ .../test_project_catalogue_http.py | 111 ++++++++ docs/roadmap_status.md | 23 +- docs/spec_authorization_service.md | 13 + 32 files changed, 1085 insertions(+), 14 deletions(-) create mode 100644 .commitrail/initiatives/WS-CLI-001/WS-CLI-001-46.md create mode 100644 backend/alembic/versions/0059_project_catalogue_read.py create mode 100644 backend/app/api/deps/project_catalogue.py create mode 100644 backend/app/api/routes/project_catalogue.py create mode 100644 backend/app/modules/authorization/api/project_catalogue.py create mode 100644 backend/app/modules/authorization/domain/project_catalogue.py create mode 100644 backend/app/modules/authorization/project_catalogue.py create mode 100644 backend/tests/migrations/test_project_catalogue_read.py create mode 100644 backend/tests/project_catalogue/test_public.py create mode 100644 cli/internal/api/project_catalogue.go create mode 100644 cli/internal/command/project_catalogue.go create mode 100644 cli/tests/integration/test_project_catalogue_http.py diff --git a/.commitrail/INDEX.md b/.commitrail/INDEX.md index dd18ab6bc..62acfd484 100644 --- a/.commitrail/INDEX.md +++ b/.commitrail/INDEX.md @@ -8,7 +8,7 @@ for current product capability. |---|---|---| | [WS-DB-002](initiatives/WS-DB-002/OVERVIEW.md) | Complete | Shared UUIDv7 record generation, native-UUID relationships and fresh v0.1 baseline; natural-owner retry custody and aligned CI/local setup | | [WS-MCP-002](initiatives/WS-MCP-002/OVERVIEW.md) | Planned | Twenty-three tools through WS-MCP-002-07: self-service, administrative access, actor/identity lifecycle, project shells and participation; 4 tools remain and WS-MCP-002-08 guide documents is next | -| [WS-CLI-001](initiatives/WS-CLI-001/OVERVIEW.md) | Planned | Public Go CLI setup, contributor/manager workflows, administrative and operational commands, Finance policies and contribution exports are detailed in the overview; -43 completes the artifact Operator client and proves the connected local unpaid automated-acceptance journey. Remaining boundaries are public human-review controls, hosted proof, optional TUI and binary distribution. Current revision context never substitutes for historical evidence. | +| [WS-CLI-001](initiatives/WS-CLI-001/OVERVIEW.md) | Planned | Public Go CLI setup, contributor/manager workflows, administrative and operational commands, Finance policies and contribution exports are detailed in the overview; -43 completes the artifact Operator client and connected local unpaid journey, and -46 adds minimal system project-reader discovery through the public API and CLI. Remaining boundaries are public human-review controls, hosted proof, optional TUI and binary distribution. Current revision context never substitutes for historical evidence. | | [WS-ARCH-001](initiatives/WS-ARCH-001/OVERVIEW.md) | Planned | Public admission-backed initial/remediation Submission entry and supported false-policy guide activation are implemented over selected production delivery. New false/pass effects require LIVE REV; a known pre-effect non-live refusal has bounded retry, with exhausted delivery dead-lettered. The [bounded local public/worker drill](initiatives/WS-ARCH-001/planning/PLAN.md#first-complete-contributor-milestone) starts from public guide inputs with recorded inference; hosted operation and live human review remain. | | [WS-ART-001](initiatives/WS-ART-001/OVERVIEW.md) | Planned | Verified input/output custody, exact-cause replacement and public admission-backed ZIP/Submission entry are delivered. The bounded local public/worker drill passed; hosted operation and setup-fault handling remain separate. | | [WS-AUTH-001](initiatives/WS-AUTH-001/OVERVIEW.md) | Planned | Exact fixed-router custody, checker remediation and checker retry are delivered. The ART Operator backend prerequisite adds exact system-Operator artifact reads and independently guarded verification retry; WS-CLI-001-43 consumes all nine public ART operations, while hosted S3 proof remains separate. Public entry and supported false-guide activation use current exact AUTH/SQL custody; human decision authority remains later work. | diff --git a/.commitrail/initiatives/WS-CLI-001/OVERVIEW.md b/.commitrail/initiatives/WS-CLI-001/OVERVIEW.md index 8c2d6b92e..c449e4516 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. @@ -62,6 +64,12 @@ The backend owns manager authorization for new writes, policy semantics, replay editing/configuring a guide does not approve or activate it. Other public API client gaps, optional TUI and binary distribution remain separate 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 @@ -324,7 +332,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..720adeda2 --- /dev/null +++ b/.commitrail/initiatives/WS-CLI-001/WS-CLI-001-46.md @@ -0,0 +1,75 @@ +# 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 and current boundary + +`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 and plan + +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 `(created_at, id)` keyset page over all projects containing only ID, name and status. 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; 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, and the checked public-schema fingerprint in `backend/tests/conftest.py` after comparing the actual object change. +- 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/`. +- `cli/internal/api/` and `cli/internal/command/` project-list client/command, plus focused CLI integration tests and `cli/README.md`. +- `docs/spec_authorization_service.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 gates, or unrelated catalogue operations. No duplicate project resolver, all-project materialization, fallback authority path, compatibility alias, automatic pagination, or merge/release. + +## Acceptance and proof + +- 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 `(created_at,id)` order and a bounded response. +- 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 and fixed columns, not a full project-ID set. Demonstrate real PostgreSQL storage and actor/resource isolation, grant revocation/concurrent-session locking, no duplicate/gap pagination, and appropriate query behavior at realistic scale. A projection/response validation failure must roll back its AUTH decision evidence. Direct-SQL proof is needed only if this change adds/bypasses a database guard. +- 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, stale-wording scan, Markdown-link check, roadmap/INDEX reconciliation, and reviewer tracks on a clean candidate. + +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`. 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 backend module runs a built +CLI against a live API and PostgreSQL; the CLI suite checks process output +against hostile HTTP responses. The HMAC cursor is integrity-protected but +its Base64 payload is not confidential; protected facts do not belong in it. + +## Risk, reviewers, human focus + +The isolated PostgreSQL 16 public-API module passes as one settled run (9 +cases): four distinct system roles, exact-grant and Access Administrator +denials, signed pagination and revocation, a concurrent grant-lock race, +invalid-projection rollback, and a built CLI against a live API. The separate +shadow-schema migration regression passes including role search-path cleanup. +AUTH catalogue and API-control inventories, Go tests/build/module verification, +CLI process tests, Ruff, Markdown links and stale-wording checks pass. 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. + + +- 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`; add `product_ops` for workspace/role semantics and `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 an unmeasured large-catalogue query. Any index/migration is justified only by measured evidence. The policy decision is resolved by the user; the human merge decision remains separate. diff --git a/backend/alembic/versions/0059_project_catalogue_read.py b/backend/alembic/versions/0059_project_catalogue_read.py new file mode 100644 index 000000000..9419d8086 --- /dev/null +++ b/backend/alembic/versions/0059_project_catalogue_read.py @@ -0,0 +1,39 @@ +"""Admit exact system project catalogue decisions to the closed audit vocabulary.""" + +from alembic import op +from sqlalchemy import text + + +revision = "0059_project_catalogue_read" +down_revision = "0058_compensation_unit_create" +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..79a08fbc8 --- /dev/null +++ b/backend/app/api/deps/project_catalogue.py @@ -0,0 +1,101 @@ +"""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): + model_config = _FROZEN + items: tuple[ProjectCatalogueItem, ...] + next_cursor: str | None + + +def _cursor_invalid() -> StructuredHTTPException: + 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: + 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: + 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: + request = ProjectCatalogueRequest(actor_profile_id=self._actor_id, limit=limit, cursor=cursor) + + async def read(grant): + 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.created_at, 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..f117786e6 --- /dev/null +++ b/backend/app/modules/authorization/api/project_catalogue.py @@ -0,0 +1,40 @@ +"""Current system-covered authority for a bounded project identity page.""" + +from dataclasses import dataclass +from datetime import datetime +from typing import Protocol +from uuid import UUID + + +@dataclass(frozen=True, slots=True) +class ProjectCatalogueRequest: + actor_profile_id: UUID + limit: int + cursor: str | None + + def __post_init__(self) -> None: + 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: + position: tuple[datetime, UUID] | None + + +class ProjectCatalogueCursorInvalid(ValueError): + """A malformed, forged or substituted continuation.""" + + +class ProjectCatalogueUnavailable(RuntimeError): + """Required signed-cursor configuration is unavailable.""" + + +class ProjectCatalogueAuthorizationPort(Protocol): + async def authorize(self, request: ProjectCatalogueRequest) -> ProjectCatalogueGrant: ... + + def encode(self, request: ProjectCatalogueRequest, position: tuple[datetime, UUID]) -> str: ... 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/project_catalogue.py b/backend/app/modules/authorization/project_catalogue.py new file mode 100644 index 000000000..42de86d04 --- /dev/null +++ b/backend/app/modules/authorization/project_catalogue.py @@ -0,0 +1,64 @@ +"""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 AuthorizationReadCursorCodec, InvalidPaginationCursor + + +class ProjectCatalogueAuthorization: + """Use the canonical kernel for each page before decoding a continuation.""" + + def __init__(self, kernel: AuthorizationService, secret) -> None: + self._kernel, self._secret = kernel, secret + + @staticmethod + def _query(request: ProjectCatalogueRequest) -> str: + return canonical_json_hash({ + "action_id": ActionId.PROJECT_CATALOGUE_LIST.value, + "actor_profile_id": str(request.actor_profile_id), + "limit": request.limit, + "order": "created_at_id_asc", + }) + + def _codec(self) -> AuthorizationReadCursorCodec: + if self._secret is None: + raise ProjectCatalogueUnavailable("Project catalogue unavailable") + return AuthorizationReadCursorCodec(decode_pagination_cursor_hmac_secret(self._secret)) + + async def authorize(self, request: ProjectCatalogueRequest) -> ProjectCatalogueGrant: + 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: tuple) -> str: + return self._codec().encode( + query_digest=self._query(request), timestamp=position[0], resource_id=position[1], + ) 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..6f227d6cf 100644 --- a/backend/app/modules/projects/api/project_summaries.py +++ b/backend/app/modules/projects/api/project_summaries.py @@ -25,9 +25,27 @@ class ProjectSummaryFacts: active_guide: ActiveGuideIdentity | None +@dataclass(frozen=True, slots=True) +class ProjectCatalogueFacts: + """Only project identity, lifecycle status and private page position.""" + + project_id: UUID + name: str + status: ProjectStatus + created_at: datetime + + 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, ) -> 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: tuple[datetime, 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..18fdd71eb 100644 --- a/backend/app/modules/projects/project_summaries.py +++ b/backend/app/modules/projects/project_summaries.py @@ -5,7 +5,9 @@ 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,6 +17,21 @@ 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, Project.created_at) + 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() + return tuple( + ProjectCatalogueFacts( + project_id=UUID(str(row.id)), name=row.name, status=row.status, created_at=row.created_at, + ) + for row in rows[:limit] + ), len(rows) > limit + async def read_project_summaries(self, project_ids, *, after, limit): if not project_ids: return (), False 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 8d3129162..c7a7b751f 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 = "a6b6381144f041b1a1ab9aaee30731dc1a2eada85d030fd0ba8d6d6ccfdc5787" +EXPECTED_PUBLIC_SCHEMA_SHA256 = "a82b90ccfb3775e665a4a7671045c9453eb785f8ee8a3758de3ace5b65faf8c0" 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..bf3ff5944 --- /dev/null +++ b/backend/tests/migrations/test_project_catalogue_read.py @@ -0,0 +1,77 @@ +"""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.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() + + +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(), "0058_compensation_unit_create") + before_public, before_shadow = asyncio.run(_prepare_shadow(isolated_database_env)) + try: + command.upgrade(_config(), "0059_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 + 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..07174d69d --- /dev/null +++ b/backend/tests/project_catalogue/test_public.py @@ -0,0 +1,259 @@ +"""Public project catalogue proofs against current PostgreSQL authority.""" + +import asyncio +import json +import os +from pathlib import Path +import socket +import sys +from types import SimpleNamespace +from uuid import UUID + +import httpx +import pytest +from sqlalchemy import select + +from app.db import session as db_session +from app.modules.authorization.models import AdminRoleGrant +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:") + + +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" + + +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 == [row["id"] for row in projects] + 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", created_at=None, + ),), 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 == [] + + +async def test_built_cli_reads_actual_public_api_and_postgresql(task_client): + """The native command is exercised against the real ASGI/PG owner, not a mock HTTP body.""" + executable = Path(os.environ["WORKSTREAM_CLI_EXECUTABLE"]) + assert executable.is_file() + project = await _project(task_client, "catalogue-native-cli") + with socket.socket() as probe: + probe.bind(("127.0.0.1", 0)) + port = probe.getsockname()[1] + origin = f"http://127.0.0.1:{port}" + server = await asyncio.create_subprocess_exec( + sys.executable, "-m", "uvicorn", "app.main:app", "--host", "127.0.0.1", "--port", str(port), + stdout=asyncio.subprocess.DEVNULL, stderr=asyncio.subprocess.DEVNULL, + ) + try: + async with httpx.AsyncClient(base_url=origin) as client: + last_status = None + for _ in range(600): + if server.returncode is not None: + raise AssertionError(f"API process exited before readiness: {server.returncode}") + try: + response = await client.get("/api/v1/health") + last_status = response.status_code + if response.status_code == 200: + break + except httpx.ConnectError: + pass + await asyncio.sleep(0.1) + else: + raise AssertionError(f"API readiness timed out; last status={last_status}") + env = dict(os.environ, WORKSTREAM_API_URL=origin, WORKSTREAM_TOKEN="task-token") + command = await asyncio.create_subprocess_exec( + str(executable), "--output", "json", "project", "list", "--limit", "1", + stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE, env=env, + ) + stdout, stderr = await asyncio.wait_for(command.communicate(), 20) + assert command.returncode == 0, stderr.decode() + page = json.loads(stdout) + assert page["next_cursor"] is None + assert page["items"] == [{ + "project_id": project["id"], "name": project["name"], "status": "draft", + }] + assert b"task-token" not in stdout + stderr + finally: + if server.returncode is None: + server.terminate() + await asyncio.wait_for(server.wait(), 20) 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/cli/README.md b/cli/README.md index ffd93e584..ad6fe0d04 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_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/roadmap_status.md b/docs/roadmap_status.md index 8e7bfe8fa..465b7454d 100644 --- a/docs/roadmap_status.md +++ b/docs/roadmap_status.md @@ -201,7 +201,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 @@ -376,9 +380,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 /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; @@ -500,6 +505,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; provider-free `manager_authored` runtime compiles a manager-uploaded rules document with no inference or provider resources ([change 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. | One corrected real-guide handoff is qualified; its two PRE rules now have a project-owned image with local rule/container proof. Implement the eight POST rules and runtime dependencies, register the images, obtain approved bindings and qualify the full journey. Hosted operation remains separate | | 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 | @@ -646,6 +652,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 @@ -844,7 +853,10 @@ 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, optional TUI and binary distribution + 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, optional TUI and binary distribution remain planned; 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 @@ -1265,6 +1277,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) task/assignment/Submission lineage + public ZIP intake/admission-backed creation ARCH-03D exact approved historical guide through durable intake handoff ARCH-04B hidden verified Submission input diff --git a/docs/spec_authorization_service.md b/docs/spec_authorization_service.md index 7f074d8de..d8cf52082 100644 --- a/docs/spec_authorization_service.md +++ b/docs/spec_authorization_service.md @@ -141,6 +141,19 @@ 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 +`(created_at, id)`. The signed cursor 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` From d70b8e1eb025f862d4d5e2a2d0fc0451cfeda30f Mon Sep 17 00:00:00 2001 From: Commitrail Probe Date: Fri, 23 Oct 2026 11:50:40 +0100 Subject: [PATCH 02/10] Close catalogue cursor disclosure and hosted proof custody --- .../initiatives/WS-CLI-001/WS-CLI-001-46.md | 40 +-- backend/app/api/deps/project_catalogue.py | 64 ++-- .../authorization/api/project_catalogue.py | 13 +- .../app/modules/authorization/pagination.py | 77 +++++ .../authorization/project_catalogue.py | 46 +-- .../modules/projects/api/project_summaries.py | 14 +- .../app/modules/projects/project_summaries.py | 64 ++-- backend/scripts/test_lane_catalogue.py | 2 + .../migrations/test_project_catalogue_read.py | 88 +++++- .../tests/project_catalogue/test_public.py | 284 ++++++++++++------ backend/tests/test_ci_lane_catalogue.py | 9 +- .../test_project_catalogue_public_api.py | 131 ++++++++ docs/operations_roles_permissions.md | 11 + docs/spec_authorization_service.md | 5 +- 14 files changed, 652 insertions(+), 196 deletions(-) create mode 100644 cli/tests/integration/test_project_catalogue_public_api.py diff --git a/.commitrail/initiatives/WS-CLI-001/WS-CLI-001-46.md b/.commitrail/initiatives/WS-CLI-001/WS-CLI-001-46.md index 720adeda2..db8c34c3d 100644 --- a/.commitrail/initiatives/WS-CLI-001/WS-CLI-001-46.md +++ b/.commitrail/initiatives/WS-CLI-001/WS-CLI-001-46.md @@ -12,7 +12,7 @@ ## Bounded change and plan 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 `(created_at, id)` keyset page over all projects containing only ID, name and status. 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; authorize before cursor parsing on every page. New `GET /api/v1/projects` returns only that page and an opaque `next_cursor`. +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 @@ -21,42 +21,46 @@ - 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, and the checked public-schema fingerprint in `backend/tests/conftest.py` after comparing the actual object change. - 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/`. +- `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. - `cli/internal/api/` and `cli/internal/command/` project-list client/command, plus focused CLI integration tests and `cli/README.md`. -- `docs/spec_authorization_service.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. +- `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 gates, or unrelated catalogue operations. No duplicate project resolver, all-project materialization, fallback authority path, compatibility alias, automatic pagination, or merge/release. +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 and proof -- 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 `(created_at,id)` order and a bounded response. +- 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 and fixed columns, not a full project-ID set. Demonstrate real PostgreSQL storage and actor/resource isolation, grant revocation/concurrent-session locking, no duplicate/gap pagination, and appropriate query behavior at realistic scale. A projection/response validation failure must roll back its AUTH decision evidence. Direct-SQL proof is needed only if this change adds/bypasses a database guard. +- 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, stale-wording scan, Markdown-link check, roadmap/INDEX reconciliation, and reviewer tracks on a clean candidate. +- Run focused backend/CLI tests, applicable full suites and public API drill, 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. 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`. It uses separately +`cli/tests/integration/test_project_catalogue_http.py` and +`cli/tests/integration/test_project_catalogue_public_api.py`. 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 backend module runs a built -CLI against a live API and PostgreSQL; the CLI suite checks process output -against hostile HTTP responses. The HMAC cursor is integrity-protected but -its Base64 payload is not confidential; protected facts do not belong in it. +and rollback of invalid projection evidence. The CLI public-API module runs +the built binary against a live API and PostgreSQL; 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. ## Risk, reviewers, human focus -The isolated PostgreSQL 16 public-API module passes as one settled run (9 -cases): four distinct system roles, exact-grant and Access Administrator -denials, signed pagination and revocation, a concurrent grant-lock race, -invalid-projection rollback, and a built CLI against a live API. The separate -shadow-schema migration regression passes including role search-path cleanup. +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. AUTH catalogue and API-control inventories, Go tests/build/module verification, CLI process tests, Ruff, Markdown links and stale-wording checks pass. These are local source/binary proofs; hosted CI and independent review belong to the @@ -65,11 +69,11 @@ type was caught by this module and fixed without weakening error normalization. - 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`; add `product_ops` for workspace/role semantics and `reuse_dedup` if the owner extension creates duplicate selection logic. The lead runs shared checks once, then freezes a clean candidate. +- 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 an unmeasured large-catalogue query. Any index/migration is justified only by measured evidence. The policy decision is resolved by the user; the human merge decision remains separate. +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/app/api/deps/project_catalogue.py b/backend/app/api/deps/project_catalogue.py index 79a08fbc8..6a9a0dd9d 100644 --- a/backend/app/api/deps/project_catalogue.py +++ b/backend/app/api/deps/project_catalogue.py @@ -14,8 +14,10 @@ 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, + ProjectCatalogueAuthorizationPort, + ProjectCatalogueCursorInvalid, + ProjectCatalogueRequest, + ProjectCatalogueUnavailable, ) from app.modules.projects.api.project_summaries import ProjectCatalogueReadPort, ProjectStatus @@ -40,15 +42,20 @@ class ProjectCataloguePage(BaseModel): def _cursor_invalid() -> StructuredHTTPException: return StructuredHTTPException( - status_code=422, detail="Invalid project catalogue cursor", - error_code="project_catalogue_cursor_invalid", error_message="Invalid project catalogue cursor", + status_code=422, + detail="Invalid project catalogue cursor", + error_code="project_catalogue_cursor_invalid", + error_message="Invalid project catalogue cursor", ) def _unavailable() -> StructuredHTTPException: return StructuredHTTPException( - status_code=503, detail="Project catalogue unavailable", - error_code="project_catalogue_unavailable", error_message="Project catalogue unavailable", retryable=True, + status_code=503, + detail="Project catalogue unavailable", + error_code="project_catalogue_unavailable", + error_message="Project catalogue unavailable", + retryable=True, ) @@ -56,31 +63,47 @@ 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, + self, + session: AsyncSession, + actor_profile_id: UUID, + authority: ProjectCatalogueAuthorizationPort, + projects: ProjectCatalogueReadPort, ) -> None: 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: - request = ProjectCatalogueRequest(actor_profile_id=self._actor_id, limit=limit, cursor=cursor) + request = ProjectCatalogueRequest( + actor_profile_id=self._actor_id, limit=limit, cursor=cursor + ) async def read(grant): - rows, more = await self._projects.read_project_catalogue(after=grant.position, limit=limit) + 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), + 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.created_at, last.project_id)) if more and last else None + 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, + self._session, + lambda: self._authority.authorize(request), + read, + cursor_invalid=ProjectCatalogueCursorInvalid, + invalid_cursor=_cursor_invalid, + faults=(ProjectCatalogueUnavailable, SQLAlchemyError, ValueError), + unavailable=_unavailable, ) @@ -95,7 +118,10 @@ async def get_project_catalogue_reads( 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), + 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/modules/authorization/api/project_catalogue.py b/backend/app/modules/authorization/api/project_catalogue.py index f117786e6..288c1aec2 100644 --- a/backend/app/modules/authorization/api/project_catalogue.py +++ b/backend/app/modules/authorization/api/project_catalogue.py @@ -1,7 +1,6 @@ """Current system-covered authority for a bounded project identity page.""" from dataclasses import dataclass -from datetime import datetime from typing import Protocol from uuid import UUID @@ -15,15 +14,19 @@ class ProjectCatalogueRequest: def __post_init__(self) -> None: 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)) + 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: - position: tuple[datetime, UUID] | None + position: UUID | None class ProjectCatalogueCursorInvalid(ValueError): @@ -37,4 +40,4 @@ class ProjectCatalogueUnavailable(RuntimeError): class ProjectCatalogueAuthorizationPort(Protocol): async def authorize(self, request: ProjectCatalogueRequest) -> ProjectCatalogueGrant: ... - def encode(self, request: ProjectCatalogueRequest, position: tuple[datetime, UUID]) -> str: ... + def encode(self, request: ProjectCatalogueRequest, position: UUID) -> str: ... 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 index 42de86d04..c2d3553d4 100644 --- a/backend/app/modules/authorization/project_catalogue.py +++ b/backend/app/modules/authorization/project_catalogue.py @@ -5,13 +5,15 @@ 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, + 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 AuthorizationReadCursorCodec, InvalidPaginationCursor +from app.modules.authorization.pagination import AuthorizationIdCursorCodec, InvalidPaginationCursor class ProjectCatalogueAuthorization: @@ -22,17 +24,19 @@ def __init__(self, kernel: AuthorizationService, secret) -> None: @staticmethod def _query(request: ProjectCatalogueRequest) -> str: - return canonical_json_hash({ - "action_id": ActionId.PROJECT_CATALOGUE_LIST.value, - "actor_profile_id": str(request.actor_profile_id), - "limit": request.limit, - "order": "created_at_id_asc", - }) + 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) -> AuthorizationReadCursorCodec: + def _codec(self) -> AuthorizationIdCursorCodec: if self._secret is None: raise ProjectCatalogueUnavailable("Project catalogue unavailable") - return AuthorizationReadCursorCodec(decode_pagination_cursor_hmac_secret(self._secret)) + return AuthorizationIdCursorCodec(decode_pagination_cursor_hmac_secret(self._secret)) async def authorize(self, request: ProjectCatalogueRequest) -> ProjectCatalogueGrant: codec = self._codec() @@ -42,13 +46,16 @@ async def authorize(self, request: ProjectCatalogueRequest) -> ProjectCatalogueG 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 - ), - }), + 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: @@ -58,7 +65,8 @@ async def authorize(self, request: ProjectCatalogueRequest) -> ProjectCatalogueG except InvalidPaginationCursor as exc: raise ProjectCatalogueCursorInvalid("Invalid project catalogue cursor") from exc - def encode(self, request: ProjectCatalogueRequest, position: tuple) -> str: + def encode(self, request: ProjectCatalogueRequest, position) -> str: return self._codec().encode( - query_digest=self._query(request), timestamp=position[0], resource_id=position[1], + query_digest=self._query(request), + resource_id=position, ) diff --git a/backend/app/modules/projects/api/project_summaries.py b/backend/app/modules/projects/api/project_summaries.py index 6f227d6cf..29d52e142 100644 --- a/backend/app/modules/projects/api/project_summaries.py +++ b/backend/app/modules/projects/api/project_summaries.py @@ -27,19 +27,22 @@ class ProjectSummaryFacts: @dataclass(frozen=True, slots=True) class ProjectCatalogueFacts: - """Only project identity, lifecycle status and private page position.""" + """Only project identity and lifecycle status.""" project_id: UUID name: str status: ProjectStatus - created_at: datetime 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]: ... @@ -47,5 +50,8 @@ class ProjectCatalogueReadPort(Protocol): """Page all project identities after AUTH locks a system reader grant.""" async def read_project_catalogue( - self, *, after: tuple[datetime, UUID] | None, limit: int, + 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 18fdd71eb..69d866cdb 100644 --- a/backend/app/modules/projects/project_summaries.py +++ b/backend/app/modules/projects/project_summaries.py @@ -6,7 +6,9 @@ from sqlalchemy.ext.asyncio import AsyncSession from app.modules.projects.api.project_summaries import ( - ActiveGuideIdentity, ProjectCatalogueFacts, ProjectSummaryFacts, + ActiveGuideIdentity, + ProjectCatalogueFacts, + ProjectSummaryFacts, ) from app.modules.projects.models import Project, ProjectGuide @@ -19,15 +21,19 @@ def __init__(self, session: AsyncSession) -> None: 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, Project.created_at) + statement = select(Project.id, Project.name, Project.status) 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(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, created_at=row.created_at, + project_id=UUID(str(row.id)), + name=row.name, + status=row.status, ) for row in rows[:limit] ), len(rows) > limit @@ -35,23 +41,43 @@ async def read_project_catalogue(self, *, after, 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/test_lane_catalogue.py b/backend/scripts/test_lane_catalogue.py index 9e34a1307..90fc5931d 100644 --- a/backend/scripts/test_lane_catalogue.py +++ b/backend/scripts/test_lane_catalogue.py @@ -300,6 +300,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", @@ -712,6 +713,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/migrations/test_project_catalogue_read.py b/backend/tests/migrations/test_project_catalogue_read.py index bf3ff5944..f82331a7d 100644 --- a/backend/tests/migrations/test_project_catalogue_read.py +++ b/backend/tests/migrations/test_project_catalogue_read.py @@ -6,6 +6,7 @@ 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 @@ -21,17 +22,23 @@ async def _prepare_shadow(url): 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)") + 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) + "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) + "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') + f'alter role "{role}" in database "{database}" set search_path=shadow,public' + ) return before_public, before_shadow finally: await connection.close() @@ -40,10 +47,17 @@ async def _prepare_shadow(url): 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")]) + 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() @@ -53,14 +67,65 @@ async def _restore_role_search_path(url): 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( - f'alter role "{role}" in database "{database}" reset search_path') + """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, + isolated_database_env, + migration_lock, ): with migration_lock(): asyncio.run(db_session.dispose_engine()) @@ -73,5 +138,6 @@ def test_upgrade_qualifies_public_constraint_under_shadowed_search_path( 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 index 07174d69d..6eba8aba3 100644 --- a/backend/tests/project_catalogue/test_public.py +++ b/backend/tests/project_catalogue/test_public.py @@ -1,20 +1,19 @@ """Public project catalogue proofs against current PostgreSQL authority.""" import asyncio +import base64 import json -import os -from pathlib import Path -import socket -import sys from types import SimpleNamespace from uuid import UUID -import httpx 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 @@ -29,9 +28,15 @@ async def _project(client, slug): - response = await client.post(PATH, headers=auth_headers(), json={ - "name": f"Catalogue {slug}", "slug": slug, "description": "Catalogue fixture", - }) + 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() @@ -42,13 +47,21 @@ async def _admit_and_grant(client, monkeypatch, subject, role, *, scope="system" 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, + session, + await actor_id(subject), + role=role, + scope=scope, + project_id=project_id, ) -@pytest.mark.parametrize("role", ["project_manager", "operator", "finance_authority", "audit_authority"]) +@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, + task_client, + monkeypatch, + role, ): first = await _project(task_client, f"catalogue-first-{role.replace('_', '-')}") second = await _project(task_client, f"catalogue-second-{role.replace('_', '-')}") @@ -62,10 +75,14 @@ async def test_each_existing_system_project_reader_sees_only_minimal_identity( 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() + 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" @@ -73,16 +90,39 @@ async def test_each_existing_system_project_reader_sees_only_minimal_identity( 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, + 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, + 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()) @@ -93,8 +133,48 @@ async def test_exact_project_grant_and_access_administrator_alone_do_not_enumera 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, + 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()) @@ -103,33 +183,79 @@ async def test_page_cursor_is_actor_and_query_bound_and_authority_is_refreshed( 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()) + 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 == [row["id"] for row in projects] - bad_limit = await task_client.get(PATH, params={"limit": 2, "cursor": first.json()["next_cursor"]}, - headers=auth_headers()) + 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()) + 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()) + 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", - )) + 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 @@ -137,23 +263,29 @@ async def test_page_cursor_is_actor_and_query_bound_and_authority_is_refreshed( 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()) + 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, + 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", - )) + 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 @@ -177,6 +309,7 @@ async def revoke(): 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()) @@ -195,65 +328,24 @@ async def test_invalid_owner_projection_rolls_back_authorization_evidence(task_c 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", created_at=None, - ),), False + 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() + count = ( + await session.scalars( + select(AuditEvent.id).where( + AuditEvent.action_id == "project.catalogue.list", + ) + ) + ).all() assert count == [] - - -async def test_built_cli_reads_actual_public_api_and_postgresql(task_client): - """The native command is exercised against the real ASGI/PG owner, not a mock HTTP body.""" - executable = Path(os.environ["WORKSTREAM_CLI_EXECUTABLE"]) - assert executable.is_file() - project = await _project(task_client, "catalogue-native-cli") - with socket.socket() as probe: - probe.bind(("127.0.0.1", 0)) - port = probe.getsockname()[1] - origin = f"http://127.0.0.1:{port}" - server = await asyncio.create_subprocess_exec( - sys.executable, "-m", "uvicorn", "app.main:app", "--host", "127.0.0.1", "--port", str(port), - stdout=asyncio.subprocess.DEVNULL, stderr=asyncio.subprocess.DEVNULL, - ) - try: - async with httpx.AsyncClient(base_url=origin) as client: - last_status = None - for _ in range(600): - if server.returncode is not None: - raise AssertionError(f"API process exited before readiness: {server.returncode}") - try: - response = await client.get("/api/v1/health") - last_status = response.status_code - if response.status_code == 200: - break - except httpx.ConnectError: - pass - await asyncio.sleep(0.1) - else: - raise AssertionError(f"API readiness timed out; last status={last_status}") - env = dict(os.environ, WORKSTREAM_API_URL=origin, WORKSTREAM_TOKEN="task-token") - command = await asyncio.create_subprocess_exec( - str(executable), "--output", "json", "project", "list", "--limit", "1", - stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE, env=env, - ) - stdout, stderr = await asyncio.wait_for(command.communicate(), 20) - assert command.returncode == 0, stderr.decode() - page = json.loads(stdout) - assert page["next_cursor"] is None - assert page["items"] == [{ - "project_id": project["id"], "name": project["name"], "status": "draft", - }] - assert b"task-token" not in stdout + stderr - finally: - if server.returncode is None: - server.terminate() - await asyncio.wait_for(server.wait(), 20) diff --git a/backend/tests/test_ci_lane_catalogue.py b/backend/tests/test_ci_lane_catalogue.py index 4258d8538..b9e7abb5d 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: "f15a3696526f852c5a872507331d313cac65d04ac1f888a916f078512b4caf29", ), "project": ( - 210, - "9e23ebc7b1631c6e858ae7bdc0425596236cada17b71a12e35afc518da1836b2", + 211, + "7a02542e3b41dfee75e4569cc9f36d2a9da7ece7da1616517d0a8fc6ee1b2cfd", ), "task": ( 95, @@ -344,9 +345,9 @@ def test_project_lane_ownership_has_an_independent_fingerprint() -> None: ) + "\n" ) - assert len(catalogue.PROJECT_PARTITIONED_MODULES) == 210 + assert len(catalogue.PROJECT_PARTITIONED_MODULES) == 211 assert hashlib.sha256(canonical.encode()).hexdigest() == ( - "1f75e059d08244a1161dfe053003dee2e0e02268e2340943d4cf825d0b4cd0b4" + "9d52700a7b165e8f57b14e89543f85d0353263791f73ffdc5a1cdaa7be01b9db" ) diff --git a/cli/tests/integration/test_project_catalogue_public_api.py b/cli/tests/integration/test_project_catalogue_public_api.py new file mode 100644 index 000000000..91dc9451b --- /dev/null +++ b/cli/tests/integration/test_project_catalogue_public_api.py @@ -0,0 +1,131 @@ +"""Built CLI -> signed public API -> PostgreSQL project catalogue custody.""" + +from __future__ import annotations + +import json +from pathlib import Path +import subprocess +import sys +from uuid import UUID, uuid4 + +import httpx +import pytest + +from test_public_self_service import _ready + +ROOT = Path(__file__).resolve().parents[3] +BACKEND = ROOT / "backend" +sys.path.insert(0, str(BACKEND / "scripts")) +sys.path.insert(0, str(BACKEND / "tests")) +from api_contract_e2e import ( # noqa: E402 + api_environment, + assert_isolated_database_url, + find_free_port, + flow_settings, + issue_flow_token, +) +from app.db.session import dispose_engine # noqa: E402 +from scripts.bootstrap_access_administrator import _run as run_bootstrap # noqa: E402 +from tests.authorization.admin_access.support import create_project # noqa: E402 +from tests.conftest import ( # noqa: E402 + clean_postgres_database as clean_postgres_database, + postgres_database_url as postgres_database_url, +) + + +@pytest.mark.asyncio +async def test_built_cli_reads_real_system_catalogue( + tmp_path: Path, + cli, + clean_postgres_database, +) -> None: + env = api_environment() + assert env["WORKSTREAM_DATABASE_URL"] == clean_postgres_database + env["WORKSTREAM_E2E_FLOW_ISSUER"] = "https://identity.flowresearch.tech" + env["WORKSTREAM_FLOW_AUTH_ISSUER"] = env["WORKSTREAM_E2E_FLOW_ISSUER"] + assert_isolated_database_url(env["WORKSTREAM_DATABASE_URL"]) + issuer, audience, secret = flow_settings(env) + tokens = { + name: issue_flow_token( + "catalogue-" + name, + [], + issuer=issuer, + audience=audience, + secret=secret, + ) + for name in ("admin", "reader") + } + origin = f"http://127.0.0.1:{find_free_port()}" + with (tmp_path / "catalogue-api.log").open("wb") as log: + process = subprocess.Popen( # noqa: S603 - fixed local API fixture + [ + sys.executable, + "-m", + "uvicorn", + "app.main:app", + "--host", + "127.0.0.1", + "--port", + origin.rsplit(":", 1)[1], + "--log-level", + "error", + "--no-access-log", + ], + cwd=BACKEND, + env=env, + stdout=log, + stderr=subprocess.STDOUT, + ) + try: + await _ready(origin + "/api/v1/health", process) + async with httpx.AsyncClient( + base_url=origin, + trust_env=False, + timeout=20, + limits=httpx.Limits(max_keepalive_connections=0), + ) as direct: + profiles = {} + for name, token in tokens.items(): + result = cli(origin, token, "whoami", "-o", "json") + assert result.returncode == 0, result.stderr + profiles[name] = json.loads(result.stdout)["actor_profile_id"] + bootstrap_code, bootstrap = await run_bootstrap( + UUID(profiles["admin"]), + execute=True, + ) + assert bootstrap_code == 0 and bootstrap["result_code"] == "bootstrapped" + grant = await direct.post( + "/api/v1/admin-role-grants", + headers={ + "Authorization": "Bearer " + tokens["admin"], + "Idempotency-Key": str(uuid4()), + }, + json={ + "target_actor_profile_id": profiles["reader"], + "role": "audit_authority", + "scope_type": "system", + "scope_project_id": None, + "reason": "Native catalogue read proof", + }, + ) + assert grant.status_code == 201, grant.text + project_id = str(await create_project("Catalogue native API")) + result = cli( + origin, tokens["reader"], "-o", "json", "project", "list", "--limit", "1" + ) + assert result.returncode == 0 and result.stderr == "", result.stderr + assert json.loads(result.stdout) == { + "items": [ + { + "project_id": project_id, + "name": "Catalogue native API", + "status": "draft", + } + ], + "next_cursor": None, + } + finally: + if process.poll() is None: + process.terminate() + process.wait(timeout=20) + await dispose_engine() 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/spec_authorization_service.md b/docs/spec_authorization_service.md index d8cf52082..63c85da80 100644 --- a/docs/spec_authorization_service.md +++ b/docs/spec_authorization_service.md @@ -148,7 +148,10 @@ 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 -`(created_at, id)`. The signed cursor binds actor, action and page limit; +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 From f7dbf919f36018f8a8c2eacd26ea1b0cdb304863 Mon Sep 17 00:00:00 2001 From: Commitrail Probe Date: Fri, 23 Oct 2026 11:58:26 +0100 Subject: [PATCH 03/10] Reuse native CLI audit journey for catalogue proof --- .../initiatives/WS-CLI-001/WS-CLI-001-46.md | 10 +- .../test_admin_inspection_public_api.py | 57 ++++---- .../test_project_catalogue_public_api.py | 131 ------------------ 3 files changed, 35 insertions(+), 163 deletions(-) delete mode 100644 cli/tests/integration/test_project_catalogue_public_api.py diff --git a/.commitrail/initiatives/WS-CLI-001/WS-CLI-001-46.md b/.commitrail/initiatives/WS-CLI-001/WS-CLI-001-46.md index db8c34c3d..2e1e83daa 100644 --- a/.commitrail/initiatives/WS-CLI-001/WS-CLI-001-46.md +++ b/.commitrail/initiatives/WS-CLI-001/WS-CLI-001-46.md @@ -40,15 +40,17 @@ No change to contributor/exact-grant `me projects`, existing project detail auth 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 -`cli/tests/integration/test_project_catalogue_public_api.py`. It uses separately +`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 CLI public-API module runs -the built binary against a live API and PostgreSQL; the CLI HTTP suite checks +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. diff --git a/cli/tests/integration/test_admin_inspection_public_api.py b/cli/tests/integration/test_admin_inspection_public_api.py index 17ad2c0d3..833c76f97 100644 --- a/cli/tests/integration/test_admin_inspection_public_api.py +++ b/cli/tests/integration/test_admin_inspection_public_api.py @@ -89,9 +89,7 @@ async def test_admin_inspection_follows_real_authority_and_scope( timeout=20, limits=httpx.Limits(max_keepalive_connections=0), ) as direct: - openapi = (await direct.get("/openapi.json", timeout=60)).json()[ - "paths" - ] + openapi = (await direct.get("/openapi.json", timeout=60)).json()["paths"] for route in ( "/api/v1/actors/{actor_profile_id}", "/api/v1/actors/{actor_profile_id}/identity-links", @@ -111,9 +109,7 @@ async def test_admin_inspection_follows_real_authority_and_scope( bootstrap_code, bootstrap = await run_bootstrap( UUID(profiles["admin"]), execute=True ) - assert ( - bootstrap_code == 0 and bootstrap["result_code"] == "bootstrapped" - ) + assert bootstrap_code == 0 and bootstrap["result_code"] == "bootstrapped" def headers(name: str, key: bool = False) -> dict[str, str]: value = {"Authorization": "Bearer " + tokens[name]} @@ -121,9 +117,7 @@ def headers(name: str, key: bool = False) -> dict[str, str]: value["Idempotency-Key"] = str(uuid4()) return value - async def issue( - target: str, role: str, project: str | None = None - ) -> str: + async def issue(target: str, role: str, project: str | None = None) -> str: response = await direct.post( "/api/v1/admin-role-grants", headers=headers("admin", True), @@ -141,6 +135,27 @@ 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"] @@ -163,16 +178,8 @@ async def issue( expected = await direct.get(path, headers=headers("auditor")) assert expected.status_code == 200, expected.text assert value == expected.json() - assert ( - inspected(cli, origin, tokens["auditor"], "permissions")["total"] - == 82 - ) - assert ( - inspected(cli, origin, tokens["auditor"], "role-definitions")[ - "total" - ] - == 5 - ) + assert inspected(cli, origin, tokens["auditor"], "permissions")["total"] == 82 + assert inspected(cli, origin, tokens["auditor"], "role-definitions")["total"] == 5 for command, path in ( (("role-grants",), "/api/v1/admin-role-grants"), ( @@ -204,9 +211,7 @@ async def issue( "limit": 1, }, ) - assert expected.status_code == 200 and value == expected.json(), ( - expected.text - ) + assert expected.status_code == 200 and value == expected.json(), expected.text assert value["total"] >= 1 first = inspected( cli, @@ -246,9 +251,7 @@ async def issue( "cursor": cursor, }, ) - assert ( - direct_second.status_code == 200 and second == direct_second.json() - ) + assert direct_second.status_code == 200 and second == direct_second.json() assert { first["items"][0]["grant_id"], second["items"][0]["grant_id"], @@ -332,9 +335,7 @@ async def issue( ) assert suspended.status_code == 200, suspended.text assert ( - inspected( - cli, origin, tokens["auditor"], "permissions", status=403 - )["code"] + inspected(cli, origin, tokens["auditor"], "permissions", status=403)["code"] == "actor_suspended" ) finally: diff --git a/cli/tests/integration/test_project_catalogue_public_api.py b/cli/tests/integration/test_project_catalogue_public_api.py deleted file mode 100644 index 91dc9451b..000000000 --- a/cli/tests/integration/test_project_catalogue_public_api.py +++ /dev/null @@ -1,131 +0,0 @@ -"""Built CLI -> signed public API -> PostgreSQL project catalogue custody.""" - -from __future__ import annotations - -import json -from pathlib import Path -import subprocess -import sys -from uuid import UUID, uuid4 - -import httpx -import pytest - -from test_public_self_service import _ready - -ROOT = Path(__file__).resolve().parents[3] -BACKEND = ROOT / "backend" -sys.path.insert(0, str(BACKEND / "scripts")) -sys.path.insert(0, str(BACKEND / "tests")) -from api_contract_e2e import ( # noqa: E402 - api_environment, - assert_isolated_database_url, - find_free_port, - flow_settings, - issue_flow_token, -) -from app.db.session import dispose_engine # noqa: E402 -from scripts.bootstrap_access_administrator import _run as run_bootstrap # noqa: E402 -from tests.authorization.admin_access.support import create_project # noqa: E402 -from tests.conftest import ( # noqa: E402 - clean_postgres_database as clean_postgres_database, - postgres_database_url as postgres_database_url, -) - - -@pytest.mark.asyncio -async def test_built_cli_reads_real_system_catalogue( - tmp_path: Path, - cli, - clean_postgres_database, -) -> None: - env = api_environment() - assert env["WORKSTREAM_DATABASE_URL"] == clean_postgres_database - env["WORKSTREAM_E2E_FLOW_ISSUER"] = "https://identity.flowresearch.tech" - env["WORKSTREAM_FLOW_AUTH_ISSUER"] = env["WORKSTREAM_E2E_FLOW_ISSUER"] - assert_isolated_database_url(env["WORKSTREAM_DATABASE_URL"]) - issuer, audience, secret = flow_settings(env) - tokens = { - name: issue_flow_token( - "catalogue-" + name, - [], - issuer=issuer, - audience=audience, - secret=secret, - ) - for name in ("admin", "reader") - } - origin = f"http://127.0.0.1:{find_free_port()}" - with (tmp_path / "catalogue-api.log").open("wb") as log: - process = subprocess.Popen( # noqa: S603 - fixed local API fixture - [ - sys.executable, - "-m", - "uvicorn", - "app.main:app", - "--host", - "127.0.0.1", - "--port", - origin.rsplit(":", 1)[1], - "--log-level", - "error", - "--no-access-log", - ], - cwd=BACKEND, - env=env, - stdout=log, - stderr=subprocess.STDOUT, - ) - try: - await _ready(origin + "/api/v1/health", process) - async with httpx.AsyncClient( - base_url=origin, - trust_env=False, - timeout=20, - limits=httpx.Limits(max_keepalive_connections=0), - ) as direct: - profiles = {} - for name, token in tokens.items(): - result = cli(origin, token, "whoami", "-o", "json") - assert result.returncode == 0, result.stderr - profiles[name] = json.loads(result.stdout)["actor_profile_id"] - bootstrap_code, bootstrap = await run_bootstrap( - UUID(profiles["admin"]), - execute=True, - ) - assert bootstrap_code == 0 and bootstrap["result_code"] == "bootstrapped" - grant = await direct.post( - "/api/v1/admin-role-grants", - headers={ - "Authorization": "Bearer " + tokens["admin"], - "Idempotency-Key": str(uuid4()), - }, - json={ - "target_actor_profile_id": profiles["reader"], - "role": "audit_authority", - "scope_type": "system", - "scope_project_id": None, - "reason": "Native catalogue read proof", - }, - ) - assert grant.status_code == 201, grant.text - project_id = str(await create_project("Catalogue native API")) - result = cli( - origin, tokens["reader"], "-o", "json", "project", "list", "--limit", "1" - ) - assert result.returncode == 0 and result.stderr == "", result.stderr - assert json.loads(result.stdout) == { - "items": [ - { - "project_id": project_id, - "name": "Catalogue native API", - "status": "draft", - } - ], - "next_cursor": None, - } - finally: - if process.poll() is None: - process.terminate() - process.wait(timeout=20) - await dispose_engine() From 6208a1f8152df8de299fa6dd1a6802d9454c9d44 Mon Sep 17 00:00:00 2001 From: Commitrail Probe Date: Fri, 23 Oct 2026 12:42:13 +0100 Subject: [PATCH 04/10] Align catalogue record and CLI proof with required checks --- .../initiatives/WS-CLI-001/WS-CLI-001-46.md | 11 +++-- .../test_admin_inspection_public_api.py | 49 +++++++++++++++---- 2 files changed, 45 insertions(+), 15 deletions(-) diff --git a/.commitrail/initiatives/WS-CLI-001/WS-CLI-001-46.md b/.commitrail/initiatives/WS-CLI-001/WS-CLI-001-46.md index 36cacd69a..790786ad7 100644 --- a/.commitrail/initiatives/WS-CLI-001/WS-CLI-001-46.md +++ b/.commitrail/initiatives/WS-CLI-001/WS-CLI-001-46.md @@ -5,11 +5,11 @@ - 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 and current boundary +## 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 and plan +## 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`. @@ -29,7 +29,7 @@ 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 and proof +## 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. @@ -38,6 +38,8 @@ No change to contributor/exact-grant `me projects`, existing project detail auth - 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, 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. +## 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 @@ -55,8 +57,6 @@ 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. -## Risk, reviewers, human focus - 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 @@ -69,6 +69,7 @@ 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. +## 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. diff --git a/cli/tests/integration/test_admin_inspection_public_api.py b/cli/tests/integration/test_admin_inspection_public_api.py index 833c76f97..321661f1c 100644 --- a/cli/tests/integration/test_admin_inspection_public_api.py +++ b/cli/tests/integration/test_admin_inspection_public_api.py @@ -89,7 +89,9 @@ async def test_admin_inspection_follows_real_authority_and_scope( timeout=20, limits=httpx.Limits(max_keepalive_connections=0), ) as direct: - openapi = (await direct.get("/openapi.json", timeout=60)).json()["paths"] + openapi = (await direct.get("/openapi.json", timeout=60)).json()[ + "paths" + ] for route in ( "/api/v1/actors/{actor_profile_id}", "/api/v1/actors/{actor_profile_id}/identity-links", @@ -109,7 +111,9 @@ async def test_admin_inspection_follows_real_authority_and_scope( bootstrap_code, bootstrap = await run_bootstrap( UUID(profiles["admin"]), execute=True ) - assert bootstrap_code == 0 and bootstrap["result_code"] == "bootstrapped" + assert ( + bootstrap_code == 0 and bootstrap["result_code"] == "bootstrapped" + ) def headers(name: str, key: bool = False) -> dict[str, str]: value = {"Authorization": "Bearer " + tokens[name]} @@ -117,7 +121,9 @@ def headers(name: str, key: bool = False) -> dict[str, str]: value["Idempotency-Key"] = str(uuid4()) return value - async def issue(target: str, role: str, project: str | None = None) -> str: + async def issue( + target: str, role: str, project: str | None = None + ) -> str: response = await direct.post( "/api/v1/admin-role-grants", headers=headers("admin", True), @@ -136,9 +142,18 @@ async def issue(target: str, role: str, project: str | None = None) -> str: 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" + origin, + tokens["auditor"], + "project", + "list", + "--limit", + "1", + "-o", + "json", + ) + assert catalogue.returncode == 0 and catalogue.stderr == "", ( + catalogue.stderr ) - assert catalogue.returncode == 0 and catalogue.stderr == "", catalogue.stderr catalogue_page = json.loads(catalogue.stdout) direct_catalogue = await direct.get( "/api/v1/projects", @@ -178,8 +193,16 @@ async def issue(target: str, role: str, project: str | None = None) -> str: expected = await direct.get(path, headers=headers("auditor")) assert expected.status_code == 200, expected.text assert value == expected.json() - assert inspected(cli, origin, tokens["auditor"], "permissions")["total"] == 82 - assert inspected(cli, origin, tokens["auditor"], "role-definitions")["total"] == 5 + assert ( + inspected(cli, origin, tokens["auditor"], "permissions")["total"] + == 82 + ) + assert ( + inspected(cli, origin, tokens["auditor"], "role-definitions")[ + "total" + ] + == 5 + ) for command, path in ( (("role-grants",), "/api/v1/admin-role-grants"), ( @@ -211,7 +234,9 @@ async def issue(target: str, role: str, project: str | None = None) -> str: "limit": 1, }, ) - assert expected.status_code == 200 and value == expected.json(), expected.text + assert expected.status_code == 200 and value == expected.json(), ( + expected.text + ) assert value["total"] >= 1 first = inspected( cli, @@ -251,7 +276,9 @@ async def issue(target: str, role: str, project: str | None = None) -> str: "cursor": cursor, }, ) - assert direct_second.status_code == 200 and second == direct_second.json() + assert ( + direct_second.status_code == 200 and second == direct_second.json() + ) assert { first["items"][0]["grant_id"], second["items"][0]["grant_id"], @@ -335,7 +362,9 @@ async def issue(target: str, role: str, project: str | None = None) -> str: ) assert suspended.status_code == 200, suspended.text assert ( - inspected(cli, origin, tokens["auditor"], "permissions", status=403)["code"] + inspected( + cli, origin, tokens["auditor"], "permissions", status=403 + )["code"] == "actor_suspended" ) finally: From 8421c6d1558fc12d48d4ca574b60bd547aee1ae6 Mon Sep 17 00:00:00 2001 From: Commitrail Probe Date: Fri, 23 Oct 2026 12:57:43 +0100 Subject: [PATCH 05/10] Reconcile MCP authorization context contract for project catalogue --- .commitrail/initiatives/WS-CLI-001/WS-CLI-001-46.md | 9 +++++++-- mcp_server/contracts/authorization_context_get.json | 5 +++-- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/.commitrail/initiatives/WS-CLI-001/WS-CLI-001-46.md b/.commitrail/initiatives/WS-CLI-001/WS-CLI-001-46.md index 790786ad7..77fbcbbed 100644 --- a/.commitrail/initiatives/WS-CLI-001/WS-CLI-001-46.md +++ b/.commitrail/initiatives/WS-CLI-001/WS-CLI-001-46.md @@ -23,6 +23,7 @@ - One public route/composition under `backend/app/api/`, router wiring and API-control tests under `backend/tests/`. - `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. - `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. No MCP operation, tool or runtime behavior changes. - `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 @@ -36,7 +37,7 @@ No change to contributor/exact-grant `me projects`, existing project detail auth - 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, 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. +- 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. ## Evidence @@ -64,7 +65,11 @@ 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. AUTH catalogue and API-control inventories, Go tests/build/module verification, -CLI process tests, Ruff, Markdown links and stale-wording checks pass. These +CLI process tests, Ruff, Markdown links and stale-wording checks pass. 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. No MCP tool or runtime 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. 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" From afa59b4bd69579771d27cc6f1ddf15181e1d118d Mon Sep 17 00:00:00 2001 From: Commitrail Probe Date: Fri, 23 Oct 2026 13:04:55 +0100 Subject: [PATCH 06/10] Clarify MCP response-validator scope in catalogue record --- .commitrail/initiatives/WS-CLI-001/WS-CLI-001-46.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/.commitrail/initiatives/WS-CLI-001/WS-CLI-001-46.md b/.commitrail/initiatives/WS-CLI-001/WS-CLI-001-46.md index 77fbcbbed..cd613feff 100644 --- a/.commitrail/initiatives/WS-CLI-001/WS-CLI-001-46.md +++ b/.commitrail/initiatives/WS-CLI-001/WS-CLI-001-46.md @@ -23,7 +23,7 @@ - One public route/composition under `backend/app/api/`, router wiring and API-control tests under `backend/tests/`. - `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. - `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. No MCP operation, tool or runtime behavior changes. +- `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 @@ -69,7 +69,8 @@ CLI process tests, Ruff, Markdown links and stale-wording checks pass. 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. No MCP tool or runtime operation was added. These +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. From 4683decf345f0cccf8d6f7f819523514e4f0e8a3 Mon Sep 17 00:00:00 2001 From: Commitrail Probe Date: Fri, 23 Oct 2026 13:19:46 +0100 Subject: [PATCH 07/10] Register project catalogue modules in behavior ownership partition --- .ci/behavior-ownership/partition.v1.json | 22 ++++++++++++++++++- .../initiatives/WS-CLI-001/WS-CLI-001-46.md | 9 ++++++++ backend/scripts/behavior_ownership.py | 8 +++++++ backend/tests/test_behavior_ownership.py | 21 ++++++++++++++++++ 4 files changed, 59 insertions(+), 1 deletion(-) diff --git a/.ci/behavior-ownership/partition.v1.json b/.ci/behavior-ownership/partition.v1.json index 11e306f08..55377451c 100644 --- a/.ci/behavior-ownership/partition.v1.json +++ b/.ci/behavior-ownership/partition.v1.json @@ -188,6 +188,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" @@ -260,6 +264,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" @@ -676,6 +684,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" @@ -864,6 +876,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" @@ -1028,6 +1044,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" @@ -2337,7 +2357,7 @@ "target": "backend/scripts/validate_test_lane_evidence.py" } ], - "authority_digest": "1f3e5fec236b7957a43e0323728a640b05059e7f42e9a1ec179bce7649487559", + "authority_digest": "fe5a6909f59a60d0d75f1159c6d0eecde9737a748895901e3f0edb5bc8d8a71a", "protected_base_commit": "7676ce4347db0c9694962a9b587a20765e16eac6", "schema": "workstream.behavior-ownership-partition.v1" } diff --git a/.commitrail/initiatives/WS-CLI-001/WS-CLI-001-46.md b/.commitrail/initiatives/WS-CLI-001/WS-CLI-001-46.md index cd613feff..7dfc054c2 100644 --- a/.commitrail/initiatives/WS-CLI-001/WS-CLI-001-46.md +++ b/.commitrail/initiatives/WS-CLI-001/WS-CLI-001-46.md @@ -22,6 +22,7 @@ - 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/`. - `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. @@ -38,6 +39,7 @@ No change to contributor/exact-grant `me projects`, existing project detail auth - 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 @@ -75,6 +77,13 @@ 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. diff --git a/backend/scripts/behavior_ownership.py b/backend/scripts/behavior_ownership.py index ba3c116b4..617166532 100644 --- a/backend/scripts/behavior_ownership.py +++ b/backend/scripts/behavior_ownership.py @@ -860,6 +860,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): @@ -1128,6 +1135,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/tests/test_behavior_ownership.py b/backend/tests/test_behavior_ownership.py index 94458aa8d..ef9b1df8b 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({ From a5f2d15c54fde1dd632801ad329ec7592202fe3f Mon Sep 17 00:00:00 2001 From: Commitrail Probe Date: Fri, 23 Oct 2026 15:10:41 +0100 Subject: [PATCH 08/10] Reconcile catalogue rate inventory and document authorization ports --- .../initiatives/WS-CLI-001/WS-CLI-001-46.md | 7 +++++-- backend/app/api/deps/project_catalogue.py | 7 +++++++ .../authorization/api/project_catalogue.py | 15 +++++++++++++-- .../modules/authorization/project_catalogue.py | 5 +++++ backend/tests/test_api_rate_controls.py | 1 + 5 files changed, 31 insertions(+), 4 deletions(-) diff --git a/.commitrail/initiatives/WS-CLI-001/WS-CLI-001-46.md b/.commitrail/initiatives/WS-CLI-001/WS-CLI-001-46.md index 7dfc054c2..9c40a61ca 100644 --- a/.commitrail/initiatives/WS-CLI-001/WS-CLI-001-46.md +++ b/.commitrail/initiatives/WS-CLI-001/WS-CLI-001-46.md @@ -20,7 +20,7 @@ - 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 to current main's `0059_neutral_guide_runtime` as predecessor; no parallel head or replacement of its retained facts. - 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/`. +- 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`. @@ -68,7 +68,10 @@ rollback. The separate shadow-schema migration regression covers crossed-pair direct SQL and role search-path cleanup. AUTH catalogue and API-control inventories, Go tests/build/module verification, CLI process tests, Ruff, Markdown links and stale-wording checks pass. The -existing MCP authorization-context selected fragment was reconciled only for +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; diff --git a/backend/app/api/deps/project_catalogue.py b/backend/app/api/deps/project_catalogue.py index 6a9a0dd9d..e673b3350 100644 --- a/backend/app/api/deps/project_catalogue.py +++ b/backend/app/api/deps/project_catalogue.py @@ -35,12 +35,15 @@ class ProjectCatalogueItem(BaseModel): 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", @@ -50,6 +53,7 @@ def _cursor_invalid() -> StructuredHTTPException: def _unavailable() -> StructuredHTTPException: + """Normalize infrastructure and projection failures without private facts.""" return StructuredHTTPException( status_code=503, detail="Project catalogue unavailable", @@ -69,15 +73,18 @@ def __init__( 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 ) diff --git a/backend/app/modules/authorization/api/project_catalogue.py b/backend/app/modules/authorization/api/project_catalogue.py index 288c1aec2..b238205c4 100644 --- a/backend/app/modules/authorization/api/project_catalogue.py +++ b/backend/app/modules/authorization/api/project_catalogue.py @@ -7,11 +7,14 @@ @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) @@ -26,6 +29,8 @@ def __post_init__(self) -> None: @dataclass(frozen=True, slots=True) class ProjectCatalogueGrant: + """An authorized page boundary, not reusable authority for another read.""" + position: UUID | None @@ -38,6 +43,12 @@ class ProjectCatalogueUnavailable(RuntimeError): class ProjectCatalogueAuthorizationPort(Protocol): - async def authorize(self, request: ProjectCatalogueRequest) -> ProjectCatalogueGrant: ... + """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: ... + 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/project_catalogue.py b/backend/app/modules/authorization/project_catalogue.py index c2d3553d4..391b778ca 100644 --- a/backend/app/modules/authorization/project_catalogue.py +++ b/backend/app/modules/authorization/project_catalogue.py @@ -20,10 +20,12 @@ 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, @@ -34,11 +36,13 @@ def _query(request: ProjectCatalogueRequest) -> str: ) 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( @@ -66,6 +70,7 @@ async def authorize(self, request: ProjectCatalogueRequest) -> ProjectCatalogueG 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/tests/test_api_rate_controls.py b/backend/tests/test_api_rate_controls.py index f5d0ea43c..b67f269d8 100644 --- a/backend/tests/test_api_rate_controls.py +++ b/backend/tests/test_api_rate_controls.py @@ -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"), From f01cbd22fab797d048e2a13d3581eb8a6df25bfe Mon Sep 17 00:00:00 2001 From: Commitrail Probe Date: Fri, 23 Oct 2026 15:23:57 +0100 Subject: [PATCH 09/10] Clarify exact rate inventory after catalogue route addition --- backend/tests/test_api_rate_controls.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/backend/tests/test_api_rate_controls.py b/backend/tests/test_api_rate_controls.py index b67f269d8..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"), From d55b5ae51d56afaaf92bd16bf7c1904a36661337 Mon Sep 17 00:00:00 2001 From: Commitrail Probe Date: Fri, 23 Oct 2026 19:20:30 +0100 Subject: [PATCH 10/10] Correct actor project discovery path in roadmap --- docs/roadmap_status.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/roadmap_status.md b/docs/roadmap_status.md index 8989f1c37..32e28b1c1 100644 --- a/docs/roadmap_status.md +++ b/docs/roadmap_status.md @@ -393,7 +393,7 @@ policy or activate a guide; the separate CLI manual artifact-policy writes only append retained drafts after authoritative setup and never authorize activation. 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 /actors/me/projects` still lists only the caller's exact-grant +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