The host now reports, for every held thing, the facts a take compares; the controller keeps them, and take puts them beside what the module declares — the found image and its age against the declared one, the found networks and who else is on them, ports and mounts, a found file's difference from the declared content — and refuses a downgrade without --downgrade and a differing file without --replace <path>. Without --yes the comparison is printed and nothing is taken. node show lists the facts and the strays the machine reports. build and the daemon's take-in say when a module's policy rolls the result out at once. The own-secret refusal points at the provider form for a required secret.
823 lines
34 KiB
Go
823 lines
34 KiB
Go
package inventory
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"slices"
|
|
"sort"
|
|
"strings"
|
|
|
|
"github.com/jackc/pgx/v5"
|
|
|
|
"github.com/novox/mesh-controller/internal/catalogue"
|
|
"github.com/novox/mesh-controller/internal/secrets"
|
|
)
|
|
|
|
// Where sealed secrets live.
|
|
//
|
|
// The table holds nothing usable — see the migration and internal/secrets for why that is the
|
|
// design rather than an inconvenience.
|
|
|
|
// Secret is one provision's credential, sealed to each end.
|
|
type Secret struct {
|
|
Name string
|
|
Consumer string
|
|
// ConsumerModule is which module on that machine it is for.
|
|
//
|
|
// **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
|
|
// 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
|
|
}
|
|
|
|
// Where a pair credential came from.
|
|
const (
|
|
OriginMade = "made"
|
|
OriginAccepted = "accepted"
|
|
)
|
|
|
|
// SecretFor is the credential one module uses for one provision, making it the first time.
|
|
//
|
|
// **Made once and kept**, rather than regenerated whenever it is asked for. A secret that changed
|
|
// on every declaration would restart both ends on every push and would mean the password a
|
|
// provider was told to create never matches the one a consumer was given — which is a mesh that
|
|
// reports success and cannot connect.
|
|
//
|
|
// **Remade when either end's sealing key changes.** A node that rejoined generated a new key and
|
|
// 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, local string) (
|
|
Secret, error) {
|
|
consumerKey, err := i.SealingKeyOf(ctx, consumer)
|
|
if err != nil {
|
|
return Secret{}, err
|
|
}
|
|
providerKey, err := i.SealingKeyOf(ctx, provider)
|
|
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
|
|
}
|
|
|
|
var held Secret
|
|
err = i.store.Pool().QueryRow(ctx,
|
|
`select for_consumer, for_provider, consumer_key, provider_key, origin from secret
|
|
where name = $1 and consumer = $2 and consumer_module = $3 and provider = $4 and local = $5`,
|
|
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, held.Local = consumerModule, local
|
|
return held, nil
|
|
}
|
|
if err == nil && held.Origin == OriginAccepted {
|
|
// A person supplied this, and the mesh does not hold the value: it cannot seal it to the
|
|
// new key. Refused aloud rather than replaced by something the mesh made up, which would
|
|
// be delivered, reported as applied, and fail to authenticate somewhere else entirely
|
|
// (novox/hq 04-ISSUES/070).
|
|
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%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
|
|
// makes a vault-provided secret recoverable, and nothing the mesh can open.
|
|
operator, err := i.OperatorKey(ctx)
|
|
if err != nil {
|
|
return Secret{}, err
|
|
}
|
|
made, blob, err := secrets.MakeWithOperator(consumerKey, providerKey, operator)
|
|
if err != nil {
|
|
return Secret{}, err
|
|
}
|
|
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, 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, local)
|
|
if err != nil {
|
|
return Secret{}, err
|
|
}
|
|
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
|
|
}
|
|
|
|
// AcceptSecretForPair takes a value a person supplied into a pair credential — sealed to the
|
|
// consumer's node and to the provider's, and to the operator when the mesh has one — where the
|
|
// mesh would otherwise have made one (novox/hq 04-ISSUES/070, ADR 0092).
|
|
//
|
|
// This is the vault's third species: a credential for something outside the mesh, which only a
|
|
// 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, local, value string) error {
|
|
// Refused for a requirement the module does not have, or a local it does not keep under it
|
|
// (novox/hq 04-ISSUES/078): the credential would sit in the pair unread.
|
|
m, err := i.declared(ctx, consumerModule)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if !slices.Contains(m.Requires, name) {
|
|
return fmt.Errorf("%s does not require %q; it requires: %s", consumerModule, name, orNone(m.Requires))
|
|
}
|
|
if locals := m.SecretsMany[name]; len(locals) > 0 {
|
|
if local == "" {
|
|
return fmt.Errorf("%s keeps several secrets for %q; name one with --local: %s",
|
|
consumerModule, name, orNone(sortedNames(locals)))
|
|
}
|
|
if _, kept := locals[local]; !kept {
|
|
return fmt.Errorf("%s does not keep %q for %q; it keeps: %s",
|
|
consumerModule, local, name, orNone(sortedNames(locals)))
|
|
}
|
|
} else if m.Secrets[name] == "" {
|
|
return fmt.Errorf("%s requires %q but keeps no secret for it, so a delivered value would sit unread; "+
|
|
"it keeps secrets for: %s", consumerModule, name, orNone(sortedNames(m.Secrets)))
|
|
} else if local != "" {
|
|
return fmt.Errorf("%s keeps one secret for %q, not several; drop --local", consumerModule, name)
|
|
}
|
|
consumerKey, err := i.SealingKeyOf(ctx, consumer)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
providerKey, err := i.SealingKeyOf(ctx, provider)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if consumerKey == "" || providerKey == "" {
|
|
return fmt.Errorf(
|
|
"both %s and %s need a sealing key before a credential can be sealed to them — a "+
|
|
"node joins to get one", consumer, provider)
|
|
}
|
|
consumerNode, err := i.NodeByName(ctx, consumer)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
providerNode, err := i.NodeByName(ctx, provider)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
sealed, err := secrets.Accept(value, consumerKey, providerKey)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
forOperator, operatorKey, err := i.operatorSeal(ctx, value)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
_, 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, 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, local)
|
|
return err
|
|
}
|
|
|
|
// RotateSecret discards what was there, so the next declaration carries a new one.
|
|
//
|
|
// Only a delete. Nothing reads the old value first, because nothing can — and making the
|
|
// replacement here rather than on the next read would be a second path to the same act, which is
|
|
// how two ends come to hold different passwords.
|
|
//
|
|
// The new secret then reaches both ends on the same push, together, which is what makes rotation
|
|
// a single event rather than a fanout with a window where half the mesh holds a dead credential.
|
|
//
|
|
// **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, local string) error {
|
|
consumerNode, err := i.NodeByName(ctx, consumer)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
providerNode, err := i.NodeByName(ctx, provider)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
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 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%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 and local = $5`,
|
|
name, consumerNode.ID, consumerModule, providerNode.ID, local)
|
|
return err
|
|
}
|
|
|
|
// SecretsFrom is every credential a provider node was issued, so it can be told what to create.
|
|
func (i *Inventory) SecretsFrom(ctx context.Context, provider string) ([]Secret, error) {
|
|
providerNode, err := i.NodeByName(ctx, provider)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
rows, err := i.store.Pool().Query(ctx,
|
|
`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, s.local`, providerNode.ID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
|
|
var out []Secret
|
|
for rows.Next() {
|
|
s := Secret{Provider: provider}
|
|
if err := rows.Scan(&s.Name, &s.Consumer, &s.ConsumerModule, &s.Local, &s.ForProvider); err != nil {
|
|
return nil, err
|
|
}
|
|
out = append(out, s)
|
|
}
|
|
return out, rows.Err()
|
|
}
|
|
|
|
// SecretForModule is a secret a module needs in order to be itself, on one machine.
|
|
//
|
|
// Not the credential a consumer is given: a superuser password is not *for* anybody. Made once
|
|
// and kept, because regenerating it on every declaration would change the password a running
|
|
// database has already been started with — and remade when the node's sealing key changes, for
|
|
// the same reason as everything else sealed here.
|
|
// ModuleSecretIfIssued is what a module already holds on a node, and nothing if it holds nothing.
|
|
//
|
|
// **The read half of SecretForModule**, which mints one when there is none — an insert, and a row
|
|
// lock, on a path that also serves questions. Composing a declaration to answer *is this machine
|
|
// running what I would send it* went through the minting version for every module on every node,
|
|
// so asking wrote to the database and blocked against the machine it was asking about.
|
|
//
|
|
// A module with no secret yet has never been sent one, which is the same answer the caller wanted
|
|
// anyway: this machine is not running what the mesh would send it.
|
|
func (i *Inventory) ModuleSecretIfIssued(
|
|
ctx context.Context, node, module, name string,
|
|
) (string, bool, error) {
|
|
key, err := i.SealingKeyOf(ctx, node)
|
|
if err != nil || key == "" {
|
|
return "", false, err
|
|
}
|
|
record, err := i.NodeByName(ctx, node)
|
|
if err != nil {
|
|
return "", false, err
|
|
}
|
|
var sealed, against, origin string
|
|
err = i.store.Pool().QueryRow(ctx,
|
|
`select sealed, node_key, origin from module_secret
|
|
where node = $1 and module = $2 and name = $3`,
|
|
record.ID, module, name).Scan(&sealed, &against, &origin)
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
return "", false, nil
|
|
}
|
|
if err != nil {
|
|
return "", false, err
|
|
}
|
|
// Sealed to a key the node no longer has is not something it holds. Reported as absent rather
|
|
// than as an error: this is the read, and refusing here would make a question fail for a
|
|
// condition its writing counterpart is the right place to explain.
|
|
if against != key {
|
|
return "", false, nil
|
|
}
|
|
return sealed, true, nil
|
|
}
|
|
|
|
func (i *Inventory) SecretForModule(ctx context.Context, node, module, name string) (string, error) {
|
|
key, err := i.SealingKeyOf(ctx, node)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
if key == "" {
|
|
return "", fmt.Errorf(
|
|
"%s needs a secret and %s has no sealing key, so nothing can be sealed to it",
|
|
module, node)
|
|
}
|
|
record, err := i.NodeByName(ctx, node)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
var sealed, against, origin string
|
|
err = i.store.Pool().QueryRow(ctx,
|
|
`select sealed, node_key, origin from module_secret
|
|
where node = $1 and module = $2 and name = $3`,
|
|
record.ID, module, name).Scan(&sealed, &against, &origin)
|
|
if err == nil && against == key {
|
|
return sealed, nil
|
|
}
|
|
if err == nil && origin == "accepted" {
|
|
// Sealed to a key this node no longer has, and not the mesh's to invent again. Making one
|
|
// would put 32 random bytes where a working credential was: the machine would apply it,
|
|
// report success, and whatever reads it would fail to authenticate somewhere else
|
|
// entirely — with the mesh insisting the secret was delivered, which it was.
|
|
return "", fmt.Errorf(
|
|
"%s on %s holds %q, which was given to the mesh rather than made by it, and %s has "+
|
|
"since generated a new sealing key. The mesh cannot make another; issue it again",
|
|
module, node, name, node)
|
|
}
|
|
|
|
operator, err := i.OperatorKey(ctx)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
// Sealed once to the machine — Make seals to two ends because a provision has two; here both
|
|
// are the same machine, and only one copy is kept — and once more to the operator when the
|
|
// mesh has one (novox/hq ADR 0085, amended), which is the copy a person can recover from.
|
|
made, blob, err := secrets.MakeWithOperator(key, key, operator)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
forOperator, operatorKey := operatorColumns(operator, blob)
|
|
if _, err := i.store.Pool().Exec(ctx,
|
|
`insert into module_secret (node, module, name, sealed, node_key, origin, operator_sealed, operator_key)
|
|
values ($1, $2, $3, $4, $5, 'made', $6, $7)
|
|
on conflict (node, module, name) do update set
|
|
sealed = excluded.sealed, node_key = excluded.node_key,
|
|
origin = excluded.origin, made_at = now(),
|
|
operator_sealed = excluded.operator_sealed, operator_key = excluded.operator_key`,
|
|
record.ID, module, name, made.ForConsumer, key, forOperator, operatorKey); err != nil {
|
|
return "", err
|
|
}
|
|
return made.ForConsumer, nil
|
|
}
|
|
|
|
// AcceptSecretForModule keeps a value somebody supplied as a module's own secret.
|
|
//
|
|
// The counterpart to SecretForModule, which generates one. Some of what a module needs the mesh
|
|
// cannot invent: a broker account exists because the broker was told about it, and the password is
|
|
// whatever was agreed with the broker at that moment. The mesh's job is to carry it to the machine
|
|
// that will use it without being able to read it afterwards.
|
|
//
|
|
// Sealed on the way in and the plaintext discarded, exactly as a generated one is — so the only
|
|
// difference between the two is where the value came from.
|
|
func (i *Inventory) AcceptSecretForModule(ctx context.Context, node, module, name, value string) error {
|
|
// Refused for a name the module does not declare. A value stored under a name nothing reads
|
|
// is a delivery that changed nothing and reported success — the shape of failure the mesh
|
|
// is built to refuse (novox/hq 04-ISSUES/078).
|
|
m, err := i.declared(ctx, module)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if _, own := m.OwnSecrets[name]; !own {
|
|
return fmt.Errorf("%s does not declare %q as an own secret; %s — a secret it requires from a provider is accepted with `--provider <node> [--local <name>]`, the value the running service already uses (novox/hq ADR 0163)", module, name, declaresOwn(m))
|
|
}
|
|
key, err := i.SealingKeyOf(ctx, node)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if key == "" {
|
|
return fmt.Errorf(
|
|
"%s has no sealing key, so nothing can be sealed to it — it joins again to get one",
|
|
node)
|
|
}
|
|
record, err := i.NodeByName(ctx, node)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
sealed, err := secrets.Accept(value, key, key)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
// And to the operator, when the mesh has one: a value a person supplied is the one a person
|
|
// most needs to get back, since the mesh cannot make another (novox/hq ADR 0085, amended).
|
|
forOperator, operatorKey, err := i.operatorSeal(ctx, value)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
_, err = i.store.Pool().Exec(ctx,
|
|
`insert into module_secret (node, module, name, sealed, node_key, origin, operator_sealed, operator_key)
|
|
values ($1, $2, $3, $4, $5, 'accepted', $6, $7)
|
|
on conflict (node, module, name) do update set
|
|
sealed = excluded.sealed, node_key = excluded.node_key,
|
|
origin = excluded.origin, made_at = now(),
|
|
operator_sealed = excluded.operator_sealed, operator_key = excluded.operator_key`,
|
|
record.ID, module, name, sealed.ForConsumer, key, forOperator, operatorKey)
|
|
return err
|
|
}
|
|
|
|
// Holder is one end-to-end credential: who gets it and who must create it.
|
|
type Holder struct {
|
|
Provision string
|
|
Consumer string
|
|
// 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
|
|
// 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.
|
|
//
|
|
// **The question rotation has to ask, and the one HAL could not.** There, a provision had a single
|
|
// shared credential and rotating it updated the provider's row; nothing enumerated who else held
|
|
// the old one, so three nodes carried dead credentials for two days and the mesh reported success
|
|
// (novox/hq ADR 0001). Here each pair has its own credential, and this is the list that makes
|
|
// "every consumer" a set the mesh can name rather than a hope.
|
|
//
|
|
// 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, 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, s.local, p.name`, provision, consumer)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
|
|
var out []Holder
|
|
for rows.Next() {
|
|
var h Holder
|
|
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
|
|
}
|
|
|
|
// declared is the manifest the mesh holds for a module — what a delivered value is checked
|
|
// against, so a delivery for a name the module does not have is refused rather than stored.
|
|
func (i *Inventory) declared(ctx context.Context, module string) (catalogue.Manifest, error) {
|
|
known, err := i.Catalogue(ctx)
|
|
if err != nil {
|
|
return catalogue.Manifest{}, err
|
|
}
|
|
m, ok := known[module]
|
|
if !ok {
|
|
return catalogue.Manifest{}, fmt.Errorf("%s is not a module the mesh knows; `module add` it first", module)
|
|
}
|
|
return m, nil
|
|
}
|
|
|
|
func declaresOwn(m catalogue.Manifest) string {
|
|
if len(m.OwnSecrets) == 0 {
|
|
return "it declares no own secrets"
|
|
}
|
|
return "it declares: " + strings.Join(sortedNames(m.OwnSecrets.Paths()), ", ")
|
|
}
|
|
|
|
func sortedNames(of map[string]string) []string {
|
|
names := make([]string, 0, len(of))
|
|
for name := range of {
|
|
names = append(names, name)
|
|
}
|
|
sort.Strings(names)
|
|
return names
|
|
}
|
|
|
|
func orNone(names []string) string {
|
|
if len(names) == 0 {
|
|
return "none"
|
|
}
|
|
return strings.Join(names, ", ")
|
|
}
|
|
|
|
// ErrNotRotatable says why the mesh will not rotate a module's own secret; the words are the caller's
|
|
// to print, and the remedy is in them.
|
|
type ErrNotRotatable struct{ Why string }
|
|
|
|
func (e ErrNotRotatable) Error() string { return e.Why }
|
|
|
|
// RotateModuleSecret makes a module's own secret anew, the way the first mint did (novox/hq
|
|
// ADR 0114, issue 180). The caller sends the node, so the module is started again on the new value.
|
|
//
|
|
// **Only a secret the module reads when it starts.** A secret the module's code applies to a
|
|
// backend that takes it once would, rotated this way, leave the backend on the old value and the
|
|
// module reading the new one — the fault issue 179 was. That form is staged, which the mesh does
|
|
// not build yet, and is refused by name. A secret whose manifest says neither is refused with the
|
|
// word to write; a secret given to the mesh rather than made by it is refused as 0113 says: the
|
|
// mesh will not replace what it cannot read.
|
|
func (i *Inventory) RotateModuleSecret(ctx context.Context, node, module, name string) error {
|
|
m, err := i.declared(ctx, module)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
own, declared := m.OwnSecrets[name]
|
|
if !declared {
|
|
return fmt.Errorf("%s does not declare %q as an own secret; %s — a secret it requires from a provider is accepted with `--provider <node> [--local <name>]`, the value the running service already uses (novox/hq ADR 0163)", module, name, declaresOwn(m))
|
|
}
|
|
switch own.Taken {
|
|
case catalogue.TakenAtStart:
|
|
case catalogue.TakenApplied:
|
|
return ErrNotRotatable{Why: fmt.Sprintf(
|
|
"%s applies %q to a backend that takes it once, so a rotation must be staged beside the "+
|
|
"current value until the module confirms it — the mesh does not do that yet (ADR 0114). "+
|
|
"Changing it is a person's work: change it in %s, then `secret accept %s %s %s`",
|
|
module, name, module, node, module, name)}
|
|
default:
|
|
return ErrNotRotatable{Why: fmt.Sprintf(
|
|
"%s does not say how it takes %q, so the mesh will not rotate it: a secret rotated under "+
|
|
"software that never reads it again is worse than one left alone. Its definition says "+
|
|
"\"own-secrets\": {%q: {\"path\": …, \"taken\": \"at-start\"}} when the module reads it as it "+
|
|
"starts, or \"applied\" when its own code applies it",
|
|
module, name, name)}
|
|
}
|
|
record, err := i.NodeByName(ctx, node)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
key, err := i.SealingKeyOf(ctx, node)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if key == "" {
|
|
return fmt.Errorf("%s has no sealing key, so nothing can be sealed to it", node)
|
|
}
|
|
var origin string
|
|
err = i.store.Pool().QueryRow(ctx,
|
|
`select origin from module_secret where node = $1 and module = $2 and name = $3`,
|
|
record.ID, module, name).Scan(&origin)
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
return fmt.Errorf("%s on %s holds no %q yet; the first push makes it", module, node, name)
|
|
}
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if origin == OriginAccepted {
|
|
return ErrNotRotatable{Why: fmt.Sprintf(
|
|
"%s on %s holds %q as a value given to the mesh, not made by it, and the mesh will not "+
|
|
"replace what it cannot read (ADR 0113). Change it where it lives, then `secret accept "+
|
|
"%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
|
|
}
|
|
made, blob, err := secrets.MakeWithOperator(key, key, operator)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
forOperator, operatorKey := operatorColumns(operator, blob)
|
|
_, err = i.store.Pool().Exec(ctx,
|
|
`update module_secret set sealed = $4, node_key = $5, origin = 'made', made_at = now(),
|
|
operator_sealed = $6, operator_key = $7
|
|
where node = $1 and module = $2 and name = $3`,
|
|
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
|
|
}
|