From 3534307d0d3a9979318182d212930b637cc4d483 Mon Sep 17 00:00:00 2001 From: Luiz Aoqui Date: Tue, 12 Sep 2023 13:53:37 -0300 Subject: [PATCH] vault: add `use_identity` and `default_identity` agent configuration and implicit workload identity (#18343) --- command/agent/config_parse.go | 36 ++++ command/agent/config_parse_test.go | 16 ++ command/agent/testdata/basic.hcl | 8 + command/agent/testdata/basic.json | 9 +- nomad/config.go | 17 ++ .../job_endpoint_hook_implicit_identities.go | 37 +++- ..._endpoint_hook_implicit_identities_test.go | 154 ++++++++++++++ nomad/job_endpoint_hooks.go | 84 ++++++-- nomad/job_endpoint_hooks_test.go | 197 ++++++++++++++++-- nomad/structs/config/vault.go | 168 +++++++++------ nomad/structs/config/vault_test.go | 38 ++++ nomad/structs/structs.go | 13 +- nomad/structs/structs_test.go | 10 +- 13 files changed, 680 insertions(+), 107 deletions(-) diff --git a/command/agent/config_parse.go b/command/agent/config_parse.go index 389eb2832..b14777ba1 100644 --- a/command/agent/config_parse.go +++ b/command/agent/config_parse.go @@ -199,6 +199,17 @@ func ParseConfigFile(path string) (*Config, error) { } } + for name, vaultConfig := range c.Vaults { + if vaultConfig.DefaultIdentity != nil { + tds = append(tds, durationConversionMap{ + fmt.Sprintf("vaults.%s.default_identity.ttl", name), nil, &vaultConfig.DefaultIdentity.TTLHCL, + func(d *time.Duration) { + vaultConfig.DefaultIdentity.TTL = d + }, + }) + } + } + // Add enterprise audit sinks for time.Duration parsing for i, sink := range c.Audit.Sinks { tds = append(tds, durationConversionMap{ @@ -370,6 +381,9 @@ func parseVaults(c *Config, list *ast.ObjectList) error { if err := hcl.DecodeObject(&m, obj.Val); err != nil { return err } + + delete(m, "default_identity") + v := &config.VaultConfig{} err := mapstructure.WeakDecode(m, v) if err != nil { @@ -383,6 +397,28 @@ func parseVaults(c *Config, list *ast.ObjectList) error { } else { c.Vaults[v.Name] = v } + + // Decode the default identity. + var listVal *ast.ObjectList + if ot, ok := obj.Val.(*ast.ObjectType); ok { + listVal = ot.List + } else { + return fmt.Errorf("should be an object") + } + + if o := listVal.Filter("default_identity"); len(o.Items) > 0 { + var m map[string]interface{} + defaultIdentityBlock := o.Items[0] + if err := hcl.DecodeObject(&m, defaultIdentityBlock.Val); err != nil { + return err + } + + var defaultIdentity config.WorkloadIdentityConfig + if err := mapstructure.WeakDecode(m, &defaultIdentity); err != nil { + return err + } + c.Vaults[v.Name].DefaultIdentity = &defaultIdentity + } } c.Vault = c.Vaults["default"] diff --git a/command/agent/config_parse_test.go b/command/agent/config_parse_test.go index 5ffdd9568..587d2255d 100644 --- a/command/agent/config_parse_test.go +++ b/command/agent/config_parse_test.go @@ -307,6 +307,14 @@ var basicConfig = &Config{ TLSSkipVerify: &trueValue, TaskTokenTTL: "1s", Token: "12345", + UseIdentity: pointer.Of(true), + DefaultIdentity: &config.WorkloadIdentityConfig{ + Audience: []string{"vault.io", "nomad.io"}, + Env: pointer.Of(false), + File: pointer.Of(true), + TTL: pointer.Of(3 * time.Hour), + TTLHCL: "3h", + }, }, Vaults: map[string]*config.VaultConfig{ "default": { @@ -324,6 +332,14 @@ var basicConfig = &Config{ TLSSkipVerify: &trueValue, TaskTokenTTL: "1s", Token: "12345", + UseIdentity: pointer.Of(true), + DefaultIdentity: &config.WorkloadIdentityConfig{ + Audience: []string{"vault.io", "nomad.io"}, + Env: pointer.Of(false), + File: pointer.Of(true), + TTL: pointer.Of(3 * time.Hour), + TTLHCL: "3h", + }, }, }, TLSConfig: &config.TLSConfig{ diff --git a/command/agent/testdata/basic.hcl b/command/agent/testdata/basic.hcl index c1dc20e6a..e9c48a5e4 100644 --- a/command/agent/testdata/basic.hcl +++ b/command/agent/testdata/basic.hcl @@ -271,6 +271,14 @@ vault { tls_server_name = "foobar" tls_skip_verify = true create_from_role = "test_role" + use_identity = true + + default_identity { + aud = ["vault.io", "nomad.io"] + env = false + file = true + ttl = "3h" + } } tls { diff --git a/command/agent/testdata/basic.json b/command/agent/testdata/basic.json index 9f05fede2..2723bb427 100644 --- a/command/agent/testdata/basic.json +++ b/command/agent/testdata/basic.json @@ -389,12 +389,19 @@ "ca_path": "/path/to/ca", "cert_file": "/path/to/cert/file", "create_from_role": "test_role", + "default_identity": { + "aud": ["vault.io", "nomad.io"], + "env": false, + "file": true, + "ttl": "3h" + }, "enabled": false, "key_file": "/path/to/key/file", "task_token_ttl": "1s", "tls_server_name": "foobar", "tls_skip_verify": true, - "token": "12345" + "token": "12345", + "use_identity": true } ] } diff --git a/nomad/config.go b/nomad/config.go index 116ce70d2..1cca718f5 100644 --- a/nomad/config.go +++ b/nomad/config.go @@ -483,6 +483,16 @@ func (c *Config) ConsulTemplateIdentity() *structs.WorkloadIdentity { return workloadIdentityFromConfig(c.ConsulConfig.TemplateIdentity) } +// VaultDefaultIdentity returns the workload identity to be used for accessing +// the Vault API. +func (c *Config) VaultDefaultIdentity() *structs.WorkloadIdentity { + if c.VaultConfig == nil { + return nil + } + + return workloadIdentityFromConfig(c.VaultConfig.DefaultIdentity) +} + // UseConsulIdentity returns true when Consul workload identity is enabled. func (c *Config) UseConsulIdentity() bool { return c.ConsulConfig != nil && @@ -490,6 +500,13 @@ func (c *Config) UseConsulIdentity() bool { *c.ConsulConfig.UseIdentity } +// UseVaultIdentity returns true when Vault workload identity is enabled. +func (c *Config) UseVaultIdentity() bool { + return c.VaultConfig != nil && + c.VaultConfig.UseIdentity != nil && + *c.VaultConfig.UseIdentity +} + // workloadIdentityFromConfig returns a structs.WorkloadIdentity to be used in // a job from a config.WorkloadIdentityConfig parsed from an agent config file. func workloadIdentityFromConfig(widConfig *config.WorkloadIdentityConfig) *structs.WorkloadIdentity { diff --git a/nomad/job_endpoint_hook_implicit_identities.go b/nomad/job_endpoint_hook_implicit_identities.go index 82cc9d060..ba938ff37 100644 --- a/nomad/job_endpoint_hook_implicit_identities.go +++ b/nomad/job_endpoint_hook_implicit_identities.go @@ -9,8 +9,9 @@ import ( "github.com/hashicorp/nomad/nomad/structs" ) -var ( +const ( consulServiceIdentityNamePrefix = "consul-service" + vaultIdentityName = "vault" ) // jobImplicitIdentitiesHook adds implicit `identity` blocks for external @@ -33,6 +34,7 @@ func (h jobImplicitIdentitiesHook) Mutate(job *structs.Job) (*structs.Job, []err for _, s := range t.Services { h.handleConsulService(s) } + h.handleVault(t) } } @@ -74,3 +76,36 @@ func (h jobImplicitIdentitiesHook) handleConsulService(s *structs.Service) { s.Identity = serviceWID } + +// handleVault injects a workload identity to the task if: +// 1. The task has a Vault block. +// 2. The server is configured with `vault.use_identity = true` and a +// `vault.default_identity` is provided. +// +// If the task already has an identity named `vault` it sets the identity name +// to the expected value. +func (h jobImplicitIdentitiesHook) handleVault(t *structs.Task) { + if !h.srv.config.UseVaultIdentity() || t.Vault == nil { + return + } + + // Use the Vault identity specified in the task. + for _, wid := range t.Identities { + if wid.Name == vaultIdentityName { + return + } + } + + // If the task doesn't specify an identity for Vault, fallback to the + // default identity defined in the server configuration. + vaultWID := h.srv.config.VaultDefaultIdentity() + if vaultWID == nil { + // If no identity is found skip inject the implicit identity and + // fallback to the legacy flow. + return + } + + // Set the expected identity name and inject it into the task. + vaultWID.Name = vaultIdentityName + t.Identities = append(t.Identities, vaultWID) +} diff --git a/nomad/job_endpoint_hook_implicit_identities_test.go b/nomad/job_endpoint_hook_implicit_identities_test.go index 809dba121..c6b8f35a6 100644 --- a/nomad/job_endpoint_hook_implicit_identities_test.go +++ b/nomad/job_endpoint_hook_implicit_identities_test.go @@ -238,3 +238,157 @@ func Test_jobImplicitIndentitiesHook_Mutate_consul_service(t *testing.T) { }) } } + +func Test_jobImplicitIndentitiesHook_Mutate_vault(t *testing.T) { + ci.Parallel(t) + + testCases := []struct { + name string + inputJob *structs.Job + inputConfig *Config + expectedOutputJob *structs.Job + }{ + { + name: "no mutation when task does not have a vault block", + inputJob: &structs.Job{ + TaskGroups: []*structs.TaskGroup{{ + Tasks: []*structs.Task{{}}, + }}, + }, + inputConfig: &Config{ + VaultConfig: &config.VaultConfig{ + UseIdentity: pointer.Of(true), + DefaultIdentity: &config.WorkloadIdentityConfig{ + Audience: []string{"vault.io"}, + }, + }, + }, + expectedOutputJob: &structs.Job{ + TaskGroups: []*structs.TaskGroup{{ + Tasks: []*structs.Task{{}}, + }}, + }, + }, + { + name: "no mutation when vault identity is disabled", + inputJob: &structs.Job{ + TaskGroups: []*structs.TaskGroup{{ + Tasks: []*structs.Task{{ + Vault: &structs.Vault{}, + }}, + }}, + }, + inputConfig: &Config{ + VaultConfig: &config.VaultConfig{ + UseIdentity: pointer.Of(false), + DefaultIdentity: &config.WorkloadIdentityConfig{ + Audience: []string{"vault.io"}, + }, + }, + }, + expectedOutputJob: &structs.Job{ + TaskGroups: []*structs.TaskGroup{{ + Tasks: []*structs.Task{{ + Vault: &structs.Vault{}, + }}, + }}, + }, + }, + { + name: "no mutation when vault identity is enabled but no default identity is configured", + inputJob: &structs.Job{ + TaskGroups: []*structs.TaskGroup{{ + Tasks: []*structs.Task{{ + Vault: &structs.Vault{}, + }}, + }}, + }, + inputConfig: &Config{ + VaultConfig: &config.VaultConfig{ + UseIdentity: pointer.Of(true), + }, + }, + expectedOutputJob: &structs.Job{ + TaskGroups: []*structs.TaskGroup{{ + Tasks: []*structs.Task{{ + Vault: &structs.Vault{}, + }}, + }}, + }, + }, + { + name: "no mutation when task has vault identity", + inputJob: &structs.Job{ + TaskGroups: []*structs.TaskGroup{{ + Tasks: []*structs.Task{{ + Identities: []*structs.WorkloadIdentity{{ + Name: "vault", + Audience: []string{"vault.io"}, + }}, + Vault: &structs.Vault{}, + }}, + }}, + }, + inputConfig: &Config{ + VaultConfig: &config.VaultConfig{ + UseIdentity: pointer.Of(true), + DefaultIdentity: &config.WorkloadIdentityConfig{ + Audience: []string{"vault.io"}, + }, + }, + }, + expectedOutputJob: &structs.Job{ + TaskGroups: []*structs.TaskGroup{{ + Tasks: []*structs.Task{{ + Identities: []*structs.WorkloadIdentity{{ + Name: "vault", + Audience: []string{"vault.io"}, + }}, + Vault: &structs.Vault{}, + }}, + }}, + }, + }, + { + name: "mutate when task does not have a vault idenity", + inputJob: &structs.Job{ + TaskGroups: []*structs.TaskGroup{{ + Tasks: []*structs.Task{{ + Vault: &structs.Vault{}, + }}, + }}, + }, + inputConfig: &Config{ + VaultConfig: &config.VaultConfig{ + UseIdentity: pointer.Of(true), + DefaultIdentity: &config.WorkloadIdentityConfig{ + Audience: []string{"vault.io"}, + }, + }, + }, + expectedOutputJob: &structs.Job{ + TaskGroups: []*structs.TaskGroup{{ + Tasks: []*structs.Task{{ + Identities: []*structs.WorkloadIdentity{{ + Name: "vault", + Audience: []string{"vault.io"}, + }}, + Vault: &structs.Vault{}, + }}, + }}, + }, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + impl := jobImplicitIdentitiesHook{srv: &Server{ + config: tc.inputConfig, + }} + actualJob, actualWarnings, actualError := impl.Mutate(tc.inputJob) + must.Eq(t, tc.expectedOutputJob, actualJob) + must.NoError(t, actualError) + must.Nil(t, actualWarnings) + }) + } +} diff --git a/nomad/job_endpoint_hooks.go b/nomad/job_endpoint_hooks.go index 0a4c7d050..910d25c45 100644 --- a/nomad/job_endpoint_hooks.go +++ b/nomad/job_endpoint_hooks.go @@ -307,45 +307,91 @@ func (v *jobValidate) Validate(job *structs.Job) (warnings []error, err error) { for _, tg := range job.TaskGroups { for _, s := range tg.Services { - serviceWarn, serviceErr := v.validateServiceIdentity(s) - if serviceErr != nil { - multierror.Append(validationErrors, serviceErr) - } - if len(serviceWarn) > 0 { - warnings = append(warnings, serviceWarn...) - } + serviceErrs := v.validateServiceIdentity(s, fmt.Sprintf("task group %s", tg.Name)) + multierror.Append(validationErrors, serviceErrs) } for _, t := range tg.Tasks { for _, s := range t.Services { - serviceWarn, serviceErr := v.validateServiceIdentity(s) - if serviceErr != nil { - multierror.Append(validationErrors, serviceErr) - } - if len(serviceWarn) > 0 { - warnings = append(warnings, serviceWarn...) - } + serviceErrs := v.validateServiceIdentity(s, fmt.Sprintf("task %s", t.Name)) + multierror.Append(validationErrors, serviceErrs) } + + vaultWarns, vaultErrs := v.validateVaultIdentity(t) + multierror.Append(validationErrors, vaultErrs) + warnings = append(warnings, vaultWarns...) } } return warnings, validationErrors.ErrorOrNil() } -func (v *jobValidate) validateServiceIdentity(s *structs.Service) (warnings []error, err error) { +func (v *jobValidate) validateServiceIdentity(s *structs.Service, parent string) error { + var mErr *multierror.Error + if s.Identity != nil { if !v.srv.config.UseConsulIdentity() { - return nil, fmt.Errorf("service %s defines an identity but server configuration for consul.use_identity is not true", s.Name) + mErr = multierror.Append(mErr, fmt.Errorf( + "Service %s in %s defines an identity but server is not configured to use Consul identities, set use_identity to true in the Consul server configuration", + s.Name, parent, + )) } if s.Identity.Name == "" { - return nil, fmt.Errorf("identity for service %s has an empty name", s.Name) + mErr = multierror.Append(mErr, fmt.Errorf("Service %s in %s has an identity with an empty name", s.Name, parent)) } } else if v.srv.config.UseConsulIdentity() && v.srv.config.ConsulServiceIdentity() == nil { - return nil, fmt.Errorf("service %s does not have an identity and no default service identity is provided", s.Name) + mErr = multierror.Append(mErr, fmt.Errorf( + "Service %s in %s expected to have an identity, add an identity block to the service or provide a default using the service_identity block in the server Consul configuration", + s.Name, parent, + )) } - return nil, nil + return mErr.ErrorOrNil() +} + +func (v *jobValidate) validateVaultIdentity(t *structs.Task) ([]error, error) { + var mErr *multierror.Error + var warnings []error + + hasVault := t.Vault != nil + hasTaskWID := t.GetIdentity(vaultIdentityName) != nil + hasDefaultWID := v.srv.config.VaultDefaultIdentity() != nil + + useIdentity := hasVault && v.srv.config.UseVaultIdentity() + hasWID := hasTaskWID || hasDefaultWID + + if useIdentity { + if !hasWID { + mErr = multierror.Append(mErr, fmt.Errorf( + "Task %s expected to have a Vault identity, add an identity block called %s or provide a default using the default_identity block in the server Vault configuration", + t.Name, vaultIdentityName, + )) + } + + if len(t.Vault.Policies) > 0 { + warnings = append(warnings, fmt.Errorf( + "Task %s has a Vault block with policies but uses workload identity to authenticate with Vault, policies will be ignored", + t.Name, + )) + } + } else if hasVault && len(t.Vault.Policies) == 0 { + mErr = multierror.Append(mErr, fmt.Errorf("Task %s has a Vault block with an empty list of policies", t.Name)) + } + + if hasTaskWID { + if !v.srv.config.UseVaultIdentity() { + warnings = append(warnings, fmt.Errorf( + "Task %s has an identity called %s but server is not configured to use Vault identities, set use_identity to true in the Vault server configuration", + t.Name, vaultIdentityName, + )) + } + if !hasVault { + warnings = append(warnings, fmt.Errorf("Task %s has an identity called %s but no vault block", t.Name, vaultIdentityName)) + } + } + + return warnings, mErr.ErrorOrNil() } type memoryOversubscriptionValidate struct { diff --git a/nomad/job_endpoint_hooks_test.go b/nomad/job_endpoint_hooks_test.go index 3653d6a99..579b00413 100644 --- a/nomad/job_endpoint_hooks_test.go +++ b/nomad/job_endpoint_hooks_test.go @@ -4,7 +4,10 @@ package nomad import ( + "fmt" + "strings" "testing" + "time" "github.com/hashicorp/nomad/ci" "github.com/hashicorp/nomad/helper/pointer" @@ -19,14 +22,14 @@ func Test_jobValidate_Validate_consul_service(t *testing.T) { ci.Parallel(t) testCases := []struct { - name string - inputService *structs.Service - inputConfig *Config - expectedWarn []error - expectedErr string + name string + inputService *structs.Service + inputConfig *Config + expectedWarns []string + expectedErr string }{ { - name: "no error when consul identity not enabled and services does not have an identity", + name: "no error when consul identity is not enabled and service does not have an identity", inputService: &structs.Service{ Provider: "consul", Name: "web", @@ -38,7 +41,7 @@ func Test_jobValidate_Validate_consul_service(t *testing.T) { }, }, { - name: "no error when consul identity is enabled and default service identity is provided", + name: "no error when consul identity is enabled and identity is provided via server config", inputService: &structs.Service{ Provider: "consul", Name: "web", @@ -53,7 +56,7 @@ func Test_jobValidate_Validate_consul_service(t *testing.T) { }, }, { - name: "no error when consul identity is enabled and service has a proper identity", + name: "no error when consul identity is enabled and identity is provided via service", inputService: &structs.Service{ Provider: "consul", Name: "web", @@ -72,11 +75,12 @@ func Test_jobValidate_Validate_consul_service(t *testing.T) { }, }, { - name: "error when service defines identity but consul identity is disabled", + name: "error when consul identity is disabled and service has identity", inputService: &structs.Service{ Provider: "consul", Name: "web", Identity: &structs.WorkloadIdentity{ + Name: fmt.Sprintf("%s/web", consulServiceIdentityNamePrefix), Audience: []string{"consul.io", "nomad.dev"}, File: true, Env: false, @@ -87,10 +91,10 @@ func Test_jobValidate_Validate_consul_service(t *testing.T) { UseIdentity: pointer.Of(false), }, }, - expectedErr: "server configuration for consul.use_identity is not true", + expectedErr: "defines an identity but server is not configured to use Consul identities", }, { - name: "error when service does not define identity and consul identity is enabled but no default is provided", + name: "error when consul identity is enabled but no service identity is provided", inputService: &structs.Service{ Provider: "consul", Name: "web", @@ -100,7 +104,7 @@ func Test_jobValidate_Validate_consul_service(t *testing.T) { UseIdentity: pointer.Of(true), }, }, - expectedErr: "no default service identity is provided", + expectedErr: "expected to have an identity", }, } @@ -115,8 +119,7 @@ func Test_jobValidate_Validate_consul_service(t *testing.T) { job.TaskGroups[0].Services = []*structs.Service{tc.inputService} job.TaskGroups[0].Tasks[0].Services = []*structs.Service{tc.inputService} - warn, err := impl.Validate(job) - must.Eq(t, tc.expectedWarn, warn) + warns, err := impl.Validate(job) if len(tc.expectedErr) == 0 { must.NoError(t, err) @@ -124,6 +127,172 @@ func Test_jobValidate_Validate_consul_service(t *testing.T) { must.Error(t, err) must.ErrorContains(t, err, tc.expectedErr) } + + must.Len(t, len(tc.expectedWarns), warns, must.Sprintf("got warnings: %v", warns)) + for _, exp := range tc.expectedWarns { + hasWarn := false + for _, w := range warns { + if strings.Contains(w.Error(), exp) { + hasWarn = true + break + } + } + must.True(t, hasWarn, must.Sprintf("expected %v to have warning with %q", warns, exp)) + } + }) + } +} + +func Test_jobValidate_Validate_vault(t *testing.T) { + ci.Parallel(t) + + testCases := []struct { + name string + inputTaskVault *structs.Vault + inputTaskIdentities []*structs.WorkloadIdentity + inputConfig *config.VaultConfig + expectedWarns []string + expectedErr string + }{ + { + name: "no error when vault identity is not enabled and task does not have a vault identity", + inputTaskVault: &structs.Vault{ + Policies: []string{"nomad-workload"}, + }, + inputTaskIdentities: nil, + inputConfig: &config.VaultConfig{ + UseIdentity: pointer.Of(false), + }, + }, + { + name: "no error when vault identity is enabled and identity is provided via config", + inputTaskVault: &structs.Vault{}, + inputTaskIdentities: nil, + inputConfig: &config.VaultConfig{ + UseIdentity: pointer.Of(true), + DefaultIdentity: &config.WorkloadIdentityConfig{ + Audience: []string{"vault.io"}, + TTL: pointer.Of(time.Hour), + }, + }, + }, + { + name: "no error when vault identity is enabled and identity is provided via task", + inputTaskVault: &structs.Vault{}, + inputTaskIdentities: []*structs.WorkloadIdentity{{ + Name: vaultIdentityName, + Audience: []string{"vault.io"}, + TTL: time.Hour, + }}, + inputConfig: &config.VaultConfig{ + UseIdentity: pointer.Of(true), + }, + }, + { + name: "error when not using vault identity and vault block is missing policies", + inputTaskVault: &structs.Vault{}, + inputTaskIdentities: nil, + inputConfig: &config.VaultConfig{ + UseIdentity: pointer.Of(false), + }, + expectedErr: "Vault block with an empty list of policies", + }, + { + name: "error when vault identity is enabled but no identity is provided", + inputTaskVault: &structs.Vault{}, + inputTaskIdentities: nil, + inputConfig: &config.VaultConfig{ + UseIdentity: pointer.Of(true), + }, + expectedErr: "expected to have a Vault identity", + }, + { + name: "warn when vault identity is enabled but task has vault policies", + inputTaskVault: &structs.Vault{ + Policies: []string{"nomad-workload"}, + }, + inputTaskIdentities: nil, + inputConfig: &config.VaultConfig{ + UseIdentity: pointer.Of(true), + DefaultIdentity: &config.WorkloadIdentityConfig{ + Audience: []string{"vault.io"}, + TTL: pointer.Of(time.Hour), + }, + }, + expectedWarns: []string{"policies will be ignored"}, + }, + { + name: "warn when vault identity is disabled but task has vault identity", + inputTaskVault: &structs.Vault{ + Policies: []string{"nomad-workload"}, + }, + inputTaskIdentities: []*structs.WorkloadIdentity{{ + Name: vaultIdentityName, + Audience: []string{"vault.io"}, + TTL: time.Hour, + }}, + inputConfig: &config.VaultConfig{ + UseIdentity: pointer.Of(false), + }, + expectedWarns: []string{ + "has an identity called vault but server is not configured to use Vault identities", + }, + }, + { + name: "warn when vault identity is provided but task does not have vault block", + inputTaskVault: nil, + inputTaskIdentities: []*structs.WorkloadIdentity{{ + Name: vaultIdentityName, + Audience: []string{"vault.io"}, + TTL: time.Hour, + }}, + inputConfig: &config.VaultConfig{ + UseIdentity: pointer.Of(true), + }, + expectedWarns: []string{ + "has an identity called vault but no vault block", + }, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + impl := jobValidate{srv: &Server{ + config: &Config{ + JobMaxPriority: 100, + VaultConfig: tc.inputConfig, + }, + }} + + job := mock.Job() + task := job.TaskGroups[0].Tasks[0] + + task.Identities = tc.inputTaskIdentities + task.Vault = tc.inputTaskVault + if task.Vault != nil { + task.Vault.ChangeMode = structs.VaultChangeModeRestart + } + + warns, err := impl.Validate(job) + + if len(tc.expectedErr) == 0 { + must.NoError(t, err) + } else { + must.Error(t, err) + must.ErrorContains(t, err, tc.expectedErr) + } + + must.Len(t, len(tc.expectedWarns), warns, must.Sprintf("got warnings: %v", warns)) + for _, exp := range tc.expectedWarns { + hasWarn := false + for _, w := range warns { + if strings.Contains(w.Error(), exp) { + hasWarn = true + break + } + } + must.True(t, hasWarn, must.Sprintf("expected %v to have warning with %q", warns, exp)) + } }) } } diff --git a/nomad/structs/config/vault.go b/nomad/structs/config/vault.go index a92d97fd7..965198955 100644 --- a/nomad/structs/config/vault.go +++ b/nomad/structs/config/vault.go @@ -18,44 +18,45 @@ const ( // VaultConfig contains the configuration information necessary to // communicate with Vault in order to: -// -// - Renew Vault tokens/leases. -// -// - Pass a token for the Nomad Server to derive sub-tokens. -// -// - Create child tokens with policy subsets of the Server's token. +// - Renew Vault tokens/leases. +// - Pass a token for the Nomad Server to derive sub-tokens. +// - Create child tokens with policy subsets of the Server's token. +// - Create Vault ACL tokens from workload identity JWTs. type VaultConfig struct { + // Servers and clients fields. + + // Name is used to identify the Vault cluster related to this + // configuration. Name string `mapstructure:"name"` // Enabled enables or disables Vault support. Enabled *bool `mapstructure:"enabled"` - // Token is the Vault token given to Nomad such that it can - // derive child tokens. Nomad will renew this token at half its lease - // lifetime. - Token string `mapstructure:"token"` - - // Role sets the role in which to create tokens from. The Token given to - // Nomad does not have to be created from this role but must have "update" + // Role sets the role in which to create tokens from. + // + // When using workload identities this field defines the default role to + // use when a job does not define a role in its `vault` block. If this + // config value is also unset, the default auth method or cluster global + // role is used. + // + // When not using workload identities, the Nomad servers will derive tokens + // using this role. The Vault token provided to the Nomad server config + // does not have to be created from this role but must have "update" // capability on "auth/token/create/". If this value is - // unset and the token is created from a role, the value is defaulted to the - // role the token is from. + // unset and the token is created from a role, the value is defaulted to + // the role the token is from. + // + // This used to be a server-only field, but it's a client-only field when + // workload identities are used, so it should be set in both places during + // the transition period. Role string `mapstructure:"create_from_role"` + // Clients-only fields. + // 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"` - // AllowUnauthenticated allows users to submit jobs requiring Vault tokens - // without providing a Vault token proving they have access to these - // policies. - AllowUnauthenticated *bool `mapstructure:"allow_unauthenticated"` - - // TaskTokenTTL is the TTL of the tokens created by Nomad Servers and used - // by the client. There should be a minimum time value such that the client - // does not have to renew with Vault at a very high frequency - TaskTokenTTL string `mapstructure:"task_token_ttl"` - // Addr is the address of the local Vault agent. This should be a complete // URL such as "http://vault.example.com" Addr string `mapstructure:"address"` @@ -83,6 +84,46 @@ type VaultConfig struct { // TLSServerName, if set, is used to set the SNI host when connecting via TLS. TLSServerName string `mapstructure:"tls_server_name"` + + // Servers-only fields. + + // UseIdentity defines if workload identities should be used to derive + // Vault tokens. + // + // It is a transitional field used only during the adoption period of + // workload identities and will be ignored and removed in future versions. + UseIdentity *bool `mapstructure:"use_identity"` + + // DefaultIdentity is the default workload identity configuration used when + // a job has a `vault` block but no `identity` named "vault". + DefaultIdentity *WorkloadIdentityConfig `mapstructure:"default_identity"` + + // Deprecated fields. + + // Token is the Vault token given to Nomad such that it can + // derive child tokens. Nomad will renew this token at half its lease + // lifetime. + // + // Deprecated: Nomad 1.7.0 is able to derive Vault tokens from workload + // identities. This field will be removed in a future release. + Token string `mapstructure:"token"` + + // AllowUnauthenticated allows users to submit jobs requiring Vault tokens + // without providing a Vault token proving they have access to these + // policies. + // + // Deprecated: Nomad 1.7.0 no longer requires a Vault token for job + // operations. This field will be removed in a future release. + AllowUnauthenticated *bool `mapstructure:"allow_unauthenticated"` + + // TaskTokenTTL is the TTL of the tokens created by Nomad Servers and used + // by the client. There should be a minimum time value such that the client + // does not have to renew with Vault at a very high frequency + // + // Deprecated: Nomad 1.7.0 derives tokens from workload identities that + // receive their TTL configuration from the Vault role used. This field + // will be removed in a future release. + TaskTokenTTL string `mapstructure:"task_token_ttl"` } // DefaultVaultConfig returns the canonical defaults for the Nomad @@ -93,6 +134,7 @@ func DefaultVaultConfig() *VaultConfig { Addr: "https://vault.service.consul:8200", ConnectionRetryIntv: DefaultVaultConnectRetryIntv, AllowUnauthenticated: pointer.Of(true), + UseIdentity: pointer.Of(false), } } @@ -117,21 +159,13 @@ func (c *VaultConfig) Merge(b *VaultConfig) *VaultConfig { if b.Enabled != nil { result.Enabled = b.Enabled } - if b.Token != "" { - result.Token = b.Token - } if b.Role != "" { result.Role = b.Role } + if b.Namespace != "" { result.Namespace = b.Namespace } - if b.AllowUnauthenticated != nil { - result.AllowUnauthenticated = b.AllowUnauthenticated - } - if b.TaskTokenTTL != "" { - result.TaskTokenTTL = b.TaskTokenTTL - } if b.Addr != "" { result.Addr = b.Addr } @@ -157,6 +191,25 @@ func (c *VaultConfig) Merge(b *VaultConfig) *VaultConfig { result.TLSServerName = b.TLSServerName } + result.UseIdentity = pointer.Merge(result.UseIdentity, b.UseIdentity) + + if result.DefaultIdentity == nil && b.DefaultIdentity != nil { + sID := *b.DefaultIdentity + result.DefaultIdentity = &sID + } else if b.DefaultIdentity != nil { + result.DefaultIdentity = result.DefaultIdentity.Merge(b.DefaultIdentity) + } + + if b.Token != "" { + result.Token = b.Token + } + if b.AllowUnauthenticated != nil { + result.AllowUnauthenticated = b.AllowUnauthenticated + } + if b.TaskTokenTTL != "" { + result.TaskTokenTTL = b.TaskTokenTTL + } + return &result } @@ -206,35 +259,19 @@ func (c *VaultConfig) Equal(b *VaultConfig) bool { return false } - if c.Enabled == nil || b.Enabled == nil { - if c.Enabled != b.Enabled { - return false - } - } else if *c.Enabled != *b.Enabled { + if c.Name != b.Name { return false } - - if c.Token != b.Token { + if !pointer.Eq(c.Enabled, b.Enabled) { return false } if c.Role != b.Role { return false } + if c.Namespace != b.Namespace { return false } - - if c.AllowUnauthenticated == nil || b.AllowUnauthenticated == nil { - if c.AllowUnauthenticated != b.AllowUnauthenticated { - return false - } - } else if *c.AllowUnauthenticated != *b.AllowUnauthenticated { - return false - } - - if c.TaskTokenTTL != b.TaskTokenTTL { - return false - } if c.Addr != b.Addr { return false } @@ -253,16 +290,27 @@ func (c *VaultConfig) Equal(b *VaultConfig) bool { if c.TLSKeyFile != b.TLSKeyFile { return false } - - if c.TLSSkipVerify == nil || b.TLSSkipVerify == nil { - if c.TLSSkipVerify != b.TLSSkipVerify { - return false - } - } else if *c.TLSSkipVerify != *b.TLSSkipVerify { + if !pointer.Eq(c.TLSSkipVerify, b.TLSSkipVerify) { + return false + } + if c.TLSServerName != b.TLSServerName { return false } - if c.TLSServerName != b.TLSServerName { + if !pointer.Eq(b.UseIdentity, c.UseIdentity) { + return false + } + if !c.DefaultIdentity.Equal(b.DefaultIdentity) { + return false + } + + if c.Token != b.Token { + return false + } + if !pointer.Eq(c.AllowUnauthenticated, b.AllowUnauthenticated) { + 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 f8c71d745..5c0127413 100644 --- a/nomad/structs/config/vault_test.go +++ b/nomad/structs/config/vault_test.go @@ -29,6 +29,8 @@ func TestVaultConfig_Merge(t *testing.T) { TLSKeyFile: "1", TLSSkipVerify: pointer.Of(true), TLSServerName: "1", + UseIdentity: pointer.Of(false), + DefaultIdentity: nil, } c2 := &VaultConfig{ @@ -44,6 +46,12 @@ func TestVaultConfig_Merge(t *testing.T) { TLSKeyFile: "2", TLSSkipVerify: nil, TLSServerName: "2", + UseIdentity: pointer.Of(true), + DefaultIdentity: &WorkloadIdentityConfig{ + Audience: []string{"vault.dev"}, + Env: pointer.Of(true), + File: pointer.Of(false), + }, } e := &VaultConfig{ @@ -59,6 +67,12 @@ func TestVaultConfig_Merge(t *testing.T) { TLSKeyFile: "2", TLSSkipVerify: pointer.Of(true), TLSServerName: "2", + UseIdentity: pointer.Of(true), + DefaultIdentity: &WorkloadIdentityConfig{ + Audience: []string{"vault.dev"}, + Env: pointer.Of(true), + File: pointer.Of(false), + }, } result := c1.Merge(c2) @@ -85,6 +99,12 @@ func TestVaultConfig_Equals(t *testing.T) { TLSKeyFile: "1", TLSSkipVerify: pointer.Of(true), TLSServerName: "1", + UseIdentity: pointer.Of(true), + DefaultIdentity: &WorkloadIdentityConfig{ + Audience: []string{"vault.dev"}, + Env: pointer.Of(true), + File: pointer.Of(false), + }, } c2 := &VaultConfig{ @@ -102,6 +122,12 @@ func TestVaultConfig_Equals(t *testing.T) { TLSKeyFile: "1", TLSSkipVerify: pointer.Of(true), TLSServerName: "1", + UseIdentity: pointer.Of(true), + DefaultIdentity: &WorkloadIdentityConfig{ + Audience: []string{"vault.dev"}, + Env: pointer.Of(true), + File: pointer.Of(false), + }, } must.Equal(t, c1, c2) @@ -121,6 +147,12 @@ func TestVaultConfig_Equals(t *testing.T) { TLSKeyFile: "1", TLSSkipVerify: pointer.Of(true), TLSServerName: "1", + UseIdentity: pointer.Of(true), + DefaultIdentity: &WorkloadIdentityConfig{ + Audience: []string{"vault.dev"}, + Env: pointer.Of(true), + File: pointer.Of(false), + }, } c4 := &VaultConfig{ @@ -138,6 +170,12 @@ func TestVaultConfig_Equals(t *testing.T) { TLSKeyFile: "1", TLSSkipVerify: pointer.Of(true), TLSServerName: "1", + UseIdentity: pointer.Of(false), + DefaultIdentity: &WorkloadIdentityConfig{ + Audience: []string{"vault.io"}, + Env: pointer.Of(false), + File: pointer.Of(true), + }, } must.NotEqual(t, c3, c4) diff --git a/nomad/structs/structs.go b/nomad/structs/structs.go index aa4dbb9eb..4703e56d2 100644 --- a/nomad/structs/structs.go +++ b/nomad/structs/structs.go @@ -7645,6 +7645,15 @@ func (t *Task) IsPoststop() bool { t.Lifecycle.Hook == TaskLifecycleHookPoststop } +func (t *Task) GetIdentity(name string) *WorkloadIdentity { + for _, wid := range t.Identities { + if wid.Name == name { + return wid + } + } + return nil +} + func (t *Task) Copy() *Task { if t == nil { return nil @@ -10004,10 +10013,6 @@ func (v *Vault) Validate() error { } var mErr multierror.Error - if len(v.Policies) == 0 { - _ = multierror.Append(&mErr, fmt.Errorf("Policy list cannot be empty")) - } - for _, p := range v.Policies { if p == "root" { _ = multierror.Append(&mErr, fmt.Errorf("Can not specify \"root\" policy")) diff --git a/nomad/structs/structs_test.go b/nomad/structs/structs_test.go index 15f0ce8ac..f096650bd 100644 --- a/nomad/structs/structs_test.go +++ b/nomad/structs/structs_test.go @@ -6191,16 +6191,10 @@ func TestVault_Validate(t *testing.T) { v := &Vault{ Env: true, - ChangeMode: VaultChangeModeNoop, + ChangeMode: VaultChangeModeSignal, + Policies: []string{"foo", "root"}, } - if err := v.Validate(); err == nil || !strings.Contains(err.Error(), "Policy list") { - t.Fatalf("Expected policy list empty error") - } - - v.Policies = []string{"foo", "root"} - v.ChangeMode = VaultChangeModeSignal - err := v.Validate() if err == nil { t.Fatalf("Expected validation errors")