Documentation
¶
Overview ¶
Package secretbroker brokers scoped, time-limited credential grants to execution backends.
The problem it replaces: pkg/secret is a flat key→value map with an all-or-nothing EnvLines(). Every workload that got any secret got all of them, forever, as plain environment variables. That is workable when the only executor is "a child process of the control plane owned by the same person who wrote the secrets down". It is not workable once workloads run in containers, on remote edge devices, and on behalf of different tenants: a single leaked GitHub PAT then reaches every repository the operator can touch, from a machine the operator does not control.
The model here is three-part:
Secret what the credential is (kind + sealed payload). Never leaves
this package in plaintext except inside a Material.
Grant who may use it, under what constraints, until when. Constraints
are kind-specific and enforced at delivery, not documented and
hoped for.
Lease a short-lived, minimized materialisation of the grants that
match one (executor, project) subject. Executors receive leases;
they never receive the store.
Enforcement, honestly scoped. Three of the constraint kinds are enforced by construction, because the broker rewrites the payload before it is delivered and a narrower payload cannot be widened by its holder:
- kubeconfig is rewritten to contain only the allowed contexts, their referenced clusters/users, and an allowed namespace pinned on each;
- registry credentials are filtered to the allowed registries;
- env secrets are filtered to the allowed keys.
github_pat is enforced at the point of use rather than by construction: GitHub has no API to narrow an already-issued PAT, so instead of exporting a bare GITHUB_TOKEN the broker emits a git credential helper that releases the token only for paths matching the allowlist (see githubpat.go). A bare GITHUB_TOKEN is exported only when the allowlist is explicitly "*".
egress_proxy carries its allowlist to the executor, whose network policy is the enforcement point; in-process callers gate on Constraints.AllowsHost. This is the one kind whose enforcement lives outside this package, and it is called out here so nobody mistakes it for the kubeconfig guarantee.
Fail-closed is the rule throughout: an unparseable constraint, an unsatisfiable one, an expired or revoked grant, or a payload that minimizes to nothing all produce a denial and an audit row — never a wider credential.
Index ¶
- Constants
- Variables
- func AADFor(set, rowID string) string
- func KubeconfigSummary(minimized []byte) string
- func MinimizeKubeconfig(raw []byte, c Constraints) ([]byte, error)
- func NewLeaseDirPath(baseDir string) (string, error)
- func NormalizeHost(host string) (string, error)
- func NormalizeProjectID(id string) string
- func NormalizeRepo(repo string) (string, error)
- func ParseLocalRepoRoot(payload []byte) (string, error)
- func RedactString(s string) string
- func SafeRef(ref string) string
- func SandboxLeaseDir(leaseID string) string
- func ValidateName(name string) error
- type Action
- type Auditor
- type AuditorFunc
- type Broker
- func (b *Broker) CheckRepoAccess(ctx context.Context, r Requester, repo, actor string) error
- func (b *Broker) DeleteSecret(ctx context.Context, ref, actor string) error
- func (b *Broker) DescribeSecret(ref string) (Secret, error)
- func (b *Broker) Grant(ctx context.Context, req GrantRequest) (Grant, error)
- func (b *Broker) Keyring() *Keyring
- func (b *Broker) Lease(ctx context.Context, executorID, projectID string) (*Lease, error)
- func (b *Broker) LeaseFor(ctx context.Context, r Requester, actor string) (*Lease, error)
- func (b *Broker) ListGrants(f GrantFilter) ([]Grant, error)
- func (b *Broker) ListSecrets() ([]Secret, error)
- func (b *Broker) Mint(ctx context.Context, req MintRequest) (Secret, error)
- func (b *Broker) Release(leaseID string)
- func (b *Broker) Renew(ctx context.Context, leaseID string) (*Lease, error)
- func (b *Broker) Revoke(ctx context.Context, grantID, actor string) error
- func (b *Broker) SweepExpired() (expired, live map[Kind]int)
- type Cipher
- type Constraints
- func (c Constraints) AllowsContext(name string) bool
- func (c Constraints) AllowsEnvKey(key string) bool
- func (c Constraints) AllowsHost(host string) bool
- func (c Constraints) AllowsNamespace(ns string) bool
- func (c Constraints) AllowsPermission(perm string) bool
- func (c Constraints) AllowsRegistry(reg string) bool
- func (c Constraints) AllowsRepo(repo string) bool
- func (c Constraints) CheckHost(host string) error
- func (c Constraints) CheckRepo(repo string) error
- func (c Constraints) Summary() string
- func (c Constraints) ValidateFor(kind Kind) error
- type Decision
- type DeliveredFile
- type Delivery
- type Envelope
- type Event
- type File
- type Grant
- type GrantFilter
- type GrantRequest
- type KEKRecord
- type KeyInfo
- type KeyStore
- type KeyUsage
- type Keyring
- func (k *Keyring) AddKey(actor string) (KeyInfo, error)
- func (k *Keyring) HasLegacy() bool
- func (k *Keyring) Keys() []KeyInfo
- func (k *Keyring) OpenEnvelope(aad string, env Envelope) ([]byte, error)
- func (k *Keyring) PrimaryID() string
- func (k *Keyring) Reload() error
- func (k *Keyring) RetireKey(id string) error
- func (k *Keyring) Rewrap(aad string, env Envelope) (Envelope, error)
- func (k *Keyring) SealFor(aad string, plaintext []byte) (Envelope, error)
- type KeyringOption
- type Kind
- type Lease
- func (l *Lease) Deliver(dir string) (*Delivery, error)
- func (l *Lease) Empty() bool
- func (l *Lease) Expired(now time.Time) bool
- func (l *Lease) Kinds() []Kind
- func (l *Lease) Materialize(baseDir string) (*Mount, error)
- func (l *Lease) MaterializeAt(dir string) (*Mount, error)
- func (l *Lease) SecretNames() []string
- func (l *Lease) TTL(now time.Time) time.Duration
- type LeaseBinding
- type LegacyImportResult
- type Material
- type MintRequest
- type Mount
- type Option
- type RepoMount
- type Requester
- type RotateOptions
- type RotationRecord
- type RotationReport
- type RotationStatus
- type RotationStore
- type Rotator
- func (r *Rotator) RetireKey(id string) error
- func (r *Rotator) Rotate(ctx context.Context, opts RotateOptions) (RotationReport, error)
- func (r *Rotator) Status(historyLimit int) (RotationStatus, error)
- func (r *Rotator) WithBatch(n int) *Rotator
- func (r *Rotator) WithHistory(h RotationStore) *Rotator
- func (r *Rotator) WithProgress(fn func(RotationRecord)) *Rotator
- type SealedRow
- type SealedSet
- type Secret
- type SetReport
- type Store
- type Subject
- type SubjectType
Constants ¶
const ( // LegacyKeyID names material sealed by the pre-envelope construction: // ciphertext directly under the passphrase-derived key, with no wrapped // DEK. Migration 0019 stamps it onto every pre-existing row so "sealed // the old way" is an explicit state rather than an inference from an // empty column. LegacyKeyID = "legacy" // KEK lifecycle states. Exactly one KEK is primary (new material is // sealed under it); any number may be active (openable, still referenced // by rows); retired KEKs have had their salt destroyed and can no longer // be derived at all. KEKStatePrimary = "primary" KEKStateActive = "active" KEKStateRetired = "retired" )
const ( SetSecrets = "secrets" SetSessions = "sessions" )
Sealed-set names. They are part of every envelope's associated data, so they are a wire format: changing one makes existing rows in that set unopenable.
Namespacing by set is not cosmetic. Without it, an attacker with database write access could copy a secret's (wrapped_dek, payload) into a sessions row whose id equals that secret's id, and the hub would authenticate it happily and then POST the kubeconfig to the identity provider as if it were a refresh token — an exfiltration channel out of the sealed store to an external host. Row ids alone cannot prevent that, because they are chosen by whoever writes the row.
const ( RotationRunning = "running" RotationCompleted = "completed" RotationInterrupted = "interrupted" RotationFailed = "failed" )
Rotation states.
const DefaultGrantTTL = 24 * time.Hour
DefaultGrantTTL is used when a grant is created without an explicit TTL from a path that requires one.
const DefaultMaxLeaseTTL = 15 * time.Minute
DefaultMaxLeaseTTL bounds how long any single lease is valid, regardless of how long its grants last.
The split between grant TTL and lease TTL is the point of the design. An operator reasonably grants CI access to a repository for a day; it does not follow that a container should hold a usable token for a day. Fifteen minutes with renewal keeps long runs working while bounding what a compromised or forgotten executor still holds — and because Renew re-evaluates every grant, a revocation lands within one lease period rather than at the end of the grant.
const DefaultRotationBatch = 128
DefaultRotationBatch is how many rows are listed per round.
Small enough that an interrupted rotation loses almost nothing and that the rotator never holds a large slice of ciphertext, large enough that a big registry does not turn into thousands of round trips.
const EnvPassphraseKey = "CLOOP_SECRET_KEY"
EnvPassphraseKey is the environment variable holding the passphrase that protects sealed payloads. It is the same variable pkg/secret uses, so an existing deployment's key keeps working across the migration.
const GitHubUsername = "x-access-token"
GitHubUsername is the basic-auth username GitHub expects alongside a PAT. GitHub ignores the value for a PAT but requires a non-empty one, and "x-access-token" is the name its own tooling uses.
const MaxLocalRepos = 32
MaxLocalRepos bounds how many repositories one grant may open.
The limit is legibility, not resource use. A grant matching forty checkouts has stopped describing "these repositories" and started describing "the machine", which is the thing the allowlist exists to prevent someone doing by accident with a broad glob.
const SandboxLeaseRoot = "/run/cloop"
SandboxLeaseRoot is the parent directory a lease's files are delivered under inside an executor that does not share the hub's filesystem.
/run is the conventional place for ephemeral runtime state and is a tmpfs on every systemd host, which is the same property the hub's /dev/shm is chosen for. A driver that cannot write there relocates and rewrites the environment; see executor.Spec.RelocateSecrets.
const SandboxRepoRoot = "/repos"
SandboxRepoRoot is where granted repositories appear inside a sandbox.
A fixed path rather than a configurable one so a harness, a setup script and a README can all name it without knowing which executor they landed on. It is outside /workspace on purpose: the workspace is bind-mounted whole by the container driver, so a target underneath it would be shadowed by that mount on some drivers and not others.
Variables ¶
var ( // ErrInvalidKind: the secret kind is not one of the known kinds. ErrInvalidKind = errors.New("secretbroker: invalid secret kind") // ErrInvalidSecret: the secret failed structural validation. ErrInvalidSecret = errors.New("secretbroker: invalid secret") // ErrInvalidGrant: the grant failed structural validation. ErrInvalidGrant = errors.New("secretbroker: invalid grant") // ErrInvalidSubject: the grant subject could not be parsed or is empty. ErrInvalidSubject = errors.New("secretbroker: invalid subject") // ErrInvalidConstraint: a constraint pattern is malformed, unsafe to // embed, or missing where its kind requires one. ErrInvalidConstraint = errors.New("secretbroker: invalid constraint") // ErrSecretNotFound: no secret with that ID or name exists. ErrSecretNotFound = errors.New("secretbroker: secret not found") // ErrGrantNotFound: no grant with that ID exists. ErrGrantNotFound = errors.New("secretbroker: grant not found") // ErrLeaseNotFound: the lease ID is unknown or already released. ErrLeaseNotFound = errors.New("secretbroker: lease not found") // ErrDuplicateName: a secret with that name already exists. ErrDuplicateName = errors.New("secretbroker: secret name already in use") // ErrGrantExpired: the grant's TTL elapsed. ErrGrantExpired = errors.New("secretbroker: grant expired") // ErrGrantRevoked: the grant was revoked. ErrGrantRevoked = errors.New("secretbroker: grant revoked") // ErrLeaseExpired: the lease's TTL elapsed; Renew or re-Lease. ErrLeaseExpired = errors.New("secretbroker: lease expired") // ErrRepoDenied: the repository is outside the grant's allowlist, or // could not be normalised into owner/repo form. ErrRepoDenied = errors.New("secretbroker: repository denied") // ErrNamespaceDenied: the Kubernetes namespace is outside the allowlist. ErrNamespaceDenied = errors.New("secretbroker: namespace denied") // ErrHostDenied: the host is outside the egress allowlist, or could not // be normalised. ErrHostDenied = errors.New("secretbroker: host denied") // ErrMinimizedEmpty: minimising the payload against the grant's // constraints left nothing to deliver. This is a denial, not an empty // success — delivering an empty kubeconfig would look like a working // credential that mysteriously cannot reach anything. ErrMinimizedEmpty = errors.New("secretbroker: nothing remains after minimization") // ErrMalformedPayload: the stored payload is not in the shape its kind // requires (e.g. a kubeconfig that is not valid YAML). ErrMalformedPayload = errors.New("secretbroker: malformed secret payload") // ErrNoKey: CLOOP_SECRET_KEY is unset, so payloads can be neither // sealed nor opened. ErrNoKey = errors.New("secretbroker: CLOOP_SECRET_KEY is not set") // ErrSealFailed: encryption or decryption failed (wrong key, or a // corrupt/tampered envelope — AES-GCM cannot tell you which). ErrSealFailed = errors.New("secretbroker: seal/unseal failed") // ErrKeyRetired: the material names a KEK whose salt was destroyed. The // material is unrecoverable and the credential must be re-minted. ErrKeyRetired = errors.New("secretbroker: sealing key retired") // current CLOOP_SECRET_KEY. The material is intact; the passphrase is // wrong. ErrKeyUnavailable = errors.New("secretbroker: sealing key unavailable") // ErrKeyUnknown: the material names a KEK this hub has no record of — // a database from another hub, or a partially restored backup. ErrKeyUnknown = errors.New("secretbroker: unknown sealing key") // ErrKeyInUse: the KEK cannot be retired because sealed material still // references it, or because it is the primary. ErrKeyInUse = errors.New("secretbroker: sealing key still in use") // ErrRotationFailed: a rotation could not rewrap every row. Rotation is // resumable, so this means "run it again", not "start over". ErrRotationFailed = errors.New("secretbroker: key rotation incomplete") )
Sentinel errors. Callers match with errors.Is; every constructor in this package wraps one of these with %w and adds detail.
The denial errors (ErrRepoDenied, ErrNamespaceDenied, ErrHostDenied, ErrGrantExpired, ErrGrantRevoked) are deliberately distinct from the validation errors: a denial is a security event that must reach the audit log, while a validation error is a malformed request that never became one.
Functions ¶
func AADFor ¶
AADFor builds the associated data for one row of one sealed set. Every seal, open, and rewrap of that row must use the identical value.
func KubeconfigSummary ¶
KubeconfigSummary describes a minimized kubeconfig for audit and CLI output: which contexts and namespaces survived. It never includes cluster endpoints' credentials, only names.
func MinimizeKubeconfig ¶
func MinimizeKubeconfig(raw []byte, c Constraints) ([]byte, error)
MinimizeKubeconfig rewrites a kubeconfig to contain only what the grant's constraints permit, and returns the reduced document.
This is the strongest enforcement in the package, because it is subtractive: the executor receives a kubeconfig that has no credentials for the clusters it may not reach and no context naming a namespace it may not touch. Nothing it can do with that document widens it back out. That is a different and better guarantee than handing over the full kubeconfig alongside a policy note.
Rules:
- a context survives only if Constraints.AllowsContext says so;
- when the grant lists namespaces, each surviving context is pinned to an allowed namespace — its own if that is allowed, otherwise the first concrete (non-glob) namespace in the allowlist;
- a context whose namespace is disallowed and which cannot be pinned (the allowlist is globs only) is dropped rather than guessed at;
- clusters and users not referenced by a surviving context are removed, so their certificates and tokens never leave the control plane;
- current-context is repointed at a surviving context;
- if nothing survives, the result is ErrMinimizedEmpty — a denial, not an empty file that would look like a working credential.
func NewLeaseDirPath ¶
NewLeaseDirPath returns an unused lease-directory path under baseDir — without creating it, and without writing anything.
It exists so a caller can record its intent to materialise *before* any plaintext lands on disk. Materialize used to pick the directory itself with MkdirTemp, which left no way to write a durable "a lease directory is about to exist at this path" row first: a hub killed between the mkdir and the row came back up with a credential directory nothing knew about, and the startup sweep had nothing to reconcile it against. /dev/shm is a tmpfs and clears on reboot, but a hub *process* restart is the common case and clears nothing.
Splitting the naming from the creation makes the ordering possible:
dir, _ := NewLeaseDirPath("") // nothing on disk yet
db.PutSecretLeaseDir(...) // intent recorded
lease.MaterializeAt(dir) // plaintext appears
A crash after the row exists leaves a trace the sweep can act on; a crash before it leaves nothing at all. There is no window in between.
baseDir may be empty to use a tmpfs. The 96-bit suffix matches lease IDs, so two hubs sharing one /dev/shm cannot collide.
func NormalizeHost ¶
NormalizeHost reduces a host, host:port, or URL authority to a bare lowercase hostname. Anything carrying a path, credentials, or a wildcard is rejected: those are the shapes an attacker uses to make "evil.com" look like "api.example.com" to a naive matcher.
func NormalizeProjectID ¶
NormalizeProjectID canonicalises a project identifier so that a grant written as "/srv/app/" and a lease requested for "/srv/app" agree. Absolute paths are cleaned; anything else (an opaque project ID, or the "*" wildcard) is passed through trimmed. Matching is exact after this — no prefix or basename fuzziness, which would let /srv/app-staging pick up /srv/app's credentials.
func NormalizeRepo ¶
NormalizeRepo canonicalises the many ways a repository gets named into the single "owner/repo" form the allowlist is written in, so that "https://github.com/Org/Repo.git" and "org/repo" are the same subject.
Anything that does not reduce to exactly one owner and one repo is an error, not a best guess: a caller that hands us something unrecognisable must be denied, not matched against a partially-parsed string.
func ParseLocalRepoRoot ¶
ParseLocalRepoRoot validates a local_repo payload.
Exported because storing one of these secrets should fail at the API with a usable message — "that path does not exist on the hub" is a typo the operator can fix in the dialog, and finding out at lease time instead means finding out during someone else's run.
func RedactString ¶
RedactString removes credential-shaped substrings from s.
Two classes are handled: known token prefixes, and URL userinfo ("https://user:password@host"), which is how an egress_proxy secret would leak if its URL ended up in an error message.
func SafeRef ¶
SafeRef renders a secret reference for an error message or an audit record without republishing it.
A "no such secret" error is one of the few places a user-supplied string is echoed verbatim, and the single most common way to reach it is to paste the credential where its *name* belongs. That mistake is silent — the operator sees a not-found error, retries correctly, and never learns that the first attempt wrote the live token into the audit log, which is then shipped off-box and retained for a year.
Redaction alone is not enough: it recognises known token prefixes, and a kubeconfig token or a registry auth blob has no prefix to recognise. So the reference is also truncated, which is the part that generalises to credential shapes nobody has enumerated yet.
func SandboxLeaseDir ¶
SandboxLeaseDir returns the directory a lease's credential files take inside a sandbox. The final element carries leaseDirPrefix so the agent's confinement rule recognises it as lease-owned.
func ValidateName ¶
nameCharset restricts secret names to characters that are safe to embed in audit payloads, CLI output, env var derivations, and file names without escaping. Rejecting at the door is cheaper than escaping at every sink.
Types ¶
type Action ¶
type Action string
Action names the brokered operation an audit event describes.
const ( ActionMint Action = "secret.mint" ActionDeleteSec Action = "secret.delete" ActionGrant Action = "secret.grant" ActionRevoke Action = "secret.revoke" ActionLease Action = "secret.lease" ActionRenew Action = "secret.renew" ActionRelease Action = "secret.release" ActionAccessCheck Action = "secret.access_check" // Lease revocation actions (Task 20178). These are separate from // ActionRelease because releasing a lease and revoking one answer // different questions. A release is the ordinary end of a workload's // credential lifetime; a revocation is somebody deciding, mid-run, that // an executor may no longer hold something it already has. // // All three exist because a revocation is not one event. It is sent, // and then it either lands or it does not, possibly minutes later when // an offline device reconnects. Collapsing them into one row would make // the trail claim a credential was withdrawn at a moment when it // demonstrably still worked. ActionLeaseRevokeSent Action = "lease.revoke_sent" ActionLeaseRevokeAcked Action = "lease.revoke_acked" ActionLeaseRevokeFailed Action = "lease.revoke_failed" // Egress actions come from pkg/egressbroker, which brokers the hub's // Internet connection as a fourth grantable resource alongside GitHub // repositories, PATs, and Kubernetes clusters. // // They share this audit trail rather than opening a second one on // purpose: "what did this executor reach, and with whose authority" is // one question, and answering it from two hash chains that can disagree // about ordering would be strictly worse than answering it from one. ActionEgressGrant Action = "egress.grant" ActionEgressRevoke Action = "egress.revoke" ActionEgressRedeem Action = "egress.redeem" ActionEgressConnect Action = "egress.connect" ActionEgressRequest Action = "egress.request" ActionEgressClose Action = "egress.close" )
type Auditor ¶
type Auditor interface {
Audit(ev Event)
}
Auditor receives brokered-operation events. Implementations must not block the broker: an audit sink that is slow or down must not stop a workload from starting, so the production adapter writes best-effort and swallows its own errors, exactly as pkg/statedb's audit emitters do.
type AuditorFunc ¶
type AuditorFunc func(Event)
AuditorFunc adapts a function to the Auditor interface.
type Broker ¶
type Broker struct {
// contains filtered or unexported fields
}
Broker issues scoped, expiring credential leases against a Store.
Safe for concurrent use.
func New ¶
New builds a Broker over store. Unless WithCipher is supplied, the payload key is derived from CLOOP_SECRET_KEY, and New fails if it is unset — a broker that cannot open payloads would otherwise fail later, at lease time, in the middle of somebody's run.
func (*Broker) CheckRepoAccess ¶
CheckRepoAccess is the in-process enforcement point for github grants: it reports whether this requester may act on repo, and audits the decision.
cloop's own GitHub call sites (pkg/github, pkg/githubsync) route through here so that a repository outside the allowlist is refused before a request is made, rather than relying on the credential helper alone.
func (*Broker) DeleteSecret ¶
DeleteSecret removes a secret and revokes every grant that pointed at it. Leaving grants behind would leave rows that resolve to nothing and read, in a grant listing, as still-live access.
func (*Broker) DescribeSecret ¶
DescribeSecret resolves a secret by ID or name. The payload stays sealed: this answers "does this reference name something, and of what kind", which is what a caller validating a grant before creating it needs.
It exists so that path does not have to re-implement reference resolution against ListSecrets, where a subtly different name match would mean a grant validated against one secret and then created against another.
func (*Broker) Keyring ¶
Keyring returns the broker's key registry, or nil when it was built over a store with no registry. Callers that rotate must handle nil rather than assume every broker can.
func (*Broker) Lease ¶
Lease issues the credentials this executor may hold for this project.
It returns only grants whose subject matches the requester and which are neither expired nor revoked, each minimized against its own constraints. An executor never sees the store, another project's grants, or any part of a payload that its constraints excluded.
A requester with no matching grants gets an empty lease, not an error: "this project has no secrets" is a normal state and must not fail a run.
func (*Broker) LeaseFor ¶
LeaseFor is Lease with executor labels (for SubjectLabel grants) and an explicit actor for the audit trail.
func (*Broker) ListGrants ¶
func (b *Broker) ListGrants(f GrantFilter) ([]Grant, error)
ListGrants returns grants matching the filter, newest first.
func (*Broker) ListSecrets ¶
ListSecrets returns stored secrets. Payloads stay sealed.
func (*Broker) Mint ¶
Mint seals a payload and stores it as a new secret.
The payload is zeroed in the caller's slice on return so a mint site does not leave plaintext in a buffer the garbage collector may not touch for a while. Callers that still need the bytes must pass a copy.
func (*Broker) Release ¶
Release drops a lease's server-side record. Callers should Close the Mount as well; Release only stops the lease from being renewable.
func (*Broker) Renew ¶
Renew re-issues a lease for the same requester.
It deliberately re-evaluates every grant from the store rather than extending the existing materials. That is what makes revocation effective within one lease period: a grant revoked a minute ago is gone from the renewed lease even though the executor and project are unchanged.
func (*Broker) Revoke ¶
Revoke marks a grant unusable. It takes effect on the next Lease or Renew: credentials already materialised into a running workload's tmpfs stay there until that lease expires, which is exactly the window the short lease TTL exists to bound.
func (*Broker) SweepExpired ¶
SweepExpired drops lease records whose TTL has passed and reports what is left, tallied by credential kind.
It exists for two reasons that happen to have the same implementation.
The metrics reason: cloop_secret_leases_live has to mean "leases that would be honoured right now", and the only way to know that is to apply the same expiry test the broker applies. Counting map entries would report leases that expired hours ago as live.
The correctness reason: before this, a lease record was removed only by Renew or Release. A lease that simply expired — the ordinary end of a task that was killed, or an executor that went away without releasing — left its leaseState in the map for the lifetime of the process. On a busy hub that is an unbounded map keyed by lease ID, which is to say a slow leak that a tenant can drive. Expiry now collects them.
The returned maps are (expired, live) counts by kind.
type Cipher ¶
type Cipher struct {
// contains filtered or unexported fields
}
Cipher seals and opens secret payloads with AES-256-GCM.
Unlike pkg/secret, which re-derives its key from a fresh salt on every save, the broker derives once from a salt persisted in the store and caches the key. That matters here: pkg/secret rewrites one file wholesale, while the broker opens individual payloads on every lease. At 200 000 SHA-256 rounds, per-payload derivation would put roughly a fifth of a second of CPU between an executor and each of its credentials. Per-payload randomness comes from the GCM nonce instead, which is what it is for.
func NewCipher ¶
NewCipher derives the payload key from the CLOOP_SECRET_KEY passphrase and the store-wide salt. The salt is created on first use and persisted, so the same passphrase yields the same key across restarts.
func NewCipherWithKey ¶
NewCipherWithKey builds a Cipher from a raw 32-byte key. Used by tests and by callers that manage key material themselves.
func NewLegacyCipher ¶
NewLegacyCipher rebuilds the pre-envelope cipher from a passphrase and the store-wide salt.
It is exported for exactly two callers: the tests that verify pre-envelope rows still open after migration 0019, and any recovery tooling that has to read a database whose broker_meta salt survived but whose registry did not. Nothing on the serving path uses it — the Keyring loads the legacy cipher itself, and only when a salt is already present.
func (*Cipher) OpenEnvelope ¶
OpenEnvelope lets a plain Cipher satisfy sealer.
func (*Cipher) SealFor ¶
SealFor lets a plain Cipher satisfy sealer. It produces legacy-shaped envelopes: no wrapped DEK, ciphertext directly under the one key.
Associated data is ignored, because the legacy construction has nowhere to put it. That is a real (small) difference in the guarantees a Cipher-backed broker offers versus a Keyring-backed one, and it is the reason production paths use a Keyring: pkg/secretstore implements KeyStore, so every hub gets envelope encryption and the row binding that comes with it.
type Constraints ¶
type Constraints struct {
// Repos is an owner/repo glob allowlist for github_pat and github_app.
// Patterns match case-insensitively; "*" alone allows every repository.
Repos []string `json:"repos,omitempty"`
// Permissions is the permission set a github credential may exercise
// ("contents:read", "pull_requests:write"). Enforced at cloop's own
// GitHub call sites via AllowsPermission; GitHub cannot narrow an
// already-issued PAT server-side.
Permissions []string `json:"permissions,omitempty"`
// Namespaces is the Kubernetes namespace allowlist for kubeconfig.
Namespaces []string `json:"namespaces,omitempty"`
// Contexts is the kubeconfig context allowlist. The delivered
// kubeconfig contains only these contexts.
Contexts []string `json:"contexts,omitempty"`
// Hosts is the allowed-host list for egress_proxy. A leading "*."
// matches subdomains only, not the bare domain.
Hosts []string `json:"hosts,omitempty"`
// Registries is the container-registry allowlist for registry secrets.
Registries []string `json:"registries,omitempty"`
// EnvKeys restricts which keys of an env secret are delivered. Empty
// means every key in the secret, which is safe because an env secret's
// keys *are* its scope — there is nothing wider to fall open to.
EnvKeys []string `json:"env_keys,omitempty"`
// Writable makes a local_repo grant read-write. It is the one constraint
// that widens rather than narrows, so it is a bool that defaults to the
// safe reading: a grant that says nothing delivers a read-only mount.
//
// Read-only is the useful default rather than a cautious one. The common
// case is a sandbox that needs to *read* a developer's checkout — build
// against it, grep it, copy from it — and a read-only bind means a
// runaway harness cannot rewrite the history of a repository that exists
// nowhere else. A project that genuinely needs to commit asks for it.
Writable bool `json:"writable,omitempty"`
}
Constraints narrow what a granted secret may be used for. Which fields apply depends on the secret's Kind; ValidateFor rejects a grant whose constraints do not gate its kind, so an under-specified grant is a creation-time error rather than a delivery-time surprise.
The wildcard "*" is always spelled out. An empty list on a gating dimension is rejected by ValidateFor rather than treated as "allow all" — the single most common way allowlists fail open is an empty slice reading as "no restrictions", and that reading is unavailable here.
func (Constraints) AllowsContext ¶
func (c Constraints) AllowsContext(name string) bool
AllowsContext reports whether a kubeconfig context name is permitted. A grant that lists namespaces but no contexts permits every context (the namespace pin is then the constraint); a grant that lists contexts restricts to exactly those.
func (Constraints) AllowsEnvKey ¶
func (c Constraints) AllowsEnvKey(key string) bool
AllowsEnvKey reports whether an env secret's key may be delivered. An empty EnvKeys list allows every key the secret contains — see the field comment for why that is not a fail-open.
func (Constraints) AllowsHost ¶
func (c Constraints) AllowsHost(host string) bool
AllowsHost reports whether host is in the egress allowlist.
func (Constraints) AllowsNamespace ¶
func (c Constraints) AllowsNamespace(ns string) bool
AllowsNamespace reports whether ns is permitted. An empty allowlist denies: ValidateFor guarantees a kubeconfig grant has namespaces or contexts, and where it has only contexts, namespace checks are not the gate.
func (Constraints) AllowsPermission ¶
func (c Constraints) AllowsPermission(perm string) bool
AllowsPermission reports whether a github permission is within the grant's permission set. An empty set permits nothing beyond read: an operator who does not enumerate permissions has not authorised any write.
func (Constraints) AllowsRegistry ¶
func (c Constraints) AllowsRegistry(reg string) bool
AllowsRegistry reports whether a container registry host is permitted. Registry names are host-shaped, so they reuse the host matcher's subdomain semantics after normalisation.
func (Constraints) AllowsRepo ¶
func (c Constraints) AllowsRepo(repo string) bool
AllowsRepo reports whether repo is covered by the allowlist.
Matching is per-segment: path.Match's "*" does not cross "/", so "org/*" covers org/tool but not a three-segment path, and a bare "*" is special- cased to mean every repository. Comparison is case-insensitive because GitHub owner and repo names are.
func (Constraints) CheckHost ¶
func (c Constraints) CheckHost(host string) error
CheckHost is AllowsHost with a reason attached.
"*.example.com" matches subdomains only — api.example.com yes, example.com no. Requiring the bare domain to be listed separately keeps "allow the API subdomains" from silently also allowing the apex, which is often a different service on a different box.
func (Constraints) CheckRepo ¶
func (c Constraints) CheckRepo(repo string) error
CheckRepo is AllowsRepo with the denial reason attached, so callers can put a cause in the audit log instead of a bare false.
func (Constraints) Summary ¶
func (c Constraints) Summary() string
Summary renders the constraints as a short, sorted, human-readable string for CLI listings and audit payloads. It contains only allowlist patterns, never payload material.
func (Constraints) ValidateFor ¶
func (c Constraints) ValidateFor(kind Kind) error
ValidateFor checks that these constraints are well-formed *and* that they actually gate the given kind. A github_pat grant with no repo allowlist is rejected here: there is no safe default for "which repositories may this token touch", so the operator has to say, even if what they say is "*".
type DeliveredFile ¶
type DeliveredFile struct {
GrantID string
SecretName string
Kind Kind
// Dir is the directory on the executor, mirroring Delivery.Dir so a
// caller can build one flat list from several leases.
Dir string
Name string
Mode os.FileMode
// Content is the plaintext.
Content []byte
}
DeliveredFile is one credential file a Delivery is handing to a driver.
func (DeliveredFile) GoString ¶
func (f DeliveredFile) GoString() string
GoString mirrors String so %#v is redacted too.
func (DeliveredFile) String ¶
func (f DeliveredFile) String() string
String redacts the content, so a `%v` on a delivery cannot log a credential.
type Delivery ¶
type Delivery struct {
// Dir is where the workload will find the files. It is a path on the
// executor, not on this host.
Dir string
// contains filtered or unexported fields
}
Delivery is a lease rendered for someone else's filesystem: the environment to hand the workload, the per-grant attribution, and the file bytes the driver must place at Dir.
It is the Mount's counterpart, with the same accessors and one difference that is the point of the type: nothing here has touched a disk, so Close zeroes buffers rather than unlinking files.
func (*Delivery) Bindings ¶
func (d *Delivery) Bindings() []LeaseBinding
Bindings returns the per-grant attribution: names and paths, never values.
func (*Delivery) Close ¶
Close zeroes the buffers holding the plaintext and drops the rest. It is idempotent, so it is safe in a defer beside an explicit call.
Zeroing is not a guarantee — Go's garbage collector may already have copied the bytes during a slice growth, and nothing here can reach that copy — but it bounds the window in which a heap dump of a long-lived control plane contains a credential it relayed hours ago.
func (*Delivery) Files ¶
func (d *Delivery) Files() []DeliveredFile
Files returns the credential files for the driver to place, with their contents. Nil after Close.
The content is copied per call rather than aliased: a driver that base64s it into a frame must not be handed the buffer Close will zero underneath it.
type Envelope ¶
Envelope is one piece of envelope-encrypted material.
A row stores all three parts. KeyID names the KEK that WrappedDEK is sealed under; Ciphertext is sealed under the DEK inside WrappedDEK. Rotation rewrites the first two and leaves the third byte-identical.
KeyID == LegacyKeyID (or "") means the pre-envelope construction: WrappedDEK is empty and Ciphertext is sealed directly under the passphrase-derived key. Those rows keep working until a rotation upgrades them.
func LegacyEnvelope ¶
LegacyEnvelope wraps a pre-envelope ciphertext for the open path.
type Event ¶
type Event struct {
Time time.Time `json:"time"`
Action Action `json:"action"`
Decision Decision `json:"decision"`
// Actor is who asked: an OIDC subject, "cli", "ui", or an executor ID.
Actor string `json:"actor,omitempty"`
// Subject is the grant subject the operation concerned, rendered in
// the CLI's "--to" syntax.
Subject string `json:"subject,omitempty"`
// SecretID and SecretName identify the credential. The name is a
// handle chosen by an operator, never the value.
SecretID string `json:"secret_id,omitempty"`
SecretName string `json:"secret_name,omitempty"`
Kind Kind `json:"kind,omitempty"`
GrantID string `json:"grant_id,omitempty"`
LeaseID string `json:"lease_id,omitempty"`
ExecutorID string `json:"executor_id,omitempty"`
ProjectID string `json:"project_id,omitempty"`
// Constraints is the allowlist summary that applied to the decision.
Constraints string `json:"constraints,omitempty"`
// ExpiresAt is the TTL stamp of the grant or lease involved.
ExpiresAt time.Time `json:"expires_at,omitempty"`
// Reason explains a denial, or annotates an allow ("2 grants matched").
Reason string `json:"reason,omitempty"`
// TaskID is the unit of work the decision was made for. Populated by
// the egress broker, which knows it; the secret broker's leases are
// per-executor rather than per-task and leave it empty.
TaskID string `json:"task_id,omitempty"`
// Host and Port name an egress destination. They are a hostname and a
// port and nothing else — deliberately not a URL, because a URL has a
// path and a query, and those are request contents rather than access
// metadata.
Host string `json:"host,omitempty"`
Port int `json:"port,omitempty"`
// BytesUp and BytesDown are the session's transfer totals at the time of
// the decision, from the workload's point of view.
BytesUp int64 `json:"bytes_up,omitempty"`
BytesDown int64 `json:"bytes_down,omitempty"`
}
Event is one audit record. Every field on it is metadata about a credential: which one, for whom, under what constraints, and what was decided. None of them is, or may become, the credential itself.
This is enforced rather than asserted: Event has no field capable of carrying a payload, and Redact scrubs the free-text Reason before the event leaves the package, because Reason is built from error strings and an error string is the one place a payload could plausibly get spliced in.
func Redact ¶
Redact returns a copy of ev safe to persist.
It scrubs the Reason of anything that pattern-matches a credential. That may look like belt-and-braces given that no code path deliberately puts a payload in a Reason — and it is. The reason to do it anyway is that Reason is assembled from wrapped errors, and error wrapping is exactly how a payload leaks: someone one day writes fmt.Errorf("bad token %q: %w", tok, err) three packages down, and the audit log quietly becomes the place the credential is stored in plaintext forever. Scrubbing at the boundary means that mistake degrades to an unhelpful log line instead of a breach.
Auditors must call this (the broker calls it on every emission), and the storage adapter calls it again on the way in.
type File ¶
type File struct {
// Name is a bare file name — no directory separators. Materialize
// re-checks this before writing, so a crafted name cannot escape the
// lease directory.
Name string
Content []byte
Mode os.FileMode
// EnvVar, when set, receives the file's absolute path after
// materialisation (KUBECONFIG, GIT_CONFIG_GLOBAL, DOCKER_CONFIG).
EnvVar string
// EnvIsDir points EnvVar at the containing directory rather than the
// file, which is what DOCKER_CONFIG expects.
EnvIsDir bool
}
File is one credential file to place in the lease directory.
type Grant ¶
type Grant struct {
ID string `json:"id"`
SecretID string `json:"secret_id"`
// Scope is a free-form operator-facing label ("ci", "deploy") used for
// grouping and filtering. It carries no authorisation weight — that is
// entirely Subject plus Constraints — so an operator cannot widen a
// grant by editing it.
Scope string `json:"scope,omitempty"`
Subject Subject `json:"subject"`
Constraints Constraints `json:"constraints"`
// ExpiresAt is when the grant stops being usable. Zero means no expiry,
// which Validate permits only because the legacy import needs it; the
// CLI always sets a TTL.
ExpiresAt time.Time `json:"expires_at,omitempty"`
CreatedAt time.Time `json:"created_at"`
CreatedBy string `json:"created_by,omitempty"`
RevokedAt time.Time `json:"revoked_at,omitempty"`
}
Grant authorises one subject to use one secret, under Constraints, until ExpiresAt.
func (Grant) DenyReason ¶
DenyReason returns the audit-friendly reason this grant cannot be used at now, or "" when it is usable. Returning the reason rather than a bool is what lets every denial land in the audit log with a cause attached.
type GrantFilter ¶
type GrantFilter struct {
// Subject, when set, keeps only grants whose subject renders to this
// string (exact match on the "--to" syntax).
Subject string
// SecretRef, when set, keeps only grants for that secret.
SecretRef string
// ActiveOnly drops expired and revoked grants.
ActiveOnly bool
}
GrantFilter narrows ListGrants.
type GrantRequest ¶
type GrantRequest struct {
// SecretRef is a secret ID or name.
SecretRef string
Subject Subject
Constraints Constraints
Scope string
// TTL is the grant's lifetime. Zero means DefaultGrantTTL unless
// NoExpiry is set.
TTL time.Duration
// NoExpiry creates a grant that never expires. Reserved for the legacy
// import, where imposing a TTL on secrets that previously had none
// would break running projects at an unpredictable moment.
NoExpiry bool
Actor string
}
GrantRequest describes a grant to create.
type KEKRecord ¶
type KEKRecord struct {
ID string
Salt string // hex-encoded KDF salt; emptied on retirement
State string // primary | active | retired
// CheckValue is kekCheckPlaintext sealed under this KEK.
CheckValue []byte
CreatedAt time.Time
CreatedBy string
RetiredAt time.Time
}
KEKRecord is one key-encryption key as stored. It never contains key material: `Salt` plus the passphrase derives the key, and the salt alone is useless.
type KeyInfo ¶
type KeyInfo struct {
ID string `json:"id"`
State string `json:"state"`
Primary bool `json:"primary"`
Openable bool `json:"openable"`
CreatedAt time.Time `json:"created_at"`
CreatedBy string `json:"created_by,omitempty"`
RetiredAt time.Time `json:"retired_at,omitempty"`
}
KeyInfo is a KEK as reported to an operator. It carries no key material and is safe to render in a CLI table or an API response.
type KeyStore ¶
type KeyStore interface {
// PutKEK inserts or replaces a KEK record.
PutKEK(k KEKRecord) error
// ListKEKs returns every KEK, including retired ones — retired rows are
// kept so `cloop hub key list` can still explain what a row referencing
// a dead key is referencing.
ListKEKs() ([]KEKRecord, error)
// PrimaryKEK returns the current primary, if there is one. It exists
// separately from ListKEKs because it is on the seal path: every seal
// re-reads it, so it must be a single indexed lookup rather than a scan.
PrimaryKEK() (KEKRecord, bool, error)
// PromoteKEK makes id the sole primary, demoting any incumbent, in one
// transaction. Atomicity matters: two hubs promoting concurrently must
// not both succeed and leave rows sealed under a key neither considers
// current.
PromoteKEK(id string, at time.Time) error
// RetireKEK marks a KEK retired and destroys its salt. It must refuse
// while any sealed row still references the key.
RetireKEK(id string, at time.Time) error
}
KeyStore is the persistence a Keyring needs on top of the broker's Store.
It is separate from Store on purpose. Store has several in-memory implementations in tests whose value is that they are twenty lines long; folding key management into it would force every one of them to grow a KEK registry to keep compiling, for no gain. A store that implements both gets envelope encryption and rotation; a store that implements only Store keeps the single-key behaviour, which is exactly right for a test double.
type KeyUsage ¶
type KeyUsage struct {
KeyID string `json:"key_id"`
Total int `json:"total"`
BySet map[string]int `json:"by_set"`
}
KeyUsage is how many rows of one set are sealed under one key.
type Keyring ¶
type Keyring struct {
// contains filtered or unexported fields
}
Keyring holds every derivable KEK and performs envelope seal/open.
Safe for concurrent use: leases and session reads both go through it from many goroutines, and a rotation mutates the primary underneath them.
func OpenKeyring ¶
func OpenKeyring(store KeyStore, opts ...KeyringOption) (*Keyring, error)
OpenKeyring loads the KEK registry, derives every KEK the passphrase can open, and ensures a primary exists.
Derivation is deliberately eager. It costs ~60ms per KEK at 200 000 KDF rounds, once, at startup — versus paying it on the first lease of the day, inside somebody's run, per key. Eager also means an unusable passphrase is a startup failure rather than an intermittent one.
func (*Keyring) AddKey ¶
AddKey mints a new KEK, promotes it to primary, and returns it. Existing material stays readable under its old KEK — that overlap is what lets a rotation run while the hub serves traffic.
func (*Keyring) HasLegacy ¶
HasLegacy reports whether pre-envelope material can still be opened on this hub. False with legacy rows present means those rows are unreadable, which `cloop hub key status` reports rather than leaving to be discovered.
func (*Keyring) OpenEnvelope ¶
OpenEnvelope unwraps the DEK and decrypts the payload.
The error cases are deliberately distinguishable, because they call for completely different operator responses: a retired key means the material is gone and the credential must be re-minted; an underivable key means the passphrase is wrong and the material is probably fine; a tag failure means tampering or corruption. Collapsing all three into "decryption failed" turns a five-minute diagnosis into an afternoon.
func (*Keyring) Reload ¶
Reload re-reads the KEK registry, adopting keys another process minted and dropping ones it retired.
It never mints and never demotes to nothing: a registry that has become unreadable leaves the in-memory state exactly as it was, because a hub that is currently serving reads is better off with a stale key set than with an empty one.
func (*Keyring) RetireKey ¶
RetireKey destroys a KEK's salt, making it permanently underivable.
The store refuses while any row still references the key, so this is safe to call optimistically; the refusal is the second step the design requires, and it is enforced in SQL rather than by a check here that a concurrent write could race past.
func (*Keyring) Rewrap ¶
Rewrap moves an envelope onto the primary KEK without decrypting its payload.
This is the operation rotation is made of, and the reason it is cheap: the payload ciphertext is returned byte-identical, so a rotation over a hundred megabytes of kubeconfigs still only encrypts a hundred DEKs. It also means a rotation never materialises a credential, so a rotation running on a serving hub does not widen the plaintext exposure window at all.
func (*Keyring) SealFor ¶
SealFor seals plaintext under a fresh DEK wrapped by the primary KEK.
aad identifies the row this envelope belongs to — build it with AADFor so it carries the sealed set as well as the row id. The identical value must be supplied to OpenEnvelope; a mismatch is an authentication failure, which is how a row-swap attack surfaces.
type KeyringOption ¶
type KeyringOption func(*keyringConfig)
KeyringOption configures a Keyring.
func WithKeyringActor ¶
func WithKeyringActor(a string) KeyringOption
WithKeyringActor records who caused a KEK to be created.
func WithKeyringClock ¶
func WithKeyringClock(fn func() time.Time) KeyringOption
WithKeyringClock overrides the time source.
func WithKeyringPassphrase ¶
func WithKeyringPassphrase(p string) KeyringOption
WithKeyringPassphrase overrides CLOOP_SECRET_KEY. Tests use it; so does any caller that sources the passphrase from somewhere other than the process environment.
func WithoutKeyCreation ¶
func WithoutKeyCreation() KeyringOption
WithoutKeyCreation opens the keyring read-only with respect to the registry: if no primary KEK exists, OpenKeyring fails rather than minting one.
Read-only diagnostics (`cloop hub key list` against someone else's hub, a health check) should not have the side effect of creating key material, and a diagnostic that silently mints a KEK would report a healthy registry it had just fabricated.
type Kind ¶
type Kind string
Kind classifies what a secret *is*, which determines both the constraints that apply to it and how it is minimized and delivered.
const ( // KindGitHubPAT is a GitHub personal access token. Constrained by a // repository allowlist and a permission set. KindGitHubPAT Kind = "github_pat" // KindGitHubApp is a GitHub App installation credential (JSON with // app_id/installation_id/private_key). Constrained like a PAT. KindGitHubApp Kind = "github_app" // KindKubeconfig is a kubeconfig YAML document. Constrained by allowed // contexts and namespaces, and rewritten to match before delivery. KindKubeconfig Kind = "kubeconfig" // KindRegistry is a container-registry credential (docker config JSON, // or "user:password"). Constrained by an allowed registry list. KindRegistry Kind = "registry" // KindEnv is an opaque environment variable or set of them. Constrained // by an allowed key list. KindEnv Kind = "env" // KindEgressProxy is an outbound proxy endpoint, possibly with embedded // credentials. Constrained by an allowed-host list. KindEgressProxy Kind = "egress_proxy" // KindLocalRepo is a directory on the hub host that holds git // repositories. Constrained by a repository allowlist, and delivered as // a bind mount rather than as bytes. // // It is the odd one out here and deliberately so. The payload is a path, // not a credential, and the thing being granted is reach into the control // plane's own filesystem — which is exactly why it belongs in this // package rather than beside SpecMount. A path that a sandbox may read is // an authority, and every property this broker already provides for // authorities is one it needs: a named subject so the grant reaches one // project and not the fleet, a TTL so it lapses, an audit row recording // who opened it, and revocation that lands within a lease period. // // The alternative — a host path in .cloop/sandbox.yaml — is the reason // SpecMount sources are workspace-relative: that file is repo-committed, // so it is whatever a pull request says it is. This kind is the same // capability with the trust inverted. A human with secret.grant names the // path, out of band, and the project receives it. KindLocalRepo Kind = "local_repo" )
func Kinds ¶
func Kinds() []Kind
Kinds returns every valid Kind, sorted, for CLI help and validation messages.
type Lease ¶
type Lease struct {
ID string `json:"id"`
ExecutorID string `json:"executor_id,omitempty"`
ProjectID string `json:"project_id,omitempty"`
IssuedAt time.Time `json:"issued_at"`
ExpiresAt time.Time `json:"expires_at"`
// Materials carries the credentials. json:"-" on the sensitive fields
// of Material keeps a marshalled Lease audit-safe.
Materials []Material `json:"materials"`
}
Lease is a time-boxed bundle of materials issued to one requester.
TTL is the point of the type. A grant may last 24 hours, but the credentials an executor holds should not: a lease expires in minutes and is renewed while the work continues, so a compromised executor's window is bounded by the lease rather than by the grant.
func (*Lease) Deliver ¶
Deliver renders the lease for a workload that will find its credentials at dir on a filesystem this process does not own, without writing anything.
The Delivery holds the plaintext in memory for the driver to place — staged into a container's own tmpfs, projected as a Kubernetes Secret, or sent to an edge agent that writes it through its confined vault path. That is the whole difference from Materialize: the control plane never has a file to leak for a credential it is only relaying.
func (*Lease) Empty ¶
Empty reports whether the lease carries no credentials. An empty lease is a legitimate outcome — a project with no grants gets one — and callers should treat it as "run with no secrets", not as an error.
func (*Lease) Kinds ¶
Kinds returns the distinct credential kinds the lease carries, sorted.
Deduplicated because a lease holding two GitHub PATs is one lease that involves PATs, not two: leaving the duplicate in would make the lease event counters a function of how many grants happened to match rather than of how many leases were issued.
func (*Lease) Materialize ¶
Materialize writes the lease's credentials into a private directory and returns the Mount describing it. baseDir may be empty to use a tmpfs.
Call it only for a workload that will read this host's filesystem — see executor.Capabilities.SecretFilesFromHostPath. For anything isolated, use Deliver: writing plaintext here for a sandbox that cannot open it creates a credential file on the control plane that nothing ever reads.
The caller must Close the Mount when the workload exits. Failure partway through cleans up what was already written rather than leaving credential files behind.
func (*Lease) MaterializeAt ¶
MaterializeAt writes the lease's credentials into dir, which it creates.
dir must be absolute and its final element must carry leaseDirPrefix: the prefix is what every wipe path in this system uses to recognise a directory as lease-owned, and one created without it could never be swept. Creation is exclusive — a directory that already exists is refused rather than reused, so two leases cannot land in one directory and have the first Close take the second's files.
func (*Lease) SecretNames ¶
SecretNames lists the delivered secrets by name, for logging.
type LeaseBinding ¶
type LeaseBinding struct {
// GrantID is the grant this material came from.
GrantID string
// SecretID and SecretName identify the stored secret, for diagnostics.
SecretID string
SecretName string
// Kind is the credential kind.
Kind Kind
// EnvKeys are the variables this grant contributed, by name.
EnvKeys []string
// Files are the absolute paths written for this grant.
Files []string
// Dir is the lease directory holding Files.
Dir string
// Mounts are the host paths this grant opened, recorded for the audit
// trail rather than for revocation.
//
// The distinction matters and is a real limitation: a revoked lease's
// files are wiped and its variables scrubbed, but a bind already in a
// running sandbox's mount namespace cannot be taken back from outside it.
// A local_repo grant therefore lapses when the workload exits, not when
// the grant is revoked. Narrowing that window means stopping the workload.
Mounts []RepoMount
}
LeaseBinding attributes part of a materialised lease to the grant that produced it: which environment variables it set, and which files it wrote.
It carries names and paths, never values. The point is revocation — an executor holding GITHUB_TOKEN cannot otherwise tell that the variable came from a grant an operator has just withdrawn, so "revoke this lease" could only be honoured by killing the whole workload. With the attribution, a driver scrubs exactly what the lease delivered and leaves the rest alone.
See pkg/executor.SecretBinding, the driver-facing shape this maps onto, and pkg/executor/remote/revoke.go for what a driver does with it.
type LegacyImportResult ¶
type LegacyImportResult struct {
// Imported names the secrets newly minted into the broker.
Imported []string
// Skipped names entries already present (the import is idempotent).
Skipped []string
}
LegacyImportResult reports what a legacy import did.
func ImportLegacySecrets ¶
func ImportLegacySecrets(ctx context.Context, b *Broker, workDir, actor string) (LegacyImportResult, error)
ImportLegacySecrets copies every entry from the flat pkg/secret store into the broker as an unscoped `env` secret with a matching wildcard grant.
"Unscoped" is the whole point and is deliberate. The entries being imported were, until this moment, delivered to every workload with no scoping at all; narrowing them during a migration would break running projects at a moment nobody chose, and would do it silently — the workload would simply stop seeing a variable it depends on. So the import preserves existing reach exactly, with SubjectAny and no expiry, and leaves tightening to an operator who can then do it one grant at a time and watch what breaks. Every imported grant is audited, so "which of my secrets are still wide open" is a query rather than an archaeology exercise.
It is idempotent: an entry whose name already exists in the broker is skipped, so this can run on every broker construction.
A missing or unreadable legacy store is not an error — most installs will not have one.
type Material ¶
type Material struct {
GrantID string `json:"grant_id"`
SecretID string `json:"secret_id"`
SecretName string `json:"secret_name"`
Kind Kind `json:"kind"`
Constraints Constraints `json:"constraints"`
// Env are variables to inject, already narrowed. Values are
// credentials; this field is json:"-" so a Material cannot be
// serialised into an API response by accident.
Env map[string]string `json:"-"`
// Files are written into the lease's tmpfs directory.
Files []File `json:"-"`
// Mounts are host paths the grant opens to the workload. Unlike Files
// they are not written anywhere: the path already exists and the grant
// is permission to reach it, so materialisation validates it and the
// driver binds it.
//
// This one is not json:"-", because a mount carries no secret — a source
// path and a read-only flag are exactly what an operator inspecting a
// lease needs to see, and hiding them would make the one material kind
// with no credential in it the hardest to audit.
Mounts []RepoMount `json:"mounts,omitempty"`
// Summary is the audit-safe description of what was delivered
// (surviving kubeconfig contexts, allowed registries, env key names).
Summary string `json:"summary,omitempty"`
}
Material is one grant's credentials, already minimized against that grant's constraints and ready to hand to an executor.
The plaintext lives here and only here. A Material is short-lived by construction: it exists between Lease and Materialize, and the Mount that results is wiped when the workload exits.
func (Material) GitHubToken ¶
GitHubToken returns the raw token a GitHub material carries, and whether it carries one at all.
It exists because workspace provisioning (pkg/executor/gitcreds) needs the token as an HTTP basic password, and the two paths that already deliver it — the credential-helper script and the bare GITHUB_TOKEN env var — are both wrong for that use. The helper needs a shell and a materialised lease directory, which is exactly the disk write a sandbox fetch must avoid; and GITHUB_TOKEN is only exported for an unrestricted "*" grant, so relying on it would make narrow grants silently unable to clone.
Reading the token out of Files rather than re-deriving it keeps one source of truth: whatever githubMaterial decided to deliver is what a caller sees. The returned string is the caller's to handle carefully — it is a credential, and nothing here can stop it being logged.
type MintRequest ¶
type MintRequest struct {
Name string
Kind Kind
Payload []byte
Metadata map[string]string
Actor string
}
MintRequest describes a secret to store.
type Mount ¶
type Mount struct {
// Dir is the lease directory. Mode 0700, on a tmpfs where one is
// available.
Dir string
// contains filtered or unexported fields
}
Mount is a materialised lease on a filesystem: a private directory holding the credential files, plus the environment to hand the workload.
Close wipes it. The zero value is not usable; get one from Lease.Materialize.
func (*Mount) Bindings ¶
func (m *Mount) Bindings() []LeaseBinding
Bindings returns the per-grant attribution for this mount: which variables and files each grant contributed. Names and paths only — no values.
A driver uses it to build executor.SecretBinding, which is what makes a mid-run revocation targetable at one credential instead of at the whole workload.
func (*Mount) Close ¶
Close wipes and removes the mount. It is idempotent and safe to call from a defer alongside an explicit call.
Each file is overwritten with zeros before removal. On a tmpfs that is belt-and-braces, since the pages are freed anyway; on the os.TempDir() fallback it is the only thing standing between an unlinked credential and whoever reads the underlying blocks next. It is not a guarantee against a copy-on-write or log-structured filesystem, which is why tmpfs is preferred rather than merely convenient.
type Option ¶
type Option func(*Broker)
Option configures a Broker.
func WithAuditor ¶
WithAuditor sets the audit sink. Without one, events are dropped — which is acceptable for tests and for read-only CLI paths, and never for the control plane.
func WithCipher ¶
WithCipher supplies a pre-built Cipher, bypassing CLOOP_SECRET_KEY.
A Cipher has no key registry, so a broker built this way seals in the legacy single-key shape and cannot rotate. That is right for a test double and wrong for a hub; prefer WithKeyring where rotation matters.
func WithClock ¶
WithClock overrides the time source. Tests use it to exercise TTL expiry without sleeping.
func WithKeyring ¶
WithKeyring supplies a pre-opened Keyring, so a caller that already built one (the hub, which shares it with the session store) does not pay the KDF cost a second time.
func WithMaxLeaseTTL ¶
WithMaxLeaseTTL overrides DefaultMaxLeaseTTL. Values above the grant's own remaining lifetime are still clamped to it.
type RepoMount ¶
type RepoMount struct {
// Name is the repository's directory name under the granted root, and
// the handle a workload refers to it by.
Name string `json:"name"`
// Source is the absolute host path. Always inside the granted root:
// selectRepos resolves symlinks before accepting one, so a symlink
// planted under the root cannot redirect the bind out of it.
Source string `json:"source"`
// Target is the absolute path the repository appears at in the sandbox.
Target string `json:"target"`
// ReadOnly mirrors the grant's Writable constraint, inverted, because
// read-only is the default and a zero value should mean the safe thing.
ReadOnly bool `json:"read_only,omitempty"`
}
RepoMount is one local git repository a grant opens to a workload.
It names paths on two different machines' terms: Source is on the host the executor runs on, Target is inside the sandbox. They are equal only for a driver that shares the host filesystem, which is why the distinction is in the type rather than left to the driver to remember.
type Requester ¶
Requester is the identity a lease is issued to: one executor running work for one project. Labels are the executor's own labels, used by SubjectLabel grants.
type RotateOptions ¶
type RotateOptions struct {
// NewKey mints a fresh KEK and promotes it before rewrapping. This is
// the normal case: rotating onto the *current* primary is an upgrade of
// stragglers, not a rotation.
NewKey bool
// Actor is recorded on the new key and the rotation record.
Actor string
// DryRun reports what would be rewrapped without writing anything.
DryRun bool
}
RotateOptions configures one run.
type RotationRecord ¶
type RotationRecord struct {
ID string `json:"id"`
ToKeyID string `json:"to_key_id"`
State string `json:"state"`
StartedAt time.Time `json:"started_at"`
UpdatedAt time.Time `json:"updated_at"`
FinishedAt time.Time `json:"finished_at,omitempty"`
StartedBy string `json:"started_by,omitempty"`
Total int `json:"total"`
Rewrapped int `json:"rewrapped"`
Skipped int `json:"skipped"`
Failed int `json:"failed"`
LastError string `json:"last_error,omitempty"`
}
RotationRecord is the operator-facing history of a rotation run.
type RotationReport ¶
type RotationReport struct {
RotationID string `json:"rotation_id,omitempty"`
TargetKeyID string `json:"target_key_id"`
Sets []SetReport `json:"sets"`
Rewrapped int `json:"rewrapped"`
Skipped int `json:"skipped"`
Failed int `json:"failed"`
Complete bool `json:"complete"`
DryRun bool `json:"dry_run,omitempty"`
}
RotationReport is the outcome of one Rotate call.
type RotationStatus ¶
type RotationStatus struct {
PrimaryKeyID string `json:"primary_key_id"`
Keys []KeyInfo `json:"keys"`
Usage []KeyUsage `json:"usage"`
Legacy int `json:"legacy_rows"`
LegacyOpen bool `json:"legacy_openable"`
Unrotated int `json:"unrotated_rows"`
Rotations []RotationRecord `json:"rotations,omitempty"`
}
RotationStatus is the whole picture: which keys exist, what each is sealing, and how the last rotation went.
type RotationStore ¶
type RotationStore interface {
PutRotation(r RotationRecord) error
ListRotations(limit int) ([]RotationRecord, error)
}
RotationStore persists rotation history. Optional: a rotator without one still rotates, it just cannot show progress after the process exits.
type Rotator ¶
type Rotator struct {
// contains filtered or unexported fields
}
Rotator rewraps every sealed row onto the keyring's primary KEK.
func NewRotator ¶
NewRotator builds a rotator over a keyring and one or more sealed sets.
func (*Rotator) RetireKey ¶
RetireKey retires a KEK after confirming nothing references it.
The check here is advisory and the store's is authoritative — a row could be written between the two — but running it first turns the common case into a message naming the sets and counts that are blocking, instead of a bare constraint violation from SQLite.
func (*Rotator) Rotate ¶
func (r *Rotator) Rotate(ctx context.Context, opts RotateOptions) (RotationReport, error)
Rotate rewraps every row onto the primary KEK.
Safe to call repeatedly, concurrently with hub traffic, and after an interruption — see the file comment. The returned report is complete only when every set drained; otherwise ErrRotationFailed is returned alongside a report that says precisely how far it got, and re-running continues.
func (*Rotator) Status ¶
func (r *Rotator) Status(historyLimit int) (RotationStatus, error)
Status assembles the report behind `cloop hub key status`.
func (*Rotator) WithHistory ¶
func (r *Rotator) WithHistory(h RotationStore) *Rotator
WithHistory attaches a store for rotation records.
func (*Rotator) WithProgress ¶
func (r *Rotator) WithProgress(fn func(RotationRecord)) *Rotator
WithProgress installs a callback invoked after each batch, for a CLI progress line.
type SealedRow ¶
SealedRow is one piece of sealed material as seen by the rotator.
It carries no plaintext and the rotator never produces any: rewrapping operates on the DEK, not the payload. AAD is the row's associated data — its ID — which must round-trip exactly or the rewrap fails authentication.
type SealedSet ¶
type SealedSet interface {
// SealedSetName identifies the set in reports ("secrets", "sessions").
SealedSetName() string
// CountSealedByKey returns row counts keyed by key ID, including
// LegacyKeyID for pre-envelope rows.
CountSealedByKey() (map[string]int, error)
// ListSealedNotUnder returns up to limit rows whose key ID differs from
// keyID. Order is unspecified; the rotator does not depend on it.
ListSealedNotUnder(keyID string, limit int) ([]SealedRow, error)
// ReplaceSealed swaps a row's envelope if and only if the stored one
// still matches expect exactly. It reports whether the swap happened;
// false is a normal outcome (a concurrent writer got there first), not
// an error.
ReplaceSealed(id string, expect, next Envelope) (bool, error)
}
SealedSet is one population of rows that participates in rotation.
Implemented by pkg/secretstore (brokered secrets) and pkg/sessionstore (session refresh tokens). Anything else that seals long-lived material under the hub key implements this and is rotated for free — which is the point of the interface: the alternative is a rotation command that knows the names of the tables, and therefore silently misses the next one.
type Secret ¶
type Secret struct {
ID string `json:"id"`
Kind Kind `json:"kind"`
// Name is a human-facing unique handle ("prod-deploy-pat"). Grants are
// created against it from the CLI.
Name string `json:"name"`
// Sealed is the AES-256-GCM ciphertext of the payload, encrypted under
// this secret's own data key (see WrappedDEK). Never logged.
Sealed []byte `json:"-"`
// KeyID names the KEK that WrappedDEK is sealed under. LegacyKeyID means
// the pre-envelope construction: WrappedDEK is empty and Sealed is
// encrypted directly under the passphrase-derived key (Task 20181).
//
// It is exported because storage must persist it, and json:"-" because a
// key ID in an API response tells a caller nothing they can act on while
// telling an attacker which rows a stolen key still opens.
KeyID string `json:"-"`
// WrappedDEK is this secret's data key, sealed under KeyID. Rotation
// rewrites this field and leaves Sealed untouched.
WrappedDEK []byte `json:"-"`
// Metadata is non-sensitive descriptive data (owner, rotation date).
// It is included in audit payloads, so callers must not put credential
// material here.
Metadata map[string]string `json:"metadata,omitempty"`
CreatedAt time.Time `json:"created_at"`
CreatedBy string `json:"created_by,omitempty"`
}
Secret is a stored credential. The payload is sealed (AES-256-GCM) at all times outside the broker: there is deliberately no plaintext field on this struct, so a Secret that escapes into a log, an API response, or the storage layer cannot carry the credential with it.
func (Secret) Envelope ¶
Envelope returns the secret's sealed material in the form the keyring understands.
func (Secret) WithEnvelope ¶
WithEnvelope returns a copy of the secret carrying env's sealed material.
type SetReport ¶
type SetReport struct {
Name string `json:"name"`
Rewrapped int `json:"rewrapped"`
Skipped int `json:"skipped"`
Failed int `json:"failed"`
Errors []string `json:"errors,omitempty"`
}
SetReport is per-set rotation detail.
type Store ¶
type Store interface {
// PutSecret inserts or replaces a secret by ID.
PutSecret(s Secret) error
// GetSecret returns the secret with the given ID, or a wrapped
// ErrSecretNotFound.
GetSecret(id string) (Secret, error)
// ListSecrets returns every stored secret.
ListSecrets() ([]Secret, error)
// DeleteSecret removes a secret. Removing a secret that does not exist
// returns a wrapped ErrSecretNotFound.
DeleteSecret(id string) error
// PutGrant inserts or replaces a grant by ID.
PutGrant(g Grant) error
// GetGrant returns the grant with the given ID, or a wrapped
// ErrGrantNotFound.
GetGrant(id string) (Grant, error)
// ListGrants returns every stored grant, including expired and revoked
// ones — the broker filters, so that callers listing for an audit UI
// can still see history.
ListGrants() ([]Grant, error)
// RevokeGrant stamps a grant revoked at the given time. Revoking an
// already-revoked grant is a no-op, not an error: revocation is
// idempotent by design, because a caller racing to revoke twice must
// not be told the second attempt failed.
RevokeGrant(id string, at time.Time) error
// Meta reads a broker-scoped metadata value.
Meta(key string) (value string, ok bool, err error)
// SetMeta writes a broker-scoped metadata value.
SetMeta(key, value string) error
}
Store is the persistence contract the broker needs.
It is an interface, and it lives here rather than in pkg/statedb, for the same reason pkg/executor/remote defines its own Store: the broker's policy logic is the part worth testing exhaustively, and it should not need a SQLite file to test. pkg/secretstore is the production implementation.
The payload crosses this boundary sealed and only sealed. Store implementations therefore cannot leak plaintext credentials even if they log every argument they receive — which is the point, since a storage layer is exactly the kind of code that grows debug logging.
type Subject ¶
type Subject struct {
Type SubjectType `json:"type"`
// Value is the project path or executor ID for those types, "*" for a
// wildcard within the type, and unused for label/any.
Value string `json:"value,omitempty"`
// Labels is the selector for SubjectLabel: every pair must be present
// on the requester for the grant to match.
Labels map[string]string `json:"labels,omitempty"`
}
Subject is the "who" of a grant.
func ParseSubject ¶
ParseSubject parses the CLI's "--to" syntax:
project:/srv/app one project, by path project:* every project executor:edge-01 one executor label:region=eu,gpu=true every executor carrying both labels any / * every requester (used by the legacy import)
An unrecognised or empty spec is an error rather than a permissive default: a typo in a grant target must not silently widen it.
func (Subject) Matches ¶
Matches reports whether this subject selects r.
A wildcard "*" is honoured within project/executor types, but only when written explicitly — an empty Value never matches, so a half-constructed Subject fails closed instead of matching everything.
type SubjectType ¶
type SubjectType string
SubjectType is the dimension a Grant's subject selects on.
const ( // SubjectProject scopes a grant to one project (or "*" for all). SubjectProject SubjectType = "project" // SubjectExecutor scopes a grant to one executor ID (or "*"). SubjectExecutor SubjectType = "executor" // SubjectLabel scopes a grant to executors carrying every label in the // selector — the way a fleet of edge devices is addressed without // enumerating device IDs. SubjectLabel SubjectType = "label" // SubjectAny matches every requester. Reserved for the legacy import // path, where narrowing an existing flat secret store would silently // break running projects. SubjectAny SubjectType = "any" )