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:

SectionPurpose
authorityAuthority issuer/metadata URL, audience list, and scopes required to request tokens (default registry.token.issue).
signingJWT 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).
registryRegistry 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).
plansPlan 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.
defaultPlanApplied when the caller’s token omits stellaops:plan.
anonymousPlanNames the plan minted for callers presenting no credentials. Unset (the default) ⇒ anonymous callers get 401. See Anonymous public reads.
revokedLicensesBlocks 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:

  1. A non-blank stellaops:plan claim names the exact plan to resolve. If it does not name an enabled plan, issuance refuses plan_unknown; it never retries with defaultPlan.
  2. 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 registryCustomer masking registry
Hostregistry.stella-ops.org (+ mirrors.stella-ops.org for the vuln DB)registry.stella-ops.local / stella-registry.stella-ops.local in an estate
Runsnginx + registry:3.0.0, htpasswd; anonymous pull already liveregistry-web + this token service
Anonymous pullYes — that is its jobNo. Strictly token-only, including stella-ops/public/**.
WritesAuthenticatedAuthenticated

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:

Valueregistry-web (compose env)Token service (registry-token.yaml / compose env)
RealmRegistry__Bearer__Realm = https://stella-ops.local/api/registry/tokenregistry.realm
ServiceRegistry__Bearer__Service = stella-registry.stella-ops.localmust appear in registry.allowedServices
IssuerRegistry__Bearer__Issuer = https://registry-token.stella-ops.localsigning.issuer
KeyRegistry__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).

PlanRepositoriesActionsWho holds it
community (the defaultPlan)stella-ops/public/*, registries/*pullany licensed caller
enterprisestella-ops/public/*, stella-ops/enterprise/*, registries/*pullenterprise subscribers
plugin-publisherstella-ops/plugins/* (pull, push), registries/* (pull)see leftthe CI/operator client that runs stella plugin sign-release
plugin-consumerstella-ops/plugins/*, registries/*pulldeploy 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.yaml ships with that capability disabled (anonymousPlan unset ⇒ anonymous callers get 401). The capability exists for the day a genuinely public registry runs registry-web plus this service; today the vendor’s public surface is registry.stella-ops.org on separate infrastructure. The shipped posture is pinned by ShippedRegistryTokenConfigTests, and registry-auth-smoke.sh asserts 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.

CallerAnswer
No Authorization header at allAnonymous plan grants, narrowed (below). No anonymous plan configured ⇒ 401.
Authorization present but invalid/expired401. 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.issueUnchanged: plan resolution, all-or-nothing scope check, 403 on refusal.

Semantics that make the anonymous branch safe:

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

  1. 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-web needs 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.

  2. Grant registry.token.issue to the clients that need it. The scope is now catalogued (StellaOpsScopes.RegistryTokenIssue) and seeded as a permission row by S049_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 /token call 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 scope claim — a client may only request what it is allowed, so a grant that never reaches the token is the usual cause of a 401 from 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 through GET /api/admin/plans before moving on. An empty store answers 403 plan_unknown and is a provisioning failure to diagnose, not a YAML fallback.

  3. Recreate registry-web and registry-token (either order works).

  4. 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

  1. Docker/OCI client contacts the registry and receives a 401 with WWW-Authenticate: Bearer realm=...,service=...,scope=repository:....
  2. Client acquires an access token from Authority with the registry.token.issue scope. On a masking registry there is no way to skip this.
  3. 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.
  4. The service validates the token, plan, and requested scopes, then issues a JWT containing an access claim 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:

MetricConditionAction
registry_token_rejected_totalincrease(...) > 0 over 5 minutesInvestigate plan misconfiguration or licence revocation.
registry_token_issued_totalSudden drop compared to baselineConfirm 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"