canned-prompts/service/tests/test_publisher_identity.py
tegwick d1631e4eb4 Publisher identity: app-local tokens, and enforceable namespace ownership
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 <noreply@anthropic.com>
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
2026-09-08 10:35:51 +02:00

144 lines
5.9 KiB
Python

"""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