Skip to content
quarry Artifact [ddf4d7a3e9]
Login

Artifact [ddf4d7a3e9]

Artifact ddf4d7a3e94e5d55a303f265eac8b974f4ac09d69451a7f5474c51abd97a3f18:


package api

import (
	"context"
	"database/sql"
	"encoding/base64"
	"encoding/json"
	"errors"
	"fmt"
	"log"
	"strings"
	"time"

	"craftdesign.group/quarry/internal/oltp"
	"craftdesign.group/quarry/internal/ops"
)

// A sign-off's secret is what an approved call minted (a token's value)
// held for the asking agent to collect once. It is sealed at rest with
// the forge's secret key, the box CI secrets use, so it is never in the
// database or a backup in the clear; and it lapses: uncollected after
// signOffSecretLife it is cleared, and a credential it held is
// revoked.
const (
	signOffSecretLife = oltp.SignOffSecretLife
	sealedPrefix      = "sealed:"
)

// signOffAsk says, inside a run of an approved request, who asked and
// which request: the settings log names them beside the approver.
type signOffAsk struct{ agent, id string }

type signOffAskKey struct{}

func withSignOffAsk(ctx context.Context, s SignOffRequest) context.Context {
	return context.WithValue(ctx, signOffAskKey{}, signOffAsk{agent: s.Agent, id: s.ID})
}

func signOffAskOf(ctx context.Context) (signOffAsk, bool) {
	a, ok := ctx.Value(signOffAskKey{}).(signOffAsk)
	return a, ok
}

func (d Deps) sealSignOffSecret(id, secret string) (string, error) {
	if secret == "" {
		return "", nil
	}
	if !d.SecretBox.Ready() {
		return "", fmt.Errorf("this forge has no secret key, so it cannot hold a credential for collection: %w", ops.ErrUnavailable)
	}
	sealed, err := d.SecretBox.Seal("signoff", id, "secret", secret)
	if err != nil {
		return "", err
	}
	return sealedPrefix + base64.StdEncoding.EncodeToString(sealed), nil
}

// openSignOffSecret reads what sealSignOffSecret stored. A row written
// before secrets were sealed holds plain JSON; it is handed over once
// like any other and the sweep clears the rest after the same limit.
func (d Deps) openSignOffSecret(id, stored string) (string, error) {
	raw, ok := strings.CutPrefix(stored, sealedPrefix)
	if !ok {
		return stored, nil
	}
	sealed, err := base64.StdEncoding.DecodeString(raw)
	if err != nil {
		return "", err
	}
	return d.SecretBox.Open("signoff", id, "secret", sealed)
}

// The kinds of credential a lapse can revoke.
const (
	lapseWorker   = "worker-token"
	lapseAgent    = "agent-token"
	lapseReissue  = "agent-token-reissue"
	lapseReporter = "reporter-token"
)

// lapseRef names one credential an approved call minted, not secret:
// the kind and the identifiers its own revoke call takes, never a
// value. A row written before kinds existed has none and is a worker
// token.
type lapseRef struct {
	Kind string `json:"kind,omitempty"`
	// Repo is the repository of a worker or reporter token.
	Repo string `json:"repo,omitempty"`
	// Account is the agent an agent token belongs to.
	Account string `json:"account,omitempty"`
	// Token is the token's id; for a reporter token, its stored hash.
	Token string `json:"token"`
}

func (r lapseRef) kind() string {
	if r.Kind == "" {
		return lapseWorker
	}
	return r.Kind
}

// lapseOps are the ops whose answer's credential a lapse can revoke, each
// with how it names what it minted. The one table: lapseRefOf reads it,
// and so does the question "can this op's value be held for pickup",
// since a value nothing can revoke must not be held where nobody may
// collect it.
var lapseOps = map[string]func(repo string, in, out any) []lapseRef{
	"ci.worker.token.new": func(repo string, in, out any) []lapseRef {
		if o, ok := out.(workerTokenNewOut); ok && o.Token.ID != "" {
			return []lapseRef{{Kind: lapseWorker, Repo: repo, Token: o.Token.ID}}
		}
		return nil
	},
	"agent.token.new": func(repo string, in, out any) []lapseRef {
		agent := inputField(in, "Agent")
		if o, ok := out.(agentTokenNewOut); ok && o.Token.ID != "" && agent != "" {
			return []lapseRef{{Kind: lapseAgent, Account: agent, Token: o.Token.ID}}
		}
		return nil
	},
	"agent.token.reissue": func(repo string, in, out any) []lapseRef {
		agent := inputField(in, "Name")
		if o, ok := out.(agentTokenReissueOut); ok && o.ID != "" && agent != "" {
			return []lapseRef{{Kind: lapseReissue, Account: agent, Token: o.ID}}
		}
		return nil
	},
	"ci.token.new": func(repo string, in, out any) []lapseRef {
		// The op mints under its own input's repository.
		repo = inputField(in, "Repo")
		if o, ok := out.(ciTokenNewOut); ok && o.Token != "" && repo != "" {
			return []lapseRef{{Kind: lapseReporter, Repo: repo, Token: oltp.ReporterTokenHash(o.Token)}}
		}
		return nil
	},
}

// lapseRefOf is the credential an op's answer minted, when it is one the
// sweep knows how to revoke. in is the input the op ran with.
func lapseRefOf(op, repo string, in, out any) []lapseRef {
	if f := lapseOps[op]; f != nil {
		return f(repo, in, out)
	}
	return nil
}

// parkable reports whether the credential op mints can be revoked by a
// lapse, and so held for pickup: the ops lapseRefOf can name a reference
// for.
func parkable(op string) bool { return lapseOps[op] != nil }

// holdSignOffSecret records what an approved request answered: the
// credentials it minted (not secret), then the answer and the sealed
// secret. A forge that cannot seal revokes what was minted rather than
// hold it in the clear.
func (d Deps) holdSignOffSecret(ctx context.Context, s SignOffRequest, result, secret string, refs []lapseRef) error {
	sealed, err := d.sealSignOffSecret(s.ID, secret)
	if err != nil {
		for _, ref := range refs {
			if rerr := d.revokeLapsed(ref, s, "no secret key to hold it"); rerr != nil {
				log.Printf("signoff %s: %v", s.ID, rerr)
			}
		}
		return err
	}
	if len(refs) > 0 {
		raw, _ := json.Marshal(refs)
		if err := d.SignOffs.SetSignOffRefs(s.ID, string(raw)); err != nil {
			return err
		}
	}
	return d.SignOffs.FinishSignOff(s.ID, result, sealed)
}

// LapseSignOffSecrets clears the secrets nobody collected within their
// limit, revoking the credentials they held and logging each. It is
// the periodic upkeep's call; a second pass finds nothing. The order is
// claim, revoke, log, then clear: the claim ends any collect for good,
// so a collected secret is never revoked and a lapsed one never
// collected. A request whose revoke fails stays lapsing, references
// kept, for the next pass; one revoked but not cleared is revoked again
// quietly, with no second log row. The count is the requests finished.
func (d Deps) LapseSignOffSecrets(now time.Time) (int, error) {
	if d.SignOffs == nil {
		return 0, nil
	}
	cutoff := oltp.SignOffSecretCutoff(now)
	due, err := d.SignOffs.DueSignOffLapses(cutoff)
	if err != nil {
		return 0, err
	}
	cleared, held := 0, 0
	var first error
	note := func(err error) {
		held++
		if first == nil {
			first = err
		}
	}
	for _, l := range due {
		// Claim first: from here no collect can succeed, and if one
		// committed first there is nothing to revoke.
		raw, ok, err := d.SignOffs.ClaimSignOffLapse(l.ID, cutoff)
		if err != nil {
			note(fmt.Errorf("signoff %s: claiming: %w", l.ID, err))
			continue
		}
		if !ok {
			continue
		}
		var refs []lapseRef
		if raw != "" {
			if jerr := json.Unmarshal([]byte(raw), &refs); jerr != nil {
				// What to revoke cannot be read: keep the row
				// lapsing, so nothing is cleared that was not
				// revoked, and say so every sweep until it is
				// looked at.
				note(fmt.Errorf("signoff %s: lapse references unreadable, row kept: %w", l.ID, jerr))
				continue
			}
		}
		failed := false
		for _, ref := range refs {
			if err := d.revokeLapsed(ref, SignOffRequest{ID: l.ID, Agent: l.Agent}, "lapsed uncollected"); err != nil {
				failed = true
				note(fmt.Errorf("signoff %s: %w", l.ID, err))
			}
		}
		if failed {
			continue
		}
		if err := d.SignOffs.ClearSignOffLapse(l.ID); err != nil {
			note(fmt.Errorf("signoff %s: clearing: %w", l.ID, err))
			continue
		}
		cleared++
	}
	if first != nil {
		return cleared, fmt.Errorf("%d lapse steps failed, left for the next sweep: %w", held, first)
	}
	return cleared, nil
}

// revokeLapsed revokes one credential its secret never reached anybody
// for, and says so where that kind of credential's changes are logged.
// One already revoked or gone is quiet: nothing changed, nothing logged.
func (d Deps) revokeLapsed(ref lapseRef, s SignOffRequest, why string) error {
	note := fmt.Sprintf("revoked: %s (request %s, asked by %s)", why, s.ID, s.Agent)
	switch ref.kind() {
	case lapseAgent, lapseReissue:
		return d.revokeLapsedAgentToken(ref, s, note)
	case lapseReporter:
		return d.revokeLapsedReporterToken(ref, s, note)
	default:
		return d.revokeLapsedWorkerToken(ref, s, note)
	}
}

func (d Deps) revokeLapsedWorkerToken(ref lapseRef, s SignOffRequest, note string) error {
	if d.WorkerTokens == nil || ref.Repo == "" {
		return nil
	}
	own, err := d.ownWorkerTokens(ref.Repo)
	if err != nil {
		return fmt.Errorf("listing tokens to revoke: %w", err)
	}
	for _, t := range own {
		if t.ID != ref.Token || t.RevokedAt != "" {
			continue
		}
		if err := d.WorkerTokens.RevokeWorkerToken(t.ID); err != nil {
			return fmt.Errorf("revoking %s: %w", t.ID, err)
		}
		if d.SettingLog != nil {
			d.SettingLog.LogSetting(ref.Repo, "forge", "", "worker-token", tokenWords(t, ""), note)
		}
	}
	return nil
}

func (d Deps) revokeLapsedReporterToken(ref lapseRef, s SignOffRequest, note string) error {
	if d.External == nil || ref.Repo == "" || ref.Token == "" {
		return nil
	}
	held, err := d.External.ReporterTokens(ref.Repo)
	if err != nil {
		return fmt.Errorf("listing reporter tokens to revoke: %w", err)
	}
	for _, t := range held {
		if t.Hash != ref.Token {
			continue
		}
		if err := d.External.RevokeReporterToken(ref.Repo, t.Hash); err != nil {
			return fmt.Errorf("revoking a reporter token: %w", err)
		}
		if d.SettingLog != nil {
			d.SettingLog.LogSetting(ref.Repo, "forge", "", "reporter-token",
				fmt.Sprintf("%q (%.12s)", t.Label, t.Hash), note)
		}
	}
	return nil
}

// revokeLapsedAgentToken revokes the one token an agent-token ask
// minted, through the store call agent.token.revoke uses, and leaves a
// receipt on the agent's account: the account-level journal is the
// forge-act table, where an act on an account is kept with no
// repository. A reissue's lapse revokes only what it minted, never
// restores the tokens the reissue revoked, so the receipt says when the
// agent is left holding none.
func (d Deps) revokeLapsedAgentToken(ref lapseRef, s SignOffRequest, note string) error {
	if ref.Account == "" || ref.Token == "" {
		return nil
	}
	as, err := d.agents()
	if err != nil {
		return fmt.Errorf("revoking an agent token: %w", err)
	}
	if err := as.RevokeToken(ref.Account, ref.Token); err != nil {
		if errors.Is(err, sql.ErrNoRows) {
			return nil
		}
		return fmt.Errorf("revoking an agent token: %w", err)
	}
	if ref.kind() == lapseReissue {
		if toks, err := as.Tokens(ref.Account); err == nil {
			live := false
			for _, t := range toks {
				live = live || t.Live(time.Now())
			}
			if !live {
				note += "; the agent now holds no live token"
			}
		}
	}
	if d.Acts != nil {
		if err := d.Acts.RecordForgeAct("forge ("+note+")", "agent.token.revoke", "", ref.Account); err != nil {
			logForgeActFailure("agent.token.revoke", "", err)
		}
	}
	return nil
}