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
215 lines
9.9 KiB
Markdown
215 lines
9.9 KiB
Markdown
# 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=®istry=` | 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.
|