Skip to content
quarry Artifact [a733fcbe65]
Login

Artifact [a733fcbe65]

Artifact a733fcbe65acfdff39bc600b5666e55754c9761a215496b1180a344e36bce433:


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)
	}
	if !d.mayAsk(ctx, desc, repo, person, in) {
		return nil, false, nil
	}
	// A request is checked when it is asked: one the owner could not
	// run, or that names what the agent itself cannot see, is not asked
	// at all, and the agent is left with the refusal its plain call
	// already got. Asking never says more than refusing would, so no
	// distinct answer for a missing repository, a missing agent or
	// somebody else's agent is ever given.
	if !d.preflightAsk(ctx, desc.Name, person, in) {
		return nil, false, nil
	}
	pickup := pickupCodeOf(ctx)
	if err := checkPickupCode(pickup); err != nil {
		return nil, true, err
	}
	if pickup != "" {
		if err := pickupAllowedFor(desc); err != nil {
			return nil, true, err
		}
	}
	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, Pickup: pickup})
	if err != nil {
		if errors.Is(err, ErrPickupBound) || errors.Is(err, ErrPickupMismatch) {
			return nil, true, pickupAskError(err)
		}
		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
}

// preflights are the ops that can say, before a request is recorded,
// whether the owner could run them with this input. An op with no entry
// is asked as it always was and judged when the owner approves. caller
// is the asking agent's own context and owner the person's.
var preflights = map[string]func(d Deps, caller, owner context.Context, in any) bool{
	"agent.grant": func(d Deps, caller, owner context.Context, in any) bool {
		repo := inputField(in, "Repo")
		// A repository the agent cannot read does not exist for it, by
		// asking as by refusing.
		if d.CanRead != nil && !d.CanRead(caller, repo) {
			return false
		}
		as, err := d.agents()
		if err != nil || d.agentToPutToWork(owner, as, inputField(in, "Agent")) != nil {
			return false
		}
		if _, err = d.repoFor(owner, repo); err != nil {
			return false
		}
		if inputField(in, "Role") != "" || strings.TrimSpace(inputField(in, "Caps")) != "" {
			return d.refuseOutOfScope(owner, as.Operator(inputField(in, "Agent")), repo) == nil
		}
		return true
	},
}

// preflightAsk runs op's preflight, if it has one: false means do not
// ask.
func (d Deps) preflightAsk(caller context.Context, op, owner string, in any) bool {
	check, ok := preflights[op]
	if !ok {
		return true
	}
	return check(d, caller, ops.WithUser(context.Background(), owner), in)
}

// mayAsk reports whether the calling agent may ask person for this call
// (RFC 0027): inside the agent's own scope, something the person holds,
// on a repository the agent can see. A plan's steps pass the same test.
func (d Deps) mayAsk(ctx context.Context, desc *ops.Descriptor, repo, person string, in any) bool {
	// 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 {
		// One exception: an agent asking about its OWN token, for a new
		// one or a wider reach. Without it an agent on a token limited to
		// repositories could never be given more than it has (#411); the
		// person sees and approves exactly what is granted.
		if repo == "" && tok.Limited && !ownCredential(desc.Name, ops.User(ctx), in) {
			return false
		}
		if repo != "" && tok.Reaches != nil && !tok.Reaches(repo) {
			return false
		}
	}
	// 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 false
		}
	}
	// 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 false
	}
	return true
}

// 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.fireSignOff(s.Owner, s.Repo, s.Agent, summary, s.ID)
}

// fireSignOff tells a person about a request beyond an open tab: the
// inbox row and the push at once, and the email through a five-minute
// digest per agent, so a burst is one email (RFC 0028).
func (d Deps) fireSignOff(to, repo, agent, summary, id string) {
	payload := func() map[string]any {
		p := map[string]any{
			"repo": repo, "from": agent, "actor": agent, "summary": summary, "surface": "signoff",
			"link": d.link("/me/requests/" + id), "waiting": d.link("/me/requests"),
		}
		if repo == "" {
			p["repoName"] = "Requests" // what the email's header names
		}
		return p
	}
	d.fireNotify(NotifyEvent{Workflow: "quarry-signoff", To: to, Payload: payload(), TxnID: "signoff:" + id})
	d.fireNotify(NotifyEvent{Workflow: "quarry-signoff-email", To: to, Payload: payload(), TxnID: "signoff-email:" + 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 ""
}

// ownCredential is a call about the caller's own tokens: a new one for
// itself, or a change to which repositories one of its own reaches.
func ownCredential(op, caller string, in any) bool {
	switch op {
	case "agent.token.new", "agent.token.repos":
		return caller != "" && inputField(in, "Agent") == caller
	}
	return false
}

// pickupAllowedFor refuses a pickup code on a call that cannot use one:
// its answer holds no credential, or holds one a lapse cannot revoke,
// which must not be held where nobody may collect it (decided by
// lapseOps, the one table of what a lapse revokes).
func pickupAllowedFor(desc *ops.Descriptor) error {
	if !desc.HasOnceSecret() {
		return fmt.Errorf("this call leaves no credential for a pickup code to collect; leave Quarry-Pickup out: %w", ops.ErrInvalid)
	}
	if !parkable(desc.Name) {
		return fmt.Errorf("this kind of value cannot be held for pickup: nothing could revoke it if it were never collected; leave Quarry-Pickup out: %w", ops.ErrInvalid)
	}
	return nil
}