Skip to content
quarry Artifact [8224b8931a]
Login

Artifact [8224b8931a]

Artifact 8224b8931abc2686f565e1d6fa339f5ffaffdc79b05a024cdeed75b9656cec5d:


// agentsetup.go is RFC 0030's agent setup: an account, its grants and
// its token written as ONE commit, so an agent never exists with a
// grant and no credential, or with a credential and no grant.
package oltp

import (
	"database/sql"
	"fmt"
	"strings"
	"time"
)

// The two kinds of setup (RFC 0030).
const (
	SetupPush = "push"
	SetupRead = "read"
)

// SetupReadCeiling is the ceiling of a read-only client's token: exactly
// what a visitor reads on a public repository, plus seeing CI (RFC 0030,
// decision 10).
const SetupReadCeiling = "gjorzh28"

// AgentSetup is one setup as the store runs it. Everything judged by the
// op (who may ask, the role, the kind) is decided before it gets here;
// what the store still judges, inside the transaction, is what only the
// rows can say: the name, the repositories, the approver's letters.
type AgentSetup struct {
	Name string
	// Approver is the person it runs for; Operator is who the agent
	// belongs to, the approver or "repo:NAME"; Scope narrows a person's
	// agent, "" or "repo:NAME".
	Approver, Operator, Scope string
	Repos                     []string
	// Caps is the string each repository's grant writes.
	Caps string
	// RefuseClip refuses the whole setup when the approver's letters
	// would cut anything from Caps, naming the letters missing.
	RefuseClip bool
	// PerPerson caps the approver's agents when one is created for a
	// person; negative is no cap.
	PerPerson int
	Token     TokenSpec
}

// SetupGrant is one repository an agent was put to work on: what it
// holds there and whether the approver's letters cut the string.
type SetupGrant struct {
	Repo    string
	Caps    string
	Clipped bool
}

// AgentSetupResult is what a committed setup made. Value is the token,
// which exists nowhere else.
type AgentSetupResult struct {
	Grants []SetupGrant
	Value  string
	Token  Token
}

// setupFault lets a test fail a setup at a named step, "account",
// "grant" or "mint", as a database error would.
var setupFault func(step string) error

func faultAt(step string) error {
	if setupFault == nil {
		return nil
	}
	return setupFault(step)
}

// SetupAgent makes a NEW agent, grants it on every repository, and
// mints its token, in one write transaction. A name that is already an
// account, in any case, or was one, is refused inside the transaction,
// so two setups racing for a name cannot both make it. A failure at any
// step rolls back all of it: no account row, no grant, no token, and no
// retired name.
func (d *DB) SetupAgent(s AgentSetup) (AgentSetupResult, error) {
	var res AgentSetupResult
	if s.Name == "" || len(s.Repos) == 0 {
		return res, Refusal{"a setup names an agent and a repository"}
	}
	err := d.guarded(func(tx *sql.Tx) error {
		if err := d.checkNewName(s.Name); err != nil {
			return err
		}
		var one int
		if tx.QueryRow(`SELECT 1 FROM account WHERE name = ? COLLATE NOCASE`, s.Name).Scan(&one) == nil {
			return Refusal{fmt.Sprintf("%s is already an account; setup makes new agents only", s.Name)}
		}
		if tx.QueryRow(`SELECT 1 FROM account_tombstone WHERE name = ? COLLATE NOCASE`, s.Name).Scan(&one) == nil {
			return tombstoneRefusal(s.Name)
		}
		if err := d.createSetupAgent(tx, s); err != nil {
			return err
		}
		if err := faultAt("account"); err != nil {
			return err
		}
		for _, repo := range s.Repos {
			g, err := d.setupGrant(tx, s, repo)
			if err != nil {
				return err
			}
			res.Grants = append(res.Grants, g)
			if err := faultAt("grant"); err != nil {
				return err
			}
		}
		value, tok, err := d.mintIn(tx, s.Name, s.Token)
		if err != nil {
			return err
		}
		res.Value, res.Token = value, tok
		return faultAt("mint")
	})
	if err != nil {
		return AgentSetupResult{}, err
	}
	return res, nil
}

// createSetupAgent inserts the new agent's row as NewDedicatedAgent or
// NewScopedAgent would, with one difference: a person's agent made here
// holds only what it is granted (inherit off).
func (d *DB) createSetupAgent(tx *sql.Tx, s AgentSetup) error {
	if repo, ok := strings.CutPrefix(s.Operator, "repo:"); ok {
		var one int
		if tx.QueryRow(`SELECT 1 FROM repo WHERE name = ?`, repo).Scan(&one) != nil {
			return Refusal{fmt.Sprintf("no repository called %s", repo)}
		}
		var n int
		if err := tx.QueryRow(`SELECT count(*) FROM account WHERE operator = ?`, s.Operator).Scan(&n); err != nil {
			return err
		}
		if n >= DedicatedAgentsPer {
			return Refusal{fmt.Sprintf("%s already has %d agents, the most this forge allows", s.Operator, DedicatedAgentsPer)}
		}
		_, err := tx.Exec(`INSERT INTO account (name, pwhash, admin, created, operator, answerable) VALUES (?, '', 0, ?, ?, ?)`,
			s.Name, now(), s.Operator, s.Approver)
		return uniqueAsTaken(err, s.Name)
	}
	if err := personOperator(tx, s.Name, s.Operator); err != nil {
		return err
	}
	if err := d.checkAgentScope(tx, s.Operator, s.Scope); err != nil {
		return err
	}
	if s.PerPerson >= 0 {
		var n int
		if err := tx.QueryRow(`SELECT count(*) FROM account WHERE operator = ?`, s.Operator).Scan(&n); err != nil {
			return err
		}
		if n >= s.PerPerson {
			return Refusal{fmt.Sprintf("%s already has %d agents, the most this forge allows", s.Operator, s.PerPerson)}
		}
	}
	_, err := tx.Exec(`INSERT INTO account (name, pwhash, admin, created, operator, inherit, agent_scope) VALUES (?, '', 0, ?, ?, 0, ?)`,
		s.Name, now(), s.Operator, s.Scope)
	return uniqueAsTaken(err, s.Name)
}

func uniqueAsTaken(err error, name string) error {
	if err != nil && strings.Contains(err.Error(), "UNIQUE") {
		return fmt.Errorf("oltp: account %q already exists", name)
	}
	return err
}

// setupGrant writes one grant through the one path agent.grant uses,
// clipped to the approver's letters, and refuses when the clip would
// take anything from what was asked and the setup says that matters.
func (d *DB) setupGrant(tx *sql.Tx, s AgentSetup, repo string) (SetupGrant, error) {
	stored, clipped, want, err := d.agentGrantIn(tx, s.Approver, s.Name, repo, s.Caps)
	if err != nil {
		return SetupGrant{}, err
	}
	if clipped && s.RefuseClip {
		kept := d.expandCeiling(repo, stored)
		var missing []string
		for _, l := range letterSet(want) {
			if !kept.HasAny(string(l)) {
				missing = append(missing, string(l))
			}
		}
		return SetupGrant{}, Refusal{fmt.Sprintf("%s does not hold %s on %s, which this role needs, so it cannot be given to an agent",
			s.Approver, strings.Join(missing, " "), repo)}
	}
	return SetupGrant{Repo: repo, Caps: stored, Clipped: clipped}, nil
}

// RetractSetup undoes a committed setup whose result could not be held
// for collection: the agent it made, its grants and its token go, and
// the name is not retired, since nothing was ever done under it. It
// refuses an account that does not hold the token the setup minted.
func (d *DB) RetractSetup(agent, tokenID string) error {
	return d.guarded(func(tx *sql.Tx) error {
		var one int
		if tx.QueryRow(`SELECT 1 FROM token WHERE id = ? AND account = ?`, tokenID, agent).Scan(&one) != nil {
			return Refusal{fmt.Sprintf("%s does not hold the token this setup minted", agent)}
		}
		return removeAccount(tx, agent)
	})
}

// SetupExpires is the token's fixed end for days, zero for none.
func SetupExpires(days int) time.Time {
	if days <= 0 {
		return time.Time{}
	}
	return time.Now().Add(time.Duration(days) * 24 * time.Hour)
}

// SetupNameFree is nil when a new account could take name: the checks
// every new name passes, without making one.
func (d *DB) SetupNameFree(name string) error { return d.checkNewName(name) }

// SetupShortfall is the letters of caps (a role's string) that approver
// does not hold on repo, spelled out; "" when they hold them all. A
// push agent cannot be given what its approver lacks.
func (d *DB) SetupShortfall(approver, repo, caps string) string {
	want := d.expandCeiling(repo, caps)
	held := d.EffectiveCaps(approver, repo)
	var missing []string
	for _, l := range letterSet(want) {
		if !held.HasAny(string(l)) {
			missing = append(missing, string(l))
		}
	}
	return strings.Join(missing, " ")
}