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)

This commit is contained in:
2026-11-03 13:38:20 +01:00
parent dd2e7c8e64
commit 07af86b153
7 changed files with 184 additions and 0 deletions
+2
View File
@@ -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
@@ -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)
}
}
}
@@ -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.<machine>.<module> (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.<module>.
// <local name>.history.<instance>` and `mesh.ledger.<module>.<local name>.snapshot.<instance>`, 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
// `<LedgerHistorySubject>.<I>`.
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
}
@@ -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 <node>.<module>.<process id>.
Process string
@@ -425,6 +432,10 @@ func (p Principal) Username() string {
return p.Login
}
return "node." + p.Node
case KindLedger:
// `ledger.<machine>.<module>`: never `<node>.<module>`, 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
// `<node>.<module>`, 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 {
@@ -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-<process id>, 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
}
@@ -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
@@ -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 {