// 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")
}
// Answering for itself names nobody who can be asked; checked here
// because answerableAdmin reads the row before it becomes forge.
if answerable == name {
return Refusal{fmt.Sprintf("%s cannot answer for itself; name the site admin who does", 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
}
// Agents of its own, and forge agents it answers for: a
// machine answers for nothing, and re-marking it silently would
// leave them answering to nobody.
if err := refuseHoldings(tx, name, append(agentHoldings[:1:1], struct{ query, what string }{
`SELECT name FROM account WHERE answerable = ? AND operator = '` + forgeOperator + `' ORDER BY name`, "answers for"})); 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 {
return answerableIn(d.db, name)
}
// answerableIn is Answerable read through q, so the guard can ask
// inside its own transaction and answer as the badge does.
func answerableIn(q querier, name string) string {
var admin string
if err := q.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()
}