secretbroker

package
v0.0.3 Latest Latest
Warning

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

Go to latest
Published: Sep 27, 2026 License: MIT Imports: 34 Imported by: 0

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

View Source
const (
	// GitPushRefsEnvKey is the hub's own ref allowlist for pushes, as full ref
	// patterns: "refs/heads/cloop/**" on a hub that kept the default.
	GitPushRefsEnvKey = "CLOOP_GIT_PUSH_REFS"
	// GitHubPushBranchesEnvKey is the grant's branch allowlist, as branch
	// names ("feature/*"), when it has one. A push must match both lists.
	GitHubPushBranchesEnvKey = "CLOOP_GITHUB_PUSH_BRANCHES"
)

The variables a guarded lease uses to say where a push will be accepted. Exported for pkg/pm, which renders them into the agent's prompt.

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

View Source
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
)
View Source
const (
	RotationRunning     = "running"
	RotationCompleted   = "completed"
	RotationInterrupted = "interrupted"
	RotationFailed      = "failed"
)

Rotation states.

View Source
const DefaultGrantTTL = 24 * time.Hour

DefaultGrantTTL is used when a grant is created without an explicit TTL from a path that requires one.

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

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

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

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

View Source
const GitHubWriteWithheldEnvKey = "CLOOP_GITHUB_WRITE_WITHHELD"

GitHubWriteWithheldEnvKey carries, into a workload's environment, the reason a GitHub grant that authorises pushes was delivered read-only. Present only when that happened. Exported for pkg/pm, which tells the agent.

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

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

View Source
const MaxBranchPatterns = 32

MaxBranchPatterns bounds a grant's branch allowlist. A handful is the real shape; the bound exists because the proxy matches every pushed ref against every pattern, and a caller should not get to choose how large that is.

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

View Source
const MaxHostInterfaces = 8

MaxHostInterfaces bounds how many interfaces one grant may open.

It matches executor.MaxInterfaces 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 the grant.

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

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

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

View Source
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")
	// ErrKeyUnavailable: the KEK exists but cannot be derived from the
	// 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")

	// ErrBranchesUnenforced: a grant limits its pushes to a branch allowlist,
	// no git proxy guards it on this hub, and its credential cannot be
	// narrowed to read-only instead (Task 20340). A denial rather than a
	// fallback: delivering the credential would hand over a push to every
	// branch it can reach, which is exactly what the allowlist was written to
	// rule out.
	ErrBranchesUnenforced = errors.New("secretbroker: branch allowlist cannot be enforced without the git proxy")
)

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.

View Source
var ErrGuardUnavailable = errors.New("secretbroker: git guard unavailable")

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

func AADFor(set, rowID string) string

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

func KubeconfigSummary(minimized []byte) string

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

func NewLeaseDirPath(baseDir string) (string, error)

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

func NormalizeHost(host string) (string, error)

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

func NormalizeOwner(identity string) string

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

func NormalizeProjectID(id string) string

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

func NormalizeRepo(repo string) (string, error)

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

func ParseLocalRepoRoot(payload []byte) (string, error)

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

func RedactString(s string) string

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

func SafeRef(ref string) string

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

func SandboxLeaseDir(leaseID string) string

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

func ValidateName(name string) error

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.

func ParseGitHubAppConnection added in v0.0.3

func ParseGitHubAppConnection(payload []byte) (*AppCredential, error)

ParseGitHubAppConnection parses the half of a github_app payload that exists before the App's installation is known: the App ID and the signing key.

It is what discovery runs on. An operator configuring cloop has just come from GitHub's App settings page, which shows an App ID and offers a key download, and has no installation ID because that is a property of the *install*, not of the App. The credential this returns can sign an app JWT — enough to ask GitHub where the App is installed — and nothing else: its InstallationID is zero, so mint refuses it (see mint's guard) and it can never be stored as a working secret by mistake.

Everything else is validated exactly as ParseGitHubApp validates it, so a malformed key is reported in the connect dialog rather than at the next step.

type AppInstallation added in v0.0.3

type AppInstallation struct {
	// ID is the installation_id a github_app payload needs.
	ID int64 `json:"id"`
	// AppID is the app the installation belongs to. Echoed back so a caller
	// holding several keys can tell which one produced this list.
	AppID int64 `json:"app_id"`
	// Account is the org or user the App is installed on ("bb-selforg").
	Account string `json:"account"`
	// AccountType is "Organization" or "User".
	AccountType string `json:"account_type"`
	// RepositorySelection is "all" or "selected" — whether the installation
	// covers every repository the account owns or an explicitly chosen subset.
	// An operator who granted "all" and still cannot reach a repository has a
	// different problem from one who granted "selected" and forgot to tick it.
	RepositorySelection string `json:"repository_selection"`
}

AppInstallation is one place an App has been installed: an organisation or a user account.

It exists because an installation ID is the one field of a github_app payload that the person configuring it does not have. GitHub hands out an App ID and a private key on the App's own settings page; the installation is created afterwards, by whoever installs the App on their org, and its ID appears only in the URL of a settings page that person may never visit. Asking an operator to go and find it — as this package did before Task 20306 — is asking them to read a number out of a browser address bar, which is exactly the kind of step that gets guessed wrong and surfaces as a 404 at lease time.

So the hub asks GitHub instead. Nothing here is a secret: an App ID, an installation ID and an account name are all public identifiers, which is what makes it safe to show the whole list in a dialog.

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.

func (AuditorFunc) Audit

func (f AuditorFunc) Audit(ev Event)

Audit implements Auditor.

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

func New(store Store, opts ...Option) (*Broker, error)

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

func (b *Broker) CheckRepoAccess(ctx context.Context, r Requester, repo, actor string) error

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

func (b *Broker) DeleteSecret(ctx context.Context, ref, actor string) error

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

func (b *Broker) DeleteSecretFor(ctx context.Context, ref string, v Viewer) error

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

func (b *Broker) DescribeSecret(ref string) (Secret, error)

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

func (b *Broker) DescribeSecretFor(ref string, v Viewer) (Secret, error)

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) DiscoverGitHubAppInstallations added in v0.0.3

func (b *Broker) DiscoverGitHubAppInstallations(ctx context.Context, payload []byte) ([]AppInstallation, error)

DiscoverGitHubAppInstallations reports where an App is installed.

The payload is a github_app payload with the installation_id omitted: an app_id, a private_key, and optionally a base_url for GitHub Enterprise Server. It is the operator's own input, arriving from a dialog, and is never stored — the caller decides which installation to keep and mints the secret afterwards with that ID filled in.

The returned list is public information: account names and numeric IDs. It is sorted by account so a hub with several installations renders the same order on every call rather than GitHub's.

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) GitHubAppRepositories added in v0.0.3

func (b *Broker) GitHubAppRepositories(ctx context.Context, ref string) ([]InstallationRepo, error)

GitHubAppRepositories lists the repositories a stored github_app secret's installation covers.

This is what turns assigning repositories to a project into picking from a list instead of typing patterns blind. A name typed by hand is a name that can be wrong in a way nothing detects until a clone fails; a name chosen from the installation's own inventory is one GitHub has already confirmed.

The secret's private key is opened, used, and dropped inside this call. The discovery token minted to read the list is metadata:read and is destroyed before the function returns. It is unscoped, for the CLI and the hub host. Anything serving a browser request must call GitHubAppRepositoriesFor instead.

func (*Broker) GitHubAppRepositoriesFor added in v0.0.3

func (b *Broker) GitHubAppRepositoriesFor(ctx context.Context, ref string, v Viewer) ([]InstallationRepo, error)

GitHubAppRepositoriesFor is GitHubAppRepositories on behalf of one identity.

Gated on SpendableBy rather than VisibleTo, which is stricter than it first looks: enumerating signs a JWT with the stored key and presents it to GitHub, so it *uses* the credential. An admin may see that a colleague keeps a personal App here and may destroy it, but no permission in the ladder lets them spend one — and a route that enumerates on their behalf would be exactly that, with the assertion already sent by the time anything refused (Task 20323).

func (*Broker) Grant

func (b *Broker) Grant(ctx context.Context, req GrantRequest) (Grant, error)

Grant authorises a subject to use a secret under constraints.

func (*Broker) Keyring

func (b *Broker) Keyring() *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

func (b *Broker) Lease(ctx context.Context, executorID, projectID string) (*Lease, error)

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

func (b *Broker) LeaseFor(ctx context.Context, r Requester, actor string) (*Lease, error)

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

func (b *Broker) ListSecrets() ([]Secret, error)

ListSecrets returns stored secrets. Payloads stay sealed.

func (*Broker) ListSecretsFor added in v0.0.2

func (b *Broker) ListSecretsFor(v Viewer) ([]Secret, error)

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

func (b *Broker) Mint(ctx context.Context, req MintRequest) (Secret, error)

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

func (b *Broker) Release(leaseID string)

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

func (b *Broker) Renew(ctx context.Context, leaseID string) (*Lease, error)

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

func (b *Broker) Revoke(ctx context.Context, grantID, actor string) error

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

func (b *Broker) SweepExpired() (expired, live map[Kind]int)

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

func (b *Broker) WithdrawRequest(ctx context.Context, id, actor string) (AccessRequest, error)

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

func NewCipher(store Store) (*Cipher, error)

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

func NewCipherWithKey(key []byte) (*Cipher, error)

NewCipherWithKey builds a Cipher from a raw 32-byte key. Used by tests and by callers that manage key material themselves.

func NewLegacyCipher

func NewLegacyCipher(passphrase string, salt []byte) (*Cipher, error)

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

func (c *Cipher) OpenEnvelope(_ string, env Envelope) ([]byte, error)

OpenEnvelope lets a plain Cipher satisfy sealer.

func (*Cipher) Seal

func (c *Cipher) Seal(plaintext []byte) ([]byte, error)

Seal encrypts plaintext, returning nonce||ciphertext||tag.

func (*Cipher) SealFor

func (c *Cipher) SealFor(_ string, plaintext []byte) (Envelope, error)

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.

func (*Cipher) Unseal

func (c *Cipher) Unseal(envelope []byte) ([]byte, error)

Unseal decrypts an envelope produced by Seal. A wrong key and a tampered envelope are indistinguishable — both surface as ErrSealFailed, which is the correct amount of information to give a caller.

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"`
	// Branches narrows where a github_pat or github_app grant may push:
	// branch-name globs such as "cloop/*", "feature/**" or "develop". Empty
	// means the grant adds no branch restriction of its own, and the hub's git
	// proxy policy (executors.git_proxy.allowed_refs) is the only limit.
	//
	// Only a grant that authorises a push may carry one — a branch list on a
	// read-only grant restricts nothing and would read as though it did — and
	// it restricts branches only: a push of a tag, or of any other ref, is
	// outside every branch list.
	//
	// Nothing inside a sandbox can enforce this, because git's credential
	// protocol never says which ref a push is for. The git proxy can, since it
	// reads every pushed command before forwarding it; see pkg/gitproxy
	// Policy.RestrictRefs. Where no proxy guards the grant, the broker withholds
	// the grant's write authority rather than deliver a credential that would
	// ignore the list: see RestrictsBranches.
	Branches []string `json:"branches,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"`
	// Interfaces is the interface-name allowlist for host_interface. Patterns
	// match case-sensitively against the inventory's handles, exactly as
	// Devices does and for the same reason; "*" alone allows every interface
	// in the inventory.
	Interfaces []string `json:"interfaces,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) BranchNames added in v0.0.3

func (c Constraints) BranchNames() []string

BranchNames is the allowlist without the refs/heads/ prefix, the form a person types and reads.

func (Constraints) BranchRefPatterns added in v0.0.3

func (c Constraints) BranchRefPatterns() []string

BranchRefPatterns renders the branch allowlist as full ref patterns, the form pkg/gitproxy matches pushed refs against: "cloop/*" becomes "refs/heads/cloop/*", and an entry already written with the prefix is kept as it is.

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) RestrictsBranches added in v0.0.3

func (c Constraints) RestrictsBranches() bool

RestrictsBranches reports whether this grant's pushes are limited to a branch allowlist.

It is the question every delivery path has to answer before handing over a credential that can write, because the answer decides whether the credential may leave the hub at all. A restricted grant is only ever honoured through the git proxy; where there is none, the broker withholds the write rather than deliver a credential that would ignore the list.

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 Decision

type Decision string

Decision is the outcome recorded for every brokered operation.

const (
	// DecisionAllow: the operation succeeded and credentials were (or will
	// be) delivered.
	DecisionAllow Decision = "allow"
	// DecisionDeny: the operation was refused. The Reason says why.
	DecisionDeny Decision = "deny"
)

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

func (d *Delivery) Close() error

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

func (d *Delivery) Env() []string

Env returns the environment additions in "K=V" form, sorted.

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.

func (*Delivery) Interfaces added in v0.0.3

func (d *Delivery) Interfaces() []GrantedInterface

Interfaces returns the host network interfaces this lease moves into the sandbox.

func (*Delivery) Mounts

func (d *Delivery) Mounts() []RepoMount

Mounts returns the host repositories this lease opens.

type Envelope

type Envelope struct {
	KeyID      string
	WrappedDEK []byte
	Ciphertext []byte
}

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

func LegacyEnvelope(ciphertext []byte) Envelope

LegacyEnvelope wraps a pre-envelope ciphertext for the open path.

func (Envelope) Empty

func (e Envelope) Empty() bool

Empty reports whether there is anything sealed here at all. A session with no refresh token stores an empty envelope rather than a sealed empty string, so "nothing to protect" and "protected nothing" stay distinct.

func (Envelope) IsLegacy

func (e Envelope) IsLegacy() bool

IsLegacy reports whether this envelope predates envelope encryption.

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

func Redact(ev Event) Event

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.

func (Event) Fields

func (ev Event) Fields() string

Fields renders an event as sorted key=value pairs, for text log sinks.

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
	// Branches is the grant's branch allowlist, as the grant stores it. The
	// guard narrows the session's ref policy to it, within the hub's own
	// allowlist. Empty means the grant adds no branch restriction.
	Branches []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
	// PushRefs is the hub's own ref allowlist for pushes — the ceiling the
	// grant's Branches narrow, as full ref patterns. Announced to the workload
	// so it can choose a branch the proxy will accept, rather than learn the
	// policy one refused push at a time. Empty when the session cannot push.
	PushRefs []string
}

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
	// ListAppInstallations enumerates where the App is installed. It is
	// authenticated with the app JWT rather than an installation token —
	// it is the one call that runs before any installation is known, which is
	// the whole reason it exists.
	ListAppInstallations(ctx context.Context, baseURL, appJWT string) ([]AppInstallation, 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) Active

func (g Grant) Active(now time.Time) bool

Active reports whether the grant may be used at time now.

func (Grant) DenyReason

func (g Grant) DenyReason(now time.Time) string

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

func (g Grant) Personal() bool

Personal reports whether this grant hands out a personally-owned credential.

func (Grant) Validate

func (g Grant) Validate(kind Kind) error

Validate checks a Grant's structural invariants, including that its constraints make sense for the kind of secret it points at.

func (Grant) VisibleTo added in v0.0.2

func (g Grant) VisibleTo(v Viewer) bool

VisibleTo reports whether v may see this grant.

A grant follows the secret it points at: over a shared secret it is organisation business, over a personal one it is its owner's, and the owner is on the row so that deciding needs no second read.

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 GrantedInterface added in v0.0.3

type GrantedInterface struct {
	// Name is the handle from the inventory, and what a grant's allowlist and
	// a sandbox spec's `interfaces:` list match against.
	Name string `json:"name"`
	// Source is the interface's name on the executor's host.
	Source string `json:"source"`
	// Target is the name it takes inside the sandbox. Equal to Source unless
	// the inventory renamed it.
	Target string `json:"target"`
	// Address is the optional CIDR to configure inside the sandbox.
	Address string `json:"address,omitempty"`
	// Gateway is the optional default route to install through it.
	Gateway string `json:"gateway,omitempty"`
	// MTU optionally overrides the interface's MTU. Zero means leave it.
	MTU int `json:"mtu,omitempty"`
}

GrantedInterface is one network interface a grant moves into a sandbox.

It mirrors executor.HostInterface and is deliberately a separate type, for the reason GrantedDevice mirrors HostDevice: this package does not import pkg/executor, so 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 ParseInterfaceInventory added in v0.0.3

func ParseInterfaceInventory(payload []byte) ([]GrantedInterface, error)

ParseInterfaceInventory validates a host_interface payload.

Exported so that storing one fails in front of the person who typed it, with a message they can act on — `cloop secret mint` and the dashboard's mint route both call it before anything is stored.

Validating early matters more here than anywhere else, because the late failure is both quiet and destructive. Broker.LeaseFor emits a deny row for a grant whose material will not build and continues with the rest, so a malformed inventory yields a sandbox that starts with no wire in it; and an inventory naming the *wrong* interface is not caught by any amount of retrying, because by then the host has already handed it over.

type InstallationRepo added in v0.0.2

type InstallationRepo struct {
	ID       int64  `json:"id"`
	FullName string `json:"full_name"`
	Private  bool   `json:"private"`
}

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.

func (KEKRecord) Retired

func (k KEKRecord) Retired() bool

Retired reports whether the KEK has been shredded.

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

func (k *Keyring) AddKey(actor string) (KeyInfo, error)

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

func (k *Keyring) HasLegacy() bool

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

func (k *Keyring) Keys() []KeyInfo

Keys returns every known KEK, primary first, then newest.

func (*Keyring) OpenEnvelope

func (k *Keyring) OpenEnvelope(aad string, env Envelope) ([]byte, error)

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

func (k *Keyring) PrimaryID() string

PrimaryID returns the key ID new material is sealed under.

func (*Keyring) Reload

func (k *Keyring) Reload() error

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

func (k *Keyring) RetireKey(id string) error

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

func (k *Keyring) Rewrap(aad string, env Envelope) (Envelope, error)

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

func (k *Keyring) SealFor(aad string, plaintext []byte) (Envelope, error)

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"
	// KindHostInterface is an inventory of network interfaces on the
	// executor's host, moved into one project's sandbox. Constrained by an
	// interface-name allowlist, and delivered as a namespace move rather
	// than as bytes.
	//
	// It is wider still than KindHostDevice, and differs from every other
	// kind in one way: honouring it takes the interface *away* from the
	// host for the life of the workload. See hostinterface.go.
	KindHostInterface Kind = "host_interface"
)

func Kinds

func Kinds() []Kind

Kinds returns every valid Kind, sorted, for CLI help and validation messages.

func ParseKind

func ParseKind(s string) (Kind, error)

ParseKind validates and normalises a user-supplied kind string.

func (Kind) Valid

func (k Kind) Valid() bool

Valid reports whether k is a known kind.

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

func (l *Lease) Deliver(dir string) (*Delivery, error)

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

func (l *Lease) Empty() bool

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

func (l *Lease) Expired(now time.Time) bool

Expired reports whether the lease is past its TTL at now.

func (*Lease) Kinds

func (l *Lease) Kinds() []Kind

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

func (l *Lease) Materialize(baseDir string) (*Mount, error)

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

func (l *Lease) MaterializeAt(dir string) (*Mount, error)

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

func (l *Lease) SecretNames() []string

SecretNames lists the delivered secrets by name, for logging.

func (*Lease) TTL

func (l *Lease) TTL(now time.Time) time.Duration

TTL returns the remaining lifetime at now, clamped at zero.

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
	// Interfaces are the host network interfaces this grant moved into the
	// sandbox, recorded for the audit trail rather than for revocation.
	//
	// The same limitation as Devices applies: an interface inside a running
	// sandbox's network namespace cannot be reached from outside it, so a
	// host_interface grant lapses when the workload exits rather than when
	// the grant is revoked.
	Interfaces []GrantedInterface
}

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"`
	// Interfaces are host network interfaces the grant moves into the
	// sandbox. Not written anywhere either, and not json:"-" for the reason
	// Devices is not: a handle, a host name and an address are what an
	// operator inspecting a lease needs to see and carry no credential.
	Interfaces []GrantedInterface `json:"interfaces,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

func (m Material) GitHubToken() (string, bool)

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

func (m *Mount) Close() error

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.

func (*Mount) Env

func (m *Mount) Env() []string

Env returns the environment additions in "K=V" form, sorted, ready to append to an executor.Spec's Env.

func (*Mount) Interfaces added in v0.0.3

func (m *Mount) Interfaces() []GrantedInterface

Interfaces returns the host network interfaces this lease moves into the sandbox. Like Devices there is nothing on disk to clean up: the netdev already exists and the grant is permission to claim it.

func (*Mount) Mounts

func (m *Mount) Mounts() []RepoMount

Mounts returns the host repositories this lease opens, in the order the materials were processed. The caller binds them into 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

func WithAuditor(a Auditor) Option

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

func WithCipher(c *Cipher) Option

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

func WithClock(fn func() time.Time) Option

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

func WithKeyring(kr *Keyring) Option

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

func WithMaxLeaseTTL(d time.Duration) Option

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
	// GitHubProxied says the caller will hand any GitHub credential in this
	// lease to cloop's git proxy itself, and never to a workload — which is
	// what workspace provisioning does when the hub runs a proxy
	// (pkg/executor/gitproxycreds). Like RunID it is never matched against.
	//
	// It matters to exactly one decision: whether a grant limited to certain
	// branches may be delivered with its push intact. Without a proxy in the
	// path the broker withholds that push (Task 20340), because nothing else
	// can hold a push to the list; with one, the proxy does, and withholding
	// would break the very pushes the grant authorises.
	//
	// Set it only when it is true of every material the lease will carry. A
	// caller that set it and then materialised the lease into a sandbox would
	// be delivering a push the grant restricted, unrestricted.
	GitHubProxied bool
}

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

func NewRotator(kr *Keyring, sets ...SealedSet) (*Rotator, error)

NewRotator builds a rotator over a keyring and one or more sealed sets.

func (*Rotator) RetireKey

func (r *Rotator) RetireKey(id string) error

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

func (r *Rotator) WithBatch(n int) *Rotator

WithBatch overrides the batch size.

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

type SealedRow struct {
	ID  string
	AAD string
	Env Envelope
}

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

func (s Secret) DeletableBy(v Viewer) bool

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

func (s Secret) Envelope() Envelope

Envelope returns the secret's sealed material in the form the keyring understands.

func (Secret) OwnedBy added in v0.0.2

func (s Secret) OwnedBy(identity string) bool

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

func (s Secret) Personal() bool

Personal reports whether the secret belongs to one person rather than to the organisation.

func (Secret) SpendableBy added in v0.0.2

func (s Secret) SpendableBy(v Viewer) bool

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

func (s Secret) Validate() error

Validate checks a Secret's structural invariants before it is stored.

func (Secret) VisibleTo added in v0.0.2

func (s Secret) VisibleTo(v Viewer) bool

VisibleTo reports whether v may see that this secret exists.

func (Secret) WithEnvelope

func (s Secret) WithEnvelope(env Envelope) Secret

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

func ParseSubject(spec string) (Subject, error)

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

func (s Subject) Matches(r Requester) bool

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

func (s Subject) String() string

String renders a Subject back into the CLI's "--to" syntax.

func (Subject) Validate

func (s Subject) Validate() error

Validate checks that a Subject is well-formed.

func (Subject) Wildcard added in v0.0.2

func (s Subject) Wildcard() bool

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 {
	// Reason is shown to the operator. It should name the configuration that
	// 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

GuardGitHub implements GitGuard by always failing.

type UnavailableKubeGuard added in v0.0.2

type UnavailableKubeGuard struct {
	// Reason is shown to the operator. It should name the configuration that
	// 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

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

func PrivilegedViewer(identity string) Viewer

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.

Jump to

Keyboard shortcuts

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