From 6182730923f21bf0332f81b486d9ac956f3f3bca Mon Sep 17 00:00:00 2001 From: jochen Date: Thu, 12 Nov 2026 05:58:25 +0100 Subject: [PATCH] GR2: grants_callers and grants_why_refused MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- modules/mesh-grants/cmd/mesh-grants/tools.go | 12 ++ .../internal/buscompose/callers.go | 3 + .../mesh-grants/internal/grants/callers.go | 139 ++++++++++++++++++ .../internal/grants/callers_test.go | 57 +++++++ modules/mesh-grants/internal/grants/tools.go | 2 +- modules/mesh-grants/module.json | 4 +- 6 files changed, 215 insertions(+), 2 deletions(-) create mode 100644 modules/mesh-grants/internal/grants/callers.go create mode 100644 modules/mesh-grants/internal/grants/callers_test.go diff --git a/modules/mesh-grants/cmd/mesh-grants/tools.go b/modules/mesh-grants/cmd/mesh-grants/tools.go index 54971dea..93153dfc 100644 --- a/modules/mesh-grants/cmd/mesh-grants/tools.go +++ b/modules/mesh-grants/cmd/mesh-grants/tools.go @@ -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 .", "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"), diff --git a/modules/mesh-grants/internal/buscompose/callers.go b/modules/mesh-grants/internal/buscompose/callers.go index 5af25dd0..2cd8a779 100644 --- a/modules/mesh-grants/internal/buscompose/callers.go +++ b/modules/mesh-grants/internal/buscompose/callers.go @@ -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..tool.`, with the node last on a node seat. +func SeatToolSubject(s Seat, verb, node string) string { return seatToolSubject(s, verb, node) } diff --git a/modules/mesh-grants/internal/grants/callers.go b/modules/mesh-grants/internal/grants/callers.go new file mode 100644 index 00000000..677f053b --- /dev/null +++ b/modules/mesh-grants/internal/grants/callers.go @@ -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 (`.`), 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 .") + } + 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 +} diff --git a/modules/mesh-grants/internal/grants/callers_test.go b/modules/mesh-grants/internal/grants/callers_test.go new file mode 100644 index 00000000..2c73338b --- /dev/null +++ b/modules/mesh-grants/internal/grants/callers_test.go @@ -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") + } +} diff --git a/modules/mesh-grants/internal/grants/tools.go b/modules/mesh-grants/internal/grants/tools.go index 1942cb64..0f2c3fe4 100644 --- a/modules/mesh-grants/internal/grants/tools.go +++ b/modules/mesh-grants/internal/grants/tools.go @@ -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). diff --git a/modules/mesh-grants/module.json b/modules/mesh-grants/module.json index 7b3efe8b..4e112870 100644 --- a/modules/mesh-grants/module.json +++ b/modules/mesh-grants/module.json @@ -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": [ {