package oltp
import (
"crypto/rand"
"crypto/sha256"
"encoding/hex"
"errors"
"strings"
"time"
)
// External CI (RFC 0009 §external-ci): checks quarry did not run, and
// the reporter tokens that may write them. A token authenticates; it
// holds no capability letters and reaches exactly one op — a
// compromised one can lie about a check and do nothing else.
const extCISchema = `
CREATE TABLE IF NOT EXISTS ci_reporter_token (
repo TEXT NOT NULL,
hash TEXT NOT NULL, -- sha256 of the token, hex
label TEXT NOT NULL DEFAULT '',
created TEXT NOT NULL,
PRIMARY KEY (repo, hash)
);
CREATE TABLE IF NOT EXISTS external_check (
repo TEXT NOT NULL,
subject TEXT NOT NULL, -- check-in hash, or pair:<branch>:<target>
context TEXT NOT NULL, -- the check's name
state TEXT NOT NULL, -- pending | success | failure | error
url TEXT NOT NULL DEFAULT '',
summary TEXT NOT NULL DEFAULT '',
updated TEXT NOT NULL,
PRIMARY KEY (repo, subject, context)
);`
// extCIMigrations widen older stores. A token that has never been
// used and one that reported an hour ago look identical in a list of
// hashes, and "which of these can I revoke?" is the question an
// administrator actually has.
var extCIMigrations = []string{
`ALTER TABLE ci_reporter_token ADD COLUMN used TEXT NOT NULL DEFAULT ''`,
}
// ReporterToken is one credential, as the settings page lists it. The
// token itself is shown once at mint and never stored.
type ReporterToken struct {
Hash string
Label string
Created string
// Used is when this token last reported, empty for one that never
// has. Written on each accepted report, not on each presentation:
// a wrong token is not this token being used.
Used string
}
// MintReporterToken creates a credential for one repository and
// returns the token — the only time it exists in the clear.
func (d *DB) MintReporterToken(repo, label string) (string, error) {
raw := make([]byte, 24)
if _, err := rand.Read(raw); err != nil {
return "", err
}
token := "qcr_" + hex.EncodeToString(raw)
_, err := d.db.Exec(`INSERT INTO ci_reporter_token (repo, hash, label, created)
VALUES (?, ?, ?, ?)`,
repo, tokenHash(token), strings.TrimSpace(label), time.Now().UTC().Format(time.RFC3339))
if err != nil {
return "", err
}
return token, nil
}
// ReporterTokens lists a repository's credentials — hashes and labels,
// never tokens.
func (d *DB) ReporterTokens(repo string) ([]ReporterToken, error) {
rows, err := d.db.Query(`SELECT hash, label, created, used FROM ci_reporter_token
WHERE repo = ? ORDER BY created DESC`, repo)
if err != nil {
return nil, err
}
defer rows.Close()
var out []ReporterToken
for rows.Next() {
var t ReporterToken
if err := rows.Scan(&t.Hash, &t.Label, &t.Created, &t.Used); err != nil {
return nil, err
}
out = append(out, t)
}
return out, rows.Err()
}
// RevokeReporterToken removes one credential by its stored hash.
func (d *DB) RevokeReporterToken(repo, hash string) error {
_, err := d.db.Exec(`DELETE FROM ci_reporter_token WHERE repo = ? AND hash = ?`, repo, hash)
return err
}
// CheckReporterToken says whether a presented token may report here,
// and remembers that it did. The write is best-effort: a store that
// cannot record the use must not refuse the report over it.
func (d *DB) CheckReporterToken(repo, token string) bool {
hash := tokenHash(token)
var n int
if err := d.db.QueryRow(`SELECT count(*) FROM ci_reporter_token WHERE repo = ? AND hash = ?`,
repo, hash).Scan(&n); err != nil {
return false
}
if n == 0 {
return false
}
_, _ = d.db.Exec(`UPDATE ci_reporter_token SET used = ? WHERE repo = ? AND hash = ?`,
time.Now().UTC().Format(time.RFC3339), repo, hash)
return true
}
func tokenHash(token string) string {
sum := sha256.Sum256([]byte(token))
return hex.EncodeToString(sum[:])
}
// ExternalCheck is one reported state, the commit-status contract
// quarry-shaped.
type ExternalCheck struct {
Subject string
Context string
State string
URL string
Summary string
Updated string
}
var checkStates = map[string]bool{"pending": true, "success": true, "failure": true, "error": true}
// UpsertExternalCheck records a state; the newest report per
// (subject, context) wins, which is the whole contract.
func (d *DB) UpsertExternalCheck(repo string, c ExternalCheck) error {
if !checkStates[c.State] {
return errors.New("oltp: check state must be pending, success, failure or error")
}
if strings.TrimSpace(c.Context) == "" || strings.TrimSpace(c.Subject) == "" {
return errors.New("oltp: a check needs a subject and a context")
}
_, err := d.db.Exec(`INSERT INTO external_check (repo, subject, context, state, url, summary, updated)
VALUES (?, ?, ?, ?, ?, ?, ?)
ON CONFLICT (repo, subject, context) DO UPDATE SET
state = excluded.state, url = excluded.url,
summary = excluded.summary, updated = excluded.updated`,
repo, c.Subject, c.Context, c.State, c.URL, c.Summary,
time.Now().UTC().Format(time.RFC3339))
return err
}
// CheckNamesSeen is every check name reported on this repository since
// a moment, for the settings page to offer where a rule names its
// required checks.
func (d *DB) CheckNamesSeen(repo string, since time.Time) ([]string, error) {
// A check has two sources: an external reporter's status, and the
// forge's own CI, whose workflow name is what a rule requires. The
// settings page warned that nothing had reported "quarry" while
// quarry's own runner reported it every hour, because only the
// first source was read.
cut := since.UTC().Format(time.RFC3339)
rows, err := d.db.Query(`SELECT context FROM external_check WHERE repo = ? AND updated >= ?
UNION SELECT workflow FROM ci_run WHERE repo = ? AND started >= ?
ORDER BY 1`, repo, cut, repo, cut)
if err != nil {
return nil, err
}
defer rows.Close()
var out []string
for rows.Next() {
var n string
if err := rows.Scan(&n); err != nil {
return nil, err
}
out = append(out, n)
}
return out, rows.Err()
}
// ExternalChecks lists what was reported for one subject.
func (d *DB) ExternalChecks(repo, subject string) ([]ExternalCheck, error) {
rows, err := d.db.Query(`SELECT subject, context, state, url, summary, updated
FROM external_check WHERE repo = ? AND subject = ? ORDER BY context`, repo, subject)
if err != nil {
return nil, err
}
defer rows.Close()
var out []ExternalCheck
for rows.Next() {
var c ExternalCheck
if err := rows.Scan(&c.Subject, &c.Context, &c.State, &c.URL, &c.Summary, &c.Updated); err != nil {
return nil, err
}
out = append(out, c)
}
return out, rows.Err()
}