Validate if we are creating a folder more than 5 levels deep (#95579)

* Add getter to FolderAPIBuilder so that we can access it in admission

* Remove deprecated return

* Fix test

* Update pkg/registry/apis/folders/register_test.go

Co-authored-by: maicon <maiconscosta@gmail.com>

* Fix maxNested folder test

* Remove log

---------

Co-authored-by: maicon <maiconscosta@gmail.com>
This commit is contained in:
Leonor Oliveira 2024-10-31 12:19:53 +01:00 committed by GitHub
parent 841a87c978
commit b1157dbd7a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 136 additions and 18 deletions

View File

@ -0,0 +1,69 @@
package folders
import (
"context"
"github.com/stretchr/testify/mock"
"k8s.io/apimachinery/pkg/apis/meta/internalversion"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/apiserver/pkg/registry/rest"
)
type storageMock struct {
*mock.Mock
rest.Storage
}
func (m storageMock) Get(ctx context.Context, name string, options *metav1.GetOptions) (runtime.Object, error) {
args := m.Called(ctx, name, options)
return args.Get(0).(runtime.Object), args.Error(1)
}
func (m storageMock) ConvertToTable(ctx context.Context, obj runtime.Object, tableOptions runtime.Object) (*metav1.Table, error) {
args := m.Called(ctx, obj, tableOptions)
return args.Get(0).(*metav1.Table), args.Error(1)
}
func (m storageMock) Create(ctx context.Context, obj runtime.Object, createValidation rest.ValidateObjectFunc, options *metav1.CreateOptions) (runtime.Object, error) {
args := m.Called(ctx, obj, createValidation, options)
return args.Get(0).(runtime.Object), args.Error(1)
}
func (m storageMock) Delete(ctx context.Context, name string, deleteValidation rest.ValidateObjectFunc, options *metav1.DeleteOptions) (runtime.Object, bool, error) {
args := m.Called(ctx, name, deleteValidation, options)
return args.Get(0).(runtime.Object), args.Bool(1), args.Error(2)
}
func (m storageMock) DeleteCollection(ctx context.Context, deleteValidation rest.ValidateObjectFunc, options *metav1.DeleteOptions, listOptions *internalversion.ListOptions) (runtime.Object, error) {
args := m.Called(ctx, deleteValidation, options, listOptions)
return args.Get(0).(runtime.Object), args.Error(1)
}
func (m storageMock) GetSingularName() string {
args := m.Called()
return args.String(0)
}
func (m storageMock) List(ctx context.Context, options *internalversion.ListOptions) (runtime.Object, error) {
args := m.Called(ctx, options)
return args.Get(0).(runtime.Object), args.Error(1)
}
func (m storageMock) NamespaceScoped() bool {
args := m.Called()
return args.Bool(0)
}
func (m storageMock) NewList() runtime.Object {
args := m.Called()
return args.Get(0).(runtime.Object)
}
func (m storageMock) Update(ctx context.Context, name string, objInfo rest.UpdatedObjectInfo, createValidation rest.ValidateObjectFunc, updateValidation rest.ValidateObjectUpdateFunc, forceAllowCreate bool, options *metav1.UpdateOptions) (runtime.Object, bool, error) {
args := m.Called(ctx, name, objInfo, createValidation, updateValidation, forceAllowCreate, options)
if name == "object-fail" {
return nil, false, args.Error(2)
}
return args.Get(0).(runtime.Object), args.Bool(1), args.Error(2)
}

View File

@ -4,6 +4,7 @@ import (
"context" "context"
"errors" "errors"
grafanarest "github.com/grafana/grafana/pkg/apiserver/rest"
"github.com/prometheus/client_golang/prometheus" "github.com/prometheus/client_golang/prometheus"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/runtime"
@ -36,20 +37,13 @@ var resourceInfo = v0alpha1.FolderResourceInfo
var errNoUser = errors.New("valid user is required") var errNoUser = errors.New("valid user is required")
var errNoResource = errors.New("resource name is required") var errNoResource = errors.New("resource name is required")
var folderValidationRules = struct {
maxDepth int
invalidNames []string
}{
maxDepth: 4,
invalidNames: []string{"general"},
}
// This is used just so wire has something unique to return // This is used just so wire has something unique to return
type FolderAPIBuilder struct { type FolderAPIBuilder struct {
gv schema.GroupVersion gv schema.GroupVersion
features featuremgmt.FeatureToggles features featuremgmt.FeatureToggles
namespacer request.NamespaceMapper namespacer request.NamespaceMapper
folderSvc folder.Service folderSvc folder.Service
storage grafanarest.Storage
accessControl accesscontrol.AccessControl accessControl accesscontrol.AccessControl
} }
@ -138,6 +132,7 @@ func (b *FolderAPIBuilder) UpdateAPIGroupInfo(apiGroupInfo *genericapiserver.API
} }
apiGroupInfo.VersionedResourcesStorageMap[v0alpha1.VERSION] = storage apiGroupInfo.VersionedResourcesStorageMap[v0alpha1.VERSION] = storage
b.storage = storage[resourceInfo.StoragePath()].(grafanarest.Storage)
return nil return nil
} }
@ -225,8 +220,15 @@ func authorizerFunc(ctx context.Context, attr authorizer.Attributes) (*authorize
return &authorizerParams{evaluator: eval, user: user}, nil return &authorizerParams{evaluator: eval, user: user}, nil
} }
var folderValidationRules = struct {
maxDepth int
invalidNames []string
}{
maxDepth: 5,
invalidNames: []string{"general"},
}
func (b *FolderAPIBuilder) Validate(ctx context.Context, a admission.Attributes, _ admission.ObjectInterfaces) error { func (b *FolderAPIBuilder) Validate(ctx context.Context, a admission.Attributes, _ admission.ObjectInterfaces) error {
// name cannot be called "general"
id := a.GetName() id := a.GetName()
for _, invalidName := range folderValidationRules.invalidNames { for _, invalidName := range folderValidationRules.invalidNames {
if id == invalidName { if id == invalidName {
@ -236,18 +238,20 @@ func (b *FolderAPIBuilder) Validate(ctx context.Context, a admission.Attributes,
obj := a.GetObject() obj := a.GetObject()
for i := 0; i <= folderValidationRules.maxDepth+1; i++ { for i := 1; i <= folderValidationRules.maxDepth; i++ {
parent := getParent(obj) parent := getParent(obj)
if parent == "" { if parent == "" {
break break
} }
if i == folderValidationRules.maxDepth+1 { if i == folderValidationRules.maxDepth {
return folder.ErrMaximumDepthReached return folder.ErrMaximumDepthReached
} }
// TODO: investigate the best way to get a folder by name, considering validate is called before the storage layer parentObj, err := b.storage.Get(ctx, parent, &metav1.GetOptions{})
// parent, err := getFolderByNameSomehow() if err != nil {
// obj = parent return err
}
obj = parentObj
} }
return nil return nil
} }

View File

@ -7,12 +7,15 @@ import (
"github.com/grafana/grafana/pkg/apimachinery/identity" "github.com/grafana/grafana/pkg/apimachinery/identity"
"github.com/grafana/grafana/pkg/apimachinery/utils" "github.com/grafana/grafana/pkg/apimachinery/utils"
"github.com/grafana/grafana/pkg/apis/folder/v0alpha1" "github.com/grafana/grafana/pkg/apis/folder/v0alpha1"
grafanarest "github.com/grafana/grafana/pkg/apiserver/rest"
"github.com/grafana/grafana/pkg/services/accesscontrol/acimpl" "github.com/grafana/grafana/pkg/services/accesscontrol/acimpl"
"github.com/grafana/grafana/pkg/services/authz/zanzana" "github.com/grafana/grafana/pkg/services/authz/zanzana"
"github.com/grafana/grafana/pkg/services/dashboards" "github.com/grafana/grafana/pkg/services/dashboards"
"github.com/grafana/grafana/pkg/services/featuremgmt" "github.com/grafana/grafana/pkg/services/featuremgmt"
"github.com/grafana/grafana/pkg/services/folder"
"github.com/grafana/grafana/pkg/services/folder/foldertest" "github.com/grafana/grafana/pkg/services/folder/foldertest"
"github.com/grafana/grafana/pkg/services/user" "github.com/grafana/grafana/pkg/services/user"
"github.com/stretchr/testify/mock"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
"k8s.io/apiserver/pkg/admission" "k8s.io/apiserver/pkg/admission"
@ -124,12 +127,13 @@ func TestFolderAPIBuilder_Validate(t *testing.T) {
name string name string
} }
tests := []struct { tests := []struct {
name string name string
input input input input
err error setupFn func(*mock.Mock)
err error
}{ }{
{ {
name: "should return error when nameis invalid", name: "should return error when name is invalid",
input: input{ input: input{
obj: &unstructured.Unstructured{ obj: &unstructured.Unstructured{
Object: map[string]interface{}{ Object: map[string]interface{}{
@ -140,17 +144,58 @@ func TestFolderAPIBuilder_Validate(t *testing.T) {
}, },
err: dashboards.ErrFolderInvalidUID, err: dashboards.ErrFolderInvalidUID,
}, },
{
name: "should return no error if every validation passes",
input: input{
obj: &unstructured.Unstructured{
Object: map[string]interface{}{
"meta": map[string]interface{}{"name": "valid-name"},
},
},
name: "valid-name",
},
},
{
name: "should return error when creating a nested folder higher than max depth",
input: input{
obj: &unstructured.Unstructured{
Object: map[string]any{
"metadata": map[string]any{"name": "valid-name", "annotations": map[string]any{"grafana.app/folder": "valid-name"}},
},
},
name: "valid-name",
},
setupFn: func(m *mock.Mock) {
m.On("Get", mock.Anything, "valid-name", mock.Anything).Return(
&unstructured.Unstructured{
Object: map[string]any{
"metadata": map[string]any{"name": "valid-name", "annotations": map[string]any{"grafana.app/folder": "valid-name"}},
},
}, nil)
},
err: folder.ErrMaximumDepthReached,
},
} }
s := (grafanarest.Storage)(nil)
m := &mock.Mock{}
us := storageMock{m, s}
b := &FolderAPIBuilder{ b := &FolderAPIBuilder{
gv: resourceInfo.GroupVersion(), gv: resourceInfo.GroupVersion(),
features: nil, features: nil,
namespacer: func(_ int64) string { return "123" }, namespacer: func(_ int64) string { return "123" },
folderSvc: foldertest.NewFakeService(), folderSvc: foldertest.NewFakeService(),
storage: us,
accessControl: acimpl.ProvideAccessControl(featuremgmt.WithFeatures("nestedFolders"), zanzana.NewNoopClient()), accessControl: acimpl.ProvideAccessControl(featuremgmt.WithFeatures("nestedFolders"), zanzana.NewNoopClient()),
} }
for _, tt := range tests { for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
if tt.setupFn != nil {
tt.setupFn(m)
}
err := b.Validate(context.Background(), admission.NewAttributesRecord( err := b.Validate(context.Background(), admission.NewAttributesRecord(
tt.input.obj, tt.input.obj,
nil, nil,