diff --git a/api/types/app_test.go b/api/types/app_test.go index b1b0d9e1a0b25..0b6328255ff23 100644 --- a/api/types/app_test.go +++ b/api/types/app_test.go @@ -95,20 +95,20 @@ func TestAppPortsValidation(t *testing.T) { { name: "valid ranges and single ports", tcpPorts: []*PortRange{ - &PortRange{Port: 22, EndPort: 25}, - &PortRange{Port: 26}, - &PortRange{Port: 65535}, + {Port: 22, EndPort: 25}, + {Port: 26}, + {Port: 65535}, }, check: hasNoErr, }, { name: "valid overlapping ranges", tcpPorts: []*PortRange{ - &PortRange{Port: 100, EndPort: 200}, - &PortRange{Port: 150, EndPort: 175}, - &PortRange{Port: 111}, - &PortRange{Port: 150, EndPort: 210}, - &PortRange{Port: 1, EndPort: 65535}, + {Port: 100, EndPort: 200}, + {Port: 150, EndPort: 175}, + {Port: 111}, + {Port: 150, EndPort: 210}, + {Port: 1, EndPort: 65535}, }, check: hasNoErr, }, @@ -116,8 +116,8 @@ func TestAppPortsValidation(t *testing.T) { name: "valid non-TCP app with ports ignored", uri: "http://localhost:8000", tcpPorts: []*PortRange{ - &PortRange{Port: 123456789}, - &PortRange{Port: 10, EndPort: 2}, + {Port: 123456789}, + {Port: 10, EndPort: 2}, }, check: hasNoErr, }, @@ -125,35 +125,35 @@ func TestAppPortsValidation(t *testing.T) { { name: "port smaller than 1", tcpPorts: []*PortRange{ - &PortRange{Port: 0}, + {Port: 0}, }, check: hasErrTypeBadParameter, }, { name: "port bigger than 65535", tcpPorts: []*PortRange{ - &PortRange{Port: 78787}, + {Port: 78787}, }, check: hasErrTypeBadParameter, }, { name: "end port smaller than 2", tcpPorts: []*PortRange{ - &PortRange{Port: 5, EndPort: 1}, + {Port: 5, EndPort: 1}, }, check: hasErrTypeBadParameterAndContains("end port must be between 6 and 65535"), }, { name: "end port bigger than 65535", tcpPorts: []*PortRange{ - &PortRange{Port: 1, EndPort: 78787}, + {Port: 1, EndPort: 78787}, }, check: hasErrTypeBadParameter, }, { name: "end port smaller than port", tcpPorts: []*PortRange{ - &PortRange{Port: 10, EndPort: 5}, + {Port: 10, EndPort: 5}, }, check: hasErrTypeBadParameterAndContains("end port must be between 11 and 65535"), }, @@ -161,7 +161,7 @@ func TestAppPortsValidation(t *testing.T) { name: "uri specifies port", uri: "tcp://localhost:1234", tcpPorts: []*PortRange{ - &PortRange{Port: 1000, EndPort: 1500}, + {Port: 1000, EndPort: 1500}, }, check: hasErrTypeBadParameterAndContains("must not include a port number"), }, @@ -169,7 +169,7 @@ func TestAppPortsValidation(t *testing.T) { name: "invalid uri", uri: "%", tcpPorts: []*PortRange{ - &PortRange{Port: 1000, EndPort: 1500}, + {Port: 1000, EndPort: 1500}, }, check: hasErrAndContains("invalid URL escape"), }, @@ -566,8 +566,8 @@ func TestNewAppV3(t *testing.T) { func TestPortRangesContains(t *testing.T) { portRanges := PortRanges([]*PortRange{ - &PortRange{Port: 10, EndPort: 20}, - &PortRange{Port: 42}, + {Port: 10, EndPort: 20}, + {Port: 42}, }) tests := []struct { diff --git a/api/types/role.go b/api/types/role.go index 28d7e1dda45c3..49bde0055885c 100644 --- a/api/types/role.go +++ b/api/types/role.go @@ -1866,9 +1866,10 @@ func setDefaultKubernetesVerbs(spec *RoleSpecV6) { // - Namespace is not empty func validateKubeResources(roleVersion string, kubeResources []KubernetesResource) error { for _, kubeResource := range kubeResources { - if !slices.Contains(KubernetesResourcesKinds, kubeResource.Kind) && kubeResource.Kind != Wildcard { - return trace.BadParameter("KubernetesResource kind %q is invalid or unsupported; Supported: %v", kubeResource.Kind, append([]string{Wildcard}, KubernetesResourcesKinds...)) - } + // TODO(@creack): Move the validation to the server side so we can lookup the list of valid CRDs. + // if !slices.Contains(KubernetesResourcesKinds, kubeResource.Kind) && kubeResource.Kind != Wildcard { + // return trace.BadParameter("KubernetesResource kind %q is invalid or unsupported; Supported: %v", kubeResource.Kind, append([]string{Wildcard}, KubernetesResourcesKinds...)) + // } for _, verb := range kubeResource.Verbs { if !slices.Contains(KubernetesVerbs, verb) && verb != Wildcard && !strings.Contains(verb, "{{") { diff --git a/api/types/role_test.go b/api/types/role_test.go index 080c138d126cc..37dde4925ca94 100644 --- a/api/types/role_test.go +++ b/api/types/role_test.go @@ -178,7 +178,16 @@ func TestRole_GetKubeResources(t *testing.T) { }, }, }, - assertErrorCreation: require.Error, + want: []KubernetesResource{ + { + Kind: "invalid resource", + Namespace: "test", + Name: "test", + }, + }, + // TODO(@creack): Find a way to validate the resource kind. For now as we support + // arbitrary CRDs, we can't validate it. + assertErrorCreation: require.NoError, }, { name: "v7", @@ -692,7 +701,7 @@ func TestRoleV6_CheckAndSetDefaults(t *testing.T) { require.ErrorContains(t, err, contains) } } - newRole := func(t *testing.T, spec RoleSpecV6) *RoleV6 { + newRole := func(_ *testing.T, spec RoleSpecV6) *RoleV6 { return &RoleV6{ Metadata: Metadata{ Name: "test", diff --git a/lib/kube/proxy/auth_test.go b/lib/kube/proxy/auth_test.go index 272f4c36db668..fbe889009b967 100644 --- a/lib/kube/proxy/auth_test.go +++ b/lib/kube/proxy/auth_test.go @@ -131,7 +131,7 @@ func failsForCluster(clusterName string) servicecfg.ImpersonationPermissionsChec func TestGetKubeCreds(t *testing.T) { t.Parallel() // kubeMock is a Kubernetes API mock for the session tests. - kubeMock, err := testingkubemock.NewKubeAPIMock() + kubeMock, err := testingkubemock.NewKubeAPIMock(testingkubemock.WithTeleportRoleCRD) require.NoError(t, err) t.Cleanup(func() { kubeMock.Close() }) targetAddr := kubeMock.Address @@ -338,7 +338,6 @@ current-context: foo }, } for _, tt := range tests { - tt := tt t.Run(tt.desc, func(t *testing.T) { t.Parallel() fwd := &Forwarder{ diff --git a/lib/kube/proxy/exec_test.go b/lib/kube/proxy/exec_test.go index 3aca4f095b157..8ef44b3be5508 100644 --- a/lib/kube/proxy/exec_test.go +++ b/lib/kube/proxy/exec_test.go @@ -482,6 +482,7 @@ func TestExecWebsocketEndToEndErrReturn(t *testing.T) { Minor: "31", GitVersion: "v1.31.0", }), + testingkubemock.WithTeleportRoleCRD, ) require.NoError(t, err) t.Cleanup(func() { diff --git a/lib/kube/proxy/forwarder.go b/lib/kube/proxy/forwarder.go index ae334c6f0db70..ceb001980313e 100644 --- a/lib/kube/proxy/forwarder.go +++ b/lib/kube/proxy/forwarder.go @@ -30,6 +30,7 @@ import ( "net" "net/http" "net/url" + "path" "slices" "strconv" "strings" @@ -810,6 +811,9 @@ func (f *Forwarder) setupContext( return nil, trace.Wrap(err) } } + if kubeResource != nil && kubeResource.Kind == utils.KubeCustomResource { + kubeResource.Kind = path.Join(apiResource.apiGroup, apiResource.apiGroupVersion, apiResource.resourceKind) + } netConfig, err := f.cfg.CachingAuthClient.GetClusterNetworkingConfig(f.ctx) if err != nil { diff --git a/lib/kube/proxy/resource_filters.go b/lib/kube/proxy/resource_filters.go index d52322d751651..4133681de0318 100644 --- a/lib/kube/proxy/resource_filters.go +++ b/lib/kube/proxy/resource_filters.go @@ -25,6 +25,7 @@ import ( "log/slog" "mime" "net/http" + "path" "github.com/gravitational/trace" appsv1 "k8s.io/api/apps/v1" @@ -617,7 +618,7 @@ func (d *resourceFilterer) encode(obj runtime.Object, w io.Writer) error { } // filterResourceList excludes resources the user should not have access to. -func filterResourceList[T kubeObjectInterface](kind, verb string, originalList []T, allowed, denied []types.KubernetesResource, log *slog.Logger) []T { +func filterResourceList[T kubeObjectInterface](kind, verb string, originalList []T, allowed, denied []types.KubernetesResource, _ *slog.Logger) []T { filteredList := make([]T, 0, len(originalList)) for _, resource := range originalList { if result, err := filterResource(kind, verb, resource, allowed, denied); err == nil && result { @@ -784,7 +785,7 @@ func filterBuffer(filterWrapper responsewriters.FilterWrapper, src *responsewrit // filterUnstructuredList filters the unstructured list object to exclude resources // that the user must not have access to. // The filtered list is re-assigned to `obj.Object["items"]`. -func filterUnstructuredList(verb string, obj *unstructured.Unstructured, allowed, denied []types.KubernetesResource, log *slog.Logger) (hasElems bool) { +func filterUnstructuredList(verb string, obj *unstructured.Unstructured, allowed, denied []types.KubernetesResource, _ *slog.Logger) (hasElems bool) { const ( itemsKey = "items" ) @@ -800,7 +801,8 @@ func filterUnstructuredList(verb string, obj *unstructured.Unstructured, allowed filteredList := make([]any, 0, len(objList.Items)) for _, resource := range objList.Items { - r := getKubeResource(utils.KubeCustomResource, verb, &resource) + kind := path.Join(resource.GetAPIVersion(), resource.GetKind()) + r := getKubeResource(kind, verb, &resource) if result, err := matchKubernetesResource( r, allowed, denied, diff --git a/lib/kube/proxy/resource_list.go b/lib/kube/proxy/resource_list.go index 02d761cbb29f9..1be40c12da82b 100644 --- a/lib/kube/proxy/resource_list.go +++ b/lib/kube/proxy/resource_list.go @@ -22,6 +22,7 @@ import ( "bytes" "io" "net/http" + "path" "strings" "sync" "time" @@ -58,6 +59,9 @@ func (f *Forwarder) listResources(sess *clusterSession, w http.ResponseWriter, r resourceKind := "" if isLocalKubeCluster { resourceKind, supportsType = sess.rbacSupportedResources.getTeleportResourceKindFromAPIResource(sess.apiResource) + if resourceKind == utils.KubeCustomResource { + resourceKind = path.Join(sess.apiResource.apiGroup, sess.apiResource.apiGroupVersion, sess.apiResource.resourceKind) + } } // status holds the returned response code. @@ -71,6 +75,49 @@ func (f *Forwarder) listResources(sess *clusterSession, w http.ResponseWriter, r status = rw.Status() } else { allowedResources, deniedResources := sess.Checker.GetKubeResources(sess.kubeCluster) + + details, err := f.findKubeDetailsByClusterName(sess.kubeClusterName) + if err != nil { + return nil, trace.Wrap(err, "failed to find kube details by cluster name %q", sess.kubeClusterName) + } + makeKind := func(resources []types.KubernetesResource) []types.KubernetesResource { + var out []types.KubernetesResource // Not pre-allocating as we don't know the final size. Likely 0 if no CRD resource defined. + loop: + for _, r := range resources { + tmp := strings.Split(r.Kind, "/") + if len(tmp) < 3 { // Expect //. If not, skip. + continue + } + group, version, kind := tmp[0], tmp[1], strings.Join(tmp[2:], "/") + + // Look for the resource in the supported list from it's fqdn. + if r2 := details.gvkSupportedResources[gvkSupportedResourcesKey{ + name: kind, apiGroup: group, version: version, + }]; r2 != nil { + // If found, append the resource with it's Kind. + r3 := r + r3.Kind = path.Join(group, version, r2.Kind) + out = append(out, r3) + continue loop + } + // If not found, look up the resource from it's Kind. + for k, v := range details.gvkSupportedResources { + if k.apiGroup == group && k.version == version && v.Kind == kind && !strings.Contains(k.name, "/") { + // If found, append the resource with it's plural. + r3 := r + r3.Kind = path.Join(group, version, k.name) + out = append(out, r3) + continue loop + } + } + // If we reach this point, there is no match for the resource. Probably a typo in the resource Kind. + // Not erroring out as a typo would break access for everyone. + } + return out + } + allowedResources = append(allowedResources, makeKind(allowedResources)...) + deniedResources = append(deniedResources, makeKind(deniedResources)...) + shouldBeAllowed, err := matchListRequestShouldBeAllowed(sess, resourceKind, allowedResources, deniedResources) if err != nil { return nil, trace.Wrap(err) @@ -119,6 +166,9 @@ func (f *Forwarder) listResourcesList(req *http.Request, w http.ResponseWriter, if !ok { return http.StatusBadRequest, trace.BadParameter("unknown resource kind %q", sess.apiResource.resourceKind) } + if resourceKind == utils.KubeCustomResource { + resourceKind = path.Join(sess.apiResource.apiGroup, sess.apiResource.apiGroupVersion, sess.apiResource.resourceKind) + } verb := sess.requestVerb // filterBuffer filters the response to exclude resources the user doesn't have access to. // The filtered payload will be written into memBuffer again. diff --git a/lib/kube/proxy/resource_rbac_test.go b/lib/kube/proxy/resource_rbac_test.go index faebea646681c..54604683125b7 100644 --- a/lib/kube/proxy/resource_rbac_test.go +++ b/lib/kube/proxy/resource_rbac_test.go @@ -40,7 +40,6 @@ import ( metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" "k8s.io/apimachinery/pkg/runtime" - "k8s.io/apimachinery/pkg/runtime/schema" "k8s.io/apimachinery/pkg/runtime/serializer/streaming" kubetypes "k8s.io/apimachinery/pkg/types" "k8s.io/apimachinery/pkg/watch" @@ -49,7 +48,7 @@ import ( "github.com/gravitational/teleport/api/types" "github.com/gravitational/teleport/lib/kube/proxy/responsewriters" - testingkubemock "github.com/gravitational/teleport/lib/kube/proxy/testing/kube_server" + tkm "github.com/gravitational/teleport/lib/kube/proxy/testing/kube_server" "github.com/gravitational/teleport/lib/utils" ) @@ -67,7 +66,7 @@ func TestListPodRBAC(t *testing.T) { // Once a new session is created, this mock will write to // stdout and stdin (if available) the pod name, followed // by copying the contents of stdin into both streams. - kubeMock, err := testingkubemock.NewKubeAPIMock() + kubeMock, err := tkm.NewKubeAPIMock() require.NoError(t, err) t.Cleanup(func() { kubeMock.Close() }) @@ -834,7 +833,7 @@ func TestDeletePodCollectionRBAC(t *testing.T) { // Once a new session is created, this mock will write to // stdout and stdin (if available) the pod name, followed // by copying the contents of stdin into both streams. - kubeMock, err := testingkubemock.NewKubeAPIMock() + kubeMock, err := tkm.NewKubeAPIMock() require.NoError(t, err) t.Cleanup(func() { kubeMock.Close() }) @@ -1023,7 +1022,7 @@ func TestListClusterRoleRBAC(t *testing.T) { // Once a new session is created, this mock will write to // stdout and stdin (if available) the pod name, followed // by copying the contents of stdin into both streams. - kubeMock, err := testingkubemock.NewKubeAPIMock() + kubeMock, err := tkm.NewKubeAPIMock() require.NoError(t, err) t.Cleanup(func() { kubeMock.Close() }) @@ -1061,8 +1060,8 @@ func TestListClusterRoleRBAC(t *testing.T) { }, ) - // create a moderator user with access to kubernetes - // (kubernetes_user and kubernetes_groups specified) + // Create a moderator user with access to kubernetes + // (kubernetes_user and kubernetes_groups specified). userWithLimitedAccess, _ := testCtx.CreateUserAndRole( testCtx.Context, t, @@ -1179,10 +1178,9 @@ func TestListClusterRoleRBAC(t *testing.T) { } for _, tt := range tests { - tt := tt t.Run(tt.name, func(t *testing.T) { t.Parallel() - // generate a kube client with user certs for auth + // Generate a kube client with user certs for auth. client, _ := testCtx.GenTestKubeClientTLSCert( t, tt.args.user.GetName(), @@ -1218,49 +1216,16 @@ func TestListClusterRoleRBAC(t *testing.T) { } } -func TestCustomResourcesRBAC(t *testing.T) { +func TestGenericCustomResourcesRBAC(t *testing.T) { const ( - usernameWithFullAccess = "full_user" - usernameWithLimitedAccess = "limited_user" - testTeleportRoleName = "telerole-test" - testTeleportRoleNamespace = "default" + usernameWithFullAccess = "full_user" + usernameWithLimitedAccess = "limited_user" + usernameWithSpecificAccess = "specific_user" + testTeleportRoleName = "telerole-test" + testTeleportRoleNamespace = "default" ) - getTeleroleUnstructured := func(kind string) *unstructured.Unstructured { - u := &unstructured.Unstructured{} - u.SetGroupVersionKind(schema.GroupVersionKind{ - Group: "resources.teleport.dev", - Version: "v6", - Kind: kind, - }) - return u - } - - // register the custom resources with the scheme - kubeScheme := runtime.NewScheme() - require.NoError(t, registerDefaultKubeTypes(kubeScheme)) - for _, kind := range []string{"TeleportRole", "TeleportRoleList"} { - kubeScheme.AddKnownTypeWithName(getTeleroleUnstructured(kind).GroupVersionKind(), getTeleroleUnstructured(kind)) - } - - // kubeMock is a Kubernetes API mock for the session tests. - // Once a new session is created, this mock will write to - // stdout and stdin (if available) the pod name, followed - // by copying the contents of stdin into both streams. - kubeMock, err := testingkubemock.NewKubeAPIMock() - require.NoError(t, err) - t.Cleanup(func() { kubeMock.Close() }) - - // creates a Kubernetes service with a configured cluster pointing to mock api server - testCtx := SetupTestContext( - context.Background(), - t, - TestConfig{ - Clusters: []KubeClusterConfig{{Name: kubeCluster, APIEndpoint: kubeMock.URL}}, - }, - ) - // close tests - t.Cleanup(func() { assert.NoError(t, testCtx.Close()) }) + kubeScheme, testCtx := newTestKubeCRDMock(t, tkm.WithTeleportRoleCRD) // create a user with full access to all namespaces. // (kubernetes_user and kubernetes_groups specified) @@ -1308,6 +1273,30 @@ func TestCustomResourcesRBAC(t *testing.T) { }, ) + // create a user with limited access to kubernetes namespaces. + userWithSpecificAccess, _ := testCtx.CreateUserAndRole( + testCtx.Context, + t, + usernameWithSpecificAccess, + RoleSpec{ + Name: usernameWithSpecificAccess, + KubeUsers: roleKubeUsers, + KubeGroups: roleKubeGroups, + SetupRoleFunc: func(r types.Role) { + r.SetKubeResources(types.Allow, + []types.KubernetesResource{ + { + Kind: "resources.teleport.dev/v6/teleportroles", + Name: types.Wildcard, + Namespace: "dev", + Verbs: []string{types.Wildcard}, + }, + }, + ) + }, + }, + ) + type args struct { user types.User opts []GenTestKubeClientTLSCertOptions @@ -1338,6 +1327,29 @@ func TestCustomResourcesRBAC(t *testing.T) { }, }, }, + { + name: "list teleport roles for user with specific crd access", + args: args{ + user: userWithSpecificAccess, + }, + want: want{ + listTeleportRolesResult: []string{ + "dev/telerole-1", + "dev/telerole-2", + }, + getTestResult: &kubeerrors.StatusError{ + ErrStatus: metav1.Status{ + Status: "Failure", + Message: fmt.Sprintf( + "teleportroles \"telerole-test\" is forbidden: User %q cannot get resource \"teleportroles\" in API group \"resources.teleport.dev\"", + usernameWithSpecificAccess, + ), + Code: 403, + Reason: metav1.StatusReasonForbidden, + }, + }, + }, + }, { name: "list teleport roles for user with limited access", args: args{ @@ -1402,7 +1414,10 @@ func TestCustomResourcesRBAC(t *testing.T) { }, }, want: want{ - listTeleportRolesResult: []string{"dev/telerole-1", "dev/telerole-2"}, + listTeleportRolesResult: []string{ + "dev/telerole-1", + "dev/telerole-2", + }, getTestResult: &kubeerrors.StatusError{ ErrStatus: metav1.Status{ Status: "Failure", @@ -1416,10 +1431,9 @@ func TestCustomResourcesRBAC(t *testing.T) { } for _, tt := range tests { - tt := tt t.Run(tt.name, func(t *testing.T) { t.Parallel() - // generate a kube client with user certs for auth + // Generate a kube client with user certs for auth. _, rest := testCtx.GenTestKubeClientTLSCert( t, tt.args.user.GetName(), @@ -1430,21 +1444,25 @@ func TestCustomResourcesRBAC(t *testing.T) { client, err := controllerclient.New(rest, controllerclient.Options{ Scheme: kubeScheme, }) - require.NoError(t, err) - list := getTeleroleUnstructured("TeleportRole") - err = client.List(context.Background(), list) - var teleportRolesList []string - if tt.want.wantListErr { - require.Error(t, err) - return - } else { - require.NoError(t, err) + t.Run("list", func(t *testing.T) { + t.Parallel() + + list := tkm.NewTeleportRoleCRD() + + if err := client.List(context.Background(), list); tt.want.wantListErr { + require.Error(t, err) + return + } else { + require.NoError(t, err) + } + require.True(t, list.IsList()) - // iterate over the list of teleport roles and get the namespace and name - // of each role in the format / + var teleportRolesList []string + // Iterate over the list of teleport roles and get the namespace and name + // of each role in the format /. require.NoError( t, list.EachListItem( @@ -1454,27 +1472,366 @@ func TestCustomResourcesRBAC(t *testing.T) { return nil }, )) - } + require.ElementsMatch(t, tt.want.listTeleportRolesResult, teleportRolesList) + }) + + t.Run("get", func(t *testing.T) { + t.Parallel() + + get := tkm.NewTeleportRoleCRD() + + if err := client.Get(context.Background(), + kubetypes.NamespacedName{ + Name: testTeleportRoleName, + Namespace: testTeleportRoleNamespace, + }, + get, + ); tt.want.getTestResult == nil { + require.NoError(t, err) + require.Equal(t, testTeleportRoleName, get.GetName()) + require.Equal(t, testTeleportRoleNamespace, get.GetNamespace()) + } else { + require.Error(t, err) + require.ErrorContains(t, err, tt.want.getTestResult.Error()) + } + }) + }) + } +} + +func newTestKubeCRDMock(t *testing.T, opts ...tkm.Option) (*runtime.Scheme, *TestContext) { + t.Helper() + + // kubeMock is a Kubernetes API mock for the session tests. + // Once a new session is created, this mock will write to + // stdout and stdin (if available) the pod name, followed + // by copying the contents of stdin into both streams. + kubeMock, err := tkm.NewKubeAPIMock(opts...) + require.NoError(t, err) + t.Cleanup(func() { kubeMock.Close() }) + + // Register the custom resources with the scheme. + kubeScheme := kubeMock.CRDScheme() + require.NoError(t, registerDefaultKubeTypes(kubeScheme)) + + // Creates a Kubernetes service with a configured cluster pointing to mock api server. + testCtx := SetupTestContext( + context.Background(), + t, + TestConfig{ + Clusters: []KubeClusterConfig{{Name: kubeCluster, APIEndpoint: kubeMock.URL}}, + }, + ) + // Close tests. + t.Cleanup(func() { assert.NoError(t, testCtx.Close()) }) - require.ElementsMatch(t, tt.want.listTeleportRolesResult, teleportRolesList) + return kubeScheme, testCtx +} - get := getTeleroleUnstructured("TeleportRole") +// Test cases: +// Given +// - 1 CRD (namespaced) +// - 3 NS: dev, staging, production +// - Resources +// - name: test-dev, ns: dev +// - name: debug, ns: dev +// - name: debug, ns: staging +// - name: web, ns: dev +// - name: web-dev, ns: dev +// - name: web-dev, ns: production +// - name: +// name: *-dev, ns: * +// name: *-dev, ns: dev +// name: debug, ns: * +// name: *, ns dev +// name: *, ns dev, { pod name *, pod ns * } +// name: *, ns dev, staging +func TestSpecificCustomResourcesRBAC(t *testing.T) { + telerolev7 := tkm.NewCRD("resources.teleport.dev", "v7", "teleportroles", "TeleportRole", "TeleportRoleList", true) + teleswagv1 := tkm.NewCRD("swag.teleport.dev", "v1", "teleswags", "TeleportSwag", "TeleportSwagList", true) + clusterswagv0 := tkm.NewCRD("resources.teleport.dev", "v0", "clusterswags", "ClusterSwag", "ClusterSwagList", false) + + kubeScheme, testCtx := newTestKubeCRDMock(t, + tkm.WithTeleportRoleCRD, + tkm.WithCRD(telerolev7, + tkm.NewObject("default", "telerole-1"), + tkm.NewObject("default", "telerole-2"), + tkm.NewObject("default", "telerole-test"), + tkm.NewObject("dev", "telerole-1"), + tkm.NewObject("dev", "telerole-2"), + ), + tkm.WithCRD(teleswagv1, + tkm.NewObject("default", "teleswag-1"), + ), + tkm.WithCRD(clusterswagv0, + tkm.NewObject("", "clusterswag-1"), + tkm.NewObject("", "clusterswag-2"), + tkm.NewObject("", "my-clusterswag"), + ), + ) - err = client.Get(context.Background(), - kubetypes.NamespacedName{ - Name: testTeleportRoleName, - Namespace: testTeleportRoleNamespace, + newUser := func(name string, resources []types.KubernetesResource) types.User { + u, _ := testCtx.CreateUserAndRole( + testCtx.Context, + t, + name, + RoleSpec{ + Name: name, + KubeUsers: roleKubeUsers, + KubeGroups: roleKubeGroups, + SetupRoleFunc: func(r types.Role) { r.SetKubeResources(types.Allow, resources) }, + }, + ) + return u + } + + type args struct { + user types.User + crds []*tkm.CRD + } + type want struct { + listTeleportRolesResult [][]string // One list per CRDs in args. + wantListErr []bool // One per CRDs in args. + } + tests := []struct { + name string + args args + want want + }{ + { + name: "list crds on multiple versions", + args: args{ + user: newUser("dev_access_two_versions", []types.KubernetesResource{ + { + Kind: tkm.NewTeleportRoleCRD().RoleKind(), + Name: types.Wildcard, + Namespace: "dev", + Verbs: []string{types.Wildcard}, + }, + { + Kind: telerolev7.RoleKind(), + Name: types.Wildcard, + Namespace: "dev", + Verbs: []string{types.Wildcard}, + }, + }), + crds: []*tkm.CRD{tkm.NewTeleportRoleCRD(), telerolev7.Copy()}, + }, + want: want{ + listTeleportRolesResult: [][]string{ + { + "dev/telerole-1", + "dev/telerole-2", + }, { + "dev/telerole-1", + "dev/telerole-2", + }, }, - get, - ) + wantListErr: []bool{false, false}, + }, + }, + { + name: "access to multiple crds listing one without access", + args: args{ + user: newUser("no_swag_access", []types.KubernetesResource{ + { + Kind: tkm.NewTeleportRoleCRD().RoleKind(), + Name: types.Wildcard, + Namespace: "dev", + Verbs: []string{types.Wildcard}, + }, + { + Kind: telerolev7.RoleKind(), + Name: types.Wildcard, + Namespace: "dev", + Verbs: []string{types.Wildcard}, + }, + }), + crds: []*tkm.CRD{tkm.NewTeleportRoleCRD(), telerolev7.Copy(), teleswagv1.Copy()}, + }, + want: want{ + listTeleportRolesResult: [][]string{ + { + "dev/telerole-1", + "dev/telerole-2", + }, + { + "dev/telerole-1", + "dev/telerole-2", + }, + nil, + }, + wantListErr: []bool{false, false, true}, + }, + }, + { + name: "different valid kind format", + args: args{ + user: newUser("diff_fmt_ok", []types.KubernetesResource{ + { + Kind: "resources.teleport.dev/v7/TeleportRole", + Name: types.Wildcard, + Namespace: "dev", + Verbs: []string{types.Wildcard}, + }, + }), + crds: []*tkm.CRD{telerolev7}, + }, + want: want{ + listTeleportRolesResult: [][]string{ + { + "dev/telerole-1", + "dev/telerole-2", + }, + }, + wantListErr: []bool{false}, + }, + }, + { + name: "different invalid kind format", + args: args{ + user: newUser("diff_fmt_ko", []types.KubernetesResource{ + { + Kind: "TeleportRole", + Name: types.Wildcard, + Namespace: types.Wildcard, + Verbs: []string{types.Wildcard}, + }, + { + Kind: "teleportrole", + Name: types.Wildcard, + Namespace: types.Wildcard, + Verbs: []string{types.Wildcard}, + }, + { + Kind: "*/teleportrole", + Name: types.Wildcard, + Namespace: types.Wildcard, + Verbs: []string{types.Wildcard}, + }, + { + Kind: "resources.teleport.dev/v7/teleportrole", + Name: types.Wildcard, + Namespace: types.Wildcard, + Verbs: []string{types.Wildcard}, + }, + { + Kind: "resources.teleport.dev/v7/*", + Name: types.Wildcard, + Namespace: types.Wildcard, + Verbs: []string{types.Wildcard}, + }, + { + Kind: "resources.teleport.dev/v7/", + Name: types.Wildcard, + Namespace: types.Wildcard, + Verbs: []string{types.Wildcard}, + }, + { + Kind: "resources.teleport.dev/v7", + Name: types.Wildcard, + Namespace: types.Wildcard, + Verbs: []string{types.Wildcard}, + }, + { + Kind: "resources.teleport.dev/", + Name: types.Wildcard, + Namespace: types.Wildcard, + Verbs: []string{types.Wildcard}, + }, + { + Kind: "resources.teleport.dev", + Name: types.Wildcard, + Namespace: types.Wildcard, + Verbs: []string{types.Wildcard}, + }, + }), + crds: []*tkm.CRD{telerolev7}, + }, + want: want{ + wantListErr: []bool{true}, + }, + }, + { + name: "cluster wide crd", + args: args{ + user: newUser("cluster_crd_ok", []types.KubernetesResource{ + { + Kind: clusterswagv0.RoleKind(), + Name: "clusterswag-*", + Namespace: types.Wildcard, + Verbs: []string{types.Wildcard}, + }, + }), + crds: []*tkm.CRD{clusterswagv0}, + }, + want: want{ + listTeleportRolesResult: [][]string{ + { + "clusterswag-1", + "clusterswag-2", + }, + }, + wantListErr: []bool{false}, + }, + }, + { + name: "cluster wide crd no access", + args: args{ + user: newUser("cluster_crd_ko", []types.KubernetesResource{ + { + Kind: telerolev7.RoleKind(), + Name: types.Wildcard, + Namespace: types.Wildcard, + Verbs: []string{types.Wildcard}, + }, + }), + crds: []*tkm.CRD{clusterswagv0}, + }, + want: want{ + wantListErr: []bool{true}, + }, + }, + } - if tt.want.getTestResult == nil { - require.NoError(t, err) - require.Equal(t, testTeleportRoleName, get.GetName()) - require.Equal(t, testTeleportRoleNamespace, get.GetNamespace()) - } else { - require.Error(t, err) - require.ErrorContains(t, err, tt.want.getTestResult.Error()) + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + // Generate a kube client with user certs for auth. + _, rest := testCtx.GenTestKubeClientTLSCert(t, tt.args.user.GetName(), kubeCluster) + + client, err := controllerclient.New(rest, controllerclient.Options{ + Scheme: kubeScheme, + }) + require.NoError(t, err) + + for i, list := range tt.args.crds { + list := list.Copy() + if err := client.List(context.Background(), list); tt.want.wantListErr[i] { + require.Error(t, err) + continue + } else { + require.NoError(t, err) + } + require.True(t, list.IsList()) + + // Iterate over the list of teleport roles and get the namespace and name + // of each role in the format /. + var retList []string + require.NoError( + t, + list.EachListItem( + func(itemI runtime.Object) error { + item, ok := itemI.(*unstructured.Unstructured) + if !ok { + return fmt.Errorf("invalid item type %T", itemI) + } + retList = append(retList, path.Join(item.GetNamespace(), item.GetName())) + return nil + }, + ), + ) + require.ElementsMatch(t, tt.want.listTeleportRolesResult[i], retList) } }) } diff --git a/lib/kube/proxy/scheme.go b/lib/kube/proxy/scheme.go index 9c5895962122d..79ac385d30f45 100644 --- a/lib/kube/proxy/scheme.go +++ b/lib/kube/proxy/scheme.go @@ -168,11 +168,6 @@ func newClusterSchemaBuilder(log *slog.Logger, client kubernetes.Interface) (*se } groupVersion := schema.GroupVersion{Group: group, Version: version} for _, apiResource := range apiGroup.APIResources { - // Skip cluster-scoped resources because we don't support RBAC restrictions - // for them. - if !apiResource.Namespaced { - continue - } // build the resource key to be able to look it up later and check if // if we support RBAC restrictions for it. resourceKey := allowedResourcesKey{ diff --git a/lib/kube/proxy/self_subject_reviews_test.go b/lib/kube/proxy/self_subject_reviews_test.go index b1557e50c40c2..289fec8198520 100644 --- a/lib/kube/proxy/self_subject_reviews_test.go +++ b/lib/kube/proxy/self_subject_reviews_test.go @@ -37,7 +37,7 @@ func TestSelfSubjectAccessReviewsRBAC(t *testing.T) { // Once a new session is created, this mock will write to // stdout and stdin (if available) the pod name, followed // by copying the contents of stdin into both streams. - kubeMock, err := testingkubemock.NewKubeAPIMock() + kubeMock, err := testingkubemock.NewKubeAPIMock(testingkubemock.WithTeleportRoleCRD) require.NoError(t, err) t.Cleanup(func() { kubeMock.Close() }) @@ -366,10 +366,9 @@ func TestSelfSubjectAccessReviewsRBAC(t *testing.T) { } for _, tt := range tests { - tt := tt t.Run(tt.name, func(t *testing.T) { t.Parallel() - // create a user with full access to kubernetes Pods. + // Create a user with full access to kubernetes Pods. // (kubernetes_user and kubernetes_groups specified) userID := uuid.New().String() user, _ := testCtx.CreateUserAndRole( diff --git a/lib/kube/proxy/testing/kube_server/crd.go b/lib/kube/proxy/testing/kube_server/crd.go index 03305e20ffab4..45bff7d63e7f0 100644 --- a/lib/kube/proxy/testing/kube_server/crd.go +++ b/lib/kube/proxy/testing/kube_server/crd.go @@ -20,131 +20,167 @@ package kubeserver import ( "net/http" + "path" "path/filepath" - "sort" "github.com/gravitational/trace" "github.com/julienschmidt/httprouter" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" "k8s.io/apimachinery/pkg/runtime" + + "github.com/gravitational/teleport/lib/httplib" ) -var teleportRoleList = metav1.List{ - TypeMeta: metav1.TypeMeta{ - Kind: "TeleportRoleList", - APIVersion: "resources.teleport.dev/v6", - }, - ListMeta: metav1.ListMeta{ - ResourceVersion: "1231415", - }, - Items: []runtime.RawExtension{ - { - Object: newTeleportRole("telerole-1", "default"), - }, - { - Object: newTeleportRole("telerole-1", "default"), - }, - { - Object: newTeleportRole("telerole-2", "default"), - }, - { - Object: newTeleportRole("telerole-test", "default"), - }, - { - Object: newTeleportRole("telerole-1", "dev"), - }, - { - Object: newTeleportRole("telerole-2", "dev"), - }, - }, +// GVP is a group, version, and plural tuple. +type GVP struct{ group, version, plural string } + +// CRD is a custom resource definition with it's resources. +type CRD struct { + *unstructured.Unstructured + GVP + kind string + listKind string + namespaced bool + items []runtime.RawExtension +} + +// RoleKind returns the kind string as expected in a Role object for kubernetes_resources. +func (c CRD) RoleKind() string { + return path.Join(c.group, c.version, c.plural) +} + +// Copy the CRD. +func (c CRD) Copy() *CRD { + cpy := c + cpy.Unstructured = cpy.Unstructured.DeepCopy() + return &cpy +} + +func NewTeleportRoleCRD() *CRD { + return NewCRD( + "resources.teleport.dev", + "v6", + "teleportroles", + "TeleportRole", + "TeleportRoleList", + true, + ) } -func newTeleportRole(name, namespace string) *unstructured.Unstructured { +var WithTeleportRoleCRD = WithCRD( + NewTeleportRoleCRD(), + NewObject("default", "telerole-1"), + NewObject("default", "telerole-1"), // Intentional duplicate. + NewObject("default", "telerole-2"), + NewObject("default", "telerole-test"), + NewObject("dev", "telerole-1"), + NewObject("dev", "telerole-2"), +) + +func NewObject(namespace, name string) *unstructured.Unstructured { obj := &unstructured.Unstructured{} - obj.SetKind("TeleportRole") - obj.SetAPIVersion("resources.teleport.dev/v6") - obj.SetName(name) obj.SetNamespace(namespace) + obj.SetName(name) return obj } -func (s *KubeMockServer) listTeleportRoles(w http.ResponseWriter, req *http.Request, p httprouter.Params) (any, error) { - items := []runtime.RawExtension{} - - namespace := p.ByName("namespace") - filter := func(obj runtime.Object) bool { - objNamespace := obj.(*unstructured.Unstructured).GetNamespace() - return len(namespace) == 0 || namespace == objNamespace +func NewCRD(group, version, plural, kind, listKind string, namespaced bool) *CRD { + obj := &unstructured.Unstructured{} + obj.SetKind(kind) + obj.SetAPIVersion(group + "/" + version) + return &CRD{ + Unstructured: obj, + GVP: GVP{group, version, plural}, + kind: kind, + listKind: listKind, + namespaced: namespaced, } - for _, obj := range teleportRoleList.Items { - if filter(obj.Object) { - items = append(items, obj) +} + +func (s *KubeMockServer) deleteCRD(crd *CRD) httplib.HandlerFunc { + return func(w http.ResponseWriter, req *http.Request, p httprouter.Params) (any, error) { + namespace := p.ByName("namespace") + name := p.ByName("name") + deleteOpts, err := parseDeleteCollectionBody(req) + if err != nil { + return nil, trace.Wrap(err) + } + reqID := "" + if deleteOpts.Preconditions != nil && deleteOpts.Preconditions.UID != nil { + reqID = string(*deleteOpts.Preconditions.UID) + } + filter := func(obj runtime.Object) bool { + namer, ok1 := obj.(interface{ GetName() string }) + if !ok1 || namer.GetName() != name { + return false + } + nser, ok2 := obj.(interface{ GetNamespace() string }) + return namespace == "" || (ok2 && nser.GetNamespace() == namespace) } + dr := deletedResource{kind: crd.kind, requestID: reqID} + for _, obj := range crd.items { + if filter(obj.Object) { + s.mu.Lock() + s.deletedResources[dr] = append(s.deletedResources[dr], filepath.Join(namespace, name)) + s.mu.Unlock() + return obj.Object, nil + } + } + return nil, trace.NotFound("teleportrole %q not found", filepath.Join(namespace, name)) } - return metav1.List{ - TypeMeta: metav1.TypeMeta{ - Kind: "TeleportRoleList", - APIVersion: "resources.teleport.dev/v6", - }, - ListMeta: metav1.ListMeta{ - ResourceVersion: "1231415", - }, - Items: items, - }, nil } -func (s *KubeMockServer) getTeleportRole(w http.ResponseWriter, req *http.Request, p httprouter.Params) (any, error) { - namespace := p.ByName("namespace") - name := p.ByName("name") - filter := func(obj runtime.Object) bool { - metaObj := obj.(*unstructured.Unstructured) - return metaObj.GetName() == name && namespace == metaObj.GetNamespace() - } - for _, obj := range teleportRoleList.Items { - if filter(obj.Object) { - return obj.Object, nil +func (s *KubeMockServer) getCRD(crd *CRD) httplib.HandlerFunc { + return func(w http.ResponseWriter, req *http.Request, p httprouter.Params) (any, error) { + namespace := p.ByName("namespace") + name := p.ByName("name") + filter := func(obj runtime.Object) bool { + namer, ok1 := obj.(interface{ GetName() string }) + if !ok1 || namer.GetName() != name { + return false + } + nser, ok2 := obj.(interface{ GetNamespace() string }) + return namespace == "" || (ok2 && nser.GetNamespace() == namespace) + } + for _, obj := range crd.items { + if filter(obj.Object) { + return obj.Object, nil + } } + return nil, trace.NotFound("teleport %q not found", filepath.Join(namespace, name)) } - return nil, trace.NotFound("teleport %q not found", filepath.Join(namespace, name)) } -const ( - teleportRoleKind = "TeleportRole" -) +func (s *KubeMockServer) listCRDs(crd *CRD) httplib.HandlerFunc { + return func(w http.ResponseWriter, req *http.Request, p httprouter.Params) (any, error) { + var items []runtime.RawExtension -func (s *KubeMockServer) deleteTeleportRole(w http.ResponseWriter, req *http.Request, p httprouter.Params) (any, error) { - namespace := p.ByName("namespace") - name := p.ByName("name") - deleteOpts, err := parseDeleteCollectionBody(req) - if err != nil { - return nil, trace.Wrap(err) - } - reqID := "" - if deleteOpts.Preconditions != nil && deleteOpts.Preconditions.UID != nil { - reqID = string(*deleteOpts.Preconditions.UID) - } - filter := func(obj runtime.Object) bool { - metaObj := obj.(*unstructured.Unstructured) - return metaObj.GetName() == name && namespace == metaObj.GetNamespace() - } - for _, obj := range teleportRoleList.Items { - if filter(obj.Object) { - s.mu.Lock() - s.deletedResources[deletedResource{kind: teleportRoleKind, requestID: reqID}] = append(s.deletedResources[deletedResource{kind: teleportRoleKind, requestID: reqID}], filepath.Join(namespace, name)) - s.mu.Unlock() - return obj.Object, nil + namespace := p.ByName("namespace") + filter := func(obj runtime.Object) bool { + if namespace == "" { + return true + } + nser, ok := obj.(interface{ GetNamespace() string }) + if !ok { + return false + } + return namespace == nser.GetNamespace() } + for _, obj := range crd.items { + if filter(obj.Object) { + items = append(items, obj) + } + } + return metav1.List{ + TypeMeta: metav1.TypeMeta{ + Kind: crd.listKind, + APIVersion: path.Join(crd.group, crd.version), + }, + ListMeta: metav1.ListMeta{ + ResourceVersion: "1231415", + }, + Items: items, + }, nil } - return nil, trace.NotFound("teleportrole %q not found", filepath.Join(namespace, name)) -} - -func (s *KubeMockServer) DeletedTeleportRoles(reqID string) []string { - s.mu.Lock() - key := deletedResource{kind: teleportRoleKind, requestID: reqID} - deleted := make([]string, len(s.deletedResources[key])) - copy(deleted, s.deletedResources[key]) - s.mu.Unlock() - sort.Strings(deleted) - return deleted } diff --git a/lib/kube/proxy/testing/kube_server/data/api_teleport.json b/lib/kube/proxy/testing/kube_server/data/api_teleport.json deleted file mode 100644 index e67a8ce59f607..0000000000000 --- a/lib/kube/proxy/testing/kube_server/data/api_teleport.json +++ /dev/null @@ -1,35 +0,0 @@ -{ - "kind": "APIResourceList", - "apiVersion": "v1", - "groupVersion": "resources.teleport.dev/v6", - "resources": [ - { - "name": "teleportroles", - "singularName": "teleportrole", - "namespaced": true, - "kind": "TeleportRole", - "verbs": [ - "delete", - "deletecollection", - "get", - "list", - "patch", - "create", - "update", - "watch" - ], - "storageVersionHash": "eQsgEapFuzY=" - }, - { - "name": "teleportroles/status", - "singularName": "", - "namespaced": true, - "kind": "TeleportRole", - "verbs": [ - "get", - "patch", - "update" - ] - } - ] -} \ No newline at end of file diff --git a/lib/kube/proxy/testing/kube_server/data/apis.json b/lib/kube/proxy/testing/kube_server/data/apis.json deleted file mode 100644 index 249f5d2436bfd..0000000000000 --- a/lib/kube/proxy/testing/kube_server/data/apis.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - "kind": "APIGroupList", - "apiVersion": "v1", - "groups": [ - { - "name": "resources.teleport.dev", - "versions": [ - { - "groupVersion": "resources.teleport.dev/v6", - "version": "v6" - } - ], - "preferredVersion": { - "groupVersion": "resources.teleport.dev/v6", - "version": "v6" - } - } - ] -} \ No newline at end of file diff --git a/lib/kube/proxy/testing/kube_server/discovery.go b/lib/kube/proxy/testing/kube_server/discovery.go index 1daece330b9a9..2dc92a80d36e4 100644 --- a/lib/kube/proxy/testing/kube_server/discovery.go +++ b/lib/kube/proxy/testing/kube_server/discovery.go @@ -20,10 +20,16 @@ package kubeserver import ( _ "embed" + "encoding/json" + "fmt" "net/http" + "sort" + "strings" "github.com/gravitational/trace" "github.com/julienschmidt/httprouter" + + "github.com/gravitational/teleport/lib/httplib" ) var ( @@ -31,17 +37,12 @@ var ( apiResponse string //go:embed data/api_v1.json apiV1Response string - //go:embed data/apis.json - apisResponse string - //go:embed data/api_teleport.json - teleportAPIResponse string ) const ( - apiEndpoint = "/api" - apiV1Endpoint = "/api/v1" - apisEndpoint = "/apis" - teleportAPIEndpoint = "/apis/resources.teleport.dev/v6" + apiEndpoint = "/api" + apiV1Endpoint = "/api/v1" + apisEndpoint = "/apis" ) func (s *KubeMockServer) discoveryEndpoint(w http.ResponseWriter, req *http.Request, p httprouter.Params) (any, error) { @@ -53,12 +54,104 @@ func (s *KubeMockServer) discoveryEndpoint(w http.ResponseWriter, req *http.Requ w.Write([]byte(apiV1Response)) return nil, nil case apisEndpoint: - w.Write([]byte(apisResponse)) - return nil, nil - case teleportAPIEndpoint: - w.Write([]byte(teleportAPIResponse)) + w.Write(apisDiscovery(s.crds)) return nil, nil default: return nil, trace.NotFound("path %v is not supported", req.URL.Path) } } + +func apisDiscovery(crds map[GVP]*CRD) []byte { + byGroup := map[string][]*CRD{} + for _, crd := range crds { + byGroup[crd.group] = append(byGroup[crd.group], crd) + } + for _, crds := range byGroup { + sort.Slice(crds, func(i, j int) bool { return crds[i].version < crds[j].version }) + } + + type ( + version struct { + GroupVersion string `json:"groupVersion"` + Version string `json:"version"` + } + group struct { + Name string `json:"name"` + Versions []version `json:"versions"` + PreferredVersion version `json:"preferredVersion"` + } + discovery struct { + Kind string `json:"kind"` + APIVersion string `json:"apiVersion"` + Groups []group `json:"groups"` + } + ) + + out := discovery{ + Kind: "APIGroupList", + APIVersion: "v1", + } + for groupName, crds := range byGroup { + g := group{ + Name: groupName, + PreferredVersion: version{ + GroupVersion: groupName + "/" + crds[0].version, + Version: crds[0].version, + }, + } + for _, crd := range crds { + g.Versions = append(g.Versions, version{ + GroupVersion: groupName + "/" + crd.version, + Version: crd.version, + }) + } + out.Groups = append(out.Groups, g) + } + + buf, _ := json.Marshal(out) // Can't fail. + return buf +} + +func crdDiscovery(crd *CRD) httplib.HandlerFunc { + return func(w http.ResponseWriter, req *http.Request, p httprouter.Params) (any, error) { + out := fmt.Sprintf(`{ + "kind": "APIResourceList", + "apiVersion": "v1", + "groupVersion": "%s/%s", + "resources": [ + { + "name": "%s", + "singularName": "%s", + "namespaced": %t, + "kind": "%s", + "verbs": [ + "delete", + "deletecollection", + "get", + "list", + "patch", + "create", + "update", + "watch" + ], + "storageVersionHash": "eQsgEapFuzY=" + }, + { + "name": "%s/status", + "singularName": "", + "namespaced": %t, + "kind": "%s", + "verbs": [ + "get", + "patch", + "update" + ] + } + ] + }`, + crd.group, crd.version, crd.plural, strings.ToLower(crd.kind), crd.namespaced, crd.kind, crd.plural, crd.namespaced, crd.kind, + ) + w.Write([]byte(out)) + return nil, nil + } +} diff --git a/lib/kube/proxy/testing/kube_server/kube_mock.go b/lib/kube/proxy/testing/kube_server/kube_mock.go index 0bdc27eeb4d67..8819648e32043 100644 --- a/lib/kube/proxy/testing/kube_server/kube_mock.go +++ b/lib/kube/proxy/testing/kube_server/kube_mock.go @@ -41,7 +41,9 @@ import ( v1 "k8s.io/api/authorization/v1" apierrors "k8s.io/apimachinery/pkg/api/errors" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" "k8s.io/apimachinery/pkg/util/httpstream" spdystream "k8s.io/apimachinery/pkg/util/httpstream/spdy" "k8s.io/apimachinery/pkg/util/httpstream/wsstream" @@ -108,6 +110,22 @@ const ( // Option is a functional option for KubeMockServer type Option func(*KubeMockServer) +// WithCRD adds a CRD to the server with the given resources. +func WithCRD(crd *CRD, resources ...*unstructured.Unstructured) Option { + return func(s *KubeMockServer) { + if s.crds == nil { + s.crds = map[GVP]*CRD{} + } + cpy := crd.Copy() + for _, r := range resources { + r2 := r.DeepCopy() + r2.SetGroupVersionKind(schema.GroupVersionKind{Group: cpy.group, Version: cpy.version, Kind: cpy.kind}) + cpy.items = append(cpy.items, runtime.RawExtension{Object: r2}) + } + s.crds[cpy.GVP] = cpy + } +} + // WithGetPodError sets the error to be returned by the GetPod call func WithGetPodError(status metav1.Status) Option { return func(s *KubeMockServer) { @@ -166,6 +184,8 @@ type KubeMockServer struct { KubeExecRequests KubeUpgradeRequests KubePortforward KubeUpgradeRequests supportsTunneledSPDY bool + + crds map[GVP]*CRD } // NewKubeAPIMock creates Kubernetes API server for handling exec calls. @@ -186,7 +206,6 @@ func NewKubeAPIMock(opts ...Option) (*KubeMockServer, error) { GitVersion: "1.20.0", }, } - for _, o := range opts { o(s) } @@ -233,21 +252,49 @@ func (s *KubeMockServer) setup() { s.router.POST("/apis/authorization.k8s.io/v1/selfsubjectaccessreviews", s.withWriter(s.selfSubjectAccessReviews)) - s.router.GET("/apis/resources.teleport.dev/v6/namespaces/:namespace/teleportroles", s.withWriter(s.listTeleportRoles)) - s.router.GET("/apis/resources.teleport.dev/v6/teleportroles", s.withWriter(s.listTeleportRoles)) - s.router.GET("/apis/resources.teleport.dev/v6/namespaces/:namespace/teleportroles/:name", s.withWriter(s.getTeleportRole)) - s.router.DELETE("/apis/resources.teleport.dev/v6/namespaces/:namespace/teleportroles/:name", s.withWriter(s.deleteTeleportRole)) + for k, crd := range s.crds { + s.router.GET("/apis/"+k.group+"/"+k.version+"/namespaces/:namespace/"+k.plural, s.withWriter(s.listCRDs(crd))) + s.router.GET("/apis/"+k.group+"/"+k.version+"/"+k.plural, s.withWriter(s.listCRDs(crd))) + s.router.GET("/apis/"+k.group+"/"+k.version+"/namespaces/:namespace/"+k.plural+"/:name", s.withWriter(s.getCRD(crd))) + s.router.DELETE("/apis/"+k.group+"/"+k.version+"/namespaces/:namespace/"+k.plural+"/:name", s.withWriter(s.deleteCRD(crd))) + } s.router.GET("/version", s.withWriter(s.versionEndpoint)) - for _, endpoint := range []string{"/api", "/api/:ver", "/apis", "/apis/resources.teleport.dev/v6"} { + for _, endpoint := range []string{"/api", "/api/:ver", "/apis"} { s.router.GET(endpoint, s.withWriter(s.discoveryEndpoint)) } + for k, v := range s.crds { + s.router.GET("/apis/"+k.group+"/"+k.version, s.withWriter(crdDiscovery(v))) + } s.server = httptest.NewUnstartedServer(s.router) s.server.EnableHTTP2 = true } +func (s *KubeMockServer) CRDScheme() *runtime.Scheme { + getUnstructuredCRD := func(group, version, kind string) *unstructured.Unstructured { + obj := &unstructured.Unstructured{} + obj.SetGroupVersionKind(schema.GroupVersionKind{ + Group: group, + Version: version, + Kind: kind, + }) + return obj + } + + kubeScheme := runtime.NewScheme() + for k, crd := range s.crds { + single := getUnstructuredCRD(k.group, k.version, crd.kind) + list := getUnstructuredCRD(k.group, k.version, crd.listKind) + + kubeScheme.AddKnownTypeWithName(single.GroupVersionKind(), single) + kubeScheme.AddKnownTypeWithName(list.GroupVersionKind(), list) + } + + return kubeScheme +} + func (s *KubeMockServer) Close() error { s.server.Close() return nil