diff --git a/backend_test.go b/backend_test.go index 04db2cb3..b44fec34 100644 --- a/backend_test.go +++ b/backend_test.go @@ -198,7 +198,7 @@ func Test_kubeAuthBackend_updateTLSConfig(t *testing.T) { for idx, config := range tt.configs { t.Run(fmt.Sprintf("config-%d", idx), func(t *testing.T) { if config.localCACert != "" { - if err := os.WriteFile(localFile, []byte(config.localCACert), 0600); err != nil { + if err := os.WriteFile(localFile, []byte(config.localCACert), 0o600); err != nil { t.Fatalf("failed to write local file %q", localFile) } t.Cleanup(func() { @@ -324,7 +324,6 @@ func Test_kubeAuthBackend_initialize(t *testing.T) { err := b.initialize(ctx, tt.req) if tt.wantErr && err == nil { t.Errorf("initialize() error = %v, wantErr %v", err, tt.wantErr) - } if !reflect.DeepEqual(err, tt.expectErr) { @@ -442,7 +441,6 @@ func Test_kubeAuthBackend_runTLSConfigUpdater(t *testing.T) { err := b.runTLSConfigUpdater(ctx, tt.storage, tt.horizon) if tt.wantErr && err == nil { t.Errorf("runTLSConfigUpdater() error = %v, wantErr %v", err, tt.wantErr) - } if !reflect.DeepEqual(err, tt.expectErr) { @@ -506,7 +504,6 @@ func assertTLSConfigEquals(t *testing.T, actual, expected *tls.Config) { t.Errorf("updateTLSConfig() actual MinVersion = %v, expected MinVersion %v", actual.MinVersion, expected.MinVersion) } - } func assertValidTransport(t *testing.T, b *kubeAuthBackend, expected *tls.Config) { diff --git a/namespace_validator.go b/namespace_validator.go new file mode 100644 index 00000000..d35bec1f --- /dev/null +++ b/namespace_validator.go @@ -0,0 +1,28 @@ +package kubeauth + +import ( + "context" + "net/http" +) + +// This exists so we can use a mock namespace validation when running tests +type namespaceValidator interface { + ValidateLabels(context.Context, *http.Client, string, map[string]string) (bool, error) +} + +type namespaceValidatorFactory func(*kubeConfig) namespaceValidator + +// This is the real implementation that calls the kubernetes API +type namespaceValidatorAPI struct { + config *kubeConfig +} + +func namespaceValidatorAPIFactory(config *kubeConfig) namespaceValidator { + return &namespaceValidatorAPI{ + config: config, + } +} + +func (t *namespaceValidatorAPI) ValidateLabels(ctx context.Context, client *http.Client, name string, labels map[string]string) (bool, error) { + return true, nil +} diff --git a/path_role.go b/path_role.go index 88f6d72a..b4703984 100644 --- a/path_role.go +++ b/path_role.go @@ -47,6 +47,12 @@ are allowed.`, Type: framework.TypeCommaStringSlice, Description: `List of namespaces allowed to access this role. If set to "*" all namespaces are allowed.`, + }, + "bound_service_account_namespace_selector": { + Type: framework.TypeString, + Description: `A label selector for Kubernetes namspaces which are allowed to access this role. +Accepts either a JSON or YAML object. If set with bound_service_account_namespaces, +the conditions are conjuncted.`, }, "audience": { Type: framework.TypeString, @@ -157,8 +163,15 @@ func (b *kubeAuthBackend) pathRoleRead(ctx context.Context, req *logical.Request // Create a map of data to be returned d := map[string]interface{}{ - "bound_service_account_names": role.ServiceAccountNames, - "bound_service_account_namespaces": role.ServiceAccountNamespaces, + "bound_service_account_names": role.ServiceAccountNames, + } + + if len(role.ServiceAccountNamespaces) > 0 { + d["bound_service_account_namespaces"] = role.ServiceAccountNamespaces + } + + if role.ServiceAccountNamespaceSelector != "" { + d["bound_service_account_namespace_selector"] = role.ServiceAccountNamespaceSelector } if role.Audience != "" { @@ -300,12 +313,15 @@ func (b *kubeAuthBackend) pathRoleCreateUpdate(ctx context.Context, req *logical if namespaces, ok := data.GetOk("bound_service_account_namespaces"); ok { role.ServiceAccountNamespaces = namespaces.([]string) - } else if req.Operation == logical.CreateOperation { - role.ServiceAccountNamespaces = data.Get("bound_service_account_namespaces").([]string) } - // Verify namespaces is not empty - if len(role.ServiceAccountNamespaces) == 0 { - return logical.ErrorResponse("%q can not be empty", "bound_service_account_namespaces"), nil + + if namespaceSelector, ok := data.GetOk("bound_service_account_namespace_selector"); ok { + role.ServiceAccountNamespaceSelector = namespaceSelector.(string) + } + + // Verify namespaces is not empty unless selector is set + if len(role.ServiceAccountNamespaces) == 0 && role.ServiceAccountNamespaceSelector == "" { + return logical.ErrorResponse("%q can not be empty if %q is not set", "bound_service_account_namespaces", "bound_service_account_namespace_selector"), nil } // Verify * was not set with other data if len(role.ServiceAccountNamespaces) > 1 && strutil.StrListContains(role.ServiceAccountNamespaces, "*") { @@ -360,6 +376,10 @@ type roleStorageEntry struct { // role. ServiceAccountNamespaces []string `json:"bound_service_account_namespaces" mapstructure:"bound_service_account_namespaces" structs:"bound_service_account_namespaces"` + // ServiceAccountNamespaceSelector is the label selector string of the + // namespaces able to access this role. + ServiceAccountNamespaceSelector string `json:"bound_service_account_namespace_selector" mapstructure:"bound_service_account_namespace_selector" structs:"bound_service_account_namespace_selector"` + // Audience is an optional jwt claim to verify Audience string `json:"audience" mapstructure:"audience" structs: "audience"` diff --git a/path_role_test.go b/path_role_test.go index 8eb6d1f3..4860f66e 100644 --- a/path_role_test.go +++ b/path_role_test.go @@ -18,6 +18,30 @@ import ( "github.com/hashicorp/vault/sdk/logical" ) +const ( + goodJSONSelector = `{ + "matchLabels": { + "stage": "prod", + "app": "vault" + } +}` + + badJSONSelector = `{ + "matchLabels": + "stage": "prod", + "app": "vault" +}` + + goodYAMLSelector = `matchLabels: + stage: prod + app: vault +` + badYAMLSelector = `matchLabels: +- stage: prod +- app: vault +` +) + func getBackend(t *testing.T) (logical.Backend, logical.Storage) { defaultLeaseTTLVal := time.Hour * 12 maxLeaseTTLVal := time.Hour * 24 @@ -68,15 +92,16 @@ func TestPath_Create(t *testing.T) { TokenNumUses: 12, TokenBoundCIDRs: nil, }, - Policies: []string{"test"}, - Period: 3 * time.Second, - ServiceAccountNames: []string{"name"}, - ServiceAccountNamespaces: []string{"namespace"}, - TTL: 1 * time.Second, - MaxTTL: 5 * time.Second, - NumUses: 12, - BoundCIDRs: nil, - AliasNameSource: aliasNameSourceDefault, + Policies: []string{"test"}, + Period: 3 * time.Second, + ServiceAccountNames: []string{"name"}, + ServiceAccountNamespaces: []string{"namespace"}, + ServiceAccountNamespaceSelector: "", + TTL: 1 * time.Second, + MaxTTL: 5 * time.Second, + NumUses: 12, + BoundCIDRs: nil, + AliasNameSource: aliasNameSourceDefault, }, }, "alias_name_source_serviceaccount_name": { @@ -99,15 +124,81 @@ func TestPath_Create(t *testing.T) { TokenNumUses: 12, TokenBoundCIDRs: nil, }, - Policies: []string{"test"}, - Period: 3 * time.Second, - ServiceAccountNames: []string{"name"}, - ServiceAccountNamespaces: []string{"namespace"}, - TTL: 1 * time.Second, - MaxTTL: 5 * time.Second, - NumUses: 12, - BoundCIDRs: nil, - AliasNameSource: aliasNameSourceSAName, + Policies: []string{"test"}, + Period: 3 * time.Second, + ServiceAccountNames: []string{"name"}, + ServiceAccountNamespaces: []string{"namespace"}, + ServiceAccountNamespaceSelector: "", + TTL: 1 * time.Second, + MaxTTL: 5 * time.Second, + NumUses: 12, + BoundCIDRs: nil, + AliasNameSource: aliasNameSourceSAName, + }, + }, + "namespace_selector": { + data: map[string]interface{}{ + "bound_service_account_names": "name", + "bound_service_account_namespace_selector": goodJSONSelector, + "policies": "test", + "period": "3s", + "ttl": "1s", + "num_uses": 12, + "max_ttl": "5s", + "alias_name_source": aliasNameSourceDefault, + }, + expected: &roleStorageEntry{ + TokenParams: tokenutil.TokenParams{ + TokenPolicies: []string{"test"}, + TokenPeriod: 3 * time.Second, + TokenTTL: 1 * time.Second, + TokenMaxTTL: 5 * time.Second, + TokenNumUses: 12, + TokenBoundCIDRs: nil, + }, + Policies: []string{"test"}, + Period: 3 * time.Second, + ServiceAccountNames: []string{"name"}, + ServiceAccountNamespaces: []string(nil), + ServiceAccountNamespaceSelector: goodJSONSelector, + TTL: 1 * time.Second, + MaxTTL: 5 * time.Second, + NumUses: 12, + BoundCIDRs: nil, + AliasNameSource: aliasNameSourceDefault, + }, + }, + "namespace_selector_with_namespaces": { + data: map[string]interface{}{ + "bound_service_account_names": "name", + "bound_service_account_namespaces": "namespace1,namespace2", + "bound_service_account_namespace_selector": goodYAMLSelector, + "policies": "test", + "period": "3s", + "ttl": "1s", + "num_uses": 12, + "max_ttl": "5s", + "alias_name_source": aliasNameSourceDefault, + }, + expected: &roleStorageEntry{ + TokenParams: tokenutil.TokenParams{ + TokenPolicies: []string{"test"}, + TokenPeriod: 3 * time.Second, + TokenTTL: 1 * time.Second, + TokenMaxTTL: 5 * time.Second, + TokenNumUses: 12, + TokenBoundCIDRs: nil, + }, + Policies: []string{"test"}, + Period: 3 * time.Second, + ServiceAccountNames: []string{"name"}, + ServiceAccountNamespaces: []string{"namespace1", "namespace2"}, + ServiceAccountNamespaceSelector: goodYAMLSelector, + TTL: 1 * time.Second, + MaxTTL: 5 * time.Second, + NumUses: 12, + BoundCIDRs: nil, + AliasNameSource: aliasNameSourceDefault, }, }, "invalid_alias_name_source": { @@ -134,7 +225,7 @@ func TestPath_Create(t *testing.T) { "bound_service_account_names": "name", "policies": "test", }, - wantErr: errors.New(`"bound_service_account_namespaces" can not be empty`), + wantErr: errors.New(`"bound_service_account_namespaces" can not be empty if "bound_service_account_namespace_selector" is not set`), }, "mixed_splat_values_names": { data: map[string]interface{}{ @@ -202,33 +293,35 @@ func TestPath_Read(t *testing.T) { b, storage := getBackend(t) configData := map[string]interface{}{ - "bound_service_account_names": "name", - "bound_service_account_namespaces": "namespace", - "policies": "test", - "period": "3s", - "ttl": "1s", - "num_uses": 12, - "max_ttl": "5s", + "bound_service_account_names": "name", + "bound_service_account_namespaces": "namespace", + "bound_service_account_namespace_selector": goodJSONSelector, + "policies": "test", + "period": "3s", + "ttl": "1s", + "num_uses": 12, + "max_ttl": "5s", } expected := map[string]interface{}{ - "bound_service_account_names": []string{"name"}, - "bound_service_account_namespaces": []string{"namespace"}, - "token_policies": []string{"test"}, - "policies": []string{"test"}, - "token_period": int64(3), - "period": int64(3), - "token_ttl": int64(1), - "ttl": int64(1), - "token_num_uses": 12, - "num_uses": 12, - "token_max_ttl": int64(5), - "max_ttl": int64(5), - "token_bound_cidrs": []string{}, - "token_type": logical.TokenTypeDefault.String(), - "token_explicit_max_ttl": int64(0), - "token_no_default_policy": false, - "alias_name_source": aliasNameSourceDefault, + "bound_service_account_names": []string{"name"}, + "bound_service_account_namespaces": []string{"namespace"}, + "bound_service_account_namespace_selector": goodJSONSelector, + "token_policies": []string{"test"}, + "policies": []string{"test"}, + "token_period": int64(3), + "period": int64(3), + "token_ttl": int64(1), + "ttl": int64(1), + "token_num_uses": 12, + "num_uses": 12, + "token_max_ttl": int64(5), + "max_ttl": int64(5), + "token_bound_cidrs": []string{}, + "token_type": logical.TokenTypeDefault.String(), + "token_explicit_max_ttl": int64(0), + "token_no_default_policy": false, + "alias_name_source": aliasNameSourceDefault, } req := &logical.Request{