diff --git a/apps/iam/pkg/reconcilers/zanzana_service.go b/apps/iam/pkg/reconcilers/zanzana_service.go index 5c93f236b7b..a62cec83b80 100644 --- a/apps/iam/pkg/reconcilers/zanzana_service.go +++ b/apps/iam/pkg/reconcilers/zanzana_service.go @@ -25,6 +25,11 @@ func (c *ZanzanaPermissionStore) SetFolderParent(ctx context.Context, namespace, return err } + if parentUID == "" { + // Setting the parent to empty means the folder is at root which Zanzana doesn't care about. + return nil + } + user, err := toFolderTuple(parentUID) if err != nil { return err diff --git a/pkg/registry/apis/folders/hooks.go b/pkg/registry/apis/folders/hooks.go new file mode 100644 index 00000000000..e686304cba5 --- /dev/null +++ b/pkg/registry/apis/folders/hooks.go @@ -0,0 +1,61 @@ +package folders + +import ( + "context" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apiserver/pkg/registry/generic/registry" + + "github.com/grafana/grafana-app-sdk/logging" + "github.com/grafana/grafana/pkg/apimachinery/utils" +) + +// "Almost nobody should use this hook" but we do because we need ctx and AfterCreate doesn't have it. +func (b *FolderAPIBuilder) beginCreate(_ context.Context, obj runtime.Object, _ *metav1.CreateOptions) (registry.FinishFunc, error) { + meta, err := utils.MetaAccessor(obj) + if err != nil { + return nil, err + } + + if meta.GetFolder() == "" { + // Zanzana only cares about parent-child folder relationships; nothing to do if folder is at root. + return func(ctx context.Context, success bool) {}, nil + } + + return func(ctx context.Context, success bool) { + if success { + b.writeFolderToZanzana(ctx, meta) + } + }, nil +} + +// "Almost nobody should use this hook" but we do because we need ctx and AfterUpdate doesn't have it. +func (b *FolderAPIBuilder) beginUpdate(_ context.Context, obj runtime.Object, old runtime.Object, _ *metav1.UpdateOptions) (registry.FinishFunc, error) { + updatedMeta, err := utils.MetaAccessor(obj) + if err != nil { + return nil, err + } + oldMeta, err := utils.MetaAccessor(old) + if err != nil { + return nil, err + } + + if updatedMeta.GetFolder() == oldMeta.GetFolder() { + // No change to parent folder, nothing to do. + return func(ctx context.Context, success bool) {}, nil + } + + return func(ctx context.Context, success bool) { + if success { + b.writeFolderToZanzana(ctx, updatedMeta) + } + }, nil +} + +func (b *FolderAPIBuilder) writeFolderToZanzana(ctx context.Context, folder utils.GrafanaMetaAccessor) { + err := b.permissionStore.SetFolderParent(ctx, folder.GetNamespace(), folder.GetName(), folder.GetFolder()) + if err != nil { + logging.FromContext(ctx).Warn("failed to propagate folder to zanzana", "err", err) + } +} diff --git a/pkg/registry/apis/folders/hooks_test.go b/pkg/registry/apis/folders/hooks_test.go new file mode 100644 index 00000000000..81f1afbb91e --- /dev/null +++ b/pkg/registry/apis/folders/hooks_test.go @@ -0,0 +1,132 @@ +package folders + +import ( + "context" + "testing" + + "github.com/grafana/grafana/apps/iam/pkg/reconcilers" + "github.com/grafana/grafana/pkg/services/folder" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" + "k8s.io/apimachinery/pkg/runtime" +) + +func TestFolderSyncHooks_Create(t *testing.T) { + tests := []struct { + name string + expectedCallsToZanzana int + folder runtime.Object + updateSuccessful bool + }{ + { + name: "folder at root does nothing", + expectedCallsToZanzana: 0, + folder: getFolderObj("foo", ""), + updateSuccessful: true, + }, + { + name: "unsuccessful create does nothing", + expectedCallsToZanzana: 0, + folder: getFolderObj("foo", "bar"), + updateSuccessful: false, + }, + { + name: "successful create writes to zanzana", + expectedCallsToZanzana: 1, + folder: getFolderObj("foo", "bar"), + updateSuccessful: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + storeMock := newMockStore() + b := &FolderAPIBuilder{ + permissionStore: storeMock, + } + + f, err := b.beginCreate(context.Background(), tt.folder, nil) + if err != nil { + require.NoError(t, err) + } + + f(nil, tt.updateSuccessful) + + storeMock.AssertNumberOfCalls(t, "SetFolderParent", tt.expectedCallsToZanzana) + }) + } +} + +func TestFolderSyncHooks_Update(t *testing.T) { + tests := []struct { + name string + expectedCallsToZanzana int + newFolder runtime.Object + oldFolder runtime.Object + updateSuccessful bool + }{ + { + name: "no folder change does nothing", + expectedCallsToZanzana: 0, + oldFolder: getFolderObj("foo", "bar"), + newFolder: getFolderObj("foo", "bar"), + updateSuccessful: true, + }, + { + name: "unsuccessful update does nothing", + expectedCallsToZanzana: 0, + oldFolder: getFolderObj("foo", "bar"), + newFolder: getFolderObj("foo", "hop"), + updateSuccessful: false, + }, + { + name: "successful update writes to zanzana", + expectedCallsToZanzana: 1, + oldFolder: getFolderObj("foo", "bar"), + newFolder: getFolderObj("foo", "hop"), + updateSuccessful: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + storeMock := newMockStore() + b := &FolderAPIBuilder{ + permissionStore: storeMock, + } + + f, err := b.beginUpdate(context.Background(), tt.newFolder, tt.oldFolder, nil) + if err != nil { + require.NoError(t, err) + } + + f(nil, tt.updateSuccessful) + + storeMock.AssertNumberOfCalls(t, "SetFolderParent", tt.expectedCallsToZanzana) + }) + } +} + +func getFolderObj(uid, parentUid string) runtime.Object { + f, _ := LegacyCreateCommandToUnstructured(&folder.CreateFolderCommand{ + UID: uid, + ParentUID: parentUid, + }) + return f +} + +func newMockStore() *mockZanzanaPermissionStore { + store := mockZanzanaPermissionStore{} + store.On("SetFolderParent", mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(nil) + return &store +} + +type mockZanzanaPermissionStore struct { + mock.Mock + reconcilers.PermissionStore +} + +func (m *mockZanzanaPermissionStore) SetFolderParent(_ context.Context, _, _, _ string) error { + m.Called() + return nil +} diff --git a/pkg/registry/apis/folders/register.go b/pkg/registry/apis/folders/register.go index 15b3b102bd3..9eeacb56e65 100644 --- a/pkg/registry/apis/folders/register.go +++ b/pkg/registry/apis/folders/register.go @@ -6,6 +6,8 @@ import ( "fmt" "strings" + "github.com/grafana/grafana/apps/iam/pkg/reconcilers" + "github.com/grafana/grafana/pkg/services/authz/zanzana" "github.com/prometheus/client_golang/prometheus" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime" @@ -51,6 +53,7 @@ type FolderAPIBuilder struct { acService accesscontrol.Service ac accesscontrol.AccessControl storage grafanarest.Storage + permissionStore reconcilers.PermissionStore authorizer authorizer.Authorizer parents parentsGetter @@ -69,6 +72,7 @@ func RegisterAPIService(cfg *setting.Cfg, acService accesscontrol.Service, registerer prometheus.Registerer, unified resource.ResourceClient, + zanzanaClient zanzana.Client, ) *FolderAPIBuilder { builder := &FolderAPIBuilder{ gv: resourceInfo.GroupVersion(), @@ -81,6 +85,7 @@ func RegisterAPIService(cfg *setting.Cfg, permissionsOnCreate: cfg.RBAC.PermissionsOnCreation("folder"), authorizer: newLegacyAuthorizer(accessControl), searcher: unified, + permissionStore: reconcilers.NewZanzanaPermissionStore(zanzanaClient), } apiregistration.RegisterAPI(builder) return builder @@ -172,6 +177,11 @@ func (b *FolderAPIBuilder) UpdateAPIGroupInfo(apiGroupInfo *genericapiserver.API return err } + if b.features.IsEnabledGlobally(featuremgmt.FlagZanzana) { + store.BeginCreate = b.beginCreate + store.BeginUpdate = b.beginUpdate + } + dw, err := dualWriteBuilder(resourceInfo.GroupResource(), legacyStore, store) if err != nil { return err diff --git a/pkg/server/wire_gen.go b/pkg/server/wire_gen.go index df214129e03..6e0eb258d92 100644 --- a/pkg/server/wire_gen.go +++ b/pkg/server/wire_gen.go @@ -797,7 +797,7 @@ func Initialize(ctx context.Context, cfg *setting.Cfg, opts Options, apiOpts api if err != nil { return nil, err } - folderAPIBuilder := folders.RegisterAPIService(cfg, featureToggles, apiserverService, folderimplService, folderPermissionsService, accessControl, acimplService, registerer, resourceClient) + folderAPIBuilder := folders.RegisterAPIService(cfg, featureToggles, apiserverService, folderimplService, folderPermissionsService, accessControl, acimplService, registerer, resourceClient, zanzanaClient) storageBackendImpl := noopstorage.ProvideStorageBackend() identityAccessManagementAPIBuilder, err := iam.RegisterAPIService(featureToggles, apiserverService, ssosettingsimplService, sqlStore, accessControl, accessClient, registerer, storageBackendImpl, storageBackendImpl) if err != nil { @@ -1382,7 +1382,7 @@ func InitializeForTest(ctx context.Context, t sqlutil.ITestDB, testingT interfac if err != nil { return nil, err } - folderAPIBuilder := folders.RegisterAPIService(cfg, featureToggles, apiserverService, folderimplService, folderPermissionsService, accessControl, acimplService, registerer, resourceClient) + folderAPIBuilder := folders.RegisterAPIService(cfg, featureToggles, apiserverService, folderimplService, folderPermissionsService, accessControl, acimplService, registerer, resourceClient, zanzanaClient) storageBackendImpl := noopstorage.ProvideStorageBackend() identityAccessManagementAPIBuilder, err := iam.RegisterAPIService(featureToggles, apiserverService, ssosettingsimplService, sqlStore, accessControl, accessClient, registerer, storageBackendImpl, storageBackendImpl) if err != nil {