From d412f7b49729af4d6d66017c3afd2e8167c74a22 Mon Sep 17 00:00:00 2001 From: Luiz Aoqui Date: Tue, 5 Apr 2022 14:18:10 -0400 Subject: [PATCH] Support Vault entity aliases (#12449) Move some common Vault API data struct decoding out of the Vault client so it can be reused in other situations. Make Vault job validation its own function so it's easier to expand it. Rename the `Job.VaultPolicies` method to just `Job.Vault` since it returns the full Vault block, not just their policies. Set `ChangeMode` on `Vault.Canonicalize`. Add some missing tests. Allows specifying an entity alias that will be used by Nomad when deriving the task Vault token. An entity alias assigns an indentity to a token, allowing better control and management of Vault clients since all tokens with the same indentity alias will now be considered the same client. This helps track Nomad activity in Vault's audit logs and better control over Vault billing. Add support for a new Nomad server configuration to define a default entity alias to be used when deriving Vault tokens. This default value will be used if the task doesn't have an entity alias defined. --- .changelog/12449.txt | 3 + api/tasks.go | 4 + api/tasks_test.go | 27 ++ command/agent/job_endpoint.go | 1 + command/agent/job_endpoint_test.go | 2 + go.mod | 2 +- go.sum | 2 + helper/funcs.go | 8 + helper/funcs_test.go | 11 + jobspec/parse.go | 1 + jobspec/parse_test.go | 9 +- jobspec/test-fixtures/basic.hcl | 5 +- nomad/job_endpoint.go | 45 +- nomad/job_endpoint_hook_vault.go | 187 ++++++++ nomad/job_endpoint_hook_vault_oss.go | 26 ++ nomad/job_endpoint_hooks.go | 10 +- nomad/job_endpoint_oss.go | 18 - nomad/job_endpoint_test.go | 264 ++++++++++- nomad/node_endpoint.go | 12 +- nomad/structs/config/vault.go | 11 + nomad/structs/config/vault_test.go | 9 +- nomad/structs/diff_test.go | 30 ++ nomad/structs/funcs.go | 35 +- nomad/structs/funcs_test.go | 150 ++++++- nomad/structs/structs.go | 24 +- nomad/structs/structs_test.go | 36 +- nomad/structs/vault.go | 75 ++++ nomad/vault.go | 142 +++--- nomad/vault_test.go | 414 +++++++++++++++--- nomad/vault_testing.go | 41 ++ website/content/docs/configuration/vault.mdx | 7 + .../docs/integrations/vault-integration.mdx | 14 + .../content/docs/job-specification/vault.mdx | 19 + 33 files changed, 1411 insertions(+), 233 deletions(-) create mode 100644 .changelog/12449.txt create mode 100644 nomad/job_endpoint_hook_vault.go create mode 100644 nomad/job_endpoint_hook_vault_oss.go create mode 100644 nomad/structs/vault.go diff --git a/.changelog/12449.txt b/.changelog/12449.txt new file mode 100644 index 000000000..86b0f46ff --- /dev/null +++ b/.changelog/12449.txt @@ -0,0 +1,3 @@ +```release-note:improvement +vault: support Vault entity aliases when deriving tokens +``` diff --git a/api/tasks.go b/api/tasks.go index 10629aa2d..5b58caf6a 100644 --- a/api/tasks.go +++ b/api/tasks.go @@ -854,6 +854,7 @@ type Vault struct { Policies []string `hcl:"policies,optional"` Namespace *string `mapstructure:"namespace" hcl:"namespace,optional"` Env *bool `hcl:"env,optional"` + EntityAlias *string `mapstructure:"entity_alias" hcl:"entity_alias,optional"` ChangeMode *string `mapstructure:"change_mode" hcl:"change_mode,optional"` ChangeSignal *string `mapstructure:"change_signal" hcl:"change_signal,optional"` } @@ -865,6 +866,9 @@ func (v *Vault) Canonicalize() { if v.Namespace == nil { v.Namespace = stringToPtr("") } + if v.EntityAlias == nil { + v.EntityAlias = stringToPtr("") + } if v.ChangeMode == nil { v.ChangeMode = stringToPtr("restart") } diff --git a/api/tasks_test.go b/api/tasks_test.go index e9781d1aa..9674389c5 100644 --- a/api/tasks_test.go +++ b/api/tasks_test.go @@ -494,6 +494,33 @@ func TestTask_Template_WaitConfig_Canonicalize_and_Copy(t *testing.T) { } } +func TestTask_Canonicalize_Vault(t *testing.T) { + testCases := []struct { + name string + input *Vault + expected *Vault + }{ + { + name: "empty", + input: &Vault{}, + expected: &Vault{ + Env: boolToPtr(true), + Namespace: stringToPtr(""), + EntityAlias: stringToPtr(""), + ChangeMode: stringToPtr("restart"), + ChangeSignal: stringToPtr("SIGHUP"), + }, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + tc.input.Canonicalize() + require.Equal(t, tc.expected, tc.input) + }) + } +} + // Ensures no regression on https://github.com/hashicorp/nomad/issues/3132 func TestTaskGroup_Canonicalize_Update(t *testing.T) { testutil.Parallel(t) diff --git a/command/agent/job_endpoint.go b/command/agent/job_endpoint.go index e29fe751d..1e07c8768 100644 --- a/command/agent/job_endpoint.go +++ b/command/agent/job_endpoint.go @@ -1197,6 +1197,7 @@ func ApiTaskToStructsTask(job *structs.Job, group *structs.TaskGroup, Policies: apiTask.Vault.Policies, Namespace: *apiTask.Vault.Namespace, Env: *apiTask.Vault.Env, + EntityAlias: *apiTask.Vault.EntityAlias, ChangeMode: *apiTask.Vault.ChangeMode, ChangeSignal: *apiTask.Vault.ChangeSignal, } diff --git a/command/agent/job_endpoint_test.go b/command/agent/job_endpoint_test.go index 3dd9197ec..6678b2a29 100644 --- a/command/agent/job_endpoint_test.go +++ b/command/agent/job_endpoint_test.go @@ -2721,6 +2721,7 @@ func TestJobs_ApiJobToStructsJob(t *testing.T) { Env: helper.BoolToPtr(true), ChangeMode: helper.StringToPtr("c"), ChangeSignal: helper.StringToPtr("sighup"), + EntityAlias: helper.StringToPtr("valid-alias"), }, Templates: []*api.Template{ { @@ -3120,6 +3121,7 @@ func TestJobs_ApiJobToStructsJob(t *testing.T) { Env: true, ChangeMode: "c", ChangeSignal: "sighup", + EntityAlias: "valid-alias", }, Templates: []*structs.Template{ { diff --git a/go.mod b/go.mod index c9ded809f..638b31811 100644 --- a/go.mod +++ b/go.mod @@ -65,6 +65,7 @@ require ( github.com/hashicorp/go-multierror v1.1.1 github.com/hashicorp/go-plugin v1.4.3 github.com/hashicorp/go-secure-stdlib/listenerutil v0.1.4 + github.com/hashicorp/go-secure-stdlib/strutil v0.1.2 github.com/hashicorp/go-sockaddr v1.0.2 github.com/hashicorp/go-syslog v1.0.0 github.com/hashicorp/go-uuid v1.0.2 @@ -205,7 +206,6 @@ require ( github.com/hashicorp/go-secure-stdlib/mlock v0.1.2 // indirect github.com/hashicorp/go-secure-stdlib/parseutil v0.1.4 // indirect github.com/hashicorp/go-secure-stdlib/reloadutil v0.1.1 // indirect - github.com/hashicorp/go-secure-stdlib/strutil v0.1.2 // indirect github.com/hashicorp/go-secure-stdlib/tlsutil v0.1.1 // indirect github.com/hashicorp/mdns v1.0.4 // indirect github.com/hashicorp/vic v1.5.1-0.20190403131502-bbfe86ec9443 // indirect diff --git a/go.sum b/go.sum index 8fdad59ae..cfcf216e4 100644 --- a/go.sum +++ b/go.sum @@ -759,6 +759,8 @@ github.com/hashicorp/go-secure-stdlib/strutil v0.1.2 h1:kes8mmyCpxJsI7FTwtzRqEy9 github.com/hashicorp/go-secure-stdlib/strutil v0.1.2/go.mod h1:Gou2R9+il93BqX25LAKCLuM+y9U2T4hlwvT1yprcna4= github.com/hashicorp/go-secure-stdlib/tlsutil v0.1.1 h1:Yc026VyMyIpq1UWRnakHRG01U8fJm+nEfEmjoAb00n8= github.com/hashicorp/go-secure-stdlib/tlsutil v0.1.1/go.mod h1:l8slYwnJA26yBz+ErHpp2IRCLr0vuOMGBORIz4rRiAs= +github.com/hashicorp/go-secure-stdlib/strutil v0.1.2 h1:kes8mmyCpxJsI7FTwtzRqEy9CdjCtrXrXGuOpxEA7Ts= +github.com/hashicorp/go-secure-stdlib/strutil v0.1.2/go.mod h1:Gou2R9+il93BqX25LAKCLuM+y9U2T4hlwvT1yprcna4= github.com/hashicorp/go-sockaddr v1.0.0/go.mod h1:7Xibr9yA9JjQq1JpNB2Vw7kxv8xerXegt+ozgdvDeDU= github.com/hashicorp/go-sockaddr v1.0.2 h1:ztczhD1jLxIRjVejw8gFomI1BQZOe2WoVOu0SyteCQc= github.com/hashicorp/go-sockaddr v1.0.2/go.mod h1:rB4wwRAUzs07qva3c5SdrY/NEtAUjGlgmH/UkBUC97A= diff --git a/helper/funcs.go b/helper/funcs.go index 166415bde..8c94d49cd 100644 --- a/helper/funcs.go +++ b/helper/funcs.go @@ -171,6 +171,14 @@ func SliceStringToSet(s []string) map[string]struct{} { return m } +func SetToSliceString(set map[string]struct{}) []string { + flattened := make([]string, 0, len(set)) + for x := range set { + flattened = append(flattened, x) + } + return flattened +} + // SliceStringIsSubset returns whether the smaller set of strings is a subset of // the larger. If the smaller slice is not a subset, the offending elements are // returned. diff --git a/helper/funcs_test.go b/helper/funcs_test.go index 0eaad19c4..ddd472dd7 100644 --- a/helper/funcs_test.go +++ b/helper/funcs_test.go @@ -165,6 +165,17 @@ func TestMapStringStringSliceValueSet(t *testing.T) { } } +func TestSetToSliceString(t *testing.T) { + set := map[string]struct{}{ + "foo": {}, + "bar": {}, + "baz": {}, + } + expect := []string{"foo", "bar", "baz"} + got := SetToSliceString(set) + require.ElementsMatch(t, expect, got) +} + func TestCopyMapStringSliceString(t *testing.T) { m := map[string][]string{ "x": {"a", "b", "c"}, diff --git a/jobspec/parse.go b/jobspec/parse.go index aad9d9fbe..09448b601 100644 --- a/jobspec/parse.go +++ b/jobspec/parse.go @@ -510,6 +510,7 @@ func parseVault(result *api.Vault, list *ast.ObjectList) error { "env", "change_mode", "change_signal", + "entity_alias", } if err := checkHCLKeys(listVal, valid); err != nil { return multierror.Prefix(err, "vault ->") diff --git a/jobspec/parse_test.go b/jobspec/parse_test.go index bb6a46556..9c6fc1755 100644 --- a/jobspec/parse_test.go +++ b/jobspec/parse_test.go @@ -349,10 +349,11 @@ func TestParse(t *testing.T) { }, }, Vault: &api.Vault{ - Namespace: stringToPtr("ns1"), - Policies: []string{"foo", "bar"}, - Env: boolToPtr(true), - ChangeMode: stringToPtr(vaultChangeModeRestart), + Namespace: stringToPtr("ns1"), + Policies: []string{"foo", "bar"}, + Env: boolToPtr(true), + ChangeMode: stringToPtr(vaultChangeModeRestart), + EntityAlias: stringToPtr("binstore-task"), }, Templates: []*api.Template{ { diff --git a/jobspec/test-fixtures/basic.hcl b/jobspec/test-fixtures/basic.hcl index 325c5e362..5b1fa4036 100644 --- a/jobspec/test-fixtures/basic.hcl +++ b/jobspec/test-fixtures/basic.hcl @@ -299,8 +299,9 @@ job "binstore-storagelocker" { } vault { - namespace = "ns1" - policies = ["foo", "bar"] + namespace = "ns1" + policies = ["foo", "bar"] + entity_alias = "binstore-task" } template { diff --git a/nomad/job_endpoint.go b/nomad/job_endpoint.go index 69414aed1..f9a4b2497 100644 --- a/nomad/job_endpoint.go +++ b/nomad/job_endpoint.go @@ -11,7 +11,6 @@ import ( "github.com/armon/go-metrics" "github.com/golang/snappy" - "github.com/hashicorp/consul/lib" "github.com/hashicorp/go-hclog" "github.com/hashicorp/go-memdb" "github.com/hashicorp/go-multierror" @@ -70,6 +69,7 @@ func NewJobEndpoints(s *Server) *Job { validators: []jobValidator{ jobConnectHook{}, jobExposeCheckHook{}, + jobVaultHook{srv: s}, jobNamespaceConstraintCheckHook{srv: s}, jobValidate{}, &memoryOversubscriptionValidate{srv: s}, @@ -218,49 +218,6 @@ func (j *Job) Register(args *structs.JobRegisterRequest, reply *structs.JobRegis return err } - // Ensure that the job has permissions for the requested Vault tokens - policies := args.Job.VaultPolicies() - if len(policies) != 0 { - vconf := j.srv.config.VaultConfig - if !vconf.IsEnabled() { - return fmt.Errorf("Vault not enabled and Vault policies requested") - } - - // Have to check if the user has permissions - if !vconf.AllowsUnauthenticated() { - if args.Job.VaultToken == "" { - return fmt.Errorf("Vault policies requested but missing Vault Token") - } - - vault := j.srv.vault - s, err := vault.LookupToken(context.Background(), args.Job.VaultToken) - if err != nil { - return err - } - - allowedPolicies, err := PoliciesFrom(s) - if err != nil { - return err - } - - // Check Namespaces - namespaceErr := j.multiVaultNamespaceValidation(policies, s) - if namespaceErr != nil { - return namespaceErr - } - - // If we are given a root token it can access all policies - if !lib.StrContains(allowedPolicies, "root") { - flatPolicies := structs.VaultPoliciesSet(policies) - subset, offending := helper.SliceStringIsSubset(allowedPolicies, flatPolicies) - if !subset { - return fmt.Errorf("Passed Vault Token doesn't allow access to the following policies: %s", - strings.Join(offending, ", ")) - } - } - } - } - // helper function that checks if the Consul token supplied with the job has // sufficient ACL permissions for: // - registering services into namespace of each group diff --git a/nomad/job_endpoint_hook_vault.go b/nomad/job_endpoint_hook_vault.go new file mode 100644 index 000000000..a2c45301e --- /dev/null +++ b/nomad/job_endpoint_hook_vault.go @@ -0,0 +1,187 @@ +package nomad + +import ( + "context" + "fmt" + "strings" + + "github.com/hashicorp/nomad/helper" + "github.com/hashicorp/nomad/nomad/structs" + vapi "github.com/hashicorp/vault/api" +) + +// jobVaultHook is an job registration admission controllver for Vault blocks. +type jobVaultHook struct { + srv *Server +} + +func (jobVaultHook) Name() string { + return "vault" +} + +func (h jobVaultHook) Validate(job *structs.Job) ([]error, error) { + vaultBlocks := job.Vault() + if len(vaultBlocks) == 0 { + return nil, nil + } + + vconf := h.srv.config.VaultConfig + if !vconf.IsEnabled() { + return nil, fmt.Errorf("Vault not enabled but used in the job") + } + + // Return early if Vault configuration doesn't require authentication. + if vconf.AllowsUnauthenticated() { + return nil, nil + } + + // At this point the job has a vault block and the server requires + // authentication, so check if the user has the right permissions. + if job.VaultToken == "" { + return nil, fmt.Errorf("Vault used in the job but missing Vault token") + } + + tokenSecret, err := h.srv.vault.LookupToken(context.Background(), job.VaultToken) + if err != nil { + return nil, fmt.Errorf("failed to lookup Vault token: %v", err) + } + + // Check namespaces. + err = h.validateNamespaces(vaultBlocks, tokenSecret) + if err != nil { + return nil, err + } + + // Check policies. + err = h.validatePolicies(vaultBlocks, tokenSecret) + if err != nil { + return nil, err + } + + // Check entity aliases. + err = h.validateEntityAliases(vaultBlocks, tokenSecret) + if err != nil { + return nil, err + } + + return nil, nil +} + +// validatePolicies returns an error if the job contains Vault blocks that +// require policies that the requirest token is not allowed to access. +func (jobVaultHook) validatePolicies( + blocks map[string]map[string]*structs.Vault, + token *vapi.Secret, +) error { + + jobPolicies := structs.VaultPoliciesSet(blocks) + if len(jobPolicies) == 0 { + return nil + } + + allowedPolicies, err := token.TokenPolicies() + if err != nil { + return fmt.Errorf("failed to lookup Vault token policies: %v", err) + } + + // If we are given a root token it can access all policies + if helper.SliceStringContains(allowedPolicies, "root") { + return nil + } + + subset, offending := helper.SliceStringIsSubset(allowedPolicies, jobPolicies) + if !subset { + return fmt.Errorf("Vault token doesn't allow access to the following policies: %s", + strings.Join(offending, ", ")) + } + + return nil +} + +// validateEntityAliases returns an error if the job contains Vault blocks that +// use an entity alias that are not allowed to be used. +// +// In order to use entity aliases in a job, the following conditions must +// be met: +// - the token used to submit the job and the Nomad server configuration +// must have a role +// - both roles must allow access to all entity aliases defined in the job +// +// If the Nomad server is configured with a default entity alias, it will +// use that for any Vault block that don't specify one, so: +// - the token used to submit the job must be allowed to use the default +// entity alias +// - except if all Vault blocks in the job define an alias, since in this +// case the server alias would not be used. +func (h jobVaultHook) validateEntityAliases( + blocks map[string]map[string]*structs.Vault, + token *vapi.Secret, +) error { + + // Assign the default entity alias from the server to any vault block with + // no entity alias already set + vconf := h.srv.config.VaultConfig + if vconf.EntityAlias != "" { + for _, task := range blocks { + for _, v := range task { + if v.EntityAlias == "" { + v.EntityAlias = vconf.EntityAlias + } + } + } + } + + aliases := structs.VaultEntityAliasesSet(blocks) + if len(aliases) == 0 { + return nil + } + + var tokenData structs.VaultTokenData + if err := structs.DecodeVaultSecretData(token, &tokenData); err != nil { + return fmt.Errorf("failed to parse Vault token data: %v", err) + } + + // Check if user token allows requested entity aliases. + if tokenData.Role == "" { + return fmt.Errorf("jobs with Vault entity aliases require the Vault token to have a role") + } + if err := h.validateRole(tokenData.Role, aliases); err != nil { + return fmt.Errorf("failed to validate entity alias against Vault token: %v", err) + } + + // Check if Nomad server role allows requested entity aliases. + if vconf.Role == "" { + return fmt.Errorf("jobs with Vault entity aliases require the Nomad server to have a Vault role") + } + if err := h.validateRole(vconf.Role, aliases); err != nil { + return fmt.Errorf("failed to validate entity alias against Nomad server configuration: %v", err) + } + + return nil +} + +// validateRole returns an error if the given role doesn't allow some of the +// aliases to be used. +func (h jobVaultHook) validateRole(role string, aliases []string) error { + s, err := h.srv.vault.LookupTokenRole(context.Background(), role) + if err != nil { + return err + } + + var data structs.VaultTokenRoleData + if err := structs.DecodeVaultSecretData(s, &data); err != nil { + return fmt.Errorf("failed to parse role data: %v", err) + } + + invalidAliases := []string{} + for _, a := range aliases { + if !data.AllowsEntityAlias(a) { + invalidAliases = append(invalidAliases, a) + } + } + if len(invalidAliases) > 0 { + return fmt.Errorf("role doesn't allow access to the following entity aliases: %s", + strings.Join(invalidAliases, ", ")) + } + return nil +} diff --git a/nomad/job_endpoint_hook_vault_oss.go b/nomad/job_endpoint_hook_vault_oss.go new file mode 100644 index 000000000..dab3c1b94 --- /dev/null +++ b/nomad/job_endpoint_hook_vault_oss.go @@ -0,0 +1,26 @@ +//go:build !ent +// +build !ent + +package nomad + +import ( + "fmt" + "strings" + + "github.com/hashicorp/nomad/nomad/structs" + vapi "github.com/hashicorp/vault/api" +) + +// validateNamespaces returns an error if the job contains multiple Vault +// namespaces. +func (jobVaultHook) validateNamespaces( + blocks map[string]map[string]*structs.Vault, + token *vapi.Secret, +) error { + + requestedNamespaces := structs.VaultNamespaceSet(blocks) + if len(requestedNamespaces) > 0 { + return fmt.Errorf("%w, Namespaces: %s", ErrMultipleNamespaces, strings.Join(requestedNamespaces, ", ")) + } + return nil +} diff --git a/nomad/job_endpoint_hooks.go b/nomad/job_endpoint_hooks.go index 61bb42b20..4d1989dfd 100644 --- a/nomad/job_endpoint_hooks.go +++ b/nomad/job_endpoint_hooks.go @@ -125,8 +125,8 @@ func (jobImpliedConstraints) Name() string { } func (jobImpliedConstraints) Mutate(j *structs.Job) (*structs.Job, []error, error) { - // Get the required Vault Policies - policies := j.VaultPolicies() + // Get the Vault blocks in the job + vaultBlocks := j.Vault() // Get the required signals signals := j.RequiredSignals() @@ -135,13 +135,13 @@ func (jobImpliedConstraints) Mutate(j *structs.Job) (*structs.Job, []error, erro nativeServiceDisco := j.RequiredNativeServiceDiscovery() // Hot path - if len(signals) == 0 && len(policies) == 0 && len(nativeServiceDisco) == 0 { + if len(signals) == 0 && len(vaultBlocks) == 0 && len(nativeServiceDisco) == 0 { return j, nil, nil } // Add Vault constraints if no Vault constraint exists for _, tg := range j.TaskGroups { - _, ok := policies[tg.Name] + _, ok := vaultBlocks[tg.Name] if !ok { // Not requesting Vault continue @@ -164,7 +164,7 @@ func (jobImpliedConstraints) Mutate(j *structs.Job) (*structs.Job, []error, erro for _, tg := range j.TaskGroups { tgSignals, ok := signals[tg.Name] if !ok { - // Not requesting Vault + // Not requesting signal continue } diff --git a/nomad/job_endpoint_oss.go b/nomad/job_endpoint_oss.go index d44853818..7f2b56c78 100644 --- a/nomad/job_endpoint_oss.go +++ b/nomad/job_endpoint_oss.go @@ -4,11 +4,7 @@ package nomad import ( - "fmt" - "strings" - "github.com/hashicorp/nomad/nomad/structs" - vapi "github.com/hashicorp/vault/api" ) // enforceSubmitJob is used to check any Sentinel policies for the submit-job scope @@ -42,17 +38,3 @@ func (j *Job) multiregionStop(job *structs.Job, args *structs.JobDeregisterReque func (j *Job) interpolateMultiregionFields(args *structs.JobPlanRequest) error { return nil } - -// multiVaultNamespaceValidation provides a convience check to ensure -// multiple vault namespaces were not requested, this returns an early friendly -// error before job registry and further feature checks. -func (j *Job) multiVaultNamespaceValidation( - policies map[string]map[string]*structs.Vault, - s *vapi.Secret, -) error { - requestedNamespaces := structs.VaultNamespaceSet(policies) - if len(requestedNamespaces) > 0 { - return fmt.Errorf("%w, Namespaces: %s", ErrMultipleNamespaces, strings.Join(requestedNamespaces, ", ")) - } - return nil -} diff --git a/nomad/job_endpoint_test.go b/nomad/job_endpoint_test.go index 8a8dbfa88..05a8a0f33 100644 --- a/nomad/job_endpoint_test.go +++ b/nomad/job_endpoint_test.go @@ -18,6 +18,7 @@ import ( "github.com/hashicorp/nomad/nomad/structs" "github.com/hashicorp/nomad/testutil" "github.com/hashicorp/raft" + vapi "github.com/hashicorp/vault/api" "github.com/kr/pretty" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -1545,7 +1546,7 @@ func TestJobEndpoint_Register_Vault_NoToken(t *testing.T) { // Fetch the response var resp structs.JobRegisterResponse err := msgpackrpc.CallWithCodec(codec, "Job.Register", req, &resp) - if err == nil || !strings.Contains(err.Error(), "missing Vault Token") { + if err == nil || !strings.Contains(err.Error(), "missing Vault token") { t.Fatalf("expected Vault not enabled error: %v", err) } } @@ -1744,6 +1745,265 @@ func TestJobEndpoint_Register_Vault_MultiNamespaces(t *testing.T) { } } +func TestJobEndpoint_Register_Vault_EntityAlias(t *testing.T) { + ci.Parallel(t) + + // Create test jobs. + jobNoVault := mock.Job() + jobNoVault.TaskGroups[0].Tasks[0].Vault = nil + + jobNoAlias := mock.Job() + jobNoVault.TaskGroups[0].Tasks[0].Vault = &structs.Vault{ + Policies: []string{"nomad"}, + } + + jobApp1 := mock.Job() + jobApp1.TaskGroups[0].Tasks[0].Vault = &structs.Vault{ + Policies: []string{"nomad"}, + EntityAlias: "app1", + } + + jobApp2 := mock.Job() + jobApp2.TaskGroups[0].Tasks[0].Vault = &structs.Vault{ + Policies: []string{"nomad"}, + EntityAlias: "app2", + } + + jobApp1App2 := mock.Job() + jobApp1App2.TaskGroups[0].Tasks[0].Vault = &structs.Vault{ + Policies: []string{"nomad"}, + EntityAlias: "app1", + } + jobApp1App2.TaskGroups[0].Tasks = append( + jobApp1App2.TaskGroups[0].Tasks, + jobApp1App2.TaskGroups[0].Tasks[0].Copy()) + jobApp1App2.TaskGroups[0].Tasks[1].Name = "web2" + jobApp1App2.TaskGroups[0].Tasks[1].Vault.EntityAlias = "app2" + + // Create test Vault server. + tvc := &TestVaultClient{} + + // Load Vault roles + tvc.SetLookupTokenRoleSecret("nomad-cluster", &vapi.Secret{ + Data: map[string]interface{}{ + "allowed_entity_aliases": []string{"nomad", "app1", "app2"}, + }, + }) + tvc.SetLookupTokenRoleSecret("nomad-app1", &vapi.Secret{ + Data: map[string]interface{}{ + "allowed_entity_aliases": []string{"nomad", "app1"}, + }, + }) + tvc.SetLookupTokenRoleSecret("nomad-app2", &vapi.Secret{ + Data: map[string]interface{}{ + "allowed_entity_aliases": []string{"app2"}, + }, + }) + tvc.SetLookupTokenRoleSecret("not-nomad", &vapi.Secret{ + Data: map[string]interface{}{ + "allowed_entity_aliases": []string{"not-nomad"}, + }, + }) + tvc.SetLookupTokenRoleSecret("no-alias", &vapi.Secret{ + Data: map[string]interface{}{ + "allowed_entity_aliases": []string{}, + }, + }) + + // Load Vault tokens + tvc.SetLookupTokenSecret("root", &vapi.Secret{ + Data: map[string]interface{}{ + "policies": []string{"root"}, + }, + }) + tvc.SetLookupTokenSecret("nomad-server", &vapi.Secret{ + Data: map[string]interface{}{ + "policies": []string{"root"}, + "role": "nomad-server", + }, + }) + tvc.SetLookupTokenSecret("user-app1", &vapi.Secret{ + Data: map[string]interface{}{ + "policies": []string{"nomad"}, + "role": "nomad-app1", + }, + }) + tvc.SetLookupTokenSecret("user-app2", &vapi.Secret{ + Data: map[string]interface{}{ + "policies": []string{"nomad"}, + "role": "nomad-app2", + }, + }) + tvc.SetLookupTokenSecret("not-allowed", &vapi.Secret{ + Data: map[string]interface{}{ + "policies": []string{"nomad"}, + "role": "not-nomad", + }, + }) + tvc.SetLookupTokenSecret("no-role", &vapi.Secret{ + Data: map[string]interface{}{ + "policies": []string{"nomad"}, + "role": "", + }, + }) + + testCases := []struct { + name string + token string + job *structs.Job + serverConfig func(*Config) + expectedError string + }{ + { + name: "no vault", + token: "not-allowed", + job: jobNoVault, + }, + { + name: "no entity alias", + token: "not-allowed", + job: jobNoAlias, + }, + { + name: "allowed", + token: "user-app1", + job: jobApp1, + }, + { + name: "allowed with multiple of same aliases", + token: "user-app1", + job: func() *structs.Job { + j := jobApp1App2.Copy() + j.TaskGroups[0].Tasks[1].Vault.EntityAlias = "app1" + return j + }(), + }, + { + name: "token without role", + token: "no-role", + job: jobApp1, + expectedError: "jobs with Vault entity aliases require the Vault token to have a role", + }, + { + name: "token without access to alias", + token: "not-allowed", + job: jobApp1, + expectedError: "role doesn't allow access to the following entity aliases: app1", + }, + { + name: "token without access to any aliases", + token: "not-allowed", + job: jobApp1App2, + expectedError: "role doesn't allow access to the following entity aliases", + }, + { + name: "token without access to one of the aliases", + token: "user-app1", + job: jobApp1App2, + expectedError: "role doesn't allow access to the following entity aliases: app2", + }, + { + name: "root taken can't submit without role", + token: "root", + job: jobApp1, + expectedError: "jobs with Vault entity aliases require the Vault token to have a role", + }, + { + name: "server without role", + token: "user-app1", + job: jobApp1, + serverConfig: func(c *Config) { + c.VaultConfig.Token = "root" + c.VaultConfig.Role = "" + }, + expectedError: "jobs with Vault entity aliases require the Nomad server to have a Vault role", + }, + { + name: "server without alias", + token: "user-app1", + job: jobApp1, + serverConfig: func(c *Config) { + c.VaultConfig.Token = "nomad-server" + c.VaultConfig.Role = "not-nomad" + }, + expectedError: "failed to validate entity alias against Nomad server configuration", + }, + { + name: "server with default entity alias and token is allowed to use it", + token: "user-app1", + job: func() *structs.Job { + j := jobApp1.Copy() + j.TaskGroups[0].Tasks[0].Vault.EntityAlias = "" + return j + }(), + }, + { + name: "server with default entity alias but token is not allowed to use it", + token: "user-app2", + job: func() *structs.Job { + j := jobApp1App2.Copy() + j.TaskGroups[0].Tasks[0].Vault.EntityAlias = "" + return j + }(), + serverConfig: func(c *Config) { + c.VaultConfig.EntityAlias = "nomad" + }, + expectedError: "role doesn't allow access to the following entity aliases: nomad", + }, + { + name: "server with default entity alias but job doesn't use it", + token: "user-app2", + job: jobApp2, + serverConfig: func(c *Config) { + c.VaultConfig.EntityAlias = "nomad" + }, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + s, cleanup := TestServer(t, func(c *Config) { + c.VaultConfig.Token = "nomad-server" + c.VaultConfig.Role = "nomad-cluster" + + if tc.serverConfig != nil { + tc.serverConfig(c) + } + }) + defer cleanup() + codec := rpcClient(t, s) + testutil.WaitForLeader(t, s.RPC) + + // Enable vault + s.config.VaultConfig.Enabled = helper.BoolToPtr(true) + s.config.VaultConfig.AllowUnauthenticated = helper.BoolToPtr(false) + + // Replace the Vault Client on the server + s.vault = tvc + + job := tc.job.Copy() + job.VaultToken = tc.token + + req := &structs.JobRegisterRequest{ + Job: job, + WriteRequest: structs.WriteRequest{ + Region: "global", + Namespace: job.Namespace, + }, + } + + var resp structs.JobRegisterResponse + err := msgpackrpc.CallWithCodec(codec, "Job.Register", req, &resp) + if tc.expectedError != "" { + require.Error(t, err) + require.Contains(t, err.Error(), tc.expectedError) + } else { + require.NoError(t, err) + } + }) + } +} + // TestJobEndpoint_Register_SemverConstraint asserts that semver ordering is // used when evaluating semver constraints. func TestJobEndpoint_Register_SemverConstraint(t *testing.T) { @@ -2605,7 +2865,7 @@ func TestJobEndpoint_Revert_Vault_NoToken(t *testing.T) { // Fetch the response err = msgpackrpc.CallWithCodec(codec, "Job.Revert", revertReq, &resp) - if err == nil || !strings.Contains(err.Error(), "missing Vault Token") { + if err == nil || !strings.Contains(err.Error(), "missing Vault token") { t.Fatalf("expected Vault not enabled error: %v", err) } } diff --git a/nomad/node_endpoint.go b/nomad/node_endpoint.go index 045fba0ae..2511cadaa 100644 --- a/nomad/node_endpoint.go +++ b/nomad/node_endpoint.go @@ -1566,15 +1566,15 @@ func (n *Node) DeriveVaultToken(args *structs.DeriveVaultTokenRequest, reply *st return nil } - // Check the policies - policies := alloc.Job.VaultPolicies() - if policies == nil { - setError(fmt.Errorf("Job doesn't require Vault policies"), false) + // Check if alloc has Vault + vaultBlocks := alloc.Job.Vault() + if vaultBlocks == nil { + setError(fmt.Errorf("Job does not require Vault token"), false) return nil } - tg, ok := policies[alloc.TaskGroup] + tg, ok := vaultBlocks[alloc.TaskGroup] if !ok { - setError(fmt.Errorf("Task group does not require Vault policies"), false) + setError(fmt.Errorf("Task group does not require Vault token"), false) return nil } diff --git a/nomad/structs/config/vault.go b/nomad/structs/config/vault.go index 83a239a19..422f61955 100644 --- a/nomad/structs/config/vault.go +++ b/nomad/structs/config/vault.go @@ -38,6 +38,11 @@ type VaultConfig struct { // role the token is from. Role string `hcl:"create_from_role"` + // EntityAlias is the entity alias to use when creating tokens for tasks + // that don't define one. The role used by Nomad must be allowed to use + // this alias. + EntityAlias string `hcl:"create_with_entity_alias"` + // Namespace sets the Vault namespace used for all calls against the // Vault API. If this is unset, then Nomad does not use Vault namespaces. Namespace string `mapstructure:"namespace"` @@ -115,6 +120,9 @@ func (c *VaultConfig) Merge(b *VaultConfig) *VaultConfig { if b.Role != "" { result.Role = b.Role } + if b.EntityAlias != "" { + result.EntityAlias = b.EntityAlias + } if b.TaskTokenTTL != "" { result.TaskTokenTTL = b.TaskTokenTTL } @@ -204,6 +212,9 @@ func (c *VaultConfig) IsEqual(b *VaultConfig) bool { if c.Role != b.Role { return false } + if c.EntityAlias != b.EntityAlias { + return false + } if c.TaskTokenTTL != b.TaskTokenTTL { return false } diff --git a/nomad/structs/config/vault_test.go b/nomad/structs/config/vault_test.go index c4eda801c..6d26397f5 100644 --- a/nomad/structs/config/vault_test.go +++ b/nomad/structs/config/vault_test.go @@ -16,6 +16,7 @@ func TestVaultConfig_Merge(t *testing.T) { Enabled: &falseValue, Token: "1", Role: "1", + EntityAlias: "1", AllowUnauthenticated: &trueValue, TaskTokenTTL: "1", Addr: "1", @@ -31,6 +32,7 @@ func TestVaultConfig_Merge(t *testing.T) { Enabled: &trueValue, Token: "2", Role: "2", + EntityAlias: "2", AllowUnauthenticated: &falseValue, TaskTokenTTL: "2", Addr: "2", @@ -46,6 +48,7 @@ func TestVaultConfig_Merge(t *testing.T) { Enabled: &trueValue, Token: "2", Role: "2", + EntityAlias: "2", AllowUnauthenticated: &falseValue, TaskTokenTTL: "2", Addr: "2", @@ -65,7 +68,7 @@ func TestVaultConfig_Merge(t *testing.T) { func TestVaultConfig_IsEqual(t *testing.T) { ci.Parallel(t) - + require := require.New(t) trueValue, falseValue := true, false @@ -73,6 +76,7 @@ func TestVaultConfig_IsEqual(t *testing.T) { Enabled: &falseValue, Token: "1", Role: "1", + EntityAlias: "1", AllowUnauthenticated: &trueValue, TaskTokenTTL: "1", Addr: "1", @@ -88,6 +92,7 @@ func TestVaultConfig_IsEqual(t *testing.T) { Enabled: &falseValue, Token: "1", Role: "1", + EntityAlias: "1", AllowUnauthenticated: &trueValue, TaskTokenTTL: "1", Addr: "1", @@ -105,6 +110,7 @@ func TestVaultConfig_IsEqual(t *testing.T) { Enabled: &trueValue, Token: "1", Role: "1", + EntityAlias: "1", AllowUnauthenticated: &trueValue, TaskTokenTTL: "1", Addr: "1", @@ -120,6 +126,7 @@ func TestVaultConfig_IsEqual(t *testing.T) { Enabled: &falseValue, Token: "1", Role: "1", + EntityAlias: "1", AllowUnauthenticated: &trueValue, TaskTokenTTL: "1", Addr: "1", diff --git a/nomad/structs/diff_test.go b/nomad/structs/diff_test.go index 93b658a26..b7a8336d3 100644 --- a/nomad/structs/diff_test.go +++ b/nomad/structs/diff_test.go @@ -6649,6 +6649,7 @@ func TestTaskDiff(t *testing.T) { Env: true, ChangeMode: "signal", ChangeSignal: "SIGUSR1", + EntityAlias: "alias", }, }, Expected: &TaskDiff{ @@ -6670,6 +6671,12 @@ func TestTaskDiff(t *testing.T) { Old: "", New: "SIGUSR1", }, + { + Type: DiffTypeAdded, + Name: "EntityAlias", + Old: "", + New: "alias", + }, { Type: DiffTypeAdded, Name: "Env", @@ -6709,6 +6716,7 @@ func TestTaskDiff(t *testing.T) { Env: true, ChangeMode: "signal", ChangeSignal: "SIGUSR1", + EntityAlias: "alias", }, }, New: &Task{}, @@ -6731,6 +6739,12 @@ func TestTaskDiff(t *testing.T) { Old: "SIGUSR1", New: "", }, + { + Type: DiffTypeDeleted, + Name: "EntityAlias", + Old: "alias", + New: "", + }, { Type: DiffTypeDeleted, Name: "Env", @@ -6771,6 +6785,7 @@ func TestTaskDiff(t *testing.T) { Env: true, ChangeMode: "signal", ChangeSignal: "SIGUSR1", + EntityAlias: "old-alias", }, }, New: &Task{ @@ -6780,6 +6795,7 @@ func TestTaskDiff(t *testing.T) { Env: false, ChangeMode: "restart", ChangeSignal: "foo", + EntityAlias: "new-alias", }, }, Expected: &TaskDiff{ @@ -6801,6 +6817,12 @@ func TestTaskDiff(t *testing.T) { Old: "SIGUSR1", New: "foo", }, + { + Type: DiffTypeEdited, + Name: "EntityAlias", + Old: "old-alias", + New: "new-alias", + }, { Type: DiffTypeEdited, Name: "Env", @@ -6848,6 +6870,7 @@ func TestTaskDiff(t *testing.T) { Env: true, ChangeMode: "signal", ChangeSignal: "SIGUSR1", + EntityAlias: "alias", }, }, New: &Task{ @@ -6857,6 +6880,7 @@ func TestTaskDiff(t *testing.T) { Env: true, ChangeMode: "signal", ChangeSignal: "SIGUSR1", + EntityAlias: "alias", }, }, Expected: &TaskDiff{ @@ -6878,6 +6902,12 @@ func TestTaskDiff(t *testing.T) { Old: "SIGUSR1", New: "SIGUSR1", }, + { + Type: DiffTypeNone, + Name: "EntityAlias", + Old: "alias", + New: "alias", + }, { Type: DiffTypeNone, Name: "Env", diff --git a/nomad/structs/funcs.go b/nomad/structs/funcs.go index 5fe4d4c6e..18678d191 100644 --- a/nomad/structs/funcs.go +++ b/nomad/structs/funcs.go @@ -13,6 +13,7 @@ import ( multierror "github.com/hashicorp/go-multierror" lru "github.com/hashicorp/golang-lru" "github.com/hashicorp/nomad/acl" + "github.com/hashicorp/nomad/helper" "golang.org/x/crypto/blake2b" ) @@ -349,17 +350,15 @@ func VaultPoliciesSet(policies map[string]map[string]*Vault) []string { for _, tgp := range policies { for _, tp := range tgp { - for _, p := range tp.Policies { - set[p] = struct{}{} + if tp != nil { + for _, p := range tp.Policies { + set[p] = struct{}{} + } } } } - flattened := make([]string, 0, len(set)) - for p := range set { - flattened = append(flattened, p) - } - return flattened + return helper.SetToSliceString(set) } // VaultNamespaceSet takes the structure returned by VaultPolicies and @@ -369,17 +368,29 @@ func VaultNamespaceSet(policies map[string]map[string]*Vault) []string { for _, tgp := range policies { for _, tp := range tgp { - if tp.Namespace != "" { + if tp != nil && tp.Namespace != "" { set[tp.Namespace] = struct{}{} } } } - flattened := make([]string, 0, len(set)) - for p := range set { - flattened = append(flattened, p) + return helper.SetToSliceString(set) +} + +// VaultEntityAliasesSet takes the structure returned by VaultPolicies and +// returns a set of required entity aliases. +func VaultEntityAliasesSet(blocks map[string]map[string]*Vault) []string { + set := make(map[string]struct{}) + + for _, task := range blocks { + for _, vault := range task { + if vault != nil && vault.EntityAlias != "" { + set[vault.EntityAlias] = struct{}{} + } + } } - return flattened + + return helper.SetToSliceString(set) } // DenormalizeAllocationJobs is used to attach a job to all allocations that are diff --git a/nomad/structs/funcs_test.go b/nomad/structs/funcs_test.go index a36a36c58..4fb7153ce 100644 --- a/nomad/structs/funcs_test.go +++ b/nomad/structs/funcs_test.go @@ -912,10 +912,158 @@ func TestMergeMultierrorWarnings(t *testing.T) { require.Equal(t, "2 warning(s):\n\n* foo\n* bar", str) } +func TestVaultPoliciesSet(t *testing.T) { + input := map[string]map[string]*Vault{ + "tg1": { + "task1": { + Policies: []string{"policy1-1"}, + }, + "task2": { + Policies: []string{"policy1-2"}, + }, + }, + "tg2": { + "task1": { + Policies: []string{"policy2"}, + }, + "task2": { + Policies: []string{"policy2"}, + }, + }, + "tg3": { + "task1": { + Policies: []string{"policy3-1"}, + }, + }, + "tg4": { + "task1": nil, + }, + "tg5": { + "task1": { + Policies: []string{"policy2"}, + }, + }, + "tg6": { + "task1": {}, + }, + "tg7": { + "task1": { + Policies: []string{"policy7", "policy7"}, + }, + }, + "tg8": { + "task1": { + Policies: []string{"policy8-1-1", "policy8-1-2"}, + }, + }, + } + expected := []string{ + "policy1-1", + "policy1-2", + "policy2", + "policy3-1", + "policy7", + "policy8-1-1", + "policy8-1-2", + } + got := VaultPoliciesSet(input) + require.ElementsMatch(t, expected, got) +} + +func TestVaultNamespaceSet(t *testing.T) { + input := map[string]map[string]*Vault{ + "tg1": { + "task1": { + Namespace: "ns1-1", + }, + "task2": { + Namespace: "ns1-2", + }, + }, + "tg2": { + "task1": { + Namespace: "ns2", + }, + "task2": { + Namespace: "ns2", + }, + }, + "tg3": { + "task1": { + Namespace: "ns3-1", + }, + }, + "tg4": { + "task1": nil, + }, + "tg5": { + "task1": { + Namespace: "ns2", + }, + }, + "tg6": { + "task1": {}, + }, + } + expected := []string{ + "ns1-1", + "ns1-2", + "ns2", + "ns3-1", + } + got := VaultNamespaceSet(input) + require.ElementsMatch(t, expected, got) +} + +func TestVaultEntityAliasesSet(t *testing.T) { + input := map[string]map[string]*Vault{ + "tg1": { + "task1": { + EntityAlias: "alias1-1", + }, + "task2": { + EntityAlias: "alias1-2", + }, + }, + "tg2": { + "task1": { + EntityAlias: "alias2", + }, + "task2": { + EntityAlias: "alias2", + }, + }, + "tg3": { + "task1": { + EntityAlias: "alias3-1", + }, + }, + "tg4": { + "task1": nil, + }, + "tg5": { + "task1": { + EntityAlias: "alias2", + }, + }, + "tg6": { + "task1": {}, + }, + } + expected := []string{ + "alias1-1", + "alias1-2", + "alias2", + "alias3-1", + } + got := VaultEntityAliasesSet(input) + require.ElementsMatch(t, expected, got) +} + // TestParsePortRanges asserts ParsePortRanges errors on invalid port ranges. func TestParsePortRanges(t *testing.T) { ci.Parallel(t) - + cases := []struct { name string spec string diff --git a/nomad/structs/structs.go b/nomad/structs/structs.go index bbbfc45fe..d1a6523b3 100644 --- a/nomad/structs/structs.go +++ b/nomad/structs/structs.go @@ -4581,27 +4581,27 @@ func (j *Job) IsPlugin() bool { return false } -// VaultPolicies returns the set of Vault policies per task group, per task -func (j *Job) VaultPolicies() map[string]map[string]*Vault { - policies := make(map[string]map[string]*Vault, len(j.TaskGroups)) +// Vault returns the set of Vault blocks per task group, per task +func (j *Job) Vault() map[string]map[string]*Vault { + blocks := make(map[string]map[string]*Vault, len(j.TaskGroups)) for _, tg := range j.TaskGroups { - tgPolicies := make(map[string]*Vault, len(tg.Tasks)) + tgBlocks := make(map[string]*Vault, len(tg.Tasks)) for _, task := range tg.Tasks { if task.Vault == nil { continue } - tgPolicies[task.Name] = task.Vault + tgBlocks[task.Name] = task.Vault } - if len(tgPolicies) != 0 { - policies[tg.Name] = tgPolicies + if len(tgBlocks) != 0 { + blocks[tg.Name] = tgBlocks } } - return policies + return blocks } // ConnectTasks returns the set of Consul Connect enabled tasks defined on the @@ -8933,6 +8933,10 @@ type Vault struct { // ChangeSignal is the signal sent to the task when a new token is // retrieved. This is only valid when using the signal change mode. ChangeSignal string + + // EntityAlias is passed to Vault when creating a token to associate that + // token with an entity. + EntityAlias string } func DefaultVaultBlock() *Vault { @@ -8957,6 +8961,10 @@ func (v *Vault) Canonicalize() { if v.ChangeSignal != "" { v.ChangeSignal = strings.ToUpper(v.ChangeSignal) } + + if v.ChangeMode == "" { + v.ChangeMode = VaultChangeModeRestart + } } // Validate returns if the Vault block is valid. diff --git a/nomad/structs/structs_test.go b/nomad/structs/structs_test.go index 3422608e4..f04b8b9a8 100644 --- a/nomad/structs/structs_test.go +++ b/nomad/structs/structs_test.go @@ -566,7 +566,7 @@ func TestJob_SystemJob_Validate(t *testing.T) { } -func TestJob_VaultPolicies(t *testing.T) { +func TestJob_Vault(t *testing.T) { ci.Parallel(t) j0 := &Job{} @@ -588,6 +588,7 @@ func TestJob_VaultPolicies(t *testing.T) { Policies: []string{ "p5", }, + EntityAlias: "alias1", } j1 := &Job{ TaskGroups: []*TaskGroup{ @@ -644,7 +645,7 @@ func TestJob_VaultPolicies(t *testing.T) { } for i, c := range cases { - got := c.Job.VaultPolicies() + got := c.Job.Vault() if !reflect.DeepEqual(got, c.Expected) { t.Fatalf("case %d: got %#v; want %#v", i+1, got, c.Expected) } @@ -5485,6 +5486,37 @@ func TestVault_Validate(t *testing.T) { } } +func TestVault_Copy(t *testing.T) { + v := &Vault{ + Policies: []string{"policy1", "policy2"}, + Namespace: "ns1", + Env: false, + ChangeMode: "noop", + ChangeSignal: "SIGKILL", + EntityAlias: "alias1", + } + + // Copy and modify. + vc := v.Copy() + vc.Policies[0] = "policy0" + vc.Namespace = "ns2" + vc.Env = true + vc.ChangeMode = "signal" + vc.ChangeSignal = "SIGHUP" + vc.EntityAlias = "alias2" + + require.NotEqual(t, v, vc) +} + +func TestVault_Canonicalize(t *testing.T) { + v := &Vault{ + ChangeSignal: "sighup", + } + v.Canonicalize() + require.Equal(t, "SIGHUP", v.ChangeSignal) + require.Equal(t, VaultChangeModeRestart, v.ChangeMode) +} + func TestParameterizedJobConfig_Validate(t *testing.T) { ci.Parallel(t) diff --git a/nomad/structs/vault.go b/nomad/structs/vault.go new file mode 100644 index 000000000..1674c996b --- /dev/null +++ b/nomad/structs/vault.go @@ -0,0 +1,75 @@ +package structs + +import ( + "fmt" + "strings" + + "github.com/hashicorp/go-secure-stdlib/strutil" + vapi "github.com/hashicorp/vault/api" + "github.com/mitchellh/mapstructure" +) + +// VaultTokenData represents some of the fields returned in the Data map of the +// sercret returned by the Vault API when doing a token lookup request. +type VaultTokenData struct { + CreationTTL int `mapstructure:"creation_ttl"` + TTL int `mapstructure:"ttl"` + Renewable bool `mapstructure:"renewable"` + Policies []string `mapstructure:"policies"` + Role string `mapstructure:"role"` + NamespacePath string `mapstructure:"namespace_path"` + + // root caches if the token has the "root" policy to avoid travesring the + // policies list every time. + root *bool +} + +// Root returns true if the token has the `root` policy. +func (d VaultTokenData) Root() bool { + if d.root != nil { + return *d.root + } + + root := strutil.StrListContains(d.Policies, "root") + d.root = &root + + return root +} + +// VaultTokenRoleData represents some of the fields returned in the Data map of +// the sercret returned by the Vault API when reading a token role. +type VaultTokenRoleData struct { + Name string `mapstructure:"name"` + ExplicitMaxTtl int `mapstructure:"explicit_max_ttl"` + TokenExplicitMaxTtl int `mapstructure:"token_explicit_max_ttl"` + Orphan bool + Period int + TokenPeriod int `mapstructure:"token_period"` + Renewable bool + DisallowedPolicies []string `mapstructure:"disallowed_policies"` + AllowedEntityAliases []string `mapstructure:"allowed_entity_aliases"` + AllowedPolicies []string `mapstructure:"allowed_policies"` +} + +// AllowsEntityAlias returns true if the token role allows the given entity +// alias to be used when creating a token. +// It applies the same checks as in: +// https://github.com/hashicorp/vault/blob/v1.10.0/vault/token_store.go#L2569-L2578 +func (d VaultTokenRoleData) AllowsEntityAlias(alias string) bool { + lowcaseAlias := strings.ToLower(alias) + return strutil.StrListContains(d.AllowedEntityAliases, lowcaseAlias) || + strutil.StrListContainsGlob(d.AllowedEntityAliases, lowcaseAlias) +} + +// DecodeVaultSecretData decodes a Vault sercret Data map into a struct. +func DecodeVaultSecretData(s *vapi.Secret, out interface{}) error { + if s == nil { + return fmt.Errorf("cannot decode nil Vault secret") + } + + if err := mapstructure.WeakDecode(s.Data, &out); err != nil { + return err + } + + return nil +} diff --git a/nomad/vault.go b/nomad/vault.go index c5012fa9b..e63deb525 100644 --- a/nomad/vault.go +++ b/nomad/vault.go @@ -20,7 +20,6 @@ import ( "github.com/hashicorp/nomad/nomad/structs" "github.com/hashicorp/nomad/nomad/structs/config" vapi "github.com/hashicorp/vault/api" - "github.com/mitchellh/mapstructure" "golang.org/x/sync/errgroup" "golang.org/x/time/rate" @@ -119,6 +118,9 @@ type VaultClient interface { // LookupToken takes a token string and returns its capabilities. LookupToken(ctx context.Context, token string) (*vapi.Secret, error) + // LookupTokenRole takes a role name string and returns its data. + LookupTokenRole(ctx context.Context, role string) (*vapi.Secret, error) + // RevokeTokens takes a set of tokens accessor and revokes the tokens RevokeTokens(ctx context.Context, accessors []*structs.VaultAccessor, committed bool) error @@ -158,18 +160,6 @@ type VaultStats struct { // will retry till there is a success type PurgeVaultAccessorFn func(accessors []*structs.VaultAccessor) error -// tokenData holds the relevant information about the Vault token passed to the -// client. -type tokenData struct { - CreationTTL int `mapstructure:"creation_ttl"` - TTL int `mapstructure:"ttl"` - Renewable bool `mapstructure:"renewable"` - Policies []string `mapstructure:"policies"` - Role string `mapstructure:"role"` - NamespacePath string `mapstructure:"namespace_path"` - Root bool -} - // vaultClient is the Servers implementation of the VaultClient interface. The // client renews the PeriodicToken given in the Vault configuration and provides // the Server with the ability to create child tokens and lookup the permissions @@ -207,7 +197,7 @@ type vaultClient struct { token string // tokenData is the data of the passed Vault token - tokenData *tokenData + tokenData *structs.VaultTokenData // revoking tracks the VaultAccessors that must be revoked revoking map[*structs.VaultAccessor]time.Time @@ -511,7 +501,7 @@ OUTER: v.client.SetWrappingLookupFunc(v.getWrappingFn()) // If we are given a non-root token, start renewing it - if v.tokenData.Root && v.tokenData.CreationTTL == 0 { + if v.tokenData.Root() && v.tokenData.CreationTTL == 0 { v.logger.Debug("not renewing token as it is root") } else { v.logger.Debug("starting renewal loop", "creation_ttl", time.Duration(v.tokenData.CreationTTL)*time.Second) @@ -701,18 +691,10 @@ func (v *vaultClient) parseSelfToken() error { } // Read and parse the fields - var data tokenData - if err := mapstructure.WeakDecode(secret.Data, &data); err != nil { + var data structs.VaultTokenData + if err := structs.DecodeVaultSecretData(secret, &data); err != nil { return fmt.Errorf("failed to parse Vault token's data block: %v", err) } - root := false - for _, p := range data.Policies { - if p == "root" { - root = true - break - } - } - data.Root = root v.tokenData = &data v.extendExpiration(data.TTL) @@ -729,11 +711,13 @@ func (v *vaultClient) parseSelfToken() error { // 1) Must allow tokens to be renewed // 2) Must not have an explicit max TTL // 3) Must have non-zero period - // 5) If not configured against a role, the token must be root + // 4) Must allow entity alias if one is defined + // 5) Must have a role if an entity alias is defined + // 6) If not configured against a role, the token must be root var mErr multierror.Error role := v.getRole() - if !data.Root { + if !data.Root() { // All non-root tokens must be renewable if !data.Renewable { _ = multierror.Append(&mErr, fmt.Errorf("Vault token is not renewable or root")) @@ -765,7 +749,7 @@ func (v *vaultClient) parseSelfToken() error { } // Check we have the correct capabilities - if err := v.validateCapabilities(role, data.Root); err != nil { + if err := v.validateCapabilities(role, data.Root()); err != nil { _ = multierror.Append(&mErr, err) } @@ -774,6 +758,9 @@ func (v *vaultClient) parseSelfToken() error { if err := v.validateRole(role); err != nil { _ = multierror.Append(&mErr, err) } + } else if v.config.EntityAlias != "" { + // If entity alias is defined the server must also have a role. + _ = multierror.Append(&mErr, fmt.Errorf("Role must be set to create tokens using an entity alias")) } return mErr.ErrorOrNil() @@ -904,15 +891,8 @@ func (v *vaultClient) validateRole(role string) error { } // Read and parse the fields - var data struct { - ExplicitMaxTtl int `mapstructure:"explicit_max_ttl"` - TokenExplicitMaxTtl int `mapstructure:"token_explicit_max_ttl"` - Orphan bool - Period int - TokenPeriod int `mapstructure:"token_period"` - Renewable bool - } - if err := mapstructure.WeakDecode(rsecret.Data, &data); err != nil { + var data structs.VaultTokenRoleData + if err := structs.DecodeVaultSecretData(rsecret, &data); err != nil { return fmt.Errorf("failed to parse Vault role's data block: %v", err) } @@ -930,6 +910,12 @@ func (v *vaultClient) validateRole(role string) error { _ = multierror.Append(&mErr, fmt.Errorf("Role must have a non-zero period to make tokens periodic.")) } + // If an entity alias is used, the role must be allowed to use it. + alias := v.config.EntityAlias + if alias != "" && !data.AllowsEntityAlias(alias) { + _ = multierror.Append(&mErr, fmt.Errorf("Role must allow entity alias %s to be used.", alias)) + } + return mErr.ErrorOrNil() } @@ -974,17 +960,17 @@ func (v *vaultClient) CreateToken(ctx context.Context, a *structs.Allocation, ta defer metrics.MeasureSince([]string{"nomad", "vault", "create_token"}, time.Now()) // Retrieve the Vault block for the task - policies := a.Job.VaultPolicies() - if policies == nil { - return nil, fmt.Errorf("Job doesn't require Vault policies") + vaultBlocks := a.Job.Vault() + if vaultBlocks == nil { + return nil, fmt.Errorf("Job does not require Vault token") } - tg, ok := policies[a.TaskGroup] + tg, ok := vaultBlocks[a.TaskGroup] if !ok { - return nil, fmt.Errorf("Task group does not require Vault policies") + return nil, fmt.Errorf("Task group does not require Vault token") } taskVault, ok := tg[task] if !ok { - return nil, fmt.Errorf("Task does not require Vault policies") + return nil, fmt.Errorf("Task does not require Vault token") } // Set namespace for task @@ -1008,6 +994,21 @@ func (v *vaultClient) CreateToken(ctx context.Context, a *structs.Allocation, ta DisplayName: fmt.Sprintf("%s-%s", a.ID, task), } + // If the task defines an entity alias, the Nomad server must have a role + // to be able to derive a token. + role := v.getRole() + if taskVault.EntityAlias != "" { + if role == "" { + return nil, fmt.Errorf("task defines a Vault entity alias, but the Nomad server does not have a Vault role") + } + req.EntityAlias = taskVault.EntityAlias + } else if v.config.EntityAlias != "" { + if role == "" { + return nil, fmt.Errorf("Vault configuration defines an entity alias, but the Nomad server does not have a Vault role") + } + req.EntityAlias = v.config.EntityAlias + } + // Ensure we are under our rate limit if err := v.limiter.Wait(ctx); err != nil { return nil, err @@ -1017,7 +1018,6 @@ func (v *vaultClient) CreateToken(ctx context.Context, a *structs.Allocation, ta // token or a role based token var secret *vapi.Secret var err error - role := v.getRole() // Fetch client for task taskClient, err := v.entHandler.clientForTask(v, namespaceForTask) @@ -1025,12 +1025,12 @@ func (v *vaultClient) CreateToken(ctx context.Context, a *structs.Allocation, ta return nil, err } - if v.tokenData.Root && role == "" { + if v.tokenData.Root() && role == "" { req.Period = v.childTTL secret, err = taskClient.Auth().Token().Create(req) } else { // Make the token using the role - secret, err = taskClient.Auth().Token().CreateWithRole(req, v.getRole()) + secret, err = taskClient.Auth().Token().CreateWithRole(req, role) } // Determine whether it is unrecoverable @@ -1092,25 +1092,41 @@ func (v *vaultClient) LookupToken(ctx context.Context, token string) (*vapi.Secr return v.auth.Lookup(token) } -// PoliciesFrom parses the set of policies returned by a token lookup. -func PoliciesFrom(s *vapi.Secret) ([]string, error) { - return s.TokenPolicies() -} - -// PolicyDataFrom parses the Data returned by a token lookup. -// It should not be used to parse TokenPolicies as the list will not be -// exhaustive. -func PolicyDataFrom(s *vapi.Secret) (tokenData, error) { - if s == nil { - return tokenData{}, fmt.Errorf("cannot parse nil Vault secret") - } - var data tokenData - - if err := mapstructure.WeakDecode(s.Data, &data); err != nil { - return tokenData{}, fmt.Errorf("failed to parse Vault token's data block: %v", err) +// LookupTokenRole takes a Vault token role and does a lookup against Vault. +// The call is rate limited and may be canceled with passed context. +func (v *vaultClient) LookupTokenRole(ctx context.Context, role string) (*vapi.Secret, error) { + if !v.Enabled() { + return nil, fmt.Errorf("Vault integration disabled") } - return data, nil + if !v.Active() { + return nil, fmt.Errorf("Vault client not active") + } + + // Check if we have established a connection with Vault + if established, err := v.ConnectionEstablished(); !established && err == nil { + return nil, structs.NewRecoverableError(fmt.Errorf("Connection to Vault has not been established"), true) + } else if err != nil { + return nil, err + } + + // Track how long the request takes + defer metrics.MeasureSince([]string{"nomad", "vault", "lookup_token_role"}, time.Now()) + + // Ensure we are under our rate limit + if err := v.limiter.Wait(ctx); err != nil { + return nil, err + } + + resp, err := v.client.Logical().Read(fmt.Sprintf("auth/token/roles/%s", role)) + if err != nil { + return nil, fmt.Errorf("failed to lookup role: %v", err) + } + if resp == nil { + return nil, fmt.Errorf("role %q does not exist", role) + } + + return resp, nil } // RevokeTokens revokes the passed set of accessors. If committed is set, the diff --git a/nomad/vault_test.go b/nomad/vault_test.go index 86532858e..311e2722f 100644 --- a/nomad/vault_test.go +++ b/nomad/vault_test.go @@ -72,9 +72,9 @@ path "secret/*" { ` ) -// defaultTestVaultWhitelistRoleAndToken creates a test Vault role and returns a token +// defaultTestVaultAllowlistRoleAndToken creates a test Vault role and returns a token // created in that role -func defaultTestVaultWhitelistRoleAndToken(v *testutil.TestVault, t *testing.T, rolePeriod int) string { +func defaultTestVaultAllowlistRoleAndToken(v *testutil.TestVault, t *testing.T, rolePeriod int) string { vaultPolicies := map[string]string{ "nomad-role-create": nomadRoleCreatePolicy, "nomad-role-management": nomadRoleManagementPolicy, @@ -82,13 +82,14 @@ func defaultTestVaultWhitelistRoleAndToken(v *testutil.TestVault, t *testing.T, d := make(map[string]interface{}, 2) d["allowed_policies"] = "nomad-role-create,nomad-role-management" d["period"] = rolePeriod + d["allowed_entity_aliases"] = []string{"valid-entity-alias"} return testVaultRoleAndToken(v, t, vaultPolicies, d, []string{"nomad-role-create", "nomad-role-management"}) } -// defaultTestVaultBlacklistRoleAndToken creates a test Vault role using +// defaultTestVaultDenylistRoleAndToken creates a test Vault role using // disallowed_policies and returns a token created in that role -func defaultTestVaultBlacklistRoleAndToken(v *testutil.TestVault, t *testing.T, rolePeriod int) string { +func defaultTestVaultDenylistRoleAndToken(v *testutil.TestVault, t *testing.T, rolePeriod int) string { vaultPolicies := map[string]string{ "nomad-role-create": nomadRoleCreatePolicy, "nomad-role-management": nomadRoleManagementPolicy, @@ -430,7 +431,7 @@ func TestVaultClient_ValidateRole_NonExistent(t *testing.T) { v := testutil.NewTestVault(t) defer v.Stop() - v.Config.Token = defaultTestVaultWhitelistRoleAndToken(v, t, 5) + v.Config.Token = defaultTestVaultAllowlistRoleAndToken(v, t, 5) v.Config.Token = v.RootToken logger := testlog.HCLogger(t) v.Config.ConnectionRetryIntv = 100 * time.Millisecond @@ -465,6 +466,96 @@ func TestVaultClient_ValidateRole_NonExistent(t *testing.T) { } } +func TestVaultClient_ValidateRole_EntityAlias(t *testing.T) { + ci.Parallel(t) + v := testutil.NewTestVault(t) + defer v.Stop() + + testCases := []struct { + name string + allowedEntityAlises []string + serverEntityAlias string + expectError string + }{ + { + name: "success", + allowedEntityAlises: []string{"valid-entity-alias"}, + serverEntityAlias: "valid-entity-alias", + }, + { + name: "no default alias", + allowedEntityAlises: []string{"valid-entity-alias"}, + serverEntityAlias: "", + }, + { + name: "no allowed alias and no default", + allowedEntityAlises: []string{}, + serverEntityAlias: "", + }, + { + name: "no allowed alias with default", + allowedEntityAlises: []string{}, + serverEntityAlias: "valid-entity-alias", + expectError: "Role must allow entity alias valid-entity-alias to be used.", + }, + { + name: "default entity alias not allowed", + allowedEntityAlises: []string{"valid-entity-alias"}, + serverEntityAlias: "not-valid-entity-alias", + expectError: "Role must allow entity alias not-valid-entity-alias to be used.", + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + // Set the configs token in a new test role + vaultPolicies := map[string]string{ + "nomad-role-create": nomadRoleCreatePolicy, + "nomad-role-management": nomadRoleManagementPolicy, + } + data := map[string]interface{}{ + "allowed_policies": "default,root", + "allowed_entity_aliases": tc.allowedEntityAlises, + "orphan": true, + "renewable": true, + "token_period": 1000, + } + v.Config.Token = testVaultRoleAndToken(v, t, vaultPolicies, data, nil) + v.Config.EntityAlias = tc.serverEntityAlias + v.Config.ConnectionRetryIntv = 100 * time.Millisecond + + logger := testlog.HCLogger(t) + client, err := NewVaultClient(v.Config, logger, nil, nil) + require.NoError(t, err) + + defer client.Stop() + + // Wait for an error + var conn bool + var connErr error + testutil.WaitForResult(func() (bool, error) { + conn, connErr = client.ConnectionEstablished() + if !conn { + return false, fmt.Errorf("Should connect") + } + + if connErr != nil { + return false, connErr + } + + return true, nil + }, func(err error) { + if tc.expectError != "" { + require.Error(t, err) + require.Contains(t, err.Error(), tc.expectError) + } else { + require.NoError(t, err) + } + }) + }) + } +} + func TestVaultClient_ValidateToken(t *testing.T) { ci.Parallel(t) v := testutil.NewTestVault(t) @@ -558,7 +649,7 @@ func TestVaultClient_SetConfig(t *testing.T) { defer v2.Stop() // Set the configs token in a new test role - v2.Config.Token = defaultTestVaultWhitelistRoleAndToken(v2, t, 20) + v2.Config.Token = defaultTestVaultAllowlistRoleAndToken(v2, t, 20) logger := testlog.HCLogger(t) client, err := NewVaultClient(v.Config, logger, nil, nil) @@ -621,7 +712,7 @@ func TestVaultClient_SetConfig_Deadlock(t *testing.T) { defer v2.Stop() // Set the configs token in a new test role - v2.Config.Token = defaultTestVaultWhitelistRoleAndToken(v2, t, 20) + v2.Config.Token = defaultTestVaultAllowlistRoleAndToken(v2, t, 20) logger := testlog.HCLogger(t) client, err := NewVaultClient(v.Config, logger, nil, nil) @@ -683,7 +774,7 @@ func TestVaultClient_RenewalLoop(t *testing.T) { defer v.Stop() // Set the configs token in a new test role - v.Config.Token = defaultTestVaultWhitelistRoleAndToken(v, t, 5) + v.Config.Token = defaultTestVaultAllowlistRoleAndToken(v, t, 5) // Start the client logger := testlog.HCLogger(t) @@ -719,7 +810,7 @@ func TestVaultClientRenewUpdatesExpiration(t *testing.T) { defer v.Stop() // Set the configs token in a new test role - v.Config.Token = defaultTestVaultWhitelistRoleAndToken(v, t, 5) + v.Config.Token = defaultTestVaultAllowlistRoleAndToken(v, t, 5) // Start the client logger := testlog.HCLogger(t) @@ -758,7 +849,7 @@ func TestVaultClient_StopsAfterPermissionError(t *testing.T) { defer v.Stop() // Set the configs token in a new test role - v.Config.Token = defaultTestVaultWhitelistRoleAndToken(v, t, 2) + v.Config.Token = defaultTestVaultAllowlistRoleAndToken(v, t, 2) // Start the client logger := testlog.HCLogger(t) @@ -792,7 +883,7 @@ func TestVaultClient_LoopsUntilCannotRenew(t *testing.T) { defer v.Stop() // Set the configs token in a new test role - v.Config.Token = defaultTestVaultWhitelistRoleAndToken(v, t, 5) + v.Config.Token = defaultTestVaultAllowlistRoleAndToken(v, t, 5) // Start the client logger := testlog.HCLogger(t) @@ -892,7 +983,7 @@ func TestVaultClient_LookupToken_Root(t *testing.T) { t.Fatalf("self lookup failed: %v", err) } - policies, err := PoliciesFrom(s) + policies, err := s.TokenPolicies() if err != nil { t.Fatalf("failed to parse policies: %v", err) } @@ -923,7 +1014,7 @@ func TestVaultClient_LookupToken_Root(t *testing.T) { t.Fatalf("self lookup failed: %v", err) } - policies, err = PoliciesFrom(s) + policies, err = s.TokenPolicies() if err != nil { t.Fatalf("failed to parse policies: %v", err) } @@ -939,7 +1030,7 @@ func TestVaultClient_LookupToken_Role(t *testing.T) { defer v.Stop() // Set the configs token in a new test role - v.Config.Token = defaultTestVaultWhitelistRoleAndToken(v, t, 5) + v.Config.Token = defaultTestVaultAllowlistRoleAndToken(v, t, 5) logger := testlog.HCLogger(t) client, err := NewVaultClient(v.Config, logger, nil, nil) @@ -957,7 +1048,7 @@ func TestVaultClient_LookupToken_Role(t *testing.T) { t.Fatalf("self lookup failed: %v", err) } - policies, err := PoliciesFrom(s) + policies, err := s.TokenPolicies() if err != nil { t.Fatalf("failed to parse policies: %v", err) } @@ -988,7 +1079,7 @@ func TestVaultClient_LookupToken_Role(t *testing.T) { t.Fatalf("self lookup failed: %v", err) } - policies, err = PoliciesFrom(s) + policies, err = s.TokenPolicies() if err != nil { t.Fatalf("failed to parse policies: %v", err) } @@ -1014,49 +1105,118 @@ func TestVaultClient_LookupToken_RateLimit(t *testing.T) { waitForConnection(client, t) client.setLimit(rate.Limit(1.0)) + testRateLimit(t, 20, client, func(ctx context.Context) error { + // Lookup ourselves + _, err := client.LookupToken(ctx, v.Config.Token) + return err + }) +} - // Spin up many requests. These should block - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() +func TestVaultClient_LookupTokenRole(t *testing.T) { + // ci.Parallel(t) + v := testutil.NewTestVault(t) + defer v.Stop() - cancels := 0 - numRequests := 20 - unblock := make(chan struct{}) - for i := 0; i < numRequests; i++ { - go func() { - // Lookup ourselves - _, err := client.LookupToken(ctx, v.Config.Token) - if err != nil { - if err == context.Canceled { - cancels += 1 - return - } - t.Errorf("self lookup failed: %v", err) - return + logger := testlog.HCLogger(t) + + // Create test role. + _, err := v.Client.Logical().Write("auth/token/roles/nomad", map[string]interface{}{ + "name": "nomad", + }) + require.NoError(t, err) + + testCases := []struct { + name string + dontWait bool + config *config.VaultConfig + run func(*testing.T, *vaultClient) + }{ + { + name: "read role", + run: func(t *testing.T, client *vaultClient) { + s, err := client.LookupTokenRole(context.Background(), "nomad") + require.NoError(t, err) + require.Equal(t, "nomad", s.Data["name"]) + }, + }, + { + name: "not enabled", + dontWait: true, + config: &config.VaultConfig{ + Enabled: helper.BoolToPtr(false), + }, + run: func(t *testing.T, client *vaultClient) { + client.SetActive(false) + _, err := client.LookupTokenRole(context.Background(), "nomad") + require.Error(t, err) + require.Contains(t, err.Error(), "disabled") + }, + }, + { + name: "not active", + run: func(t *testing.T, client *vaultClient) { + client.SetActive(false) + _, err := client.LookupTokenRole(context.Background(), "nomad") + require.Error(t, err) + require.Contains(t, err.Error(), "not active") + }, + }, + { + name: "fail to establish connection", + dontWait: true, + config: &config.VaultConfig{ + Addr: "http://foobar:12345", + Token: uuid.Generate(), + }, + run: func(t *testing.T, client *vaultClient) { + _, err := client.LookupTokenRole(context.Background(), "nomad") + require.Error(t, err) + require.Contains(t, err.Error(), "Connection to Vault has not been established") + }, + }, + { + name: "read non-existing role", + run: func(t *testing.T, client *vaultClient) { + _, err := client.LookupTokenRole(context.Background(), "invalid") + require.Error(t, err) + require.Contains(t, err.Error(), "does not exist") + }, + }, + { + name: "rate limit", + run: func(t *testing.T, client *vaultClient) { + client.setLimit(rate.Limit(1.0)) + + testRateLimit(t, 20, client, func(ctx context.Context) error { + // Lookup role + _, err := client.LookupTokenRole(ctx, "nomad") + return err + }) + }, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + config := v.Config + if tc.config != nil { + config = config.Merge(tc.config) } - // Cancel the context - close(unblock) - }() + client, err := NewVaultClient(config, logger, nil, nil) + require.NoError(t, err) + client.SetActive(true) + defer client.Stop() + + if !tc.dontWait { + waitForConnection(client, t) + } + + if tc.run != nil { + tc.run(t, client) + } + }) } - - select { - case <-time.After(5 * time.Second): - t.Fatalf("timeout") - case <-unblock: - cancel() - } - - desired := numRequests - 1 - testutil.WaitForResult(func() (bool, error) { - if desired-cancels > 2 { - return false, fmt.Errorf("Incorrect number of cancels; got %d; want %d", cancels, desired) - } - - return true, nil - }, func(err error) { - t.Fatal(err) - }) } func TestVaultClient_CreateToken_Root(t *testing.T) { @@ -1103,13 +1263,14 @@ func TestVaultClient_CreateToken_Root(t *testing.T) { } } -func TestVaultClient_CreateToken_Whitelist_Role(t *testing.T) { +func TestVaultClient_CreateToken_Allowlist_Role(t *testing.T) { ci.Parallel(t) + v := testutil.NewTestVault(t) defer v.Stop() // Set the configs token in a new test role - v.Config.Token = defaultTestVaultWhitelistRoleAndToken(v, t, 5) + v.Config.Token = defaultTestVaultAllowlistRoleAndToken(v, t, 5) // Start the client logger := testlog.HCLogger(t) @@ -1157,7 +1318,7 @@ func TestVaultClient_CreateToken_Root_Target_Role(t *testing.T) { defer v.Stop() // Create the test role - defaultTestVaultWhitelistRoleAndToken(v, t, 5) + defaultTestVaultAllowlistRoleAndToken(v, t, 5) // Target the test role v.Config.Role = "test" @@ -1202,8 +1363,9 @@ func TestVaultClient_CreateToken_Root_Target_Role(t *testing.T) { } } -func TestVaultClient_CreateToken_Blacklist_Role(t *testing.T) { +func TestVaultClient_CreateToken_Denylist_Role(t *testing.T) { ci.Parallel(t) + // Need to skip if test is 0.6.4 version, err := testutil.VaultVersion() if err != nil { @@ -1218,7 +1380,7 @@ func TestVaultClient_CreateToken_Blacklist_Role(t *testing.T) { defer v.Stop() // Set the configs token in a new test role - v.Config.Token = defaultTestVaultBlacklistRoleAndToken(v, t, 5) + v.Config.Token = defaultTestVaultDenylistRoleAndToken(v, t, 5) v.Config.Role = "test" // Start the client @@ -1267,7 +1429,7 @@ func TestVaultClient_CreateToken_Role_InvalidToken(t *testing.T) { defer v.Stop() // Set the configs token in a new test role - defaultTestVaultWhitelistRoleAndToken(v, t, 5) + defaultTestVaultAllowlistRoleAndToken(v, t, 5) v.Config.Token = "foo-bar" // Start the client @@ -1306,7 +1468,7 @@ func TestVaultClient_CreateToken_Role_Unrecoverable(t *testing.T) { defer v.Stop() // Set the configs token in a new test role - v.Config.Token = defaultTestVaultWhitelistRoleAndToken(v, t, 5) + v.Config.Token = defaultTestVaultAllowlistRoleAndToken(v, t, 5) // Start the client logger := testlog.HCLogger(t) @@ -1439,6 +1601,87 @@ func TestVaultClient_RevokeTokens_PreEstablishs(t *testing.T) { } } +func TestVaultClient_CreateToken_EntityAlias(t *testing.T) { + ci.Parallel(t) + + logger := testlog.HCLogger(t) + v := testutil.NewTestVault(t) + defer v.Stop() + + testCases := []struct { + name string + entityAlias string + serverEntityAlias string + noRole bool + expectError string + requireEntityID bool + }{ + { + name: "success", + entityAlias: "valid-entity-alias", + requireEntityID: true, + }, + { + name: "invalid entity alias", + entityAlias: "not-valid-entity-alias", + expectError: "invalid 'entity_alias'", + }, + { + name: "token without role", + noRole: true, + requireEntityID: false, + }, + { + name: "use server entity alias", + entityAlias: "", + serverEntityAlias: "valid-entity-alias", + requireEntityID: true, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + if !tc.noRole { + v.Config.Token = defaultTestVaultAllowlistRoleAndToken(v, t, 5) + } + if tc.serverEntityAlias != "" { + v.Config.EntityAlias = tc.serverEntityAlias + } + + client, err := NewVaultClient(v.Config, logger, nil, nil) + require.NoError(t, err) + client.SetActive(true) + defer client.Stop() + + waitForConnection(client, t) + + // Create test alloc and set vault block. + alloc := mock.Alloc() + task := alloc.Job.TaskGroups[0].Tasks[0] + task.Vault = &structs.Vault{ + Policies: []string{"default"}, + EntityAlias: tc.entityAlias, + } + + s, err := client.CreateToken(context.Background(), alloc, task.Name) + + if tc.expectError != "" { + require.Error(t, err) + require.Contains(t, err.Error(), tc.expectError) + } else { + require.NoError(t, err) + + // Unwrap token from its cubbyhole. + unwrapToken, err := client.client.Logical().Unwrap(s.WrapInfo.Token) + require.NoError(t, err) + if tc.requireEntityID { + require.NotEmpty(t, unwrapToken.Auth.EntityID) + } + } + }) + } +} + // TestVaultClient_RevokeTokens_Failures_TTL asserts that // the registered TTL doesn't get extended on retries func TestVaultClient_RevokeTokens_Failures_TTL(t *testing.T) { @@ -1556,7 +1799,7 @@ func TestVaultClient_RevokeTokens_Role(t *testing.T) { defer v.Stop() // Set the configs token in a new test role - v.Config.Token = defaultTestVaultWhitelistRoleAndToken(v, t, 5) + v.Config.Token = defaultTestVaultAllowlistRoleAndToken(v, t, 5) purged := 0 purge := func(accessors []*structs.VaultAccessor) error { @@ -1625,7 +1868,7 @@ func TestVaultClient_RevokeTokens_Idempotent(t *testing.T) { defer v.Stop() // Set the configs token in a new test role - v.Config.Token = defaultTestVaultWhitelistRoleAndToken(v, t, 5) + v.Config.Token = defaultTestVaultAllowlistRoleAndToken(v, t, 5) purged := map[string]struct{}{} purge := func(accessors []*structs.VaultAccessor) error { @@ -1705,7 +1948,7 @@ func TestVaultClient_RevokeDaemon_Bounded(t *testing.T) { defer v.Stop() // Set the configs token in a new test role - v.Config.Token = defaultTestVaultWhitelistRoleAndToken(v, t, 5) + v.Config.Token = defaultTestVaultAllowlistRoleAndToken(v, t, 5) // Disable client until we can change settings for testing conf := v.Config.Copy() @@ -1820,3 +2063,46 @@ func TestVaultClient_nextBackoff(t *testing.T) { } }) } + +func testRateLimit(t *testing.T, count int, client *vaultClient, fn func(context.Context) error) { + // Spin up many requests. These should block + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + cancels := 0 + unblock := make(chan struct{}) + for i := 0; i < count; i++ { + go func() { + err := fn(ctx) + if err != nil { + if err == context.Canceled { + cancels += 1 + return + } + t.Errorf("request failed: %v", err) + return + } + + // Cancel the context + close(unblock) + }() + } + + select { + case <-time.After(5 * time.Second): + t.Fatalf("timeout") + case <-unblock: + cancel() + } + + desired := count - 1 + testutil.WaitForResult(func() (bool, error) { + if desired-cancels > 2 { + return false, fmt.Errorf("Incorrect number of cancels; got %d; want %d", cancels, desired) + } + + return true, nil + }, func(err error) { + t.Fatal(err) + }) +} diff --git a/nomad/vault_testing.go b/nomad/vault_testing.go index 857cd5215..db1e386a2 100644 --- a/nomad/vault_testing.go +++ b/nomad/vault_testing.go @@ -21,6 +21,14 @@ type TestVaultClient struct { // by the LookupToken call LookupTokenSecret map[string]*vapi.Secret + // LookupTokenRoleErrors maps a token role name to an error that will be + // returned by the LookupTokenRole call + LookupTokenRoleErrors map[string]error + + // LookupTokenRoleSecret maps a token role name to the Vault secret that + // will be returned by the LookupTokenRole call + LookupTokenRoleSecret map[string]*vapi.Secret + // CreateTokenErrors maps a token to an error that will be returned by the // CreateToken call CreateTokenErrors map[string]map[string]error @@ -46,6 +54,20 @@ func (v *TestVaultClient) LookupToken(ctx context.Context, token string) (*vapi. return secret, err } +func (v *TestVaultClient) LookupTokenRole(ctx context.Context, role string) (*vapi.Secret, error) { + var secret *vapi.Secret + var err error + + if v.LookupTokenRoleSecret != nil { + secret = v.LookupTokenRoleSecret[role] + } + if v.LookupTokenRoleErrors != nil { + err = v.LookupTokenRoleErrors[role] + } + + return secret, err +} + // SetLookupTokenError sets the error that will be returned by the token // lookup func (v *TestVaultClient) SetLookupTokenError(token string, err error) { @@ -78,6 +100,25 @@ func (v *TestVaultClient) SetLookupTokenAllowedPolicies(token string, policies [ v.SetLookupTokenSecret(token, s) } +// SetLookupTokenRoleError sets the error that will be returned by the role +// lookup. +func (v *TestVaultClient) SetLookupTokenRoleError(token string, err error) { + if v.LookupTokenRoleErrors == nil { + v.LookupTokenRoleErrors = make(map[string]error) + } + + v.LookupTokenRoleErrors[token] = err +} + +// SetLookupTokenRoleSecret sets the secret that will be returned by the role +// lookup. +func (v *TestVaultClient) SetLookupTokenRoleSecret(role string, secret *vapi.Secret) { + if v.LookupTokenRoleSecret == nil { + v.LookupTokenRoleSecret = make(map[string]*vapi.Secret) + } + v.LookupTokenRoleSecret[role] = secret +} + func (v *TestVaultClient) CreateToken(ctx context.Context, a *structs.Allocation, task string) (*vapi.Secret, error) { var secret *vapi.Secret var err error diff --git a/website/content/docs/configuration/vault.mdx b/website/content/docs/configuration/vault.mdx index 825366878..87aa59bc8 100644 --- a/website/content/docs/configuration/vault.mdx +++ b/website/content/docs/configuration/vault.mdx @@ -46,6 +46,12 @@ vault { compatibility. It is recommended to set the `create_from_role` field if Nomad is deriving child tokens from a role. +- `create_with_entity_alias` `(string: "")` - Specifies the entity alias to use + when creating tokens. If empty, tasks that don't define their own entity + alias will receive a token not associated with any entity. The role set in + `create_from_role`, or the Nomad server token role, must have this entity + alias in its [`allowed_entity_aliases`] list. + - `task_token_ttl` `(string: "72h")` - Specifies the TTL of created tokens when using a root token. This is specified using a label suffix like "30s" or "1h". @@ -148,5 +154,6 @@ The Vault configuration can be reloaded on servers. This can be useful if a new token needs to be given to the servers without having to restart them. A reload can be accomplished by sending the process a `SIGHUP` signal. +[`allowed_entity_aliases`]: https://www.vaultproject.io/api-docs/auth/token#allowed_entity_aliases [vault]: https://www.vaultproject.io/ 'Vault by HashiCorp' [nomad-vault]: /docs/vault-integration 'Nomad Vault Integration' diff --git a/website/content/docs/integrations/vault-integration.mdx b/website/content/docs/integrations/vault-integration.mdx index aecfc383e..81883226e 100644 --- a/website/content/docs/integrations/vault-integration.mdx +++ b/website/content/docs/integrations/vault-integration.mdx @@ -137,11 +137,17 @@ not in the `disallowed_policies` list. There are trade-offs to both approaches but generally it is easier to use the denylist approach and add policies that you would not like tasks to have access to into the `disallowed_policies` list. +For tasks to receive tokens associated with a Vault entity, the role must +include a list of `allowed_entity_aliases` indicating the entity aliases +allowed for use. This field supports globbing to cover multiple entity aliases, +refer to the Vault documentation for more information. + An example token role definition is given below: ```json { "disallowed_policies": "nomad-server", + "allowed_entity_aliases": ["nomad-cluster", "nomad-app-*"], "token_explicit_max_ttl": 0, "name": "nomad-cluster", "orphan": true, @@ -174,6 +180,12 @@ documentation for all possible fields and more complete documentation. Nomad. This was remedied in 0.6.5 and does not effect earlier versions of Vault. +- `allowed_entity_aliases` - Specifies the entity aliases that can be used to + create a token for the task. This list must include any entity alias expected + to be used in a job [`vault.entity_alias`] field and, if defined, the alias + set in the [`create_with_entity_alias`] server configuration. This field + supports globbing for matching multiple values. + - `token_explicit_max_ttl` - Specifies the max TTL of a token. **Must be set to `0`** to allow periodic tokens. @@ -463,9 +475,11 @@ $ VAULT_TOKEN=s.H39hfS7eHSbb1GpkdzOQLTmz.fvuLy nomad job run vault.nomad [auth]: https://www.vaultproject.io/docs/auth/token 'Vault Authentication Backend' [config]: /docs/configuration/vault 'Nomad Vault Configuration Block' [createfromrole]: /docs/configuration/vault#create_from_role 'Nomad vault create_from_role Configuration Flag' +[`create_with_entity_alias`]: /docs/configuration/vault#create_with_entity_alias [template]: /docs/job-specification/template 'Nomad template Job Specification' [vault]: https://www.vaultproject.io/ 'Vault by HashiCorp' [vault-spec]: /docs/job-specification/vault 'Nomad Vault Job Specification' +[`vault.entity_alias`]: /docs/job-specification/vault#entity_alias [tokenhierarchy]: https://www.vaultproject.io/docs/concepts/tokens#token-hierarchies-and-orphan-tokens 'Vault Tokens - Token Hierarchies and Orphan Tokens' [vault-secrets-version]: https://www.vaultproject.io/docs/secrets/kv 'KV Secrets Engine' [vault-kv-templates]: /docs/job-specification/template#vault-kv-api-v1 'Vault KV API v1' diff --git a/website/content/docs/job-specification/vault.mdx b/website/content/docs/job-specification/vault.mdx index e6ab5ac6a..1ef121859 100644 --- a/website/content/docs/job-specification/vault.mdx +++ b/website/content/docs/job-specification/vault.mdx @@ -74,6 +74,10 @@ with Vault as well. to use for the task. The Nomad client will retrieve a Vault token that is scoped to this particular namespace. +- `entity_alias` `(string: "")` - Specifies the entity alias to use when creating + Vault tokens for the task. This allows multiple tokens to be created as a single + [Vault client][vault_client]. + - `policies` `(array: [])` - Specifies the set of Vault policies that the task requires. The Nomad client will retrieve a Vault token that is limited to those policies. @@ -109,6 +113,19 @@ vault { } ``` +### Entity Alias + +This example shows a task that will receive a token created with a specified +entity alias. If [`allow_unauthenticated`] is set to `true`, the token used to +register this job must be allowed to use the `frontend` entity alias. + +```hcl +vault { + policies = ["frontend"] + entity_alias = "frontend" +} +``` + ### Vault Namespace This example shows specifying a particular Vault namespace for a given task. @@ -125,6 +142,8 @@ vault { } ``` +[`allow_unauthenticated`]: /docs/configuration/vault#allow_unauthenticated [restart]: /docs/job-specification/restart 'Nomad restart Job Specification' [template]: /docs/job-specification/template 'Nomad template Job Specification' [vault]: https://www.vaultproject.io/ 'Vault by HashiCorp' +[vault_client]: https://www.vaultproject.io/docs/concepts/client-count