workloadidentity

package
v0.16.2 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Sep 29, 2026 License: Apache-2.0 Imports: 25 Imported by: 0

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

View Source
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"
)
View Source
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.
View Source
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.

View Source
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

View Source
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

func PeekSandboxClaims(tokenString string) (app, role string, ok bool)

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

func PeekTokenIssuer(tokenString string) (string, bool)

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

func (iss *Issuer) AcceptedIssuers() []string

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

func (iss *Issuer) AcceptsIssuer(issuer string) bool

AcceptsIssuer reports whether tokens carrying this iss should be verified.

func (*Issuer) DiscoveryDocument

func (iss *Issuer) DiscoveryDocument() []byte

func (*Issuer) Hostname

func (iss *Issuer) Hostname() string

func (*Issuer) Hostnames added in v0.14.0

func (iss *Issuer) Hostnames() []string

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) IssueToken

func (iss *Issuer) IssueToken(app, sandboxID string) (string, error)

func (*Issuer) IssueTokenWithOptions

func (iss *Issuer) IssueTokenWithOptions(app, sandboxID string, opts TokenOptions) (string, error)

func (*Issuer) IssuerURL

func (iss *Issuer) IssuerURL() string

func (*Issuer) JWKSDocument

func (iss *Issuer) JWKSDocument() ([]byte, error)

func (*Issuer) KeySetFingerprint added in v0.14.0

func (iss *Issuer) KeySetFingerprint() string

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) PublicKey

func (iss *Issuer) PublicKey() any

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 IssuerConfig struct {
	DataPath       string
	IssuerURL      string
	OrganizationID string
	ClusterID      string
}

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.

func (Subject) String added in v0.13.0

func (s Subject) String() string

String renders the subject as alternating colon-delimited labels and values.

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 TokenOptions struct {
	Audience []string
	TTL      time.Duration
	// Role is the authorization role to embed. Empty means the default
	// (workloadroles.Default). An unknown role name is embedded as-is and is
	// denied everything at authorize time, so a misconfiguration fails closed.
	Role string
}

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 NewValidator(iss *Issuer) *Validator

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"`
}

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL