From 362dddae401c36dc68a626320e90409b2a15fab8 Mon Sep 17 00:00:00 2001 From: jochen Date: Mon, 2 Nov 2026 00:24:07 +0100 Subject: [PATCH] Compose mesh-enrolment's grants: its bucket written by it alone, the vault's enrolment verbs called by it alone MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ADR 0587 §1-§3 (changing ADR 0561 §5) make mesh-enrolment the only caller of the vault's enrolment-token and node-account and a caller of sign, and §2 its bucket enrolments read by it and by the grants part as a named reader. The holder calls the vault through its manifest's invokes, in its own name, as any caller does; while the vault serves either enrolment verb every other principal is denied both, however wide its grant (a runtime's or an agent's `*`), as ControllerOnly denies the desk's prompt. The writers table gains the open enrolments (mesh-enrolment) and the node-logins (the vault), each one writer; NamedReaders names the grants part for enrolments. Golden lists unchanged. Tested as a subtest of TestTheCopiedComposerComposesAsTheControllers, no new test function. (issue 774, 774/S41) Claude-Session: https://claude.ai/code/session_01NcbgfJ9Gum47zkFKWRaovL --- go/buscompose/buscompose_test.go | 42 ++++++++++++++++++++++++++++++++ go/buscompose/nats.go | 28 +++++++++++++++++++++ go/buscompose/userlist_switch.go | 4 ++- go/buscompose/users.go | 7 ++++++ go/buscompose/writers.go | 23 +++++++++++++++++ 5 files changed, 103 insertions(+), 1 deletion(-) diff --git a/go/buscompose/buscompose_test.go b/go/buscompose/buscompose_test.go index b5a4fd2..b896812 100644 --- a/go/buscompose/buscompose_test.go +++ b/go/buscompose/buscompose_test.go @@ -528,6 +528,48 @@ func TestTheCopiedComposerComposesAsTheControllers(t *testing.T) { t.Error("an enrolment user that may enrol nowhere was composed") } }) + // 774/S41/AC1 (ADR 0587 §1-§3, ADR 0561 §5): mesh-enrolment writes its bucket (with per-key expiry) and calls the + // vault's enrolment-token, node-account and sign in its own name; no other module or runtime may call those two, + // however wide its grant; and its bucket is read by the grants part alone besides it. + t.Run("mesh-enrolment alone asks the vault to enrol", func(t *testing.T) { + vault := Seat{Name: VaultSeat, Serves: []string{"sign", "enrolment-token", "node-account"}} + holder := Seat{Name: catalogue.EnrolmentSeat, Serves: []string{"issue", "redeem", "confirm", "revoke", "tokens", "again"}} + calls := []string{"seat:mesh-vault.enrolment-token", "seat:mesh-vault.node-account", "seat:mesh-vault.sign"} + users, err := Users(Records{Nodes: []string{"ace"}, Assigned: map[string][]Declared{"ace": { + {Module: "mesh-vault", Holds: []Seat{vault}}, + // One process with a bus account of its own (ADR 0587 §1): never carried by the tool runner. + {Module: "mesh-enrolment", RunsAs: "mesh-enrolment", Holds: []Seat{holder}, Invokes: calls, + State: []Bucket{{Module: "mesh-enrolment", Name: "enrolments", KeyTTL: true}}}, + {Module: "shop", Invokes: calls[:2]}, + {Module: RuntimeModule}, {Module: "agent-tools", Invokes: []string{"*"}}}}}) + if err != nil { + t.Fatal(err) + } + call := func(verb, user string) string { return "mesh.seat.mesh-vault.call." + verb + "." + CallerToken(user) } + for _, u := range users { + perms, err := PermissionsFor(u) + if err != nil { + t.Fatal(err) + } + name := u.Username() + for _, verb := range []string{"enrolment-token", "node-account"} { + enrols := name == "ace.mesh-enrolment" + if MayPublish(perms, call(verb, name)) != enrols || (!enrols && MayPublish(perms, "mesh.seat.mesh-vault.tool."+verb)) { + t.Errorf("%s may call %s: %v", name, verb, !enrols) + } + } + if name == "ace.mesh-enrolment" && (!MayPublish(perms, call("sign", name)) || + !MayPublish(perms, "$KV."+delivery.EnrolmentsBucket+".node.lab") || !MayPublish(perms, "$KV."+delivery.EnrolmentsBucket+".address.10.0.0.7")) { + t.Errorf("mesh-enrolment does not sign or write its bucket: %v", perms.Publish) + } + if name != "ace.mesh-enrolment" && MayPublish(perms, "$KV."+delivery.EnrolmentsBucket+".node.lab") { + t.Errorf("%s writes the enrolments", name) + } + } + if !ReaderMay(GrantsModule, delivery.EnrolmentsRead) || ReaderMay("shop", delivery.EnrolmentsRead) { + t.Error("the enrolments' named readers") + } + }) t.Run("the controller's golden server configuration", func(t *testing.T) { seat := Seat{Name: "telegram-sender", Accepts: []string{"send"}, Emits: []string{"delivered", "failed"}} got, err := Compose( diff --git a/go/buscompose/nats.go b/go/buscompose/nats.go index 461b36d..ccdb259 100644 --- a/go/buscompose/nats.go +++ b/go/buscompose/nats.go @@ -270,6 +270,10 @@ type Principal struct { Redeems bool LeaseHandedAway bool + // VaultEnrols says the vault's holder serves an enrolment verb (VerbsTheEnrolmentAsksTheVault): every principal but + // the module mesh-enrolment and its processes is then denied them, however wide its grant (ADR 0587 §1). + VaultEnrols bool + // PasswordHash is the bcrypt hash the mesh minted. The plaintext is sealed to the principal // and never appears here: this file is written to a node's disk and read by a server, and a // secret that can be read from a configuration file is a secret with a wider blast radius @@ -320,6 +324,22 @@ func ControllerOnly() []string { return out } +// VerbsTheEnrolmentAsksTheVault are the vault's verbs the seat mesh-enrolment's holder alone calls (novox/hq ADR 0587 +// §1, §3; ADR 0561 §5): a token and its hash made and dropped, a joining node's account minted and retired. +var VerbsTheEnrolmentAsksTheVault = []SeatVerb{{Seat: VaultSeat, Verb: delivery.EnrolmentTokenVerb}, + {Seat: VaultSeat, Verb: delivery.NodeAccountVerb}} + +// EnrolmentOnly are the subjects the holder of mesh-enrolment alone may publish, however wide another's grant: the +// vault's two enrolment verbs, by the tool's and the caller-named shape (Principal.VaultEnrols denies them). +func EnrolmentOnly() []string { + var out []string + for _, v := range VerbsTheEnrolmentAsksTheVault { + out = append(out, "mesh.seat."+v.Seat+".tool."+v.Verb, "mesh.seat."+v.Seat+".tool."+v.Verb+".>", + "mesh.seat."+v.Seat+"."+CallKind+"."+v.Verb+".>") + } + return out +} + // MayPublish says whether permissions let a principal publish one subject: an allow covers it and no deny does. func MayPublish(perms Permissions, subject string) bool { for _, d := range perms.PublishDeny { @@ -1514,6 +1534,14 @@ func PermissionsFor(p Principal) (Permissions, error) { } } } + // And the vault's enrolment verbs to every principal but mesh-enrolment's (novox/hq ADR 0587 §1). + if p.VaultEnrols && !((p.Kind == KindModule || p.Kind == KindProcess) && p.Module == delivery.EnrolmentModule) { + for _, only := range EnrolmentOnly() { + if slices.ContainsFunc(pub, func(a string) bool { return SubjectsOverlap(a, only) }) { + deny = append(deny, only) + } + } + } return Permissions{ Publish: pub, PublishDeny: deny, diff --git a/go/buscompose/userlist_switch.go b/go/buscompose/userlist_switch.go index 15f3342..5eee315 100644 --- a/go/buscompose/userlist_switch.go +++ b/go/buscompose/userlist_switch.go @@ -107,7 +107,9 @@ var NamedReaders = map[string][]string{VaultBusAccounts: {GrantsModule}, GrantsA delivery.EngineDataRead: {health.DiskLoadModule}, // ADR 0583 §1 insight: every node's login is read by the grants part alone, each engine its own node's key through its // machine principal (NodeLoginsReads), never through `reads`. - delivery.NodeLoginsRead: {GrantsModule}} + delivery.NodeLoginsRead: {GrantsModule}, + // ADR 0587 §2: the open enrolments are read by their holder and, as a named reader, by the grants part. + delivery.EnrolmentsRead: {GrantsModule}} // ReaderMay says whether module may read the state read (`.`) by the named readers: always, for a state // that names none. diff --git a/go/buscompose/users.go b/go/buscompose/users.go index 6802033..2e33efe 100644 --- a/go/buscompose/users.go +++ b/go/buscompose/users.go @@ -301,6 +301,13 @@ func Users(r Records) ([]Principal, error) { out = append(out, p) } + // The vault's enrolment verbs are mesh-enrolment's alone, however wide another's grant (novox/hq ADR 0587 §1). + if slices.ContainsFunc(VerbsTheEnrolmentAsksTheVault, func(v SeatVerb) bool { return servesVerb(r, v) }) { + for i := range out { + out[i].VaultEnrols = true + } + } + // The grants part creates the durable consumers the accounts need (novox/hq ADR 0561 §6), by stream and name. if grants >= 0 { out[grants].CreatesConsumers = AccountConsumers(out) diff --git a/go/buscompose/writers.go b/go/buscompose/writers.go index 39d78f7..fe5db6f 100644 --- a/go/buscompose/writers.go +++ b/go/buscompose/writers.go @@ -96,6 +96,19 @@ func ownModule(p Principal, pattern string) bool { return false } +// moduleNamed is the writer that is module: its own principal, a process of it, or the node tools carrying it. +func moduleNamed(module string) func(Principal, string) bool { + return func(p Principal, _ string) bool { + switch p.Kind { + case KindModule, KindProcess: + return p.Module == module + case KindNodeTools: + return slices.ContainsFunc(p.Carries, func(d Declared) bool { return d.Module == module }) + } + return false + } +} + // ownAnnouncer is a module, a process of it or the node tools carrying it saying what that module keeps, or the // controller saying what it keeps itself (novox/hq ADR 0495 clause 2): each under its own name alone. func ownAnnouncer(p Principal, pattern string) bool { @@ -218,6 +231,16 @@ var WritersTable = []WriterRow{ Writes: ownModule}, {State: "the operator-channel's open messages", Writer: "the seat's holder", KeptIn: "its own key-value state", Others: "—"}, + // The seat mesh-enrolment's open enrolments (novox/hq ADR 0587 §2, issue 774): its holder alone writes them, each a + // compare-and-set at the revision read; the grants part reads them as a named reader. + {State: "the open enrolments", Writer: "the seat mesh-enrolment's holder, compare-and-set", + KeptIn: "key-value " + delivery.EnrolmentsBucket + ", with per-key expiry", + Others: "the grants part reads them (a named reader) and composes each enrol. user from them", + Subjects: kvOf(delivery.EnrolmentsBucket), Writes: moduleNamed(delivery.EnrolmentModule)}, + // Which login each node uses (novox/hq ADR 0583 §1 insight): the vault alone writes it. + {State: "a node's logins", Writer: "the vault", KeptIn: "key-value " + delivery.NodeLoginsBucket, + Others: "the grants part reads every key, each node's engine its own node's key alone", + Subjects: kvOf(delivery.NodeLoginsBucket), Writes: moduleNamed(delivery.VaultModule)}, {State: "the facts snapshot", Writer: "controller", KeptIn: "the artifact store, facts/latest", Others: "the build seat reads"}, // What a consumer gave up on (novox/hq issue 330): copied by the controller from the server's notice,