IAM: Update ExternalGroupMapping authorizer (#115627)

* wip

* Add target resource authorizer to ExternalGroupMapping

* Regenerate OpenAPI snapshot

* Update pkg/registry/apis/iam/authorizer/external_group_mapping.go

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Update pkg/registry/apis/iam/register.go

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Address feedback, reorganize

* Add tests to the public interface separately

* Address feedback

* Address feedback

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
Misi
2026-01-08 11:47:00 +01:00
committed by GitHub
co-authored by Copilot
parent 79f2016a66
commit 5fcc67837a
8 changed files with 440 additions and 91 deletions
+1 -1
View File
@@ -53,7 +53,7 @@ func newIAMAuthorizer(
resourceAuthorizer[iamv0.RoleBindingInfo.GetName()] = authorizer
resourceAuthorizer[iamv0.ServiceAccountResourceInfo.GetName()] = authorizer
resourceAuthorizer[iamv0.UserResourceInfo.GetName()] = authorizer
resourceAuthorizer[iamv0.ExternalGroupMappingResourceInfo.GetName()] = authorizer
resourceAuthorizer[iamv0.ExternalGroupMappingResourceInfo.GetName()] = allowAuthorizer
resourceAuthorizer[iamv0.TeamResourceInfo.GetName()] = authorizer
resourceAuthorizer["searchUsers"] = serviceAuthorizer
resourceAuthorizer["searchTeams"] = serviceAuthorizer
@@ -0,0 +1,150 @@
package authorizer
import (
"context"
"fmt"
"github.com/grafana/authlib/types"
"k8s.io/apimachinery/pkg/runtime"
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"
apierrors "k8s.io/apimachinery/pkg/api/errors"
)
type ExternalGroupMappingAuthorizer struct {
accessClient types.AccessClient
}
var _ storewrapper.ResourceStorageAuthorizer = (*ExternalGroupMappingAuthorizer)(nil)
func NewExternalGroupMappingAuthorizer(
accessClient types.AccessClient,
) *ExternalGroupMappingAuthorizer {
return &ExternalGroupMappingAuthorizer{
accessClient: accessClient,
}
}
// AfterGet implements ResourceStorageAuthorizer.
func (r *ExternalGroupMappingAuthorizer) AfterGet(ctx context.Context, obj runtime.Object) error {
authInfo, ok := types.AuthInfoFrom(ctx)
if !ok {
return storewrapper.ErrUnauthenticated
}
concreteObj, ok := obj.(*iamv0.ExternalGroupMapping)
if !ok {
return apierrors.NewInternalError(fmt.Errorf("expected ExternalGroupMapping, got %T: %w", obj, storewrapper.ErrUnexpectedType))
}
teamName := concreteObj.Spec.TeamRef.Name
checkReq := types.CheckRequest{
Namespace: authInfo.GetNamespace(),
Group: iamv0.GROUP,
Resource: iamv0.TeamResourceInfo.GetName(),
Verb: utils.VerbGetPermissions,
Name: teamName,
}
res, err := r.accessClient.Check(ctx, authInfo, checkReq, "")
if err != nil {
return apierrors.NewInternalError(err)
}
if !res.Allowed {
return apierrors.NewForbidden(
iamv0.ExternalGroupMappingResourceInfo.GroupResource(),
concreteObj.Name,
fmt.Errorf("user cannot access team %s", teamName),
)
}
return nil
}
// BeforeCreate implements ResourceStorageAuthorizer.
func (r *ExternalGroupMappingAuthorizer) BeforeCreate(ctx context.Context, obj runtime.Object) error {
return r.beforeWrite(ctx, obj)
}
// BeforeDelete implements ResourceStorageAuthorizer.
func (r *ExternalGroupMappingAuthorizer) BeforeDelete(ctx context.Context, obj runtime.Object) error {
return r.beforeWrite(ctx, obj)
}
// BeforeUpdate implements ResourceStorageAuthorizer.
func (r *ExternalGroupMappingAuthorizer) BeforeUpdate(ctx context.Context, obj runtime.Object) error {
// Update is not supported for ExternalGroupMapping resources and update attempts are blocked at a lower level,
// so this is just a safeguard.
return apierrors.NewMethodNotSupported(iamv0.ExternalGroupMappingResourceInfo.GroupResource(), "PUT/PATCH")
}
func (r *ExternalGroupMappingAuthorizer) beforeWrite(ctx context.Context, obj runtime.Object) error {
authInfo, ok := types.AuthInfoFrom(ctx)
if !ok {
return storewrapper.ErrUnauthenticated
}
concreteObj, ok := obj.(*iamv0.ExternalGroupMapping)
if !ok {
return apierrors.NewInternalError(fmt.Errorf("expected ExternalGroupMapping, got %T: %w", obj, storewrapper.ErrUnexpectedType))
}
teamName := concreteObj.Spec.TeamRef.Name
checkReq := types.CheckRequest{
Namespace: authInfo.GetNamespace(),
Group: iamv0.GROUP,
Resource: iamv0.TeamResourceInfo.GetName(),
Verb: utils.VerbSetPermissions,
Name: teamName,
}
res, err := r.accessClient.Check(ctx, authInfo, checkReq, "")
if err != nil {
return apierrors.NewInternalError(err)
}
if !res.Allowed {
return apierrors.NewForbidden(
iamv0.ExternalGroupMappingResourceInfo.GroupResource(),
concreteObj.Name,
fmt.Errorf("user cannot write team %s", teamName),
)
}
return nil
}
// FilterList implements ResourceStorageAuthorizer.
func (r *ExternalGroupMappingAuthorizer) FilterList(ctx context.Context, list runtime.Object) (runtime.Object, error) {
authInfo, ok := types.AuthInfoFrom(ctx)
if !ok {
return nil, storewrapper.ErrUnauthenticated
}
l, ok := list.(*iamv0.ExternalGroupMappingList)
if !ok {
return nil, apierrors.NewInternalError(fmt.Errorf("expected ExternalGroupMappingList, got %T: %w", list, storewrapper.ErrUnexpectedType))
}
var filteredItems []iamv0.ExternalGroupMapping
listReq := types.ListRequest{
Namespace: authInfo.GetNamespace(),
Group: iamv0.GROUP,
Resource: iamv0.TeamResourceInfo.GetName(),
Verb: utils.VerbGetPermissions,
}
canView, _, err := r.accessClient.Compile(ctx, authInfo, listReq)
if err != nil {
return nil, apierrors.NewInternalError(err)
}
for _, item := range l.Items {
if canView(item.Spec.TeamRef.Name, "") {
filteredItems = append(filteredItems, item)
}
}
l.Items = filteredItems
return l, nil
}
@@ -0,0 +1,229 @@
package authorizer
import (
"context"
"testing"
"github.com/stretchr/testify/require"
apierrors "k8s.io/apimachinery/pkg/api/errors"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"github.com/grafana/authlib/types"
iamv0 "github.com/grafana/grafana/apps/iam/pkg/apis/iam/v0alpha1"
"github.com/grafana/grafana/pkg/apimachinery/utils"
)
func newExternalGroupMapping(teamName, name string) *iamv0.ExternalGroupMapping {
return &iamv0.ExternalGroupMapping{
ObjectMeta: metav1.ObjectMeta{Namespace: "org-2", Name: name},
Spec: iamv0.ExternalGroupMappingSpec{
TeamRef: iamv0.ExternalGroupMappingTeamRef{
Name: teamName,
},
},
}
}
func TestExternalGroupMapping_AfterGet(t *testing.T) {
mapping := newExternalGroupMapping("team-1", "mapping-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)
require.Equal(t, "user:u001", id.GetUID())
require.Equal(t, "org-2", id.GetNamespace())
require.Equal(t, "org-2", req.Namespace)
require.Equal(t, iamv0.GROUP, req.Group)
require.Equal(t, iamv0.TeamResourceInfo.GetName(), req.Resource)
require.Equal(t, "team-1", req.Name)
require.Equal(t, utils.VerbGetPermissions, req.Verb)
require.Equal(t, "", folder)
return types.CheckResponse{Allowed: tt.shouldAllow}, nil
}
accessClient := &fakeAccessClient{checkFunc: checkFunc}
authz := NewExternalGroupMappingAuthorizer(accessClient)
ctx := types.WithAuthInfo(context.Background(), user)
err := authz.AfterGet(ctx, mapping)
if tt.shouldAllow {
require.NoError(t, err)
} else {
require.Error(t, err)
}
require.True(t, accessClient.checkCalled)
})
}
}
func TestExternalGroupMapping_FilterList(t *testing.T) {
list := &iamv0.ExternalGroupMappingList{
Items: []iamv0.ExternalGroupMapping{
*newExternalGroupMapping("team-1", "mapping-1"),
*newExternalGroupMapping("team-2", "mapping-2"),
},
ListMeta: metav1.ListMeta{
SelfLink: "/apis/iam.grafana.app/v0alpha1/namespaces/org-2/externalgroupmappings",
},
}
compileFunc := func(id types.AuthInfo, req types.ListRequest) (types.ItemChecker, types.Zookie, error) {
require.NotNil(t, id)
require.Equal(t, "user:u001", id.GetUID())
require.Equal(t, "org-2", id.GetNamespace())
require.Equal(t, "org-2", req.Namespace)
require.Equal(t, iamv0.GROUP, req.Group)
require.Equal(t, iamv0.TeamResourceInfo.GetName(), req.Resource)
require.Equal(t, utils.VerbGetPermissions, req.Verb)
return func(name, folder string) bool {
return name == "team-1"
}, &types.NoopZookie{}, nil
}
accessClient := &fakeAccessClient{compileFunc: compileFunc}
authz := NewExternalGroupMappingAuthorizer(accessClient)
ctx := types.WithAuthInfo(context.Background(), user)
obj, err := authz.FilterList(ctx, list)
require.NoError(t, err)
require.NotNil(t, list)
require.True(t, accessClient.compileCalled)
filtered, ok := obj.(*iamv0.ExternalGroupMappingList)
require.True(t, ok)
require.Len(t, filtered.Items, 1)
require.Equal(t, "mapping-1", filtered.Items[0].Name)
}
func TestExternalGroupMapping_BeforeCreate(t *testing.T) {
mapping := newExternalGroupMapping("team-1", "mapping-1")
tests := []struct {
name string
shouldAllow bool
}{
{
name: "allow create",
shouldAllow: true,
},
{
name: "deny create",
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)
require.Equal(t, "user:u001", id.GetUID())
require.Equal(t, "org-2", id.GetNamespace())
require.Equal(t, "org-2", req.Namespace)
require.Equal(t, iamv0.GROUP, req.Group)
require.Equal(t, iamv0.TeamResourceInfo.GetName(), req.Resource)
require.Equal(t, "team-1", req.Name)
require.Equal(t, utils.VerbSetPermissions, req.Verb)
require.Equal(t, "", folder)
return types.CheckResponse{Allowed: tt.shouldAllow}, nil
}
accessClient := &fakeAccessClient{checkFunc: checkFunc}
authz := NewExternalGroupMappingAuthorizer(accessClient)
ctx := types.WithAuthInfo(context.Background(), user)
err := authz.BeforeCreate(ctx, mapping)
if tt.shouldAllow {
require.NoError(t, err)
} else {
require.Error(t, err)
}
require.True(t, accessClient.checkCalled)
})
}
}
func TestExternalGroupMapping_BeforeUpdate(t *testing.T) {
mapping := newExternalGroupMapping("team-1", "mapping-1")
accessClient := &fakeAccessClient{
checkFunc: func(id types.AuthInfo, req *types.CheckRequest, folder string) (types.CheckResponse, error) {
require.Fail(t, "check should not be called")
return types.CheckResponse{}, nil
},
}
authz := NewExternalGroupMappingAuthorizer(accessClient)
ctx := types.WithAuthInfo(context.Background(), user)
err := authz.BeforeUpdate(ctx, mapping)
require.Error(t, err)
require.True(t, apierrors.IsMethodNotSupported(err))
require.Contains(t, err.Error(), "PUT/PATCH")
require.False(t, accessClient.checkCalled)
}
func TestExternalGroupMapping_BeforeDelete(t *testing.T) {
mapping := newExternalGroupMapping("team-1", "mapping-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)
require.Equal(t, "user:u001", id.GetUID())
require.Equal(t, "org-2", id.GetNamespace())
require.Equal(t, "org-2", req.Namespace)
require.Equal(t, iamv0.GROUP, req.Group)
require.Equal(t, iamv0.TeamResourceInfo.GetName(), req.Resource)
require.Equal(t, "team-1", req.Name)
require.Equal(t, utils.VerbSetPermissions, req.Verb)
require.Equal(t, "", folder)
return types.CheckResponse{Allowed: tt.shouldAllow}, nil
}
accessClient := &fakeAccessClient{checkFunc: checkFunc}
authz := NewExternalGroupMappingAuthorizer(accessClient)
ctx := types.WithAuthInfo(context.Background(), user)
err := authz.BeforeDelete(ctx, mapping)
if tt.shouldAllow {
require.NoError(t, err)
} else {
require.Error(t, err)
}
require.True(t, accessClient.checkCalled)
})
}
}
@@ -4,35 +4,15 @@ import (
"context"
"testing"
"github.com/go-jose/go-jose/v4/jwt"
"github.com/stretchr/testify/require"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime/schema"
"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"
)
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"},
@@ -222,26 +202,6 @@ func TestResourcePermissions_beforeWrite(t *testing.T) {
}
}
// 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)
type fakeParentProvider struct {
hasParent bool
getParentCalled bool
@@ -0,0 +1,48 @@
package authorizer
import (
"context"
"github.com/go-jose/go-jose/v4/jwt"
"github.com/grafana/authlib/authn"
"github.com/grafana/authlib/types"
"github.com/grafana/grafana/pkg/apimachinery/identity"
)
var (
// Shared test user identity
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},
},
)
)
var _ types.AccessClient = (*fakeAccessClient)(nil)
// 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)
}
+11 -2
View File
@@ -353,7 +353,8 @@ func (b *IdentityAccessManagementAPIBuilder) UpdateAPIGroupInfo(apiGroupInfo *ge
if err != nil {
return err
}
storage[extGroupMappingResource.StoragePath()] = extGroupMappingUniStore
var extGroupMappingStore storewrapper.K8sStorage = extGroupMappingUniStore
if b.externalGroupMappingStorage != nil {
extGroupMappingLegacyStore, err := NewLocalStore(extGroupMappingResource, apiGroupInfo.Scheme, opts.OptsGetter, b.reg, b.accessClient, b.externalGroupMappingStorage)
@@ -365,9 +366,17 @@ func (b *IdentityAccessManagementAPIBuilder) UpdateAPIGroupInfo(apiGroupInfo *ge
if err != nil {
return err
}
storage[extGroupMappingResource.StoragePath()] = dw
var ok bool
extGroupMappingStore, ok = dw.(storewrapper.K8sStorage)
if !ok {
return fmt.Errorf("expected storewrapper.K8sStorage, got %T", dw)
}
}
authzWrapper := storewrapper.New(extGroupMappingStore, iamauthorizer.NewExternalGroupMappingAuthorizer(b.accessClient))
storage[extGroupMappingResource.StoragePath()] = authzWrapper
//nolint:staticcheck // not yet migrated to OpenFeature
if b.features.IsEnabledGlobally(featuremgmt.FlagKubernetesAuthzApis) {
// v0alpha1