diff --git a/api/types/constants.go b/api/types/constants.go index 4b71576ea6806..246c27e0979b4 100644 --- a/api/types/constants.go +++ b/api/types/constants.go @@ -201,6 +201,8 @@ const ( KindCrownJewel = "crown_jewel" // KindKubernetesCluster is a Kubernetes cluster. KindKubernetesCluster = "kube_cluster" + // KindKubernetesResource is a Kubernetes resource within a cluster. + KindKubernetesResource = "kube_resource" // KindKubePod is a Kubernetes Pod resource type. KindKubePod = "pod" @@ -1354,10 +1356,22 @@ const ( var RequestableResourceKinds = []string{ KindNode, KindKubernetesCluster, + KindKubernetesResource, KindDatabase, KindApp, KindWindowsDesktop, KindUserGroup, + KindSAMLIdPServiceProvider, + KindIdentityCenterAccount, + KindIdentityCenterAccountAssignment, + KindGitServer, +} + +// LegacyRequestableKubeResourceKinds lists all legacy Teleport resource kinds users can request access to. +// Those are the requestable Kubernetes resource kinds that were supported before the introduction of +// custom resource support. We need to keep them to maintain support with older Teleport versions. +// TODO(@creack): DELETE IN v20.0.0. +var LegacyRequestableKubeResourceKinds = []string{ KindKubePod, KindKubeSecret, KindKubeConfigmap, @@ -1379,12 +1393,18 @@ var RequestableResourceKinds = []string{ KindKubeJob, KindKubeCertificateSigningRequest, KindKubeIngress, - KindSAMLIdPServiceProvider, - KindIdentityCenterAccount, - KindIdentityCenterAccountAssignment, - KindGitServer, } +// Prefix constants to identify kubernetes resources in access requests. +const ( + // AccessRequestPrefixKindKube denotes that the resource is a kubernetes one. Used for access requests. + AccessRequestPrefixKindKube = "kube:" + // AccessRequestPrefixKindKubeClusterWide denotes that the kube resource is cluster-wide. + AccessRequestPrefixKindKubeClusterWide = AccessRequestPrefixKindKube + "cw:" + // AccessRequestPrefixKindKubeNamespaced denotes that the kube resource is namespaced. + AccessRequestPrefixKindKubeNamespaced = AccessRequestPrefixKindKube + "ns:" +) + // The list below needs to be kept in sync with `kubernetesResourceKindOptions` // in `web/packages/teleport/src/Roles/RoleEditor/standardmodel.ts`. (Keeping // this comment separate to prevent it from being included in the official @@ -1529,6 +1549,7 @@ var KubernetesVerbs = []string{ // KubernetesClusterWideResourceKinds is the list of supported Kubernetes cluster resource kinds // that are not namespaced. // Needed to maintain backward compatibility. +// TODO(@creack): Make this a map[string]struct{} to simplify lookups. var KubernetesClusterWideResourceKinds = []string{ KindKubeNamespace, KindKubeNode, diff --git a/api/types/kubernetes.go b/api/types/kubernetes.go index 0ddac7c6ce28d..c833e661b55c3 100644 --- a/api/types/kubernetes.go +++ b/api/types/kubernetes.go @@ -21,6 +21,7 @@ import ( "regexp" "slices" "sort" + "strings" "time" "github.com/gravitational/trace" @@ -547,28 +548,14 @@ func DeduplicateKubeClusters(kubeclusters []KubeCluster) []KubeCluster { var _ ResourceWithLabels = (*KubernetesResourceV1)(nil) -// NewKubernetesPodV1 creates a new kubernetes resource with kind "pod". -func NewKubernetesPodV1(meta Metadata, spec KubernetesResourceSpecV1) (*KubernetesResourceV1, error) { - pod := &KubernetesResourceV1{ - Kind: KindKubePod, - Metadata: meta, - Spec: spec, - } - - if err := pod.CheckAndSetDefaults(); err != nil { - return nil, trace.Wrap(err) - } - return pod, nil -} - // NewKubernetesResourceV1 creates a new kubernetes resource . -func NewKubernetesResourceV1(kind string, meta Metadata, spec KubernetesResourceSpecV1) (*KubernetesResourceV1, error) { +func NewKubernetesResourceV1(kind string, namespaced bool, meta Metadata, spec KubernetesResourceSpecV1) (*KubernetesResourceV1, error) { resource := &KubernetesResourceV1{ Kind: kind, Metadata: meta, Spec: spec, } - if err := resource.CheckAndSetDefaults(); err != nil { + if err := resource.CheckAndSetDefaults(namespaced); err != nil { return nil, trace.Wrap(err) } return resource, nil @@ -631,17 +618,17 @@ func (k *KubernetesResourceV1) SetRevision(rev string) { // CheckAndSetDefaults validates the Resource and sets any empty fields to // default values. -func (k *KubernetesResourceV1) CheckAndSetDefaults() error { +func (k *KubernetesResourceV1) CheckAndSetDefaults(namespaced bool) error { k.setStaticFields() - if !slices.Contains(KubernetesResourcesKinds, k.Kind) { - return trace.BadParameter("invalid kind %q defined; allowed values: %v", k.Kind, KubernetesResourcesKinds) + if !slices.Contains(KubernetesResourcesKinds, k.Kind) && !strings.HasPrefix(k.Kind, AccessRequestPrefixKindKube) { + return trace.BadParameter("invalid kind %q defined; allowed values: %v, %s", k.Kind, KubernetesResourcesKinds, AccessRequestPrefixKindKube) } if err := k.Metadata.CheckAndSetDefaults(); err != nil { return trace.Wrap(err) } // Unless the resource is cluster-wide, it must have a namespace. - if len(k.Spec.Namespace) == 0 && !slices.Contains(KubernetesClusterWideResourceKinds, k.Kind) { + if len(k.Spec.Namespace) == 0 && namespaced { return trace.BadParameter("missing kubernetes namespace") } @@ -753,3 +740,27 @@ func (k KubeResources) AsResources() ResourcesWithLabels { } return resources } + +// KubeResource represents either a KubernetesResource or RequestKubernetesResource. +type KubeResource interface { + GetAPIGroup() string + GetKind() string + GetNamespace() string + SetAPIGroup(string) + SetKind(string) + SetNamespace(string) +} + +// Setter/Getter to enable generics. +func (m *RequestKubernetesResource) GetAPIGroup() string { return m.APIGroup } +func (m *KubernetesResource) GetAPIGroup() string { return m.APIGroup } +func (m *RequestKubernetesResource) SetAPIGroup(group string) { m.APIGroup = group } +func (m *KubernetesResource) SetAPIGroup(group string) { m.APIGroup = group } +func (m *RequestKubernetesResource) GetKind() string { return m.Kind } +func (m *KubernetesResource) GetKind() string { return m.Kind } +func (m *RequestKubernetesResource) SetKind(kind string) { m.Kind = kind } +func (m *KubernetesResource) SetKind(kind string) { m.Kind = kind } +func (m *RequestKubernetesResource) GetNamespace() string { return "" } +func (m *KubernetesResource) GetNamespace() string { return m.Namespace } +func (m *RequestKubernetesResource) SetNamespace(ns string) {} +func (m *KubernetesResource) SetNamespace(ns string) { m.Namespace = ns } diff --git a/api/types/resource_ids.go b/api/types/resource_ids.go index 3c6289351947d..0ae351da078f0 100644 --- a/api/types/resource_ids.go +++ b/api/types/resource_ids.go @@ -32,15 +32,29 @@ func (id *ResourceID) CheckAndSetDefaults() error { if len(id.Kind) == 0 { return trace.BadParameter("ResourceID must include Kind") } - if !slices.Contains(RequestableResourceKinds, id.Kind) { - return trace.BadParameter("Resource kind %q is invalid or unsupported", id.Kind) - } if len(id.Name) == 0 { return trace.BadParameter("ResourceID must include Name") } + // TODO(@creack): DELETE IN v20.0.0. Here to maintain backwards compatibility with older clients. + if id.Kind != KindKubeNamespace && slices.Contains(KubernetesResourcesKinds, id.Kind) { + apiGroup := KubernetesResourcesV7KindGroups[id.Kind] + if slices.Contains(KubernetesClusterWideResourceKinds, id.Kind) { + id.Kind = AccessRequestPrefixKindKubeClusterWide + KubernetesResourcesKindsPlurals[id.Kind] + } else { + id.Kind = AccessRequestPrefixKindKubeNamespaced + KubernetesResourcesKindsPlurals[id.Kind] + } + if apiGroup != "" { + id.Kind += "." + apiGroup + } + } + + if id.Kind != KindKubeNamespace && !slices.Contains(RequestableResourceKinds, id.Kind) && !strings.HasPrefix(id.Kind, AccessRequestPrefixKindKube) { + return trace.BadParameter("Resource kind %q is invalid or unsupported", id.Kind) + } + switch { - case slices.Contains(KubernetesResourcesKinds, id.Kind): + case id.Kind == KindKubeNamespace || strings.HasPrefix(id.Kind, AccessRequestPrefixKindKube): return trace.Wrap(id.validateK8sSubResource()) case id.SubResourceName != "": return trace.BadParameter("resource kind %q doesn't allow sub resources", id.Kind) @@ -52,17 +66,17 @@ func (id *ResourceID) validateK8sSubResource() error { if id.SubResourceName == "" { return trace.BadParameter("resource of kind %q must include a subresource name", id.Kind) } - isResourceNamespaceScoped := slices.Contains(KubernetesClusterWideResourceKinds, id.Kind) + isResourceClusterwide := id.Kind == KindKubeNamespace || slices.Contains(KubernetesClusterWideResourceKinds, id.Kind) || strings.HasPrefix(id.Kind, AccessRequestPrefixKindKubeClusterWide) switch split := strings.Split(id.SubResourceName, "/"); { - case isResourceNamespaceScoped && len(split) != 1: + case isResourceClusterwide && len(split) != 1: return trace.BadParameter("subresource %q must follow the following format: ", id.SubResourceName) - case isResourceNamespaceScoped && split[0] == "": + case isResourceClusterwide && split[0] == "": return trace.BadParameter("subresource %q must include a non-empty name: ", id.SubResourceName) - case !isResourceNamespaceScoped && len(split) != 2: + case !isResourceClusterwide && len(split) != 2: return trace.BadParameter("subresource %q must follow the following format: /", id.SubResourceName) - case !isResourceNamespaceScoped && split[0] == "": + case !isResourceClusterwide && split[0] == "": return trace.BadParameter("subresource %q must include a non-empty namespace: /", id.SubResourceName) - case !isResourceNamespaceScoped && split[1] == "": + case !isResourceClusterwide && split[1] == "": return trace.BadParameter("subresource %q must include a non-empty name: /", id.SubResourceName) } @@ -95,9 +109,10 @@ func ResourceIDFromString(raw string) (ResourceID, error) { Kind: parts[1], Name: parts[2], } + switch { - case slices.Contains(KubernetesResourcesKinds, resourceID.Kind): - isResourceNamespaceScoped := slices.Contains(KubernetesClusterWideResourceKinds, resourceID.Kind) + case slices.Contains(KubernetesResourcesKinds, resourceID.Kind) || strings.HasPrefix(resourceID.Kind, AccessRequestPrefixKindKube) || resourceID.Kind == KindKubeNamespace: + isResourceClusterWide := resourceID.Kind == KindKubeNamespace || slices.Contains(KubernetesClusterWideResourceKinds, resourceID.Kind) || strings.HasPrefix(resourceID.Kind, AccessRequestPrefixKindKubeClusterWide) // Kubernetes forbids slashes "/" in Namespaces and Pod names, so it's safe to // explode the resourceID.Name and extract the last two entries as namespace // and name. @@ -107,10 +122,10 @@ func ResourceIDFromString(raw string) (ResourceID, error) { // will fail because, for kind=pod, it's mandatory to present a non-empty // namespace and name. splits := strings.Split(resourceID.Name, "/") - if !isResourceNamespaceScoped && len(splits) >= 3 { + if !isResourceClusterWide && len(splits) >= 3 { resourceID.Name = strings.Join(splits[:len(splits)-2], "/") resourceID.SubResourceName = strings.Join(splits[len(splits)-2:], "/") - } else if isResourceNamespaceScoped && len(splits) >= 2 { + } else if isResourceClusterWide && len(splits) >= 2 { resourceID.Name = strings.Join(splits[:len(splits)-1], "/") resourceID.SubResourceName = strings.Join(splits[len(splits)-1:], "/") } diff --git a/api/types/resource_ids_test.go b/api/types/resource_ids_test.go index 8b95e2df1c8d4..47a474b5cab95 100644 --- a/api/types/resource_ids_test.go +++ b/api/types/resource_ids_test.go @@ -107,317 +107,327 @@ func TestResourceIDs(t *testing.T) { desc: "pod resource name in cluster with slash", in: []ResourceID{{ ClusterName: "one", - Kind: KindKubePod, + Kind: "kube:ns:pods", Name: "cluster/1", SubResourceName: "namespace/pod*", }}, - expected: `["/one/pod/cluster/1/namespace/pod*"]`, + expected: `["/one/kube:ns:pods/cluster/1/namespace/pod*"]`, }, { desc: "pod resource name", in: []ResourceID{{ ClusterName: "one", - Kind: KindKubePod, + Kind: "kube:ns:pods", Name: "cluster", SubResourceName: "namespace/pod*", }}, - expected: `["/one/pod/cluster/namespace/pod*"]`, + expected: `["/one/kube:ns:pods/cluster/namespace/pod*"]`, }, { desc: "pod resource name with missing namespace", in: []ResourceID{{ ClusterName: "one", - Kind: KindKubePod, + Kind: "kube:ns:pods", Name: "cluster", SubResourceName: "/pod*", }}, - expected: `["/one/pod/cluster//pod*"]`, + expected: `["/one/kube:ns:pods/cluster//pod*"]`, expectParseError: true, }, { desc: "pod resource name with missing namespace and pod name", in: []ResourceID{{ ClusterName: "one", - Kind: KindKubePod, + Kind: "kube:ns:pods", Name: "cluster", }}, - expected: `["/one/pod/cluster"]`, + expected: `["/one/kube:ns:pods/cluster"]`, expectParseError: true, }, { desc: "pod resource name in cluster with slash", in: []ResourceID{{ ClusterName: "one", - Kind: KindKubePod, + Kind: "kube:ns:pods", Name: "cluster", SubResourceName: "namespace/pod*", }}, - expected: `["/one/pod/cluster/namespace/pod*"]`, + expected: `["/one/kube:ns:pods/cluster/namespace/pod*"]`, }, { desc: "secret resource name with missing namespace", in: []ResourceID{{ ClusterName: "one", - Kind: KindKubeSecret, + Kind: "kube:ns:secrets", Name: "cluster", SubResourceName: "/secret*", }}, - expected: `["/one/secret/cluster//secret*"]`, + expected: `["/one/kube:ns:secrets/cluster//secret*"]`, expectParseError: true, }, { desc: "secret resource name with missing namespace and pod name", in: []ResourceID{{ ClusterName: "one", - Kind: KindKubeSecret, + Kind: "kube:ns:secrets", Name: "cluster", }}, - expected: `["/one/secret/cluster"]`, + expected: `["/one/kube:ns:secrets/cluster"]`, expectParseError: true, }, { desc: "secret resource name in cluster with slash", in: []ResourceID{{ ClusterName: "one", - Kind: KindKubeSecret, + Kind: "kube:ns:secrets", Name: "cluster", SubResourceName: "namespace/secret*", }}, - expected: `["/one/secret/cluster/namespace/secret*"]`, + expected: `["/one/kube:ns:secrets/cluster/namespace/secret*"]`, }, { desc: "configmap resource name with missing namespace", in: []ResourceID{{ ClusterName: "one", - Kind: KindKubeConfigmap, + Kind: "kube:ns:configmaps", Name: "cluster", SubResourceName: "/configmap*", }}, - expected: `["/one/configmap/cluster//configmap*"]`, + expected: `["/one/kube:ns:configmaps/cluster//configmap*"]`, expectParseError: true, }, { desc: "configmap resource name with missing namespace and pod name", in: []ResourceID{{ ClusterName: "one", - Kind: KindKubeConfigmap, + Kind: "kube:ns:configmaps", Name: "cluster", }}, - expected: `["/one/configmap/cluster"]`, + expected: `["/one/kube:ns:configmaps/cluster"]`, expectParseError: true, }, { desc: "configmap resource name in cluster with slash", in: []ResourceID{{ ClusterName: "one", - Kind: KindKubeConfigmap, + Kind: "kube:ns:configmaps", Name: "cluster", SubResourceName: "namespace/configmap*", }}, - expected: `["/one/configmap/cluster/namespace/configmap*"]`, + expected: `["/one/kube:ns:configmaps/cluster/namespace/configmap*"]`, }, { desc: "service resource name with missing namespace", in: []ResourceID{{ ClusterName: "one", - Kind: KindKubeService, + Kind: "kube:ns:services", Name: "cluster", SubResourceName: "/service*", }}, - expected: `["/one/service/cluster//service*"]`, + expected: `["/one/kube:ns:services/cluster//service*"]`, expectParseError: true, }, { desc: "service resource name with missing namespace and pod name", in: []ResourceID{{ ClusterName: "one", - Kind: KindKubeService, + Kind: "kube:ns:services", Name: "cluster", }}, - expected: `["/one/service/cluster"]`, + expected: `["/one/kube:ns:services/cluster"]`, expectParseError: true, }, { desc: "service resource name in cluster with slash", in: []ResourceID{{ ClusterName: "one", - Kind: KindKubeService, + Kind: "kube:ns:services", Name: "cluster", SubResourceName: "namespace/service*", }}, - expected: `["/one/service/cluster/namespace/service*"]`, + expected: `["/one/kube:ns:services/cluster/namespace/service*"]`, }, { desc: "service_account resource name with missing namespace", in: []ResourceID{{ ClusterName: "one", - Kind: KindKubeServiceAccount, + Kind: "kube:ns:serviceaccounts", Name: "cluster", SubResourceName: "/service_account*", }}, - expected: `["/one/serviceaccount/cluster//service_account*"]`, + expected: `["/one/kube:ns:serviceaccounts/cluster//service_account*"]`, expectParseError: true, }, { desc: "service_account resource name with missing namespace and pod name", in: []ResourceID{{ ClusterName: "one", - Kind: KindKubeServiceAccount, + Kind: "kube:ns:serviceaccounts", Name: "cluster", }}, - expected: `["/one/serviceaccount/cluster"]`, + expected: `["/one/kube:ns:serviceaccounts/cluster"]`, expectParseError: true, }, { desc: "service_account resource name in cluster with slash", in: []ResourceID{{ ClusterName: "one", - Kind: KindKubeServiceAccount, + Kind: "kube:ns:serviceaccounts", Name: "cluster", SubResourceName: "namespace/service_account*", }}, - expected: `["/one/serviceaccount/cluster/namespace/service_account*"]`, + expected: `["/one/kube:ns:serviceaccounts/cluster/namespace/service_account*"]`, }, { desc: "persistent_volume_claim resource name with missing namespace", in: []ResourceID{{ ClusterName: "one", - Kind: KindKubePersistentVolumeClaim, + Kind: "kube:ns:persistentvolumeclaims", Name: "cluster", SubResourceName: "/persistent_volume_claim*", }}, - expected: `["/one/persistentvolumeclaim/cluster//persistent_volume_claim*"]`, + expected: `["/one/kube:ns:persistentvolumeclaims/cluster//persistent_volume_claim*"]`, expectParseError: true, }, { desc: "persistent_volume_claim resource name with missing namespace and pod name", in: []ResourceID{{ ClusterName: "one", - Kind: KindKubePersistentVolumeClaim, + Kind: "kube:ns:persistentvolumeclaims", Name: "cluster", }}, - expected: `["/one/persistentvolumeclaim/cluster"]`, + expected: `["/one/kube:ns:persistentvolumeclaims/cluster"]`, expectParseError: true, }, { desc: "namespace resource name with missing namespace and pod name", in: []ResourceID{{ ClusterName: "one", - Kind: KindKubeNamespace, + Kind: "kube:cw:namespaces", Name: "cluster", }}, - expected: `["/one/namespace/cluster"]`, + expected: `["/one/kube:cw:namespaces/cluster"]`, expectParseError: true, }, { desc: "namespace resource name in cluster with slash", in: []ResourceID{{ ClusterName: "one", - Kind: KindKubeNamespace, + Kind: "kube:cw:namespaces", Name: "cluster", SubResourceName: "namespace*", }}, - expected: `["/one/namespace/cluster/namespace*"]`, + expected: `["/one/kube:cw:namespaces/cluster/namespace*"]`, }, { desc: "kube_node resource name with missing namespace and pod name", in: []ResourceID{{ ClusterName: "one", - Kind: KindKubeNode, + Kind: "kube:cw:nodes", Name: "cluster", }}, - expected: `["/one/kube_node/cluster"]`, + expected: `["/one/kube:cw:nodes/cluster"]`, expectParseError: true, }, { desc: "kube_node resource name in cluster with slash", in: []ResourceID{{ ClusterName: "one", - Kind: KindKubeNode, + Kind: "kube:cw:nodes", Name: "cluster", SubResourceName: "kube_node*", }}, - expected: `["/one/kube_node/cluster/kube_node*"]`, + expected: `["/one/kube:cw:nodes/cluster/kube_node*"]`, }, { desc: "persistent_volume resource name with missing namespace and pod name", in: []ResourceID{{ ClusterName: "one", - Kind: KindKubePersistentVolume, + Kind: "kube:cw:persistentvolumes", Name: "cluster", }}, - expected: `["/one/persistentvolume/cluster"]`, + expected: `["/one/kube:cw:persistentvolumes/cluster"]`, expectParseError: true, }, { desc: "persistent_volume resource name in cluster with slash", in: []ResourceID{{ ClusterName: "one", - Kind: KindKubePersistentVolume, + Kind: "kube:cw:persistentvolumes", Name: "cluster", SubResourceName: "persistent_volume*", }}, - expected: `["/one/persistentvolume/cluster/persistent_volume*"]`, + expected: `["/one/kube:cw:persistentvolumes/cluster/persistent_volume*"]`, }, { desc: "cluster_role resource name with missing namespace and pod name", in: []ResourceID{{ ClusterName: "one", - Kind: KindKubeClusterRole, + Kind: "kube:cw:clusterroles.rbac.authorization.k8s.io", Name: "cluster", }}, - expected: `["/one/clusterrole/cluster"]`, + expected: `["/one/kube:cw:clusterroles.rbac.authorization.k8s.io/cluster"]`, expectParseError: true, }, { desc: "cluster_role resource name in cluster with slash", in: []ResourceID{{ ClusterName: "one", - Kind: KindKubeClusterRole, + Kind: "kube:cw:clusterroles.rbac.authorization.k8s.io", Name: "cluster", SubResourceName: "cluster_role*", }}, - expected: `["/one/clusterrole/cluster/cluster_role*"]`, + expected: `["/one/kube:cw:clusterroles.rbac.authorization.k8s.io/cluster/cluster_role*"]`, }, { desc: "cluster_role_binding resource name with missing namespace and pod name", in: []ResourceID{{ ClusterName: "one", - Kind: KindKubeClusterRoleBinding, + Kind: "kube:cw:clusterrolebindings.rbac.authorization.k8s.io", Name: "cluster", }}, - expected: `["/one/clusterrolebinding/cluster"]`, + expected: `["/one/kube:cw:clusterrolebindings.rbac.authorization.k8s.io/cluster"]`, expectParseError: true, }, { desc: "cluster_role_binding resource name in cluster with slash", in: []ResourceID{{ ClusterName: "one", - Kind: KindKubeClusterRoleBinding, + Kind: "kube:cw:clusterrolebindings.rbac.authorization.k8s.io", Name: "cluster", SubResourceName: "cluster_role_binding*", }}, - expected: `["/one/clusterrolebinding/cluster/cluster_role_binding*"]`, + expected: `["/one/kube:cw:clusterrolebindings.rbac.authorization.k8s.io/cluster/cluster_role_binding*"]`, }, { desc: "certificate_signing_request resource name with missing namespace and pod name", in: []ResourceID{{ ClusterName: "one", - Kind: KindKubeCertificateSigningRequest, + Kind: "kube:cw:certificatesigningrequests.certificates.k8s.io", Name: "cluster", }}, - expected: `["/one/certificatesigningrequest/cluster"]`, + expected: `["/one/kube:cw:certificatesigningrequests.certificates.k8s.io/cluster"]`, expectParseError: true, }, { desc: "certificate_signing_request resource name in cluster with slash", in: []ResourceID{{ ClusterName: "one", - Kind: KindKubeCertificateSigningRequest, + Kind: "kube:cw:certificatesigningrequests.certificates.k8s.io", Name: "cluster", SubResourceName: "certificate_signing_request*", }}, - expected: `["/one/certificatesigningrequest/cluster/certificate_signing_request*"]`, + expected: `["/one/kube:cw:certificatesigningrequests.certificates.k8s.io/cluster/certificate_signing_request*"]`, + }, + { + desc: "full kube namespace access", + in: []ResourceID{{ + ClusterName: "one", + Kind: "kube:ns:*.*", // kind: *, api group: *. + Name: "cluster", + SubResourceName: "default/*", // namespace: default, resource name: *. + }}, + expected: `["/one/kube:ns:*.*/cluster/default/*"]`, }, } for _, tc := range testCases { @@ -452,3 +462,341 @@ func TestResourceIDs(t *testing.T) { }) } } + +// TODO(@creack): DELETE IN v20.0.0 when we no longer support legacy kube kinds. +func TestLegacyKubeResourceIDs(t *testing.T) { + testCases := []struct { + desc string + expect []ResourceID + in string + expectParseError bool + }{ + { + desc: "pod resource name in cluster with slash", + expect: []ResourceID{{ + ClusterName: "one", + Kind: "kube:ns:pods", + Name: "cluster/1", + SubResourceName: "namespace/pod*", + }}, + in: `["/one/pod/cluster/1/namespace/pod*"]`, + }, + { + desc: "pod resource name", + expect: []ResourceID{{ + ClusterName: "one", + Kind: "kube:ns:pods", + Name: "cluster", + SubResourceName: "namespace/pod*", + }}, + in: `["/one/pod/cluster/namespace/pod*"]`, + }, + { + desc: "pod resource name with missing namespace", + expect: []ResourceID{{ + ClusterName: "one", + Kind: "kube:ns:pods", + Name: "cluster", + SubResourceName: "/pod*", + }}, + in: `["/one/pod/cluster//pod*"]`, + expectParseError: true, + }, + { + desc: "pod resource name with missing namespace and pod name", + expect: []ResourceID{{ + ClusterName: "one", + Kind: "kube:ns:pods", + Name: "cluster", + }}, + in: `["/one/pod/cluster"]`, + expectParseError: true, + }, + { + desc: "pod resource name in cluster with slash", + expect: []ResourceID{{ + ClusterName: "one", + Kind: "kube:ns:pods", + Name: "cluster", + SubResourceName: "namespace/pod*", + }}, + in: `["/one/pod/cluster/namespace/pod*"]`, + }, + { + desc: "secret resource name with missing namespace", + expect: []ResourceID{{ + ClusterName: "one", + Kind: "kube:ns:secrets", + Name: "cluster", + SubResourceName: "/secret*", + }}, + in: `["/one/secret/cluster//secret*"]`, + expectParseError: true, + }, + { + desc: "secret resource name with missing namespace and pod name", + expect: []ResourceID{{ + ClusterName: "one", + Kind: "kube:ns:secrets", + Name: "cluster", + }}, + in: `["/one/secret/cluster"]`, + expectParseError: true, + }, + { + desc: "secret resource name in cluster with slash", + expect: []ResourceID{{ + ClusterName: "one", + Kind: "kube:ns:secrets", + Name: "cluster", + SubResourceName: "namespace/secret*", + }}, + in: `["/one/secret/cluster/namespace/secret*"]`, + }, + { + desc: "configmap resource name with missing namespace", + expect: []ResourceID{{ + ClusterName: "one", + Kind: "kube:ns:configmaps", + Name: "cluster", + SubResourceName: "/configmap*", + }}, + in: `["/one/configmap/cluster//configmap*"]`, + expectParseError: true, + }, + { + desc: "configmap resource name with missing namespace and pod name", + expect: []ResourceID{{ + ClusterName: "one", + Kind: "kube:ns:configmaps", + Name: "cluster", + }}, + in: `["/one/configmap/cluster"]`, + expectParseError: true, + }, + { + desc: "configmap resource name in cluster with slash", + expect: []ResourceID{{ + ClusterName: "one", + Kind: "kube:ns:configmaps", + Name: "cluster", + SubResourceName: "namespace/configmap*", + }}, + in: `["/one/configmap/cluster/namespace/configmap*"]`, + }, + { + desc: "service resource name with missing namespace", + expect: []ResourceID{{ + ClusterName: "one", + Kind: "kube:ns:services", + Name: "cluster", + SubResourceName: "/service*", + }}, + in: `["/one/service/cluster//service*"]`, + expectParseError: true, + }, + { + desc: "service resource name with missing namespace and pod name", + expect: []ResourceID{{ + ClusterName: "one", + Kind: "kube:ns:services", + Name: "cluster", + }}, + in: `["/one/service/cluster"]`, + expectParseError: true, + }, + { + desc: "service resource name in cluster with slash", + expect: []ResourceID{{ + ClusterName: "one", + Kind: "kube:ns:services", + Name: "cluster", + SubResourceName: "namespace/service*", + }}, + in: `["/one/service/cluster/namespace/service*"]`, + }, + { + desc: "service_account resource name with missing namespace", + expect: []ResourceID{{ + ClusterName: "one", + Kind: "kube:ns:serviceaccounts", + Name: "cluster", + SubResourceName: "/service_account*", + }}, + in: `["/one/serviceaccount/cluster//service_account*"]`, + expectParseError: true, + }, + { + desc: "service_account resource name with missing namespace and pod name", + expect: []ResourceID{{ + ClusterName: "one", + Kind: "kube:ns:serviceaccounts", + Name: "cluster", + }}, + in: `["/one/serviceaccount/cluster"]`, + expectParseError: true, + }, + { + desc: "service_account resource name in cluster with slash", + expect: []ResourceID{{ + ClusterName: "one", + Kind: "kube:ns:serviceaccounts", + Name: "cluster", + SubResourceName: "namespace/service_account*", + }}, + in: `["/one/serviceaccount/cluster/namespace/service_account*"]`, + }, + { + desc: "persistent_volume_claim resource name with missing namespace", + expect: []ResourceID{{ + ClusterName: "one", + Kind: "kube:ns:persistentvolumeclaims", + Name: "cluster", + SubResourceName: "/persistent_volume_claim*", + }}, + in: `["/one/persistentvolumeclaim/cluster//persistent_volume_claim*"]`, + expectParseError: true, + }, + { + desc: "persistent_volume_claim resource name with missing namespace and pod name", + expect: []ResourceID{{ + ClusterName: "one", + Kind: "kube:ns:persistentvolumeclaims", + Name: "cluster", + }}, + in: `["/one/persistentvolumeclaim/cluster"]`, + expectParseError: true, + }, + { + desc: "namespace resource name with missing namespace and pod name", + expect: []ResourceID{{ + ClusterName: "one", + Kind: "namespaces", + Name: "cluster", + }}, + in: `["/one/namespace/cluster"]`, + expectParseError: true, + }, + { + desc: "namespace resource name in cluster with slash", + expect: []ResourceID{{ + ClusterName: "one", + Kind: "namespace", + Name: "cluster", + SubResourceName: "namespace*", + }}, + in: `["/one/namespace/cluster/namespace*"]`, + }, + { + desc: "kube_node resource name with missing namespace and pod name", + expect: []ResourceID{{ + ClusterName: "one", + Kind: "kube:cw:nodes", + Name: "cluster", + }}, + in: `["/one/kube_node/cluster"]`, + expectParseError: true, + }, + { + desc: "kube_node resource name in cluster with slash", + expect: []ResourceID{{ + ClusterName: "one", + Kind: "kube:cw:nodes", + Name: "cluster", + SubResourceName: "kube_node*", + }}, + in: `["/one/kube_node/cluster/kube_node*"]`, + }, + { + desc: "persistent_volume resource name with missing namespace and pod name", + expect: []ResourceID{{ + ClusterName: "one", + Kind: "kube:cw:persistentvolumes", + Name: "cluster", + }}, + in: `["/one/persistentvolume/cluster"]`, + expectParseError: true, + }, + { + desc: "persistent_volume resource name in cluster with slash", + expect: []ResourceID{{ + ClusterName: "one", + Kind: "kube:cw:persistentvolumes", + Name: "cluster", + SubResourceName: "persistent_volume*", + }}, + in: `["/one/persistentvolume/cluster/persistent_volume*"]`, + }, + { + desc: "cluster_role resource name with missing namespace and pod name", + expect: []ResourceID{{ + ClusterName: "one", + Kind: "kube:cw:clusterroles.rbac.authorization.k8s.io", + Name: "cluster", + }}, + in: `["/one/clusterrole/cluster"]`, + expectParseError: true, + }, + { + desc: "cluster_role resource name in cluster with slash", + expect: []ResourceID{{ + ClusterName: "one", + Kind: "kube:cw:clusterroles.rbac.authorization.k8s.io", + Name: "cluster", + SubResourceName: "cluster_role*", + }}, + in: `["/one/clusterrole/cluster/cluster_role*"]`, + }, + { + desc: "cluster_role_binding resource name with missing namespace and pod name", + expect: []ResourceID{{ + ClusterName: "one", + Kind: "kube:cw:clusterrolebindings.rbac.authorization.k8s.io", + Name: "cluster", + }}, + in: `["/one/clusterrolebinding/cluster"]`, + expectParseError: true, + }, + { + desc: "cluster_role_binding resource name in cluster with slash", + expect: []ResourceID{{ + ClusterName: "one", + Kind: "kube:cw:clusterrolebindings.rbac.authorization.k8s.io", + Name: "cluster", + SubResourceName: "cluster_role_binding*", + }}, + in: `["/one/clusterrolebinding/cluster/cluster_role_binding*"]`, + }, + { + desc: "certificate_signing_request resource name with missing namespace and pod name", + expect: []ResourceID{{ + ClusterName: "one", + Kind: "kube:cw:certificatesigningrequests.certificates.k8s.io", + Name: "cluster", + }}, + in: `["/one/certificatesigningrequest/cluster"]`, + expectParseError: true, + }, + { + desc: "certificate_signing_request resource name in cluster with slash", + expect: []ResourceID{{ + ClusterName: "one", + Kind: "kube:cw:certificatesigningrequests.certificates.k8s.io", + Name: "cluster", + SubResourceName: "certificate_signing_request*", + }}, + in: `["/one/certificatesigningrequest/cluster/certificate_signing_request*"]`, + }, + } + for _, tc := range testCases { + t.Run(tc.desc, func(t *testing.T) { + parsed, err := ResourceIDsFromString(tc.in) + if tc.expectParseError { + require.Error(t, err, "expected to get an error parsing resource IDs") + return + } + require.NoError(t, err) + require.Equal(t, tc.expect, parsed, "parsed resource IDs do not match the originals") + }) + } +} diff --git a/api/types/role.go b/api/types/role.go index c9e3cd5e1168f..d6a3ffbf6a240 100644 --- a/api/types/role.go +++ b/api/types/role.go @@ -152,6 +152,8 @@ type Role interface { // SetKubeResources configures the Kubernetes Resources for the RoleConditionType. SetKubeResources(rct RoleConditionType, pods []KubernetesResource) + // GetRequestKubernetesResources returns the request Kubernetes resources. + GetRequestKubernetesResources(rct RoleConditionType) []RequestKubernetesResource // SetRequestKubernetesResources sets the request kubernetes resources. SetRequestKubernetesResources(rct RoleConditionType, resources []RequestKubernetesResource) @@ -481,6 +483,8 @@ func (r *RoleV6) GetKubeResources(rct RoleConditionType) []KubernetesResource { // For roles v8, it returns the list as it is. // // For roles <=v7, it maps the legacy teleport Kinds to k8s plurals and sets the APIGroup to wildcard. +// +// Must be in sync with RoleV6.convertRequestKubernetesResourcesBetweenRoleVersions. func (r *RoleV6) convertKubernetesResourcesBetweenRoleVersions(resources []KubernetesResource) []KubernetesResource { switch r.Version { case V8: @@ -495,7 +499,7 @@ func (r *RoleV6) convertKubernetesResourcesBetweenRoleVersions(resources []Kuber if r.Kind == KindKubeNamespace { r.Kind = Wildcard if r.Name == Wildcard { - r.Namespace = "^" + Wildcard + "$" + r.Namespace = "^.+$" } else { r.Namespace = r.Name } @@ -514,9 +518,11 @@ func (r *RoleV6) convertKubernetesResourcesBetweenRoleVersions(resources []Kuber r.Namespace = "" } if k, ok := KubernetesResourcesKindsPlurals[r.Kind]; ok { // Can be empty if the kind is a wildcard. + r.APIGroup = KubernetesResourcesV7KindGroups[r.Kind] r.Kind = k + } else { + r.APIGroup = Wildcard } - r.APIGroup = Wildcard v7resources[i] = r if r.Kind == Wildcard { // If we have a wildcard, inject the clusterwide resources. for _, elem := range KubernetesClusterWideResourceKinds { @@ -579,13 +585,16 @@ func (r *RoleV6) convertAllowKubernetesResourcesBetweenRoleVersions(resources [] v6resources := slices.Clone(resources) for i, r := range v6resources { if k, ok := KubernetesResourcesKindsPlurals[r.Kind]; ok { + r.APIGroup = KubernetesResourcesV7KindGroups[r.Kind] r.Kind = k + } else { + r.APIGroup = Wildcard } - r.APIGroup = Wildcard v6resources[i] = r } for _, resource := range KubernetesResourcesKinds { // Iterate over the list to have deterministic order. + group := KubernetesResourcesV7KindGroups[resource] resource = KubernetesResourcesKindsPlurals[resource] // Ignore Pod resources for older roles because Pods were already supported // so we don't need to keep backwards compatibility for them. @@ -594,7 +603,7 @@ func (r *RoleV6) convertAllowKubernetesResourcesBetweenRoleVersions(resources [] if resource == "pods" || resource == "namespaces" { continue } - v6resources = append(v6resources, KubernetesResource{Kind: resource, Name: Wildcard, Namespace: Wildcard, Verbs: []string{Wildcard}, APIGroup: Wildcard}) + v6resources = append(v6resources, KubernetesResource{Kind: resource, Name: Wildcard, Namespace: Wildcard, Verbs: []string{Wildcard}, APIGroup: group}) } return v6resources } @@ -612,6 +621,20 @@ func (r *RoleV6) SetKubeResources(rct RoleConditionType, pods []KubernetesResour } } +// GetRequestKubernetesResources returns the upgraded request kubernetes resources. +func (r *RoleV6) GetRequestKubernetesResources(rct RoleConditionType) []RequestKubernetesResource { + if rct == Allow { + if r.Spec.Allow.Request == nil { + return nil + } + return r.convertRequestKubernetesResourcesBetweenRoleVersions(r.Spec.Allow.Request.KubernetesResources) + } + if r.Spec.Deny.Request == nil { + return nil + } + return r.convertRequestKubernetesResourcesBetweenRoleVersions(r.Spec.Deny.Request.KubernetesResources) +} + // SetRequestKubernetesResources sets the request kubernetes resources. func (r *RoleV6) SetRequestKubernetesResources(rct RoleConditionType, resources []RequestKubernetesResource) { roleConditions := &r.Spec.Allow @@ -655,6 +678,40 @@ func (r *RoleV6) GetAccessRequestConditions(rct RoleConditionType) AccessRequest return *cond } +// convertRequestKubernetesResourcesBetweenRoleVersions converts Access Request Kubernetes resources between role versions. +// +// This is required to keep compatibility between role versions to avoid breaking changes +// when using an older role version. +// +// For roles v8, it returns the list as it is. +// +// For roles <=v7, it maps the legacy teleport Kinds to k8s plurals and sets the APIGroup to wildcard. +// +// Must be in sync with RoleV6.convertDenyKubernetesResourcesBetweenRoleVersions. +func (r *RoleV6) convertRequestKubernetesResourcesBetweenRoleVersions(resources []RequestKubernetesResource) []RequestKubernetesResource { + if len(resources) == 0 { + return nil + } + switch r.Version { + case V8: + return resources + default: + v7resources := slices.Clone(resources) + for i, r := range v7resources { + if k, ok := KubernetesResourcesKindsPlurals[r.Kind]; ok { // Can be empty if the kind is a wildcard. + r.APIGroup = KubernetesResourcesV7KindGroups[r.Kind] + r.Kind = k + } else if r.Kind == KindKubeNamespace { + r.Kind = "namespaces" + } else { + r.APIGroup = Wildcard + } + v7resources[i] = r + } + return v7resources + } +} + // SetAccessRequestConditions sets allow/deny conditions for access requests. func (r *RoleV6) SetAccessRequestConditions(rct RoleConditionType, cond AccessRequestConditions) { if rct == Allow { @@ -1986,12 +2043,12 @@ func validateKubeResources(roleVersion string, kubeResources []KubernetesResourc } } - // Only Pod resources are supported in role version <=V6. - // This is mandatory because we must append the other resources to the - // kubernetes resources. switch roleVersion { // Teleport does not support role versions < v3. case V6, V5, V4, V3: + // Only Pod resources are supported in role version <=V6. + // This is mandatory because we must append the other resources to the + // kubernetes resources. if kubeResource.Kind != KindKubePod { return trace.BadParameter("KubernetesResource kind %q is not supported in role version %q. Upgrade the role version to %q", kubeResource.Kind, roleVersion, V8) } @@ -2042,26 +2099,50 @@ func validateKubeResources(roleVersion string, kubeResources []KubernetesResourc } // validateRequestKubeResources validates each kubeResources entry for `allow.request.kubernetes_resources` field. -// Currently the only supported field for this particular field is: -// - Kind (belonging to KubernetesResourcesKinds) +// Currently the only supported field for this particular field are: +// - Kind +// - APIGroup // // Mimics types.KubernetesResource data model, but opted to create own type as we don't support other fields yet. -// -// TODO(@creack): Handle rolev8 kind/group to support CRDs. Still use the teleport kinds for now. func validateRequestKubeResources(roleVersion string, kubeResources []RequestKubernetesResource) error { for _, kubeResource := range kubeResources { - if !slices.Contains(KubernetesResourcesKinds, kubeResource.Kind) && kubeResource.Kind != Wildcard { - return trace.BadParameter("request.kubernetes_resource kind %q is invalid or unsupported; Supported: %v", kubeResource.Kind, append([]string{Wildcard}, KubernetesResourcesKinds...)) - } - - // Only Pod resources are supported in role version <=V6. - // This is mandatory because we must append the other resources to the - // kubernetes resources. switch roleVersion { + case V8: + if kubeResource.Kind == "" { + return trace.BadParameter("request.kubernetes_resource kind is required in role version %q", roleVersion) + } + // If we have a kind that match a role v7 one, check the api group. + if slices.Contains(KubernetesResourcesKinds, kubeResource.Kind) { + // If the api group is a wildcard or match v7, then it is mostly definitely a mistake, reject the role. + if kubeResource.APIGroup == Wildcard || kubeResource.APIGroup == KubernetesResourcesV7KindGroups[kubeResource.Kind] { + return trace.BadParameter("request.kubernetes_resource kind %q is invalid. Please use plural name for role version %q", kubeResource.Kind, roleVersion) + } + } + // Only allow empty string for known core resources. + if kubeResource.APIGroup == "" { + if _, ok := KubernetesCoreResourceKinds[kubeResource.Kind]; !ok { + return trace.BadParameter("request.kubernetes_resource api_group is required for resource %q in role version %q", kubeResource.Kind, roleVersion) + } + } + case V7: + if kubeResource.APIGroup != "" { + return trace.BadParameter("request.kubernetes_resource api_group is not supported in role version %q. Upgrade the role version to %q", roleVersion, V8) + } + if !slices.Contains(KubernetesResourcesKinds, kubeResource.Kind) && kubeResource.Kind != Wildcard { + return trace.BadParameter("request.kubernetes_resource kind %q is invalid or unsupported in role version %q; Supported: %v", + kubeResource.Kind, roleVersion, append([]string{Wildcard}, KubernetesResourcesKinds...)) + } // Teleport does not support role versions < v3. case V6, V5, V4, V3: + if kubeResource.APIGroup != "" { + return trace.BadParameter("request.kubernetes_resource api_group is not supported in role version %q. Upgrade the role version to %q", roleVersion, V8) + } + // Only Pod resources are supported in role version <=V6. + // This is mandatory because we must append the other resources to the + // kubernetes resources. if kubeResource.Kind != KindKubePod { - return trace.BadParameter("request.kubernetes_resources kind %q is not supported in role version %q. Upgrade the role version to %q", kubeResource.Kind, roleVersion, V8) + return trace.BadParameter("request.kubernetes_resources kind %q is not supported in role version %q. Upgrade the role version to %q", + kubeResource.Kind, roleVersion, V8) } } } @@ -2070,8 +2151,8 @@ func validateRequestKubeResources(roleVersion string, kubeResources []RequestKub // ClusterResource returns the resource name in the following format // /. -func (k *KubernetesResource) ClusterResource() string { - return path.Join(k.Namespace, k.Name) +func (m *KubernetesResource) ClusterResource() string { + return path.Join(m.Namespace, m.Name) } // IsEmpty will return true if the condition is empty. diff --git a/api/types/role_test.go b/api/types/role_test.go index 56c5e60d1eea1..6007177d61425 100644 --- a/api/types/role_test.go +++ b/api/types/role_test.go @@ -267,6 +267,12 @@ func TestRole_GetKubeResources(t *testing.T) { Name: "test", Verbs: []string{Wildcard}, }, + { + Kind: KindKubeJob, + Namespace: "test", + Name: "test", + Verbs: []string{Wildcard}, + }, }, }, wantDeny: []KubernetesResource{ @@ -275,7 +281,14 @@ func TestRole_GetKubeResources(t *testing.T) { Namespace: "test", Name: "test", Verbs: []string{Wildcard}, - APIGroup: Wildcard, + APIGroup: "", + }, + { + Kind: "jobs", + Namespace: "test", + Name: "test", + Verbs: []string{Wildcard}, + APIGroup: "batch", }, }, assertErrorCreation: require.NoError, @@ -306,6 +319,11 @@ func TestRole_GetKubeResources(t *testing.T) { Namespace: "test", Name: "test", }, + { + Kind: KindKubeDeployment, + Namespace: "test", + Name: "test", + }, }, }, assertErrorCreation: require.NoError, @@ -314,7 +332,13 @@ func TestRole_GetKubeResources(t *testing.T) { Kind: "pods", Namespace: "test", Name: "test", - APIGroup: Wildcard, + APIGroup: "", + }, + { + Kind: "deployments", + Namespace: "test", + Name: "test", + APIGroup: "apps", }, }, }, @@ -353,7 +377,7 @@ func TestRole_GetKubeResources(t *testing.T) { Kind: "pods", Namespace: "test", Name: "test", - APIGroup: Wildcard, + APIGroup: "", }, }, }, @@ -553,7 +577,7 @@ func TestRole_GetKubeResources(t *testing.T) { Namespace: "test", Name: "test", Verbs: []string{Wildcard}, - APIGroup: Wildcard, + APIGroup: "", }, }, appendV7KubeResources()...), @@ -602,7 +626,7 @@ func TestRole_GetKubeResources(t *testing.T) { Namespace: "test", Name: "test", Verbs: []string{Wildcard}, - APIGroup: Wildcard, + APIGroup: "", }, }, appendV7KubeResources()...), @@ -880,6 +904,7 @@ func appendV7KubeResources() []KubernetesResource { resources := []KubernetesResource{} // append other kubernetes resources for _, resource := range KubernetesResourcesKinds { + group := KubernetesResourcesV7KindGroups[resource] resource = KubernetesResourcesKindsPlurals[resource] if resource == "pods" || resource == "namespaces" { continue @@ -889,7 +914,7 @@ func appendV7KubeResources() []KubernetesResource { Namespace: Wildcard, Name: Wildcard, Verbs: []string{Wildcard}, - APIGroup: Wildcard, + APIGroup: group, }, ) } diff --git a/lib/auth/grpcserver.go b/lib/auth/grpcserver.go index b3dd99e76b6fb..b416592dd7f72 100644 --- a/lib/auth/grpcserver.go +++ b/lib/auth/grpcserver.go @@ -128,6 +128,7 @@ import ( usagereporter "github.com/gravitational/teleport/lib/usagereporter/teleport" "github.com/gravitational/teleport/lib/utils" logutils "github.com/gravitational/teleport/lib/utils/log" + "github.com/gravitational/teleport/lib/utils/slices" ) var ( @@ -2019,52 +2020,75 @@ func downgradeSAMLIdPRBAC(downgradedRole *types.RoleV6) *types.RoleV6 { return downgradedRole } -func maybeDowngradeRoleK8sAPIGroupToV7(role *types.RoleV6) *types.RoleV6 { - downgrade := func(resources []types.KubernetesResource) ([]types.KubernetesResource, bool) { - var out []types.KubernetesResource - for _, elem := range resources { - // If group is '*', simply remove it as the behavior in v7 would be the same. - if elem.APIGroup == types.Wildcard { - elem.APIGroup = "" - } - // If we have a wildcard kind, only keep it if the namespace is also a wildcard. - if elem.Kind == types.Wildcard && elem.Namespace == types.Wildcard && elem.APIGroup == "" { - out = append(out, elem) - continue - } - // If Kind is known in v7 and group is known, remove it the api group and keep the resource. - if v, ok := defaultRBACResources[allowedResourcesKey{elem.APIGroup, elem.Kind}]; ok { - elem.APIGroup = "" - elem.Kind = v - out = append(out, elem) - continue - } +func downgradeKubeResources[T types.KubeResource](role *types.RoleV6, resources []T) []T { + var out []T + for _, elem := range resources { + apiGroup, kind, namespace := elem.GetAPIGroup(), elem.GetKind(), elem.GetNamespace() + // If group is '*', simply remove it as the behavior in v7 would be the same. + if apiGroup == types.Wildcard { + elem.SetAPIGroup("") + apiGroup = "" + } + // If we have a wildcard kind, only keep it if the namespace is also a wildcard. + if kind == types.Wildcard && namespace == types.Wildcard && apiGroup == "" { + out = append(out, elem) + continue + } + // If Kind is known in v7 and group is known, remove it. + if v, ok := defaultRBACResources[allowedResourcesKey{apiGroup, kind}]; ok { + elem.SetAPIGroup("") + elem.SetKind(v) + out = append(out, elem) + continue + } - // If we reach this point, we are dealing with a resource we don't know about or a wildcard - // As the scope of permissions granted differs, deny everything. - role.Spec.Allow.KubernetesResources = nil - role.Spec.Deny.KubernetesLabels = types.Labels{ - types.Wildcard: {types.Wildcard}, - } - role.Spec.Deny.KubernetesResources = []types.KubernetesResource{ - { - Kind: types.Wildcard, - Name: types.Wildcard, - Namespace: types.Wildcard, - Verbs: []string{types.Wildcard}, - }, - } - return nil, false + // If we reach this point, we are dealing with a resource we don't know about or a wildcard + // As the scope of permissions granted differs, deny everything. + role.Spec.Allow.KubernetesResources = nil + role.Spec.Deny.KubernetesLabels = types.Labels{ + types.Wildcard: {types.Wildcard}, + } + role.Spec.Deny.KubernetesResources = []types.KubernetesResource{ + { + Kind: types.Wildcard, + Name: types.Wildcard, + Namespace: types.Wildcard, + Verbs: []string{types.Wildcard}, + }, } - return out, true + return nil } + return out +} - var ok bool - role.Spec.Allow.KubernetesResources, ok = downgrade(role.Spec.Allow.KubernetesResources) - if !ok { - return role +// maybeDowngradeRoleK8sAPIGroupToV7 downgrades the role to kubernetes resources to role v7 if the client +// is below the minimum supported version. +// +// If there is an unsupported resource, clear them and inject deny all at the label level as well as setting the resource to be a wildcard deny. +// +// In the case of access requests, if there is a deny, also inject a wildcard deny in the request level. +func maybeDowngradeRoleK8sAPIGroupToV7(role *types.RoleV6) *types.RoleV6 { + role.Spec.Allow.KubernetesResources = slices.FromPointers(downgradeKubeResources(role, slices.ToPointers(role.Spec.Allow.KubernetesResources))) + role.Spec.Deny.KubernetesResources = slices.FromPointers(downgradeKubeResources(role, slices.ToPointers(role.Spec.Deny.KubernetesResources))) + + // NOTE: Make sure to handle access request deny before the allow. + if role.Spec.Deny.Request != nil && len(role.Spec.Deny.Request.KubernetesResources) > 0 { + role.Spec.Deny.Request.KubernetesResources = slices.FromPointers(downgradeKubeResources(role, slices.ToPointers(role.Spec.Deny.Request.KubernetesResources))) + // If we cleared the Deny, inject a wildcard. + if len(role.Spec.Deny.Request.KubernetesResources) == 0 { + role.Spec.Deny.Request.KubernetesResources = []types.RequestKubernetesResource{{Kind: types.Wildcard}} + } + } + if role.Spec.Allow.Request != nil && len(role.Spec.Allow.Request.KubernetesResources) > 0 { + role.Spec.Allow.Request.KubernetesResources = slices.FromPointers(downgradeKubeResources(role, slices.ToPointers(role.Spec.Allow.Request.KubernetesResources))) + // If we cleared out the Allow, inject a wildcard in Deny. + if len(role.Spec.Allow.Request.KubernetesResources) == 0 { + if role.Spec.Deny.Request == nil { + role.Spec.Deny.Request = &types.AccessRequestConditions{} + } + role.Spec.Deny.Request.KubernetesResources = append(role.Spec.Deny.Request.KubernetesResources, types.RequestKubernetesResource{Kind: types.Wildcard}) + } } - role.Spec.Deny.KubernetesResources, _ = downgrade(role.Spec.Deny.KubernetesResources) return role } diff --git a/lib/auth/grpcserver_test.go b/lib/auth/grpcserver_test.go index 0921d7d099235..fed6a5673023f 100644 --- a/lib/auth/grpcserver_test.go +++ b/lib/auth/grpcserver_test.go @@ -5544,6 +5544,72 @@ func TestRoleVersionV8ToV7Downgrade(t *testing.T) { }, }, }) + downgradev7ValidAccessRequest := newRole("downgrade_v7_valid_access_request", types.V8, types.RoleSpecV6{ + Allow: types.RoleConditions{ + Request: &types.AccessRequestConditions{ + SearchAsRoles: []string{"test_role_1"}, + KubernetesResources: []types.RequestKubernetesResource{ + { + Kind: "pods", + }, + { + Kind: "clusterroles", + APIGroup: "rbac.authorization.k8s.io", + }, + { + Kind: "deployments", + APIGroup: "*", + }, + }, + }, + }, + Deny: types.RoleConditions{ + Request: &types.AccessRequestConditions{ + SearchAsRoles: []string{"test_role_1"}, + KubernetesResources: []types.RequestKubernetesResource{ + { + Kind: "pods", + }, + { + Kind: "clusterroles", + APIGroup: "rbac.authorization.k8s.io", + }, + { + Kind: "deployments", + APIGroup: "*", + }, + }, + }, + }, + }) + + downgradeInvalidAccessRequestAllow := newRole("downgrade_v7_invalid_access_request_allow", types.V8, types.RoleSpecV6{ + Allow: types.RoleConditions{ + Request: &types.AccessRequestConditions{ + SearchAsRoles: []string{"test_role_1"}, + KubernetesResources: []types.RequestKubernetesResource{ + { + Kind: "crontabs", + APIGroup: "stable.example.com", + }, + }, + }, + }, + }) + downgradeInvalidAccessRequestDeny := newRole("downgrade_v7_invalid_access_request_deny", types.V8, types.RoleSpecV6{ + Deny: types.RoleConditions{ + Request: &types.AccessRequestConditions{ + SearchAsRoles: []string{"test_role_1"}, + KubernetesResources: []types.RequestKubernetesResource{ + { + Kind: "crontabs", + APIGroup: "stable.example.com", + }, + }, + }, + }, + }) + user, err := CreateUser(context.Background(), srv.Auth(), "user", testRole1, downgradev7comptibleK8sResourcesRole, @@ -5552,6 +5618,9 @@ func TestRoleVersionV8ToV7Downgrade(t *testing.T) { downgradev7incompatibleNamespacedWildcard, downgradev7incompatibleClusterWideWildcard, downgradev7mixedK8sResourcesRole, + downgradev7ValidAccessRequest, + downgradeInvalidAccessRequestAllow, + downgradeInvalidAccessRequestDeny, ) require.NoError(t, err) @@ -5827,6 +5896,139 @@ func TestRoleVersionV8ToV7Downgrade(t *testing.T) { }), expectDowngraded: true, }, + { + desc: "downgrade valid access request role version to v7", + clientVersions: []string{ + "17.2.7", + }, + inputRole: downgradev7ValidAccessRequest, + expectedRole: newRole(downgradev7ValidAccessRequest.GetName(), types.V7, types.RoleSpecV6{ + Allow: types.RoleConditions{ + Request: &types.AccessRequestConditions{ + SearchAsRoles: []string{"test_role_1"}, + KubernetesResources: []types.RequestKubernetesResource{ + { + Kind: types.KindKubePod, + }, + { + Kind: types.KindKubeClusterRole, + }, + { + Kind: types.KindKubeDeployment, + }, + }, + }, + }, + Deny: types.RoleConditions{ + Request: &types.AccessRequestConditions{ + SearchAsRoles: []string{"test_role_1"}, + KubernetesResources: []types.RequestKubernetesResource{ + { + Kind: types.KindKubePod, + }, + { + Kind: types.KindKubeClusterRole, + }, + { + Kind: types.KindKubeDeployment, + }, + }, + }, + }, + + Options: types.RoleOptions{ + IDP: &types.IdPOptions{ + SAML: &types.IdPSAMLOptions{ + Enabled: types.NewBoolOption(false), + }, + }, + }, + }), + expectDowngraded: true, + }, + { + desc: "downgrade invalid access request allow role version to v7", + clientVersions: []string{ + "17.2.7", + }, + inputRole: downgradeInvalidAccessRequestAllow, + expectedRole: newRole(downgradeInvalidAccessRequestAllow.GetName(), types.V7, types.RoleSpecV6{ + Allow: types.RoleConditions{ + Request: &types.AccessRequestConditions{ + SearchAsRoles: []string{"test_role_1"}, + KubernetesResources: nil, + }, + }, + Deny: types.RoleConditions{ + KubernetesLabels: types.Labels{ + types.Wildcard: {types.Wildcard}, + }, + KubernetesResources: []types.KubernetesResource{ + { + Kind: types.Wildcard, + Name: types.Wildcard, + Namespace: types.Wildcard, + Verbs: []string{types.Wildcard}, + }, + }, + Request: &types.AccessRequestConditions{ + KubernetesResources: []types.RequestKubernetesResource{ + { + Kind: types.Wildcard, + }, + }, + }, + }, + + Options: types.RoleOptions{ + IDP: &types.IdPOptions{ + SAML: &types.IdPSAMLOptions{ + Enabled: types.NewBoolOption(false), + }, + }, + }, + }), + expectDowngraded: true, + }, + { + desc: "downgrade invalid access request deny role version to v7", + clientVersions: []string{ + "17.2.7", + }, + inputRole: downgradeInvalidAccessRequestDeny, + expectedRole: newRole(downgradeInvalidAccessRequestDeny.GetName(), types.V7, types.RoleSpecV6{ + Deny: types.RoleConditions{ + KubernetesLabels: types.Labels{ + types.Wildcard: {types.Wildcard}, + }, + KubernetesResources: []types.KubernetesResource{ + { + Kind: types.Wildcard, + Name: types.Wildcard, + Namespace: types.Wildcard, + Verbs: []string{types.Wildcard}, + }, + }, + Request: &types.AccessRequestConditions{ + SearchAsRoles: []string{"test_role_1"}, + KubernetesResources: []types.RequestKubernetesResource{ + { + Kind: types.Wildcard, + }, + }, + }, + }, + + Options: types.RoleOptions{ + IDP: &types.IdPOptions{ + SAML: &types.IdPSAMLOptions{ + Enabled: types.NewBoolOption(false), + }, + }, + }, + }), + expectDowngraded: true, + }, } { t.Run(tc.desc, func(t *testing.T) { for _, clientVersion := range tc.clientVersions { diff --git a/lib/kube/grpc/grpc.go b/lib/kube/grpc/grpc.go index 7e59db205b80e..a17d580e7da0f 100644 --- a/lib/kube/grpc/grpc.go +++ b/lib/kube/grpc/grpc.go @@ -23,9 +23,12 @@ import ( "errors" "log/slog" "slices" + "strings" "github.com/gravitational/trace" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/client-go/dynamic" "k8s.io/client-go/kubernetes" "github.com/gravitational/teleport" @@ -47,10 +50,11 @@ var errDone = errors.New("done iterating") // Server implements KubeService gRPC server. type Server struct { proto.UnimplementedKubeServiceServer - cfg Config - proxyAddress string - kubeProxySNI string - kubeClient kubernetes.Interface + cfg Config + proxyAddress string + kubeProxySNI string + kubeClient kubernetes.Interface + kubeDynamicClient *dynamic.DynamicClient } // New creates a new instance of Kube gRPC handler. @@ -70,7 +74,7 @@ func New(cfg Config) (*Server, error) { s := &Server{cfg: cfg, proxyAddress: addr, kubeProxySNI: sni} - if s.kubeClient, err = s.buildKubeClient(); err != nil { + if err := s.buildKubeClient(); err != nil { return nil, trace.Wrap(err, "unable to create kubeClient") } @@ -190,7 +194,7 @@ func (s *Server) ListKubernetesResources(ctx context.Context, req *proto.ListKub case requiresFakePagination(req): rsp, err := s.listResourcesUsingFakePagination(ctx, req) return rsp, trail.ToGRPC(err) - case slices.Contains(types.KubernetesResourcesKinds, req.ResourceType): + case slices.Contains(types.KubernetesResourcesKinds, req.ResourceType) || strings.HasPrefix(req.ResourceType, types.AccessRequestPrefixKindKube): rsp, err := s.listKubernetesResources(ctx, true, req) return rsp, trail.ToGRPC(err) default: @@ -282,6 +286,77 @@ func (s *Server) listKubernetesResources( return rsp, trace.Wrap(err) } +func fetchAPIGroups(ctx context.Context, clientset kubernetes.Interface) (*metav1.APIGroupList, error) { + restClient := clientset.Discovery().RESTClient() + + var groupList metav1.APIGroupList + if err := restClient.Get().AbsPath("/apis").Do(ctx).Into(&groupList); err != nil { + return nil, trace.Wrap(err) + } + return &groupList, nil +} + +func fetchAPIResources(ctx context.Context, clientset kubernetes.Interface, apiGroup, version string) (*metav1.APIResourceList, error) { + absPath := "" + if apiGroup == "" { + absPath = "/api/v1" + } else { + absPath = "/apis/" + apiGroup + "/" + version + } + restClient := clientset.Discovery().RESTClient() + + var rsList metav1.APIResourceList + if err := restClient.Get().AbsPath(absPath).Do(ctx).Into(&rsList); err != nil { + return nil, trace.Wrap(err) + } + return &rsList, nil +} + +// lookupAPIGroupVersions looks up the GroupKind in the actual API Group list. +// Returns the extracted api group name, and the list of versions, starting with the preferred version. +func lookupAPIGroupVersions(ctx context.Context, kubeClient kubernetes.Interface, gk schema.GroupKind) (apiGroup string, versions []string, err error) { + if gk.Group == "" { + return "", []string{"v1"}, nil + } + groupList, err := fetchAPIGroups(ctx, kubeClient) + if err != nil { + return "", nil, trace.Wrap(err) + } + for _, g := range groupList.Groups { + if g.Name != gk.Group { + continue + } + versions := []string{g.PreferredVersion.Version} + for _, elem := range g.Versions { + if elem.Version == g.PreferredVersion.Version { + continue + } + versions = append(versions, elem.Version) + } + return gk.Group, versions, nil + } + return "", nil, trace.BadParameter("unsupported resource type %q in group %q", gk.Kind, gk.Group) +} + +// lookupAPIResource looks up the given resource kind.apiGroup in the actual API Resource list for the given versions. +// Expects the versions list to start with the preferred one and returns the first match. +func lookupAPIResource(ctx context.Context, kubeClient kubernetes.Interface, gk schema.GroupKind, versions []string) (version string, isClusterWide bool, err error) { + // Lookup the resource version, starting from the group preferred version. + for _, v := range versions { + // Lookup the resource within the group/version. + // The goal is to 1) validate that the resource exists and 2) get the + // namespaced scope of the resource. + rs, err := fetchAPIResources(ctx, kubeClient, gk.Group, v) + if err != nil { + return "", false, trace.Wrap(err) + } + if idx := slices.IndexFunc(rs.APIResources, func(r metav1.APIResource) bool { return r.Name == gk.Kind }); idx != -1 { + return v, !rs.APIResources[idx].Namespaced, nil + } + } + return "", false, trace.BadParameter("unsupported resource type %q in group %q versions %v", gk.Kind, gk.Group, versions) +} + // iterateKubernetesResources creates a new Kubernetes Client with temporary user // certificates and iterates through the returned Kubernetes resources. // For each resources discovered, the fn function is called to decide the action. @@ -299,9 +374,19 @@ func (s *Server) iterateKubernetesResources( fn func(*types.KubernetesResourceV1, string) (int, error), ) error { kubeClient := s.kubeClient + kubeDynamicClient := s.kubeDynamicClient + // Pagination. continueKey := req.StartKey itemsAppended := 0 + + // Unknown resources. + resourceType := req.ResourceType + apiGroup := "" + version := "" + + // Flag to format/validate the resourceID. + isClusterWide := false for { var ( items []kObject @@ -312,6 +397,7 @@ func (s *Server) iterateKubernetesResources( } ) + // TODO(@creack): DELETE IN v20.0.0 when we no longer support tsh v18. switch req.ResourceType { case types.KindKubePod: lItems, err := kubeClient.CoreV1().Pods(req.KubernetesNamespace).List(ctx, listOpts) @@ -335,6 +421,7 @@ func (s *Server) iterateKubernetesResources( items = itemListToKObjectList(itemListToItemListPtr(lItems.Items)) nextContinueKey = lItems.Continue case types.KindKubeNamespace: + isClusterWide = true lItems, err := kubeClient.CoreV1().Namespaces().List(ctx, listOpts) if err != nil { return trace.Wrap(err) @@ -356,6 +443,7 @@ func (s *Server) iterateKubernetesResources( items = itemListToKObjectList(itemListToItemListPtr(lItems.Items)) nextContinueKey = lItems.Continue case types.KindKubeNode: + isClusterWide = true lItems, err := kubeClient.CoreV1().Nodes().List(ctx, listOpts) if err != nil { return trace.Wrap(err) @@ -363,6 +451,7 @@ func (s *Server) iterateKubernetesResources( items = itemListToKObjectList(itemListToItemListPtr(lItems.Items)) nextContinueKey = lItems.Continue case types.KindKubePersistentVolume: + isClusterWide = true lItems, err := kubeClient.CoreV1().PersistentVolumes().List(ctx, listOpts) if err != nil { return trace.Wrap(err) @@ -405,6 +494,7 @@ func (s *Server) iterateKubernetesResources( items = itemListToKObjectList(itemListToItemListPtr(lItems.Items)) nextContinueKey = lItems.Continue case types.KindKubeClusterRole: + isClusterWide = true lItems, err := kubeClient.RbacV1().ClusterRoles().List(ctx, listOpts) if err != nil { return trace.Wrap(err) @@ -419,6 +509,7 @@ func (s *Server) iterateKubernetesResources( items = itemListToKObjectList(itemListToItemListPtr(lItems.Items)) nextContinueKey = lItems.Continue case types.KindKubeClusterRoleBinding: + isClusterWide = true lItems, err := kubeClient.RbacV1().ClusterRoleBindings().List(ctx, listOpts) if err != nil { return trace.Wrap(err) @@ -447,6 +538,7 @@ func (s *Server) iterateKubernetesResources( items = itemListToKObjectList(itemListToItemListPtr(lItems.Items)) nextContinueKey = lItems.Continue case types.KindKubeCertificateSigningRequest: + isClusterWide = true lItems, err := kubeClient.CertificatesV1().CertificateSigningRequests().List(ctx, listOpts) if err != nil { return trace.Wrap(err) @@ -461,11 +553,50 @@ func (s *Server) iterateKubernetesResources( items = itemListToKObjectList(itemListToItemListPtr(lItems.Items)) nextContinueKey = lItems.Continue default: - return trace.BadParameter("unsupported resource type: %q", req.ResourceType) + // If we don't have a known legacy value, we expect a 'kube:' prefix. + if !strings.HasPrefix(req.ResourceType, types.AccessRequestPrefixKindKube) { + return trace.BadParameter("unsupported resource type %q", resourceType) + } + + // If the apiGroup var is not set, it is the first time we are here, + // Lookup the group versions to validate the requested group actually exists. + // The next iteration of paginatin, we don't need to lookup the values again. + if apiGroup == "" && version == "" { + // TODO(@creack): Consider caching the discovery. Needs to be periodically invalidated. + // As it is only for the access request search, it is likely not heavily used and the request + // is between us and kube_proxy which is likely on the same host. + gk := schema.ParseGroupKind(strings.TrimPrefix(req.ResourceType, types.AccessRequestPrefixKindKube)) + resourceType = gk.Kind + g, versions, err := lookupAPIGroupVersions(ctx, kubeClient, gk) + if err != nil { + return trace.Wrap(err) + } + v, clusterWide, err := lookupAPIResource(ctx, kubeClient, gk, versions) + if err != nil { + return trace.Wrap(err) + } + apiGroup, version, isClusterWide = g, v, clusterWide + } + + // NOTE: The CLI sends the 'default' namespace regardless of the kind. Make sure to clear it out for globals. + if req.KubernetesNamespace == defaults.Namespace && isClusterWide { + req.KubernetesNamespace = "" + } + + lItems, err := kubeDynamicClient.Resource(schema.GroupVersionResource{ + Group: apiGroup, + Version: version, + Resource: resourceType, + }).Namespace(req.KubernetesNamespace).List(ctx, listOpts) + if err != nil { + return trace.Wrap(err) + } + items = itemListToKObjectList(itemListToItemListPtr(lItems.Items)) + nextContinueKey = lItems.GetContinue() } for _, resource := range items { - resource, err := getKubernetesResourceFromKObject(resource, req.ResourceType) + resource, err := getKubernetesResourceFromKObject(resource, !isClusterWide, req.ResourceType) if err != nil { return trace.Wrap(err) } @@ -495,11 +626,19 @@ type kObject interface { // getKubernetesResourceFromKObject converts a Kubernetes object to a // KubernetesResourceV1. func getKubernetesResourceFromKObject( - kObj kObject, + kObj kObject, namespaced bool, resourceType string, ) (*types.KubernetesResourceV1, error) { + if strings.HasPrefix(resourceType, types.AccessRequestPrefixKindKube) { + if namespaced { + resourceType = strings.Replace(resourceType, types.AccessRequestPrefixKindKube, types.AccessRequestPrefixKindKubeNamespaced, 1) + } else { + resourceType = strings.Replace(resourceType, types.AccessRequestPrefixKindKube, types.AccessRequestPrefixKindKubeClusterWide, 1) + } + } return types.NewKubernetesResourceV1( resourceType, + namespaced, types.Metadata{ Name: kObj.GetName(), Labels: kObj.GetLabels(), @@ -515,6 +654,9 @@ func getKubernetesResourceFromKObject( // This is needed because the Kubernetes API returns a list of items, but // only a list of pointers to items satisfies the kObject interface. func itemListToItemListPtr[T any](items []T) []*T { + if len(items) == 0 { + return nil + } kObjects := make([]*T, len(items)) for i := range items { kObjects[i] = &(items[i]) @@ -525,6 +667,9 @@ func itemListToItemListPtr[T any](items []T) []*T { // itemListToKObjectList is a helper function that converts a list of items // to a list of kObjects. func itemListToKObjectList[T kObject](items []T) []kObject { + if len(items) == 0 { + return nil + } kObjects := make([]kObject, len(items)) for i, item := range items { kObjects[i] = item @@ -544,7 +689,7 @@ func (s *Server) listResourcesUsingFakePagination( err error ) switch { - case slices.Contains(types.KubernetesResourcesKinds, req.ResourceType): + case slices.Contains(types.KubernetesResourcesKinds, req.ResourceType) || strings.HasPrefix(req.ResourceType, types.AccessRequestPrefixKindKube): rsp, err = s.listKubernetesResources(ctx, false /* do not respect the limit value */, req) if err != nil { return nil, trace.Wrap(err) diff --git a/lib/kube/grpc/grpc_test.go b/lib/kube/grpc/grpc_test.go index 952cecc132259..07be53d77e2c3 100644 --- a/lib/kube/grpc/grpc_test.go +++ b/lib/kube/grpc/grpc_test.go @@ -24,6 +24,7 @@ import ( "crypto/x509" "log/slog" "net" + "slices" "testing" "github.com/google/go-cmp/cmp" @@ -118,7 +119,7 @@ func TestListKubernetesResources(t *testing.T) { // 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"}}) + role.SetRequestKubernetesResources(types.Allow, []types.RequestKubernetesResource{{Kind: "namespaces"}, {Kind: "pods"}}) }, }, ) @@ -137,7 +138,7 @@ func TestListKubernetesResources(t *testing.T) { // 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"}}) + role.SetRequestKubernetesResources(types.Allow, []types.RequestKubernetesResource{{Kind: "secrets"}}) }, }, @@ -618,8 +619,9 @@ func TestListKubernetesResources(t *testing.T) { tt.assertErr(t, err) if tt.want != nil { for _, want := range tt.want.Resources { + isClusterWide := slices.Contains(types.KubernetesClusterWideResourceKinds, want.Kind) // fill in defaults - err := want.CheckAndSetDefaults() + err := want.CheckAndSetDefaults(!isClusterWide) require.NoError(t, err) } } diff --git a/lib/kube/grpc/utils.go b/lib/kube/grpc/utils.go index ffa5665bc4d2e..a0b2d9ed53a8f 100644 --- a/lib/kube/grpc/utils.go +++ b/lib/kube/grpc/utils.go @@ -27,6 +27,7 @@ import ( "github.com/gravitational/trace" utilnet "k8s.io/apimachinery/pkg/util/net" + "k8s.io/client-go/dynamic" "k8s.io/client-go/kubernetes" "k8s.io/client-go/rest" @@ -67,7 +68,7 @@ func getWebAddrAndKubeSNI(proxyAddr string) (string, string, error) { // buildKubeClient creates a new Kubernetes client that is used to communicate // with the Kubernetes API server. -func (s *Server) buildKubeClient() (kubernetes.Interface, error) { +func (s *Server) buildKubeClient() error { const idleConnsPerHost = 25 tlsConfig := utils.TLSConfig(s.cfg.ConnTLSCipherSuites) @@ -96,8 +97,19 @@ func (s *Server) buildKubeClient() (kubernetes.Interface, error) { Host: s.proxyAddress, Transport: internal.NewImpersonatorRoundTripper(transport), } + kubeClient, err := kubernetes.NewForConfig(cfg) - return kubeClient, trace.Wrap(err) + if err != nil { + return trace.Wrap(err) + } + s.kubeClient = kubeClient + dynamicClient, err := dynamic.NewForConfig(cfg) + if err != nil { + return trace.Wrap(err) + } + s.kubeDynamicClient = dynamicClient + + return nil } // decideLimit returns the number of items we should request for. If respectLimit diff --git a/lib/kube/proxy/resource_rbac_test.go b/lib/kube/proxy/resource_rbac_test.go index 9a9dd6d196247..5caa5697cbf23 100644 --- a/lib/kube/proxy/resource_rbac_test.go +++ b/lib/kube/proxy/resource_rbac_test.go @@ -423,7 +423,7 @@ func TestListPodRBAC(t *testing.T) { }, }, { - name: "user with pod access request that no longer fullfills the role requirements", + name: "user with legacy pod access request that no longer fullfills the role requirements", args: args{ user: userWithLimitedAccess, namespace: metav1.NamespaceDefault, @@ -458,7 +458,42 @@ func TestListPodRBAC(t *testing.T) { }, }, }, - + { + name: "user with pod access request that no longer fullfills the role requirements", + args: args{ + user: userWithLimitedAccess, + namespace: metav1.NamespaceDefault, + opts: []GenTestKubeClientTLSCertOptions{ + WithResourceAccessRequests( + types.ResourceID{ + ClusterName: testCtx.ClusterName, + Kind: types.AccessRequestPrefixKindKubeNamespaced + "pods", + Name: kubeCluster, + SubResourceName: fmt.Sprintf("%s/%s", metav1.NamespaceDefault, testPodName), + }, + ), + }, + }, + want: want{ + listPodsResult: []string{}, + listPodErr: &kubeerrors.StatusError{ + ErrStatus: metav1.Status{ + Status: "Failure", + Message: "pods is forbidden: User \"limited_user\" cannot list resource \"pods\" in API group \"\" in the namespace \"default\"", + Code: 403, + Reason: metav1.StatusReasonForbidden, + }, + }, + getTestPodResult: &kubeerrors.StatusError{ + ErrStatus: metav1.Status{ + Status: "Failure", + Message: "pods \"test\" is forbidden: User \"limited_user\" cannot get resource \"pods\" in API group \"\" in the namespace \"default\"", + Code: 403, + Reason: metav1.StatusReasonForbidden, + }, + }, + }, + }, { name: "list default namespace pods for user with limited access", args: args{ @@ -1448,10 +1483,11 @@ func TestGenericCustomResourcesRBAC(t *testing.T) { // create a user with full access to all namespaces. // (kubernetes_user and kubernetes_groups specified) - userWithFullAccess, _ := testCtx.CreateUserAndRole( + userWithFullAccess, _ := testCtx.CreateUserAndRoleVersion( testCtx.Context, t, usernameWithFullAccess, + types.V8, RoleSpec{ Name: usernameWithFullAccess, KubeUsers: roleKubeUsers, @@ -1472,10 +1508,11 @@ func TestGenericCustomResourcesRBAC(t *testing.T) { ) // create a user with limited access to kubernetes namespaces. - userWithLimitedAccess, _ := testCtx.CreateUserAndRole( + userWithLimitedAccess, _ := testCtx.CreateUserAndRoleVersion( testCtx.Context, t, usernameWithLimitedAccess, + types.V8, RoleSpec{ Name: usernameWithLimitedAccess, KubeUsers: roleKubeUsers, @@ -1502,10 +1539,11 @@ func TestGenericCustomResourcesRBAC(t *testing.T) { ) // create a user with limited access to kubernetes namespaces. - userWithSpecificAccess, _ := testCtx.CreateUserAndRole( + userWithSpecificAccess, _ := testCtx.CreateUserAndRoleVersion( testCtx.Context, t, usernameWithSpecificAccess, + types.V8, RoleSpec{ Name: usernameWithSpecificAccess, KubeUsers: roleKubeUsers, @@ -1645,9 +1683,9 @@ func TestGenericCustomResourcesRBAC(t *testing.T) { WithResourceAccessRequests( types.ResourceID{ ClusterName: testCtx.ClusterName, - Kind: types.KindKubeNamespace, + Kind: "kube:ns:*.*", Name: kubeCluster, - SubResourceName: "dev", + SubResourceName: "dev/*", }, ), }, @@ -1839,7 +1877,7 @@ func TestV8JailedNamespaceListRBAC(t *testing.T) { { Kind: types.Wildcard, Name: types.Wildcard, - Namespace: "^" + types.Wildcard + "$", + Namespace: "^.+$", Verbs: []string{types.Wildcard}, APIGroup: types.Wildcard, }, @@ -2443,7 +2481,7 @@ func TestV7V8Match(t *testing.T) { Kind: types.Wildcard, APIGroup: types.Wildcard, Name: types.Wildcard, - Namespace: "^" + types.Wildcard + "$", + Namespace: "^.+$", Verbs: []string{types.Wildcard}, }, }, nil), @@ -2464,7 +2502,7 @@ func TestV7V8Match(t *testing.T) { Kind: types.Wildcard, APIGroup: types.Wildcard, Name: types.Wildcard, - Namespace: "^" + types.Wildcard + "$", + Namespace: "^.+$", Verbs: []string{types.Wildcard}, }, }, nil), @@ -2485,7 +2523,7 @@ func TestV7V8Match(t *testing.T) { Kind: types.Wildcard, APIGroup: types.Wildcard, Name: types.Wildcard, - Namespace: "^" + types.Wildcard + "$", + Namespace: "^.+$", Verbs: []string{types.Wildcard}, }, }, nil), diff --git a/lib/kube/proxy/scheme.go b/lib/kube/proxy/scheme.go index d9b4b53a8e87e..d21dd7bb60729 100644 --- a/lib/kube/proxy/scheme.go +++ b/lib/kube/proxy/scheme.go @@ -169,7 +169,6 @@ func newClusterSchemaBuilder(log *slog.Logger, client kubernetes.Interface) (*se resourceKind: apiResource.Name, } - // TODO(@creack): Keep track of the namespaced field. supportedResources[resourceKey] = apiResource // Create the group version kind for the resource. diff --git a/lib/services/access_checker.go b/lib/services/access_checker.go index 812a569395c27..f60aef7860f61 100644 --- a/lib/services/access_checker.go +++ b/lib/services/access_checker.go @@ -29,6 +29,7 @@ import ( "time" "github.com/gravitational/trace" + "k8s.io/apimachinery/pkg/runtime/schema" "github.com/gravitational/teleport/api/constants" decisionpb "github.com/gravitational/teleport/api/gen/proto/go/teleport/decision/v1alpha1" @@ -470,9 +471,10 @@ func matchesUCRResource(requestedR types.ResourceID, r AccessCheckable) bool { // access the Kubernetes cluster that it belongs to. // At this point, we do not verify that the accessed resource matches the // allowed resources, but that verification happens in the caller function. - if slices.Contains(types.KubernetesResourcesKinds, requestedR.Kind) { + if slices.Contains(types.KubernetesResourcesKinds, requestedR.Kind) || strings.HasPrefix(requestedR.Kind, types.AccessRequestPrefixKindKube) { return r.GetKind() == types.KindKubernetesCluster } + // Identity Center account is stored as KindApp kind and // KindIdentityCenterAccount subKind in the unified resource cache. if requestedR.Kind == types.KindIdentityCenterAccount { @@ -532,7 +534,7 @@ func (a *accessChecker) GetKubeResources(cluster types.KubeCluster) (allowed, de if elem.Kind == types.KindKubeNamespace { allowedResourceIDs = append(allowedResourceIDs, types.ResourceID{ ClusterName: elem.ClusterName, - Kind: "namespaces", + Kind: types.AccessRequestPrefixKindKubeClusterWide + "namespaces", SubResourceName: elem.SubResourceName, Name: elem.Name, }) @@ -548,14 +550,15 @@ func (a *accessChecker) GetKubeResources(cluster types.KubeCluster) (allowed, de continue } switch { - case slices.Contains(types.KubernetesResourcesKinds, r.Kind): + case slices.Contains(types.KubernetesResourcesKinds, r.Kind) || strings.HasPrefix(r.Kind, types.AccessRequestPrefixKindKube): namespace := "" name := "" - // TODO(@creack): Make sure this gets handled in the AccessRequest PR. - if slices.Contains(types.KubernetesClusterWideResourceKinds, r.Kind) { + if slices.Contains(types.KubernetesClusterWideResourceKinds, r.Kind) || strings.HasPrefix(r.Kind, types.AccessRequestPrefixKindKubeClusterWide) { // Cluster wide resources do not have a namespace. name = r.SubResourceName + r.Kind = strings.TrimPrefix(r.Kind, types.AccessRequestPrefixKindKubeClusterWide) } else { + r.Kind = strings.TrimPrefix(r.Kind, types.AccessRequestPrefixKindKubeNamespaced) splitted := strings.SplitN(r.SubResourceName, "/", 3) // This condition should never happen since SubResourceName is validated // but it's better to validate it. @@ -563,31 +566,44 @@ func (a *accessChecker) GetKubeResources(cluster types.KubeCluster) (allowed, de continue } namespace = splitted[0] + // namespace * would also include cluster-wide resources, if we + // have a wildcard with a known namespaced resource, use a pattern + // that will not match cluster-wide resources. + if namespace == types.Wildcard { + namespace = "^.+$" + } name = splitted[1] } - // TODO(@creack): Find a better way. For now this only enables support for existing access requests. - // It doesnt support CRDs. + // Map legacy names to the new ones. kind := types.KubernetesResourcesKindsPlurals[r.Kind] if kind == "" { kind = r.Kind } - // NOTE: The 'namespace' behavior changed, to maintain backwards compatibility, + // NOTE: The kind 'namespace' behavior changed, to maintain backwards compatibility, // map the legacy value to wildcard. if r.Kind == types.KindKubeNamespace { - kind = types.Wildcard + // When requesting the legacy "namespace" kind, we include all api groups. + kind = types.Wildcard + "." + types.Wildcard namespace = name + // namespace * would also include cluster-wide resources, if we + // have a wildcard with the legacy "namespace" kind, use a pattern + // that will not match cluster-wide resources. if namespace == types.Wildcard { - namespace = "^" + types.Wildcard + "$" + namespace = "^.+$" } name = types.Wildcard } + + gk := schema.ParseGroupKind(kind) + if gk.Group == "" { + gk.Group = types.KubernetesResourcesV7KindGroups[r.Kind] + } r := types.KubernetesResource{ - Kind: kind, + Kind: gk.Kind, Namespace: namespace, Name: name, - // TODO(@creack): Add support for CRDs in AccessRequests. - APIGroup: types.Wildcard, + APIGroup: gk.Group, } // matchKubernetesResource checks if the Kubernetes Resource matches the tuple // (kind, namespace, kame) from the allowed/denied list and does not match the resource diff --git a/lib/services/access_checker_test.go b/lib/services/access_checker_test.go index 02465a12d3485..17e7de5211b1c 100644 --- a/lib/services/access_checker_test.go +++ b/lib/services/access_checker_test.go @@ -233,7 +233,7 @@ func TestAccessCheckerKubeResources(t *testing.T) { Name: "dev", Namespace: "dev", Verbs: []string{types.Wildcard}, - APIGroup: "*", + APIGroup: "", }, }, wantDenied: emptySet, @@ -372,7 +372,7 @@ func TestAccessCheckerKubeResources(t *testing.T) { Name: "dev", Namespace: "dev", Verbs: []string{types.Wildcard}, - APIGroup: types.Wildcard, + APIGroup: "", }, }, wantDenied: emptySet, diff --git a/lib/services/access_request.go b/lib/services/access_request.go index e1700cd95b995..2dbacfb3cc7fa 100644 --- a/lib/services/access_request.go +++ b/lib/services/access_request.go @@ -31,6 +31,7 @@ import ( "github.com/google/uuid" "github.com/gravitational/trace" "github.com/jonboulle/clockwork" + "k8s.io/apimachinery/pkg/runtime/schema" "github.com/gravitational/teleport" "github.com/gravitational/teleport/api/accessrequest" @@ -1656,10 +1657,11 @@ func (m *requestValidator) push(ctx context.Context, role types.Role) error { } } - setAllowRequestKubeResourceLookup(allow.KubernetesResources, allow.SearchAsRoles, m.kubernetesResource.allow) + // NOTE: Not using allow.KubernetesResources as we need to map older roles to new values. + setAllowRequestKubeResourceLookup(role.GetRequestKubernetesResources(types.Allow), allow.SearchAsRoles, m.kubernetesResource.allow) - if len(deny.KubernetesResources) > 0 { - m.kubernetesResource.deny = append(m.kubernetesResource.deny, deny.KubernetesResources...) + if deniedKubeResources := role.GetRequestKubernetesResources(types.Deny); len(deniedKubeResources) > 0 { + m.kubernetesResource.deny = append(m.kubernetesResource.deny, deniedKubeResources...) } m.roles.denyRequest, err = appendRoleMatchers(m.roles.denyRequest, deny.Roles, deny.ClaimsToRoles, m.userState.GetTraits()) @@ -1747,6 +1749,23 @@ func (m *requestValidator) setRolesForResourceRequest(ctx context.Context, req t return nil } +// requestResourcesToStrings formats the resource list as .. +// Removes wildcards if any. +func requestResourcesToStrings(resources, denied []types.RequestKubernetesResource) []string { + strs := make([]string, 0, len(resources)) + for _, resource := range resources { + str := resource.Kind + if resource.APIGroup != "" { + str += "." + resource.APIGroup + } + if resource.Kind == types.Wildcard && len(denied) > 0 { + str += "(- " + strings.Join(requestResourcesToStrings(denied, nil), ", ") + ")" + } + strs = append(strs, str) + } + return strs +} + // pruneRequestedRolesNotMatchingKubernetesResourceKinds will filter out the kubernetes kinds from the requested resource IDs (kube_cluster and its subresources) // disregarding whether it's leaf or root cluster request, and for each requested role, ensures that all requested kube resource kind are allowed by the role. // Roles not matching with every kind requested, will be pruned from the requested roles. @@ -1755,10 +1774,12 @@ func (m *requestValidator) setRolesForResourceRequest(ctx context.Context, req t // lets user know which kinds are allowed for each requested roles. func (m *requestValidator) pruneRequestedRolesNotMatchingKubernetesResourceKinds(requestedResourceIDs []types.ResourceID, requestedRoles []string) ([]string, map[string][]string) { // Filter for the kube_cluster and its subresource kinds. - requestedKubeKinds := make(map[string]struct{}) + requestedKubeKinds := map[gk]struct{}{} for _, resourceID := range requestedResourceIDs { - if resourceID.Kind == types.KindKubernetesCluster || slices.Contains(types.KubernetesResourcesKinds, resourceID.Kind) { - requestedKubeKinds[resourceID.Kind] = struct{}{} + if resourceID.Kind == types.KindKubernetesCluster { + requestedKubeKinds[gk{kind: types.KindKubernetesCluster}] = struct{}{} + } else if slices.Contains(types.KubernetesResourcesKinds, resourceID.Kind) || strings.HasPrefix(resourceID.Kind, types.AccessRequestPrefixKindKube) { + requestedKubeKinds[normalizeKubernetesKind(resourceID.Kind)] = struct{}{} } } @@ -1766,12 +1787,12 @@ func (m *requestValidator) pruneRequestedRolesNotMatchingKubernetesResourceKinds return requestedRoles, nil } - goodRoles := make(map[string]struct{}) - mappedRequestedRolesToAllowedKinds := make(map[string][]string) + goodRoles := map[string]struct{}{} + mappedRequestedRolesToAllowedKinds := map[string][]string{} for _, requestedRoleName := range requestedRoles { - allowedKinds, deniedKinds := getKubeResourceKinds(m.kubernetesResource.allow[requestedRoleName]), getKubeResourceKinds(m.kubernetesResource.deny) + allowedKinds, deniedKinds := m.kubernetesResource.allow[requestedRoleName], m.kubernetesResource.deny - // Any resource is allowed. + // If there is nothing in allowed nor deny, everything is allowed. if len(allowedKinds) == 0 && len(deniedKinds) == 0 { goodRoles[requestedRoleName] = struct{}{} continue @@ -1779,28 +1800,63 @@ func (m *requestValidator) pruneRequestedRolesNotMatchingKubernetesResourceKinds // All supported kube kinds are allowed when there was nothing configured. if len(allowedKinds) == 0 { - allowedKinds = types.KubernetesResourcesKinds - allowedKinds = append(allowedKinds, types.KindKubernetesCluster) + allowedKinds = append(allowedKinds, + types.RequestKubernetesResource{Kind: types.Wildcard, APIGroup: types.Wildcard}, + ) + // If there is nothing in deny, also include kube_cluster. + if len(deniedKinds) == 0 { + allowedKinds = append(allowedKinds, + types.RequestKubernetesResource{Kind: types.KindKubernetesCluster}, + ) + } } - // Filter out denied kinds from the allowed kinds - if len(deniedKinds) > 0 && len(allowedKinds) > 0 { - allowedKinds = getAllowedKubeResourceKinds(allowedKinds, deniedKinds) - } + allowedKinds = slices.DeleteFunc(allowedKinds, func(in types.RequestKubernetesResource) bool { + for _, elem := range deniedKinds { + if matchRequestKubernetesResources(gk{group: in.APIGroup, kind: in.Kind}, elem, types.Allow) { + return true + } + } + return false + }) - mappedRequestedRolesToAllowedKinds[requestedRoleName] = allowedKinds + // TODO(@creack): Consider removing this. We shouldn't disclose to the user what they could request when getting an access denied error. + // Keeping existing behavior for now. + mappedRequestedRolesToAllowedKinds[requestedRoleName] = requestResourcesToStrings(allowedKinds, deniedKinds) - roleIsDenied := false + // If we have any requested kinds that is either not allowed or that is denied, reject the role. + // TODO(@creack): Reconsider this, we may want to allow some kinds and deny others. + // Keeping existing behavior for now. + filteredAllowedKinds := make([]types.RequestKubernetesResource, 0, len(requestedKubeKinds)) for requestedKubeKind := range requestedKubeKinds { - if !slices.Contains(allowedKinds, requestedKubeKind) { - roleIsDenied = true - continue + for _, k := range allowedKinds { + if matchRequestKubernetesResources(requestedKubeKind, k, types.Allow) { + filteredAllowedKinds = append(filteredAllowedKinds, types.RequestKubernetesResource{Kind: requestedKubeKind.kind, APIGroup: requestedKubeKind.group}) + break + } } } + if len(filteredAllowedKinds) != len(requestedKubeKinds) { + // If we don't have as many allowed kinds as request, we reject the role. + continue + } - if !roleIsDenied { - goodRoles[requestedRoleName] = struct{}{} + // If there is something to deny, make sure we reject 'namespace' and 'kube_cluster', as it would grant access to everything. + for requestedKubeKind := range requestedKubeKinds { + for _, k := range deniedKinds { + if requestedKubeKind.kind == types.KindKubernetesCluster || requestedKubeKind.kind == "namespaces" { + // We have a deny entry and the request is for a kube_cluster or namespaces, reject. + return nil, mappedRequestedRolesToAllowedKinds + } + + if matchRequestKubernetesResources(requestedKubeKind, k, types.Deny) { + // If we have any requested kinds that is denied, reject all roles. + return nil, mappedRequestedRolesToAllowedKinds + } + } } + + goodRoles[requestedRoleName] = struct{}{} } return slices.Collect(maps.Keys(goodRoles)), mappedRequestedRolesToAllowedKinds @@ -2169,8 +2225,7 @@ func (m *requestValidator) pruneResourceRequestRoles( return roles, nil } - var mappedRequestedRolesToAllowedKinds map[string][]string - roles, mappedRequestedRolesToAllowedKinds = m.pruneRequestedRolesNotMatchingKubernetesResourceKinds(resourceIDs, roles) + roles, mappedRequestedRolesToAllowedKinds := m.pruneRequestedRolesNotMatchingKubernetesResourceKinds(resourceIDs, roles) if len(roles) == 0 { // all roles got pruned from not matching every kube requested kind. return nil, getInvalidKubeKindAccessRequestsError(mappedRequestedRolesToAllowedKinds, false /* requestedRoles */) } @@ -2304,32 +2359,6 @@ func countAllowedLogins(role types.Role) int { return len(allowed) } -// getKubeResourceKinds just extracts the kinds from the list. -// If a wildcard is present, then all supported resource types are returned. -func getKubeResourceKinds(kubernetesResources []types.RequestKubernetesResource) []string { - var kinds []string - for _, rm := range kubernetesResources { - if rm.Kind == types.Wildcard { - return types.KubernetesResourcesKinds - } - kinds = append(kinds, rm.Kind) - } - return kinds -} - -// getAllowedKubeResourceKinds returns only the allowed kinds that were not in the -// denied list. -func getAllowedKubeResourceKinds(allowedKinds []string, deniedKinds []string) []string { - allowed := make(map[string]struct{}, len(allowedKinds)) - for _, kind := range allowedKinds { - allowed[kind] = struct{}{} - } - for _, kind := range deniedKinds { - delete(allowed, kind) - } - return slices.Collect(maps.Keys(allowed)) -} - func (m *requestValidator) roleAllowsResource( role types.Role, resource types.ResourceWithLabels, @@ -2370,7 +2399,7 @@ func (m *requestValidator) getUnderlyingResourcesByResourceIDs(ctx context.Conte // requested is fulfilled by at least one role. searchableResourcesIDs := slices.Clone(resourceIDs) for i := range searchableResourcesIDs { - if slices.Contains(types.KubernetesResourcesKinds, searchableResourcesIDs[i].Kind) { + if slices.Contains(types.KubernetesResourcesKinds, searchableResourcesIDs[i].Kind) || strings.HasPrefix(searchableResourcesIDs[i].Kind, types.AccessRequestPrefixKindKube) { searchableResourcesIDs[i].Kind = types.KindKubernetesCluster } } @@ -2385,17 +2414,53 @@ func getKubeResourcesFromResourceIDs(resourceIDs []types.ResourceID, clusterName kubernetesResources := make([]types.KubernetesResource, 0, len(resourceIDs)) for _, resourceID := range resourceIDs { - if slices.Contains(types.KubernetesResourcesKinds, resourceID.Kind) && resourceID.Name == clusterName { + if resourceID.Name != clusterName { + continue + } + // TODO(@creack): DELETE IN v20.0.0 when we no longer support legacy access request formats. + // Special case to support legacy "namespace" kind request. + if resourceID.Kind == types.KindKubeNamespace { + // If the target namespace is a wildcard, update the pattern to make sure cluster-wide resources won't be matched. + targetNS := resourceID.SubResourceName + if targetNS == types.Wildcard { + targetNS = "^.+$" + } + kubernetesResources = append(kubernetesResources, + types.KubernetesResource{ + Kind: "namespaces", + Name: resourceID.SubResourceName, + APIGroup: "", + }, + types.KubernetesResource{ + Kind: types.Wildcard, + Name: types.Wildcard, + Namespace: targetNS, + APIGroup: "", + }, + ) + continue + } + if slices.Contains(types.KubernetesResourcesKinds, resourceID.Kind) || strings.HasPrefix(resourceID.Kind, types.AccessRequestPrefixKindKube) { kind := types.KubernetesResourcesKindsPlurals[resourceID.Kind] if kind == "" { kind = resourceID.Kind } + isClusterWide := slices.Contains(types.KubernetesClusterWideResourceKinds, resourceID.Kind) || strings.HasPrefix(kind, types.AccessRequestPrefixKindKubeClusterWide) + if !isClusterWide { + kind = strings.TrimPrefix(kind, types.AccessRequestPrefixKindKubeNamespaced) + } else { + kind = strings.TrimPrefix(kind, types.AccessRequestPrefixKindKubeClusterWide) + } + gk := schema.ParseGroupKind(kind) + if gk.Group == "" { + gk.Group = types.KubernetesResourcesV7KindGroups[resourceID.Kind] + } switch { - // TODO(@creack): Make sure this is handled in the AccessRequest PR. - case slices.Contains(types.KubernetesClusterWideResourceKinds, resourceID.Kind): + case isClusterWide: kubernetesResources = append(kubernetesResources, types.KubernetesResource{ - Kind: kind, - Name: resourceID.SubResourceName, + Kind: gk.Kind, + Name: resourceID.SubResourceName, + APIGroup: gk.Group, }) default: splits := strings.Split(resourceID.SubResourceName, "/") @@ -2403,9 +2468,10 @@ func getKubeResourcesFromResourceIDs(resourceIDs []types.ResourceID, clusterName return nil, trace.BadParameter("subresource name %q does not follow / format", resourceID.SubResourceName) } kubernetesResources = append(kubernetesResources, types.KubernetesResource{ - Kind: kind, + Kind: gk.Kind, Namespace: splits[0], Name: splits[1], + APIGroup: gk.Group, }) } } diff --git a/lib/services/access_request_test.go b/lib/services/access_request_test.go index bab4c3b34c21d..9b3f2fc02fe68 100644 --- a/lib/services/access_request_test.go +++ b/lib/services/access_request_test.go @@ -2935,7 +2935,7 @@ func TestValidate_WithAllowRequestKubernetesResources(t *testing.T) { "*": {"*"}, }, KubernetesResources: []types.KubernetesResource{ - {Kind: "*", Namespace: "*", Name: "*", Verbs: []string{"*"}, APIGroup: "*"}, + {Kind: "*", Namespace: "*", Name: "*", Verbs: []string{"*"}}, }, }, }, @@ -2948,7 +2948,7 @@ func TestValidate_WithAllowRequestKubernetesResources(t *testing.T) { "*": {"*"}, }, KubernetesResources: []types.KubernetesResource{ - {Kind: "*", Namespace: "*", Name: "*", Verbs: []string{"*"}, APIGroup: "*"}, + {Kind: "namespace", Namespace: "*", Name: "*", Verbs: []string{"*"}}, }, }, }, @@ -2958,7 +2958,7 @@ func TestValidate_WithAllowRequestKubernetesResources(t *testing.T) { "*": {"*"}, }, KubernetesResources: []types.KubernetesResource{ - {Kind: "pods", Namespace: "*", Name: "*", Verbs: []string{"*"}, APIGroup: "*"}, + {Kind: "pod", Namespace: "*", Name: "*", Verbs: []string{"*"}}, }, }, }, @@ -2968,7 +2968,7 @@ func TestValidate_WithAllowRequestKubernetesResources(t *testing.T) { "*": {"*"}, }, KubernetesResources: []types.KubernetesResource{ - {Kind: "deployments", Namespace: "*", Name: "*", Verbs: []string{"*"}, APIGroup: "*"}, + {Kind: "deployment", Namespace: "*", Name: "*", Verbs: []string{"*"}}, }, }, }, @@ -2991,7 +2991,7 @@ func TestValidate_WithAllowRequestKubernetesResources(t *testing.T) { Allow: types.RoleConditions{ Request: &types.AccessRequestConditions{ KubernetesResources: []types.RequestKubernetesResource{ - {Kind: types.KindKubePod}, + {Kind: "pod"}, }, }, }, @@ -3001,7 +3001,7 @@ func TestValidate_WithAllowRequestKubernetesResources(t *testing.T) { Request: &types.AccessRequestConditions{ SearchAsRoles: []string{"kube-access-namespace", "db-access-wildcard"}, KubernetesResources: []types.RequestKubernetesResource{ - {Kind: types.KindKubeNamespace}, + {Kind: "namespace"}, }, }, }, @@ -3012,7 +3012,7 @@ func TestValidate_WithAllowRequestKubernetesResources(t *testing.T) { Request: &types.AccessRequestConditions{ SearchAsRoles: []string{"kube-access-wildcard", "db-access-wildcard"}, KubernetesResources: []types.RequestKubernetesResource{ - {Kind: types.Wildcard}, + {Kind: "*"}, }, }, }, @@ -3023,7 +3023,7 @@ func TestValidate_WithAllowRequestKubernetesResources(t *testing.T) { Request: &types.AccessRequestConditions{ SearchAsRoles: []string{"kube-access-wildcard"}, KubernetesResources: []types.RequestKubernetesResource{ - {Kind: types.KindKubeSecret}, + {Kind: "secret"}, }, }, }, @@ -3033,7 +3033,7 @@ func TestValidate_WithAllowRequestKubernetesResources(t *testing.T) { Request: &types.AccessRequestConditions{ SearchAsRoles: []string{"kube-access-pod"}, KubernetesResources: []types.RequestKubernetesResource{ - {Kind: types.KindKubePod}, + {Kind: "pod"}, }, }, }, @@ -3043,7 +3043,7 @@ func TestValidate_WithAllowRequestKubernetesResources(t *testing.T) { Request: &types.AccessRequestConditions{ SearchAsRoles: []string{"kube-access-deployment"}, KubernetesResources: []types.RequestKubernetesResource{ - {Kind: types.KindKubeDeployment}, + {Kind: "deployment"}, }, }, }, @@ -3053,8 +3053,8 @@ func TestValidate_WithAllowRequestKubernetesResources(t *testing.T) { Request: &types.AccessRequestConditions{ SearchAsRoles: []string{"kube-access-deployment", "kube-access-pod"}, KubernetesResources: []types.RequestKubernetesResource{ - {Kind: types.KindKubeDeployment}, - {Kind: types.KindKubePod}, + {Kind: "deployment"}, + {Kind: "pod"}, }, }, }, @@ -3064,7 +3064,7 @@ func TestValidate_WithAllowRequestKubernetesResources(t *testing.T) { Request: &types.AccessRequestConditions{ SearchAsRoles: []string{"db-access-wildcard", "kube-no-access"}, KubernetesResources: []types.RequestKubernetesResource{ - {Kind: types.KindNamespace}, + {Kind: "namespace"}, }, }, }, @@ -3074,14 +3074,14 @@ func TestValidate_WithAllowRequestKubernetesResources(t *testing.T) { Request: &types.AccessRequestConditions{ SearchAsRoles: []string{"kube-access-namespace"}, KubernetesResources: []types.RequestKubernetesResource{ - {Kind: types.KindNamespace}, + {Kind: "namespace"}, }, }, }, Deny: types.RoleConditions{ Request: &types.AccessRequestConditions{ KubernetesResources: []types.RequestKubernetesResource{ - {Kind: types.KindKubeSecret}, + {Kind: "secret"}, }, }, }, @@ -3095,8 +3095,8 @@ func TestValidate_WithAllowRequestKubernetesResources(t *testing.T) { Deny: types.RoleConditions{ Request: &types.AccessRequestConditions{ KubernetesResources: []types.RequestKubernetesResource{ - {Kind: types.KindKubeDeployment}, - {Kind: types.KindKubePod}, + {Kind: "deployment"}, + {Kind: "pod"}, }, }, }, @@ -3106,14 +3106,14 @@ func TestValidate_WithAllowRequestKubernetesResources(t *testing.T) { Request: &types.AccessRequestConditions{ SearchAsRoles: []string{"kube-access-namespace"}, KubernetesResources: []types.RequestKubernetesResource{ - {Kind: types.Wildcard}, + {Kind: "*"}, }, }, }, Deny: types.RoleConditions{ Request: &types.AccessRequestConditions{ KubernetesResources: []types.RequestKubernetesResource{ - {Kind: types.Wildcard}, + {Kind: "*"}, }, }, }, @@ -3121,7 +3121,7 @@ func TestValidate_WithAllowRequestKubernetesResources(t *testing.T) { } roles := make(map[string]types.Role) for name, spec := range roleDesc { - role, err := types.NewRole(name, spec) + role, err := types.NewRoleWithVersion(name, types.V7, spec) require.NoError(t, err) roles[name] = role } @@ -3341,22 +3341,24 @@ func TestValidate_WithAllowRequestKubernetesResources(t *testing.T) { wantInvalidRequestKindErr: true, }, { - desc: "allow namespace request when deny is not matched", + desc: "deny namespace request when deny is not matched", userStaticRoles: []string{"request-namespace_search-namespace_deny-secret"}, expectedRequestRoles: []string{"kube-access-namespace"}, requestResourceIDs: []types.ResourceID{ {Kind: types.KindKubeNamespace, ClusterName: myClusterName, Name: "kube", SubResourceName: "namespace"}, {Kind: types.KindKubeNamespace, ClusterName: myClusterName, Name: "kube", SubResourceName: "namespace2"}, }, + wantInvalidRequestKindErr: true, }, { - desc: "allow namespace request when deny is not matched with leaf clusters", + desc: "deny namespace request when deny is not matched with leaf clusters", userStaticRoles: []string{"request-namespace_search-namespace_deny-secret"}, expectedRequestRoles: []string{"kube-access-namespace"}, requestResourceIDs: []types.ResourceID{ {Kind: types.KindKubeNamespace, ClusterName: "leaf-cluster", Name: "kube", SubResourceName: "namespace"}, {Kind: types.KindKubeNamespace, ClusterName: "leaf-cluster", Name: "kube", SubResourceName: "namespace2"}, }, + wantInvalidRequestKindErr: true, }, { desc: "allow a list of different request.kubernetes_resources from same role", @@ -3376,13 +3378,22 @@ func TestValidate_WithAllowRequestKubernetesResources(t *testing.T) { wantInvalidRequestKindErr: true, }, { - desc: "allow wildcard request when deny is not matched", + desc: "deny wildcard request when deny is not matched - ns", userStaticRoles: []string{"request-undefined_search-wildcard_deny-deployment-pod"}, expectedRequestRoles: []string{"kube-access-wildcard"}, requestResourceIDs: []types.ResourceID{ {Kind: types.KindKubeNamespace, ClusterName: myClusterName, Name: "kube", SubResourceName: "namespace"}, + }, + wantInvalidRequestKindErr: true, + }, + { + desc: "deny wildcard request when deny is not matched - cluster", + userStaticRoles: []string{"request-undefined_search-wildcard_deny-deployment-pod"}, + expectedRequestRoles: []string{"kube-access-wildcard"}, + requestResourceIDs: []types.ResourceID{ {Kind: types.KindKubernetesCluster, ClusterName: myClusterName, Name: "kube"}, }, + wantInvalidRequestKindErr: true, }, { desc: "deny wildcard request when deny is matched", diff --git a/lib/services/matchers.go b/lib/services/matchers.go index b79edc71499bb..33eb26e9249af 100644 --- a/lib/services/matchers.go +++ b/lib/services/matchers.go @@ -22,6 +22,7 @@ import ( "context" "log/slog" "slices" + "strings" "github.com/gravitational/trace" @@ -209,7 +210,7 @@ func MatchResourceByFilters(resource types.ResourceWithLabels, filter MatchResou // We check if the resource kind is a Kubernetes resource kind to reduce the amount of // of cases we need to handle. If the resource type didn't match any arm before // and it is not a Kubernetes resource kind, we return an error. - if !slices.Contains(types.KubernetesResourcesKinds, filter.ResourceKind) { + if !slices.Contains(types.KubernetesResourcesKinds, filter.ResourceKind) && !strings.HasPrefix(filter.ResourceKind, types.AccessRequestPrefixKindKube) { return false, trace.NotImplemented("filtering for resource kind %q not supported", kind) } specResource = resource diff --git a/lib/services/role.go b/lib/services/role.go index 4ee3918bd1763..4aeebaae210c1 100644 --- a/lib/services/role.go +++ b/lib/services/role.go @@ -3399,13 +3399,101 @@ func (set RoleSet) GetAllowedSearchAsRoles(allowFilters ...SearchAsRolesOption) return apiutils.Deduplicate(allowed) } +type gk struct{ group, kind string } + +// noramlize the give kube kind. Maps legacy values to plural+group, trim the kube: prefix. +// Returns [.]. +func normalizeKubernetesKind(in string) (out gk) { + // Check if we have a legacy kind. + out.group = types.KubernetesResourcesV7KindGroups[in] + out.kind = types.KubernetesResourcesKindsPlurals[in] + if out.kind == "" { + switch { + case in == types.KindKubeNamespace: + out.kind = "namespaces" + return out + case strings.HasPrefix(in, types.AccessRequestPrefixKindKubeNamespaced): + out.kind = strings.TrimPrefix(in, types.AccessRequestPrefixKindKubeNamespaced) + case strings.HasPrefix(in, types.AccessRequestPrefixKindKubeClusterWide): + out.kind = strings.TrimPrefix(in, types.AccessRequestPrefixKindKubeClusterWide) + // Subset if the two first used in search. Must be last. + case strings.HasPrefix(in, types.AccessRequestPrefixKindKube): + out.kind = strings.TrimPrefix(in, types.AccessRequestPrefixKindKube) + } + } + if out.group != "" { // If we have a group, we are dealing with legacy value, we have the noramlized version. + return out + } + + // Otherwise, parse the group from the trimmed input. + if i := strings.Index(out.kind, "."); i != -1 { + out.group = out.kind[i+1:] + out.kind = out.kind[:i] + return out + } + return out +} + +// matchRequestKubernetesResources checks if the input matches the reference +// based on the condition type. +// +// Similar logic as utils.KubeResourceMatchesRegex(), but with support for wildcard input +// and without support for verbs/names/namespaces. +// +// Examples: +// Request: *.apps Deny: deployments.apps -> match. +// Request: *.apps Deny: deployments.* -> match. (*.apps could be deployments.apps which matches deployments.*) +// Request: *.apps Deny: *.* -> match. +// Request: deployments.* Deny: deployments.apps -> match. +// Request: deployments.* Deny: deployments.* -> match. +// Request: deployments.* Deny: *.* -> match. +// Request: *.* Deny: deployments.apps -> match. +// Request: *.* Deny: deployments.* -> match. +// Request: *.* Deny: *.* -> match. +func matchRequestKubernetesResources(input gk, reference types.RequestKubernetesResource, cond types.RoleConditionType) bool { + // If we have an exact match, we are done. + if input.kind == reference.Kind && input.group == reference.APIGroup { + return true + } + // If the reference is a wildcard and the input kube_cluster, we don't match allow, but we match deny. + // Ref: + // https://github.com/gravitational/teleport/blob/master/rfd/0183-access-request-kube-resource-allow-list.md#as-an-admin-i-want-to-require-users-to-request-for-kubernetes-subresources-instead-of-the-whole-kubernetes-cluster + if reference.Kind == types.Wildcard && input.kind == types.KindKubernetesCluster { + return cond == types.Deny + } + + if cond == types.Allow { + // In allow mode, if the reference kind is not a wildcard and doesn't match exactly, we reject. + if reference.Kind != types.Wildcard && input.kind != reference.Kind { + return false + } + + // If the reference api group is a wildcard or is an exact match, we are done. + if reference.APIGroup == types.Wildcard || input.group == reference.APIGroup { + return true + } + + // Otherwise, attempt to match the api group pattern. + ok, _ := utils.MatchString(input.group, reference.APIGroup) + return ok + } + // In deny mode, we reject only if both input/ref are not wildcard and are not equal. + if reference.Kind != types.Wildcard && input.kind != types.Wildcard && input.kind != reference.Kind { + return false + } + // If there is no conflict on the kind, check the group. As we support pattern matching, check both sides. + ok1, _ := utils.MatchString(input.group, reference.APIGroup) + ok2, _ := utils.MatchString(reference.APIGroup, input.group) + return ok1 || ok2 +} + // 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 { + for _, kr := range role.GetRequestKubernetesResources(types.Deny) { + if matchRequestKubernetesResources(normalizeKubernetesKind(requestedKubeResourceKind), kr, types.Deny) { return nil } } @@ -3419,12 +3507,12 @@ func (set RoleSet) GetAllowedSearchAsRolesForKubeResourceKind(requestedKubeResou 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. + // 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 { + for _, kr := range role.GetRequestKubernetesResources(types.Allow) { + if matchRequestKubernetesResources(normalizeKubernetesKind(requestedKubeResourceKind), kr, types.Allow) { return true } } diff --git a/lib/services/role_test.go b/lib/services/role_test.go index bb742f4fbf865..d30a881079bed 100644 --- a/lib/services/role_test.go +++ b/lib/services/role_test.go @@ -204,7 +204,7 @@ func TestRoleParse(t *testing.T) { } }`, error: trace.BadParameter(""), - matchMessage: "invalid or unsupported", + matchMessage: "kind \"abcd\" is not supported in role version \"v6\"", }, { name: "validation error, kubernetes_resources kind namespace not supported in v6", @@ -9968,3 +9968,160 @@ func TestCheckAccessToGitServer(t *testing.T) { }) } } + +func TestMatchRequestKubernetesResource(t *testing.T) { + tests := []struct { + input gk + reference types.RequestKubernetesResource + cond types.RoleConditionType + expected bool + }{ + // Deny not match. + {gk{kind: "deployments", group: "apps"}, types.RequestKubernetesResource{Kind: "jobs", APIGroup: "batch"}, types.Deny, false}, + {gk{kind: "deployments", group: "apps"}, types.RequestKubernetesResource{Kind: "jobs", APIGroup: "*"}, types.Deny, false}, + {gk{kind: "deployments", group: "apps"}, types.RequestKubernetesResource{Kind: "*", APIGroup: "batch"}, types.Deny, false}, + {gk{kind: "*", group: "apps"}, types.RequestKubernetesResource{Kind: "jobs", APIGroup: "batch"}, types.Deny, false}, + {gk{kind: "*", group: "apps"}, types.RequestKubernetesResource{Kind: "*", APIGroup: "batch"}, types.Deny, false}, + {gk{kind: "deployments", group: "*"}, types.RequestKubernetesResource{Kind: "jobs", APIGroup: "batch"}, types.Deny, false}, + {gk{kind: "deployments", group: "*"}, types.RequestKubernetesResource{Kind: "jobs", APIGroup: "*"}, types.Deny, false}, + {gk{kind: "deployments", group: "batch"}, types.RequestKubernetesResource{Kind: "jobs", APIGroup: "batch"}, types.Deny, false}, + {gk{kind: "deployments", group: "batch"}, types.RequestKubernetesResource{Kind: "jobs", APIGroup: "*"}, types.Deny, false}, + // Same checks with allow. + {gk{kind: "deployments", group: "apps"}, types.RequestKubernetesResource{Kind: "jobs", APIGroup: "batch"}, types.Allow, false}, + {gk{kind: "deployments", group: "apps"}, types.RequestKubernetesResource{Kind: "jobs", APIGroup: "*"}, types.Allow, false}, + {gk{kind: "deployments", group: "apps"}, types.RequestKubernetesResource{Kind: "*", APIGroup: "batch"}, types.Allow, false}, + {gk{kind: "*", group: "apps"}, types.RequestKubernetesResource{Kind: "jobs", APIGroup: "batch"}, types.Allow, false}, + {gk{kind: "*", group: "apps"}, types.RequestKubernetesResource{Kind: "*", APIGroup: "batch"}, types.Allow, false}, + {gk{kind: "deployments", group: "*"}, types.RequestKubernetesResource{Kind: "jobs", APIGroup: "batch"}, types.Allow, false}, + {gk{kind: "deployments", group: "*"}, types.RequestKubernetesResource{Kind: "jobs", APIGroup: "*"}, types.Allow, false}, + {gk{kind: "deployments", group: "batch"}, types.RequestKubernetesResource{Kind: "jobs", APIGroup: "batch"}, types.Allow, false}, + {gk{kind: "deployments", group: "batch"}, types.RequestKubernetesResource{Kind: "jobs", APIGroup: "*"}, types.Allow, false}, + + // Deny match. + {gk{kind: "deployments", group: "apps"}, types.RequestKubernetesResource{Kind: "deployments", APIGroup: "apps"}, types.Deny, true}, + {gk{kind: "deployments", group: "apps"}, types.RequestKubernetesResource{Kind: "deployments", APIGroup: "*"}, types.Deny, true}, + {gk{kind: "deployments", group: "apps"}, types.RequestKubernetesResource{Kind: "*", APIGroup: "apps"}, types.Deny, true}, + {gk{kind: "deployments", group: "apps"}, types.RequestKubernetesResource{Kind: "*", APIGroup: "*"}, types.Deny, true}, + {gk{kind: "*", group: "apps"}, types.RequestKubernetesResource{Kind: "deployments", APIGroup: "apps"}, types.Deny, true}, + {gk{kind: "*", group: "apps"}, types.RequestKubernetesResource{Kind: "deployments", APIGroup: "*"}, types.Deny, true}, + {gk{kind: "*", group: "apps"}, types.RequestKubernetesResource{Kind: "*", APIGroup: "apps"}, types.Deny, true}, + {gk{kind: "*", group: "apps"}, types.RequestKubernetesResource{Kind: "*", APIGroup: "*"}, types.Deny, true}, + {gk{kind: "deployments", group: "*"}, types.RequestKubernetesResource{Kind: "deployments", APIGroup: "apps"}, types.Deny, true}, + {gk{kind: "deployments", group: "*"}, types.RequestKubernetesResource{Kind: "deployments", APIGroup: "*"}, types.Deny, true}, + {gk{kind: "deployments", group: "*"}, types.RequestKubernetesResource{Kind: "*", APIGroup: "apps"}, types.Deny, true}, + {gk{kind: "deployments", group: "*"}, types.RequestKubernetesResource{Kind: "*", APIGroup: "*"}, types.Deny, true}, + {gk{kind: "*", group: "*"}, types.RequestKubernetesResource{Kind: "deployments", APIGroup: "apps"}, types.Deny, true}, + {gk{kind: "*", group: "*"}, types.RequestKubernetesResource{Kind: "deployments", APIGroup: "*"}, types.Deny, true}, + {gk{kind: "*", group: "*"}, types.RequestKubernetesResource{Kind: "*", APIGroup: "apps"}, types.Deny, true}, + {gk{kind: "*", group: "*"}, types.RequestKubernetesResource{Kind: "*", APIGroup: "*"}, types.Deny, true}, + {gk{kind: "deployments", group: "ap*"}, types.RequestKubernetesResource{Kind: "deployments", APIGroup: "apps"}, types.Deny, true}, + {gk{kind: "deployments", group: "ap*"}, types.RequestKubernetesResource{Kind: "deployments", APIGroup: "*"}, types.Deny, true}, + {gk{kind: "deployments", group: "ap*"}, types.RequestKubernetesResource{Kind: "*", APIGroup: "apps"}, types.Deny, true}, + {gk{kind: "deployments", group: "ap*"}, types.RequestKubernetesResource{Kind: "*", APIGroup: "*"}, types.Deny, true}, + {gk{kind: "*", group: "ap*"}, types.RequestKubernetesResource{Kind: "deployments", APIGroup: "apps"}, types.Deny, true}, + {gk{kind: "*", group: "ap*"}, types.RequestKubernetesResource{Kind: "deployments", APIGroup: "*"}, types.Deny, true}, + {gk{kind: "*", group: "ap*"}, types.RequestKubernetesResource{Kind: "*", APIGroup: "apps"}, types.Deny, true}, + {gk{kind: "*", group: "ap*"}, types.RequestKubernetesResource{Kind: "*", APIGroup: "*"}, types.Deny, true}, + // Same checks with allow. + {gk{kind: "deployments", group: "apps"}, types.RequestKubernetesResource{Kind: "deployments", APIGroup: "apps"}, types.Allow, true}, + {gk{kind: "deployments", group: "apps"}, types.RequestKubernetesResource{Kind: "deployments", APIGroup: "*"}, types.Allow, true}, + {gk{kind: "deployments", group: "apps"}, types.RequestKubernetesResource{Kind: "*", APIGroup: "apps"}, types.Allow, true}, + {gk{kind: "deployments", group: "apps"}, types.RequestKubernetesResource{Kind: "*", APIGroup: "*"}, types.Allow, true}, + {gk{kind: "*", group: "apps"}, types.RequestKubernetesResource{Kind: "deployments", APIGroup: "apps"}, types.Allow, false}, + {gk{kind: "*", group: "apps"}, types.RequestKubernetesResource{Kind: "deployments", APIGroup: "*"}, types.Allow, false}, + {gk{kind: "*", group: "apps"}, types.RequestKubernetesResource{Kind: "*", APIGroup: "apps"}, types.Allow, true}, + {gk{kind: "*", group: "apps"}, types.RequestKubernetesResource{Kind: "*", APIGroup: "*"}, types.Allow, true}, + {gk{kind: "deployments", group: "*"}, types.RequestKubernetesResource{Kind: "deployments", APIGroup: "apps"}, types.Allow, false}, + {gk{kind: "deployments", group: "*"}, types.RequestKubernetesResource{Kind: "deployments", APIGroup: "*"}, types.Allow, true}, + {gk{kind: "deployments", group: "*"}, types.RequestKubernetesResource{Kind: "*", APIGroup: "apps"}, types.Allow, false}, + {gk{kind: "deployments", group: "*"}, types.RequestKubernetesResource{Kind: "*", APIGroup: "*"}, types.Allow, true}, + {gk{kind: "*", group: "*"}, types.RequestKubernetesResource{Kind: "deployments", APIGroup: "apps"}, types.Allow, false}, + {gk{kind: "*", group: "*"}, types.RequestKubernetesResource{Kind: "deployments", APIGroup: "*"}, types.Allow, false}, + {gk{kind: "*", group: "*"}, types.RequestKubernetesResource{Kind: "*", APIGroup: "apps"}, types.Allow, false}, + {gk{kind: "*", group: "*"}, types.RequestKubernetesResource{Kind: "*", APIGroup: "*"}, types.Allow, true}, + {gk{kind: "deployments", group: "ap*"}, types.RequestKubernetesResource{Kind: "deployments", APIGroup: "apps"}, types.Allow, false}, + {gk{kind: "deployments", group: "ap*"}, types.RequestKubernetesResource{Kind: "deployments", APIGroup: "*"}, types.Allow, true}, + {gk{kind: "deployments", group: "ap*"}, types.RequestKubernetesResource{Kind: "*", APIGroup: "apps"}, types.Allow, false}, + {gk{kind: "deployments", group: "ap*"}, types.RequestKubernetesResource{Kind: "*", APIGroup: "*"}, types.Allow, true}, + {gk{kind: "*", group: "ap*"}, types.RequestKubernetesResource{Kind: "deployments", APIGroup: "apps"}, types.Allow, false}, + {gk{kind: "*", group: "ap*"}, types.RequestKubernetesResource{Kind: "deployments", APIGroup: "*"}, types.Allow, false}, + {gk{kind: "*", group: "ap*"}, types.RequestKubernetesResource{Kind: "*", APIGroup: "apps"}, types.Allow, false}, + {gk{kind: "*", group: "ap*"}, types.RequestKubernetesResource{Kind: "*", APIGroup: "*"}, types.Allow, true}, + + // Allow not match. Some dups from earlier, but keeping them for logic, before it was the counter-part of the deny, here it is the main check. + {gk{kind: "deployments", group: "apps"}, types.RequestKubernetesResource{Kind: "jobs", APIGroup: "batch"}, types.Allow, false}, + {gk{kind: "deployments", group: "apps"}, types.RequestKubernetesResource{Kind: "jobs", APIGroup: "*"}, types.Allow, false}, + {gk{kind: "deployments", group: "apps"}, types.RequestKubernetesResource{Kind: "*", APIGroup: "batch"}, types.Allow, false}, + {gk{kind: "deployments", group: "apps"}, types.RequestKubernetesResource{Kind: "*", APIGroup: "ba*"}, types.Allow, false}, + {gk{kind: "deployments", group: "*"}, types.RequestKubernetesResource{Kind: "jobs", APIGroup: "batch"}, types.Allow, false}, + {gk{kind: "deployments", group: "*"}, types.RequestKubernetesResource{Kind: "jobs", APIGroup: "*"}, types.Allow, false}, + {gk{kind: "deployments", group: "*"}, types.RequestKubernetesResource{Kind: "*", APIGroup: "batch"}, types.Allow, false}, + {gk{kind: "deployments", group: "*"}, types.RequestKubernetesResource{Kind: "*", APIGroup: "apps"}, types.Allow, false}, + {gk{kind: "deployments", group: "*"}, types.RequestKubernetesResource{Kind: "*", APIGroup: "a*"}, types.Allow, false}, + {gk{kind: "deployments", group: "*"}, types.RequestKubernetesResource{Kind: "deployments", APIGroup: "batch"}, types.Allow, false}, + {gk{kind: "*", group: "apps"}, types.RequestKubernetesResource{Kind: "*", APIGroup: "batch"}, types.Allow, false}, + {gk{kind: "*", group: "apps"}, types.RequestKubernetesResource{Kind: "*", APIGroup: "ba*"}, types.Allow, false}, + {gk{kind: "*", group: "apps"}, types.RequestKubernetesResource{Kind: "deployments", APIGroup: "apps"}, types.Allow, false}, + {gk{kind: "*", group: "apps"}, types.RequestKubernetesResource{Kind: "deployments", APIGroup: "*"}, types.Allow, false}, + {gk{kind: "*", group: "*"}, types.RequestKubernetesResource{Kind: "deployments", APIGroup: "apps"}, types.Allow, false}, + {gk{kind: "*", group: "*"}, types.RequestKubernetesResource{Kind: "deployments", APIGroup: "*"}, types.Allow, false}, + {gk{kind: "*", group: "*"}, types.RequestKubernetesResource{Kind: "*", APIGroup: "apps"}, types.Allow, false}, + // Same checks with deny. + {gk{kind: "deployments", group: "apps"}, types.RequestKubernetesResource{Kind: "jobs", APIGroup: "batch"}, types.Deny, false}, + {gk{kind: "deployments", group: "apps"}, types.RequestKubernetesResource{Kind: "jobs", APIGroup: "*"}, types.Deny, false}, + {gk{kind: "deployments", group: "apps"}, types.RequestKubernetesResource{Kind: "*", APIGroup: "batch"}, types.Deny, false}, + {gk{kind: "deployments", group: "apps"}, types.RequestKubernetesResource{Kind: "*", APIGroup: "ba*"}, types.Deny, false}, + {gk{kind: "deployments", group: "*"}, types.RequestKubernetesResource{Kind: "jobs", APIGroup: "batch"}, types.Deny, false}, + {gk{kind: "deployments", group: "*"}, types.RequestKubernetesResource{Kind: "jobs", APIGroup: "*"}, types.Deny, false}, + {gk{kind: "deployments", group: "*"}, types.RequestKubernetesResource{Kind: "*", APIGroup: "batch"}, types.Deny, true}, + {gk{kind: "deployments", group: "*"}, types.RequestKubernetesResource{Kind: "*", APIGroup: "apps"}, types.Deny, true}, + {gk{kind: "deployments", group: "*"}, types.RequestKubernetesResource{Kind: "*", APIGroup: "a*"}, types.Deny, true}, + {gk{kind: "deployments", group: "*"}, types.RequestKubernetesResource{Kind: "deployments", APIGroup: "batch"}, types.Deny, true}, + {gk{kind: "*", group: "apps"}, types.RequestKubernetesResource{Kind: "*", APIGroup: "batch"}, types.Deny, false}, + {gk{kind: "*", group: "apps"}, types.RequestKubernetesResource{Kind: "*", APIGroup: "ba*"}, types.Deny, false}, + {gk{kind: "*", group: "apps"}, types.RequestKubernetesResource{Kind: "deployments", APIGroup: "apps"}, types.Deny, true}, + {gk{kind: "*", group: "apps"}, types.RequestKubernetesResource{Kind: "deployments", APIGroup: "*"}, types.Deny, true}, + {gk{kind: "*", group: "*"}, types.RequestKubernetesResource{Kind: "deployments", APIGroup: "apps"}, types.Deny, true}, + {gk{kind: "*", group: "*"}, types.RequestKubernetesResource{Kind: "deployments", APIGroup: "*"}, types.Deny, true}, + {gk{kind: "*", group: "*"}, types.RequestKubernetesResource{Kind: "*", APIGroup: "apps"}, types.Deny, true}, + + // Allow match. Some dups from earlier, but keeping them for logic, before it was the counter-part of the deny, here it is the main check. + {gk{kind: "deployments", group: "apps"}, types.RequestKubernetesResource{Kind: "deployments", APIGroup: "apps"}, types.Allow, true}, + {gk{kind: "deployments", group: "apps"}, types.RequestKubernetesResource{Kind: "deployments", APIGroup: "*"}, types.Allow, true}, + {gk{kind: "deployments", group: "apps"}, types.RequestKubernetesResource{Kind: "*", APIGroup: "apps"}, types.Allow, true}, + {gk{kind: "deployments", group: "apps"}, types.RequestKubernetesResource{Kind: "*", APIGroup: "*"}, types.Allow, true}, + {gk{kind: "deployments", group: "apps"}, types.RequestKubernetesResource{Kind: "*", APIGroup: "ap*"}, types.Allow, true}, + {gk{kind: "deployments", group: "*"}, types.RequestKubernetesResource{Kind: "deployments", APIGroup: "*"}, types.Allow, true}, + {gk{kind: "deployments", group: "*"}, types.RequestKubernetesResource{Kind: "*", APIGroup: "*"}, types.Allow, true}, + {gk{kind: "*", group: "apps"}, types.RequestKubernetesResource{Kind: "*", APIGroup: "apps"}, types.Allow, true}, + {gk{kind: "*", group: "apps"}, types.RequestKubernetesResource{Kind: "*", APIGroup: "*"}, types.Allow, true}, + {gk{kind: "*", group: "apps"}, types.RequestKubernetesResource{Kind: "*", APIGroup: "ap*"}, types.Allow, true}, + {gk{kind: "*", group: "*"}, types.RequestKubernetesResource{Kind: "*", APIGroup: "*"}, types.Allow, true}, + // Same checks with deny. + {gk{kind: "deployments", group: "apps"}, types.RequestKubernetesResource{Kind: "deployments", APIGroup: "apps"}, types.Deny, true}, + {gk{kind: "deployments", group: "apps"}, types.RequestKubernetesResource{Kind: "deployments", APIGroup: "*"}, types.Deny, true}, + {gk{kind: "deployments", group: "apps"}, types.RequestKubernetesResource{Kind: "*", APIGroup: "apps"}, types.Deny, true}, + {gk{kind: "deployments", group: "apps"}, types.RequestKubernetesResource{Kind: "*", APIGroup: "*"}, types.Deny, true}, + {gk{kind: "deployments", group: "apps"}, types.RequestKubernetesResource{Kind: "*", APIGroup: "ap*"}, types.Deny, true}, + {gk{kind: "deployments", group: "*"}, types.RequestKubernetesResource{Kind: "deployments", APIGroup: "*"}, types.Deny, true}, + {gk{kind: "deployments", group: "*"}, types.RequestKubernetesResource{Kind: "*", APIGroup: "*"}, types.Deny, true}, + {gk{kind: "*", group: "apps"}, types.RequestKubernetesResource{Kind: "*", APIGroup: "apps"}, types.Deny, true}, + {gk{kind: "*", group: "apps"}, types.RequestKubernetesResource{Kind: "*", APIGroup: "*"}, types.Deny, true}, + {gk{kind: "*", group: "apps"}, types.RequestKubernetesResource{Kind: "*", APIGroup: "ap*"}, types.Deny, true}, + {gk{kind: "*", group: "*"}, types.RequestKubernetesResource{Kind: "*", APIGroup: "*"}, types.Deny, true}, + } + + for i, test := range tests { + t.Run(fmt.Sprint(i), func(t *testing.T) { + if result := matchRequestKubernetesResources(test.input, test.reference, test.cond); result != test.expected { + checkType := "allow" + if test.cond == types.Deny { + checkType = "deny" + } + t.Fatalf("Checking '%s.%s' %s by '%s.%s', expected %t, got %t", + test.input.kind, test.input.group, checkType, test.reference.Kind, test.reference.APIGroup, test.expected, result) + } + }) + } + +} diff --git a/lib/sshca/identity_test.go b/lib/sshca/identity_test.go index fe42bec5fccad..07760beeab431 100644 --- a/lib/sshca/identity_test.go +++ b/lib/sshca/identity_test.go @@ -68,7 +68,7 @@ func TestIdentityConversion(t *testing.T) { BotInstanceID: "instance", AllowedResourceIDs: []types.ResourceID{{ ClusterName: "cluster", - Kind: types.KindKubePod, // must use a kube resource kind for parsing of sub-resource to work correctly + Kind: "kube:ns:pods", // must use a kube resource kind with ns for parsing of sub-resource to work correctly. Name: "name", SubResourceName: "sub/sub", }}, diff --git a/lib/web/servers.go b/lib/web/servers.go index 59fca5a12f850..8b8aeb2d471f1 100644 --- a/lib/web/servers.go +++ b/lib/web/servers.go @@ -21,6 +21,7 @@ package web import ( "net/http" "slices" + "strings" "github.com/gravitational/trace" "github.com/julienschmidt/httprouter" @@ -75,8 +76,8 @@ func (h *Handler) clusterKubeResourcesGet(w http.ResponseWriter, r *http.Request return nil, trace.BadParameter("missing param %q", "kind") } - if !slices.Contains(types.KubernetesResourcesKinds, kind) { - return nil, trace.BadParameter("kind is not valid, valid kinds %v", types.KubernetesResourcesKinds) + if !slices.Contains(types.KubernetesResourcesKinds, kind) && !strings.HasPrefix(kind, types.AccessRequestPrefixKindKube) { + return nil, trace.BadParameter("kind is not valid, valid kinds %v %s", types.KubernetesResourcesKinds, types.AccessRequestPrefixKindKube) } clt, err := sctx.NewKubernetesServiceClient(r.Context(), h.cfg.ProxyWebAddr.Addr) diff --git a/tool/tsh/common/access_request.go b/tool/tsh/common/access_request.go index a3e4a73a3b940..4ca65b51377fe 100644 --- a/tool/tsh/common/access_request.go +++ b/tool/tsh/common/access_request.go @@ -21,7 +21,6 @@ package common import ( "fmt" "path" - "slices" "sort" "strings" "time" @@ -416,8 +415,8 @@ func onRequestSearch(cf *CLIConf) error { if cf.KubernetesCluster == "" { cf.KubernetesCluster, _ = kubeconfig.SelectedKubeCluster(getKubeConfigPath(cf, ""), tc.SiteName) } - if slices.Contains(types.KubernetesResourcesKinds, cf.ResourceKind) && cf.KubernetesCluster == "" { - return trace.BadParameter("when searching for Pods, --kube-cluster cannot be empty") + if cf.KubernetesCluster == "" && cf.ResourceKind == types.KindKubernetesResource { + return trace.BadParameter("--kube-cluster is required when searching for Kubernetes resources") } // if --all-namespaces flag was provided we search in every namespace. // This means sending an empty namespace to the ListResources API. @@ -427,14 +426,17 @@ func onRequestSearch(cf *CLIConf) error { var resources types.ResourcesWithLabels var tableColumns []string - switch { - case slices.Contains(types.KubernetesResourcesKinds, cf.ResourceKind): + if cf.ResourceKind == types.KindKubernetesResource { proxyGRPCClient, err := tc.NewKubernetesServiceClient(cf.Context, tc.SiteName) if err != nil { return trace.Wrap(err) } + resourceType := types.AccessRequestPrefixKindKube + cf.kubeResourceKind + if cf.kubeAPIGroup != "" { + resourceType = resourceType + "." + cf.kubeAPIGroup + } req := kubeproto.ListKubernetesResourcesRequest{ - ResourceType: cf.ResourceKind, + ResourceType: resourceType, Labels: tc.Labels, PredicateExpression: cf.PredicateExpression, SearchKeywords: tc.SearchKeywords, @@ -450,7 +452,7 @@ func onRequestSearch(cf *CLIConf) error { } tableColumns = []string{"Name", "Namespace", "Labels", "Resource ID"} - default: + } else { // For all other resources, we need to connect to the auth server. clusterClient, err := tc.ConnectToCluster(cf.Context) if err != nil { @@ -487,50 +489,50 @@ func onRequestSearch(cf *CLIConf) error { case *types.KubernetesResourceV1: resourceID := types.ResourceIDToString(types.ResourceID{ ClusterName: tc.SiteName, - Kind: resource.GetKind(), + Kind: r.GetKind(), Name: cf.KubernetesCluster, - SubResourceName: path.Join(r.Spec.Namespace, resource.GetName()), + SubResourceName: path.Join(r.Spec.Namespace, r.GetName()), }) - if ignoreDuplicateResourceId(deduplicateResourceIDs, resourceID) { + if ignoreDuplicateResourceID(deduplicateResourceIDs, resourceID) { continue } resourceIDs = append(resourceIDs, resourceID) row = []string{ - common.FormatResourceName(resource, cf.Verbose), + common.FormatResourceName(r, cf.Verbose), r.Spec.Namespace, - common.FormatLabels(resource.GetAllLabels(), cf.Verbose), + common.FormatLabels(r.GetAllLabels(), cf.Verbose), resourceID, } default: resourceID := types.ResourceIDToString(types.ResourceID{ ClusterName: tc.SiteName, - Kind: resource.GetKind(), - Name: resource.GetName(), + Kind: r.GetKind(), + Name: r.GetName(), }) - if ignoreDuplicateResourceId(deduplicateResourceIDs, resourceID) { + if ignoreDuplicateResourceID(deduplicateResourceIDs, resourceID) { continue } resourceIDs = append(resourceIDs, resourceID) hostName := "" - if r, ok := resource.(interface{ GetHostname() string }); ok { - hostName = r.GetHostname() + if r2, ok := r.(interface{ GetHostname() string }); ok { + hostName = r2.GetHostname() } switch cf.ResourceKind { case types.KindDatabase: row = []string{ - common.FormatResourceName(resource, cf.Verbose), - common.FormatLabels(resource.GetAllLabels(), cf.Verbose), + common.FormatResourceName(r, cf.Verbose), + common.FormatLabels(r.GetAllLabels(), cf.Verbose), resourceID, } default: row = []string{ - common.FormatResourceName(resource, cf.Verbose), + common.FormatResourceName(r, cf.Verbose), hostName, - common.FormatLabels(resource.GetAllLabels(), cf.Verbose), + common.FormatLabels(r.GetAllLabels(), cf.Verbose), resourceID, } } @@ -560,10 +562,10 @@ To request access to these resources, run return nil } -// ignoreDuplicateResourceId returns true if the resource ID is a duplicate +// ignoreDuplicateResourceID returns true if the resource ID is a duplicate // and should be ignored. Otherwise, it returns false and adds the resource ID // to the deduplicateResourceIDs map. -func ignoreDuplicateResourceId(deduplicateResourceIDs map[string]struct{}, resourceID string) bool { +func ignoreDuplicateResourceID(deduplicateResourceIDs map[string]struct{}, resourceID string) bool { // Ignore duplicate resource IDs. if _, ok := deduplicateResourceIDs[resourceID]; ok { return true diff --git a/tool/tsh/common/access_request_test.go b/tool/tsh/common/access_request_test.go index 9b4c5bafff653..f43ad61ff0d3d 100644 --- a/tool/tsh/common/access_request_test.go +++ b/tool/tsh/common/access_request_test.go @@ -116,9 +116,9 @@ func TestAccessRequestSearch(t *testing.T) { table := asciitable.MakeTableWithTruncatedColumn( []string{"Name", "Namespace", "Labels", "Resource ID"}, [][]string{ - {"nginx-1", "default", "", fmt.Sprintf("/%s/pod/%s/default/nginx-1", rootClusterName, rootKubeCluster)}, - {"nginx-2", "default", "", fmt.Sprintf("/%s/pod/%s/default/nginx-2", rootClusterName, rootKubeCluster)}, - {"test", "default", "", fmt.Sprintf("/%s/pod/%s/default/test", rootClusterName, rootKubeCluster)}, + {"nginx-1", "default", "", fmt.Sprintf("/%s/kube:ns:pods/%s/default/nginx-1", rootClusterName, rootKubeCluster)}, + {"nginx-2", "default", "", fmt.Sprintf("/%s/kube:ns:pods/%s/default/nginx-2", rootClusterName, rootKubeCluster)}, + {"test", "default", "", fmt.Sprintf("/%s/kube:ns:pods/%s/default/test", rootClusterName, rootKubeCluster)}, }, "Labels") return table.AsBuffer().String() @@ -135,7 +135,7 @@ func TestAccessRequestSearch(t *testing.T) { table := asciitable.MakeTableWithTruncatedColumn( []string{"Name", "Namespace", "Labels", "Resource ID"}, [][]string{ - {"nginx-1", "dev", "", fmt.Sprintf("/%s/pod/%s/dev/nginx-1", rootClusterName, rootKubeCluster)}, + {"nginx-1", "dev", "", fmt.Sprintf("/%s/kube:ns:pods/%s/dev/nginx-1", rootClusterName, rootKubeCluster)}, }, "Labels") return table.AsBuffer().String() @@ -152,9 +152,9 @@ func TestAccessRequestSearch(t *testing.T) { table := asciitable.MakeTableWithTruncatedColumn( []string{"Name", "Namespace", "Labels", "Resource ID"}, [][]string{ - {"nginx-1", "default", "", fmt.Sprintf("/%s/pod/%s/default/nginx-1", leafClusterName, leafKubeCluster)}, - {"nginx-2", "default", "", fmt.Sprintf("/%s/pod/%s/default/nginx-2", leafClusterName, leafKubeCluster)}, - {"test", "default", "", fmt.Sprintf("/%s/pod/%s/default/test", leafClusterName, leafKubeCluster)}, + {"nginx-1", "default", "", fmt.Sprintf("/%s/kube:ns:pods/%s/default/nginx-1", leafClusterName, leafKubeCluster)}, + {"nginx-2", "default", "", fmt.Sprintf("/%s/kube:ns:pods/%s/default/nginx-2", leafClusterName, leafKubeCluster)}, + {"test", "default", "", fmt.Sprintf("/%s/kube:ns:pods/%s/default/test", leafClusterName, leafKubeCluster)}, }, "Labels") return table.AsBuffer().String() @@ -171,8 +171,8 @@ func TestAccessRequestSearch(t *testing.T) { table := asciitable.MakeTableWithTruncatedColumn( []string{"Name", "Namespace", "Labels", "Resource ID"}, [][]string{ - {"nginx-1", "default", "", fmt.Sprintf("/%s/pod/%s/default/nginx-1", leafClusterName, leafKubeCluster)}, - {"nginx-1", "dev", "", fmt.Sprintf("/%s/pod/%s/dev/nginx-1", leafClusterName, leafKubeCluster)}, + {"nginx-1", "default", "", fmt.Sprintf("/%s/kube:ns:pods/%s/default/nginx-1", leafClusterName, leafKubeCluster)}, + {"nginx-1", "dev", "", fmt.Sprintf("/%s/kube:ns:pods/%s/dev/nginx-1", leafClusterName, leafKubeCluster)}, }, "Labels") return table.AsBuffer().String() @@ -200,10 +200,12 @@ func TestAccessRequestSearch(t *testing.T) { tc := tc t.Run(tc.name, func(t *testing.T) { t.Parallel() + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() homePath, _ := mustLoginLegacy(t, s, tc.args.teleportCluster) captureStdout := new(bytes.Buffer) err := Run( - context.Background(), + ctx, append([]string{ "--insecure", "request", diff --git a/tool/tsh/common/tsh.go b/tool/tsh/common/tsh.go index 56d680920562c..7be52596faeab 100644 --- a/tool/tsh/common/tsh.go +++ b/tool/tsh/common/tsh.go @@ -528,9 +528,15 @@ type CLIConf struct { // kubeNamespace allows to configure the default Kubernetes namespace. kubeNamespace string - // kubeAllNamespaces allows users to search for pods in every namespace. + // kubeAllNamespaces allows users to search for resources in every namespace. kubeAllNamespaces bool + // kubeResourceKind allows to search for resources. + kubeResourceKind string + + // kubeAPIGroup allows to search for CRD and unknown resources. + kubeAPIGroup string + // KubeConfigPath is the location of the Kubeconfig for the current test. // Setting this value allows Teleport tests to run `tsh login` commands in // parallel. @@ -1295,10 +1301,46 @@ func Run(ctx context.Context, args []string, opts ...CliOption) error { reqReview.Flag("assume-start-time", "Sets time roles can be assumed by requestor (RFC3339 e.g 2023-12-12T23:20:50.52Z)").StringVar(&cf.AssumeStartTimeRaw) reqSearch := req.Command("search", "Search for resources to request access to.") - reqSearch.Flag("kind", - fmt.Sprintf("Resource kind to search for (%s)", - strings.Join(types.RequestableResourceKinds, ", ")), - ).Required().EnumVar(&cf.ResourceKind, types.RequestableResourceKinds...) + reqSearch.Flag("kind", fmt.Sprintf("Resource kind to search for (%s).", strings.Join(types.RequestableResourceKinds, ", "))).Required().StringVar(&cf.ResourceKind) + reqSearch.Flag("kube-kind", fmt.Sprintf("Kubernetes resource kind name (plural) to search for. Required with --kind=%q Ex: pods, deployements, namespaces, etc.", types.KindKubernetesResource)).StringVar(&cf.kubeResourceKind) + reqSearch.Flag("kube-api-group", "Kubernetes API group to search for resources.").StringVar(&cf.kubeAPIGroup) + reqSearch.PreAction(func(*kingpin.ParseContext) error { + // TODO(@creack): DELETE IN v20.0.0. Allow legacy kinds with a warning for now. + if slices.Contains(types.LegacyRequestableKubeResourceKinds, cf.ResourceKind) { + cf.kubeAPIGroup = types.KubernetesResourcesV7KindGroups[cf.ResourceKind] + if cf.ResourceKind == types.KindKubeNamespace { + cf.kubeResourceKind = "namespaces" + } else { + cf.kubeResourceKind = types.KubernetesResourcesKindsPlurals[cf.ResourceKind] + } + originalKubeKind := cf.ResourceKind + cf.ResourceKind = types.KindKubernetesResource + + nsFlag := fmt.Sprintf("--kube-namespace=%q", cf.kubeNamespace) + if cf.kubeAllNamespaces { + nsFlag = "--all-kube-namespaces" + } + fmt.Fprintf(os.Stderr, "Warning: %q is deprecated, use:\n", originalKubeKind) + fmt.Fprintf(os.Stderr, ">tsh request search --kind=%q --kube-kind=%q --kube-api-group=%q %s\n\n", types.KindKubernetesResource, cf.kubeResourceKind, cf.kubeAPIGroup, nsFlag) + } + switch cf.ResourceKind { + case types.KindKubernetesResource: + if cf.kubeResourceKind == "" { + return trace.BadParameter("--kube-kind is required when using --kind=%q", types.KindKubernetesResource) + } + if _, ok := types.KubernetesCoreResourceKinds[cf.kubeResourceKind]; !ok && cf.kubeAPIGroup == "" && cf.kubeResourceKind != types.KindKubeNamespace { + return trace.BadParameter("--kube-api-group is required for resource kind %q", cf.kubeResourceKind) + } + case "": + return trace.BadParameter("required flag --kind not provided") + default: + if !slices.Contains(types.RequestableResourceKinds, cf.ResourceKind) { + return trace.BadParameter("--kind must be one of %s, got %q", strings.Join(types.RequestableResourceKinds, ", "), cf.ResourceKind) + } + } + return nil + }) + reqSearch.Flag("search", searchHelp).StringVar(&cf.SearchKeywords) reqSearch.Flag("query", queryHelp).StringVar(&cf.PredicateExpression) reqSearch.Flag("labels", labelHelp).StringVar(&cf.Labels)