IAM: Add role to User (#111256)

* codegen fix

* Return user role from the legacy store

* Lint

* Add tests, gen openapi

* make generate

* revert go.mod, go.sum, go.work.sum changes

* Update go.mod and go.sum
This commit is contained in:
Misi
2025-09-19 11:12:00 +02:00
committed by GitHub
parent f48efa1003
commit c012a95fa1
39 changed files with 1485 additions and 274 deletions
+8
View File
@@ -0,0 +1,8 @@
package common
import "github.com/grafana/grafana/pkg/services/user"
type UserWithRole struct {
user.User
Role string
}
@@ -1,6 +1,6 @@
SELECT o.org_id, u.id, u.uid, u.login, u.email, u.name,
u.created, u.updated, u.is_service_account, u.is_disabled, u.is_admin,
u.email_verified, u.is_provisioned, u.last_seen_at
u.email_verified, u.is_provisioned, u.last_seen_at, o.role
FROM `grafana`.`user` as u JOIN `grafana`.`org_user` as o ON u.id = o.user_id
WHERE o.org_id = 0
AND NOT u.is_service_account
@@ -1,6 +1,6 @@
SELECT o.org_id, u.id, u.uid, u.login, u.email, u.name,
u.created, u.updated, u.is_service_account, u.is_disabled, u.is_admin,
u.email_verified, u.is_provisioned, u.last_seen_at
u.email_verified, u.is_provisioned, u.last_seen_at, o.role
FROM `grafana`.`user` as u JOIN `grafana`.`org_user` as o ON u.id = o.user_id
WHERE o.org_id = 0
AND NOT u.is_service_account
@@ -1,6 +1,6 @@
SELECT o.org_id, u.id, u.uid, u.login, u.email, u.name,
u.created, u.updated, u.is_service_account, u.is_disabled, u.is_admin,
u.email_verified, u.is_provisioned, u.last_seen_at
u.email_verified, u.is_provisioned, u.last_seen_at, o.role
FROM `grafana`.`user` as u JOIN `grafana`.`org_user` as o ON u.id = o.user_id
WHERE o.org_id = 0
AND NOT u.is_service_account
@@ -1,6 +1,6 @@
SELECT o.org_id, u.id, u.uid, u.login, u.email, u.name,
u.created, u.updated, u.is_service_account, u.is_disabled, u.is_admin,
u.email_verified, u.is_provisioned, u.last_seen_at
u.email_verified, u.is_provisioned, u.last_seen_at, o.role
FROM "grafana"."user" as u JOIN "grafana"."org_user" as o ON u.id = o.user_id
WHERE o.org_id = 0
AND NOT u.is_service_account
@@ -1,6 +1,6 @@
SELECT o.org_id, u.id, u.uid, u.login, u.email, u.name,
u.created, u.updated, u.is_service_account, u.is_disabled, u.is_admin,
u.email_verified, u.is_provisioned, u.last_seen_at
u.email_verified, u.is_provisioned, u.last_seen_at, o.role
FROM "grafana"."user" as u JOIN "grafana"."org_user" as o ON u.id = o.user_id
WHERE o.org_id = 0
AND NOT u.is_service_account
@@ -1,6 +1,6 @@
SELECT o.org_id, u.id, u.uid, u.login, u.email, u.name,
u.created, u.updated, u.is_service_account, u.is_disabled, u.is_admin,
u.email_verified, u.is_provisioned, u.last_seen_at
u.email_verified, u.is_provisioned, u.last_seen_at, o.role
FROM "grafana"."user" as u JOIN "grafana"."org_user" as o ON u.id = o.user_id
WHERE o.org_id = 0
AND NOT u.is_service_account
@@ -1,6 +1,6 @@
SELECT o.org_id, u.id, u.uid, u.login, u.email, u.name,
u.created, u.updated, u.is_service_account, u.is_disabled, u.is_admin,
u.email_verified, u.is_provisioned, u.last_seen_at
u.email_verified, u.is_provisioned, u.last_seen_at, o.role
FROM "grafana"."user" as u JOIN "grafana"."org_user" as o ON u.id = o.user_id
WHERE o.org_id = 0
AND NOT u.is_service_account
@@ -1,6 +1,6 @@
SELECT o.org_id, u.id, u.uid, u.login, u.email, u.name,
u.created, u.updated, u.is_service_account, u.is_disabled, u.is_admin,
u.email_verified, u.is_provisioned, u.last_seen_at
u.email_verified, u.is_provisioned, u.last_seen_at, o.role
FROM "grafana"."user" as u JOIN "grafana"."org_user" as o ON u.id = o.user_id
WHERE o.org_id = 0
AND NOT u.is_service_account
@@ -1,6 +1,6 @@
SELECT o.org_id, u.id, u.uid, u.login, u.email, u.name,
u.created, u.updated, u.is_service_account, u.is_disabled, u.is_admin,
u.email_verified, u.is_provisioned, u.last_seen_at
u.email_verified, u.is_provisioned, u.last_seen_at, o.role
FROM "grafana"."user" as u JOIN "grafana"."org_user" as o ON u.id = o.user_id
WHERE o.org_id = 0
AND NOT u.is_service_account
+25 -22
View File
@@ -98,7 +98,7 @@ type ListUserQuery struct {
}
type ListUserResult struct {
Users []user.User
Users []common.UserWithRole
Continue int64
RV int64
}
@@ -166,10 +166,10 @@ func (s *legacySQLStore) queryUsers(ctx context.Context, sql *legacysql.LegacyDa
if err == nil {
var lastID int64
for rows.Next() {
u := user.User{}
u := common.UserWithRole{}
err = rows.Scan(&u.OrgID, &u.ID, &u.UID, &u.Login, &u.Email, &u.Name,
&u.Created, &u.Updated, &u.IsServiceAccount, &u.IsDisabled, &u.IsAdmin, &u.EmailVerified,
&u.IsProvisioned, &u.LastSeenAt,
&u.IsProvisioned, &u.LastSeenAt, &u.Role,
)
if err != nil {
return res, err
@@ -310,7 +310,7 @@ type CreateUserCommand struct {
}
type CreateUserResult struct {
User user.User
User common.UserWithRole
}
type CreateOrgUserCommand struct {
@@ -398,7 +398,7 @@ func (s *legacySQLStore) CreateUser(ctx context.Context, ns claims.NamespaceInfo
req := newCreateUser(sql, &cmd)
var createdUser user.User
var createdUser common.UserWithRole
err = sql.DB.GetSqlxSession().WithTransaction(ctx, func(st *session.SessionTx) error {
userQuery, err := sqltemplate.Execute(sqlCreateUserTemplate, req)
if err != nil {
@@ -429,23 +429,26 @@ func (s *legacySQLStore) CreateUser(ctx context.Context, ns claims.NamespaceInfo
return fmt.Errorf("failed to create org_user relationship: %w", err)
}
createdUser = user.User{
ID: userID,
UID: cmd.UID,
Login: cmd.Login,
Email: cmd.Email,
Name: cmd.Name,
OrgID: cmd.OrgID,
IsAdmin: cmd.IsAdmin,
IsDisabled: cmd.IsDisabled,
EmailVerified: cmd.EmailVerified,
IsProvisioned: cmd.IsProvisioned,
Salt: cmd.Salt,
Rands: cmd.Rands,
Created: cmd.Created.Time,
Updated: cmd.Updated.Time,
LastSeenAt: cmd.LastSeenAt.Time,
IsServiceAccount: false,
createdUser = common.UserWithRole{
User: user.User{
ID: userID,
UID: cmd.UID,
Login: cmd.Login,
Email: cmd.Email,
Name: cmd.Name,
OrgID: cmd.OrgID,
IsAdmin: cmd.IsAdmin,
IsDisabled: cmd.IsDisabled,
EmailVerified: cmd.EmailVerified,
IsProvisioned: cmd.IsProvisioned,
Salt: cmd.Salt,
Rands: cmd.Rands,
Created: cmd.Created.Time,
Updated: cmd.Updated.Time,
LastSeenAt: cmd.LastSeenAt.Time,
IsServiceAccount: false,
},
Role: cmd.Role,
}
return nil
+1 -1
View File
@@ -1,6 +1,6 @@
SELECT o.org_id, u.id, u.uid, u.login, u.email, u.name,
u.created, u.updated, u.is_service_account, u.is_disabled, u.is_admin,
u.email_verified, u.is_provisioned, u.last_seen_at
u.email_verified, u.is_provisioned, u.last_seen_at, o.role
FROM {{ .Ident .UserTable }} as u JOIN {{ .Ident .OrgUserTable }} as o ON u.id = o.user_id
WHERE o.org_id = {{ .Arg .Query.OrgID }}
AND NOT u.is_service_account
+1 -28
View File
@@ -2,12 +2,10 @@ package iam
import (
"context"
"fmt"
"maps"
"strings"
"github.com/prometheus/client_golang/prometheus"
apierrors "k8s.io/apimachinery/pkg/api/errors"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/apimachinery/pkg/runtime/schema"
@@ -325,7 +323,7 @@ func (b *IdentityAccessManagementAPIBuilder) Validate(ctx context.Context, a adm
case admission.Create:
switch typedObj := a.GetObject().(type) {
case *iamv0.User:
return b.validateCreateUser(ctx, a, o)
return user.ValidateOnCreate(ctx, typedObj)
case *iamv0.ServiceAccount:
return serviceaccount.ValidateOnCreate(ctx, typedObj)
case *iamv0.Team:
@@ -343,31 +341,6 @@ func (b *IdentityAccessManagementAPIBuilder) Validate(ctx context.Context, a adm
return nil
}
func (b *IdentityAccessManagementAPIBuilder) validateCreateUser(ctx context.Context, a admission.Attributes, o admission.ObjectInterfaces) error {
userObj, ok := a.GetObject().(*iamv0.User)
if !ok {
return nil
}
requester, err := identity.GetRequester(ctx)
if err != nil {
return apierrors.NewUnauthorized("no identity found")
}
// Temporary validation that the user is not trying to create a Grafana Admin without being a Grafana Admin.
if userObj.Spec.GrafanaAdmin && !requester.GetIsGrafanaAdmin() {
return apierrors.NewForbidden(iamv0.UserResourceInfo.GroupResource(),
userObj.Name,
fmt.Errorf("only grafana admins can create grafana admins"))
}
if userObj.Spec.Login == "" && userObj.Spec.Email == "" {
return apierrors.NewBadRequest("user must have either login or email")
}
return nil
}
// Mutate implements builder.APIGroupMutation.
// TODO: Move this to the MutateFunc of the user resource after moving the APIs to use the app-platofrm-sdk.
// TODO: https://github.com/grafana/grafana/blob/main/apps/playlist/pkg/app/app.go#L62
-3
View File
@@ -14,9 +14,6 @@ func MutateOnCreate(ctx context.Context, obj *iamv0alpha1.User) error {
if obj.Spec.Login == "" {
obj.Spec.Login = obj.Spec.Email
}
if obj.Spec.Email == "" {
obj.Spec.Email = obj.Spec.Login
}
return nil
}
@@ -44,7 +44,6 @@ func TestMutateOnCreate_LoginEmail(t *testing.T) {
},
},
expectedLogin: "only.login",
expectedEmail: "only.login",
},
{
name: "login and email already lowercase",
+9 -6
View File
@@ -16,7 +16,7 @@ import (
"github.com/grafana/grafana/pkg/registry/apis/iam/common"
"github.com/grafana/grafana/pkg/registry/apis/iam/legacy"
"github.com/grafana/grafana/pkg/services/apiserver/endpoints/request"
"github.com/grafana/grafana/pkg/services/user"
"github.com/grafana/grafana/pkg/util"
apierrors "k8s.io/apimachinery/pkg/api/errors"
)
@@ -196,16 +196,17 @@ func (s *LegacyStore) Create(ctx context.Context, obj runtime.Object, createVali
return nil, fmt.Errorf("expected User object, got %T", obj)
}
if userObj.GenerateName != "" {
userObj.Name = userObj.GenerateName + util.GenerateShortUID()
userObj.GenerateName = ""
}
if createValidation != nil {
if err := createValidation(ctx, obj); err != nil {
return nil, err
}
}
if userObj.Spec.Login == "" && userObj.Spec.Email == "" {
return nil, fmt.Errorf("user must have either login or email")
}
createCmd := legacy.CreateUserCommand{
UID: userObj.Name,
Login: userObj.Spec.Login,
@@ -215,6 +216,7 @@ func (s *LegacyStore) Create(ctx context.Context, obj runtime.Object, createVali
IsDisabled: userObj.Spec.Disabled,
EmailVerified: userObj.Spec.EmailVerified,
IsProvisioned: userObj.Spec.Provisioned,
Role: userObj.Spec.Role,
}
result, err := s.store.CreateUser(ctx, ns, createCmd)
@@ -226,7 +228,7 @@ func (s *LegacyStore) Create(ctx context.Context, obj runtime.Object, createVali
return &iamUser, nil
}
func toUserItem(u *user.User, ns string) iamv0alpha1.User {
func toUserItem(u *common.UserWithRole, ns string) iamv0alpha1.User {
item := &iamv0alpha1.User{
ObjectMeta: metav1.ObjectMeta{
Name: u.UID,
@@ -242,6 +244,7 @@ func toUserItem(u *user.User, ns string) iamv0alpha1.User {
Disabled: u.IsDisabled,
GrafanaAdmin: u.IsAdmin,
Provisioned: u.IsProvisioned,
Role: u.Role,
},
}
obj, _ := utils.MetaAccessor(item)
+31
View File
@@ -0,0 +1,31 @@
package user
import (
"context"
"fmt"
apierrors "k8s.io/apimachinery/pkg/api/errors"
iamv0alpha1 "github.com/grafana/grafana/apps/iam/pkg/apis/iam/v0alpha1"
"github.com/grafana/grafana/pkg/apimachinery/identity"
)
func ValidateOnCreate(ctx context.Context, obj *iamv0alpha1.User) error {
requester, err := identity.GetRequester(ctx)
if err != nil {
return apierrors.NewUnauthorized("no identity found")
}
// Temporary validation that the user is not trying to create a Grafana Admin without being a Grafana Admin.
if obj.Spec.GrafanaAdmin && !requester.GetIsGrafanaAdmin() {
return apierrors.NewForbidden(iamv0alpha1.UserResourceInfo.GroupResource(),
obj.Name,
fmt.Errorf("only grafana admins can create grafana admins"))
}
if obj.Spec.Login == "" && obj.Spec.Email == "" {
return apierrors.NewBadRequest("user must have either login or email")
}
return nil
}
+123
View File
@@ -0,0 +1,123 @@
package user
import (
"context"
"testing"
"github.com/stretchr/testify/require"
"github.com/grafana/authlib/types"
iamv0alpha1 "github.com/grafana/grafana/apps/iam/pkg/apis/iam/v0alpha1"
"github.com/grafana/grafana/pkg/apimachinery/identity"
)
func TestValidateOnCreate(t *testing.T) {
tests := []struct {
name string
user *iamv0alpha1.User
requester *identity.StaticRequester
expectError bool
errorContains string
}{
{
name: "valid user creation by grafana admin",
user: &iamv0alpha1.User{
Spec: iamv0alpha1.UserSpec{
Login: "testuser",
},
},
requester: &identity.StaticRequester{
Type: types.TypeUser,
IsGrafanaAdmin: true,
},
expectError: false,
},
{
name: "grafana admin creating another grafana admin",
user: &iamv0alpha1.User{
Spec: iamv0alpha1.UserSpec{
Login: "newadmin",
GrafanaAdmin: true,
},
},
requester: &identity.StaticRequester{
Type: types.TypeUser,
IsGrafanaAdmin: true,
},
expectError: false,
},
{
name: "non-admin trying to create a grafana admin",
user: &iamv0alpha1.User{
Spec: iamv0alpha1.UserSpec{
Login: "newadmin",
GrafanaAdmin: true,
},
},
requester: &identity.StaticRequester{
Type: types.TypeUser,
IsGrafanaAdmin: false,
},
expectError: true,
errorContains: "only grafana admins can create grafana admins",
},
{
name: "user with empty login and email",
user: &iamv0alpha1.User{
Spec: iamv0alpha1.UserSpec{},
},
requester: &identity.StaticRequester{
Type: types.TypeUser,
IsGrafanaAdmin: false,
},
expectError: true,
errorContains: "user must have either login or email",
},
{
name: "user with only login",
user: &iamv0alpha1.User{
Spec: iamv0alpha1.UserSpec{
Login: "testuser",
},
},
requester: &identity.StaticRequester{
Type: types.TypeUser,
IsGrafanaAdmin: false,
},
expectError: false,
},
{
name: "user with only email",
user: &iamv0alpha1.User{
Spec: iamv0alpha1.UserSpec{
Email: "test@test.com",
},
},
requester: &identity.StaticRequester{
Type: types.TypeUser,
IsGrafanaAdmin: false,
},
expectError: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
ctx := identity.WithRequester(
context.Background(),
tt.requester,
)
err := ValidateOnCreate(ctx, tt.user)
if tt.expectError {
require.Error(t, err)
if tt.errorContains != "" {
require.Contains(t, err.Error(), tt.errorContains)
}
} else {
require.NoError(t, err)
}
})
}
}
+20 -181
View File
@@ -2,18 +2,13 @@ package identity
import (
"context"
"fmt"
"testing"
"github.com/stretchr/testify/require"
"k8s.io/apimachinery/pkg/api/errors"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime/schema"
"github.com/grafana/grafana/pkg/apiserver/rest"
"github.com/grafana/grafana/pkg/services/featuremgmt"
"github.com/grafana/grafana/pkg/services/user"
"github.com/grafana/grafana/pkg/setting"
"github.com/grafana/grafana/pkg/tests/apis"
"github.com/grafana/grafana/pkg/tests/testinfra"
"github.com/grafana/grafana/pkg/tests/testsuite"
@@ -98,7 +93,8 @@ func TestIntegrationIdentity(t *testing.T) {
"grafanaAdmin": true,
"login": "admin",
"name": "",
"provisioned": false
"provisioned": false,
"role": "Admin"
},
{
"disabled": false,
@@ -107,7 +103,8 @@ func TestIntegrationIdentity(t *testing.T) {
"grafanaAdmin": true,
"login": "grafana-admin",
"name": "admin2",
"provisioned": false
"provisioned": false,
"role": "Admin"
},
{
"disabled": false,
@@ -116,7 +113,8 @@ func TestIntegrationIdentity(t *testing.T) {
"grafanaAdmin": false,
"login": "editor",
"name": "editor",
"provisioned": false
"provisioned": false,
"role": "Editor"
},
{
"disabled": false,
@@ -125,7 +123,8 @@ func TestIntegrationIdentity(t *testing.T) {
"grafanaAdmin": false,
"login": "viewer",
"name": "viewer",
"provisioned": false
"provisioned": false,
"role": "Viewer"
},
{
"disabled": false,
@@ -134,7 +133,8 @@ func TestIntegrationIdentity(t *testing.T) {
"grafanaAdmin": false,
"login": "none",
"name": "none",
"provisioned": false
"provisioned": false,
"role": "None"
}
]`, found)
@@ -157,7 +157,8 @@ func TestIntegrationIdentity(t *testing.T) {
"grafanaAdmin": true,
"login": "grafana-admin",
"name": "admin2",
"provisioned": false
"provisioned": false,
"role": "Admin"
},
{
"disabled": false,
@@ -166,7 +167,8 @@ func TestIntegrationIdentity(t *testing.T) {
"grafanaAdmin": false,
"login": "admin2-org-2",
"name": "admin2",
"provisioned": false
"provisioned": false,
"role": "Admin"
},
{
"disabled": false,
@@ -175,7 +177,8 @@ func TestIntegrationIdentity(t *testing.T) {
"grafanaAdmin": false,
"login": "editor-org-2",
"name": "editor",
"provisioned": false
"provisioned": false,
"role": "Editor"
},
{
"disabled": false,
@@ -184,7 +187,8 @@ func TestIntegrationIdentity(t *testing.T) {
"grafanaAdmin": false,
"login": "viewer-org-2",
"name": "viewer",
"provisioned": false
"provisioned": false,
"role": "Viewer"
},
{
"disabled": false,
@@ -193,174 +197,9 @@ func TestIntegrationIdentity(t *testing.T) {
"grafanaAdmin": false,
"login": "none-org-2",
"name": "none",
"provisioned": false
"provisioned": false,
"role": "None"
}
] `, found)
})
}
func TestIntegrationUsers(t *testing.T) {
testutil.SkipIntegrationTestInShortMode(t)
// TODO: Figure out why rest.Mode4 is failing
modes := []rest.DualWriterMode{rest.Mode0, rest.Mode1, rest.Mode2, rest.Mode3}
for _, mode := range modes {
t.Run(fmt.Sprintf("User CRUD operations with dual writer mode %d", mode), func(t *testing.T) {
helper := apis.NewK8sTestHelper(t, testinfra.GrafanaOpts{
AppModeProduction: false,
DisableAnonymous: true,
APIServerStorageType: "unified",
UnifiedStorageConfig: map[string]setting.UnifiedStorageConfig{
"users.iam.grafana.app": {
DualWriterMode: mode,
},
},
EnableFeatureToggles: []string{
featuremgmt.FlagGrafanaAPIServerWithExperimentalAPIs,
featuremgmt.FlagKubernetesAuthnMutation,
},
})
doUserCRUDTestsUsingTheNewAPIs(t, helper)
if mode < 3 {
doUserCRUDTestsUsingTheLegacyAPIs(t, helper)
}
})
}
}
func doUserCRUDTestsUsingTheNewAPIs(t *testing.T, helper *apis.K8sTestHelper) {
t.Run("should create user and delete it using the new APIs as a GrafanaAdmin", func(t *testing.T) {
ctx := context.Background()
userClient := helper.GetResourceClient(apis.ResourceClientArgs{
User: helper.Org1.Admin,
Namespace: helper.Namespacer(helper.Org1.Admin.Identity.GetOrgID()),
GVR: gvrUsers,
})
// Create the user
created, err := userClient.Resource.Create(ctx, helper.LoadYAMLOrJSONFile("testdata/user-test-create-v0.yaml"), metav1.CreateOptions{})
require.NoError(t, err)
require.NotNil(t, created)
// Verify creation response
createdSpec := created.Object["spec"].(map[string]interface{})
require.Equal(t, "testuser1@example123.com", createdSpec["email"])
require.Equal(t, "testuser1", createdSpec["login"])
require.Equal(t, "Test User 1", createdSpec["name"])
require.Equal(t, false, createdSpec["provisioned"])
// Get the UID from created user for fetching
createdUID := created.GetName()
require.NotEmpty(t, createdUID)
_, err = userClient.Resource.List(ctx, metav1.ListOptions{})
require.NoError(t, err)
fetched, err := userClient.Resource.Get(ctx, createdUID, metav1.GetOptions{})
require.NoError(t, err)
require.NotNil(t, fetched)
// Verify fetched user matches created user
fetchedSpec := fetched.Object["spec"].(map[string]interface{})
require.Equal(t, "testuser1@example123.com", fetchedSpec["email"])
require.Equal(t, "testuser1", fetchedSpec["login"])
require.Equal(t, "Test User 1", fetchedSpec["name"])
require.Equal(t, false, fetchedSpec["provisioned"])
// Verify metadata
require.Equal(t, createdUID, fetched.GetName())
require.Equal(t, "default", fetched.GetNamespace())
// TODO: Uncomment when we know how to handle global scope (global.users:)
// err = userClient.Resource.Delete(ctx, createdUID, metav1.DeleteOptions{})
// require.NoError(t, err)
// Verify deletion
// _, err = userClient.Resource.Get(ctx, createdUID, metav1.GetOptions{})
// require.Error(t, err)
// require.Contains(t, err.Error(), "not found")
})
t.Run("should not be able to create user when using a user with insufficient permissions", func(t *testing.T) {
for _, user := range []apis.User{
helper.OrgB.Admin, // Not a Grafana Admin
helper.Org1.Editor,
helper.Org1.Viewer,
} {
t.Run(fmt.Sprintf("with basic role_%s", user.Identity.GetOrgRole()), func(t *testing.T) {
ctx := context.Background()
userClient := helper.GetResourceClient(apis.ResourceClientArgs{
User: user,
Namespace: helper.Namespacer(helper.Org1.Admin.Identity.GetOrgID()),
GVR: gvrUsers,
})
// Create the user
_, err := userClient.Resource.Create(ctx, helper.LoadYAMLOrJSONFile("testdata/user-test-create-v0.yaml"), metav1.CreateOptions{})
require.Error(t, err)
var statusErr *errors.StatusError
require.ErrorAs(t, err, &statusErr)
require.Equal(t, int32(403), statusErr.ErrStatus.Code)
})
}
})
}
func doUserCRUDTestsUsingTheLegacyAPIs(t *testing.T, helper *apis.K8sTestHelper) {
t.Run("should create user using legacy APIs and delete it using the new APIs", func(t *testing.T) {
ctx := context.Background()
userClient := helper.GetResourceClient(apis.ResourceClientArgs{
User: helper.Org1.Admin,
GVR: gvrUsers,
})
legacyUserPayload := `{
"name": "Test User 2",
"email": "testuser2@example.com",
"login": "testuser2",
"password": "password123"
}`
rsp := apis.DoRequest(helper, apis.RequestParams{
User: helper.Org1.Admin,
Method: "POST",
Path: "/api/admin/users",
Body: []byte(legacyUserPayload),
}, &user.User{})
require.NotNil(t, rsp)
require.Equal(t, 200, rsp.Response.StatusCode)
require.NotEmpty(t, rsp.Result.UID)
// Now try to fetch the user via the new API
user, err := userClient.Resource.Get(context.Background(), rsp.Result.UID, metav1.GetOptions{})
require.NoError(t, err)
require.NotNil(t, user)
// Verify fetched user matches created user
userSpec := user.Object["spec"].(map[string]interface{})
require.Equal(t, "testuser2@example.com", userSpec["email"])
require.Equal(t, "testuser2", userSpec["login"])
require.Equal(t, "Test User 2", userSpec["name"])
require.Equal(t, false, userSpec["provisioned"])
// Verify metadata
require.Equal(t, rsp.Result.UID, user.GetName())
require.Equal(t, "default", user.GetNamespace())
// Now delete the user using the legacy API
deleteRsp := apis.DoRequest(helper, apis.RequestParams{
User: helper.Org1.Admin,
Method: "DELETE",
Path: fmt.Sprintf("/api/admin/users/%d", rsp.Result.ID),
}, &apis.AnyResource{})
require.Equal(t, 200, deleteRsp.Response.StatusCode)
// Verify deletion
_, err = userClient.Resource.Get(ctx, rsp.Result.UID, metav1.GetOptions{})
require.Error(t, err)
require.Contains(t, err.Error(), "not found")
})
}
+184
View File
@@ -0,0 +1,184 @@
package identity
import (
"context"
"fmt"
"testing"
"github.com/grafana/grafana/pkg/apiserver/rest"
"github.com/grafana/grafana/pkg/services/featuremgmt"
"github.com/grafana/grafana/pkg/services/user"
"github.com/grafana/grafana/pkg/setting"
"github.com/grafana/grafana/pkg/tests/apis"
"github.com/grafana/grafana/pkg/tests/testinfra"
"github.com/grafana/grafana/pkg/util/testutil"
"github.com/stretchr/testify/require"
"k8s.io/apimachinery/pkg/api/errors"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
)
func TestIntegrationUsers(t *testing.T) {
testutil.SkipIntegrationTestInShortMode(t)
// TODO: Figure out why rest.Mode4 is failing
modes := []rest.DualWriterMode{rest.Mode0, rest.Mode1, rest.Mode2, rest.Mode3}
for _, mode := range modes {
t.Run(fmt.Sprintf("User CRUD operations with dual writer mode %d", mode), func(t *testing.T) {
helper := apis.NewK8sTestHelper(t, testinfra.GrafanaOpts{
AppModeProduction: false,
DisableAnonymous: true,
APIServerStorageType: "unified",
UnifiedStorageConfig: map[string]setting.UnifiedStorageConfig{
"users.iam.grafana.app": {
DualWriterMode: mode,
},
},
EnableFeatureToggles: []string{
featuremgmt.FlagGrafanaAPIServerWithExperimentalAPIs,
featuremgmt.FlagKubernetesAuthnMutation,
},
})
doUserCRUDTestsUsingTheNewAPIs(t, helper)
if mode < 3 {
doUserCRUDTestsUsingTheLegacyAPIs(t, helper)
}
})
}
}
func doUserCRUDTestsUsingTheNewAPIs(t *testing.T, helper *apis.K8sTestHelper) {
t.Run("should create user and delete it using the new APIs as a GrafanaAdmin", func(t *testing.T) {
ctx := context.Background()
userClient := helper.GetResourceClient(apis.ResourceClientArgs{
User: helper.Org1.Admin,
Namespace: helper.Namespacer(helper.Org1.Admin.Identity.GetOrgID()),
GVR: gvrUsers,
})
// Create the user
created, err := userClient.Resource.Create(ctx, helper.LoadYAMLOrJSONFile("testdata/user-test-create-v0.yaml"), metav1.CreateOptions{})
require.NoError(t, err)
require.NotNil(t, created)
// Verify creation response
createdSpec := created.Object["spec"].(map[string]interface{})
require.Equal(t, "testuser1@example123.com", createdSpec["email"])
require.Equal(t, "testuser1", createdSpec["login"])
require.Equal(t, "Test User 1", createdSpec["name"])
require.Equal(t, false, createdSpec["provisioned"])
// Get the UID from created user for fetching
createdUID := created.GetName()
require.NotEmpty(t, createdUID)
_, err = userClient.Resource.List(ctx, metav1.ListOptions{})
require.NoError(t, err)
fetched, err := userClient.Resource.Get(ctx, createdUID, metav1.GetOptions{})
require.NoError(t, err)
require.NotNil(t, fetched)
// Verify fetched user matches created user
fetchedSpec := fetched.Object["spec"].(map[string]interface{})
require.Equal(t, "testuser1@example123.com", fetchedSpec["email"])
require.Equal(t, "testuser1", fetchedSpec["login"])
require.Equal(t, "Test User 1", fetchedSpec["name"])
require.Equal(t, false, fetchedSpec["provisioned"])
// Verify metadata
require.Equal(t, createdUID, fetched.GetName())
require.Equal(t, "default", fetched.GetNamespace())
// TODO: Uncomment when we know how to handle global scope (global.users:)
// err = userClient.Resource.Delete(ctx, createdUID, metav1.DeleteOptions{})
// require.NoError(t, err)
// Verify deletion
// _, err = userClient.Resource.Get(ctx, createdUID, metav1.GetOptions{})
// require.Error(t, err)
// require.Contains(t, err.Error(), "not found")
})
t.Run("should not be able to create user when using a user with insufficient permissions", func(t *testing.T) {
for _, user := range []apis.User{
helper.OrgB.Admin, // Not a Grafana Admin
helper.Org1.Editor,
helper.Org1.Viewer,
} {
t.Run(fmt.Sprintf("with basic role_%s", user.Identity.GetOrgRole()), func(t *testing.T) {
ctx := context.Background()
userClient := helper.GetResourceClient(apis.ResourceClientArgs{
User: user,
Namespace: helper.Namespacer(helper.Org1.Admin.Identity.GetOrgID()),
GVR: gvrUsers,
})
// Create the user
_, err := userClient.Resource.Create(ctx, helper.LoadYAMLOrJSONFile("testdata/user-test-create-v0.yaml"), metav1.CreateOptions{})
require.Error(t, err)
var statusErr *errors.StatusError
require.ErrorAs(t, err, &statusErr)
require.Equal(t, int32(403), statusErr.ErrStatus.Code)
})
}
})
}
func doUserCRUDTestsUsingTheLegacyAPIs(t *testing.T, helper *apis.K8sTestHelper) {
t.Run("should create user using legacy APIs and delete it using the new APIs", func(t *testing.T) {
ctx := context.Background()
userClient := helper.GetResourceClient(apis.ResourceClientArgs{
User: helper.Org1.Admin,
GVR: gvrUsers,
})
legacyUserPayload := `{
"name": "Test User 2",
"email": "testuser2@example.com",
"login": "testuser2",
"password": "password123"
}`
rsp := apis.DoRequest(helper, apis.RequestParams{
User: helper.Org1.Admin,
Method: "POST",
Path: "/api/admin/users",
Body: []byte(legacyUserPayload),
}, &user.User{})
require.NotNil(t, rsp)
require.Equal(t, 200, rsp.Response.StatusCode)
require.NotEmpty(t, rsp.Result.UID)
// Now try to fetch the user via the new API
user, err := userClient.Resource.Get(context.Background(), rsp.Result.UID, metav1.GetOptions{})
require.NoError(t, err)
require.NotNil(t, user)
// Verify fetched user matches created user
userSpec := user.Object["spec"].(map[string]interface{})
require.Equal(t, "testuser2@example.com", userSpec["email"])
require.Equal(t, "testuser2", userSpec["login"])
require.Equal(t, "Test User 2", userSpec["name"])
require.Equal(t, false, userSpec["provisioned"])
// Verify metadata
require.Equal(t, rsp.Result.UID, user.GetName())
require.Equal(t, "default", user.GetNamespace())
// Now delete the user using the legacy API
deleteRsp := apis.DoRequest(helper, apis.RequestParams{
User: helper.Org1.Admin,
Method: "DELETE",
Path: fmt.Sprintf("/api/admin/users/%d", rsp.Result.ID),
}, &apis.AnyResource{})
require.Equal(t, 200, deleteRsp.Response.StatusCode)
// Verify deletion
_, err = userClient.Resource.Get(ctx, rsp.Result.UID, metav1.GetOptions{})
require.Error(t, err)
require.Contains(t, err.Error(), "not found")
})
}
@@ -4155,7 +4155,8 @@
"grafanaAdmin",
"login",
"name",
"provisioned"
"provisioned",
"role"
],
"properties": {
"disabled": {
@@ -4183,9 +4184,12 @@
"default": ""
},
"provisioned": {
"description": "What to do with salt, rands and password?",
"type": "boolean",
"default": false
},
"role": {
"type": "string",
"default": ""
}
}
},
@@ -6139,7 +6143,8 @@
"grafanaAdmin",
"login",
"name",
"provisioned"
"provisioned",
"role"
],
"properties": {
"disabled": {
@@ -6167,9 +6172,12 @@
"default": ""
},
"provisioned": {
"description": "What to do with salt, rands and password?",
"type": "boolean",
"default": false
},
"role": {
"type": "string",
"default": ""
}
}
},