diff --git a/modules/mesh-grants/internal/buscompose/gated.go b/modules/mesh-grants/internal/buscompose/gated.go new file mode 100644 index 00000000..d87f4aaf --- /dev/null +++ b/modules/mesh-grants/internal/buscompose/gated.go @@ -0,0 +1,54 @@ +package buscompose + +import ( + "slices" + "strings" + + "git.novox.be/novox/mesh-sdk/go/keys" + "git.novox.be/novox/mesh-sdk/go/seatdef" +) + +// **A held call's end is heard by its caller alone** (design 47 G §677; lead answer A-264): a holder says the end of a call of +// a marked verb on its seat's event `gated-ended`, subject `mesh.seat..event.gated-ended..`, `` the +// account the bus proved escaped as a key's name is, kept last per subject for a day in the stream SEAT__gated-ended +// (KeptEventsOf, from the definition seatdef.Resolve gives). The caller is granted hearing `gated-ended..>` and no other +// caller's, from its invokes of the marked verb by name; it reads the stream through an ordered consumer filtered to its own +// subjects (mesh-sdk process awaitGated), made for the read. A process that answers a marked verb says the event to any caller. + +// gatedEndedGrants are the grants of the principal user from the marked verbs (`seat:.`) it invokes by name and +// those it answers. +func gatedEndedGrants(user string, marked, invokes, answers []string) (pub, sub []string) { + caller := keys.EscapeName(user) + if user == "" { + return nil, nil + } + var heard, said []string + for _, v := range marked { + seat, _, ok := strings.Cut(strings.TrimPrefix(v, "seat:"), ".") + if !ok || !safeSubject.MatchString(seat) { + continue + } + if slices.Contains(invokes, v) { + heard = append(heard, seat) + } + if slices.Contains(answers, v) { + said = append(said, seat) + } + } + for _, seat := range unique(heard) { + stream := seatdef.KeptStream(seat, seatdef.Message{Name: seatdef.GatedEndedEvent}) + own := GatedEndedSubject(seat, caller) + ".>" + sub = append(sub, own) + pub = append(pub, "$JS.API.STREAM.INFO."+stream, "$JS.API.CONSUMER.CREATE."+stream+".*."+own, + "$JS.API.CONSUMER.MSG.NEXT."+stream+".*", "$JS.API.CONSUMER.INFO."+stream+".*", "$JS.API.CONSUMER.DELETE."+stream+".*") + } + for _, seat := range unique(said) { + pub = append(pub, GatedEndedSubject(seat, "*")+".*") + } + return pub, sub +} + +// GatedEndedSubject is the subject of the seat's gated-ended for one caller, its escaped name (keys.EscapeName) or `*`. +func GatedEndedSubject(seat, caller string) string { + return seatdef.EventSubject(seat, seatdef.GatedEndedEvent) + "." + caller +} diff --git a/modules/mesh-grants/internal/buscompose/gated_test.go b/modules/mesh-grants/internal/buscompose/gated_test.go new file mode 100644 index 00000000..9058ebeb --- /dev/null +++ b/modules/mesh-grants/internal/buscompose/gated_test.go @@ -0,0 +1,55 @@ +package buscompose + +import ( + "slices" + "testing" + + "git.novox.be/novox/mesh-sdk/go/seatdef" +) + +// A held call's end is heard by its caller alone (design 47 G §677, A-264): the definition seatdef.Resolve gives a marked verb +// keeps gated-ended in SEAT__gated-ended, last per subject for a day; a module's and a process's account that invoke the +// marked verb by name hear `gated-ended..>` under their own escaped name and read the stream filtered to it, one that +// does not invoke it hears nothing, and a process that answers it says the event to any caller. +func TestAHeldCallsEndIsHeardByItsCallerAlone(t *testing.T) { + def, err := seatdef.Resolve(seatdef.Definition{Seat: "node-login-shell", Verbs: []seatdef.Verb{{Name: "execute", NeedsApproval: true}}}, nil) + if err != nil { + t.Fatal(err) + } + kept := KeptEventsOf(map[string]seatdef.Definition{def.Seat: def}) + if len(kept) != 1 || kept[0].Stream != "SEAT_node-login-shell_gated-ended" || !kept[0].Last || kept[0].MaxAgeDays != 1 || + kept[0].Filter != "mesh.seat.node-login-shell.event.gated-ended.*.*" { + t.Fatalf("kept = %+v, want the stream SEAT_node-login-shell_gated-ended, last per subject, one day", kept) + } + marked := []string{"seat:node-login-shell.execute"} + stream := "SEAT_node-login-shell_gated-ended" + caller, err := PermissionsFor(Principal{Kind: KindModule, Node: "a", Module: "shop", Invokes: marked, MarkedVerbs: marked}) + if err != nil { + t.Fatal(err) + } + own := "mesh.seat.node-login-shell.event.gated-ended.a=2eshop.>" + if !slices.Contains(caller.Subscribe, own) || !slices.Contains(caller.Publish, "$JS.API.CONSUMER.CREATE."+stream+".*."+own) || + !slices.Contains(caller.Publish, "$JS.API.CONSUMER.MSG.NEXT."+stream+".*") { + t.Errorf("the caller hears %v and reads %v, want its own ends alone", caller.Subscribe, caller.Publish) + } + other, _ := PermissionsFor(Principal{Kind: KindModule, Node: "a", Module: "bank", MarkedVerbs: marked}) + for _, s := range append(other.Subscribe, other.Publish...) { + if slices.Contains([]string{own, "$JS.API.STREAM.INFO." + stream}, s) { + t.Errorf("a module that does not invoke the marked verb is granted %s", s) + } + } + proc, err := PermissionsFor(Principal{Kind: KindProcess, Node: "a", Module: "shop", Process: "run", Invokes: marked, MarkedVerbs: marked}) + if err != nil { + t.Fatal(err) + } + if !slices.Contains(proc.Subscribe, "mesh.seat.node-login-shell.event.gated-ended.a=2eshop=2erun.>") { + t.Errorf("a process hears %v, want its own account's ends", proc.Subscribe) + } + holder, err := PermissionsFor(Principal{Kind: KindProcess, Node: "a", Module: "shell", Process: "run", AnswersSeat: marked, MarkedVerbs: marked}) + if err != nil { + t.Fatal(err) + } + if !slices.Contains(holder.Publish, "mesh.seat.node-login-shell.event.gated-ended.*.*") || slices.Contains(holder.Subscribe, own) { + t.Errorf("the holder publishes %v, want gated-ended to any caller and no caller's hearing", holder.Publish) + } +} diff --git a/modules/mesh-grants/internal/buscompose/nats.go b/modules/mesh-grants/internal/buscompose/nats.go index fb77edff..d9293bba 100644 --- a/modules/mesh-grants/internal/buscompose/nats.go +++ b/modules/mesh-grants/internal/buscompose/nats.go @@ -218,6 +218,9 @@ type Principal struct { Invokes []string // AnswersSeat are the seat verbs a process answers, each seat:. (design 47 G A-109), of a process with a bus on its own account, else of the module's own (A-115). AnswersSeat []string + // MarkedVerbs are the marked verbs of its module's declaration (Declared.MarkedVerbs): those of them it invokes by name it + // hears the ends of on gated-ended, and those it answers it says them on (design 47 G §677). + MarkedVerbs []string // AsksIssued are, for the vault's holder, the tools it asks in its own name for a credential a provider's software // issued, each `.` (novox/hq ADR 0305, issue 723, vaultCalls): on the subject that names its caller, @@ -1107,6 +1110,9 @@ func permissionsFor(p Principal) (Permissions, error) { } } } + // 4a. The ends of its calls of a marked verb, on the seat's gated-ended, its own alone (design 47 G §677, A-264). + gp, gs := gatedEndedGrants(p.Username(), p.MarkedVerbs, p.Invokes, p.AnswersSeat) + pub, sub = append(pub, gp...), append(sub, gs...) // 5. Its state, and the state of others it reads (novox/hq ADR 0201): every one read and // watched, its own written too. diff --git a/modules/mesh-grants/internal/buscompose/process_grants.go b/modules/mesh-grants/internal/buscompose/process_grants.go index 97f1ae41..d083408f 100644 --- a/modules/mesh-grants/internal/buscompose/process_grants.go +++ b/modules/mesh-grants/internal/buscompose/process_grants.go @@ -79,6 +79,11 @@ func processGrants(p Principal) (pub, sub []string, err error) { } sub = append(sub, answered...) + // 5c. The ends of its calls of a marked verb, heard on the seat's gated-ended, its own alone; and those of the marked verbs + // it answers, said to any caller (design 47 G §677, A-264). + gp, gs := gatedEndedGrants(p.Username(), p.MarkedVerbs, p.Invokes, p.AnswersSeat) + pub, sub = append(pub, gp...), append(sub, gs...) + // 6. The bus module's process (busstate.go, ADR 0595 §4, §5): every module's bus state it keeps, by name; its own // consumer of what its module consumes; its module's own stream's events; and its conditions through the seats its // module uses (the operator channel's accept.condition in its module's name). diff --git a/modules/mesh-grants/internal/buscompose/users.go b/modules/mesh-grants/internal/buscompose/users.go index 43994e79..576ab9f0 100644 --- a/modules/mesh-grants/internal/buscompose/users.go +++ b/modules/mesh-grants/internal/buscompose/users.go @@ -115,6 +115,9 @@ type Declared struct { // ServesOwn are the seat verbs of this module's processes that declare no bus, each seat:.: such a process // runs on the module's own account, which is granted their subscribes (design 47 G A-115, A-127). ServesOwn []string + // MarkedVerbs are the marked verbs (`seat:.`, definition-marked, design 47 G §677) the module or one of its + // processes invokes or answers: each caller hears its own calls' ends on the seat's gated-ended (gatedEndedGrants). + MarkedVerbs []string // Ledgers are the local names of the ledgers it asks for in `requires` (catalogue.StorageLedger); each assignment of a // module with any gets a ledger principal of its own (issue 1966 D5, ADR 0597 §9). Ledgers []string @@ -295,7 +298,7 @@ func Users(r Records) ([]Principal, error) { out = append(out, Principal{ Kind: KindProcess, Node: node, Module: d.Module, Process: pr.Process, Emits: pr.Emits, Queues: pr.Queues, State: pr.State, Uses: d.Uses, Asks: pr.Asks, - Invokes: pr.Invokes, AnswersSeat: pr.Serves, Removes: pr.Removes, + Invokes: pr.Invokes, AnswersSeat: pr.Serves, Removes: pr.Removes, MarkedVerbs: d.MarkedVerbs, }) // The process of the module holding mesh-broker whose bus declares `keeps` keeps every module's bus state // (busstate.go, ADR 0595 §4, ADR 0626 §1); `keeps` on any other module composes nothing (the judge refuses it, @@ -341,6 +344,7 @@ func Users(r Records) ([]Principal, error) { PerMachine: perMachineNames(d.State), ReadOnly: readOnlyNames(d.State), KeyedReads: d.KeyedReads, Logs: logNames(d.Logs), Streams: d.Streams, Hears: ComposedHears(d), Queues: d.Queues, Objects: objectNames(d.Objects), ReadsObjects: d.ReadsObjects, Bound: d.Bindings, AnswersSeat: d.ServesOwn, + MarkedVerbs: d.MarkedVerbs, }) if d.HoldsRecovery { out[len(out)-1].Recovers = r.RecoveryNames diff --git a/modules/mesh-grants/internal/grants/records.go b/modules/mesh-grants/internal/grants/records.go index 7b3a184c..f537ed5e 100644 --- a/modules/mesh-grants/internal/grants/records.go +++ b/modules/mesh-grants/internal/grants/records.go @@ -184,6 +184,7 @@ func declaredFor(m catalogue.Manifest, seats map[string]catalogue.SeatDeclaratio d.Marked = append(d.Marked, mark) } } + d.MarkedVerbs = markedNamed(d, seats) d.CatchUps = catchUpsOf(m) if _, reads := m.OwnSecrets["broker"]; !reads { d.NoAccount = true @@ -431,6 +432,27 @@ func markedVerbs(m catalogue.Manifest, s catalogue.SeatDeclaration) []string { return out } +// markedNamed are the verbs a seat definition marks (design 46 approval mark §2; design 47 G §677) that the declaration's module +// or one of its processes invokes by name or answers, each `seat:.`, sorted. +func markedNamed(d buscompose.Declared, seats map[string]catalogue.SeatDeclaration) []string { + named := slices.Concat(d.Invokes, d.ServesOwn) + for _, pr := range d.Processes { + named = append(named, slices.Concat(pr.Invokes, pr.Serves)...) + } + var out []string + for _, v := range named { + seat, verb, ok := catalogue.SeatInvoke(v) + if !ok { + continue + } + if i := slices.IndexFunc(seats[seat].Serves, func(s catalogue.Verb) bool { return s.Name == verb }); i >= 0 && seats[seat].Serves[i].NeedsApproval { + out = append(out, v) + } + } + slices.Sort(out) + return slices.Compact(out) +} + func asSeat(s catalogue.SeatDeclaration, declarer string) buscompose.Seat { seat := buscompose.Seat{Name: s.Name, Scope: s.Scope, Accepts: s.Accepts, Emits: s.Emits, Serves: catalogue.VerbNames(s.Serves), Kinded: s.Kinded, ByCaller: s.ByCaller, Proofs: s.Proofs, DeclaredBy: declarer} diff --git a/modules/mesh-grants/internal/grants/sentobjects_test.go b/modules/mesh-grants/internal/grants/sentobjects_test.go index 3f02cf7f..0db59117 100644 --- a/modules/mesh-grants/internal/grants/sentobjects_test.go +++ b/modules/mesh-grants/internal/grants/sentobjects_test.go @@ -44,3 +44,19 @@ func TestSentStoresRead(t *testing.T) { t.Errorf("a used seat's verbs carry %v, a held seat no definition names %v", d.Uses[0].VerbCallers, d.Holds[0].VerbCallers) } } + +// The marked verbs a declaration names (design 47 G §677): a definition-marked verb its module invokes or a process answers, +// and no unmarked verb. +func TestTheMarkedVerbsADeclarationNames(t *testing.T) { + seats := map[string]catalogue.SeatDeclaration{"node-login-shell": {Name: "node-login-shell", + Serves: []catalogue.Verb{{Name: "execute", NeedsApproval: true}, {Name: "list"}}}} + d := buscompose.Declared{Invokes: []string{"seat:node-login-shell.execute", "seat:node-login-shell.list", "shop.tool"}, + Processes: []buscompose.ProcessPrincipal{{Serves: []string{"seat:node-login-shell.execute"}}}} + if got := markedNamed(d, seats); !slices.Equal(got, []string{"seat:node-login-shell.execute"}) { + t.Errorf("marked = %v", got) + } + m := catalogue.Manifest{Module: "shop", Invokes: []string{"seat:node-login-shell.execute"}} + if got := declaredFor(m, seats, nil).MarkedVerbs; !slices.Equal(got, []string{"seat:node-login-shell.execute"}) { + t.Errorf("the declaration carries %v, want the marked verb it invokes", got) + } +}