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:

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

  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. 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 registryCustomer masking registry
Hostregistry.stella-ops.org (+ mirrors.stella-ops.org for the vuln DB)registry.stella-ops.local in devops/compose
Runsnginx + registry:3.0.0, htpasswd; anonymous pull already livezot + 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

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:

Valuezot (zot-config.json)Token service (registry-token.yaml / compose env)
Realmhttp.auth.bearer.realm = https://stella-ops.local/api/registry/tokenregistry.realm (documentation only)
Servicehttp.auth.bearer.service = registry.stella-ops.localmust appear in registry.allowedServices
Keyhttp.auth.bearer.cert = /etc/zot/secrets/registry-token-signing-public.crtsigning.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).

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 zot 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, 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:

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.

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, which panics zot and later makes openssl fail with “Is a directory”. zot needs an X.509 certificate; a bare PUBLIC KEY PEM panics it at pkg/api/authn.go:407.

  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 and registry-token (in that order the registry starts challenging; in the other order tokens exist before anyone needs them — either works).

  4. 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 is zot verify on 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

  1. Docker/OCI client contacts the registry and receives a 401 with WWW-Authenticate: Bearer realm=...,service=...,scope=repository:....
  2. Client acquires an OpTok from Authority (DPoP/mTLS bound) 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, presenting the OpTok and 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"