canned-prompts/service/README.md
tegwick d1631e4eb4 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
2026-09-08 10:35:51 +02:00

215 lines
9.9 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# canned-prompts service
The hosted registry and index (`CANP-WP-0006`). Product ownership lives here
alongside the specification and the reference CLI; deployment and operation will
belong to `rapp-canned-prompts` once there is an image digest to pin.
This service **hosts** packages. It does not execute them — `INTENT.md`'s
deliberate boundary holds, so rendering stays deterministic and a `derive`
default remains a declaration the service does not satisfy.
`reference/` is deliberately untouched by this. It is the format's conformance
witness and stays dependency-light; the service is a separate consumer of the
same package semantics.
## Stack
FastAPI, SQLAlchemy, Alembic, PostgreSQL — matching `state-hub` and
`sbom-nexus`.
```bash
cd service
uv venv && uv pip install -e ".[dev]"
.venv/bin/python -m pytest -q
export CANNED_PROMPTS_DATABASE_URL=postgresql+psycopg://...
.venv/bin/alembic upgrade head
.venv/bin/uvicorn canned_prompts_service.api:create_app --factory
```
Credentials may arrive as **files** rather than environment variables —
`CANNED_PROMPTS_DATABASE_URL_FILE` and `CANNED_PROMPTS_PUBLISH_TOKEN_FILE`
which is how they are supplied in the cluster. An env var holding a password is
visible in `kubectl describe`, in crash dumps, and to anything that can read
`/proc`; a mounted secret should stay a file. When both forms are set the file
wins, because a rotated secret must take effect rather than be shadowed by a
stale env var.
`CANNED_PROMPTS_DATABASE_URL` has **no default**. A service that silently falls
back to a local database when its real one is misconfigured is worse than one
that refuses to start.
## Health surface
`RailianceAppDeploymentGuide.md` requires unauthenticated `/healthz` and
`/readyz`; `/state/health` matches the rest of the fleet.
| Endpoint | Answers | Fails when |
|---|---|---|
| `/healthz` | is the process up | never — deliberately checks nothing else, so a database blip does not restart pods |
| `/readyz` | can it serve | no database configured, unreachable, or schema not migrated |
| `/state/health` | fleet-shaped status | same as `/readyz`, reported as `degraded` |
Connectivity and schema are checked separately. Both mean not-ready, but they
send an operator to different places — credentials and network, or an unrun
migration — so collapsing them into one message would send people to the wrong
one.
## Tenancy
Every table holding owned data carries a `tenant` key from migration `0001`,
per `business-app-service-contract_v0.1` § 1.3. The service is deployed
single-tenant today; the key is present so a later consolidation is a data copy
rather than a rewrite, and no query may assume it is the only tenant (§ 1.4).
Uniqueness is `(tenant, registry, package_id, version)` — registry-scoped
because identity is (§ 3.2), and tenant-scoped so two tenants may legitimately
hold the same id.
## Schema
| Table | Holds |
|---|---|
| `package_versions` | one immutable `<registry>:<id>@<version>`, its manifest, and indexed discovery fields |
| `package_files` | the package's files as content, not parsed into rows |
| `index_entries` | how a version arrived here (§ 20.3): source, method, `included_at` never overwritten, `last_seen_at` |
## Read API
Routes address packages with the format's own reference syntax
(`<registry>:<id>@<version>`, § 3.2 and § 17) rather than a second one invented
for HTTP. A package id contains `/`, so the reference is captured as a greedy
path and parsed; each route keeps a distinct prefix so the greediness cannot
swallow a neighbouring segment.
| Route | Returns |
|---|---|
| `GET /packages?q=&registry=` | search over id, name, summary and tags |
| `GET /packages/{id}` | the versions of an id |
| `GET /packages/{id}@{version}` | one manifest |
| `GET /archives/{id}@{version}` | the package's files, text or base64 |
| `GET /index` | § 20.3 entries: source, method, `included_at`, `last_seen_at` |
A bare id present in more than one registry returns **409 with the candidates**,
never a guess (§ 3.2). 409 rather than 300 because the request is answerable —
once the caller says which registry they meant.
Asking for a package without naming a version applies § 17.1's selector rules,
so a prerelease is never chosen implicitly.
## Publish API
`POST /packages` takes `{registry, source, files}`, where `files` is the same
shape `GET /archives` returns — so an archive round-trips into a publish
without translation, and a mirror is a GET followed by a POST.
Validation is the reference implementation's, applied to the posted files
materialized in a temporary directory. Only reserved paths and
manifest-referenced files are stored (§ 2); path traversal is refused.
Re-publishing identical content is accepted, different content under the same
`<id>@<version>` is 409 (§ 17).
### What identity means here
**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.
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**, 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
```bash
make image # builds from the repo root; reference/ is a real dependency
make smoke BASE=http://... # service-level checks, exits non-zero on failure
```
The image is a two-stage `python:3.12-slim` build carrying no build toolchain,
running as a non-root user, writing nothing to disk — its state is the database.
It deliberately does **not** run migrations on start-up: a schema change is a
deployment step with its own rollback, not something that races between replicas.
`tools/smoke.py` asserts what can be known about a *running service*: liveness,
readiness, fleet health shape, the migration revision, and that the index and
registry are queryable. It is stdlib-only so it runs inside the runtime image,
and it exits non-zero so a deployment gate can call it directly.
Cluster-level checks a `rapp` contract also names — NetworkPolicies present,
external secrets ready, private-Service-only, live image digest match — are
properties of the deployment rather than of this process, and belong to
`rapp-canned-prompts`.
`--expect-migration` matters: without it the check can only confirm the schema
is stamped at all, and it says so rather than implying it verified the head.
## Deployed
Running on railiance01 since 2026-09-08 via `rapp-canned-prompts`, image tag
0.1.4, schema at alembic `0002`, read-only by design.
Four defects surfaced during that first rollout that the test suite could not
have caught, because each needed a real cluster, a mounted secret, or a live
PostgreSQL:
- `env.py` read `database_url` rather than `resolved_database_url`, so the
migration could not run where the credential is a file;
- `SET ROLE` opened an implicit transaction that Alembic then **nested inside
rather than owning**, so every revision logged as applied and was rolled
back — success reported against an empty database;
- a missing *optional* publish-token file was treated as a hard failure, so the
documented read-only posture returned 500 instead of an explanatory 503;
- and separately in the rapp, an egress NetworkPolicy that did not select the
migration Job at all.
Each now has a regression test. The transaction one is the most worth knowing:
touching an Alembic connection before Alembic does changes who owns the
transaction, and the failure is silent in both directions.
## Status
`CANP-WP-0006` is complete: skeleton and health surface, tenant-keyed schema
(migrations `0001``0002`), read and publish APIs, HTTP registries in the
reference CLI, and a verified container image with smoke checks.
**Not done, and needed before `rapp-canned-prompts`:** the image has been built
and verified locally but never pushed. `rapp.yaml` pins
`upstream_components.version` to a digest from the fleet's registry, and that
digest only exists once the image is published — an operator action needing
registry credentials.
**Per-publisher identity** remains deferred, and is the main thing between this
and a registry several people can publish to.