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 41d88d4f..bc9e9365 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" @@ -1154,6 +1155,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