Registry Token Service Operations
Operating the Stella Ops registry token service: issuing short-lived Docker registry bearer tokens gated on Authority tokens and plan entitlements.
Audience: Operators deploying and monitoring the registry token service, and integrators wiring Docker/OCI clients to a Stella Ops-fronted registry.
Component: src/Registry/StellaOps.Registry.TokenService
The registry token service issues short-lived Docker registry bearer tokens after validating an Authority OpTok (DPoP/mTLS sender constraint) and the customer’s plan entitlements. It is fronted by the Docker registry’s Bearer realm flow. For the broader registry design and how this service fits in, see the Registry module overview and architecture.
Configuration
Configuration lives in etc/registry-token.yaml and can be overridden through environment variables prefixed with REGISTRY_TOKEN_. Key sections:
| Section | Purpose |
|---|---|
authority | Authority issuer/metadata URL, audience list, and scopes required to request tokens (default registry.token.issue). |
signing | JWT issuer, signing key (PEM or PFX), optional key ID, and token lifetime (default five minutes). The key is operator-supplied and never committed: the default keyPath is the git-ignored etc/secrets/registry-token-signing.pem. Generate it per environment (openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:4096 -out devops/etc/secrets/registry-token-signing.pem), keep custody in your secret store (Vault is the flagship backend), and mount it read-only. The service fails closed with that guidance when the file is missing or unusable — it never generates a key and has no inline fallback (AGENTS.md §2.12). |
registry | Registry realm URL and optional allow-list of service values accepted from the registry challenge. Only allowedServices is enforced (Program.cs); realm is documentation on this side — the value that reaches clients is the one in the registry’s own config. |
plans | Plan catalogue mapping plan name → repository patterns and allowed actions. A pattern compiles to an anchored regex with * → .* (PlanRegistry.Compile), so a single * spans path separators: stella-ops/public/* covers stella-ops/public/a/b. |
defaultPlan | Applied when the caller’s token omits stellaops:plan. |
anonymousPlan | Names the plan minted for callers presenting no credentials. Unset (the default) ⇒ anonymous callers get 401. See Anonymous public reads. |
revokedLicenses | Blocks issuance when the caller presents a matching stellaops:license claim. |
Plan entries must cover every private repository namespace. Actions default to pull if omitted.
Authority-issued plan binding
Authenticated plan assignment belongs to Authority. A Standard-plugin bootstrapClients entry may carry one optional singular plan property; after Authority reconciles that committed configuration, client-credentials tokens for the client carry stellaops:plan=<configured value>. Removing the property removes the stored assignment at the next reconciliation. A client with no assignment emits no plan claim.
Resolution is deliberately asymmetric:
- A non-blank
stellaops:planclaim names the exact plan to resolve. If it does not name an enabled plan, issuance refusesplan_unknown; it never retries withdefaultPlan. - Only a missing or blank claim uses
defaultPlan.
That refusal makes Authority/Registry assignment drift visible instead of silently downgrading a stale elevated binding. SR-10 delivered this capability in the tree without assigning a live publisher client: it did not edit standard.yaml, restart Authority, or perform a real elevated mint. SR-6 owns that live binding and forcing function once the publisher identity/window is selected.
The physical plan_rules.allowlist column is retained only for forward-compatible schema history and has no authorization semantics. Plan create and catalogue import write []; plan update does not write the column; create/update request schemas and OpenAPI do not expose it; response DTOs retain it as a read-only compatibility value. Migration 002_reserve_plan_allowlist.sql records that contract for fresh and upgraded databases without editing applied migration 001_initial_schema.sql.
Which registry is this?
Architect ruling of 2026-08-03 (SPRINT_20260722_024 B1). Two different registries exist, on different infrastructure, with different rules — conflating them is the mistake this section exists to prevent:
| Vendor public registry | Customer masking registry | |
|---|---|---|
| Host | registry.stella-ops.org (+ mirrors.stella-ops.org for the vuln DB) | registry.stella-ops.local in devops/compose |
| Runs | nginx + registry:3.0.0, htpasswd; anonymous pull already live | zot + this token service |
| Anonymous pull | Yes — that is its job | No. Strictly token-only, including stella-ops/public/**. |
| Writes | Authenticated | Authenticated |
Everything below documents the masking registry. Its rule is simple: every pull needs a plan-scoped bearer token, and every write is authenticated. It has no anonymous path and must not be given one — the public surface is served by vendor infrastructure, not by a customer’s deployment.
What is enabled in the tree
devops/compose/zot-config.json carries http.auth.bearer (realm, service, cert), so the registry runs the standard OCI token dance and refuses unauthenticated reads and writes alike. Three values must agree, and drift between them is silent until a client fails:
| Value | zot (zot-config.json) | Token service (registry-token.yaml / compose env) |
|---|---|---|
| Realm | http.auth.bearer.realm = https://stella-ops.local/api/registry/token | registry.realm (documentation only) |
| Service | http.auth.bearer.service = registry.stella-ops.local | must appear in registry.allowedServices |
| Key | http.auth.bearer.cert = /etc/zot/secrets/registry-token-signing-public.crt | signing.keyPath = the private half |
The realm is served by the gateway route ^/api/registry/token(.*) → http://registry-token.stella-ops.local/token$1, present in both gateway configs and pinned by RegistryTokenRealmRouteConfigTests.
The durable plan store authorizes a live deployment
Read this before debugging a 403 with a correct-looking plan file.
When RegistryTokenService:Postgres:ConnectionString is set — which it is on the compose stack and in the release bundle — Program.cs registers IPlanRuleStore, and PlanRegistry.ResolvePlanAsync then resolves every plan from the plan_rules table. It does not fall back to options.Plans at authorization time. On first boot with an empty store and no durable import marker, PlanCatalogueImporter copies the shipped catalogue into PostgreSQL and records CatalogueImported. After that, database rows are authoritative and file edits never overwrite operator changes. If the store is empty after startup, every request answers plan_unknown → 403; treat that as failed catalogue provisioning, not permission to infer grants from YAML.
The shipped YAML is startup validation, the reviewed first-boot catalogue, and the static source for a test/in-memory host configured without Postgres. Once a durable import has completed, it is no longer the live grant.
Consequence for rollout: verify imported plan rows and the durable marker; do not infer them from the file. An empty durable store leaves the registry unusable rather than insecure — fail-closed, but a surprise if the import did not complete.
The shipped plan catalogue
devops/etc/registry-token.yaml ships four plans. Grants are literal — actions lists exactly what the plan buys, because the list default is empty and a rule naming no action is refused at startup (see “Grants never widen” below).
| Plan | Repositories | Actions | Who holds it |
|---|---|---|---|
community (the defaultPlan) | stella-ops/public/*, registries/* | pull | any licensed caller |
enterprise | stella-ops/public/*, stella-ops/enterprise/*, registries/* | pull | enterprise subscribers |
plugin-publisher | stella-ops/plugins/* (pull, push), registries/* (pull) | see left | the CI/operator client that runs stella plugin sign-release |
plugin-consumer | stella-ops/plugins/*, registries/* | pull | deploy targets and agents resolving pluginPull.registry |
plugin-publisher is the only plan in the catalogue that grants a write, and stella-ops/plugins/* is the only namespace it writes to (ADR-040 D5). The split is deliberate: the licence tiers answer “what does this subscription buy?” and must never answer “may this identity ship code?”, so widening a tier can never hand out a push. ShippedRegistryTokenConfigTests pins all of this against the real shipped file.
registries/* — the meta-masking facade over integrated external registries (ADR-041 D3) — is granted on every plan, pull-only, by owner ruling (2026-08-19, decision register docs/product/decisions/20260816-owner-inputs-post-topology-close.md item 20): the facade is usable by any customer type and is never a premium tier. Pull-only is structural, not a licensing choice — the facade refuses writes permanently by design, so a push grant here would promise what the data plane refuses (ShippedRegistryTokenConfigTests.Every_plan_can_pull_through_the_facade_and_none_can_push_to_it pins both halves). Never name a plan carrying this rule as anonymousPlan: the facade fronts customers’ own registries.
Live-estate caveat: the first-boot import is once-ever (see “the live grant” above), so adding this rule to the file does not update an estate whose catalogue was already imported — the grant reaches existing estates through the admin API (PUT /api/admin/plans/{id}), one audited update per plan. The lab estate carries the grant as of 2026-08-19 (four version-checked PUTs, v1 → v2, audited in registry_token.plan_audit; a live mint for repository:registries/…:pull verified 200 through the gateway realm).
Pattern precision matters here. stella-ops/plugins/* compiles to ^stella-ops/plugins/.*$, which covers every nested plugin id and nothing else — in particular it does not admit the bare stella-ops/plugins repository, because ADR-040 D5 makes stella-ops/plugins/<id> the canonical target and a push at the namespace root is a mistake that should fail closed. The publish path that must match is stella plugin sign-release --registry <host>/stella-ops/plugins/<plugin-id>, which OciArtifactPusher turns into repository:stella-ops/plugins/<plugin-id>:pull,push.
Plugins intended for public distribution are a different question: they live under stella-ops/public/plugins/** and ride the anonymous plan in a deployment that has opted in (ADR-040 D4). They are not covered by plugin-publisher/plugin-consumer, and on this registry — the customer masking registry — the anonymous plan stays unset regardless.
Grants never widen
ConfigurationBinder APPENDS bound list items to a non-empty default rather than replacing it. RepositoryRule.Actions used to default to ["pull"], so a rule written actions: [ "push" ] bound to ["pull", "push"] — a read grant nobody asked for, on a repository deliberately scoped to writes. Authority.requiredScopes had the same shape: narrowing it unioned with the default instead of replacing it.
Both defaults are now empty. A repository rule that names no action is rejected at startup with operator guidance rather than silently meaning pull, and an empty requiredScopes means “not configured”, with the registry.token.issue floor applied by the host. Pinned by RegistryTokenOptionsBindingTests. When writing a plan, assume nothing is implied: whatever actions says is exactly what the token will carry.
Anonymous public reads — the capability, and why it ships OFF
Do not enable this on a masking registry. The service can mint anonymous pull-only tokens, and
devops/etc/registry-token.yamlships with that capability disabled (anonymousPlanunset ⇒ anonymous callers get401). The capability exists for the day a genuinely public registry runs zot plus this service; today the vendor’s public surface isregistry.stella-ops.orgon separate infrastructure. The shipped posture is pinned byShippedRegistryTokenConfigTests, andregistry-auth-smoke.shasserts by default that the realm refuses a credential-less caller.
The rest of this section documents the mechanism, and why it lives in the token service rather than in registry config at all.
An anonymous surface cannot be expressed in zot v2.1.3’s accessControl once bearer auth is on, verified against ghcr.io/project-zot/zot-linux-amd64:v2.1.3 rather than inferred:
- An
accessControlblock containingdefaultPolicyor user policies is rejected at startup when the only authn is bearer —zot verifyexits 1 with “access control config requires one of httpasswd, ldap or openid authentication or using only ‘anonymousPolicy’ policies”. - An
anonymousPolicy-only block passesverifyand then does nothing: withhttp.auth.bearerconfigured, an anonymousGET /v2/stella-ops/public/<repo>/tags/liststill returns401with the bearer challenge. zot’s bearer handler is exclusive — the token carries the grants, so the accessControl authorizer never runs.
Shipping the inert block would have looked like an anonymous surface while denying every anonymous pull, so zot-config.json deliberately has no accessControl section.
So the token service is the only place it could live — the path Docker Hub and GHCR take. GET /token is AllowAnonymous, and when (and only when) an anonymous plan is configured, a caller presenting no credentials receives a token carrying only that plan’s grants; the client then completes the ordinary token dance: challenge → realm (no credentials) → retry with the token.
| Caller | Answer |
|---|---|
No Authorization header at all | Anonymous plan grants, narrowed (below). No anonymous plan configured ⇒ 401. |
Authorization present but invalid/expired | 401. Not anonymous — presenting credentials commits the caller to the authenticated branch. Downgrading a failed authentication to anonymous grants would hand a rejected client a working token and hide credential rot behind a permanently green pull. |
Valid Authority token with registry.token.issue | Unchanged: plan resolution, all-or-nothing scope check, 403 on refusal. |
Semantics that make the anonymous branch safe:
- It never consults Authority. No token validation, no metadata fetch, no tenant.
- It narrows, never widens. A Docker client replays whatever scope the registry challenged it with, so requests outside the plan — a gated repository, or any write — are silently dropped from the minted
accessclaim rather than refused. The client then gets the registry’s own401/403for the repository it actually wanted. This is deliberately asymmetric with the authenticated path, which still denies all-or-nothing with403. - Writes are impossible, twice over. The service refuses to start if a statically configured anonymous plan grants any action other than
pull, and narrowing floors grants atpullagain at mint time — so a durable plan rule edited in the database after startup still cannot produce an anonymous write. - Tokens are marked. Subject is
anonymousand the payload carries"stellaops:anonymous": true. Key audit off the marker claim: the authenticated path can also fall back to the subjectanonymousfor a principal carrying no identity claims. There is no separate issuance audit table —plan_auditrecords plan CRUD, not token issuance — so metrics (registry_token_issued_total{plan}) are the issuance trail.
Configuration — not shipped enabled. etc/registry-token.yaml carries this only as a commented opt-in, for a deployment that genuinely serves a public namespace:
anonymousPlan: "anonymous"
plans:
- name: "anonymous"
repositories:
- pattern: "stella-ops/public/*"
actions: [ "pull" ]
Give it its own plan rather than reusing community: the two answer different questions (“what does this licence buy?” vs “what may anyone read?”), and widening a licence tier later must not silently widen the public surface. Startup fails closed if anonymousPlan names a plan absent from the catalogue.
Enablement steps
Generate the key pair —
devops/etc/secrets/README.md. Both halves must exist before either container is recreated: Docker materialises a missing bind-mount source as an empty directory, which panics zot and later makesopensslfail with “Is a directory”. zot needs an X.509 certificate; a barePUBLIC KEYPEM panics it atpkg/api/authn.go:407.Grant
registry.token.issueto the clients that need it. The scope is now catalogued (StellaOpsScopes.RegistryTokenIssue) and seeded as a permission row byS049_registry_token_issue_scope.sql, but — following the S045–S048 posture — the seed grants it to no role and no client. Until an operator grants it, every/tokencall fails authorization and the registry is unpullable — with no anonymous plan configured there is no other path to a token. Grant it per client:UPDATE authority.clients SET allowed_scopes = ( SELECT jsonb_agg(DISTINCT value) FROM jsonb_array_elements_text(allowed_scopes || '["registry.token.issue"]'::jsonb) AS value) WHERE tenant_id = 'default' AND client_id = '<client-id>';Restart Authority afterwards so the client cache picks the change up, then confirm the scope appears in a freshly minted token’s
scopeclaim — a client may only request what it is allowed, so a grant that never reaches the token is the usual cause of a401from the realm. 2a. Verify the durable plan rows. First boot imports the shipped catalogue once; later file changes do not re-import or overwrite audited operator edits (see “The durable plan store authorizes a live deployment”). Confirm the expected rows throughGET /api/admin/plansbefore moving on. An empty store answers403 plan_unknownand is a provisioning failure to diagnose, not a YAML fallback.Recreate
registryandregistry-token(in that order the registry starts challenging; in the other order tokens exist before anyone needs them — either works).Prove it with
tools/scripts/qa/registry-auth-smoke.sh(see below). Treat a green run as the forcing function; “the container is healthy” is not evidence, because zot’s healthcheck iszot verifyon the config file and never touches the socket.
Verifying
tools/scripts/qa/registry-auth-smoke.sh \
--registry https://registry.stella-ops.local \
--realm https://stella-ops.local/api/registry/token \
--authority-token-file /run/secrets/authority-token \
--insecure # lab hosts only: self-signed
The script asserts the challenge advertises the realm and service actually configured, that no repository accepts an anonymous write, that a plan-scoped token opens exactly its grants, and — the anonymous posture — that the realm refuses a credential-less caller. It runs the real token dance to establish that (challenge → parse WWW-Authenticate → call the realm with no credentials), because a bare GET can only ever show you the challenge. It exits with the number of failed assertions.
A bare anonymous GET returning 401 is therefore the expected challenge, not a finding. What would be a finding is the realm handing that caller a token: on a masking registry that means someone set anonymousPlan.
--anonymous-surface public flips the expectation for a deployment that is genuinely meant to serve anonymous pulls through zot plus this service — the dance must then complete for stella-ops/public/**, and the minted token must still open nothing else and refuse a push. That mode does not describe registry.stella-ops.org today, which is nginx + registry:3.0.0 and does not use this token service at all.
Request flow
- Docker/OCI client contacts the registry and receives a
401withWWW-Authenticate: Bearer realm=...,service=...,scope=repository:.... - Client acquires an OpTok from Authority (DPoP/mTLS bound) with the
registry.token.issuescope. On a masking registry there is no way to skip this. - Client calls
GET /token?service=<service>&scope=repository:<name>:<actions>against the token service, presenting the OpTok and matching DPoP proof. - The service validates the token, plan, and requested scopes, then issues a JWT containing an
accessclaim conforming to the Docker registry spec.
A caller presenting no Authorization header takes the anonymous branch, which answers 401 unless an anonymous plan is configured — it is not, by default (see Anonymous public reads).
Denial paths return RFC 6750-style problem responses: HTTP 400 for malformed scopes, 401 when credentials are absent-and-unserveable or present-and-invalid, 403 for plan or revocation failures, and 503 when the host is not provisioned for issuance.
Monitoring
The service emits OpenTelemetry metrics via registry_token_issued_total and registry_token_rejected_total. Suggested Prometheus alerts:
| Metric | Condition | Action |
|---|---|---|
registry_token_rejected_total | increase(...) > 0 over 5 minutes | Investigate plan misconfiguration or licence revocation. |
registry_token_issued_total | Sudden drop compared to baseline | Confirm registry is still challenging with the expected realm/service. |
Enable the built-in /healthz endpoint for liveness checks. Authentication and DPoP failures surface via the service logs (Serilog console output).
Sample deployment
dotnet run --project src/Registry/StellaOps.Registry.TokenService \
--urls "http://0.0.0.0:8085"
curl -H "Authorization: Bearer <OpTok>" \
-H "DPoP: $(dpop-proof ...)" \
"http://localhost:8085/token?service=registry.stella-ops.local&scope=repository:stella-ops/public/base:pull"
Replace <OpTok> and DPoP with tokens issued by Authority. The response contains token, expires_in, and issued_at fields suitable for Docker/OCI clients.
The same call with no Authorization header takes the anonymous branch, which returns 401 with the shipped config and only issues a token where an operator has deliberately configured an anonymousPlan:
curl -i "http://localhost:8085/token?service=registry.stella-ops.local&scope=repository:stella-ops/public/base:pull"
