diff --git a/go/buscompose/buscompose_test.go b/go/buscompose/buscompose_test.go index 913a30a..6bf897b 100644 --- a/go/buscompose/buscompose_test.go +++ b/go/buscompose/buscompose_test.go @@ -3,6 +3,7 @@ package buscompose import ( "encoding/json" "errors" + "maps" "os" "path/filepath" "reflect" @@ -419,6 +420,76 @@ func TestTheCopiedComposerComposesAsTheControllers(t *testing.T) { } } }) + // 774/S39 (ADR 0583 §1): while a node rotates, both its logins are users with the node's permissions and its one + // inbox, each calling in its own name; every node reads its own node-logins key alone; with no rotation the list + // is the one above (AC2: the controller's golden lists, unchanged). + t.Run("a node rotating has two logins of one node", func(t *testing.T) { + vault := Seat{Name: VaultSeat, Serves: []string{"rotate-node", "node-account-confirm", "sign", "own"}} + since := time.Date(2026, 11, 2, 1, 0, 0, 0, time.UTC) + users, err := Users(Records{Nodes: []string{"ace", "g14"}, + Assigned: map[string][]Declared{"ace": {{Module: "mesh-vault", Holds: []Seat{vault}}}}, + NodeLogins: map[string]delivery.NodeLogin{ + "g14": {Node: "g14", Login: "node.g14", Rotating: "node.g14--b", Stage: delivery.RotationBegun, Since: since}, + // A node that rotated before and rotates no more logs in as its second login alone. + "ace": {Node: "ace", Login: "node.ace--b", Since: since}}}) + if err != nil { + t.Fatal(err) + } + perms := map[string]Permissions{} + for _, u := range users { + if u.Kind == KindNode { + p, err := PermissionsFor(u) + if err != nil { + t.Fatal(err) + } + perms[u.Username()] = p + } + } + if len(perms) != 3 || perms["node.g14"].Publish == nil || perms["node.g14--b"].Publish == nil || perms["node.ace--b"].Publish == nil { + t.Fatalf("the node users are %v", slices.Sorted(maps.Keys(perms))) + } + // Equal, but for the caller each names in its calls (the bus's proof of which login called, ADR 0583 §3). + own := func(p Permissions, login string) Permissions { + swap := func(in []string) []string { + out := make([]string, len(in)) + for i, s := range in { + out[i] = strings.ReplaceAll(s, CallerToken(login), "") + } + return out + } + return Permissions{Publish: swap(p.Publish), PublishDeny: swap(p.PublishDeny), Subscribe: swap(p.Subscribe), + AllowResponses: p.AllowResponses} + } + if a, b := own(perms["node.g14"], "node.g14"), own(perms["node.g14--b"], "node.g14--b"); !reflect.DeepEqual(a, b) { + t.Errorf("the two logins' permissions differ:\n%v\n%v", a, b) + } + for login, p := range perms { + node, _ := delivery.LoginNode(login) + if !slices.Contains(p.Subscribe, "_INBOX.node."+node+".>") || slices.Contains(p.Subscribe, "_INBOX."+login+".>") && login != "node."+node { + t.Errorf("%s's inbox: %v", login, p.Subscribe) + } + } + // AC3: g14 reads node-logins key g14 and not ace, and writes neither; it confirms in its own login's name. + get := func(key string) string { + return "$JS.API.DIRECT.GET.KV_" + delivery.NodeLoginsBucket + ".$KV." + delivery.NodeLoginsBucket + "." + key + } + confirm := func(login string) string { + return "mesh.seat.mesh-vault.call.node-account-confirm." + CallerToken(login) + } + for subject, want := range map[string]bool{get("g14"): true, get("ace"): false, "$KV." + delivery.NodeLoginsBucket + ".g14": false, + confirm("node.g14--b"): true, confirm("node.g14"): false} { + if MayPublish(perms["node.g14--b"], subject) != want { + t.Errorf("node.g14--b may publish %s: %v, want %v", subject, !want, want) + } + } + if plain, _ := PermissionsFor(Principal{Kind: KindNode, Node: "g14"}); MayPublish(plain, get("g14")) { + t.Error("a node reads node-logins while the vault does not rotate") + } + // And nothing but the grants part names node-logins among its reads. + if !ReaderMay(GrantsModule, delivery.NodeLoginsRead) || ReaderMay("shop", delivery.NodeLoginsRead) { + t.Error("node-logins' 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 8b56c96..7fea9e0 100644 --- a/go/buscompose/nats.go +++ b/go/buscompose/nats.go @@ -253,6 +253,16 @@ type Principal struct { // stream and name. Created only, never updated or deleted. CreatesConsumers []ModuleConsumer + // Login is a node principal's login when it is not `node.`: its second login, `node.--b`, composed beside or + // in place of the first as the vault's node-logins says (novox/hq ADR 0583 §1, issue 774). Each login is granted + // the node's permissions and the node's inbox `_INBOX.node.`; what it calls, it calls in its own name, so the + // vault tells the login that confirms a rotation from the one it replaces (§3). + Login string + // RotatesItsLogin is a machine principal whose node-engine reads its own node's key of the vault's node-logins, + // and no other key, and confirms a switch with node-account-confirm in its own name (novox/hq ADR 0583 §1 insight, + // §3). Set while the vault's holder serves rotate-node. + RotatesItsLogin 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 @@ -380,6 +390,23 @@ var VaultOwn = SeatVerb{Seat: "mesh-vault", Verb: "own"} // ADR 0550 §2); granted as Principal.AsksTheVaultToSign says. var VaultSign = SeatVerb{Seat: "mesh-vault", Verb: delivery.SignVerb} +// VaultRotateNode is the vault's verb that begins a node's account rotation; while its holder serves it, every node +// principal reads its own node's key of node-logins (novox/hq ADR 0583 §1, §2). VaultNodeAccountConfirm is the verb a +// node-engine confirms a switch with, in its new login's name (§3). +var ( + VaultRotateNode = SeatVerb{Seat: "mesh-vault", Verb: delivery.RotateNodeVerb} + VaultNodeAccountConfirm = SeatVerb{Seat: "mesh-vault", Verb: delivery.NodeAccountConfirmVerb} +) + +// NodeLoginsReads is what a node's engine is granted of the vault's node-logins: its own node's key, read and watched, +// and no other (novox/hq ADR 0583 §1 insight). +func NodeLoginsReads(node string) []string { + if !safeSubject.MatchString(node) { + return nil + } + return readGrants(delivery.NodeLoginsBucket, node) +} + // VerbsTheControllerActsOnAWarrant are the other seat verbs the controller calls when the operator's warrant // chooses them (novox/hq ADR 0259): a machine's service restarted, and a walk started or stopped through the // controller's own `plans`, a machine's held builds sent by its own `push` and the builds held after a failed release @@ -528,6 +555,9 @@ func (p Principal) Username() string { // 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: + if p.Login != "" { + return p.Login + } return "node." + p.Node case KindController: return "controller" @@ -587,7 +617,14 @@ func AskAccessActSubject(node string) string { return "mesh.node." + node + ".as // inbox is a principal's own reply space. No user is ever granted a bare `_INBOX.>` (design 25 // §4): with one account, inbox privacy is the permission list or it is nothing, so each user's // inbox is derived from its own identity and its permissions name that prefix and no other. -func (p Principal) inbox() string { return "_INBOX." + p.Username() + ".>" } +// +// A node's two logins share the node's inbox (novox/hq ADR 0583 §1): its answers reach it whichever login it dials as. +func (p Principal) inbox() string { + if p.Kind == KindNode { + return "_INBOX.node." + p.Node + ".>" + } + return "_INBOX." + p.Username() + ".>" +} // Permissions is what a principal may publish and subscribe, and whether it may answer. type Permissions struct { @@ -1003,6 +1040,13 @@ func PermissionsFor(p Principal) (Permissions, error) { pub = append(pub, readGrants(delivery.SignersBucket, "")...) pub = append(pub, readGrants(LeaseBucket, leaseKey)...) } + // And, while the vault rotates node accounts, its own node's key of node-logins, read and watched, and no other + // key (novox/hq ADR 0583 §1 insight): the login it dials as; and the confirmation of a switch in its own name (§3). + if p.RotatesItsLogin { + pub = append(pub, NodeLoginsReads(p.Node)...) + pub = append(pub, "mesh.seat."+VaultNodeAccountConfirm.Seat+"."+CallKind+"."+VaultNodeAccountConfirm.Verb+"."+ + CallerToken(p.Username())) + } // And its node seat, node-engine, and its asks to the operator in its own name (novox/hq ADR 0563 §1, §3). ep, es := engineGrants(p) pub = append(pub, ep...) diff --git a/go/buscompose/userlist_switch.go b/go/buscompose/userlist_switch.go index 5f9d7b1..15f3342 100644 --- a/go/buscompose/userlist_switch.go +++ b/go/buscompose/userlist_switch.go @@ -104,7 +104,10 @@ var NamedReaders = map[string][]string{VaultBusAccounts: {GrantsModule}, GrantsA ControllerConditions: {health.KeeperModule}, ControllerHandActs: {health.HealersModule, handacts.Module}, HealersWould: {}, // ADR 0577 clause 1: the node-engine's data items are read by disk-load alone, each node's its own node's keys // (NodeKeyedReads); the engine announces nothing of its own, so its readers are named here. - delivery.EngineDataRead: {health.DiskLoadModule}} + 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}} // 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 720b030..ebd11dd 100644 --- a/go/buscompose/users.go +++ b/go/buscompose/users.go @@ -2,10 +2,12 @@ package buscompose import ( "fmt" - "git.novox.be/novox/mesh-sdk/go/announce" "slices" "sort" "strings" + + "git.novox.be/novox/mesh-sdk/go/announce" + "git.novox.be/novox/mesh-sdk/go/delivery" ) // Every user the composed file should contain, derived from what the mesh knows. @@ -145,6 +147,9 @@ type Records struct { // grants part as a placed module's are; it adds no principal, since each node's engine speaks as the node's account. // Nil where its manifest was not read. Engine *Declared + // NodeLogins is each node's key of the vault's node-logins (novox/hq ADR 0583 §1): the login in use and the one + // rotating, each composed as a user of the node's permissions. A node absent logs in as `node.` alone. + NodeLogins map[string]delivery.NodeLogin // Announced is each module's last announcement of the states it keeps (novox/hq ADR 0495, issue 1790), as the // controller keeps it; a module absent has announced nothing yet. Nil where none was read. Announced map[string]announce.States @@ -180,10 +185,14 @@ func Users(r Records) ([]Principal, error) { } checks = append(checks, d.Checks...) } - out = append(out, Principal{Kind: KindNode, Node: node, WitnessesController: witness, Checks: checks, - MovesIntoTheVault: servesMoveIn(r), StatesItsKeyToTheVault: servesVerb(r, VaultNodeKey), - AsksItsOwnSecrets: servesVerb(r, VaultOwn), AsksTheVaultToSign: servesVerb(r, VaultSign), Uses: asksSeatOf(r), - FollowsGrantsUserList: bus, ServedReads: servedOn(served, node)}) + // **One user per login the node has** (novox/hq ADR 0583 §1): the one in use, and while it rotates the other too, + // each with the node's permissions. + for _, login := range nodeLoginsOf(r, node) { + out = append(out, Principal{Kind: KindNode, Node: node, Login: login, WitnessesController: witness, Checks: checks, + MovesIntoTheVault: servesMoveIn(r), StatesItsKeyToTheVault: servesVerb(r, VaultNodeKey), + AsksItsOwnSecrets: servesVerb(r, VaultOwn), AsksTheVaultToSign: servesVerb(r, VaultSign), Uses: asksSeatOf(r), + FollowsGrantsUserList: bus, ServedReads: servedOn(served, node), RotatesItsLogin: servesVerb(r, VaultRotateNode)}) + } // **And the machine's provisioner client, on an account of its own** (novox/hq ADR 0349 clause 11): one // per machine that runs it, delivered to the module running it. for _, d := range r.Assigned[node] { @@ -385,6 +394,27 @@ func asksSeatOf(r Records) []Seat { return nil } +// nodeLoginsOf are the logins composed for node, as Principal.Login names them ("" for `node.`): the one its +// node-logins key says is in use and the one rotating, in that order; `node.` alone when the key is absent or does +// not hold (a key naming another node's login is no login of this node's). +func nodeLoginsOf(r Records, node string) []string { + l, ok := r.NodeLogins[node] + if !ok || l.Node != node || (l.Login != delivery.FirstLogin(node) && l.Login != delivery.SecondLogin(node)) { + return []string{""} + } + login := func(name string) string { + if name == delivery.FirstLogin(node) { + return "" + } + return name + } + out := []string{login(l.Login)} + if l.Rotates() && l.Rotating == delivery.OtherLogin(node, l.Login) { + out = append(out, login(l.Rotating)) + } + return out +} + // VaultSeat is the vault's seat: its holder alone asks the desks and the issuing providers in its own name. const VaultSeat = "mesh-vault"