Documentation
¶
Overview ¶
Package controlplane is the authenticated API surface over the agent's resource store: the one boundary the CLI, the web dashboard, and remote peers read and act through. This file is the authentication and authorization model; server.go serves the read/watch API over it.
The model is scope-based and identity-first: every request resolves to a Principal carrying a Scope, and a handler requires a minimum scope. Local, single-operator use authenticates with a bearer token; cross-instance delegated authority (public-key, attenuable capability tokens) is a later layer that plugs in behind the same Authenticator boundary, so the write and remote paths inherit authentication that already exists.
Package controlplane is the read model over the resource store: one generic way to list, describe, and watch any registered kind. The command-line reader, a future remote API, and a dashboard all read through this package instead of each re-deriving per-kind queries, so there is a single read path to keep correct. A kind becomes listable by supplying a Descriptor (its display columns); nothing here is hard-coded to a specific kind, so adding a kind never edits this package.
Index ¶
- Constants
- Variables
- func Attenuate(tok string, holder *Identity, nextAudience ed25519.PublicKey, scope Scope, ...) (string, error)
- func ExternalName(pub ed25519.PublicKey, base, purpose string, c Constraints) (string, error)
- func Issue(issuer *Identity, audience ed25519.PublicKey, scope Scope, ...) (string, error)
- func Name() func(resource.Resource) string
- func ParsePrincipalID(id string) (ed25519.PublicKey, error)
- func PrincipalID(pub ed25519.PublicKey) string
- func SignProof(holder *Identity, method, path, nonce string, now time.Time) (string, error)
- func SpecField(path ...string) func(resource.Resource) string
- func StatusField(path ...string) func(resource.Resource) string
- type ActionFunc
- type ActionSpec
- type Authenticator
- type Authority
- type Change
- type ChangeKind
- type Column
- type Constraints
- type DelegationAuthenticator
- type DenyAll
- type Descriptor
- type Detail
- type FieldDelta
- type Identity
- type NameSource
- type Option
- type PollWatcher
- type PossessionAuthenticator
- type PossessionOption
- type Principal
- type ResolvedName
- type Row
- type Scope
- type SeedVault
- type Server
- type Table
- type TokenAuthenticator
- type Watcher
Constants ¶
const ( // AuditStream is the spine stream every control-plane access decision is recorded // on, separate from the resource stream the watch tails. AuditStream = "controlplane.audit" // EvAccess is the event type of one access decision. EvAccess = "controlplane.access" // EvApproval is the event type of one signed-approval decision for a dangerous // verb: who authorized (the contributing key ids), the bound action and target, and // whether it was granted. It is the non-repudiable "who approved what" record, // distinct from the access decision EvAccess. EvApproval = "controlplane.approval" )
Audit constants name the immutable record of who accessed the control plane. Every authenticated request records its decision on AuditStream as an EvAccess event, so the access history is a replayable fold over the spine, not a best-effort log.
const IdentityVaultRef = "controlplane/instance-identity"
IdentityVaultRef is the vault reference the instance identity seed is sealed under.
const ProofHeader = "Flynn-Proof"
ProofHeader is the request header carrying the signed possession proof.
Variables ¶
var ErrUnauthenticated = errors.New("controlplane: unauthenticated")
ErrUnauthenticated means no valid credential was presented.
Functions ¶
func Attenuate ¶
func Attenuate(tok string, holder *Identity, nextAudience ed25519.PublicKey, scope Scope, actions []string, notAfter time.Time) (string, error)
Attenuate appends a narrowing block to a token. The holder must be the token's current leaf audience (it signs with the key that block named), and the new block restricts the scope and names an explicit action subset and the next audience. Attenuation can only shrink authority; Verify rejects any block that tries to widen it. The result is a longer chain delegating a strictly smaller authority to nextAudience.
func ExternalName ¶
ExternalName projects a public key into a name valid under c, beginning with the human-readable base and ending in a suffix derived from the key. The suffix is a hash of the key under this resource purpose, encoded into c.Charset, so two different identities almost never share a name and the same identity always derives the same one. It is the external-namespace companion to PrincipalID, which renders the same key as the internal id. base is the literal prefix that appears in the name (for example "flynn-agent"); purpose names the resource role and feeds only the hash (for example "fly-app"), so one identity derives distinct names for distinct roles. It returns an error when base or the constraints cannot yield a valid name (for example a base too long for c.MaxLen).
func Issue ¶
func Issue(issuer *Identity, audience ed25519.PublicKey, scope Scope, grant capability.Grant, notAfter time.Time) (string, error)
Issue mints a root capability token: the issuer signs scope and grant to an audience public key (the holder authorized to exercise or further attenuate it), valid until notAfter. The token verifies offline against the issuer's public key.
func Name ¶
Name projects the resource's logical name. It is the column nearly every kind lists first.
func ParsePrincipalID ¶
ParsePrincipalID decodes a principal id minted by PrincipalID back into its Ed25519 public key. It is the inverse of PrincipalID, used to recover the leaf key a verified token was issued to so a possession proof can be checked against it. A malformed id or a key of the wrong length fails closed.
func PrincipalID ¶
PrincipalID renders an Ed25519 public key as a stable, self-certifying principal id.
func SignProof ¶
SignProof builds the signed possession proof a holder presents alongside its token. The holder signs that it possesses the leaf key (identified by holder.ID()) and binds the proof to this exact request (method, path) at this instant, with a single-use nonce. The returned value is placed in the ProofHeader. The nonce must be unique per request (ids.Token in production); reusing one is refused by the server as a replay.
Types ¶
type ActionFunc ¶
ActionFunc executes an action verb against a resolved resource, once the gate has admitted it. It runs with the effective (intersected) capability grant already bound to ctx, so any further dispatch it performs is checked against the same authority. Its result is JSON-encoded into the response; an error is mapped to an HTTP status.
type ActionSpec ¶
type ActionSpec struct {
// Action is the dispatch action name the gate checks against the effective grant.
// It is what authority the verb requires, named so the same grant vocabulary that
// governs local dispatch governs remote action.
Action string
// MinScope is the coarse access level the verb requires (operator or admin). A
// token below it is refused before the grant gate is even consulted.
MinScope Scope
// Dangerous marks a verb that the signed-approval gate must also clear (remote
// dispatch, credential release, destructive/irreversible). It is a declarative
// property so a new dangerous verb cannot forget the gate: a Dangerous verb fails
// closed if no approval verifier is configured, and requires a fresh, bound,
// single-use signature before it runs, regardless of how broad the caller's scope
// or grant is.
Dangerous bool
// Quorum is how many distinct authorized signatures a dangerous verb requires
// (M-of-N). Zero is treated as one for a Dangerous verb; it is ignored for a
// non-dangerous verb.
Quorum int
// Run performs the verb after admission.
Run ActionFunc
}
ActionSpec declares an action subresource: a kube-style verb on a resource kind, distinct from a read. The control-plane owns the gate; the kill-switch and lifecycle layers own the actual verbs and register them with WithAction.
type Authenticator ¶
Authenticator resolves a request to a Principal, or an error if it cannot be authenticated. Implementations must be safe for concurrent use.
type Authority ¶
type Authority struct {
Subject string
Scope Scope
Grant capability.Grant
}
Authority is the effective permission a verified token confers: the leaf subject it was delegated to, the scope, and the capability grant (the intersection of every block in the chain). It is the value a request gate checks an action against.
func Verify ¶
Verify checks a token against the trusted issuer key as of now and returns the effective authority it confers: the leaf subject, the minimum scope across the chain, and the intersection of every block's grant. It fails closed on any broken signature, any block that tries to widen authority (a higher scope or an action the parent did not hold), an expired block, or a malformed token, so a token can never confer more than the issuer delegated.
type Change ¶
type Change struct {
Kind ChangeKind
Resource resource.Resource
}
Change is one resource transition reported by a Watcher.
type ChangeKind ¶
type ChangeKind string
ChangeKind labels how a resource changed between two polls.
const ( // Added is a resource that appeared since the last poll. Added ChangeKind = "Added" // Modified is a resource whose stored version advanced since the last poll. Modified ChangeKind = "Modified" // Deleted is a resource that was live at the last poll and is now gone. Deleted ChangeKind = "Deleted" )
type Column ¶
Column is one display column of a kind's table: a header and a pure projection from a resource to its cell text. A projection must be deterministic (no clock, no randomness) so a List renders identically on every machine and under replay.
type Constraints ¶
Constraints describes a provider namespace's rules so one derivation serves every provider: the rules are data, not a per-provider code path. The derived suffix uses only `Charset`; the whole name is at least `MinLen` and at most `MaxLen`; when `LeadLetter` is set the name begins with an ASCII letter (the safest-everywhere lead, valid even where a leading digit is not). `Separator` is the literal joiner between the human-readable base and the derived suffix; it is legal in a name but never appears in the suffix, so a derived name can never carry a leading, trailing, or doubled separator.
func DNSName ¶
func DNSName(maxLen int) Constraints
DNSName returns the constraints for an RFC 1123 DNS label capped at maxLen: lowercase alphanumerics joined by single hyphens, beginning with a letter. This one shape covers the common providers, whose names are all DNS labels: a Fly app (it becomes `<app>.fly.dev`), a DNS record, and a DNS-addressable object-store bucket. A provider's own length limit is the only thing that varies, so it is the single parameter; a caller that needs a different floor sets `MinLen` on the returned value.
func (Constraints) Validate ¶
func (c Constraints) Validate(name string) error
Validate reports whether name satisfies the constraints: every character is in the charset or is a separator, the length is within the window, the name begins with a letter when required, and it neither begins nor ends with a separator (the rule every DNS-label provider enforces). A caller's override is checked through this before it is used.
type DelegationAuthenticator ¶
type DelegationAuthenticator struct {
// contains filtered or unexported fields
}
DelegationAuthenticator resolves a presented capability token to a Principal, implementing the Authenticator boundary the server already gates every request through. It is the bridge from the cryptographic delegation layer to the request model: a bearer token is verified offline against the trusted issuer key, and the authority it proves (subject, scope, and the monotonically attenuated Grant) becomes the Principal the action gate later checks. Because Verify fails closed on any forged, widened, expired, or malformed chain, an unverifiable token is simply unauthenticated; nothing here can mint authority the token did not already carry.
func NewDelegationAuthenticator ¶
func NewDelegationAuthenticator(issuer ed25519.PublicKey, clk clock.Clock) *DelegationAuthenticator
NewDelegationAuthenticator builds an authenticator that accepts tokens issued (and transitively delegated) under issuer, reading the current time from clk so expiry is checked against the same clock the rest of the system uses (System in production, Manual in tests). A nil clock falls back to the system clock; an issuer of the wrong size makes every token fail to verify, which is the fail-closed outcome.
func (*DelegationAuthenticator) Authenticate ¶
func (a *DelegationAuthenticator) Authenticate(r *http.Request) (Principal, error)
Authenticate verifies the request's bearer token and resolves it to a Principal carrying the verified Grant. A missing or unverifiable token is ErrUnauthenticated, never a partial or escalated authority.
type DenyAll ¶
type DenyAll struct{}
DenyAll is an authenticator that refuses every request. It is the fail-closed default: a server constructed without an explicit authenticator gets this, so a forgotten or misconfigured auth setup locks the door rather than serving openly. An unauthenticated API surface is therefore not representable: there is no "no auth" mode, only "deny everything" or a real authenticator.
type Descriptor ¶
Descriptor declares how a kind is presented in the read model: its resource kind name and the columns a list shows. Supplying a Descriptor is the only step needed to make a kind appear in list/describe and, later, the remote API and dashboard, because every reader projects through it.
type Detail ¶
Detail is a single resource fully described: the resource itself, its one-row projection through the descriptor (with the column headers), and its recent change history from the event log.
func Describe ¶
func Describe(ctx context.Context, store resource.Store, log spine.Log, d Descriptor, id string, tail int) (Detail, error)
Describe returns the resource addressed by id together with its recent change history. History is the resource's own mutations on the store's stream (when it was created, updated, or deleted), decoded and filtered to this resource, in order and capped to the last tail (tail <= 0 returns all). It reads through the same store and event log the rest of the system writes, so a describe and a replay of the same resource agree. A nil log yields an empty history.
type FieldDelta ¶
FieldDelta is one field whose value differs between two resources, addressed by its dotted path under spec or status (for example "spec.model", "status.phase").
func Diff ¶
func Diff(a, b resource.Resource) []FieldDelta
Diff compares two resources field by field over their Spec and Status, returning the fields that differ in a stable, path-sorted order. A field present on only one side compares against the empty string on the other. It walks the stored JSON rather than a typed struct, so it is kind-agnostic: flynn diff and any future face (the remote API, a dashboard) share this one comparison for every kind, the same way they share List and Describe. Envelope metadata (ids, versions, timestamps) is excluded; the diff is of declared and observed content, not bookkeeping.
type Identity ¶
type Identity struct {
// contains filtered or unexported fields
}
Identity is an instance's self-issued Ed25519 keypair: its verifiable identity on the fleet and the key it signs delegations with. It extends the bookkeeping OriginInstanceID into something another instance can cryptographically check, with no central authority.
func GenerateIdentity ¶
GenerateIdentity creates a fresh instance identity. Persisting the key across restarts (sealed in the vault) is a later hardening step; a generated-in-memory identity is enough to issue and verify within a process and across a fleet that exchanges public keys.
func IdentityFromSeed ¶
IdentityFromSeed reconstructs an identity from a 32-byte Ed25519 seed, the inverse of Seed. A seed of the wrong length is rejected rather than silently truncated, so a corrupted or foreign sealed value fails closed instead of yielding a usable-but-wrong identity.
func LoadOrCreateIdentity ¶
LoadOrCreateIdentity returns the instance's stable identity: it reads the sealed seed from the vault and reconstructs the keypair, or, on first run (no sealed seed yet), generates a fresh identity and seals its seed before returning it. Either way the returned identity is the same one a later restart will load, so the instance's public key and principal id survive restarts rather than churning per process.
It fails closed on a present-but-corrupt seed (a wrong length or undecodable value): rather than silently minting a new identity and orphaning every token issued to the old key, it surfaces the error so the operator notices a tampered or truncated vault.
func (*Identity) ExternalName ¶
func (i *Identity) ExternalName(base, purpose string, c Constraints) (string, error)
ExternalName projects this identity into a name valid under c. It is the external-name companion to ID, mirroring how ExternalName(pub, ...) companions PrincipalID(pub): the identity owns both renderings of itself. See the package-level ExternalName for the meaning of base and purpose.
func (*Identity) ID ¶
ID returns the identity's stable principal id, derived from its public key so it is self-certifying: the id and the verifying key are one and the same.
func (*Identity) Public ¶
Public returns the identity's public key, the value other instances verify its delegations against.
func (*Identity) Seed ¶
Seed returns the identity's 32-byte Ed25519 seed, the single secret the rest of the keypair derives from. It is the value LoadOrCreateIdentity seals in the vault. Holding it is equivalent to holding the private key, so callers must treat it as a secret and never log or persist it unsealed.
type NameSource ¶
type NameSource string
NameSource records how ResolveName produced a name, so the choice is observable: an explicit name, a stable name derived from the instance identity, or an ephemeral name derived from a throwaway identity because none was in scope (the name will not be stable across runs).
const ( // NameOverride means the caller supplied the name verbatim. NameOverride NameSource = "override" // NameIdentity means the name was derived from the instance's stable identity. NameIdentity NameSource = "identity" // NameEphemeral means no identity was in scope, so the name was derived from a // throwaway identity and will differ on the next run. NameEphemeral NameSource = "ephemeral" )
type Option ¶
type Option func(*Server)
Option configures a Server.
func WithAction ¶
func WithAction(verb string, spec ActionSpec) Option
WithAction registers an action verb (a kube-style subresource) and its gate. The verb is served at POST /v1/{kind}/{name}/<verb>, refuses a token below spec.MinScope, and admits only if the caller's grant intersected with the instance's local grant permits spec.Action. Verbs with an empty Action or a nil Run are ignored, so a half-declared action cannot open an ungated route.
func WithAdmitter ¶
WithAdmitter overrides the capability waist the action gate admits a verb through. The default is capability.Admitter, which reads the effective grant from the request context; this hook exists for tests.
func WithApprovals ¶
WithApprovals wires the verifier that checks the signed approvals a dangerous verb requires, and the host id an approval must be bound to (it should match the verifier's own host). Without it, a verb marked Dangerous fails closed: it cannot run, because the second factor cannot be verified. A non-dangerous verb is unaffected.
func WithLocalGrant ¶
func WithLocalGrant(g capability.Grant) Option
WithLocalGrant sets this instance's own action authority: the ceiling every remote caller's grant is intersected against, so no presented token can act beyond what the instance itself admits locally. The default is AllowAll (locally unconstrained).
func WithLogger ¶
WithLogger sets the logger used for audit and errors (default: a discard logger).
func WithWatchPoll ¶
WithWatchPoll overrides the watch poll interval. A non-positive value is ignored.
type PollWatcher ¶
type PollWatcher struct {
// contains filtered or unexported fields
}
PollWatcher detects changes by comparing successive list snapshots of a kind. It holds the last-seen sync version per resource id; each Poll returns the transitions since the previous Poll and advances the baseline. A production caller drives Poll on a ticker; tests drive it directly, so change detection is deterministic and free of any clock. The first Poll reports every current resource as Added.
func NewPollWatcher ¶
NewPollWatcher returns a watcher over the live resources of kind matching sel.
func (*PollWatcher) Poll ¶
func (w *PollWatcher) Poll(ctx context.Context) ([]Change, error)
Poll lists the kind and returns the changes since the previous Poll: Added for ids not seen before, Modified for ids whose SyncVersion advanced, and Deleted for ids that were present before and are absent now. Changes are ordered by resource name then id, so the output is deterministic.
type PossessionAuthenticator ¶
type PossessionAuthenticator struct {
// contains filtered or unexported fields
}
PossessionAuthenticator wraps an inner Authenticator and additionally requires the caller to prove possession of the leaf key the resolved principal is bound to. The inner authenticator establishes authority (the verified, attenuated Grant); this layer establishes that the presenter actually holds the key, not just a copy of the token.
A request is admitted only if the inner authenticator resolves a principal AND a valid possession proof binds to that principal: either a signed ProofHeader, or, when enabled, an mTLS client certificate carrying the leaf key. Any failure collapses to ErrUnauthenticated.
func RequirePossession ¶
func RequirePossession(inner Authenticator, clk clock.Clock, opts ...PossessionOption) *PossessionAuthenticator
RequirePossession wraps inner so that, beyond resolving a principal, every request must prove possession of the principal's leaf key. clk supplies the time proofs are checked against (System in production, Manual in tests); a nil clock falls back to the system clock.
func (*PossessionAuthenticator) Authenticate ¶
func (p *PossessionAuthenticator) Authenticate(r *http.Request) (Principal, error)
Authenticate resolves the request through the inner authenticator, then requires a valid possession proof for the resolved principal. A missing or invalid proof, or a proof that does not bind to the resolved principal, is ErrUnauthenticated, so a stolen token alone never authenticates.
type PossessionOption ¶
type PossessionOption func(*PossessionAuthenticator)
PossessionOption configures a PossessionAuthenticator.
func WithProofSkew ¶
func WithProofSkew(d time.Duration) PossessionOption
WithProofSkew overrides the accepted timestamp skew for signed proofs. A non-positive value is ignored, keeping the safe default.
func WithTLSPossession ¶
func WithTLSPossession() PossessionOption
WithTLSPossession enables mTLS channel binding as an accepted possession proof: a request over a TLS connection whose verified client certificate carries the leaf public key is admitted without a signed header. Off by default, since it requires the server to be configured to request and verify client certificates.
type Principal ¶
type Principal struct {
ID string
Scope Scope
Grant capability.Grant
}
Principal is the authenticated identity behind a request, with the authority it carries. It is recorded with every action so the audit trail attributes who did what.
Authority is two-axis. Scope is the coarse access level (read/operator/admin) a handler requires. Grant is the fine-grained action authority the principal holds: a local operator token carries capability.AllowAll (scope is its only limit), while a delegated principal carries the verified, monotonically attenuated Grant its capability token proved. The action gate at the dispatch waist intersects this Grant with the target's own local grant, so a narrowed remote grant can never act beyond what the target would do locally. The zero Grant denies every action; an empty principal (an unauthenticated attempt) therefore carries no authority by construction.
type ResolvedName ¶
type ResolvedName struct {
Value string
Source NameSource
}
ResolvedName is a name together with how it was chosen.
func ResolveName ¶
func ResolveName(id *Identity, base, purpose, override string, c Constraints) (ResolvedName, error)
ResolveName is the single port every call site names an external resource through, so the override-then-identity-then-fallback policy is defined once rather than re-implemented per provider. An explicit override always wins (validated against c so a bad name fails fast rather than at the provider). Otherwise the name is derived from id. When id is nil no identity is in scope, so a throwaway identity is minted and used, and the result is marked NameEphemeral so the caller can see the name will not be stable. See ExternalName for base and purpose.
type Row ¶
Row is one resource projected to its cells, carrying the resource id and name so a renderer can address or link it independently of the displayed columns.
type Scope ¶
type Scope int
Scope is the access level behind a request. Scopes are ordered: Admin contains Operator contains Read. A handler states the minimum it requires.
const ( // ScopeNone is no access; the zero value, so an unset Principal can do nothing. ScopeNone Scope = iota // ScopeRead permits get, list, and watch. ScopeRead // ScopeOperator adds lifecycle and dispatch actions (pause, resume, halt, run). ScopeOperator // ScopeAdmin adds credentials, configuration, and upgrade. ScopeAdmin )
type SeedVault ¶
type SeedVault interface {
Lookup(ctx context.Context, ref string) (secret.Text, error)
Set(ctx context.Context, ref string, value secret.Text) error
}
SeedVault is the slice of a credential vault this package needs to persist an identity: read a sealed value, write one. It is exactly satisfied by *vault.Store, so the production wiring passes the real vault while a test passes an in-memory stub, and controlplane never has to import the vault backend.
type Server ¶
type Server struct {
// contains filtered or unexported fields
}
Server is the read/watch control-plane API over a resource store. It serves get/list/watch for any registered kind, gated by the Authenticator, plus action subresources (operator/admin verbs) behind the same auth boundary and a second, finer gate: the caller's verified grant intersected with this instance's own local grant, so a narrowed remote grant can never act beyond what the target would do locally.
type Table ¶
Table is a rendered, kind-agnostic result: the column headers and one row per resource, in a stable order. A renderer (text, JSON, HTML) formats it; the read model itself never formats.
func List ¶
func List(ctx context.Context, store resource.Store, d Descriptor, sel resource.Selector) (Table, error)
List returns every live resource of the descriptor's kind whose labels satisfy sel (a nil selector matches all), each projected through the descriptor's columns. Results follow the store's order (scope then name), so the table is deterministic.
type TokenAuthenticator ¶
type TokenAuthenticator struct {
// contains filtered or unexported fields
}
TokenAuthenticator authenticates a bearer token against a fixed table. It compares in constant time and scans every entry regardless of an early match, so neither a token's value nor which tokens exist leaks through timing.
func GeneratedOperator ¶
func GeneratedOperator(id string, scope Scope, mint func() (string, error)) (*TokenAuthenticator, string, error)
GeneratedOperator mints a fresh, cryptographically-random bearer token and returns an authenticator that accepts exactly that token as a single operator-scoped principal, along with the token so the caller can present it to the operator once. It is the auth-on-by-default path: bringing up the API with no operator-supplied credential yields a secured-by-default endpoint with zero configuration, so there is never a reason to fall back to an open one. The provided mint is the entropy source (ids.Token in production); a test can inject a deterministic one.
func NewTokenAuthenticator ¶
func NewTokenAuthenticator(tokens map[string]Principal) *TokenAuthenticator
NewTokenAuthenticator builds an authenticator over a token -> Principal table.
func (*TokenAuthenticator) Authenticate ¶
func (a *TokenAuthenticator) Authenticate(r *http.Request) (Principal, error)
Authenticate resolves the request's bearer token to its Principal.
type Watcher ¶
Watcher reports resource changes for a kind. It is the interface a remote API serves over a streaming transport; locally it is driven by polling. Defining it alongside List and Describe keeps list, get, and watch one read model, so adding live streaming later is a new transport over this interface, not a new query path bolted on.