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-issued access token and the caller’s plan entitlements. It is the realm of the Stella Registry data plane (registry-web) in the Docker registry 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 (required, absolute URI) and optional allow-list of service values accepted from the registry challenge. Only allowedServices is enforced at request time (Program.cs); the realm clients follow is the one registry-web advertises (Registry:Bearer:Realm). |
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. A repository rule must list its actions; a rule without actions is refused at startup (see “Grants never widen” below). A requested scope without actions (repository:<name>) asks for pull.
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. The committed Standard-plugin configuration (devops/etc/authority/plugins/standard.yaml) assigns plan: "plugin-publisher" to the stellaops-plugin-publisher client, which carries only registry.token.issue and the audiences stellaops and registry. Both audiences are needed: registry for the token service and stellaops for the gateway that fronts the realm.
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 / stella-registry.stella-ops.local in an estate |
| Runs | nginx + registry:3.0.0, htpasswd; anonymous pull already live | registry-web + 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
registry-web enforces bearer authentication on every read and write and runs the standard OCI token dance. These values must agree between the data plane and the token service, and drift between them is silent until a client fails:
| Value | registry-web (compose env) | Token service (registry-token.yaml / compose env) |
|---|---|---|
| Realm | Registry__Bearer__Realm = https://stella-ops.local/api/registry/token | registry.realm |
| Service | Registry__Bearer__Service = stella-registry.stella-ops.local | must appear in registry.allowedServices |
| Issuer | Registry__Bearer__Issuer = https://registry-token.stella-ops.local | signing.issuer |
| Key | Registry__Bearer__SigningCertificatePath = /etc/secrets/registry-token-signing-public.crt (public X.509 certificate) | signing.keyPath = the private half |
registry-web answers on both registry.stella-ops.local and stella-registry.stella-ops.local but challenges with the configured service. Take the service from the 401 challenge, never from the URL: a service that is not in allowedServices is refused with 403 “service is not permitted”, which reads like a grant failure.
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 runsregistry-webplus 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. registry-web has no anonymous access-control layer of its own: every request is authorized by the access claim of a bearer token. An anonymous surface can therefore only be expressed in the token service.
This is 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.registry-webneeds an X.509 certificate for the public half, refuses to start when it is missing or unreadable, and stops accepting tokens once the certificate’s validity period has lapsed.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
registry-webandregistry-token(either order works).Prove it with
tools/scripts/qa/registry-auth-smoke.sh(see below). Treat a green run as the forcing function; a healthy container is not evidence of a working token dance.
Verifying
tools/scripts/qa/registry-auth-smoke.sh \
--registry https://stella-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 registry-web 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 access token from Authority 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 (through the gateway realm), presenting the access token and, for a DPoP-bound token, the 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"
