ciauth

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: 22 Imported by: 0

Documentation

Overview

Package ciauth federates a CI/CD pipeline's OIDC identity into a cloop session, without the pipeline ever holding a long-lived cloop credential.

The problem it solves

Running an agent harness from CI means the runner needs an Anthropic credential. The obvious way to arrange that is a repository secret holding an API key, and it is the wrong way: the key is long-lived, it is copied into every fork-adjacent workflow that can read the secret, it survives the job that used it, and nothing about it says which pipeline spent it.

GitHub Actions can instead mint a short-lived, signed OIDC token that says what the job *is* — repository, ref, workflow, environment, actor, event — and hand that to a third party. This package is the third party. It verifies the token against the forge's own signing keys, matches the claims against an allowlist the operator wrote, and returns the policy a matching pipeline has been granted. What the pipeline receives in exchange is minted by pkg/claudeproxy and is worth only what the policy says, for only as long as the job runs.

The credential never leaves the hub. That is the same bargain pkg/gitproxy makes for a GitHub PAT and pkg/kubeguard makes for a kubeconfig, applied to the one credential CI actually wants.

Trust is in two independent halves

Verify establishes that the forge signed this token and that it was minted for this hub. Match establishes that the operator meant to trust the identity it describes. Neither is sufficient: every GitHub repository in the world can obtain a correctly signed token naming itself, so a hub that verifies without matching trusts the internet.

Index

Constants

View Source
const (
	MinTTL     = 1 * time.Minute
	MaxTTL     = 6 * time.Hour
	DefaultTTL = 60 * time.Minute
)

TTL bounds for a minted CI session. The floor keeps a rule from being configured into uselessness; the ceiling is the real control, because the session token is the thing a leaked runner log would expose.

View Source
const (
	MaxRequestsCeiling = 10000
	DefaultMaxRequests = 500
)

Request-count bounds. A CI session's blast radius is "how much Claude can this job spend", and the request cap is the crude but effective lever.

View Source
const (

	// DefaultClockSkew tolerates modest clock drift between the forge and
	// this hub. Kept small deliberately: these tokens live minutes, so a
	// generous skew is a meaningful extension of their life.
	DefaultClockSkew = 60 * time.Second

	// MaxClockSkew bounds what an operator may configure.
	MaxClockSkew = 5 * time.Minute
)
View Source
const DefaultAudience = "cloop"

DefaultAudience is the `aud` value a pipeline must request. It is not the hub URL: GitHub lets a workflow choose any audience string, so binding to a URL would give a false sense that the token could only have been minted for this deployment. Operators who run more than one hub should set a distinct audience per hub and, more importantly, write rules that name repositories.

View Source
const GitHubActionsIssuer = "https://token.actions.githubusercontent.com"

GitHubActionsIssuer is the issuer GitHub Actions stamps into the ID tokens it mints for hosted and self-hosted runners alike.

Variables

View Source
var (
	// ErrUnverified means the token is not something this hub will read: bad
	// signature, wrong issuer, wrong audience, expired, replayed.
	//
	// Its counterpart — a genuine token naming a pipeline no rule admits — has
	// no error, because it is not one. RuleSet.Match reports a nil Rule, which
	// is the ordinary outcome on a correctly configured hub and the caller
	// turns into a 403.
	ErrUnverified = errors.New("ciauth: token not verified")

	// ErrReplayed means this exact token was already exchanged.
	ErrReplayed = errors.New("ciauth: token has already been exchanged")

	// ErrDisabled means CI federation is not turned on for this hub.
	ErrDisabled = errors.New("ciauth: CI federation is not enabled")
)

Errors callers distinguish. Everything a pipeline sees collapses to one HTTP status; these exist so the audit trail and the operator-facing message can say which of the two halves of trust failed.

Functions

This section is empty.

Types

type Claims

type Claims struct {
	// Issuer, Subject, Audience and the lifetime bounds are the parts Verify
	// checked. They are recorded rather than recomputed so an audit entry
	// describes the token that was actually accepted.
	Issuer   string   `json:"iss"`
	Subject  string   `json:"sub"`
	Audience []string `json:"aud"`
	JTI      string   `json:"jti,omitempty"`

	IssuedAt  int64 `json:"iat,omitempty"`
	NotBefore int64 `json:"nbf,omitempty"`
	ExpiresAt int64 `json:"exp,omitempty"`

	// The workload's identity, as GitHub describes it.
	Repository          string `json:"repository,omitempty"`
	RepositoryOwner     string `json:"repository_owner,omitempty"`
	RepositoryVisiblity string `json:"repository_visibility,omitempty"`
	Ref                 string `json:"ref,omitempty"`
	RefType             string `json:"ref_type,omitempty"`
	Workflow            string `json:"workflow,omitempty"`
	WorkflowRef         string `json:"workflow_ref,omitempty"`
	JobWorkflowRef      string `json:"job_workflow_ref,omitempty"`
	Environment         string `json:"environment,omitempty"`
	Actor               string `json:"actor,omitempty"`
	EventName           string `json:"event_name,omitempty"`
	RunID               string `json:"run_id,omitempty"`
	RunAttempt          string `json:"run_attempt,omitempty"`
	RunnerEnvironment   string `json:"runner_environment,omitempty"`
	SHA                 string `json:"sha,omitempty"`

	// All is the decoded payload, for CEL conditions.
	All map[string]any `json:"-"`
}

Claims is a verified token's payload.

The typed fields are the GitHub Actions claims rules are usually written against; All carries the decoded payload so a CEL condition can reach any claim, including ones GitHub adds after this code was written. Rules are evaluated against All, never against the typed fields, so the two can never disagree.

func (*Claims) Label

func (c *Claims) Label() string

Label is a short, credential-free description for audit entries and the sessions table. It never contains the token.

func (*Claims) RunURL

func (c *Claims) RunURL(forgeBase string) string

RunURL reconstructs the Actions run this token was minted for, or "" when the claims do not carry enough to do so. Useful in the dashboard: an operator looking at a denied exchange wants the job, not the claim set.

type GrantPolicy

type GrantPolicy struct {
	// Models is an allowlist of model IDs, matched as globs so
	// "claude-sonnet-*" keeps working across point releases. Empty means the
	// hub's own default allowlist applies; it does not mean "any model".
	Models []string `json:"models,omitempty"`

	// MaxRequests caps how many upstream calls one session may make. Zero
	// uses DefaultMaxRequests.
	MaxRequests int `json:"max_requests,omitempty"`

	// MaxOutputTokens clamps `max_tokens` on every request. Zero means the
	// hub default applies.
	MaxOutputTokens int `json:"max_output_tokens,omitempty"`

	// TTLSeconds is the session lifetime. Zero uses DefaultTTL; values are
	// clamped to [MinTTL, MaxTTL].
	TTLSeconds int `json:"ttl_seconds,omitempty"`
}

GrantPolicy is what a matching pipeline is allowed to do with the session it receives.

func (GrantPolicy) AllowsModel

func (p GrantPolicy) AllowsModel(model string) bool

AllowsModel reports whether the policy admits a model ID.

An empty allowlist is "the hub's default applies", resolved by the caller before it reaches here; by the time a session holds a policy the list is populated. A policy that somehow reached here empty denies, because the one reading of an empty allowlist that must never be "everything" is this one.

func (GrantPolicy) Requests

func (p GrantPolicy) Requests() int

Requests returns the clamped request cap.

func (GrantPolicy) TTL

func (p GrantPolicy) TTL() time.Duration

TTL returns the clamped session lifetime.

type MatchResult

type MatchResult struct {
	// Rule is the first rule that admitted the claims, or nil.
	Rule *Rule
	// Considered counts the enabled rules examined.
	Considered int
	// Errors names rules that could not be decided, keyed by rule ID. A rule
	// here neither admitted nor cleanly declined, and the distinction is the
	// whole reason this field exists: "no rule matched" and "your rule reads
	// a claim this token does not have" are different problems.
	Errors map[string]string
}

MatchResult explains a search, for audit and for the operator staring at a pipeline that is being refused.

type Matcher

type Matcher struct {
	// Repository is a glob over the `repository` claim, e.g. "acme/tool" or
	// "acme/*". The owner segment may not contain a wildcard.
	Repository string `json:"repository,omitempty"`

	// Ref is a glob over the `ref` claim, e.g. "refs/heads/main" or
	// "refs/heads/**". A trailing "/**" admits anything strictly below.
	Ref string `json:"ref,omitempty"`

	// Workflow is a glob matched against the workflow's display name (the
	// `workflow` claim), its full reference (`workflow_ref`), or that
	// reference with the trailing "@<ref>" removed. Naming the file is the
	// stronger form: a display name is whatever the workflow file says it is,
	// and anyone who can open a pull request can say it.
	//
	// The third form exists because `workflow_ref` is
	// "owner/name/.github/workflows/x.yml@refs/heads/main" and a glob's `*`
	// does not cross a slash, so the pattern an operator naturally writes —
	// ".../x.yml@*" — would match the ref "main" and not "release/1.2". That
	// is a trap that fails open-looking and closed-behaving, so the path is
	// offered as its own thing to match.
	Workflow string `json:"workflow,omitempty"`

	// Environment is a glob over the `environment` claim. A token only
	// carries one when the job declares an environment, so a rule that sets
	// this never admits a job that did not — which is the point: GitHub
	// environment protection rules are the approval gate this leans on.
	Environment string `json:"environment,omitempty"`

	// Actor is a glob over the `actor` claim: who triggered the run.
	Actor string `json:"actor,omitempty"`

	// EventName is a glob over the `event_name` claim, e.g. "push".
	EventName string `json:"event_name,omitempty"`

	// Condition is a strict-CEL expression over `assertion.<claim>`. See
	// pkg/celmatch for exactly what the dialect accepts.
	Condition string `json:"condition,omitempty"`
}

Matcher describes which pipeline identities a rule admits.

An empty field is "don't care". At least one of Repository or Condition must be set — see Validate for why the others cannot stand alone.

func (Matcher) IsEmpty

func (m Matcher) IsEmpty() bool

IsEmpty reports whether the matcher constrains nothing.

type Rule

type Rule struct {
	ID      string `json:"id"`
	Name    string `json:"name"`
	Enabled bool   `json:"enabled"`

	Match  Matcher     `json:"match"`
	Policy GrantPolicy `json:"policy"`

	// Project optionally binds sessions minted under this rule to a cloop
	// project, so the spend shows up against it.
	Project string `json:"project,omitempty"`

	CreatedBy string    `json:"created_by,omitempty"`
	CreatedAt time.Time `json:"created_at"`
	UpdatedAt time.Time `json:"updated_at,omitempty"`

	// LastMatchedAt is advisory, updated out of band. It is what tells an
	// operator that a rule they were about to delete is still load-bearing.
	LastMatchedAt time.Time `json:"last_matched_at,omitempty"`
	// contains filtered or unexported fields
}

Rule is one entry of the pipeline allowlist.

func (*Rule) Matches

func (r *Rule) Matches(c *Claims) (bool, error)

Matches reports whether the rule admits these claims.

The error return is not "no match": it means the rule could not be decided, which happens when a condition reads a claim the token does not carry. A caller must treat it as a denial, and callers do — RuleSet.Match records it and moves on to the next rule rather than letting an undecidable rule stop the search or, worse, admit.

func (*Rule) Prepare

func (r *Rule) Prepare() error

Prepare compiles a rule loaded from storage so it can match.

It is separate from Validate only in intent: Validate is what a write path calls, Prepare is what a read path calls, and they do the same work because a rule that round-tripped through the database must be held to exactly the checks it passed on the way in. A rule that fails here is not silently dropped — RuleSet records it so an operator can see that a stored rule has stopped being loadable.

func (*Rule) Validate

func (r *Rule) Validate() error

Validate checks a rule an operator submitted and compiles its condition.

It is strict on purpose. Every refusal here is a rule that would otherwise have admitted more pipelines than the person writing it intended, and the cost of finding that out later is a third party's workload spending this hub's Anthropic credential.

type RuleSet

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

RuleSet is an ordered allowlist. First match wins.

func NewRuleSet

func NewRuleSet(rules []Rule) *RuleSet

NewRuleSet prepares rules and returns a set ready to match against.

Preparation failures do not fail the call. One unparseable rule must not take a hub's whole CI allowlist offline — the rest keep working, and the broken one is inert and reported. The alternative, refusing to serve any pipeline because one row is bad, turns a typo into an outage.

func (*RuleSet) Broken

func (rs *RuleSet) Broken() map[string]string

Broken returns the IDs of stored rules that could not be prepared, mapped to the reason.

func (*RuleSet) Len

func (rs *RuleSet) Len() int

Len returns the number of rules in force.

func (*RuleSet) Match

func (rs *RuleSet) Match(c *Claims) MatchResult

Match finds the first rule that admits c.

A nil Rule with a nil error means no rule matched, which is the ordinary outcome for a pipeline nobody has allowlisted.

type Verifier

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

Verifier verifies CI OIDC assertions. It caches the issuer's discovery document and key set, and is safe for concurrent use.

func NewVerifier

func NewVerifier(cfg VerifierConfig) (*Verifier, error)

NewVerifier validates the configuration and returns a Verifier.

It does not contact the issuer: a hub must start when the forge is unreachable, and the first exchange will report the failure with the detail an operator needs. What it does refuse is a configuration that could not work — an issuer that is not an https URL, or a missing audience — because those are mistakes better found at boot than at the first pipeline run.

func (*Verifier) Audience

func (v *Verifier) Audience() string

Audience returns the configured audience.

func (*Verifier) Issuer

func (v *Verifier) Issuer() string

Issuer returns the configured issuer.

func (*Verifier) Verify

func (v *Verifier) Verify(ctx context.Context, raw string) (*Claims, error)

Verify checks the signature and the registered claims of raw, and returns its payload.

Every failure wraps ErrUnverified so a caller can answer "genuine token, unknown pipeline" differently from "not a token I will read", while the message keeps the detail an operator needs. The message is safe to log and is deliberately *not* returned to the pipeline: a caller learning precisely which check failed learns how to approach the next one.

type VerifierConfig

type VerifierConfig struct {
	// Issuer is the OIDC issuer, e.g. GitHubActionsIssuer. Discovery runs at
	// <issuer>/.well-known/openid-configuration.
	Issuer string

	// Audience is the `aud` a token must carry. Required: accepting any
	// audience would let a token a pipeline minted for an unrelated service
	// be replayed here.
	Audience string

	// ClockSkew tolerated on exp/iat/nbf. Zero uses DefaultClockSkew,
	// values are clamped to MaxClockSkew.
	ClockSkew time.Duration

	// Client overrides the HTTP client used for discovery and JWKS. Tests
	// point this at an httptest server; production leaves it nil.
	Client *http.Client

	// Now overrides the clock, for tests.
	Now func() time.Time
}

VerifierConfig configures a Verifier.

Jump to

Keyboard shortcuts

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