grafana-iam: Implement api level user authorization (#114498)

* OnGoing

comment

* WIP on the wrapper

* Get before Delete

* WIP: add an unimplemented storage authorizer

* WIP implementing the resource permission authorize

* Implement beforeCreate

* Create, Delete, Update

* List

* Use a resource permissions wrapper

* Switch the main authorizer to service

* Add namespace

* Use compile for list

* Comment

* Remove unecessary comments

* fix bug with folder permissions

* Implement tests for List

* Test get

* List test small refactor

* Delete test

* Reorganize code

* imports

* Start splitting the tests

* test AfterDelete

* actually test beforeWrite

* Implement tests for wrapper create

* Test delete

* Test List and Get

* Fix List

* Remaining tests

* simplify

* Remove comments

* Reorder

* Change authorizer to allow access
This commit is contained in:
Gabriel MABILLE
2025-12-03 17:06:26 +01:00
committed by GitHub
parent 0f698d08d3
commit 8998b1fde4
7 changed files with 995 additions and 3 deletions
+14 -1
View File
@@ -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
@@ -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)
}
}
@@ -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)
+11 -1
View File
@@ -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
}