Skip to content
quarry Artifact [4a1a908168]
Login

Artifact [4a1a908168]

Artifact 4a1a908168920f1888ea942b774ca2c5f249c87931f7c00aefcd193be0ef4cd9:


// agentops.go is RFC 0021's operator surface: a person creates agents
// for themselves, mints and revokes their tokens, and puts them to work
// on repositories they reach. The rules an agent is held to (the
// operator intersection, the deed, the two `a` guards) live in oltp,
// where every path meets them; this file is the doors.
package api

import (
	"context"
	"fmt"
	"sort"
	"strings"
	"time"

	"craftdesign.group/quarry/internal/oltp"
	"craftdesign.group/quarry/internal/ops"
)

// AgentStore is the optional half of the account seam behind the
// agent.* ops (oltp/agent.go, oltp/token.go). A store without it has
// no agents.
type AgentStore interface {
	// NewAgent counts the operator's agents against perPerson under the
	// lock it inserts with; negative is no cap.
	NewAgent(name, operator string, perPerson int) error
	Agents(operator string) ([]oltp.Account, error)
	AgentCount(operator string) (int, error)
	Operator(name string) string
	MintToken(account string, spec oltp.TokenSpec) (value string, tok oltp.Token, err error)
	Tokens(account string) ([]oltp.Token, error)
	RevokeToken(account, id string) error
	RevokeAgentTokens(operator string) (int, error)
	AgentGrant(operator, agent, repo, caps string) (stored string, clipped bool, err error)
}

// forgeOperator is the reserved operator of a forge agent (RFC 0021,
// Forge agents), which is nobody's agent in the sense these ops mean.
const forgeOperator = "forge"

func (d Deps) agents() (AgentStore, error) {
	if d.Accounts == nil {
		return nil, errNoAccounts()
	}
	as, ok := d.Accounts.(AgentStore)
	if !ok {
		return nil, fmt.Errorf("this forge's account store has no agents: %w", ops.ErrUnavailable)
	}
	return as, nil
}

// operatorOf names who answers for name: "" for a person or an
// unknown name. Read through the account list the other identity
// questions here use (isSiteAdmin), so a fake that lists no operator
// has no agents.
func (d Deps) operatorOf(name string) string {
	if d.Accounts == nil || name == "" {
		return ""
	}
	if as, ok := d.Accounts.(AgentStore); ok {
		return as.Operator(name)
	}
	accts, err := d.Accounts.Accounts()
	if err != nil {
		return ""
	}
	for _, a := range accts {
		if a.Name == name {
			return a.Operator
		}
	}
	return ""
}

// refuseAgent is the refusal in front of every deed act (RFC 0021,
// What an agent may hold): an agent is refused BEFORE the deed is
// consulted, held or unheld, so an agent with `a` on an unclaimed
// repository can neither retire it nor claim it. A forge agent is the
// exception, under its own section's rules.
func (d Deps) refuseAgent(ctx context.Context, verb string) error {
	who := ops.User(ctx)
	if op := d.operatorOf(who); op != "" && op != forgeOperator {
		return fmt.Errorf("%s is an agent, operated by %s, and an agent never holds a repository's deed, so it cannot %s one: %w",
			who, op, verb, ops.ErrForbidden)
	}
	return nil
}

// refuseAboveCeiling keeps a token from writing a letter it does not
// itself resolve to on repo: a token ceilinged to developer that
// somehow reached a grant writes developer at most. The literal letters
// of the string are judged, so an umbrella is judged as itself.
func (d Deps) refuseAboveCeiling(ctx context.Context, repo, caps string) error {
	tok, ok := ops.TokenOf(ctx)
	if !ok {
		return nil
	}
	for _, l := range caps {
		if !tok.Caps(repo).HasAny(string(l)) {
			return fmt.Errorf("this token does not hold %c on %s and cannot grant it: %w", l, repo, ops.ErrForbidden)
		}
	}
	return nil
}

// myAgent answers the agent the caller may manage: their own, or
// anybody's for a site admin. Never a person, whatever the caller: the
// agent ops delete and re-credential accounts, and a person's account
// is not one of them.
func (d Deps) myAgent(ctx context.Context, as AgentStore, name string) (operator string, err error) {
	who := ops.User(ctx)
	if who == "" {
		return "", fmt.Errorf("managing agents needs a signed-in account: %w", ops.ErrInvalid)
	}
	operator = as.Operator(name)
	if operator == "" || operator == forgeOperator {
		return "", fmt.Errorf("%s is not a person's agent: %w", name, ops.ErrNotFound)
	}
	if operator != who && !d.isSiteAdmin(ctx, who) {
		return "", fmt.Errorf("%s is %s's agent, not yours: %w", name, operator, ops.ErrForbidden)
	}
	return operator, nil
}

type agentNewIn struct {
	Name string `json:"name" validate:"required,max=64"`
	// Operator is who answers for the agent: the caller, unless a site
	// admin names somebody else.
	Operator string `json:"operator" validate:"max=64"`
}

type agentOut struct {
	Name     string `json:"name"`
	Operator string `json:"operator"`
}

type agentListIn struct {
	// Operator limits the list to one person's agents; a site admin
	// may name anyone, everyone else gets their own regardless.
	Operator string `json:"operator" validate:"max=64"`
}

// agentRepo is one repository an agent holds a string on, with what
// it resolves to there under its operator.
type agentRepo struct {
	Repo string `json:"repo"`
	// Caps is the stored string; Effective is what it resolves to
	// under the operator's letters, which may be less.
	Caps      string `json:"caps"`
	Effective string `json:"effective"`
	Role      string `json:"role"`
}

type agentRow struct {
	Name      string       `json:"name"`
	Operator  string       `json:"operator"`
	Created   string       `json:"created"`
	Disabled  string       `json:"disabled,omitempty"`
	Suspended string       `json:"suspended,omitempty"`
	Tokens    []meTokenRow `json:"tokens"`
	Repos     []agentRepo  `json:"repos"`
}

type agentListOut struct {
	Agents []agentRow `json:"agents"`
	Count  int        `json:"count"`
}

type agentNameIn struct {
	Name string `json:"name" validate:"required,max=64"`
}

type agentTokenNewIn struct {
	Agent string `json:"agent" validate:"required,max=64"`
	Name  string `json:"name" validate:"required,max=80"`
	// Scopes is a comma-separated list of sync, api and web.
	Scopes string `json:"scopes" validate:"required,max=20"`
	// Repos is a comma-separated list of repositories the token is
	// narrowed to; empty means every repository the agent reaches.
	Repos string `json:"repos" validate:"max=2000"`
	// Ceiling is a cap string the token's letters are cut to; empty
	// means the agent's own.
	Ceiling string `json:"ceiling" validate:"max=64"`
	// Days until expiry: 90 by default, a year at most.
	Days int `json:"days" validate:"min=0,max=366"`
}

type agentTokenNewOut struct {
	// Value is the token, readable this once.
	Value string     `json:"value"`
	Token meTokenRow `json:"token"`
}

type agentTokenListIn struct {
	Agent string `json:"agent" validate:"required,max=64"`
}

type agentTokenListOut struct {
	Agent  string       `json:"agent"`
	Tokens []meTokenRow `json:"tokens"`
}

type agentTokenRevokeIn struct {
	Agent string `json:"agent" validate:"required,max=64"`
	ID    string `json:"id" validate:"required,max=32"`
}

type agentRevokeAllOut struct {
	Revoked int `json:"revoked"`
}

type agentGrantIn struct {
	Agent string `json:"agent" validate:"required,max=64"`
	Repo  string `json:"repo" validate:"required,max=64"`
	// Role is a preset (reader, developer, maintainer, admin); Caps is
	// any string instead. One of the two; an empty pair revokes.
	Role string `json:"role" validate:"max=20"`
	Caps string `json:"caps" validate:"max=64"`
}

type agentGrantOut struct {
	Agent string `json:"agent"`
	Repo  string `json:"repo"`
	// Caps is the string stored, clipped to the operator's letters
	// there; Clipped says the clip took something, and Warning says so
	// in words.
	Caps    string `json:"caps"`
	Clipped bool   `json:"clipped"`
	Warning string `json:"warning,omitempty"`
}

// registerAgents adds the operator's doors. Every one refuses a token
// (refuseToken): they manage accounts and credentials, which is what
// a token must not reach.
func registerAgents(r *ops.Registry, d Deps) error {
	if err := ops.Register(r, ops.Op[agentNewIn, agentOut]{
		Name: "agent.new",
		Doc: "Create an agent of your own: an account with no password that holds tokens instead, and never more than you hold (RFC 0021). " +
			"You become its operator; a site admin may name another operator. Refused past the forge's agents-per-person cap, and for everyone but site admins when the cap is zero.",
		Kind:  ops.Command,
		Auth:  ops.Token,
		Route: "POST /api/v1/agents",
		Run: func(ctx context.Context, in agentNewIn) (agentOut, error) {
			if err := refuseToken(ctx, "agent.new"); err != nil {
				return agentOut{}, err
			}
			as, err := d.agents()
			if err != nil {
				return agentOut{}, err
			}
			who := ops.User(ctx)
			if who == "" {
				return agentOut{}, fmt.Errorf("creating an agent needs a signed-in account: %w", ops.ErrInvalid)
			}
			admin := d.isSiteAdmin(ctx, who)
			operator := strings.TrimSpace(in.Operator)
			if operator == "" {
				operator = who
			}
			if operator != who && !admin {
				return agentOut{}, fmt.Errorf("only a site admin may create an agent for somebody else: %w", ops.ErrForbidden)
			}
			// The cap is counted in the store, under the lock the row is
			// written with; a site admin is never capped.
			perPerson := -1
			if !admin {
				if d.AgentsPerPerson <= 0 {
					return agentOut{}, fmt.Errorf("this forge does not let people create agents: %w", ops.ErrForbidden)
				}
				perPerson = d.AgentsPerPerson
			}
			if err := as.NewAgent(in.Name, operator, perPerson); err != nil {
				return agentOut{}, refusalAsInvalid(fmt.Errorf("%w: %v", ops.ErrInvalid, err))
			}
			name := in.Name
			d.provision(func(ctx context.Context) error {
				return d.Provision.EnsureSubscriber(ctx, name)
			})
			return agentOut{Name: in.Name, Operator: operator}, nil
		},
	}); err != nil {
		return err
	}
	if err := ops.Register(r, ops.Op[agentListIn, agentListOut]{
		Name: "agent.list",
		Doc: "Your agents, each with its tokens (names, scopes, dates, never the values) and the repositories it holds a string on, " +
			"with what each resolves to under your letters. A site admin may ask for anybody's with operator=.",
		Kind:  ops.Query,
		Auth:  ops.Token,
		Route: "GET /api/v1/agents",
		Run: func(ctx context.Context, in agentListIn) (agentListOut, error) {
			if err := refuseToken(ctx, "agent.list"); err != nil {
				return agentListOut{}, err
			}
			as, err := d.agents()
			if err != nil {
				return agentListOut{}, err
			}
			who := ops.User(ctx)
			if who == "" {
				return agentListOut{}, fmt.Errorf("listing agents needs a signed-in account: %w", ops.ErrInvalid)
			}
			operator := who
			if in.Operator != "" && in.Operator != who {
				if !d.isSiteAdmin(ctx, who) {
					return agentListOut{}, fmt.Errorf("only a site admin may list somebody else's agents: %w", ops.ErrForbidden)
				}
				operator = in.Operator
			}
			agents, err := as.Agents(operator)
			if err != nil {
				return agentListOut{}, err
			}
			ex, canExplain := d.Accounts.(CapsExplainer)
			out := agentListOut{Agents: []agentRow{}}
			now := time.Now()
			for _, a := range agents {
				row := agentRow{Name: a.Name, Operator: a.Operator, Created: a.Created, Disabled: a.Disabled,
					Suspended: a.Suspended, Tokens: []meTokenRow{}, Repos: []agentRepo{}}
				if toks, err := as.Tokens(a.Name); err == nil {
					row.Tokens = tokenRows(toks, now)
				}
				grants, err := d.Accounts.GrantsFor(a.Name)
				if err != nil {
					return agentListOut{}, err
				}
				repos := make([]string, 0, len(grants))
				for repo := range grants {
					// "" is a fleet-scope row, not a repository.
					if repo != "" {
						repos = append(repos, repo)
					}
				}
				sort.Strings(repos)
				for _, repo := range repos {
					ar := agentRepo{Repo: repo, Role: grants[repo]}
					if canExplain {
						if stored, eff, err := ex.CapsAt(a.Name, repo); err == nil {
							ar.Caps, ar.Effective = stored, eff
							ar.Role = oltp.Infer(stored).Role
						}
					}
					row.Repos = append(row.Repos, ar)
				}
				out.Agents = append(out.Agents, row)
			}
			out.Count = len(out.Agents)
			return out, nil
		},
	}); err != nil {
		return err
	}
	if err := ops.Register(r, ops.Op[agentNameIn, agentOut]{
		Name: "agent.delete",
		Doc: "Delete one of your agents: every session ended, every token revoked, the row removed and the name retired, the cascade a deleted person gets. " +
			"Its check-ins and tickets stay in the record under its name. A site admin may delete anybody's.",
		Kind:  ops.Command,
		Auth:  ops.Token,
		Route: "POST /api/v1/agents/delete",
		Run: func(ctx context.Context, in agentNameIn) (agentOut, error) {
			if err := refuseToken(ctx, "agent.delete"); err != nil {
				return agentOut{}, err
			}
			as, err := d.agents()
			if err != nil {
				return agentOut{}, err
			}
			operator, err := d.myAgent(ctx, as, in.Name)
			if err != nil {
				return agentOut{}, err
			}
			if err := d.Accounts.DeleteAccount(in.Name); err != nil {
				return agentOut{}, refusalAsInvalid(err)
			}
			name := in.Name
			d.provision(func(ctx context.Context) error {
				return d.Provision.RemoveSubscriber(ctx, name)
			})
			return agentOut{Name: in.Name, Operator: operator}, nil
		},
	}); err != nil {
		return err
	}
	if err := ops.Register(r, ops.Op[agentTokenNewIn, agentTokenNewOut]{
		Name: "agent.token.new",
		Doc: "Mint a token for one of your agents and return it the ONE time it is readable. `scopes` is any of sync, api and web, comma-separated; " +
			"`repos` narrows it to named repositories; `ceiling` cuts its letters; `days` is its life, 90 by default and a year at most. " +
			"Minting adds; the agent's other tokens keep working.",
		Kind:  ops.Command,
		Auth:  ops.Token,
		Route: "POST /api/v1/agents/tokens",
		Run: func(ctx context.Context, in agentTokenNewIn) (agentTokenNewOut, error) {
			if err := refuseToken(ctx, "agent.token.new"); err != nil {
				return agentTokenNewOut{}, err
			}
			as, err := d.agents()
			if err != nil {
				return agentTokenNewOut{}, err
			}
			if _, err := d.myAgent(ctx, as, in.Agent); err != nil {
				return agentTokenNewOut{}, err
			}
			spec := oltp.TokenSpec{Name: in.Name, Scopes: splitList(in.Scopes), Repos: splitList(in.Repos), Ceiling: strings.TrimSpace(in.Ceiling)}
			if in.Days > 0 {
				spec.Expires = time.Now().Add(time.Duration(in.Days) * 24 * time.Hour)
			}
			value, tok, err := as.MintToken(in.Agent, spec)
			if err != nil {
				return agentTokenNewOut{}, refusalAsInvalid(err)
			}
			return agentTokenNewOut{Value: value, Token: tokenRows([]oltp.Token{tok}, time.Now())[0]}, nil
		},
	}); err != nil {
		return err
	}
	if err := ops.Register(r, ops.Op[agentTokenListIn, agentTokenListOut]{
		Name:  "agent.token.list",
		Doc:   "One of your agents' tokens: names, scopes, repositories, ceilings and dates, expiring ones first — never a value.",
		Kind:  ops.Query,
		Auth:  ops.Token,
		Route: "GET /api/v1/agents/tokens",
		Run: func(ctx context.Context, in agentTokenListIn) (agentTokenListOut, error) {
			if err := refuseToken(ctx, "agent.token.list"); err != nil {
				return agentTokenListOut{}, err
			}
			as, err := d.agents()
			if err != nil {
				return agentTokenListOut{}, err
			}
			if _, err := d.myAgent(ctx, as, in.Agent); err != nil {
				return agentTokenListOut{}, err
			}
			toks, err := as.Tokens(in.Agent)
			if err != nil {
				return agentTokenListOut{}, err
			}
			return agentTokenListOut{Agent: in.Agent, Tokens: tokenRows(toks, time.Now())}, nil
		},
	}); err != nil {
		return err
	}
	if err := ops.Register(r, ops.Op[agentTokenRevokeIn, struct{}]{
		Name:  "agent.token.revoke",
		Doc:   "Revoke one token of one of your agents. The row stays, marked, for the list; every door refuses it from the next request.",
		Kind:  ops.Command,
		Auth:  ops.Token,
		Route: "POST /api/v1/agents/tokens/revoke",
		Run: func(ctx context.Context, in agentTokenRevokeIn) (struct{}, error) {
			if err := refuseToken(ctx, "agent.token.revoke"); err != nil {
				return struct{}{}, err
			}
			as, err := d.agents()
			if err != nil {
				return struct{}{}, err
			}
			if _, err := d.myAgent(ctx, as, in.Agent); err != nil {
				return struct{}{}, err
			}
			if err := as.RevokeToken(in.Agent, in.ID); err != nil {
				return struct{}{}, fmt.Errorf("%s holds no such live token: %w", in.Agent, ops.ErrNotFound)
			}
			return struct{}{}, nil
		},
	}); err != nil {
		return err
	}
	if err := ops.Register(r, ops.Op[struct{}, agentRevokeAllOut]{
		Name: "agent.token.revoke_all",
		Doc: "Revoke every token of every agent of yours and end their sessions, in one transaction: the control for a laptop that went missing, " +
			"one button because there is no safe answer to which of your tokens was on it. Your own tokens are untouched.",
		Kind:  ops.Command,
		Auth:  ops.Token,
		Route: "POST /api/v1/agents/tokens/revoke-all",
		Run: func(ctx context.Context, _ struct{}) (agentRevokeAllOut, error) {
			if err := refuseToken(ctx, "agent.token.revoke_all"); err != nil {
				return agentRevokeAllOut{}, err
			}
			as, err := d.agents()
			if err != nil {
				return agentRevokeAllOut{}, err
			}
			who := ops.User(ctx)
			if who == "" {
				return agentRevokeAllOut{}, fmt.Errorf("revoking your agents' tokens needs a signed-in account: %w", ops.ErrInvalid)
			}
			n, err := as.RevokeAgentTokens(who)
			if err != nil {
				return agentRevokeAllOut{}, err
			}
			return agentRevokeAllOut{Revoked: n}, nil
		},
	}); err != nil {
		return err
	}
	return ops.Register(r, ops.Op[agentGrantIn, agentGrantOut]{
		Name: "agent.grant",
		Doc: "Put one of your agents to work on a repository you reach, administered or not: `role` is a preset, `caps` any string, both empty revokes. " +
			"The string is clipped to your own letters there before it is stored, and resolves under them on every read after, so the agent never holds more than you. " +
			"Refused on a repository you cannot reach.",
		Kind:  ops.Command,
		Auth:  ops.Token,
		Route: "POST /api/v1/agents/grant",
		Run: func(ctx context.Context, in agentGrantIn) (agentGrantOut, error) {
			if err := refuseToken(ctx, "agent.grant"); err != nil {
				return agentGrantOut{}, err
			}
			as, err := d.agents()
			if err != nil {
				return agentGrantOut{}, err
			}
			who := ops.User(ctx)
			if who == "" {
				return agentGrantOut{}, fmt.Errorf("granting needs a signed-in account: %w", ops.ErrInvalid)
			}
			// The operator's own agent only: a site admin uses
			// account.grant, which clips nothing and lets the
			// intersection keep the result honest.
			if as.Operator(in.Agent) != who {
				return agentGrantOut{}, fmt.Errorf("%s is not your agent: %w", in.Agent, ops.ErrForbidden)
			}
			// A repository the operator cannot read is one that does
			// not exist for them, so neither does the grant.
			if _, err := d.repoFor(ctx, in.Repo); err != nil {
				return agentGrantOut{}, err
			}
			caps := strings.TrimSpace(in.Caps)
			if in.Role != "" {
				role, err := oltp.ParseRole(in.Role)
				if err != nil {
					return agentGrantOut{}, fmt.Errorf("%w: %v", ops.ErrInvalid, err)
				}
				caps = role.Caps()
			}
			beforeCaps := d.capsNow(in.Agent, in.Repo)
			stored, clipped, err := as.AgentGrant(who, in.Agent, in.Repo, caps)
			if err != nil {
				return agentGrantOut{}, refusalAsInvalid(err)
			}
			out := agentGrantOut{Agent: in.Agent, Repo: in.Repo, Caps: stored, Clipped: clipped}
			if clipped {
				out.Warning = "clipped to " + who + "'s own letters on " + in.Repo + "; an agent never holds more than its operator"
			}
			d.raiseAccountChange(ctx, in.Repo, in.Agent, beforeCaps, stored)
			agent, repoName := in.Agent, in.Repo
			d.provision(func(ctx context.Context) error {
				return d.Provision.SetTopicMember(ctx, repoName, agent, stored != "")
			})
			return out, nil
		},
	})
}

// splitList is a comma-separated field as a list, blanks dropped.
func splitList(s string) []string {
	var out []string
	for _, p := range strings.Split(s, ",") {
		if p = strings.TrimSpace(p); p != "" {
			out = append(out, p)
		}
	}
	return out
}

// refuseAgentTeams keeps an agent out of the team ops that change
// grants, membership or where a repository lives (team.create,
// team.delete, team.member, team.unmember, team.repo.*). A team is a
// route to letters beside the one the operator intersection governs,
// and an agent holding a could otherwise make one, grant a third
// account through it, move a repository under it or unmember its
// operator. Team-owned agents are RFC 0021's rung 8; until then an
// agent does not manage teams.
func (d Deps) refuseAgentTeams(ctx context.Context) error {
	who := ops.User(ctx)
	if op := d.operatorOf(who); op != "" && op != forgeOperator {
		return fmt.Errorf("%s is an agent, operated by %s, and an agent does not manage teams: %w", who, op, ops.ErrForbidden)
	}
	return nil
}

// refuseAgentAbove is agentGuard's letter rule (oltp/guard.go) at the
// one write the guard does not see: a category string. A category is
// not a grant to an account, so guardCaps never judges it, but it hands
// letters to everyone in the category, and the nobody category sits
// outside the operator intersection, so an agent writing a letter it
// does not resolve to into one would hand it to others and, through
// nobody, to itself. An agent caller may write only letters it resolves
// to on that repository (its token's, when it came in on one); a person
// is held to nothing here but the `a` the op is gated on.
func (d Deps) refuseAgentAbove(ctx context.Context, repo, caps string) error {
	who := ops.User(ctx)
	if op := d.operatorOf(who); op == "" || op == forgeOperator || d.CapsFor == nil {
		return nil
	}
	mine := d.capsFor(ctx, who, repo)
	for _, l := range caps {
		if !mine.HasAny(string(l)) {
			return fmt.Errorf("%s does not hold %c on %s itself; an agent cannot grant a letter it does not resolve to: %w", who, l, repo, ops.ErrForbidden)
		}
	}
	return nil
}