Grant a bus process an account scoped to its own declaration (issue 1180)

This commit is contained in:
jochen
2026-10-19 02:44:38 +02:00
parent de651e0fd1
commit 93a82cb032
5 changed files with 367 additions and 2 deletions
+30 -2
View File
@@ -48,6 +48,11 @@ const (
// service of the module holding NodeProvisionerSeat, outside the tool runner, under an account of its own so // 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. // the vault can tell it from an agent. Its authority is provisionerGrants and nothing else.
KindProvisioner Kind = "provisioner" 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 // 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 Kind Kind
Node string Node string
Module string Module string
// Process is the process resource's id, for a KindProcess principal alone (novox/hq ADR 0436 §2):
// Username and inbox are <node>.<module>.<process id>.
Process string
Emits []string Emits []string
Consumes []string Consumes []string
@@ -171,8 +179,11 @@ type Principal struct {
Streams []ModuleStream Streams []ModuleStream
Hears []Heard Hears []Heard
// Queues are its own work-queue streams and the one process each is the consumer of (novox/hq ADR 0436 // 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 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 // 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 // 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 // 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. // that path knows the node and the module, not the kind.
return p.Node + "." + p.Module return p.Node + "." + p.Module
case KindProcess:
// One account per process and machine (novox/hq ADR 0436 §2): `<machine>.<module>.<process id>`,
// 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: case KindNode:
return "node." + p.Node return "node." + p.Node
case KindController: 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. // a permission that cannot be derived from a declaration is a permission nobody can explain.
func PermissionsFor(p Principal) (Permissions, error) { func PermissionsFor(p Principal) (Permissions, error) {
for _, part := range []struct{ what, value string }{ 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 == "" { if part.value == "" {
continue continue
@@ -738,6 +753,12 @@ func PermissionsFor(p Principal) (Permissions, error) {
return Permissions{}, err return Permissions{}, err
} }
case KindProcess:
var err error
if pub, sub, err = processGrants(p); err != nil {
return Permissions{}, err
}
case KindPerson: case KindPerson:
// Tools, and nothing else. Every subject a person may publish is a tool call; a person // 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. // 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. // for the same reason a person has none: nothing is delivered to it.
sub = append(sub, p.inbox()) 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 { if p.Kind == KindModule || p.Kind == KindNode || p.Kind == KindController {
// Its own reply space, and nothing wider. // Its own reply space, and nothing wider.
+52
View File
@@ -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
}
+218
View File
@@ -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 <machine>.<module>.<process id>, 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
}
+36
View File
@@ -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 // 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. // P3). The client is given a bus account of its own, never the module's or the tool runner's.
ProvisionsNode bool 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-<process id>, 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 <node>.<module>.<process id>.
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 <module>.<tool> or a role's
// seat:<seat>.<verb>, 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. // 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] { for _, d := range r.Assigned[node] {
// **A process account is its own, delivered in its own secret** (broker-<process id>, 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 { if runtimeHere && d.Module == RuntimeModule {
continue continue
} }
+31
View File
@@ -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 // 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). // consumer (novox/hq ADR 0436 §1, §2, §6).
Queues: queueOwnersOf(m), 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. // And the tools its health asks (novox/hq ADR 0240): the machine's node-engine is granted them.
Checks: catalogue.HealthChecks(m), Checks: catalogue.HealthChecks(m),
// And whether it runs as an account of its own (novox/hq ADR 0259 §8). // 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 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 // 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). // stream exists from registration, as a log does (novox/hq ADR 0363 §1).
func (i *Inventory) DeclaredStreams(ctx context.Context) ([]broker.ModuleStream, error) { func (i *Inventory) DeclaredStreams(ctx context.Context) ([]broker.ModuleStream, error) {