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 ExecutorBindings(leaseID string, expiresAt time.Time, raw []LeaseBinding) []executor.SecretBinding
- func ExecutorSecretFiles(leaseID string, raw []DeliveredFile) []executor.SecretFile
- func GitHubAppPermissions(c Constraints) (map[string]string, error)
- 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 NormalizeOwner(identity string) string
- 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 AccessRequest
- type AccessRequestInput
- type Action
- type AppCredential
- type Auditor
- type AuditorFunc
- type Broker
- func (b *Broker) ApproveRequest(ctx context.Context, in DecideInput) (AccessRequest, Grant, error)
- 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) DeleteSecretFor(ctx context.Context, ref string, v Viewer) error
- func (b *Broker) DenyRequest(ctx context.Context, in DecideInput) (AccessRequest, error)
- func (b *Broker) DescribeSecret(ref string) (Secret, error)
- func (b *Broker) DescribeSecretFor(ref string, v Viewer) (Secret, error)
- func (b *Broker) ExpireRequests(ctx context.Context) ([]AccessRequest, error)
- func (b *Broker) GetRequest(id string) (AccessRequest, 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) ListGrantsFor(f GrantFilter, v Viewer) ([]Grant, error)
- func (b *Broker) ListRequests(f RequestFilter) ([]AccessRequest, error)
- func (b *Broker) ListSecrets() ([]Secret, error)
- func (b *Broker) ListSecretsFor(v Viewer) ([]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) RequestAccess(ctx context.Context, in AccessRequestInput) (AccessRequest, error)
- func (b *Broker) RequestUses(requestID string) ([]RequestUse, error)
- func (b *Broker) Revoke(ctx context.Context, grantID, actor string) error
- func (b *Broker) SweepExpired() (expired, live map[Kind]int)
- func (b *Broker) WithdrawRequest(ctx context.Context, id, actor string) (AccessRequest, error)
- 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) KubeReadOnly() bool
- func (c Constraints) KubeVerbs() []string
- func (c Constraints) Summary() string
- func (c Constraints) ValidateFor(kind Kind) error
- type DecideInput
- type Decision
- type Delegation
- type DeliveredFile
- type Delivery
- type Envelope
- type Event
- type File
- type GitGuard
- type GitGuardRequest
- type GitGuardResult
- type GitHubAppAPI
- type Grant
- type GrantFilter
- type GrantRequest
- type GrantedDevice
- type InstallationRepo
- type InstallationToken
- type InstallationTokenRequest
- 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 KubeGuard
- type KubeGuardRequest
- type KubeGuardResult
- 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 RequestFilter
- type RequestState
- type RequestStore
- type RequestUse
- 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
- func (s Secret) DeletableBy(v Viewer) bool
- func (s Secret) Envelope() Envelope
- func (s Secret) OwnedBy(identity string) bool
- func (s Secret) Personal() bool
- func (s Secret) SpendableBy(v Viewer) bool
- func (s Secret) Validate() error
- func (s Secret) VisibleTo(v Viewer) bool
- func (s Secret) WithEnvelope(env Envelope) Secret
- type SetReport
- type Store
- type Subject
- type SubjectType
- type UnavailableGitGuard
- type UnavailableKubeGuard
- type Viewer
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 ( // DefaultRequestTTL is how long a request waits for a decision before it // lapses. // // Bounded at all because the failure mode of an unbounded queue is not that // it grows — it is that a stale request gets approved. Three days is long // enough to survive a weekend and short enough that an approval is still // being made against a reason the approver can evaluate. DefaultRequestTTL = 72 * time.Hour // MaxRequestTTL caps how long a request may wait, whatever the caller asks // for. Same argument, applied to the caller who would rather it never // lapsed. MaxRequestTTL = 30 * 24 * time.Hour // DefaultDelegationMaxTTL is the ceiling on a minted grant's lifetime when // an approver's Delegation does not state one. // // Deliberately shorter than a request may ask for: the default answer to // "how long should this credential live" should be days, and an approver who // genuinely needs longer states it rather than gets it by omission. DefaultDelegationMaxTTL = 7 * 24 * time.Hour // MaxDelegationTTL is the longest lifetime any approver may hand out, // whatever their Delegation says. // // Not a security boundary — an approver can always approve again, and one // with a shell on the hub can write the row by hand. What it is, is a floor // under the product's own honesty: the broker exists to replace the // forever-credential, and an approval path that could mint a ten-year grant // in one click would put the thing back exactly where it was found. Ninety // days matches the ceiling the Secrets panel already enforces on // hand-minted grants, so the two doors cannot disagree. MaxDelegationTTL = 90 * 24 * time.Hour // MaxJustificationBytes bounds the free text a request carries. Long enough // for a paragraph and a ticket link; short enough that the field cannot be // used to stuff the control-plane database. MaxJustificationBytes = 4096 )
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 GitProxySessionEnvKey = "CLOOP_GIT_PROXY_SESSION"
GitProxySessionEnvKey is the environment variable a guarded delivery carries the proxy session id in.
Exported because the hub reads it back to revoke the session when the lease is released, and a second copy of the literal in pkg/ui would be a string that could drift — leaving sessions alive with nothing pointing at them.
The id is not secret: the token it names lives in a separate 0600 file, and the proxy's own audit events already carry the id in the clear.
const KubeGuardSessionEnvKey = "CLOOP_KUBEGUARD_SESSION"
KubeGuardSessionEnvKey carries the monitor session's id into the delivered environment, so a lease's sessions can be found again at release time without this package having to learn what a monitor session is.
The id is not a credential — the bearer token is inside the kubeconfig file, at 0600 — so carrying it in the environment costs nothing and buys revocation that lands before the TTL.
const MaxHostDevices = 16
MaxHostDevices bounds how many devices one grant may open.
It matches executor.MaxDevices because they bound the same list at two ends of the same pipe, and a grant that could mint more than a Spec can carry would fail at dispatch with an error about the Spec rather than about the grant.
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") // ErrGitHubAppMint: a GitHub App installation token could not be minted. // The grant is denied — the private key is never delivered as a fallback. ErrGitHubAppMint = errors.New("secretbroker: github app token mint failed") // ErrGitHubAppRevoke: a minted installation token could not be destroyed // at GitHub. It still lapses on GitHub's own expiry, but until then it is // a credential nothing here can take back. ErrGitHubAppRevoke = errors.New("secretbroker: github app token revoke failed") // 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") // ErrRequestsUnsupported: this broker's store does not keep requests, so // the request path is unavailable. Reported rather than silently degrading // to "no requests", which would make an empty queue indistinguishable from // a queue nobody can read. ErrRequestsUnsupported = errors.New("secretbroker: this store does not keep access requests") // ErrInvalidRequest: the access request failed structural validation. ErrInvalidRequest = errors.New("secretbroker: invalid access request") // ErrRequestNotFound: no access request with that ID exists. ErrRequestNotFound = errors.New("secretbroker: access request not found") // ErrRequestNotPending: the request was already decided, withdrawn or // expired, so it cannot be decided again. ErrRequestNotPending = errors.New("secretbroker: access request is already decided") // ErrRequestExpired: the request lapsed before anyone decided it. ErrRequestExpired = errors.New("secretbroker: access request expired") // ErrNotRequester: only the identity that filed a request may withdraw it. ErrNotRequester = errors.New("secretbroker: only the requester may withdraw a request") // ErrSelfApproval: the decider filed the request. Refused explicitly rather // than left to the role check, because on a small team the requester very // often holds the approving permission too. ErrSelfApproval = errors.New("secretbroker: a request cannot be approved by the person who filed it") // ErrDelegationExceeded: the approval would hand out more than this // approver is entitled to delegate. ErrDelegationExceeded = errors.New("secretbroker: approval exceeds what this approver may delegate") // ErrNotOwner: the caller tried to spend, re-scope or destroy a personal // secret belonging to somebody else. A denial, not a validation error: // reaching for another person's credential is precisely the event the // ownership model exists to record. // // Paths that must not reveal the secret exists at all report // ErrSecretNotFound instead; this sentinel is for the paths where the // caller has already legitimately been shown the secret and is asking to // act on it. ErrNotOwner = errors.New("secretbroker: this secret belongs to another user") // ErrOwnerRequired: a personal secret was requested without an identity to // own it. An anonymously-owned secret would be indistinguishable from a // shared one at every later check, so it is refused at the point of // minting rather than silently widened into the organisation's. ErrOwnerRequired = errors.New("secretbroker: a personal secret needs an owner identity") // ErrPersonalWildcard: a personal secret was granted to a wildcard or // "any" subject. A personal credential exists to be spent on its owner's // own work, and a subject matching every project or every executor hands // it to whoever runs next — the one outcome personal ownership is meant // to prevent. ErrPersonalWildcard = errors.New("secretbroker: a personal secret cannot be granted to a wildcard subject") )
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.
ErrGuardUnavailable reports that a guard was configured but could not produce a session.
It is a distinct sentinel because the choice it forces is not a detail. A guarded delivery that quietly fell back to writing the PAT into the sandbox would hand over the broad credential at precisely the moment the boundary meant to narrow it was broken — the one circumstance in which it must not. Callers fail the lease instead. See githubMaterial.
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 ExecutorBindings ¶ added in v0.0.2
func ExecutorBindings(leaseID string, expiresAt time.Time, raw []LeaseBinding) []executor.SecretBinding
ExecutorBindings projects a lease's per-grant attribution onto the driver-facing executor.SecretBinding.
The bindings are what let a driver take *one* credential back mid-run instead of killing the workload: they name the environment variables and file paths a given grant contributed, and nothing else. No material crosses this boundary — see SecretBinding's own documentation for why that is a hard rule rather than a convention.
expiresAt rides along on every binding so a driver that has lost contact with the control plane can expire the material locally. Without it a lease TTL binds only the hub, which is the half of the system least likely to be the one holding the credential when it matters.
func ExecutorSecretFiles ¶ added in v0.0.2
func ExecutorSecretFiles(leaseID string, raw []DeliveredFile) []executor.SecretFile
ExecutorSecretFiles projects the credential files a driver has to place itself onto executor.SecretFile.
Only a delivered (not materialised) lease has these. When the hub wrote the files to its own filesystem, the workload reads them from there and the bindings already name the paths; handing the contents over as well would put plaintext into a second place for no benefit.
func GitHubAppPermissions ¶ added in v0.0.2
func GitHubAppPermissions(c Constraints) (map[string]string, error)
GitHubAppPermissions maps a grant's permission set onto the permissions object of an installation token request.
The default is contents:read, because a github credential in cloop exists to clone, and a grant that says nothing about permissions has authorised nothing more than reading. A grant that allows a write to contents gets contents:write, so a push works and nothing else widens.
A "*" entry returns nil, which asks GitHub for the installation's full permission set. That is the same reading allowsAllRepos gives the repository allowlist: the wildcard has to be written out, and when it is, it means it.
The error is the reason this is exported to Constraints.ValidateFor as well as used at mint time: "pull_requests:maybe" is a typo that should be refused in front of the operator who wrote it, not eight hours later inside a lease.
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 NormalizeOwner ¶ added in v0.0.2
NormalizeOwner trims an owner identity into the form stored on a Secret.
Exported because the hub needs to compare a request's identity against a stored owner, and a second normaliser written at the call site is how the two come to disagree about whether "Alice@Corp" owns "alice@corp".
Lowercased because OwnerKey already is for the email case, and storing a mixed-case copy would make the SQL index disagree with OwnedBy's case-insensitive comparison — two answers to "is this yours" is one too many. The "sub:" form is left alone beyond the prefix: an issuer subject is opaque and case-sensitive, so lowercasing it could merge two distinct people.
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 AccessRequest ¶ added in v0.0.2
type AccessRequest struct {
ID string `json:"id"`
// RequestedBy is the identity that asked — an OIDC subject, a service
// account, or "cli:<user>". It is the value ApproveRequest compares against
// the decider, so it must identify a person and not a role.
RequestedBy string `json:"requested_by"`
SecretID string `json:"secret_id"`
SecretName string `json:"secret_name,omitempty"`
Kind Kind `json:"kind,omitempty"`
// Subject and Constraints are the grant this request is asking to have
// created. They are replayed verbatim on approval.
Subject Subject `json:"subject"`
Constraints Constraints `json:"constraints"`
Scope string `json:"scope,omitempty"`
// TTL is the grant lifetime asked for. What was actually issued is
// GrantExpiresAt minus DecidedAt, which will be shorter whenever the
// approver or their delegation clamped it — and a reviewer comparing the
// two is exactly how "did anyone actually narrow this" gets answered.
TTL time.Duration `json:"ttl"`
// Justification is why. Required, because a request with no stated reason
// cannot be evaluated, only rubber-stamped.
Justification string `json:"justification"`
State RequestState `json:"state"`
CreatedAt time.Time `json:"created_at"`
// ExpiresAt is when this *request* lapses undecided. Never the credential's
// expiry — that is GrantExpiresAt.
ExpiresAt time.Time `json:"expires_at"`
DecidedBy string `json:"decided_by,omitempty"`
DecidedAt time.Time `json:"decided_at,omitempty"`
DecisionNote string `json:"decision_note,omitempty"`
// GrantID is the grant an approval minted, and empty in every other state.
GrantID string `json:"grant_id,omitempty"`
GrantExpiresAt time.Time `json:"grant_expires_at,omitempty"`
}
AccessRequest is one developer's ask, and its decision.
There is no payload field and nowhere one could go: a request names a secret that already exists. The credential itself never touches this type, which is what makes an AccessRequest safe to render into a review panel, a CLI table and an audit payload without any of them having to redact it.
func (AccessRequest) Pending ¶ added in v0.0.2
func (r AccessRequest) Pending(now time.Time) bool
Pending reports whether the request is still awaiting a decision at now, treating a lapsed deadline as not pending even before a sweep has moved it.
The clock is consulted rather than the state alone so that a decision path cannot act on a request the next sweep is about to expire. Without it, whether a stale request could still be approved would depend on how recently the janitor happened to run.
type AccessRequestInput ¶ added in v0.0.2
type AccessRequestInput struct {
// SecretRef is a secret ID or name. The secret must already exist: a
// request asks for access to a credential an operator stored, never for one
// to be created, because creating one means supplying material and a
// requester by definition does not have it.
SecretRef string
Subject Subject
Constraints Constraints
Scope string
TTL time.Duration
Justification string
// RequestTTL is how long to wait for a decision. Zero means
// DefaultRequestTTL.
RequestTTL time.Duration
// Actor is the requesting identity. Required: an anonymous request cannot
// be approved, because the self-approval check has nothing to compare.
Actor string
}
AccessRequestInput is what a requester supplies.
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" // ActionAppTokenDestroy records a GitHub App installation token being // destroyed at GitHub (Task 20254). // // It is deliberately not ActionRevoke. A github_app token is destroyed on // every ordinary lease release, so folding these rows into `secret.revoke` // would make "how many grants did an operator withdraw" a number dominated // by routine task teardown — and the one question the revoke series exists // to answer would stop having an answer. // // It is also not ActionRelease, for the reason the lease.revoke_* family is // separate from it: a release says a workload finished with a credential, // while this says a credential stopped existing. When the DELETE fails the // first is still true and the second is not, and an incident response needs // to be able to tell those apart. ActionAppTokenDestroy Action = "github_app.token_destroy" // Self-service request actions (Task 20271). // // Separate from the secret.grant family rather than folded into it, because // a request and a grant are different facts and a reviewer needs to tell // them apart. secret.grant answers "what authority exists"; these answer // "who asked, who decided, and how long it took" — and the gap between a // request and its approval is the number that tells an operator whether the // request path is actually being used or routed around. // // secret.request_expire is its own action, not a flavour of deny, for the // reason RequestExpired is its own state: denied is an answer and expired is // the absence of one, and a trail that conflated them would hide a queue // nobody is working. ActionRequestOpen Action = "secret.request" ActionRequestApprove Action = "secret.request_approve" ActionRequestDeny Action = "secret.request_deny" ActionRequestWithdraw Action = "secret.request_withdraw" ActionRequestExpire Action = "secret.request_expire" // 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 AppCredential ¶ added in v0.0.2
type AppCredential struct {
AppID int64
InstallationID int64
// BaseURL is the API root, defaulted to github.com.
BaseURL string
// contains filtered or unexported fields
}
AppCredential is a parsed, validated github_app payload.
The private key is unexported and there is no accessor: the only thing any caller may do with an AppCredential is ask it to sign a JWT, which is what keeps "the key never leaves the hub" a property of the type rather than a convention.
func ParseGitHubApp ¶ added in v0.0.2
func ParseGitHubApp(payload []byte) (*AppCredential, error)
ParseGitHubApp parses and validates a github_app payload.
It is deliberately strict. Before Task 20254 the payload was free-form — the broker never looked at it, so a typo, a PEM with a mangled line ending, or a PAT pasted into the wrong kind was stored happily and failed at lease time, during someone else's run. Now the only accepted shape is
{"app_id": 123, "installation_id": 456, "private_key": "-----BEGIN ..."}
with an optional "base_url" for GitHub Enterprise Server. Both IDs must be integers and the key must parse as RSA, because RS256 is the only algorithm GitHub accepts for an app JWT.
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 {
// GitGuard, when set, takes custody of a GitHub PAT so the sandbox
// receives a git-proxy session instead of the token. It turns the
// repository allowlist from something the workload is asked to respect
// into something it cannot exceed. See gitguard.go.
//
// Exported and settable after construction because the proxy it fronts is
// started by pkg/ui alongside the hub, later than the broker and from a
// layer this package must not import.
GitGuard GitGuard
// KubeGuard, when set, takes custody of a kubeconfig so the sandbox
// receives a monitor session instead of the cluster credential. It turns
// the namespace allowlist from a client-side default into a bound, and it
// is the only way a kubeconfig grant's verbs mean anything at all. See
// kubeguard.go.
//
// Exported and settable after construction for the same reason GitGuard
// is: the monitor it fronts is started by pkg/ui alongside the hub, later
// than the broker and from a layer this package must not import.
KubeGuard KubeGuard
// 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) ApproveRequest ¶ added in v0.0.2
func (b *Broker) ApproveRequest(ctx context.Context, in DecideInput) (AccessRequest, Grant, error)
ApproveRequest mints the grant a request asked for.
The grant is created by calling Broker.Grant — the same entry point the CLI and the REST panel use — rather than by writing a Grant struct here. That is the single most important line in this file: Grant is where a grant's constraints are validated against its secret's kind, where the audit row for a creation is emitted, and where any future check will be added. A second minting path would be a second place for that logic to be missing.
What this function adds on top is the two-person rule, the delegation clamp, and the record linking the grant back to the ask.
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) DeleteSecretFor ¶ added in v0.0.2
DeleteSecretFor removes a secret on behalf of v, revoking its grants.
Deleting is the one act where an admin may reach a personal secret that is not theirs, because the alternative is a hub that cannot complete an offboarding: when someone leaves, their credentials must go with them, and an account that no longer exists cannot come back to press the button. The audit event Broker.DeleteSecret emits names the actor, so an admin doing this is recorded doing it.
func (*Broker) DenyRequest ¶ added in v0.0.2
func (b *Broker) DenyRequest(ctx context.Context, in DecideInput) (AccessRequest, error)
DenyRequest refuses a request, on the record.
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) DescribeSecretFor ¶ added in v0.0.2
DescribeSecretFor resolves a secret by ID or name, for a viewer.
A secret v may not see reports ErrSecretNotFound rather than ErrNotOwner, and the distinction matters: names are chosen by people and are guessable ("alice-github-pat"), so an error that separates "no such secret" from "not yours" would turn this endpoint into an oracle for who keeps what. The hub makes the same choice for projects a caller cannot reach, where require() answers 404 rather than 403.
func (*Broker) ExpireRequests ¶ added in v0.0.2
func (b *Broker) ExpireRequests(ctx context.Context) ([]AccessRequest, error)
ExpireRequests moves pending requests past their deadline to expired and emits one audit event for each.
An event per expiry rather than a summary count, because a lapsed request is an outcome the requester is owed: from their side, an expiry and an approval nobody told them about look identical until they try to use the credential.
func (*Broker) GetRequest ¶ added in v0.0.2
func (b *Broker) GetRequest(id string) (AccessRequest, error)
GetRequest returns one request by ID.
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) ListGrantsFor ¶ added in v0.0.2
func (b *Broker) ListGrantsFor(f GrantFilter, v Viewer) ([]Grant, error)
ListGrantsFor returns the grants v may see.
Grants over personal secrets follow their secret: the grant row carries the owner precisely so this filter needs no second read.
func (*Broker) ListRequests ¶ added in v0.0.2
func (b *Broker) ListRequests(f RequestFilter) ([]AccessRequest, error)
ListRequests returns requests matching the filter, newest first.
func (*Broker) ListSecrets ¶
ListSecrets returns stored secrets. Payloads stay sealed.
func (*Broker) ListSecretsFor ¶ added in v0.0.2
ListSecretsFor returns the secrets v may see, sorted by name.
The scoped counterpart to ListSecrets, which stays unscoped because rotation, offboarding and the hub-host CLI all legitimately need every row. Request handlers must use this one; tests/security enforces that they do.
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 and destroys any credential the hub minted for it. Callers should Close the Mount as well; wiping the lease directory is what removes the copy on disk, and this is what removes the one at GitHub.
It takes no context because every caller is an executor cleanup path that has already finished its work. The revocation gets its own bounded deadline instead (appRevokeTimeout), so a hub cannot block on api.github.com while a task teardown waits behind it.
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) RequestAccess ¶ added in v0.0.2
func (b *Broker) RequestAccess(ctx context.Context, in AccessRequestInput) (AccessRequest, error)
RequestAccess files a request for a grant that does not exist yet.
The request is validated against the secret's kind at filing time, using the same Constraints.ValidateFor that Grant uses. That is deliberate: an ask that could never be approved — a github request with no repository allowlist — should fail in front of the person who can fix it, not days later in front of the approver, who would then have to guess what was meant.
func (*Broker) RequestUses ¶ added in v0.0.2
func (b *Broker) RequestUses(requestID string) ([]RequestUse, error)
RequestUses returns what an approval actually produced: every lease that redeemed the grant, and the task that held it where one is known.
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.
func (*Broker) WithdrawRequest ¶ added in v0.0.2
WithdrawRequest lets the requester take back a pending ask.
Only the requester may withdraw, and the check is here rather than at the route: "withdraw" and "deny" are different words for different acts, and an approver who wants a request gone says no to it, on the record, with a note. Letting an approver withdraw would give them a way to make a request they did not want to answer disappear without ever appearing as a refusal.
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"`
// Verbs is the RBAC verb allowlist for a kubeconfig grant: what the
// holder may *do* to the cluster, as opposed to which parts of it the
// other two fields let them see.
//
// Empty means read-only — get, list and watch — which is the answer to
// "an operator granted a cluster and said nothing about writes". It is
// the one place in this struct where an empty list is neither "allow all"
// nor a validation error, and the asymmetry is deliberate: the safe
// reading of silence about a cluster credential is that nobody asked for
// the ability to change anything.
//
// Unlike Namespaces and Contexts, this cannot be enforced by rewriting
// the kubeconfig — a kubeconfig has no field for it. It is enforced by
// pkg/kubeguard, the monitor the hub runs outside the sandbox, and it
// therefore only takes effect when executors.kube_guard is enabled.
// KubeconfigGuarded reports whether that is so for a given grant, and the
// Secrets panel renders the difference rather than letting a grant claim
// an enforcement that is not running.
Verbs []string `json:"verbs,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"`
// Devices is the device-name allowlist for host_device. Patterns match
// case-sensitively against the inventory's handles; "*" alone allows
// every device in the inventory.
//
// Case-sensitive for the reason the local_repo matcher is: these names
// are chosen by an operator and matched exactly, and folding would make a
// grant on "gpu0" also open "GPU0" — which, if both existed, would be two
// different pieces of hardware nobody named.
Devices []string `json:"devices,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) KubeReadOnly ¶ added in v0.0.2
func (c Constraints) KubeReadOnly() bool
KubeReadOnly reports whether the grant's effective verbs only read. It is what the UI badges and the audit summary render.
func (Constraints) KubeVerbs ¶ added in v0.0.2
func (c Constraints) KubeVerbs() []string
KubeVerbs returns the effective verb allowlist for a kubeconfig grant, resolving the empty list to read-only.
Callers must use this rather than reading Verbs directly, because the two differ in exactly the case that matters: a grant nobody configured.
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 DecideInput ¶ added in v0.0.2
type DecideInput struct {
RequestID string
// Actor is the deciding identity, compared against the requester to refuse
// self-approval.
Actor string
// Note is the approver's comment. Required on a denial — a refusal with no
// reason is one the requester can only respond to by asking again — and
// optional on an approval.
Note string
// TTL, when positive, shortens the minted grant. It can only ever narrow:
// a value above what was requested, or above the delegation ceiling, is
// clamped rather than honoured.
TTL time.Duration
// Delegation is the ceiling this approver may hand out.
Delegation Delegation
}
DecideInput is what an approver supplies. Shared by ApproveRequest and DenyRequest so the two cannot drift apart on identity or note handling.
type Delegation ¶ added in v0.0.2
type Delegation struct {
// MaxTTL caps the minted grant's lifetime. Zero means
// DefaultDelegationMaxTTL.
MaxTTL time.Duration
// AllowWildcardSubject permits approving a request aimed at every project,
// every executor, or every requester.
//
// Its own flag rather than a TTL question because the two failure modes are
// not comparable: a grant that lives too long is bounded by its expiry,
// while a grant aimed at project:* reaches every tenant on the hub for as
// long as it lasts. An approver entitled to the first is not automatically
// entitled to the second.
AllowWildcardSubject bool
}
Delegation bounds what one approver may hand out.
It is a parameter rather than something this package derives, because the answer lives in pkg/authz and in the caller's session: the broker knows what a grant *is*, not who the person clicking approve is allowed to be. Passing it in keeps the authorisation model in one place and still lets the clamp be applied at the point the grant is minted, where it cannot be bypassed by a caller that forgot to check.
The zero value is the safe one: no wildcard subjects, and the default TTL ceiling. A caller that forgets to populate it gets the narrow answer.
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) Devices ¶ added in v0.0.2
func (d *Delivery) Devices() []GrantedDevice
Devices returns the host devices this lease opens.
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"`
// RequestID is the access request an event concerns (Task 20271). Present
// on the request family above, and on nothing else: a grant minted directly
// has no request behind it, and leaving the field empty is what makes
// "which grants came through review" answerable from the trail.
RequestID string `json:"request_id,omitempty"`
LeaseID string `json:"lease_id,omitempty"`
ExecutorID string `json:"executor_id,omitempty"`
ProjectID string `json:"project_id,omitempty"`
// RunID names the execution a lease was issued to (Task 20282). It is what
// makes "which credentials did this task hold" answerable exactly: a task
// id may run many times and unions every attempt's leases, whereas a run id
// names the one execution that spent them.
//
// Populated for dispatch-time leases. Empty for grant and mint events,
// which are operator actions with no execution behind them.
RunID string `json:"run_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 GitGuard ¶ added in v0.0.2
type GitGuard interface {
// GuardGitHub takes a GitHub token and the constraints it was granted
// under, and returns the credentials a sandbox should use in its place.
//
// Returning a zero GitGuardResult with a nil error means "not guarding
// this one" — the delivery falls back to the in-sandbox helper. That is
// how a hub with the proxy switched off keeps working, and it is a
// decision the implementation makes deliberately rather than one this
// package infers from an error it cannot interpret.
GuardGitHub(ctx context.Context, req GitGuardRequest) (GitGuardResult, error)
}
GitGuard interposes an out-of-sandbox proxy between a workload and GitHub.
An implementation takes custody of the token and returns credentials for a proxy that holds it. It must not return a result that embeds the token.
type GitGuardRequest ¶ added in v0.0.2
type GitGuardRequest struct {
// Token is the credential the guard takes custody of. It must not be
// copied into the result, into a log, or into anything the sandbox reads.
Token string
// Repos is the grant's owner/name glob allowlist. The guard enforces it.
Repos []string
// Permissions is the grant's permission set ("contents:read"). The guard
// translates it into what the proxy will allow — most importantly whether
// the sandbox may push at all.
Permissions []string
// Labels. None is secret; all are audit bookkeeping.
SecretName string
SecretID string
GrantID string
LeaseID string
ProjectID string
ExecutorID string
Actor string
// Owner is the identity a personal secret belongs to, or "" for a shared
// one. It rides along so the proxy's audit rows name the person whose
// credential is being spent, which for a personal PAT is the whole point.
Owner string
}
GitGuardRequest is one token and the authority it was granted.
type GitGuardResult ¶ added in v0.0.2
type GitGuardResult struct {
// BaseURL is the proxy's https base, with no path. GitHub URLs are
// rewritten to it.
BaseURL string
// Username and Password authenticate to the proxy and to nothing else.
Username string
Password string
// ExpiresAt is when the proxy stops honouring them.
ExpiresAt time.Time
// SessionID identifies the proxy session, for audit joins and revocation.
SessionID string
// Summary is an audit-safe description of what the proxy will enforce
// ("read-only, acme/*"). It carries no credential.
Summary string
// ReadOnly records whether the grant's permissions left the session
// unable to push. Surfaced so the lease's own summary can say so without
// re-deriving the rule the guard already applied.
ReadOnly bool
}
GitGuardResult is the substitute credential a sandbox receives.
func (GitGuardResult) Guarded ¶ added in v0.0.2
func (r GitGuardResult) Guarded() bool
Guarded reports whether the result actually carries a substitute credential. The zero value means the guard declined, which is not an error.
type GitHubAppAPI ¶ added in v0.0.2
type GitHubAppAPI interface {
// CreateInstallationToken mints an installation access token.
CreateInstallationToken(ctx context.Context, req InstallationTokenRequest) (InstallationToken, error)
// ListInstallationRepos enumerates the repositories the *token* reaches.
// It is authenticated with an installation token, not the app JWT —
// GitHub offers no app-JWT route to this list.
ListInstallationRepos(ctx context.Context, baseURL, installationToken string) ([]InstallationRepo, error)
// RevokeInstallationToken destroys the token used to authenticate the
// call. After it returns nil the credential is dead at GitHub, not merely
// deleted from a sandbox's disk.
RevokeInstallationToken(ctx context.Context, baseURL, installationToken string) error
}
GitHubAppAPI is every call this package makes to GitHub.
It exists so the mint/refresh/revoke logic can be tested without a network, and so that a hub operator reading this file can see the complete list of what cloop asks GitHub for on their behalf: mint a scoped token, enumerate the installation, destroy a token.
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"`
// Owner is copied from the secret this grant points at when that secret
// is personal, and is empty otherwise.
//
// Denormalised on purpose. A grant listing is read far more often than a
// secret is minted, and the alternative — resolving every grant's secret
// to find out whether the row should be shown — is the N+1 read that the
// listing handlers already go out of their way to avoid. More importantly
// it keeps the row self-describing: when the secret is deleted, the
// revoked grants it leaves behind still say whose credential they spent,
// which is exactly the question an offboarding or incident review asks.
Owner string `json:"owner,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.
func (Grant) Personal ¶ added in v0.0.2
Personal reports whether this grant hands out a personally-owned credential.
func (Grant) Validate ¶
Validate checks a Grant's structural invariants, including that its constraints make sense for the kind of secret it points at.
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
// Viewer is the identity the grant is created on behalf of. It gates
// personal secrets: only their owner may hand one to an executor
// (Task 20275). The zero value sees no personal secret at all, so a
// caller that forgets it is refused rather than privileged.
//
// Shared secrets ignore it entirely, which is what keeps every existing
// call site — the CLI, the legacy import, the approval path — correct
// without change.
Viewer Viewer
}
GrantRequest describes a grant to create.
type GrantedDevice ¶ added in v0.0.2
type GrantedDevice struct {
// Name is the handle from the inventory, and what a grant's allowlist and
// a sandbox spec's `devices:` list match against.
Name string `json:"name"`
// Source is the absolute device path on the executor's host.
Source string `json:"source"`
// Target is where it appears inside the sandbox. Equal to Source unless
// the inventory remapped it.
Target string `json:"target"`
// Permissions is the rwm access string ("r", "rw", "rwm").
Permissions string `json:"permissions"`
}
GrantedDevice is one device node a grant opens to a workload.
It mirrors executor.HostDevice and is deliberately a separate type, for the reason RepoMount is: this package does not import pkg/executor, so that the broker stays a statement about authority and the executor package stays a statement about execution. The caller that holds both — pkg/ui — converts.
func ParseDeviceInventory ¶ added in v0.0.2
func ParseDeviceInventory(payload []byte) ([]GrantedDevice, error)
ParseDeviceInventory validates a host_device payload.
Exported so that storing one of these fails in front of the person who typed it, with a message they can act on. It is called by `cloop secret mint` and by the dashboard's mint route before either stores anything.
Validating early matters because the late failure is *quiet*. Broker.LeaseFor emits a deny row for a grant whose material will not build and continues with the rest (see broker.go), so a malformed inventory yields a lease that is simply missing its devices — and a sandbox that starts, runs, and reports on hardware it never had. The audit row is there for anyone who goes looking; the run does not fail, and nobody is looking.
type InstallationRepo ¶ added in v0.0.2
InstallationRepo is one repository an installation covers.
type InstallationToken ¶ added in v0.0.2
type InstallationToken struct {
Token string `json:"token"`
ExpiresAt time.Time `json:"expires_at"`
Permissions map[string]string `json:"permissions,omitempty"`
RepositorySelection string `json:"repository_selection,omitempty"`
}
InstallationToken is what GitHub minted.
func (InstallationToken) GoString ¶ added in v0.0.2
func (t InstallationToken) GoString() string
GoString mirrors String so %#v is redacted too.
func (InstallationToken) String ¶ added in v0.0.2
func (t InstallationToken) String() string
String redacts the token so a %v on a mint result cannot log a credential.
type InstallationTokenRequest ¶ added in v0.0.2
type InstallationTokenRequest struct {
// BaseURL and AppJWT authenticate the request as the app.
BaseURL string
AppJWT string
// InstallationID names the installation to mint for.
InstallationID int64
// RepositoryIDs narrows the token to these repositories. IDs rather than
// names: a name is ambiguous across the owners an installation may span,
// and an ID cannot be made to mean a different repository by a rename.
RepositoryIDs []int64
// Permissions narrows what the token may do. Nil asks for the
// installation's full permission set.
Permissions map[string]string
}
InstallationTokenRequest is a mint request. Zero-valued scoping fields mean "the whole installation", which is why every caller here sets one unless the grant's allowlist is explicitly "*".
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" // KindHostDevice is an inventory of device nodes on the executor's host, // opened to one project. Constrained by a device-name allowlist, and // delivered as a runtime --device rather than as bytes. // // It is the widest authority this broker issues, and it is here for the // reason KindLocalRepo is: the alternative is a device path in // .cloop/sandbox.yaml, which is whatever a pull request says it is. See // hostdevice.go. KindHostDevice Kind = "host_device" )
func Kinds ¶
func Kinds() []Kind
Kinds returns every valid Kind, sorted, for CLI help and validation messages.
type KubeGuard ¶ added in v0.0.2
type KubeGuard interface {
// GuardKubeconfig takes custody of a kubeconfig and the constraints it
// was granted under, and returns the document to deliver instead.
//
// Returning a zero result with a nil error means "declined": there is no
// monitor on this hub, and the caller should deliver the minimised
// kubeconfig as before. Returning an error means a monitor was required
// and could not be provided, which must fail the lease rather than fall
// back — a hub whose config promises enforcement and silently delivers
// the raw credential is worse than one that never promised.
GuardKubeconfig(ctx context.Context, req KubeGuardRequest) (KubeGuardResult, error)
}
KubeGuard interposes an out-of-sandbox monitor between a workload and a Kubernetes cluster.
type KubeGuardRequest ¶ added in v0.0.2
type KubeGuardRequest struct {
// Kubeconfig is the minimised document, already reduced to the contexts
// the grant allows. The monitor takes custody of it; it must not be
// copied into the result, into a log, or into anything the sandbox reads.
//
// Minimised rather than raw on purpose: the two narrowings compose, so a
// monitor that was somehow pointed at the wrong context still cannot
// reach a cluster the grant excluded.
Kubeconfig []byte
// Verbs is the grant's effective verb allowlist, already resolved from
// Constraints.KubeVerbs — so the monitor never has to re-derive that an
// empty list means read-only.
Verbs []string
// Namespaces is the grant's namespace allowlist. The monitor enforces it
// on every request, which is the thing the delivered document could not.
Namespaces []string
// Labels. None is secret; all are audit bookkeeping.
SecretName string
SecretID string
GrantID string
LeaseID string
ProjectID string
ExecutorID string
Actor string
// Owner is the identity a personal kubeconfig belongs to, or "" for a
// shared one. It rides along so the monitor's audit rows name the person
// whose cluster credential is being spent.
Owner string
}
KubeGuardRequest is what the broker hands the monitor.
type KubeGuardResult ¶ added in v0.0.2
type KubeGuardResult struct {
// Kubeconfig is the document to deliver into the sandbox. It points at
// the monitor and carries a session token, and it contains no cluster
// credential — that is the whole point, and kubeconfig_test.go in
// pkg/kubeguard asserts it against a known plaintext.
Kubeconfig []byte
// SessionID identifies the monitor session, for audit joins and
// revocation.
SessionID string
// ExpiresAt is when the monitor stops honouring the session.
ExpiresAt time.Time
// ReadOnly records whether the resulting session can only read. Surfaced
// so the lease summary can say so without re-deriving the rule.
ReadOnly bool
// Summary is an audit-safe description of what the monitor will enforce
// ("read-only, ns app|app-staging"). It carries no credential.
Summary string
}
KubeGuardResult is the monitor's answer.
func (KubeGuardResult) Guarded ¶ added in v0.0.2
func (r KubeGuardResult) Guarded() bool
Guarded reports whether the monitor actually took custody. A zero result is a decline.
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"`
// RunID is the execution this lease was issued to (Task 20282). Carried on
// the lease so the renew and release rows correlate to the same run as the
// issuance — a lease's *end* is as audit-relevant as its start, and a
// release row that could not name its run would break the join at exactly
// the point an auditor is checking that a credential stopped being held.
RunID string `json:"run_id,omitempty"`
// 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
// Devices are the host device nodes this grant opened, recorded for the
// audit trail rather than for revocation.
//
// The same limitation as Mounts applies, and harder: a device already in a
// running sandbox's cgroup and mount namespace cannot be taken back from
// outside it. A host_device grant therefore lapses when the workload exits,
// not when the grant is revoked, and narrowing that window means stopping
// the workload.
Devices []GrantedDevice
}
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:"-"`
// SensitiveEnv names the Env keys whose values are credentials rather
// than the constraints those credentials were narrowed to.
//
// The distinction only exists because output redaction needs it. A
// material delivers GITHUB_TOKEN *and* CLOOP_GITHUB_REPO_ALLOWLIST;
// scrubbing the first out of a task transcript protects a credential,
// and scrubbing the second would replace every mention of a repository
// name with a marker. Nothing about the two values says which is which,
// so the builder that knows says so here.
//
// Names only — a value here would be a second copy of the secret in a
// struct whose whole point is to keep track of the first.
SensitiveEnv []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"`
// Devices are host device nodes the grant opens to the workload. Like
// Mounts they are not written anywhere — the node already exists and the
// grant is permission to reach it — and like Mounts they are not
// json:"-", because a device name, path and access mode are exactly what
// an operator inspecting a lease needs to see and carry no credential.
Devices []GrantedDevice `json:"devices,omitempty"`
// Summary is the audit-safe description of what was delivered
// (surviving kubeconfig contexts, allowed registries, env key names).
Summary string `json:"summary,omitempty"`
// contains filtered or unexported fields
}
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.
The returned string is the caller's to handle carefully — it is a credential, and nothing here can stop it being logged.
The dedicated field is consulted first and the Files scan is the fallback. The two used to be one thing, and separating them is what lets a guarded delivery withhold the token from the sandbox without also withholding it from the hub: under GitGuard there is no token file to find, but the hub's own workspace provisioning still needs the credential to hand to the proxy. The fallback keeps Materials built by older paths — and by tests that construct one directly — answering as they did.
type MintRequest ¶
type MintRequest struct {
Name string
Kind Kind
Payload []byte
Metadata map[string]string
Actor string
// Owner, when set, mints a *personal* secret belonging to that identity
// rather than a shared one belonging to the organisation (Task 20275).
// See ownership.go for everything that changes as a result.
Owner string
// Personal asserts that this mint is meant to produce a personal secret.
//
// Redundant with a non-empty Owner in the success case, and that is the
// point: it separates "mint this for alice@corp" from "mint this for
// whatever identity the caller happened to resolve", so a handler whose
// identity lookup silently returned "" produces ErrOwnerRequired instead
// of quietly minting the user's private credential as an organisation-wide
// one that every maintainer can then spend.
Personal bool
}
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.
func (*Mount) Devices ¶ added in v0.0.2
func (m *Mount) Devices() []GrantedDevice
Devices returns the host devices this lease opens, in the order the materials were processed. The caller exposes them to the sandbox; there is nothing on disk here to clean up, so Close does not touch them.
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 WithGitHubApp ¶ added in v0.0.2
func WithGitHubApp(api GitHubAppAPI) Option
WithGitHubApp supplies the GitHub API client used to mint, enumerate and destroy App installation tokens.
Without it a broker talks to api.github.com. Tests substitute a fake so the suite is hermetic, and a hub with no outbound access can substitute one that refuses — which denies github_app grants rather than falling back to delivering the private key, because there is no safe fallback.
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 RequestFilter ¶ added in v0.0.2
type RequestFilter struct {
// State, when set, keeps only requests in that state.
State RequestState
// RequestedBy, when set, keeps only that identity's requests. This is what
// scopes the list for a caller who may file requests but not decide them.
RequestedBy string
// SecretRef, when set, keeps only requests for that secret.
SecretRef string
// PendingOnly keeps only requests still awaiting a decision, including
// dropping ones whose deadline has passed but which no sweep has moved yet.
PendingOnly bool
}
RequestFilter narrows ListRequests.
type RequestState ¶ added in v0.0.2
type RequestState string
RequestState is where an access request sits in its lifecycle.
const ( // RequestPending: filed, not yet decided. The only state from which any // transition is possible. RequestPending RequestState = "pending" // RequestApproved: a grant was minted. GrantID names it. RequestApproved RequestState = "approved" // RequestDenied: an approver refused, with a note. RequestDenied RequestState = "denied" // RequestWithdrawn: the requester took it back. RequestWithdrawn RequestState = "withdrawn" // RequestExpired: nobody decided it in time. // // A terminal state of its own rather than a flavour of denied, because the // two mean opposite things to the person who asked: denied is an answer, // expired is the absence of one. An operator whose requests keep expiring // has a queue problem, and collapsing the states would hide it. RequestExpired RequestState = "expired" )
func (RequestState) Terminal ¶ added in v0.0.2
func (s RequestState) Terminal() bool
Terminal reports whether no further transition is possible from s.
func (RequestState) Valid ¶ added in v0.0.2
func (s RequestState) Valid() bool
Valid reports whether s is a known state.
type RequestStore ¶ added in v0.0.2
type RequestStore interface {
// PutAccessRequest inserts or replaces a request by ID.
PutAccessRequest(r AccessRequest) error
// GetAccessRequest returns one request, or a wrapped ErrRequestNotFound.
GetAccessRequest(id string) (AccessRequest, error)
// ListAccessRequests returns every request, including decided ones — the
// broker filters, so a review UI can still see history.
ListAccessRequests() ([]AccessRequest, error)
// ExpireAccessRequests moves pending requests past their deadline to
// expired and returns the ones it changed.
ExpireAccessRequests(now time.Time) ([]AccessRequest, error)
// RecordRequestUse notes that a lease redeemed an approved request's
// grant, upserting on (request, lease).
RecordRequestUse(u RequestUse) error
// ListRequestUses returns the uses recorded against one request, or every
// use when requestID is empty.
ListRequestUses(requestID string) ([]RequestUse, error)
}
RequestStore is the persistence a broker needs to serve requests.
Optional, and detected by type assertion in the same shape as KeyStore: the core Store contract is implemented by test doubles and by anything embedding the broker, and widening it would break every one of them to add a feature most do not need. A broker over a store that does not implement it refuses request operations with ErrRequestsUnsupported rather than silently pretending to record them.
type RequestUse ¶ added in v0.0.2
type RequestUse struct {
RequestID string `json:"request_id"`
LeaseID string `json:"lease_id"`
GrantID string `json:"grant_id,omitempty"`
ExecutorID string `json:"executor_id,omitempty"`
ProjectID string `json:"project_id,omitempty"`
// TaskID is the unit of work that held the lease, or 0 when the lease was
// issued for a whole project run rather than one task.
//
// Zero at the moment the lease is issued and filled in afterwards: the hub
// leases credentials *before* it dispatches the workload that will hold
// them, so at lease time there is genuinely no task to name. See
// statedb.AttributeRequestUseTasks.
TaskID int `json:"task_id,omitempty"`
FirstSeen time.Time `json:"first_seen"`
LastSeen time.Time `json:"last_seen"`
}
RequestUse is one lease that redeemed the grant an approval produced.
It answers the question an approver actually has a week later — not "does this grant exist", which they know, but "did anything use it, and for what". A grant nothing ever redeemed and a grant feeding a workload around the clock are indistinguishable from broker_grants alone.
type Requester ¶
type Requester struct {
ExecutorID string
ProjectID string
Labels map[string]string
// RunID names the execution the credentials are for (Task 20282). It is
// recorded on the audit rows this request produces and is deliberately
// never matched against: a grant is issued to a project or an executor, not
// to one run, so letting a run id participate in Subject.Matches would
// invent an authority scope that operators cannot see, list or revoke.
//
// Empty for every non-dispatch caller — a CLI lease, a UI inspection — and
// an empty run id simply leaves the field off the row.
RunID string
}
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 (rotation date, ticket).
// 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"`
// Owner is the identity this secret belongs to personally, in the
// OwnerKey namespace the rest of the hub uses for people (a lowercased
// email, or "sub:<issuer subject>"). Empty means a shared secret: the
// organisation's credential, provisioned by a maintainer, which is what
// every secret was before Task 20275.
//
// It is deliberately not a Metadata entry. Metadata is operator free text
// that lands in audit payloads and that nothing enforces; Owner is an
// authorisation input read by VisibleTo and by every mutation path in the
// broker. A rule enforced against a map any caller may overwrite is not a
// rule, and the two have opposite handling: metadata is for humans to
// read, Owner is for this package to obey.
Owner string `json:"owner,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) DeletableBy ¶ added in v0.0.2
DeletableBy reports whether v may destroy this secret.
Wider than SpendableBy by exactly one case: an admin may delete a personal secret they do not own, because otherwise a hub could never complete an offboarding — an account that no longer exists cannot come back to press the button. The deletion is audited with the actor's name attached.
func (Secret) Envelope ¶
Envelope returns the secret's sealed material in the form the keyring understands.
func (Secret) OwnedBy ¶ added in v0.0.2
OwnedBy reports whether identity owns this secret.
Both sides go through NormalizeOwner and are then compared exactly, so that "is this yours" has one definition rather than two that can drift. The normaliser is where the asymmetry lives, and it is deliberate on both sides:
email folded to lowercase. IdPs are inconsistent about the case they
release an address in, and "Alice@corp" locked out of the secret
"alice@corp" minted is a lockout, not a security win.
sub: left exactly as issued. An issuer subject is an opaque identifier,
case-sensitive by specification, so folding it could silently merge
two distinct people into one owner.
func (Secret) Personal ¶ added in v0.0.2
Personal reports whether the secret belongs to one person rather than to the organisation.
func (Secret) SpendableBy ¶ added in v0.0.2
SpendableBy reports whether v may grant this secret to an executor — the act that puts the credential to work.
Strictly narrower than VisibleTo, and the one place where Admin buys nothing. An admin may see a colleague's personal secret and may delete it; handing it to a workload is neither of those, and allowing it would make ownership advisory.
func (Secret) VisibleTo ¶ added in v0.0.2
VisibleTo reports whether v may see that this secret exists.
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.
func (Subject) Wildcard ¶ added in v0.0.2
Wildcard reports whether this subject selects an open-ended set of requesters rather than one named project or executor.
"any" and an explicit "*" both qualify. A label selector does not: it names a property of a machine, and an operator who labels two executors has said which two — whereas "*" says "and every one added later", which is a different promise and the one a personal credential cannot make.
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" )
type UnavailableGitGuard ¶ added in v0.0.2
type UnavailableGitGuard struct {
// is not in effect, not merely say that something is missing.
Reason string
}
UnavailableGitGuard refuses every request, for a caller that must not deliver a GitHub token unguarded but has no proxy to guard it with.
It exists because "the proxy is required and is not here" arises in two unrelated places — the hub whose proxy failed to start, and a short-lived CLI process that never had one (cloop hub doctor --smoke brokers a real lease and materialises it into a real sandbox). Both must fail closed, and a second hand-rolled copy of that decision is how one of them ends up not doing so.
func (UnavailableGitGuard) GuardGitHub ¶ added in v0.0.2
func (g UnavailableGitGuard) GuardGitHub(context.Context, GitGuardRequest) (GitGuardResult, error)
GuardGitHub implements GitGuard by always failing.
type UnavailableKubeGuard ¶ added in v0.0.2
type UnavailableKubeGuard struct {
// is not in effect, not merely say that something is missing.
Reason string
}
UnavailableKubeGuard refuses every request, for a hub whose configuration asked for a monitor and has not got one.
It refuses rather than being left nil because the two are read differently: nil means "this deployment does not intercept Kubernetes traffic", which is a valid configuration, while this means "it was supposed to and is not".
func (UnavailableKubeGuard) GuardKubeconfig ¶ added in v0.0.2
func (g UnavailableKubeGuard) GuardKubeconfig(context.Context, KubeGuardRequest) (KubeGuardResult, error)
GuardKubeconfig implements KubeGuard by always failing.
type Viewer ¶ added in v0.0.2
type Viewer struct {
// Identity is the viewer's OwnerKey, matched against Secret.Owner. It
// authorises everything about the viewer's own personal secrets and
// nothing about anybody else's.
Identity string
// Admin marks a caller entitled to administer *other people's* personal
// secrets: to see that alice@corp keeps one here, and to destroy it when
// she leaves.
//
// It confers visibility and deletion, never use. Offboarding requires both,
// and pretending an admin cannot see the row would be theatre — the
// material is sealed under a key the hub operator already holds. Spending
// somebody else's credential is a different act, it is the one thing the
// ownership model exists to prevent, and no flag here enables it.
Admin bool
}
Viewer is the identity a read or a mutation is performed on behalf of.
It exists so the broker can answer "may this person see, spend or destroy this secret" without importing pkg/authz, which would invert the dependency: authz is a policy over HTTP routes and OIDC claims, and the broker must stay usable from the CLI and from tests with neither present.
A Viewer answers one question and deliberately not two: *ownership*. Whether the caller holds organisation-level authority over the shared, unowned secrets that were the only kind before Task 20275 is not asked here — that is authz.PermSecretGrant and authz.PermSecretRevoke, enforced by the hub's route table, which already does it correctly and is already tested. Answering it twice, in two packages, with two notions of who counts, is how the two answers come to disagree.
So: shared secrets pass through this type untouched, exactly as they did before, and every existing call site stays correct without change. What the zero value cannot do is see, spend or delete a *personal* secret — which is the whole of the new capability, and which therefore fails closed for any caller that forgets to say who they are.
func PrivilegedViewer ¶ added in v0.0.2
PrivilegedViewer is the viewer for callers that already hold full authority over the secret store: the CLI running on the hub host, key rotation, and offboarding sweeps.
Named rather than written as a literal at each site so that the places which deliberately see every user's secrets are greppable, and so that reviewing "what can see everything" is a search for one identifier.