diff --git a/go/buscompose/accounts.go b/go/buscompose/accounts.go index 1402804..4c2b3fd 100644 --- a/go/buscompose/accounts.go +++ b/go/buscompose/accounts.go @@ -186,3 +186,59 @@ func HeldCredential(w WantedAccount, password string) ([]byte, error) { }{URL: w.URL, Fingerprint: w.Fingerprint, Node: w.Node, Module: w.Module, User: w.User, Password: password, Claims: w.Claims}) } + +// **One issuer at every moment: minting turns on at the lease write** (novox/hq ADR 0561 §3, §6). The controller's lease +// is the one key `holder` of its bucket (LeaseBucket). The operator's credential hands it away by writing a value that +// names its new holder in `handed-to`; while the key holds that, the vault mints and the grants part creates the durable +// consumers an account needs. A controller's value, a key deleted or expired, or one that cannot be read or parsed, turn +// both off: when in doubt nothing is minted, so the controller and the vault never both mint one account. No clock and +// no setting turns it. + +// ControllerLease is the lease's bucket as a reader names it; the vault and the grants part alone read it +// (NamedReaders), the controller's announcement marking it readable by nobody. +const ControllerLease = ControllerSeat + ".lease" + +// ControllerLeaseKey is the lease's one key. +const ControllerLeaseKey = leaseKey + +// LeaseHolder is the lease key's value: a controller instance's (mesh-controller internal/lease Holder), or, once handed +// away, one naming its holder in HandedTo. +type LeaseHolder struct { + Instance string `json:"instance,omitempty"` + Host string `json:"host,omitempty"` + Epoch uint64 `json:"epoch,omitempty"` + Taken time.Time `json:"taken,omitzero"` + Renewed time.Time `json:"renewed,omitzero"` + // HandedTo is who holds the lease once the controller handed it away: never a controller instance. + HandedTo string `json:"handed-to,omitempty"` + // HandedBy is the credential that wrote it (the operator's), and Why its reason. + HandedBy string `json:"handed-by,omitempty"` + Why string `json:"why,omitempty"` +} + +// LeaseHandedAway reads the lease key as a getter answered it (its value, whether it was there, the read's error) and +// says whether it is handed to a holder that is no controller, with why either way. +func LeaseHandedAway(raw []byte, found bool, readErr error) (bool, string) { + switch { + case readErr != nil: + return false, "the controller's lease cannot be read (" + readErr.Error() + "), so nothing is minted" + case !found || len(raw) == 0 || string(raw) == "null": + return false, "no lease is written (expired, deleted, or never taken): a controller may take it, so nothing is minted" + } + var h LeaseHolder + if err := json.Unmarshal(raw, &h); err != nil { + return false, "the controller's lease is not readable as a holder (" + err.Error() + "), so nothing is minted" + } + if strings.TrimSpace(h.HandedTo) == "" { + who := h.Instance + if who == "" { + who = "an instance that names itself not" + } + return false, "the controller holds the lease (" + who + "): it mints, so the vault mints nothing (ADR 0561 §3)" + } + if h.Instance != "" { + return false, "the lease names both a controller instance (" + h.Instance + ") and a holder it is handed to (" + + h.HandedTo + "), so nothing is minted" + } + return true, "the lease is handed to " + h.HandedTo + ": the vault mints every new account (ADR 0561 §1, §3)" +} diff --git a/go/buscompose/announced.go b/go/buscompose/announced.go index c03e705..09637ba 100644 --- a/go/buscompose/announced.go +++ b/go/buscompose/announced.go @@ -1,6 +1,7 @@ package buscompose import ( + "slices" "sort" "strings" @@ -58,6 +59,10 @@ func ReadsAnnouncedBy(reader string, reads []string, announced map[string]announ switch { case reader != "" && !ReaderMay(reader, r): refused = append(refused, r) + case reader != "" && namedReader(reader, r): + // A state the composer names its readers for is theirs whatever its owner announced (ADR 0561 §3: the + // controller's lease, which its announcement marks readable by nobody). + granted = append(granted, r) case reader != "" && ok && said && s.Keeps(name): if s.ReadableBy(name, reader) { granted = append(granted, r) @@ -95,3 +100,8 @@ func NotAnnounced(r Records) []string { sort.Strings(out) return out } + +// namedReader says the composer names module among the readers of read (NamedReaders). +func namedReader(module, read string) bool { + return slices.Contains(NamedReaders[strings.TrimSpace(read)], module) +} diff --git a/go/buscompose/buscompose_test.go b/go/buscompose/buscompose_test.go index 2b55e87..86aa24e 100644 --- a/go/buscompose/buscompose_test.go +++ b/go/buscompose/buscompose_test.go @@ -2,6 +2,11 @@ package buscompose import ( "encoding/json" + "errors" + "time" + + "git.novox.be/novox/mesh-sdk/go/announce" + "github.com/nats-io/nats.go/jetstream" "os" "path/filepath" "reflect" @@ -51,6 +56,13 @@ func mayBeyondTheControllers(user string) map[string]bool { VaultSecretChanged: true} } +// createsAnAccountsConsumer is the grants part's create and read of a durable consumer an account needs, by its stream +// and name (novox/hq ADR 0561 §6): beyond the controller's on purpose, since the controller made them alone. +func createsAnAccountsConsumer(user, g string) bool { + return strings.HasSuffix(user, "."+GrantsModule) && + (strings.HasPrefix(g, "$JS.API.CONSUMER.CREATE.") || strings.HasPrefix(g, "$JS.API.CONSUMER.INFO.")) +} + // quotedToken is a quoted token of a composed list. var quotedToken = regexp.MustCompile(`"[^"]*"`) @@ -102,7 +114,8 @@ func grantsAsTheControllers(t *testing.T, got, want string) { } } for g := range now { - if !was[g] && !intended[g] && !mayBeyondTheControllers(user)[g] && !createdAdvisory.MatchString(`"`+g+`", `) { + if !was[g] && !intended[g] && !mayBeyondTheControllers(user)[g] && !createdAdvisory.MatchString(`"`+g+`", `) && + !createsAnAccountsConsumer(user, g) { t.Errorf("%s: the copy grants %s beyond the controller's, and no decision names it", user, g) } } @@ -397,4 +410,65 @@ func TestTheCopiedComposerComposesAsTheControllers(t *testing.T) { t.Error("an account was held with no password") } }) + // 774 subtasks 6 and 8 (novox/hq ADR 0561 §3, §6): minting and the grants part's consumers turn on only while the + // lease is handed to a holder that is no controller; the vault and the grants part alone read the lease, whatever the + // controller announced; a module's consumer is created as the controller's EnsureConsumer creates it. + t.Run("the lease turns minting on only when handed away; a consumer is created as the controller's", func(t *testing.T) { + for _, row := range []struct { + name string + raw string + found bool + err error + on bool + }{ + {"a controller holds it", `{"instance":"novox/1234/2026-10-27T20:00:00Z","epoch":57}`, true, nil, false}, + {"handed away", `{"handed-to":"the operator's hand-over","handed-by":"person.jochen"}`, true, nil, true}, + {"expired or deleted", ``, false, nil, false}, + {"unreadable", ``, false, errors.New("refused"), false}, + {"not a holder", `[1]`, true, nil, false}, + {"both a controller and a handed holder", `{"instance":"novox/1","handed-to":"x"}`, true, nil, false}, + } { + if on, why := LeaseHandedAway([]byte(row.raw), row.found, row.err); on != row.on || why == "" { + t.Errorf("%s: on %v (%s), want %v", row.name, on, why, row.on) + } + } + announced := map[string]announce.States{ControllerSeat: ControllerAnnouncement("novox")} + for reader, want := range map[string]bool{VaultSeat: true, GrantsModule: true, "mesh-delivery-view": false} { + granted, _ := ReadsAnnouncedBy(reader, []string{ControllerLease}, announced) + if (len(granted) == 1) != want { + t.Errorf("%s reads the lease: %v, want %v", reader, granted, want) + } + } + c, _ := ConsumerFor(Principal{Kind: KindModule, Node: "a", Module: "m", Consumes: []string{"x.happened"}}) + cfg := ConsumerConfig(c) + if cfg.Durable != "a_m" || cfg.AckPolicy != jetstream.AckExplicitPolicy || cfg.AckWait != 30*time.Second || + cfg.MaxDeliver != 5 || cfg.DeliverPolicy != jetstream.DeliverNewPolicy || len(cfg.FilterSubjects) != 2 || + cfg.Description == "" { + t.Errorf("a module's consumer is created as %+v", cfg) + } + // The grants part is granted the create of each account's consumer by its stream and name, and no other. + users, err := Users(Records{Nodes: []string{"a"}, Assigned: map[string][]Declared{"a": { + {Module: GrantsModule}, {Module: "m", Consumes: []string{"x.happened"}}, {Module: "quiet"}}}}) + if err != nil { + t.Fatal(err) + } + for _, u := range users { + if u.Module != GrantsModule || u.Kind != KindModule { + continue + } + perms, err := PermissionsFor(u) + if err != nil { + t.Fatal(err) + } + if !MayPublish(perms, "$JS.API.CONSUMER.CREATE.EVENTS.a_m") || MayPublish(perms, "$JS.API.CONSUMER.CREATE.EVENTS.a_quiet") || + MayPublish(perms, "$JS.API.CONSUMER.DELETE.EVENTS.a_m") || MayPublish(perms, "$JS.API.CONSUMER.CREATE.EVENTS.a_m.mesh.mod.x.event.happened") { + t.Errorf("the grants part creates consumers as %v", u.CreatesConsumers) + } + } + q := ConsumerConfig(QueueConsumer(Principal{Kind: KindProcess, Node: "a", Module: "m", Process: "p"}, + QueueOwner{Process: "p", Stream: "M_WORK", MaxDeliver: 3})) + if q.Durable != "a_m_p" || q.FilterSubject != "" || q.MaxDeliver != 3 || q.DeliverPolicy != jetstream.DeliverAllPolicy { + t.Errorf("a process's queue consumer is created as %+v", q) + } + }) } diff --git a/go/buscompose/create.go b/go/buscompose/create.go index 17adb72..0147716 100644 --- a/go/buscompose/create.go +++ b/go/buscompose/create.go @@ -86,3 +86,30 @@ func ModuleStreamConfig(m ModuleStream) jetstream.StreamConfig { } return cfg } + +// ConsumerConfig is a pull consumer as the controller's EnsureConsumer creates it (mesh-controller internal/broker +// JetStream.EnsureConsumer, a consumer not there yet): its durable name, explicit acks, its ack wait, max deliver and max +// ack pending, its description, one filter or several, and from the stream's end when it is made FromNow. The grants part +// creates a module's and a process's consumers with it from the lease write (novox/hq ADR 0561 §6): a create alone, +// never an update of one the bus holds. A push consumer or a queue group is the controller's own and never made here. +func ConsumerConfig(c Consumer) jetstream.ConsumerConfig { + cfg := jetstream.ConsumerConfig{ + Durable: c.Name, + AckPolicy: jetstream.AckExplicitPolicy, + AckWait: time.Duration(c.AckWaitSeconds) * time.Second, + MaxDeliver: c.MaxDeliver, + MaxAckPending: c.MaxAckPending, + Description: c.Why, + } + switch len(c.Filters) { + case 0: + case 1: + cfg.FilterSubject = c.Filters[0] + default: + cfg.FilterSubjects = c.Filters + } + if c.FromNow { + cfg.DeliverPolicy = jetstream.DeliverNewPolicy + } + return cfg +} diff --git a/go/buscompose/derived.go b/go/buscompose/derived.go index 04e0e87..a727e84 100644 --- a/go/buscompose/derived.go +++ b/go/buscompose/derived.go @@ -247,6 +247,30 @@ func ConsumersOf(users []Principal) []ModuleConsumer { return out } +// AccountConsumers are the durable consumers an account needs (novox/hq ADR 0561 §6): of ConsumersOf, each module's on +// EVENTS and each process's on its own work queue, never one on a module stream it hears (those the controller makes with +// the stream's grant). The grants part creates them from the lease write, with these configurations. +func AccountConsumers(users []Principal) []ModuleConsumer { + queues := map[string]bool{} + for _, p := range users { + for _, q := range p.Queues { + queues[q.Stream] = true + } + for _, d := range p.Carries { + for _, q := range d.Queues { + queues[q.Stream] = true + } + } + } + var out []ModuleConsumer + for _, mc := range ConsumersOf(users) { + if mc.Consumer.Stream == consumerStream(Principal{Kind: KindModule}) || queues[mc.Consumer.Stream] { + out = append(out, mc) + } + } + return out +} + // HolderConsumerFor is the worker a seat's holders share on that seat's work queue. // // **One worker for every holder, and each holder pulls one ask when it is idle** (novox/hq ADR diff --git a/go/buscompose/nats.go b/go/buscompose/nats.go index 70d7cc9..d6c35c6 100644 --- a/go/buscompose/nats.go +++ b/go/buscompose/nats.go @@ -232,6 +232,10 @@ type Principal struct { // by name. Created only, never updated or deleted: a new module state is on the bus when the grants part hears its // manifest, not at the controller's next start (issue 1790). CreatesStreams []string + // CreatesConsumers are the durable consumers the grants part's principal alone may create beside the controller + // (novox/hq ADR 0561 §6): each placed module's on EVENTS and each process's on its work queue (AccountConsumers), by + // stream and name. Created only, never updated or deleted. + CreatesConsumers []ModuleConsumer // 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 @@ -972,6 +976,8 @@ func PermissionsFor(p Principal) (Permissions, error) { // And creating each stream a manifest declares, by its own name (the operator, 2026-10-26 08:54; issue // 1790): the create and the read of that one stream, never an update, a delete or a stream not declared. pub = append(pub, CreateStreamGrants(p.CreatesStreams)...) + // And creating each durable consumer an account needs, by its stream and name (ADR 0561 §6). + pub = append(pub, CreateConsumerGrants(p.CreatesConsumers)...) // And its own object bucket, where it writes the user list sealed to the bus node (ADR 0547 clause 7). pub = append(pub, GrantsUserListWrites()...) } @@ -1659,6 +1665,21 @@ func CreateStreamGrants(streams []string) []string { return out } +// CreateConsumerGrants are the subjects that create each consumer by its stream and name, and read its info back: the +// create of the client library's CreateConsumer for a consumer with no filter or several, never one filter's form (a +// wildcard in a filter would widen the grant), an update or a delete. +func CreateConsumerGrants(consumers []ModuleConsumer) []string { + var out []string + for _, mc := range consumers { + c := mc.Consumer + if !safeSubject.MatchString(c.Stream) || !safeSubject.MatchString(c.Name) || len(c.Filters) == 1 { + continue + } + out = append(out, "$JS.API.CONSUMER.CREATE."+c.Stream+"."+c.Name, "$JS.API.CONSUMER.INFO."+c.Stream+"."+c.Name) + } + return out +} + // GrantsModule is the one module whose principal publishes the modules' memberships (novox/hq ADR 0547 §5): the grants // part, which composes them from the manifests and placement. It defines no stream and is granted no other subject here. const GrantsModule = "mesh-grants" diff --git a/go/buscompose/userlist_switch.go b/go/buscompose/userlist_switch.go index f03427c..51b7e72 100644 --- a/go/buscompose/userlist_switch.go +++ b/go/buscompose/userlist_switch.go @@ -79,7 +79,9 @@ const VaultBusAccounts = "mesh-vault.bus-accounts" // NamedReaders are the states only the modules named may read, by `.`: the grants part's wanted accounts // are read by the vault alone (novox/hq ADR 0561 §2). -var NamedReaders = map[string][]string{VaultBusAccounts: {GrantsModule}, GrantsAccounts: {VaultSeat}} +// The controller's lease is read by the two parts that turn on at its write (ADR 0561 §3, §6). +var NamedReaders = map[string][]string{VaultBusAccounts: {GrantsModule}, GrantsAccounts: {VaultSeat}, + ControllerLease: {VaultSeat, 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 71be224..15cbd14 100644 --- a/go/buscompose/users.go +++ b/go/buscompose/users.go @@ -149,6 +149,7 @@ func Users(r Records) ([]Principal, error) { AsksTheConfig: servedOf(r, VerbsTheControllerAsksTheConfig), FilesFor: sortedCopy(r.Nodes)}} served := servedReadsOf(r) + grants := -1 for _, node := range sortedCopy(r.Nodes) { witness := false var checks []string @@ -225,6 +226,7 @@ func Users(r Records) ([]Principal, error) { // 08:54): every placed module's buckets, logs and streams, each by its stream's name. 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. @@ -258,6 +260,11 @@ func Users(r Records) ([]Principal, error) { out = append(out, p) } + // 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.