package api
import (
"context"
"database/sql"
"errors"
"fmt"
"sort"
"strings"
"time"
"craftdesign.group/quarry/internal/cisecret"
"craftdesign.group/quarry/internal/oltp"
"craftdesign.group/quarry/internal/ops"
)
// SecretStore is RFC 0019's environments and secrets, and the one fact
// about a CI run that delivery needs: which check-in it is testing, and
// whether it is a landing's merge.
type SecretStore interface {
SetEnvironment(repo, name string, branches []string, actor string) error
DeleteEnvironment(repo, name, actor string) error
SetSecret(repo, environment, name string, sealed []byte, actor string) error
DeleteSecret(repo, environment, name, actor string) error
Environments(repo string) ([]oltp.Environment, error)
HasEnvironment(repo, name string) (bool, error)
SealedSecrets(repo, environment string, names []string) (map[string]oltp.SealedSecret, error)
CIRunByID(repo, runID string) (oltp.CIRun, bool, error)
// RFC 0021 §runs: what a run token is minted from.
SetEnvironmentTokens(repo, name, allowed, starter, actor string) error
EnvironmentPolicy(repo, name string) (oltp.EnvPolicy, error)
CIRunStart(runID string) (event, starter string, jobs map[string]oltp.RunJobGrant, err error)
EffectiveCaps(account, repo string) oltp.CapSet
MintRunToken(spec oltp.RunTokenSpec) (login, value string, tok oltp.Token, err error)
RunTokens(runID string) ([]oltp.Token, error)
}
// maxSecretValue bounds one secret. A private key or a service
// account's JSON fits; a file does not belong in an env var.
const maxSecretValue = 48 << 10
type ciEnvironmentsIn struct {
Repo string `json:"repo" validate:"required,max=64"`
}
type ciEnvironmentsOut struct {
Environments []oltp.Environment `json:"environments"`
// Ready is false when the forge has no key: nothing can be stored or
// delivered, and the settings page says so instead of failing late.
Ready bool `json:"ready"`
}
type ciEnvironmentSetIn struct {
Repo string `json:"repo" validate:"required,max=64"`
// Name "" is the repository's own policy, which a job naming no
// environment is held to.
Name string `json:"name" validate:"max=64"`
Branches []string `json:"branches" validate:"max=32"`
// Allowed is what a run token may at most carry here, as letters
// (RFC 0021 §runs); Starter the one letter a run's starter must
// hold, "" for none. Absent keeps what is set.
Allowed *string `json:"allowed,omitempty"`
Starter *string `json:"starter,omitempty"`
}
type ciEnvironmentDeleteIn struct {
Repo string `json:"repo" validate:"required,max=64"`
Name string `json:"name" validate:"required,max=64"`
}
type ciSecretSetIn struct {
Repo string `json:"repo" validate:"required,max=64"`
// Environment is "" for the repository's own secrets.
Environment string `json:"environment" validate:"max=64"`
Name string `json:"name" validate:"required,max=128"`
Value string `json:"value" validate:"required"`
}
type ciSecretDeleteIn struct {
Repo string `json:"repo" validate:"required,max=64"`
Environment string `json:"environment" validate:"max=64"`
Name string `json:"name" validate:"required,max=128"`
}
type ciRunTokensIn struct {
Repo string `json:"repo" validate:"required,max=64"`
RunID string `json:"runId" validate:"required,max=128"`
}
// runTokenLine is what a run's page says about one job's token: the
// letters it carried and whether it is over. Never a value.
type runTokenLine struct {
Job string `json:"job"`
Letters string `json:"letters"`
Created string `json:"created"`
Expires string `json:"expires"`
Revoked string `json:"revoked"`
}
type ciRunTokensOut struct {
Tokens []runTokenLine `json:"tokens"`
}
func registerCISecrets(r *ops.Registry, d Deps) error {
if err := ops.Register(r, ops.Op[ciRunTokensIn, ciRunTokensOut]{
Name: "ci.run.tokens",
Caps: "8",
Doc: "The run tokens a CI run's jobs carried (RFC 0021): each job's letters and when its token was revoked, \"\" while it lives. Never a value.",
Kind: ops.Query,
Auth: ops.Anonymous,
Route: "GET /api/v1/r/{repo}/ci/runs/{runId}/tokens",
Run: func(ctx context.Context, in ciRunTokensIn) (ciRunTokensOut, error) {
if _, err := d.repoFor(ctx, in.Repo); err != nil {
return ciRunTokensOut{}, err
}
out := ciRunTokensOut{Tokens: []runTokenLine{}}
if d.Secrets == nil {
return out, nil
}
if _, ok, err := d.Secrets.CIRunByID(in.Repo, in.RunID); err != nil || !ok {
return ciRunTokensOut{}, fmt.Errorf("no run %q: %w", in.RunID, ops.ErrNotFound)
}
toks, err := d.Secrets.RunTokens(in.RunID)
if err != nil {
return ciRunTokensOut{}, err
}
for _, t := range toks {
out.Tokens = append(out.Tokens, runTokenLine{Job: t.Name, Letters: t.Ceiling, Created: t.Created, Expires: t.Expires, Revoked: t.Revoked})
}
return out, nil
},
}); err != nil {
return err
}
unavailable := func() error {
return fmt.Errorf("CI secrets are not configured on this forge: %w", ops.ErrUnavailable)
}
list := func(ctx context.Context, repo string) (ciEnvironmentsOut, error) {
envs, err := d.Secrets.Environments(repo)
if err != nil {
return ciEnvironmentsOut{}, err
}
if envs == nil {
envs = []oltp.Environment{}
}
return ciEnvironmentsOut{Environments: envs, Ready: d.SecretBox.Ready()}, nil
}
if err := ops.Register(r, ops.Op[ciEnvironmentsIn, ciEnvironmentsOut]{
Name: "ci.environments",
Caps: "a",
Doc: "A repository's CI environments (RFC 0019): each one's branch policy and the names of its secrets, never their values. The environment named \"\" holds the repository's own secrets.",
Kind: ops.Query,
Auth: ops.Token,
Route: "GET /api/v1/r/{repo}/ci/environments",
Run: func(ctx context.Context, in ciEnvironmentsIn) (ciEnvironmentsOut, error) {
if d.Secrets == nil {
return ciEnvironmentsOut{}, unavailable()
}
if _, err := d.repoFor(ctx, in.Repo); err != nil {
return ciEnvironmentsOut{}, err
}
return list(ctx, in.Repo)
},
}); err != nil {
return err
}
if err := ops.Register(r, ops.Op[ciEnvironmentSetIn, ciEnvironmentsOut]{
Name: "ci.environment.set",
Caps: "a",
Doc: "Create a CI environment or change which branches may receive its secrets and run tokens: no branches means protected branches only (RFC 0007), the default. allowed is the letters a run token may at most carry there (default o g 8), starter the one letter the person who starts a run must hold for its jobs to receive one (RFC 0021). Name \"\" is the repository's own policy, for jobs naming no environment. A landing's merge never qualifies, whatever the policy. Recorded in the settings log.",
Kind: ops.Command,
Auth: ops.Token,
Route: "POST /api/v1/r/{repo}/ci/environments",
Run: func(ctx context.Context, in ciEnvironmentSetIn) (ciEnvironmentsOut, error) {
if d.Secrets == nil {
return ciEnvironmentsOut{}, unavailable()
}
if _, err := d.repoFor(ctx, in.Repo); err != nil {
return ciEnvironmentsOut{}, err
}
name := strings.TrimSpace(in.Name)
if err := d.Secrets.SetEnvironment(in.Repo, name, in.Branches, ops.User(ctx)); err != nil {
return ciEnvironmentsOut{}, fmt.Errorf("%v: %w", err, ops.ErrInvalid)
}
if in.Allowed != nil || in.Starter != nil {
p, err := d.Secrets.EnvironmentPolicy(in.Repo, name)
if err != nil {
return ciEnvironmentsOut{}, err
}
allowed, starter := p.Allowed, p.Starter
if in.Allowed != nil {
allowed = strings.Join(strings.Fields(*in.Allowed), "")
}
if in.Starter != nil {
starter = strings.TrimSpace(*in.Starter)
}
if err := d.Secrets.SetEnvironmentTokens(in.Repo, name, allowed, starter, ops.User(ctx)); err != nil {
return ciEnvironmentsOut{}, fmt.Errorf("%v: %w", err, ops.ErrInvalid)
}
}
return list(ctx, in.Repo)
},
}); err != nil {
return err
}
if err := ops.Register(r, ops.Op[ciEnvironmentDeleteIn, ciEnvironmentsOut]{
Name: "ci.environment.delete",
Caps: "a",
Doc: "Delete a CI environment and every secret in it. Recorded in the settings log.",
Kind: ops.Command,
Auth: ops.Token,
Route: "POST /api/v1/r/{repo}/ci/environments/delete",
Run: func(ctx context.Context, in ciEnvironmentDeleteIn) (ciEnvironmentsOut, error) {
if d.Secrets == nil {
return ciEnvironmentsOut{}, unavailable()
}
if _, err := d.repoFor(ctx, in.Repo); err != nil {
return ciEnvironmentsOut{}, err
}
err := d.Secrets.DeleteEnvironment(in.Repo, in.Name, ops.User(ctx))
switch {
case errors.Is(err, oltp.ErrNoEnvironment):
return ciEnvironmentsOut{}, fmt.Errorf("no environment %q: %w", in.Name, ops.ErrNotFound)
case err != nil:
return ciEnvironmentsOut{}, fmt.Errorf("%v: %w", err, ops.ErrInvalid)
}
return list(ctx, in.Repo)
},
}); err != nil {
return err
}
if err := ops.Register(r, ops.Op[ciSecretSetIn, ciEnvironmentsOut]{
Name: "ci.secret.set",
Caps: "a",
Doc: "Set a CI secret, in an environment or (environment \"\") the repository's own. The value is sealed with the forge's key before it is stored and is never returned or logged; the settings log records that it was set, by whom.",
Kind: ops.Command,
Auth: ops.Token,
Route: "POST /api/v1/r/{repo}/ci/secrets",
Run: func(ctx context.Context, in ciSecretSetIn) (ciEnvironmentsOut, error) {
if d.Secrets == nil || !d.SecretBox.Ready() {
return ciEnvironmentsOut{}, unavailable()
}
if _, err := d.repoFor(ctx, in.Repo); err != nil {
return ciEnvironmentsOut{}, err
}
if len(in.Value) > maxSecretValue {
return ciEnvironmentsOut{}, fmt.Errorf("a secret is at most %d KiB: %w", maxSecretValue>>10, ops.ErrInvalid)
}
if !oltp.ValidSecretName(in.Name) {
return ciEnvironmentsOut{}, fmt.Errorf("a secret's name is an env var name: letters, digits and '_', not starting with a digit: %w", ops.ErrInvalid)
}
sealed, err := d.SecretBox.Seal(in.Repo, in.Environment, in.Name, in.Value)
if err != nil {
return ciEnvironmentsOut{}, err
}
err = d.Secrets.SetSecret(in.Repo, in.Environment, in.Name, sealed, ops.User(ctx))
switch {
case errors.Is(err, oltp.ErrNoEnvironment):
return ciEnvironmentsOut{}, fmt.Errorf("no environment %q; create it first, choosing which branches may receive it: %w", in.Environment, ops.ErrNotFound)
case err != nil:
return ciEnvironmentsOut{}, fmt.Errorf("%v: %w", err, ops.ErrInvalid)
}
return list(ctx, in.Repo)
},
}); err != nil {
return err
}
return ops.Register(r, ops.Op[ciSecretDeleteIn, ciEnvironmentsOut]{
Name: "ci.secret.delete",
Caps: "a",
Doc: "Delete a CI secret. Recorded in the settings log.",
Kind: ops.Command,
Auth: ops.Token,
Route: "POST /api/v1/r/{repo}/ci/secrets/delete",
Run: func(ctx context.Context, in ciSecretDeleteIn) (ciEnvironmentsOut, error) {
if d.Secrets == nil {
return ciEnvironmentsOut{}, unavailable()
}
if _, err := d.repoFor(ctx, in.Repo); err != nil {
return ciEnvironmentsOut{}, err
}
err := d.Secrets.DeleteSecret(in.Repo, in.Environment, in.Name, ops.User(ctx))
switch {
case errors.Is(err, sql.ErrNoRows):
return ciEnvironmentsOut{}, fmt.Errorf("no secret %q: %w", in.Name, ops.ErrNotFound)
case err != nil:
return ciEnvironmentsOut{}, err
}
return list(ctx, in.Repo)
},
})
}
// ErrSecretRefused is a delivery the rule refuses. Its text says why,
// in terms of names and branches, never values.
var ErrSecretRefused = errors.New("secrets refused")
// DeliverSecrets decides whether the CI task taskID ("<run>.<step>") may
// have the named secrets, and opens them if so (RFC 0019 §secrets).
//
// Everything it decides by is the forge's own record, never the
// runner's word: the run is quarry's (it launched it), its check-in and
// whether it is a landing's merge come from that record, and the
// check-in's branch from the repository's index. The caller has already
// proved that the asking runner holds the task (the engine accepted its
// heartbeat for it with the runner's own token).
//
// A landing's merge never qualifies. Otherwise each secret's
// environment decides: protected branches only, unless it names the
// branch patterns that qualify.
func (d Deps) DeliverSecrets(repo, taskID, environment string, names []string) (map[string]string, error) {
if d.Secrets == nil || !d.SecretBox.Ready() {
return nil, cisecret.ErrNoKey
}
runID, _, ok := strings.Cut(taskID, ".")
if !ok || runID == "" {
return nil, fmt.Errorf("%w: %q is not a task id", ErrSecretRefused, taskID)
}
run, ok, err := d.Secrets.CIRunByID(repo, runID)
if err != nil {
return nil, err
}
if !ok {
return nil, fmt.Errorf("%w: no run %.12s in %s", ErrSecretRefused, runID, repo)
}
if run.Pair != "" || strings.HasPrefix(run.Checkin, "pair:") {
return nil, fmt.Errorf("%w: a landing's merge is unreviewed code and never receives secrets", ErrSecretRefused)
}
branch, err := d.checkinBranch(repo, run.Checkin)
if err != nil {
return nil, err
}
if environment != "" {
has, err := d.Secrets.HasEnvironment(repo, environment)
if err != nil {
return nil, err
}
if !has {
return nil, fmt.Errorf("%w: the job names environment %q, which %s does not have", ErrSecretRefused, environment, repo)
}
}
sealed, err := d.Secrets.SealedSecrets(repo, environment, names)
if err != nil {
return nil, err
}
var missing []string
for _, n := range names {
if _, ok := sealed[n]; !ok {
missing = append(missing, n)
}
}
if len(missing) > 0 {
sort.Strings(missing)
return nil, fmt.Errorf("%w: %s has no secret %s", ErrSecretRefused, repo, strings.Join(missing, ", "))
}
protections, err := d.protections(repo)
if err != nil {
return nil, err
}
out := make(map[string]string, len(sealed))
for _, n := range names {
s := sealed[n]
if !branchQualifies(s.Branches, protections, branch) {
where := "the repository's own secrets"
if s.Environment != "" {
where = "environment " + s.Environment
}
rule := "protected branches only"
if len(s.Branches) > 0 {
rule = "branches " + strings.Join(s.Branches, " ")
}
return nil, fmt.Errorf("%w: %s goes to %s (%s), and this check-in is on %q", ErrSecretRefused, n, rule, where, branch)
}
v, err := d.SecretBox.Open(repo, s.Environment, n, s.Sealed)
if err != nil {
return nil, err
}
out[n] = v
}
return out, nil
}
func branchQualifies(patterns []string, protections []oltp.Protection, branch string) bool {
if branch == "" {
return false
}
if len(patterns) == 0 {
for _, p := range protections {
if oltp.BranchMatches(p.Branch, branch) {
return true
}
}
return false
}
for _, p := range patterns {
if oltp.BranchMatches(p, branch) {
return true
}
}
return false
}
func (d Deps) protections(repo string) ([]oltp.Protection, error) {
if d.Protect == nil {
return nil, nil
}
return d.Protect.Protections(repo)
}
// checkinBranch reads which branch a check-in is on from the
// repository's index.
func (d Deps) checkinBranch(repo, checkin string) (string, error) {
r, err := d.repo(repo)
if err != nil {
return "", err
}
if r.Index == nil {
return "", fmt.Errorf("%s has no index to say which branch %.12s is on", repo, checkin)
}
m, err := r.Index.BranchOf([]string{checkin})
if err != nil {
return "", err
}
return m[checkin], nil
}
// runTokenEvents are the events whose runs may be minted a token. A
// landing's merge is a "landing" too, and is refused by the record
// before the event is read.
var runTokenEvents = map[string]bool{"push": true, "landing": true, "tag": true, "workflow_dispatch": true, "schedule": true}
// runTokenLife is a job's token's life when its job states no
// timeout-minutes: Actions' own default for a job.
const runTokenLife = 360 * time.Minute
// DeliverRunToken decides whether the CI task taskID ("<run>.<job>")
// gets a run token, and mints it if so (RFC 0021 §runs). The answer is
// the job's environment additions (QUARRY_USER, QUARRY_TOKEN), or none
// and a note for the job's log saying why. The caller has proved the
// asking runner holds the task, as for secrets.
//
// Everything decided here is the forge's record: the run's start (its
// event, its starter, the job's declaration as the workflow file said
// it when the run started), the environment's policy, the check-in's
// branch. Minted here rather than when the run starts because a value
// can be handed over only by whoever just made it: the forge keeps a
// hash. A job nobody runs is minted nothing.
func (d Deps) DeliverRunToken(repo, taskID string) (map[string]string, string, error) {
if d.Secrets == nil {
return nil, "", nil
}
runID, job, ok := strings.Cut(taskID, ".")
if !ok || runID == "" || job == "" {
return nil, "", fmt.Errorf("%w: %q is not a task id", ErrSecretRefused, taskID)
}
run, ok, err := d.Secrets.CIRunByID(repo, runID)
if err != nil || !ok {
return nil, "", err
}
event, starter, jobs, err := d.Secrets.CIRunStart(runID)
if err != nil {
return nil, "", err
}
g, ok := jobs[job]
if !ok {
return nil, "", nil // a JSON pipeline, or a run started before run tokens
}
policy, err := d.Secrets.EnvironmentPolicy(repo, g.Environment)
if err != nil {
return nil, "", err
}
letters := intersectLetters(g.Letters, policy.Allowed)
if strings.Contains(letters, "y") {
letters += "go" // what MintRunToken adds: a uv push reads first
}
public := d.Secrets.EffectiveCaps("", repo)
exceeds := false
for _, l := range letters {
if !public.HasAny(string(l)) {
exceeds = true
}
}
if !exceeds {
return nil, "", nil // the public already holds it: nothing to mint
}
where := "the repository's own policy"
if g.Environment != "" {
where = "environment " + g.Environment
}
switch {
case run.Pair != "" || strings.HasPrefix(run.Checkin, "pair:"):
return nil, "no run token: a landing's merge is unreviewed code and is never handed one", nil
case !runTokenEvents[event]:
return nil, fmt.Sprintf("no run token: a %q run is not one that may be handed one", event), nil
case g.Environment != "" && !policy.Exists:
return nil, fmt.Sprintf("no run token: the job names environment %q, which %s does not have", g.Environment, repo), nil
}
branch, err := d.checkinBranch(repo, run.Checkin)
if err != nil {
return nil, "", err
}
protections, err := d.protections(repo)
if err != nil {
return nil, "", err
}
if !branchQualifies(policy.Branches, protections, branch) {
rule := "protected branches only"
if len(policy.Branches) > 0 {
rule = "branches " + strings.Join(policy.Branches, " ")
}
return nil, fmt.Sprintf("no run token: %s hands one to %s, and this check-in is on %q", where, rule, branch), nil
}
if policy.Starter != "" {
switch {
case starter == "":
return nil, fmt.Sprintf("no run token: %s requires the person who starts a run to hold %s, and a scheduled run has no starter", where, policy.Starter), nil
case !d.Secrets.EffectiveCaps(starter, repo).HasAny(policy.Starter):
return nil, fmt.Sprintf("no run token: %s requires the person who starts a run to hold %s on %s, and %s does not", where, policy.Starter, repo, starter), nil
}
}
life := runTokenLife
if g.Timeout > 0 {
life = time.Duration(g.Timeout) * time.Minute
}
login, value, _, err := d.Secrets.MintRunToken(oltp.RunTokenSpec{Run: runID, Repo: repo, Job: job,
Letters: intersectLetters(g.Letters, policy.Allowed), Expires: time.Now().Add(life)})
var refusal oltp.Refusal
if errors.As(err, &refusal) {
return nil, "no run token: " + refusal.Error(), nil
}
if err != nil {
return nil, "", err
}
return map[string]string{"QUARRY_USER": login, "QUARRY_TOKEN": value}, "", nil
}
// intersectLetters is the letters of a that b holds, spelled one by one.
func intersectLetters(a, b string) string {
var out strings.Builder
for _, r := range a {
if strings.ContainsRune(b, r) {
out.WriteRune(r)
}
}
return out.String()
}