diff --git a/modules/mesh-grants/internal/buscompose/buscompose_test.go b/modules/mesh-grants/internal/buscompose/buscompose_test.go index 43644ab7..e2e9f89e 100644 --- a/modules/mesh-grants/internal/buscompose/buscompose_test.go +++ b/modules/mesh-grants/internal/buscompose/buscompose_test.go @@ -21,6 +21,7 @@ import ( "git.novox.be/novox/mesh-delivery/sdk/buildstep" "git.novox.be/novox/mesh-sdk/go/announce" "git.novox.be/novox/mesh-sdk/go/catalogue" + "git.novox.be/novox/mesh-sdk/go/seatdef" "github.com/nats-io/nats.go/jetstream" ) @@ -58,6 +59,7 @@ func TestTheCopiedComposerComposesAsTheControllers(t *testing.T) { t.Run("every node's compositions are read by their named readers alone and written by their node", everyNodesCompositions) t.Run("a module's ledger is written by its ledger account alone", aLedgerIsWrittenByItsLedgerAccountAlone) t.Run("the bus module's process alone keeps every module's bus state and serves the broker's verbs", theBusModulesProcessKeepsBusState) + t.Run("chunk subjects go to the principal that answers the verb, and the reverse to an account naming it", chunkSubjectsFollowTheAnswerer) // ADR 0567 §3, §9: a node reads every key of the seat's assignments and its candidates (it composes with the whole // proposal laid over), and none of its open proposals; it writes neither. reads := strings.Join(NodeComposeReads("laptop"), " ") @@ -1380,3 +1382,58 @@ func TestASeatsEventsArePublishedByItsHolderAlone(t *testing.T) { } } } + +// chunkSubjectsFollowTheAnswerer is design 47 G §681 as A-60 has it: the chunk subjects of a `chunks: true` verb are granted to the +// principal that answers the verb (the tool runner's for a carried module's tools bundle), the reverse to an account naming the +// verb by name in `invokes`, none to a `*` entry; and the keeper holds a standing restore grant of the restricted streams alone. +func chunkSubjectsFollowTheAnswerer(t *testing.T) { + defs := map[string]seatdef.Definition{meshbroker.Seat: {Seat: meshbroker.Seat, Verbs: []seatdef.Verb{{Name: "list"}, {Name: "snapshot-get", Chunks: true}}}} + chunked := ChunkVerbsOf(defs) + if len(chunked) != 1 || chunked[0] != (ChunkVerb{Seat: meshbroker.Seat, Verb: "snapshot-get"}) { + t.Fatalf("the seat definitions' chunked verbs = %v, want the broker's snapshot-get alone", chunked) + } + chunks, acks := "mesh.seat.mesh-broker.chunk.snapshot-get.>", "mesh.seat.mesh-broker.ack.snapshot-get.>" + r := Records{Nodes: []string{"a"}, Chunked: chunked, Assigned: map[string][]Declared{"a": { + {Module: "node-tools", ToolRunner: true}, + {Module: "nats", SnapshotsTheBus: true, Holds: []Seat{{Name: meshbroker.Seat, Serves: []string{"list", "snapshot-get"}}}, + Processes: []ProcessPrincipal{{Process: "keeper", Keeps: true}}}, + {Module: "recovery", Invokes: []string{"seat:mesh-broker.snapshot-get"}, Processes: []ProcessPrincipal{{Process: "recover", Invokes: []string{"seat:mesh-broker.snapshot-get"}}}}, + {Module: "admin-ish", Invokes: []string{"*"}}, + {Module: "mod-x", Streams: []ModuleStream{{Module: "mod-x", Name: "SECRETS", Events: []string{"sealed"}, Restricted: true}, + {Module: "mod-x", Name: "PLAIN", Events: []string{"plain"}}}}, + }}} + users, err := Users(r) + if err != nil { + t.Fatal(err) + } + perms := map[string]Permissions{} + for _, u := range users { + p, err := PermissionsFor(u) + if err != nil { + t.Fatalf("%s: %v", u.Username(), err) + } + perms[u.Username()] = p + } + has := func(list []string, s string) bool { return slices.Contains(list, s) } + if runner := perms["a.node-tools"]; !has(runner.Publish, chunks) || !has(runner.Subscribe, acks) { + t.Errorf("the tool runner, answering the verb for the bus module's tools bundle, may not send chunks and hear acks: %v / %v", runner.Publish, runner.Subscribe) + } + if rec := perms["a.recovery"]; !has(rec.Subscribe, chunks) || !has(rec.Publish, acks) || has(rec.Publish, chunks) { + t.Errorf("the account naming the verb may not hear chunks and send acks, or may send chunks: %v / %v", rec.Publish, rec.Subscribe) + } + if rec := perms["a.recovery.recover"]; !has(rec.Subscribe, chunks) || !has(rec.Publish, acks) { + t.Errorf("the process naming the verb may not hear chunks and send acks") + } + for _, u := range []string{"a.admin-ish", "a.nats.keeper"} { + if p := perms[u]; has(p.Subscribe, chunks) || has(p.Publish, chunks) || has(p.Publish, acks) { + t.Errorf("%s, naming no chunked verb, is granted chunk subjects: %v / %v", u, p.Publish, p.Subscribe) + } + } + keeper := perms["a.nats.keeper"] + if !MayPublish(keeper, "$JS.API.STREAM.RESTORE.SECRETS") || !MayPublish(keeper, "$JS.SNAPSHOT.RESTORE.SECRETS.abc") { + t.Errorf("the keeper holds no standing restore of the restricted stream") + } + if MayPublish(keeper, "$JS.API.STREAM.RESTORE.PLAIN") || MayPublish(keeper, "$JS.API.STREAM.RESTORE.KV_mod-x_records") { + t.Errorf("the keeper's restore grant reaches a stream that is not restricted") + } +} diff --git a/modules/mesh-grants/internal/buscompose/chunks.go b/modules/mesh-grants/internal/buscompose/chunks.go new file mode 100644 index 00000000..a38f46a4 --- /dev/null +++ b/modules/mesh-grants/internal/buscompose/chunks.go @@ -0,0 +1,104 @@ +package buscompose + +import ( + "slices" + "sort" + "strings" + + "git.novox.be/novox/mesh-sdk/go/seatdef" +) + +// **The chunk subjects of a verb that sends a file** (design 47 G §681, A-45, A-60): a verb whose seat definition says `chunks: true` +// has its answer's file sent in chunks on `mesh.seat..chunk...`, each acknowledged by the caller on +// `mesh.seat..ack...`. They are granted to the principal that answers the verb (publish the chunks, subscribe to +// the acknowledgements) and, reversed, to an account that names the verb by name in `invokes`; a `*` entry grants none. + +// ChunkVerb is one verb of a seat whose definition says it sends chunks. +type ChunkVerb struct{ Seat, Verb string } + +// ChunkVerbsOf is every verb the seat definitions say send chunks, sorted. Pure. +func ChunkVerbsOf(defs map[string]seatdef.Definition) []ChunkVerb { + var out []ChunkVerb + for _, d := range defs { + for _, v := range d.Verbs { + if v.Chunks { + out = append(out, ChunkVerb{Seat: d.Seat, Verb: v.Name}) + } + } + } + sort.Slice(out, func(i, j int) bool { + if out[i].Seat != out[j].Seat { + return out[i].Seat < out[j].Seat + } + return out[i].Verb < out[j].Verb + }) + return out +} + +func chunkSubject(c ChunkVerb) string { return "mesh.seat." + c.Seat + ".chunk." + c.Verb + ".>" } +func ackSubject(c ChunkVerb) string { return "mesh.seat." + c.Seat + ".ack." + c.Verb + ".>" } + +// chunkGrants are the chunk subjects p is granted: as the principal that answers a chunked verb, the publish of its chunks and +// the subscription to their acknowledgements (a module's own account for a seat it holds, the tool runner's for each seat a +// carried module holds; a process serves nothing, ADR 0436 §3), and as an account that names a chunked verb by name in `invokes`, +// the subscription to the chunks and the publish of the acknowledgements. +func chunkGrants(p Principal) (pub, sub []string) { + if len(p.Chunked) == 0 { + return nil, nil + } + var served []Seat + switch p.Kind { + case KindModule: + served = p.Holds + case KindNodeTools: + for _, d := range p.Carries { + served = append(served, d.Holds...) + } + } + for _, c := range p.Chunked { + for _, s := range served { + if s.Name == c.Seat && slices.Contains(s.Serves, c.Verb) { + pub, sub = append(pub, chunkSubject(c)), append(sub, ackSubject(c)) + break + } + } + if slices.Contains(p.Invokes, "seat:"+c.Seat+"."+c.Verb) { + sub, pub = append(sub, chunkSubject(c)), append(pub, ackSubject(c)) + } + } + return pub, sub +} + +// restoreGrants are the keeper's standing grant of the bus's stream restore subjects and their chunk subjects, for the bus names +// of the streams ADR 0436 §3 restricts and only those (design 47 G §681, A-60): the restore request of each and the chunks the +// snapshot is sent to. +func restoreGrants(names []string) []string { + var out []string + for _, n := range names { + if !safeSubject.MatchString(n) || strings.Contains(n, ".") { + continue + } + out = append(out, "$JS.API.STREAM.RESTORE."+n, "$JS.SNAPSHOT.RESTORE."+n+".>") + } + return out +} + +// restrictedStreamNames are the bus names of the streams every placed module declares restricted (ADR 0363 §4), sorted, once each. +func restrictedStreamNames(r Records) []string { + seen := map[string]bool{} + for _, mods := range r.Assigned { + for _, d := range mods { + for _, s := range d.Streams { + if s.Restricted { + seen[s.Name] = true + } + } + } + } + out := make([]string, 0, len(seen)) + for n := range seen { + out = append(out, n) + } + sort.Strings(out) + return out +} diff --git a/modules/mesh-grants/internal/buscompose/nats.go b/modules/mesh-grants/internal/buscompose/nats.go index e21e5807..d266dc80 100644 --- a/modules/mesh-grants/internal/buscompose/nats.go +++ b/modules/mesh-grants/internal/buscompose/nats.go @@ -131,6 +131,10 @@ type Principal struct { // §4, §5), and KeepsBusState are the streams it defines, every module's bus state by its name. KeepsBus bool KeepsBusState []string + // KeepsRestricted are the bus names of the streams ADR 0436 §3 restricts, the keeper's standing restore grant (chunks.go). + KeepsRestricted []string + // Chunked are the verbs whose answers are sent in chunks (Records.Chunked), the grants of which chunkGrants derives. + Chunked []ChunkVerb // SaysSeatEvents are the subjects of the seat mesh-broker's events the bus module's keeper says them on (the lead's // ruling, registry milestone 13:0x): `mesh.seat.mesh-broker.event.` for each event the seat's definition // names, as a holder's own account would be granted them; set on the keeper alone. @@ -633,6 +637,18 @@ var BusSnapshotGrants = struct{ Publish []string }{Publish: []string{ // PermissionsFor derives a principal's authority. Pure, and the only place authority is decided: // a permission that cannot be derived from a declaration is a permission nobody can explain. func PermissionsFor(p Principal) (Permissions, error) { + perms, err := permissionsFor(p) + if err != nil || p.SnapshotsTheBus && p.Kind == KindModule { + return perms, err + } + // The chunk subjects of a verb that sends a file (design 47 G §681, A-60), to the principal that answers it and to an account + // naming it in `invokes`. + pub, sub := chunkGrants(p) + perms.Publish, perms.Subscribe = append(perms.Publish, pub...), append(perms.Subscribe, sub...) + return perms, nil +} + +func permissionsFor(p Principal) (Permissions, error) { for _, part := range []struct{ what, value string }{ {"node", p.Node}, {"module", p.Module}, {"process", p.Process}, } { diff --git a/modules/mesh-grants/internal/buscompose/process_grants.go b/modules/mesh-grants/internal/buscompose/process_grants.go index e6509a3c..1d54fdda 100644 --- a/modules/mesh-grants/internal/buscompose/process_grants.go +++ b/modules/mesh-grants/internal/buscompose/process_grants.go @@ -57,6 +57,8 @@ func processGrants(p Principal) (pub, sub []string, err error) { // module uses (the operator channel's accept.condition in its module's name). if p.KeepsBus { pub = append(pub, busStateKeeperGrants(p.KeepsBusState)...) + // And its standing restore of the streams ADR 0436 §3 restricts, used on a `snapshot-restore` call alone (design 47 G §681, A-60). + pub = append(pub, restoreGrants(p.KeepsRestricted)...) if c, ok := keeperConsumer(p); ok { pub = append(pub, keeperConsumerGrants(c)...) } diff --git a/modules/mesh-grants/internal/buscompose/users.go b/modules/mesh-grants/internal/buscompose/users.go index 0bfb2456..9ec461eb 100644 --- a/modules/mesh-grants/internal/buscompose/users.go +++ b/modules/mesh-grants/internal/buscompose/users.go @@ -149,6 +149,9 @@ type Records struct { // Kept is every seat event kept in a stream of its own (kept.go): the grants part makes each stream, so its principal is // granted the create of each by name. Kept []KeptEvent + // Chunked is every verb the seat definitions say sends chunks (chunks.go, design 47 G §681): their subjects are granted to the + // principal that answers the verb and to an account naming it in `invokes`. + Chunked []ChunkVerb // EngineEvents are the node-engine seat's events that carry parts in their subject, by local name: where a node's own // name sits in each (machineEvent). EngineEvents map[string]string @@ -270,6 +273,7 @@ func Users(r Records) ([]Principal, error) { if d.SnapshotsTheBus && pr.Keeps && !pr.Tools { k := &out[len(out)-1] k.KeepsBus, k.KeepsBusState = true, BusStateNames(r.BusState, r.Bound) + k.KeepsRestricted = restrictedStreamNames(r) k.Consumes, k.Streams = d.Consumes, d.Streams k.Watches = d.Watches k.SaysSeatEvents = brokerSeatEvents(d.Holds) @@ -386,6 +390,7 @@ func Users(r Records) ([]Principal, error) { if slices.Contains(reads, ComposedRead) { out[i].Nodes = sortedCopy(r.Nodes) } + out[i].Chunked = r.Chunked } return out, nil } diff --git a/modules/mesh-grants/internal/grants/inputs.go b/modules/mesh-grants/internal/grants/inputs.go index 62a56579..23d09e56 100644 --- a/modules/mesh-grants/internal/grants/inputs.go +++ b/modules/mesh-grants/internal/grants/inputs.go @@ -185,6 +185,8 @@ type Inputs struct { // Kept is every seat event the seat list says is kept in a stream of its own (buscompose.KeptEventsOf): the part makes // each stream and each hearing module's durable on it. Empty while the list is unread. Kept []buscompose.KeptEvent + // Chunked is every verb the seat list says sends its answer's file in chunks (design 47 G §681): the part grants their subjects. + Chunked []buscompose.ChunkVerb // EventSubjects are, by seat, the subjects of its events that carry parts, by the event's local name, as the definitions // give them: where a node-scoped seat's event names its machine (buscompose.Seat.EventSubjects). EventSubjects map[string]map[string]string @@ -366,6 +368,7 @@ func ReadInputs(r Reader, cache *Manifests) Inputs { // consumer only gains filters, so one made with such an event's subject on EVENTS would keep it). if d, ok := gate.(SeatDefinitions); ok { in.Kept = buscompose.KeptEventsOf(d.Definitions()) + in.Chunked = buscompose.ChunkVerbsOf(d.Definitions()) in.EventSubjects = eventSubjectsOf(d.Definitions()) in.Defined = definedEventsOf(d.Definitions()) } diff --git a/modules/mesh-grants/internal/grants/records.go b/modules/mesh-grants/internal/grants/records.go index 15e0fc29..ac3c4d04 100644 --- a/modules/mesh-grants/internal/grants/records.go +++ b/modules/mesh-grants/internal/grants/records.go @@ -44,7 +44,7 @@ func records(in Inputs, union bool) (buscompose.Records, []Gap) { out := buscompose.Records{Assigned: map[string][]buscompose.Declared{}, People: map[string][]string{}, Interchangeable: map[string]bool{}, Enrolling: slices.Clone(in.Enrolling), // And each node's logins (ADR 0583 §1): both while it rotates. - NodeLogins: in.NodeLogins, Kept: in.Kept, EngineEvents: in.EventSubjects[buscompose.EngineSeat], + NodeLogins: in.NodeLogins, Kept: in.Kept, Chunked: in.Chunked, EngineEvents: in.EventSubjects[buscompose.EngineSeat], // What the bus module's process may define: every manifest's bus state, and what it said it created (DLV-G2). BusState: map[string][]catalogue.BusStateEntry{}, Bound: in.Bound} for module, m := range in.Manifests {