diff --git a/lib/kube/grpc/grpc.go b/lib/kube/grpc/grpc.go index 90d5329c4bd86..cafcc869692f1 100644 --- a/lib/kube/grpc/grpc.go +++ b/lib/kube/grpc/grpc.go @@ -134,7 +134,8 @@ func (s *Server) ListKubernetesResources(ctx context.Context, req *proto.ListKub if req.UseSearchAsRoles || req.UsePreviewAsRoles { var extraRoles []string if req.UseSearchAsRoles { - extraRoles = append(extraRoles, userContext.Checker.GetAllowedSearchAsRoles()...) + allowedSearchAsRoles := userContext.Checker.GetAllowedSearchAsRolesForKubeResourceKind(req.ResourceType) + extraRoles = append(extraRoles, allowedSearchAsRoles...) } if req.UsePreviewAsRoles { extraRoles = append(extraRoles, userContext.Checker.GetAllowedPreviewAsRoles()...) diff --git a/lib/kube/grpc/grpc_test.go b/lib/kube/grpc/grpc_test.go index b2c53c0520c67..d5125ca4b7f8d 100644 --- a/lib/kube/grpc/grpc_test.go +++ b/lib/kube/grpc/grpc_test.go @@ -47,11 +47,13 @@ import ( func TestListKubernetesResources(t *testing.T) { modules.SetInsecureTestMode(true) var ( - usernameWithFullAccess = "full_user" - usernameNoAccess = "limited_user" - kubeCluster = "test_cluster" - kubeUsers = []string{"kube_user"} - kubeGroups = []string{"kube_user"} + usernameWithFullAccess = "full_user" + usernameNoAccess = "limited_user" + usernameWithEnforceKubePodOrNamespace = "request_kind_enforce_pod_user" + usernameWithEnforceKubeSecret = "request_kind_enforce_secret_user" + kubeCluster = "test_cluster" + kubeUsers = []string{"kube_user"} + kubeGroups = []string{"kube_user"} ) // kubeMock is a Kubernetes API mock for the session tests. // Once a new session is created, this mock will write to @@ -94,6 +96,45 @@ func TestListKubernetesResources(t *testing.T) { }, ) + userWithEnforceKubePodOrNamespace, _ := testCtx.CreateUserAndRole( + testCtx.Context, + t, + usernameWithEnforceKubePodOrNamespace, + kubeproxy.RoleSpec{ + Name: usernameWithEnforceKubePodOrNamespace, + KubeUsers: kubeUsers, + KubeGroups: kubeGroups, + SetupRoleFunc: func(role types.Role) { + // override the role to deny access to all kube resources. + role.SetKubernetesLabels(types.Allow, nil) + // set the role to allow searching as fullAccessRole. + role.SetSearchAsRoles(types.Allow, []string{fullAccessRole.GetName()}) + // restrict querying to pods only + role.SetRequestKubernetesResources(types.Allow, []types.RequestKubernetesResource{{Kind: "namespace"}, {Kind: "pod"}}) + }, + }, + ) + + userWithEnforceKubeSecret, _ := testCtx.CreateUserAndRole( + testCtx.Context, + t, + usernameWithEnforceKubeSecret, + kubeproxy.RoleSpec{ + Name: usernameWithEnforceKubeSecret, + KubeUsers: kubeUsers, + KubeGroups: kubeGroups, + SetupRoleFunc: func(role types.Role) { + // override the role to deny access to all kube resources. + role.SetKubernetesLabels(types.Allow, nil) + // set the role to allow searching as fullAccessRole. + role.SetSearchAsRoles(types.Allow, []string{fullAccessRole.GetName()}) + // restrict querying to secrets only + role.SetRequestKubernetesResources(types.Allow, []types.RequestKubernetesResource{{Kind: "secret"}}) + + }, + }, + ) + userNoAccess, _ := testCtx.CreateUserAndRole( testCtx.Context, t, @@ -357,6 +398,82 @@ func TestListKubernetesResources(t *testing.T) { }, assertErr: require.NoError, }, + { + name: "user with no access, deny listing dev pod, with role that enforces secret", + args: args{ + user: userWithEnforceKubeSecret, + searchAsRoles: true, + namespace: "dev", + resourceKind: types.KindKubePod, + }, + assertErr: require.Error, + }, + { + name: "user with no access, allow listing dev secret, with role that enforces secret", + args: args{ + user: userWithEnforceKubeSecret, + searchAsRoles: true, + namespace: "dev", + resourceKind: types.KindKubeSecret, + }, + want: &proto.ListKubernetesResourcesResponse{ + Resources: []*types.KubernetesResourceV1{ + { + Kind: types.KindKubeSecret, + Version: "v1", + Metadata: types.Metadata{ + Name: "secret-1", + }, + Spec: types.KubernetesResourceSpecV1{ + Namespace: "dev", + }, + }, + { + Kind: types.KindKubeSecret, + Version: "v1", + Metadata: types.Metadata{ + Name: "secret-2", + }, + Spec: types.KubernetesResourceSpecV1{ + Namespace: "dev", + }, + }, + }, + }, + assertErr: require.NoError, + }, + { + name: "user with no access, allow listing dev pod, with role that enforces namespace or pods", + args: args{ + user: userWithEnforceKubePodOrNamespace, + searchAsRoles: true, + namespace: "dev", + resourceKind: types.KindKubePod, + }, + want: &proto.ListKubernetesResourcesResponse{ + Resources: []*types.KubernetesResourceV1{ + { + Kind: "pod", + Metadata: types.Metadata{ + Name: "nginx-1", + }, + Spec: types.KubernetesResourceSpecV1{ + Namespace: "dev", + }, + }, + { + Kind: "pod", + Metadata: types.Metadata{ + Name: "nginx-2", + }, + Spec: types.KubernetesResourceSpecV1{ + Namespace: "dev", + }, + }, + }, + }, + assertErr: require.NoError, + }, { name: "user with full access and listing secrets in all namespaces", args: args{ diff --git a/lib/services/access_checker.go b/lib/services/access_checker.go index 8091ef220e39e..f1ec3453671cf 100644 --- a/lib/services/access_checker.go +++ b/lib/services/access_checker.go @@ -182,7 +182,11 @@ type AccessChecker interface { CertificateExtensions() []*types.CertExtension // GetAllowedSearchAsRoles returns all of the allowed SearchAsRoles. - GetAllowedSearchAsRoles() []string + GetAllowedSearchAsRoles(allowFilters ...SearchAsRolesOption) []string + + // GetAllowedSearchAsRolesForKubeResourceKind returns all of the allowed SearchAsRoles + // that allowed requesting to the requested Kubernetes resource kind. + GetAllowedSearchAsRolesForKubeResourceKind(requestedKubeResourceKind string) []string // GetAllowedPreviewAsRoles returns all of the allowed PreviewAsRoles. GetAllowedPreviewAsRoles() []string diff --git a/lib/services/role.go b/lib/services/role.go index 9e82b812cf0f7..15e82fc1cdda6 100644 --- a/lib/services/role.go +++ b/lib/services/role.go @@ -3234,8 +3234,11 @@ func (set RoleSet) ExtractConditionForIdentifier(ctx RuleContext, namespace, res return &types.WhereExpr{And: types.WhereExpr2{L: denyCond, R: allowCond}}, nil } +// SearchAsRolesOption is a functional option for filtering SearchAsRoles. +type SearchAsRolesOption func(role types.Role) bool + // GetSearchAsRoles returns all SearchAsRoles for this RoleSet. -func (set RoleSet) GetAllowedSearchAsRoles() []string { +func (set RoleSet) GetAllowedSearchAsRoles(allowFilters ...SearchAsRolesOption) []string { denied := make(map[string]struct{}) var allowed []string for _, role := range set { @@ -3244,15 +3247,55 @@ func (set RoleSet) GetAllowedSearchAsRoles() []string { } } for _, role := range set { + if slices.ContainsFunc(allowFilters, func(filter SearchAsRolesOption) bool { + return !filter(role) + }) { + // Don't consider this base role if it's filtered out. + continue + } for _, a := range role.GetSearchAsRoles(types.Allow) { - if _, ok := denied[a]; !ok { - allowed = append(allowed, a) + if _, isDenied := denied[a]; isDenied { + continue } + allowed = append(allowed, a) } } return apiutils.Deduplicate(allowed) } +// GetAllowedSearchAsRolesForKubeResourceKind returns all of the allowed SearchAsRoles +// that allowed requesting to the requested Kubernetes resource kind. +func (set RoleSet) GetAllowedSearchAsRolesForKubeResourceKind(requestedKubeResourceKind string) []string { + // Return no results if encountering any denies since its globally matched. + for _, role := range set { + for _, kr := range role.GetAccessRequestConditions(types.Deny).KubernetesResources { + if kr.Kind == types.Wildcard || kr.Kind == requestedKubeResourceKind { + return nil + } + } + } + return set.GetAllowedSearchAsRoles(WithAllowedKubernetesResourceKindFilter(requestedKubeResourceKind)) +} + +// WithAllowedKubernetesResourceKindFilter returns a SearchAsRolesOption func +// that will check that the requestedKubeResourceKind exists in the allow list +// for the current role. +func WithAllowedKubernetesResourceKindFilter(requestedKubeResourceKind string) SearchAsRolesOption { + return func(role types.Role) bool { + allowed := role.GetAccessRequestConditions(types.Allow).KubernetesResources + // any kind is allowed if nothing was configured. + if len(allowed) == 0 { + return true + } + for _, kr := range role.GetAccessRequestConditions(types.Allow).KubernetesResources { + if kr.Kind == types.Wildcard || kr.Kind == requestedKubeResourceKind { + return true + } + } + return false + } +} + // GetAllowedPreviewAsRoles returns all PreviewAsRoles for this RoleSet. func (set RoleSet) GetAllowedPreviewAsRoles() []string { denied := make(map[string]struct{}) diff --git a/lib/services/role_test.go b/lib/services/role_test.go index 489894389ada6..0377d81c7e641 100644 --- a/lib/services/role_test.go +++ b/lib/services/role_test.go @@ -4491,6 +4491,91 @@ func TestGetAllowedLoginsForResource(t *testing.T) { } } +func TestGetAllowedSearchAsRoles_WithAllowedKubernetesResourceKindFilter(t *testing.T) { + newRole := func( + allowRoles []string, + denyRoles []string, + allowedResources []types.RequestKubernetesResource, + deniedResources []types.RequestKubernetesResource, + ) *types.RoleV6 { + return &types.RoleV6{ + Spec: types.RoleSpecV6{ + Allow: types.RoleConditions{ + Request: &types.AccessRequestConditions{ + SearchAsRoles: allowRoles, + KubernetesResources: allowedResources, + }, + }, + Deny: types.RoleConditions{ + Request: &types.AccessRequestConditions{ + SearchAsRoles: denyRoles, + KubernetesResources: deniedResources, + }, + }, + }, + } + } + + roleWithNamespace := newRole([]string{"sar1"}, nil, []types.RequestKubernetesResource{{Kind: types.KindNamespace}}, []types.RequestKubernetesResource{}) + roleWithSecret := newRole([]string{"sar2"}, nil, []types.RequestKubernetesResource{{Kind: types.KindKubeSecret}}, []types.RequestKubernetesResource{}) + roleWithNoConfigure := newRole([]string{"sar3"}, nil, nil, nil) + roleWithDenyRole := newRole([]string{"sar4", "sar5", "sar6", "sar7"}, []string{"sar4", "sar6"}, []types.RequestKubernetesResource{{Kind: types.KindNamespace}, {Kind: types.KindKubePod}}, []types.RequestKubernetesResource{{Kind: types.KindKubePod}}) + roleWithDenyWildcard := newRole([]string{"sar10"}, nil, []types.RequestKubernetesResource{{Kind: types.KindNamespace}}, []types.RequestKubernetesResource{{Kind: types.Wildcard}}) + roleWithAllowWildcard := newRole([]string{"sar4", "sar5"}, nil, []types.RequestKubernetesResource{{Kind: types.Wildcard}}, nil) + + tt := []struct { + name string + roleSet RoleSet + requestType string + expectedAllowedRoles []string + }{ + { + name: "single match", + roleSet: NewRoleSet(roleWithNamespace, roleWithSecret), + requestType: types.KindKubeSecret, + expectedAllowedRoles: []string{"sar2"}, + }, + { + name: "multi match", + roleSet: NewRoleSet(roleWithNamespace, roleWithNoConfigure), + requestType: types.KindNamespace, + expectedAllowedRoles: []string{"sar1", "sar3"}, + }, + { + name: "wildcard allow", + roleSet: NewRoleSet(roleWithAllowWildcard, roleWithNamespace), + requestType: types.KindNamespace, + expectedAllowedRoles: []string{"sar1", "sar4", "sar5"}, + }, + { + name: "wildcard deny", + roleSet: NewRoleSet(roleWithAllowWildcard, roleWithDenyWildcard), + requestType: types.KindNamespace, + expectedAllowedRoles: []string{}, + }, + { + name: "wildcard deny with unconfigured allow", + roleSet: NewRoleSet(roleWithNoConfigure, roleWithDenyWildcard), + requestType: types.KindNamespace, + expectedAllowedRoles: []string{}, + }, + { + name: "with deny role", + roleSet: NewRoleSet(roleWithDenyRole, roleWithNamespace), + requestType: types.KindNamespace, + expectedAllowedRoles: []string{"sar5", "sar7", "sar1"}, + }, + } + for _, tc := range tt { + accessChecker := makeAccessCheckerWithRoleSet(tc.roleSet) + t.Run(tc.name, func(t *testing.T) { + + allowedRoles := accessChecker.GetAllowedSearchAsRolesForKubeResourceKind(tc.requestType) + require.ElementsMatch(t, tc.expectedAllowedRoles, allowedRoles) + }) + } +} + // mustMakeTestServer creates a server with labels and an empty spec. // It panics in case of an error. Used only for testing func mustMakeTestServer(labels map[string]string) types.Server {