Skip to content
quarry Artifact [cda7e31bf5]
Login

Artifact [cda7e31bf5]

Artifact cda7e31bf581743bb65607e53bd6f309a3ab49ec2222780e9fbc351903d7d188:


// runtoken.go is RFC 0021's run token: a row in the token table with
// no account behind it, minted for one job of one CI run, on one
// repository, and revoked when the run ends however it ends.
//
// Its login is run-<id>, a name no account or team may take, so every
// door tells a run from a person by the name alone. The value is
// qrt_<id>_<secret>, hashed like an api token. A run token that pushes
// (i or y) also keeps the one §6.3 secret the sync front needs for its
// one repository, in sync_secret, until the run ends.
package oltp

import (
	"crypto/subtle"
	"database/sql"
	"encoding/json"
	"errors"
	"fmt"
	"strings"
	"time"

	"craftdesign.group/quarry/internal/xfer"
)

const runTokenPrefix = "qrt_"

// RunTokenSpec is one job's token as the forge decided it.
type RunTokenSpec struct {
	Run     string
	Repo    string
	Job     string
	Letters string // what the job may carry: declared ∩ the environment's allowed
	Expires time.Time
}

// runReadLetters are what a uv push needs to reach a private
// repository first: the sync front answers no one it cannot show the
// repository to. Added to a y token whatever the environment allows,
// because a y it cannot use is no grant at all.
const runReadLetters = "go"

// MintRunToken adds the token for spec's job and returns its login and
// value the only time the value is readable. A job holds one live token:
// a second mint (a retried task) revokes the first. A run that is over,
// or not this repository's, is minted nothing.
func (d *DB) MintRunToken(spec RunTokenSpec) (login, value string, tok Token, err error) {
	if spec.Run == "" || spec.Repo == "" || spec.Job == "" {
		return "", "", Token{}, errors.New("oltp: a run token needs a run, a repository and a job")
	}
	letters := strings.ReplaceAll(spec.Letters, "s", "")
	if strings.ContainsRune(letters, 'y') {
		letters = capUnion(letters, runReadLetters)
	}
	letters = inCapOrder(letters)
	now := time.Now().UTC()
	if !spec.Expires.After(now) {
		return "", "", Token{}, Refusal{"a run token expires after it is minted"}
	}
	// sync always: a job clones and pulls its repository over the sync
	// lane's Basic round, which a private repository answers only for a
	// credential. The §6.3 secret, which only a push's login card
	// needs, is kept only when the letters push.
	scopes := []string{ScopeSync, ScopeAPI}
	id, err := newTokenID()
	if err != nil {
		return "", "", Token{}, err
	}
	secret, err := newTokenSecret()
	if err != nil {
		return "", "", Token{}, err
	}
	login, value = runPrefix+id, runTokenPrefix+id+"_"+secret
	pushSecret := ""
	if strings.ContainsAny(letters, "iy") {
		code, ok := "", false
		if d.SyncProjectCode != nil {
			code, ok = d.SyncProjectCode(spec.Repo)
		}
		if !ok || code == "" {
			return "", "", Token{}, fmt.Errorf("oltp: %s has no project code to derive a push secret from", spec.Repo)
		}
		pushSecret = syncSecret(code, login, value)
	}
	tok = Token{ID: id, Run: spec.Run, Name: spec.Job, Scopes: scopes, Repos: []string{spec.Repo}, Ceiling: letters,
		Created: now.Format(time.RFC3339), Expires: spec.Expires.UTC().Format(time.RFC3339)}
	tx, err := d.w.Begin()
	if err != nil {
		return "", "", Token{}, err
	}
	defer tx.Rollback()
	var status string
	err = tx.QueryRow(`SELECT status FROM ci_run WHERE run_id = ? AND repo = ?`, spec.Run, spec.Repo).Scan(&status)
	if errors.Is(err, sql.ErrNoRows) {
		return "", "", Token{}, Refusal{fmt.Sprintf("no run %.12s in %s", spec.Run, spec.Repo)}
	}
	if err != nil {
		return "", "", Token{}, err
	}
	if ciTerminalStatus(status) {
		return "", "", Token{}, Refusal{fmt.Sprintf("run %.12s is over (%s)", spec.Run, status)}
	}
	if _, err := tx.Exec(`UPDATE token SET revoked = ?, sync_secret = '' WHERE run = ? AND name = ? AND revoked = ''`,
		tok.Created, spec.Run, spec.Job); err != nil {
		return "", "", Token{}, err
	}
	if _, err := tx.Exec(`INSERT INTO token (id, account, run, name, verifier, sealed, sync_secret, scope, repos, ceiling, created, expires)
		VALUES (?, '', ?, ?, ?, 0, ?, ?, ?, ?, ?, ?)`,
		id, spec.Run, spec.Job, hashToken(value), pushSecret, strings.Join(scopes, ","), spec.Repo, letters,
		tok.Created, tok.Expires); err != nil {
		return "", "", Token{}, err
	}
	return login, value, tok, tx.Commit()
}

// ciTerminalStatus is ciTerminal as Go reads it.
func ciTerminalStatus(status string) bool {
	return strings.Contains(ciTerminal, "'"+status+"'")
}

// RunTokens lists a run's tokens, revoked ones included, oldest first:
// what the run page says its jobs carried. Never a value.
func (d *DB) RunTokens(runID string) ([]Token, error) {
	rows, err := d.db.Query(`SELECT `+tokenColumns+` FROM token WHERE run = ? AND run != '' ORDER BY created, id`, runID)
	if err != nil {
		return nil, err
	}
	defer rows.Close()
	var out []Token
	for rows.Next() {
		t, err := scanToken(rows)
		if err != nil {
			return nil, err
		}
		out = append(out, t)
	}
	return out, rows.Err()
}

// RevokeRunTokens ends every live token of a run: a cancel asked for,
// which must not wait for the engine to report the run over.
func (d *DB) RevokeRunTokens(runID string) error {
	_, err := revokeRunTokensIn(d.w, runID)
	return err
}

// revokeRunTokensIn revokes inside the caller's transaction, so a run
// and its tokens end together. Revoking clears sync_secret and keeps the
// row: the audit still resolves the id while nothing usable remains.
func revokeRunTokensIn(q execer, runID string) (int64, error) {
	res, err := q.Exec(`UPDATE token SET revoked = ?, sync_secret = '' WHERE run = ? AND run != '' AND revoked = ''`,
		time.Now().UTC().Format(time.RFC3339), runID)
	if err != nil {
		return 0, err
	}
	return res.RowsAffected()
}

// SweepRunTokens revokes the live tokens of every run that is no longer
// going, or no longer recorded: run at startup, for a run that ended
// while no process was there to revoke at its close.
func (d *DB) SweepRunTokens() (int64, error) {
	res, err := d.w.Exec(`UPDATE token SET revoked = ?, sync_secret = ''
		WHERE run != '' AND revoked = ''
		  AND run NOT IN (SELECT run_id FROM ci_run WHERE status NOT IN `+ciTerminal+`)`,
		time.Now().UTC().Format(time.RFC3339))
	if err != nil {
		return 0, err
	}
	return res.RowsAffected()
}

// runToken is the run token login names, live or not.
func (d *DB) runToken(login string) (Token, bool) {
	id := login[len(runPrefix):]
	t, err := scanToken(d.db.QueryRow(`SELECT `+tokenColumns+` FROM token WHERE id = ? AND run != '' AND account = ''`, id))
	if err != nil || runPrefix+t.ID != login {
		return Token{}, false
	}
	return t, true
}

// resolveRunToken is ResolveToken for a run-<id> login: the value must
// be that row's, compared by hash.
func (d *DB) resolveRunToken(login, value, scope string) (Token, bool) {
	id, _, ok := splitRunToken(value)
	t, found := d.runToken(login)
	if !ok || !found || runPrefix+id != login {
		subtle.ConstantTimeCompare([]byte(hashToken(value)), []byte(decoyTokenHash))
		return Token{}, false
	}
	if !d.tokenMatches(t, value) || !t.Has(scope) || !t.Live(time.Now()) {
		return Token{}, false
	}
	d.touchToken(t, time.Now())
	return t, true
}

func splitRunToken(s string) (id, secret string, ok bool) {
	rest, ok := strings.CutPrefix(s, runTokenPrefix)
	if !ok {
		return "", "", false
	}
	id, secret, ok = strings.Cut(rest, "_")
	return id, secret, ok && id != "" && secret != ""
}

// runTokenCaps is what a run token holds on repo: the public set, and
// on its one repository while it lives, its letters. s never flows.
func (d *DB) runTokenCaps(t Token, repo string) CapSet {
	public := categoryFor(d.db, repo, "nobody")
	if !t.Live(time.Now()) || !t.Reaches(repo) || repo == "" {
		return CapSet(public)
	}
	return CapSet(strings.ReplaceAll(capUnion(public, string(d.expandCeiling(repo, t.Ceiling))), "s", ""))
}

// runLoginCaps is EffectiveCaps for a run-<id> name.
func (d *DB) runLoginCaps(login, repo string) CapSet {
	if t, ok := d.runToken(login); ok {
		return d.runTokenCaps(t, repo)
	}
	return CapSet(categoryFor(d.db, repo, "nobody"))
}

// runSecrets is Secrets for a run-<id> login: the one secret the row
// keeps, while it lives, for the one repository it names.
func (d *DB) runSecrets(repo, login string) []xfer.Credential {
	t, ok := d.runToken(login)
	if !ok || !t.Live(time.Now()) || !t.Reaches(repo) || !t.Has(ScopeSync) {
		return nil
	}
	var secret string
	if d.db.QueryRow(`SELECT sync_secret FROM token WHERE id = ?`, t.ID).Scan(&secret) != nil || secret == "" {
		return nil
	}
	return []xfer.Credential{{Secret: secret, Token: t.ID,
		CheckIn: strings.ContainsRune(t.Ceiling, 'i'), Publish: strings.ContainsRune(t.Ceiling, 'y')}}
}

// RunJobGrant is one job's declaration as a run's start records it:
// its environment, the letters its permissions: map to, its timeout in
// minutes (0: none stated).
type RunJobGrant struct {
	Environment string `json:"environment,omitempty"`
	Letters     string `json:"letters"`
	Timeout     int    `json:"timeout,omitempty"`
}

var runStartMigrations = []string{
	// RFC 0021 §runs: what a run token is minted from, kept beside the
	// run: the event, the person whose act started it, each job's
	// declaration.
	`ALTER TABLE ci_run ADD COLUMN event TEXT NOT NULL DEFAULT ''`,
	`ALTER TABLE ci_run ADD COLUMN starter TEXT NOT NULL DEFAULT ''`,
	`ALTER TABLE ci_run ADD COLUMN grants TEXT NOT NULL DEFAULT ''`,
	// What a run token may at most carry in an environment, and the
	// letter its starter must hold there.
	`ALTER TABLE ci_environment ADD COLUMN allowed TEXT NOT NULL DEFAULT '` + DefaultAllowed + `'`,
	`ALTER TABLE ci_environment ADD COLUMN starter TEXT NOT NULL DEFAULT ''`,
}

// RecordCIRunStart keeps a run's start.
func (d *DB) RecordCIRunStart(runID, event, starter string, jobs map[string]RunJobGrant) error {
	grants := ""
	if len(jobs) > 0 {
		b, err := json.Marshal(jobs)
		if err != nil {
			return err
		}
		grants = string(b)
	}
	_, err := d.w.Exec(`UPDATE ci_run SET event = ?, starter = ?, grants = ? WHERE run_id = ?`, event, starter, grants, runID)
	return err
}

// CIRunStart reads what RecordCIRunStart kept.
func (d *DB) CIRunStart(runID string) (event, starter string, jobs map[string]RunJobGrant, err error) {
	var grants string
	if err = d.db.QueryRow(`SELECT event, starter, grants FROM ci_run WHERE run_id = ?`, runID).Scan(&event, &starter, &grants); err != nil {
		return "", "", nil, err
	}
	if grants != "" {
		if err = json.Unmarshal([]byte(grants), &jobs); err != nil {
			return "", "", nil, err
		}
	}
	return event, starter, jobs, nil
}

// DefaultAllowed is what a run token may carry where nobody said: read
// and clone, and report checks.
const DefaultAllowed = "go8"

// EnvPolicy is an environment's rule for secrets and run tokens.
// Exists is false for an environment never made; the repository's own
// ("") then reads as its defaults.
type EnvPolicy struct {
	Exists   bool
	Branches []string // empty: protected branches only
	Allowed  string
	Starter  string // the letter a run's starter must hold; "" for none
}

// EnvironmentPolicy reads one environment's policy.
func (d *DB) EnvironmentPolicy(repo, name string) (EnvPolicy, error) {
	var p EnvPolicy
	var branches string
	err := d.db.QueryRow(`SELECT branches, allowed, starter FROM ci_environment WHERE repo = ? AND name = ?`, repo, name).
		Scan(&branches, &p.Allowed, &p.Starter)
	if errors.Is(err, sql.ErrNoRows) {
		return EnvPolicy{Allowed: DefaultAllowed}, nil
	}
	if err != nil {
		return EnvPolicy{}, err
	}
	p.Exists, p.Branches = true, strings.Fields(branches)
	return p, nil
}

// SetEnvironmentTokens sets what a run token may carry in an
// environment and the letter its starter must hold. The repository's
// own policy ("") is made on first use; a named environment must exist.
func (d *DB) SetEnvironmentTokens(repo, name, allowed, starter, actor string) error {
	for _, r := range allowed {
		if !strings.ContainsRune(capLetters, r) {
			return Refusal{fmt.Sprintf("allowed: %q is not a letter a run token may carry", r)}
		}
	}
	if len(starter) > 1 || (starter != "" && !strings.Contains(capLetters, starter)) {
		return Refusal{"the required starter letter is one letter, or none"}
	}
	allowed = inCapOrder(allowed)
	tx, err := d.w.Begin()
	if err != nil {
		return err
	}
	defer tx.Rollback()
	var wasAllowed, wasStarter string
	err = tx.QueryRow(`SELECT allowed, starter FROM ci_environment WHERE repo = ? AND name = ?`, repo, name).Scan(&wasAllowed, &wasStarter)
	switch {
	case errors.Is(err, sql.ErrNoRows) && name != "":
		return ErrNoEnvironment
	case errors.Is(err, sql.ErrNoRows):
		wasAllowed = DefaultAllowed
		if _, err := tx.Exec(`INSERT INTO ci_environment (repo, name, branches) VALUES (?, '', '')`, repo); err != nil {
			return err
		}
	case err != nil:
		return err
	}
	if wasAllowed == allowed && wasStarter == starter {
		return tx.Commit()
	}
	if _, err := tx.Exec(`UPDATE ci_environment SET allowed = ?, starter = ? WHERE repo = ? AND name = ?`, allowed, starter, repo, name); err != nil {
		return err
	}
	if wasAllowed != allowed {
		if err := logSetting(tx, repo, actor, "environment:"+name+":allowed", wasAllowed, allowed); err != nil {
			return err
		}
	}
	if wasStarter != starter {
		if err := logSetting(tx, repo, actor, "environment:"+name+":starter", wasStarter, starter); err != nil {
			return err
		}
	}
	return tx.Commit()
}

func inCapOrder(s string) string {
	var out strings.Builder
	for _, r := range capLetters {
		if strings.ContainsRune(s, r) {
			out.WriteRune(r)
		}
	}
	return out.String()
}

// RunTokenEnded says why a run-<id> login's token no longer answers,
// "" when it lives or names no run token: the refusal a job's leftover
// shell gets is that its run is over, not that it lacks a letter.
func (d *DB) RunTokenEnded(login string) string {
	if !reservedRunName(login) {
		return ""
	}
	t, ok := d.runToken(login)
	if !ok || t.Live(time.Now()) {
		return ""
	}
	if t.Revoked != "" {
		return fmt.Sprintf("workflow run %.12s is over; its token ended with it at %s", t.Run, t.Revoked)
	}
	return fmt.Sprintf("workflow run %.12s is over; its token expired at %s", t.Run, t.Expires)
}

// CredentialEnded is RunTokenEnded as the xfer front asks it.
func (d *DB) CredentialEnded(_, user string) string { return d.RunTokenEnded(user) }