package api
import (
"context"
"fmt"
"log"
"time"
"craftdesign.group/quarry/internal/ci"
"craftdesign.group/quarry/internal/ops"
)
// WorkerTokenEngine is the engine's worker-token surface: the credential
// a CI runner presents to claim work. *ci.Engine satisfies it.
type WorkerTokenEngine interface {
WorkerTokens() ([]ci.WorkerToken, error)
MintWorkerToken(label string, prefixes, groups []string) (ci.WorkerToken, error)
RevokeWorkerToken(id string) error
}
// SettingLogger writes one row of a repository's settings log.
type SettingLogger interface {
LogSetting(repo, actor, via, field, was, now string) error
}
type workerTokenRow struct {
ID string `json:"id"`
Label string `json:"label"`
Created string `json:"created"`
// Used is when the token last claimed work, when the engine says.
Used string `json:"used,omitempty"`
// Revoked is when it was revoked; empty for a live token.
Revoked string `json:"revoked,omitempty"`
}
func workerTokenRowOf(t ci.WorkerToken) workerTokenRow {
return workerTokenRow{ID: t.ID, Label: t.Label, Created: t.CreatedAt, Used: t.LastUsedAt, Revoked: t.RevokedAt}
}
type workerTokenNewIn struct {
Repo string `json:"repo" validate:"required,max=64"`
Label string `json:"label" validate:"max=100"`
}
type workerTokenNewOut struct {
// Value is the credential, in the clear here and nowhere else.
Value string `json:"value" secret:"once"`
Token workerTokenRow `json:"token"`
}
type workerTokenListIn struct {
Repo string `json:"repo" validate:"required,max=64"`
}
type workerTokenListOut struct {
Tokens []workerTokenRow `json:"tokens"`
}
type workerTokenRevokeIn struct {
Repo string `json:"repo" validate:"required,max=64"`
ID string `json:"id" validate:"required,max=200"`
}
// ownWorkerTokens is the repository's own tokens: scoped to its worker
// group and to nothing else. The one place that decides it, so the list,
// the revoke and the settings page cannot disagree.
func (d Deps) ownWorkerTokens(repo string) ([]ci.WorkerToken, error) {
all, err := d.WorkerTokens.WorkerTokens()
if err != nil {
return nil, err
}
var created time.Time
if d.RepoCreated != nil {
created = d.RepoCreated(repo)
}
var own []ci.WorkerToken
for _, t := range all {
if t.BelongsTo(repo, created) {
own = append(own, t)
}
}
return own, nil
}
func (d Deps) workerTokenList(repo string) (workerTokenListOut, error) {
own, err := d.ownWorkerTokens(repo)
if err != nil {
return workerTokenListOut{}, err
}
out := workerTokenListOut{Tokens: []workerTokenRow{}}
for _, t := range own {
out.Tokens = append(out.Tokens, workerTokenRowOf(t))
}
return out, nil
}
func registerWorkerTokens(r *ops.Registry, d Deps) error {
// No engine is no such door, for every one of the three.
noEngine := fmt.Errorf("no CI engine is configured here: %w", ops.ErrNotFound)
if err := ops.Register(r, ops.Op[workerTokenNewIn, workerTokenNewOut]{
Name: "ci.worker.token.new",
Ask: "Mint a worker token for {repo}",
Caps: "a",
Doc: "Mint a worker token for one repository: the credential a CI runner presents to claim that " +
"repository's work. It is scoped to the repository's own worker group and to nothing wider; a " +
"fleet-wide credential is not minted here. The value is returned once and is not recoverable " +
"afterward; `ci.worker.token.list` never shows it. Refused for a repository that is not served. " +
"An agent does not mint one, since the token never expires and is handed the repository's secrets: it is refused, " +
"and may ask the person who answers for it with `Quarry-Ask`, who then mints it as themselves. Recorded in the settings log, by whom, with the token's label and id and never its value.",
Kind: ops.Command,
Auth: ops.Token,
Route: "POST /api/v1/r/{repo}/ci/worker-tokens",
Run: func(ctx context.Context, in workerTokenNewIn) (workerTokenNewOut, error) {
if d.WorkerTokens == nil {
return workerTokenNewOut{}, noEngine
}
// A worker token never expires and is delivered every secret
// and run token of the repository's jobs, so an agent does not
// mint one: it asks the person who answers for it (Quarry-Ask),
// who then mints it as themselves.
if who := ops.User(ctx); who != "" {
if op := d.operatorOf(who); op != "" {
return workerTokenNewOut{}, fmt.Errorf("%s is an agent, operated by %s, and an agent does not mint a worker token, which never expires and is handed the repository's secrets; ask %s: %w",
who, op, op, ops.ErrForbidden)
}
}
// Only for a repository that is served. A delete stops serving
// first and purges the caps last, so without this a token minted
// in between outlived the repository and ran CI for the next one
// of its name (quarry#294).
if _, err := d.repoFor(ctx, in.Repo); err != nil {
return workerTokenNewOut{}, err
}
// The web calls this in process, where the router's gate does
// not run: ask the letter again here (#318).
if err := d.refuseWithout(ctx, in.Repo, "a", false); err != nil {
return workerTokenNewOut{}, err
}
// The group is the boundary, and a group alone claims nothing:
// the engine reads an empty task-type scope as "no task types at
// all", so the group's own name is the task-type scope too. The
// token claims `<group>` or `<group>.<anything>`, still only
// inside this repository's group.
group := ci.GroupFor(in.Repo)
tok, err := d.WorkerTokens.MintWorkerToken(in.Label, []string{group}, []string{group})
if err != nil {
return workerTokenNewOut{}, err
}
value := tok.Token
tok.Token = ""
d.logWorkerToken(in.Repo, ctx, "", "minted "+tokenWords(tok, in.Label))
return workerTokenNewOut{Value: value, Token: workerTokenRowOf(tok)}, nil
},
}); err != nil {
return err
}
if err := ops.Register(r, ops.Op[workerTokenListIn, workerTokenListOut]{
Name: "ci.worker.token.list",
Caps: "a",
Doc: "List this repository's worker tokens: id, label, created, last used when the engine reports it, " +
"and when revoked. Only tokens scoped to the repository's own worker group; never a value or " +
"anything that could be replayed.",
Kind: ops.Query,
Auth: ops.Token,
Route: "GET /api/v1/r/{repo}/ci/worker-tokens",
Run: func(ctx context.Context, in workerTokenListIn) (workerTokenListOut, error) {
if d.WorkerTokens == nil {
return workerTokenListOut{}, noEngine
}
if _, err := d.repoFor(ctx, in.Repo); err != nil {
return workerTokenListOut{}, err
}
if err := d.refuseWithout(ctx, in.Repo, "a", false); err != nil {
return workerTokenListOut{}, err
}
return d.workerTokenList(in.Repo)
},
}); err != nil {
return err
}
return ops.Register(r, ops.Op[workerTokenRevokeIn, workerTokenListOut]{
Name: "ci.worker.token.revoke",
Caps: "a",
Doc: "Revoke one worker token by its id, at once, returning the list that remains. Only a token that " +
"`ci.worker.token.list` shows for this repository can be revoked here; any other id, another " +
"repository's or a fleet-wide one included, is not found, so the route cannot be used to learn " +
"which tokens exist elsewhere.",
Kind: ops.Command,
Auth: ops.Token,
Route: "POST /api/v1/r/{repo}/ci/worker-tokens/revoke",
Run: func(ctx context.Context, in workerTokenRevokeIn) (workerTokenListOut, error) {
if d.WorkerTokens == nil {
return workerTokenListOut{}, noEngine
}
if _, err := d.repoFor(ctx, in.Repo); err != nil {
return workerTokenListOut{}, err
}
if err := d.refuseWithout(ctx, in.Repo, "a", false); err != nil {
return workerTokenListOut{}, err
}
own, err := d.ownWorkerTokens(in.Repo)
if err != nil {
return workerTokenListOut{}, err
}
var found *ci.WorkerToken
for i := range own {
if own[i].ID == in.ID {
found = &own[i]
break
}
}
if found == nil {
return workerTokenListOut{}, fmt.Errorf("no worker token %q on %s: %w", in.ID, in.Repo, ops.ErrNotFound)
}
// Already revoked: the answer is the list, with no second call
// to the engine and no second row in the log.
if found.RevokedAt == "" {
if err := d.WorkerTokens.RevokeWorkerToken(in.ID); err != nil {
return workerTokenListOut{}, err
}
d.logWorkerToken(in.Repo, ctx, tokenWords(*found, ""), "revoked")
}
return d.workerTokenList(in.Repo)
},
})
}
// tokenWords names a token for the settings log: its label and id,
// never its value.
func tokenWords(t ci.WorkerToken, label string) string {
if t.Label != "" {
label = t.Label
}
return fmt.Sprintf("%q (%s)", label, t.ID)
}
// logWorkerToken records a mint or a revoke in the repository's
// settings log, as ci.secret.set records a secret: who, and through
// which credential. A failure to write the row is logged, not returned:
// the token already exists or is already gone.
func (d Deps) logWorkerToken(repo string, ctx context.Context, was, now string) {
if d.SettingLog == nil {
return
}
// Run for an approved request, the actor is the approver and the row
// says whose request it was.
if ask, ok := signOffAskOf(ctx); ok {
now = fmt.Sprintf("%s (asked by %s, request %s)", now, ask.agent, ask.id)
}
if err := d.SettingLog.LogSetting(repo, ops.User(ctx), ops.Via(ctx), "worker-token", was, now); err != nil {
log.Printf("settings log: worker token on %s: %v", repo, err)
}
}