From 6cb23b995a9fd8edf92e6e4535a8a581cee0fd28 Mon Sep 17 00:00:00 2001 From: Chad Roberts Date: Tue, 8 Apr 2025 12:19:04 -0400 Subject: [PATCH 1/5] PoC for including namespace-specific permission in schemas --- pkg/resources/common/formatter.go | 29 +++++++++++++++++++++++++++++ pkg/schema/factory.go | 10 ++++++++++ 2 files changed, 39 insertions(+) diff --git a/pkg/resources/common/formatter.go b/pkg/resources/common/formatter.go index 7cc6da09..133d141b 100644 --- a/pkg/resources/common/formatter.go +++ b/pkg/resources/common/formatter.go @@ -146,6 +146,27 @@ func formatter(summarycache *summarycache.SummaryCache, asl accesscontrol.Access excludeValues(request, unstr) } + if permsQuery := request.Query.Get("checkPermissions"); permsQuery != "" { + id := resource.APIObject.ID + ns := getNamespaceFromResource(id) + gvr := attributes.GVR(resource.Schema) + permissions := map[string]map[string]bool{} + for _, res := range strings.Split(permsQuery, ",") { + perms := map[string]bool{} + for _, verb := range []string{"create", "update", "delete", "list", "get", "watch", "patch"} { + allowed := asl.AccessFor(userInfo).Grants(verb, schema2.GroupResource{Group: gvr.Group, Resource: res}, ns, "") + // TODO maybe add links rather than true/false + perms[verb] = allowed + } + permissions[res] = perms + } + + if unstr, ok := resource.APIObject.Object.(*unstructured.Unstructured); ok { + data.PutValue(unstr.Object, permissions, "resourcePermissions") + } + + } + } } @@ -184,3 +205,11 @@ func excludeValues(request *types.APIRequest, unstr *unstructured.Unstructured) } } } + +func getNamespaceFromResource(resourceId string) string { + parts := strings.SplitN(resourceId, "/", 2) + if len(parts) == 2 { + return parts[1] + } + return resourceId +} diff --git a/pkg/schema/factory.go b/pkg/schema/factory.go index 53c35173..c75c1345 100644 --- a/pkg/schema/factory.go +++ b/pkg/schema/factory.go @@ -11,6 +11,7 @@ import ( "github.com/rancher/apiserver/pkg/types" "github.com/rancher/steve/pkg/accesscontrol" "github.com/rancher/steve/pkg/attributes" + "github.com/rancher/wrangler/v3/pkg/schemas" "k8s.io/apimachinery/pkg/runtime/schema" "k8s.io/apiserver/pkg/authentication/user" ) @@ -143,6 +144,15 @@ func (c *Collection) schemasForSubject(access *accesscontrol.AccessSet) (*types. s = s.DeepCopy() attributes.SetAccess(s, verbAccess) + + if s.ResourceFields == nil { + s.ResourceFields = make(map[string]schemas.Field) + } + s.ResourceFields["resourcePermissions"] = schemas.Field{ + Type: "map[json]", + Description: "Per-resource access permissions", + } + if verbAccess.AnyVerb("list", "get") { s.ResourceMethods = append(s.ResourceMethods, allowed(http.MethodGet)) s.CollectionMethods = append(s.CollectionMethods, allowed(http.MethodGet)) From 1a4e6ebfcd3814529a473569e6f5583560430d38 Mon Sep 17 00:00:00 2001 From: Chad Roberts Date: Thu, 10 Apr 2025 05:25:45 -0400 Subject: [PATCH 2/5] Small improvement to the formatter to use the already created accessSet. --- pkg/resources/common/formatter.go | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/pkg/resources/common/formatter.go b/pkg/resources/common/formatter.go index 133d141b..9f5b6a2a 100644 --- a/pkg/resources/common/formatter.go +++ b/pkg/resources/common/formatter.go @@ -154,7 +154,7 @@ func formatter(summarycache *summarycache.SummaryCache, asl accesscontrol.Access for _, res := range strings.Split(permsQuery, ",") { perms := map[string]bool{} for _, verb := range []string{"create", "update", "delete", "list", "get", "watch", "patch"} { - allowed := asl.AccessFor(userInfo).Grants(verb, schema2.GroupResource{Group: gvr.Group, Resource: res}, ns, "") + allowed := accessSet.Grants(verb, schema2.GroupResource{Group: gvr.Group, Resource: res}, ns, "") // TODO maybe add links rather than true/false perms[verb] = allowed } @@ -164,9 +164,7 @@ func formatter(summarycache *summarycache.SummaryCache, asl accesscontrol.Access if unstr, ok := resource.APIObject.Object.(*unstructured.Unstructured); ok { data.PutValue(unstr.Object, permissions, "resourcePermissions") } - } - } } @@ -206,10 +204,10 @@ func excludeValues(request *types.APIRequest, unstr *unstructured.Unstructured) } } -func getNamespaceFromResource(resourceId string) string { - parts := strings.SplitN(resourceId, "/", 2) +func getNamespaceFromResource(resourceID string) string { + parts := strings.SplitN(resourceID, "/", 2) if len(parts) == 2 { return parts[1] } - return resourceId + return resourceID } From cbefe738ef85d60671914ceaef68a858b235c6be Mon Sep 17 00:00:00 2001 From: Chad Roberts Date: Tue, 15 Apr 2025 12:59:32 -0400 Subject: [PATCH 3/5] Update formatter signature to use the interface to facilitate using a fake for testing --- pkg/resources/common/formatter.go | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/pkg/resources/common/formatter.go b/pkg/resources/common/formatter.go index 9f5b6a2a..a1028dbd 100644 --- a/pkg/resources/common/formatter.go +++ b/pkg/resources/common/formatter.go @@ -7,7 +7,9 @@ import ( "github.com/rancher/apiserver/pkg/types" "github.com/rancher/steve/pkg/accesscontrol" "github.com/rancher/steve/pkg/attributes" + "github.com/rancher/steve/pkg/resources/virtual/common" "github.com/rancher/steve/pkg/schema" + metricsStore "githu metricsStore "github.com/rancher/steve/pkg/stores/metrics" "github.com/rancher/steve/pkg/stores/proxy" "github.com/rancher/steve/pkg/summarycache" @@ -73,7 +75,7 @@ func selfLink(gvr schema2.GroupVersionResource, meta metav1.Object) (prefix stri return buf.String() } -func formatter(summarycache *summarycache.SummaryCache, asl accesscontrol.AccessSetLookup) types.Formatter { +func formatter(summarycache common.SummaryCache, asl accesscontrol.AccessSetLookup) types.Formatter { return func(request *types.APIRequest, resource *types.RawResource) { if resource.Schema == nil { return From eed59ebf8a359ede37f87e980768c71680aaa9da Mon Sep 17 00:00:00 2001 From: Chad Roberts Date: Wed, 16 Apr 2025 14:16:32 -0400 Subject: [PATCH 4/5] Add a few unit tests --- pkg/resources/common/formatter.go | 1 - pkg/resources/common/formatter_test.go | 220 +++++++++++++++++++++++++ 2 files changed, 220 insertions(+), 1 deletion(-) diff --git a/pkg/resources/common/formatter.go b/pkg/resources/common/formatter.go index a1028dbd..e7ed846e 100644 --- a/pkg/resources/common/formatter.go +++ b/pkg/resources/common/formatter.go @@ -9,7 +9,6 @@ import ( "github.com/rancher/steve/pkg/attributes" "github.com/rancher/steve/pkg/resources/virtual/common" "github.com/rancher/steve/pkg/schema" - metricsStore "githu metricsStore "github.com/rancher/steve/pkg/stores/metrics" "github.com/rancher/steve/pkg/stores/proxy" "github.com/rancher/steve/pkg/summarycache" diff --git a/pkg/resources/common/formatter_test.go b/pkg/resources/common/formatter_test.go index be46fa0a..4ef10540 100644 --- a/pkg/resources/common/formatter_test.go +++ b/pkg/resources/common/formatter_test.go @@ -5,6 +5,7 @@ import ( "context" "net/http" "net/url" + "strings" "testing" "github.com/rancher/apiserver/pkg/types" @@ -12,7 +13,9 @@ import ( "github.com/rancher/steve/pkg/accesscontrol" "github.com/rancher/steve/pkg/accesscontrol/fake" "github.com/rancher/steve/pkg/attributes" + "github.com/rancher/steve/pkg/resources/virtual/common" "github.com/rancher/wrangler/v3/pkg/schemas" + "github.com/rancher/wrangler/v3/pkg/summary" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "go.uber.org/mock/gomock" @@ -1072,3 +1075,220 @@ func Test_formatterLinks(t *testing.T) { }) } } + +func TestFormatterAddsResourcePermissions(t *testing.T) { + const ( + clusterid = "clusterid" + projectid = "projectid" + ) + + tests := []struct { + name string + topLevelPermissions []string + resourcePermissions map[string][]string + schema *types.APISchema + apiObject types.APIObject + want map[string]map[string]bool + }{ + { + name: "get update patch on project and get on projectroletemplatebindings", + topLevelPermissions: []string{"get", "update", "patch"}, + resourcePermissions: map[string][]string{ + "projectroletemplatebindings": {"get", "list", "watch"}, + }, + schema: &types.APISchema{ + Schema: &schemas.Schema{ + ID: clusterid + "/" + projectid, + Attributes: map[string]interface{}{ + "group": "management.cattle.io", + "version": "v1", + "resource": "projects", + }, + }, + }, + apiObject: types.APIObject{ + ID: clusterid + "/" + projectid, + Object: &unstructured.Unstructured{ + Object: map[string]interface{}{}, + }, + }, + want: map[string]map[string]bool{ + "projectroletemplatebindings": { + "get": true, + "list": true, + "watch": true, + "create": false, + "delete": false, + "patch": false, + "update": false, + }, + }, + }, + { + name: "get update patch on project and get on projectroletemplatebindings and pods", + topLevelPermissions: []string{"get", "update", "patch"}, + resourcePermissions: map[string][]string{ + "projectroletemplatebindings": {"get", "list", "watch"}, + "pods": {"get", "list", "watch"}, + }, + schema: &types.APISchema{ + Schema: &schemas.Schema{ + ID: clusterid + "/" + projectid, + Attributes: map[string]interface{}{ + "group": "management.cattle.io", + "version": "v1", + "resource": "projects", + }, + }, + }, + apiObject: types.APIObject{ + ID: clusterid + "/" + projectid, + Object: &unstructured.Unstructured{ + Object: map[string]interface{}{}, + }, + }, + want: map[string]map[string]bool{ + "projectroletemplatebindings": { + "get": true, + "list": true, + "watch": true, + "create": false, + "delete": false, + "patch": false, + "update": false, + }, + "pods": { + "get": true, + "list": true, + "watch": true, + "create": false, + "delete": false, + "patch": false, + "update": false, + }, + }, + }, + { + name: "get update remove on project and a checkPermissions on an unknown resource", + topLevelPermissions: []string{"get", "update", "patch"}, + resourcePermissions: map[string][]string{}, + schema: &types.APISchema{ + Schema: &schemas.Schema{ + ID: clusterid + "/" + projectid, + Attributes: map[string]interface{}{ + "group": "management.cattle.io", + "version": "v1", + "resource": "projects", + }, + }, + }, + apiObject: types.APIObject{ + ID: clusterid + "/" + projectid, + Object: &unstructured.Unstructured{ + Object: map[string]interface{}{}, + }, + }, + want: map[string]map[string]bool{ + "unknown": { + "get": false, + "list": false, + "watch": false, + "create": false, + "delete": false, + "patch": false, + "update": false, + }, + }, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + defaultUserInfo := user.DefaultInfo{} + + ctrl := gomock.NewController(t) + asl := fake.NewMockAccessSetLookup(ctrl) + accessSet := accesscontrol.AccessSet{} + + // Se up the AccessSet for the top level resource + if len(test.topLevelPermissions) > 0 { + gvr := attributes.GVR(test.schema) + objMeta, _ := meta.Accessor(test.apiObject.Object) + resource := accesscontrol.Access{ + Namespace: objMeta.GetNamespace(), + ResourceName: objMeta.GetName(), + } + + for _, verb := range test.topLevelPermissions { + accessSet.Add(verb, gvr.GroupResource(), resource) + } + } + + // Se up the AccessSet for the top nested resources + for resource, verbs := range test.resourcePermissions { + gvr := schema2.GroupVersionResource{ + Group: "management.cattle.io", + Version: "v1", + Resource: resource, + } + for _, verb := range verbs { + accessSet.Add(verb, gvr.GroupResource(), accesscontrol.Access{ + Namespace: projectid, + }) + } + } + + ctx := context.Background() + ctx = request.WithUser(ctx, &defaultUserInfo) + httpRequest, _ := http.NewRequestWithContext(ctx, "", "", bytes.NewBuffer([]byte{})) + + var checkPerms []string + for res := range test.want { + checkPerms = append(checkPerms, res) + } + + req := &types.APIRequest{ + Request: httpRequest, + URLBuilder: &urlbuilder.DefaultURLBuilder{}, + Query: url.Values{ + "checkPermissions": {strings.Join(checkPerms, ",")}, + }, + } + + resource := &types.RawResource{ + Schema: test.schema, + APIObject: test.apiObject, + Links: map[string]string{}, + } + fakeCache := &common.FakeSummaryCache{ + SummarizedObject: &summary.SummarizedObject{}, + } + + asl.EXPECT().AccessFor(&defaultUserInfo).Return(&accessSet).AnyTimes() + + formatter := formatter(fakeCache, asl) + formatter(req, resource) + + // Extract the resultant resourcePermissions + u, ok := resource.APIObject.Object.(*unstructured.Unstructured) + require.True(t, ok, "APIObject.Object is not Unstructured") + + rawPerms, ok := u.Object["resourcePermissions"] + require.True(t, ok, "resourcePermissions field missing") + + permMap, ok := rawPerms.(map[string]map[string]bool) + require.True(t, ok, "resourcePermissions is not map[string]map[string]bool") + + got := map[string]map[string]bool{} + for res, actionMap := range permMap { + got[res] = map[string]bool{} + for action, boolVal := range actionMap { + got[res][action] = boolVal + } + } + + require.Equal(t, test.want, got) + }) + } +} From 5fa7be534a1f411930fe6e161546e9990d333294 Mon Sep 17 00:00:00 2001 From: Chad Roberts Date: Wed, 23 Apr 2025 12:36:55 -0400 Subject: [PATCH 5/5] Refactor to add links instead of bool for resourcePermissions, updated tests accordingly. --- pkg/resources/common/formatter.go | 70 ++++++++++++++----- pkg/resources/common/formatter_test.go | 97 +++++++++++++------------- 2 files changed, 101 insertions(+), 66 deletions(-) diff --git a/pkg/resources/common/formatter.go b/pkg/resources/common/formatter.go index e7ed846e..3dd959b0 100644 --- a/pkg/resources/common/formatter.go +++ b/pkg/resources/common/formatter.go @@ -42,15 +42,20 @@ func DefaultTemplateForStore(store types.Store, } func selfLink(gvr schema2.GroupVersionResource, meta metav1.Object) (prefix string) { + return buildBasePath(gvr, meta.GetNamespace(), meta.GetName()) +} + +func buildBasePath(gvr schema2.GroupVersionResource, namespace string, includeName string) string { buf := &strings.Builder{} + if gvr.Group == "management.cattle.io" && gvr.Version == "v3" { buf.WriteString("/v1/") buf.WriteString(gvr.Group) buf.WriteString(".") buf.WriteString(gvr.Resource) - if meta.GetNamespace() != "" { + if namespace != "" { buf.WriteString("/") - buf.WriteString(meta.GetNamespace()) + buf.WriteString(namespace) } } else { if gvr.Group == "" { @@ -62,15 +67,19 @@ func selfLink(gvr schema2.GroupVersionResource, meta metav1.Object) (prefix stri buf.WriteString(gvr.Version) buf.WriteString("/") } - if meta.GetNamespace() != "" { + if namespace != "" { buf.WriteString("namespaces/") - buf.WriteString(meta.GetNamespace()) + buf.WriteString(namespace) buf.WriteString("/") } buf.WriteString(gvr.Resource) } - buf.WriteString("/") - buf.WriteString(meta.GetName()) + + if includeName != "" { + buf.WriteString("/") + buf.WriteString(includeName) + } + return buf.String() } @@ -148,16 +157,23 @@ func formatter(summarycache common.SummaryCache, asl accesscontrol.AccessSetLook } if permsQuery := request.Query.Get("checkPermissions"); permsQuery != "" { - id := resource.APIObject.ID - ns := getNamespaceFromResource(id) - gvr := attributes.GVR(resource.Schema) - permissions := map[string]map[string]bool{} + ns := getNamespaceFromResource(resource.APIObject) + permissions := map[string]map[string]string{} + for _, res := range strings.Split(permsQuery, ",") { - perms := map[string]bool{} + s := request.Schemas.LookupSchema(res) + if s == nil { + continue + } + gvr := attributes.GVR(s) + gr := schema2.GroupResource{Group: gvr.Group, Resource: gvr.Resource} + perms := map[string]string{} + for _, verb := range []string{"create", "update", "delete", "list", "get", "watch", "patch"} { - allowed := accessSet.Grants(verb, schema2.GroupResource{Group: gvr.Group, Resource: res}, ns, "") - // TODO maybe add links rather than true/false - perms[verb] = allowed + if accessSet.Grants(verb, gr, ns, "") { + url := request.URLBuilder.RelativeToRoot(buildBasePath(gvr, ns, "")) + perms[verb] = url + } } permissions[res] = perms } @@ -205,10 +221,26 @@ func excludeValues(request *types.APIRequest, unstr *unstructured.Unstructured) } } -func getNamespaceFromResource(resourceID string) string { - parts := strings.SplitN(resourceID, "/", 2) - if len(parts) == 2 { - return parts[1] +func getNamespaceFromResource(obj types.APIObject) string { + unstr, ok := obj.Object.(*unstructured.Unstructured) + if !ok { + return "" } - return resourceID + + // If we have a backingNamespace, use that + if statusRaw, ok := unstr.Object["status"]; ok { + if statusMap, ok := statusRaw.(map[string]interface{}); ok { + if backingNamespace, ok := statusMap["backingNamespace"].(string); ok && backingNamespace != "" { + return backingNamespace + } + } + } + + // Otherwise, if the id has a slash, we will interpret that + parts := strings.SplitN(obj.ID, "/", 2) + if len(parts) == 2 { + return parts[0] + "-" + parts[1] + } + + return obj.ID } diff --git a/pkg/resources/common/formatter_test.go b/pkg/resources/common/formatter_test.go index 4ef10540..0c1fd226 100644 --- a/pkg/resources/common/formatter_test.go +++ b/pkg/resources/common/formatter_test.go @@ -1088,7 +1088,7 @@ func TestFormatterAddsResourcePermissions(t *testing.T) { resourcePermissions map[string][]string schema *types.APISchema apiObject types.APIObject - want map[string]map[string]bool + want map[string]map[string]string }{ { name: "get update patch on project and get on projectroletemplatebindings", @@ -1112,15 +1112,11 @@ func TestFormatterAddsResourcePermissions(t *testing.T) { Object: map[string]interface{}{}, }, }, - want: map[string]map[string]bool{ + want: map[string]map[string]string{ "projectroletemplatebindings": { - "get": true, - "list": true, - "watch": true, - "create": false, - "delete": false, - "patch": false, - "update": false, + "get": "/apis/management.cattle.io/v1/namespaces/clusterid-projectid/projectroletemplatebindings", + "list": "/apis/management.cattle.io/v1/namespaces/clusterid-projectid/projectroletemplatebindings", + "watch": "/apis/management.cattle.io/v1/namespaces/clusterid-projectid/projectroletemplatebindings", }, }, }, @@ -1147,24 +1143,16 @@ func TestFormatterAddsResourcePermissions(t *testing.T) { Object: map[string]interface{}{}, }, }, - want: map[string]map[string]bool{ + want: map[string]map[string]string{ "projectroletemplatebindings": { - "get": true, - "list": true, - "watch": true, - "create": false, - "delete": false, - "patch": false, - "update": false, + "get": "/apis/management.cattle.io/v1/namespaces/clusterid-projectid/projectroletemplatebindings", + "list": "/apis/management.cattle.io/v1/namespaces/clusterid-projectid/projectroletemplatebindings", + "watch": "/apis/management.cattle.io/v1/namespaces/clusterid-projectid/projectroletemplatebindings", }, "pods": { - "get": true, - "list": true, - "watch": true, - "create": false, - "delete": false, - "patch": false, - "update": false, + "get": "/apis/management.cattle.io/v1/namespaces/clusterid-projectid/pods", + "list": "/apis/management.cattle.io/v1/namespaces/clusterid-projectid/pods", + "watch": "/apis/management.cattle.io/v1/namespaces/clusterid-projectid/pods", }, }, }, @@ -1188,17 +1176,7 @@ func TestFormatterAddsResourcePermissions(t *testing.T) { Object: map[string]interface{}{}, }, }, - want: map[string]map[string]bool{ - "unknown": { - "get": false, - "list": false, - "watch": false, - "create": false, - "delete": false, - "patch": false, - "update": false, - }, - }, + want: nil, }, } @@ -1234,7 +1212,7 @@ func TestFormatterAddsResourcePermissions(t *testing.T) { } for _, verb := range verbs { accessSet.Add(verb, gvr.GroupResource(), accesscontrol.Access{ - Namespace: projectid, + Namespace: clusterid + "-" + projectid, }) } } @@ -1254,6 +1232,30 @@ func TestFormatterAddsResourcePermissions(t *testing.T) { Query: url.Values{ "checkPermissions": {strings.Join(checkPerms, ",")}, }, + Schemas: types.EmptyAPISchemas(), + } + addSchema := func(names ...string) { + for _, name := range names { + if name == "unknown" { + continue + } + req.Schemas.MustAddSchema(types.APISchema{ + Schema: &schemas.Schema{ + ID: name, + CollectionMethods: []string{"get"}, + ResourceMethods: []string{"get"}, + Attributes: map[string]interface{}{ + "group": "management.cattle.io", + "resource": name, + "version": "v1", + }, + }, + }) + } + } + + for res := range test.want { + addSchema(res) } resource := &types.RawResource{ @@ -1274,21 +1276,22 @@ func TestFormatterAddsResourcePermissions(t *testing.T) { u, ok := resource.APIObject.Object.(*unstructured.Unstructured) require.True(t, ok, "APIObject.Object is not Unstructured") - rawPerms, ok := u.Object["resourcePermissions"] - require.True(t, ok, "resourcePermissions field missing") + if test.want != nil { + rawPerms, ok := u.Object["resourcePermissions"] + require.True(t, ok, "resourcePermissions field missing") - permMap, ok := rawPerms.(map[string]map[string]bool) - require.True(t, ok, "resourcePermissions is not map[string]map[string]bool") + permMap, ok := rawPerms.(map[string]map[string]string) + require.True(t, ok, "resourcePermissions is not map[string]map[string]string") - got := map[string]map[string]bool{} - for res, actionMap := range permMap { - got[res] = map[string]bool{} - for action, boolVal := range actionMap { - got[res][action] = boolVal + got := map[string]map[string]string{} + for res, actionMap := range permMap { + got[res] = map[string]string{} + for action, boolVal := range actionMap { + got[res][action] = boolVal + } } + require.Equal(t, test.want, got) } - - require.Equal(t, test.want, got) }) } }