Merge pull request 'A provider with one credential shares it with every consumer, remade for all at once (ADR 0158)' (#184) from feat/0158-a-provider-with-one-credential-shares-it into main
This commit was merged in pull request #184.
This commit is contained in:
@@ -163,7 +163,14 @@ func planFor(ctx context.Context, open *stores, nodeName string) (catalogue.Reso
|
||||
}
|
||||
continue
|
||||
}
|
||||
secret, err := inv.SecretFor(ctx, n.Name, nodeName, n.For, n.From, n.Local)
|
||||
var secret inventory.Secret
|
||||
var err error
|
||||
if n.SharedOwn != "" {
|
||||
// The provider's one credential, sealed to this consumer too (novox/hq ADR 0158).
|
||||
secret, err = inv.SharedSecretFor(ctx, n.Name, nodeName, n.For, n.From, providerModuleOf(resolved, open, ctx, n), n.Local, n.SharedOwn)
|
||||
} else {
|
||||
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
|
||||
@@ -1338,3 +1345,23 @@ func foundationPortsFor(brokerPort int, modules []catalogue.Manifest) []int {
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// providerModuleOf is which module answers a need on the providing node: the one in this node's
|
||||
// own set when the provider is here, else the one the catalogue says offers it.
|
||||
func providerModuleOf(resolved catalogue.Resolution, open *stores, ctx context.Context, n catalogue.Needed) string {
|
||||
for _, m := range resolved.Modules {
|
||||
if _, shared := m.SharedCredentialOf(n.Name); shared {
|
||||
return m.Module
|
||||
}
|
||||
}
|
||||
shelf, err := open.inventory.Catalogue(ctx)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
for name, m := range shelf {
|
||||
if _, shared := m.SharedCredentialOf(n.Name); shared {
|
||||
return name
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
@@ -387,10 +387,23 @@ func secretRotate(ctx context.Context, args []string) error {
|
||||
}
|
||||
fmt.Printf("rotated %q of %s on %s at %s, asked by %s; the value is sealed and not shown\n",
|
||||
name, module, node, time.Now().UTC().Format(time.RFC3339), whoAsked())
|
||||
// A shared credential (ADR 0158) has as many holders as the provision has consumers, and all
|
||||
// of them are sent in one act, so no machine is left reading a value the provider no longer takes.
|
||||
machines, err := open.inventory.SharedHolders(ctx, node, module, name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(machines) == 0 {
|
||||
machines = []string{node}
|
||||
}
|
||||
if len(machines) == 1 {
|
||||
fmt.Printf("sending %s, so %s starts again on the new value:\n", node, module)
|
||||
if err := sendTo(ctx, open, []string{node}); err != nil {
|
||||
} else {
|
||||
fmt.Printf("shared with every consumer; sending %s together:\n", strings.Join(machines, ", "))
|
||||
}
|
||||
if err := sendTo(ctx, open, machines); err != nil {
|
||||
return fmt.Errorf("%w\n\nThe new value is sealed and not yet delivered; what runs keeps the old "+
|
||||
"one until the machine next applies. Fix the cause and run `push %s`", err, node)
|
||||
"one until the machines next apply. Fix the cause and run `push --behind`", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -127,6 +127,38 @@ type Offer struct {
|
||||
Name string `json:"name"`
|
||||
// Scope defaults to the node, which is where most things must be to be usable.
|
||||
Scope string `json:"scope,omitempty"`
|
||||
// Credential, when set, says this provision's credential is one of the provider's own secrets,
|
||||
// shared by every consumer (novox/hq ADR 0158): software that holds one password or one key
|
||||
// cannot give each consumer a login of its own. The named secret must say how it is taken.
|
||||
Credential *OfferCredential `json:"credential,omitempty"`
|
||||
}
|
||||
|
||||
// OfferCredential names which of the provider's own secrets a provision's consumers receive.
|
||||
type OfferCredential struct {
|
||||
Own string `json:"own"`
|
||||
}
|
||||
|
||||
// SharedCredentialOf is the own secret an offer of this module names as the provision's credential,
|
||||
// and whether it names one.
|
||||
func (m Manifest) SharedCredentialOf(provision string) (string, bool) {
|
||||
for _, o := range m.Provides {
|
||||
if o.Name == provision && o.Credential != nil && o.Credential.Own != "" {
|
||||
return o.Credential.Own, true
|
||||
}
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
// ProvisionsSharing is every provision of this module whose credential is the named own secret.
|
||||
func (m Manifest) ProvisionsSharing(own string) []string {
|
||||
var out []string
|
||||
for _, o := range m.Provides {
|
||||
if o.Credential != nil && o.Credential.Own == own {
|
||||
out = append(out, o.Name)
|
||||
}
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// At is this offer's scope, with the default applied.
|
||||
@@ -147,24 +179,28 @@ func (o *Offer) UnmarshalJSON(raw []byte) error {
|
||||
var full struct {
|
||||
Name string `json:"name"`
|
||||
Scope string `json:"scope,omitempty"`
|
||||
Credential *OfferCredential `json:"credential,omitempty"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &full); err != nil {
|
||||
return fmt.Errorf("a provided name is either a string or {name, scope}: %w", err)
|
||||
dec := json.NewDecoder(bytes.NewReader(raw))
|
||||
dec.DisallowUnknownFields()
|
||||
if err := dec.Decode(&full); err != nil {
|
||||
return fmt.Errorf("a provided name is either a string or {name, scope, credential}: %w", err)
|
||||
}
|
||||
o.Name, o.Scope = full.Name, full.Scope
|
||||
o.Name, o.Scope, o.Credential = full.Name, full.Scope, full.Credential
|
||||
return nil
|
||||
}
|
||||
|
||||
// MarshalJSON writes back the short form when there is nothing else to say, so a manifest that
|
||||
// went through the mesh comes out looking like the one that went in.
|
||||
func (o Offer) MarshalJSON() ([]byte, error) {
|
||||
if o.Scope == "" {
|
||||
if o.Scope == "" && o.Credential == nil {
|
||||
return json.Marshal(o.Name)
|
||||
}
|
||||
return json.Marshal(struct {
|
||||
Name string `json:"name"`
|
||||
Scope string `json:"scope"`
|
||||
}{o.Name, o.Scope})
|
||||
Scope string `json:"scope,omitempty"`
|
||||
Credential *OfferCredential `json:"credential,omitempty"`
|
||||
}{o.Name, o.Scope, o.Credential})
|
||||
}
|
||||
|
||||
// Manifest is everything a module says about itself.
|
||||
@@ -1142,6 +1178,23 @@ func ParseManifest(raw []byte) (Manifest, error) {
|
||||
if !name.MatchString(p) {
|
||||
problems = append(problems, fmt.Sprintf("%q is not a usable name to provide", p))
|
||||
}
|
||||
if offer.Credential != nil {
|
||||
own, declared := m.OwnSecrets[offer.Credential.Own]
|
||||
switch {
|
||||
case offer.Credential.Own == "":
|
||||
problems = append(problems, fmt.Sprintf(
|
||||
"%s provides %q with a credential that names no own secret", m.Module, p))
|
||||
case !declared:
|
||||
problems = append(problems, fmt.Sprintf(
|
||||
"%s provides %q with its own secret %q as the credential, and declares no such secret",
|
||||
m.Module, p, offer.Credential.Own))
|
||||
case own.Taken == "":
|
||||
problems = append(problems, fmt.Sprintf(
|
||||
"%s provides %q with its own secret %q as the credential every consumer receives, so "+
|
||||
"the secret must say how the module takes it: \"taken\": \"at-start\" or \"applied\" (ADR 0158)",
|
||||
m.Module, p, offer.Credential.Own))
|
||||
}
|
||||
}
|
||||
if instead, generic := engineGeneric[p]; generic {
|
||||
// A consumer is written against an engine, not a role (novox/hq ADR 0027). Providing
|
||||
// the role means a requirement for it matches any engine, resolves as satisfied, and
|
||||
|
||||
@@ -179,6 +179,10 @@ type Needed struct {
|
||||
// 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
|
||||
// SharedOwn is set when the provision's credential is one of the provider's own secrets, shared
|
||||
// by every consumer (novox/hq ADR 0158): the name of that secret in the provider's definition.
|
||||
// The plan mints the pair's copy from the provider's value rather than a value of its own.
|
||||
SharedOwn 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
|
||||
@@ -322,7 +326,8 @@ func Resolve(catalogue map[string]Manifest, assigned []string, node Node, world
|
||||
}
|
||||
needs = append(needs, Needed{
|
||||
Name: want, From: node.Name, At: at,
|
||||
Serves: servedHere(catalogue, chosen, want), For: because[want]})
|
||||
Serves: servedHere(catalogue, chosen, want), For: because[want],
|
||||
SharedOwn: sharedHere(catalogue, chosen, want)})
|
||||
} else if served := servedHere(catalogue, chosen, want); len(served) > 0 {
|
||||
// Answered here with no credential to mint, but the provider serves facts the
|
||||
// consumer cannot guess — a port, a model name — and so still needs a binding.
|
||||
@@ -369,8 +374,12 @@ func Resolve(catalogue map[string]Manifest, assigned []string, node Node, world
|
||||
node.Name, want, p.Node, meshNetwork))
|
||||
return
|
||||
}
|
||||
shared := ""
|
||||
if pm, known := catalogue[p.Module]; known {
|
||||
shared, _ = pm.SharedCredentialOf(want)
|
||||
}
|
||||
needs = append(needs, Needed{Name: want, From: p.Node, At: p.At,
|
||||
Serves: p.Serves, For: because[want]})
|
||||
Serves: p.Serves, For: because[want], SharedOwn: shared})
|
||||
}
|
||||
switch {
|
||||
case world.Unchecked:
|
||||
@@ -588,6 +597,20 @@ func Resolve(catalogue map[string]Manifest, assigned []string, node Node, world
|
||||
// need that is never created is a binding the consumer never gets. It is right about that from the
|
||||
// manifest alone, which is why walking the catalogue mid-resolution is enough here and is not
|
||||
// enough for the values.
|
||||
// sharedHere is the own secret the provider of a provision on this same machine names as its
|
||||
// credential (ADR 0158), or "" when the provider gives each consumer its own.
|
||||
func sharedHere(catalogue map[string]Manifest, chosen map[string]bool, want string) string {
|
||||
for name, m := range catalogue {
|
||||
if !chosen[name] {
|
||||
continue
|
||||
}
|
||||
if own, shared := m.SharedCredentialOf(want); shared {
|
||||
return own
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func servedHere(catalogue map[string]Manifest, chosen map[string]bool, want string) map[string]any {
|
||||
for name, m := range catalogue {
|
||||
if !chosen[name] {
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
package catalogue
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// A provider with one credential shares it (novox/hq ADR 0158): the offer names the own secret, the
|
||||
// secret says how it is taken, and a consumer's need carries the name so the plan mints its copy
|
||||
// from the provider's value.
|
||||
func TestAnOfferMayNameAnOwnSecretAsItsCredential(t *testing.T) {
|
||||
m, err := ParseManifest([]byte(`{"module":"downloader","version":"1",
|
||||
"own-secrets":{"password":{"path":"/var/lib/mesh/downloader/password","taken":"at-start"}},
|
||||
"provides":[{"name":"downloader-api","credential":{"own":"password"}}],
|
||||
"serves":{"downloader-api":{"port":8080,"username":"admin"}}}`))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if own, shared := m.SharedCredentialOf("downloader-api"); !shared || own != "password" {
|
||||
t.Fatalf("the offer's credential was not read: %v %v", own, shared)
|
||||
}
|
||||
if got := m.ProvisionsSharing("password"); len(got) != 1 || got[0] != "downloader-api" {
|
||||
t.Fatalf("the provisions sharing the secret: %v", got)
|
||||
}
|
||||
|
||||
for want, raw := range map[string]string{
|
||||
"declares no such secret": `{"module":"d","version":"1","provides":[{"name":"d-api","credential":{"own":"password"}}]}`,
|
||||
"must say how the module takes it": `{"module":"d","version":"1","own-secrets":{"password":"/p"},
|
||||
"provides":[{"name":"d-api","credential":{"own":"password"}}]}`,
|
||||
"names no own secret": `{"module":"d","version":"1","provides":[{"name":"d-api","credential":{"own":""}}]}`,
|
||||
} {
|
||||
if _, err := ParseManifest([]byte(raw)); err == nil || !strings.Contains(err.Error(), want) {
|
||||
t.Errorf("expected a refusal saying %q, got %v", want, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func sharingShelf() map[string]Manifest {
|
||||
return shelf(
|
||||
Manifest{Module: "downloader", Version: "1",
|
||||
Provides: []Offer{{Name: "downloader-api", Scope: ScopeMesh, Credential: &OfferCredential{Own: "password"}}},
|
||||
OwnSecrets: OwnSecrets{"password": {Path: "/var/lib/mesh/downloader/password", Taken: TakenAtStart}},
|
||||
Serves: map[string]map[string]any{"downloader-api": {"port": 8080, "username": "admin"}}},
|
||||
Manifest{Module: "manager", Version: "1", Requires: []string{"downloader-api"}},
|
||||
)
|
||||
}
|
||||
|
||||
func TestAConsumersNeedCarriesTheSharedSecretsName(t *testing.T) {
|
||||
// On the same machine.
|
||||
together, err := Resolve(sharingShelf(), []string{"downloader", "manager"}, workstation(), World{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
found := false
|
||||
for _, n := range together.Needs {
|
||||
if n.Name == "downloader-api" && n.For == "manager" {
|
||||
found = true
|
||||
if n.SharedOwn != "password" {
|
||||
t.Fatalf("the need on one machine does not name the shared secret: %+v", n)
|
||||
}
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("the manager's need was not resolved: %+v", together.Needs)
|
||||
}
|
||||
// Across machines, the provider known by its module.
|
||||
apart, err := Resolve(sharingShelf(), []string{"manager"}, onBoth("example.tld"), World{
|
||||
Offered: map[string][]Provider{"downloader-api": {{Node: "home-server", At: "home-server.internal",
|
||||
Module: "downloader", Serves: map[string]any{"port": 8080}}}},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, n := range apart.Needs {
|
||||
if n.Name == "downloader-api" && n.SharedOwn != "password" {
|
||||
t.Fatalf("the need across machines does not name the shared secret: %+v", n)
|
||||
}
|
||||
}
|
||||
}
|
||||
+8
@@ -0,0 +1,8 @@
|
||||
-- A provider with one credential shares it with every consumer (novox/hq ADR 0158).
|
||||
--
|
||||
-- The provider's own secret and every consumer's pair row then carry one value, sealed once per
|
||||
-- holder. The mesh keeps no plaintext, so it cannot tell by reading that they agree; it stamps the
|
||||
-- act that made them instead. A pair row whose stamp is the own secret's was sealed from the same
|
||||
-- value; one whose stamp differs, or is missing, is remade for every holder at once.
|
||||
alter table module_secret add column generation text;
|
||||
alter table secret add column generation text;
|
||||
@@ -592,6 +592,10 @@ func (i *Inventory) RotateModuleSecret(ctx context.Context, node, module, name s
|
||||
"%s %s %s` with the new value",
|
||||
module, node, name, node, module, name)}
|
||||
}
|
||||
if len(m.ProvisionsSharing(name)) > 0 {
|
||||
// Shared with every consumer of those provisions (ADR 0158): one new value, sealed to all.
|
||||
return i.remakeShared(ctx, record.ID, key, module, name, "", nil, "", "", "")
|
||||
}
|
||||
operator, err := i.OperatorKey(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -608,3 +612,211 @@ func (i *Inventory) RotateModuleSecret(ctx context.Context, node, module, name s
|
||||
record.ID, module, name, made.ForConsumer, key, forOperator, operatorKey)
|
||||
return err
|
||||
}
|
||||
|
||||
// SharedSecretFor is a consumer's copy of a provider's one credential (novox/hq ADR 0158): the
|
||||
// provider's own secret, sealed to this consumer as a pair credential would be.
|
||||
//
|
||||
// **One value, many seals, made in one act.** The mesh keeps no plaintext, so a value cannot be
|
||||
// sealed to a consumer that binds later; when a consumer's copy is missing or was made in a
|
||||
// different act than the provider's own secret, a fresh value is made and sealed to the provider,
|
||||
// to every consumer that holds the provision from this provider, to this consumer and to the
|
||||
// operator — one generation, stamped on every row. Every holding machine must then be sent, which
|
||||
// the plan's caller does by sending the node it was composing and `secret rotate` does for all.
|
||||
//
|
||||
// An accepted value is sealed to the consumers of the moment it was accepted and never remade: a
|
||||
// consumer that binds later is refused with the way out, as ADR 0113 says.
|
||||
func (i *Inventory) SharedSecretFor(ctx context.Context, provision, consumer, consumerModule,
|
||||
provider, providerModule, local, own string) (Secret, error) {
|
||||
consumerKey, err := i.SealingKeyOf(ctx, consumer)
|
||||
if err != nil {
|
||||
return Secret{}, err
|
||||
}
|
||||
consumerNode, err := i.NodeByName(ctx, consumer)
|
||||
if err != nil {
|
||||
return Secret{}, err
|
||||
}
|
||||
providerNode, err := i.NodeByName(ctx, provider)
|
||||
if err != nil {
|
||||
return Secret{}, err
|
||||
}
|
||||
providerKey, err := i.SealingKeyOf(ctx, provider)
|
||||
if err != nil {
|
||||
return Secret{}, err
|
||||
}
|
||||
if consumerKey == "" || providerKey == "" {
|
||||
return Secret{}, fmt.Errorf("%s and %s both need a sealing key before %s can be shared", consumer, provider, provision)
|
||||
}
|
||||
|
||||
var ownGeneration, ownOrigin, ownKey *string
|
||||
err = i.store.Pool().QueryRow(ctx,
|
||||
`select generation, origin, node_key from module_secret where node = $1 and module = $2 and name = $3`,
|
||||
providerNode.ID, providerModule, own).Scan(&ownGeneration, &ownOrigin, &ownKey)
|
||||
if err != nil && !errors.Is(err, pgx.ErrNoRows) {
|
||||
return Secret{}, err
|
||||
}
|
||||
var held Secret
|
||||
var pairGeneration *string
|
||||
err = i.store.Pool().QueryRow(ctx,
|
||||
`select for_consumer, for_provider, consumer_key, provider_key, origin, generation from secret
|
||||
where name = $1 and consumer = $2 and consumer_module = $3 and provider = $4 and local = $5`,
|
||||
provision, consumerNode.ID, consumerModule, providerNode.ID, local).
|
||||
Scan(&held.ForConsumer, &held.ForProvider, &held.ConsumerKey, &held.ProviderKey, &held.Origin, &pairGeneration)
|
||||
if err != nil && !errors.Is(err, pgx.ErrNoRows) {
|
||||
return Secret{}, err
|
||||
}
|
||||
current := ownGeneration != nil && pairGeneration != nil && *ownGeneration == *pairGeneration &&
|
||||
held.ConsumerKey == consumerKey && held.ProviderKey == providerKey && ownKey != nil && *ownKey == providerKey
|
||||
if current {
|
||||
held.Name, held.Consumer, held.Provider = provision, consumer, provider
|
||||
held.ConsumerModule, held.Local = consumerModule, local
|
||||
return held, nil
|
||||
}
|
||||
if ownOrigin != nil && *ownOrigin == OriginAccepted {
|
||||
return Secret{}, fmt.Errorf(
|
||||
"%s on %s needs %s from %s, whose credential is %s's own secret %q — a value given to the "+
|
||||
"mesh, which cannot seal it to a consumer that binds later (ADR 0158): `secret accept %s %s %s` "+
|
||||
"again, which seals it to every current consumer",
|
||||
consumerModule, consumer, provision, provider, providerModule, own, provider, providerModule, own)
|
||||
}
|
||||
if err := i.remakeShared(ctx, providerNode.ID, providerKey, providerModule, own, provision, consumerNode.ID, consumerKey, consumerModule, local); err != nil {
|
||||
return Secret{}, err
|
||||
}
|
||||
return i.SharedSecretFor(ctx, provision, consumer, consumerModule, provider, providerModule, local, own)
|
||||
}
|
||||
|
||||
// remakeShared makes one fresh value and seals it to the provider's own secret, to every pair row
|
||||
// of the provisions sharing it, to the one consumer being added (when there is one), and to the
|
||||
// operator, all under one generation.
|
||||
func (i *Inventory) remakeShared(ctx context.Context, providerID any, providerKey, providerModule, own,
|
||||
provision string, addConsumerID any, addConsumerKey, addConsumerModule, addLocal string) error {
|
||||
m, err := i.declared(ctx, providerModule)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
provisions := m.ProvisionsSharing(own)
|
||||
if len(provisions) == 0 {
|
||||
return fmt.Errorf("%s names no provision whose credential is its own secret %q", providerModule, own)
|
||||
}
|
||||
operator, err := i.OperatorKey(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
value := secrets.Fresh()
|
||||
generation := secrets.Stamp()
|
||||
ownSealed, err := secrets.Seal(providerKey, []byte(value))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
forOperator, operatorKey := "", ""
|
||||
if operator != "" {
|
||||
if forOperator, err = secrets.Seal(operator, []byte(value)); err != nil {
|
||||
return err
|
||||
}
|
||||
operatorKey = operator
|
||||
}
|
||||
tx, err := i.store.Pool().Begin(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() { _ = tx.Rollback(ctx) }()
|
||||
if _, err := tx.Exec(ctx,
|
||||
`insert into module_secret (node, module, name, sealed, node_key, origin, operator_sealed, operator_key, generation)
|
||||
values ($1, $2, $3, $4, $5, 'made', nullif($6,''), nullif($7,''), $8)
|
||||
on conflict (node, module, name) do update set
|
||||
sealed = excluded.sealed, node_key = excluded.node_key, origin = 'made', made_at = now(),
|
||||
operator_sealed = excluded.operator_sealed, operator_key = excluded.operator_key,
|
||||
generation = excluded.generation`,
|
||||
providerID, providerModule, own, ownSealed, providerKey, forOperator, operatorKey, generation); err != nil {
|
||||
return err
|
||||
}
|
||||
// Every consumer that already holds one of the sharing provisions from this provider.
|
||||
rows, err := tx.Query(ctx,
|
||||
`select s.consumer, s.consumer_module, s.local, s.name, n.sealing_key
|
||||
from secret s join node n on n.id = s.consumer
|
||||
where s.provider = $1 and s.name = any($2)`, providerID, provisions)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
type holder struct {
|
||||
consumer any
|
||||
consumerModule, local, name, key string
|
||||
}
|
||||
var holders []holder
|
||||
for rows.Next() {
|
||||
var h holder
|
||||
var key *string
|
||||
if err := rows.Scan(&h.consumer, &h.consumerModule, &h.local, &h.name, &key); err != nil {
|
||||
rows.Close()
|
||||
return err
|
||||
}
|
||||
if key != nil {
|
||||
h.key = *key
|
||||
}
|
||||
holders = append(holders, h)
|
||||
}
|
||||
rows.Close()
|
||||
if addConsumerID != nil {
|
||||
holders = append(holders, holder{consumer: addConsumerID, consumerModule: addConsumerModule,
|
||||
local: addLocal, name: provision, key: addConsumerKey})
|
||||
}
|
||||
for _, h := range holders {
|
||||
if h.key == "" {
|
||||
continue // a consumer whose key is gone cannot be sealed to; it is remade when it reports one
|
||||
}
|
||||
sealed, err := secrets.Accept(value, h.key, providerKey)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := tx.Exec(ctx,
|
||||
`insert into secret (name, consumer, consumer_module, provider, for_consumer, for_provider,
|
||||
consumer_key, provider_key, origin, local, generation)
|
||||
values ($1, $2, $3, $4, $5, $6, $7, $8, 'made', $9, $10)
|
||||
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,
|
||||
origin = 'made', generation = excluded.generation`,
|
||||
h.name, h.consumer, h.consumerModule, providerID, sealed.ForConsumer, sealed.ForProvider,
|
||||
h.key, providerKey, h.local, generation); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return tx.Commit(ctx)
|
||||
}
|
||||
|
||||
// SharedHolders is every machine holding a copy of a provider's shared credential: the provider's
|
||||
// and every consumer's, for the send that follows a rotation.
|
||||
func (i *Inventory) SharedHolders(ctx context.Context, provider, providerModule, own string) ([]string, error) {
|
||||
m, err := i.declared(ctx, providerModule)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
provisions := m.ProvisionsSharing(own)
|
||||
if len(provisions) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
providerNode, err := i.NodeByName(ctx, provider)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rows, err := i.store.Pool().Query(ctx,
|
||||
`select distinct n.name from secret s join node n on n.id = s.consumer
|
||||
where s.provider = $1 and s.name = any($2)`, providerNode.ID, provisions)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
seen := map[string]bool{provider: true}
|
||||
out := []string{provider}
|
||||
for rows.Next() {
|
||||
var name string
|
||||
if err := rows.Scan(&name); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !seen[name] {
|
||||
seen[name] = true
|
||||
out = append(out, name)
|
||||
}
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out, nil
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"errors"
|
||||
"github.com/novox/mesh-controller/internal/catalogue"
|
||||
"github.com/novox/mesh-controller/internal/secrets"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
@@ -843,3 +844,93 @@ func TestAnOwnSecretRotatesOnlyWhenTheModuleReadsItAtStart(t *testing.T) {
|
||||
t.Fatalf("an undeclared secret: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// A provider's one credential is one value sealed to every holder, remade for all at once when a
|
||||
// consumer binds or a rotation is asked (novox/hq ADR 0158).
|
||||
func TestASharedCredentialIsOneValueSealedToEveryHolder(t *testing.T) {
|
||||
inv, ctx := twoNodesWithKeys(t)
|
||||
provider := catalogue.Manifest{Module: "downloader", Version: "1",
|
||||
Provides: []catalogue.Offer{{Name: "downloader-api", Scope: catalogue.ScopeMesh, Credential: &catalogue.OfferCredential{Own: "password"}}},
|
||||
OwnSecrets: catalogue.OwnSecrets{"password": {Path: "/var/lib/mesh/downloader/password", Taken: catalogue.TakenAtStart}}}
|
||||
if err := inv.RegisterModule(ctx, provider, Source{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, m := range []string{"manager", "indexer"} {
|
||||
if err := inv.RegisterModule(ctx, catalogue.Manifest{Module: m, Version: "1", Requires: []string{"downloader-api"}}, Source{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
generationOf := func() string {
|
||||
var g *string
|
||||
node, _ := inv.NodeByName(ctx, "provider")
|
||||
if err := inv.store.Pool().QueryRow(ctx, `select generation from module_secret where node = $1 and module = 'downloader' and name = 'password'`, node.ID).Scan(&g); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if g == nil {
|
||||
t.Fatal("the provider's own secret carries no generation")
|
||||
}
|
||||
return *g
|
||||
}
|
||||
pairGeneration := func(module string) string {
|
||||
var g *string
|
||||
cn, _ := inv.NodeByName(ctx, "consumer")
|
||||
pn, _ := inv.NodeByName(ctx, "provider")
|
||||
if err := inv.store.Pool().QueryRow(ctx, `select generation from secret where name = 'downloader-api' and consumer = $1 and consumer_module = $2 and provider = $3`, cn.ID, module, pn.ID).Scan(&g); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if g == nil {
|
||||
return ""
|
||||
}
|
||||
return *g
|
||||
}
|
||||
|
||||
first, err := inv.SharedSecretFor(ctx, "downloader-api", "consumer", "manager", "provider", "downloader", "", "password")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
g1 := generationOf()
|
||||
if pairGeneration("manager") != g1 {
|
||||
t.Fatal("the consumer's copy was not sealed in the same act as the provider's own secret")
|
||||
}
|
||||
again, err := inv.SharedSecretFor(ctx, "downloader-api", "consumer", "manager", "provider", "downloader", "", "password")
|
||||
if err != nil || again.ForConsumer != first.ForConsumer {
|
||||
t.Fatalf("asking twice remade the value: %v", err)
|
||||
}
|
||||
|
||||
// A second consumer binding remakes the value for everyone, in one generation.
|
||||
if _, err := inv.SharedSecretFor(ctx, "downloader-api", "consumer", "indexer", "provider", "downloader", "", "password"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
g2 := generationOf()
|
||||
if g2 == g1 {
|
||||
t.Fatal("a new consumer did not remake the shared value")
|
||||
}
|
||||
if pairGeneration("manager") != g2 || pairGeneration("indexer") != g2 {
|
||||
t.Fatalf("not every holder was sealed in the new act: %s %s %s", g2, pairGeneration("manager"), pairGeneration("indexer"))
|
||||
}
|
||||
holders, err := inv.SharedHolders(ctx, "provider", "downloader", "password")
|
||||
if err != nil || !reflect.DeepEqual(holders, []string{"consumer", "provider"}) {
|
||||
t.Fatalf("the holders: %v %v", holders, err)
|
||||
}
|
||||
|
||||
// Rotation remakes every copy.
|
||||
if err := inv.RotateModuleSecret(ctx, "provider", "downloader", "password"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
g3 := generationOf()
|
||||
if g3 == g2 || pairGeneration("manager") != g3 || pairGeneration("indexer") != g3 {
|
||||
t.Fatal("rotation did not remake every holder's copy")
|
||||
}
|
||||
|
||||
// An accepted value: sealed to the consumers of the moment, and a later consumer is refused.
|
||||
if err := inv.AcceptSecretForModule(ctx, "provider", "downloader", "password", "the-real-one"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := inv.RegisterModule(ctx, catalogue.Manifest{Module: "late", Version: "1", Requires: []string{"downloader-api"}}, Source{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, err = inv.SharedSecretFor(ctx, "downloader-api", "consumer", "late", "provider", "downloader", "", "password")
|
||||
if err == nil || !strings.Contains(err.Error(), "given to the mesh") {
|
||||
t.Fatalf("a consumer binding after an acceptance must be refused with the way out: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -45,6 +45,26 @@ type Sealed struct {
|
||||
ProviderKey string
|
||||
}
|
||||
|
||||
// Fresh is a new secret value, the shape Make seals: for the one caller that must seal one value
|
||||
// to many holders at once (novox/hq ADR 0158) and discards it the same way.
|
||||
func Fresh() string {
|
||||
value := make([]byte, 30)
|
||||
if _, err := rand.Read(value); err != nil {
|
||||
panic("the system's random source failed: " + err.Error())
|
||||
}
|
||||
return base64.RawURLEncoding.EncodeToString(value)
|
||||
}
|
||||
|
||||
// Stamp is a random mark for one act of sealing a value to several holders: rows carrying the same
|
||||
// stamp were sealed from the same value, which the mesh cannot otherwise tell, holding no plaintext.
|
||||
func Stamp() string {
|
||||
mark := make([]byte, 16)
|
||||
if _, err := rand.Read(mark); err != nil {
|
||||
panic("the system's random source failed: " + err.Error())
|
||||
}
|
||||
return hex.EncodeToString(mark)
|
||||
}
|
||||
|
||||
// Make generates a secret and seals it to both ends, keeping no readable copy.
|
||||
//
|
||||
// The plaintext exists for the length of this call. Rotation is therefore generating a new one
|
||||
|
||||
Reference in New Issue
Block a user