Registry Token Service architecture
Audience: Operators deploying the Registry Token Service and integrators wiring Docker/OCI clients or the plan-administration API. Source:
src/Registry/StellaOps.Registry.TokenService. Related: token-service operations runbook.
The OCI registry itself is not in this module.
stellaops-registryis the third-party zot registry (ghcr.io/project-zot/zot-linux-amd64:v2.1.3,devops/compose/docker-compose.stella-infra.yml:126-128) — infrastructure, not Stella Ops source. This dossier covers onlystellaops-registry-token, the service that issues that registry’s bearer tokens.
Overview
Registry Token Service is the Stella Ops component that issues short-lived Docker registry bearer tokens for private or mirrored registries. It is designed for offline/self-hosted operation and enforces plan/licence constraints before minting any registry token.
In addition to token exchange, the service exposes an authenticated Plan Administration API for managing the plan-rule catalogue (CRUD, dry-run validation, and audit history) that drives those authorization decisions.
The service surface is small and focused:
- Token exchange:
GET /token(gated by theregistry.token.issuepolicy). - Plan administration:
/api/admin/plans/*(gated by theregistry.adminpolicy/scope). - Operational endpoints:
GET /healthz(liveness),GET /openapi/v1.json(anonymous OpenAPI document), and a build-info endpoint.
Authorization decisions are based on (a) Authority-issued identity token claims and (b) plan rules read from the durable PostgreSQL store (or, in non-durable test hosts, an explicitly registered in-memory store).
Primary responsibilities
- Validate caller identity using Authority-issued tokens (deployment profile may use bearer-only, DPoP, and/or mTLS).
- Authorize requested registry scopes against the configured/persisted plan catalogue.
- Deny issuance for revoked licences and for disabled plans.
- Mint a Docker-registry-compatible JWT with an
accessclaim covering the permitted repository actions. - Provide an admin API for plan CRUD, dry-run validation, and audit history (authorized via the
registry.adminscope). - Emit deterministic observability signals (metrics, traces, structured logs) and unified audit events for audits and ops.
Runtime components
Minimal API host
- Project:
src/Registry/StellaOps.Registry.TokenService - Router service name:
registry-token(registered with the Stella Router microservice integration). - Endpoints:
GET /token(authorized viaregistry.token.issue)GET /api/admin/plansand related plan-admin routes (authorized viaregistry.admin)GET /healthz(unauthenticated liveness)GET /openapi/v1.json(anonymous OpenAPI discovery document for the gateway aggregator)- Build-info endpoint (image provenance for the operator verify aggregator)
Cross-cutting integrations
- Air-gap egress policy (
AddAirGapEgressPolicy) consistent with the offline-first posture. - Tenant services + tenant middleware (
AddStellaOpsTenantServices/UseStellaOpsTenantMiddleware). - Localization + embedded translation bundles (
AddStellaOpsLocalization/AddTranslationBundle); admin error messages are localized via translation keys (e.g.registry.error.plan_not_found). - CORS (
AddStellaOpsCors). - Unified audit emission to the Timeline service (
AddAuditEmission). - OpenTelemetry metrics, runtime instrumentation, and ASP.NET Core / HttpClient tracing (
AddStellaOpsTelemetry).
Auth integration
- Resource server validation is configured from
RegistryTokenService:Authority(issuer, optionalMetadataAddress,RequireHttpsMetadata, audiences). The Authority issuer must be an absolute URI; HTTPS is required unless the issuer is loopback orRequireHttpsMetadatais disabled. - Authorization policies:
registry.token.issue— required to callGET /token. Required scopes default toregistry.token.issue(configurable viaRegistryTokenService:Authority:RequiredScopes).registry.admin— required to call the plan-admin endpoints. Backed by theStellaOpsScopes.RegistryAdmin(registry.admin) scope.
- Note:
registry.adminis a defined constant inStellaOps.Auth.Abstractions/StellaOpsScopes.cs;registry.token.issueis used as a literal scope string in this service and is not (yet) a named constant in that catalogue.
Plan registry (token authorization rules)
- The caller’s plan is read from the
stellaops:planclaim (fallback: configuredDefaultPlan). - Licence revocation uses the
stellaops:licenseclaim and configuredRevokedLicenses(normalized lower-case). - The plan registry resolves the plan either from the durable
IPlanRuleStore(canonical in production) or from the statically configuredPlanslist when no store is wired (test/in-memory hosts only). - A resolved plan that is
enabled = falseis rejected (plan_disabled); an unknown plan name is rejected (plan_unknown). Theenabledflag only exists on durable (PostgresPlanRuleStore) plan rules — the statically configuredPlanRulemodel (RegistryTokenServiceOptions.PlanRule) has noenabledfield, soplan_disabledcan only arise on the durable path. - Plan rules match repositories by wildcard pattern (
*→.*, anchored, case-insensitive) and authorize a request only if every requested action is a subset of the matched repository rule’s allowed actions.
Plan administration API (/api/admin/plans)
- All routes require the
registry.adminpolicy. Routes:GET /api/admin/plans— list plan rules (ordered by name).GET /api/admin/plans/{planId}— get a plan by ID (404if missing).POST /api/admin/plans— create a plan (201;400invalid;409name conflict).PUT /api/admin/plans/{planId}— update a plan; requiresversionfor optimistic concurrency (200;400;404;409version/name conflict).DELETE /api/admin/plans/{planId}— delete a plan (204;404).POST /api/admin/plans/validate— dry-run validation; optionally evaluatestestScopesagainst the rules without persisting.GET /api/admin/plans/audit— paginated audit history (optionalplanIdfilter;page,pageSizeclamped to 1…100).
- The actor recorded in audit entries is derived from the caller’s
nameidentifier/sub/nameclaims. PlanValidatorenforces: required plan name (≤128 chars), valid repository patterns (rejects**, control chars, non-compiling regex), valid actions (pull,push,delete,*), positive rate-limit values, and non-empty allowlist entries; it surfaces warnings for empty repository lists and overlapping patterns.
Plan administration storage
- The admin
IPlanRuleStoreis backed by PostgreSQL (PostgresPlanRuleStore) whenRegistryTokenService:Postgres:ConnectionStringis configured. - Default schema is
registry_token(override viaRegistryTokenService:Postgres:SchemaName). Tables:plan_rules— id, name, description, enabled,repositories/allowlist/rate_limit(jsonb), timestamps, and an integerversionfor optimistic concurrency. A case-insensitive unique index enforces unique plan names.plan_audit— append-only change history (action, actor, timestamp, summary, previous/new version).
- Startup migrations run automatically via
AddStartupMigrationsagainst theregistry_tokenschema. The migration SQL (Migrations/001_initial_schema.sql) is shipped as an embedded resource, so a fresh database converges on startup without manual scripts. - The production TokenService assembly does not provide an in-memory
IPlanRuleStore; tests that need one register the test-local store explicitly throughConfigureTestServices. With no Postgres connection string and outside theTestingenvironment, the host fails fast at startup (durable persistence is mandatory in live runtime). - When Postgres persistence is configured, the host may start without any statically configured
Plans; persisted plan rules become the canonical source for admin CRUD and token issuance.
Token issuer
- Tokens are signed with an RSA private key (algorithm
RS256/SecurityAlgorithms.RsaSha256) loaded fromRegistryTokenService:Signing:KeyPath. The loader (SigningKeyLoader) picks PFX when the path extension is.pfx(optionalKeyPassword; requires an RSA private key) and PEM otherwise (RSA.ImportFromPem). issisSigning:Issuer.auddefaults to the requested registryservicevalue unlessSigning:Audienceis set.kidresolution: an explicitSigning:KeyIdalways wins; otherwise the PFX path defaultskidto the certificate thumbprint, while the PEM path emits nokid.- Token lifetime (
Signing:Lifetime) must be greater than zero and at most 1h (default 5m); values outside this range fail validation at startup.
Observability
- OpenTelemetry metrics (meter
StellaOps.Registry.TokenService):registry_token_issued_total{plan=...}registry_token_rejected_total{reason=...}
- OpenTelemetry tracing for ASP.NET Core and outbound HTTP, plus runtime instrumentation.
- Structured logs via Serilog request logging.
- Unified audit events emitted to the Timeline service.
Request flow
- Docker/OCI client receives a
401from the registry with aWWW-Authenticate: Bearer realm=...,service=...,scope=repository:...challenge. - Client obtains an Authority token with the
registry.token.issuescope (and any required sender constraints for the deployment). - Client calls
GET /token?service=<service>&scope=repository:<repo>:<actions>on Registry Token Service. Thescopequery may repeat or be space-delimited (OAuth2 style); each scope isrepository:<name>:<comma-separated-actions>. - Service validates:
serviceis present (and is allow-listed ifRegistry:AllowedServicesis configured)- requested scopes parse correctly (type must be
repository; at most 3 colon-separated segments; missing actions default topull) - at least one scope is requested
- caller plan/licence claims authorize all requested repository actions
- Service returns a JSON response containing the signed registry token.
Denial paths:
400for malformed requests (servicemissing, invalidscopequery, no scopes requested).403for authorization failures — the requested service not allow-listed, orIssueTokenAsyncdenial with reasonlicense_revoked,plan_unknown,plan_disabled, orscope_not_permitted.- Note:
PlanRegistry.AuthorizeAsyncalso defines ano_scopes_requestedreason, but the/tokenendpoint short-circuits empty scope lists with a400before the issuer runs, so that reason is unreachable over HTTP (it can surface only via direct library use ofIssueTokenAsync/AuthorizeAsync).
Token shape (Docker registry compatible)
The JWT header carries alg = RS256 and, when a key id is resolved (see Token issuer), a kid.
The issued JWT payload includes registered claims iss, aud, nbf, iat, exp, plus:
sub: subject derived fromnameidentifier/client_id/subclaims (falls back toanonymous)jti: per-token unique identifierservice: the requested registry serviceaccess: array of{ type, name, actions[] }entries (type isrepository)- Optional:
stellaops:licensepassthrough claim (for downstream correlation), present only when the caller carries it
The GET /token HTTP response is a JSON envelope:
{
"token": "<jwt>",
"expires_in": 300,
"issued_at": "<ISO-8601 UTC>",
"issued_token_type": "urn:ietf:params:oauth:token-type:access_token"
}
Configuration
Configuration is loaded from:
etc/registry-token.yaml(optional; resolved relative to the content root as../etc/registry-token.yaml)- environment variables prefixed with
REGISTRY_TOKEN_
Key sections are defined by RegistryTokenServiceOptions (root section RegistryTokenService):
Authority(Issuer, optionalMetadataAddress,RequireHttpsMetadata,Audiences,RequiredScopes)Signing(Issuer, optionalAudience,KeyPath, optionalKeyPassword, optionalKeyId,Lifetime)Registry(Realm, allow-listedAllowedServices)Plans,DefaultPlan,RevokedLicenses
Durable plan-rule persistence is configured separately under RegistryTokenService:Postgres (at minimum ConnectionString; optional SchemaName, defaulting to registry_token). When Postgres persistence is configured, the host may start without any statically configured Plans; persisted plan rules become the canonical source for admin CRUD and token issuance. Without a connection string, the host requires at least one statically configured plan and refuses to start in non-test environments.
Roadmap / not-yet-implemented
RateLimitDto(maxRequests,windowSeconds) is part of the admin plan model and persisted inplan_rules, but it is not yet enforced at theGET /tokenissuance path. Treat it as advisory metadata until enforcement lands.- Plan
Allowlist(explicit client identifiers) is stored and validated but is not consulted during/tokenauthorization, which keys on thestellaops:plan/stellaops:licenseclaims.
References
- Operations/runbook:
docs/modules/registry/operations/token-service.md
