diff --git a/cmd/mesh-controller/grants.go b/cmd/mesh-controller/grants.go new file mode 100644 index 00000000..b81ddd6b --- /dev/null +++ b/cmd/mesh-controller/grants.go @@ -0,0 +1,168 @@ +package main + +import ( + "context" + "errors" + "fmt" + "sort" + "strings" + + sdkdelivery "git.novox.be/novox/mesh-sdk/go/delivery" + "github.com/nats-io/nats.go/jetstream" + + "github.com/novox/mesh-controller/internal/broker" + "github.com/novox/mesh-controller/internal/inventory" +) + +// The grants issued (novox/hq ADR 0528 clause 2, ADR 0429 §6 rule 6, issue 1394): what the assigner's sixth rule reads, +// that a module's bus credential for a fingerprint exists. A module's permissions on the bus are what the bus's user list +// composes from its registered manifest (inv.BusRecords), and a module's manifest is registered at a fingerprint when the +// controller takes it in from the builder's key; so once the list is sent to the machine that runs the bus, the +// credential of every module it carried is issued at the fingerprint of the build it is registered at. The controller +// writes `.` then, naming the accounts it composed for the module, never removed. A module one +// of whose accounts has no password yet (the list leaves it out) has no grant until a later send carries it. + +// grantsFrom is the grant of every module whose accounts all have a password in hashes, at the fingerprint current names +// for it; a module whose current build names no fingerprint of the new form has none. principals are the bus's users +// as broker.Users composes them. +func grantsFrom(principals []broker.Principal, hashes map[string]string, current map[string]string) map[string]sdkdelivery.ControllerGrant { + accounts, missing := map[string][]string{}, map[string]bool{} + for _, p := range principals { + if p.Module == "" || (p.Kind != broker.KindModule && p.Kind != broker.KindProcess) { + continue + } + if hashes[p.Username()] == "" { + missing[p.Module] = true + continue + } + accounts[p.Module] = append(accounts[p.Module], p.Username()) + } + out := map[string]sdkdelivery.ControllerGrant{} + for module, fp := range current { + if missing[module] || !strings.HasPrefix(fp, sdkdelivery.SourcePrefix2) { + continue + } + key, err := sdkdelivery.ControllerGrantKey(module, fp) + if err != nil { + continue + } + names := accounts[module] + sort.Strings(names) + out[key] = sdkdelivery.ControllerGrant{Accounts: names} + } + return out +} + +// currentFingerprints is, by module, the fingerprint of the build each registered module is registered at: its +// manifest's commit (built_from), that commit's newest successful build, and the fingerprint that build names. +func currentFingerprints(ctx context.Context, inv *inventory.Inventory) (map[string]string, error) { + builds, err := inv.CurrentBuilds(ctx) + if err != nil { + return nil, err + } + out := map[string]string{} + for module, b := range builds { + if b.Commit == "" { + continue + } + id, err := inv.BuildOf(ctx, module, b.Commit) + if err != nil || id == "" { + continue + } + build, found, err := inv.BuildByID(ctx, id) + if err != nil || !found { + continue + } + out[module] = build.SourceFingerprint + } + return out, nil +} + +// grantsKV is what writing the grants needs of their bucket. +type grantsKV interface { + Get(ctx context.Context, key string) (jetstream.KeyValueEntry, error) + Create(ctx context.Context, key string, value []byte, opts ...jetstream.KVCreateOpt) (uint64, error) + Update(ctx context.Context, key string, value []byte, revision uint64) (uint64, error) +} + +// keepGrants writes every grant the list just sent issued that the bucket does not hold as it is: a create for a new one, +// a compare-and-set for one whose accounts changed. It answers how many it wrote. +func keepGrants(ctx context.Context, kv grantsKV, grants map[string]sdkdelivery.ControllerGrant) (int, error) { + keys := make([]string, 0, len(grants)) + for k := range grants { + keys = append(keys, k) + } + sort.Strings(keys) + wrote := 0 + var errs []error + for _, key := range keys { + value, err := sdkdelivery.EncodeControllerGrant(grants[key]) + if err != nil { + errs = append(errs, fmt.Errorf("%s: %w", key, err)) + continue + } + e, err := kv.Get(ctx, key) + switch { + case errors.Is(err, jetstream.ErrKeyNotFound): + _, err = kv.Create(ctx, key, value) + case err != nil: + default: + if before, derr := sdkdelivery.DecodeControllerGrant(e.Value()); derr == nil && sdkdelivery.SameControllerGrant(before, withVersion(grants[key])) { + continue + } + _, err = kv.Update(ctx, key, value, e.Revision()) + } + if err != nil { + errs = append(errs, fmt.Errorf("%s: %w", key, err)) + continue + } + wrote++ + } + return wrote, errors.Join(errs...) +} + +func withVersion(g sdkdelivery.ControllerGrant) sdkdelivery.ControllerGrant { + g.V = sdkdelivery.Version + return g +} + +// grantsIssued writes the grants the bus's user list just sent to its machine issued (ADR 0528 clause 2). Said, never +// fatal: a grant not written now is written at the next send of the list, and the assigner's wants wait for it. +func grantsIssued(ctx context.Context, inv *inventory.Inventory, js jetstream.JetStream, indent string) { + kv, err := js.KeyValue(ctx, broker.GrantsBucket) + if err != nil { + fmt.Printf("%sthe grants issued are not written: the bucket %s: %v\n", indent, broker.GrantsBucket, err) + return + } + records, err := inv.BusRecords(ctx) + if err != nil { + fmt.Printf("%sthe grants issued are not written: %v\n", indent, err) + return + } + principals, err := broker.Users(records) + if err != nil { + fmt.Printf("%sthe grants issued are not written: %v\n", indent, err) + return + } + kept, err := inv.BusUsers(ctx) + if err != nil { + fmt.Printf("%sthe grants issued are not written: %v\n", indent, err) + return + } + hashes := make(map[string]string, len(kept)) + for name, u := range kept { + hashes[name] = u.PasswordHash + } + current, err := currentFingerprints(ctx, inv) + if err != nil { + fmt.Printf("%sthe grants issued are not written: %v\n", indent, err) + return + } + wrote, err := keepGrants(ctx, kv, grantsFrom(principals, hashes, current)) + if wrote > 0 { + fmt.Printf("%s%d grant(s) issued written for the assigner\n", indent, wrote) + } + if err != nil { + fmt.Printf("%ssome grants issued are not written: %v\n", indent, err) + } +} diff --git a/cmd/mesh-controller/grants_integration_test.go b/cmd/mesh-controller/grants_integration_test.go new file mode 100644 index 00000000..ecccac01 --- /dev/null +++ b/cmd/mesh-controller/grants_integration_test.go @@ -0,0 +1,71 @@ +//go:build integration + +package main + +import ( + "testing" + + "git.novox.be/novox/mesh-sdk/go/catalogue" + sdkdelivery "git.novox.be/novox/mesh-sdk/go/delivery" + "github.com/nats-io/nats.go" + "github.com/nats-io/nats.go/jetstream" + + "github.com/novox/mesh-controller/internal/broker" + "github.com/novox/mesh-controller/internal/inventory" + "github.com/novox/mesh-controller/internal/testbus" +) + +// 1394/AC1 of C9 (ADR 0528 clause 2): when the controller has issued mesh-x's credential for fingerprint F (mesh-x +// registered at a build of F, its account minted, the bus's user list sent), then grants.mesh-x.F exists on the bus, +// and a second pass writes it again as nothing new. +func TestTheGrantOfAnIssuedCredentialIsOnTheBus(t *testing.T) { + open := aMesh(t) + ctx := t.Context() + inv := open.inventory + fp := grantFP('d') + m := catalogue.Manifest{Module: "mesh-x", Version: "1", OwnSecrets: catalogue.OwnSecrets{"broker": {Path: "/var/lib/mesh-x/broker"}}} + if err := inv.RecordBuild(ctx, inventory.Build{ID: "build-x1", Repository: "novox/mesh-x", Module: "mesh-x", Commit: "c0ffee1", + SourceFingerprint: fp}); err != nil { + t.Fatal(err) + } + if err := inv.RegisterModule(ctx, m, inventory.Source{Repository: "novox/mesh-x", BuiltFrom: "c0ffee1", Head: "c0ffee1"}); err != nil { + t.Fatal(err) + } + if _, err := assign(ctx, open, "anchor", "mesh-x"); err != nil { + t.Fatal(err) + } + user := broker.Principal{Kind: broker.KindModule, Node: "anchor", Module: "mesh-x"}.Username() + if _, err := inv.MintBusPassword(ctx, inventory.BusUser{Username: user, Kind: inventory.BusModule, Node: "anchor", Module: "mesh-x"}); err != nil { + t.Fatal(err) + } + conn, err := nats.Connect(testbus.URL(t)) + if err != nil { + t.Fatal(err) + } + t.Cleanup(conn.Close) + js, err := jetstream.New(conn) + if err != nil { + t.Fatal(err) + } + if err := broker.OnConn(conn).EnsureControllerBuckets(); err != nil { + t.Fatal(err) + } + grantsIssued(ctx, inv, js, "") + kv, err := js.KeyValue(ctx, broker.GrantsBucket) + if err != nil { + t.Fatal(err) + } + key, _ := sdkdelivery.ControllerGrantKey("mesh-x", fp) + e, err := kv.Get(ctx, key) + if err != nil { + t.Fatalf("no grant %s after the list was issued: %v", key, err) + } + g, err := sdkdelivery.DecodeControllerGrant(e.Value()) + if err != nil || len(g.Accounts) != 1 || g.Accounts[0] != user { + t.Fatalf("the grant reads %+v, %v; want the account %s", g, err, user) + } + grantsIssued(ctx, inv, js, "") + if again, _ := kv.Get(ctx, key); again.Revision() != e.Revision() { + t.Errorf("a second pass wrote the grant again (revision %d, was %d)", again.Revision(), e.Revision()) + } +} diff --git a/cmd/mesh-controller/grants_test.go b/cmd/mesh-controller/grants_test.go new file mode 100644 index 00000000..b14715ef --- /dev/null +++ b/cmd/mesh-controller/grants_test.go @@ -0,0 +1,106 @@ +package main + +import ( + "context" + "strings" + "testing" + "time" + + sdkdelivery "git.novox.be/novox/mesh-sdk/go/delivery" + "github.com/nats-io/nats.go/jetstream" + + "github.com/novox/mesh-controller/internal/broker" +) + +func grantFP(c byte) string { return sdkdelivery.SourcePrefix2 + strings.Repeat(string(c), 64) } + +// 1394/AC (unit half): the list sent issues the credential of every module whose accounts all have a password, at the +// fingerprint its build names; a module with an account the list leaves out, and one registered at a fingerprint not of +// the new form, get no grant; a module with no account of its own is granted with none. +func TestTheGrantsAListIssues(t *testing.T) { + x := broker.Principal{Kind: broker.KindModule, Node: "novox", Module: "mesh-x"} + xp := broker.Principal{Kind: broker.KindProcess, Node: "novox", Module: "mesh-x", Process: "run"} + y := broker.Principal{Kind: broker.KindModule, Node: "ace", Module: "mesh-y"} + node := broker.Principal{Kind: broker.KindNode, Node: "novox"} + hashes := map[string]string{x.Username(): "h1", xp.Username(): "h2", node.Username(): "h3"} + got := grantsFrom([]broker.Principal{x, xp, y, node}, hashes, + map[string]string{"mesh-x": grantFP('a'), "mesh-y": grantFP('b'), "web": grantFP('c'), "old": "src1:abc"}) + xKey, _ := sdkdelivery.ControllerGrantKey("mesh-x", grantFP('a')) + webKey, _ := sdkdelivery.ControllerGrantKey("web", grantFP('c')) + if len(got) != 2 { + t.Fatalf("grants = %v, want mesh-x's and web's", got) + } + if g := got[xKey]; len(g.Accounts) != 2 || g.Accounts[0] != x.Username() && g.Accounts[1] != x.Username() { + t.Errorf("mesh-x's grant = %+v, want both its accounts", g) + } + if g, ok := got[webKey]; !ok || len(g.Accounts) != 0 { + t.Errorf("web, with no account of its own, is granted %+v, %v", g, ok) + } +} + +// fakeGrants is the grants bucket in memory. +type fakeGrants struct { + values map[string][]byte + creates int + updates int +} + +type fakeEntry struct { + key string + value []byte + rev uint64 +} + +func (e fakeEntry) Bucket() string { return broker.GrantsBucket } +func (e fakeEntry) Key() string { return e.key } +func (e fakeEntry) Value() []byte { return e.value } +func (e fakeEntry) Revision() uint64 { return e.rev } +func (e fakeEntry) Created() time.Time { return time.Time{} } +func (e fakeEntry) Delta() uint64 { return 0 } +func (e fakeEntry) Operation() jetstream.KeyValueOp { return jetstream.KeyValuePut } + +func (f *fakeGrants) Get(_ context.Context, key string) (jetstream.KeyValueEntry, error) { + v, ok := f.values[key] + if !ok { + return nil, jetstream.ErrKeyNotFound + } + return fakeEntry{key: key, value: v, rev: 1}, nil +} + +func (f *fakeGrants) Create(_ context.Context, key string, value []byte, _ ...jetstream.KVCreateOpt) (uint64, error) { + if _, ok := f.values[key]; ok { + return 0, jetstream.ErrKeyExists + } + f.values[key] = value + f.creates++ + return 1, nil +} + +func (f *fakeGrants) Update(_ context.Context, key string, value []byte, _ uint64) (uint64, error) { + f.values[key] = value + f.updates++ + return 2, nil +} + +// 1394/AC (unit half): a grant is created once; a second send of the same list writes nothing; a reissue for other +// accounts is a new revision of the same key. +func TestAGrantIsWrittenOnceAndMovedOnAReissue(t *testing.T) { + kv := &fakeGrants{values: map[string][]byte{}} + key, _ := sdkdelivery.ControllerGrantKey("mesh-x", grantFP('a')) + grants := map[string]sdkdelivery.ControllerGrant{key: {Accounts: []string{"novox.mesh-x"}}} + for range 2 { + if _, err := keepGrants(t.Context(), kv, grants); err != nil { + t.Fatal(err) + } + } + if kv.creates != 1 || kv.updates != 0 { + t.Errorf("creates %d, updates %d, want one create", kv.creates, kv.updates) + } + grants[key] = sdkdelivery.ControllerGrant{Accounts: []string{"ace.mesh-x"}} + if wrote, err := keepGrants(t.Context(), kv, grants); err != nil || wrote != 1 || kv.updates != 1 { + t.Errorf("a reissue wrote %d (%v), updates %d", wrote, err, kv.updates) + } + if g, err := sdkdelivery.DecodeControllerGrant(kv.values[key]); err != nil || g.Accounts[0] != "ace.mesh-x" { + t.Errorf("the grant reads %+v, %v", g, err) + } +} diff --git a/cmd/mesh-controller/push.go b/cmd/mesh-controller/push.go index e900ba0d..99035909 100644 --- a/cmd/mesh-controller/push.go +++ b/cmd/mesh-controller/push.go @@ -20,6 +20,7 @@ import ( "git.novox.be/novox/mesh-sdk/go/catalogue" "git.novox.be/novox/mesh-sdk/go/compose" "github.com/nats-io/nats.go" + "github.com/nats-io/nats.go/jetstream" "github.com/novox/mesh-controller/internal/broker" "github.com/novox/mesh-controller/internal/inventory" "github.com/novox/mesh-controller/internal/link" @@ -1149,6 +1150,15 @@ func (b overTheBus) declare(ctx context.Context, s readyNode, body []byte) (stri if err != nil { return "", err } + // The list sent issued the credential of every module it carried, at the fingerprint each is registered at: + // the grants the assigner's sixth rule reads (novox/hq ADR 0528 clause 2, issue 1394). + if bus, ok := b.server.Bus().(link.OverNATS); ok { + if js, err := jetstream.New(bus.Conn); err == nil { + writing, cancel := context.WithTimeout(context.WithoutCancel(ctx), 30*time.Second) + grantsIssued(writing, b.open.inventory, js, b.indent) + cancel() + } + } } fmt.Printf("%ssent %s %d resource(s)\n", b.indent, s.node, len(s.declared.Resources)) return digest, nil diff --git a/go.mod b/go.mod index 1ba1a88c..a7b13671 100644 --- a/go.mod +++ b/go.mod @@ -3,7 +3,7 @@ module github.com/novox/mesh-controller go 1.26.0 require ( - git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261022190740-104533e8c0c9 + git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261023132058-f625d98713e4 github.com/jackc/pgx/v5 v5.10.0 github.com/nats-io/nats-server/v2 v2.11.17 github.com/nats-io/nats.go v1.54.0 diff --git a/internal/broker/announced.go b/internal/broker/announced.go index 7379e0a9..dd0ec5f9 100644 --- a/internal/broker/announced.go +++ b/internal/broker/announced.go @@ -17,9 +17,10 @@ import ( const StatesAnnouncedBucket = announce.KeptBucket // controllerReadable are the controller's states others may read: the record mirrors (the packages each node declares -// among them, which the pacman module reads, ADR 0507 §5) and the chain's buckets. +// among them, which the pacman module reads, ADR 0507 §5), the chain's buckets and the grants issued, which the assigner +// reads (ADR 0528 clause 2). var controllerReadable = []string{MirrorAssignments, MirrorDeclaredConfig, MirrorPredefined, ChainBuilds, ChainRollouts, - MirrorDeclaredPackages} + MirrorDeclaredPackages, ControllerGrants} // ControllerAnnouncement is what the controller announces it keeps (ADR 0495 clause 2): every bucket of its own by its local // name, the ones others may read marked readable. It announces itself, as any module does from its code. diff --git a/internal/broker/announced_test.go b/internal/broker/announced_test.go index a05830b8..f538c557 100644 --- a/internal/broker/announced_test.go +++ b/internal/broker/announced_test.go @@ -39,7 +39,7 @@ func TestTheControllerAnnouncesWhatItKeeps(t *testing.T) { } } want := []string{ChainBuilds, MirrorDeclaredConfig, MirrorPredefined, ChainRollouts, - MirrorAssignments, MirrorDeclaredPackages} + MirrorAssignments, MirrorDeclaredPackages, ControllerGrants} slices.Sort(want) if !slices.Equal(readable, want) { t.Fatalf("readable %v, want %v", readable, want) diff --git a/internal/broker/controller_buckets.go b/internal/broker/controller_buckets.go index e584b88d..f7dd0a2b 100644 --- a/internal/broker/controller_buckets.go +++ b/internal/broker/controller_buckets.go @@ -125,6 +125,16 @@ const ( ChainAssignmentBytes = 64 << 20 ) +// GrantsBucket holds, keyed `.`, each fingerprint of a module whose bus credential the controller +// has issued (novox/hq ADR 0528 clause 2, ADR 0429 §6 rule 6, issue 1394): written by the controller alone once the bus's +// user list composed with the module's manifest at that fingerprint was sent to the bus's machine; read by the assigner, +// whose sixth rule moves a node's want to a fingerprint only once its grant exists. The name and layout are mesh-sdk's +// (delivery.ControllerGrantsBucket, ControllerGrantKey). +var GrantsBucket = BucketName(ControllerSeat, ControllerGrants) + +// ControllerGrants is the grants bucket's local name, as a reader names it in `reads` after `mesh-controller.`. +const ControllerGrants = "grants" + // ChainBuckets are the three buckets of the chain: builds, rollouts and assignments. func ChainBuckets() []string { return []string{BuildsBucket, RolloutsBucket, AssignmentsBucket} } @@ -185,14 +195,15 @@ func IsControllerBucket(bucket string) bool { bucket == DeclaredPackagesBucket || bucket == MergeWindowBucket || bucket == RolloutSilentBucket || bucket == DeliveryMergesBucket || bucket == BuildsBucket || bucket == RolloutsBucket || - bucket == delivery.HaveBucket || bucket == StatesAnnouncedBucket + bucket == delivery.HaveBucket || bucket == StatesAnnouncedBucket || + bucket == GrantsBucket } // ControllerBuckets are the controller's own buckets, in the order they are asserted. func ControllerBuckets() []string { return []string{LeaseBucket, CallsBucket, HandActsBucket, ConditionsBucket, ConditionHistoryBucket, AskedBucket, AssignmentsBucket, DeclaredConfigBucket, PredefinedBucket, DeclaredPackagesBucket, MergeWindowBucket, RolloutSilentBucket, - DeliveryMergesBucket, BuildsBucket, RolloutsBucket, delivery.HaveBucket, StatesAnnouncedBucket} + DeliveryMergesBucket, BuildsBucket, RolloutsBucket, delivery.HaveBucket, StatesAnnouncedBucket, GrantsBucket} } // ControllerBucketsAsserter is what raising the controller's buckets needs of a connection. @@ -332,6 +343,20 @@ func (j *JetStream) EnsureControllerBuckets() error { }); err != nil { return fmt.Errorf("asserting bucket %s: %w", StatesAnnouncedBucket, err) } + // **The grants issued: no age** (ADR 0528 clause 2, issue 1394). One key a module's fingerprint, never removed: a grant + // that expired would hold back a want whose credential exists. + if _, err := js.CreateOrUpdateKeyValue(ctx, jetstream.KeyValueConfig{ + Bucket: GrantsBucket, + Description: "each fingerprint of a module whose bus credential the controller issued (novox/hq ADR 0528): " + + "`.` holds the accounts issued, put once the bus's user list composed with the module's " + + "manifest at it was sent; read by the assigner through `reads`; written by the controller alone", + History: delivery.ControllerGrantsHistory, + MaxValueSize: 4 << 10, + MaxBytes: 64 << 20, + Storage: jetstream.FileStorage, + }); err != nil { + return fmt.Errorf("asserting bucket %s: %w", GrantsBucket, err) + } // **The merges mesh-delivery announced: keys that age** (ADR 0420 §7, issue 1025). One message a key; a week. if _, err := js.CreateOrUpdateKeyValue(ctx, jetstream.KeyValueConfig{ Bucket: DeliveryMergesBucket, diff --git a/internal/broker/controller_buckets_test.go b/internal/broker/controller_buckets_test.go index f0637377..2488ea44 100644 --- a/internal/broker/controller_buckets_test.go +++ b/internal/broker/controller_buckets_test.go @@ -120,7 +120,7 @@ func TestTheMirrorsListAndTheWritersTableAgree(t *testing.T) { module := Principal{Kind: KindModule, Module: ControllerSeat, Node: "one"} rowed := map[string]bool{} for _, row := range WritersTable { - if !strings.HasSuffix(row.State, " mirror") && !strings.HasSuffix(row.State, " of the chain") { + if !strings.HasSuffix(row.State, " mirror") && !strings.HasSuffix(row.State, " of the chain") && row.State != "the grants issued" { continue } for _, subject := range row.Subjects { diff --git a/internal/broker/grants_test.go b/internal/broker/grants_test.go new file mode 100644 index 00000000..7ebd3ce5 --- /dev/null +++ b/internal/broker/grants_test.go @@ -0,0 +1,44 @@ +package broker + +import ( + "slices" + "testing" + + "git.novox.be/novox/mesh-sdk/go/delivery" +) + +// Issue 1394/C9a (ADR 0528 clause 2): the grants bucket is mesh-sdk's, one of the controller's own buckets asserted at its +// start, written by the controller alone; the assigner, naming mesh-controller.grants in `reads`, reads it and writes +// nothing of it. +func TestTheAssignerReadsTheGrantsAndWritesNone(t *testing.T) { + if GrantsBucket != delivery.ControllerGrantsBucket { + t.Fatalf("the grants bucket is %s, mesh-sdk names %s", GrantsBucket, delivery.ControllerGrantsBucket) + } + if !slices.Contains(controllerReadableStates(), ControllerGrants) { + t.Fatalf("a module cannot name %s in `reads`: %v", GrantsBucket, controllerReadableStates()) + } + if _, other := MirrorsWrittenByAnother[ControllerGrants]; other { + t.Error("the controller writes the grants itself") + } + if !IsControllerBucket(GrantsBucket) || !slices.Contains(ControllerBuckets(), GrantsBucket) { + t.Errorf("%s is not among the controller's buckets", GrantsBucket) + } + perms, err := PermissionsFor(Principal{Kind: KindModule, Node: "novox", Module: "mesh-delivery-assigner", PasswordHash: "x", + Reads: []string{"mesh-controller.grants"}}) + if err != nil { + t.Fatal(err) + } + stream := "KV_" + GrantsBucket + has(t, perms.Publish, "$JS.API.STREAM.INFO."+stream) + has(t, perms.Publish, "$JS.API.CONSUMER.CREATE."+stream+".>") + for _, p := range perms.Publish { + if SubjectsOverlap(p, "$KV."+GrantsBucket+".mesh-x") { + t.Errorf("the assigner may write %s through %q", GrantsBucket, p) + } + } + controller, err := PermissionsFor(Principal{Kind: KindController, PasswordHash: "x"}) + if err != nil { + t.Fatal(err) + } + has(t, controller.Publish, "$KV."+GrantsBucket+".>") +} diff --git a/internal/broker/nats.go b/internal/broker/nats.go index 1d8e66b6..6cf5af43 100644 --- a/internal/broker/nats.go +++ b/internal/broker/nats.go @@ -441,6 +441,12 @@ var DegradableGrants = []DegradableGrant{ Reason: "carried only at genesis: the controller composes its own permissions and pushes them to the bus at its " + "first push, and writes the chain only after that, holding the lease (ADR 0408 §2 and §8, issue 1089)", }, + { + Subject: "$KV." + GrantsBucket + ".>", + Reason: "carried only at genesis: the controller composes its own permissions and pushes them to the bus at its " + + "first push, and writes a grant only after a send of the bus's user list, which comes after that (ADR 0528 " + + "clause 2, issue 1394)", + }, { Subject: "$KV." + RolloutsBucket + ".>", Reason: "carried only at genesis: the controller composes its own permissions and pushes them to the bus at its " + diff --git a/internal/broker/testdata/composed.conf b/internal/broker/testdata/composed.conf index b24479d7..a5ec0774 100644 --- a/internal/broker/testdata/composed.conf +++ b/internal/broker/testdata/composed.conf @@ -29,7 +29,7 @@ accounts { ] users = [ { user: "controller", password: "$2a$11$cccccccccccccccccccccc", permissions: { - publish: { allow: ["$JS.ACK.CONTROL.controller.>", "$JS.ACK.DEAD_LETTER_NOTICES.controller.>", "$JS.ACK.EVENTS.controller-merges.>", "$JS.ACK.EVENTS.controller.>", "$JS.API.>", "$KV.SEAT_MESH_BUILD_MACHINE_cancelled.>", "$KV.SEAT_NODE_BUILD_AGENT_cancelled.>", "$KV.mesh-controller_asked.>", "$KV.mesh-controller_assignments.>", "$KV.mesh-controller_builds.>", "$KV.mesh-controller_calls.>", "$KV.mesh-controller_condition-history.>", "$KV.mesh-controller_conditions.>", "$KV.mesh-controller_declared-config.>", "$KV.mesh-controller_declared-packages.>", "$KV.mesh-controller_delivery-merges.>", "$KV.mesh-controller_hand-acts.>", "$KV.mesh-controller_lease.>", "$KV.mesh-controller_merge-window.>", "$KV.mesh-controller_predefined.>", "$KV.mesh-controller_rollout-silent.>", "$KV.mesh-controller_rollouts.>", "$KV.mesh-controller_states-announced.>", "$SRV.INFO", "_INBOX.enrol.>", "mesh.again.>", "mesh.assignment.>", "mesh.events.dead.>", "mesh.mod.*.tool.>", "mesh.mod.mesh-controller.event.states", "mesh.node.>", "mesh.seat.mesh-build-machine.accept.>", "mesh.seat.mesh-build-machine.tool.>", "mesh.seat.mesh-controller.event.applied", "mesh.seat.mesh-controller.event.assignment-changed", "mesh.seat.mesh-controller.event.built-before", "mesh.seat.mesh-controller.event.checked", "mesh.seat.mesh-controller.event.condition-changed", "mesh.seat.mesh-controller.event.condition-cleared", "mesh.seat.mesh-controller.event.condition-raised", "mesh.seat.mesh-controller.event.declared-config-changed", "mesh.seat.mesh-controller.event.doctor-heartbeat", "mesh.seat.mesh-controller.event.healer-acted", "mesh.seat.mesh-controller.event.plan-moved", "mesh.seat.mesh-controller.event.predefined-changed", "mesh.seat.mesh-controller.event.refused", "mesh.seat.mesh-controller.event.rolled-back", "mesh.seat.mesh-controller.event.secret-replaced", "mesh.seat.mesh-controller.event.sent", "mesh.seat.mesh-controller.tool.plans", "mesh.seat.mesh-controller.tool.push", "mesh.seat.mesh-controller.tool.upgrade", "mesh.seat.mesh-delivery.tool.close", "mesh.seat.mesh-delivery.tool.groups", "mesh.seat.mesh-delivery.tool.release", "mesh.seat.mesh-delivery.tool.stalled", "mesh.seat.mesh-delivery.tool.stop", "mesh.seat.mesh-delivery.tool.times", "mesh.seat.node-backup.tool.backed-up.*", "mesh.seat.node-backup.tool.now.*", "mesh.seat.node-build-agent.accept.>", "mesh.seat.node-build-agent.tool.>", "mesh.seat.node-intrusion-prevention.tool.banned.*", "mesh.seat.node-launcher.tool.secret.*", "mesh.seat.node-service-manager.tool.restart.*", "mesh.seat.operator-channel.tool.tell"] } + publish: { allow: ["$JS.ACK.CONTROL.controller.>", "$JS.ACK.DEAD_LETTER_NOTICES.controller.>", "$JS.ACK.EVENTS.controller-merges.>", "$JS.ACK.EVENTS.controller.>", "$JS.API.>", "$KV.SEAT_MESH_BUILD_MACHINE_cancelled.>", "$KV.SEAT_NODE_BUILD_AGENT_cancelled.>", "$KV.mesh-controller_asked.>", "$KV.mesh-controller_assignments.>", "$KV.mesh-controller_builds.>", "$KV.mesh-controller_calls.>", "$KV.mesh-controller_condition-history.>", "$KV.mesh-controller_conditions.>", "$KV.mesh-controller_declared-config.>", "$KV.mesh-controller_declared-packages.>", "$KV.mesh-controller_delivery-merges.>", "$KV.mesh-controller_grants.>", "$KV.mesh-controller_hand-acts.>", "$KV.mesh-controller_lease.>", "$KV.mesh-controller_merge-window.>", "$KV.mesh-controller_predefined.>", "$KV.mesh-controller_rollout-silent.>", "$KV.mesh-controller_rollouts.>", "$KV.mesh-controller_states-announced.>", "$SRV.INFO", "_INBOX.enrol.>", "mesh.again.>", "mesh.assignment.>", "mesh.events.dead.>", "mesh.mod.*.tool.>", "mesh.mod.mesh-controller.event.states", "mesh.node.>", "mesh.seat.mesh-build-machine.accept.>", "mesh.seat.mesh-build-machine.tool.>", "mesh.seat.mesh-controller.event.applied", "mesh.seat.mesh-controller.event.assignment-changed", "mesh.seat.mesh-controller.event.built-before", "mesh.seat.mesh-controller.event.checked", "mesh.seat.mesh-controller.event.condition-changed", "mesh.seat.mesh-controller.event.condition-cleared", "mesh.seat.mesh-controller.event.condition-raised", "mesh.seat.mesh-controller.event.declared-config-changed", "mesh.seat.mesh-controller.event.doctor-heartbeat", "mesh.seat.mesh-controller.event.healer-acted", "mesh.seat.mesh-controller.event.plan-moved", "mesh.seat.mesh-controller.event.predefined-changed", "mesh.seat.mesh-controller.event.refused", "mesh.seat.mesh-controller.event.rolled-back", "mesh.seat.mesh-controller.event.secret-replaced", "mesh.seat.mesh-controller.event.sent", "mesh.seat.mesh-controller.tool.plans", "mesh.seat.mesh-controller.tool.push", "mesh.seat.mesh-controller.tool.upgrade", "mesh.seat.mesh-delivery.tool.close", "mesh.seat.mesh-delivery.tool.groups", "mesh.seat.mesh-delivery.tool.release", "mesh.seat.mesh-delivery.tool.stalled", "mesh.seat.mesh-delivery.tool.stop", "mesh.seat.mesh-delivery.tool.times", "mesh.seat.node-backup.tool.backed-up.*", "mesh.seat.node-backup.tool.now.*", "mesh.seat.node-build-agent.accept.>", "mesh.seat.node-build-agent.tool.>", "mesh.seat.node-intrusion-prevention.tool.banned.*", "mesh.seat.node-launcher.tool.secret.*", "mesh.seat.node-service-manager.tool.restart.*", "mesh.seat.operator-channel.tool.tell"] } subscribe: { allow: ["$JS.API.>", "$JS.EVENT.ADVISORY.CONSUMER.DELETED.>", "$JS.EVENT.ADVISORY.CONSUMER.MAX_DELIVERIES.>", "$SRV.INFO", "$SRV.INFO.mesh-controller", "$SRV.INFO.mesh-controller.>", "$SRV.PING", "$SRV.PING.mesh-controller", "$SRV.PING.mesh-controller.>", "$SRV.STATS", "$SRV.STATS.mesh-controller", "$SRV.STATS.mesh-controller.>", "_DELIVER.controller", "_DELIVER.controller.>", "_INBOX.controller.>", "mesh.control.>", "mesh.mod.*.event.provisioner.failing", "mesh.mod.*.event.provisioner.recovered", "mesh.mod.*.event.provisioner.retirement", "mesh.mod.gitea.event.pull.merged", "mesh.mod.gitea.event.pull.updated", "mesh.mod.mesh-catalog.event.catching-up", "mesh.mod.mesh-catalog.event.upgraded", "mesh.seat.mesh-build-machine.event.built", "mesh.seat.mesh-controller.tool.>", "mesh.seat.node-build-agent.event.built", "mesh.seat.operator-channel.event.decided.mesh-controller"] } allow_responses: { max: 1, ttl: "1m" } } } diff --git a/internal/broker/writers.go b/internal/broker/writers.go index 00643b3d..b904ed1e 100644 --- a/internal/broker/writers.go +++ b/internal/broker/writers.go @@ -221,6 +221,10 @@ var WritersTable = []WriterRow{ {State: "the rollouts of the chain", Writer: "controller (lease holder), compare-and-set on the key's revision", KeptIn: "key-value " + RolloutsBucket + ", sixty-four revisions of each key, none removed", Others: "read through `reads` as mesh-controller." + ChainRollouts, Subjects: kvOf(RolloutsBucket), Writes: isController}, + // The grants issued (novox/hq ADR 0528 clause 2, issue 1394): the controller's, read by the assigner for its sixth rule. + {State: "the grants issued", Writer: "controller (lease holder), once the bus's user list carrying a module's manifest at a fingerprint was sent", + KeptIn: "key-value " + GrantsBucket + ", one key a module's fingerprint, none removed", + Others: "read through `reads` as mesh-controller." + ControllerGrants, Subjects: kvOf(GrantsBucket), Writes: isController}, // What each machine has of each module (novox/hq ADR 0429 §1, design 47): written by that machine's node-engine under // its own node's keys, never by the controller; the view and the watchman read it. {State: "a machine's have", Writer: "the node-engine of the machine, under its own keys `.>`", diff --git a/internal/broker/writers_test.go b/internal/broker/writers_test.go index 749c3569..f1e55584 100644 --- a/internal/broker/writers_test.go +++ b/internal/broker/writers_test.go @@ -42,6 +42,8 @@ var designRows = []string{ // The chain beside the assignments, the record itself and no mirror of one (ADR 0408, design 47, issue 1089). "the builds of the chain", "the rollouts of the chain", + // The grants issued, which the assigner's sixth rule reads (ADR 0528 clause 2, issue 1394). + "the grants issued", // What each machine has of each module, written by its node-engine under its own keys (ADR 0429 §1, issue 1136). "a machine's have", // The contents of a declaration's large files, in the bus's object bucket (ADR 0486 §2, issue 1697). diff --git a/testdata/beside/CAPTURED b/testdata/beside/CAPTURED index 84ac613c..b8a8e6ba 100644 --- a/testdata/beside/CAPTURED +++ b/testdata/beside/CAPTURED @@ -9,7 +9,7 @@ a second place to clean besides the catalogue itself. mesh-catalog e0bc919d4945bb5f13f971f74643a0b714cf45da modules/*/module.json, modules/nats/Dockerfile mesh-host 9d9e3a46c4f5943cfffb413d962ce48b75e2fd39 examples/foundation-first-node.lock -mesh-sdk 104533e8c0c9116fd1cbb02c474bafbbe229ce22 conformance/events/module-event.json +mesh-sdk f625d98713e478e37962e59e4421213770735d34 conformance/events/module-event.json To move them, from this repository's root, with the two repositories checked out beside it: