Skip to content
quarry Artifact [36cab29d7a]
Login

Artifact [36cab29d7a]

Artifact 36cab29d7a47b71919760108606a27bd371e59e75e1bffe0a98fc74072f7c599:


package api

import (
	"context"
	"encoding/json"
	"errors"
	"fmt"
	"reflect"
	"strings"

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

// RFC 0027: an agent refused for want of authority its person holds may
// ask them instead. The transports find this through the registry
// (ops.Registry.AskerOf) and call it only when the caller sent
// Quarry-Ask; it decides whether this caller may ask for this call,
// files the request and tells the person.

// personFor is who answers for agent: a personal agent's operator, a
// forge agent's answerable admin. "" for a person, for an agent whose
// person is gone or shut, or for an agent that answers to another
// agent.
func (d Deps) personFor(agent string) string {
	acct, ok := d.accountNamed(agent)
	if !ok || acct.Operator == "" || acct.Disabled != "" {
		return ""
	}
	person := acct.Operator
	if person == forgeOperator {
		person = acct.Answerable
	}
	p, ok := d.accountNamed(person)
	if !ok || p.Disabled != "" || p.Operator != "" {
		return ""
	}
	return person
}

// askPerson is the forge's ops.Asker.
func (d Deps) askPerson(ctx context.Context, desc *ops.Descriptor, repo string, in any, reason string) (any, bool, error) {
	if d.SignOffs == nil {
		return nil, false, nil
	}
	agent := ops.User(ctx)
	person := d.personFor(agent)
	if person == "" || !askableFor(desc.Name, person, in) {
		return nil, false, nil
	}
	if !d.agentAsks(agent) {
		return nil, true, fmt.Errorf("%s takes no requests from %s: %w", person, agent, ops.ErrForbidden)
	}
	// The agent's own scope bounds what it may ask about: a token
	// limited to repositories asks only about those, and nothing
	// forge-wide.
	if tok, byToken := ops.TokenOf(ctx); byToken {
		if repo == "" && tok.Limited {
			return nil, false, nil
		}
		if repo != "" && tok.Reaches != nil && !tok.Reaches(repo) {
			return nil, false, nil
		}
	}
	// And the person must hold what the call needs. Judged without the
	// agent's token: these are the person's own letters, or their site
	// flag for an operator op. An op that names no letters is judged
	// when it runs, as them.
	if desc.Caps != "" {
		holds := d.CapsFor != nil && d.CapsFor(person, repo).HasAny(desc.Caps)
		if !holds && (desc.Operator || desc.Scope == ops.ScopeFleet) {
			holds = d.isSiteAdmin(context.Background(), person)
		}
		if !holds {
			return nil, false, nil
		}
	}
	// A repository the agent cannot read is answered as one that does
	// not exist, by asking as by refusing.
	if repo != "" && d.CanRead != nil && !d.CanRead(ctx, repo) {
		return nil, false, nil
	}
	code := ""
	if repo != "" {
		r, err := d.repo(repo)
		if err != nil {
			return nil, false, nil
		}
		code = r.Code
	}
	raw, err := json.Marshal(in)
	if err != nil {
		return nil, true, err
	}
	reason = strings.TrimSpace(reason)
	if len(reason) > 200 {
		reason = reason[:200]
	}
	summary := desc.Sentence(in)
	if summary == "" {
		summary = describeCall(desc.Name, repo, in)
	}
	s, err := d.SignOffs.RequestSignOff(SignOffRequest{Agent: agent, Owner: person, Op: desc.Name, Repo: repo,
		Code: code, Input: string(raw), Summary: summary, Reason: reason})
	if err != nil {
		if errors.Is(err, ErrTooManySignOffs) {
			return nil, true, fmt.Errorf("%s already has as many requests waiting for %s as it may: %w", agent, person, ops.ErrInvalid)
		}
		return nil, true, err
	}
	d.tellPerson(s)
	return askedOut{SignOff: s}, true, nil
}

// ErrTooManySignOffs is a SignOffStore's refusal of a request from an
// agent that already has as many waiting as it may (RFC 0027, Limits).
var ErrTooManySignOffs = errors.New("too many sign-off requests waiting")

// askedOut is what an agent that asked is answered with.
type askedOut struct {
	SignOff SignOffRequest `json:"signoff"`
}

// describeCall names a call for the person in words that carry no
// secrets: the op, and the repository and account it names. Never the
// input itself, which may hold a password.
func describeCall(op, repo string, in any) string {
	s := op
	name := inputField(in, "Agent")
	if name == "" {
		name = nameOf(in)
	}
	if name != "" && name != repo {
		s += " for " + name
	}
	if repo != "" {
		s += " on " + repo
	}
	return s
}

// tellPerson reaches the person wherever they are: the page's attention
// stream, push and email, as every sign-off does.
func (d Deps) tellPerson(s SignOffRequest) {
	summary := s.Summary
	if s.Reason != "" {
		summary += ` ("` + s.Reason + `")`
	}
	d.attend(s.Owner, AttentionEvent{Kind: "signoff", Repo: s.Repo, From: s.Agent, Summary: summary, Link: "/me/requests/" + s.ID})
	d.fireNotify(NotifyEvent{
		Workflow: "quarry-signoff", To: s.Owner,
		Payload: map[string]any{
			"repo": s.Repo, "from": s.Agent, "summary": summary, "surface": "signoff",
			"link": d.link("/me/requests/" + s.ID),
		},
		TxnID: "signoff:" + s.ID,
	})
}

// prefAsk is the agent account's setting that turns asking off: "off",
// or unset for on (RFC 0027, Limits).
const prefAsk = "ask"

// agentAsks reports whether agent may ask its person.
func (d Deps) agentAsks(agent string) bool {
	return d.Prefs == nil || d.Prefs.AccountPref(agent, prefAsk) != "off"
}

// inputField is an input's string field by name, or "".
func inputField(in any, field string) string {
	v := reflect.ValueOf(in)
	if v.Kind() == reflect.Ptr {
		v = v.Elem()
	}
	if v.Kind() != reflect.Struct {
		return ""
	}
	if f := v.FieldByName(field); f.IsValid() && f.Kind() == reflect.String {
		return f.String()
	}
	return ""
}