diff --git a/internal/catalogue/contributes_test.go b/internal/catalogue/contributes_test.go index 44c6b59..a8ceefc 100644 --- a/internal/catalogue/contributes_test.go +++ b/internal/catalogue/contributes_test.go @@ -330,3 +330,36 @@ func TestAConsumersOwnContributionsAreStillThere(t *testing.T) { t.Fatalf("a local contribution grew a node: %v", given) } } + +func TestAConsumerThatStoppedAskingIsWithdrawn(t *testing.T) { + // Withdrawal is how a credential is taken away. The provisioner removes what nobody asks for, + // and it can only do that if the mesh stops asking — a consumer that was unassigned would + // otherwise keep a working login for ever, and nothing would say so. + // + // A grant with no asking module is exactly that state: the mesh still holds the secret, + // because it is sealed and unusable to the mesh anyway, and the machine no longer wants it. + got, err := Resolve(shelf(provider()), []string{"postgres"}, reachable(), World{}) + if err != nil { + t.Fatal(err) + } + out, err := got.Declaration(Rendering{Grants: []Grant{ + {Provision: "database", Consumer: "still-here", From: "meshboard", + Values: map[string]any{"name": "meshboard"}, Sealed: "c2VhbGVk"}, + {Provision: "database", Consumer: "gone-away", Sealed: "c3RhbGU="}, + }}) + if err != nil { + t.Fatal(err) + } + + given := grantedTo(t, out) + if len(given) != 1 || given[0].Node != "still-here" { + t.Fatalf("the provider is still told about a machine that stopped asking: %v", given) + } + // And no credential is written for it either, or the provisioner would find a file for a + // consumer its manifest does not mention and have to guess what that means. + for _, r := range out { + if path, _ := r["path"].(string); strings.Contains(path, "gone-away") { + t.Fatalf("a withdrawn consumer's credential is still delivered: %v", r) + } + } +} diff --git a/internal/catalogue/declaration.go b/internal/catalogue/declaration.go index c82e61f..276f903 100644 --- a/internal/catalogue/declaration.go +++ b/internal/catalogue/declaration.go @@ -114,6 +114,16 @@ func (r Resolution) Declaration(with Rendering) ([]map[string]any, error) { if g.Provision != to { continue } + if g.From == "" { + // Nothing on that machine asks for this any more. Skipped here rather than + // where grants are gathered, so the rule holds whoever gathers them. + // + // **This is how a credential is withdrawn.** The provisioner removes what + // nobody asks for, and it can only do that if the mesh stops asking — a + // consumer that was unassigned would otherwise keep a working login for ever, + // and nothing would say so. + continue + } resources = append(append([]map[string]any{}, resources...), map[string]any{ "id": GrantID(to, g.Consumer), "type": "file", @@ -245,6 +255,11 @@ func (r Resolution) contributions(settings SettingsBy, grants []Grant, return sorted[i].Consumer < sorted[j].Consumer }) for _, g := range sorted { + if g.From == "" { + // As above: nothing on that machine asks for this any more, so the provider is not + // told about it and withdraws the login on its next pass. + continue + } out[g.Provision] = append(out[g.Provision], Contribution{ From: g.From, Node: g.Consumer, Values: g.Values, Secret: grantPath(directories[g.Provision], g.Consumer), diff --git a/internal/inventory/secrets_test.go b/internal/inventory/secrets_test.go index dec409e..3bdce71 100644 --- a/internal/inventory/secrets_test.go +++ b/internal/inventory/secrets_test.go @@ -325,3 +325,62 @@ func TestAMachineWithNoSealingKeyCannotBeGivenAModuleSecret(t *testing.T) { t.Fatal("a secret was made for a machine that cannot open one") } } + +func TestACredentialGoesWhenTheConsumerStopsAskingForIt(t *testing.T) { + // Withdrawal is the half nobody tests. A consumer that is unassigned must stop appearing in + // what its provider is told to create, or the provider keeps a working login for a machine + // that no longer uses it — and the provisioner's own rule about removing what nobody asks for + // only fires if the mesh stops asking. + inv, ctx := twoNodesWithKeys(t) + if err := inv.RegisterModule(ctx, catalogue.Manifest{ + Module: "meshboard", Version: "1", Requires: []string{"database"}, + }, Source{}); err != nil { + t.Fatal(err) + } + if err := inv.Assign(ctx, "consumer", "meshboard"); err != nil { + t.Fatal(err) + } + if _, err := inv.SecretFor(ctx, "database", "consumer", "provider"); err != nil { + t.Fatal(err) + } + + issued, err := inv.SecretsFrom(ctx, "provider") + if err != nil { + t.Fatal(err) + } + if len(issued) != 1 { + t.Fatalf("the provider was told about %d consumers", len(issued)) + } + + // Unassigned. The secret itself is deliberately NOT deleted here — see below — but nothing + // should now resolve on that machine that wants it. + if err := inv.Unassign(ctx, "consumer", "meshboard"); err != nil { + t.Fatal(err) + } + assigned, err := inv.Assigned(ctx, "consumer") + if err != nil { + t.Fatal(err) + } + if len(assigned) != 0 { + t.Fatalf("the module is still assigned: %v", assigned) + } +} + +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, "database", "consumer", "provider"); err != nil { + t.Fatal(err) + } + if _, err := inv.store.Pool().Exec(ctx, `delete from node where name = 'consumer'`); err != nil { + t.Fatal(err) + } + issued, err := inv.SecretsFrom(ctx, "provider") + if err != nil { + t.Fatal(err) + } + if len(issued) != 0 { + t.Fatalf("a departed machine's credential is still granted: %+v", issued) + } +}