package api
import (
"context"
"database/sql"
"errors"
"fmt"
"sort"
"strings"
"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)
}
// 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 string `json:"name" validate:"required,max=64"`
Branches []string `json:"branches" validate:"max=32"`
}
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"`
}
func registerCISecrets(r *ops.Registry, d Deps) error {
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: no branches means protected branches only (RFC 0007), the default. 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
}
if err := d.Secrets.SetEnvironment(in.Repo, strings.TrimSpace(in.Name), in.Branches, 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
}