Skip to content
quarry Artifact [6eedd9f9cf]
Login

Artifact [6eedd9f9cf]

Artifact 6eedd9f9cf8e3990ffecb337147f6ca0d5074bbab1bd6f75cd3167e1f0275131:


package api

import (
	"context"
	"encoding/json"
	"fmt"
	"log"
	"strings"
	"time"

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

// RFC 0030, Agent setup by one approval. One operation makes an agent,
// puts it to work on its repositories and mints its token, in one store
// transaction, from a request a person approves. What the credential
// reaches is fixed by the kind and is not an input; the asker chooses
// only the name, the kind, the repositories, the push role and the
// token's life.

const (
	setupOp         = "agent.setup"
	setupMaxRepos   = 20
	setupReadSent   = "Reads the code, tickets, wiki and CI runs; nothing else."
	setupBelongRepo = "repository"
	setupBelongMe   = "person"
)

type agentSetupIn struct {
	Name string `json:"name" validate:"required,max=64"`
	// Kind is push (a pushing agent, sync scope) or read (a read-only
	// client, api scope).
	Kind string `json:"kind" validate:"required,max=8"`
	// Repos is repository addresses, comma-separated: exactly one for
	// push, one to twenty for read.
	Repos string `json:"repos" validate:"required,max=2000"`
	// Role is for push only: developer (the default), publisher or
	// maintainer. admin is never asked for through setup.
	Role string `json:"role" validate:"max=20"`
	// Days is a fixed life for the token, up to 366; zero is no date and
	// the token lapses after 90 days unused.
	Days int `json:"days" validate:"min=0,max=366"`
	// BelongsTo is repository or person; empty takes the rule: a pushing
	// agent belongs to the repository when the approver administers it.
	BelongsTo string `json:"belongs_to" validate:"max=12"`
	// Ceiling is the letters the token is cut to. It is never the
	// asker's to choose: the forge writes it into the request when it is
	// filed, so the owner approves the letters that will be minted.
	Ceiling string `json:"ceiling,omitempty" validate:"max=64"`
}

type setupGrantOut struct {
	Repo string `json:"repo"`
	Role string `json:"role"`
	// Caps is the string stored, clipped to the approver's letters.
	Caps string `json:"caps"`
}

type setupRemote struct {
	Repo        string `json:"repo"`
	URL         string `json:"url"`
	ProjectCode string `json:"project_code"`
}

type agentSetupOut struct {
	Agent    string          `json:"agent"`
	Operator string          `json:"operator"`
	Kind     string          `json:"kind"`
	Grants   []setupGrantOut `json:"grants"`
	Token    meTokenRow      `json:"token"`
	Remotes  []setupRemote   `json:"remotes"`
	// Value is the token, readable once, to the holder of the pickup key
	// or the asking agent; never in a page, a log or a list.
	Value string `json:"value" secret:"once"`
	// after is what goes outward once the result is held: logs, events,
	// the receipt and topic membership. Not part of the answer.
	after func()
}

// setupPlan is a request resolved against its approver: what the store
// will be told, and what the owner's page says.
type setupPlan struct {
	Name, Kind string
	Repos      []string
	// Role is the preset's lower-case name; Sentence says what it lets
	// the agent do.
	Role, Sentence string
	// Belongs is repository or person; Owner names which.
	Belongs, Owner string
	Operator       string
	Scopes         []string
	Ceiling        string
	Days           int
	Store          oltp.AgentSetup
}

// setupChecker is the part of a forge's account store that can say,
// without writing, whether a name could be taken and what letters an
// approver lacks.
type setupChecker interface {
	SetupNameFree(name string) error
	SetupShortfall(approver, repo, caps string) string
}

// setupShape judges the input alone: the kind, the role, the number of
// repositories and who it belongs to. Nothing here reads the forge, so
// refusing says nothing an asker did not already know.
func setupShape(in agentSetupIn) (kind string, repos []string, role string, err error) {
	invalid := func(format string, a ...any) error { return fmt.Errorf(format+": %w", append(a, ops.ErrInvalid)...) }
	kind = strings.ToLower(strings.TrimSpace(in.Kind))
	if kind != oltp.SetupPush && kind != oltp.SetupRead {
		return "", nil, "", invalid("kind is push or read, not %q", in.Kind)
	}
	for _, r := range splitList(in.Repos) {
		dup := false
		for _, have := range repos {
			dup = dup || have == r
		}
		if !dup {
			repos = append(repos, r)
		}
	}
	role = strings.ToLower(strings.TrimSpace(in.Role))
	switch kind {
	case oltp.SetupPush:
		if len(repos) != 1 {
			return "", nil, "", invalid("a pushing agent is set up on exactly one repository; name one")
		}
		switch role {
		case "":
			role = "developer"
		case "developer", "publisher", "maintainer":
		case "admin":
			return "", nil, "", invalid("admin cannot be asked for through setup; give the agent that role separately, as a visible grant")
		default:
			return "", nil, "", invalid("a pushing agent's role is developer, publisher or maintainer, not %q", in.Role)
		}
	case oltp.SetupRead:
		if len(repos) < 1 || len(repos) > setupMaxRepos {
			return "", nil, "", invalid("a read-only client is set up on one to %d repositories", setupMaxRepos)
		}
		if role != "" {
			return "", nil, "", invalid("a read-only client's role is always reader; leave role out")
		}
		role = "reader"
	}
	switch strings.ToLower(strings.TrimSpace(in.BelongsTo)) {
	case "", setupBelongRepo, setupBelongMe:
	default:
		return "", nil, "", invalid("belongs_to is repository or person, not %q", in.BelongsTo)
	}
	if kind == oltp.SetupRead && strings.EqualFold(strings.TrimSpace(in.BelongsTo), setupBelongRepo) {
		return "", nil, "", invalid("a read-only client belongs to the person who approves it, not to a repository")
	}
	return kind, repos, role, nil
}

// planSetup resolves in as owner would run it: every repository exists
// and is theirs to reach, the name is free or is the owner's own agent,
// the role is one they may give, and the token is the narrow default of
// the kind. A refusal is the sentence the owner's own call would get.
func (d Deps) planSetup(owner string, in agentSetupIn) (setupPlan, error) {
	kind, repos, role, err := setupShape(in)
	if err != nil {
		return setupPlan{}, err
	}
	octx := ops.WithUser(context.Background(), owner)
	p := setupPlan{Name: in.Name, Kind: kind, Repos: repos, Role: role, Days: in.Days}
	for _, repo := range repos {
		if _, err := d.repoFor(octx, repo); err != nil {
			return setupPlan{}, err
		}
		if err := d.refuseHeldRepo(octx, repo); err != nil {
			return setupPlan{}, err
		}
	}
	preset, err := oltp.ParseRole(role)
	if err != nil {
		return setupPlan{}, fmt.Errorf("%w: %v", ops.ErrInvalid, err)
	}
	caps := preset.Caps()
	p.Sentence = setupRoleSentence(kind, role)
	belongs := strings.ToLower(strings.TrimSpace(in.BelongsTo))
	store := oltp.AgentSetup{Name: in.Name, Approver: owner, Repos: repos, Caps: caps, PerPerson: -1,
		Token: oltp.TokenSpec{Name: "setup", Repos: repos, Expires: oltp.SetupExpires(in.Days)}}
	switch kind {
	case oltp.SetupPush:
		holdsA := d.CapsFor != nil && d.CapsFor(owner, repos[0]).HasAny("a")
		if belongs == "" {
			belongs = setupBelongMe
			if holdsA {
				belongs = setupBelongRepo
			}
		}
		if belongs == setupBelongRepo && !holdsA {
			return setupPlan{}, fmt.Errorf("a repository's own agent needs the admin letter on %s: %w", repos[0], ops.ErrForbidden)
		}
		store.RefuseClip = true
		store.Token.Scopes = []string{oltp.ScopeSync}
		store.Token.Ceiling = caps
		p.Scopes, p.Ceiling = store.Token.Scopes, caps
		if belongs == setupBelongRepo {
			store.Operator = "repo:" + repos[0]
			p.Owner = repos[0]
		} else {
			store.Operator, store.Scope = owner, "repo:"+repos[0]
			p.Owner = owner
		}
		if sc, ok := d.Accounts.(setupChecker); ok {
			if miss := sc.SetupShortfall(owner, repos[0], caps); miss != "" {
				return setupPlan{}, fmt.Errorf("%s does not hold %s on %s, which the role %s needs, so an agent cannot be given it: %w",
					owner, miss, repos[0], role, ops.ErrForbidden)
			}
		}
	case oltp.SetupRead:
		belongs = setupBelongMe
		store.Operator, p.Owner = owner, owner
		if len(repos) == 1 {
			store.Scope = "repo:" + repos[0]
		}
		store.Token.Scopes = []string{oltp.ScopeAPI}
		store.Token.Ceiling = oltp.SetupReadCeiling
		p.Scopes, p.Ceiling = store.Token.Scopes, oltp.SetupReadCeiling
	}
	p.Belongs, p.Operator = belongs, store.Operator
	if in.Ceiling != "" && in.Ceiling != p.Ceiling {
		return setupPlan{}, fmt.Errorf("the letters this role gives have changed since it was asked (%s, now %s); ask again: %w", in.Ceiling, p.Ceiling, ops.ErrInvalid)
	}
	// Setup makes a new agent only: a name that is any account, in any
	// case, is refused.
	if d.accountLike(in.Name) {
		return setupPlan{}, fmt.Errorf("%s is already an account; setup makes new agents only: %w", in.Name, ops.ErrInvalid)
	}
	if sc, ok := d.Accounts.(setupChecker); ok {
		if err := sc.SetupNameFree(in.Name); err != nil {
			return setupPlan{}, refusalAsInvalid(fmt.Errorf("%w: %v", ops.ErrInvalid, err))
		}
	}
	if belongs == setupBelongMe && !d.isSiteAdmin(octx, owner) {
		if d.AgentsPerPerson <= 0 {
			return setupPlan{}, fmt.Errorf("this forge does not let people create agents: %w", ops.ErrForbidden)
		}
		store.PerPerson = d.AgentsPerPerson
	}
	p.Store = store
	return p, nil
}

// setupRoleSentence says what a role lets the agent do.
func setupRoleSentence(kind, role string) string {
	if kind == oltp.SetupPush {
		// The People page's blurbs are for people; what a sync-only token
		// can do is said here.
		switch strings.ToLower(role) {
		case "maintainer":
			return "Checks in, and moderates the forum, ticket comments and wiki appends. What it posts by sync (forum, wiki, tickets) skips moderation."
		case "publisher":
			return "Reads, and pushes unversioned files: downloads and release files. It cannot check in, and holds no ticket or settings letter."
		}
		for _, pr := range oltp.Presets {
			if strings.EqualFold(pr.Name, role) {
				return strings.ToUpper(pr.Blurb[:1]) + pr.Blurb[1:] + "."
			}
		}
	}
	return setupReadSent
}

// accountLike reports whether any live account is called name, in any
// case.
func (d Deps) accountLike(name string) bool {
	if d.Accounts == nil {
		return false
	}
	accts, err := d.Accounts.Accounts()
	if err != nil {
		return false
	}
	for _, a := range accts {
		if strings.EqualFold(a.Name, name) {
			return true
		}
	}
	return false
}

// setupSummary is the line a request's row and email read.
func setupSummary(in any) string {
	name, kind, repos := inputField(in, "Name"), strings.ToLower(inputField(in, "Kind")), strings.Join(splitList(inputField(in, "Repos")), ", ")
	if name == "" || repos == "" {
		return ""
	}
	switch kind {
	case oltp.SetupPush:
		return "Set up agent " + name + " to push to " + repos
	case oltp.SetupRead:
		return "Set up agent " + name + " to read " + repos
	}
	return ""
}

// askedWithinReach is a token limited to repositories asking for
// agent.setup: allowed when every repository named is one it reaches
// (RFC 0030, Who may ask).
func askedWithinReach(tok ops.TokenAuth, op string, in any) bool {
	if op != setupOp || tok.Reaches == nil {
		return false
	}
	repos := splitList(inputField(in, "Repos"))
	for _, r := range repos {
		if !tok.Reaches(r) {
			return false
		}
	}
	return len(repos) > 0
}

// setupInOf reads an asked input, a value or a pointer, as the setup's.
func setupInOf(in any) agentSetupIn {
	switch v := in.(type) {
	case agentSetupIn:
		return v
	case *agentSetupIn:
		return *v
	}
	return agentSetupIn{}
}

// setupPreflight is asking-time judgment: the asker must read every
// repository (check as the agent, so asking cannot probe for private
// ones), and the request must be one the owner could run now. False is
// the refusal the asker's plain call already got.
func setupPreflight(d Deps, caller, owner context.Context, in any) bool {
	si := setupInOf(in)
	for _, repo := range splitList(si.Repos) {
		if d.CanRead != nil && !d.CanRead(caller, repo) {
			return false
		}
		if tok, byToken := ops.TokenOf(caller); byToken && tok.Reaches != nil && !tok.Reaches(repo) {
			return false
		}
	}
	_, err := d.planSetup(ops.User(owner), si)
	return err == nil
}

func registerAgentSetup(r *ops.Registry, d Deps) error {
	return ops.Register(r, ops.Op[agentSetupIn, agentSetupOut]{
		Name: setupOp,
		Ask:  "Set up agent {name} for {repos}",
		Doc: "Make an agent, put it to work on its repositories and mint its credential in one step, from a request its person approves (RFC 0030). " +
			"Never run directly: an agent asks for it with Quarry-Ask, and the person approves from the request's page, which shows every line below before the buttons. " +
			"`kind` push makes a pushing agent on exactly one repository: `role` developer (the default), publisher or maintainer, never admin; its token is sync scope only, limited to that repository, " +
			"cut to the role's letters. It belongs to the repository when the approver administers it, and to the approver otherwise (`belongs_to` person forces that). " +
			"`kind` read makes a read-only client on one to twenty repositories, always the approver's own, granted reader on each: its token is api scope only, limited to those repositories, " +
			"cut to gjorzh28 (what a visitor reads on a public repository, and seeing CI). The agent, its grants and its token exist together or not at all. " +
			"Setup makes a new agent only: a name that is already an account, in any case, or that was one, is refused. `ceiling` is not an input: the forge writes it into the request, and a request that sends it is refused. `days` gives the token a fixed life up to 366; without it, it lapses after 90 days unused. " +
			"The token's value is the secret half, sealed, released once to the holder of the pickup key (Quarry-Pickup) or the asking agent, and revoked if nobody collects it within 24 hours; an agent it created stays. " +
			"Only an agent its person operates, or a forge agent with an answerable admin, may ask; a repository's own agent cannot.",
		Kind:  ops.Command,
		Auth:  ops.Token,
		Route: "POST /api/v1/agents/setup",
		Run: func(ctx context.Context, in agentSetupIn) (agentSetupOut, error) {
			if err := refuseToken(ctx, setupOp); err != nil {
				return agentSetupOut{}, err
			}
			ask, asked := signOffAskOf(ctx)
			if !asked {
				return agentSetupOut{}, fmt.Errorf("%s runs when a person approves a request for it; an agent asks with Quarry-Ask: %w", setupOp, ops.ErrForbidden)
			}
			who := ops.User(ctx)
			as, err := d.agents()
			if err != nil {
				return agentSetupOut{}, err
			}
			p, err := d.planSetup(who, in)
			if err != nil {
				return agentSetupOut{}, err
			}
			if !d.SecretBox.Ready() {
				return agentSetupOut{}, fmt.Errorf("this forge has no secret key, so it cannot hold a credential for collection; nothing was made: %w", ops.ErrUnavailable)
			}
			p.Store.Token.Name = "setup " + shortID(ask.id)
			res, err := as.SetupAgent(p.Store)
			if err != nil {
				return agentSetupOut{}, refusalAsInvalid(err)
			}
			return d.finishSetup(ctx, who, ask, p, res), nil
		},
	})
}

func shortID(id string) string {
	if len(id) > 8 {
		return id[:8]
	}
	return id
}

// finishSetup builds the answer. Nothing outward happens here: the logs,
// events, receipt and topic membership are in out.after, which runs only
// once the result is held (sealed and stored), so a setup that cannot be
// held leaves nothing outward to take back.
func (d Deps) finishSetup(ctx context.Context, who string, ask signOffAsk, p setupPlan, res oltp.AgentSetupResult) agentSetupOut {
	out := agentSetupOut{Agent: p.Name, Operator: p.Operator, Kind: p.Kind,
		Grants: []setupGrantOut{}, Token: tokenRows([]oltp.Token{res.Token}, time.Now())[0], Remotes: []setupRemote{},
		Value: res.Value}
	for _, g := range res.Grants {
		out.Grants = append(out.Grants, setupGrantOut{Repo: g.Repo, Role: p.Role, Caps: g.Caps})
		remote := setupRemote{Repo: g.Repo, URL: d.link("/r/" + g.Repo)}
		if r, err := d.repo(g.Repo); err == nil {
			remote.ProjectCode = r.Code
		}
		out.Remotes = append(out.Remotes, remote)
	}
	out.after = func() {
		for _, g := range res.Grants {
			// The settings log: creation, grant and mint in one row, the
			// approver as actor, the asker and the request named, never
			// the value.
			if d.SettingLog != nil {
				now := fmt.Sprintf("agent %q set up as %s; token %q (%s), %s, %s (asked by %s, request %s)",
					p.Name, p.Role, res.Token.Name, res.Token.ID, strings.Join(res.Token.Scopes, ", "), setupReach(res.Token), ask.agent, ask.id)
				if err := d.SettingLog.LogSetting(g.Repo, who, ops.Via(ctx), "agent-setup", "", now); err != nil {
					log.Printf("settings log: agent setup on %s: %v", g.Repo, err)
				}
			}
			d.raiseAccountChange(ctx, g.Repo, p.Name, "", d.agentHolds(p.Name, g.Repo))
			agent, repo, held := p.Name, g.Repo, g.Caps != ""
			d.provision(func(ctx context.Context) error { return d.Provision.SetTopicMember(ctx, repo, agent, held) })
		}
		name := p.Name
		d.provision(func(ctx context.Context) error { return d.Provision.EnsureSubscriber(ctx, name) })
		if d.Acts != nil {
			actor := fmt.Sprintf("%s (asked by %s, request %s: set up as %s, token %s)", who, ask.agent, ask.id, p.Role, res.Token.ID)
			if err := d.Acts.RecordForgeAct(actor, setupOp, "", p.Name); err != nil {
				logForgeActFailure(setupOp, "", err)
			}
		}
	}
	return out
}

// setupReach says in words which repositories a token reaches.
func setupReach(t oltp.Token) string {
	if len(t.Repos) == 0 {
		return "every repository its agent reaches"
	}
	return strings.Join(t.Repos, ", ")
}

// retractSetup takes back a setup whose result could not be held: the
// agent, its grants and its token, with the name left free. Nothing
// outward had gone out, so there is nothing else to undo.
func (d Deps) retractSetup(out any) error {
	o, ok := out.(agentSetupOut)
	if !ok {
		return nil
	}
	as, err := d.agents()
	if err != nil {
		return err
	}
	return as.RetractSetup(o.Agent, o.Token.ID)
}

// Outcomes of a setup that did not complete, kept in the request's
// result so the page tells the truth about what remains.
const (
	setupUndone = "undone"
	setupLeft   = "left"
	// setupUnknown is a failed setup whose outcome was never recorded.
	setupUnknown = "unknown"
)

type setupOutcome struct {
	Outcome string   `json:"setup_outcome"`
	Left    []string `json:"left,omitempty"`
}

// SetupView is what a setup request's page says before it is approved
// and after: every line is data, so the page draws each as its own
// element and a test reads each value. Before approval it is the plan as
// the approver stands now; after, what the approved call answered.
type SetupView struct {
	Agent string `json:"agent"`
	Kind  string `json:"kind"`
	// Belongs is repository or person and Owner names which.
	Belongs string `json:"belongs,omitempty"`
	Owner   string `json:"owner,omitempty"`
	// Repos are where the agent is put to work, Role what it holds
	// there, RoleSentence what that lets it do.
	Repos        []string `json:"repos"`
	Role         string   `json:"role"`
	RoleSentence string   `json:"role_sentence"`
	// The credential: its scopes, the repositories it reaches, its
	// ceiling, its fixed life in days (0 for none).
	Scopes     []string `json:"scopes"`
	TokenRepos []string `json:"token_repos"`
	Ceiling    string   `json:"ceiling"`
	Days       int      `json:"days"`
	// Pickup is the collector's code, "" for the asking agent.
	Pickup string `json:"pickup,omitempty"`
	// Done says the approved call ran and the values are its own;
	// Token is then the name it minted under, never the value.
	Done  bool   `json:"done,omitempty"`
	Token string `json:"token,omitempty"`
	// Outcome says what became of a setup that failed: undone (nothing
	// was made) or left (something remains, named in Left).
	Outcome string   `json:"outcome,omitempty"`
	Left    []string `json:"left,omitempty"`
	// Problem says, to the approver only, why the request could not run
	// as things stand.
	Problem string `json:"problem,omitempty"`
}

// setupViewOf is the page's account of a setup request, nil for any
// other op. who is the reader: only the person asked is told why a
// request could not run.
func (d Deps) setupViewOf(s SignOffRequest, who string) *SetupView {
	if s.Op != setupOp {
		return nil
	}
	var in agentSetupIn
	if err := json.Unmarshal([]byte(s.Input), &in); err != nil {
		return nil
	}
	kind, repos, role, err := setupShape(in)
	if err != nil {
		return nil
	}
	preset, _ := oltp.ParseRole(role)
	v := &SetupView{Agent: in.Name, Kind: kind, Repos: repos, Role: role, Days: in.Days, Pickup: s.Pickup,
		TokenRepos: repos, RoleSentence: setupReadSent}
	v.RoleSentence = setupRoleSentence(kind, role)
	if kind == oltp.SetupPush {
		v.Scopes, v.Ceiling = []string{oltp.ScopeSync}, preset.Caps()
	} else {
		v.Scopes, v.Ceiling = []string{oltp.ScopeAPI}, oltp.SetupReadCeiling
	}
	// What the page shows was fixed when the request was filed: who the
	// agent belongs to and the letters of the ceiling are read back from
	// the stored input, not decided again from the forge as it is now.
	if in.Ceiling != "" {
		v.Ceiling = in.Ceiling
	}
	if b := strings.ToLower(in.BelongsTo); b == setupBelongRepo {
		v.Belongs, v.Owner = b, repos[0]
	} else if b == setupBelongMe {
		v.Belongs, v.Owner = b, s.Owner
	}
	if s.State == "failed" {
		var oc setupOutcome
		if json.Unmarshal([]byte(s.Result), &oc) == nil && oc.Outcome != "" {
			v.Outcome, v.Left = oc.Outcome, oc.Left
		} else {
			// What became of it could not be written down, so nothing can
			// honestly be said: the page asks for a look.
			v.Outcome = setupUnknown
		}
	}
	var done agentSetupOut
	if s.Result != "" && json.Unmarshal([]byte(s.Result), &done) == nil && done.Agent != "" {
		v.Done, v.Token = true, done.Token.Name
		v.Scopes, v.TokenRepos, v.Ceiling = done.Token.Scopes, done.Token.Repos, done.Token.Ceiling
		v.Repos = v.Repos[:0:0]
		for _, g := range done.Grants {
			v.Repos = append(v.Repos, g.Repo)
		}
		if repo, ok := strings.CutPrefix(done.Operator, "repo:"); ok {
			v.Belongs, v.Owner = setupBelongRepo, repo
		} else {
			v.Belongs, v.Owner = setupBelongMe, done.Operator
		}
		return v
	}
	if _, err := d.planSetup(s.Owner, in); err != nil && who == s.Owner && s.State == "pending" {
		v.Problem = strings.TrimRight(ops.Sentence(err), ".") + "."
	}
	return v
}