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
|
||||
|
||||
|
|
|
|||
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]
|
||||
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 = [
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.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 . 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,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
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