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) {
return d.planSetupAs(owner, in, true)
}
// planSetupAs is planSetup, with the name judged only when checkName: a
// claim page asks, repository by repository, what the person may do, and
// says once, in words, when the name itself cannot be had.
func (d Deps) planSetupAs(owner string, in agentSetupIn, checkName bool) (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 checkName {
if err := d.setupNameProblem(in.Name); err != nil {
return setupPlan{}, 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
}
// setupNameProblem is the refusal a name that setup cannot make an agent
// under gets, nil when it is free.
func (d Deps) setupNameProblem(name string) error {
if d.accountLike(name) {
return fmt.Errorf("%s is already an account; setup makes new agents only: %w", name, ops.ErrInvalid)
}
if sc, ok := d.Accounts.(setupChecker); ok {
if err := sc.SetupNameFree(name); err != nil {
return refusalAsInvalid(fmt.Errorf("%w: %v", ops.ErrInvalid, err))
}
}
return 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
}