// 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"
"log"
"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)
// The inherit switch and dropping a narrowing (RFC 0026).
SetInherit(operator, agent string, on bool) error
AgentInheritHere(operator, agent, repo string) error
ClosedTo(agent string) ([]string, error)
// The forge's own agents (RFC 0021, Forge agents): made or
// re-marked with a site admin answerable, and who answers now.
NewForgeAgent(name, answerable string) error
SetForgeAgent(name, answerable string) error
SetAnswerable(name, admin string) error
Answerable(name string) string
}
// 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
}
// agentsAnd is user and every agent acting for them: the ones they
// operate, and the forge agents they answer for. A store without agents
// gives the person alone.
func (d Deps) agentsAnd(user string) []string {
out := []string{user}
as, err := d.agents()
if err != nil {
return out
}
if mine, err := as.Agents(user); err == nil {
for _, a := range mine {
out = append(out, a.Name)
}
}
if forge, err := as.Agents(forgeOperator); err == nil {
for _, a := range forge {
if as.Answerable(a.Name) == user {
out = append(out, a.Name)
}
}
}
return out
}
// 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
}
// refuseForgeAgent is the edge a forge agent holding s stops at (RFC
// 0021, Forge agents: Guards): it may do what a site admin may, except
// make or alter another forge agent or change any account's s flag.
// Those stay with a person, so that a machine cannot multiply machines
// or grant itself a successor. verb is what it was trying to do.
func (d Deps) refuseForgeAgent(ctx context.Context, verb string) error {
who := ops.User(ctx)
if d.operatorOf(who) == forgeOperator {
return fmt.Errorf("%s is a forge agent, and a forge agent never %s: %w", who, verb, ops.ErrForbidden)
}
return nil
}
// refuseForgeAgentOn is refuseForgeAgent for the account-level ops a
// site admin may run against another account (mark, clear, disable,
// delete, reset, email, sessions, grants): a forge agent caller is
// refused when the target is another forge agent or holds s. Marking
// strips the flag and the password, disabling ends the sessions, a
// reset or a new address takes the account: each is a way for a
// machine to lock a person out or alter a peer, which is what the
// edge exists to stop.
func (d Deps) refuseForgeAgentOn(ctx context.Context, target string) error {
who := ops.User(ctx)
if d.operatorOf(who) != forgeOperator || target == who {
return nil
}
if d.operatorOf(target) == forgeOperator {
return d.refuseForgeAgent(ctx, "alters another forge agent")
}
if a, ok := d.accountNamed(target); ok && a.Admin {
return d.refuseForgeAgent(ctx, "alters a site admin's account")
}
return nil
}
// refuseForgeAgentPeer is refuseForgeAgentOn for a grant write
// (account.grant, account.revoke, account.caps): only another forge
// agent is refused here. Raising a site admin's letters cannot lock
// them out, so a forge agent may (zeitgeist grants admins their
// mirrors); that a machine never LOWERS a site admin's reach is judged
// on effective sets by the store's guard, which every grant write
// passes through (oltp/guard.go agentGuard), so a grant that replaces
// a with reader is refused there whatever door it came in by.
func (d Deps) refuseForgeAgentPeer(ctx context.Context, target string) error {
who := ops.User(ctx)
if d.operatorOf(who) == forgeOperator && target != who && d.operatorOf(target) == forgeOperator {
return d.refuseForgeAgent(ctx, "alters another forge agent")
}
return nil
}
// refuseForgeAgentFlag is refuseForgeAgent at the two ops that write
// an account's s flag on the way in (account.add, account.invite):
// a forge agent may invite a person, never a site admin, and never
// re-invite one in a way that moves the flag either way.
func (d Deps) refuseForgeAgentFlag(ctx context.Context, name string, admin bool) error {
if d.operatorOf(ops.User(ctx)) != forgeOperator {
return nil
}
now, exists := d.accountNamed(name)
if admin || (exists && now.Admin) {
return d.refuseForgeAgent(ctx, "changes an account's site-admin flag")
}
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. A forge agent is any site admin's to manage
// (RFC 0021, Forge agents: any site admin controls it), and its own:
// another forge agent's never.
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 == "" {
return "", fmt.Errorf("%s is not an agent: %w", name, ops.ErrNotFound)
}
if operator == forgeOperator {
if name != who {
if err := d.refuseForgeAgent(ctx, "alters another forge agent"); err != nil {
return "", err
}
}
if !d.isSiteAdmin(ctx, who) {
return "", fmt.Errorf("%s is a forge agent, which only a site admin manages: %w", name, ops.ErrForbidden)
}
return operator, nil
}
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. The reserved name forge makes a forge
// agent, a site admin's act, which needs Answerable.
Operator string `json:"operator" validate:"max=64"`
// Answerable is the site admin who answers for a forge agent;
// required with operator forge and meaningless otherwise.
Answerable string `json:"answerable" validate:"max=64"`
}
type accountAnswerableIn struct {
Name string `json:"name" validate:"required,max=64"`
// Answerable is the site admin who answers for the forge agent
// from now on.
Answerable string `json:"answerable" validate:"required,max=64"`
}
type accountAnswerableOut struct {
Name string `json:"name"`
Answerable string `json:"answerable"`
}
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"`
// Answerable is the site admin who answers for a forge agent now,
// "" when nobody does (RFC 0021, Forge agents); "" for a person's
// agent, whose operator answers.
Answerable string `json:"answerable,omitempty"`
// Admin is the site-admin flag, which only a forge agent may hold.
Admin bool `json:"admin,omitempty"`
Created string `json:"created"`
Disabled string `json:"disabled,omitempty"`
Suspended string `json:"suspended,omitempty"`
// Inherit says a person's agent holds its operator's letters
// wherever Repos has no row for it (RFC 0026); false holds Repos
// alone.
Inherit bool `json:"inherit"`
Tokens []meTokenRow `json:"tokens"`
Repos []agentRepo `json:"repos"`
}
type agentListOut struct {
Agents []agentRow `json:"agents"`
Count int `json:"count"`
// CanCreate says whether the caller may make another agent now: a
// site admin always, anybody else while the forge allows agents and
// they are under its cap. A page draws its create control from this
// rather than offering one agent.new would refuse.
CanCreate bool `json:"can_create"`
}
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, web and site.
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"`
// Inherit drops the agent's string here instead, so an inheriting
// agent holds your letters on the repository again (RFC 0026).
Inherit bool `json:"inherit"`
}
type agentInheritIn struct {
Agent string `json:"agent" validate:"required,max=64"`
On bool `json:"on"`
}
type agentInheritOut struct {
Agent string `json:"agent"`
Inherit bool `json:"inherit"`
}
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. " +
"A site admin may create a forge agent with operator=forge and answerable= naming the site admin who answers for it: no cap, no operator ceiling, listed on /fleet.",
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 == forgeOperator {
// The forge's own agent: a site admin's act, and a
// person's, with a site admin named answerable.
if err := d.refuseForgeAgent(ctx, "creates another forge agent"); err != nil {
return agentOut{}, err
}
if !admin {
return agentOut{}, fmt.Errorf("only a site admin may create a forge agent: %w", ops.ErrForbidden)
}
if strings.TrimSpace(in.Answerable) == "" {
return agentOut{}, fmt.Errorf("a forge agent needs answerable=, the site admin who answers for it: %w", ops.ErrInvalid)
}
if err := as.NewForgeAgent(in.Name, strings.TrimSpace(in.Answerable)); 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: forgeOperator}, nil
}
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, Admin: a.Admin, Created: a.Created, Disabled: a.Disabled,
Suspended: a.Suspended, Inherit: a.Inherit, Tokens: []meTokenRow{}, Repos: []agentRepo{}}
if a.Operator == forgeOperator {
row.Answerable = as.Answerable(a.Name)
}
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)
// An inheriting agent narrowed to nothing holds an empty
// string, which GrantsFor does not list.
closed := map[string]bool{}
if a.Inherit {
names, err := as.ClosedTo(a.Name)
if err != nil {
return agentListOut{}, err
}
for _, n := range names {
closed[n] = true
repos = append(repos, n)
}
sort.Strings(repos)
}
for _, repo := range repos {
if closed[repo] {
row.Repos = append(row.Repos, agentRepo{Repo: repo, Role: "No access"})
continue
}
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)
switch {
case d.isSiteAdmin(ctx, who):
out.CanCreate = true
case d.AgentsPerPerson > 0:
n, err := as.AgentCount(who)
if err != nil {
// Not offering the form is the safe answer; the trace is
// how a failed read is told from a full quota.
log.Printf("agent.list: counting %s's agents: %v", who, err)
}
out.CanCreate = err == nil && n < d.AgentsPerPerson
}
return out, nil
},
}); err != nil {
return err
}
if err := ops.Register(r, ops.Op[accountAnswerableIn, accountAnswerableOut]{
Name: "account.answerable",
Doc: "Name the site admin who answers for a forge agent, replacing whoever did (RFC 0021, Forge agents). Any site admin may; " +
"a forge agent itself may not. Disabling or deleting the answerable admin never touches the agent: it answers to nobody until one is named.",
Kind: ops.Command,
Auth: ops.Token,
Route: "POST /api/v1/accounts/answerable",
Run: func(ctx context.Context, in accountAnswerableIn) (accountAnswerableOut, error) {
if err := refuseToken(ctx, "account.answerable"); err != nil {
return accountAnswerableOut{}, err
}
as, err := d.agents()
if err != nil {
return accountAnswerableOut{}, err
}
if err := d.refuseForgeAgent(ctx, "alters a forge agent"); err != nil {
return accountAnswerableOut{}, err
}
if !d.isSiteAdmin(ctx, ops.User(ctx)) {
return accountAnswerableOut{}, fmt.Errorf("only a site admin names who answers for a forge agent: %w", ops.ErrForbidden)
}
if err := as.SetAnswerable(in.Name, strings.TrimSpace(in.Answerable)); err != nil {
return accountAnswerableOut{}, refusalAsInvalid(fmt.Errorf("%w: %v", ops.ErrInvalid, err))
}
return accountAnswerableOut{Name: in.Name, Answerable: strings.TrimSpace(in.Answerable)}, 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; " +
"site as well, beside api or web, for a forge agent holding the site-admin flag: its token then acts as a site admin, except in a private repository it holds no grant on. " +
"`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
}
if err := ops.Register(r, ops.Op[agentInheritIn, agentInheritOut]{
Name: "agent.inherit",
Doc: "Switch one of your agents between inherit and only-where-set (RFC 0026). Inheriting, it holds your letters on every repository where you have not narrowed it; " +
"only-where-set, it holds its strings alone. Either way it never holds more than you, and never your site-admin flag.",
Kind: ops.Command,
Auth: ops.Token,
Route: "POST /api/v1/agents/inherit",
Run: func(ctx context.Context, in agentInheritIn) (agentInheritOut, error) {
if err := refuseToken(ctx, "agent.inherit"); err != nil {
return agentInheritOut{}, err
}
as, err := d.agents()
if err != nil {
return agentInheritOut{}, err
}
who := ops.User(ctx)
if who == "" {
return agentInheritOut{}, fmt.Errorf("switching an agent needs a signed-in account: %w", ops.ErrInvalid)
}
if as.Operator(in.Agent) != who {
return agentInheritOut{}, fmt.Errorf("%s is not your agent: %w", in.Agent, ops.ErrForbidden)
}
if err := as.SetInherit(who, in.Agent, in.On); err != nil {
return agentInheritOut{}, refusalAsInvalid(err)
}
return agentInheritOut{Agent: in.Agent, Inherit: in.On}, 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. " +
"For an inheriting agent (RFC 0026) a string narrows what it inherits there, an empty one leaves it nothing, and inherit=true drops the string so it inherits again. " +
"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
}
if err := d.refuseHeldRepo(ctx, in.Repo); err != nil {
return agentGrantOut{}, err
}
if in.Inherit {
beforeCaps := d.capsNow(in.Agent, in.Repo)
if err := as.AgentInheritHere(who, in.Agent, in.Repo); err != nil {
return agentGrantOut{}, refusalAsInvalid(err)
}
d.raiseAccountChange(ctx, in.Repo, in.Agent, beforeCaps, "")
return agentGrantOut{Agent: in.Agent, Repo: in.Repo}, nil
}
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
}