Grant a bus process an account scoped to its own declaration (issue 1180)
This commit is contained in:
+30
-2
@@ -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 <node>.<module>.<process id>.
|
||||
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): `<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:
|
||||
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.
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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-<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.
|
||||
@@ -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-<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 {
|
||||
continue
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user