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.

Currency note (2026-08-11). The Registry is a TWO-ROLE family. registry-token issues the bearer tokens (this file’s first half); registry-web is the Stella Registry data plane (src/Registry/StellaOps.Registry.WebService, documented under The data plane (registry-web)). They share one database and one family, and deliberately share NO source edge — the contract between them crosses as a signed token verified against a certificate, which is also what lets them deploy and restart independently.

LIVE SINCE THE SR-6 CUTOVER, 2026-08-21. The Stella Registry serves registry.stella-ops.local in the lab estate and the interim zot is retired — container removed, compose key gone, its volume retained as the rollback. Proven, not assumed: a plugin bundle was signed and published through the ADVERTISED realm and pulled back by digest (@sha256:591d56ac…), answering 200 with a plan-scoped token and 401 without, with the manifest re-hashing to the requested digest; the alias was then re-verified THROUGH the inherited name after the flip. registry_token.plan_rules holds the four shipped plans.

Two things this banner used to warn about are now closed and one distinction survives:

  • the alias still points at the interim zot — moved;
  • the plan catalogue has not been imported, so plan_rules is empty — imported;
  • the meta-masking facade is still not configured in the lab (no Registry__Facade__*), so registries/<slug> answers the honest “facade is not configured” refusal there. Its mechanism IS proven — SR-8’s forcing function ran it end to end against a real customer registry with referrers from the meta SoR — but that ran in a test host, not in this estate.

Read “served” as reachable in the lab today, EXCEPT for the facade, which is implemented and conformance-tested rather than configured. Sections whose truth depends on a cutover keep their own currency marker.

What changed about the sentence that used to be here. It read: “the OCI registry itself is not in this module — stellaops-registry is the third-party zot registry, infrastructure, not Stella Ops source.” That is still true of the compose key stellaops-registry during the interim window, but it is no longer true of the module: the product’s own OCI registry is Stella Ops source now, and zot’s end state is the demo-integration simulator.

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:

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

Runtime components

Minimal API host

Cross-cutting integrations

Auth integration

Plan registry (token authorization rules)

Plan administration API (/api/admin/plans)

Plan administration storage

Token issuer

Observability

Request flow

  1. Docker/OCI client receives a 401 from the registry with a WWW-Authenticate: Bearer realm=...,service=...,scope=repository:... challenge.
  2. Client obtains an Authority token with the registry.token.issue scope (and any required sender constraints for the deployment).
  3. Client calls GET /token?service=<service>&scope=repository:<repo>:<actions> on Registry Token Service. The scope query may repeat or be space-delimited (OAuth2 style); each scope is repository:<name>:<comma-separated-actions>.
  4. Service validates:
    • service is present (and is allow-listed if Registry:AllowedServices is configured)
    • requested scopes parse correctly (type must be repository; at most 3 colon-separated segments; missing actions default to pull)
    • at least one scope is requested
    • caller plan/licence claims authorize all requested repository actions
  5. Service returns a JSON response containing the signed registry token.

Denial paths:

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:

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:

Key sections are defined by RegistryTokenServiceOptions (root section RegistryTokenService):

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.

The data plane (registry-web)

Source: src/Registry/StellaOps.Registry.WebService. Own database stellaops_registry (schema registry, fail-closed on STELLAOPS_POSTGRES_REGISTRY_CONNECTION), own object-store bucket. It validates the bearer tokens registry-token mints — same issuer, same service audience, same access claim — and never references that project in source; the contract between the two roles is a signed token verified against a certificate.

Distribution-spec surface, named by the spec’s own endpoint identifiers (which is also how the official conformance suite groups its cases):

EndpointRouteStatus
end-1GET /v2/served
end-2 / end-3GET/HEAD blobs, manifestsserved (SR-2)
end-4aPOST /v2/<name>/blobs/uploads/202, session start
end-4bPOST /v2/<name>/blobs/uploads/?digest=201, monolithic single POST
end-5PATCH /v2/<name>/blobs/uploads/<ref>202, or 416 on an out-of-order chunk
end-6PUT /v2/<name>/blobs/uploads/<ref>?digest=201, optional final chunk in the body
end-7PUT /v2/<name>/manifests/<ref>201, OCI-Subject echoed when the manifest has a subject
end-8a / end-8bGET /v2/<name>/tags/list[?n=&last=]200, lexical order, Link: rel="next"
end-9 / end-10DELETE manifest / blob405 UNSUPPORTED— deletion is GC policy, SR-4
end-11POST /v2/<name>/blobs/uploads/?mount=&from=201, or 202 fallback when the mount cannot be satisfied
end-12a / end-12bGET /v2/<name>/referrers/<digest>200 with an image index, always — never 404
end-13GET /v2/<name>/blobs/uploads/<ref>204 with the resumable cursor

Load-bearing behaviours an integrator should not have to discover:

Proven by the official suite. The OCI distribution-spec conformance suite is vendored at pinned v1.1.1 under tools/conformance/oci-distribution-spec/ (dev/test only — never distributed; the non-release guard is VendoredConformanceSuiteIsolationConformanceTests). Last measured result: 65 passed / 0 failed / 14 skipped — Pull 23/23, Push 27/27, Referrers 4/4. Content Management is disabled rather than failing: end-9/end-10 answer the spec’s 405 UNSUPPORTED because deletion is GC policy (below), not a client-callable route.

Reading the raw junit.xml reports tests="80"; that count includes a non-spec html custom reporter entry. 65 is the spec-case number.

Referrers (end-12a / end-12b)

Deletion is GC policy, not a route

end-9/end-10 answer 405 permanently. Reclamation runs as a sweep (Gc/RegistryGarbageCollector.cs), and the shape is deliberate:

The meta-masking facade (registries/<slug>/…)

Currency (2026-08-21): the FORCING FUNCTION HAS RUN; this estate is still not configured for it. The ADR-041 D3 proof landed at SR-8 and is no longer “scheduled” — a real customer image was pulled through registries/<slug> from a real zot, byte-for-byte and re-hashing to the requested digest, with its referrers ABSENT when read directly from the customer registry and present when read through the facade, fetched over real HTTP from a different origin. That is the contract demonstrated, not asserted.

What remains unverified is deployment, and only that: the lab estate sets no Registry__Facade__* keys, so registries/<slug> there answers the honest “the meta-masking facade is not configured” refusal. Treat the behaviour as proven and any claim about how a particular estate is CONFIGURED as unverified — including this one, which is measured as of the date above and is exactly the kind of fact that changes without touching this file.

ADR-041 D3 gives registries/<slug>/<repo> to a pull-through of a tenant’s own registry, with Stella’s metadata overlaid on top. Two rules make it more than a naming convention:

It relays; it does not cache. The upstream leg is OciDistributionClient, the read half of StellaOps.Oci.Core — deliberately NOT OciPullThrough, which is a store primitive right for the agent’s loopback cache and wrong here: caching would write customer bytes into Stella’s own bucket under the very namespace reserved to prevent that, arriving through the server instead of a client PUT. A caching tier, if ever wanted, is an explicit custody decision.

ConcernBehaviour
Slug → registryGET /api/v1/integrations/registries/by-slug/{slug} on Integrations (owner API, not a compiled reference). Returns a credential REFERENCE, never material.
Unknown vs disabled slugIndistinguishable from outside — both 404 NAME_UNKNOWN, or the response becomes an oracle for which tenants have which registries connected.
Integrations unreachableRefusal, never a fallback to the own store: an outage must not become namespace confusion.
CredentialsauthRefUri de-referenced in-process via ISecretProvider under the Registry’s own identity.
Tags400 DIGEST_INVALID — digest-pinned references only.
ManifestsVerified BEFORE a byte is sent; upstream mismatch is 502.
BlobsStreamed with an incremental hash; a mismatch ABORTS the response mid-flight.
ReferrersFrom the meta system of record via Scanner’s owner API, never a DB read.
Tag listing501 UNSUPPORTED — not part of a digest-pinned pull path.

Three behaviours worth stating explicitly, because each has a plausible-looking wrong alternative:

Trap for whoever next touches Scanner’s referrers producer. Scanner accepts ?artifactType= and filters silently — it emits no OCI-Filters-Applied. The facade therefore fetches UNFILTERED and filters locally, because a filtered body without that header is wrong in the one way the conformance suite checks. If the producer ever starts emitting the header, revisit RegistryFacadePullThrough.ServeReferrersAsync rather than assuming pass-through became safe.

Configuration (all off by default; the facade and the meta overlay are separate opt-ins):

KeyMeaning
Registry:Facade:IntegrationsBaseAddressEmpty = facade off. No default — a default would point this registry at a host nobody chose.
Registry:Facade:TenantTenant whose integration records resolve. Required when the facade is on; a blank tenant with a set address is rejected at startup because it looks configured and resolves nothing.
Registry:Facade:ScannerBaseAddressEmpty = meta overlay off (referrers answer 501).
Registry:Facade:ScannerScopeDefault scanner.scans.read.
Registry:Facade:Authority:{Issuer,ClientId,ClientSecret}Service-token identity for the meta overlay. The Registry talks to Scanner as ITSELF — the caller’s registry bearer is a different issuer’s token whose repository-shaped grants mean nothing there.
Crypto:SecretProvider:Backendvault / openbao for facade upstream credentials (see the builtin:// note above).

Known precision limit. Scanner’s referrers row is keyed on (tenant, registry_host, repo_path, subject_digest) but its HTTP contract exposes only repo path and digest, so two integrated registries in one tenant sharing a repository path and a subject digest see the same metadata. That is defensible — the metadata is about content, and an identical digest is the identical image — but it is a precision limit, not a designed guarantee. Making it exact needs a registryHost filter on the producer route.

Roadmap / not-yet-implemented

References