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"
lapseSetup = "agent-setup-token"
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
},
setupOp: func(repo string, in, out any) []lapseRef {
if o, ok := out.(agentSetupOut); ok && o.Token.ID != "" && o.Agent != "" {
return []lapseRef{{Kind: lapseSetup, Account: o.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, lapseSetup:
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 ref.kind() == lapseSetup {
// Decision 14: the agent a setup made stays; only its credential
// goes. The receipt says so.
note += "; the agent " + ref.Account + " stays"
}
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
}