diff --git a/pkg/resources/common/formatter.go b/pkg/resources/common/formatter.go index da7707f5..80230bf3 100644 --- a/pkg/resources/common/formatter.go +++ b/pkg/resources/common/formatter.go @@ -1,6 +1,7 @@ package common import ( + "net/http" "strings" "github.com/rancher/apiserver/pkg/types" @@ -12,7 +13,6 @@ import ( "github.com/rancher/steve/pkg/summarycache" "github.com/rancher/wrangler/v3/pkg/data" corecontrollers "github.com/rancher/wrangler/v3/pkg/generated/controllers/core/v1" - "github.com/rancher/wrangler/v3/pkg/slice" "github.com/rancher/wrangler/v3/pkg/summary" "k8s.io/apimachinery/pkg/api/meta" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" @@ -26,15 +26,17 @@ func DefaultTemplate(clientGetter proxy.ClientGetter, namespaceCache corecontrollers.NamespaceCache) schema.Template { return schema.Template{ Store: metricsStore.NewMetricsStore(proxy.NewProxyStore(clientGetter, summaryCache, asl, namespaceCache)), - Formatter: formatter(summaryCache), + Formatter: formatter(summaryCache, asl), } } // DefaultTemplateForStore provides a default schema template which uses a provided, pre-initialized store. Primarily used when creating a Template that uses a Lasso SQL store internally. -func DefaultTemplateForStore(store types.Store, summaryCache *summarycache.SummaryCache) schema.Template { +func DefaultTemplateForStore(store types.Store, + summaryCache *summarycache.SummaryCache, + asl accesscontrol.AccessSetLookup) schema.Template { return schema.Template{ Store: store, - Formatter: formatter(summaryCache), + Formatter: formatter(summaryCache, asl), } } @@ -71,7 +73,7 @@ func selfLink(gvr schema2.GroupVersionResource, meta metav1.Object) (prefix stri return buf.String() } -func formatter(summarycache *summarycache.SummaryCache) types.Formatter { +func formatter(summarycache *summarycache.SummaryCache, asl accesscontrol.AccessSetLookup) types.Formatter { return func(request *types.APIRequest, resource *types.RawResource) { if resource.Schema == nil { return @@ -86,21 +88,39 @@ func formatter(summarycache *summarycache.SummaryCache) types.Formatter { if err != nil { return } + userInfo, ok := request.GetUserInfo() + if !ok { + return + } + accessSet := asl.AccessFor(userInfo) + if accessSet == nil { + return + } + hasUpdate := accessSet.Grants("update", gvr.GroupResource(), resource.APIObject.Namespace(), resource.APIObject.Name()) + hasDelete := accessSet.Grants("delete", gvr.GroupResource(), resource.APIObject.Namespace(), resource.APIObject.Name()) + selfLink := selfLink(gvr, meta) u := request.URLBuilder.RelativeToRoot(selfLink) resource.Links["view"] = u - if _, ok := resource.Links["update"]; !ok && slice.ContainsString(resource.Schema.CollectionMethods, "PUT") { - resource.Links["update"] = u + if hasUpdate { + if attributes.DisallowMethods(resource.Schema)[http.MethodPut] { + resource.Links["update"] = "blocked" + } else { + resource.Links["update"] = u + } + } else { + delete(resource.Links, "update") } - - if _, ok := resource.Links["update"]; !ok && slice.ContainsString(resource.Schema.ResourceMethods, "blocked-PUT") { - resource.Links["update"] = "blocked" - } - - if _, ok := resource.Links["remove"]; !ok && slice.ContainsString(resource.Schema.ResourceMethods, "blocked-DELETE") { - resource.Links["remove"] = "blocked" + if hasDelete { + if attributes.DisallowMethods(resource.Schema)[http.MethodDelete] { + resource.Links["remove"] = "blocked" + } else { + resource.Links["remove"] = u + } + } else { + delete(resource.Links, "remove") } if _, ok := resource.Links["patch"]; !ok && slice.ContainsString(resource.Schema.ResourceMethods, "blocked-PATCH") { diff --git a/pkg/resources/common/formatter_test.go b/pkg/resources/common/formatter_test.go index e5a3d600..704dc197 100644 --- a/pkg/resources/common/formatter_test.go +++ b/pkg/resources/common/formatter_test.go @@ -1,13 +1,28 @@ package common import ( + "bytes" + "context" + "net/http" "net/url" "testing" "github.com/rancher/apiserver/pkg/types" + "github.com/rancher/apiserver/pkg/urlbuilder" + "github.com/rancher/steve/pkg/accesscontrol" + "github.com/rancher/steve/pkg/accesscontrol/fake" + "github.com/rancher/steve/pkg/attributes" + "github.com/rancher/wrangler/v3/pkg/schemas" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.uber.org/mock/gomock" + v1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/api/meta" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" schema2 "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/apiserver/pkg/authentication/user" + "k8s.io/apiserver/pkg/endpoints/request" ) func Test_includeFields(t *testing.T) { @@ -612,3 +627,366 @@ func Test_selfLink(t *testing.T) { }) } } + +func Test_formatterLinks(t *testing.T) { + t.Parallel() + type permissions struct { + hasGet bool + hasUpdate bool + hasRemove bool + } + tests := []struct { + name string + hasUser bool + permissions *permissions + schema *types.APISchema + apiObject types.APIObject + currentLinks map[string]string + wantLinks map[string]string + }{ + { + name: "no schema", + currentLinks: map[string]string{ + "default": "defaultVal", + }, + wantLinks: map[string]string{ + "default": "defaultVal", + }, + }, + { + name: "no gvr in schema", + schema: &types.APISchema{ + Schema: &schemas.Schema{ + ID: "example", + Attributes: map[string]interface{}{ + "some": "thing", + }, + }, + }, + currentLinks: map[string]string{ + "default": "defaultVal", + }, + wantLinks: map[string]string{ + "default": "defaultVal", + }, + }, + { + name: "api object has no accessor", + schema: &types.APISchema{ + Schema: &schemas.Schema{ + ID: "example", + Attributes: map[string]interface{}{ + "group": "", + "version": "v1", + "resource": "pods", + }, + }, + }, + apiObject: types.APIObject{ + ID: "example", + Object: struct{}{}, + }, + currentLinks: map[string]string{ + "default": "defaultVal", + }, + wantLinks: map[string]string{ + "default": "defaultVal", + }, + }, + { + name: "no user info", + schema: &types.APISchema{ + Schema: &schemas.Schema{ + ID: "example", + Attributes: map[string]interface{}{ + "group": "", + "version": "v1", + "resource": "pods", + }, + }, + }, + apiObject: types.APIObject{ + ID: "example", + Object: &v1.Pod{ + ObjectMeta: metav1.ObjectMeta{ + Name: "example-pod", + Namespace: "example-ns", + }, + }, + }, + currentLinks: map[string]string{ + "default": "defaultVal", + }, + wantLinks: map[string]string{ + "default": "defaultVal", + }, + }, + { + name: "no accessSet", + hasUser: true, + schema: &types.APISchema{ + Schema: &schemas.Schema{ + ID: "example", + Attributes: map[string]interface{}{ + "group": "", + "version": "v1", + "resource": "pods", + }, + }, + }, + apiObject: types.APIObject{ + ID: "example", + Object: &v1.Pod{ + ObjectMeta: metav1.ObjectMeta{ + Name: "example-pod", + Namespace: "example-ns", + }, + }, + }, + currentLinks: map[string]string{ + "default": "defaultVal", + }, + wantLinks: map[string]string{ + "default": "defaultVal", + }, + }, + { + name: "no update/remove permissions", + hasUser: true, + permissions: &permissions{}, + schema: &types.APISchema{ + Schema: &schemas.Schema{ + ID: "example", + Attributes: map[string]interface{}{ + "group": "", + "version": "v1", + "resource": "pods", + }, + }, + }, + apiObject: types.APIObject{ + ID: "example", + Object: &v1.Pod{ + ObjectMeta: metav1.ObjectMeta{ + Name: "example-pod", + Namespace: "example-ns", + }, + }, + }, + currentLinks: map[string]string{ + "default": "defaultVal", + "update": "../api/v1/namespaces/example-ns/pods/example-pod", + "remove": "../api/v1/namespaces/example-ns/pods/example-pod", + }, + wantLinks: map[string]string{ + "default": "defaultVal", + "view": "/api/v1/namespaces/example-ns/pods/example-pod", + }, + }, + { + name: "update but no remove permissions", + hasUser: true, + permissions: &permissions{ + hasUpdate: true, + }, + schema: &types.APISchema{ + Schema: &schemas.Schema{ + ID: "example", + Attributes: map[string]interface{}{ + "group": "", + "version": "v1", + "resource": "pods", + }, + }, + }, + apiObject: types.APIObject{ + ID: "example", + Object: &v1.Pod{ + ObjectMeta: metav1.ObjectMeta{ + Name: "example-pod", + Namespace: "example-ns", + }, + }, + }, + currentLinks: map[string]string{ + "default": "defaultVal", + "update": "../api/v1/namespaces/example-ns/pods/example-pod", + "remove": "../api/v1/namespaces/example-ns/pods/example-pod", + }, + wantLinks: map[string]string{ + "default": "defaultVal", + "update": "/api/v1/namespaces/example-ns/pods/example-pod", + "view": "/api/v1/namespaces/example-ns/pods/example-pod", + }, + }, + { + name: "remove but no update permissions", + hasUser: true, + permissions: &permissions{ + hasRemove: true, + }, + schema: &types.APISchema{ + Schema: &schemas.Schema{ + ID: "example", + Attributes: map[string]interface{}{ + "group": "", + "version": "v1", + "resource": "pods", + }, + }, + }, + apiObject: types.APIObject{ + ID: "example", + Object: &v1.Pod{ + ObjectMeta: metav1.ObjectMeta{ + Name: "example-pod", + Namespace: "example-ns", + }, + }, + }, + currentLinks: map[string]string{ + "default": "defaultVal", + "update": "../api/v1/namespaces/example-ns/pods/example-pod", + "remove": "../api/v1/namespaces/example-ns/pods/example-pod", + }, + wantLinks: map[string]string{ + "default": "defaultVal", + "remove": "/api/v1/namespaces/example-ns/pods/example-pod", + "view": "/api/v1/namespaces/example-ns/pods/example-pod", + }, + }, + { + name: "update and remove permissions", + hasUser: true, + permissions: &permissions{ + hasUpdate: true, + hasRemove: true, + }, + schema: &types.APISchema{ + Schema: &schemas.Schema{ + ID: "example", + Attributes: map[string]interface{}{ + "group": "", + "version": "v1", + "resource": "pods", + }, + }, + }, + apiObject: types.APIObject{ + ID: "example", + Object: &v1.Pod{ + ObjectMeta: metav1.ObjectMeta{ + Name: "example-pod", + Namespace: "example-ns", + }, + }, + }, + currentLinks: map[string]string{ + "default": "defaultVal", + "update": "../api/v1/namespaces/example-ns/pods/example-pod", + "remove": "../api/v1/namespaces/example-ns/pods/example-pod", + }, + wantLinks: map[string]string{ + "default": "defaultVal", + "update": "/api/v1/namespaces/example-ns/pods/example-pod", + "remove": "/api/v1/namespaces/example-ns/pods/example-pod", + "view": "/api/v1/namespaces/example-ns/pods/example-pod", + }, + }, + { + name: "update and remove permissions, but blocked", + hasUser: true, + permissions: &permissions{ + hasUpdate: true, + hasRemove: true, + }, + schema: &types.APISchema{ + Schema: &schemas.Schema{ + ID: "example", + Attributes: map[string]interface{}{ + "group": "", + "version": "v1", + "resource": "pods", + "disallowMethods": map[string]bool{ + http.MethodPut: true, + http.MethodDelete: true, + }, + }, + }, + }, + apiObject: types.APIObject{ + ID: "example", + Object: &v1.Pod{ + ObjectMeta: metav1.ObjectMeta{ + Name: "example-pod", + Namespace: "example-ns", + }, + }, + }, + currentLinks: map[string]string{ + "default": "defaultVal", + "update": "../api/v1/namespaces/example-ns/pods/example-pod", + "remove": "../api/v1/namespaces/example-ns/pods/example-pod", + }, + wantLinks: map[string]string{ + "default": "defaultVal", + "update": "blocked", + "remove": "blocked", + "view": "/api/v1/namespaces/example-ns/pods/example-pod", + }, + }, + } + + for _, test := range tests { + test := test + t.Run(test.name, func(t *testing.T) { + t.Parallel() + defaultUserInfo := user.DefaultInfo{ + Name: "test-user", + Groups: []string{"groups"}, + } + ctrl := gomock.NewController(t) + asl := fake.NewMockAccessSetLookup(ctrl) + if test.permissions != nil { + gvr := attributes.GVR(test.schema) + meta, err := meta.Accessor(test.apiObject.Object) + accessSet := accesscontrol.AccessSet{} + require.NoError(t, err) + if test.permissions.hasUpdate { + accessSet.Add("update", gvr.GroupResource(), accesscontrol.Access{ + Namespace: meta.GetNamespace(), + ResourceName: meta.GetName(), + }) + } + if test.permissions.hasRemove { + accessSet.Add("delete", gvr.GroupResource(), accesscontrol.Access{ + Namespace: meta.GetNamespace(), + ResourceName: meta.GetName(), + }) + } + asl.EXPECT().AccessFor(&defaultUserInfo).Return(&accessSet) + } else { + asl.EXPECT().AccessFor(&defaultUserInfo).Return(nil).AnyTimes() + } + ctx := context.Background() + if test.hasUser { + ctx = request.WithUser(ctx, &defaultUserInfo) + } + httpRequest, err := http.NewRequestWithContext(ctx, "", "", bytes.NewBuffer([]byte{})) + require.NoError(t, err) + request := &types.APIRequest{ + Request: httpRequest, + URLBuilder: &urlbuilder.DefaultURLBuilder{}, + } + resource := &types.RawResource{ + Schema: test.schema, + APIObject: test.apiObject, + Links: test.currentLinks, + } + fmtter := formatter(nil, asl) + fmtter(request, resource) + require.Equal(t, test.wantLinks, resource.Links) + + }) + } +} diff --git a/pkg/resources/schema.go b/pkg/resources/schema.go index 8e4c060c..cb7e527c 100644 --- a/pkg/resources/schema.go +++ b/pkg/resources/schema.go @@ -76,10 +76,11 @@ func DefaultSchemaTemplates(cf *client.Factory, func DefaultSchemaTemplatesForStore(store types.Store, baseSchemas *types.APISchemas, summaryCache *summarycache.SummaryCache, + lookup accesscontrol.AccessSetLookup, discovery discovery.DiscoveryInterface) []schema.Template { return []schema.Template{ - common.DefaultTemplateForStore(store, summaryCache), + common.DefaultTemplateForStore(store, summaryCache, lookup), apigroups.Template(discovery), { ID: "configmap", diff --git a/pkg/server/server.go b/pkg/server/server.go index c43c78d6..a9711e77 100644 --- a/pkg/server/server.go +++ b/pkg/server/server.go @@ -206,7 +206,7 @@ func setup(ctx context.Context, server *Server) error { store := metricsStore.NewMetricsStore(errStore) // end store setup code - for _, template := range resources.DefaultSchemaTemplatesForStore(store, server.BaseSchemas, summaryCache, server.controllers.K8s.Discovery()) { + for _, template := range resources.DefaultSchemaTemplatesForStore(store, server.BaseSchemas, summaryCache, asl, server.controllers.K8s.Discovery()) { sf.AddTemplate(template) }