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