diff --git a/cmd/mesh-controller/plan.go b/cmd/mesh-controller/plan.go index 17eaa30..db1ff0e 100644 --- a/cmd/mesh-controller/plan.go +++ b/cmd/mesh-controller/plan.go @@ -122,7 +122,7 @@ func planFor(ctx context.Context, open *stores, nodeName string) (catalogue.Reso } continue } - secret, err := inv.SecretFor(ctx, n.Name, nodeName, n.For, n.From) + secret, err := inv.SecretFor(ctx, n.Name, nodeName, n.For, n.From, n.Local) if err != nil { // Said rather than skipped. A machine that resolves cleanly and receives no // credential is one that will fail to authenticate at some later, less obvious @@ -693,7 +693,7 @@ func grantsFor(ctx context.Context, open *stores, node string) ([]catalogue.Gran } out = append(out, catalogue.Grant{ Provision: s.Name, Consumer: s.Consumer, At: onNetwork[s.Consumer], - From: from, Values: values, Slug: slug, Sealed: s.ForProvider}) + From: from, Values: values, Slug: slug, Sealed: s.ForProvider, Local: s.Local}) } return out, nil } diff --git a/cmd/mesh-controller/rotate.go b/cmd/mesh-controller/rotate.go index e7e7c96..0037254 100644 --- a/cmd/mesh-controller/rotate.go +++ b/cmd/mesh-controller/rotate.go @@ -83,11 +83,11 @@ func rotateCommand(ctx context.Context, args []string) error { for _, h := range holders { // The module, because a machine may hold several credentials for one provision and // rotating "anchor's database password" now means rotating three of them. - fmt.Printf(" %s on %s, from %s\n", h.ConsumerModule, h.Consumer, h.Provider) + fmt.Printf(" %s on %s, from %s%s\n", h.ConsumerModule, h.Consumer, h.Provider, asLocal(h.Local)) } for _, h := range holders { - if err := inv.RotateSecret(ctx, h.Provision, h.Consumer, h.ConsumerModule, h.Provider); err != nil { + if err := inv.RotateSecret(ctx, h.Provision, h.Consumer, h.ConsumerModule, h.Provider, h.Local); err != nil { // Partly rotated, and said so plainly. What is gone is remade on the next push, so // the remedy is to run this again rather than to repair anything — but a machine // whose secret was discarded and not resent is holding a credential the provider is @@ -115,3 +115,11 @@ func rotateCommand(ctx context.Context, args []string) error { "changed cannot authenticate — `status` says who is still behind\n", len(machines)) return nil } + +// asLocal names the credential inside the consumer where it holds several (ADR 0094). +func asLocal(local string) string { + if local == "" { + return "" + } + return " (as " + local + ")" +} diff --git a/cmd/mesh-controller/secret.go b/cmd/mesh-controller/secret.go index 295baaf..3a4105d 100644 --- a/cmd/mesh-controller/secret.go +++ b/cmd/mesh-controller/secret.go @@ -52,6 +52,9 @@ func secretCommand(ctx context.Context, args []string) error { provider := set.String("provider", "", "the node providing : the value becomes the PAIR credential between on "+ "and that provider, sealed to both — the vault's operator-delivered secret (ADR 0092)") + local := set.String("local", "", + "with --provider: the name the credential goes by inside , where its manifest keeps "+ + "several for (ADR 0094)") if err := set.Parse(flags); err != nil { return err } @@ -79,10 +82,10 @@ func secretCommand(ctx context.Context, args []string) error { // Into the pair, not into the module's own secrets: what the provider is asked to create // and what the consumer reads are the same value, and neither end can be told a different // one later without the other (novox/hq 04-ISSUES/070). - if err := open.inventory.AcceptSecretForPair(ctx, name, node, module, *provider, value); err != nil { + if err := open.inventory.AcceptSecretForPair(ctx, name, node, module, *provider, *local, value); err != nil { return err } - fmt.Printf("%s on %s now holds %q from %s, sealed to both machines.\n", module, node, name, *provider) + fmt.Printf("%s on %s now holds %q from %s%s, sealed to both machines.\n", module, node, name, *provider, asLocal(*local)) fmt.Printf(" the mesh cannot read it back, will not replace it with one of its own, and will not rotate it\n") fmt.Printf(" run `push %s` and `push %s` to send it\n", *provider, node) return nil @@ -98,7 +101,7 @@ func secretCommand(ctx context.Context, args []string) error { return nil } -const secretUsage = "secret accept [--from ] [--provider ]\n" + +const secretUsage = "secret accept [--from ] [--provider [--local ]]\n" + "secret recover --key [--out ] [--from-export ] [--provider ]\n" + "secret export [--out ]" diff --git a/internal/catalogue/declaration.go b/internal/catalogue/declaration.go index f06e91e..7aae727 100644 --- a/internal/catalogue/declaration.go +++ b/internal/catalogue/declaration.go @@ -60,6 +60,9 @@ type Grant struct { // Values are what that module contributed — the name it wants, and anything else the // provision's own vocabulary defines. Values map[string]any + // Local is the name the credential goes by inside the consumer where it keeps several for one + // provision (ADR 0094); empty for the ordinary one. The provider sees it as a holder of its own. + Local string // Slug is the consumer module's identity slug, if it declared one — carried on the grant so the // provider side derives the same login the consumer does, even across nodes where the consumer's // manifest is not in view (novox/hq ADR 0049). Empty means "use the module name". @@ -285,45 +288,48 @@ func (r Resolution) Declaration(with Rendering) ([]map[string]any, error) { "id": AccessID(a.Path), "type": "access", "path": a.Path, "mode": a.At(), }) } - for _, to := range sortedKeys(m.Secrets) { - var found *Needed - for i, n := range r.Needs { - // **This module's need, not the provision's** (novox/hq 04-ISSUES/022). Matching - // on the name alone, every consumer of a provision took whichever credential - // happened to be last in the list — so on a node with two of them, one module - // would be given the other's password and fail to authenticate with a valid - // credential belonging to somebody else. - if n.Name == to && n.For == m.Module { - found = &r.Needs[i] + for _, to := range m.SecretRequirements() { + for _, file := range m.SecretFiles(to) { + var found *Needed + for i, n := range r.Needs { + // **This module's need, not the provision's** (novox/hq 04-ISSUES/022). Matching + // on the name alone, every consumer of a provision took whichever credential + // happened to be last in the list — so on a node with two of them, one module + // would be given the other's password and fail to authenticate with a valid + // credential belonging to somebody else. And this file's local name, where the + // module keeps several (ADR 0094). + if n.Name == to && n.For == m.Module && n.Local == file.Local { + found = &r.Needs[i] + } } + if found != nil && found.ByRecord && found.Sealed == "" && !found.Manager { + // Answered by a record whose key has not been supplied since this consumer was + // put on it. **Refused, not skipped.** The mesh discarded the plaintext when the + // key was accepted and cannot seal another, so a machine that resolved cleanly + // would receive no file at all and fail at whatever tried to read it — which is + // the outcome ADR 0024 exists to avoid, arrived at politely. + // + // The manager holder is the one exception (novox/hq ADR 0050): an empty refresh token + // is a licence whose manager has not adopted one yet, a real waiting state rather than + // a lost key. It falls through to the skip below — its bound facts (carrying the + // manager's public key) are still delivered, which is what adoption needs to seal the + // first refresh token. + return nil, fmt.Errorf( + "%s on this machine uses the licence %q and no key has been sealed to it. "+ + "The mesh cannot make one; supply it again with `licence key %s`", + m.Module, found.From, found.From) + } + if found == nil || found.Sealed == "" { + // Answered on this machine, or answered by a node the mesh could not seal to. + // Nothing to write either way, and writing an empty credential file would be + // worse than none: something would read it and fail authenticating. + continue + } + first = append(first, ownedBy(m.SecretsOwner, map[string]any{ + "id": SecretID(SecretLocal(to, file.Local)), "type": "file", "path": file.Path, + "sealed": found.Sealed, + })) } - if found != nil && found.ByRecord && found.Sealed == "" && !found.Manager { - // Answered by a record whose key has not been supplied since this consumer was - // put on it. **Refused, not skipped.** The mesh discarded the plaintext when the - // key was accepted and cannot seal another, so a machine that resolved cleanly - // would receive no file at all and fail at whatever tried to read it — which is - // the outcome ADR 0024 exists to avoid, arrived at politely. - // - // The manager holder is the one exception (novox/hq ADR 0050): an empty refresh token - // is a licence whose manager has not adopted one yet, a real waiting state rather than - // a lost key. It falls through to the skip below — its bound facts (carrying the - // manager's public key) are still delivered, which is what adoption needs to seal the - // first refresh token. - return nil, fmt.Errorf( - "%s on this machine uses the licence %q and no key has been sealed to it. "+ - "The mesh cannot make one; supply it again with `licence key %s`", - m.Module, found.From, found.From) - } - if found == nil || found.Sealed == "" { - // Answered on this machine, or answered by a node the mesh could not seal to. - // Nothing to write either way, and writing an empty credential file would be - // worse than none: something would read it and fail authenticating. - continue - } - first = append(first, ownedBy(m.SecretsOwner, map[string]any{ - "id": SecretID(to), "type": "file", "path": m.Secrets[to], - "sealed": found.Sealed, - })) } for _, to := range sortedKeys(m.Grants) { for _, g := range with.Grants { @@ -613,6 +619,15 @@ func grantPath(directory, consumer, module string) string { return strings.TrimRight(directory, "/") + "/" + consumer + "." + module + ".secret" } +// holderAs is a consumer's name at the provider with a local name after it, where it keeps several +// credentials for one provision (ADR 0094); the name alone otherwise. +func holderAs(as, local string) string { + if local == "" { + return as + } + return as + "_" + local +} + // contributions collects what every module in this set contributes, by requirement. // // Ordered by contributing module, because the result becomes a file on a machine and a file whose @@ -649,8 +664,11 @@ func (r Resolution) contributions(settings SettingsBy, grants []Grant, } out[g.Provision] = append(out[g.Provision], Contribution{ From: g.From, Node: g.Consumer, At: g.At, Values: g.Values, - As: ConsumerIdentity(g.Consumer, IdentitySource(g.Slug, g.From)), - Secret: grantPath(directories[g.Provision], g.Consumer, g.From), + // One holder per local name: the identity the consumer is known by, and the local name + // after it where the module keeps several (ADR 0094). Not a login any backend checks — + // a secret is not a login — so the identity limit does not apply to the suffix. + As: holderAs(ConsumerIdentity(g.Consumer, IdentitySource(g.Slug, g.From)), g.Local), + Secret: grantPath(directories[g.Provision], g.Consumer, holderAs(g.From, g.Local)), }) if granted[g.Provision] == nil { granted[g.Provision] = map[string]bool{} diff --git a/internal/catalogue/manifest.go b/internal/catalogue/manifest.go index 4976795..01aaea1 100644 --- a/internal/catalogue/manifest.go +++ b/internal/catalogue/manifest.go @@ -278,6 +278,14 @@ type Manifest struct { // makes `restart-on` precise. Secrets map[string]string `json:"secrets,omitempty"` + // SecretsMany is the same key, `secrets`, where a requirement maps to SEVERAL files under local + // names — `"secret": {"admin": "/…/admin", "token": "/…/token"}` — because a module may need + // more than one value from a provider that gives one per pair (novox/hq 04-ISSUES/069, ADR + // 0094). Each local name is a pair credential of its own, keyed on that name, delivered as its + // own file, served to the provider as its own holder, and rotated with the others. Filled from + // the manifest's `secrets` object by UnmarshalJSON; never written by hand. + SecretsMany map[string]map[string]string `json:"-"` + // OwnSecrets are secrets this module needs in order to be itself, and where to put them. // // **Named for whose they are, not how secret they are.** `secrets` above is a credential for @@ -619,6 +627,134 @@ func ReceivedID(requirement string) string { return "received-" + requirement } // // Every problem is reported rather than the first, because somebody writing a manifest fixes // them in one pass or in four. +// manifestFields is Manifest without its methods, so the JSON methods below can use the ordinary +// field decoding for everything but `secrets`. +type manifestFields Manifest + +// UnmarshalJSON reads `secrets` in both of its shapes — a path, or an object of local names to +// paths (ADR 0094) — and everything else exactly as the fields declare, unknown keys refused. +func (m *Manifest) UnmarshalJSON(raw []byte) error { + var keys map[string]json.RawMessage + if err := json.Unmarshal(raw, &keys); err != nil { + return err + } + plain := map[string]string{} + many := map[string]map[string]string{} + if secrets, ok := keys["secrets"]; ok && string(secrets) != "null" { + var byName map[string]json.RawMessage + if err := json.Unmarshal(secrets, &byName); err != nil { + return fmt.Errorf("secrets: an object of requirement to path, or to {local name: path}: %w", err) + } + for to, v := range byName { + switch { + case len(v) > 0 && v[0] == '"': + var path string + if err := json.Unmarshal(v, &path); err != nil { + return err + } + plain[to] = path + case len(v) > 0 && v[0] == '{': + var paths map[string]string + if err := json.Unmarshal(v, &paths); err != nil { + return fmt.Errorf("secrets.%s: an object of local name to path: %w", to, err) + } + many[to] = paths + default: + return fmt.Errorf("secrets.%s: a path, or an object of local name to path, not %s", to, v) + } + } + delete(keys, "secrets") + } + rest, err := json.Marshal(keys) + if err != nil { + return err + } + decoder := json.NewDecoder(bytes.NewReader(rest)) + decoder.DisallowUnknownFields() + var fields manifestFields + if err := decoder.Decode(&fields); err != nil { + return err + } + *m = Manifest(fields) + if len(plain) > 0 { + m.Secrets = plain + } + if len(many) > 0 { + m.SecretsMany = many + } + return nil +} + +// MarshalJSON writes `secrets` back in the shape it was read: paths, and objects of local names. +func (m Manifest) MarshalJSON() ([]byte, error) { + raw, err := json.Marshal(manifestFields(m)) + if err != nil { + return nil, err + } + if len(m.SecretsMany) == 0 { + return raw, nil + } + var keys map[string]json.RawMessage + if err := json.Unmarshal(raw, &keys); err != nil { + return nil, err + } + merged := map[string]any{} + for to, path := range m.Secrets { + merged[to] = path + } + for to, paths := range m.SecretsMany { + merged[to] = paths + } + secrets, err := json.Marshal(merged) + if err != nil { + return nil, err + } + keys["secrets"] = secrets + return json.Marshal(keys) +} + +// SecretFile is one file a module is given a credential in: the local name it goes by inside +// the module (empty for the ordinary one-file case, where the requirement's name serves) and where. +type SecretFile struct { + Local string + Path string +} + +// SecretFiles is every file a module wants the credential for one requirement in, in a stable +// order: the plain path as one entry with no local name, or one entry per local name. +func (m Manifest) SecretFiles(to string) []SecretFile { + if path, ok := m.Secrets[to]; ok { + return []SecretFile{{Path: path}} + } + paths := m.SecretsMany[to] + out := make([]SecretFile, 0, len(paths)) + for _, local := range sortedKeys(paths) { + out = append(out, SecretFile{Local: local, Path: paths[local]}) + } + return out +} + +// SecretRequirements is every requirement this module wants a credential file for, sorted. +func (m Manifest) SecretRequirements() []string { + seen := map[string]bool{} + for to := range m.Secrets { + seen[to] = true + } + for to := range m.SecretsMany { + seen[to] = true + } + return sortedKeys(seen) +} + +// SecretLocal is the name a credential goes by inside the module: the local name where the +// requirement maps to several, else the requirement itself. It is what `${secret:}` says. +func SecretLocal(to, local string) string { + if local == "" { + return to + } + return local +} + func ParseManifest(raw []byte) (Manifest, error) { var m Manifest // Strictly. **An unknown key is refused**, which is the discipline the host's declaration @@ -908,11 +1044,47 @@ func ParseManifest(raw []byte) (Manifest, error) { problems = append(problems, m.Module+" needs a secret with no name") } } - for to, where := range m.Secrets { - if !strings.HasPrefix(where, "/") { - problems = append(problems, fmt.Sprintf( - "%s keeps the credential for %q at %q, which is not an absolute path", - m.Module, to, where)) + for _, to := range m.SecretRequirements() { + if _, plain := m.Secrets[to]; plain { + if _, also := m.SecretsMany[to]; also { + problems = append(problems, fmt.Sprintf( + "%s keeps the credential for %q both as one file and as several", m.Module, to)) + } + } + for _, f := range m.SecretFiles(to) { + if !strings.HasPrefix(f.Path, "/") { + problems = append(problems, fmt.Sprintf( + "%s keeps the credential for %q at %q, which is not an absolute path", + m.Module, SecretLocal(to, f.Local), f.Path)) + } + if f.Local != "" && !name.MatchString(f.Local) { + problems = append(problems, fmt.Sprintf( + "%s keeps a credential for %q under %q, which is not a usable name", + m.Module, to, f.Local)) + } + // A local name is what `${secret:}` says, so it may not be another requirement's + // name or one of the module's own secrets — the file would hold the wrong credential + // while every check passed. + if f.Local != "" { + if _, own := m.OwnSecrets[f.Local]; own { + problems = append(problems, fmt.Sprintf( + "%s keeps a credential for %q under %q, which is also one of its own secrets", + m.Module, to, f.Local)) + } + for _, w := range m.Wants() { + if w == f.Local { + problems = append(problems, fmt.Sprintf( + "%s keeps a credential for %q under %q, which is also something it requires", + m.Module, to, f.Local)) + } + } + } + } + if len(m.SecretsMany[to]) == 0 && m.Secrets[to] == "" { + if _, many := m.SecretsMany[to]; many { + problems = append(problems, fmt.Sprintf( + "%s keeps the credential for %q as several files and names none", m.Module, to)) + } } var wanted bool for _, w := range m.Wants() { @@ -1119,8 +1291,10 @@ func (m Manifest) undeclaredMounts() []string { for _, where := range m.OwnSecrets { claim(where) } - for _, where := range m.Secrets { - claim(where) + for _, to := range m.SecretRequirements() { + for _, f := range m.SecretFiles(to) { + claim(f.Path) + } } for _, where := range m.Receives { claim(where) diff --git a/internal/catalogue/resolve.go b/internal/catalogue/resolve.go index 55c00b2..319684b 100644 --- a/internal/catalogue/resolve.go +++ b/internal/catalogue/resolve.go @@ -157,6 +157,10 @@ type Needed struct { Sealed string // For is the module that wanted it. For string + // Local is the name this credential goes by inside that module, where the module wants several + // for one requirement (ADR 0094); empty for the ordinary one. Part of what identifies the pair + // credential, so two secrets from one provider to one module are two secrets. + Local string // Manager is set when this holder is a refreshable-grant licence's MANAGER, delivered the refresh // token rather than an access token (novox/hq ADR 0050). It changes one thing downstream: an empty // Sealed is tolerated — the manager has not adopted a refresh token yet, which is a real waiting @@ -298,7 +302,7 @@ func Resolve(catalogue map[string]Manifest, assigned []string, node Node, world if at == "" { at = "127.0.0.1" } - needs = append(needs, Needed{ + needs = eachLocal(needs, catalogue, Needed{ Name: want, From: node.Name, At: at, Serves: servedHere(catalogue, chosen, want), For: because[want]}) } else if served := servedHere(catalogue, chosen, want); len(served) > 0 { @@ -319,7 +323,7 @@ func Resolve(catalogue map[string]Manifest, assigned []string, node Node, world if at == "" { at = "127.0.0.1" } - needs = append(needs, Needed{ + needs = eachLocal(needs, catalogue, Needed{ Name: want, From: node.Name, At: at, Serves: served, For: because[want]}) } continue @@ -347,7 +351,7 @@ func Resolve(catalogue map[string]Manifest, assigned []string, node Node, world node.Name, want, p.Node, meshNetwork)) return } - needs = append(needs, Needed{Name: want, From: p.Node, At: p.At, + needs = eachLocal(needs, catalogue, Needed{Name: want, From: p.Node, At: p.At, Serves: p.Serves, For: because[want]}) } switch { @@ -854,3 +858,19 @@ func perConsumer(needs []Needed, order []string, catalogue map[string]Manifest) } return out } + +// eachLocal appends the need once per file the wanting module keeps the credential in: once, with +// no local name, in the ordinary case; once per local name where the module wants several values +// from one provider (ADR 0094). Each is its own pair credential downstream. +func eachLocal(needs []Needed, catalogue map[string]Manifest, n Needed) []Needed { + files := catalogue[n.For].SecretFiles(n.Name) + if len(files) <= 1 { + return append(needs, n) + } + for _, f := range files { + one := n + one.Local = f.Local + needs = append(needs, one) + } + return needs +} diff --git a/internal/catalogue/secrets_into_files.go b/internal/catalogue/secrets_into_files.go index f47656c..5b1eb97 100644 --- a/internal/catalogue/secrets_into_files.go +++ b/internal/catalogue/secrets_into_files.go @@ -61,23 +61,26 @@ func sealedFor(m Manifest, needs []Needed, with Rendering) (map[string]string, e sealed[name] = value } } - for _, to := range sortedKeys(m.Secrets) { - if _, taken := sealed[to]; taken { - // A module whose own secret and whose requirement share a name. Refused rather than - // settled by precedence: whichever won, the manifest would read as though the other - // had, and the file would hold the credential for the wrong thing while every check - // passed. - return nil, fmt.Errorf( - "%s has a secret of its own called %q and also requires %q, so a file saying "+ - "${secret:%s} could mean either — rename one of them", m.Module, to, to, to) - } - for i := range needs { - // `For == m.Module`, not name alone: on a node with two modules requiring the same - // provision, both appear in `needs`, and matching by name would fill ${secret:X} with - // whichever came last — the other module's credential (novox/hq 04-ISSUES/022). The - // `secrets:`-map path already guards this way; the ${secret:…} placeholder path did not. - if needs[i].Name == to && needs[i].For == m.Module && needs[i].Sealed != "" { - sealed[to] = needs[i].Sealed + for _, to := range m.SecretRequirements() { + for _, file := range m.SecretFiles(to) { + key := SecretLocal(to, file.Local) + if _, taken := sealed[key]; taken { + // A module whose own secret and whose requirement share a name. Refused rather than + // settled by precedence: whichever won, the manifest would read as though the other + // had, and the file would hold the credential for the wrong thing while every check + // passed. + return nil, fmt.Errorf( + "%s has a secret of its own called %q and also requires %q, so a file saying "+ + "${secret:%s} could mean either — rename one of them", m.Module, key, key, key) + } + for i := range needs { + // `For == m.Module`, not name alone: on a node with two modules requiring the same + // provision, both appear in `needs`, and matching by name would fill ${secret:X} with + // whichever came last — the other module's credential (novox/hq 04-ISSUES/022). And + // the local name, where the module keeps several (ADR 0094). + if needs[i].Name == to && needs[i].For == m.Module && needs[i].Local == file.Local && needs[i].Sealed != "" { + sealed[key] = needs[i].Sealed + } } } } diff --git a/internal/catalogue/several_secrets_test.go b/internal/catalogue/several_secrets_test.go new file mode 100644 index 0000000..71acf22 --- /dev/null +++ b/internal/catalogue/several_secrets_test.go @@ -0,0 +1,125 @@ +package catalogue + +import ( + "encoding/json" + "strings" + "testing" +) + +// A module may need several values from one provider that gives one per pair (novox/hq +// 04-ISSUES/069, ADR 0094): `secrets` maps a requirement to several files under local names, and +// each local name is a pair credential of its own — its own need, its own file, its own holder. + +const twoSecrets = `{"module":"ca","version":"1","requires":["secret"], + "secrets":{"secret":{"root-key":"/var/lib/ca/root.key","root-pass":"/var/lib/ca/root.pass"}}, + "resources":[{"id":"state","type":"directory","path":"/var/lib/ca","mode":"0700"}]}` + +func TestSecretsReadBothShapesAndWriteThemBack(t *testing.T) { + m, err := ParseManifest([]byte(twoSecrets)) + if err != nil { + t.Fatal(err) + } + files := m.SecretFiles("secret") + if len(files) != 2 || files[0].Local != "root-key" || files[1].Path != "/var/lib/ca/root.pass" { + t.Fatalf("two files under local names, in order: %+v", files) + } + plain, err := ParseManifest([]byte(`{"module":"app","version":"1","requires":["secret"],"secrets":{"secret":"/var/lib/app/secret"}}`)) + if err != nil { + t.Fatal(err) + } + if got := plain.SecretFiles("secret"); len(got) != 1 || got[0].Local != "" || got[0].Path != "/var/lib/app/secret" { + t.Fatalf("the plain shape is one file with no local name: %+v", got) + } + // Written back in the shape it was read, so a built manifest keeps its local names. + raw, err := json.Marshal(m) + if err != nil { + t.Fatal(err) + } + again, err := ParseManifest(raw) + if err != nil { + t.Fatalf("what was written does not read: %v\n%s", err, raw) + } + if len(again.SecretFiles("secret")) != 2 { + t.Fatalf("the local names did not survive a round trip:\n%s", raw) + } +} + +func TestALocalNameMayNotCollideWithWhatTheModuleAlreadyCallsSomething(t *testing.T) { + for _, bad := range []string{ + // One of the module's own secrets. + `{"module":"ca","version":"1","requires":["secret"],"own-secrets":{"root-key":"/var/lib/ca/own"}, + "secrets":{"secret":{"root-key":"/var/lib/ca/root.key"}}}`, + // Something it requires. + `{"module":"ca","version":"1","requires":["secret","postgres-database"], + "secrets":{"secret":{"postgres-database":"/var/lib/ca/x"}}}`, + // Not a usable name. + `{"module":"ca","version":"1","requires":["secret"],"secrets":{"secret":{"Root Key":"/var/lib/ca/x"}}}`, + // A relative path. + `{"module":"ca","version":"1","requires":["secret"],"secrets":{"secret":{"root-key":"root.key"}}}`, + } { + if _, err := ParseManifest([]byte(bad)); err == nil { + t.Errorf("accepted:\n%s", bad) + } + } +} + +func vaultAndCA() map[string]Manifest { + ca, _ := ParseManifest([]byte(twoSecrets)) + vault := Manifest{Module: "mesh-vault", Version: "1", Provides: FromAnywhere("secret"), + Grants: map[string]string{"secret": "/var/lib/vault/grants"}, + Receives: map[string]string{"secret": "/var/lib/vault/grants/mesh.json"}} + return shelf(vault, ca) +} + +func TestEachLocalNameIsANeedAFileAndAHolderOfItsOwn(t *testing.T) { + got, err := Resolve(vaultAndCA(), []string{"mesh-vault", "ca"}, workstation(), World{}) + if err != nil { + t.Fatal(err) + } + var locals []string + for _, n := range got.Needs { + if n.Name == "secret" && n.For == "ca" { + locals = append(locals, n.Local) + } + } + if strings.Join(locals, ",") != "root-key,root-pass" { + t.Fatalf("two secrets from one provider are two needs: %v", got.Needs) + } + for i := range got.Needs { + got.Needs[i].Sealed = "sealed-" + got.Needs[i].Local + } + out, err := got.Declaration(Rendering{}) + if err != nil { + t.Fatal(err) + } + seen := map[string]string{} + for _, r := range out { + if r["type"] == "file" && strings.HasPrefix(r["path"].(string), "/var/lib/ca/root.") { + seen[r["id"].(string)] = r["sealed"].(string) + } + } + if seen["ca."+SecretID("root-key")] != "sealed-root-key" || seen["ca."+SecretID("root-pass")] != "sealed-root-pass" { + t.Fatalf("each local name is its own file with its own credential: %v", seen) + } +} + +func TestAProviderSeesEachLocalNameAsAHolderOfItsOwn(t *testing.T) { + r := Resolution{Modules: []Manifest{vaultAndCA()["mesh-vault"]}} + got, err := r.contributions(SettingsBy{}, []Grant{ + {Provision: "secret", Consumer: "workstation", From: "ca", Local: "root-key", Sealed: "x"}, + {Provision: "secret", Consumer: "workstation", From: "ca", Local: "root-pass", Sealed: "y"}, + }, map[string]string{"secret": "/var/lib/vault/grants"}) + if err != nil { + t.Fatal(err) + } + given := got["secret"] + if len(given) != 2 { + t.Fatalf("two holders: %+v", given) + } + if given[0].As != "mesh_workstation_ca_root_key" && given[0].As != "mesh_workstation_ca_root-key" { + t.Fatalf("the holder is the consumer's identity with the local name after it: %q", given[0].As) + } + if given[0].Secret == given[1].Secret { + t.Fatalf("two holders share one file on the provider: %q", given[0].Secret) + } +} diff --git a/internal/inventory/migrations/0027-a-module-may-hold-several-secrets-from-one-provider.sql b/internal/inventory/migrations/0027-a-module-may-hold-several-secrets-from-one-provider.sql new file mode 100644 index 0000000..b86832d --- /dev/null +++ b/internal/inventory/migrations/0027-a-module-may-hold-several-secrets-from-one-provider.sql @@ -0,0 +1,12 @@ +-- A module may need several values from one provider that gives one per pair +-- (novox/hq 04-ISSUES/069, ADR 0094). +-- +-- A pair credential was keyed on (provision, consumer node, consumer module, provider): one value +-- per module per provider. Seven catalogue modules hold two to four independent secrets of their +-- own -- a root certificate, its key and that key's password -- and the vault could serve each +-- module one. The pair now carries the LOCAL name the credential goes by inside the module; empty +-- for the ordinary one, so every existing row is the credential it was. + +alter table secret add column local text not null default ''; +alter table secret drop constraint secret_pkey; +alter table secret add primary key (name, local, consumer, consumer_module, provider); diff --git a/internal/inventory/operator_test.go b/internal/inventory/operator_test.go index 4db167a..e4179e9 100644 --- a/internal/inventory/operator_test.go +++ b/internal/inventory/operator_test.go @@ -164,7 +164,7 @@ func TestAPairCredentialIsSealedToTheOperatorToo(t *testing.T) { if _, err := inv.SetOperatorKey(ctx, pub); err != nil { t.Fatal(err) } - made, err := inv.SecretFor(ctx, "secret", "consumer", "gitea", "provider") + made, err := inv.SecretFor(ctx, "secret", "consumer", "gitea", "provider", "") if err != nil { t.Fatal(err) } @@ -191,7 +191,7 @@ func TestAPairCredentialIsSealedToTheOperatorToo(t *testing.T) { // A second provider of the same provision: two rows, refused rather than the first one taken, // unless the provider is named. And replacing the key counts pair credentials as orphaned. - if _, err := inv.SecretFor(ctx, "secret", "consumer", "gitea", "consumer"); err != nil { + if _, err := inv.SecretFor(ctx, "secret", "consumer", "gitea", "consumer", ""); err != nil { t.Fatal(err) } if _, err := inv.KeptSecret(ctx, "consumer", "gitea", "secret", ""); err == nil || !strings.Contains(err.Error(), "--provider") { diff --git a/internal/inventory/secrets.go b/internal/inventory/secrets.go index f605070..1364e13 100644 --- a/internal/inventory/secrets.go +++ b/internal/inventory/secrets.go @@ -24,11 +24,14 @@ type Secret struct { // **Part of the key, not a label** (novox/hq 04-ISSUES/022). Two modules on one node wanting // the same provision are two consumers, and were one credential until this. ConsumerModule string - Provider string - ForConsumer string - ForProvider string - ConsumerKey string - ProviderKey string + // Local is the name the credential goes by inside the consumer where it keeps several for one + // provision (novox/hq ADR 0094); empty for the ordinary one. Part of the key. + Local string + Provider string + ForConsumer string + ForProvider string + ConsumerKey string + ProviderKey string // Origin is `made` — the mesh generated it — or `accepted` — a person supplied it, for // something outside the mesh, and the mesh cannot make another (novox/hq 04-ISSUES/070). Origin string @@ -51,7 +54,7 @@ const ( // can no longer open what was sealed to the old one, so keeping the blob would deliver something // unreadable for ever. The new secret reaches both ends in the same push, which is the only // moment they can be changed together. -func (i *Inventory) SecretFor(ctx context.Context, name, consumer, consumerModule, provider string) ( +func (i *Inventory) SecretFor(ctx context.Context, name, consumer, consumerModule, provider, local string) ( Secret, error) { consumerKey, err := i.SealingKeyOf(ctx, consumer) if err != nil { @@ -74,12 +77,12 @@ func (i *Inventory) SecretFor(ctx context.Context, name, consumer, consumerModul 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`, - name, consumerNode.ID, consumerModule, providerNode.ID). + where name = $1 and consumer = $2 and consumer_module = $3 and provider = $4 and local = $5`, + name, consumerNode.ID, consumerModule, providerNode.ID, local). Scan(&held.ForConsumer, &held.ForProvider, &held.ConsumerKey, &held.ProviderKey, &held.Origin) if err == nil && held.ConsumerKey == consumerKey && held.ProviderKey == providerKey { held.Name, held.Consumer, held.Provider = name, consumer, provider - held.ConsumerModule = consumerModule + held.ConsumerModule, held.Local = consumerModule, local return held, nil } if err == nil && held.Origin == OriginAccepted { @@ -90,8 +93,8 @@ func (i *Inventory) SecretFor(ctx context.Context, name, consumer, consumerModul return Secret{}, fmt.Errorf( "%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`", - consumerModule, name, provider, consumer, consumerModule, name, provider) + "again with `secret accept %s %s %s --provider %s%s`", + consumerModule, name, provider, consumer, consumerModule, name, provider, localFlag(local)) } // And to the operator, when the mesh has one (novox/hq ADR 0085, amended): the third copy that @@ -107,19 +110,19 @@ func (i *Inventory) SecretFor(ctx context.Context, name, consumer, consumerModul forOperator, operatorKey := operatorColumns(operator, blob) _, err = i.store.Pool().Exec(ctx, `insert into secret (name, consumer, consumer_module, provider, for_consumer, for_provider, - consumer_key, provider_key, operator_sealed, operator_key) - values ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) - on conflict (name, consumer, consumer_module, provider) do update set + consumer_key, provider_key, operator_sealed, operator_key, local) + values ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) + on conflict (name, local, consumer, consumer_module, provider) do update set for_consumer = excluded.for_consumer, for_provider = excluded.for_provider, consumer_key = excluded.consumer_key, provider_key = excluded.provider_key, created_at = now(), operator_sealed = excluded.operator_sealed, operator_key = excluded.operator_key`, name, consumerNode.ID, consumerModule, providerNode.ID, - made.ForConsumer, made.ForProvider, made.ConsumerKey, made.ProviderKey, forOperator, operatorKey) + made.ForConsumer, made.ForProvider, made.ConsumerKey, made.ProviderKey, forOperator, operatorKey, local) if err != nil { return Secret{}, err } - return Secret{Name: name, Consumer: consumer, ConsumerModule: consumerModule, + return Secret{Name: name, Consumer: consumer, ConsumerModule: consumerModule, Local: local, Provider: provider, ForConsumer: made.ForConsumer, ForProvider: made.ForProvider, ConsumerKey: made.ConsumerKey, ProviderKey: made.ProviderKey, Origin: OriginMade}, nil @@ -133,7 +136,7 @@ func (i *Inventory) SecretFor(ctx context.Context, name, consumer, consumerModul // person can supply. It is the counterpart to AcceptSecretForModule for a module's own secret; // what differs is that both ends of the pair are sealed to, and that the record says `accepted` // so a later read never replaces it with a minted one. The plaintext is discarded here. -func (i *Inventory) AcceptSecretForPair(ctx context.Context, name, consumer, consumerModule, provider, value string) error { +func (i *Inventory) AcceptSecretForPair(ctx context.Context, name, consumer, consumerModule, provider, local, value string) error { consumerKey, err := i.SealingKeyOf(ctx, consumer) if err != nil { return err @@ -165,16 +168,16 @@ func (i *Inventory) AcceptSecretForPair(ctx context.Context, name, consumer, con } _, err = i.store.Pool().Exec(ctx, `insert into secret (name, consumer, consumer_module, provider, for_consumer, for_provider, - consumer_key, provider_key, operator_sealed, operator_key, origin) - values ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) - on conflict (name, consumer, consumer_module, provider) do update set + consumer_key, provider_key, operator_sealed, operator_key, origin, local) + values ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12) + on conflict (name, local, consumer, consumer_module, provider) do update set for_consumer = excluded.for_consumer, for_provider = excluded.for_provider, consumer_key = excluded.consumer_key, provider_key = excluded.provider_key, created_at = now(), origin = excluded.origin, operator_sealed = excluded.operator_sealed, operator_key = excluded.operator_key`, name, consumerNode.ID, consumerModule, providerNode.ID, sealed.ForConsumer, sealed.ForProvider, sealed.ConsumerKey, sealed.ProviderKey, - forOperator, operatorKey, OriginAccepted) + forOperator, operatorKey, OriginAccepted, local) return err } @@ -190,7 +193,7 @@ func (i *Inventory) AcceptSecretForPair(ctx context.Context, name, consumer, con // **An accepted credential is not rotated.** The mesh did not make it and cannot make its // replacement; deleting it would have the next read mint one, which is exactly the wrong value // delivered with the mesh insisting it was (novox/hq 04-ISSUES/070). Refused, and the remedy named. -func (i *Inventory) RotateSecret(ctx context.Context, name, consumer, consumerModule, provider string) error { +func (i *Inventory) RotateSecret(ctx context.Context, name, consumer, consumerModule, provider, local string) error { consumerNode, err := i.NodeByName(ctx, consumer) if err != nil { return err @@ -202,19 +205,19 @@ func (i *Inventory) RotateSecret(ctx context.Context, name, consumer, consumerMo var origin string err = i.store.Pool().QueryRow(ctx, `select origin from secret where name = $1 and consumer = $2 and consumer_module = $3 - and provider = $4`, - name, consumerNode.ID, consumerModule, providerNode.ID).Scan(&origin) + and provider = $4 and local = $5`, + name, consumerNode.ID, consumerModule, providerNode.ID, local).Scan(&origin) if err == nil && origin == OriginAccepted { return fmt.Errorf( "%s's %q credential from %s was accepted from a person, and the mesh cannot make "+ "its replacement. Accept the new value instead: `secret accept %s %s %s "+ - "--provider %s --from `", - consumerModule, name, provider, consumer, consumerModule, name, provider) + "--provider %s%s --from `", + consumerModule, name, provider, consumer, consumerModule, name, provider, localFlag(local)) } _, err = i.store.Pool().Exec(ctx, `delete from secret where name = $1 and consumer = $2 and consumer_module = $3 - and provider = $4`, - name, consumerNode.ID, consumerModule, providerNode.ID) + and provider = $4 and local = $5`, + name, consumerNode.ID, consumerModule, providerNode.ID, local) return err } @@ -225,9 +228,9 @@ func (i *Inventory) SecretsFrom(ctx context.Context, provider string) ([]Secret, return nil, err } rows, err := i.store.Pool().Query(ctx, - `select s.name, c.name, s.consumer_module, s.for_provider from secret s + `select s.name, c.name, s.consumer_module, s.local, s.for_provider from secret s join node c on c.id = s.consumer - where s.provider = $1 order by s.name, c.name, s.consumer_module`, providerNode.ID) + where s.provider = $1 order by s.name, c.name, s.consumer_module, s.local`, providerNode.ID) if err != nil { return nil, err } @@ -236,7 +239,7 @@ func (i *Inventory) SecretsFrom(ctx context.Context, provider string) ([]Secret, var out []Secret for rows.Next() { s := Secret{Provider: provider} - if err := rows.Scan(&s.Name, &s.Consumer, &s.ConsumerModule, &s.ForProvider); err != nil { + if err := rows.Scan(&s.Name, &s.Consumer, &s.ConsumerModule, &s.Local, &s.ForProvider); err != nil { return nil, err } out = append(out, s) @@ -401,7 +404,9 @@ type Holder struct { // ConsumerModule is which module on that machine holds it. Part of what identifies a // credential (novox/hq 04-ISSUES/022), so rotating one consumer's does not touch another's. ConsumerModule string - Provider string + // Local is the credential's name inside the consumer where it holds several (ADR 0094). + Local string + Provider string } // HoldersOf is every pair sharing a credential for one provision. @@ -415,11 +420,11 @@ type Holder struct { // Empty consumer means all of them. func (i *Inventory) HoldersOf(ctx context.Context, provision, consumer string) ([]Holder, error) { rows, err := i.store.Pool().Query(ctx, - `select s.name, c.name, s.consumer_module, p.name from secret s + `select s.name, c.name, s.consumer_module, s.local, p.name from secret s join node c on c.id = s.consumer join node p on p.id = s.provider where s.name = $1 and ($2 = '' or c.name = $2) - order by c.name, s.consumer_module, p.name`, provision, consumer) + order by c.name, s.consumer_module, s.local, p.name`, provision, consumer) if err != nil { return nil, err } @@ -428,10 +433,18 @@ func (i *Inventory) HoldersOf(ctx context.Context, provision, consumer string) ( var out []Holder for rows.Next() { var h Holder - if err := rows.Scan(&h.Provision, &h.Consumer, &h.ConsumerModule, &h.Provider); err != nil { + if err := rows.Scan(&h.Provision, &h.Consumer, &h.ConsumerModule, &h.Local, &h.Provider); err != nil { return nil, err } out = append(out, h) } return out, rows.Err() } + +// localFlag is the `--local` a remedy has to name where a credential has a local name. +func localFlag(local string) string { + if local == "" { + return "" + } + return " --local " + local +} diff --git a/internal/inventory/secrets_test.go b/internal/inventory/secrets_test.go index 07c6a46..b6e015d 100644 --- a/internal/inventory/secrets_test.go +++ b/internal/inventory/secrets_test.go @@ -63,11 +63,11 @@ func TestASecretIsMadeOnceAndKept(t *testing.T) { // Regenerating on every declaration would restart both ends on every push, and — worse — the // password a provider was told to create would never be the one its consumer was given. inv, ctx := twoNodesWithKeys(t) - first, err := inv.SecretFor(ctx, "postgres-database", "consumer", "gitea", "provider") + first, err := inv.SecretFor(ctx, "postgres-database", "consumer", "gitea", "provider", "") if err != nil { t.Fatal(err) } - second, err := inv.SecretFor(ctx, "postgres-database", "consumer", "gitea", "provider") + second, err := inv.SecretFor(ctx, "postgres-database", "consumer", "gitea", "provider", "") if err != nil { t.Fatal(err) } @@ -81,7 +81,7 @@ func TestTheStoredSecretIsNotTheSecret(t *testing.T) { // what an encrypted column does not achieve, because whoever runs the control plane can read // through it. inv, ctx := twoNodesWithKeys(t) - got, err := inv.SecretFor(ctx, "postgres-database", "consumer", "gitea", "provider") + got, err := inv.SecretFor(ctx, "postgres-database", "consumer", "gitea", "provider", "") if err != nil { t.Fatal(err) } @@ -114,7 +114,7 @@ func TestANewSealingKeyMeansANewSecret(t *testing.T) { // A node that rejoined generated a new key and can no longer open what was sealed to the old // one. Keeping the blob would deliver something unreadable for ever, reported as configured. inv, ctx := twoNodesWithKeys(t) - before, err := inv.SecretFor(ctx, "postgres-database", "consumer", "gitea", "provider") + before, err := inv.SecretFor(ctx, "postgres-database", "consumer", "gitea", "provider", "") if err != nil { t.Fatal(err) } @@ -126,7 +126,7 @@ func TestANewSealingKeyMeansANewSecret(t *testing.T) { if err := inv.RecordSealingKey(ctx, node.ID, fresh); err != nil { t.Fatal(err) } - after, err := inv.SecretFor(ctx, "postgres-database", "consumer", "gitea", "provider") + after, err := inv.SecretFor(ctx, "postgres-database", "consumer", "gitea", "provider", "") if err != nil { t.Fatal(err) } @@ -142,14 +142,14 @@ func TestANewSealingKeyMeansANewSecret(t *testing.T) { func TestRotatingReachesBothEnds(t *testing.T) { inv, ctx := twoNodesWithKeys(t) - before, err := inv.SecretFor(ctx, "postgres-database", "consumer", "gitea", "provider") + before, err := inv.SecretFor(ctx, "postgres-database", "consumer", "gitea", "provider", "") if err != nil { t.Fatal(err) } - if err := inv.RotateSecret(ctx, "postgres-database", "consumer", "gitea", "provider"); err != nil { + if err := inv.RotateSecret(ctx, "postgres-database", "consumer", "gitea", "provider", ""); err != nil { t.Fatal(err) } - after, err := inv.SecretFor(ctx, "postgres-database", "consumer", "gitea", "provider") + after, err := inv.SecretFor(ctx, "postgres-database", "consumer", "gitea", "provider", "") if err != nil { t.Fatal(err) } @@ -184,7 +184,7 @@ func TestAProviderIsToldEveryCredentialItMustCreate(t *testing.T) { t.Fatal(err) } for _, who := range []string{"consumer", "second-consumer"} { - if _, err := inv.SecretFor(ctx, "postgres-database", who, "gitea", "provider"); err != nil { + if _, err := inv.SecretFor(ctx, "postgres-database", who, "gitea", "provider", ""); err != nil { t.Fatal(err) } } @@ -215,7 +215,7 @@ func TestANodeWithNoSealingKeyCannotBeGivenASecret(t *testing.T) { t.Fatal(err) } } - _, err := inv.SecretFor(ctx, "postgres-database", "consumer", "gitea", "provider") + _, err := inv.SecretFor(ctx, "postgres-database", "consumer", "gitea", "provider", "") if err == nil { t.Fatal("a credential was made for nodes that cannot open one") } @@ -226,7 +226,7 @@ func TestANodeWithNoSealingKeyCannotBeGivenASecret(t *testing.T) { func TestSecretsGoWhenANodeLeaves(t *testing.T) { inv, ctx := twoNodesWithKeys(t) - if _, err := inv.SecretFor(ctx, "postgres-database", "consumer", "gitea", "provider"); err != nil { + if _, err := inv.SecretFor(ctx, "postgres-database", "consumer", "gitea", "provider", ""); err != nil { t.Fatal(err) } if _, err := inv.store.Pool().Exec(ctx, `delete from node where name = 'consumer'`); err != nil { @@ -349,7 +349,7 @@ func TestACredentialGoesWhenTheConsumerStopsAskingForIt(t *testing.T) { if err := inv.Assign(ctx, "consumer", "meshboard"); err != nil { t.Fatal(err) } - if _, err := inv.SecretFor(ctx, "postgres-database", "consumer", "gitea", "provider"); err != nil { + if _, err := inv.SecretFor(ctx, "postgres-database", "consumer", "gitea", "provider", ""); err != nil { t.Fatal(err) } @@ -379,7 +379,7 @@ func TestACredentialGoesWhenEitherMachineDoes(t *testing.T) { // The case that must not leave a live login behind: a machine removed from the mesh. Its // credentials go with it, and the provider stops being told to keep them. inv, ctx := twoNodesWithKeys(t) - if _, err := inv.SecretFor(ctx, "postgres-database", "consumer", "gitea", "provider"); err != nil { + if _, err := inv.SecretFor(ctx, "postgres-database", "consumer", "gitea", "provider", ""); err != nil { t.Fatal(err) } if _, err := inv.store.Pool().Exec(ctx, `delete from node where name = 'consumer'`); err != nil { @@ -473,12 +473,12 @@ func TestEveryHolderOfACredentialCanBeNamed(t *testing.T) { t.Fatal(err) } for _, consumer := range []string{"consumer", "third"} { - if _, err := inv.SecretFor(ctx, "postgres-database", consumer, "gitea", "provider"); err != nil { + if _, err := inv.SecretFor(ctx, "postgres-database", consumer, "gitea", "provider", ""); err != nil { t.Fatal(err) } } // And one for a different provision, which must not be swept up. - if _, err := inv.SecretFor(ctx, "cache", "consumer", "gitea", "provider"); err != nil { + if _, err := inv.SecretFor(ctx, "cache", "consumer", "gitea", "provider", ""); err != nil { t.Fatal(err) } @@ -509,14 +509,14 @@ func TestEveryHolderOfACredentialCanBeNamed(t *testing.T) { // And rotating gives both ends a new credential, together — the same one. func TestRotatingGivesBothEndsTheSameNewCredential(t *testing.T) { inv, ctx := twoNodesWithKeys(t) - before, err := inv.SecretFor(ctx, "postgres-database", "consumer", "gitea", "provider") + before, err := inv.SecretFor(ctx, "postgres-database", "consumer", "gitea", "provider", "") if err != nil { t.Fatal(err) } - if err := inv.RotateSecret(ctx, "postgres-database", "consumer", "gitea", "provider"); err != nil { + if err := inv.RotateSecret(ctx, "postgres-database", "consumer", "gitea", "provider", ""); err != nil { t.Fatal(err) } - after, err := inv.SecretFor(ctx, "postgres-database", "consumer", "gitea", "provider") + after, err := inv.SecretFor(ctx, "postgres-database", "consumer", "gitea", "provider", "") if err != nil { t.Fatal(err) } @@ -543,14 +543,14 @@ func TestRotatingGivesBothEndsTheSameNewCredential(t *testing.T) { if err := inv.RecordSealingKey(ctx, third.ID, key); err != nil { t.Fatal(err) } - untouched, err := inv.SecretFor(ctx, "postgres-database", "third", "gitea", "provider") + untouched, err := inv.SecretFor(ctx, "postgres-database", "third", "gitea", "provider", "") if err != nil { t.Fatal(err) } - if err := inv.RotateSecret(ctx, "postgres-database", "consumer", "gitea", "provider"); err != nil { + if err := inv.RotateSecret(ctx, "postgres-database", "consumer", "gitea", "provider", ""); err != nil { t.Fatal(err) } - again, err := inv.SecretFor(ctx, "postgres-database", "third", "gitea", "provider") + again, err := inv.SecretFor(ctx, "postgres-database", "third", "gitea", "provider", "") if err != nil { t.Fatal(err) } @@ -565,17 +565,17 @@ func TestRotatingGivesBothEndsTheSameNewCredential(t *testing.T) { // because it cannot make the replacement. func TestAnAcceptedPairCredentialIsKeptAndNeverRemade(t *testing.T) { inv, ctx := twoNodesWithKeys(t) - if err := inv.AcceptSecretForPair(ctx, "secret", "consumer", "gitea", "provider", "hunter2"); err != nil { + if err := inv.AcceptSecretForPair(ctx, "secret", "consumer", "gitea", "provider", "", "hunter2"); err != nil { t.Fatal(err) } - got, err := inv.SecretFor(ctx, "secret", "consumer", "gitea", "provider") + got, err := inv.SecretFor(ctx, "secret", "consumer", "gitea", "provider", "") if err != nil { t.Fatal(err) } if got.Origin != OriginAccepted { t.Fatalf("an accepted credential reads back as %q", got.Origin) } - again, err := inv.SecretFor(ctx, "secret", "consumer", "gitea", "provider") + again, err := inv.SecretFor(ctx, "secret", "consumer", "gitea", "provider", "") if err != nil { t.Fatal(err) } @@ -584,15 +584,15 @@ func TestAnAcceptedPairCredentialIsKeptAndNeverRemade(t *testing.T) { } // Rotation is refused, and says what to do instead. - err = inv.RotateSecret(ctx, "secret", "consumer", "gitea", "provider") + err = inv.RotateSecret(ctx, "secret", "consumer", "gitea", "provider", "") if err == nil || !strings.Contains(err.Error(), "secret accept") { t.Fatalf("rotating an accepted credential was not refused with the remedy: %v", err) } // And a made one still rotates. - if _, err := inv.SecretFor(ctx, "postgres-database", "consumer", "gitea", "provider"); err != nil { + if _, err := inv.SecretFor(ctx, "postgres-database", "consumer", "gitea", "provider", ""); err != nil { t.Fatal(err) } - if err := inv.RotateSecret(ctx, "postgres-database", "consumer", "gitea", "provider"); err != nil { + if err := inv.RotateSecret(ctx, "postgres-database", "consumer", "gitea", "provider", ""); err != nil { t.Fatalf("a made credential no longer rotates: %v", err) } } @@ -601,7 +601,7 @@ func TestAnAcceptedPairCredentialIsKeptAndNeverRemade(t *testing.T) { // re-seal what it does not hold: refused aloud, never quietly replaced by a minted one. func TestAnAcceptedPairCredentialIsNotRemadeWhenAKeyChanges(t *testing.T) { inv, ctx := twoNodesWithKeys(t) - if err := inv.AcceptSecretForPair(ctx, "secret", "consumer", "gitea", "provider", "hunter2"); err != nil { + if err := inv.AcceptSecretForPair(ctx, "secret", "consumer", "gitea", "provider", "", "hunter2"); err != nil { t.Fatal(err) } node, err := inv.NodeByName(ctx, "consumer") @@ -612,15 +612,64 @@ func TestAnAcceptedPairCredentialIsNotRemadeWhenAKeyChanges(t *testing.T) { if err := inv.RecordSealingKey(ctx, node.ID, fresh); err != nil { t.Fatal(err) } - _, err = inv.SecretFor(ctx, "secret", "consumer", "gitea", "provider") + _, err = inv.SecretFor(ctx, "secret", "consumer", "gitea", "provider", "") if err == nil || !strings.Contains(err.Error(), "accept it again") { t.Fatalf("an accepted credential was remade, or refused without the remedy: %v", err) } // Accepting it again is the remedy, and it works. - if err := inv.AcceptSecretForPair(ctx, "secret", "consumer", "gitea", "provider", "hunter3"); err != nil { + if err := inv.AcceptSecretForPair(ctx, "secret", "consumer", "gitea", "provider", "", "hunter3"); err != nil { t.Fatal(err) } - if _, err := inv.SecretFor(ctx, "secret", "consumer", "gitea", "provider"); err != nil { + if _, err := inv.SecretFor(ctx, "secret", "consumer", "gitea", "provider", ""); err != nil { t.Fatal(err) } } + +// Two secrets from one provider to one module are two credentials (novox/hq 04-ISSUES/069, ADR +// 0094): keyed on the local name, made and rotated apart, and listed apart for the provider. +func TestTwoLocalNamesAreTwoCredentials(t *testing.T) { + inv, ctx := twoNodesWithKeys(t) + key, err := inv.SecretFor(ctx, "secret", "consumer", "gitea", "provider", "root-key") + if err != nil { + t.Fatal(err) + } + pass, err := inv.SecretFor(ctx, "secret", "consumer", "gitea", "provider", "root-pass") + if err != nil { + t.Fatal(err) + } + if key.ForConsumer == pass.ForConsumer { + t.Fatal("two local names were given one credential") + } + if err := inv.RotateSecret(ctx, "secret", "consumer", "gitea", "provider", "root-key"); err != nil { + t.Fatal(err) + } + keyAgain, err := inv.SecretFor(ctx, "secret", "consumer", "gitea", "provider", "root-key") + if err != nil { + t.Fatal(err) + } + passAgain, err := inv.SecretFor(ctx, "secret", "consumer", "gitea", "provider", "root-pass") + if err != nil { + t.Fatal(err) + } + if keyAgain.ForConsumer == key.ForConsumer || passAgain.ForConsumer != pass.ForConsumer { + t.Fatal("rotating one local name touched the other, or neither") + } + holders, err := inv.HoldersOf(ctx, "secret", "") + if err != nil { + t.Fatal(err) + } + var locals []string + for _, h := range holders { + locals = append(locals, h.Local) + } + if strings.Join(locals, ",") != "root-key,root-pass" { + t.Fatalf("the holders are listed apart, by local name: %v", holders) + } + from, err := inv.SecretsFrom(ctx, "provider") + if err != nil { + t.Fatal(err) + } + if len(from) != 2 || from[0].Local == from[1].Local { + t.Fatalf("the provider is told two credentials to create: %+v", from) + } +} diff --git a/internal/link/enrol_shape_test.go b/internal/link/enrol_shape_test.go index e28070b..54a1b54 100644 --- a/internal/link/enrol_shape_test.go +++ b/internal/link/enrol_shape_test.go @@ -85,7 +85,7 @@ func TestWhatANodeSaysWhenItJoinsIsWhatThisMeshReads(t *testing.T) { t.Fatal(err) } - secret, err := inv.SecretFor(ctx, "postgres-database", request.Node, "gitea", "the-other-end") + secret, err := inv.SecretFor(ctx, "postgres-database", request.Node, "gitea", "the-other-end", "") if err != nil { t.Fatalf("nothing could be sealed to a key that arrived from a real node: %v", err) }