A module may hold several secrets from one provider, each a pair of its own
secrets: maps a requirement to several files under local names. Each local name is its own need, its own pair credential (the pair is keyed on it: migration 0027), its own file on the consumer, its own holder at the provider (the identity with the local name after it) and rotates apart from the others. The plain shape is unchanged and every existing row is the credential it was (novox/hq 04-ISSUES/069, ADR 0094).
This commit is contained in:
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 + ")"
|
||||
}
|
||||
|
||||
@@ -52,6 +52,9 @@ func secretCommand(ctx context.Context, args []string) error {
|
||||
provider := set.String("provider", "",
|
||||
"the node providing <name>: the value becomes the PAIR credential between <module> on <node> "+
|
||||
"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 <module>, where its manifest keeps "+
|
||||
"several for <name> (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 <node> <module> <name> [--from <file>] [--provider <node>]\n" +
|
||||
const secretUsage = "secret accept <node> <module> <name> [--from <file>] [--provider <node> [--local <name>]]\n" +
|
||||
"secret recover <node> <module> <name> --key <operator-key> [--out <file>] [--from-export <file>] [--provider <node>]\n" +
|
||||
"secret export [--out <file>]"
|
||||
|
||||
|
||||
@@ -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{}
|
||||
|
||||
@@ -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:<name>}` 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:<name>}` 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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
+12
@@ -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);
|
||||
@@ -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") {
|
||||
|
||||
@@ -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 <file>`",
|
||||
consumerModule, name, provider, consumer, consumerModule, name, provider)
|
||||
"--provider %s%s --from <file>`",
|
||||
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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user