diff --git a/pkg/registry/apis/iam/authorizer.go b/pkg/registry/apis/iam/authorizer.go index 89f3f5ff2fa..0ec018d86de 100644 --- a/pkg/registry/apis/iam/authorizer.go +++ b/pkg/registry/apis/iam/authorizer.go @@ -22,6 +22,19 @@ type iamAuthorizer struct { func newIAMAuthorizer(accessClient authlib.AccessClient, legacyAccessClient authlib.AccessClient) authorizer.Authorizer { resourceAuthorizer := make(map[string]authorizer.Authorizer) + // Authorizer that allows any authenticated user + // To be used when authorization is handled at the storage layer + allowAuthorizer := authorizer.AuthorizerFunc(func( + ctx context.Context, attr authorizer.Attributes, + ) (authorized authorizer.Decision, reason string, err error) { + if !attr.IsResourceRequest() { + return authorizer.DecisionNoOpinion, "", nil + } + + // Any authenticated user can access the API + return authorizer.DecisionAllow, "", nil + }) + // Identity specific resources legacyAuthorizer := gfauthorizer.NewResourceAuthorizer(legacyAccessClient) resourceAuthorizer[iamv0.TeamBindingResourceInfo.GetName()] = legacyAuthorizer @@ -31,7 +44,7 @@ func newIAMAuthorizer(accessClient authlib.AccessClient, legacyAccessClient auth authorizer := gfauthorizer.NewResourceAuthorizer(accessClient) resourceAuthorizer[iamv0.CoreRoleInfo.GetName()] = iamauthorizer.NewCoreRoleAuthorizer(accessClient) resourceAuthorizer[iamv0.RoleInfo.GetName()] = authorizer - resourceAuthorizer[iamv0.ResourcePermissionInfo.GetName()] = authorizer + resourceAuthorizer[iamv0.ResourcePermissionInfo.GetName()] = allowAuthorizer // Handled at storage layer resourceAuthorizer[iamv0.RoleBindingInfo.GetName()] = authorizer resourceAuthorizer[iamv0.ServiceAccountResourceInfo.GetName()] = authorizer resourceAuthorizer[iamv0.UserResourceInfo.GetName()] = authorizer diff --git a/pkg/registry/apis/iam/authorizer/resource_permissions.go b/pkg/registry/apis/iam/authorizer/resource_permissions.go new file mode 100644 index 00000000000..237739c78f1 --- /dev/null +++ b/pkg/registry/apis/iam/authorizer/resource_permissions.go @@ -0,0 +1,163 @@ +package authorizer + +import ( + "context" + "fmt" + + "github.com/grafana/authlib/types" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" + + iamv0 "github.com/grafana/grafana/apps/iam/pkg/apis/iam/v0alpha1" + "github.com/grafana/grafana/pkg/apimachinery/utils" + "github.com/grafana/grafana/pkg/services/apiserver/auth/authorizer/storewrapper" +) + +// TODO: Logs, Metrics, Traces? + +// ResourcePermissionsAuthorizer +type ResourcePermissionsAuthorizer struct { + accessClient types.AccessClient +} + +var _ storewrapper.ResourceStorageAuthorizer = (*ResourcePermissionsAuthorizer)(nil) + +func NewResourcePermissionsAuthorizer(accessClient types.AccessClient) *ResourcePermissionsAuthorizer { + return &ResourcePermissionsAuthorizer{ + accessClient: accessClient, + } +} + +// AfterGet implements ResourceStorageAuthorizer. +func (r *ResourcePermissionsAuthorizer) AfterGet(ctx context.Context, obj runtime.Object) error { + authInfo, ok := types.AuthInfoFrom(ctx) + if !ok { + return storewrapper.ErrUnauthenticated + } + switch o := obj.(type) { + case *iamv0.ResourcePermission: + target := o.Spec.Resource + + // TODO: Fetch the resource to retrieve its parent folder. + parent := "" + + checkReq := types.CheckRequest{ + Namespace: o.Namespace, + Group: target.ApiGroup, + Resource: target.Resource, + Verb: utils.VerbGetPermissions, + Name: target.Name, + } + res, err := r.accessClient.Check(ctx, authInfo, checkReq, parent) + if err != nil { + return err + } + if !res.Allowed { + return storewrapper.ErrUnauthorized + } + return nil + default: + return fmt.Errorf("expected ResourcePermission, got %T: %w", o, storewrapper.ErrUnexpectedType) + } +} + +func (r *ResourcePermissionsAuthorizer) beforeWrite(ctx context.Context, obj runtime.Object) error { + authInfo, ok := types.AuthInfoFrom(ctx) + if !ok { + return storewrapper.ErrUnauthenticated + } + switch o := obj.(type) { + case *iamv0.ResourcePermission: + target := o.Spec.Resource + + // TODO: Fetch the resource to retrieve its parent folder. + parent := "" + + checkReq := types.CheckRequest{ + Namespace: o.Namespace, + Group: target.ApiGroup, + Resource: target.Resource, + Verb: utils.VerbSetPermissions, + Name: target.Name, + } + res, err := r.accessClient.Check(ctx, authInfo, checkReq, parent) + if err != nil { + return err + } + if !res.Allowed { + return storewrapper.ErrUnauthorized + } + return nil + default: + return fmt.Errorf("expected ResourcePermission, got %T: %w", o, storewrapper.ErrUnexpectedType) + } +} + +// BeforeCreate implements ResourceStorageAuthorizer. +func (r *ResourcePermissionsAuthorizer) BeforeCreate(ctx context.Context, obj runtime.Object) error { + return r.beforeWrite(ctx, obj) +} + +// BeforeDelete implements ResourceStorageAuthorizer. +func (r *ResourcePermissionsAuthorizer) BeforeDelete(ctx context.Context, obj runtime.Object) error { + return r.beforeWrite(ctx, obj) +} + +// BeforeUpdate implements ResourceStorageAuthorizer. +func (r *ResourcePermissionsAuthorizer) BeforeUpdate(ctx context.Context, obj runtime.Object) error { + return r.beforeWrite(ctx, obj) +} + +// FilterList implements ResourceStorageAuthorizer. +func (r *ResourcePermissionsAuthorizer) FilterList(ctx context.Context, list runtime.Object) (runtime.Object, error) { + authInfo, ok := types.AuthInfoFrom(ctx) + if !ok { + return nil, storewrapper.ErrUnauthenticated + } + + switch l := list.(type) { + case *iamv0.ResourcePermissionList: + var ( + filteredItems []iamv0.ResourcePermission + err error + canViewFuncs = map[schema.GroupResource]types.ItemChecker{} + ) + for _, item := range l.Items { + gr := schema.GroupResource{ + Group: item.Spec.Resource.ApiGroup, + Resource: item.Spec.Resource.Resource, + } + + // Reuse the same canView for items with the same resource + canView, found := canViewFuncs[gr] + + if !found { + listReq := types.ListRequest{ + Namespace: item.Namespace, + Group: item.Spec.Resource.ApiGroup, + Resource: item.Spec.Resource.Resource, + Verb: utils.VerbGetPermissions, + } + + canView, _, err = r.accessClient.Compile(ctx, authInfo, listReq) + if err != nil { + return nil, err + } + + canViewFuncs[gr] = canView + } + + // TODO : Fetch the resource to retrieve its parent folder. + parent := "" + + allowed := canView(item.Spec.Resource.Name, parent) + if allowed { + filteredItems = append(filteredItems, item) + } + } + l.Items = filteredItems + return l, nil + default: + return nil, fmt.Errorf("expected ResourcePermissionList, got %T: %w", l, storewrapper.ErrUnexpectedType) + } +} diff --git a/pkg/registry/apis/iam/authorizer/resource_permissions_test.go b/pkg/registry/apis/iam/authorizer/resource_permissions_test.go new file mode 100644 index 00000000000..9f1762e365f --- /dev/null +++ b/pkg/registry/apis/iam/authorizer/resource_permissions_test.go @@ -0,0 +1,216 @@ +package authorizer + +import ( + "context" + "testing" + + "github.com/go-jose/go-jose/v4/jwt" + "github.com/grafana/authlib/authn" + "github.com/grafana/authlib/types" + iamv0 "github.com/grafana/grafana/apps/iam/pkg/apis/iam/v0alpha1" + "github.com/grafana/grafana/pkg/apimachinery/identity" + "github.com/grafana/grafana/pkg/apimachinery/utils" + "github.com/stretchr/testify/require" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +var ( + user = authn.NewIDTokenAuthInfo( + authn.Claims[authn.AccessTokenClaims]{ + Claims: jwt.Claims{Issuer: "grafana", + Subject: types.NewTypeID(types.TypeAccessPolicy, "grafana"), Audience: []string{"iam.grafana.app"}}, + Rest: authn.AccessTokenClaims{ + Namespace: "*", + Permissions: identity.ServiceIdentityClaims.Rest.Permissions, + DelegatedPermissions: identity.ServiceIdentityClaims.Rest.DelegatedPermissions, + }, + }, &authn.Claims[authn.IDTokenClaims]{ + Claims: jwt.Claims{Subject: types.NewTypeID(types.TypeUser, "u001")}, + Rest: authn.IDTokenClaims{Namespace: "org-2", Identifier: "u001", Type: types.TypeUser}, + }, + ) +) + +func newResourcePermission(apiGroup, resource, name string) *iamv0.ResourcePermission { + return &iamv0.ResourcePermission{ + ObjectMeta: metav1.ObjectMeta{Namespace: "org-2"}, + Spec: iamv0.ResourcePermissionSpec{ + Resource: iamv0.ResourcePermissionspecResource{ + ApiGroup: apiGroup, + Resource: resource, + Name: name, + }, + }, + } +} + +func TestResourcePermissions_AfterGet(t *testing.T) { + // In this test, we verify that AfterGet calls accessClient.Check with the correct parameters + fold1 := newResourcePermission("folder.grafana.app", "folders", "fold-1") + + tests := []struct { + name string + shouldAllow bool + }{ + { + name: "allow access", + shouldAllow: true, + }, + { + name: "deny access", + shouldAllow: false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + checkFunc := func(id types.AuthInfo, req *types.CheckRequest, folder string) (types.CheckResponse, error) { + require.NotNil(t, id) + // Check is called with the user's identity + require.Equal(t, "user:u001", id.GetUID()) + require.Equal(t, "org-2", id.GetNamespace()) + // Check the request values + require.Equal(t, "org-2", req.Namespace) + require.Equal(t, fold1.Spec.Resource.ApiGroup, req.Group) + require.Equal(t, fold1.Spec.Resource.Resource, req.Resource) + require.Equal(t, fold1.Spec.Resource.Name, req.Name) + require.Equal(t, utils.VerbGetPermissions, req.Verb) + + return types.CheckResponse{Allowed: tt.shouldAllow}, nil + } + + accessClient := &fakeAccessClient{checkFunc: checkFunc} + resPermAuthz := NewResourcePermissionsAuthorizer(accessClient) + ctx := types.WithAuthInfo(context.Background(), user) + + err := resPermAuthz.AfterGet(ctx, fold1) + if tt.shouldAllow { + require.NoError(t, err, "expected no error for allowed access") + } else { + require.Error(t, err, "expected error for denied access") + } + require.True(t, accessClient.checkCalled, "accessClient.Check should be called") + }) + } +} + +func TestResourcePermissions_FilterList(t *testing.T) { + // In this test, the user has permission to access only fold-1 and dash-2. + // We verify that FilterList returns only those two objects. + + list := &iamv0.ResourcePermissionList{ + Items: []iamv0.ResourcePermission{ + *newResourcePermission("folder.grafana.app", "folders", "fold-1"), + *newResourcePermission("folder.grafana.app", "folders", "fold-2"), + *newResourcePermission("dashboard.grafana.app", "dashboards", "dash-2"), + }, + } + + compileFunc := func(id types.AuthInfo, req types.ListRequest) (types.ItemChecker, types.Zookie, error) { + require.NotNil(t, id) + // Compile is called with the user's identity + require.Equal(t, "user:u001", id.GetUID()) + require.Equal(t, "org-2", id.GetNamespace()) + // Check the request values + require.Equal(t, "org-2", req.Namespace) + if req.Resource == "folders" { + require.Equal(t, "folder.grafana.app", req.Group) + require.Equal(t, "folders", req.Resource) + } + if req.Resource == "dashboards" { + require.Equal(t, "dashboard.grafana.app", req.Group) + require.Equal(t, "dashboards", req.Resource) + } + + // Return a checker that allows only specific resources: fold-1 and dash-2 + return func(name, folder string) bool { + if name == "fold-1" || name == "dash-2" { + return true + } + return false + }, &types.NoopZookie{}, nil + } + + accessClient := &fakeAccessClient{compileFunc: compileFunc} + resPermAuthz := NewResourcePermissionsAuthorizer(accessClient) + ctx := types.WithAuthInfo(context.Background(), user) + + obj, err := resPermAuthz.FilterList(ctx, list) + require.NoError(t, err) + require.NotNil(t, list) + require.True(t, accessClient.compileCalled, "accessClient.Compile should be called") + + filtered, ok := obj.(*iamv0.ResourcePermissionList) + require.True(t, ok, "response should be of type ResourcePermissionList") + require.Len(t, filtered.Items, 2, "response list should have 2 items after filtering") + require.Equal(t, "fold-1", filtered.Items[0].Spec.Resource.Name) + require.Equal(t, "dash-2", filtered.Items[1].Spec.Resource.Name) +} + +func TestResourcePermissions_beforeWrite(t *testing.T) { + // In this test, we verify that beforeWrite calls accessClient.Check with the correct parameters + fold1 := newResourcePermission("folder.grafana.app", "folders", "fold-1") + + tests := []struct { + name string + shouldAllow bool + }{ + { + name: "allow delete", + shouldAllow: true, + }, + { + name: "deny delete", + shouldAllow: false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + checkFunc := func(id types.AuthInfo, req *types.CheckRequest, folder string) (types.CheckResponse, error) { + require.NotNil(t, id) + // Check is called with the user's identity + require.Equal(t, "user:u001", id.GetUID()) + require.Equal(t, "org-2", id.GetNamespace()) + // Check the request values + require.Equal(t, "org-2", req.Namespace) + require.Equal(t, fold1.Spec.Resource.ApiGroup, req.Group) + require.Equal(t, fold1.Spec.Resource.Resource, req.Resource) + require.Equal(t, fold1.Spec.Resource.Name, req.Name) + require.Equal(t, utils.VerbSetPermissions, req.Verb) + + return types.CheckResponse{Allowed: tt.shouldAllow}, nil + } + + accessClient := &fakeAccessClient{checkFunc: checkFunc} + resPermAuthz := NewResourcePermissionsAuthorizer(accessClient) + ctx := types.WithAuthInfo(context.Background(), user) + + err := resPermAuthz.beforeWrite(ctx, fold1) + if tt.shouldAllow { + require.NoError(t, err, "expected no error for allowed delete") + } else { + require.Error(t, err, "expected error for denied delete") + } + require.True(t, accessClient.checkCalled, "accessClient.Check should be called") + }) + } +} + +// fakeAccessClient is a mock implementation of claims.AccessClient +type fakeAccessClient struct { + checkCalled bool + checkFunc func(id types.AuthInfo, req *types.CheckRequest, folder string) (types.CheckResponse, error) + compileCalled bool + compileFunc func(id types.AuthInfo, req types.ListRequest) (types.ItemChecker, types.Zookie, error) +} + +func (m *fakeAccessClient) Check(ctx context.Context, id types.AuthInfo, req types.CheckRequest, folder string) (types.CheckResponse, error) { + m.checkCalled = true + return m.checkFunc(id, &req, folder) +} + +func (m *fakeAccessClient) Compile(ctx context.Context, id types.AuthInfo, req types.ListRequest) (types.ItemChecker, types.Zookie, error) { + m.compileCalled = true + return m.compileFunc(id, req) +} + +var _ types.AccessClient = (*fakeAccessClient)(nil) diff --git a/pkg/registry/apis/iam/register.go b/pkg/registry/apis/iam/register.go index 2417c84aed8..741419b30b0 100644 --- a/pkg/registry/apis/iam/register.go +++ b/pkg/registry/apis/iam/register.go @@ -29,6 +29,7 @@ import ( grafanaregistry "github.com/grafana/grafana/pkg/apiserver/registry/generic" "github.com/grafana/grafana/pkg/infra/db" "github.com/grafana/grafana/pkg/infra/log" + iamauthorizer "github.com/grafana/grafana/pkg/registry/apis/iam/authorizer" "github.com/grafana/grafana/pkg/registry/apis/iam/externalgroupmapping" "github.com/grafana/grafana/pkg/registry/apis/iam/legacy" "github.com/grafana/grafana/pkg/registry/apis/iam/resourcepermission" @@ -39,6 +40,7 @@ import ( "github.com/grafana/grafana/pkg/registry/apis/iam/user" "github.com/grafana/grafana/pkg/services/accesscontrol" gfauthorizer "github.com/grafana/grafana/pkg/services/apiserver/auth/authorizer" + "github.com/grafana/grafana/pkg/services/apiserver/auth/authorizer/storewrapper" "github.com/grafana/grafana/pkg/services/apiserver/builder" "github.com/grafana/grafana/pkg/services/authz/zanzana" "github.com/grafana/grafana/pkg/services/featuremgmt" @@ -402,7 +404,15 @@ func (b *IdentityAccessManagementAPIBuilder) UpdateResourcePermissionsAPIGroup( return err } - storage[iamv0.ResourcePermissionInfo.StoragePath()] = dw + // Not ideal, the alternative is to wrap both stores that dualwrite uses + regStoreDW, ok := dw.(*registry.Store) + if !ok { + return fmt.Errorf("expected RegistryStoreDualWrite, got %T", dw) + } + + authzWrapper := storewrapper.New(regStoreDW, iamauthorizer.NewResourcePermissionsAuthorizer(b.accessClient)) + + storage[iamv0.ResourcePermissionInfo.StoragePath()] = authzWrapper return nil } diff --git a/pkg/services/apiserver/auth/authorizer/storewrapper/wrapper.go b/pkg/services/apiserver/auth/authorizer/storewrapper/wrapper.go new file mode 100644 index 00000000000..bfc49e25607 --- /dev/null +++ b/pkg/services/apiserver/auth/authorizer/storewrapper/wrapper.go @@ -0,0 +1,191 @@ +package storewrapper + +import ( + "context" + "fmt" + + "github.com/grafana/grafana/pkg/apimachinery/identity" + "github.com/grafana/grafana/pkg/apiserver/rest" + "k8s.io/apimachinery/pkg/apis/meta/internalversion" + metaV1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + k8srest "k8s.io/apiserver/pkg/registry/rest" +) + +var ( + ErrUnauthenticated = fmt.Errorf("unauthenticated") + ErrUnauthorized = fmt.Errorf("unauthorized") + ErrUnexpectedType = fmt.Errorf("unexpected object type") +) + +// ResourceStorageAuthorizer defines authorization hooks for resource storage operations. +type ResourceStorageAuthorizer interface { + BeforeCreate(ctx context.Context, obj runtime.Object) error + BeforeUpdate(ctx context.Context, obj runtime.Object) error + BeforeDelete(ctx context.Context, obj runtime.Object) error + AfterGet(ctx context.Context, obj runtime.Object) error + FilterList(ctx context.Context, list runtime.Object) (runtime.Object, error) +} + +// Wrapper is a k8sStorage (e.g. registry.Store) wrapper that enforces authorization based on ResourceStorageAuthorizer. +// It overrides the identity in the context to use service identity for the underlying store operations. +// That way, the underlying store authorization is always successful, and the authorization is enforced by the wrapper. +type Wrapper struct { + inner K8sStorage + authorizer ResourceStorageAuthorizer +} + +type K8sStorage interface { + k8srest.Storage + k8srest.Scoper + k8srest.SingularNameProvider + k8srest.Lister + k8srest.Getter + k8srest.CreaterUpdater + k8srest.GracefulDeleter +} + +var _ rest.Storage = (*Wrapper)(nil) + +func New(store K8sStorage, authz ResourceStorageAuthorizer) *Wrapper { + return &Wrapper{inner: store, authorizer: authz} +} + +func (w *Wrapper) ConvertToTable(ctx context.Context, object runtime.Object, tableOptions runtime.Object) (*metaV1.Table, error) { + return w.inner.ConvertToTable(ctx, object, tableOptions) +} + +func (w *Wrapper) Create(ctx context.Context, obj runtime.Object, createValidation k8srest.ValidateObjectFunc, options *metaV1.CreateOptions) (runtime.Object, error) { + // Enforce authorization based on the user permissions before creating the object + err := w.authorizer.BeforeCreate(ctx, obj) + if err != nil { + return nil, err + } + // Override the identity to use service identity for the underlying store operation + srvCtx, _ := identity.WithServiceIdentity(ctx, 0) + + return w.inner.Create(srvCtx, obj, createValidation, options) +} + +func (w *Wrapper) Delete(ctx context.Context, name string, deleteValidation k8srest.ValidateObjectFunc, options *metaV1.DeleteOptions) (runtime.Object, bool, error) { + // Fetch the object first to authorize + srvCtx, _ := identity.WithServiceIdentity(ctx, 0) + getOpts := &metaV1.GetOptions{TypeMeta: options.TypeMeta} + if options.Preconditions != nil { + getOpts.ResourceVersion = *options.Preconditions.ResourceVersion + } + obj, err := w.inner.Get(srvCtx, name, getOpts) + if err != nil { + return nil, false, err + } + + // Enforce authorization based on the user permissions + if err := w.authorizer.BeforeDelete(ctx, obj); err != nil { + return nil, false, err + } + + return w.inner.Delete(srvCtx, name, deleteValidation, options) +} + +func (w *Wrapper) DeleteCollection(ctx context.Context, deleteValidation k8srest.ValidateObjectFunc, options *metaV1.DeleteOptions, listOptions *internalversion.ListOptions) (runtime.Object, error) { + // DeleteCollection is complex to authorize properly + // For now, deny it entirely for safety + return nil, fmt.Errorf("bulk delete operations are not supported through this API") +} + +func (w *Wrapper) Destroy() { + w.inner.Destroy() +} + +func (w *Wrapper) Get(ctx context.Context, name string, options *metaV1.GetOptions) (runtime.Object, error) { + // Override the identity to use service identity for the underlying store operation + srvCtx, _ := identity.WithServiceIdentity(ctx, 0) + + item, err := w.inner.Get(srvCtx, name, options) + if err != nil { + return nil, err + } + + // Enforce authorization based on the user permissions after retrieving the object + err = w.authorizer.AfterGet(ctx, item) + if err != nil { + return nil, err + } + return item, nil +} + +func (w *Wrapper) GetSingularName() string { + return w.inner.GetSingularName() +} + +func (w *Wrapper) List(ctx context.Context, options *internalversion.ListOptions) (runtime.Object, error) { + // Override the identity to use service identity for the underlying store operation + srvCtx, _ := identity.WithServiceIdentity(ctx, 0) + + list, err := w.inner.List(srvCtx, options) + if err != nil { + return nil, err + } + + // Enforce authorization based on the user permissions after retrieving the list + return w.authorizer.FilterList(ctx, list) +} + +func (w *Wrapper) NamespaceScoped() bool { + return w.inner.NamespaceScoped() +} + +func (w *Wrapper) New() runtime.Object { + return w.inner.New() +} + +func (w *Wrapper) NewList() runtime.Object { + return w.inner.NewList() +} + +func (w *Wrapper) Update( + ctx context.Context, + name string, + objInfo k8srest.UpdatedObjectInfo, + createValidation k8srest.ValidateObjectFunc, + updateValidation k8srest.ValidateObjectUpdateFunc, + forceAllowCreate bool, + options *metaV1.UpdateOptions, +) (runtime.Object, bool, error) { + // Create a wrapper around UpdatedObjectInfo to inject authorization + wrappedObjInfo := &authorizedUpdateInfo{ + inner: objInfo, + authorizer: w.authorizer, + userCtx: ctx, // Keep original context for authorization + } + + // Override the identity to use service identity for the underlying store operation + srvCtx, _ := identity.WithServiceIdentity(ctx, 0) + + return w.inner.Update(srvCtx, name, wrappedObjInfo, createValidation, updateValidation, forceAllowCreate, options) +} + +type authorizedUpdateInfo struct { + inner k8srest.UpdatedObjectInfo + authorizer ResourceStorageAuthorizer + userCtx context.Context +} + +func (a *authorizedUpdateInfo) Preconditions() *metaV1.Preconditions { + return a.inner.Preconditions() +} + +func (a *authorizedUpdateInfo) UpdatedObject(ctx context.Context, oldObj runtime.Object) (runtime.Object, error) { + // Get the updated object + updatedObj, err := a.inner.UpdatedObject(ctx, oldObj) + if err != nil { + return nil, err + } + + // Enforce authorization using the original user context + if err := a.authorizer.BeforeUpdate(a.userCtx, updatedObj); err != nil { + return nil, err + } + + return updatedObj, nil +} diff --git a/pkg/services/apiserver/auth/authorizer/storewrapper/wrapper_test.go b/pkg/services/apiserver/auth/authorizer/storewrapper/wrapper_test.go new file mode 100644 index 00000000000..d1dd3bb5e4e --- /dev/null +++ b/pkg/services/apiserver/auth/authorizer/storewrapper/wrapper_test.go @@ -0,0 +1,399 @@ +package storewrapper + +import ( + "context" + "testing" + + "github.com/grafana/authlib/types" + "github.com/grafana/grafana/pkg/apimachinery/identity" + "github.com/grafana/grafana/pkg/apiserver/rest" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" + "k8s.io/apimachinery/pkg/apis/meta/internalversion" + metaV1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" +) + +type testSetup struct { + mockStore *rest.MockStorage + mockAuth *FakeAuthorizer + wrapper *Wrapper + ctx context.Context +} + +func newTestSetup(t *testing.T) *testSetup { + mockStore := rest.NewMockStorage(t) + mockAuth := &FakeAuthorizer{} + wrapper := New(mockStore, mockAuth) + + ctx := identity.WithRequester( + context.Background(), + &identity.StaticRequester{UserUID: "u001", Type: types.TypeUser}, + ) + + return &testSetup{mockStore: mockStore, mockAuth: mockAuth, wrapper: wrapper, ctx: ctx} +} + +func matchesOriginalUser() func(context.Context) bool { + return func(ctx context.Context) bool { + user, err := identity.GetRequester(ctx) + return err == nil && user.GetUID() == "user:u001" + } +} + +func matchesServiceIdentity() func(context.Context) bool { + return func(ctx context.Context) bool { + return identity.IsServiceIdentity(ctx) + } +} + +func TestWrapper_Create(t *testing.T) { + t.Run("success", func(t *testing.T) { + setup := newTestSetup(t) + + obj := &fakeObject{} + createOpts := &metaV1.CreateOptions{} + expectedObj := &fakeObject{ObjectMeta: metaV1.ObjectMeta{Name: "created"}} + + // Verify original user identity is used for authorization + setup.mockAuth.On("BeforeCreate", mock.MatchedBy(matchesOriginalUser()), obj).Return(nil) + + // Verify service identity is used to call the underlying store + setup.mockStore.On("Create", mock.MatchedBy(matchesServiceIdentity()), obj, mock.Anything, createOpts).Return(expectedObj, nil) + + result, err := setup.wrapper.Create(setup.ctx, obj, nil, createOpts) + + require.NoError(t, err) + assert.Equal(t, expectedObj, result) + + // Assert expectations + setup.mockAuth.AssertExpectations(t) + setup.mockStore.AssertExpectations(t) + }) + t.Run("unauthorized", func(t *testing.T) { + setup := newTestSetup(t) + + obj := &fakeObject{} + createOpts := &metaV1.CreateOptions{} + + // Simulate unauthorized error from authorizer + setup.mockAuth.On("BeforeCreate", mock.MatchedBy(matchesOriginalUser()), obj).Return(ErrUnauthorized) + + result, err := setup.wrapper.Create(setup.ctx, obj, nil, createOpts) + + require.Error(t, err) + assert.Nil(t, result) + assert.Equal(t, ErrUnauthorized, err) + + // Assert expectations + setup.mockAuth.AssertExpectations(t) + setup.mockStore.AssertNotCalled(t, "Create") + }) +} + +func TestWrapper_Delete(t *testing.T) { + t.Run("success", func(t *testing.T) { + setup := newTestSetup(t) + version := "1" + obj := &fakeObject{ObjectMeta: metaV1.ObjectMeta{Name: "to-delete"}} + deleteOpts := &metaV1.DeleteOptions{Preconditions: &metaV1.Preconditions{ResourceVersion: &version}} + expectedObj := &fakeObject{ObjectMeta: metaV1.ObjectMeta{Name: "deleted"}} + + // Mock Get to fetch the object before deletion + setup.mockStore.On("Get", mock.MatchedBy(matchesServiceIdentity()), "to-delete", mock.Anything).Return(obj, nil) + + // Verify original user identity is used for authorization + setup.mockAuth.On("BeforeDelete", mock.MatchedBy(matchesOriginalUser()), obj).Return(nil) + + // Verify service identity is used to call the underlying store + setup.mockStore.On("Delete", mock.MatchedBy(matchesServiceIdentity()), "to-delete", mock.Anything, deleteOpts).Return(expectedObj, true, nil) + + result, deleted, err := setup.wrapper.Delete(setup.ctx, "to-delete", nil, deleteOpts) + + require.NoError(t, err) + assert.Equal(t, expectedObj, result) + assert.True(t, deleted) + + // Assert expectations + setup.mockAuth.AssertExpectations(t) + setup.mockStore.AssertExpectations(t) + }) + t.Run("unauthorized", func(t *testing.T) { + setup := newTestSetup(t) + version := "1" + obj := &fakeObject{ObjectMeta: metaV1.ObjectMeta{Name: "to-delete"}} + deleteOpts := &metaV1.DeleteOptions{Preconditions: &metaV1.Preconditions{ResourceVersion: &version}} + + // Mock Get to fetch the object before deletion + setup.mockStore.On("Get", mock.MatchedBy(matchesServiceIdentity()), "to-delete", mock.Anything).Return(obj, nil) + + // Simulate unauthorized error from authorizer + setup.mockAuth.On("BeforeDelete", mock.MatchedBy(matchesOriginalUser()), obj).Return(ErrUnauthorized) + + result, deleted, err := setup.wrapper.Delete(setup.ctx, "to-delete", nil, deleteOpts) + + require.Error(t, err) + assert.Nil(t, result) + assert.False(t, deleted) + assert.Equal(t, ErrUnauthorized, err) + + // Assert expectations + setup.mockAuth.AssertExpectations(t) + setup.mockStore.AssertExpectations(t) + setup.mockStore.AssertNotCalled(t, "Delete") + }) +} + +func TestWrapper_Get(t *testing.T) { + t.Run("success", func(t *testing.T) { + setup := newTestSetup(t) + + obj := &fakeObject{ObjectMeta: metaV1.ObjectMeta{Name: "fetched"}} + + // Verify service identity is used to call the underlying store + setup.mockStore.On("Get", mock.MatchedBy(matchesServiceIdentity()), "fetched", mock.Anything).Return(obj, nil) + + // Verify original user identity is used for after-get authorization + setup.mockAuth.On("AfterGet", mock.MatchedBy(matchesOriginalUser()), obj).Return(nil) + + result, err := setup.wrapper.Get(setup.ctx, "fetched", &metaV1.GetOptions{}) + + require.NoError(t, err) + assert.Equal(t, obj, result) + + // Assert expectations + setup.mockAuth.AssertExpectations(t) + setup.mockStore.AssertExpectations(t) + }) + t.Run("unauthorized", func(t *testing.T) { + setup := newTestSetup(t) + + obj := &fakeObject{ObjectMeta: metaV1.ObjectMeta{Name: "fetched"}} + + // Verify service identity is used to call the underlying store + setup.mockStore.On("Get", mock.MatchedBy(matchesServiceIdentity()), "fetched", mock.Anything).Return(obj, nil) + + // Simulate unauthorized error from after-get authorizer + setup.mockAuth.On("AfterGet", mock.MatchedBy(matchesOriginalUser()), obj).Return(ErrUnauthorized) + + result, err := setup.wrapper.Get(setup.ctx, "fetched", &metaV1.GetOptions{}) + + require.Error(t, err) + assert.Nil(t, result) + assert.Equal(t, ErrUnauthorized, err) + + // Assert expectations + setup.mockAuth.AssertExpectations(t) + setup.mockStore.AssertExpectations(t) + }) +} + +func TestWrapper_List(t *testing.T) { + t.Run("success", func(t *testing.T) { + setup := newTestSetup(t) + + listObj := &metaV1.List{Items: []runtime.RawExtension{ + {Object: &fakeObject{ObjectMeta: metaV1.ObjectMeta{Name: "item1"}}}, + {Object: &fakeObject{ObjectMeta: metaV1.ObjectMeta{Name: "item2"}}}, + }} + + filteredListObj := &metaV1.List{Items: []runtime.RawExtension{ + {Object: &fakeObject{ObjectMeta: metaV1.ObjectMeta{Name: "item1"}}}, + }} + + // Verify service identity is used to call the underlying store + setup.mockStore.On("List", mock.MatchedBy(matchesServiceIdentity()), mock.Anything).Return(listObj, nil) + + // Verify original user identity is used for filtering the list + setup.mockAuth.On("FilterList", mock.MatchedBy(matchesOriginalUser()), listObj).Return(filteredListObj, nil) + + result, err := setup.wrapper.List(setup.ctx, &internalversion.ListOptions{}) + + require.NoError(t, err) + assert.Equal(t, filteredListObj, result) + + // Assert expectations + setup.mockAuth.AssertExpectations(t) + setup.mockStore.AssertExpectations(t) + }) + t.Run("unauthorized", func(t *testing.T) { + setup := newTestSetup(t) + + listObj := &metaV1.List{Items: []runtime.RawExtension{ + {Object: &fakeObject{ObjectMeta: metaV1.ObjectMeta{Name: "item1"}}}, + {Object: &fakeObject{ObjectMeta: metaV1.ObjectMeta{Name: "item2"}}}, + }} + + // Verify service identity is used to call the underlying store + setup.mockStore.On("List", mock.MatchedBy(matchesServiceIdentity()), mock.Anything).Return(listObj, nil) + + // Simulate unauthorized error from FilterList authorizer + setup.mockAuth.On("FilterList", mock.MatchedBy(matchesOriginalUser()), listObj).Return(nil, ErrUnauthorized) + + result, err := setup.wrapper.List(setup.ctx, &internalversion.ListOptions{}) + + require.Error(t, err) + assert.Nil(t, result) + assert.Equal(t, ErrUnauthorized, err) + + // Assert expectations + setup.mockAuth.AssertExpectations(t) + setup.mockStore.AssertExpectations(t) + }) +} + +func TestWrapper_Update(t *testing.T) { + setup := newTestSetup(t) + + oldObj := &fakeObject{ObjectMeta: metaV1.ObjectMeta{ + Name: "to-update", ResourceVersion: "2", Labels: map[string]string{"updated": "false"}, + }} + objInfo := &fakeUpdatedObjectInfo{obj: oldObj} + updateOpts := &metaV1.UpdateOptions{} + + var authzInfo *authorizedUpdateInfo + + // Verify service identity is used to call the underlying store + setup.mockStore.On("Update", + mock.MatchedBy(matchesServiceIdentity()), + "to-update", + mock.MatchedBy(func(info *authorizedUpdateInfo) bool { + // Capture the authorizedUpdateInfo for later verification + authzInfo = info + return true + }), + mock.Anything, + mock.Anything, + false, + updateOpts).Return(oldObj, true, nil) + + result, updated, err := setup.wrapper.Update(setup.ctx, "to-update", objInfo, nil, nil, false, updateOpts) + require.NoError(t, err) + assert.Equal(t, oldObj, result) + assert.True(t, updated) + + // Now verify that the authorization is performed inside UpdatedObject + setup.mockAuth.On("BeforeUpdate", mock.MatchedBy(matchesOriginalUser()), oldObj).Return(nil) + obj, err := authzInfo.UpdatedObject(context.Background(), oldObj) + require.NoError(t, err) + assert.Equal(t, oldObj, obj) + + // Assert expectations + setup.mockAuth.AssertExpectations(t) + setup.mockStore.AssertExpectations(t) +} + +func TestWrapper_DeleteCollection(t *testing.T) { + setup := newTestSetup(t) + + result, err := setup.wrapper.DeleteCollection(setup.ctx, nil, &metaV1.DeleteOptions{}, &internalversion.ListOptions{}) + + require.Error(t, err) + assert.Contains(t, err.Error(), "bulk delete operations are not supported") + assert.Nil(t, result) +} + +func TestWrapper_PassthroughMethods(t *testing.T) { + setup := newTestSetup(t) + + t.Run("New", func(t *testing.T) { + obj := &fakeObject{} + setup.mockStore.On("New").Return(obj).Once() + assert.Equal(t, obj, setup.wrapper.New()) + }) + + t.Run("NewList", func(t *testing.T) { + obj := &fakeObject{} + setup.mockStore.On("NewList").Return(obj).Once() + assert.Equal(t, obj, setup.wrapper.NewList()) + }) + + t.Run("GetSingularName", func(t *testing.T) { + setup.mockStore.On("GetSingularName").Return("fake").Once() + assert.Equal(t, "fake", setup.wrapper.GetSingularName()) + }) + + t.Run("NamespaceScoped", func(t *testing.T) { + setup.mockStore.On("NamespaceScoped").Return(true).Once() + assert.True(t, setup.wrapper.NamespaceScoped()) + }) + + t.Run("Destroy", func(t *testing.T) { + setup.mockStore.On("Destroy").Once() + setup.wrapper.Destroy() + }) + + t.Run("ConvertToTable", func(t *testing.T) { + obj := &fakeObject{} + table := &metaV1.Table{} + setup.mockStore.On("ConvertToTable", setup.ctx, obj, mock.Anything).Return(table, nil).Once() + result, err := setup.wrapper.ConvertToTable(setup.ctx, obj, nil) + require.NoError(t, err) + assert.Equal(t, table, result) + }) + + setup.mockStore.AssertExpectations(t) +} + +// ----- +// Fakes +// ----- + +type FakeAuthorizer struct { + mock.Mock +} + +func (f *FakeAuthorizer) BeforeCreate(ctx context.Context, obj runtime.Object) error { + args := f.Called(ctx, obj) + return args.Error(0) +} + +func (f *FakeAuthorizer) BeforeUpdate(ctx context.Context, obj runtime.Object) error { + args := f.Called(ctx, obj) + return args.Error(0) +} + +func (f *FakeAuthorizer) BeforeDelete(ctx context.Context, obj runtime.Object) error { + args := f.Called(ctx, obj) + return args.Error(0) +} + +func (f *FakeAuthorizer) AfterGet(ctx context.Context, obj runtime.Object) error { + args := f.Called(ctx, obj) + return args.Error(0) +} + +func (f *FakeAuthorizer) FilterList(ctx context.Context, list runtime.Object) (runtime.Object, error) { + args := f.Called(ctx, list) + var res runtime.Object + if args.Get(0) != nil { + res = args.Get(0).(runtime.Object) + } + return res, args.Error(1) +} + +type fakeObject struct { + metaV1.TypeMeta + metaV1.ObjectMeta +} + +func (f *fakeObject) DeepCopyObject() runtime.Object { + return &fakeObject{ + TypeMeta: f.TypeMeta, + ObjectMeta: f.ObjectMeta, + } +} + +// fakeUpdatedObjectInfo implements k8srest.UpdatedObjectInfo for testing +type fakeUpdatedObjectInfo struct { + obj runtime.Object +} + +func (f *fakeUpdatedObjectInfo) Preconditions() *metaV1.Preconditions { + return nil +} + +func (f *fakeUpdatedObjectInfo) UpdatedObject(ctx context.Context, oldObj runtime.Object) (runtime.Object, error) { + return f.obj, nil +} diff --git a/pkg/services/authz/rbac/service.go b/pkg/services/authz/rbac/service.go index 5ef2e0c5ad6..4c3f3ba0393 100644 --- a/pkg/services/authz/rbac/service.go +++ b/pkg/services/authz/rbac/service.go @@ -782,7 +782,7 @@ func (s *Service) listPermission(ctx context.Context, scopeMap map[string]bool, } var res *authzv1.ListResponse - if strings.HasPrefix(req.Action, "folders:") { + if strings.HasPrefix(req.Action, "folders:") || strings.HasPrefix(req.Action, "folders.permissions:") { res = buildFolderList(scopeMap, tree) } else { res = buildItemList(scopeMap, tree, t.Prefix())