package ext import ( "bytes" "context" "encoding/json" "fmt" "io" "net" "net/http" "net/http/httptest" "os" "path/filepath" "testing" "time" "github.com/rancher/lasso/pkg/controller" "github.com/rancher/steve/pkg/accesscontrol" "github.com/rancher/steve/pkg/accesscontrol/fake" wrbacv1 "github.com/rancher/wrangler/v3/pkg/generated/controllers/rbac/v1" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "go.uber.org/mock/gomock" rbacv1 "k8s.io/api/rbac/v1" apierrors "k8s.io/apimachinery/pkg/api/errors" metainternalversion "k8s.io/apimachinery/pkg/apis/meta/internalversion" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/runtime/serializer" yamlutil "k8s.io/apimachinery/pkg/util/yaml" "k8s.io/apimachinery/pkg/watch" "k8s.io/apiserver/pkg/authentication/authenticator" "k8s.io/apiserver/pkg/authentication/user" "k8s.io/apiserver/pkg/authorization/authorizer" "k8s.io/apiserver/pkg/endpoints/request" "k8s.io/apiserver/pkg/registry/rest" "k8s.io/apiserver/pkg/server/options" ) type authzTestStore struct { *testStore[*TestType, *TestTypeList] authorizer authorizer.Authorizer } // Get implements [rest.Getter] func (t *authzTestStore) Get(ctx context.Context, name string, options *metav1.GetOptions) (runtime.Object, error) { return t.get(ctx, name, options) } // List implements [rest.Lister] func (t *authzTestStore) List(ctx context.Context, _ *metainternalversion.ListOptions) (runtime.Object, error) { userInfo, ok := request.UserFrom(ctx) if !ok { return nil, convertError(fmt.Errorf("missing user info")) } if userInfo.GetName() == "read-only-error" { decision, _, err := t.authorizer.Authorize(ctx, authorizer.AttributesRecord{ User: userInfo, Verb: "customverb", Resource: "testtypes", ResourceRequest: true, APIGroup: "ext.cattle.io", }) if err != nil || decision != authorizer.DecisionAllow { if err == nil { err = fmt.Errorf("not allowed") } forbidden := apierrors.NewForbidden(t.gvr.GroupResource(), "Forbidden", err) forbidden.ErrStatus.Kind = "Status" forbidden.ErrStatus.APIVersion = "v1" return nil, forbidden } } return &testTypeListFixture, nil } func (t *authzTestStore) get(_ context.Context, name string, _ *metav1.GetOptions) (*TestType, error) { if name == "not-found" { return nil, apierrors.NewNotFound(t.gvr.GroupResource(), name) } return &testTypeFixture, nil } func (t *authzTestStore) create(_ context.Context, _ *TestType, _ *metav1.CreateOptions) (*TestType, error) { return &testTypeFixture, nil } func (t *authzTestStore) update(_ context.Context, _ *TestType, _ *metav1.UpdateOptions) (*TestType, error) { return &testTypeFixture, nil } // Create implements [rest.Creater] func (t *authzTestStore) Create(ctx context.Context, obj runtime.Object, createValidation rest.ValidateObjectFunc, options *metav1.CreateOptions) (runtime.Object, error) { if createValidation != nil { err := createValidation(ctx, obj) if err != nil { return obj, err } } objT, ok := obj.(*TestType) if !ok { var zeroT *TestType return nil, convertError(fmt.Errorf("expected %T but got %T", zeroT, obj)) } return t.create(ctx, objT, options) } // Update implements [rest.Updater] func (t *authzTestStore) Update(ctx context.Context, name string, objInfo rest.UpdatedObjectInfo, createValidation rest.ValidateObjectFunc, updateValidation rest.ValidateObjectUpdateFunc, forceAllowCreate bool, options *metav1.UpdateOptions) (runtime.Object, bool, error) { return CreateOrUpdate(ctx, name, objInfo, createValidation, updateValidation, forceAllowCreate, options, t.get, t.create, t.update) } // Watch implements [rest.Watcher] func (t *authzTestStore) Watch(_ context.Context, _ *metainternalversion.ListOptions) (watch.Interface, error) { return nil, nil } // Delete implements [rest.GracefulDeleter] func (t *authzTestStore) Delete(_ context.Context, _ string, _ rest.ValidateObjectFunc, _ *metav1.DeleteOptions) (runtime.Object, bool, error) { return nil, false, nil } func (s *ExtensionAPIServerSuite) TestAuthorization() { t := s.T() scheme := runtime.NewScheme() AddToScheme(scheme) rbacv1.AddToScheme(scheme) codecs := serializer.NewCodecFactory(scheme) controllerFactory, err := controller.NewSharedControllerFactoryFromConfigWithOptions(s.restConfig, scheme, &controller.SharedControllerFactoryOptions{}) require.NoError(t, err) rbacController := wrbacv1.New(controllerFactory) accessStore := accesscontrol.NewAccessStore(s.ctx, false, rbacController) authz := NewAccessSetAuthorizer(accessStore) err = controllerFactory.Start(s.ctx, 2) require.NoError(t, err) ln, _, err := options.CreateListener("", ":0", net.ListenConfig{}) require.NoError(t, err) extensionAPIServer, err := setupExtensionAPIServerNoStore(t, scheme, func(opts *ExtensionAPIServerOptions) { opts.Listener = ln opts.Authorizer = authz opts.Authenticator = authenticator.RequestFunc(func(req *http.Request) (*authenticator.Response, bool, error) { user, ok := request.UserFrom(req.Context()) if !ok { return nil, false, nil } return &authenticator.Response{ User: user, }, true, nil }) }, func(s *ExtensionAPIServer) error { store := &authzTestStore{ testStore: newDefaultTestStore(), authorizer: s.GetAuthorizer(), } err := s.Install("testtypes", testTypeGV.WithKind("TestType"), store) if err != nil { return err } return nil }) require.NoError(t, err) rbacBytes, err := os.ReadFile(filepath.Join("testdata", "rbac.yaml")) require.NoError(t, err) decoder := yamlutil.NewYAMLOrJSONDecoder(bytes.NewReader(rbacBytes), 4096) for { var rawObj runtime.RawExtension if err = decoder.Decode(&rawObj); err != nil { break } obj, _, err := codecs.UniversalDecoder(rbacv1.SchemeGroupVersion).Decode(rawObj.Raw, nil, nil) require.NoError(t, err) switch obj := obj.(type) { case *rbacv1.ClusterRole: _, err = s.client.RbacV1().ClusterRoles().Create(s.ctx, obj, metav1.CreateOptions{}) defer func(name string) { s.client.RbacV1().ClusterRoles().Delete(s.ctx, obj.GetName(), metav1.DeleteOptions{}) }(obj.GetName()) case *rbacv1.ClusterRoleBinding: _, err = s.client.RbacV1().ClusterRoleBindings().Create(s.ctx, obj, metav1.CreateOptions{}) defer func(name string) { s.client.RbacV1().ClusterRoleBindings().Delete(s.ctx, obj.GetName(), metav1.DeleteOptions{}) }(obj.GetName()) } require.NoError(t, err, "creating") } tests := []struct { name string user *user.DefaultInfo createRequest func() *http.Request expectedStatusCode int expectedStatus apierrors.APIStatus }{ { name: "authorized get read-only not found", user: &user.DefaultInfo{ Name: "read-only", }, createRequest: func() *http.Request { return httptest.NewRequest(http.MethodGet, "/apis/ext.cattle.io/v1/testtypes/not-found", nil) }, expectedStatusCode: http.StatusNotFound, }, { name: "authorized get read-only", user: &user.DefaultInfo{ Name: "read-only", }, createRequest: func() *http.Request { return httptest.NewRequest(http.MethodGet, "/apis/ext.cattle.io/v1/testtypes/foo", nil) }, expectedStatusCode: http.StatusOK, }, { name: "authorized list read-only", user: &user.DefaultInfo{ Name: "read-only", }, createRequest: func() *http.Request { return httptest.NewRequest(http.MethodGet, "/apis/ext.cattle.io/v1/testtypes", nil) }, expectedStatusCode: http.StatusOK, }, { name: "unauthorized create from read-only", user: &user.DefaultInfo{ Name: "read-only", }, createRequest: func() *http.Request { return httptest.NewRequest(http.MethodPost, "/apis/ext.cattle.io/v1/testtypes", nil) }, expectedStatusCode: http.StatusForbidden, }, { name: "unauthorized update from read-only", user: &user.DefaultInfo{ Name: "read-only", }, createRequest: func() *http.Request { return httptest.NewRequest(http.MethodPut, "/apis/ext.cattle.io/v1/testtypes/foo", nil) }, expectedStatusCode: http.StatusForbidden, }, { name: "unauthorized delete from read-only", user: &user.DefaultInfo{ Name: "read-only", }, createRequest: func() *http.Request { return httptest.NewRequest(http.MethodDelete, "/apis/ext.cattle.io/v1/testtypes/foo", nil) }, expectedStatusCode: http.StatusForbidden, }, { name: "unauthorized create-on-update", user: &user.DefaultInfo{ Name: "update-not-create", }, createRequest: func() *http.Request { var buf bytes.Buffer json.NewEncoder(&buf).Encode(&TestType{ TypeMeta: metav1.TypeMeta{ Kind: "TestType", APIVersion: testTypeGV.String(), }, ObjectMeta: metav1.ObjectMeta{ Name: "not-found", }, }) return httptest.NewRequest(http.MethodPut, "/apis/ext.cattle.io/v1/testtypes/not-found", &buf) }, expectedStatusCode: http.StatusForbidden, }, { name: "authorized read-only-error with custom store authorization", user: &user.DefaultInfo{ Name: "read-only-error", }, createRequest: func() *http.Request { return httptest.NewRequest(http.MethodGet, "/apis/ext.cattle.io/v1/testtypes", nil) }, expectedStatusCode: http.StatusForbidden, }, { name: "authorized get read-write not found", user: &user.DefaultInfo{ Name: "read-write", }, createRequest: func() *http.Request { return httptest.NewRequest(http.MethodGet, "/apis/ext.cattle.io/v1/testtypes/not-found", nil) }, expectedStatusCode: http.StatusNotFound, }, { name: "authorized get read-write", user: &user.DefaultInfo{ Name: "read-write", }, createRequest: func() *http.Request { return httptest.NewRequest(http.MethodGet, "/apis/ext.cattle.io/v1/testtypes/foo", nil) }, expectedStatusCode: http.StatusOK, }, { name: "authorized list read-write", user: &user.DefaultInfo{ Name: "read-write", }, createRequest: func() *http.Request { return httptest.NewRequest(http.MethodGet, "/apis/ext.cattle.io/v1/testtypes", nil) }, expectedStatusCode: http.StatusOK, }, { name: "authorized create from read-write", user: &user.DefaultInfo{ Name: "read-write", }, createRequest: func() *http.Request { var buf bytes.Buffer json.NewEncoder(&buf).Encode(&TestType{ TypeMeta: metav1.TypeMeta{ Kind: "TestType", APIVersion: testTypeGV.String(), }, ObjectMeta: metav1.ObjectMeta{ Name: "foo", }, }) return httptest.NewRequest(http.MethodPost, "/apis/ext.cattle.io/v1/testtypes", &buf) }, expectedStatusCode: http.StatusCreated, }, { name: "authorized update from read-write", user: &user.DefaultInfo{ Name: "read-write", }, createRequest: func() *http.Request { var buf bytes.Buffer json.NewEncoder(&buf).Encode(&TestType{ TypeMeta: metav1.TypeMeta{ Kind: "TestType", APIVersion: testTypeGV.String(), }, ObjectMeta: metav1.ObjectMeta{ Name: "foo", }, }) return httptest.NewRequest(http.MethodPut, "/apis/ext.cattle.io/v1/testtypes/foo", &buf) }, expectedStatusCode: http.StatusOK, }, { name: "unauthorized user", user: &user.DefaultInfo{ Name: "unknown-user", }, createRequest: func() *http.Request { return httptest.NewRequest(http.MethodGet, "/apis/ext.cattle.io/v1/testtypes", nil) }, expectedStatusCode: http.StatusForbidden, }, { name: "authorized access to non-resource url", user: &user.DefaultInfo{ Name: "openapi-v2-only", }, createRequest: func() *http.Request { return httptest.NewRequest(http.MethodGet, "/openapi/v2", nil) }, expectedStatusCode: http.StatusOK, }, { name: "unauthorized verb to non-resource url", user: &user.DefaultInfo{ Name: "openapi-v2-only", }, createRequest: func() *http.Request { return httptest.NewRequest(http.MethodPost, "/openapi/v2", nil) }, expectedStatusCode: http.StatusForbidden, }, { name: "unauthorized access to non-resource url (user can access only openapi/v2)", user: &user.DefaultInfo{ Name: "openapi-v2-only", }, createRequest: func() *http.Request { return httptest.NewRequest(http.MethodGet, "/openapi/v3", nil) }, expectedStatusCode: http.StatusForbidden, }, { name: "authorized user can access both openapi v2 and v3 (v2)", user: &user.DefaultInfo{ Name: "openapi-v2-v3", }, createRequest: func() *http.Request { return httptest.NewRequest(http.MethodGet, "/openapi/v2", nil) }, expectedStatusCode: http.StatusOK, }, { name: "authorized user can access both openapi v2 and v3 (v3)", user: &user.DefaultInfo{ Name: "openapi-v2-v3", }, createRequest: func() *http.Request { return httptest.NewRequest(http.MethodGet, "/openapi/v3", nil) }, expectedStatusCode: http.StatusOK, }, { name: "authorized user can access url based in wildcard rule", user: &user.DefaultInfo{ Name: "openapi-v2-v3", }, createRequest: func() *http.Request { return httptest.NewRequest(http.MethodGet, "/openapi/v3/apis/ext.cattle.io/v1", nil) }, expectedStatusCode: http.StatusOK, }, } for _, test := range tests { t.Run(test.name, func(t *testing.T) { req := test.createRequest() w := httptest.NewRecorder() if test.user != nil { assert.EventuallyWithT(t, func(c *assert.CollectT) { accessSet := accessStore.AccessFor(test.user) assert.NotNil(c, accessSet) }, time.Second*5, 100*time.Millisecond) ctx := request.WithUser(req.Context(), test.user) req = req.WithContext(ctx) } extensionAPIServer.ServeHTTP(w, req) resp := w.Result() body, _ := io.ReadAll(resp.Body) responseStatus := metav1.Status{} json.Unmarshal(body, &responseStatus) require.Equal(t, test.expectedStatusCode, resp.StatusCode) if test.expectedStatus != nil { require.Equal(t, test.expectedStatus.Status(), responseStatus, "for request "+req.URL.String()) } }) } } func TestAuthorization_NonResourceURLs(t *testing.T) { type input struct { ctx context.Context attrs authorizer.Attributes } type expected struct { authorized authorizer.Decision reason string err error } sampleReadOnlyUser := &user.DefaultInfo{ Name: "read-only-user", } sampleReadOnlyAccessSet := func() *accesscontrol.AccessSet { accessSet := &accesscontrol.AccessSet{} accessSet.AddNonResourceURLs([]string{ "get", }, []string{ "/metrics", "/healthz", }) return accessSet }() sampleReadWriteUser := &user.DefaultInfo{ Name: "read-write-user", } sampleReadWriteAccessSet := func() *accesscontrol.AccessSet { accessSet := &accesscontrol.AccessSet{} accessSet.AddNonResourceURLs([]string{ "get", "post", }, []string{ "/metrics", "/healthz", }) return accessSet }() tests := []struct { name string input input expected expected mockUsername *user.DefaultInfo mockAccessSet *accesscontrol.AccessSet }{ { name: "authorized read-only user to read data", input: input{ ctx: context.TODO(), attrs: authorizer.AttributesRecord{ User: sampleReadOnlyUser, ResourceRequest: false, Path: "/healthz", Verb: "get", }, }, expected: expected{ authorized: authorizer.DecisionAllow, reason: "", err: nil, }, mockUsername: sampleReadOnlyUser, mockAccessSet: sampleReadOnlyAccessSet, }, { name: "unauthorized read-only user to write data", input: input{ ctx: context.TODO(), attrs: authorizer.AttributesRecord{ User: sampleReadOnlyUser, ResourceRequest: false, Path: "/metrics", Verb: "post", }, }, expected: expected{ authorized: authorizer.DecisionDeny, reason: "", err: nil, }, mockUsername: sampleReadOnlyUser, mockAccessSet: sampleReadOnlyAccessSet, }, { name: "authorized read-write user to read data", input: input{ ctx: context.TODO(), attrs: authorizer.AttributesRecord{ User: sampleReadWriteUser, ResourceRequest: false, Path: "/metrics", Verb: "get", }, }, expected: expected{ authorized: authorizer.DecisionAllow, reason: "", err: nil, }, mockUsername: sampleReadWriteUser, mockAccessSet: sampleReadWriteAccessSet, }, { name: "authorized read-write user to write data", input: input{ ctx: context.TODO(), attrs: authorizer.AttributesRecord{ User: sampleReadWriteUser, ResourceRequest: false, Path: "/metrics", Verb: "post", }, }, expected: expected{ authorized: authorizer.DecisionAllow, reason: "", err: nil, }, mockUsername: sampleReadWriteUser, mockAccessSet: sampleReadWriteAccessSet, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { crtl := gomock.NewController(t) asl := fake.NewMockAccessSetLookup(crtl) asl.EXPECT().AccessFor(tt.mockUsername).Return(tt.mockAccessSet) auth := NewAccessSetAuthorizer(asl) authorized, reason, err := auth.Authorize(tt.input.ctx, tt.input.attrs) require.Equal(t, tt.expected.authorized, authorized) require.Equal(t, tt.expected.reason, reason) if tt.expected.err != nil { require.Error(t, err) } else { require.NoError(t, err) } }) } }