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
This commit is contained in:
tegwick 2026-09-08 10:35:51 +02:00
parent f6e20b5e0c
commit d1631e4eb4
9 changed files with 496 additions and 53 deletions

View file

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

View file

@ -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 ###

View file

@ -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 = [

View file

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

View file

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

View file

@ -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.12.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

View file

@ -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,
}

View file

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

View file

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