GR2: grants_callers and grants_why_refused
The first reads the callers each verb and accepted kind of the seat definitions admits, the second says which call subject an account lacks (the bus's own nats_user_can) beside the definition's callers. Builds: design 47 G §83, §391, §395 Claude-Session: https://claude.ai/code/session_01NcbgfGKaxw984XyAPfLm4h
This commit is contained in:
@@ -257,6 +257,18 @@ func toolsOf(p *grants.Part) []stdio.Tool {
|
||||
"composed yet by this version of the part, with its step, the acting part, its version, since when, its bound (10 minutes) and " +
|
||||
"clock-from (the start of this version's first pass), and which have passed their bound. Read-only.",
|
||||
Run: func(map[string]any) (any, error) { return p.WaitsOf() }},
|
||||
{Name: grants.ToolCallers, Description: "The callers each verb and each accepted kind of the seat definitions admits (design 47 G §83): " +
|
||||
"the list the definition names, or the default of the verb's class. Name a seat, or a seat and a verb, or neither for every one. Read-only.",
|
||||
Input: schema(map[string]string{"seat": "a seat's name (default: every seat)", "verb": "a verb or accepted kind (default: every one)"}),
|
||||
Run: func(a map[string]any) (any, error) {
|
||||
return p.Callers(grants.Answer(a, "seat"), grants.Answer(a, "verb"))
|
||||
}},
|
||||
{Name: grants.ToolWhyRefused, Description: "Why an account's call of a seat's verb is refused (design 47 G §83): the call subject the account " +
|
||||
"lacks a grant for (the bus's own answer, nats_user_can) and the definition's callers that leave it out. Read-only.",
|
||||
Input: schema(map[string]string{"account": "the bus user, e.g. g14.node-tools", "verb": "the verb as <seat>.<verb>", "node": "the node, for a node seat"}, "account", "verb"),
|
||||
Run: func(a map[string]any) (any, error) {
|
||||
return p.WhyRefused(grants.Answer(a, "account"), grants.Answer(a, "verb"), grants.Answer(a, "node"))
|
||||
}},
|
||||
{Name: grants.ToolRelease, Description: "Release one held granting wait by a person's act with why, recorded as a hand act " +
|
||||
"(the seat mesh-hand-acts) before it is released (design 47 G §276). grants_waits lists them.",
|
||||
Input: schema(map[string]string{"module": "the held entry's module", "why": "why, kept in the hand act"}, "module", "why"),
|
||||
|
||||
@@ -115,3 +115,6 @@ func callerNamed(user string, pub, sub []string) ([]string, []string) {
|
||||
}
|
||||
return unique(pub), unique(sub)
|
||||
}
|
||||
|
||||
// SeatToolSubject is where a seat's verb is served: `mesh.seat.<seat>.tool.<verb>`, with the node last on a node seat.
|
||||
func SeatToolSubject(s Seat, verb, node string) string { return seatToolSubject(s, verb, node) }
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
package grants
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"slices"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/seatdef"
|
||||
|
||||
"mesh-grants/internal/buscompose"
|
||||
)
|
||||
|
||||
// The tools that say who may call and why a call is refused (design 47 G §83, GR2).
|
||||
const (
|
||||
ToolCallers = "grants_callers"
|
||||
ToolWhyRefused = "grants_why_refused"
|
||||
)
|
||||
|
||||
// CallerLine is one verb's, or one accepted kind's, callers as a seat definition gives them.
|
||||
type CallerLine struct {
|
||||
Seat string `json:"seat"`
|
||||
// Verb is the verb's name, or the accepted kind's.
|
||||
Verb string `json:"verb"`
|
||||
// Kind is `verb` or `accepts`.
|
||||
Kind string `json:"kind"`
|
||||
Class string `json:"class,omitempty"`
|
||||
// Callers are the entries the verb admits: the list the definition names, or its class's default.
|
||||
Callers []string `json:"callers"`
|
||||
// Named says the definition names the list; false is the class's default (§391).
|
||||
Named bool `json:"named"`
|
||||
}
|
||||
|
||||
// definitions are the seat definitions the part composes from, or why it has none.
|
||||
func (p *Part) definitions() (map[string]seatdef.Definition, error) {
|
||||
d, ok := p.SeatList.(SeatDefinitions)
|
||||
if !ok {
|
||||
return nil, errors.New("the seat list is not read here: the definitions' callers are unknown")
|
||||
}
|
||||
defs := d.Definitions()
|
||||
if len(defs) == 0 {
|
||||
return nil, errors.New("the seat list holds no definitions yet")
|
||||
}
|
||||
return defs, nil
|
||||
}
|
||||
|
||||
// CallerLines are the callers each verb and each accepted kind admits, from the definitions, of the seat named (every seat
|
||||
// when empty) and the verb named (every verb when empty).
|
||||
func CallerLines(defs map[string]seatdef.Definition, seat, verb string) []CallerLine {
|
||||
var out []CallerLine
|
||||
for _, d := range defs {
|
||||
if seat != "" && d.Seat != seat {
|
||||
continue
|
||||
}
|
||||
for _, v := range d.Verbs {
|
||||
if verb == "" || v.Name == verb {
|
||||
out = append(out, CallerLine{Seat: d.Seat, Verb: v.Name, Kind: "verb", Class: v.Class,
|
||||
Callers: v.EffectiveCallers(), Named: v.Callers != nil && !v.Callers.IsText()})
|
||||
}
|
||||
}
|
||||
for _, a := range d.Accepts {
|
||||
if verb == "" || a.Name == verb {
|
||||
l := CallerLine{Seat: d.Seat, Verb: a.Name, Kind: "accepts"}
|
||||
if a.Callers != nil && !a.Callers.IsText() {
|
||||
l.Callers, l.Named = a.Callers.List, true
|
||||
}
|
||||
out = append(out, l)
|
||||
}
|
||||
}
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool {
|
||||
a, b := out[i], out[j]
|
||||
return a.Seat+"\x00"+a.Kind+"\x00"+a.Verb < b.Seat+"\x00"+b.Kind+"\x00"+b.Verb
|
||||
})
|
||||
return out
|
||||
}
|
||||
|
||||
// Callers answers the tool grants_callers.
|
||||
func (p *Part) Callers(seat, verb string) (any, error) {
|
||||
defs, err := p.definitions()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
lines := CallerLines(defs, seat, verb)
|
||||
if len(lines) == 0 {
|
||||
return nil, fmt.Errorf("no seat definition names a verb or an accepted kind matching seat %q verb %q", seat, verb)
|
||||
}
|
||||
return map[string]any{"callers": lines, "count": len(lines)}, nil
|
||||
}
|
||||
|
||||
// WhyRefused answers the tool grants_why_refused: the grant an account lacks to call address (`<seat>.<verb>`), and the
|
||||
// definition's line that leaves it out. The bus answers whether the account may publish the verb's call subject
|
||||
// (nats.nats_user_can, the account's grants as the server holds them); the definition's callers say who was meant to.
|
||||
func (p *Part) WhyRefused(account, address, node string) (any, error) {
|
||||
seat, verb, ok := strings.Cut(address, ".")
|
||||
if account == "" || !ok || seat == "" || verb == "" {
|
||||
return nil, errors.New("why_refused names an account and a verb as <seat>.<verb>")
|
||||
}
|
||||
defs, err := p.definitions()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
lines := CallerLines(defs, seat, verb)
|
||||
if len(lines) == 0 {
|
||||
return nil, fmt.Errorf("no seat definition names the verb %s", address)
|
||||
}
|
||||
def := defs[seat]
|
||||
s := buscompose.Seat{Name: seat, Scope: def.Scope}
|
||||
subject := buscompose.CalledSubject(buscompose.SeatToolSubject(s, verb, node), account)
|
||||
if subject == "" {
|
||||
return nil, fmt.Errorf("%s is not the name of a bus user", account)
|
||||
}
|
||||
out := map[string]any{"account": account, "verb": address, "subject": subject, "definition": lines}
|
||||
raw, err := p.Call.Call(CallUserCan, map[string]any{"user": account, "action": "publish", "subject": subject})
|
||||
if err != nil {
|
||||
out["bus"] = "not read: " + err.Error()
|
||||
return out, nil
|
||||
}
|
||||
var can struct {
|
||||
Allowed *bool `json:"allowed"`
|
||||
}
|
||||
_ = json.Unmarshal(raw, &can)
|
||||
out["bus"] = json.RawMessage(raw)
|
||||
switch {
|
||||
case can.Allowed == nil:
|
||||
out["says"] = "the bus's answer names no verdict; read it beside the definition's line"
|
||||
case *can.Allowed:
|
||||
out["says"] = "not refused at the bus: the account holds the grant to publish " + subject
|
||||
default:
|
||||
callers := lines[0].Callers
|
||||
out["says"] = fmt.Sprintf("the account lacks the grant to publish %s; the definition admits %s (%s), and the grants part grants a call only to a caller one of those entries names",
|
||||
subject, strings.Join(callers, ", "), map[bool]string{true: "named by the definition", false: "the class's default"}[lines[0].Named])
|
||||
out["lacks"] = subject
|
||||
out["admitted_by"] = slices.Clone(callers)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
package grants
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/seatdef"
|
||||
)
|
||||
|
||||
type defsGate struct{ defs map[string]seatdef.Definition }
|
||||
|
||||
func (defsGate) MayAct(...string) bool { return true }
|
||||
func (g defsGate) Definitions() map[string]seatdef.Definition { return g.defs }
|
||||
|
||||
type canCall struct {
|
||||
allowed bool
|
||||
asked map[string]any
|
||||
}
|
||||
|
||||
func (c *canCall) Call(key string, body any) (json.RawMessage, error) {
|
||||
c.asked = body.(map[string]any)
|
||||
return json.Marshal(map[string]any{"allowed": c.allowed})
|
||||
}
|
||||
|
||||
// grants_callers and grants_why_refused read the definitions' callers (design 47 G §83): a verb that names none takes its
|
||||
// class's default, and a refused account is told the call subject it lacks and the callers the definition admits.
|
||||
func TestCallersAndWhyRefused(t *testing.T) {
|
||||
defs := map[string]seatdef.Definition{"mesh-wants": {Seat: "mesh-wants", Scope: "mesh", Verbs: []seatdef.Verb{
|
||||
{Name: "upgrade", Class: seatdef.ClassAct},
|
||||
{Name: "entry", Callers: &seatdef.Callers{List: []string{"seat:mesh-pipeline-runner", "operator"}}},
|
||||
}}}
|
||||
lines := CallerLines(defs, "mesh-wants", "")
|
||||
if len(lines) != 2 || lines[0].Verb != "entry" || !lines[0].Named || lines[1].Named ||
|
||||
strings.Join(lines[1].Callers, ",") != "operator,sessions" {
|
||||
t.Fatalf("lines: %+v", lines)
|
||||
}
|
||||
can := &canCall{}
|
||||
p := &Part{SeatList: defsGate{defs}, Call: can}
|
||||
got, err := p.WhyRefused("one.shop", "mesh-wants.upgrade", "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
out := got.(map[string]any)
|
||||
if out["lacks"] != "mesh.seat.mesh-wants.call.upgrade.one~shop" || can.asked["subject"] != out["lacks"] ||
|
||||
!strings.Contains(out["says"].(string), "operator, sessions") {
|
||||
t.Errorf("a refused caller: %+v", out)
|
||||
}
|
||||
can.allowed = true
|
||||
got, _ = p.WhyRefused("one.shop", "mesh-wants.upgrade", "")
|
||||
if _, lacks := got.(map[string]any)["lacks"]; lacks {
|
||||
t.Errorf("an allowed caller lacks a grant: %+v", got)
|
||||
}
|
||||
if _, err := p.WhyRefused("one.shop", "mesh-wants.nothing", ""); err == nil {
|
||||
t.Error("a verb no definition names was explained")
|
||||
}
|
||||
}
|
||||
@@ -40,7 +40,7 @@ const (
|
||||
|
||||
// Tools is every tool, in the manifest's order.
|
||||
var Tools = []string{ToolGrants, ToolMembership, ToolUserList, ToolComposeNow, ToolAssert, ToolAccounts, ToolConsumerMatch,
|
||||
ToolAcceptHashes, ToolFirstViews, ToolFirstViewAgain, ToolRunning, ToolWaits, ToolRelease, ToolSeatList, ToolSeatListAgain}
|
||||
ToolAcceptHashes, ToolFirstViews, ToolFirstViewAgain, ToolRunning, ToolWaits, ToolRelease, ToolSeatList, ToolSeatListAgain, ToolCallers, ToolWhyRefused}
|
||||
|
||||
// WritesGrants is what grants says of the part's own grants: written once the list the bus loaded carries a build's
|
||||
// accounts, and the one key the assigner reads (ADR 0571 clause 9, which changes ADR 0547 clause 5).
|
||||
|
||||
@@ -73,7 +73,9 @@
|
||||
"grants_waits",
|
||||
"grants_release",
|
||||
"grants_seat_list",
|
||||
"grants_seat_list_again"
|
||||
"grants_seat_list_again",
|
||||
"grants_callers",
|
||||
"grants_why_refused"
|
||||
],
|
||||
"state": [
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user