// 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()
}