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