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
144 lines
5.9 KiB
Python
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
|