diff --git a/pkg/resources/common/formatter.go b/pkg/resources/common/formatter.go index 7cc6da09..3dd959b0 100644 --- a/pkg/resources/common/formatter.go +++ b/pkg/resources/common/formatter.go @@ -7,6 +7,7 @@ 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 "github.com/rancher/steve/pkg/stores/metrics" "github.com/rancher/steve/pkg/stores/proxy" @@ -41,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 == "" { @@ -61,19 +67,23 @@ 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() } -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 @@ -146,6 +156,32 @@ func formatter(summarycache *summarycache.SummaryCache, asl accesscontrol.Access excludeValues(request, unstr) } + if permsQuery := request.Query.Get("checkPermissions"); permsQuery != "" { + ns := getNamespaceFromResource(resource.APIObject) + permissions := map[string]map[string]string{} + + for _, res := range strings.Split(permsQuery, ",") { + 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"} { + if accessSet.Grants(verb, gr, ns, "") { + url := request.URLBuilder.RelativeToRoot(buildBasePath(gvr, ns, "")) + perms[verb] = url + } + } + permissions[res] = perms + } + + if unstr, ok := resource.APIObject.Object.(*unstructured.Unstructured); ok { + data.PutValue(unstr.Object, permissions, "resourcePermissions") + } + } } } @@ -184,3 +220,27 @@ func excludeValues(request *types.APIRequest, unstr *unstructured.Unstructured) } } } + +func getNamespaceFromResource(obj types.APIObject) string { + unstr, ok := obj.Object.(*unstructured.Unstructured) + if !ok { + return "" + } + + // 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 be46fa0a..0c1fd226 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,223 @@ 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]string + }{ + { + 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]string{ + "projectroletemplatebindings": { + "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", + }, + }, + }, + { + 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]string{ + "projectroletemplatebindings": { + "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": "/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", + }, + }, + }, + { + 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: nil, + }, + } + + 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: clusterid + "-" + 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, ",")}, + }, + 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{ + 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") + + if test.want != nil { + rawPerms, ok := u.Object["resourcePermissions"] + require.True(t, ok, "resourcePermissions field missing") + + 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]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) + } + }) + } +} 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))