mirror of
https://github.com/grafana/grafana.git
synced 2026-03-13 15:29:48 +08:00
IAM: Add users filtering and improved RBAC mapper for users API (#119100)
* IAM: Add hidden users filtering and improved RBAC mapper for users API - Add StoreWrapper for user resource that filters hidden users on Get/List - Wire up StoreWrapper in the users API group registration - Expand RBAC verb mapping for users to use explicit action translations - Add integration tests for hidden users filtering behavior Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * IAM: Fix duplicate user validation and storewrapper context propagation The storewrapper replaced the request context with a service identity (OrgID=0) before invoking createValidation/updateValidation callbacks. Since these callbacks wrap k8s admission webhooks (including the duplicate email/login checks), the validation ran with OrgID=0 causing SearchOrgUsers to return no results, silently passing duplicates through to the DB which then returned a 500 instead of 409. Fix 1 (storewrapper): Add validationWithUserContext and updateValidationWithUserContext helpers that rebind validation callbacks to the original user context before passing them to the inner store. Fix 2 (legacy store): Add toUserConflictError as defense-in-depth that converts SQLite UNIQUE constraint failures on user.email/user.login into proper 409 Conflict API errors in CreateUser and UpdateUser. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * Regen * Use configprovider.ConfigProvider instead of setting.Cfg * Enforce hidden-users restrictions on write operations BeforeCreate, BeforeUpdate, and BeforeDelete in the user StoreWrapper now return HTTP 403 when the target user's login is in the hidden-users list, returning a generic "operation not permitted" message to callers and logging the hidden-user detail server-side via a structured logger. Integration tests are updated to create the user before marking it hidden (so BeforeCreate does not block setup), then verify all four guarded paths (get→404, list filtered, update→403, delete→403) and add a dedicated sub-test that confirms create is blocked once a login is in the hidden list. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * IAM: Add WithPreserveIdentity option to storewrapper Introduces a WithPreserveIdentity() functional option on storewrapper.New() so the users storage path passes the original caller identity through to the inner store instead of replacing it with a service identity. This ensures admission validation (e.g. duplicate email/login checks) runs with the correct OrgID. Adds unit tests for the new option. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * Address feedback * Fix some minor issues * Update pkg/registry/apis/iam/register.go Co-authored-by: Gabriel MABILLE <gamab@users.noreply.github.com> * Address feedback --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com> Co-authored-by: Gabriel MABILLE <gamab@users.noreply.github.com>
This commit is contained in:
@@ -271,6 +271,7 @@ contrib.go.opencensus.io/exporter/ocagent v0.6.0 h1:Z1n6UAyr0QwM284yUuh5Zd8JlvxU
|
||||
contrib.go.opencensus.io/exporter/stackdriver v0.13.15-0.20230702191903-2de6d2748484 h1:xRc46S76eyn4ZF3jWX8I+aUSKVLw5EQ1aDvHwfV5W1o=
|
||||
contrib.go.opencensus.io/exporter/stackdriver v0.13.15-0.20230702191903-2de6d2748484/go.mod h1:uxw+4/0SiKbbVSD/F2tk5pJTdVcfIBBcsQ8gwcu4X+E=
|
||||
dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9 h1:VpgP7xuJadIUuKccphEpTJnWhS2jkQyMt6Y7pJCD7fY=
|
||||
filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc=
|
||||
gioui.org v0.2.0 h1:RbzDn1h/pCVf/q44ImQSa/J3MIFpY3OWphzT/Tyei+w=
|
||||
gioui.org v0.2.0/go.mod h1:1H72sKEk/fNFV+l0JNeM2Dt3co3Y4uaQcD+I+/GQ0e4=
|
||||
gioui.org/cpu v0.0.0-20220412190645-f1e9e8c3b1f7 h1:tNJdnP5CgM39PRc+KWmBRRYX/zJ+rd5XaYxY5d5veqA=
|
||||
|
||||
@@ -3855,7 +3855,7 @@
|
||||
"/users": {
|
||||
"get": {
|
||||
"tags": ["User"],
|
||||
"description": "list objects of kind User",
|
||||
"description": "list or watch objects of kind User",
|
||||
"operationId": "listUser",
|
||||
"parameters": [
|
||||
{
|
||||
|
||||
@@ -73,7 +73,7 @@ func (r *ExternalGroupMappingAuthorizer) BeforeDelete(ctx context.Context, obj r
|
||||
}
|
||||
|
||||
// BeforeUpdate implements ResourceStorageAuthorizer.
|
||||
func (r *ExternalGroupMappingAuthorizer) BeforeUpdate(ctx context.Context, obj runtime.Object) error {
|
||||
func (r *ExternalGroupMappingAuthorizer) BeforeUpdate(ctx context.Context, oldObj, 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")
|
||||
|
||||
@@ -173,7 +173,7 @@ func TestExternalGroupMapping_BeforeUpdate(t *testing.T) {
|
||||
authz := NewExternalGroupMappingAuthorizer(accessClient)
|
||||
ctx := types.WithAuthInfo(context.Background(), user)
|
||||
|
||||
err := authz.BeforeUpdate(ctx, mapping)
|
||||
err := authz.BeforeUpdate(ctx, mapping, mapping)
|
||||
require.Error(t, err)
|
||||
require.True(t, apierrors.IsMethodNotSupported(err))
|
||||
require.Contains(t, err.Error(), "PUT/PATCH")
|
||||
|
||||
@@ -160,7 +160,7 @@ func (r *ResourcePermissionsAuthorizer) BeforeDelete(ctx context.Context, obj ru
|
||||
}
|
||||
|
||||
// BeforeUpdate implements ResourceStorageAuthorizer.
|
||||
func (r *ResourcePermissionsAuthorizer) BeforeUpdate(ctx context.Context, obj runtime.Object) error {
|
||||
func (r *ResourcePermissionsAuthorizer) BeforeUpdate(ctx context.Context, oldObj, obj runtime.Object) error {
|
||||
return r.beforeWrite(ctx, obj)
|
||||
}
|
||||
|
||||
|
||||
@@ -38,7 +38,7 @@ func (a *RoleBindingAuthorizer) BeforeCreate(ctx context.Context, obj runtime.Ob
|
||||
return a.beforeWrite(ctx, obj)
|
||||
}
|
||||
|
||||
func (a *RoleBindingAuthorizer) BeforeUpdate(ctx context.Context, obj runtime.Object) error {
|
||||
func (a *RoleBindingAuthorizer) BeforeUpdate(ctx context.Context, oldObj, obj runtime.Object) error {
|
||||
return a.beforeWrite(ctx, obj)
|
||||
}
|
||||
|
||||
@@ -96,7 +96,7 @@ func (d *DenyCustomRoleRefsAuthorizer) BeforeCreate(ctx context.Context, obj run
|
||||
return d.beforeWrite(ctx, obj)
|
||||
}
|
||||
|
||||
func (d *DenyCustomRoleRefsAuthorizer) BeforeUpdate(ctx context.Context, obj runtime.Object) error {
|
||||
func (d *DenyCustomRoleRefsAuthorizer) BeforeUpdate(ctx context.Context, oldObj, obj runtime.Object) error {
|
||||
return d.beforeWrite(ctx, obj)
|
||||
}
|
||||
|
||||
|
||||
@@ -79,7 +79,7 @@ func (r *TeamBindingAuthorizer) BeforeDelete(ctx context.Context, obj runtime.Ob
|
||||
}
|
||||
|
||||
// BeforeUpdate implements ResourceStorageAuthorizer.
|
||||
func (r *TeamBindingAuthorizer) BeforeUpdate(ctx context.Context, obj runtime.Object) error {
|
||||
func (r *TeamBindingAuthorizer) BeforeUpdate(ctx context.Context, oldObj, obj runtime.Object) error {
|
||||
return r.beforeWrite(ctx, obj)
|
||||
}
|
||||
|
||||
|
||||
@@ -199,7 +199,7 @@ func TestTeamBinding_BeforeUpdate(t *testing.T) {
|
||||
authz := NewTeamBindingAuthorizer(accessClient)
|
||||
ctx := types.WithAuthInfo(context.Background(), user)
|
||||
|
||||
err := authz.BeforeUpdate(ctx, binding)
|
||||
err := authz.BeforeUpdate(ctx, binding, binding)
|
||||
if tt.shouldAllow {
|
||||
require.NoError(t, err)
|
||||
} else {
|
||||
|
||||
@@ -5,17 +5,21 @@ import (
|
||||
stdsql "database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"text/template"
|
||||
"time"
|
||||
|
||||
claims "github.com/grafana/authlib/types"
|
||||
iamv0 "github.com/grafana/grafana/apps/iam/pkg/apis/iam/v0alpha1"
|
||||
"github.com/grafana/grafana/pkg/registry/apis/iam/common"
|
||||
"github.com/grafana/grafana/pkg/services/sqlstore/migrator"
|
||||
"github.com/grafana/grafana/pkg/services/sqlstore/session"
|
||||
"github.com/grafana/grafana/pkg/services/team"
|
||||
"github.com/grafana/grafana/pkg/services/user"
|
||||
"github.com/grafana/grafana/pkg/storage/legacysql"
|
||||
"github.com/grafana/grafana/pkg/storage/unified/sql/sqltemplate"
|
||||
"github.com/grafana/grafana/pkg/util"
|
||||
apierrors "k8s.io/apimachinery/pkg/api/errors"
|
||||
)
|
||||
|
||||
type GetUserInternalIDQuery struct {
|
||||
@@ -472,12 +476,36 @@ func (s *legacySQLStore) CreateUser(ctx context.Context, ns claims.NamespaceInfo
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, toUserConflictError(sql.DB.GetDialect(), err, cmd.UID)
|
||||
}
|
||||
|
||||
return &CreateUserResult{User: createdUser}, nil
|
||||
}
|
||||
|
||||
// toUserConflictError converts a UNIQUE constraint violation on user.email or
|
||||
// user.login into a 409 Conflict API error. All other errors are returned as-is.
|
||||
// It uses the DB dialect to detect the violation across SQLite, MySQL and PostgreSQL.
|
||||
func toUserConflictError(dialect migrator.Dialect, err error, uid string) error {
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
if dialect != nil && dialect.IsUniqueConstraintViolation(err) {
|
||||
msg := dialect.ErrorMessage(err)
|
||||
switch {
|
||||
case strings.Contains(msg, "email"):
|
||||
return apierrors.NewConflict(iamv0.UserResourceInfo.GroupResource(), uid,
|
||||
fmt.Errorf("email is already taken"))
|
||||
case strings.Contains(msg, "login"):
|
||||
return apierrors.NewConflict(iamv0.UserResourceInfo.GroupResource(), uid,
|
||||
fmt.Errorf("login is already taken"))
|
||||
default:
|
||||
return apierrors.NewConflict(iamv0.UserResourceInfo.GroupResource(), uid,
|
||||
fmt.Errorf("user already exists"))
|
||||
}
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func newDeleteUser(sql *legacysql.LegacyDatabaseHelper, cmd *DeleteUserCommand) deleteUserQuery {
|
||||
return deleteUserQuery{
|
||||
SQLTemplate: sqltemplate.New(sql.DialectForDriver()),
|
||||
@@ -733,7 +761,7 @@ func (s *legacySQLStore) UpdateUser(ctx context.Context, ns claims.NamespaceInfo
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, toUserConflictError(sql.DB.GetDialect(), err, cmd.UID)
|
||||
}
|
||||
|
||||
return &UpdateUserResult{User: updatedUser}, nil
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
|
||||
"github.com/grafana/authlib/types"
|
||||
|
||||
"github.com/grafana/grafana/pkg/configprovider"
|
||||
"github.com/grafana/grafana/pkg/infra/log"
|
||||
"github.com/grafana/grafana/pkg/infra/tracing"
|
||||
iamauthorizer "github.com/grafana/grafana/pkg/registry/apis/iam/authorizer"
|
||||
@@ -108,4 +109,13 @@ type IdentityAccessManagementAPIBuilder struct {
|
||||
features featuremgmt.FeatureToggles
|
||||
|
||||
tracing tracing.Tracer
|
||||
|
||||
cfgProvider configprovider.ConfigProvider
|
||||
|
||||
apiConfig Config
|
||||
}
|
||||
|
||||
// Config holds IAM-specific configuration
|
||||
type Config struct {
|
||||
SingleOrganization bool
|
||||
}
|
||||
|
||||
@@ -30,6 +30,7 @@ import (
|
||||
"github.com/grafana/grafana/pkg/apimachinery/utils"
|
||||
legacyiamv0 "github.com/grafana/grafana/pkg/apis/iam/v0alpha1"
|
||||
grafanaregistry "github.com/grafana/grafana/pkg/apiserver/registry/generic"
|
||||
"github.com/grafana/grafana/pkg/configprovider"
|
||||
"github.com/grafana/grafana/pkg/infra/db"
|
||||
"github.com/grafana/grafana/pkg/infra/log"
|
||||
"github.com/grafana/grafana/pkg/infra/tracing"
|
||||
@@ -65,6 +66,7 @@ const MaxConcurrentZanzanaWrites = 20
|
||||
|
||||
func RegisterAPIService(
|
||||
cfg *setting.Cfg,
|
||||
cfgProvider configprovider.ConfigProvider,
|
||||
features featuremgmt.FeatureToggles,
|
||||
apiregistration builder.APIRegistrar,
|
||||
ssoService ssosettings.Service,
|
||||
@@ -134,8 +136,12 @@ func RegisterAPIService(
|
||||
unified: unified,
|
||||
userSearchClient: resource.NewSearchClient(dualwrite.NewSearchAdapter(dual), iamv0.UserResourceInfo.GroupResource(),
|
||||
unified, user.NewUserLegacySearchClient(orgService, tracing, cfg), features),
|
||||
teamSearch: NewTeamSearchHandler(tracing, dual, team.NewLegacyTeamSearchClient(teamService, tracing), unified, features, accessClient),
|
||||
tracing: tracing,
|
||||
teamSearch: NewTeamSearchHandler(tracing, dual, team.NewLegacyTeamSearchClient(teamService, tracing), unified, features, accessClient),
|
||||
tracing: tracing,
|
||||
cfgProvider: cfgProvider,
|
||||
apiConfig: Config{
|
||||
SingleOrganization: cfg.RBAC.SingleOrganization,
|
||||
},
|
||||
}
|
||||
builder.userSearchHandler = user.NewSearchHandler(tracing, builder.userSearchClient, features, cfg, accessClient)
|
||||
|
||||
@@ -192,6 +198,7 @@ func NewAPIService(
|
||||
reg: reg,
|
||||
roleApiInstaller: roleApiInstaller,
|
||||
globalRoleApiInstaller: globalRoleApiInstaller,
|
||||
apiConfig: Config{SingleOrganization: true},
|
||||
teamLBACApiInstaller: teamLBACApiInstaller,
|
||||
authorizer: authorizer.AuthorizerFunc(
|
||||
func(ctx context.Context, a authorizer.Attributes) (authorizer.Decision, string, error) {
|
||||
@@ -323,7 +330,8 @@ func (b *IdentityAccessManagementAPIBuilder) UpdateAPIGroupInfo(apiGroupInfo *ge
|
||||
enableRoleBindingsApi := client.Boolean(ctx, featuremgmt.FlagKubernetesAuthzRoleBindingsApi, false, openfeature.TransactionContext(ctx))
|
||||
enableGlobalRolesApi := client.Boolean(ctx, featuremgmt.FlagKubernetesAuthzGlobalRolesApi, false, openfeature.TransactionContext(ctx))
|
||||
enableTeamLBACRuleApi := client.Boolean(ctx, featuremgmt.FlagKubernetesAuthzTeamLBACRuleApi, false, openfeature.TransactionContext(ctx))
|
||||
enableUserApi := client.Boolean(ctx, featuremgmt.FlagKubernetesUsersApi, false, openfeature.TransactionContext(ctx))
|
||||
// Until users have been fully migrated to namespaces (no global users), we only enable the users api in single organization setups.
|
||||
enableUserApi := b.isSingleOrgSetup() && client.Boolean(ctx, featuremgmt.FlagKubernetesUsersApi, false, openfeature.TransactionContext(ctx))
|
||||
enableServiceAccountsApi := client.Boolean(ctx, featuremgmt.FlagKubernetesServiceAccountsApi, false, openfeature.TransactionContext(ctx))
|
||||
enableServiceAccountTokensApi := client.Boolean(ctx, featuremgmt.FlagKubernetesServiceAccountTokensApi, false, openfeature.TransactionContext(ctx))
|
||||
enableExternalGroupMappingsApi := client.Boolean(ctx, featuremgmt.FlagKubernetesExternalGroupMappingsApi, false, openfeature.TransactionContext(ctx))
|
||||
@@ -494,7 +502,6 @@ func (b *IdentityAccessManagementAPIBuilder) UpdateUsersAPIGroup(opts builder.AP
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
storage[userResource.StoragePath()] = userUniStore
|
||||
|
||||
if enableZanzanaSync {
|
||||
b.logger.Info("Enabling hooks for User to sync basic role assignments to Zanzana")
|
||||
@@ -503,15 +510,23 @@ func (b *IdentityAccessManagementAPIBuilder) UpdateUsersAPIGroup(opts builder.AP
|
||||
userUniStore.AfterDelete = b.AfterUserDelete
|
||||
}
|
||||
|
||||
var userStore storewrapper.K8sStorage = userUniStore
|
||||
|
||||
if b.userLegacyStore != nil {
|
||||
dw, err := opts.DualWriteBuilder(userResource.GroupResource(), b.userLegacyStore, userUniStore)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
storage[userResource.StoragePath()] = dw
|
||||
var ok bool
|
||||
userStore, ok = dw.(storewrapper.K8sStorage)
|
||||
if !ok {
|
||||
return fmt.Errorf("expected storewrapper.K8sStorage, got %T", dw)
|
||||
}
|
||||
}
|
||||
|
||||
storage[userResource.StoragePath()] = storewrapper.New(userStore, user.NewStoreWrapper(b.cfgProvider), storewrapper.WithPreserveIdentity())
|
||||
|
||||
if b.dual != nil && b.unified != nil {
|
||||
legacyTeamBindingSearchClient := teambinding.NewLegacyTeamBindingSearchClient(b.store, b.tracing)
|
||||
|
||||
@@ -766,7 +781,7 @@ func (b *IdentityAccessManagementAPIBuilder) GetAPIRoutes(gv schema.GroupVersion
|
||||
ctx, cancelFn := context.WithTimeout(context.Background(), time.Second*5)
|
||||
defer cancelFn()
|
||||
|
||||
enableUserApi := client.Boolean(ctx, featuremgmt.FlagKubernetesUsersApi, false, openfeature.TransactionContext(ctx))
|
||||
enableUserApi := b.isSingleOrgSetup() && client.Boolean(ctx, featuremgmt.FlagKubernetesUsersApi, false, openfeature.TransactionContext(ctx))
|
||||
enableExternalGroupMappingsApi := client.Boolean(ctx, featuremgmt.FlagKubernetesExternalGroupMappingsApi, false, openfeature.TransactionContext(ctx))
|
||||
|
||||
searchRoutes := make([]*builder.APIRoutes, 0, 3)
|
||||
@@ -971,6 +986,10 @@ func NewLocalStore(resourceInfo utils.ResourceInfo, scheme *runtime.Scheme, defa
|
||||
return store, err
|
||||
}
|
||||
|
||||
func (b *IdentityAccessManagementAPIBuilder) isSingleOrgSetup() bool {
|
||||
return b.apiConfig.SingleOrganization
|
||||
}
|
||||
|
||||
func mergeAPIRoutes(routes ...*builder.APIRoutes) *builder.APIRoutes {
|
||||
merged := &builder.APIRoutes{}
|
||||
for _, r := range routes {
|
||||
|
||||
171
pkg/registry/apis/iam/user/store_wrapper.go
Normal file
171
pkg/registry/apis/iam/user/store_wrapper.go
Normal file
@@ -0,0 +1,171 @@
|
||||
package user
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
claims "github.com/grafana/authlib/types"
|
||||
apierrors "k8s.io/apimachinery/pkg/api/errors"
|
||||
"k8s.io/apimachinery/pkg/runtime"
|
||||
|
||||
iamv0 "github.com/grafana/grafana/apps/iam/pkg/apis/iam/v0alpha1"
|
||||
"github.com/grafana/grafana/pkg/configprovider"
|
||||
"github.com/grafana/grafana/pkg/infra/log"
|
||||
"github.com/grafana/grafana/pkg/services/apiserver/auth/authorizer/storewrapper"
|
||||
)
|
||||
|
||||
// StoreWrapper filters users based on the hidden users configuration.
|
||||
// It does not enforce any write authorization — those are handled at the API level.
|
||||
type StoreWrapper struct {
|
||||
cfgProvider configprovider.ConfigProvider
|
||||
logger log.Logger
|
||||
}
|
||||
|
||||
var _ storewrapper.ResourceStorageAuthorizer = (*StoreWrapper)(nil)
|
||||
|
||||
func NewStoreWrapper(cfgProvider configprovider.ConfigProvider) *StoreWrapper {
|
||||
return &StoreWrapper{
|
||||
cfgProvider: cfgProvider,
|
||||
logger: log.New("grafana-apiserver.users.storewrapper"),
|
||||
}
|
||||
}
|
||||
|
||||
// AfterGet returns NotFound if the user's login is in the hidden users list
|
||||
// and the requester is not the user themselves.
|
||||
func (f *StoreWrapper) AfterGet(ctx context.Context, obj runtime.Object) error {
|
||||
cfg, err := f.cfgProvider.Get(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(cfg.HiddenUsers) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
authInfo, ok := claims.AuthInfoFrom(ctx)
|
||||
if !ok {
|
||||
return storewrapper.ErrUnauthenticated
|
||||
}
|
||||
|
||||
u, ok := obj.(*iamv0.User)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
|
||||
login := u.Spec.Login
|
||||
if _, isHidden := cfg.HiddenUsers[login]; isHidden && login != authInfo.GetUsername() {
|
||||
return apierrors.NewNotFound(iamv0.UserResourceInfo.GroupResource(), u.Name)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// FilterList removes hidden users from the list, except for the requester themselves.
|
||||
func (f *StoreWrapper) FilterList(ctx context.Context, list runtime.Object) (runtime.Object, error) {
|
||||
cfg, err := f.cfgProvider.Get(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(cfg.HiddenUsers) == 0 {
|
||||
return list, nil
|
||||
}
|
||||
|
||||
authInfo, ok := claims.AuthInfoFrom(ctx)
|
||||
if !ok {
|
||||
return nil, storewrapper.ErrUnauthenticated
|
||||
}
|
||||
|
||||
userList, ok := list.(*iamv0.UserList)
|
||||
if !ok {
|
||||
return list, nil
|
||||
}
|
||||
|
||||
requesterLogin := authInfo.GetUsername()
|
||||
filtered := make([]iamv0.User, 0, len(userList.Items))
|
||||
for _, u := range userList.Items {
|
||||
if _, isHidden := cfg.HiddenUsers[u.Spec.Login]; !isHidden || u.Spec.Login == requesterLogin {
|
||||
filtered = append(filtered, u)
|
||||
}
|
||||
}
|
||||
userList.Items = filtered
|
||||
return userList, nil
|
||||
}
|
||||
|
||||
// BeforeCreate returns Forbidden if the new user's login is in the hidden users list.
|
||||
func (f *StoreWrapper) BeforeCreate(ctx context.Context, obj runtime.Object) error {
|
||||
cfg, err := f.cfgProvider.Get(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(cfg.HiddenUsers) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
u, ok := obj.(*iamv0.User)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
|
||||
if _, isHidden := cfg.HiddenUsers[u.Spec.Login]; isHidden {
|
||||
f.logger.Info("blocked create for hidden user", "login", u.Spec.Login, "name", u.Name)
|
||||
return apierrors.NewForbidden(iamv0.UserResourceInfo.GroupResource(), u.Name, errors.New("operation not permitted"))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// BeforeUpdate returns Forbidden if the target user (old object) or the new login is in the hidden users list.
|
||||
func (f *StoreWrapper) BeforeUpdate(ctx context.Context, oldObj, obj runtime.Object) error {
|
||||
cfg, err := f.cfgProvider.Get(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(cfg.HiddenUsers) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
oldUser, ok := oldObj.(*iamv0.User)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
|
||||
if _, isHidden := cfg.HiddenUsers[oldUser.Spec.Login]; isHidden {
|
||||
f.logger.Info("blocked update for hidden user", "login", oldUser.Spec.Login, "name", oldUser.Name)
|
||||
return apierrors.NewForbidden(iamv0.UserResourceInfo.GroupResource(), oldUser.Name, errors.New("operation not permitted"))
|
||||
}
|
||||
|
||||
// Also check the new object in case the login is being changed to a hidden user's login.
|
||||
newUser, ok := obj.(*iamv0.User)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
|
||||
if _, isHidden := cfg.HiddenUsers[newUser.Spec.Login]; isHidden {
|
||||
f.logger.Info("blocked update to hidden user login", "login", newUser.Spec.Login, "name", newUser.Name)
|
||||
return apierrors.NewForbidden(iamv0.UserResourceInfo.GroupResource(), newUser.Name, errors.New("operation not permitted"))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// BeforeDelete returns Forbidden if the target user is in the hidden users list.
|
||||
func (f *StoreWrapper) BeforeDelete(ctx context.Context, obj runtime.Object) error {
|
||||
cfg, err := f.cfgProvider.Get(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(cfg.HiddenUsers) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
u, ok := obj.(*iamv0.User)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
|
||||
if _, isHidden := cfg.HiddenUsers[u.Spec.Login]; isHidden {
|
||||
f.logger.Info("blocked delete for hidden user", "login", u.Spec.Login, "name", u.Name)
|
||||
return apierrors.NewForbidden(iamv0.UserResourceInfo.GroupResource(), u.Name, errors.New("operation not permitted"))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -3,6 +3,7 @@ package user
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"reflect"
|
||||
|
||||
apierrors "k8s.io/apimachinery/pkg/api/errors"
|
||||
"k8s.io/apimachinery/pkg/selection"
|
||||
@@ -86,6 +87,15 @@ func ValidateOnUpdate(ctx context.Context, userSearchClient resourcepb.ResourceI
|
||||
}
|
||||
}
|
||||
|
||||
// Only service identities or Grafana admins may update profile fields (login, email, title, etc.).
|
||||
// OrgAdmins are allowed through the RBAC gate with "org.users:write" but
|
||||
// are restricted to only role updates.
|
||||
if !isServiceUser && !isGrafanaAdmin && !onlyAllowedFieldsChanged(oldObj.Spec, newObj.Spec) {
|
||||
return apierrors.NewForbidden(iamv0alpha1.UserResourceInfo.GroupResource(),
|
||||
newObj.Name,
|
||||
fmt.Errorf("updating fields beyond org role requires service identity or grafana admin"))
|
||||
}
|
||||
|
||||
if newObj.Spec.Login == "" && newObj.Spec.Email == "" {
|
||||
return apierrors.NewBadRequest("user must have either login or email")
|
||||
}
|
||||
@@ -109,6 +119,31 @@ func ValidateOnUpdate(ctx context.Context, userSearchClient resourcepb.ResourceI
|
||||
return nil
|
||||
}
|
||||
|
||||
// allowedFieldsForNonServiceUsers lists the UserSpec fields that non-service users
|
||||
// are allowed to change. Role is the only one for now. Any field NOT in this set requires a service identity to modify.
|
||||
var allowedFieldsForNonServiceUsers = map[string]bool{
|
||||
"Role": true,
|
||||
}
|
||||
|
||||
// onlyAllowedFieldsChanged iterates over all fields of UserSpec via reflection and
|
||||
// returns true only if every field that changed is in the allowed set. This is
|
||||
// forward-compatible: any new field added to UserSpec is automatically restricted
|
||||
// to service identities without needing to update this function.
|
||||
func onlyAllowedFieldsChanged(oldSpec, newSpec iamv0alpha1.UserSpec) bool {
|
||||
oldVal := reflect.ValueOf(oldSpec)
|
||||
newVal := reflect.ValueOf(newSpec)
|
||||
|
||||
for i := range oldVal.NumField() {
|
||||
if allowedFieldsForNonServiceUsers[oldVal.Type().Field(i).Name] {
|
||||
continue
|
||||
}
|
||||
if !reflect.DeepEqual(oldVal.Field(i).Interface(), newVal.Field(i).Interface()) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func validateRole(obj *iamv0alpha1.User) error {
|
||||
if obj.Spec.Role == "" {
|
||||
return apierrors.NewBadRequest("role is required")
|
||||
|
||||
@@ -307,7 +307,7 @@ func TestValidateOnUpdate(t *testing.T) {
|
||||
Spec: iamv0alpha1.UserSpec{Login: "", Email: "", Role: "Viewer"},
|
||||
},
|
||||
requester: &identity.StaticRequester{
|
||||
Type: types.TypeUser,
|
||||
Type: types.TypeAccessPolicy,
|
||||
},
|
||||
expectError: true,
|
||||
errorContains: "user must have either login or email",
|
||||
@@ -321,8 +321,7 @@ func TestValidateOnUpdate(t *testing.T) {
|
||||
Spec: iamv0alpha1.UserSpec{Login: "testuser", Email: "", Role: "Viewer"},
|
||||
},
|
||||
requester: &identity.StaticRequester{
|
||||
Type: types.TypeUser,
|
||||
IsGrafanaAdmin: true,
|
||||
Type: types.TypeAccessPolicy,
|
||||
},
|
||||
searchClient: &FakeUserLegacySearchClient{},
|
||||
expectError: false,
|
||||
@@ -336,8 +335,7 @@ func TestValidateOnUpdate(t *testing.T) {
|
||||
Spec: iamv0alpha1.UserSpec{Login: "", Email: "test@example", Role: "Viewer"},
|
||||
},
|
||||
requester: &identity.StaticRequester{
|
||||
Type: types.TypeUser,
|
||||
IsGrafanaAdmin: true,
|
||||
Type: types.TypeAccessPolicy,
|
||||
},
|
||||
searchClient: &FakeUserLegacySearchClient{},
|
||||
expectError: false,
|
||||
@@ -471,6 +469,49 @@ func TestValidateOnUpdate(t *testing.T) {
|
||||
},
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
name: "non-service non-admin user updating role field is allowed",
|
||||
oldUser: &iamv0alpha1.User{
|
||||
Spec: iamv0alpha1.UserSpec{Login: "testuser", Email: "user@example", Role: "Viewer"},
|
||||
},
|
||||
newUser: &iamv0alpha1.User{
|
||||
Spec: iamv0alpha1.UserSpec{Login: "testuser", Email: "user@example", Role: "Editor"},
|
||||
},
|
||||
requester: &identity.StaticRequester{
|
||||
Type: types.TypeUser,
|
||||
IsGrafanaAdmin: false,
|
||||
},
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
name: "non-service non-admin user updating non-role fields is forbidden",
|
||||
oldUser: &iamv0alpha1.User{
|
||||
Spec: iamv0alpha1.UserSpec{Login: "testuser", Email: "old@example", Role: "Viewer"},
|
||||
},
|
||||
newUser: &iamv0alpha1.User{
|
||||
Spec: iamv0alpha1.UserSpec{Login: "testuser", Email: "new@example", Role: "Viewer"},
|
||||
},
|
||||
requester: &identity.StaticRequester{
|
||||
Type: types.TypeUser,
|
||||
IsGrafanaAdmin: false,
|
||||
},
|
||||
expectError: true,
|
||||
errorContains: "updating fields beyond org role requires service identity or grafana admin",
|
||||
},
|
||||
{
|
||||
name: "grafana admin updating non-role fields is allowed",
|
||||
oldUser: &iamv0alpha1.User{
|
||||
Spec: iamv0alpha1.UserSpec{Login: "testuser", Email: "user@example", Title: "Old Title", Role: "Viewer"},
|
||||
},
|
||||
newUser: &iamv0alpha1.User{
|
||||
Spec: iamv0alpha1.UserSpec{Login: "testuser", Email: "user@example", Title: "New Title", Role: "Viewer"},
|
||||
},
|
||||
requester: &identity.StaticRequester{
|
||||
Type: types.TypeUser,
|
||||
IsGrafanaAdmin: true,
|
||||
},
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
name: "update with existing email",
|
||||
oldUser: &iamv0alpha1.User{
|
||||
@@ -486,7 +527,7 @@ func TestValidateOnUpdate(t *testing.T) {
|
||||
Spec: iamv0alpha1.UserSpec{Email: "two@example", Role: "Viewer"},
|
||||
},
|
||||
requester: &identity.StaticRequester{
|
||||
Type: types.TypeUser,
|
||||
Type: types.TypeAccessPolicy,
|
||||
IsGrafanaAdmin: true,
|
||||
},
|
||||
searchClient: &FakeUserLegacySearchClient{
|
||||
@@ -512,7 +553,7 @@ func TestValidateOnUpdate(t *testing.T) {
|
||||
Spec: iamv0alpha1.UserSpec{Login: "two", Role: "Viewer"},
|
||||
},
|
||||
requester: &identity.StaticRequester{
|
||||
Type: types.TypeUser,
|
||||
Type: types.TypeAccessPolicy,
|
||||
IsGrafanaAdmin: true,
|
||||
},
|
||||
searchClient: &FakeUserLegacySearchClient{
|
||||
|
||||
4
pkg/server/wire_gen.go
generated
4
pkg/server/wire_gen.go
generated
@@ -910,7 +910,7 @@ func Initialize(ctx context.Context, cfg *setting.Cfg, opts Options, apiOpts api
|
||||
storageBackendImpl := noopstorage.ProvideStorageBackend()
|
||||
noopTeamGroupsREST := externalgroupmapping.ProvideNoopTeamGroupsREST()
|
||||
noopSearchREST := externalgroupmapping.ProvideNoopSearchREST()
|
||||
identityAccessManagementAPIBuilder, err := iam.RegisterAPIService(cfg, featureToggles, apiserverService, ssosettingsimplService, sqlStore, accessControl, accessClient, zanzanaClient, registerer, roleApiInstaller, globalRoleApiInstaller, teamLBACApiInstaller, externalGroupMappingApiInstaller, tracingService, storageBackendImpl, noopTeamGroupsREST, noopSearchREST, dualwriteService, resourceClient, orgService, userService, teamService, eventualRestConfigProvider)
|
||||
identityAccessManagementAPIBuilder, err := iam.RegisterAPIService(cfg, configProvider, featureToggles, apiserverService, ssosettingsimplService, sqlStore, accessControl, accessClient, zanzanaClient, registerer, roleApiInstaller, globalRoleApiInstaller, teamLBACApiInstaller, externalGroupMappingApiInstaller, tracingService, storageBackendImpl, noopTeamGroupsREST, noopSearchREST, dualwriteService, resourceClient, orgService, userService, teamService, eventualRestConfigProvider)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -1605,7 +1605,7 @@ func InitializeForTest(ctx context.Context, t sqlutil.ITestDB, testingT interfac
|
||||
storageBackendImpl := noopstorage.ProvideStorageBackend()
|
||||
noopTeamGroupsREST := externalgroupmapping.ProvideNoopTeamGroupsREST()
|
||||
noopSearchREST := externalgroupmapping.ProvideNoopSearchREST()
|
||||
identityAccessManagementAPIBuilder, err := iam.RegisterAPIService(cfg, featureToggles, apiserverService, ssosettingsimplService, sqlStore, accessControl, accessClient, zanzanaClient, registerer, roleApiInstaller, globalRoleApiInstaller, teamLBACApiInstaller, externalGroupMappingApiInstaller, tracingService, storageBackendImpl, noopTeamGroupsREST, noopSearchREST, dualwriteService, resourceClient, orgService, userService, teamService, eventualRestConfigProvider)
|
||||
identityAccessManagementAPIBuilder, err := iam.RegisterAPIService(cfg, configProvider, featureToggles, apiserverService, ssosettingsimplService, sqlStore, accessControl, accessClient, zanzanaClient, registerer, roleApiInstaller, globalRoleApiInstaller, teamLBACApiInstaller, externalGroupMappingApiInstaller, tracingService, storageBackendImpl, noopTeamGroupsREST, noopSearchREST, dualwriteService, resourceClient, orgService, userService, teamService, eventualRestConfigProvider)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -24,7 +24,7 @@ var (
|
||||
// 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
|
||||
BeforeUpdate(ctx context.Context, oldObj, 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)
|
||||
@@ -34,9 +34,25 @@ type ResourceStorageAuthorizer interface {
|
||||
// It overrides the identity in the context to use service identity for the underlying store operations so the
|
||||
// store's authorization always succeeds and the wrapper enforces authorization. The wrapper injects the original
|
||||
// user's UID as metadata identity so unistore can set createdBy/updatedBy correctly (see identity.WithOriginalIdentityUID).
|
||||
// The wrapper also supports an option to preserve the original caller's identity in the context for inner store calls instead of replacing it with a service identity.
|
||||
// Use this when the inner store does not perform its own RBAC checks and the caller's identity is needed downstream (e.g. for admission webhooks).
|
||||
type Wrapper struct {
|
||||
inner K8sStorage
|
||||
authorizer ResourceStorageAuthorizer
|
||||
inner K8sStorage
|
||||
authorizer ResourceStorageAuthorizer
|
||||
preserveIdentity bool
|
||||
}
|
||||
|
||||
// Option configures a Wrapper.
|
||||
type Option func(*Wrapper)
|
||||
|
||||
// WithPreserveIdentity instructs the Wrapper to leave the caller's identity in the context when
|
||||
// calling the inner store, instead of replacing it with a service identity. Use this when the inner
|
||||
// store does not perform its own RBAC checks and the caller's identity is needed downstream
|
||||
// (e.g. for admission webhooks).
|
||||
func WithPreserveIdentity() Option {
|
||||
return func(w *Wrapper) {
|
||||
w.preserveIdentity = true
|
||||
}
|
||||
}
|
||||
|
||||
type K8sStorage interface {
|
||||
@@ -54,17 +70,27 @@ var _ k8srest.Watcher = (*Wrapper)(nil)
|
||||
|
||||
// New returns a Wrapper that enforces authorization and uses service identity for inner store calls,
|
||||
// injecting the original user's UID for createdBy/updatedBy annotations.
|
||||
func New(store K8sStorage, authz ResourceStorageAuthorizer) *Wrapper {
|
||||
return &Wrapper{inner: store, authorizer: authz}
|
||||
func New(store K8sStorage, authz ResourceStorageAuthorizer, opts ...Option) *Wrapper {
|
||||
w := &Wrapper{inner: store, authorizer: authz}
|
||||
for _, opt := range opts {
|
||||
opt(w)
|
||||
}
|
||||
return w
|
||||
}
|
||||
|
||||
// storeCtx returns the context for inner store calls: service identity so the store's authorization
|
||||
// succeeds, with the original user's UID injected as metadata identity for createdBy/updatedBy (see identity.WithOriginalIdentityUID).
|
||||
// When preserveIdentity is true the original caller context is returned unchanged;
|
||||
func (w *Wrapper) storeCtx(ctx context.Context) context.Context {
|
||||
if w.preserveIdentity {
|
||||
return ctx
|
||||
}
|
||||
|
||||
srvCtx, _ := identity.WithServiceIdentity(ctx, 0)
|
||||
if user, err := identity.GetRequester(ctx); err == nil && user.GetUID() != "" {
|
||||
srvCtx = identity.WithOriginalIdentityUID(srvCtx, user.GetUID())
|
||||
}
|
||||
|
||||
return srvCtx
|
||||
}
|
||||
|
||||
@@ -78,6 +104,7 @@ func (w *Wrapper) Create(ctx context.Context, obj runtime.Object, createValidati
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return w.inner.Create(w.storeCtx(ctx), obj, createValidation, options)
|
||||
}
|
||||
|
||||
@@ -188,7 +215,7 @@ func (a *authorizedUpdateInfo) UpdatedObject(ctx context.Context, oldObj runtime
|
||||
}
|
||||
|
||||
// Enforce authorization using the original user context
|
||||
if err := a.authorizer.BeforeUpdate(a.userCtx, updatedObj); err != nil {
|
||||
if err := a.authorizer.BeforeUpdate(a.userCtx, oldObj, updatedObj); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -212,7 +239,7 @@ func (b *NoopAuthorizer) BeforeCreate(ctx context.Context, obj runtime.Object) e
|
||||
return nil
|
||||
}
|
||||
|
||||
func (b *NoopAuthorizer) BeforeUpdate(ctx context.Context, obj runtime.Object) error {
|
||||
func (b *NoopAuthorizer) BeforeUpdate(ctx context.Context, oldObj, obj runtime.Object) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -237,7 +264,7 @@ func (d *DenyAuthorizer) BeforeCreate(ctx context.Context, obj runtime.Object) e
|
||||
return ErrUnauthorized
|
||||
}
|
||||
|
||||
func (d *DenyAuthorizer) BeforeUpdate(ctx context.Context, obj runtime.Object) error {
|
||||
func (d *DenyAuthorizer) BeforeUpdate(ctx context.Context, oldObj, obj runtime.Object) error {
|
||||
return ErrUnauthorized
|
||||
}
|
||||
|
||||
|
||||
@@ -35,6 +35,19 @@ func newTestSetup(t *testing.T) *testSetup {
|
||||
return &testSetup{mockStore: mockStore, mockAuth: mockAuth, wrapper: wrapper, ctx: ctx}
|
||||
}
|
||||
|
||||
func newTestSetupWithPreserveIdentity(t *testing.T) *testSetup {
|
||||
mockStore := rest.NewMockStorage(t)
|
||||
mockAuth := &FakeAuthorizer{}
|
||||
wrapper := New(mockStore, mockAuth, WithPreserveIdentity())
|
||||
|
||||
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)
|
||||
@@ -274,7 +287,7 @@ func TestWrapper_Update(t *testing.T) {
|
||||
assert.True(t, updated)
|
||||
|
||||
// Now verify that the authorization is performed inside UpdatedObject
|
||||
setup.mockAuth.On("BeforeUpdate", mock.MatchedBy(matchesOriginalUser()), oldObj).Return(nil)
|
||||
setup.mockAuth.On("BeforeUpdate", mock.MatchedBy(matchesOriginalUser()), oldObj, oldObj).Return(nil)
|
||||
obj, err := authzInfo.UpdatedObject(context.Background(), oldObj)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, oldObj, obj)
|
||||
@@ -336,6 +349,78 @@ func TestWrapper_PassthroughMethods(t *testing.T) {
|
||||
setup.mockStore.AssertExpectations(t)
|
||||
}
|
||||
|
||||
func TestWrapper_WithPreserveIdentity(t *testing.T) {
|
||||
t.Run("Create passes original user identity to inner store", func(t *testing.T) {
|
||||
setup := newTestSetupWithPreserveIdentity(t)
|
||||
|
||||
obj := &fakeObject{}
|
||||
createOpts := &metaV1.CreateOptions{}
|
||||
expectedObj := &fakeObject{ObjectMeta: metaV1.ObjectMeta{Name: "created"}}
|
||||
|
||||
setup.mockAuth.On("BeforeCreate", mock.MatchedBy(matchesOriginalUser()), obj).Return(nil)
|
||||
// Inner store must receive original user identity, not service identity.
|
||||
setup.mockStore.On("Create", mock.MatchedBy(matchesOriginalUser()), 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)
|
||||
setup.mockAuth.AssertExpectations(t)
|
||||
setup.mockStore.AssertExpectations(t)
|
||||
})
|
||||
|
||||
t.Run("Get passes original user identity to inner store", func(t *testing.T) {
|
||||
setup := newTestSetupWithPreserveIdentity(t)
|
||||
|
||||
obj := &fakeObject{ObjectMeta: metaV1.ObjectMeta{Name: "fetched"}}
|
||||
|
||||
setup.mockStore.On("Get", mock.MatchedBy(matchesOriginalUser()), "fetched", mock.Anything).Return(obj, nil)
|
||||
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)
|
||||
setup.mockAuth.AssertExpectations(t)
|
||||
setup.mockStore.AssertExpectations(t)
|
||||
})
|
||||
|
||||
t.Run("List passes original user identity to inner store", func(t *testing.T) {
|
||||
setup := newTestSetupWithPreserveIdentity(t)
|
||||
|
||||
listObj := &metaV1.List{}
|
||||
|
||||
setup.mockStore.On("List", mock.MatchedBy(matchesOriginalUser()), mock.Anything).Return(listObj, nil)
|
||||
setup.mockAuth.On("FilterList", mock.MatchedBy(matchesOriginalUser()), listObj).Return(listObj, nil)
|
||||
|
||||
result, err := setup.wrapper.List(setup.ctx, &internalversion.ListOptions{})
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, listObj, result)
|
||||
setup.mockAuth.AssertExpectations(t)
|
||||
setup.mockStore.AssertExpectations(t)
|
||||
})
|
||||
|
||||
t.Run("Delete passes original user identity to inner store", func(t *testing.T) {
|
||||
setup := newTestSetupWithPreserveIdentity(t)
|
||||
|
||||
obj := &fakeObject{ObjectMeta: metaV1.ObjectMeta{Name: "to-delete"}}
|
||||
deleteOpts := &metaV1.DeleteOptions{}
|
||||
|
||||
setup.mockStore.On("Get", mock.MatchedBy(matchesOriginalUser()), "to-delete", mock.Anything).Return(obj, nil)
|
||||
setup.mockAuth.On("BeforeDelete", mock.MatchedBy(matchesOriginalUser()), obj).Return(nil)
|
||||
setup.mockStore.On("Delete", mock.MatchedBy(matchesOriginalUser()), "to-delete", mock.Anything, deleteOpts).Return(obj, true, nil)
|
||||
|
||||
result, deleted, err := setup.wrapper.Delete(setup.ctx, "to-delete", nil, deleteOpts)
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, obj, result)
|
||||
assert.True(t, deleted)
|
||||
setup.mockAuth.AssertExpectations(t)
|
||||
setup.mockStore.AssertExpectations(t)
|
||||
})
|
||||
}
|
||||
|
||||
// -----
|
||||
// Fakes
|
||||
// -----
|
||||
@@ -349,8 +434,8 @@ func (f *FakeAuthorizer) BeforeCreate(ctx context.Context, obj runtime.Object) e
|
||||
return args.Error(0)
|
||||
}
|
||||
|
||||
func (f *FakeAuthorizer) BeforeUpdate(ctx context.Context, obj runtime.Object) error {
|
||||
args := f.Called(ctx, obj)
|
||||
func (f *FakeAuthorizer) BeforeUpdate(ctx context.Context, oldObj, obj runtime.Object) error {
|
||||
args := f.Called(ctx, oldObj, obj)
|
||||
return args.Error(0)
|
||||
}
|
||||
|
||||
|
||||
@@ -219,7 +219,24 @@ func NewMapperRegistry() MapperRegistry {
|
||||
},
|
||||
"iam.grafana.app": {
|
||||
// Users is a special case. We translate user permissions from id to uid based.
|
||||
"users": newResourceTranslation("users", "uid", false, map[string]bool{utils.VerbCreate: true}),
|
||||
"users": translation{
|
||||
resource: "users",
|
||||
attribute: "uid",
|
||||
verbMapping: map[string]string{
|
||||
utils.VerbCreate: "users:create",
|
||||
utils.VerbGet: "org.users:read",
|
||||
utils.VerbUpdate: "org.users:write",
|
||||
utils.VerbPatch: "org.users:write",
|
||||
utils.VerbDelete: "org.users:remove",
|
||||
utils.VerbDeleteCollection: "users:delete",
|
||||
utils.VerbList: "org.users:read",
|
||||
utils.VerbWatch: "org.users:read",
|
||||
utils.VerbGetPermissions: "users.permissions:read",
|
||||
utils.VerbSetPermissions: "users.permissions:write",
|
||||
},
|
||||
folderSupport: false,
|
||||
skipScopeOnVerb: map[string]bool{utils.VerbCreate: true},
|
||||
},
|
||||
"serviceaccounts": newResourceTranslation("serviceaccounts", "uid", false, map[string]bool{utils.VerbCreate: true}),
|
||||
// Teams is a special case. We translate user permissions from id to uid based.
|
||||
"teams": newResourceTranslation("teams", "uid", false, map[string]bool{utils.VerbCreate: true}),
|
||||
|
||||
@@ -41,8 +41,9 @@ func TestIntegrationIdentity(t *testing.T) {
|
||||
testutil.SkipIntegrationTestInShortMode(t)
|
||||
|
||||
helper := apis.NewK8sTestHelper(t, testinfra.GrafanaOpts{
|
||||
AppModeProduction: false, // required for experimental APIs
|
||||
DisableAnonymous: true,
|
||||
AppModeProduction: false, // required for experimental APIs
|
||||
DisableAnonymous: true,
|
||||
RBACSingleOrganization: true,
|
||||
EnableFeatureToggles: []string{
|
||||
featuremgmt.FlagGrafanaAPIServerWithExperimentalAPIs, // Required to start the example service
|
||||
featuremgmt.FlagKubernetesUsersApi,
|
||||
|
||||
@@ -28,9 +28,10 @@ func TestIntegrationTeamBindings(t *testing.T) {
|
||||
for _, mode := range modes {
|
||||
t.Run(fmt.Sprintf("Team binding CRUD operations with dual writer mode %d", mode), func(t *testing.T) {
|
||||
helper := apis.NewK8sTestHelper(t, testinfra.GrafanaOpts{
|
||||
AppModeProduction: false,
|
||||
DisableAnonymous: true,
|
||||
APIServerStorageType: "unified",
|
||||
AppModeProduction: false,
|
||||
DisableAnonymous: true,
|
||||
RBACSingleOrganization: true,
|
||||
APIServerStorageType: "unified",
|
||||
UnifiedStorageConfig: map[string]setting.UnifiedStorageConfig{
|
||||
"teambindings.iam.grafana.app": {
|
||||
DualWriterMode: mode,
|
||||
|
||||
@@ -35,9 +35,10 @@ func TestIntegrationTeamMembers(t *testing.T) {
|
||||
for _, mode := range modes {
|
||||
t.Run(fmt.Sprintf("With dual writer mode %d", mode), func(t *testing.T) {
|
||||
helper := apis.NewK8sTestHelper(t, testinfra.GrafanaOpts{
|
||||
AppModeProduction: false,
|
||||
DisableAnonymous: true,
|
||||
APIServerStorageType: "unified",
|
||||
AppModeProduction: false,
|
||||
DisableAnonymous: true,
|
||||
RBACSingleOrganization: true,
|
||||
APIServerStorageType: "unified",
|
||||
UnifiedStorageConfig: map[string]setting.UnifiedStorageConfig{
|
||||
"users.iam.grafana.app": {
|
||||
DualWriterMode: mode,
|
||||
|
||||
@@ -24,9 +24,10 @@ func TestIntegrationUsers(t *testing.T) {
|
||||
for _, mode := range modes {
|
||||
t.Run(fmt.Sprintf("DualWriterMode %d", mode), func(t *testing.T) {
|
||||
helper := apis.NewK8sTestHelper(t, testinfra.GrafanaOpts{
|
||||
AppModeProduction: false,
|
||||
DisableAnonymous: true,
|
||||
APIServerStorageType: "unified",
|
||||
AppModeProduction: false,
|
||||
DisableAnonymous: true,
|
||||
RBACSingleOrganization: true,
|
||||
APIServerStorageType: "unified",
|
||||
UnifiedStorageConfig: map[string]setting.UnifiedStorageConfig{
|
||||
"users.iam.grafana.app": {
|
||||
DualWriterMode: mode,
|
||||
@@ -44,6 +45,7 @@ func TestIntegrationUsers(t *testing.T) {
|
||||
})
|
||||
|
||||
doUserCRUDTestsUsingTheNewAPIs(t, helper)
|
||||
doHiddenUsersTests(t, helper)
|
||||
doUserFieldSelectorTests(t, helper)
|
||||
|
||||
if mode < 3 {
|
||||
@@ -119,6 +121,9 @@ func doUserCRUDTestsUsingTheNewAPIs(t *testing.T, helper *apis.K8sTestHelper) {
|
||||
created, err := userClient.Resource.Create(ctx, helper.LoadYAMLOrJSONFile("testdata/user-test-create-v1.yaml"), metav1.CreateOptions{})
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, created)
|
||||
t.Cleanup(func() {
|
||||
_ = userClient.Resource.Delete(context.Background(), created.GetName(), metav1.DeleteOptions{})
|
||||
})
|
||||
|
||||
// Get the user to update
|
||||
createdUID := created.GetName()
|
||||
@@ -373,6 +378,102 @@ func doUserCRUDTestsUsingTheLegacyAPIs(t *testing.T, helper *apis.K8sTestHelper)
|
||||
})
|
||||
}
|
||||
|
||||
func doHiddenUsersTests(t *testing.T, helper *apis.K8sTestHelper) {
|
||||
t.Run("should hide users from the hidden users list on Get and List", func(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
const hiddenLogin = "hidden-integration-user"
|
||||
|
||||
adminClient := helper.GetResourceClient(apis.ResourceClientArgs{
|
||||
User: helper.Org1.Admin,
|
||||
Namespace: helper.Namespacer(helper.Org1.Admin.Identity.GetOrgID()),
|
||||
GVR: gvrUsers,
|
||||
})
|
||||
|
||||
// Create the user before marking it as hidden so BeforeCreate does not block it.
|
||||
obj := helper.LoadYAMLOrJSONFile("testdata/user-test-create-v0.yaml")
|
||||
spec := obj.Object["spec"].(map[string]interface{})
|
||||
spec["login"] = hiddenLogin
|
||||
spec["email"] = hiddenLogin + "@example.com"
|
||||
obj.Object["spec"] = spec
|
||||
|
||||
created, err := adminClient.Resource.Create(ctx, obj, metav1.CreateOptions{})
|
||||
require.NoError(t, err)
|
||||
createdUID := created.GetName()
|
||||
|
||||
// Register the hidden login in the live Cfg so UserFilter picks it up.
|
||||
helper.GetEnv().Cfg.HiddenUsers[hiddenLogin] = struct{}{}
|
||||
// Cleanup: remove from hidden list first so that Delete can succeed.
|
||||
t.Cleanup(func() {
|
||||
delete(helper.GetEnv().Cfg.HiddenUsers, hiddenLogin)
|
||||
_ = adminClient.Resource.Delete(context.Background(), createdUID, metav1.DeleteOptions{})
|
||||
})
|
||||
|
||||
// Get should return 404 when the requester is not the hidden user.
|
||||
_, err = adminClient.Resource.Get(ctx, createdUID, metav1.GetOptions{})
|
||||
require.Error(t, err)
|
||||
var statusErr *errors.StatusError
|
||||
require.ErrorAs(t, err, &statusErr)
|
||||
require.Equal(t, int32(404), statusErr.ErrStatus.Code)
|
||||
|
||||
// List should not include the hidden user.
|
||||
list, err := adminClient.Resource.List(ctx, metav1.ListOptions{})
|
||||
require.NoError(t, err)
|
||||
for _, item := range list.Items {
|
||||
itemSpec := item.Object["spec"].(map[string]interface{})
|
||||
require.NotEqual(t, hiddenLogin, itemSpec["login"])
|
||||
}
|
||||
|
||||
// Update should return 403 for a hidden user.
|
||||
userToUpdate := created.DeepCopy()
|
||||
updateSpec := userToUpdate.Object["spec"].(map[string]interface{})
|
||||
updateSpec["title"] = "Updated Title"
|
||||
userToUpdate.Object["spec"] = updateSpec
|
||||
_, err = adminClient.Resource.Update(ctx, userToUpdate, metav1.UpdateOptions{})
|
||||
require.Error(t, err)
|
||||
require.ErrorAs(t, err, &statusErr)
|
||||
require.Equal(t, int32(403), statusErr.ErrStatus.Code)
|
||||
require.Contains(t, statusErr.ErrStatus.Message, "operation not permitted")
|
||||
|
||||
// Delete should return 403 for a hidden user.
|
||||
err = adminClient.Resource.Delete(ctx, createdUID, metav1.DeleteOptions{})
|
||||
require.Error(t, err)
|
||||
require.ErrorAs(t, err, &statusErr)
|
||||
require.Equal(t, int32(403), statusErr.ErrStatus.Code)
|
||||
require.Contains(t, statusErr.ErrStatus.Message, "operation not permitted")
|
||||
})
|
||||
|
||||
t.Run("should not be able to create a user whose login is in the hidden users list", func(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
const hiddenLogin = "hidden-create-blocked-user"
|
||||
|
||||
adminClient := helper.GetResourceClient(apis.ResourceClientArgs{
|
||||
User: helper.Org1.Admin,
|
||||
Namespace: helper.Namespacer(helper.Org1.Admin.Identity.GetOrgID()),
|
||||
GVR: gvrUsers,
|
||||
})
|
||||
|
||||
helper.GetEnv().Cfg.HiddenUsers[hiddenLogin] = struct{}{}
|
||||
t.Cleanup(func() {
|
||||
delete(helper.GetEnv().Cfg.HiddenUsers, hiddenLogin)
|
||||
})
|
||||
|
||||
obj := helper.LoadYAMLOrJSONFile("testdata/user-test-create-v0.yaml")
|
||||
spec := obj.Object["spec"].(map[string]interface{})
|
||||
spec["login"] = hiddenLogin
|
||||
spec["email"] = hiddenLogin + "@example.com"
|
||||
obj.Object["spec"] = spec
|
||||
|
||||
_, err := adminClient.Resource.Create(ctx, obj, metav1.CreateOptions{})
|
||||
require.Error(t, err)
|
||||
var statusErr *errors.StatusError
|
||||
require.ErrorAs(t, err, &statusErr)
|
||||
require.Equal(t, int32(403), statusErr.ErrStatus.Code)
|
||||
require.Contains(t, statusErr.ErrStatus.Message, "operation not permitted")
|
||||
})
|
||||
}
|
||||
|
||||
func doUserFieldSelectorTests(t *testing.T, helper *apis.K8sTestHelper) {
|
||||
t.Run("should list users using field selectors", func(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
@@ -30,9 +30,10 @@ func TestIntegrationUserSearch(t *testing.T) {
|
||||
for _, mode := range modes {
|
||||
t.Run(fmt.Sprintf("DualWriterMode %d", mode), func(t *testing.T) {
|
||||
helper := apis.NewK8sTestHelper(t, testinfra.GrafanaOpts{
|
||||
AppModeProduction: false,
|
||||
DisableAnonymous: true,
|
||||
APIServerStorageType: "unified",
|
||||
AppModeProduction: false,
|
||||
DisableAnonymous: true,
|
||||
RBACSingleOrganization: true,
|
||||
APIServerStorageType: "unified",
|
||||
UnifiedStorageConfig: map[string]setting.UnifiedStorageConfig{
|
||||
"users.iam.grafana.app": {
|
||||
DualWriterMode: mode,
|
||||
@@ -79,9 +80,10 @@ func TestIntegrationUserSearch_WithSorting(t *testing.T) {
|
||||
for _, mode := range modes {
|
||||
t.Run(fmt.Sprintf("DualWriterMode %d", mode), func(t *testing.T) {
|
||||
helper := apis.NewK8sTestHelper(t, testinfra.GrafanaOpts{
|
||||
AppModeProduction: false,
|
||||
DisableAnonymous: true,
|
||||
APIServerStorageType: "unified",
|
||||
AppModeProduction: false,
|
||||
DisableAnonymous: true,
|
||||
RBACSingleOrganization: true,
|
||||
APIServerStorageType: "unified",
|
||||
UnifiedStorageConfig: map[string]setting.UnifiedStorageConfig{
|
||||
"users.iam.grafana.app": {
|
||||
DualWriterMode: mode,
|
||||
@@ -178,9 +180,10 @@ func TestIntegrationUserSearch_SortCompareLegacy(t *testing.T) {
|
||||
for _, mode := range modes {
|
||||
t.Run(fmt.Sprintf("DualWriterMode %d", mode), func(t *testing.T) {
|
||||
helper := apis.NewK8sTestHelper(t, testinfra.GrafanaOpts{
|
||||
AppModeProduction: false,
|
||||
DisableAnonymous: true,
|
||||
APIServerStorageType: "unified",
|
||||
AppModeProduction: false,
|
||||
DisableAnonymous: true,
|
||||
RBACSingleOrganization: true,
|
||||
APIServerStorageType: "unified",
|
||||
UnifiedStorageConfig: map[string]setting.UnifiedStorageConfig{
|
||||
"users.iam.grafana.app": {
|
||||
DualWriterMode: mode,
|
||||
@@ -245,9 +248,10 @@ func TestIntegrationUserSearch_Paging(t *testing.T) {
|
||||
for _, mode := range modes {
|
||||
t.Run(fmt.Sprintf("DualWriterMode %d", mode), func(t *testing.T) {
|
||||
helper := apis.NewK8sTestHelper(t, testinfra.GrafanaOpts{
|
||||
AppModeProduction: false,
|
||||
DisableAnonymous: true,
|
||||
APIServerStorageType: "unified",
|
||||
AppModeProduction: false,
|
||||
DisableAnonymous: true,
|
||||
RBACSingleOrganization: true,
|
||||
APIServerStorageType: "unified",
|
||||
UnifiedStorageConfig: map[string]setting.UnifiedStorageConfig{
|
||||
"users.iam.grafana.app": {
|
||||
DualWriterMode: mode,
|
||||
@@ -345,9 +349,10 @@ func TestIntegrationUserSearch_AccessControl(t *testing.T) {
|
||||
for _, mode := range modes {
|
||||
t.Run(fmt.Sprintf("DualWriterMode %d", mode), func(t *testing.T) {
|
||||
helper := apis.NewK8sTestHelper(t, testinfra.GrafanaOpts{
|
||||
AppModeProduction: false,
|
||||
DisableAnonymous: true,
|
||||
APIServerStorageType: "unified",
|
||||
AppModeProduction: false,
|
||||
DisableAnonymous: true,
|
||||
RBACSingleOrganization: true,
|
||||
APIServerStorageType: "unified",
|
||||
UnifiedStorageConfig: map[string]setting.UnifiedStorageConfig{
|
||||
"users.iam.grafana.app": {
|
||||
DualWriterMode: mode,
|
||||
|
||||
@@ -35,9 +35,10 @@ func TestIntegrationUserTeams(t *testing.T) {
|
||||
for _, mode := range modes {
|
||||
t.Run(fmt.Sprintf("With dual writer mode %d", mode), func(t *testing.T) {
|
||||
helper := apis.NewK8sTestHelper(t, testinfra.GrafanaOpts{
|
||||
AppModeProduction: false,
|
||||
DisableAnonymous: true,
|
||||
APIServerStorageType: "unified",
|
||||
AppModeProduction: false,
|
||||
DisableAnonymous: true,
|
||||
RBACSingleOrganization: true,
|
||||
APIServerStorageType: "unified",
|
||||
UnifiedStorageConfig: map[string]setting.UnifiedStorageConfig{
|
||||
"users.iam.grafana.app": {
|
||||
DualWriterMode: mode,
|
||||
|
||||
@@ -4109,7 +4109,7 @@
|
||||
"tags": [
|
||||
"User"
|
||||
],
|
||||
"description": "list objects of kind User",
|
||||
"description": "list or watch objects of kind User",
|
||||
"operationId": "listUser",
|
||||
"parameters": [
|
||||
{
|
||||
|
||||
@@ -26,7 +26,8 @@ func TestIntegrationOpenAPIs(t *testing.T) {
|
||||
testutil.SkipIntegrationTestInShortMode(t)
|
||||
|
||||
h := NewK8sTestHelper(t, testinfra.GrafanaOpts{
|
||||
AppModeProduction: false, // required for experimental APIs
|
||||
AppModeProduction: false, // required for experimental APIs
|
||||
RBACSingleOrganization: true, // required for the Users API
|
||||
EnableFeatureToggles: []string{
|
||||
featuremgmt.FlagQueryService, // Query Library
|
||||
featuremgmt.FlagProvisioning,
|
||||
|
||||
@@ -402,6 +402,10 @@ func CreateGrafDir(t *testing.T, opts GrafanaOpts) (string, string) {
|
||||
require.NoError(t, err)
|
||||
_, err = rbacSect.NewKey("permission_cache", "false")
|
||||
require.NoError(t, err)
|
||||
if opts.RBACSingleOrganization {
|
||||
_, err = rbacSect.NewKey("single_organization", "true")
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
if opts.DisableAuthZClientCache {
|
||||
authzSect, err := cfg.NewSection("authorization")
|
||||
@@ -833,6 +837,7 @@ type GrafanaOpts struct {
|
||||
LicensePath string
|
||||
EnableRecordingRules bool
|
||||
EnableSCIM bool
|
||||
RBACSingleOrganization bool
|
||||
APIServerRuntimeConfig string
|
||||
DisableControllers bool
|
||||
DisableDBCleanup bool
|
||||
|
||||
Reference in New Issue
Block a user