diff --git a/internal/broker/nats.go b/internal/broker/nats.go index cef0fef8..71380f2d 100644 --- a/internal/broker/nats.go +++ b/internal/broker/nats.go @@ -48,6 +48,11 @@ const ( // service of the module holding NodeProvisionerSeat, outside the tool runner, under an account of its own so // the vault can tell it from an agent. Its authority is provisionerGrants and nothing else. KindProvisioner Kind = "provisioner" + // KindProcess is one process of a module that declares bus (novox/hq ADR 0436 §1, §2): a service of that + // module, outside the tool runner and outside the module's own account, under a bus account scoped to + // exactly what its bus says — never root, never the operator account, and never able to answer a tool + // call or a seat's verb (§3). One per process and machine. + KindProcess Kind = "process" ) // RuntimeModule is the module that IS the node's tool runtime (novox/hq ADR 0175). Where it is @@ -95,6 +100,9 @@ type Principal struct { Kind Kind Node string Module string + // Process is the process resource's id, for a KindProcess principal alone (novox/hq ADR 0436 §2): + // Username and inbox are ... + Process string Emits []string Consumes []string @@ -171,8 +179,11 @@ type Principal struct { Streams []ModuleStream Hears []Heard // Queues are its own work-queue streams and the one process each is the consumer of (novox/hq ADR 0436 - // §1, §2, §6). + // §1, §2, §6); for a KindProcess principal, its own alone. Queues []QueueOwner + // Asks says a KindProcess principal reads the records of its module's own asks (novox/hq ADR 0436 §1), + // on whichever seat of Uses keeps them (novox/hq ADR 0259 §3) — never set for any other kind. + Asks bool // SnapshotsTheBus is the bus's own module, the one holding mesh-broker (novox/hq ADR 0235). Its // whole authority is BusSnapshotGrants: it copies the streams for the night's backup and nothing @@ -449,6 +460,10 @@ func (p Principal) Username() string { // issues its credential through the same path a module's takes (`module issue`), and // that path knows the node and the module, not the kind. return p.Node + "." + p.Module + case KindProcess: + // One account per process and machine (novox/hq ADR 0436 §2): `..`, + // never the module's own name alone, which is a different account with a different secret. + return p.Node + "." + p.Module + "." + p.Process case KindNode: return "node." + p.Node case KindController: @@ -561,7 +576,7 @@ var BusSnapshotGrants = struct{ Publish []string }{Publish: []string{ // a permission that cannot be derived from a declaration is a permission nobody can explain. func PermissionsFor(p Principal) (Permissions, error) { for _, part := range []struct{ what, value string }{ - {"node", p.Node}, {"module", p.Module}, + {"node", p.Node}, {"module", p.Module}, {"process", p.Process}, } { if part.value == "" { continue @@ -738,6 +753,12 @@ func PermissionsFor(p Principal) (Permissions, error) { return Permissions{}, err } + case KindProcess: + var err error + if pub, sub, err = processGrants(p); err != nil { + return Permissions{}, err + } + case KindPerson: // Tools, and nothing else. Every subject a person may publish is a tool call; a person // who could publish an event would be able to claim a module said something. @@ -1131,6 +1152,13 @@ func PermissionsFor(p Principal) (Permissions, error) { // for the same reason a person has none: nothing is delivered to it. sub = append(sub, p.inbox()) } + if p.Kind == KindProcess { + // Its own reply space, and nothing wider (novox/hq ADR 0436 §1, §3): a process's queue consumer is + // pulled and acknowledged by publishing, granted above with its own stream and consumer name, never + // a generic EVENTS consumer's — consumerStream/consumerDurable below are a module's own, not a + // process's. + sub = append(sub, p.inbox()) + } if p.Kind == KindModule || p.Kind == KindNode || p.Kind == KindController { // Its own reply space, and nothing wider. diff --git a/internal/broker/process_grants.go b/internal/broker/process_grants.go new file mode 100644 index 00000000..d0689e46 --- /dev/null +++ b/internal/broker/process_grants.go @@ -0,0 +1,52 @@ +package broker + +// The bus account of one process of a module that declares bus (novox/hq ADR 0436 §1, §2, §3, §5). +// +// **Never the module's own account's grants, never the tool runner's.** A process's authority is read +// straight from its bus declaration — its emits, its queue's consumer, its state, whether it reads the +// records of its module's own asks, and the tools and seat verbs it calls — and nothing else: it holds no +// seat, serves no tool, hears no module stream, and answers nothing a caller asked (§3). Modelled on +// provisionerGrants, the other kind of process account the mesh mints (ADR 0349 clauses 6 and 11). + +// processGrants is one process's whole authority: to publish, and to subscribe. +func processGrants(p Principal) (pub, sub []string, err error) { + // 1. Its own events, under its module's own namespace — the same subjects the module's own principal + // would publish them on, had it declared them there instead (novox/hq ADR 0436 §1). + own := "mesh.mod." + p.Module + for _, e := range p.Emits { + pub = append(pub, own+".event."+e) + } + + // 2. The one durable consumer of each work-queue stream its bus names (novox/hq ADR 0436 §1, §2, §6): + // asked about, pulled from and acknowledged, by the same name ConsumersOf composes the consumer under + // (queueConsumer) — and no other stream's. + for _, q := range p.Queues { + consumer := p.Node + "_" + p.Module + "_" + q.Process + pub = append(pub, + "$JS.API.CONSUMER.INFO."+q.Stream+"."+consumer, + "$JS.API.CONSUMER.MSG.NEXT."+q.Stream+"."+consumer, + "$JS.ACK."+q.Stream+"."+consumer+".>") + } + + // 3. Its module's own state, by the local names its bus names alone — never the whole of what the + // module keeps (novox/hq ADR 0436 §1), read and written as stateGrants gives any module's own bucket. + pub = append(pub, stateGrants(stateAccess{Module: p.Module, Node: p.Node, Keeps: p.State})...) + + // 4. The router's records of its module's own asks (novox/hq ADR 0436 §1, ADR 0259 §3): the same + // direct-get subjects a module's own principal reads for the seats its Uses names that keep them — + // read here from the one list, so the two cannot disagree. + if p.Asks { + pub = append(pub, SeatTrafficOf(p.Module, nil, p.Uses, nil).Records...) + } + + // 5. The tools and seat verbs it calls (novox/hq ADR 0152, ADR 0436 §1): the same derivation a + // module's own principal's invokes takes, so `callerNamed` (applied to every non-controller principal) + // adds the caller-named `call` variants for it exactly as it would for a module. + invoked, err := invokedSubjects(p.Invokes) + if err != nil { + return nil, nil, err + } + pub = append(pub, invoked...) + + return pub, sub, nil +} diff --git a/internal/broker/process_grants_test.go b/internal/broker/process_grants_test.go new file mode 100644 index 00000000..e24400bf --- /dev/null +++ b/internal/broker/process_grants_test.go @@ -0,0 +1,218 @@ +package broker + +import "testing" + +// A module's process with a bus declaration gets a bus account of its own (novox/hq ADR 0436 §1, §2, §3, +// §5): named .., granted exactly its bus declaration and nothing of its +// module's other grants or the tool runner's. The fixture is mesh-mail's future manifest shape (ADR 0436 +// §7, §8): process "send", stream "mail-sends" (MAIL_SENDS), state "sends", using operator-channel as the +// router of its own asks (novox/hq ADR 0259 §3, seattraffic_test.go's operatorChannel()). + +// mailSenderProcess is the process principal PermissionsFor composes a bus account from, as +// inventory.processesOf would read it off mesh-mail's manifest (1179/AC1's fixture, 1178's queue owner). +func mailSenderProcess() Principal { + return Principal{Kind: KindProcess, Node: "novox", Module: "mesh-mail", Process: "send", + Emits: []string{"mail.send.sent", "mail.send.failed"}, + Queues: []QueueOwner{{Process: "send", Stream: "MAIL_SENDS", MaxDeliver: 5}}, + State: []string{"sends"}, Asks: true, Uses: []Seat{operatorChannel()}, + Invokes: []string{"seat:operator-channel.tell"}, + } +} + +// 1180/AC1: the process principal publishes its emits, pulls and acknowledges its one queue consumer, reads +// and writes its own state bucket, reads the router's records of its module's own asks, publishes its +// invoked seat verb in its own name and the plain form, and subscribes only its own inbox. +func TestAProcessIsGrantedExactlyItsBusDeclaration(t *testing.T) { + p := mailSenderProcess() + if got := p.Username(); got != "novox.mesh-mail.send" { + t.Fatalf("username: %q", got) + } + got := perms(t, p) + for _, s := range []string{ + "mesh.mod.mesh-mail.event.mail.send.sent", + "mesh.mod.mesh-mail.event.mail.send.failed", + "$JS.API.CONSUMER.INFO.MAIL_SENDS.novox_mesh-mail_send", + "$JS.API.CONSUMER.MSG.NEXT.MAIL_SENDS.novox_mesh-mail_send", + "$JS.ACK.MAIL_SENDS.novox_mesh-mail_send.x", + "$KV.mesh-mail_sends.x", + "$JS.API.DIRECT.GET.KV_mesh-mail_sends.$KV.mesh-mail_sends.x", + "$JS.API.DIRECT.GET.KV_messenger_asks.$KV.messenger_asks.mesh-mail.a1", + "mesh.seat.operator-channel.tool.tell", + "mesh.seat.operator-channel.call.tell.novox~mesh-mail~send", + } { + if !allowed(got.Publish, s) { + t.Errorf("the process may not publish %s", s) + } + } + if !allowed(got.Subscribe, "_INBOX.novox.mesh-mail.send.x") { + t.Error("the process does not hear its own inbox") + } + if len(got.Subscribe) != 1 { + t.Errorf("the process subscribes %v; want only its own inbox", got.Subscribe) + } +} + +// 1180/AC3: a module with no bus process composes as main does, and adding one to mesh-mail changes +// neither the tool runner's grants for a module it carries nor another module's own. +func TestAddingAProcessLeavesOtherGrantsUnchanged(t *testing.T) { + plain := Principal{Kind: KindModule, Node: "novox", Module: "other", PasswordHash: "x"} + before := perms(t, plain) + users, err := Users(Records{Nodes: []string{"novox"}, Assigned: map[string][]Declared{"novox": { + {Module: "other"}, + {Module: "mesh-mail", Processes: []ProcessPrincipal{{Process: "send", + Emits: []string{"mail.send.sent"}}}, NoAccount: true}, + }}}) + if err != nil { + t.Fatal(err) + } + var sawProcess, sawOther bool + for _, u := range users { + if u.Kind == KindProcess && u.Module == "mesh-mail" { + sawProcess = true + } + if u.Module == "other" { + sawOther = true + if got := perms(t, u); !equalStringSlices(got.Publish, before.Publish) || !equalStringSlices(got.Subscribe, before.Subscribe) { + t.Errorf("another module's grants changed: %+v, want %+v", got, before) + } + } + if u.Kind == KindModule && u.Module == "mesh-mail" { + t.Error("mesh-mail has no own secret broker (NoAccount), and should have no module principal of its own") + } + } + if !sawProcess { + t.Fatal("the process account was not composed at all") + } + if !sawOther { + t.Fatal("the module with no process was not composed") + } +} + +// 1180/AC3: a process account is never carried by the machine's tool runner, so adding one to a module the +// runtime carries changes nothing of the runtime's own grants for that module. +func TestAddingAProcessLeavesTheToolRunnersCarryingUnchanged(t *testing.T) { + assigned := func(processes []ProcessPrincipal) map[string][]Declared { + return map[string][]Declared{"novox": { + {Module: RuntimeModule}, + {Module: "mesh-mail", Serves: []string{"status"}, Processes: processes}, + }} + } + without, err := Users(Records{Nodes: []string{"novox"}, Assigned: assigned(nil)}) + if err != nil { + t.Fatal(err) + } + with, err := Users(Records{Nodes: []string{"novox"}, Assigned: assigned([]ProcessPrincipal{ + {Process: "send", Emits: []string{"mail.send.sent"}}, + })}) + if err != nil { + t.Fatal(err) + } + runtimeOf := func(users []Principal) Principal { + for _, u := range users { + if u.Kind == KindNodeTools { + return u + } + } + t.Fatal("the tool runner was not composed") + return Principal{} + } + before, after := perms(t, runtimeOf(without)), perms(t, runtimeOf(with)) + if !equalStringSlices(before.Publish, after.Publish) || !equalStringSlices(before.Subscribe, after.Subscribe) { + t.Errorf("the tool runner's grants changed when mesh-mail gained a process: before %+v, after %+v", + before, after) + } +} + +// 1180/AC2, the grant test of ADR 0436 clause 3: a process account answers no tool call or seat verb, +// publishes no warrant, holds no seat (operator-channel included), and hears no restricted stream — however +// wide its own grants (emits, its queue, its state, its asks and its invokes) are. +func TestAProcessAccountAnswersNothing(t *testing.T) { + p := mailSenderProcess() + got := perms(t, p) + for _, s := range []string{ + // Every shape a tool call or a seat verb is answered on: a module's own namespace, a seat's, and + // the caller-named `call` kind naming any caller — which calledSubscribe would add if a `tool` + // grant ever reached Subscribe. + "mesh.mod.mesh-mail.tool.send", + "mesh.mod.mesh-mail.tool.>", + "mesh.seat.operator-channel.tool.tell", + "mesh.seat.operator-channel.call.tell.*", + "mesh.seat.operator-channel.call.tell.mesh-controller", + } { + if allowed(got.Subscribe, s) { + t.Errorf("a process account may subscribe %s, and could answer a call or a verb", s) + } + } + // No warrant: only operator-channel's holder publishes the asker's decided event (ADR 0259 §3). + if allowed(got.Publish, "mesh.seat.operator-channel.event.decided.mesh-mail") { + t.Error("a process account may publish a warrant") + } + // No seat held: the holder-only subjects (its accept queue, its worker) are refused. + for _, s := range []string{ + "mesh.seat.operator-channel.accept.ask.mesh-mail", + "$JS.API.CONSUMER.MSG.NEXT.SEAT_OPERATOR_CHANNEL.SEAT_OPERATOR_CHANNEL_worker", + } { + if allowed(got.Subscribe, s) || allowed(got.Publish, s) { + t.Errorf("a process account holds operator-channel: %s", s) + } + } + // No restricted stream: a process never hears a module stream at all (it has no Hears), restricted or + // not — the generic rule ADR 0376 §1 holds for it as it does for the tool runner. + if allowed(got.Subscribe, "mesh.stream.mesh-mail.event.mail.mesh.message.added") { + t.Error("a process account hears a module's stream") + } + if got.Subscribe == nil || len(got.Subscribe) != 1 { + t.Fatalf("a process account's whole subscribe list: %v; want only its own inbox", got.Subscribe) + } +} + +// Every process account composed for a mesh is held to the same rule (ADR 0436 clause 3): checked across +// the whole shape Users() composes, not only the one fixture above. +func TestEveryComposedProcessAccountAnswersNothing(t *testing.T) { + users, err := Users(Records{Nodes: []string{"novox"}, Assigned: map[string][]Declared{"novox": { + {Module: "messenger", Holds: []Seat{operatorChannel()}, RunsAs: "messenger"}, + {Module: "mesh-mail", RunsAs: "mesh-mail", Uses: []Seat{operatorChannel()}, + Processes: []ProcessPrincipal{{Process: "send", Emits: []string{"mail.send.sent"}, + Asks: true, Invokes: []string{"seat:operator-channel.tell"}}}}, + }}}) + if err != nil { + t.Fatal(err) + } + checked := 0 + for _, u := range users { + if u.Kind != KindProcess { + continue + } + checked++ + got := perms(t, u) + for _, s := range []string{ + "mesh.seat.operator-channel.call.tell.*", // answers any caller + "mesh.seat.operator-channel.accept.ask." + u.Module, // a caller's own submit, never a process's + "mesh.seat.operator-channel.event.decided." + u.Module, // the holder's warrant alone + } { + if allowed(got.Subscribe, s) || allowed(got.Publish, s) { + t.Errorf("%s may reach %s, which only a holder or an asking module's own principal may", + u.Username(), s) + } + } + } + if checked == 0 { + t.Fatal("no process account was composed to check") + } +} + +func equalStringSlices(a, b []string) bool { + if len(a) != len(b) { + return false + } + seen := map[string]bool{} + for _, x := range a { + seen[x] = true + } + for _, x := range b { + if !seen[x] { + return false + } + } + return true +} diff --git a/internal/broker/users.go b/internal/broker/users.go index ac325a4f..46c01604 100644 --- a/internal/broker/users.go +++ b/internal/broker/users.go @@ -76,6 +76,31 @@ type Declared struct { // declares the own secret ProvisionerSecret its account is delivered in (novox/hq ADR 0349 clause 11, design 57 // P3). The client is given a bus account of its own, never the module's or the tool runner's. ProvisionsNode bool + // Processes are this module's own processes that declare bus (novox/hq ADR 0436 §1), each getting a + // principal of its own: granted exactly what its bus says, and nothing of this module's other grants — + // composed whether or not the module itself has an account (NoAccount), because a process account is + // delivered in its own secret, broker-, never the module's `broker`. + Processes []ProcessPrincipal +} + +// ProcessPrincipal is one process of a module that declares bus (novox/hq ADR 0436 §1, §2), as the broker +// composes its own account from: its own emits, its own work-queue consumers, its own state, whether it +// reads the router's records of its module's own asks, and the tools and seat verbs it calls. +type ProcessPrincipal struct { + // Process is the process resource's id, naming the account ... + Process string + // Emits are the events this process may publish, named as the module's own Emits are. + Emits []string + // Queues are the module's own work-queue streams this process is the one consumer of. + Queues []QueueOwner + // State is the module's own state, by local name, this process reads and writes. + State []string + // Asks says whether this process reads the records of its module's own asks (novox/hq ADR 0259 §3), on + // whichever seats the module uses that keep them (the router's, operator-channel, among others). + Asks bool + // Invokes are the tools and seat verbs this process calls, each . or a role's + // seat:., or * for every tool on the mesh. + Invokes []string } // Records is what composing a user list needs to know about the mesh, and nothing more. @@ -140,6 +165,17 @@ func Users(r Records) ([]Principal, error) { } } for _, d := range r.Assigned[node] { + // **A process account is its own, delivered in its own secret** (broker-, novox/hq + // ADR 0436 §1, §2), never the module's `broker` — so it is composed whether or not the module + // itself has an account, and is never carried by the machine's runtime, which runs as the + // operator's account (ADR 0436 §5): added here, before either of those is judged. + for _, pr := range d.Processes { + 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, + }) + } if runtimeHere && d.Module == RuntimeModule { continue } diff --git a/internal/inventory/busrecords.go b/internal/inventory/busrecords.go index e7a0c4f6..a78d2d8a 100644 --- a/internal/inventory/busrecords.go +++ b/internal/inventory/busrecords.go @@ -190,6 +190,9 @@ func declaredFor(m catalogue.Manifest, seats map[string]catalogue.SeatDeclaratio // And its own work-queue streams, each with the one process its bus.queues names as the // consumer (novox/hq ADR 0436 §1, §2, §6). Queues: queueOwnersOf(m), + // And every process of its own that declares bus, each granted a bus account of its own (novox/hq + // ADR 0436 §1, §2). + Processes: processesOf(m), // And the tools its health asks (novox/hq ADR 0240): the machine's node-engine is granted them. Checks: catalogue.HealthChecks(m), // And whether it runs as an account of its own (novox/hq ADR 0259 §8). @@ -462,6 +465,34 @@ func queueOwnersOf(m catalogue.Manifest) []broker.QueueOwner { return out } +// processesOf is every process of a module that declares bus, as the broker composes its own account from +// (novox/hq ADR 0436 §1, §2): its own emits, its own work-queue consumers (queueOwnersOf, filtered to each +// process's own), its own state, whether it reads its module's own asks, and the tools and seat verbs it +// calls. +func processesOf(m catalogue.Manifest) []broker.ProcessPrincipal { + buses := m.ProcessBuses() + if len(buses) == 0 { + return nil + } + queues := queueOwnersOf(m) + var out []broker.ProcessPrincipal + for _, r := range m.Resources { + id := fmt.Sprint(r["id"]) + b, declared := buses[id] + if !declared { + continue + } + pr := broker.ProcessPrincipal{Process: id, Emits: b.Emits, State: b.State, Asks: b.Asks, Invokes: b.Invokes} + for _, q := range queues { + if q.Process == id { + pr.Queues = append(pr.Queues, q) + } + } + out = append(out, pr) + } + return out +} + // DeclaredStreams is every module stream the catalogue declares, registered modules assigned or not: a // stream exists from registration, as a log does (novox/hq ADR 0363 §1). func (i *Inventory) DeclaredStreams(ctx context.Context) ([]broker.ModuleStream, error) {