Skip to content
quarry Artifact [d51474892d]
Login

Artifact [d51474892d]

Artifact d51474892d2d69ec34d1164dc756a5ad650ffda4b890669a1aaaa171d2784c0a:


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
}