buscompose keeps only the bus's names and shapes; the composer of the user list is the grants part's own (ADR 0593 §1)
Claude-Session: https://claude.ai/code/session_01NcbgfJ9Gum47zkFKWRaovL
This commit is contained in:
@@ -1,28 +1,5 @@
|
||||
package buscompose
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"slices"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/announce"
|
||||
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
)
|
||||
|
||||
// Every user the composed file should contain, derived from what the mesh knows.
|
||||
//
|
||||
// **The list is derived, never kept.** A stored user list would be a second account of who may
|
||||
// reach the bus, able to disagree with the records it came from — and the disagreement would be
|
||||
// invisible, because both would look internally consistent. So this is a pure function of the
|
||||
// mesh's records, run again every time the file is written.
|
||||
//
|
||||
// Records are mirrored into this package's own types rather than imported from the catalogue, for
|
||||
// the reason DeclaredSeat is: composing authority is a different job from parsing a manifest, and
|
||||
// this package stays free of the other's types so a change to a manifest field cannot quietly widen
|
||||
// a permission.
|
||||
|
||||
// Declared is one module on one node, as composing its authority needs it.
|
||||
type Declared struct {
|
||||
Module string
|
||||
@@ -124,445 +101,5 @@ type ProcessPrincipal struct {
|
||||
Invokes []string
|
||||
}
|
||||
|
||||
// Records is what composing a user list needs to know about the mesh, and nothing more.
|
||||
type Records struct {
|
||||
// Nodes is every machine the mesh knows. Each gets a host user.
|
||||
Nodes []string
|
||||
// Assigned is the modules on each node, as they declare themselves.
|
||||
Assigned map[string][]Declared
|
||||
// Enrolling is every node with a live token — one enrolment user each, because the inbox an
|
||||
// answer goes to is scoped to the token and a shared one is one machine reading another's
|
||||
// sealed credentials (design 25 §6). The grants part fills it from the seat mesh-enrolment's keys at live or
|
||||
// redeeming (novox/hq ADR 0587 §4), the controller from its store.
|
||||
Enrolling []string
|
||||
// People is each person's name against the tools they may invoke, `*` for an administrator.
|
||||
People map[string][]string
|
||||
// Interchangeable is each module whose definition says its instances are the same anywhere
|
||||
// (ADR 0160), which decides whether the module's plain subject is issued to every instance.
|
||||
Interchangeable map[string]bool
|
||||
// RootFree is each machine judged root-free when this was composed (novox/hq ADR 0259 §8): it names an
|
||||
// account agents run as, judged unable to become root by its node-engine, and serves no login shell
|
||||
// execute. A machine absent is not free: no record of a pass is no pass.
|
||||
RootFree map[string]bool
|
||||
// Engine is the node-engine's own declaration, from its manifest (EngineModule): placed on every node though no node is
|
||||
// assigned it, since every node runs it (novox/hq ADR 0563 §2, §3). Its state, logs and streams are created by the
|
||||
// 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.<n>` 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
|
||||
}
|
||||
|
||||
// Users is every user the composed file should contain, in the order it will be written.
|
||||
func Users(r Records) ([]Principal, error) {
|
||||
var out []Principal
|
||||
|
||||
served := servedReadsOf(r)
|
||||
logs := DeclaredLogs(r)
|
||||
grants := -1
|
||||
for _, node := range sortedCopy(r.Nodes) {
|
||||
var checks []string
|
||||
bus := false
|
||||
for _, d := range r.Assigned[node] {
|
||||
if d.SnapshotsTheBus {
|
||||
bus = true
|
||||
}
|
||||
checks = append(checks, d.Checks...)
|
||||
}
|
||||
// **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, 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] {
|
||||
if d.ProvisionsNode {
|
||||
out = append(out, Principal{Kind: KindProvisioner, Node: node, Module: d.Module,
|
||||
BoundBuckets: boundBucketsOf(r)})
|
||||
break
|
||||
}
|
||||
}
|
||||
// **Where the runtime is assigned, the machine gets one runtime principal in place of the
|
||||
// runtime module's own** (novox/hq ADR 0175, to-be 38). It carries every module on the
|
||||
// node: its serving grants are the union of theirs. Every other module keeps its own
|
||||
// principal — a module still serving tools from its own container holds its own
|
||||
// credential until it moves, and the two serve side by side in the meantime.
|
||||
runtimeHere := false
|
||||
for _, d := range r.Assigned[node] {
|
||||
if d.Module == RuntimeModule {
|
||||
runtimeHere = true
|
||||
}
|
||||
}
|
||||
for _, d := range r.Assigned[node] {
|
||||
// **The node-engine's manifest is no placed module's** (novox/hq ADR 0563 §2, §3; issue 774): where a placement
|
||||
// names it, it adds no user, no process and nothing the runner carries; its grants are the node's account's.
|
||||
if !Composed(d) {
|
||||
continue
|
||||
}
|
||||
// A state with named readers is read by them alone (ADR 0547 clause 9), and a read is granted what its owner
|
||||
// announced (ADR 0495 clause 3): judged here, for this reader, whatever split the caller made (issue 774).
|
||||
d = withReadLogs(withAnnouncedReads(d, r.Announced), logs)
|
||||
// **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, Removes: pr.Removes,
|
||||
})
|
||||
// The ended controller's leftovers include each node's declaration files' bucket (ADR 0590 clause 6).
|
||||
if slices.Contains(pr.Removes, catalogue.RemovesControllerLeftovers) {
|
||||
out[len(out)-1].FilesFor = sortedCopy(r.Nodes)
|
||||
}
|
||||
}
|
||||
if runtimeHere && d.Module == RuntimeModule {
|
||||
continue
|
||||
}
|
||||
// **A module with nowhere to read an account is no user** (novox/hq issue 195). `module
|
||||
// issue` refuses it one (issue 078: an account nothing reads is an orphan), so its user
|
||||
// could only ever be left out of the file for want of a password — and every such module
|
||||
// was named, on every status and plan, as a credential the mesh had not minted. Where the
|
||||
// runtime is, it speaks for the module; where it is not, the module cannot speak at all,
|
||||
// and a user would not change that.
|
||||
if d.NoAccount {
|
||||
continue
|
||||
}
|
||||
out = append(out, Principal{
|
||||
Kind: KindModule, Node: node, Module: d.Module,
|
||||
Emits: d.Emits, Consumes: d.Consumes, Serves: d.Serves,
|
||||
Holds: d.Holds, Uses: d.Uses, Watches: d.Watches, Invokes: d.Invokes,
|
||||
State: stateNames(d.State), Reads: d.Reads, ReadLogs: d.ReadLogs, SnapshotsTheBus: d.SnapshotsTheBus,
|
||||
PerMachine: perMachineNames(d.State), KeyedReads: d.KeyedReads,
|
||||
Logs: logNames(d.Logs), Streams: d.Streams, Hears: ComposedHears(d), Queues: d.Queues,
|
||||
})
|
||||
// **The grants part creates what a manifest declares** (novox/hq ADR 0547 §5; the operator, 2026-10-26
|
||||
// 08:54): every placed module's buckets, logs and streams, each by its stream's name.
|
||||
if d.Module == WatchmanModule {
|
||||
out[len(out)-1].DeadLetterSources = deadLetterSourcesOf(r)
|
||||
}
|
||||
if d.Module == GrantsModule {
|
||||
out[len(out)-1].CreatesStreams = declaredStreamsOf(r)
|
||||
grants = len(out) - 1
|
||||
}
|
||||
// **The vault's holder asks the desks and the issuing providers itself** (issue 723), under the
|
||||
// account its own runtime runs on (ADR 0259 §8's runs-as): the one module granted it.
|
||||
if holdsSeat(d, VaultSeat) {
|
||||
out[len(out)-1].AsksTheDesk = true
|
||||
out[len(out)-1].AsksIssued = issuedOf(r)
|
||||
}
|
||||
}
|
||||
if runtimeHere {
|
||||
var carried []Declared
|
||||
for _, d := range r.Assigned[node] {
|
||||
if CarriedByRunner(d) {
|
||||
d = withReadLogs(withAnnouncedReads(d, r.Announced), logs)
|
||||
// Never a restricted stream for what the tool runner carries (novox/hq ADR 0376 §1).
|
||||
d.Hears = ComposedHears(d)
|
||||
carried = append(carried, d)
|
||||
}
|
||||
}
|
||||
out = append(out, Principal{Kind: KindNodeTools, Node: node, Module: RuntimeModule, Carries: carried})
|
||||
}
|
||||
}
|
||||
// A node enrols through the seat mesh-enrolment alone (ADR 0587 §6, ADR 0590): while no holder serves redeem, an
|
||||
// enrolling node has nowhere to enrol, and gets no user.
|
||||
if servesVerb(r, EnrolmentRedeem) {
|
||||
for _, node := range sortedCopy(r.Enrolling) {
|
||||
out = append(out, Principal{Kind: KindEnrolment, Node: node, Redeems: true})
|
||||
}
|
||||
}
|
||||
for _, person := range sortedNames(r.People) {
|
||||
out = append(out, Principal{Kind: KindPerson, Module: person, Invokes: r.People[person]})
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
|
||||
// Refused here rather than discovered by the server. Two users with one name is a file the
|
||||
// server reads as one of them, and which one depends on the order — so a module assigned to a
|
||||
// node twice, or a person named after nothing, is a composition that must not be written.
|
||||
seen := map[string]string{}
|
||||
for _, p := range out {
|
||||
name := p.Username()
|
||||
if name == "" || name == "." {
|
||||
return nil, fmt.Errorf("a %s user has no name, so nothing could authenticate as it", p.Kind)
|
||||
}
|
||||
if first, already := seen[name]; already {
|
||||
return nil, fmt.Errorf(
|
||||
"two users would be called %q (a %s and a %s): the server would read the file as "+
|
||||
"one of them, and which one depends on the order", name, first, p.Kind)
|
||||
}
|
||||
seen[name] = string(p.Kind)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// WithPasswords fills each user's hash from what the mesh minted, and says which users have none.
|
||||
//
|
||||
// **Separated from Users because they fail differently.** A user missing from the records is a bug
|
||||
// in deriving them; a user with no password is a step that has not happened yet — a module assigned
|
||||
// but never given a credential, a node enrolled before this existed. The second is ordinary and its
|
||||
// remedy is to mint one, so it is named rather than returned as an error, and the caller decides
|
||||
// whether a partial composition is worth writing.
|
||||
func WithPasswords(principals []Principal, hashes map[string]string) (filled []Principal, missing []string) {
|
||||
for _, p := range principals {
|
||||
hash, ok := hashes[p.Username()]
|
||||
if !ok || hash == "" {
|
||||
missing = append(missing, p.Username())
|
||||
continue
|
||||
}
|
||||
p.PasswordHash = hash
|
||||
filled = append(filled, p)
|
||||
}
|
||||
return filled, missing
|
||||
}
|
||||
|
||||
func sortedCopy(in []string) []string {
|
||||
out := append([]string(nil), in...)
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
|
||||
func sortedNames(in map[string][]string) []string {
|
||||
out := make([]string, 0, len(in))
|
||||
for k := range in {
|
||||
out = append(out, k)
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// asksSeatOf is the seat an ask is made on, as its holder declares it (novox/hq ADR 0259): the controller
|
||||
// asks the operator through it like any other user, and is granted what its declaration names for a caller.
|
||||
// None while nothing holds it.
|
||||
// issuedOf is every tool of a module assigned anywhere that answers a credential its software issued, as
|
||||
// `<module>.<tool>` (novox/hq ADR 0305): what the controller asks in its own name, and nothing beside.
|
||||
func issuedOf(r Records) []string {
|
||||
seen := map[string]bool{}
|
||||
var out []string
|
||||
for _, node := range sortedCopy(r.Nodes) {
|
||||
for _, d := range r.Assigned[node] {
|
||||
for _, t := range d.Issued {
|
||||
if key := d.Module + "." + t; !seen[key] {
|
||||
seen[key] = true
|
||||
out = append(out, key)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
|
||||
func asksSeatOf(r Records) []Seat {
|
||||
for _, node := range sortedCopy(r.Nodes) {
|
||||
for _, d := range r.Assigned[node] {
|
||||
for _, s := range d.Holds {
|
||||
if s.Name == AsksSeat && namesVerb(s.ByCaller, "ask") {
|
||||
seat := s
|
||||
seat.Kind, seat.Capabilities = "", nil
|
||||
return []Seat{seat}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// nodeLoginsOf are the logins composed for node, as Principal.Login names them ("" for `node.<n>`): the one its
|
||||
// node-logins key says is in use and the one rotating, in that order; `node.<n>` 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"
|
||||
|
||||
func holdsSeat(d Declared, seat string) bool {
|
||||
for _, s := range d.Holds {
|
||||
if s.Name == seat {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// servedOf are the verbs of asked that a seat's holder serves, as the records say, by name.
|
||||
func servedOf(r Records, asked []SeatVerb) []string {
|
||||
seen := map[string]bool{}
|
||||
var out []string
|
||||
for _, node := range sortedCopy(r.Nodes) {
|
||||
for _, d := range r.Assigned[node] {
|
||||
for _, s := range d.Holds {
|
||||
for _, v := range asked {
|
||||
if s.Name == v.Seat && slices.Contains(s.Serves, v.Verb) && !seen[v.Verb] {
|
||||
seen[v.Verb] = true
|
||||
out = append(out, v.Verb)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// servesMoveIn says the vault's holder serves move-in, as the records say (issue 676): what lets each machine's
|
||||
// node-engine ask it in its own name.
|
||||
func servesMoveIn(r Records) bool { return servesVerb(r, VaultMoveIn) }
|
||||
|
||||
// servesVerb says a holder of verb's seat serves it, as the records say.
|
||||
func servesVerb(r Records, verb SeatVerb) bool {
|
||||
for _, node := range r.Nodes {
|
||||
for _, d := range r.Assigned[node] {
|
||||
for _, s := range d.Holds {
|
||||
if s.Name == verb.Seat && slices.Contains(s.Serves, verb.Verb) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// boundBucketsOf is every provider's bucket of entries, `<module>_bound` (mesh-sdk go/provider, issue 678): the state
|
||||
// named bound of every module placed anywhere, once each, in name order. A consumer on any machine may be bound to
|
||||
// it, so each machine's provisioner client reads its own keys of each (issue 679).
|
||||
func boundBucketsOf(r Records) []string {
|
||||
seen := map[string]bool{}
|
||||
for _, mods := range r.Assigned {
|
||||
for _, d := range mods {
|
||||
for _, b := range d.State {
|
||||
if b.Name == BoundState {
|
||||
seen[b.Bucket()] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
out := make([]string, 0, len(seen))
|
||||
for b := range seen {
|
||||
out = append(out, b)
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// Composed says a placed module is composed as one: its own user, its processes and what the runner carries of it.
|
||||
// The node-engine's manifest (EngineModule) is not, though a placement may name it: each node's engine speaks as the
|
||||
// node's account, which alone writes the engine's state under its own key (engineGrants, novox/hq ADR 0563 §2, §3), and
|
||||
// Records.Engine creates that state. Granted to another account it is a second writer, and the writers table refuses
|
||||
// the whole list (issue 774: met for mesh-host_acts and then mesh-host_health, 2026-10-30).
|
||||
func Composed(d Declared) bool { return d.Module != EngineModule }
|
||||
|
||||
// CarriedByRunner says the machine's tool runner carries this placed module: a module composed at all (Composed) that
|
||||
// runs as no account of its own and has something for the runner to serve (runnerServes). The one test of what the
|
||||
// runner's grants are composed from.
|
||||
func CarriedByRunner(d Declared) bool { return Composed(d) && d.RunsAs == "" && runnerServes(d) }
|
||||
|
||||
// runnerServes says the machine's tool runner has something of this module to serve (novox/hq ADR 0436 §1,
|
||||
// ADR 0437 §4, issue 1329): tools or loads it answers, a seat it holds, event families a session waits on,
|
||||
// a bundle the runtime loads (Loads), or a check its health asks. A module with none of these is a process of its own on its own credential, and
|
||||
// carrying it would hand every session on the machine the runner's account with that module's state, seat
|
||||
// traffic and streams. A module with no account of its own (NoAccount) is spoken for by the runner, so it
|
||||
// stays carried, and so does the runtime module itself. Whether the module has its own broker secret is not
|
||||
// the test: a module with tools and its own credential is still carried (ADR 0175).
|
||||
func runnerServes(d Declared) bool {
|
||||
return d.Module == RuntimeModule || d.NoAccount || d.Loads || len(d.Serves) > 0 || len(d.Holds) > 0 || len(d.Waits) > 0 || len(d.Checks) > 0
|
||||
}
|
||||
|
||||
// declaredStreamsOf is the stream of every bucket, log and stream a module placed anywhere declares, once each, in name
|
||||
// order (novox/hq ADR 0547 §5, issue 1790): `KV_<module>_<state>`, `LOG_<module>_<log>` and each module stream's own
|
||||
// name. What the grants part may create, and nothing beside.
|
||||
func declaredStreamsOf(r Records) []string {
|
||||
seen := map[string]bool{}
|
||||
placed := [][]Declared{}
|
||||
for _, mods := range r.Assigned {
|
||||
placed = append(placed, mods)
|
||||
}
|
||||
// The node-engine's manifest is placed on every node (ADR 0563): its declared state is created as a placed module's.
|
||||
if r.Engine != nil {
|
||||
placed = append(placed, []Declared{*r.Engine})
|
||||
}
|
||||
for _, mods := range placed {
|
||||
for _, d := range mods {
|
||||
for _, b := range d.State {
|
||||
seen["KV_"+b.Bucket()] = true
|
||||
}
|
||||
for _, l := range d.Logs {
|
||||
seen[l.Stream()] = true
|
||||
}
|
||||
for _, s := range d.Streams {
|
||||
seen[s.Name] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
out := make([]string, 0, len(seen))
|
||||
for s := range seen {
|
||||
out = append(out, s)
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// servedReadsOf is every key of mesh-config's resolved values a module assigned on a node serves from,
|
||||
// `<node>.<module>.<key>`, sorted: each part a subject token, so a key that cannot be one is granted nothing (and the
|
||||
// node composing a consumer of it refuses, naming it), never a wider read.
|
||||
func servedReadsOf(r Records) []string {
|
||||
var out []string
|
||||
for node, declared := range r.Assigned {
|
||||
for _, d := range declared {
|
||||
for _, k := range d.Served {
|
||||
if safeSubject.MatchString(node) && safeSubject.MatchString(d.Module) && safeSubject.MatchString(k) {
|
||||
out = append(out, node+"."+d.Module+"."+k)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
sort.Strings(out)
|
||||
return slices.Compact(out)
|
||||
}
|
||||
|
||||
// servedOn is, of the served keys, those of other nodes than node: its own slice it reads whole.
|
||||
func servedOn(served []string, node string) []string {
|
||||
var out []string
|
||||
for _, k := range served {
|
||||
if !strings.HasPrefix(k, node+".") {
|
||||
out = append(out, k)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user