diff --git a/pkg/apis/folder/v0alpha1/types.go b/pkg/apis/folder/v0alpha1/types.go index 0687ea51054..05a29fc09e8 100644 --- a/pkg/apis/folder/v0alpha1/types.go +++ b/pkg/apis/folder/v0alpha1/types.go @@ -52,3 +52,21 @@ type FolderInfo struct { // The parent folder UID Parent string `json:"parent,omitempty"` } + +// Access control information for the current user +// +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object +type FolderAccessInfo struct { + metav1.TypeMeta `json:",inline"` + + CanSave bool `json:"canSave"` + CanEdit bool `json:"canEdit"` + CanAdmin bool `json:"canAdmin"` + CanDelete bool `json:"canDelete"` +} + +// +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object +type DescendantCounts struct { + metav1.TypeMeta `json:",inline"` + + Counts map[string]int64 `json:"counts"` +} diff --git a/pkg/apis/folder/v0alpha1/zz_generated.deepcopy.go b/pkg/apis/folder/v0alpha1/zz_generated.deepcopy.go index c98fd8cba48..2ddf035b533 100644 --- a/pkg/apis/folder/v0alpha1/zz_generated.deepcopy.go +++ b/pkg/apis/folder/v0alpha1/zz_generated.deepcopy.go @@ -11,6 +11,38 @@ import ( runtime "k8s.io/apimachinery/pkg/runtime" ) +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *DescendantCounts) DeepCopyInto(out *DescendantCounts) { + *out = *in + out.TypeMeta = in.TypeMeta + if in.Counts != nil { + in, out := &in.Counts, &out.Counts + *out = make(map[string]int64, len(*in)) + for key, val := range *in { + (*out)[key] = val + } + } + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new DescendantCounts. +func (in *DescendantCounts) DeepCopy() *DescendantCounts { + if in == nil { + return nil + } + out := new(DescendantCounts) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *DescendantCounts) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *Folder) DeepCopyInto(out *Folder) { *out = *in @@ -38,6 +70,31 @@ func (in *Folder) DeepCopyObject() runtime.Object { return nil } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *FolderAccessInfo) DeepCopyInto(out *FolderAccessInfo) { + *out = *in + out.TypeMeta = in.TypeMeta + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new FolderAccessInfo. +func (in *FolderAccessInfo) DeepCopy() *FolderAccessInfo { + if in == nil { + return nil + } + out := new(FolderAccessInfo) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *FolderAccessInfo) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *FolderInfo) DeepCopyInto(out *FolderInfo) { *out = *in diff --git a/pkg/apis/folder/v0alpha1/zz_generated.openapi.go b/pkg/apis/folder/v0alpha1/zz_generated.openapi.go index 361cd55a748..71d03fb914d 100644 --- a/pkg/apis/folder/v0alpha1/zz_generated.openapi.go +++ b/pkg/apis/folder/v0alpha1/zz_generated.openapi.go @@ -16,11 +16,55 @@ import ( func GetOpenAPIDefinitions(ref common.ReferenceCallback) map[string]common.OpenAPIDefinition { return map[string]common.OpenAPIDefinition{ - "github.com/grafana/grafana/pkg/apis/folder/v0alpha1.Folder": schema_pkg_apis_folder_v0alpha1_Folder(ref), - "github.com/grafana/grafana/pkg/apis/folder/v0alpha1.FolderInfo": schema_pkg_apis_folder_v0alpha1_FolderInfo(ref), - "github.com/grafana/grafana/pkg/apis/folder/v0alpha1.FolderInfoList": schema_pkg_apis_folder_v0alpha1_FolderInfoList(ref), - "github.com/grafana/grafana/pkg/apis/folder/v0alpha1.FolderList": schema_pkg_apis_folder_v0alpha1_FolderList(ref), - "github.com/grafana/grafana/pkg/apis/folder/v0alpha1.Spec": schema_pkg_apis_folder_v0alpha1_Spec(ref), + "github.com/grafana/grafana/pkg/apis/folder/v0alpha1.DescendantCounts": schema_pkg_apis_folder_v0alpha1_DescendantCounts(ref), + "github.com/grafana/grafana/pkg/apis/folder/v0alpha1.Folder": schema_pkg_apis_folder_v0alpha1_Folder(ref), + "github.com/grafana/grafana/pkg/apis/folder/v0alpha1.FolderAccessInfo": schema_pkg_apis_folder_v0alpha1_FolderAccessInfo(ref), + "github.com/grafana/grafana/pkg/apis/folder/v0alpha1.FolderInfo": schema_pkg_apis_folder_v0alpha1_FolderInfo(ref), + "github.com/grafana/grafana/pkg/apis/folder/v0alpha1.FolderInfoList": schema_pkg_apis_folder_v0alpha1_FolderInfoList(ref), + "github.com/grafana/grafana/pkg/apis/folder/v0alpha1.FolderList": schema_pkg_apis_folder_v0alpha1_FolderList(ref), + "github.com/grafana/grafana/pkg/apis/folder/v0alpha1.Spec": schema_pkg_apis_folder_v0alpha1_Spec(ref), + } +} + +func schema_pkg_apis_folder_v0alpha1_DescendantCounts(ref common.ReferenceCallback) common.OpenAPIDefinition { + return common.OpenAPIDefinition{ + Schema: spec.Schema{ + SchemaProps: spec.SchemaProps{ + Type: []string{"object"}, + Properties: map[string]spec.Schema{ + "kind": { + SchemaProps: spec.SchemaProps{ + Description: "Kind is a string value representing the REST resource this object represents. Servers may infer this from the endpoint the client submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds", + Type: []string{"string"}, + Format: "", + }, + }, + "apiVersion": { + SchemaProps: spec.SchemaProps{ + Description: "APIVersion defines the versioned schema of this representation of an object. Servers should convert recognized schemas to the latest internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources", + Type: []string{"string"}, + Format: "", + }, + }, + "counts": { + SchemaProps: spec.SchemaProps{ + Type: []string{"object"}, + AdditionalProperties: &spec.SchemaOrBool{ + Allows: true, + Schema: &spec.Schema{ + SchemaProps: spec.SchemaProps{ + Default: 0, + Type: []string{"integer"}, + Format: "int64", + }, + }, + }, + }, + }, + }, + Required: []string{"counts"}, + }, + }, } } @@ -64,6 +108,62 @@ func schema_pkg_apis_folder_v0alpha1_Folder(ref common.ReferenceCallback) common } } +func schema_pkg_apis_folder_v0alpha1_FolderAccessInfo(ref common.ReferenceCallback) common.OpenAPIDefinition { + return common.OpenAPIDefinition{ + Schema: spec.Schema{ + SchemaProps: spec.SchemaProps{ + Description: "Access control information for the current user", + Type: []string{"object"}, + Properties: map[string]spec.Schema{ + "kind": { + SchemaProps: spec.SchemaProps{ + Description: "Kind is a string value representing the REST resource this object represents. Servers may infer this from the endpoint the client submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds", + Type: []string{"string"}, + Format: "", + }, + }, + "apiVersion": { + SchemaProps: spec.SchemaProps{ + Description: "APIVersion defines the versioned schema of this representation of an object. Servers should convert recognized schemas to the latest internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources", + Type: []string{"string"}, + Format: "", + }, + }, + "canSave": { + SchemaProps: spec.SchemaProps{ + Default: false, + Type: []string{"boolean"}, + Format: "", + }, + }, + "canEdit": { + SchemaProps: spec.SchemaProps{ + Default: false, + Type: []string{"boolean"}, + Format: "", + }, + }, + "canAdmin": { + SchemaProps: spec.SchemaProps{ + Default: false, + Type: []string{"boolean"}, + Format: "", + }, + }, + "canDelete": { + SchemaProps: spec.SchemaProps{ + Default: false, + Type: []string{"boolean"}, + Format: "", + }, + }, + }, + Required: []string{"canSave", "canEdit", "canAdmin", "canDelete"}, + }, + }, + } +} + func schema_pkg_apis_folder_v0alpha1_FolderInfo(ref common.ReferenceCallback) common.OpenAPIDefinition { return common.OpenAPIDefinition{ Schema: spec.Schema{ diff --git a/pkg/registry/apis/folders/conversions.go b/pkg/registry/apis/folders/conversions.go index 11ec26f28fb..9c9678a4909 100644 --- a/pkg/registry/apis/folders/conversions.go +++ b/pkg/registry/apis/folders/conversions.go @@ -42,7 +42,9 @@ func convertToK8sResource(v *folder.Folder, namespacer request.NamespaceMapper) meta.SetUpdatedBy(fmt.Sprintf("user:%d", v.UpdatedBy)) } } - + if v.ParentUID != "" { + meta.SetFolder(v.ParentUID) + } f.UID = utils.CalculateClusterWideUID(f) return f } diff --git a/pkg/registry/apis/folders/register.go b/pkg/registry/apis/folders/register.go index 15544b9f68b..5147d801643 100644 --- a/pkg/registry/apis/folders/register.go +++ b/pkg/registry/apis/folders/register.go @@ -1,6 +1,7 @@ package folders import ( + "context" "fmt" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" @@ -14,10 +15,13 @@ import ( common "k8s.io/kube-openapi/pkg/common" "github.com/grafana/grafana/pkg/apis/folder/v0alpha1" + "github.com/grafana/grafana/pkg/infra/appcontext" + "github.com/grafana/grafana/pkg/services/accesscontrol" "github.com/grafana/grafana/pkg/services/apiserver/builder" "github.com/grafana/grafana/pkg/services/apiserver/endpoints/request" grafanarest "github.com/grafana/grafana/pkg/services/apiserver/rest" "github.com/grafana/grafana/pkg/services/apiserver/utils" + "github.com/grafana/grafana/pkg/services/dashboards" "github.com/grafana/grafana/pkg/services/featuremgmt" "github.com/grafana/grafana/pkg/services/folder" "github.com/grafana/grafana/pkg/setting" @@ -29,26 +33,29 @@ var resourceInfo = v0alpha1.FolderResourceInfo // This is used just so wire has something unique to return type FolderAPIBuilder struct { - gv schema.GroupVersion - features *featuremgmt.FeatureManager - namespacer request.NamespaceMapper - folderSvc folder.Service + gv schema.GroupVersion + features *featuremgmt.FeatureManager + namespacer request.NamespaceMapper + folderSvc folder.Service + accessControl accesscontrol.AccessControl } func RegisterAPIService(cfg *setting.Cfg, features *featuremgmt.FeatureManager, apiregistration builder.APIRegistrar, folderSvc folder.Service, + accessControl accesscontrol.AccessControl, ) *FolderAPIBuilder { if !features.IsEnabledGlobally(featuremgmt.FlagGrafanaAPIServerWithExperimentalAPIs) { return nil // skip registration unless opting into experimental apis } builder := &FolderAPIBuilder{ - gv: resourceInfo.GroupVersion(), - features: features, - namespacer: request.GetNamespaceMapper(cfg), - folderSvc: folderSvc, + gv: resourceInfo.GroupVersion(), + features: features, + namespacer: request.GetNamespaceMapper(cfg), + folderSvc: folderSvc, + accessControl: accessControl, } apiregistration.RegisterAPI(builder) return builder @@ -63,6 +70,8 @@ func addKnownTypes(scheme *runtime.Scheme, gv schema.GroupVersion) { &v0alpha1.Folder{}, &v0alpha1.FolderList{}, &v0alpha1.FolderInfoList{}, + &v0alpha1.DescendantCounts{}, + &v0alpha1.FolderAccessInfo{}, ) } @@ -120,7 +129,8 @@ func (b *FolderAPIBuilder) GetAPIGroupInfo( storage := map[string]rest.Storage{} storage[resourceInfo.StoragePath()] = legacyStore storage[resourceInfo.StoragePath("parents")] = &subParentsREST{b.folderSvc} - storage[resourceInfo.StoragePath("children")] = &subChildrenREST{b.folderSvc} + storage[resourceInfo.StoragePath("count")] = &subCountREST{b.folderSvc} + storage[resourceInfo.StoragePath("access")] = &subAccessREST{b.folderSvc} // enable dual writes if a RESTOptionsGetter is provided if dualWrite && optsGetter != nil { @@ -144,5 +154,39 @@ func (b *FolderAPIBuilder) GetAPIRoutes() *builder.APIRoutes { } func (b *FolderAPIBuilder) GetAuthorizer() authorizer.Authorizer { - return nil // TODO: the FGAC rules encoded in the service can be moved here + return authorizer.AuthorizerFunc( + func(ctx context.Context, attr authorizer.Attributes) (authorized authorizer.Decision, reason string, err error) { + if !attr.IsResourceRequest() || attr.GetName() == "" { + return authorizer.DecisionNoOpinion, "", nil + } + + // require a user + user, err := appcontext.User(ctx) + if err != nil { + return authorizer.DecisionDeny, "valid user is required", err + } + + action := dashboards.ActionFoldersRead + scope := dashboards.ScopeFoldersProvider.GetResourceScopeUID(attr.GetName()) + + // "get" is used for sub-resources with GET http (parents, access, count) + switch attr.GetVerb() { + case "patch": + fallthrough + case "create": + fallthrough + case "update": + action = dashboards.ActionFoldersWrite + case "deletecollection": + fallthrough + case "delete": + action = dashboards.ActionFoldersDelete + } + + ok, err := b.accessControl.Evaluate(ctx, user, accesscontrol.EvalPermission(action, scope)) + if ok { + return authorizer.DecisionAllow, "", nil + } + return authorizer.DecisionDeny, "folder", err + }) } diff --git a/pkg/registry/apis/folders/sub_access.go b/pkg/registry/apis/folders/sub_access.go new file mode 100644 index 00000000000..46dc7dd5aad --- /dev/null +++ b/pkg/registry/apis/folders/sub_access.go @@ -0,0 +1,69 @@ +package folders + +import ( + "context" + "net/http" + + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apiserver/pkg/registry/rest" + + "github.com/grafana/grafana/pkg/apis/folder/v0alpha1" + "github.com/grafana/grafana/pkg/infra/appcontext" + "github.com/grafana/grafana/pkg/services/apiserver/endpoints/request" + "github.com/grafana/grafana/pkg/services/folder" + "github.com/grafana/grafana/pkg/services/guardian" +) + +type subAccessREST struct { + service folder.Service +} + +var _ = rest.Connecter(&subAccessREST{}) + +func (r *subAccessREST) New() runtime.Object { + return &v0alpha1.FolderAccessInfo{} +} + +func (r *subAccessREST) Destroy() { +} + +func (r *subAccessREST) ConnectMethods() []string { + return []string{"GET"} +} + +func (r *subAccessREST) NewConnectOptions() (runtime.Object, bool, string) { + return nil, false, "" // true means you can use the trailing path as a variable +} + +func (r *subAccessREST) Connect(ctx context.Context, name string, opts runtime.Object, responder rest.Responder) (http.Handler, error) { + ns, err := request.NamespaceInfoFrom(ctx, true) + if err != nil { + return nil, err + } + user, err := appcontext.User(ctx) + if err != nil { + return nil, err + } + // Can view is managed here (and in the Authorizer) + f, err := r.service.Get(ctx, &folder.GetFolderQuery{ + UID: &name, + OrgID: ns.OrgID, + SignedInUser: user, + }) + if err != nil { + return nil, err + } + guardian, err := guardian.NewByFolder(ctx, f, ns.OrgID, user) + if err != nil { + return nil, err + } + + return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { + access := &v0alpha1.FolderAccessInfo{} + access.CanEdit, _ = guardian.CanEdit() + access.CanSave, _ = guardian.CanSave() + access.CanAdmin, _ = guardian.CanAdmin() + access.CanDelete, _ = guardian.CanDelete() + responder.Object(http.StatusOK, access) + }), nil +} diff --git a/pkg/registry/apis/folders/sub_children.go b/pkg/registry/apis/folders/sub_children.go deleted file mode 100644 index 9d5deb40712..00000000000 --- a/pkg/registry/apis/folders/sub_children.go +++ /dev/null @@ -1,73 +0,0 @@ -package folders - -import ( - "context" - "net/http" - - "k8s.io/apimachinery/pkg/runtime" - "k8s.io/apiserver/pkg/registry/rest" - - "github.com/grafana/grafana/pkg/apis/folder/v0alpha1" - "github.com/grafana/grafana/pkg/infra/appcontext" - "github.com/grafana/grafana/pkg/services/apiserver/endpoints/request" - "github.com/grafana/grafana/pkg/services/folder" -) - -type subChildrenREST struct { - service folder.Service -} - -var _ = rest.Connecter(&subChildrenREST{}) - -func (r *subChildrenREST) New() runtime.Object { - return &v0alpha1.FolderInfoList{} -} - -func (r *subChildrenREST) Destroy() { -} - -func (r *subChildrenREST) ConnectMethods() []string { - return []string{"GET"} -} - -func (r *subChildrenREST) NewConnectOptions() (runtime.Object, bool, string) { - return nil, false, "" // true means you can use the trailing path as a variable -} - -func (r *subChildrenREST) Connect(ctx context.Context, name string, opts runtime.Object, responder rest.Responder) (http.Handler, error) { - return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { - ns, err := request.NamespaceInfoFrom(ctx, true) - if err != nil { - responder.Error(err) - return - } - - user, err := appcontext.User(ctx) - if err != nil { - responder.Error(err) - return - } - - children, err := r.service.GetChildren(ctx, &folder.GetChildrenQuery{ - SignedInUser: user, - UID: name, - OrgID: ns.OrgID, - }) - if err != nil { - responder.Error(err) - return - } - - info := &v0alpha1.FolderInfoList{ - Items: make([]v0alpha1.FolderInfo, 0), - } - for _, parent := range children { - info.Items = append(info.Items, v0alpha1.FolderInfo{ - UID: parent.UID, - Title: parent.Title, - Parent: parent.ParentUID, - }) - } - responder.Object(http.StatusOK, info) - }), nil -} diff --git a/pkg/registry/apis/folders/sub_count.go b/pkg/registry/apis/folders/sub_count.go new file mode 100644 index 00000000000..cf2c00f8ad2 --- /dev/null +++ b/pkg/registry/apis/folders/sub_count.go @@ -0,0 +1,64 @@ +package folders + +import ( + "context" + "net/http" + + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apiserver/pkg/registry/rest" + + "github.com/grafana/grafana/pkg/apis/folder/v0alpha1" + "github.com/grafana/grafana/pkg/infra/appcontext" + "github.com/grafana/grafana/pkg/services/apiserver/endpoints/request" + "github.com/grafana/grafana/pkg/services/folder" +) + +type subCountREST struct { + service folder.Service +} + +var _ = rest.Connecter(&subCountREST{}) + +func (r *subCountREST) New() runtime.Object { + return &v0alpha1.DescendantCounts{} +} + +func (r *subCountREST) Destroy() { +} + +func (r *subCountREST) ConnectMethods() []string { + return []string{"GET"} +} + +func (r *subCountREST) NewConnectOptions() (runtime.Object, bool, string) { + return nil, false, "" // true means you can use the trailing path as a variable +} + +func (r *subCountREST) Connect(ctx context.Context, name string, opts runtime.Object, responder rest.Responder) (http.Handler, error) { + user, err := appcontext.User(ctx) + if err != nil { + return nil, err + } + + return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { + ns, err := request.NamespaceInfoFrom(ctx, true) + if err != nil { + responder.Error(err) + return + } + + counts, err := r.service.GetDescendantCounts(ctx, &folder.GetDescendantCountsQuery{ + UID: &name, + OrgID: ns.OrgID, + SignedInUser: user, + }) + if err != nil { + responder.Error(err) + return + } + + responder.Object(http.StatusOK, &v0alpha1.DescendantCounts{ + Counts: counts, + }) + }), nil +}