From d27f5f65fee6ae4d886ef1c2da20435a978dd3c0 Mon Sep 17 00:00:00 2001 From: Lisa Kim Date: Wed, 16 Oct 2024 14:40:49 -0700 Subject: [PATCH] Prune search as roles not meeting request mode when querying kube resources --- lib/kube/grpc/grpc.go | 2 +- lib/kube/grpc/grpc_test.go | 96 ++++++++++++++++++++-- lib/services/access_checker.go | 4 + lib/services/role.go | 46 +++++++++++ lib/services/role_test.go | 142 ++++++++++++++++++++++++++++++++- 5 files changed, 283 insertions(+), 7 deletions(-) diff --git a/lib/kube/grpc/grpc.go b/lib/kube/grpc/grpc.go index bb06c3fe00aff..df5a01e62d2d2 100644 --- a/lib/kube/grpc/grpc.go +++ b/lib/kube/grpc/grpc.go @@ -159,7 +159,7 @@ 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()...) + extraRoles = append(extraRoles, userContext.Checker.GetAllowedSearchAsRolesMeetingKubeRequestModes(req.ResourceType)...) } 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 6d6c888315d58..885d0821b5656 100644 --- a/lib/kube/grpc/grpc_test.go +++ b/lib/kube/grpc/grpc_test.go @@ -48,11 +48,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" + usernameWithRequestModePod = "request_mode_pod_user" + usernameWithRequestModeSecret = "request_mode_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 @@ -95,6 +97,48 @@ func TestListKubernetesResources(t *testing.T) { }, ) + userWithRequestModePod, _ := testCtx.CreateUserAndRole( + testCtx.Context, + t, + usernameWithRequestModePod, + kubeproxy.RoleSpec{ + Name: usernameWithRequestModePod, + 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.SetRequestMode(&types.AccessRequestMode{ + KubernetesResources: []types.RequestModeKubernetesResource{{Kind: "namespace"}, {Kind: "pod"}}, + }) + }, + }, + ) + + userWithRequestModeSecret, _ := testCtx.CreateUserAndRole( + testCtx.Context, + t, + usernameWithRequestModeSecret, + kubeproxy.RoleSpec{ + Name: usernameWithRequestModeSecret, + 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.SetRequestMode(&types.AccessRequestMode{ + KubernetesResources: []types.RequestModeKubernetesResource{{Kind: "secret"}}, + }) + }, + }, + ) + userNoAccess, _ := testCtx.CreateUserAndRole( testCtx.Context, t, @@ -292,6 +336,48 @@ func TestListKubernetesResources(t *testing.T) { }, assertErr: require.NoError, }, + { + name: "user with no access, listing dev namespace using search as roles with request mode secret, request type pod", + args: args{ + user: userWithRequestModeSecret, + searchAsRoles: true, + namespace: "dev", + resourceKind: types.KindKubePod, + }, + assertErr: require.Error, + }, + { + name: "user with no access listing dev namespace using search as roles with request mode pod, request type pod", + args: args{ + user: userWithRequestModePod, + 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 no access listing dev namespace using search as roles and sort", args: args{ diff --git a/lib/services/access_checker.go b/lib/services/access_checker.go index 7a3a2b8469ce6..9d04c0e1f281a 100644 --- a/lib/services/access_checker.go +++ b/lib/services/access_checker.go @@ -184,6 +184,10 @@ type AccessChecker interface { // GetAllowedSearchAsRoles returns all of the allowed SearchAsRoles. GetAllowedSearchAsRoles() []string + // GetAllowedSearchAsRolesMeetingKubeRequestModes returns all of the allowed SearchAsRoles that + // also passes the test where requestType matches the requestMode found for allowed role. + GetAllowedSearchAsRolesMeetingKubeRequestModes(requestType string) []string + // GetAllowedPreviewAsRoles returns all of the allowed PreviewAsRoles. GetAllowedPreviewAsRoles() []string diff --git a/lib/services/role.go b/lib/services/role.go index 0d877761bbced..67ffd884f44df 100644 --- a/lib/services/role.go +++ b/lib/services/role.go @@ -3293,6 +3293,52 @@ func (set RoleSet) GetAllowedSearchAsRoles() []string { return apiutils.Deduplicate(allowed) } +// GetAllowedSearchAsRolesMeetingKubeRequestModes returns all of the allowed SearchAsRoles that +// also passes the test where requestType matches the requestMode found for allowed role. +func (set RoleSet) GetAllowedSearchAsRolesMeetingKubeRequestModes(requestType string) []string { + denied := make(map[string]struct{}) + for _, role := range set { + for _, d := range role.GetSearchAsRoles(types.Deny) { + denied[d] = struct{}{} + } + } + + searchAsRolesLookup := make(map[string][]types.RequestModeKubernetesResource) + + for _, role := range set { + hasRequestMode := role.GetOptions().RequestMode != nil && len(role.GetOptions().RequestMode.KubernetesResources) > 0 + + for _, allowedRole := range role.GetSearchAsRoles(types.Allow) { + if _, denied := denied[allowedRole]; !denied { + if hasRequestMode { + kubeRequestModes := role.GetOptions().RequestMode.KubernetesResources + if _, exists := searchAsRolesLookup[allowedRole]; exists { + kubeRequestModes = append(kubeRequestModes, searchAsRolesLookup[allowedRole]...) + } + searchAsRolesLookup[allowedRole] = kubeRequestModes + } else { + searchAsRolesLookup[allowedRole] = nil + } + } + } + } + + var allowedRoleNames []string + for allowedRole, kubeResources := range searchAsRolesLookup { + if len(kubeResources) == 0 { + allowedRoleNames = append(allowedRoleNames, allowedRole) + continue + } + for _, kubeResource := range kubeResources { + if kubeResource.Kind == requestType || kubeResource.Kind == types.Wildcard { + allowedRoleNames = append(allowedRoleNames, allowedRole) + } + } + } + + return apiutils.Deduplicate(allowedRoleNames) +} + // 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 9beb05cfe0d2b..ed21a973360c6 100644 --- a/lib/services/role_test.go +++ b/lib/services/role_test.go @@ -189,6 +189,40 @@ func TestRoleParse(t *testing.T) { error: trace.BadParameter(""), matchMessage: "KubernetesResource must include Namespace", }, + { + name: "validation error, invalid request mode kube resource kind", + in: `{ + "kind": "role", + "version": "v6", + "metadata": {"name": "name1"}, + "spec": { + "options": { + "request_mode": { + "kubernetes_resources": [{"kind":"abcd"}] + } + } + } + }`, + error: trace.BadParameter(""), + matchMessage: "invalid or unsupported", + }, + { + name: "validation error, request mode namespace not supported in v6", + in: `{ + "kind": "role", + "version": "v6", + "metadata": {"name": "name1"}, + "spec": { + "options": { + "request_mode": { + "kubernetes_resources": [{"kind":"namespace"}] + } + } + } + }`, + error: trace.BadParameter(""), + matchMessage: "not supported in role version \"v6\"", + }, { name: "validation error, missing podname in pod names", in: `{ @@ -331,7 +365,10 @@ func TestRoleParse(t *testing.T) { "enhanced_recording": ["command", "network"], "desktop_clipboard": true, "desktop_directory_sharing": true, - "ssh_file_copy" : false + "ssh_file_copy" : false, + "request_mode": { + "kubernetes_resources": [{"kind":"pod"}] + } }, "allow": { "node_labels": {"a": "b", "c-d": "e"}, @@ -368,6 +405,11 @@ func TestRoleParse(t *testing.T) { }, Spec: types.RoleSpecV6{ Options: types.RoleOptions{ + RequestMode: &types.AccessRequestMode{ + KubernetesResources: []types.RequestModeKubernetesResource{ + {Kind: types.KindKubePod}, + }, + }, CertificateFormat: constants.CertificateFormatStandard, MaxSessionTTL: types.NewDuration(20 * time.Hour), PortForwarding: types.NewBoolOption(true), @@ -4686,6 +4728,104 @@ func TestGetAllowedLoginsForResource(t *testing.T) { } } +func TestGetAllowedSearchAsRolesMeetingKubeRequestModes(t *testing.T) { + newRole := func( + allowRoles []string, + denyRoles []string, + requestModes []types.RequestModeKubernetesResource, + ) *types.RoleV6 { + return &types.RoleV6{ + Spec: types.RoleSpecV6{ + Allow: types.RoleConditions{ + Request: &types.AccessRequestConditions{ + SearchAsRoles: allowRoles, + }, + }, + Deny: types.RoleConditions{ + Request: &types.AccessRequestConditions{ + SearchAsRoles: denyRoles, + }, + }, + Options: types.RoleOptions{ + RequestMode: &types.AccessRequestMode{ + KubernetesResources: requestModes, + }, + }, + }, + } + } + + withoutRequestModes := newRole([]string{"role1", "role2"}, []string{"role3"}, []types.RequestModeKubernetesResource{}) + withRequestModesNamespaceAndPod := newRole([]string{"role2", "role3", "role10"}, []string{"role3"}, []types.RequestModeKubernetesResource{ + {Kind: types.KindNamespace}, + {Kind: types.KindKubePod}, + }) + withRequestModeWildcard := newRole([]string{"role4", "role5"}, []string{"role3"}, []types.RequestModeKubernetesResource{ + {Kind: types.KindNamespace}, + {Kind: types.KindKubePod}, + {Kind: types.Wildcard}, + }) + withRequestModeSecret := newRole([]string{"role5", "role6"}, []string{"role3"}, []types.RequestModeKubernetesResource{ + {Kind: types.KindKubeSecret}, + }) + + tt := []struct { + name string + labels map[string]string + roleSet RoleSet + requestType string + expectedAllowedRoles []string + }{ + { + name: "return all allowed roles that doesn't have request mode defined since type doesn't match", + roleSet: NewRoleSet(withRequestModeSecret, withoutRequestModes), + requestType: types.KindNamespace, + // only roles from "withoutRequestModes" + expectedAllowedRoles: []string{"role1", "role2"}, + }, + { + name: "return all allowed roles with wildcard", + roleSet: NewRoleSet(withRequestModeSecret, withRequestModeWildcard), + requestType: types.KindKubeNamespace, + // only roles from "withRequestModeWildcard" + expectedAllowedRoles: []string{"role4", "role5"}, + }, + { + name: "return all allowed roles with matching type and wildcard", + roleSet: NewRoleSet(withRequestModeSecret, withRequestModeWildcard), + requestType: types.KindKubeSecret, + // roles from both "withRequestModeWildcard" & "withRequestModeSecret" + expectedAllowedRoles: []string{"role4", "role5", "role6"}, + }, + { + name: "return empty if there were no matching types", + roleSet: NewRoleSet(withRequestModeSecret, withRequestModesNamespaceAndPod), + requestType: types.KindKubeDeployment, + }, + { + name: "return all allowed roles only matching types", + roleSet: NewRoleSet(withRequestModeSecret, withRequestModesNamespaceAndPod), + requestType: types.KindKubePod, + // roles from "withRequestModesNamespaceAndPod" + expectedAllowedRoles: []string{"role2", "role10"}, + }, + { + name: "return all allowed roles with multiple rolesets", + roleSet: NewRoleSet(withoutRequestModes, withRequestModesNamespaceAndPod, withRequestModeWildcard, withRequestModeSecret), + requestType: types.KindKubeSecret, + expectedAllowedRoles: []string{"role1", "role4", "role5", "role6"}, + }, + } + for _, tc := range tt { + accessChecker := makeAccessCheckerWithRoleSet(tc.roleSet) + t.Run(tc.name, func(t *testing.T) { + + allowedRoles := accessChecker.GetAllowedSearchAsRolesMeetingKubeRequestModes(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 {