diff --git a/internal/catalogue/declaration.go b/internal/catalogue/declaration.go index b4194db..ed635ce 100644 --- a/internal/catalogue/declaration.go +++ b/internal/catalogue/declaration.go @@ -739,10 +739,19 @@ func receivedFile(requirement, path string, given []Contribution) (map[string]an // Kept is one secret as the operator can recover it: where it belongs, and the value sealed to the // operator's key. Never a node's blob, and never a value. +// +// Two kinds, addressed the same way — by the node and module that hold it and the name they know +// it by. An `own` secret is one a module has for itself; a `pair` secret is a credential the +// module was granted for a provision it requires (`name` is the provision), and Provider says which +// node grants it. type Kept struct { Node string `json:"node"` Module string `json:"module"` Name string `json:"name"` + // Kind is `own` or `pair`. + Kind string `json:"kind"` + // Provider is the node granting a pair credential; empty for an own secret. + Provider string `json:"provider,omitempty"` // Origin is `made` or `accepted` — whether the mesh minted it or a person supplied it. Origin string `json:"origin"` // Sealed is the value, sealed to the operator key named in Key. diff --git a/internal/inventory/migrations/0024-a-pair-credential-is-sealed-to-the-operator-too.sql b/internal/inventory/migrations/0024-a-pair-credential-is-sealed-to-the-operator-too.sql new file mode 100644 index 0000000..f4b3ab7 --- /dev/null +++ b/internal/inventory/migrations/0024-a-pair-credential-is-sealed-to-the-operator-too.sql @@ -0,0 +1,10 @@ +-- The same second seal for a pair credential (novox/hq ADR 0085, amended). +-- +-- 0023 gave a module's own secrets an operator-sealed copy. A secret the vault provides to a module +-- is not an own secret -- it is the credential of the consumer-vault pair -- and so were the +-- credentials every provider grants. Without this, a vault-provided password could be rotated and +-- audited but not recovered, which is a vault that keeps everything except what it was for. + +alter table secret + add column operator_sealed text, + add column operator_key text; diff --git a/internal/inventory/operator.go b/internal/inventory/operator.go index 2092b89..848b77a 100644 --- a/internal/inventory/operator.go +++ b/internal/inventory/operator.go @@ -69,17 +69,21 @@ type Kept = catalogue.Kept // an export say what it does not cover, rather than being taken for complete. func (i *Inventory) KeptForOperator(ctx context.Context) (kept []Kept, unrecoverable []Kept, err error) { rows, err := i.store.Pool().Query(ctx, - `select n.name, s.module, s.name, s.origin, coalesce(s.operator_sealed, ''), + `select 'own', n.name, s.module, s.name, '', s.origin, coalesce(s.operator_sealed, ''), coalesce(s.operator_key, ''), s.made_at from module_secret s join node n on n.id = s.node - order by n.name, s.module, s.name`) + union all + select 'pair', c.name, s.consumer_module, s.name, p.name, 'made', coalesce(s.operator_sealed, ''), + coalesce(s.operator_key, ''), s.created_at + from secret s join node c on c.id = s.consumer join node p on p.id = s.provider + order by 1, 2, 3, 4`) if err != nil { return nil, nil, err } defer rows.Close() for rows.Next() { var k Kept - if err := rows.Scan(&k.Node, &k.Module, &k.Name, &k.Origin, &k.Sealed, &k.Key, &k.MadeAt); err != nil { + if err := rows.Scan(&k.Kind, &k.Node, &k.Module, &k.Name, &k.Provider, &k.Origin, &k.Sealed, &k.Key, &k.MadeAt); err != nil { return nil, nil, err } if k.Sealed == "" { @@ -93,15 +97,25 @@ func (i *Inventory) KeptForOperator(ctx context.Context) (kept []Kept, unrecover // KeptSecret is one secret's operator-sealed copy, for recovery. func (i *Inventory) KeptSecret(ctx context.Context, node, module, name string) (Kept, error) { + // An own secret first, then a pair credential by the provision's name. A module whose own + // secret and requirement share a name is refused at resolution, so the two cannot both answer. var k Kept err := i.store.Pool().QueryRow(ctx, - `select n.name, s.module, s.name, s.origin, coalesce(s.operator_sealed, ''), + `select 'own', n.name, s.module, s.name, '', s.origin, coalesce(s.operator_sealed, ''), coalesce(s.operator_key, ''), s.made_at from module_secret s join node n on n.id = s.node where n.name = $1 and s.module = $2 and s.name = $3`, node, module, name). - Scan(&k.Node, &k.Module, &k.Name, &k.Origin, &k.Sealed, &k.Key, &k.MadeAt) + Scan(&k.Kind, &k.Node, &k.Module, &k.Name, &k.Provider, &k.Origin, &k.Sealed, &k.Key, &k.MadeAt) if errors.Is(err, pgx.ErrNoRows) { - return Kept{}, fmt.Errorf("%s on %s holds nothing called %q", module, node, name) + err = i.store.Pool().QueryRow(ctx, + `select 'pair', c.name, s.consumer_module, s.name, p.name, 'made', coalesce(s.operator_sealed, ''), + coalesce(s.operator_key, ''), s.created_at + from secret s join node c on c.id = s.consumer join node p on p.id = s.provider + where c.name = $1 and s.consumer_module = $2 and s.name = $3`, node, module, name). + Scan(&k.Kind, &k.Node, &k.Module, &k.Name, &k.Provider, &k.Origin, &k.Sealed, &k.Key, &k.MadeAt) + } + if errors.Is(err, pgx.ErrNoRows) { + return Kept{}, fmt.Errorf("%s on %s holds nothing called %q — neither a secret of its own nor a credential for a provision", module, node, name) } if err != nil { return Kept{}, err diff --git a/internal/inventory/operator_test.go b/internal/inventory/operator_test.go index 600fd7c..6ead7c5 100644 --- a/internal/inventory/operator_test.go +++ b/internal/inventory/operator_test.go @@ -1,6 +1,7 @@ package inventory import ( + "context" "testing" "github.com/novox/mesh-controller/internal/catalogue" @@ -95,3 +96,74 @@ func TestAnOwnSecretIsSealedToTheOperatorToo(t *testing.T) { t.Fatal("the new key is not the mesh's key") } } + +// A pair credential — what the vault provides a module — is sealed to the operator too, and the +// operator's copy is the very value the consumer's node unseals. +func TestAPairCredentialIsSealedToTheOperatorToo(t *testing.T) { + inv := fresh(t) + ctx := context.Background() + // Two nodes with keys, keeping the consumer's opener: the test made the key, so it can play the + // consumer's host for one assertion. + var openAsConsumer func(string) ([]byte, bool) + for _, n := range []string{"consumer", "provider"} { + node, err := inv.AddNode(ctx, n) + if err != nil { + t.Fatal(err) + } + key, open := aSealingKey(t) + if n == "consumer" { + openAsConsumer = open + } + if err := inv.RecordSealingKey(ctx, node.ID, key); err != nil { + t.Fatal(err) + } + } + for _, m := range []string{"gitea", "mesh-vault"} { + if err := inv.RegisterModule(ctx, catalogue.Manifest{Module: m, Version: "1"}, Source{}); err != nil { + t.Fatal(err) + } + } + pub, priv, err := secrets.Keypair() + if err != nil { + t.Fatal(err) + } + if _, err := inv.SetOperatorKey(ctx, pub); err != nil { + t.Fatal(err) + } + made, err := inv.SecretFor(ctx, "secret", "consumer", "gitea", "provider") + if err != nil { + t.Fatal(err) + } + kept, err := inv.KeptSecret(ctx, "consumer", "gitea", "secret") + if err != nil { + t.Fatal(err) + } + if kept.Kind != "pair" || kept.Provider != "provider" { + t.Fatalf("kept as %+v", kept) + } + fromOperator, err := secrets.Open(priv, kept.Sealed) + if err != nil { + t.Fatal(err) + } + // The consumer's blob is sealed to the consumer node. Same value, two recipients. + fromNode, ok := openAsConsumer(made.ForConsumer) + if !ok { + t.Fatal("the consumer cannot open its own blob") + } + if string(fromOperator) != string(fromNode) { + t.Fatal("the operator's copy of the pair credential differs from the consumer's") + } + all, _, err := inv.KeptForOperator(ctx) + if err != nil { + t.Fatal(err) + } + var pairs int + for _, k := range all { + if k.Kind == "pair" { + pairs++ + } + } + if pairs != 1 { + t.Fatalf("%d pair credential(s) in the export, expected 1", pairs) + } +} diff --git a/internal/inventory/secrets.go b/internal/inventory/secrets.go index 173b03b..182f0c7 100644 --- a/internal/inventory/secrets.go +++ b/internal/inventory/secrets.go @@ -74,20 +74,35 @@ func (i *Inventory) SecretFor(ctx context.Context, name, consumer, consumerModul return held, nil } - made, err := secrets.Make(consumerKey, providerKey) + // And to the operator, when the mesh has one (novox/hq ADR 0085, amended): the third copy that + // makes a vault-provided secret recoverable, and nothing the mesh can open. + operator, err := i.OperatorKey(ctx) if err != nil { return Secret{}, err } + var also []string + if operator != "" { + also = append(also, operator) + } + made, more, err := secrets.MakeAlso(consumerKey, providerKey, also...) + if err != nil { + return Secret{}, err + } + var forOperator, operatorKey *string + if operator != "" { + forOperator, operatorKey = &more[0], &operator + } _, err = i.store.Pool().Exec(ctx, `insert into secret (name, consumer, consumer_module, provider, for_consumer, for_provider, - consumer_key, provider_key) - values ($1, $2, $3, $4, $5, $6, $7, $8) + 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 for_consumer = excluded.for_consumer, for_provider = excluded.for_provider, consumer_key = excluded.consumer_key, provider_key = excluded.provider_key, - created_at = now()`, + 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) + made.ForConsumer, made.ForProvider, made.ConsumerKey, made.ProviderKey, forOperator, operatorKey) if err != nil { return Secret{}, err }