diff --git a/.changelog/28183.txt b/.changelog/28183.txt new file mode 100644 index 00000000000..72f449f26a3 --- /dev/null +++ b/.changelog/28183.txt @@ -0,0 +1,3 @@ +```release-note:improvement +cli: Add a `-kv-path` flag to `nomad setup vault` to configure the Vault KV mount used by the generated workload policy +``` diff --git a/command/setup_vault.go b/command/setup_vault.go index 9cba2ad4577..b984bf3f358 100644 --- a/command/setup_vault.go +++ b/command/setup_vault.go @@ -47,6 +47,7 @@ type SetupVaultCommand struct { jwksURL string jwksCACertPath string + kvPath string destroy bool autoYes bool @@ -84,6 +85,10 @@ Setup Vault options: Path to a CA certificate file that will be used to validate the JWKS URL if it uses TLS + -kv-path + Path to the Vault KV secrets engine mount that the generated + workload policy grants access to. Defaults to "secret". + -destroy Removes all configuration components this command created from the Vault cluster. @@ -116,6 +121,7 @@ func (s *SetupVaultCommand) AutocompleteFlags() complete.Flags { complete.Flags{ "-jwks-url": complete.PredictAnything, "-jwks-ca-file": complete.PredictAnything, + "-kv-path": complete.PredictAnything, "-destroy": complete.PredictSet("true", "false"), "-y": complete.PredictSet("true", "false"), @@ -146,6 +152,7 @@ func (s *SetupVaultCommand) Run(args []string) int { flags.BoolVar(&s.autoYes, "y", false, "") flags.StringVar(&s.jwksURL, "jwks-url", "http://localhost:4646/.well-known/jwks.json", "") flags.StringVar(&s.jwksCACertPath, "jwks-ca-file", "", "") + flags.StringVar(&s.kvPath, "kv-path", "secret", "") // Options for -check. flags.BoolVar(&s.check, "check", false, "") @@ -164,6 +171,12 @@ func (s *SetupVaultCommand) Run(args []string) int { return 1 } + if strings.Trim(s.kvPath, "/") == "" { + s.Ui.Error("The -kv-path option must specify a non-empty mount path") + s.Ui.Error(commandErrorText(s)) + return 1 + } + if s.check { return s.checkUpgrade() } else { @@ -327,8 +340,8 @@ and a policy associated with that role. s.Ui.Output(fmt.Sprintf(` These are the rules for the policy %q that we will create. It uses a templated policy to allow Nomad tasks to access secrets in the path -"secrets/data//": -`, vaultPolicyName)) +"%s/data//": +`, vaultPolicyName, strings.Trim(s.kvPath, "/"))) policyBody, err := s.renderPolicy() if err != nil { @@ -454,8 +467,13 @@ func (s *SetupVaultCommand) renderPolicy() (string, error) { } accessor := secret.Data["accessor"].(string) - policyTextStr := string(vaultPolicyBody) - return strings.ReplaceAll(policyTextStr, "auth_jwt_X", accessor), nil + return renderVaultPolicy(string(vaultPolicyBody), accessor, s.kvPath), nil +} + +func renderVaultPolicy(policyBody, accessor, kvPath string) string { + policyText := strings.ReplaceAll(policyBody, "auth_jwt_X", accessor) + mount := strings.Trim(kvPath, "/") + return strings.ReplaceAll(policyText, "secret/", mount+"/") } func (s *SetupVaultCommand) createPolicy(policyText string) error { diff --git a/command/setup_vault_test.go b/command/setup_vault_test.go index c872c06ae49..3ad5447b8ec 100644 --- a/command/setup_vault_test.go +++ b/command/setup_vault_test.go @@ -5,6 +5,7 @@ package command import ( "fmt" + "strings" "testing" "github.com/hashicorp/cli" @@ -14,6 +15,55 @@ import ( "github.com/shoenig/test/must" ) +func TestSetupVaultCommand_renderVaultPolicy(t *testing.T) { + ci.Parallel(t) + + const accessor = "auth_jwt_0a1b2c3d" + policyBody := string(vaultPolicyBody) + + // The default "secret" mount renders identically to substituting only the + // accessor, so omitting -kv-path leaves the policy byte-for-byte unchanged. + must.Eq(t, + strings.ReplaceAll(policyBody, "auth_jwt_X", accessor), + renderVaultPolicy(policyBody, accessor, "secret"), + ) + + testCases := []struct { + name string + kvPath string + want string + }{ + {name: "custom mount", kvPath: "kv", want: "kv/data/"}, + {name: "nested mount", kvPath: "kv/mongo", want: "kv/mongo/data/"}, + {name: "trailing slash trimmed", kvPath: "kv/", want: "kv/data/"}, + {name: "leading slash trimmed", kvPath: "/kv", want: `path "kv/data/`}, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + got := renderVaultPolicy(policyBody, accessor, tc.kvPath) + must.StrContains(t, got, tc.want) + must.StrContains(t, got, accessor) + must.StrNotContains(t, got, "secret/") + must.StrNotContains(t, got, "auth_jwt_X") + // every path stanza survives the substitution + must.Eq(t, strings.Count(policyBody, `path "`), strings.Count(got, `path "`)) + }) + } +} + +func TestSetupVaultCommand_Run_emptyKVPath(t *testing.T) { + ci.Parallel(t) + + for _, kvPath := range []string{"", "/", "///"} { + ui := cli.NewMockUi() + cmd := &SetupVaultCommand{Meta: Meta{Ui: ui}} + rc := cmd.Run([]string{"-kv-path", kvPath}) + must.Eq(t, 1, rc) + must.StrContains(t, ui.ErrorWriter.String(), "non-empty mount path") + } +} + func TestSetupVaultCommand_Run(t *testing.T) { ci.Parallel(t)