From 07af86b153174a56562041b3e2cd305e07f1b363 Mon Sep 17 00:00:00 2001 From: jochen Date: Tue, 3 Nov 2026 13:38:20 +0100 Subject: [PATCH] The grants part composes a ledger account per assignment of a module asking for a ledger, publishing only on its ledger histories and snapshots (issue 1966 D5) --- modules/mesh-grants/go.mod | 2 + .../internal/buscompose/buscompose_test.go | 73 +++++++++++++++++++ .../mesh-grants/internal/buscompose/ledger.go | 66 +++++++++++++++++ .../mesh-grants/internal/buscompose/nats.go | 16 ++++ .../mesh-grants/internal/buscompose/users.go | 8 ++ .../internal/buscompose/writers.go | 6 ++ .../mesh-grants/internal/grants/records.go | 13 ++++ 7 files changed, 184 insertions(+) create mode 100644 modules/mesh-grants/internal/buscompose/ledger.go diff --git a/modules/mesh-grants/go.mod b/modules/mesh-grants/go.mod index 75906a2e..7a9a9c76 100644 --- a/modules/mesh-grants/go.mod +++ b/modules/mesh-grants/go.mod @@ -20,3 +20,5 @@ require ( golang.org/x/sys v0.48.0 // indirect golang.org/x/time v0.15.0 // indirect ) + +replace git.novox.be/novox/mesh-sdk/go => ../../../mesh-sdk/go diff --git a/modules/mesh-grants/internal/buscompose/buscompose_test.go b/modules/mesh-grants/internal/buscompose/buscompose_test.go index 7f837dfe..32953ed9 100644 --- a/modules/mesh-grants/internal/buscompose/buscompose_test.go +++ b/modules/mesh-grants/internal/buscompose/buscompose_test.go @@ -49,6 +49,7 @@ func TestTheCopiedComposerComposesAsTheControllers(t *testing.T) { t.Run("only declared streams are created", theGrantsPartCreatesOnlyDeclaredStreams) t.Run("a declared stream is created as the controller asserted it", declaredStreamsAreCreatedAsTheControllerAssertsThem) t.Run("every node's compositions are read by their named readers alone and written by their node", everyNodesCompositions) + t.Run("a module's ledger is written by its ledger account alone", aLedgerIsWrittenByItsLedgerAccountAlone) // ADR 0567 §3, §9: a node reads every key of the seat's placement and its candidates (it composes with the whole // proposal laid over), and none of its open proposals; it writes neither. reads := strings.Join(NodeComposeReads("laptop"), " ") @@ -942,3 +943,75 @@ func everyNodesCompositions(t *testing.T) { t.Errorf("the view's membership issues %+v", m.State) } } + +// 1966/AC20, AC21 (issue 1966 D5, ADR 0597 §9): a module asking for a ledger, assigned on two machines, gets one ledger account +// on each, publishing only on that module's ledger histories and snapshots; the account is gone from a machine the module +// leaves; and the module's own account, its process and the machine's tool runner hold no publish on its ledger. +func aLedgerIsWrittenByItsLedgerAccountAlone(t *testing.T) { + assigned := func(nodes ...string) []Principal { + r := Records{Nodes: nodes, Assigned: map[string][]Declared{}} + for _, n := range nodes { + r.Assigned[n] = []Declared{ + {Module: RuntimeModule}, + {Module: "m", Ledgers: []string{"records", "audit"}, Processes: []ProcessPrincipal{{Process: "w"}}}, + {Module: "plain"}, + } + } + users, err := Users(r) + if err != nil { + t.Fatal(err) + } + return users + } + ledgers := func(users []Principal) []string { + var out []string + for _, p := range users { + if p.Kind == KindLedger { + out = append(out, p.Username()) + } + } + return out + } + both := assigned("a", "b") + if got := ledgers(both); !slices.Equal(got, []string{"ledger.a.m", "ledger.b.m"}) { + t.Fatalf("ledger accounts %v, want ledger.a.m and ledger.b.m", got) + } + if got := ledgers(assigned("a")); !slices.Equal(got, []string{"ledger.a.m"}) { + t.Fatalf("after m left b: ledger accounts %v, want ledger.a.m alone", got) + } + want := []string{"mesh.ledger.m.audit.history.>", "mesh.ledger.m.audit.snapshot.>", + "mesh.ledger.m.records.history.>", "mesh.ledger.m.records.snapshot.>"} + raw := []string{"mesh.ledger.m.records.history.decision/0290", "mesh.ledger.m.records.snapshot.decision/0290"} + for _, p := range both { + perms, err := PermissionsFor(p) + if err != nil { + t.Fatalf("%s: %v", p.Username(), err) + } + if p.Kind == KindLedger { + if !slices.Equal(perms.Publish, want) || len(perms.PublishDeny) != 0 { + t.Errorf("%s may publish %v, want %v alone", p.Username(), perms.Publish, want) + } + continue + } + for _, pattern := range perms.Publish { + for _, subject := range raw { + if SubjectsOverlap(pattern, subject) { + t.Errorf("%s may publish %s, which reaches the ledger subject %s", p.Username(), pattern, subject) + } + } + } + if p.Kind == KindModule || p.Kind == KindNodeTools || p.Kind == KindProcess { + if err := CheckWriters(p, want); err == nil { + t.Errorf("%s was granted a raw publish on a ledger's history", p.Username()) + } + } + } + if err := CheckWriters(Principal{Kind: KindLedger, Node: "a", Module: "other", Ledgers: []string{"records"}}, want); err == nil { + t.Error("another module's ledger account was granted m's ledger histories") + } + for _, bad := range []Principal{{Kind: KindLedger, Node: "a", Module: "m"}, {Kind: KindLedger, Node: "a", Module: "m", Ledgers: []string{"a.b"}}} { + if _, err := PermissionsFor(bad); err == nil { + t.Errorf("%+v was composed", bad) + } + } +} diff --git a/modules/mesh-grants/internal/buscompose/ledger.go b/modules/mesh-grants/internal/buscompose/ledger.go new file mode 100644 index 00000000..e6f66ed3 --- /dev/null +++ b/modules/mesh-grants/internal/buscompose/ledger.go @@ -0,0 +1,66 @@ +package buscompose + +import ( + "fmt" + "slices" + "sort" + "strings" +) + +// A module's ledger and the one account that writes it (issue 1966 D5; novox/hq ADR 0597 §9, design 25 § 12.4). +// +// **Only the tool runner's ledger credential publishes on a ledger's history** (ADR 0597 §9): one per module assignment, +// named ledger.. (KindLedger), scoped to the subjects of that module's ledger histories and snapshots, and +// gone when the module is unassigned from the machine. The module's own account, its processes and the machine's tool +// runner hold no publish there, so a raw publish on a history is refused by the bus. The subjects are `mesh.ledger.. +// .history.` and `mesh.ledger...snapshot.`, one subject per instance +// (design 25 § 12.4); the bus module that creates the ledger's history stream (B2) takes them from here. + +// LedgerHistorySubject is the subject a ledger's history is on, without the instance: an operation on instance I is on +// `.`. +func LedgerHistorySubject(module, local string) string { + return "mesh.ledger." + module + "." + local + ".history" +} + +// LedgerSnapshotSubject is the subject a ledger's instances' snapshots are on, without the instance. +func LedgerSnapshotSubject(module, local string) string { + return "mesh.ledger." + module + "." + local + ".snapshot" +} + +// LedgerPublishGrants are the publish patterns a module's ledger account is granted: the histories and the snapshots of the +// ledgers named, every instance of each, and nothing else. Sorted. +func LedgerPublishGrants(module string, locals []string) []string { + var out []string + for _, local := range locals { + out = append(out, LedgerHistorySubject(module, local)+".>", LedgerSnapshotSubject(module, local)+".>") + } + sort.Strings(out) + return out +} + +// writesLedger is a module's ledger account publishing on that module's own ledgers' histories and snapshots, and no other +// principal (ADR 0597 §9). +func writesLedger(p Principal, pattern string) bool { + return p.Kind == KindLedger && slices.Contains(LedgerPublishGrants(p.Module, p.Ledgers), pattern) +} + +// ledgerSubjects are the subjects no principal but a ledger account may publish on, for the writers table. +var ledgerSubjects = []string{"mesh.ledger.*.*.history.>", "mesh.ledger.*.*.snapshot.>"} + +// ledgerPermissions are a ledger account's whole authority: publishing on its module's ledgers, and its own reply space for +// the stream's acknowledgements. Refused when it names no ledger or a name that is no subject token. +func ledgerPermissions(p Principal) (Permissions, error) { + pub := LedgerPublishGrants(p.Module, p.Ledgers) + if len(p.Ledgers) == 0 { + return Permissions{}, fmt.Errorf("%s asks for no ledger, so its account would publish nowhere", p.Username()) + } + for _, l := range p.Ledgers { + if !safeSubject.MatchString(l) || strings.Contains(l, "_") { + return Permissions{}, fmt.Errorf("%s: %q cannot be part of a subject: a permission is a subject pattern, and this would widen it", p.Username(), l) + } + } + if err := CheckWriters(p, pub); err != nil { + return Permissions{}, err + } + return Permissions{Publish: pub, Subscribe: []string{p.inbox()}}, nil +} diff --git a/modules/mesh-grants/internal/buscompose/nats.go b/modules/mesh-grants/internal/buscompose/nats.go index 6c5ba202..93482882 100644 --- a/modules/mesh-grants/internal/buscompose/nats.go +++ b/modules/mesh-grants/internal/buscompose/nats.go @@ -52,6 +52,10 @@ const ( // 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" + // KindLedger is one module assignment's ledger credential (novox/hq ADR 0597 §9, design 25 § 12.4): held by that + // machine's tool runner, never by the module's own code, and the only account that publishes on that module's ledger + // histories and snapshots. One per module assignment, whose module asks for a ledger in `requires`. + KindLedger Kind = "ledger" ) // RuntimeModule is the module that IS the node's tool runtime (novox/hq ADR 0175). Where it is @@ -102,6 +106,9 @@ type Principal struct { Kind Kind Node string Module string + // Ledgers are the local names of the ledgers its module asks for in `requires`, for a KindLedger principal alone + // (issue 1966 D5): its whole authority is publishing on those ledgers' histories and snapshots (LedgerPublishGrants). + Ledgers []string // Process is the process resource's id, for a KindProcess principal alone (novox/hq ADR 0436 §2): // Username and inbox are ... Process string @@ -425,6 +432,10 @@ func (p Principal) Username() string { return p.Login } return "node." + p.Node + case KindLedger: + // `ledger..`: never `.`, which is the module's own account or the tool runner's, + // so the module's code never holds it. + return "ledger." + p.Node + "." + p.Module case KindProvisioner: // Named for the machine, as a node is: one client per machine, whichever module runs it, and never // `.`, which is the module's own account or the tool runner's. @@ -567,6 +578,11 @@ func PermissionsFor(p Principal) (Permissions, error) { return Permissions{Publish: pub, Subscribe: []string{p.inbox()}}, nil } + // A ledger account publishes on its module's ledger histories and snapshots and nothing else (issue 1966 D5). + if p.Kind == KindLedger { + return ledgerPermissions(p) + } + if p.Kind == KindProcess && len(p.Removes) > 0 { pub, err := BusRemovalGrants(p.Removes, p.FilesFor) if err != nil { diff --git a/modules/mesh-grants/internal/buscompose/users.go b/modules/mesh-grants/internal/buscompose/users.go index 2dcabb73..cbf0a027 100644 --- a/modules/mesh-grants/internal/buscompose/users.go +++ b/modules/mesh-grants/internal/buscompose/users.go @@ -103,6 +103,9 @@ type Declared struct { // 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 + // Ledgers are the local names of the ledgers it asks for in `requires` (catalogue.StorageLedger); each assignment of a + // module with any gets a ledger principal of its own (issue 1966 D5, ADR 0597 §9). + Ledgers []string } // ProcessPrincipal is one process of a module that declares bus (novox/hq ADR 0436 §1, §2), as the broker @@ -229,6 +232,11 @@ func Users(r Records) ([]Principal, error) { out[len(out)-1].FilesFor = sortedCopy(r.Nodes) } } + // **A ledger account per assignment of a module that asks for a ledger** (issue 1966 D5, ADR 0597 §9): held by the + // machine's tool runner, so it is composed whether or not the module has an account of its own. + if len(d.Ledgers) > 0 { + out = append(out, Principal{Kind: KindLedger, Node: node, Module: d.Module, Ledgers: sortedCopy(d.Ledgers)}) + } if runtimeHere && d.Module == RuntimeModule { continue } diff --git a/modules/mesh-grants/internal/buscompose/writers.go b/modules/mesh-grants/internal/buscompose/writers.go index 576d26b1..efb0712b 100644 --- a/modules/mesh-grants/internal/buscompose/writers.go +++ b/modules/mesh-grants/internal/buscompose/writers.go @@ -236,6 +236,12 @@ var WritersTable = []WriterRow{ ProvisionerAppliedBucket, Others: "the watchman reads it; a failure is that machine's failed apply", Subjects: []string{"prov.applied.*.>"}, Writes: ownProvisioner}, + // A ledger's history and its snapshots (novox/hq ADR 0597 §9, issue 1966 D5): the tool runner's ledger credential of + // the owning module alone, so the module's own code, its processes and the runner's own account cannot write one raw. + {State: "a ledger's history and snapshots", Writer: "the tool runner's ledger credential of the owning module", + KeptIn: "the bus, the ledger's history stream and its snapshot subject", + Others: "the owner reads them through the tool runner's calls; nobody else reads them (ADR 0596 §2)", + Subjects: ledgerSubjects, Writes: writesLedger}, } // CheckWriters refuses a grant that lets a principal publish on a subject the writers table gives diff --git a/modules/mesh-grants/internal/grants/records.go b/modules/mesh-grants/internal/grants/records.go index 2b9dcc71..bb74d475 100644 --- a/modules/mesh-grants/internal/grants/records.go +++ b/modules/mesh-grants/internal/grants/records.go @@ -139,6 +139,8 @@ func declaredFor(m catalogue.Manifest, seats map[string]catalogue.SeatDeclaratio Served: catalogue.ServedSettings(m), // Its own object buckets (ADR 0592 §5). Objects: objectsOf(m), + // The ledgers it asks for in `requires` (issue 1966 D5): each assignment gets a ledger account. + Ledgers: ledgersOf(m), } d.Loads = loadsABundle(m) if _, reads := m.OwnSecrets["broker"]; !reads { @@ -282,6 +284,17 @@ func readsOfObjects(module, node string, reads, refused []string, manifests map[ return states, objects, refusedOut } +// ledgersOf are the local names of the ledgers a manifest asks for in `requires`. +func ledgersOf(m catalogue.Manifest) []string { + var out []string + for _, e := range m.BusState { + if e.Storage == catalogue.StorageLedger { + out = append(out, e.Name) + } + } + return out +} + func logsOf(m catalogue.Manifest) []buscompose.Log { var out []buscompose.Log for _, l := range m.Logs {