broker

package
v0.7.13 Latest Latest
Warning

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

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

Documentation

Overview

Package broker implements Wardyn's token broker: the ONLY component that holds long-lived secrets and the sole issuer of short-lived run credentials.

SECURITY INVARIANTS (see ARCHITECTURE.md, non-negotiable):

  • Approval mints the credential. For a grant whose spec sets RequiresApproval, the mint happens ONLY inside the same Postgres transaction that verifies approvals.state='APPROVED' for this run, and writes approvals.minted_jti back in that same transaction. Single-use: a non-empty minted_jti blocks any second mint.
  • No widening. The scope minted is EXACTLY the scope the approver saw: approvals.requested_scope must deep-equal the grant spec scope, else ErrScopeMismatch. github_token scopes are additionally clamped to a ceiling of contents:write + pull_requests:write.
  • Secrets never enter the sandbox. api_key grants resolve to a proxy-side egress.InjectionRule (returned by reference, never the secret value).
  • Fail closed. cloud_sts hard-requires SPIRE and is refused here.
  • Full attribution. credential.mint audit events carry actor_type=agent and actor=run SPIFFE ID.

Index

Constants

This section is empty.

Variables

View Source
var (
	// ErrScopeMismatch fires when approvals.requested_scope does not deep-equal
	// the grant spec scope (the no-widening invariant).
	ErrScopeMismatch = errors.New("broker: requested scope does not match grant spec scope (no-widening invariant)")
	// ErrRequiresSPIRE fires for cloud_sts grants; the embedded path refuses them.
	ErrRequiresSPIRE = errors.New("broker: cloud_sts grants hard-require the spire identity provider")
	// ErrAlreadyMinted fires on a second mint attempt for a single-use approval.
	ErrAlreadyMinted = errors.New("broker: credential already minted for this approval (single-use)")
	// ErrNotApproved fires when the joined approval is not in state APPROVED.
	ErrNotApproved = errors.New("broker: approval is not in state APPROVED")
	// ErrGrantNotFound fires when the grant id resolves to no row.
	ErrGrantNotFound = errors.New("broker: grant not found")
	// ErrRunMismatch fires when the caller's run does not own the grant.
	ErrRunMismatch = errors.New("broker: caller run does not own this grant")
	// ErrRunRevoked fires when the run has been revoked (kill-switch) before the
	// mint: the mint tx checks identity_revocations and fails closed.
	ErrRunRevoked = errors.New("broker: run is revoked; refusing to mint (kill-switch)")
	// ErrUnknownGrantKind fires for an unrecognized grant kind.
	ErrUnknownGrantKind = errors.New("broker: unknown grant kind")
)

Sentinel and typed errors. All map to fail-closed REST responses.

View Source
var ErrRefRulesetRequired = errors.New("broker: " + envRequireRefRuleset + " is set and the repo is not confined by a GitHub ruleset")

ErrRefRulesetRequired is returned when envRequireRefRuleset is set and the repo is not confined by a ruleset (or the verification could not be made).

Functions

func ReservedSecretName added in v0.6.0

func ReservedSecretName(name string) bool

ReservedSecretName reports whether name is a key the broker must never resolve into a sandbox. Exported for the same single caller api.ReservedPlatformSecret is: cmd/wardynd's TestPlatformSecretsAreReservedEverywhere, which ties the daemon's platform-key constants to BOTH reserved sets so a future key cannot be added to one and missed in the other.

func ValidateGitHubScopeShape added in v0.4.4

func ValidateGitHubScopeShape(scope json.RawMessage) error

ValidateGitHubScopeShape rejects a github_token grant scope that mintGitHub would refuse at MINT time — repos in owner/name form sharing one owner (splitRepos) and permission keys go-github recognizes (toInstallationPermissions). It lives HERE, next to the two predicates it runs, so a policy-write check and the mint gate can never drift apart; the api package (which already imports this one) calls it from validatePolicySpec so a bad key/repo is a 400 at author time instead of a run-time mint failure hours later. Results are discarded — this is the shape only. The permissions CEILING is a separate concern enforced by clampGitHubPermissions before minting.

An EMPTY scope is valid: eligible_grants are TEMPLATES and the run supplies the concrete repos, so every shipped example policy carries "repos": []. (A literal `null` is 4 bytes, not len 0, and unmarshals into the struct as a no-op — which is what keeps a scope-less github grant passing.)

Types

type Broker

type Broker struct {
	// contains filtered or unexported fields
}

Broker is the token broker. It is safe for concurrent use; all mutating state lives in Postgres and every mint is serialized by SELECT ... FOR UPDATE on the grant+approval rows.

func New

New constructs a Broker. github may be nil if no github_token grants will be minted; a nil minter on a github_token grant fails closed.

func (*Broker) MintForGrant

func (b *Broker) MintForGrant(ctx context.Context, caller *identity.Claims, grantID uuid.UUID) (Minted, error)

MintForGrant is the public entry point. It verifies the caller's run owns the grant, then routes to the approval-gated or auto-mint path. If the grant requires approval and no decided approval exists, it ensures a PENDING approval (creating one if absent) and returns ErrApprovalPending.

func (*Broker) RevokeRun

func (b *Broker) RevokeRun(ctx context.Context, runID uuid.UUID) error

RevokeRun best-effort revokes credentials minted for a run, part of the kill-switch cascade. HONEST LIMITATION: nothing here invalidates a credential that was already handed out. GitHub App installation tokens are not revoked individually before their (<=1h) expiry: GitHub's DELETE /installation/token endpoint is real, but it can only be called by presenting the token itself, and RevokeRun has only the jti. The token value is never PERSISTED (mintGitHub returns it in Minted{Token: token}, broker.go), but in-memory copies DO exist and none is addressable by jti or consulted here: mint() registers the bytes in the run's mask corpus (b.maskReg.Add(caller.RunID, minted.Token), broker.go; evicted by the RunSecretGrace sweep, runs_lifecycle.go) so PTY/asciicast streams can mask them, and the proxy sidecar's brokered-credential path may cache a minted token per grant for re-use within one clone. Neither is a revocation handle — and a git_pat/ssh_key is an operator-managed secret Wardyn never owned. We therefore (a) audit each minted jti as a revoke with outcome=success for the audit join, recording in the event data the per-KIND story (revokeNote) rather than one GitHub-shaped sentence, and (b) rely on identity revocation (identity.Provider.RevokeRun, called by the kill cascade) to deny any further mints for the run.

The cascade enumerates what the run ACTUALLY minted (mintedCredentialsSQL: credential.mint audit rows UNION the approvals burn), not just the approvals whose minted_jti was burnt. Sourcing it from approvals alone emitted ZERO rows for every auto-mintable grant (requires_approval=false creates no approval row) and for the 2nd..Nth mint of a leased git_pat, while THREAT-MODEL.md publishes step 4 of the kill cascade as "every minted credential for the run" (F096/F122).

func (*Broker) WithMaskRegistry

func (b *Broker) WithMaskRegistry(reg *secretmask.Registry) *Broker

WithMaskRegistry attaches a secret-mask Registry to the Broker. After a successful github_token mint the token bytes are registered under the caller's RunID so they are masked from PTY/asciicast output. A nil reg is accepted (no-op). Call before the Broker is used.

func (*Broker) WithSIEM added in v0.6.0

func (b *Broker) WithSIEM(sink audit.Sink) *Broker

WithSIEM attaches the SIEM fanout sink the broker emits the credential.mint SUCCESS event to AFTER commit (D29): the durable record is written in-tx, and this fans the same event to file/webhook/syslog sinks WITHOUT re-writing the primary store. A nil sink is accepted (no-op). Call before the Broker is used.

type ErrApprovalDenied

type ErrApprovalDenied struct {
	ApprovalID uuid.UUID
	Reason     string
}

ErrApprovalDenied signals the human denied the approval. Fail closed.

func (ErrApprovalDenied) Error

func (e ErrApprovalDenied) Error() string

type ErrApprovalPending

type ErrApprovalPending struct {
	ApprovalID uuid.UUID
}

ErrApprovalPending signals that a human approval gate is still open. The REST layer renders this as 409 with the approval id so the caller can poll.

func (ErrApprovalPending) Error

func (e ErrApprovalPending) Error() string

type FakeGitHubMinter

type FakeGitHubMinter struct {

	// Token/Expiry returned by MintInstallationToken (Expiry defaults to +1h).
	Token  string
	Expiry time.Time
	// Err, if set, is returned (to exercise the failure-audit path).
	Err error

	// Captured inputs from the last call.
	LastRepos       []string
	LastPermissions map[string]string
	LastTTL         time.Duration
	Calls           int

	// RefRulesetConfined/Detail/Err drive VerifyRefRuleset. The zero value
	// answers "not confined", which is the honest default for a fake with no
	// GitHub behind it — a test that wants the WARDYN_GITHUB_REQUIRE_REF_RULESET
	// gate to PASS has to say so.
	RefRulesetConfined bool
	RefRulesetDetail   string
	RefRulesetErr      error
	// LastVerifiedRepos records every repo VerifyRefRuleset was asked about.
	LastVerifiedRepos []string

	// Revoked counts Revoke calls and RevokedTokens records what was handed
	// back, so a test can assert BOTH that every discarded token was revoked
	// (Revoked == Calls-1 on a lost race) and that the RETURNED one was not.
	Revoked       int
	RevokedTokens []string
	// RevokeErr, if set, is returned by Revoke — the best-effort contract says
	// a failed revoke must not change the caller's own error.
	RevokeErr error
	// contains filtered or unexported fields
}

FakeGitHubMinter is a deterministic GitHubMinter for tests. It records the last request so tests can assert the broker passed exactly the clamped scope, and returns a synthetic token.

func (*FakeGitHubMinter) MintInstallationToken

func (f *FakeGitHubMinter) MintInstallationToken(_ context.Context, repos []string, permissions map[string]string, ttl time.Duration) (string, time.Time, error)

func (*FakeGitHubMinter) Revoke added in v0.7.4

func (f *FakeGitHubMinter) Revoke(_ context.Context, token string) error

Revoke records the hand-back. An empty token is a no-op here exactly as it is in the real minter, so a fake that is asked to revoke "nothing" does not inflate the count a test is asserting on.

func (*FakeGitHubMinter) VerifyRefRuleset added in v0.5.0

func (f *FakeGitHubMinter) VerifyRefRuleset(_ context.Context, repo string) (bool, string, error)

type GitHubMinter

type GitHubMinter interface {
	MintInstallationToken(ctx context.Context, repos []string, permissions map[string]string, ttl time.Duration) (token string, expiresAt time.Time, err error)
	// VerifyRefRuleset reports whether GitHub itself confines this App's writes
	// on repo ("owner/name") to refNamespaceGlob, plus an operator-readable
	// detail. A non-nil error means UNKNOWN (network, rate limit, permission
	// refused, a bypass mode that could not be read) and must never be rendered
	// as "unconfined". It is on the
	// interface rather than an optional type assertion because
	// WARDYN_GITHUB_REQUIRE_REF_RULESET turns the answer into a mint gate: an
	// implementation that silently answered "confined" would open the gate it
	// was supposed to close. The compiler must ask.
	VerifyRefRuleset(ctx context.Context, repo string) (confined bool, detail string, err error)
	// Revoke hands a minted installation token back to GitHub
	// (Apps.RevokeInstallationToken — the call VerifyRefRuleset in ruleset.go already makes
	// for its probe token). It exists for the mint() arms that mint a REAL token
	// and then DISCARD it: the lost single-use race, a failed minted_jti write,
	// a failed audit insert, a failed commit. Such a token is live for GitHub's
	// full ~1h with NO credential.mint jti behind it, so mintedCredentialsSQL
	// cannot see it and RevokeRun cannot reach it — this call is the only door
	// back. Best effort at every call site: a failed revoke must never change
	// the error the caller is already getting. It is on the interface rather
	// than an optional type assertion for the same reason VerifyRefRuleset is —
	// a minter that silently did nothing would leave the discard doors leaking
	// exactly as they leaked before, and the compiler must ask.
	Revoke(ctx context.Context, token string) error
}

GitHubMinter mints a short-lived, down-scoped GitHub App installation token. The real implementation (githubMinter) holds the App private key; the fake (FakeGitHubMinter) is used in unit tests. Down-scoping is via repositories + permissions on the installation-token request.

func NewGitHubMinter

func NewGitHubMinter(store secretstore.Store, cfg GitHubMinterConfig) (GitHubMinter, error)

NewGitHubMinter builds a LAZY GitHubMinter: it validates the secret names but does NOT read the App credentials — those are read (and the ghinstallation transport built) on the first mint, then cached. When the secrets are genuinely absent at mint time the mint fails closed with a clear error.

type GitHubMinterConfig

type GitHubMinterConfig struct {
	// AppIDSecret is the secretstore name holding the numeric App ID (ASCII).
	AppIDSecret string
	// PrivateKeySecret is the secretstore name holding the PEM private key.
	PrivateKeySecret string
}

GitHubMinterConfig names the secrets holding the GitHub App credentials.

type Minted

type Minted struct {
	Kind       types.GrantKind `json:"kind"`
	JTI        string          `json:"jti"`
	ExpiresAt  time.Time       `json:"expires_at"`
	GrantID    uuid.UUID       `json:"grant_id"`
	ApprovalID uuid.UUID       `json:"approval_id,omitempty"`
	// Token is the bearer credential for github_token, the stored PAT VALUE for
	// git_pat, or the stored PRIVATE KEY material for ssh_key. Empty for api_key
	// (whose value never leaves the broker).
	Token string `json:"token,omitempty"`
	// Username is the git username to pair with Token for git_pat (ADO=pat,
	// GitLab=oauth2, or an explicit override) or ssh_key (default "git"). Empty for
	// github_token (the helper uses x-access-token) and api_key.
	Username string `json:"username,omitempty"`
	// KnownHosts is the OpenSSH known_hosts material for an ssh_key grant whose
	// scope named a known_hosts_secret_ref. Empty otherwise (ssh_key runs fall back
	// to the image-baked /etc/ssh/ssh_known_hosts for github.com / ADO). Nominally
	// public host-key data, not a secret — but mask-registered by mint() anyway
	// (W12-B-2), as defense in depth: storedSecretGrantPairing (internal/api)
	// now pins a MEMBER's known_hosts_secret_ref to exactly the operator's own
	// ceiling pairing, but an unclamped (operator-authored) grant could still
	// name an unexpected secret here, and this was the one mint output never
	// mask-registered at all.
	KnownHosts string `json:"known_hosts,omitempty"`
	// Injection is the proxy-side rule for api_key. Nil for github_token/git_pat.
	Injection *egress.InjectionRule `json:"injection,omitempty"`
	// Metadata carries kind-specific, non-secret context (e.g. github_token
	// branch namespace, repos, clamped permissions).
	Metadata map[string]string `json:"metadata,omitempty"`
}

Minted is the result of a successful mint. For github_token, Token carries the installation token. For api_key, the secret value NEVER appears: only an egress.InjectionRule (resolved proxy-side at use time) is returned.

type MintedCredential added in v0.7.0

type MintedCredential struct {
	JTI  string
	Kind string
}

MintedCredential is one credential this run actually minted: the jti recorded on the credential.mint audit row (or burnt onto its approval) and the grant KIND it was minted for. RevokeRun needs the kind because the honest revoke story differs per kind — a github_token expires on its own <=1h TTL, while a git_pat or ssh_key is an operator-managed secret Wardyn can neither expire nor down-scope (see broker_mint_kinds.go's mintGitPAT/mintSSHKey doc comments). Kind is "" when the grant row behind the mint is gone (a deleted grant still leaves its audit row), which RevokeRun reports as the unknown-kind note rather than guessing.

type PgxStore

type PgxStore struct {
	Pool *pgxpool.Pool
}

PgxStore adapts a *pgxpool.Pool to the broker's TxBeginner/Tx surface so the transaction logic stays testable behind an interface (no pgxmock dependency).

func NewPgxStore

func NewPgxStore(pool *pgxpool.Pool) *PgxStore

NewPgxStore wraps a pgx pool for use as the broker's db.

func (*PgxStore) BeginReadCommitted added in v0.7.0

func (s *PgxStore) BeginReadCommitted(ctx context.Context) (Tx, error)

BeginReadCommitted starts a real pgx transaction pinned to READ COMMITTED.

SET TRANSACTION rather than pgx.TxOptions{IsoLevel: pgx.ReadCommitted}: it is exactly equivalent as long as it is the FIRST statement of the transaction, which it is here, and it keeps the broker's Tx seam (Querier + Commit/Rollback) free of pgx option types that the in-memory fake would then have to model. A failed SET rolls the half-open transaction back rather than handing the caller a tx at the pool's isolation — fail closed: the audit chain's serialization depends on this, so an unpinned tx must never be returned.

func (*PgxStore) MintedCredentials added in v0.7.0

func (s *PgxStore) MintedCredentials(ctx context.Context, runID uuid.UUID) ([]MintedCredential, error)

MintedCredentials lists every credential this run minted, with its grant kind.

type Querier

type Querier interface {
	QueryRow(ctx context.Context, sql string, args ...any) Row
	// Exec runs a statement and returns the number of rows affected. Rows
	// affected is load-bearing for the single-use guard: a 0-row conditional
	// `UPDATE approvals SET minted_jti ... WHERE minted_jti=”` means another
	// mint won the race, so the caller fails closed. pgx's CommandTag is the
	// source; the fake mirrors its rows-affected semantics.
	Exec(ctx context.Context, sql string, args ...any) (int64, error)
}

Querier is the minimal transaction surface the broker needs. It is satisfied by pgx.Tx; tests fake it so the mint logic runs with no Postgres. Method shapes match pgx so the production Begin/Commit/Rollback dance is unchanged.

type Row

type Row interface {
	Scan(dest ...any) error
}

Row is the single-row result surface (subset of pgx.Row).

type Tx

type Tx interface {
	Querier
	Commit(ctx context.Context) error
	Rollback(ctx context.Context) error
}

Tx is a Querier with commit/rollback. Rollback after Commit is a no-op.

type TxBeginner

type TxBeginner interface {
	// BeginReadCommitted starts a transaction pinned to READ COMMITTED. It is
	// the ONLY transaction start on this seam — there is deliberately no bare
	// Begin for a new call site to reach for, because every transaction the
	// broker opens either writes an audit row itself (mint) or holds grant /
	// approval rows a mint tx will join.
	BeginReadCommitted(ctx context.Context) (Tx, error)
	MintedCredentials(ctx context.Context, runID uuid.UUID) ([]MintedCredential, error)
}

TxBeginner opens a transaction exposing a Querier, and answers the one non-transactional bulk read the broker needs (MintedCredentials, for RevokeRun's audit cascade). *pgxAdapter wraps a real *pgxpool.Pool; tests provide a fake. Commit/Rollback bound the mint tx.

MintedCredentials is on the interface, not an optional type assertion: an implementation that silently answered "nothing minted" would make RevokeRun emit ZERO credential.revoke events with a nil error. The compiler must ask.

BeginReadCommitted is likewise on the interface rather than a type assertion, and every transaction in this package that can carry an audit_events row goes through it. An audit-joining writer must NOT inherit default_transaction_isolation: under a pool set to REPEATABLE READ, two writers racing on the audit chain advisory lock both read the chain head from their OWN pre-lock snapshot, so the loser's prev_hash points at a row that is no longer the head and the hash chain FORKS (the same defect P1 reproduced on store.InsertAuditEvent and fixed at internal/store/store.go BeginTx, commit 081c075b). READ COMMITTED makes the post-lock read see the winner's committed row, which is the whole point of taking the lock. Implementations pin it as the FIRST statement of the tx.

Jump to

Keyboard shortcuts

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