Skip to content
quarry Artifact [37507b229a]
Login

Artifact [37507b229a]

Artifact 37507b229a86d07568f78ee7a676e05eb9878e1df41e076c2f07db0f8e4badbf:


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