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 ¶
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.
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.
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 )
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.
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 ¶
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 ClaimsFromPayload ¶ added in v0.0.5
ClaimsFromPayload rebuilds Claims from a payload Verify accepted earlier and the hub kept: the record of a CI session (Task 20390), so the session's rule can be asked again whether it admits the pipeline. It verifies nothing — the signature is not kept, and the payload is trusted only because the hub wrote it after Verify.
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.
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 ¶
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 ¶
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 ¶
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 ¶
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 ¶
Broken returns the IDs of stored rules that could not be prepared, mapped to the reason.
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) Federates ¶ added in v0.0.5
Federates reports why claims Verify accepted earlier are not ones this verifier would accept now for its issuer and audience, or nil. It checks nothing about time or replay: it is asked of a session minted earlier, held to the hub's configuration as it stands now (Task 20390).
func (*Verifier) Verify ¶
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
// with the token's jti and expiry, and a false answer refuses the token
// as replayed (Task 20354). Several hub processes may serve one control
// plane, each with its own memory, and a token refused as a replay by one
// must not be accepted by the next. An error is not a refusal: the
// in-memory check already passed, and the token's short life is the
// defence that holds when the shared one cannot answer.
SharedReplay func(jti string, expires time.Time) (fresh bool, err error)
}
VerifierConfig configures a Verifier.