Skip to content
quarry Artifact [db430ee344]
Login

Artifact [db430ee344]

Artifact db430ee344c8010f87b7473d518cbb5a45757f6c68afdd0c993b043866b8d8a4:


// 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)}
	}
	return d.guarded(func(tx *sql.Tx) error {
		if operator != "" {
			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 personsAgent(op) {
				return Refusal{fmt.Sprintf("%s is itself an agent; name the person who runs %s", operator, name)}
			}
			if err := refuseAgentHoldings(tx, name); err != nil {
				return err
			}
		}
		res, err := tx.Exec(`UPDATE account SET operator = ? 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)
	})
}

// refuseAgentHoldings is what an account must not hold to become an
// agent: agents of its own, a deed, or a team it alone owns. Named in
// the refusal, so the site admin can move them in the open first.
func refuseAgentHoldings(tx *sql.Tx, name string) error {
	for _, q := range []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"},
	} {
		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 {
		op, ok := accountByName(tx, operator)
		if !ok {
			return fmt.Errorf("oltp: no account named %q to answer for %q", operator, name)
		}
		if personsAgent(op) {
			return Refusal{fmt.Sprintf("%s is itself an agent; name the person who runs %s", operator, name)}
		}
		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
	})
}

// 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 name, admin, created, email, email_pending, disabled, last_login, last_seen, operator, suspended,
			pwhash = '' AND legacy_pw = '' AND operator = ''
		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.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()
}