Skip to content
quarry Artifact [096be171d7]
Login

Artifact [096be171d7]

Artifact 096be171d713020328ae532beeb79dd0b0eb326d13fe8768ea0dfb44b24d8199:


// agent.go — which accounts are machine-operated, and who answers for
// them.
//
// A forge that cannot say this has a record it cannot read back. On
// this one, in a single day: eleven tickets filed by an automated
// campaign under a person's name, permanent in every clone, indexed
// forever as that person's words; a sync credential minted in the
// operator's name by a machine; and two agent sessions writing all day
// with nothing on any page to say either was a machine.
//
// The mark is on the ACCOUNT, not the write. A write-level mark is more
// honest and much larger — it would have to reach the artifact format —
// and the account-level one is what makes the write-level one worth
// building later: you cannot ask "which of this account's writes were
// machine-made" until you can ask "is this account a machine".
//
// RFC 0021 makes the mark worth more: an agent is an account with no
// password (an empty pwhash, an operator named), holds tokens instead, and
// never holds more than its operator (caps.go's intersection). What
// follows is the agent's lifecycle: creation, the operator's grant, the
// suspension that follows its operator's disabling or deletion, and the
// deed it may never hold (SetOwner, repo.go).
package oltp

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

// forgeOperator is the reserved operator of a forge agent (RFC 0021,
// Forge agents): one the forge itself runs, with no operator ceiling.
// Everything here that cuts an agent down to its operator leaves a
// forge agent alone; the rest of its rules are its own section's.
const forgeOperator = "forge"

// agentSuspendedAfter is how long a suspended agent waits for a new
// operator before it is deleted as its operator was (RFC 0021,
// Lifecycle).
const agentSuspendedAfter = 30 * 24 * time.Hour

// personsAgent reports whether a is a person's agent: marked, and not
// the forge's own.
func personsAgent(a Account) bool {
	return a.Operator != "" && a.Operator != forgeOperator
}

// IsAgent reports whether name is a person's agent (operator set, and
// not the forge's). False for a person, a forge agent and an unknown
// name alike.
func (d *DB) IsAgent(name string) bool {
	a, ok := d.AccountByName(name)
	return ok && personsAgent(a)
}

// Operator names the person answerable for a machine-operated account.
// Empty means the account is a person's own.
func (d *DB) Operator(name string) string {
	var op string
	if err := d.db.QueryRow(`SELECT operator FROM account WHERE name = ?`, name).Scan(&op); err != nil {
		return ""
	}
	return op
}

// SetOperator marks an account as machine-operated and records who runs
// it; an empty operator clears the mark.
//
// The GATE is the caller's, not this function's — see api's account.agent
// for the asymmetry: declaring yourself an agent is always allowed and
// undeclaring is a site admin's call. Stated there rather than here
// because it is a policy about callers, and this is a table.
//
// Marking clears the password, the site-admin flag and the sessions
// in the same transaction (RFC 0021, Creating an agent): a token is
// the one credential an agent has, an account that kept its password
// while marked would be an agent with the credential an agent must not
// have, and a person's agent is never a site admin. Marking a
// suspended agent with a new operator lifts the suspension, which is
// how a site admin reassigns one (Lifecycle).
//
// Marking refuses, naming them, an account that operates agents (a
// chain would put the agents past their operator's operator), holds a
// repository's deed, or is a team's only owner: each is a thing an
// agent may not hold, and moving it silently would hide the change.
func (d *DB) SetOperator(name, operator string) error {
	if name == "" {
		return errors.New("oltp: empty account name")
	}
	if operator != "" && !validLogin(operator) {
		return fmt.Errorf("oltp: operator %q is not an account name", operator)
	}
	// An agent reassigned to itself names nobody who can be asked, and
	// an agent answering for an agent names somebody who cannot be
	// either: the operator is a person.
	if operator != "" && operator == name {
		return Refusal{fmt.Sprintf("%s cannot be its own operator; name the person who runs it", name)}
	}
	// The forge's own agents have an answerable admin where a person's
	// have an operator, and keep what a person's agent may not hold:
	// SetForgeAgent is their path, and nothing else writes forge here.
	if operator == forgeOperator {
		return Refusal{fmt.Sprintf("%s would be a forge agent, which names a site admin answerable for it, not an operator", name)}
	}
	return d.guarded(func(tx *sql.Tx) error {
		if operator != "" {
			if err := personOperator(tx, name, operator); err != nil {
				return err
			}
			if err := refuseAgentHoldings(tx, name); err != nil {
				return err
			}
		}
		// A forge agent re-marked as a person's, or as a person, no
		// longer has an admin to answer for it.
		res, err := tx.Exec(`UPDATE account SET operator = ?, answerable = '' WHERE name = ?`, operator, name)
		if err != nil {
			return err
		}
		if n, _ := res.RowsAffected(); n == 0 {
			return fmt.Errorf("oltp: no such account %q", name)
		}
		if operator == "" {
			// A person again: whatever suspension it carried as an agent
			// ends with the mark. Its password is still gone; the caller
			// mints the invite that lets somebody set one.
			_, err := tx.Exec(`UPDATE account SET suspended = '', disabled = '' WHERE name = ? AND suspended <> ''`, name)
			return err
		}
		if _, err := tx.Exec(`UPDATE account SET pwhash = '', legacy_pw = '', legacy_code = '', admin = 0,
			suspended = '', disabled = CASE WHEN suspended <> '' THEN '' ELSE disabled END
			WHERE name = ?`, name); err != nil {
			return err
		}
		return endAccountSessions(tx, name)
	})
}

// personOperator is the check every operator name passes: an account,
// and a person. An agent answering for an agent, a person's or the
// forge's, names somebody who cannot be asked, and would put the agent
// past its operator's operator.
func personOperator(tx *sql.Tx, name, operator string) error {
	op, ok := accountByName(tx, operator)
	if !ok {
		// An operator who is not an account is a name nobody can
		// be asked about, which is the thing this exists to end.
		return fmt.Errorf("oltp: no account named %q to answer for %q", operator, name)
	}
	if op.Operator != "" {
		return Refusal{fmt.Sprintf("%s is itself an agent; name the person who runs %s", operator, name)}
	}
	return nil
}

// agentHoldings is what a person's agent must not hold: agents of its
// own, a deed, or a team it alone owns. A forge agent may hold the
// last two (RFC 0021, Forge agents) and never the first.
var agentHoldings = []struct{ query, what string }{
	{`SELECT name FROM account WHERE operator = ? ORDER BY name`, "operates agents"},
	{`SELECT name FROM repo WHERE owner = ? ORDER BY name`, "holds the deed to"},
	{`SELECT m.team FROM team_member m WHERE m.account = ? AND m.owner = 1
		AND (SELECT count(*) FROM team_member o WHERE o.team = m.team AND o.owner = 1) = 1 ORDER BY m.team`, "is the only owner of"},
}

// refuseAgentHoldings is what an account must not hold to become a
// person's agent, named in the refusal so the site admin can move
// them in the open first.
func refuseAgentHoldings(tx *sql.Tx, name string) error {
	return refuseHoldings(tx, name, agentHoldings)
}

func refuseHoldings(tx *sql.Tx, name string, holdings []struct{ query, what string }) error {
	for _, q := range holdings {
		rows, err := tx.Query(q.query, name)
		if err != nil {
			return err
		}
		var held []string
		for rows.Next() {
			var h string
			if err := rows.Scan(&h); err != nil {
				rows.Close()
				return err
			}
			held = append(held, h)
		}
		rows.Close()
		if len(held) > 0 {
			return Refusal{fmt.Sprintf("%s %s %s, which an agent cannot; reassign them first", name, q.what, strings.Join(held, ", "))}
		}
	}
	return nil
}

// NewAgent creates a person's agent: an account with no password whose
// operator is set from the start (RFC 0021, Creating an agent). The
// name comes from the account namespace under every rule AddAccount
// applies. Never a site admin: the flag is a person's. The operator is
// a person, never an agent and never the forge: the forge's own agents
// are made by a site admin's path of their own (Forge agents).
//
// perPerson is the forge's cap on the operator's agents, counted and
// written under one lock so two creations racing cannot both pass it;
// negative is no cap (a site admin's creation).
func (d *DB) NewAgent(name, operator string, perPerson int) error {
	if err := d.checkNewName(name); err != nil {
		return err
	}
	if operator == "" {
		return errors.New("oltp: an agent needs an operator")
	}
	if operator == name {
		return Refusal{fmt.Sprintf("%s cannot be its own operator; name the person who runs it", name)}
	}
	if operator == forgeOperator {
		return Refusal{"a forge agent is created by a site admin's own path, not here"}
	}
	return d.guarded(func(tx *sql.Tx) error {
		if err := personOperator(tx, name, operator); err != nil {
			return err
		}
		if perPerson >= 0 {
			var n int
			if err := tx.QueryRow(`SELECT count(*) FROM account WHERE operator = ?`, operator).Scan(&n); err != nil {
				return err
			}
			if n >= perPerson {
				return Refusal{fmt.Sprintf("%s already has %d agents, the most this forge allows", operator, perPerson)}
			}
		}
		_, err := tx.Exec(`INSERT INTO account (name, pwhash, admin, created, operator) VALUES (?, '', 0, ?, ?)`,
			name, now(), operator)
		if err != nil && strings.Contains(err.Error(), "UNIQUE") {
			return fmt.Errorf("oltp: account %q already exists", name)
		}
		return err
	})
}

// IsForgeAgent reports whether name is one of the forge's own agents
// (RFC 0021, Forge agents).
func (d *DB) IsForgeAgent(name string) bool {
	a, ok := d.AccountByName(name)
	return ok && a.Operator == forgeOperator
}

// answerableAdmin is the check a forge agent's answerable name passes:
// a site admin, open. The one who answers must be able to.
func answerableAdmin(tx *sql.Tx, name, admin string) error {
	if admin == "" {
		return Refusal{fmt.Sprintf("a forge agent names a site admin answerable for it; %s names nobody", name)}
	}
	a, ok := accountByName(tx, admin)
	if !ok {
		return fmt.Errorf("oltp: no account named %q to answer for %q", admin, name)
	}
	if !a.Admin || a.Operator != "" || a.Disabled != "" {
		return Refusal{fmt.Sprintf("%s is not an open site admin, so cannot answer for %s", admin, name)}
	}
	return nil
}

// NewForgeAgent creates one of the forge's own agents (RFC 0021, Forge
// agents): no password, operator forge, a site admin answerable. The
// caller's gate is a site admin's and nobody else's; this is the row.
// No cap: the forge is not a person.
func (d *DB) NewForgeAgent(name, answerable string) error {
	if err := d.checkNewName(name); err != nil {
		return err
	}
	return d.guarded(func(tx *sql.Tx) error {
		if err := answerableAdmin(tx, name, answerable); err != nil {
			return err
		}
		_, err := tx.Exec(`INSERT INTO account (name, pwhash, admin, created, operator, answerable) VALUES (?, '', 0, ?, ?, ?)`,
			name, now(), forgeOperator, answerable)
		if err != nil && strings.Contains(err.Error(), "UNIQUE") {
			return fmt.Errorf("oltp: account %q already exists", name)
		}
		return err
	})
}

// SetForgeAgent re-marks an existing account as a forge agent, which
// is how zeitgeist migrates (RFC 0021, Migration): it keeps its deeds,
// its teams, its grants, its flag and its sessions, since a forge agent
// may hold every one of them, and names the site admin answerable.
// Only agents of its own are refused, as for any agent: the operator
// is a person. A suspension it carried as a person's agent ends with
// the new mark.
//
// The password is NOT cleared here. Retiring it is the cutover's own
// step (Build order, rung 7), taken once the agent's tokens are in
// place; until then the account accepts both, which is the window in
// which running sessions move.
func (d *DB) SetForgeAgent(name, answerable string) error {
	if name == "" {
		return errors.New("oltp: empty account name")
	}
	return d.guarded(func(tx *sql.Tx) error {
		if _, ok := accountByName(tx, name); !ok {
			return fmt.Errorf("oltp: no such account %q", name)
		}
		if err := answerableAdmin(tx, name, answerable); err != nil {
			return err
		}
		if err := refuseHoldings(tx, name, agentHoldings[:1]); err != nil {
			return err
		}
		_, err := tx.Exec(`UPDATE account SET operator = ?, answerable = ?,
			suspended = '', disabled = CASE WHEN suspended <> '' THEN '' ELSE disabled END
			WHERE name = ?`, forgeOperator, answerable, name)
		return err
	})
}

// SetAnswerable names the site admin who answers for a forge agent,
// replacing whoever did. Any site admin may: the answerable admin is a
// name to ask, not an exclusive key.
func (d *DB) SetAnswerable(name, admin string) error {
	return d.guarded(func(tx *sql.Tx) error {
		a, ok := accountByName(tx, name)
		if !ok {
			return fmt.Errorf("oltp: no such account %q", name)
		}
		if a.Operator != forgeOperator {
			return Refusal{fmt.Sprintf("%s is not a forge agent; only a forge agent names an answerable admin", name)}
		}
		if err := answerableAdmin(tx, name, admin); err != nil {
			return err
		}
		_, err := tx.Exec(`UPDATE account SET answerable = ? WHERE name = ?`, admin, name)
		return err
	})
}

// Answerable names the site admin who answers for name NOW: the stored
// name when that account still exists, holds the flag and is open, and
// "" (nobody) otherwise. Disabling, deleting or unflagging the admin
// does not touch the agent (RFC 0021, Forge agents); it leaves it
// answering to nobody, which is the one state a page complains about.
func (d *DB) Answerable(name string) string {
	var admin string
	if err := d.db.QueryRow(`SELECT o.name FROM account a JOIN account o ON o.name = a.answerable
		WHERE a.name = ? AND a.operator = ? AND o.admin = 1 AND o.disabled = '' AND o.operator = ''`,
		name, forgeOperator).Scan(&admin); err != nil {
		return ""
	}
	return admin
}

// Agents lists operator's agents, name-ordered. A forge agent's
// operator is forge, so Agents(forge) is the fleet.
func (d *DB) Agents(operator string) ([]Account, error) {
	if operator == "" {
		return nil, nil
	}
	rows, err := d.db.Query(`SELECT `+accountColumns+` FROM account WHERE operator = ? ORDER BY name`, operator)
	if err != nil {
		return nil, err
	}
	defer rows.Close()
	var out []Account
	for rows.Next() {
		var a Account
		var admin int
		if err := rows.Scan(&a.Name, &admin, &a.Created, &a.Email, &a.EmailPending, &a.Disabled, &a.LastLogin, &a.LastSeen,
			&a.Operator, &a.Suspended, &a.Answerable, &a.Pending); err != nil {
			return nil, err
		}
		a.Admin = admin != 0
		out = append(out, a)
	}
	return out, rows.Err()
}

// AgentCount is how many agents operator has, for the per-person cap.
func (d *DB) AgentCount(operator string) (int, error) {
	var n int
	err := d.db.QueryRow(`SELECT count(*) FROM account WHERE operator = ?`, operator).Scan(&n)
	return n, err
}

// suspendAgents is the cascade an operator's disabling or deletion
// runs on their agents (RFC 0021, Lifecycle): sessions gone, tokens
// refused (a disabled account's are, token.go), the rows kept and
// marked with when. An agent a site admin had already shut stays shut
// and is not marked, so reopening the operator does not reopen it.
func suspendAgents(tx *sql.Tx, operator string, at time.Time) error {
	when := at.UTC().Format(time.RFC3339)
	if _, err := tx.Exec(`DELETE FROM session WHERE account IN (SELECT name FROM account WHERE operator = ?)`, operator); err != nil {
		return err
	}
	_, err := tx.Exec(`UPDATE account SET suspended = ?, disabled = ? WHERE operator = ? AND disabled = ''`, when, when, operator)
	return err
}

// liftAgents undoes suspendAgents for operator's agents: the ones the
// cascade shut, and only those.
func liftAgents(tx *sql.Tx, operator string) error {
	_, err := tx.Exec(`UPDATE account SET suspended = '', disabled = '' WHERE operator = ? AND suspended <> ''`, operator)
	return err
}

// SweepSuspendedAgents deletes the agents whose operator was deleted
// more than thirty days ago and that nobody reassigned since, as the
// person was deleted: the same cascade, the same tombstone. An agent
// suspended because its operator is merely disabled waits for the
// operator, however long.
func (d *DB) SweepSuspendedAgents(now time.Time) (int, error) {
	cut := now.Add(-agentSuspendedAfter).UTC().Format(time.RFC3339)
	rows, err := d.db.Query(`SELECT name FROM account a WHERE suspended <> '' AND suspended < ?
		AND NOT EXISTS (SELECT 1 FROM account o WHERE o.name = a.operator)`, cut)
	if err != nil {
		return 0, err
	}
	var names []string
	for rows.Next() {
		var n string
		if err := rows.Scan(&n); err != nil {
			rows.Close()
			return 0, err
		}
		names = append(names, n)
	}
	rows.Close()
	if err := rows.Err(); err != nil {
		return 0, err
	}
	// A refusal (a deed that somehow reached the agent, a team it alone
	// owns) stops that agent, not the sweep: the rest go, and the
	// refusals are reported together.
	var refused []string
	deleted := 0
	for _, n := range names {
		if err := d.DeleteAccount(n); err != nil {
			refused = append(refused, n+": "+err.Error())
			continue
		}
		deleted++
	}
	if len(refused) > 0 {
		return deleted, fmt.Errorf("oltp: agent sweep left %d behind: %s", len(refused), strings.Join(refused, "; "))
	}
	return deleted, nil
}

// RevokeAgentTokens is the operator's one button (RFC 0021, Lifecycle:
// revoke all): every live token of every agent of theirs revoked and
// every session of theirs ended, in one transaction. It is what a
// person reaches for when a laptop goes missing, and it is one button
// because there is no safe answer to "which of my twelve tokens was on
// that machine".
func (d *DB) RevokeAgentTokens(operator string) (int, error) {
	if operator == "" {
		return 0, errors.New("oltp: empty operator name")
	}
	var n int64
	err := d.guarded(func(tx *sql.Tx) error {
		res, err := tx.Exec(`UPDATE token SET revoked = ? WHERE revoked = '' AND account <> ''
			AND account IN (SELECT name FROM account WHERE operator = ?)`, time.Now().UTC().Format(time.RFC3339), operator)
		if err != nil {
			return err
		}
		n, _ = res.RowsAffected()
		_, err = tx.Exec(`DELETE FROM session WHERE account IN (SELECT name FROM account WHERE operator = ?)`, operator)
		return err
	})
	return int(n), err
}

// AgentGrant writes operator's own agent's string on a repository
// (RFC 0021, Granting): any repository the operator can reach, with the
// string clipped to the operator's effective letters there before it is
// stored. The clipped string is returned, and clipped says whether the
// clip took anything. Refused on a repository the operator cannot read,
// and for an agent that is not the operator's.
//
// Clipping at write time is what the page shows; the intersection in
// effectiveIn runs on every read regardless, so a letter the operator
// loses later is gone from this string's effect without a rewrite.
func (d *DB) AgentGrant(operator, agent, repo, caps string) (stored string, clipped bool, err error) {
	if repo == "" {
		return "", false, errors.New("oltp: empty repository name")
	}
	err = d.guarded(func(tx *sql.Tx) error {
		a, ok := accountByName(tx, agent)
		if !ok || !personsAgent(a) || a.Operator != operator {
			return Refusal{fmt.Sprintf("%s is not an agent of %s's", agent, operator)}
		}
		opOwn, _ := capsOf(tx, operator, repo)
		opEff := d.personEffectiveIn(tx, operator, repo, opOwn)
		if !opEff.HasAny("gjoh2r") {
			return Refusal{fmt.Sprintf("%s cannot reach %s, so %s cannot be put to work there", operator, repo, agent)}
		}
		// Within the operator's letters the string is stored as given,
		// so a preset reads back as itself; above them, the letters
		// both hold, spelled out.
		want := d.expandCeiling(repo, caps)
		stored = string(want)
		if cut := capIntersect(want, opEff); letterSet(cut) != letterSet(want) {
			stored, clipped = Minimal(string(cut)), true
		} else {
			stored = noSetup(caps)
		}
		return setCaps(tx, agent, repo, stored)
	})
	return stored, clipped, err
}

// letterSet is the letters a set answers for, spelled out in a fixed
// order, so two sets compare as sets.
func letterSet(c CapSet) string {
	var b strings.Builder
	for _, l := range capLetters {
		if c.HasAny(string(l)) {
			b.WriteRune(l)
		}
	}
	return b.String()
}