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
|
### What identity means here
|
||||||
|
|
||||||
A single shared bearer token (`CANNED_PROMPTS_PUBLISH_TOKEN`), proving the
|
**App-local publisher tokens**, per DR-3 (resolved 2026-07-10: app-local
|
||||||
caller is **the operator of this service** — not per-publisher identity. Every
|
accounts, platform OIDC demand-gated on client SSO requests, instance
|
||||||
holder of the token is indistinguishable.
|
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
|
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,
|
publication into a closed namespace it does not consider the publisher to own,
|
||||||
and an unauthenticated service considers nobody to own anything.
|
and an unauthenticated service considers nobody to own anything.
|
||||||
|
|
||||||
Namespace claims (§ 20.1) live in `namespace_claims` and are **enforced** here,
|
Namespace claims (§ 20.1) live in `namespace_claims` and are **enforced**, which
|
||||||
which a filesystem registry cannot do at all — but only as precisely as the
|
a filesystem registry cannot do at all. `owner` now names a publisher, so a
|
||||||
identity behind them. A `closed` namespace is protected from anonymous callers;
|
closed namespace is a real access decision rather than documentation.
|
||||||
it cannot be attributed among several publishers. Until per-publisher identity
|
|
||||||
exists, a claim's `owner` is documentation rather than an access decision, and
|
A closed namespace with **no** owner recorded admits nobody, including the
|
||||||
the code says so where it matters.
|
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
|
## Image and smoke
|
||||||
|
|
||||||
|
|
|
||||||
44
service/migrations/versions/0003_publisher_identity.py
Normal file
44
service/migrations/versions/0003_publisher_identity.py
Normal 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 ###
|
||||||
|
|
@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "canned-prompts-service"
|
name = "canned-prompts-service"
|
||||||
version = "0.1.5"
|
version = "0.2.0"
|
||||||
description = "Hosted registry and index service for Canned Prompt Format packages"
|
description = "Hosted registry and index service for Canned Prompt Format packages"
|
||||||
requires-python = ">=3.12"
|
requires-python = ">=3.12"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
|
|
||||||
|
|
@ -8,4 +8,4 @@ boundary holds, so rendering stays deterministic and a `derive` default remains
|
||||||
a declaration the service does not satisfy.
|
a declaration the service does not satisfy.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
__version__ = "0.1.5"
|
__version__ = "0.2.0"
|
||||||
|
|
|
||||||
|
|
@ -1,39 +1,117 @@
|
||||||
"""Publisher identity.
|
"""Publisher identity.
|
||||||
|
|
||||||
What this is, stated plainly so nobody mistakes it for more: a single shared
|
**The whole authentication boundary lives in this module.** Contract § 2.3 asks
|
||||||
bearer token proving the caller is *the operator of this service*. It is not
|
that it stay one place so a later switch to platform OIDC is a bounded change
|
||||||
per-publisher identity — every holder of the token is indistinguishable.
|
rather than a search. Nothing outside here decides who is calling.
|
||||||
|
|
||||||
That limit decides how § 20.1 namespace claims are enforced here. A `closed`
|
The model is app-local publisher tokens, per DR-3 (resolved 2026-07-10:
|
||||||
namespace can be protected from anonymous callers, which a filesystem registry
|
app-local accounts, platform OIDC demand-gated on client SSO requests, instance
|
||||||
cannot do at all, but it cannot be attributed to one of several publishers.
|
consolidation, or local-account toil across more than two apps). None of those
|
||||||
Per-publisher identity is deferred; until it exists, a claim's `owner` is
|
triggers has fired for this service, so this is deliberately not OIDC.
|
||||||
documentation rather than an access decision.
|
|
||||||
|
|
||||||
With no token configured the service is read-only. Refusing writes is the
|
Tokens rather than accounts because a registry is consumed by CLIs and agents.
|
||||||
correct default for a registry that cannot tell who is calling — § 20.1 asks a
|
There is no browser, no session to establish, and no UI to log into; adding a
|
||||||
registry to refuse publication into a closed namespace it does not consider the
|
login surface nothing uses would be a liability, not a feature.
|
||||||
publisher to own, and an unauthenticated service considers nobody to own
|
|
||||||
anything.
|
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
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
import hmac
|
import hmac
|
||||||
|
import secrets
|
||||||
|
|
||||||
from fastapi import HTTPException, Request
|
from fastapi import HTTPException, Request
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from .models import Publisher
|
||||||
from .settings import Settings
|
from .settings import Settings
|
||||||
|
|
||||||
|
TOKEN_BYTES = 32
|
||||||
|
TOKEN_PREFIX = "cp_"
|
||||||
|
|
||||||
|
|
||||||
class PublishDenied(HTTPException):
|
class PublishDenied(HTTPException):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
def require_publisher(request: Request, settings: Settings) -> str:
|
def mint_token() -> str:
|
||||||
"""Return the publisher identity, or refuse."""
|
"""A fresh publisher token. Prefixed so it is recognisable in a leak scan."""
|
||||||
token = settings.resolved_publish_token
|
return TOKEN_PREFIX + secrets.token_urlsafe(TOKEN_BYTES)
|
||||||
if not token:
|
|
||||||
|
|
||||||
|
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(
|
raise PublishDenied(
|
||||||
status_code=503,
|
status_code=503,
|
||||||
detail=(
|
detail=(
|
||||||
|
|
@ -42,14 +120,18 @@ def require_publisher(request: Request, settings: Settings) -> str:
|
||||||
"rather than accepting anonymous publishes (§ 20.1)"
|
"rather than accepting anonymous publishes (§ 20.1)"
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
return identify_publisher(request, session, settings, tenant)
|
||||||
|
|
||||||
header = request.headers.get("authorization", "")
|
|
||||||
scheme, _, credential = header.partition(" ")
|
def owns_namespace(claim, publisher: str, settings: Settings) -> bool:
|
||||||
if scheme.lower() != "bearer" or not credential:
|
"""Whether `publisher` may publish into a claimed namespace (§ 20.1).
|
||||||
raise PublishDenied(
|
|
||||||
status_code=401, detail="publishing requires an Authorization: Bearer token"
|
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
|
||||||
# compare_digest so a wrong token cannot be recovered by timing the reply.
|
it. Reading a missing owner as "anyone" would invert the point of closing it.
|
||||||
if not hmac.compare_digest(credential, token):
|
"""
|
||||||
raise PublishDenied(status_code=403, detail="token not accepted")
|
if claim is None or claim.policy != "closed":
|
||||||
return settings.publisher_name
|
return True
|
||||||
|
if not claim.owner:
|
||||||
|
return False
|
||||||
|
return claim.owner == publisher
|
||||||
|
|
|
||||||
|
|
@ -129,6 +129,11 @@ class IndexEntry(Base):
|
||||||
declared_source: Mapped[str | None] = mapped_column(Text)
|
declared_source: Mapped[str | None] = mapped_column(Text)
|
||||||
license: Mapped[str | None] = mapped_column(String(128))
|
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(
|
included_at: Mapped[dt.datetime] = mapped_column(
|
||||||
DateTime(timezone=True), nullable=False, default=utcnow
|
DateTime(timezone=True), nullable=False, default=utcnow
|
||||||
)
|
)
|
||||||
|
|
@ -154,3 +159,40 @@ class NamespaceClaim(Base):
|
||||||
namespace: Mapped[str] = mapped_column(String(256), nullable=False)
|
namespace: Mapped[str] = mapped_column(String(256), nullable=False)
|
||||||
policy: Mapped[str] = mapped_column(String(16), nullable=False, default="open")
|
policy: Mapped[str] = mapped_column(String(16), nullable=False, default="open")
|
||||||
owner: Mapped[str | None] = mapped_column(String(256))
|
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.1–2.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
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,7 @@ from fastapi import APIRouter, Body, Depends, HTTPException, Query, Request
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from . import store
|
from . import store
|
||||||
from .auth import require_publisher
|
from .auth import owns_namespace, require_publisher
|
||||||
from .settings import Settings
|
from .settings import Settings
|
||||||
from .store import Ambiguous, Conflict, NotFound, Reference, StoreError
|
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),
|
session: Session = Depends(get_session),
|
||||||
tenant: str = Depends(get_tenant),
|
tenant: str = Depends(get_tenant),
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
publisher = require_publisher(request, settings)
|
publisher = require_publisher(request, session, settings, tenant)
|
||||||
|
|
||||||
registry = str(payload.get("registry") or "").strip()
|
registry = str(payload.get("registry") or "").strip()
|
||||||
if not registry:
|
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
|
# service can refuse. It can only refuse as precisely as its
|
||||||
# identity allows — see auth.py.
|
# identity allows — see auth.py.
|
||||||
claim = store.claim_for(session, tenant, registry, manifest_id)
|
claim = store.claim_for(session, tenant, registry, manifest_id)
|
||||||
if claim is not None and claim.policy == "closed":
|
if not owns_namespace(claim, publisher, settings):
|
||||||
owner = claim.owner or "unspecified"
|
owner = (claim.owner if claim else None) or "unassigned"
|
||||||
if publisher != (claim.owner or publisher):
|
raise HTTPException(
|
||||||
raise HTTPException(
|
403,
|
||||||
403,
|
f"namespace {store.namespace_of(manifest_id)!r} in registry "
|
||||||
f"namespace {store.namespace_of(manifest_id)!r} in registry "
|
f"{registry!r} is closed and owned by {owner}; "
|
||||||
f"{registry!r} is closed (owner: {owner})",
|
f"you are publishing as {publisher!r}",
|
||||||
)
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
version = store.ingest(
|
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()
|
session.commit()
|
||||||
except StoreError as exc:
|
except StoreError as exc:
|
||||||
|
|
@ -130,6 +131,104 @@ def make_router(get_session, get_tenant, settings: Settings) -> APIRouter:
|
||||||
|
|
||||||
return version_payload(version)
|
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")
|
@api.get("/packages")
|
||||||
def list_packages(
|
def list_packages(
|
||||||
q: str = Query("", description="Substring over id, name, summary and tags"),
|
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_author": r.declared_author,
|
||||||
"declared_source": r.declared_source,
|
"declared_source": r.declared_source,
|
||||||
"license": r.license,
|
"license": r.license,
|
||||||
|
"published_by": r.published_by,
|
||||||
"included_at": r.included_at.isoformat() if r.included_at else None,
|
"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,
|
"last_seen_at": r.last_seen_at.isoformat() if r.last_seen_at else None,
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -87,6 +87,7 @@ def ingest(
|
||||||
package_dir: Path,
|
package_dir: Path,
|
||||||
source: str,
|
source: str,
|
||||||
method: str,
|
method: str,
|
||||||
|
published_by: str | None = None,
|
||||||
) -> PackageVersion:
|
) -> PackageVersion:
|
||||||
"""Validate a package and store it. Shared by publish and by fixtures."""
|
"""Validate a package and store it. Shared by publish and by fixtures."""
|
||||||
manifest = cp.validate_package(package_dir)
|
manifest = cp.validate_package(package_dir)
|
||||||
|
|
@ -105,7 +106,7 @@ def ingest(
|
||||||
# § 17: a published id@version is immutable within a registry. Identical
|
# § 17: a published id@version is immutable within a registry. Identical
|
||||||
# content is a harmless re-publish; different content is a conflict.
|
# content is a harmless re-publish; different content is a conflict.
|
||||||
if existing.content_digest == digest:
|
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
|
return existing
|
||||||
raise Conflict(
|
raise Conflict(
|
||||||
f"{registry}:{manifest['id']}@{manifest['version']} already exists "
|
f"{registry}:{manifest['id']}@{manifest['version']} already exists "
|
||||||
|
|
@ -130,13 +131,14 @@ def ingest(
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
session.add(version)
|
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()
|
session.flush()
|
||||||
return version
|
return version
|
||||||
|
|
||||||
|
|
||||||
def record_index_entry(
|
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:
|
) -> IndexEntry:
|
||||||
"""§ 20.3. `included_at` is first arrival and is never overwritten."""
|
"""§ 20.3. `included_at` is first arrival and is never overwritten."""
|
||||||
provenance = manifest.get("provenance") or {}
|
provenance = manifest.get("provenance") or {}
|
||||||
|
|
@ -150,6 +152,10 @@ def record_index_entry(
|
||||||
)
|
)
|
||||||
if entry is not None:
|
if entry is not None:
|
||||||
entry.last_seen_at = utcnow()
|
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
|
return entry
|
||||||
|
|
||||||
entry = IndexEntry(
|
entry = IndexEntry(
|
||||||
|
|
@ -162,6 +168,7 @@ def record_index_entry(
|
||||||
declared_author=str(provenance.get("author")) if provenance.get("author") else None,
|
declared_author=str(provenance.get("author")) if provenance.get("author") else None,
|
||||||
declared_source=str(provenance.get("source")) if provenance.get("source") else None,
|
declared_source=str(provenance.get("source")) if provenance.get("source") else None,
|
||||||
license=manifest.get("license"),
|
license=manifest.get("license"),
|
||||||
|
published_by=published_by,
|
||||||
)
|
)
|
||||||
session.add(entry)
|
session.add(entry)
|
||||||
return entry
|
return entry
|
||||||
|
|
|
||||||
144
service/tests/test_publisher_identity.py
Normal file
144
service/tests/test_publisher_identity.py
Normal 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
|
||||||
Loading…
Add table
Add a link
Reference in a new issue