diff --git a/cmd/mesh-controller/compose_input.go b/cmd/mesh-controller/compose_input.go index 41ca4bf7..b13c1a4d 100644 --- a/cmd/mesh-controller/compose_input.go +++ b/cmd/mesh-controller/compose_input.go @@ -53,21 +53,19 @@ func readComposeInput(ctx context.Context, open *stores) (*meshInput, error) { if err := readSettings(ctx, open, &in, plans); err != nil { return nil, err } - if err := readCredentialsAndBuilds(ctx, open, &in, plans, shape); err != nil { + wouldMake, err := readCredentialsAndBuilds(ctx, open, &in, plans, shape) + if err != nil { return nil, err } composeInputBuilt() - return &meshInput{Input: in}, nil + return &meshInput{Input: in, wouldMake: wouldMake}, nil } -// readCredentialsAndBuilds is the credential and build half of the Input, which issue 1383 fills. -func readCredentialsAndBuilds(context.Context, *stores, *compose.Input, map[string]compose.Resolved, inputShape) error { - return nil -} - -// meshInput is the Input an operation composes over. +// meshInput is the Input an operation composes over, and the pair credentials its reading found none held for: the ones the +// next send makes, which perform makes. type meshInput struct { compose.Input + wouldMake []inventory.PairCredential } // nodeOf is the node of that name in an Input. diff --git a/cmd/mesh-controller/compose_input_credentials.go b/cmd/mesh-controller/compose_input_credentials.go new file mode 100644 index 00000000..6dc5c91c --- /dev/null +++ b/cmd/mesh-controller/compose_input_credentials.go @@ -0,0 +1,280 @@ +package main + +import ( + "context" + "sort" + "strings" + + "git.novox.be/novox/mesh-sdk/go/catalogue" + "git.novox.be/novox/mesh-sdk/go/compose" + "git.novox.be/novox/mesh-sdk/go/overlay" + "github.com/novox/mesh-controller/internal/inventory" +) + +// The second half of the adapter (issue 1383): the credential and build fields of the Input, read and never made. + +// readCredentialsAndBuilds fills what the first half left: the credentials, the own secrets, the vault's boxes, the bus, +// each node's serving key and certificate, and the builds. +func readCredentialsAndBuilds(ctx context.Context, open *stores, in *compose.Input, plans map[string]compose.Resolved, + shape inputShape) ([]inventory.PairCredential, error) { + if err := readIdentity(ctx, open, in); err != nil { + return nil, err + } + wouldMake, err := readCredentials(ctx, open, in, plans) + if err != nil { + return nil, err + } + return wouldMake, readBuilds(ctx, open, in, plans, shape) +} + +// readIdentity is the mesh's authority and each node's serving key and the certificate kept for it, for a mesh in which a +// module asks for a certificate. It establishes and issues nothing: a certificate not kept yet is composed with a stand-in, and +// issued by the send (certificateFor). +func readIdentity(ctx context.Context, open *stores, in *compose.Input) error { + needs := false + for _, m := range in.Catalogue { + needs = needs || m.Certificate != nil + } + if !needs { + return nil + } + ident, err := open.Identity(ctx) + if err != nil { + return err + } + if certificate, known, err := ident.KnownAuthority(ctx); err != nil { + return err + } else if known { + in.Authority = certificate + } + nodes, err := open.inventory.Nodes(ctx) + if err != nil { + return err + } + ids := map[string]string{} + for _, n := range nodes { + ids[n.Name] = n.ID + } + for i := range in.Nodes { + n := &in.Nodes[i] + if n.ServingKey, err = ident.ServingKeyOf(ctx, ids[n.Name]); err != nil { + return err + } + if n.ServingKey == "" { + continue + } + if certificate, known, err := ident.KeptCertificate(ctx, overlay.InternalName(n.Name), n.ServingKey); err != nil { + return err + } else if known { + n.Certificate = certificate + } + } + return nil +} + +// readCredentials is how each pair credential a node takes from elsewhere stands, the credentials made for each provider's +// consumers, the own secrets, the vault's boxes and the bus. It reads and never makes: a credential a send would make is +// left out (the composition's would-make), a credential only a person gives and nobody gave is not-given, and one the send +// would be refused is refused with the controller's words. +func readCredentials(ctx context.Context, open *stores, in *compose.Input, plans map[string]compose.Resolved) ( + []inventory.PairCredential, error) { + var wouldMake []inventory.PairCredential + inv := open.inventory + shelf := in.Catalogue + nodeOf := map[string]compose.Node{} + for _, n := range in.Nodes { + nodeOf[n.Name] = n + } + names := make([]string, 0, len(plans)) + for name := range plans { + names = append(names, name) + } + sort.Strings(names) + for _, name := range names { + resolved := plans[name] + nodeShelf := shelfWith(shelf, nodeOf[name].Kept) + for _, need := range resolved.Plan.Needs { + if need.ByRecord { + continue + } + p := inventory.PairCredential{Name: need.Name, Consumer: name, ConsumerModule: need.For, + Provider: need.From, Local: need.Local} + if module, outside := issuedOutsideBy(nodeShelf, need); outside { + p.Kind, p.ProviderModule = inventory.PairOutside, module + } else if need.SharedOwn != "" { + p.Kind, p.Own = inventory.PairShared, need.SharedOwn + p.ProviderModule = sharedProviderModule(resolved.Plan, shelf, need) + } + state, err := inv.PairCredentialState(ctx, p) + if err != nil { + return nil, err + } + c := compose.Credential{Provision: need.Name, Consumer: name, ConsumerModule: need.For, + Provider: need.From, Local: need.Local} + switch state.State { + case inventory.PairHeld: + c.State, c.Sealed = compose.CredentialHeld, state.Sealed + case inventory.PairNotGiven: + c.State, c.Why = compose.CredentialNotGiven, state.Why + case inventory.PairRefused: + c.State, c.Why = compose.CredentialRefused, state.Why + default: + // Left out: a need with no entry is the credential the next send makes, and perform makes it. + wouldMake = append(wouldMake, p) + continue + } + in.Credentials = append(in.Credentials, c) + } + } + + // The credentials made for each provider's consumers, so the grants it must create are read from them. + for _, n := range in.Nodes { + rows, err := inv.SecretsFrom(ctx, n.Name) + if err != nil { + return nil, err + } + for _, s := range rows { + in.Issued = append(in.Issued, compose.Issued{Provision: s.Name, Consumer: s.Consumer, + ConsumerModule: s.ConsumerModule, Local: s.Local, Provider: n.Name, ForProvider: s.ForProvider}) + } + } + own, err := inv.OwnSecretRows(ctx) + if err != nil { + return nil, err + } + for _, r := range own { + in.OwnSecrets = append(in.OwnSecrets, compose.OwnSecretRow{Node: r.Node, Module: r.Module, Name: r.Name, + Origin: r.Origin, SealedTo: r.SealedTo, Sealed: r.Sealed}) + } + for _, n := range in.Nodes { + boxes, err := inv.KeptCoreBoxes(ctx, n.Name) + if err != nil { + return nil, err + } + for module, byName := range boxes { + for name, b := range byName { + in.KeptCoreBoxes = append(in.KeptCoreBoxes, compose.CoreBox{Node: n.Name, Module: module, Name: name, + Generation: b.Generation, SealedTo: b.SealedTo, Box: b.Box}) + } + } + } + sort.Slice(in.KeptCoreBoxes, func(i, j int) bool { + a, b := in.KeptCoreBoxes[i], in.KeptCoreBoxes[j] + if a.Node != b.Node { + return a.Node < b.Node + } + if a.Module != b.Module { + return a.Module < b.Module + } + return a.Name < b.Name + }) + + // The bus: its user list as the controller composes it, for the machine that runs it, and who the mesh has minted a + // credential for. + var bus []catalogue.Manifest + for _, m := range shelf { + if m.BusUsers != "" && m.ClaimsSeat("mesh-broker") { + bus = append(bus, m) + } + } + if len(bus) > 0 { + list, missing, err := busUserList(ctx, inv, bus) + if err != nil && !strings.Contains(err.Error(), "not one user has a credential") { + return nil, err + } + in.BusUsers, in.BusMissing = list, missing + } + if in.BusMinted, err = inv.BusMinted(ctx); err != nil { + return nil, err + } + if in.BusMemberships, err = inv.BusMemberships(ctx); err != nil { + return nil, err + } + return wouldMake, nil +} + +// readBuilds is the build half of the Input: every repository the mesh built, each module's current build, and the build made +// from a commit of a module for the machines whose node-engine reads them. +func readBuilds(ctx context.Context, open *stores, in *compose.Input, plans map[string]compose.Resolved, shape inputShape) error { + inv := open.inventory + held, err := inv.Held(ctx) + if err != nil { + return err + } + for repository := range held { + in.Built = append(in.Built, repository) + } + sort.Strings(in.Built) + in.CurrentBuilds = make(map[string]string, len(shape.current)) + for module, b := range shape.current { + in.CurrentBuilds[module] = b.Commit + } + // The build made from each commit a declaration may carry, for the machines that read a build per module. + for _, n := range in.Nodes { + if !n.ReadsModules { + continue + } + resolved, ok := plans[n.Name] + if !ok { + continue + } + for _, m := range resolved.Plan.Modules { + for _, commit := range []string{shape.current[m.Module].Commit, shape.kept[n.Name][m.Module], shape.sent[n.Name][m.Module]} { + if commit == "" { + continue + } + if _, done := in.BuildIDs[m.Module][commit]; done { + continue + } + build, err := inv.BuildOf(ctx, m.Module, commit) + if err != nil { + return err + } + if in.BuildIDs == nil { + in.BuildIDs = map[string]map[string]string{} + } + if in.BuildIDs[m.Module] == nil { + in.BuildIDs[m.Module] = map[string]string{} + } + in.BuildIDs[m.Module][commit] = build + } + } + } + return nil +} + +// shelfWith is the shelf with each module a node keeps at the build it runs put in place of the current one: a copy. +func shelfWith(shelf map[string]catalogue.Manifest, kept map[string]catalogue.Manifest) map[string]catalogue.Manifest { + if len(kept) == 0 { + return shelf + } + out := make(map[string]catalogue.Manifest, len(shelf)+len(kept)) + for name, m := range shelf { + out[name] = m + } + for name, m := range kept { + out[name] = m + } + return out +} + +// sharedProviderModule is which module answers a shared credential on the providing node: the one in the plan, else the first +// the catalogue names (providerModuleOf, in an order that does not move between calls). +func sharedProviderModule(plan catalogue.Resolution, shelf map[string]catalogue.Manifest, n catalogue.Needed) string { + for _, m := range plan.Modules { + if _, shared := m.SharedCredentialOf(n.Name); shared { + return m.Module + } + } + names := make([]string, 0, len(shelf)) + for name := range shelf { + names = append(names, name) + } + sort.Strings(names) + for _, name := range names { + if _, shared := shelf[name].SharedCredentialOf(n.Name); shared { + return name + } + } + return "" +} diff --git a/cmd/mesh-controller/compose_input_credentials_integration_test.go b/cmd/mesh-controller/compose_input_credentials_integration_test.go new file mode 100644 index 00000000..d3905fd9 --- /dev/null +++ b/cmd/mesh-controller/compose_input_credentials_integration_test.go @@ -0,0 +1,330 @@ +//go:build integration + +package main + +import ( + "encoding/json" + "fmt" + "sort" + "strings" + "testing" + + "git.novox.be/novox/mesh-sdk/go/catalogue" + "git.novox.be/novox/mesh-sdk/go/compose" + "github.com/novox/mesh-controller/internal/inventory" +) + +// tableState is every table's rows and every sequence's last value, as the adapter's reads must leave them. +func tableState(t *testing.T, inv *inventory.Inventory) map[string]string { + t.Helper() + out := map[string]string{} + for _, row := range inv.QueryForTest(t, `select table_name from information_schema.tables + where table_schema = 'public' and table_type = 'BASE TABLE' order by table_name`) { + name := strings.Trim(row[0], `"`) + count := inv.QueryForTest(t, fmt.Sprintf(`select count(*)::text from %q`, name)) + out["rows:"+name] = fmt.Sprint(count) + digest := inv.QueryForTest(t, fmt.Sprintf(`select coalesce(md5(string_agg(t::text, '|' order by t::text)), '') from %q t`, name)) + out["content:"+name] = fmt.Sprint(digest) + } + for _, row := range inv.QueryForTest(t, `select sequencename, coalesce(last_value::text, '') from pg_sequences + where schemaname = 'public' order by sequencename`) { + out["sequence:"+row[0]] = row[1] + } + return out +} + +// 1382/AC4 and 1383/AC6: building the Input writes nothing. The store's rows, their contents and its sequences are the same +// before and after, on a mesh whose credentials are not made, whose ports are not given and which keeps a recorded build. +func TestBuildingTheInputWritesNothing(t *testing.T) { + open, _ := aMeshWithSecrets(t) + ctx := t.Context() + inv := open.inventory + // A consumer whose credential nothing has made yet, on a mesh that already composed. + register(t, open, catalogue.Manifest{Module: "reports", Version: "1", Requires: []string{"s3-bucket"}, + Resources: []map[string]any{{"id": "config", "type": "file", "path": "/etc/reports/config.json", "mode": "0600", + "content": "{}", "merge": "json"}}}) + if _, err := inv.Assign(ctx, "laptop", "reports"); err != nil { + t.Fatal(err) + } + before := tableState(t, inv) + if _, err := readComposeInput(ctx, open); err != nil { + t.Fatal(err) + } + after := tableState(t, inv) + var moved []string + for k, v := range after { + if before[k] != v { + moved = append(moved, k) + } + } + sort.Strings(moved) + if len(moved) > 0 { + t.Fatalf("building the Input wrote: %v", moved) + } +} + +// a consumer of the s3-bucket aMeshWithSecrets' provider offers, with a file the credential goes into. +func aConsumerOfTheBucket(name string) catalogue.Manifest { + return catalogue.Manifest{Module: name, Version: "1", Requires: []string{"s3-bucket"}, + Secrets: map[string]string{"s3-bucket": "/etc/" + name + "/s3"}, + Resources: []map[string]any{{"id": "config", "type": "file", "path": "/etc/" + name + "/config.json", "mode": "0600", + "content": "{}", "merge": "json"}}} +} + +// 1383/AC1: of a provider's three consumers, two have their credentials made and one has not: Issued holds the two rows, with +// the copy sealed to the provider, the third is a credential the next send makes, and no row was inserted for it (planFor +// inserted it, plan.go: SecretFor in every mode). +func TestAPairCredentialNoRowHoldsIsAWouldMakeAndNoRowIsInserted(t *testing.T) { + open, _ := aMeshWithSecrets(t) + ctx := t.Context() + inv := open.inventory + register(t, open, aConsumerOfTheBucket("reports")) + if _, err := inv.Assign(ctx, "laptop", "reports"); err != nil { + t.Fatal(err) + } + // The controller's own planning makes the credentials of files and reports. + if _, _, err := planFor(ctx, open, "laptop"); err != nil { + t.Fatal(err) + } + register(t, open, aConsumerOfTheBucket("metrics")) + if _, err := inv.Assign(ctx, "laptop", "metrics"); err != nil { + t.Fatal(err) + } + before := tableState(t, inv) + in, err := readComposeInput(ctx, open) + if err != nil { + t.Fatal(err) + } + var rows []compose.Issued + for _, r := range in.Issued { + if r.Provider == "anchor" && r.Provision == "s3-bucket" { + rows = append(rows, r) + if r.ForProvider == "" { + t.Errorf("the row of %s carries no copy sealed to the provider", r.ConsumerModule) + } + } + } + if len(rows) != 2 { + t.Fatalf("Issued holds %d row(s) for anchor's s3-bucket, want files and reports: %+v", len(rows), rows) + } + var would []string + for _, p := range in.wouldMake { + would = append(would, p.ConsumerModule) + } + if len(would) != 1 || would[0] != "metrics" { + t.Fatalf("the credentials the next send makes are %v, want metrics", would) + } + for _, c := range in.Credentials { + if c.ConsumerModule == "metrics" { + t.Errorf("a credential nobody holds has an entry: %+v", c) + } + } + after := tableState(t, inv) + for k, v := range after { + if before[k] != v { + t.Errorf("reading the Input moved %s", k) + } + } +} + +// 1383/AC2: a credential only a person gives that nobody gave is not given, and so is one given under a key the machine no +// longer has; a credential given by a person for the pair and sealed to a key one end no longer has is refused: in the words +// plan.go and secrets.go use. +func TestACredentialOnlyAPersonGivesIsNotGivenAndAGivenOneUnderAnOldKeyIsSaidSo(t *testing.T) { + open := aMesh(t) + ctx := t.Context() + inv := open.inventory + register(t, open, catalogue.Manifest{Module: "indexers", Version: "1", Provides: []catalogue.Offer{{ + Name: "indexer-api", Scope: catalogue.ScopeMesh, Credential: &catalogue.OfferCredential{IssuedBy: catalogue.IssuedOutside}}}}) + register(t, open, catalogue.Manifest{Module: "grabber", Version: "1", Requires: []string{"indexer-api"}, + Secrets: map[string]string{"indexer-api": "/etc/grabber/key"}}) + register(t, open, aConsumerOfTheBucket("ledger")) + register(t, open, catalogue.Manifest{Module: "objects", Version: "1", Provides: catalogue.FromAnywhere("s3-bucket")}) + for _, a := range [][2]string{{"anchor", "indexers"}, {"laptop", "grabber"}, {"anchor", "objects"}, {"laptop", "ledger"}} { + if _, err := inv.Assign(ctx, a[0], a[1]); err != nil { + t.Fatalf("assign %s %s: %v", a[0], a[1], err) + } + } + stateOf := func(provision, consumerModule string) (compose.Credential, bool) { + in, err := readComposeInput(ctx, open) + if err != nil { + t.Fatal(err) + } + for _, c := range in.Credentials { + if c.Provision == provision && c.ConsumerModule == consumerModule { + return c, true + } + } + return compose.Credential{}, false + } + + c, found := stateOf("indexer-api", "grabber") + if !found || c.State != compose.CredentialNotGiven || !strings.Contains(c.Why, "nobody gave it") { + t.Fatalf("a credential nobody gave reads as %+v", c) + } + if err := inv.AcceptSecretForPair(ctx, "indexer-api", "laptop", "grabber", "anchor", "", "the-key"); err != nil { + t.Fatal(err) + } + if c, found := stateOf("indexer-api", "grabber"); !found || c.State != compose.CredentialHeld || c.Sealed == "" { + t.Fatalf("a credential a person gave reads as %+v", c) + } + // The consumer's machine generated a key of its own since. + if err := inv.RecordSealingKey(ctx, nodeID(t, open, "laptop"), aPublicKey(t)); err != nil { + t.Fatal(err) + } + if c, found := stateOf("indexer-api", "grabber"); !found || c.State != compose.CredentialNotGiven || + !strings.Contains(c.Why, "a sealing key one end no longer has") { + t.Fatalf("a credential given under a key the machine no longer has reads as %+v", c) + } + + // A credential for a pair, given by a person, under a key one end has since replaced: the mesh cannot make another. + if err := inv.RecordSealingKey(ctx, nodeID(t, open, "laptop"), aPublicKey(t)); err != nil { + t.Fatal(err) + } + if err := inv.AcceptSecretForPair(ctx, "s3-bucket", "laptop", "ledger", "anchor", "", "the-bucket-key"); err != nil { + t.Fatal(err) + } + if err := inv.RecordSealingKey(ctx, nodeID(t, open, "laptop"), aPublicKey(t)); err != nil { + t.Fatal(err) + } + if c, found := stateOf("s3-bucket", "ledger"); !found || c.State != compose.CredentialRefused || + !strings.Contains(c.Why, "was accepted from a person, and a sealing key at one end has changed since") { + t.Fatalf("a credential given for the pair under an old key reads as %+v", c) + } +} + +// 1383/AC3: the own secrets carry their origin, the key they are sealed to and the sealed value; the vault's kept boxes, the +// licences (with their keys given, so an empty key means the mesh holds none), the users the bus has minted and the +// memberships equal what the inventory answers. +func TestTheAdaptersInputCarriesTheOwnSecretsTheBoxesTheLicencesAndTheBus(t *testing.T) { + open, _ := aMeshWithSecrets(t) + ctx := t.Context() + inv := open.inventory + key, err := inv.SealingKeyOf(ctx, "anchor") + if err != nil { + t.Fatal(err) + } + sealed, err := inv.SecretForModule(ctx, "anchor", "objects", "admin") + if err != nil { + t.Fatal(err) + } + box := json.RawMessage(`{"v":1,"eph":"e","iv":"i","tag":"t","ct":"c"}`) + if err := inv.KeepCoreBox(ctx, "anchor", inventory.CoreBox{Module: "objects", Name: "admin", Generation: 4, + SealedTo: key, Box: box}); err != nil { + t.Fatal(err) + } + if err := inv.SeedBusUser(ctx, inventory.BusUser{Username: "anchor.objects", Kind: "module", Node: "anchor", + Module: "objects"}, "a-password"); err != nil { + t.Fatal(err) + } + if err := inv.PutBusMembership(ctx, "laptop", "sealed-membership"); err != nil { + t.Fatal(err) + } + held, err := open.Licences(ctx) + if err != nil { + t.Fatal(err) + } + if err := held.Add(ctx, "claude", "static", map[string]any{"model": "x"}); err != nil { + t.Fatal(err) + } + + in, err := readComposeInput(ctx, open) + if err != nil { + t.Fatal(err) + } + var own *compose.OwnSecretRow + for i := range in.OwnSecrets { + if r := &in.OwnSecrets[i]; r.Node == "anchor" && r.Module == "objects" && r.Name == "admin" { + own = r + } + } + if own == nil || own.Origin != "made" || own.SealedTo != key || own.Sealed != sealed { + t.Errorf("the own secret reads as %+v; the inventory holds %q sealed to %q", own, sealed, key) + } + var kept *compose.CoreBox + for i := range in.KeptCoreBoxes { + if b := &in.KeptCoreBoxes[i]; b.Node == "anchor" && b.Module == "objects" && b.Name == "admin" { + kept = b + } + } + if kept == nil || kept.Generation != 4 || kept.SealedTo != key || string(kept.Box) != string(box) { + t.Errorf("the kept box reads as %+v", kept) + } + if got := strings.Join(in.BusMinted, ","); !strings.Contains(got, "anchor.objects") { + t.Errorf("BusMinted is %v", in.BusMinted) + } + if in.BusMemberships["laptop"] != "sealed-membership" { + t.Errorf("BusMemberships is %v", in.BusMemberships) + } + if len(in.Licences) != 1 || in.Licences[0].Name != "claude" || in.Licences[0].Keys == nil { + t.Errorf("Licences read as %+v", in.Licences) + } +} + +// 1383/AC4: the bus's user list is what busUserList gives for the machine that runs the bus, and the Input carries none when +// no module of the shelf runs the bus. +func TestTheBusUsersAreWhatTheControllerComposesForTheMachineThatRunsTheBus(t *testing.T) { + open, _ := aMeshWithSecrets(t) + ctx := t.Context() + in, err := readComposeInput(ctx, open) + if err != nil { + t.Fatal(err) + } + if in.BusUsers != "" || len(in.BusMissing) != 0 { + t.Fatalf("a mesh whose shelf runs no bus carries a bus user list: %q %v", in.BusUsers, in.BusMissing) + } + bus := catalogue.Manifest{Module: "nats", Version: "1", BusUsers: "/etc/nats/users.conf", + Claims: []catalogue.Claim{{Name: "mesh-broker", Scope: catalogue.ScopeMesh}}} + register(t, open, bus) + if err := open.inventory.SeedBusUser(ctx, inventory.BusUser{Username: "controller", Kind: "controller"}, "pw"); err != nil { + t.Fatal(err) + } + in, err = readComposeInput(ctx, open) + if err != nil { + t.Fatal(err) + } + list, missing, _ := busUserList(ctx, open.inventory, []catalogue.Manifest{bus}) + if in.BusUsers != list || fmt.Sprint(in.BusMissing) != fmt.Sprint(missing) { + t.Errorf("the Input carries %q (%v); busUserList gives %q (%v)", in.BusUsers, in.BusMissing, list, missing) + } +} + +// 1383/AC5: Built, CurrentBuilds and BuildIDs equal the inventory's Held, CurrentBuilds and BuildOf, and the serving key and +// the certificate are the machine's. +func TestTheAdaptersInputCarriesTheBuildsAndTheMachinesKey(t *testing.T) { + open, _ := aMeshWithSecrets(t) + ctx := t.Context() + inv := open.inventory + in, err := readComposeInput(ctx, open) + if err != nil { + t.Fatal(err) + } + held, err := inv.Held(ctx) + if err != nil { + t.Fatal(err) + } + var wantBuilt []string + for repository := range held { + wantBuilt = append(wantBuilt, repository) + } + sort.Strings(wantBuilt) + if fmt.Sprint(in.Built) != fmt.Sprint(wantBuilt) && !(len(in.Built) == 0 && len(wantBuilt) == 0) { + t.Errorf("Built is %v, the inventory's %v", in.Built, wantBuilt) + } + current, err := inv.CurrentBuilds(ctx) + if err != nil { + t.Fatal(err) + } + for module, b := range current { + if in.CurrentBuilds[module] != b.Commit { + t.Errorf("CurrentBuilds[%s] is %q, the inventory's %q", module, in.CurrentBuilds[module], b.Commit) + } + } + for module, byCommit := range in.BuildIDs { + for commit, build := range byCommit { + if want, _ := inv.BuildOf(ctx, module, commit); want != build { + t.Errorf("BuildIDs[%s][%s] is %q, the inventory's %q", module, commit, build, want) + } + } + } +} diff --git a/internal/identity/read_kept.go b/internal/identity/read_kept.go new file mode 100644 index 00000000..a2d477b5 --- /dev/null +++ b/internal/identity/read_kept.go @@ -0,0 +1,47 @@ +package identity + +import ( + "context" + "crypto/ed25519" + "encoding/base64" + "errors" + + "github.com/jackc/pgx/v5" +) + +// The reads of what Certify and EstablishAuthority would keep, without keeping anything (design 47 R4b, issue 1383): the +// controller's adapter from its stores to mesh-sdk's composition input fills the certificate and the authority it holds, and +// a machine whose certificate is not held yet is composed with a stand-in until the next send issues it. + +// KnownAuthority is the mesh's public certificate when it has an authority, and false when none was established yet. It +// establishes nothing. +func (i *Identity) KnownAuthority(ctx context.Context) (string, bool, error) { + held, err := i.authority(ctx) + if errors.Is(err, pgx.ErrNoRows) { + return "", false, nil + } + if err != nil { + return "", false, err + } + return held.Certificate, true, nil +} + +// KeptCertificate is the certificate Certify would answer without signing, for a node's internal name and serving key: the +// one kept for the key while it is for that name, binds that key and has life left. False when Certify would sign a fresh +// one. It writes nothing. +func (i *Identity) KeptCertificate(ctx context.Context, name, servingKey string) (string, bool, error) { + public, err := base64.StdEncoding.DecodeString(servingKey) + if err != nil || len(public) != ed25519.PublicKeySize { + return "", false, nil + } + var kept *string + err = i.store.Pool().QueryRow(ctx, + `select certificate from node_key where serving_key = $1 and revoked is null`, servingKey).Scan(&kept) + if err != nil && !errors.Is(err, pgx.ErrNoRows) { + return "", false, err + } + if kept != nil && stillStands(*kept, name, public) { + return *kept, true, nil + } + return "", false, nil +} diff --git a/internal/inventory/read_state.go b/internal/inventory/read_state.go new file mode 100644 index 00000000..cd44018c --- /dev/null +++ b/internal/inventory/read_state.go @@ -0,0 +1,234 @@ +package inventory + +import ( + "context" + "errors" + "fmt" + "sort" + + "github.com/jackc/pgx/v5" + + "github.com/novox/mesh-controller/internal/secrets" +) + +// The reads of what a composition would make or hold, in one place and writing nothing (design 47 R4b, issue 1383): the +// controller's adapter from its stores to mesh-sdk's composition input asks them once for the whole mesh, where the +// composition's own reads (SecretFor, SharedSecretFor, ModuleSecretIfIssued, GivenOwnSecret) ask for one pair or one +// secret and make what is not held. + +// OwnSecretRow is one own secret held on a machine (a module_secret row): who made it (`made`, or `accepted` for a +// value a person gave), the sealing key it is sealed to, and the value sealed to that key. +type OwnSecretRow struct { + Node, Module, Name, Origin, SealedTo, Sealed string +} + +// OwnSecretRows is every own secret held on every machine, by machine, module and name. It writes nothing. +func (i *Inventory) OwnSecretRows(ctx context.Context) ([]OwnSecretRow, error) { + rows, err := i.store.Pool().Query(ctx, + `select n.name, m.module, m.name, m.origin, m.node_key, m.sealed + from module_secret m join node n on n.id = m.node + order by n.name, m.module, m.name`) + if err != nil { + return nil, err + } + defer rows.Close() + var out []OwnSecretRow + for rows.Next() { + var r OwnSecretRow + if err := rows.Scan(&r.Node, &r.Module, &r.Name, &r.Origin, &r.SealedTo, &r.Sealed); err != nil { + return nil, err + } + out = append(out, r) + } + return out, rows.Err() +} + +// PairState is how one pair credential stands, as the composition's reads of it would find it (what SecretFor, +// SharedSecretFor and GivenPairSecret answer), without making anything. +type PairState struct { + // State is one of the Pair constants. + State string + // Sealed is the consumer's copy as it is sealed to the consumer's key, for a held credential. + Sealed string + // Why is the controller's words for a credential not given or refused. + Why string +} + +// The states of a pair credential. +const ( + // PairHeld is a credential held, sealed to the keys both ends have now. + PairHeld = "held" + // PairWouldMake is a credential the next send makes. + PairWouldMake = "would-make" + // PairNotGiven is a credential only a person gives (novox/hq ADR 0337), which nobody gave under the keys the ends have now. + PairNotGiven = "not-given" + // PairRefused is a credential the next send would be refused when it made it, for the reason Why gives. + PairRefused = "refused" +) + +// PairCredentialKind says which of the controller's reads a pair is answered by. +type PairCredentialKind int + +const ( + // PairPlain is a credential made for the pair (SecretFor). + PairPlain PairCredentialKind = iota + // PairShared is the provider's one credential sealed to the consumer too (SharedSecretFor; Own names the provider's own secret). + PairShared + // PairOutside is a credential only a person gives (GivenPairSecret). + PairOutside +) + +// PairCredential is one pair credential, by the ends it joins. +type PairCredential struct { + Kind PairCredentialKind + Name, Consumer, ConsumerModule, Provider, ProviderModule, Local string + // Own is, for a shared credential, the provider's own secret it is. + Own string +} + +// PairCredentialState says how a pair credential stands now, by the rules the next send applies and without applying +// them: nothing is inserted, remade or sealed. A credential the send would make is PairWouldMake, unless the send would be +// refused (a machine with no sealing key, a value given and sealed to a key one end no longer has), which is PairRefused +// in the send's own words. +func (i *Inventory) PairCredentialState(ctx context.Context, p PairCredential) (PairState, error) { + switch p.Kind { + case PairOutside: + return i.outsideState(ctx, p) + case PairShared: + return i.sharedState(ctx, p) + } + return i.plainState(ctx, p) +} + +func (i *Inventory) outsideState(ctx context.Context, p PairCredential) (PairState, error) { + got, err := i.GivenPairSecret(ctx, p.Name, p.Consumer, p.ConsumerModule, p.Provider, p.ProviderModule, p.Local) + var notGiven *NotGivenError + switch { + case errors.As(err, ¬Given): + return PairState{State: PairNotGiven, Why: notGiven.Why}, nil + case err != nil: + return PairState{}, err + } + return PairState{State: PairHeld, Sealed: got.ForConsumer}, nil +} + +func (i *Inventory) plainState(ctx context.Context, p PairCredential) (PairState, error) { + consumerKey, err := i.SealingKeyOf(ctx, p.Consumer) + if err != nil { + return PairState{}, err + } + providerKey, err := i.SealingKeyOf(ctx, p.Provider) + if err != nil { + return PairState{}, err + } + consumerNode, err := i.NodeByName(ctx, p.Consumer) + if err != nil { + return PairState{}, err + } + providerNode, err := i.NodeByName(ctx, p.Provider) + if err != nil { + return PairState{}, err + } + var held Secret + err = i.store.Pool().QueryRow(ctx, + `select for_consumer, for_provider, consumer_key, provider_key, origin from secret + where name = $1 and consumer = $2 and consumer_module = $3 and provider = $4 and local = $5`, + p.Name, consumerNode.ID, p.ConsumerModule, providerNode.ID, p.Local). + Scan(&held.ForConsumer, &held.ForProvider, &held.ConsumerKey, &held.ProviderKey, &held.Origin) + if err != nil && !errors.Is(err, pgx.ErrNoRows) { + return PairState{}, err + } + if err == nil && held.ConsumerKey == consumerKey && held.ProviderKey == providerKey { + return PairState{State: PairHeld, Sealed: held.ForConsumer}, nil + } + if err == nil && held.Origin == OriginAccepted { + return PairState{State: PairRefused, Why: fmt.Sprintf( + "%s's %q credential from %s was accepted from a person, and a sealing key at one end "+ + "has changed since. The mesh cannot re-seal a value it does not hold: accept it "+ + "again with `secret accept %s %s %s --provider %s%s`", + p.ConsumerModule, p.Name, p.Provider, p.Consumer, p.ConsumerModule, p.Name, p.Provider, localFlag(p.Local))}, nil + } + // What making one would meet: the operator's key and the two sealing keys, by the same call that seals them. + operator, err := i.OperatorKey(ctx) + if err != nil { + return PairState{}, err + } + if _, _, merr := secrets.MakeWithOperator(consumerKey, providerKey, operator); merr != nil { + return PairState{State: PairRefused, Why: merr.Error()}, nil + } + return PairState{State: PairWouldMake}, nil +} + +func (i *Inventory) sharedState(ctx context.Context, p PairCredential) (PairState, error) { + consumerKey, err := i.SealingKeyOf(ctx, p.Consumer) + if err != nil { + return PairState{}, err + } + consumerNode, err := i.NodeByName(ctx, p.Consumer) + if err != nil { + return PairState{}, err + } + providerNode, err := i.NodeByName(ctx, p.Provider) + if err != nil { + return PairState{}, err + } + providerKey, err := i.SealingKeyOf(ctx, p.Provider) + if err != nil { + return PairState{}, err + } + if consumerKey == "" || providerKey == "" { + return PairState{State: PairRefused, Why: fmt.Sprintf("%s and %s both need a sealing key before %s can be shared", + p.Consumer, p.Provider, p.Name)}, nil + } + var ownGeneration, ownOrigin, ownKey *string + err = i.store.Pool().QueryRow(ctx, + `select generation, origin, node_key from module_secret where node = $1 and module = $2 and name = $3`, + providerNode.ID, p.ProviderModule, p.Own).Scan(&ownGeneration, &ownOrigin, &ownKey) + if err != nil && !errors.Is(err, pgx.ErrNoRows) { + return PairState{}, err + } + var held Secret + var pairGeneration *string + err = i.store.Pool().QueryRow(ctx, + `select for_consumer, for_provider, consumer_key, provider_key, origin, generation from secret + where name = $1 and consumer = $2 and consumer_module = $3 and provider = $4 and local = $5`, + p.Name, consumerNode.ID, p.ConsumerModule, providerNode.ID, p.Local). + Scan(&held.ForConsumer, &held.ForProvider, &held.ConsumerKey, &held.ProviderKey, &held.Origin, &pairGeneration) + if err != nil && !errors.Is(err, pgx.ErrNoRows) { + return PairState{}, err + } + current := ownGeneration != nil && pairGeneration != nil && *ownGeneration == *pairGeneration && + held.ConsumerKey == consumerKey && held.ProviderKey == providerKey && ownKey != nil && *ownKey == providerKey + if current { + return PairState{State: PairHeld, Sealed: held.ForConsumer}, nil + } + if _, outside, err := i.ownIssuedOutside(ctx, p.ProviderModule, p.Own); err != nil { + return PairState{}, err + } else if outside && (ownOrigin == nil || *ownOrigin != OriginAccepted) { + return PairState{State: PairNotGiven, + Why: "nobody gave " + p.ProviderModule + "'s own secret " + p.Own + ", which is issued outside the mesh"}, nil + } + if ownOrigin != nil && *ownOrigin == OriginAccepted { + return PairState{State: PairRefused, Why: fmt.Sprintf( + "%s on %s needs %s from %s, whose credential is %s's own secret %q — a value given to the "+ + "mesh, which cannot seal it to a consumer that binds later (ADR 0158): `secret accept %s %s %s` "+ + "again, which seals it to every current consumer", + p.ConsumerModule, p.Consumer, p.Name, p.Provider, p.ProviderModule, p.Own, p.Provider, p.ProviderModule, p.Own)}, nil + } + return PairState{State: PairWouldMake}, nil +} + +// BusMinted is the name of every bus user the mesh has minted a password hash for: `.` for a module and +// `provisioner.` for a machine's provisioner client. +func (i *Inventory) BusMinted(ctx context.Context) ([]string, error) { + kept, err := i.BusUsers(ctx) + if err != nil { + return nil, err + } + out := make([]string, 0, len(kept)) + for name := range kept { + out = append(out, name) + } + sort.Strings(out) + return out, nil +}