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:
parent
f6e20b5e0c
commit
d1631e4eb4
9 changed files with 496 additions and 53 deletions
|
|
@ -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
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue