// 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, " ")
}