Documentation
¶
Overview ¶
Package workloadidentity implements OIDC workload identity tokens for sandbox containers, following the GitHub Actions OIDC pattern.
Trust Model ¶
Each cluster is its own OIDC issuer with an independent signing key. New clusters sign with RS256 (RSA), the universally supported default; clusters provisioned before that default keep their EdDSA key advertised in JWKS for verification while new tokens are signed with a freshly generated RS256 key.
Miren Cloud is never in the signing path. The private key is generated here, stays on disk here, and is not sent anywhere — so a compromise of cloud yields public keys and no ability to mint an identity. Cloud contributes organization_id and cluster_id as claim metadata during registration and, when a cluster opts in, hosts discovery on the cluster's behalf.
Issuer URL (iss claim) ¶
The issuer URL is the cluster's cryptographic identity anchor — it's baked into every token and pinned in external trust configurations. In precedence order:
- The cloud-assigned anchor (https://api.miren.cloud/identity/<cluster-id>), when the cluster is registered and --identity-anchor=cloud. The cluster publishes the public half of its key set and cloud serves discovery for it. This is what lets a cluster behind carrier NAT federate to an outside verifier at all, and keeps federation working while the cluster is down.
- The cloud-provisioned DNS hostname (e.g. https://cluster-abc.miren.systems), with the cluster serving its own discovery. The default, because moving a registered cluster's iss breaks every external trust configuration pinned to the old value.
- cfg.TLS.AdditionalNames[0] for bare-metal clusters without registration, meaning the anchor is determined by config list order. This fallback is intentionally simple for v1; a more deliberate selection mechanism (e.g., an explicit --issuer-url flag) may be warranted if bare-metal OIDC federation sees adoption.
Anchoring per cluster means external verifiers configure trust per cluster rather than once for all of Miren. A central issuer would reduce that to one trust config scoped by claims, but would introduce a single point of compromise for every cluster — which is precisely what keeping the signing keys here avoids, cloud-hosted discovery or not.
A cluster with no hostname at all still gets an issuer, anchored at LocalIssuerURL. Identity is not conditional on being externally addressable: the cluster's own services authenticate to each other with these tokens and verify them in-process against the signing keys, which needs no DNS, no reachability, and no publicly trusted certificate. Only federation to an outside party needs those, and that is a property of the anchor rather than a mode the cluster is in.
Index ¶
- Constants
- Variables
- func PeekSandboxClaims(tokenString string) (app, role string, ok bool)
- func PeekTokenIssuer(tokenString string) (string, bool)
- type Authenticator
- type IdentityType
- type Issuer
- func (iss *Issuer) AcceptedIssuers() []string
- func (iss *Issuer) AcceptsIssuer(issuer string) bool
- func (iss *Issuer) DiscoveryDocument() []byte
- func (iss *Issuer) Hostname() string
- func (iss *Issuer) Hostnames() []string
- func (iss *Issuer) IssueSystemWorkloadToken(workload SystemWorkload, opts TokenOptions) (string, error)
- func (iss *Issuer) IssueToken(app, sandboxID string) (string, error)
- func (iss *Issuer) IssueTokenWithOptions(app, sandboxID string, opts TokenOptions) (string, error)
- func (iss *Issuer) IssuerURL() string
- func (iss *Issuer) JWKSDocument() ([]byte, error)
- func (iss *Issuer) KeySetFingerprint() string
- func (iss *Issuer) PublicKey() any
- func (iss *Issuer) VerificationKeys() []jose.JSONWebKey
- func (iss *Issuer) VerifySystemWorkloadToken(tokenString, expectedAudience string, expectedWorkload SystemWorkload) (*WorkloadClaims, error)
- func (iss *Issuer) VerifyToken(tokenString, expectedAudience string) (*WorkloadClaims, error)
- type IssuerConfig
- type Subject
- type SystemWorkload
- type TokenIssuer
- type TokenOptions
- type Validator
- type WorkloadClaims
Constants ¶
const ( DefaultTTL = 1 * time.Hour MaxTTL = 24 * time.Hour MinTTL = 60 * time.Second // DefaultAudience is the audience stamped on a token whose caller did not // ask for a specific one. Callers guarding a particular service should // always request their own audience and verify it, so that a token minted // for one service cannot be replayed against another. DefaultAudience = "miren" )
const ( CurrentIssuerFile = "workload-identity.issuer" PrevIssuerFile = "workload-identity.prev-issuer" )
Files, under the server data directory, that track the cluster's identity anchor across restarts.
- CurrentIssuerFile is the anchor the last run minted under. Comparing it to the anchor this run resolved is how a move is detected, and the server is the only thing that knows both — which is why the transition is recorded here rather than by whatever flipped the setting.
- PrevIssuerFile is an anchor left behind by a move, still accepted for verification until the tokens carrying it have expired.
const APIAudience = "miren"
APIAudience is the audience carried by the identity token mounted into every sandbox, and the only audience accepted for inbound calls to the cluster API. Tokens minted for external relying parties (AWS STS and friends) request an explicit audience, so they never satisfy this check and cannot be replayed against us.
const LocalIssuerURL = "https://cluster.local"
LocalIssuerURL anchors a cluster that has no hostname to advertise.
It is deliberately a name the cluster already owns rather than a synthetic placeholder: cluster.local is how the cluster refers to itself internally, resolved to whatever is locally appropriate (the registry router on the coordinator, the coordinator's address on a runner). Nothing outside can resolve it at all, which is the honest signal that such a cluster cannot federate to an external party until it is given a real hostname.
Note that this is an identity anchor, not an endpoint. It is compared as a string when verifying, never fetched, so it does not matter that the resolution it names is set up later in startup or means different things in different processes.
Variables ¶
var ErrNoIssuer = errors.New("workloadidentity: no issuer configured")
ErrNoIssuer is returned when a Validator has no usable issuer to verify against. It fails closed rather than accepting anything.
Functions ¶
func PeekSandboxClaims ¶ added in v0.14.0
PeekSandboxClaims reads the app and role from a token WITHOUT verifying its signature. It is only for recovering values from a token this cluster already minted and wrote to local disk — e.g. re-registering a sandbox for token refresh after a controller restart, so it keeps the role it was built with rather than picking up a role the app was reconfigured to since. Never use it to authenticate an inbound token.
func PeekTokenIssuer ¶ added in v0.14.0
PeekTokenIssuer reads the iss claim from a token WITHOUT verifying its signature. Like PeekSandboxClaims, it is only for inspecting a token this cluster already minted and wrote to local disk — used at boot to notice that a mounted token predates an anchor flip and needs rewriting. Never use it to authenticate an inbound token.
Types ¶
type Authenticator ¶ added in v0.14.0
type Authenticator struct {
// contains filtered or unexported fields
}
Authenticator authenticates callers presenting a workload identity token minted by this cluster — code running inside a sandbox, using the token mounted at MIREN_IDENTITY_TOKEN_PATH.
It takes a concrete *Issuer rather than the TokenIssuer interface on purpose: only the coordinator holds the signing key, so only the coordinator can verify. Distributed runners hold a proxy issuer that mints over RPC and cannot satisfy this, which is correct — sandboxes on a runner still dial the coordinator's API, where this authenticator lives.
func NewAuthenticator ¶ added in v0.14.0
func NewAuthenticator(iss *Issuer, logger *slog.Logger) *Authenticator
func (*Authenticator) Authenticate ¶ added in v0.14.0
func (a *Authenticator) Authenticate(ctx context.Context, creds *rpc.Credentials) (*rpc.Identity, error)
Authenticate returns an identity for a valid workload identity token, or (nil, nil) for anything else so the rest of the authenticator chain can try.
It never returns an error: the RPC server treats an authenticator error as terminal and rejects the request, which would break every other credential type the moment a malformed bearer token arrived.
type IdentityType ¶ added in v0.13.0
type IdentityType string
IdentityType names the kind of principal a token represents.
The value travels as its own claim rather than being inferred from the subject. This lets verifiers authorize an identity class without reparsing the subject grammar. Tokens issued before this claim existed carry no identity_type, which reads as "not a system workload" and so fails closed for system-only resources.
It is a defined type so that switches over it are exhaustiveness-checked, the way rpc.AuthMethod already is. Note that this buys nothing at the trust boundary itself: a token arriving from a caller decodes into whatever string it contained, so verification still has to compare the value rather than assume it is one of the constants below.
const ( IdentityTypeSandbox IdentityType = "sandbox" IdentityTypeSystem IdentityType = "system" )
type Issuer ¶
type Issuer struct {
// contains filtered or unexported fields
}
func NewIssuer ¶
func NewIssuer(cfg IssuerConfig) (*Issuer, error)
func (*Issuer) AcceptedIssuers ¶ added in v0.14.0
AcceptedIssuers are the iss values this cluster will verify: the current anchor, and a superseded one still inside its overlap window.
Only the current anchor is ever minted under — see IssuerURL. Verification paths must consult this instead of comparing against IssuerURL, or an anchor flip invalidates every token already in circulation.
func (*Issuer) AcceptsIssuer ¶ added in v0.14.0
AcceptsIssuer reports whether tokens carrying this iss should be verified.
func (*Issuer) DiscoveryDocument ¶
func (*Issuer) Hostnames ¶ added in v0.14.0
Hostnames are the hosts this cluster answers discovery on: the current anchor and a superseded one still inside its overlap window.
The old host keeps serving so an external verifier pinned to the previous anchor can still fetch keys for tokens minted before the flip. Nothing new is issued under it, so the window closes on its own.
func (*Issuer) IssueSystemWorkloadToken ¶ added in v0.13.0
func (iss *Issuer) IssueSystemWorkloadToken(workload SystemWorkload, opts TokenOptions) (string, error)
IssueSystemWorkloadToken mints a token identifying a Miren-owned workload (the sandbox controller, a telemetry writer) rather than a customer workload.
System workload tokens let Miren's own code authenticate to cluster-internal services without a second credential system: they are minted by the same issuer, verified the same way, and inherit the short lifetimes that make revocation tractable. They are deliberately distinguishable from sandbox tokens by the identity_type claim, because the services they open are precisely the ones a sandbox must not reach.
Callers choose an audience scoped to the service they intend to reach, since one workload may call several services. The receiving service verifies that audience and the expected workload together so a token minted for one workload or service cannot be replayed against another.
func (*Issuer) IssueTokenWithOptions ¶
func (iss *Issuer) IssueTokenWithOptions(app, sandboxID string, opts TokenOptions) (string, error)
func (*Issuer) JWKSDocument ¶
func (*Issuer) KeySetFingerprint ¶ added in v0.14.0
KeySetFingerprint identifies the current set of verification keys.
It exists so publication to Miren Cloud can be skipped when nothing changed: the key set turns over on rotation and otherwise stays put for months, so republishing an identical set on every status cycle is pure noise. Sorted before hashing so a reordering of the same keys is not mistaken for a rotation.
func (*Issuer) VerificationKeys ¶ added in v0.14.0
func (iss *Issuer) VerificationKeys() []jose.JSONWebKey
VerificationKeys returns every public key that may have signed a live token: the live signing keys plus any advertised verify-only keys (a demoted legacy EdDSA key, or a previous key kept for rotation overlap). Keys are deduplicated by ID, primary first.
Callers must not cache the result: it tracks whatever key set the Issuer currently holds, which is what keeps verification correct across a rotation.
func (*Issuer) VerifySystemWorkloadToken ¶ added in v0.13.0
func (iss *Issuer) VerifySystemWorkloadToken(tokenString, expectedAudience string, expectedWorkload SystemWorkload) (*WorkloadClaims, error)
VerifySystemWorkloadToken verifies a token and additionally requires that it identifies the expected Miren-owned system workload rather than a customer workload or a different system workload.
Services that only system workloads may reach should call this rather than VerifyToken, so neither the identity type nor workload authorization can be forgotten at a call site. Sandbox tokens are signed by the same key and will verify cleanly; these claims are the only thing separating them.
func (*Issuer) VerifyToken ¶ added in v0.13.0
func (iss *Issuer) VerifyToken(tokenString, expectedAudience string) (*WorkloadClaims, error)
VerifyToken checks a token minted by this issuer and returns its claims.
This is the in-process verification path, for callers that live in the same process as the signing key (the coordinator). It reads the public keys directly and performs no network I/O, so it neither depends on nor races the JWKS endpoint. Verifiers in other processes should fetch JWKS over the issuer's discovery document instead — see pkg/oidcauth's Validator.
expectedAudience is required. A token is only accepted if it names that audience, which is what stops a token minted for one service being replayed against another.
type IssuerConfig ¶
type Subject ¶ added in v0.13.0
type Subject struct {
// contains filtered or unexported fields
}
Subject is the structured identity rendered into a workload token's sub claim. Its segments are private so every subject the issuer accepts has passed through the delimiter validation in newSubject.
type SystemWorkload ¶ added in v0.13.0
type SystemWorkload string
SystemWorkload names a Miren-owned workload that may receive an identity. System workloads are a closed set: adding one is a code change, not a runtime configuration operation.
const ( SystemWorkloadSandboxController SystemWorkload = "sandboxcontroller" SystemWorkloadTelemetryWriter SystemWorkload = "telemetrywriter" SystemWorkloadBuildKit SystemWorkload = "buildkit" )
func ParseSystemWorkload ¶ added in v0.13.0
func ParseSystemWorkload(value string) (SystemWorkload, error)
ParseSystemWorkload converts the string representation used on the wire into a known system workload.
type TokenIssuer ¶
type TokenIssuer interface {
IssueToken(app, sandboxID string) (string, error)
IssueTokenWithOptions(app, sandboxID string, opts TokenOptions) (string, error)
IssueSystemWorkloadToken(workload SystemWorkload, opts TokenOptions) (string, error)
IssuerURL() string
}
TokenIssuer is the minting surface the sandbox controller depends on. The concrete *Issuer satisfies it directly (the coordinator holds the signing key). Distributed runners have no signing key, so they supply an implementation that proxies minting to the coordinator over RPC.
type TokenOptions ¶
type Validator ¶ added in v0.14.0
type Validator struct {
// contains filtered or unexported fields
}
Validator verifies workload identity tokens minted by this cluster.
It deliberately does not reuse the other JWT paths in the tree: pkg/auth is pinned to Ed25519 against Miren Cloud's JWKS, and pkg/oidcauth resolves trust through oidc_binding entities and validates the audience against the request host. Both model a third-party issuer. This one verifies our own issuer, whose keys we already hold in process — no HTTP round-trip to ourselves, and no dependency on the ingress being up to authenticate a call.
func NewValidator ¶ added in v0.14.0
func (*Validator) Validate ¶ added in v0.14.0
func (v *Validator) Validate(tokenString string) (*WorkloadClaims, error)
Validate parses and verifies a token, returning its claims. The returned claims are safe to derive an identity from: issuer, audience, signature, and expiry have all been checked, and the app and sandbox bindings are non-empty.
type WorkloadClaims ¶
type WorkloadClaims struct {
jwt.RegisteredClaims
OrganizationID string `json:"organization_id,omitempty"`
ClusterID string `json:"cluster_id,omitempty"`
App string `json:"app,omitempty"`
// SandboxID deliberately keeps its unconditional encoding: it predates
// system workload tokens and external verifiers may already federate on its
// presence. System workload tokens therefore carry an empty sandbox_id
// rather than omitting it.
SandboxID string `json:"sandbox_id"`
IdentityType IdentityType `json:"identity_type,omitempty"`
SystemWorkload SystemWorkload `json:"system_workload,omitempty"`
// Role is the authorization role the token authenticates as (see
// pkg/workloadroles). Resolved server-side from the app the sandbox belongs
// to; never supplied by the workload. Only sandbox tokens carry it.
Role string `json:"role,omitempty"`
}