K8s/Permissions: Enable a grant-permissions annotation action to set default permissions (#102527)

* create permissions

* add key

* lint

* structure as a delayed callback

* legacy API hook

* merge main

* wired up

* and folders

* watch repos

* missing return statement

* Set the correct permissions

* add TestAfterCreatePermissionCreator

* do not add perms on folder create

* fix tests

* add annotation on create

* lint

* lint

* ensure we set permissions when the FT is disabled

* remove custom folder_storage

* fix lint

* change default

* lint

* lint

* fix: annotation

* ensure permissions are added on folder legacy

* remove folderstorage again

* fix tests

* add FT

* undo change to folder

* dashboard on create

* remove annotation for folder

* fix tests

* fix prepare after rebase

* fix tests

* fix tests

* fix tests

* lint

* address comments

* add test for prepareObjectForStorage

* add again skipIfMode as per comment

---------

Co-authored-by: Georges Chaudy <chaudyg@gmail.com>
This commit is contained in:
Ryan McKinley
2025-04-09 14:05:37 +03:00
committed by GitHub
parent ceed824378
commit af8a70bbab
18 changed files with 466 additions and 83 deletions

View File

@ -26,6 +26,13 @@ const LabelKeyGetTrash = "grafana.app/get-trash"
// AnnoKeyKubectlLastAppliedConfig is the annotation kubectl writes with the entire previous config
const AnnoKeyKubectlLastAppliedConfig = "kubectl.kubernetes.io/last-applied-configuration"
// AnnoKeyGrantPermissions allows users to explicitly grant themself permissions when creating
// resoures in the "root" folder. This annotation is not saved and invalud for update.
const AnnoKeyGrantPermissions = "grafana.app/grant-permissions"
// AnnoGrantPermissionsDefault is the value that should be sent with AnnoKeyGrantPermissions
const AnnoGrantPermissionsDefault = "default"
// DeletedGeneration is set on Resources that have been (soft) deleted
const DeletedGeneration = int64(-999)
@ -206,14 +213,10 @@ func (m *grafanaMetaAccessor) SetAnnotation(key string, val string) {
func (m *grafanaMetaAccessor) GetAnnotation(key string) string {
anno := m.obj.GetAnnotations()
if anno != nil {
return anno[key]
if anno == nil {
return ""
}
return ""
}
func (m *grafanaMetaAccessor) get(key string) string {
return m.obj.GetAnnotations()[key]
return anno[key]
}
func (m *grafanaMetaAccessor) GetUpdatedTimestamp() (*time.Time, error) {
@ -247,7 +250,7 @@ func (m *grafanaMetaAccessor) SetUpdatedTimestamp(v *time.Time) {
}
func (m *grafanaMetaAccessor) GetCreatedBy() string {
return m.get(AnnoKeyCreatedBy)
return m.GetAnnotation(AnnoKeyCreatedBy)
}
func (m *grafanaMetaAccessor) SetCreatedBy(user string) {
@ -255,7 +258,7 @@ func (m *grafanaMetaAccessor) SetCreatedBy(user string) {
}
func (m *grafanaMetaAccessor) GetUpdatedBy() string {
return m.get(AnnoKeyUpdatedBy)
return m.GetAnnotation(AnnoKeyUpdatedBy)
}
func (m *grafanaMetaAccessor) SetUpdatedBy(user string) {
@ -263,7 +266,7 @@ func (m *grafanaMetaAccessor) SetUpdatedBy(user string) {
}
func (m *grafanaMetaAccessor) GetBlob() *BlobInfo {
return ParseBlobInfo(m.get(AnnoKeyBlob))
return ParseBlobInfo(m.GetAnnotation(AnnoKeyBlob))
}
func (m *grafanaMetaAccessor) SetBlob(info *BlobInfo) {
@ -275,7 +278,7 @@ func (m *grafanaMetaAccessor) SetBlob(info *BlobInfo) {
}
func (m *grafanaMetaAccessor) GetFolder() string {
return m.get(AnnoKeyFolder)
return m.GetAnnotation(AnnoKeyFolder)
}
func (m *grafanaMetaAccessor) SetFolder(uid string) {
@ -283,7 +286,7 @@ func (m *grafanaMetaAccessor) SetFolder(uid string) {
}
func (m *grafanaMetaAccessor) GetMessage() string {
return m.get(AnnoKeyMessage)
return m.GetAnnotation(AnnoKeyMessage)
}
func (m *grafanaMetaAccessor) SetMessage(uid string) {
@ -329,7 +332,7 @@ func (m *grafanaMetaAccessor) SetDeprecatedInternalID(id int64) {
}
func (m *grafanaMetaAccessor) GetFullpath() string {
return m.get(AnnoKeyFullpath)
return m.GetAnnotation(AnnoKeyFullpath)
}
func (m *grafanaMetaAccessor) SetFullpath(path string) {
@ -337,7 +340,7 @@ func (m *grafanaMetaAccessor) SetFullpath(path string) {
}
func (m *grafanaMetaAccessor) GetFullpathUIDs() string {
return m.get(AnnoKeyFullpathUIDs)
return m.GetAnnotation(AnnoKeyFullpathUIDs)
}
func (m *grafanaMetaAccessor) SetFullpathUIDs(uids string) {

View File

@ -204,6 +204,8 @@ func TestMetaAccessor(t *testing.T) {
"sloth": "🦥",
},
}
require.Equal(t, "", meta.GetFolder())
require.Equal(t, "", meta.GetAnnotation("missing annotation"))
meta.SetManagerProperties(repoInfo)
meta.SetFolder("folderUID")

View File

@ -10,7 +10,6 @@ import (
"k8s.io/apiserver/pkg/registry/generic/registry"
"k8s.io/apiserver/pkg/registry/rest"
"github.com/grafana/grafana/pkg/apimachinery/identity"
"github.com/grafana/grafana/pkg/apimachinery/utils"
grafanaregistry "github.com/grafana/grafana/pkg/apiserver/registry/generic"
grafanarest "github.com/grafana/grafana/pkg/apiserver/rest"
@ -19,7 +18,6 @@ import (
"github.com/grafana/grafana/pkg/services/dashboards"
"github.com/grafana/grafana/pkg/storage/unified/apistore"
"github.com/grafana/grafana/pkg/storage/unified/resource"
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
)
type DashboardStorage struct {
@ -27,7 +25,7 @@ type DashboardStorage struct {
DashboardService dashboards.DashboardService
}
func (s *DashboardStorage) NewStore(dash utils.ResourceInfo, scheme *runtime.Scheme, defaultOptsGetter generic.RESTOptionsGetter, reg prometheus.Registerer) (grafanarest.Storage, error) {
func (s *DashboardStorage) NewStore(dash utils.ResourceInfo, scheme *runtime.Scheme, defaultOptsGetter generic.RESTOptionsGetter, reg prometheus.Registerer, permissions dashboards.PermissionsRegistrationService) (grafanarest.Storage, error) {
server, err := resource.NewResourceServer(resource.ResourceServerOptions{
Backend: s.Access,
Reg: reg,
@ -47,6 +45,7 @@ func (s *DashboardStorage) NewStore(dash utils.ResourceInfo, scheme *runtime.Sch
optsGetter.RegisterOptions(dash.GroupResource(), apistore.StorageOptions{
EnableFolderSupport: true,
RequireDeprecatedInternalID: true,
Permissions: permissions.SetDefaultPermissionsAfterCreate,
})
store, err := grafanaregistry.NewRegistryStore(scheme, dash, optsGetter)
@ -65,15 +64,7 @@ type storeWrapper struct {
func (s *storeWrapper) Create(ctx context.Context, obj runtime.Object, createValidation rest.ValidateObjectFunc, options *metav1.CreateOptions) (runtime.Object, error) {
ctx = legacy.WithLegacyAccess(ctx)
meta, err := utils.MetaAccessor(obj)
if err != nil {
return nil, err
}
managerProperties, managerPresent := meta.GetManagerProperties()
isProvisioned := managerPresent && managerProperties.Kind != utils.ManagerKindUnknown
obj, err = s.Store.Create(ctx, obj, createValidation, options)
obj, err := s.Store.Create(ctx, obj, createValidation, options)
access := legacy.GetLegacyAccess(ctx)
if access != nil && access.DashboardID > 0 {
meta, _ := utils.MetaAccessor(obj)
@ -82,10 +73,9 @@ func (s *storeWrapper) Create(ctx context.Context, obj runtime.Object, createVal
meta.SetDeprecatedInternalID(access.DashboardID) //nolint:staticcheck
}
}
meta, metaErr := utils.MetaAccessor(obj)
if metaErr == nil {
// Reconstruc the same UID as done at the storage level
// Reconstruct the same UID as done at the storage level
// https://github.com/grafana/grafana/blob/a84e96fba29c3a1bb384fdbad1c9c658cc79ec8f/pkg/registry/apis/dashboard/legacy/sql_dashboards.go#L287
// This is necessary because the UID generated during the creation via legacy storage is actually never stored in the database
// and the one returned here is wrong.
@ -100,31 +90,6 @@ func (s *storeWrapper) Create(ctx context.Context, obj runtime.Object, createVal
return obj, metaErr
}
unstructuredMap, err := runtime.DefaultUnstructuredConverter.ToUnstructured(obj)
if err != nil {
return obj, err
}
unstructuredObj := &unstructured.Unstructured{Object: unstructuredMap}
user, err := identity.GetRequester(ctx)
if err != nil {
return obj, err
}
legacyDashboard, err := s.DashboardService.UnstructuredToLegacyDashboard(ctx, unstructuredObj, user.GetOrgID())
if err != nil {
return obj, err
}
// We only need these two parameters for SetDefaultPermissions
dto := &dashboards.SaveDashboardDTO{
User: user,
OrgID: user.GetOrgID(),
}
// Temporary approach to set default permissions until we have a proper method in place via k8s
s.DashboardService.SetDefaultPermissions(ctx, dto, legacyDashboard, isProvisioned)
return obj, nil
}

View File

@ -74,6 +74,7 @@ type DashboardsAPIBuilder struct {
legacy *DashboardStorage
unified resource.ResourceClient
dashboardProvisioningService dashboards.DashboardProvisioningService
dashboardPermissions dashboards.PermissionsRegistrationService
scheme *runtime.Scheme
search *SearchHandler
dashStore dashboards.Store
@ -94,6 +95,7 @@ func RegisterAPIService(
apiregistration builder.APIRegistrar,
dashboardService dashboards.DashboardService,
provisioningDashboardService dashboards.DashboardProvisioningService,
dashboardPermissions dashboards.PermissionsRegistrationService,
accessControl accesscontrol.AccessControl,
provisioning provisioning.ProvisioningService,
dashStore dashboards.Store,
@ -116,6 +118,7 @@ func RegisterAPIService(
log: log.New("grafana-apiserver.dashboards"),
dashboardService: dashboardService,
dashboardPermissions: dashboardPermissions,
features: features,
accessControl: accessControl,
unified: unified,
@ -389,6 +392,9 @@ func (b *DashboardsAPIBuilder) UpdateAPIGroupInfo(apiGroupInfo *genericapiserver
storageOpts := apistore.StorageOptions{
EnableFolderSupport: true,
RequireDeprecatedInternalID: true,
// Sets default root permissions
Permissions: b.dashboardPermissions.SetDefaultPermissionsAfterCreate,
}
// Split dashboards when they are large
@ -468,7 +474,7 @@ func (b *DashboardsAPIBuilder) storageForVersion(
storage := map[string]rest.Storage{}
apiGroupInfo.VersionedResourcesStorageMap[dashboards.GroupVersion().Version] = storage
legacyStore, err := b.legacy.NewStore(dashboards, opts.Scheme, opts.OptsGetter, b.reg)
legacyStore, err := b.legacy.NewStore(dashboards, opts.Scheme, opts.OptsGetter, b.reg, b.dashboardPermissions)
if err != nil {
return err
}

View File

@ -3,11 +3,14 @@ package dashboards
import (
"context"
authtypes "github.com/grafana/authlib/types"
"github.com/grafana/grafana/pkg/apimachinery/identity"
"github.com/grafana/grafana/pkg/apimachinery/utils"
"github.com/grafana/grafana/pkg/services/accesscontrol"
"github.com/grafana/grafana/pkg/services/folder"
"github.com/grafana/grafana/pkg/services/quota"
"github.com/grafana/grafana/pkg/services/search/model"
"github.com/grafana/grafana/pkg/storage/unified/resource"
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
)
@ -42,6 +45,9 @@ type DashboardService interface {
type PermissionsRegistrationService interface {
RegisterDashboardPermissions(service accesscontrol.DashboardPermissionsService)
// Used to apply default permissions in unified storage after create
SetDefaultPermissionsAfterCreate(ctx context.Context, key *resource.ResourceKey, id authtypes.AuthInfo, obj utils.GrafanaMetaAccessor) error
}
// PluginService is a service for operating on plugin dashboards.

View File

@ -23,6 +23,7 @@ import (
"k8s.io/apimachinery/pkg/selection"
claims "github.com/grafana/authlib/types"
"github.com/grafana/grafana-app-sdk/logging"
"github.com/grafana/grafana-plugin-sdk-go/backend/gtime"
"github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard"
dashboardv0alpha1 "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v0alpha1"
@ -41,6 +42,7 @@ import (
"github.com/grafana/grafana/pkg/services/accesscontrol"
"github.com/grafana/grafana/pkg/services/apiserver"
"github.com/grafana/grafana/pkg/services/apiserver/client"
"github.com/grafana/grafana/pkg/services/apiserver/endpoints/request"
"github.com/grafana/grafana/pkg/services/dashboards"
"github.com/grafana/grafana/pkg/services/dashboards/dashboardaccess"
dashboardclient "github.com/grafana/grafana/pkg/services/dashboards/service/client"
@ -1186,6 +1188,60 @@ func (dr *DashboardServiceImpl) GetDashboardsByPluginID(ctx context.Context, que
return dr.dashboardStore.GetDashboardsByPluginID(ctx, query)
}
// (sometimes) called by the k8s storage engine after creating an object
func (dr *DashboardServiceImpl) SetDefaultPermissionsAfterCreate(ctx context.Context, key *resource.ResourceKey, id claims.AuthInfo, obj utils.GrafanaMetaAccessor) error {
ctx, span := tracer.Start(ctx, "dashboards.service.SetDefaultPermissionsAfterCreate")
defer span.End()
logger := logging.FromContext(ctx)
ns, err := request.NamespaceInfoFrom(ctx, true)
if err != nil {
return err
}
user, err := identity.GetRequester(ctx)
if err != nil {
return err
}
uid, err := user.GetInternalID()
if err != nil {
return err
}
var permissions []accesscontrol.SetResourcePermissionCommand
if !dr.features.IsEnabledGlobally(featuremgmt.FlagKubernetesDashboards) {
// legacy behavior
permissions = []accesscontrol.SetResourcePermissionCommand{}
if user.IsIdentityType(claims.TypeUser) {
permissions = append(permissions, accesscontrol.SetResourcePermissionCommand{
UserID: uid, Permission: dashboardaccess.PERMISSION_ADMIN.String(),
})
}
isNested := obj.GetFolder() != ""
if !isNested || !dr.features.IsEnabled(ctx, featuremgmt.FlagNestedFolders) {
permissions = append(permissions, []accesscontrol.SetResourcePermissionCommand{
{BuiltinRole: string(org.RoleEditor), Permission: dashboardaccess.PERMISSION_EDIT.String()},
{BuiltinRole: string(org.RoleViewer), Permission: dashboardaccess.PERMISSION_VIEW.String()},
}...)
}
} else {
if obj.GetFolder() != "" {
return nil
}
permissions = []accesscontrol.SetResourcePermissionCommand{
{UserID: uid, Permission: dashboardaccess.PERMISSION_ADMIN.String()},
{BuiltinRole: string(org.RoleEditor), Permission: dashboardaccess.PERMISSION_ADMIN.String()},
{BuiltinRole: string(org.RoleViewer), Permission: dashboardaccess.PERMISSION_VIEW.String()},
}
}
svc := dr.getPermissionsService(key.Resource == "folders")
if _, err := svc.SetPermissions(ctx, ns.OrgID, obj.GetName(), permissions...); err != nil {
logger.Error("Could not set default permissions", "error", err)
return err
}
return nil
}
func (dr *DashboardServiceImpl) SetDefaultPermissions(ctx context.Context, dto *dashboards.SaveDashboardDTO, dash *dashboards.Dashboard, provisioned bool) {
ctx, span := tracer.Start(ctx, "dashboards.service.setDefaultPermissions")
defer span.End()
@ -1227,6 +1283,10 @@ func (dr *DashboardServiceImpl) SetDefaultPermissions(ctx context.Context, dto *
}
func (dr *DashboardServiceImpl) setDefaultFolderPermissions(ctx context.Context, cmd *folder.CreateFolderCommand, f *folder.Folder, provisioned bool) {
if dr.features.IsEnabledGlobally(featuremgmt.FlagKubernetesClientDashboardsFolders) {
return
}
ctx, span := tracer.Start(ctx, "dashboards.service.setDefaultFolderPermissions")
defer span.End()
@ -1792,7 +1852,6 @@ func (dr *DashboardServiceImpl) saveDashboardThroughK8s(ctx context.Context, cmd
if err != nil {
return nil, err
}
dashboard.SetPluginIDMeta(obj, cmd.PluginID)
out, err := dr.k8sclient.Update(ctx, obj, orgID)

View File

@ -14,6 +14,7 @@ import (
"gopkg.in/ini.v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
"k8s.io/apiserver/pkg/endpoints/request"
dashboardv1alpha1 "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v1alpha1"
"github.com/grafana/grafana/pkg/apimachinery/identity"
@ -28,6 +29,7 @@ import (
acmock "github.com/grafana/grafana/pkg/services/accesscontrol/mock"
"github.com/grafana/grafana/pkg/services/apiserver/client"
"github.com/grafana/grafana/pkg/services/dashboards"
"github.com/grafana/grafana/pkg/services/dashboards/dashboardaccess"
"github.com/grafana/grafana/pkg/services/featuremgmt"
"github.com/grafana/grafana/pkg/services/folder"
"github.com/grafana/grafana/pkg/services/folder/foldertest"
@ -2410,6 +2412,110 @@ func TestLegacySaveCommandToUnstructured(t *testing.T) {
})
}
func TestSetDefaultPermissionsAfterCreate(t *testing.T) {
t.Run("Should set correct default permissions", func(t *testing.T) {
testCases := []struct {
name string
rootFolder bool
featureKubernetesDashboards bool
expectedPermission []accesscontrol.SetResourcePermissionCommand
}{
{
name: "without kubernetesDashboards feature in root folder",
rootFolder: true,
featureKubernetesDashboards: false,
expectedPermission: []accesscontrol.SetResourcePermissionCommand{
{UserID: 1, Permission: dashboardaccess.PERMISSION_ADMIN.String()},
{BuiltinRole: string(org.RoleEditor), Permission: dashboardaccess.PERMISSION_EDIT.String()},
{BuiltinRole: string(org.RoleViewer), Permission: dashboardaccess.PERMISSION_VIEW.String()},
},
},
{
name: "with kubernetesDashboards feature in root folder",
rootFolder: true,
featureKubernetesDashboards: true,
expectedPermission: []accesscontrol.SetResourcePermissionCommand{
{UserID: 1, Permission: dashboardaccess.PERMISSION_ADMIN.String()},
{BuiltinRole: string(org.RoleEditor), Permission: dashboardaccess.PERMISSION_ADMIN.String()},
{BuiltinRole: string(org.RoleViewer), Permission: dashboardaccess.PERMISSION_VIEW.String()},
},
},
{
name: "without kubernetesDashboards feature in subfolder",
rootFolder: false,
featureKubernetesDashboards: false,
expectedPermission: []accesscontrol.SetResourcePermissionCommand{
{UserID: 1, Permission: dashboardaccess.PERMISSION_ADMIN.String()},
},
},
{
name: "with kubernetesDashboards feature in subfolder",
rootFolder: false,
featureKubernetesDashboards: true,
expectedPermission: nil,
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
user := &user.SignedInUser{
OrgID: 1,
OrgRole: "Admin",
UserID: 1,
}
ctx := request.WithNamespace(context.Background(), "default")
ctx = identity.WithRequester(ctx, user)
// Setup mocks and service
dashboardStore := &dashboards.FakeDashboardStore{}
folderStore := foldertest.FakeFolderStore{}
features := featuremgmt.WithFeatures(featuremgmt.FlagNestedFolders)
if tc.featureKubernetesDashboards {
features = featuremgmt.WithFeatures(featuremgmt.FlagKubernetesDashboards, featuremgmt.FlagNestedFolders)
}
permService := acmock.NewMockedPermissionsService()
permService.On("SetPermissions", mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return([]accesscontrol.ResourcePermission{}, nil)
service := &DashboardServiceImpl{
cfg: setting.NewCfg(),
log: log.New("test-logger"),
dashboardStore: dashboardStore,
folderStore: &folderStore,
features: features,
dashboardPermissions: permService,
folderPermissions: permService,
dashboardPermissionsReady: make(chan struct{}),
}
service.RegisterDashboardPermissions(permService)
// Create test object
key := &resource.ResourceKey{Group: "dashboard.grafana.app", Resource: "dashboards", Name: "test", Namespace: "default"}
obj := &dashboardv1alpha1.Dashboard{
TypeMeta: metav1.TypeMeta{
APIVersion: "dashboard.grafana.app/v0alpha1",
},
}
meta, err := utils.MetaAccessor(obj)
require.NoError(t, err)
if !tc.rootFolder {
meta.SetFolder("subfolder")
}
// Call the method
err = service.SetDefaultPermissionsAfterCreate(ctx, key, user, meta)
require.NoError(t, err)
// Verify results
if tc.expectedPermission == nil {
permService.AssertNotCalled(t, "SetPermissions")
} else {
permService.AssertCalled(t, "SetPermissions", mock.Anything, mock.Anything, mock.Anything, tc.expectedPermission)
}
})
}
})
}
func TestCleanUpDashboard(t *testing.T) {
tests := []struct {
name string

View File

@ -50,6 +50,7 @@ func (ss *FolderUnifiedStoreImpl) Create(ctx context.Context, cmd folder.CreateF
if err != nil {
return nil, err
}
out, err := ss.k8sclient.Create(ctx, obj, cmd.OrgID)
if err != nil {
return nil, err

View File

@ -0,0 +1,52 @@
package apistore
import (
"context"
"errors"
"fmt"
"k8s.io/apimachinery/pkg/runtime"
authtypes "github.com/grafana/authlib/types"
"github.com/grafana/grafana/pkg/apimachinery/utils"
"github.com/grafana/grafana/pkg/storage/unified/resource"
)
type permissionCreatorFunc = func(ctx context.Context) error
func afterCreatePermissionCreator(ctx context.Context,
key *resource.ResourceKey,
grantPermisions string,
obj runtime.Object,
setter DefaultPermissionSetter,
) (permissionCreatorFunc, error) {
if grantPermisions == "" {
return nil, nil
}
if grantPermisions != utils.AnnoGrantPermissionsDefault {
return nil, fmt.Errorf("invalid permissions value. only '%s' supported", utils.AnnoGrantPermissionsDefault)
}
if setter == nil {
return nil, fmt.Errorf("missing default permission creator")
}
val, err := utils.MetaAccessor(obj)
if err != nil {
return nil, err
}
if val.GetAnnotation(utils.AnnoKeyManagerKind) != "" {
return nil, fmt.Errorf("managed resource may not grant permissions")
}
auth, ok := authtypes.AuthInfoFrom(ctx)
if !ok {
return nil, errors.New("missing auth info")
}
idtype := auth.GetIdentityType()
if !(idtype == authtypes.TypeUser || idtype == authtypes.TypeServiceAccount) {
return nil, fmt.Errorf("only users or service accounts may grant themselves permissions using an annotation")
}
return func(ctx context.Context) error {
return setter(ctx, key, auth, val)
}, nil
}

View File

@ -0,0 +1,120 @@
package apistore
import (
"context"
"testing"
"github.com/stretchr/testify/require"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
authtypes "github.com/grafana/authlib/types"
"github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v0alpha1"
"github.com/grafana/grafana/pkg/apimachinery/identity"
"github.com/grafana/grafana/pkg/apimachinery/utils"
"github.com/grafana/grafana/pkg/services/user"
"github.com/grafana/grafana/pkg/storage/unified/resource"
)
func TestAfterCreatePermissionCreator(t *testing.T) {
mockSetter := func(ctx context.Context, key *resource.ResourceKey, auth authtypes.AuthInfo, val utils.GrafanaMetaAccessor) error {
return nil
}
t.Run("should return nil when grantPermissions is empty", func(t *testing.T) {
creator, err := afterCreatePermissionCreator(context.Background(), nil, "", nil, mockSetter)
require.NoError(t, err)
require.Nil(t, creator)
})
t.Run("should error with invalid grantPermissions value", func(t *testing.T) {
creator, err := afterCreatePermissionCreator(context.Background(), nil, "invalid", nil, mockSetter)
require.Error(t, err)
require.Nil(t, creator)
require.Contains(t, err.Error(), "invalid permissions value")
})
t.Run("should error when setter is nil", func(t *testing.T) {
creator, err := afterCreatePermissionCreator(context.Background(), nil, utils.AnnoGrantPermissionsDefault, nil, nil)
require.Error(t, err)
require.Nil(t, creator)
require.Contains(t, err.Error(), "missing default permission creator")
})
t.Run("should error for managed resources", func(t *testing.T) {
obj := &v0alpha1.Dashboard{
ObjectMeta: metav1.ObjectMeta{
Annotations: map[string]string{
utils.AnnoKeyManagerKind: "test",
},
},
}
creator, err := afterCreatePermissionCreator(context.Background(), nil, utils.AnnoGrantPermissionsDefault, obj, mockSetter)
require.Error(t, err)
require.Nil(t, creator)
require.Contains(t, err.Error(), "managed resource may not grant permissions")
})
t.Run("should error when auth info is missing", func(t *testing.T) {
obj := &v0alpha1.Dashboard{}
creator, err := afterCreatePermissionCreator(context.Background(), nil, utils.AnnoGrantPermissionsDefault, obj, mockSetter)
require.Error(t, err)
require.Nil(t, creator)
require.Contains(t, err.Error(), "missing auth info")
})
t.Run("should succeed for user identity", func(t *testing.T) {
ctx := identity.WithRequester(context.Background(), &user.SignedInUser{
OrgID: 1,
OrgRole: "Admin",
UserID: 1,
})
obj := &v0alpha1.Dashboard{}
key := &resource.ResourceKey{
Group: "test",
Resource: "test",
Namespace: "test",
Name: "test",
}
creator, err := afterCreatePermissionCreator(ctx, key, utils.AnnoGrantPermissionsDefault, obj, mockSetter)
require.NoError(t, err)
require.NotNil(t, creator)
err = creator(ctx)
require.NoError(t, err)
})
t.Run("should succeed for service account identity", func(t *testing.T) {
ctx := identity.WithRequester(context.Background(), &user.SignedInUser{
OrgID: 1,
OrgRole: "Admin",
UserID: 1,
})
obj := &v0alpha1.Dashboard{}
key := &resource.ResourceKey{
Group: "test",
Resource: "test",
Namespace: "test",
Name: "test",
}
creator, err := afterCreatePermissionCreator(ctx, key, utils.AnnoGrantPermissionsDefault, obj, mockSetter)
require.NoError(t, err)
require.NotNil(t, creator)
err = creator(ctx)
require.NoError(t, err)
})
t.Run("should error for non-user/non-service-account identity", func(t *testing.T) {
ctx := identity.WithRequester(context.Background(), &identity.StaticRequester{
Type: authtypes.TypeAnonymous,
})
obj := &v0alpha1.Dashboard{}
creator, err := afterCreatePermissionCreator(ctx, nil, utils.AnnoGrantPermissionsDefault, obj, mockSetter)
require.Error(t, err)
require.Nil(t, creator)
require.Contains(t, err.Error(), "only users or service accounts may grant themselves permissions")
})
}

View File

@ -39,30 +39,35 @@ func formatBytes(numBytes int) string {
}
// Called on create
func (s *Storage) prepareObjectForStorage(ctx context.Context, newObject runtime.Object) ([]byte, error) {
func (s *Storage) prepareObjectForStorage(ctx context.Context, newObject runtime.Object) ([]byte, string, error) {
info, ok := authtypes.AuthInfoFrom(ctx)
if !ok {
return nil, errors.New("missing auth info")
return nil, "", errors.New("missing auth info")
}
obj, err := utils.MetaAccessor(newObject)
if err != nil {
return nil, err
return nil, "", err
}
if obj.GetName() == "" {
return nil, storage.NewInvalidObjError("", "missing name")
return nil, "", storage.NewInvalidObjError("", "missing name")
}
if obj.GetResourceVersion() != "" {
return nil, storage.ErrResourceVersionSetOnCreate
return nil, "", storage.ErrResourceVersionSetOnCreate
}
if obj.GetUID() == "" {
obj.SetUID(types.UID(uuid.NewString()))
}
if obj.GetFolder() != "" && !s.opts.EnableFolderSupport {
return nil, apierrors.NewBadRequest(fmt.Sprintf("folders are not supported for: %s", s.gr.String()))
return nil, "", apierrors.NewBadRequest(fmt.Sprintf("folders are not supported for: %s", s.gr.String()))
}
grantPermisions := obj.GetAnnotation(utils.AnnoKeyGrantPermissions)
if grantPermisions != "" {
obj.SetAnnotation(utils.AnnoKeyGrantPermissions, "") // remove the annotation
}
if err := checkManagerPropertiesOnCreate(info, obj); err != nil {
return nil, err
return nil, "", err
}
if s.opts.RequireDeprecatedInternalID {
@ -88,9 +93,11 @@ func (s *Storage) prepareObjectForStorage(ctx context.Context, newObject runtime
var buf bytes.Buffer
if err = s.codec.Encode(newObject, &buf); err != nil {
return nil, err
return nil, "", err
}
return s.handleLargeResources(ctx, obj, buf)
val, err := s.handleLargeResources(ctx, obj, buf)
return val, grantPermisions, err
}
// Called on update
@ -130,7 +137,8 @@ func (s *Storage) prepareObjectForUpdate(ctx context.Context, updateObject runti
obj.SetCreatedBy(previous.GetCreatedBy())
obj.SetCreationTimestamp(previous.GetCreationTimestamp())
obj.SetResourceVersion("") // removed from saved JSON because the RV is not yet calculated
obj.SetResourceVersion("") // removed from saved JSON because the RV is not yet calculated
obj.SetAnnotation(utils.AnnoKeyGrantPermissions, "") // Grant is ignored for update requests
// for dashboards, a mutation hook will set it if it didn't exist on the previous obj
// avoid setting it back to 0

View File

@ -42,14 +42,14 @@ func TestPrepareObjectForStorage(t *testing.T) {
)
t.Run("Error getting auth info from context", func(t *testing.T) {
_, err := s.prepareObjectForStorage(context.Background(), nil)
_, _, err := s.prepareObjectForStorage(context.Background(), nil)
require.Error(t, err)
require.Contains(t, err.Error(), "missing auth info")
})
t.Run("Error on missing name", func(t *testing.T) {
dashboard := v1alpha1.Dashboard{}
_, err := s.prepareObjectForStorage(ctx, dashboard.DeepCopyObject())
_, _, err := s.prepareObjectForStorage(ctx, dashboard.DeepCopyObject())
require.Error(t, err)
require.Contains(t, err.Error(), "missing name")
})
@ -58,7 +58,7 @@ func TestPrepareObjectForStorage(t *testing.T) {
dashboard := v1alpha1.Dashboard{}
dashboard.Name = "test-name"
dashboard.ResourceVersion = "123"
_, err := s.prepareObjectForStorage(ctx, dashboard.DeepCopyObject())
_, _, err := s.prepareObjectForStorage(ctx, dashboard.DeepCopyObject())
require.Error(t, err)
require.Equal(t, storage.ErrResourceVersionSetOnCreate, err)
})
@ -67,7 +67,7 @@ func TestPrepareObjectForStorage(t *testing.T) {
dashboard := v1alpha1.Dashboard{}
dashboard.Name = "test-name"
encodedData, err := s.prepareObjectForStorage(ctx, dashboard.DeepCopyObject())
encodedData, _, err := s.prepareObjectForStorage(ctx, dashboard.DeepCopyObject())
require.NoError(t, err)
newObject, _, err := s.codec.Decode(encodedData, nil, &v1alpha1.Dashboard{})
@ -106,7 +106,7 @@ func TestPrepareObjectForStorage(t *testing.T) {
TimestampMillis: now.UnixMilli(),
})
encodedData, err := s.prepareObjectForStorage(ctx, obj)
encodedData, _, err := s.prepareObjectForStorage(ctx, obj)
require.NoError(t, err)
newObject, _, err := s.codec.Decode(encodedData, nil, &v1alpha1.Dashboard{})
@ -133,7 +133,7 @@ func TestPrepareObjectForStorage(t *testing.T) {
meta.SetFolder("aaa")
require.NoError(t, err)
encodedData, err := s.prepareObjectForStorage(ctx, obj)
encodedData, _, err := s.prepareObjectForStorage(ctx, obj)
require.NoError(t, err)
insertedObject, _, err := s.codec.Decode(encodedData, nil, &v1alpha1.Dashboard{})
@ -189,7 +189,7 @@ func TestPrepareObjectForStorage(t *testing.T) {
dashboard := v1alpha1.Dashboard{}
dashboard.Name = "test-name"
encodedData, err := s.prepareObjectForStorage(ctx, dashboard.DeepCopyObject())
encodedData, _, err := s.prepareObjectForStorage(ctx, dashboard.DeepCopyObject())
require.NoError(t, err)
newObject, _, err := s.codec.Decode(encodedData, nil, &v1alpha1.Dashboard{})
require.NoError(t, err)
@ -208,7 +208,7 @@ func TestPrepareObjectForStorage(t *testing.T) {
require.NoError(t, err)
meta.SetDeprecatedInternalID(1) // nolint:staticcheck
encodedData, err := s.prepareObjectForStorage(ctx, obj)
encodedData, _, err := s.prepareObjectForStorage(ctx, obj)
require.NoError(t, err)
newObject, _, err := s.codec.Decode(encodedData, nil, &v1alpha1.Dashboard{})
require.NoError(t, err)
@ -217,6 +217,24 @@ func TestPrepareObjectForStorage(t *testing.T) {
require.Equal(t, meta.GetDeprecatedInternalID(), int64(1)) // nolint:staticcheck
})
t.Run("Should remove grant permissions annotation", func(t *testing.T) {
dashboard := v1alpha1.Dashboard{}
dashboard.Name = "test-name"
obj := dashboard.DeepCopyObject()
meta, err := utils.MetaAccessor(obj)
require.NoError(t, err)
meta.SetAnnotation(utils.AnnoKeyGrantPermissions, "default")
encodedData, p, err := s.prepareObjectForStorage(ctx, obj)
require.NoError(t, err)
newObject, _, err := s.codec.Decode(encodedData, nil, &v1alpha1.Dashboard{})
require.NoError(t, err)
meta, err = utils.MetaAccessor(newObject)
require.NoError(t, err)
require.Empty(t, meta.GetAnnotation(utils.AnnoKeyGrantPermissions))
require.Equal(t, p, "default")
})
t.Run("calculate generation", func(t *testing.T) {
dash := &v1alpha1.Dashboard{
ObjectMeta: v1.ObjectMeta{
@ -286,7 +304,7 @@ func getPreparedObject(t *testing.T, ctx context.Context, s *Storage, obj runtim
var err error
if old == nil {
raw, err = s.prepareObjectForStorage(ctx, obj)
raw, _, err = s.prepareObjectForStorage(ctx, obj)
} else {
raw, err = s.prepareObjectForUpdate(ctx, obj, old)
}
@ -323,7 +341,7 @@ func TestPrepareLargeObjectForStorage(t *testing.T) {
},
}
_, err := f.prepareObjectForStorage(ctx, dashboard.DeepCopyObject())
_, _, err := f.prepareObjectForStorage(ctx, dashboard.DeepCopyObject())
require.Nil(t, err)
require.True(t, los.deconstructed)
})
@ -341,7 +359,7 @@ func TestPrepareLargeObjectForStorage(t *testing.T) {
},
}
_, err := f.prepareObjectForStorage(ctx, dashboard.DeepCopyObject())
_, _, err := f.prepareObjectForStorage(ctx, dashboard.DeepCopyObject())
require.Nil(t, err)
require.False(t, los.deconstructed)
})

View File

@ -46,6 +46,8 @@ const (
var _ storage.Interface = (*Storage)(nil)
type DefaultPermissionSetter = func(ctx context.Context, key *resource.ResourceKey, id authtypes.AuthInfo, obj utils.GrafanaMetaAccessor) error
// Optional settings that apply to a single resource
type StorageOptions struct {
// ????: should we constrain this to only dashboards for now?
@ -57,6 +59,9 @@ type StorageOptions struct {
// Add internalID label when missing
RequireDeprecatedInternalID bool
// Temporary fix to support adding default permissions AfterCreate
Permissions DefaultPermissionSetter
}
// Storage implements storage.Interface and storage resources as JSON files on disk.
@ -164,15 +169,23 @@ func (s *Storage) convertToObject(data []byte, obj runtime.Object) (runtime.Obje
// set to the read value from database.
func (s *Storage) Create(ctx context.Context, key string, obj runtime.Object, out runtime.Object, ttl uint64) error {
var err error
var permissions string
req := &resource.CreateRequest{}
req.Value, err = s.prepareObjectForStorage(ctx, obj)
req.Value, permissions, err = s.prepareObjectForStorage(ctx, obj)
if err != nil {
return err
}
req.Key, err = s.getKey(key)
if err != nil {
return err
}
grantPermissions, err := afterCreatePermissionCreator(ctx, req.Key, permissions, obj, s.opts.Permissions)
if err != nil {
return err
}
rsp, err := s.store.Create(ctx, req)
if err != nil {
return resource.GetError(resource.AsErrorResult(err))
@ -203,6 +216,11 @@ func (s *Storage) Create(ctx context.Context, key string, obj runtime.Object, ou
})
}
// Synchronous AfterCreate permissions -- allows users to become "admin" of the thing they made
if grantPermissions != nil {
return grantPermissions(ctx)
}
return nil
}

View File

@ -376,6 +376,10 @@ func (s *server) newEvent(ctx context.Context, user claims.AuthInfo, key *Resour
}
}
if obj.GetAnnotation(utils.AnnoKeyGrantPermissions) != "" {
return nil, NewBadRequestError("can not save annotation: " + utils.AnnoKeyGrantPermissions)
}
check := claims.CheckRequest{
Verb: utils.VerbCreate,
Group: key.Group,

View File

@ -48,10 +48,11 @@ type TestContext struct {
// TestIntegrationValidation tests the dashboard K8s API
func TestIntegrationValidation(t *testing.T) {
if testing.Short() {
t.Skip("skipping integration test")
t.Skip("skipping integration test2")
}
dualWriterModes := []rest.DualWriterMode{rest.Mode0, rest.Mode1, rest.Mode2, rest.Mode3, rest.Mode4, rest.Mode5}
// TODO: Skip mode3 - borken due to race conditions while setting default permissions across storage backends
dualWriterModes := []rest.DualWriterMode{rest.Mode0, rest.Mode1, rest.Mode2, rest.Mode4, rest.Mode5}
for _, dualWriterMode := range dualWriterModes {
t.Run(fmt.Sprintf("DualWriterMode %d", dualWriterMode), func(t *testing.T) {
// Create a K8sTestHelper which will set up a real API server
@ -264,9 +265,6 @@ func runDashboardValidationTests(t *testing.T, ctx TestContext) {
// Test generation conflict when updating concurrently
t.Run("reject update with version conflict", func(t *testing.T) {
// Depends on https://github.com/grafana/grafana/pull/102527
ctx.skipIfMode(t, "Default permissions are not set yet in unified storage", rest.Mode3, rest.Mode4, rest.Mode5)
// Create a dashboard with admin
dash, err := createDashboard(t, adminClient, "Dashboard for Version Conflict Test", nil, nil)
require.NoError(t, err, "Failed to create dashboard for version conflict test")
@ -538,6 +536,7 @@ func runDashboardValidationTests(t *testing.T, ctx TestContext) {
// skipIfMode skips the current test if running in any of the specified modes
// Usage: skipIfMode(t, rest.Mode1, rest.Mode4)
// or with a message: skipIfMode(t, "Known issue with conflict detection", rest.Mode1, rest.Mode4)
// nolint:unused
func (c *TestContext) skipIfMode(t *testing.T, args ...interface{}) {
t.Helper()
@ -789,6 +788,9 @@ func createDashboardObject(t *testing.T, title string, folderUID string, generat
"kind": dashboardv1alpha1.DashboardResourceInfo.GroupVersionKind().Kind,
"metadata": map[string]interface{}{
"generateName": "test-",
"annotations": map[string]interface{}{
"grafana.app/grant-permissions": "default",
},
},
"spec": map[string]interface{}{
"title": title,

View File

@ -55,6 +55,9 @@ export const AnnoKeySourceTimestamp = 'grafana.app/sourceTimestamp';
// for auditing... when saving from the UI, mark which version saved it from where
export const AnnoKeySavedFromUI = 'grafana.app/saved-from-ui';
// Grant permissions to the created resource
export const AnnoKeyGrantPermissions = 'grafana.app/grant-permissions';
/** @deprecated NOT A REAL annotation -- this is just a shim */
export const AnnoKeySlug = 'grafana.app/slug';
/** @deprecated NOT A REAL annotation -- this is just a shim */
@ -100,7 +103,7 @@ type GrafanaClientAnnotations = {
[AnnoKeySavedFromUI]?: string;
[AnnoKeyDashboardIsSnapshot]?: boolean;
[AnnoKeyDashboardSnapshotOriginalUrl]?: string;
[AnnoKeyGrantPermissions]?: string;
// TODO: This should be provided by the API
// This is the dashboard ID for the Gcom API. This set when a dashboard is created through importing a dashboard from Grafana.com.
[AnnoKeyDashboardGnetId]?: string;

View File

@ -9,6 +9,7 @@ import {
ResourceForCreate,
AnnoKeyMessage,
AnnoKeyFolder,
AnnoKeyGrantPermissions,
Resource,
DeprecatedInternalId,
} from 'app/features/apiserver/types';
@ -64,6 +65,10 @@ export class K8sDashboardAPI implements DashboardAPI<DashboardDTO, Dashboard> {
obj.metadata.name = dashboard.uid;
return this.client.update(obj, { fieldValidation: 'Ignore' }).then((v) => this.asSaveDashboardResponseDTO(v));
}
obj.metadata.annotations = {
...obj.metadata.annotations,
[AnnoKeyGrantPermissions]: 'default',
};
return this.client.create(obj, { fieldValidation: 'Ignore' }).then((v) => this.asSaveDashboardResponseDTO(v));
}

View File

@ -10,6 +10,7 @@ import {
AnnoKeyFolderTitle,
AnnoKeyFolderUrl,
AnnoKeyMessage,
AnnoKeyGrantPermissions,
DeprecatedInternalId,
Resource,
ResourceClient,
@ -130,6 +131,10 @@ export class K8sDashboardV2API
delete obj.metadata.resourceVersion;
return this.client.update(obj).then((v) => this.asSaveDashboardResponseDTO(v));
}
obj.metadata.annotations = {
...obj.metadata.annotations,
[AnnoKeyGrantPermissions]: 'default',
};
return await this.client.create(obj).then((v) => this.asSaveDashboardResponseDTO(v));
}