From d1631e4eb4ec3ed77e76c93575ad34dee9d65a09 Mon Sep 17 00:00:00 2001 From: tegwick Date: Tue, 8 Sep 2026 10:35:51 +0200 Subject: [PATCH] Publisher identity: app-local tokens, and enforceable namespace ownership MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes the gap that made section 20.1 ownership advisory. The service could refuse anonymous callers but could not tell two publishers apart, so a closed namespace could be protected and never attributed. Follows DR-3, resolved 2026-07-10: app-local accounts, with platform OIDC demand-gated on client SSO requests, instance consolidation, or local-account toil across more than two apps. None of those triggers has fired here, so this is deliberately not OIDC. Tokens rather than accounts because a registry is consumed by CLIs and agents — no browser, no session, no UI to log into, and a login surface nothing uses is a liability. The whole authentication boundary stays in auth.py, so contract section 2.3 is met and a later OIDC switch is bounded rather than a search. The properties that matter are the ones about what a credential cannot do: - tokens are stored hashed, because a registry that can print its own credentials back is one database read away from impersonating every publisher it knows, and are shown once at creation; - an unknown token and a wrong token get the same answer, so a caller cannot enumerate which tokens exist; - a publisher cannot mint publishers — that would be an administrator with extra steps, and revoking one would no longer revoke what it could do; - the operator token publishes but owns nothing, so it is a bootstrap path rather than an identity that can hold a namespace; - a closed namespace with no owner recorded admits nobody, including the operator: reading a missing owner as "anyone" would invert the point of closing it; - revocation is a timestamp, not a delete, so what someone published stays attributed to them after their credential is withdrawn. Migration 0003 adds publishers and index_entries.published_by. The attribution is a name rather than a foreign key, so deleting a publisher cannot erase the history of what they published. Service tests 49 -> 61. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Bjefh8NUiEiahN4JLwoSKM Assistant: claude-code Assistant-Model: opus Assistant-Process: 388925@bnt-lap001 Assistant-Session: 3507023f-e0fd-4a1e-9d90-a0d4217d1502 --- service/README.md | 44 ++++-- .../versions/0003_publisher_identity.py | 44 ++++++ service/pyproject.toml | 2 +- .../src/canned_prompts_service/__init__.py | 2 +- service/src/canned_prompts_service/auth.py | 136 +++++++++++++---- service/src/canned_prompts_service/models.py | 42 +++++ service/src/canned_prompts_service/routes.py | 122 +++++++++++++-- service/src/canned_prompts_service/store.py | 13 +- service/tests/test_publisher_identity.py | 144 ++++++++++++++++++ 9 files changed, 496 insertions(+), 53 deletions(-) create mode 100644 service/migrations/versions/0003_publisher_identity.py create mode 100644 service/tests/test_publisher_identity.py diff --git a/service/README.md b/service/README.md index 667bc00..de3bf4a 100644 --- a/service/README.md +++ b/service/README.md @@ -111,21 +111,45 @@ Re-publishing identical content is accepted, different content under the same ### What identity means here -A single shared bearer token (`CANNED_PROMPTS_PUBLISH_TOKEN`), proving the -caller is **the operator of this service** — not per-publisher identity. Every -holder of the token is indistinguishable. +**App-local publisher tokens**, per DR-3 (resolved 2026-07-10: app-local +accounts, platform OIDC demand-gated on client SSO requests, instance +consolidation, or local-account toil across more than two apps — none of which +has fired here). Tokens rather than accounts because a registry is consumed by +CLIs and agents: no browser, no session, no UI to log into. -With no token configured the service is **read-only**. That is the correct +The whole authentication boundary is `auth.py` and nothing outside it decides +who is calling, so contract § 2.3's "one module" requirement is met and a later +OIDC switch is bounded. + +| Credential | Identifies | May own a namespace | +|---|---|---| +| publisher token | someone in particular | yes | +| operator token (`CANNED_PROMPTS_PUBLISH_TOKEN`) | whoever holds it | **no** — bootstrap and administration only | + +Tokens are stored **hashed**; a registry that can print its own credentials back +is one database read away from impersonating every publisher it knows. They are +shown once, at creation. Comparison is constant-time. An unknown token and a +wrong token get the same answer, so a caller cannot enumerate which tokens +exist. Revocation is a timestamp rather than a delete, so what someone +published stays attributed to them after their credential is withdrawn. + +`POST /publishers` mints one (operator only — a publisher able to mint +publishers would be an administrator with extra steps), `GET /publishers` lists +them without tokens, `DELETE /publishers/{name}` revokes. + +With neither an operator token nor any publisher configured, the service is +**read-only**. That is the correct default rather than an inconvenience: § 20.1 asks a registry to refuse publication into a closed namespace it does not consider the publisher to own, and an unauthenticated service considers nobody to own anything. -Namespace claims (§ 20.1) live in `namespace_claims` and are **enforced** here, -which a filesystem registry cannot do at all — but only as precisely as the -identity behind them. A `closed` namespace is protected from anonymous callers; -it cannot be attributed among several publishers. Until per-publisher identity -exists, a claim's `owner` is documentation rather than an access decision, and -the code says so where it matters. +Namespace claims (§ 20.1) live in `namespace_claims` and are **enforced**, which +a filesystem registry cannot do at all. `owner` now names a publisher, so a +closed namespace is a real access decision rather than documentation. + +A closed namespace with **no** owner recorded admits nobody, including the +operator: a namespace nobody has been granted is not open season, and reading a +missing owner as "anyone" would invert the point of closing it. ## Image and smoke diff --git a/service/migrations/versions/0003_publisher_identity.py b/service/migrations/versions/0003_publisher_identity.py new file mode 100644 index 0000000..3c5201a --- /dev/null +++ b/service/migrations/versions/0003_publisher_identity.py @@ -0,0 +1,44 @@ +"""publisher identity + +App-local publisher tokens (DR-3, resolved 2026-07-10: app-local accounts with +platform OIDC demand-gated; business-app-service-contract sections 2.1-2.2). +Tokens are stored hashed. Revocation is a timestamp, and index entries record a +publisher *name*, so attribution outlives the credential that produced it. + +Revision ID: 0003 +Revises: 0002 +""" +from alembic import op +import sqlalchemy as sa + +BigIntPK = sa.BigInteger().with_variant(sa.Integer, "sqlite") + +revision = '0003' +down_revision = '0002' +branch_labels = None +depends_on = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('publishers', + sa.Column('id', sa.BigInteger().with_variant(sa.Integer(), 'sqlite'), autoincrement=True, nullable=False), + sa.Column('tenant', sa.String(length=64), nullable=False), + sa.Column('name', sa.String(length=128), nullable=False), + sa.Column('token_hash', sa.String(length=64), nullable=False), + sa.Column('description', sa.Text(), nullable=True), + sa.Column('created_at', sa.DateTime(timezone=True), nullable=False), + sa.Column('revoked_at', sa.DateTime(timezone=True), nullable=True), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('tenant', 'name', name='uq_publisher_name'), + sa.UniqueConstraint('token_hash', name='uq_publisher_token') + ) + op.add_column('index_entries', sa.Column('published_by', sa.String(length=128), nullable=True)) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_column('index_entries', 'published_by') + op.drop_table('publishers') + # ### end Alembic commands ### diff --git a/service/pyproject.toml b/service/pyproject.toml index dc08b87..d863283 100644 --- a/service/pyproject.toml +++ b/service/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "canned-prompts-service" -version = "0.1.5" +version = "0.2.0" description = "Hosted registry and index service for Canned Prompt Format packages" requires-python = ">=3.12" dependencies = [ diff --git a/service/src/canned_prompts_service/__init__.py b/service/src/canned_prompts_service/__init__.py index b300a9a..20c7da7 100644 --- a/service/src/canned_prompts_service/__init__.py +++ b/service/src/canned_prompts_service/__init__.py @@ -8,4 +8,4 @@ boundary holds, so rendering stays deterministic and a `derive` default remains a declaration the service does not satisfy. """ -__version__ = "0.1.5" +__version__ = "0.2.0" diff --git a/service/src/canned_prompts_service/auth.py b/service/src/canned_prompts_service/auth.py index 0ba3acd..d7c3f31 100644 --- a/service/src/canned_prompts_service/auth.py +++ b/service/src/canned_prompts_service/auth.py @@ -1,39 +1,117 @@ """Publisher identity. -What this is, stated plainly so nobody mistakes it for more: a single shared -bearer token proving the caller is *the operator of this service*. It is not -per-publisher identity — every holder of the token is indistinguishable. +**The whole authentication boundary lives in this module.** Contract § 2.3 asks +that it stay one place so a later switch to platform OIDC is a bounded change +rather than a search. Nothing outside here decides who is calling. -That limit decides how § 20.1 namespace claims are enforced here. A `closed` -namespace can be protected from anonymous callers, which a filesystem registry -cannot do at all, but it cannot be attributed to one of several publishers. -Per-publisher identity is deferred; until it exists, a claim's `owner` is -documentation rather than an access decision. +The model is app-local publisher tokens, per DR-3 (resolved 2026-07-10: +app-local accounts, platform OIDC demand-gated on client SSO requests, instance +consolidation, or local-account toil across more than two apps). None of those +triggers has fired for this service, so this is deliberately not OIDC. -With no token configured the service is read-only. Refusing writes is the -correct default for a registry that cannot tell who is calling — § 20.1 asks a -registry to refuse publication into a closed namespace it does not consider the -publisher to own, and an unauthenticated service considers nobody to own -anything. +Tokens rather than accounts because a registry is consumed by CLIs and agents. +There is no browser, no session to establish, and no UI to log into; adding a +login surface nothing uses would be a liability, not a feature. + +Three properties worth stating because they are easy to get wrong: + +- Tokens are stored **hashed**. A registry that can print its own credentials + back is one database read away from impersonating every publisher it knows. +- Comparison is constant-time, so a wrong token cannot be recovered by timing. +- Revocation is a timestamp, not a delete, so a package's attribution survives + the revocation of the credential that published it. """ from __future__ import annotations +import hashlib import hmac +import secrets from fastapi import HTTPException, Request +from sqlalchemy import select +from sqlalchemy.orm import Session +from .models import Publisher from .settings import Settings +TOKEN_BYTES = 32 +TOKEN_PREFIX = "cp_" + class PublishDenied(HTTPException): pass -def require_publisher(request: Request, settings: Settings) -> str: - """Return the publisher identity, or refuse.""" - token = settings.resolved_publish_token - if not token: +def mint_token() -> str: + """A fresh publisher token. Prefixed so it is recognisable in a leak scan.""" + return TOKEN_PREFIX + secrets.token_urlsafe(TOKEN_BYTES) + + +def hash_token(token: str) -> str: + return hashlib.sha256(token.encode("utf-8")).hexdigest() + + +def bearer_token(request: Request) -> str: + header = request.headers.get("authorization", "") + scheme, _, credential = header.partition(" ") + if scheme.lower() != "bearer" or not credential: + raise PublishDenied( + status_code=401, detail="publishing requires an Authorization: Bearer token" + ) + return credential + + +def identify_publisher( + request: Request, session: Session, settings: Settings, tenant: str +) -> str: + """Return the publisher's name, or refuse. The only caller-identity decision. + + Two credentials are accepted, and the difference matters. A publisher token + identifies *someone in particular* and can own a namespace. The operator + token identifies only "whoever holds the operator credential" — it is the + bootstrap path, because the first publisher has to be created by something, + and it deliberately cannot own a closed namespace. + """ + credential = bearer_token(request) + digest = hash_token(credential) + + publisher = session.scalar( + select(Publisher).where( + Publisher.tenant == tenant, Publisher.token_hash == digest + ) + ) + if publisher is not None: + if not publisher.active: + raise PublishDenied( + status_code=403, + detail=f"publisher {publisher.name!r} is revoked", + ) + return publisher.name + + operator = settings.resolved_publish_token + if operator and hmac.compare_digest(credential, operator): + return settings.publisher_name + + # Same answer for an unknown token and a wrong one: distinguishing them + # would let a caller enumerate which tokens exist. + raise PublishDenied(status_code=403, detail="token not accepted") + + +def require_publisher( + request: Request, session: Session, settings: Settings, tenant: str +) -> str: + """Refuse writes outright when no identity mechanism is configured. + + § 20.1 asks a registry to refuse publication into a closed namespace it does + not consider the publisher to own. A service that cannot tell who is calling + considers nobody to own anything, so it declines rather than accepting + anonymous writes. + """ + has_publishers = session.scalar( + select(Publisher.id).where(Publisher.tenant == tenant).limit(1) + ) + if not settings.resolved_publish_token and has_publishers is None: raise PublishDenied( status_code=503, detail=( @@ -42,14 +120,18 @@ def require_publisher(request: Request, settings: Settings) -> str: "rather than accepting anonymous publishes (§ 20.1)" ), ) + return identify_publisher(request, session, settings, tenant) - header = request.headers.get("authorization", "") - scheme, _, credential = header.partition(" ") - if scheme.lower() != "bearer" or not credential: - raise PublishDenied( - status_code=401, detail="publishing requires an Authorization: Bearer token" - ) - # compare_digest so a wrong token cannot be recovered by timing the reply. - if not hmac.compare_digest(credential, token): - raise PublishDenied(status_code=403, detail="token not accepted") - return settings.publisher_name + +def owns_namespace(claim, publisher: str, settings: Settings) -> bool: + """Whether `publisher` may publish into a claimed namespace (§ 20.1). + + An unowned claim is not open season: a namespace marked closed with no owner + recorded is a namespace nobody has been granted, so nobody may publish into + it. Reading a missing owner as "anyone" would invert the point of closing it. + """ + if claim is None or claim.policy != "closed": + return True + if not claim.owner: + return False + return claim.owner == publisher diff --git a/service/src/canned_prompts_service/models.py b/service/src/canned_prompts_service/models.py index 565f149..2966e57 100644 --- a/service/src/canned_prompts_service/models.py +++ b/service/src/canned_prompts_service/models.py @@ -129,6 +129,11 @@ class IndexEntry(Base): declared_source: Mapped[str | None] = mapped_column(Text) license: Mapped[str | None] = mapped_column(String(128)) + # Who published it. A name rather than a foreign key: attribution must + # outlive the publisher record, and a deleted publisher must not erase the + # history of what they published. + published_by: Mapped[str | None] = mapped_column(String(128)) + included_at: Mapped[dt.datetime] = mapped_column( DateTime(timezone=True), nullable=False, default=utcnow ) @@ -154,3 +159,40 @@ class NamespaceClaim(Base): namespace: Mapped[str] = mapped_column(String(256), nullable=False) policy: Mapped[str] = mapped_column(String(16), nullable=False, default="open") owner: Mapped[str | None] = mapped_column(String(256)) + + +class Publisher(Base): + """Someone who may publish, and can be told apart from someone else. + + App-local identity, per DR-3 (resolved 2026-07-10: app-local accounts, + platform OIDC demand-gated) and business-app-service-contract § 2.1–2.2. + Tokens rather than accounts because a registry is consumed by CLIs and + agents, not browsers — there is no session to establish and no UI to log + into. + + The token is stored as a **hash**. A registry that can print its own + credentials back is one database read away from impersonating every + publisher it knows. + """ + + __tablename__ = "publishers" + __table_args__ = ( + UniqueConstraint("tenant", "name", name="uq_publisher_name"), + UniqueConstraint("token_hash", name="uq_publisher_token"), + ) + + id: Mapped[int] = mapped_column(BigIntPK, primary_key=True, autoincrement=True) + tenant: Mapped[str] = mapped_column(String(64), nullable=False) + name: Mapped[str] = mapped_column(String(128), nullable=False) + token_hash: Mapped[str] = mapped_column(String(64), nullable=False) + description: Mapped[str | None] = mapped_column(Text) + created_at: Mapped[dt.datetime] = mapped_column( + DateTime(timezone=True), nullable=False, default=utcnow + ) + # Revocation is a timestamp rather than a delete, so a published package's + # attribution survives the revocation of the credential that made it. + revoked_at: Mapped[dt.datetime | None] = mapped_column(DateTime(timezone=True)) + + @property + def active(self) -> bool: + return self.revoked_at is None diff --git a/service/src/canned_prompts_service/routes.py b/service/src/canned_prompts_service/routes.py index da55e8a..a4532f8 100644 --- a/service/src/canned_prompts_service/routes.py +++ b/service/src/canned_prompts_service/routes.py @@ -18,7 +18,7 @@ from fastapi import APIRouter, Body, Depends, HTTPException, Query, Request from sqlalchemy.orm import Session from . import store -from .auth import require_publisher +from .auth import owns_namespace, require_publisher from .settings import Settings from .store import Ambiguous, Conflict, NotFound, Reference, StoreError @@ -86,7 +86,7 @@ def make_router(get_session, get_tenant, settings: Settings) -> APIRouter: session: Session = Depends(get_session), tenant: str = Depends(get_tenant), ) -> dict[str, Any]: - publisher = require_publisher(request, settings) + publisher = require_publisher(request, session, settings, tenant) registry = str(payload.get("registry") or "").strip() if not registry: @@ -107,18 +107,19 @@ def make_router(get_session, get_tenant, settings: Settings) -> APIRouter: # service can refuse. It can only refuse as precisely as its # identity allows — see auth.py. claim = store.claim_for(session, tenant, registry, manifest_id) - if claim is not None and claim.policy == "closed": - owner = claim.owner or "unspecified" - if publisher != (claim.owner or publisher): - raise HTTPException( - 403, - f"namespace {store.namespace_of(manifest_id)!r} in registry " - f"{registry!r} is closed (owner: {owner})", - ) + if not owns_namespace(claim, publisher, settings): + owner = (claim.owner if claim else None) or "unassigned" + raise HTTPException( + 403, + f"namespace {store.namespace_of(manifest_id)!r} in registry " + f"{registry!r} is closed and owned by {owner}; " + f"you are publishing as {publisher!r}", + ) try: version = store.ingest( - session, tenant, registry, package_dir, source=source, method="publish" + session, tenant, registry, package_dir, source=source, + method="publish", published_by=publisher, ) session.commit() except StoreError as exc: @@ -130,6 +131,104 @@ def make_router(get_session, get_tenant, settings: Settings) -> APIRouter: return version_payload(version) + def require_operator(request, session, tenant: str) -> str: + """Publisher administration is the operator's, not a publisher's. + + A publisher that could mint publishers would be an administrator with + extra steps, and revoking one would no longer revoke what it could do. + """ + from .auth import PublishDenied, bearer_token, hash_token + import hmac as _hmac + + operator = settings.resolved_publish_token + if not operator: + raise PublishDenied( + status_code=503, + detail="publisher administration requires the operator token, which is not configured", + ) + if not _hmac.compare_digest(bearer_token(request), operator): + raise PublishDenied(status_code=403, detail="operator token required") + return settings.publisher_name + + @api.post("/publishers", status_code=201) + def create_publisher( + request: Request, + payload: dict[str, Any] = Body(...), + session: Session = Depends(get_session), + tenant: str = Depends(get_tenant), + ) -> dict[str, Any]: + """Mint a publisher. The token is returned **once** and never again.""" + from .auth import mint_token, hash_token + from .models import Publisher + + require_operator(request, session, tenant) + name = str(payload.get("name") or "").strip() + if not name: + raise HTTPException(400, "name is required") + existing = session.query(Publisher).filter( + Publisher.tenant == tenant, Publisher.name == name + ).one_or_none() + if existing is not None: + raise HTTPException(409, f"publisher {name!r} already exists") + + token = mint_token() + publisher = Publisher( + tenant=tenant, name=name, token_hash=hash_token(token), + description=str(payload.get("description") or "") or None, + ) + session.add(publisher) + session.commit() + return { + "name": publisher.name, + "token": token, + "note": "stored hashed; this is the only time it is shown", + } + + @api.get("/publishers") + def list_publishers( + request: Request, + session: Session = Depends(get_session), + tenant: str = Depends(get_tenant), + ) -> dict[str, Any]: + from .models import Publisher + + require_operator(request, session, tenant) + rows = session.query(Publisher).filter(Publisher.tenant == tenant).all() + return { + "count": len(rows), + "publishers": [ + { + "name": r.name, + "description": r.description, + "active": r.active, + "created_at": r.created_at.isoformat() if r.created_at else None, + "revoked_at": r.revoked_at.isoformat() if r.revoked_at else None, + } + for r in rows + ], + } + + @api.delete("/publishers/{name}") + def revoke_publisher( + name: str, + request: Request, + session: Session = Depends(get_session), + tenant: str = Depends(get_tenant), + ) -> dict[str, Any]: + """Revoke by timestamp, never by delete: what they published stays theirs.""" + from .models import Publisher, utcnow + + require_operator(request, session, tenant) + publisher = session.query(Publisher).filter( + Publisher.tenant == tenant, Publisher.name == name + ).one_or_none() + if publisher is None: + raise HTTPException(404, f"publisher not found: {name}") + if publisher.revoked_at is None: + publisher.revoked_at = utcnow() + session.commit() + return {"name": publisher.name, "revoked_at": publisher.revoked_at.isoformat()} + @api.get("/packages") def list_packages( q: str = Query("", description="Substring over id, name, summary and tags"), @@ -203,6 +302,7 @@ def make_router(get_session, get_tenant, settings: Settings) -> APIRouter: "declared_author": r.declared_author, "declared_source": r.declared_source, "license": r.license, + "published_by": r.published_by, "included_at": r.included_at.isoformat() if r.included_at else None, "last_seen_at": r.last_seen_at.isoformat() if r.last_seen_at else None, } diff --git a/service/src/canned_prompts_service/store.py b/service/src/canned_prompts_service/store.py index 91bd37c..45ab0c6 100644 --- a/service/src/canned_prompts_service/store.py +++ b/service/src/canned_prompts_service/store.py @@ -87,6 +87,7 @@ def ingest( package_dir: Path, source: str, method: str, + published_by: str | None = None, ) -> PackageVersion: """Validate a package and store it. Shared by publish and by fixtures.""" manifest = cp.validate_package(package_dir) @@ -105,7 +106,7 @@ def ingest( # § 17: a published id@version is immutable within a registry. Identical # content is a harmless re-publish; different content is a conflict. if existing.content_digest == digest: - record_index_entry(session, tenant, registry, manifest, source, method) + record_index_entry(session, tenant, registry, manifest, source, method, published_by) return existing raise Conflict( f"{registry}:{manifest['id']}@{manifest['version']} already exists " @@ -130,13 +131,14 @@ def ingest( ], ) session.add(version) - record_index_entry(session, tenant, registry, manifest, source, method) + record_index_entry(session, tenant, registry, manifest, source, method, published_by) session.flush() return version def record_index_entry( - session: Session, tenant: str, registry: str, manifest: dict, source: str, method: str + session: Session, tenant: str, registry: str, manifest: dict, source: str, + method: str, published_by: str | None = None ) -> IndexEntry: """§ 20.3. `included_at` is first arrival and is never overwritten.""" provenance = manifest.get("provenance") or {} @@ -150,6 +152,10 @@ def record_index_entry( ) if entry is not None: entry.last_seen_at = utcnow() + # First publisher is kept: attribution records who put it here, not who + # most recently re-pushed identical content. + if entry.published_by is None: + entry.published_by = published_by return entry entry = IndexEntry( @@ -162,6 +168,7 @@ def record_index_entry( declared_author=str(provenance.get("author")) if provenance.get("author") else None, declared_source=str(provenance.get("source")) if provenance.get("source") else None, license=manifest.get("license"), + published_by=published_by, ) session.add(entry) return entry diff --git a/service/tests/test_publisher_identity.py b/service/tests/test_publisher_identity.py new file mode 100644 index 0000000..231a522 --- /dev/null +++ b/service/tests/test_publisher_identity.py @@ -0,0 +1,144 @@ +"""Publisher identity (CANP-WP-0006 / RCP-WP-0002-T05). + +App-local publisher tokens per DR-3. The tests that matter here are the ones +about what a credential *cannot* do. +""" + +from __future__ import annotations + +from pathlib import Path + +import pytest +from fastapi.testclient import TestClient + +from canned_prompts_service.api import create_app +from canned_prompts_service.auth import hash_token, mint_token, owns_namespace +from canned_prompts_service.db import make_engine +from canned_prompts_service.models import NamespaceClaim, Publisher +from canned_prompts_service.settings import Settings + +REPO = Path(__file__).resolve().parents[2] +OPERATOR = "operator-token" +OP = {"Authorization": f"Bearer {OPERATOR}"} + + +def payload(name: str = "house-style", registry: str = "local") -> dict: + import canned_prompts as cp + + src = REPO / "examples" / name + manifest = cp.validate_package(src) + return { + "registry": registry, + "source": f"examples/{name}", + "files": { + rel: {"text": (src / rel).read_text(encoding="utf-8")} + for rel in sorted(cp.package_members(src, manifest)) + }, + } + + +@pytest.fixture() +def app(db_url: str) -> TestClient: + return TestClient( + create_app(Settings(database_url=db_url, publish_token=OPERATOR), make_engine(db_url)) + ) + + +# --- token handling --- + +def test_tokens_are_unguessable_and_prefixed() -> None: + a, b = mint_token(), mint_token() + assert a != b and a.startswith("cp_") and len(a) > 40 + + +def test_tokens_are_stored_hashed(app: TestClient) -> None: + """A registry that can print its own credentials back can impersonate.""" + token = app.post("/publishers", json={"name": "ada"}, headers=OP).json()["token"] + listed = app.get("/publishers", headers=OP).json()["publishers"][0] + assert "token" not in listed + assert hash_token(token) != token + + +def test_token_is_shown_once_only(app: TestClient) -> None: + app.post("/publishers", json={"name": "ada"}, headers=OP) + body = app.get("/publishers", headers=OP).json() + assert all("token" not in p for p in body["publishers"]) + + +# --- what a credential cannot do --- + +def test_publisher_token_cannot_mint_publishers(app: TestClient) -> None: + """A publisher that could mint publishers is an administrator in disguise.""" + token = app.post("/publishers", json={"name": "ada"}, headers=OP).json()["token"] + response = app.post( + "/publishers", json={"name": "mallory"}, + headers={"Authorization": f"Bearer {token}"}, + ) + assert response.status_code == 403 + + +def test_unknown_and_wrong_tokens_are_indistinguishable(app: TestClient) -> None: + """Different answers would let a caller enumerate which tokens exist.""" + a = app.post("/packages", json=payload(), headers={"Authorization": "Bearer cp_nope"}) + b = app.post("/packages", json=payload(), headers={"Authorization": "Bearer garbage"}) + assert a.status_code == b.status_code == 403 + assert a.json()["detail"] == b.json()["detail"] + + +def test_revoked_publisher_is_refused(app: TestClient) -> None: + token = app.post("/publishers", json={"name": "ada"}, headers=OP).json()["token"] + assert app.delete("/publishers/ada", headers=OP).status_code == 200 + response = app.post("/packages", json=payload(), headers={"Authorization": f"Bearer {token}"}) + assert response.status_code == 403 + assert "revoked" in response.json()["detail"] + + +def test_revocation_preserves_attribution(app: TestClient) -> None: + """What someone published stays theirs after their credential is revoked.""" + token = app.post("/publishers", json={"name": "ada"}, headers=OP).json()["token"] + app.post("/packages", json=payload(), headers={"Authorization": f"Bearer {token}"}) + app.delete("/publishers/ada", headers=OP) + entry = app.get("/index").json()["entries"][0] + assert entry["published_by"] == "ada" + + +# --- namespace ownership (§ 20.1), now enforceable --- + +def test_owner_may_publish_into_their_closed_namespace(app: TestClient, session) -> None: + token = app.post("/publishers", json={"name": "ada"}, headers=OP).json()["token"] + session.add(NamespaceClaim(tenant="default", registry="local", + namespace="practice", policy="closed", owner="ada")) + session.commit() + response = app.post("/packages", json=payload(), headers={"Authorization": f"Bearer {token}"}) + assert response.status_code == 201 + + +def test_non_owner_is_refused_a_closed_namespace(app: TestClient, session) -> None: + token = app.post("/publishers", json={"name": "mallory"}, headers=OP).json()["token"] + session.add(NamespaceClaim(tenant="default", registry="local", + namespace="practice", policy="closed", owner="ada")) + session.commit() + response = app.post("/packages", json=payload(), headers={"Authorization": f"Bearer {token}"}) + assert response.status_code == 403 + assert "owned by ada" in response.json()["detail"] + + +def test_closed_namespace_without_an_owner_admits_nobody() -> None: + """A missing owner means nobody has been granted it — not that anyone may.""" + claim = NamespaceClaim(tenant="t", registry="r", namespace="n", policy="closed", owner=None) + assert owns_namespace(claim, "ada", Settings()) is False + assert owns_namespace(claim, "operator", Settings()) is False + + +def test_open_and_unclaimed_namespaces_admit_any_publisher() -> None: + assert owns_namespace(None, "ada", Settings()) is True + claim = NamespaceClaim(tenant="t", registry="r", namespace="n", policy="open") + assert owns_namespace(claim, "ada", Settings()) is True + + +def test_operator_publishes_but_owns_no_closed_namespace(app: TestClient, session) -> None: + """The operator token is a bootstrap path, not an identity that owns things.""" + session.add(NamespaceClaim(tenant="default", registry="local", + namespace="practice", policy="closed", owner="ada")) + session.commit() + assert app.post("/packages", json=payload(), headers=OP).status_code == 403