mirror of
https://github.com/grafana/grafana.git
synced 2026-03-13 15:29:48 +08:00
* 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>
649 lines
20 KiB
Go
649 lines
20 KiB
Go
package identity
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"net/url"
|
|
"sort"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/stretchr/testify/require"
|
|
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
|
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
|
|
"k8s.io/apimachinery/pkg/runtime"
|
|
|
|
iamv0 "github.com/grafana/grafana/apps/iam/pkg/apis/iam/v0alpha1"
|
|
"github.com/grafana/grafana/pkg/apiserver/rest"
|
|
"github.com/grafana/grafana/pkg/infra/db"
|
|
"github.com/grafana/grafana/pkg/services/featuremgmt"
|
|
"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"
|
|
)
|
|
|
|
func TestIntegrationUserSearch(t *testing.T) {
|
|
testutil.SkipIntegrationTestInShortMode(t)
|
|
|
|
modes := []rest.DualWriterMode{rest.Mode0, rest.Mode1, rest.Mode5}
|
|
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,
|
|
RBACSingleOrganization: true,
|
|
APIServerStorageType: "unified",
|
|
UnifiedStorageConfig: map[string]setting.UnifiedStorageConfig{
|
|
"users.iam.grafana.app": {
|
|
DualWriterMode: mode,
|
|
},
|
|
},
|
|
EnableFeatureToggles: []string{
|
|
featuremgmt.FlagGrafanaAPIServerWithExperimentalAPIs,
|
|
featuremgmt.FlagKubernetesAuthnMutation,
|
|
featuremgmt.FlagKubernetesUsersApi,
|
|
},
|
|
})
|
|
|
|
t.Cleanup(func() {
|
|
helper.Shutdown()
|
|
})
|
|
|
|
setupUsers(t, helper)
|
|
|
|
t.Run("search by title", func(t *testing.T) {
|
|
res := searchUsers(t, helper, "Alice")
|
|
require.Len(t, res.Hits, 1)
|
|
require.Equal(t, "TestUser Alice", res.Hits[0].Title)
|
|
})
|
|
|
|
t.Run("search by login", func(t *testing.T) {
|
|
res := searchUsers(t, helper, "bob")
|
|
require.Len(t, res.Hits, 1)
|
|
require.Equal(t, "bob", res.Hits[0].Login)
|
|
})
|
|
|
|
t.Run("search by email", func(t *testing.T) {
|
|
res := searchUsers(t, helper, "charlie@example.com")
|
|
require.Len(t, res.Hits, 1)
|
|
require.Equal(t, "charlie@example.com", res.Hits[0].Email)
|
|
})
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestIntegrationUserSearch_WithSorting(t *testing.T) {
|
|
testutil.SkipIntegrationTestInShortMode(t)
|
|
|
|
modes := []rest.DualWriterMode{rest.Mode0, rest.Mode1, rest.Mode5}
|
|
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,
|
|
RBACSingleOrganization: true,
|
|
APIServerStorageType: "unified",
|
|
UnifiedStorageConfig: map[string]setting.UnifiedStorageConfig{
|
|
"users.iam.grafana.app": {
|
|
DualWriterMode: mode,
|
|
},
|
|
},
|
|
EnableFeatureToggles: []string{
|
|
featuremgmt.FlagGrafanaAPIServerWithExperimentalAPIs,
|
|
featuremgmt.FlagKubernetesAuthnMutation,
|
|
featuremgmt.FlagKubernetesUsersApi,
|
|
},
|
|
})
|
|
|
|
t.Cleanup(func() {
|
|
helper.Shutdown()
|
|
})
|
|
|
|
setupUsers(t, helper)
|
|
|
|
tests := []struct {
|
|
field string
|
|
extractor func(iamv0.GetSearchUsersUserHit) string
|
|
expected []string
|
|
}{
|
|
{
|
|
field: "title",
|
|
extractor: func(h iamv0.GetSearchUsersUserHit) string { return h.Title },
|
|
expected: []string{"TestUser Alice", "TestUser Bob", "TestUser Charlie", "TestUser Editor", "TestUser Viewer"},
|
|
},
|
|
{
|
|
field: "login",
|
|
extractor: func(h iamv0.GetSearchUsersUserHit) string { return h.Login },
|
|
expected: []string{"alice", "bob", "charlie", "testuser-editor", "testuser-viewer"},
|
|
},
|
|
{
|
|
field: "email",
|
|
extractor: func(h iamv0.GetSearchUsersUserHit) string { return h.Email },
|
|
expected: []string{"alice@example.com", "bob@example.com", "charlie@example.com", "testuser-editor@example.com", "testuser-viewer@example.com"},
|
|
},
|
|
}
|
|
|
|
for _, tc := range tests {
|
|
t.Run("sort by "+tc.field, func(t *testing.T) {
|
|
// ASC
|
|
res := searchUsersWithSort(t, helper, "TestUser", tc.field)
|
|
require.GreaterOrEqual(t, len(res.Hits), 5)
|
|
verifyOrder(t, res.Hits, tc.expected, tc.extractor)
|
|
|
|
// DESC
|
|
res = searchUsersWithSort(t, helper, "TestUser", "-"+tc.field)
|
|
require.GreaterOrEqual(t, len(res.Hits), 5)
|
|
|
|
// Reverse expected
|
|
reversed := make([]string, len(tc.expected))
|
|
copy(reversed, tc.expected)
|
|
sort.Sort(sort.Reverse(sort.StringSlice(reversed)))
|
|
verifyOrder(t, res.Hits, reversed, tc.extractor)
|
|
})
|
|
}
|
|
|
|
t.Run("sort by lastSeenAt", func(t *testing.T) {
|
|
if mode >= rest.Mode4 {
|
|
t.Skip("Skipping lastSeenAt sort test for Mode >= 4: API does not persist status.lastSeenAt")
|
|
}
|
|
// Populate lastSeenAt
|
|
// Alice: 30 minutes ago
|
|
// Bob: 1 minute ago
|
|
// Charlie: 2 hours ago
|
|
// Editor: 40 minutes ago
|
|
// Viewer: 1h 30 mins ago
|
|
now := time.Date(2025, 1, 1, 12, 0, 0, 0, time.UTC)
|
|
updateLastSeenAt(t, helper, "alice", now.Add(-30*time.Minute), mode)
|
|
updateLastSeenAt(t, helper, "bob", now.Add(-1*time.Minute), mode)
|
|
updateLastSeenAt(t, helper, "charlie", now.Add(-2*time.Hour), mode)
|
|
updateLastSeenAt(t, helper, "testuser-editor", now.Add(-40*time.Minute), mode)
|
|
updateLastSeenAt(t, helper, "testuser-viewer", now.Add(-90*time.Minute), mode)
|
|
|
|
// lastSeenAt ASC means oldest date first to match legacy behavior
|
|
res := searchUsersWithSort(t, helper, "TestUser", "lastSeenAt")
|
|
require.GreaterOrEqual(t, len(res.Hits), 5)
|
|
verifyOrder(t, res.Hits, []string{"charlie", "testuser-viewer", "testuser-editor", "alice", "bob"}, func(h iamv0.GetSearchUsersUserHit) string { return h.Login })
|
|
|
|
res = searchUsersWithSort(t, helper, "TestUser", "-lastSeenAt")
|
|
require.GreaterOrEqual(t, len(res.Hits), 5)
|
|
verifyOrder(t, res.Hits, []string{"bob", "alice", "testuser-editor", "testuser-viewer", "charlie"}, func(h iamv0.GetSearchUsersUserHit) string { return h.Login })
|
|
})
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestIntegrationUserSearch_SortCompareLegacy(t *testing.T) {
|
|
testutil.SkipIntegrationTestInShortMode(t)
|
|
|
|
modes := []rest.DualWriterMode{rest.Mode1}
|
|
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,
|
|
RBACSingleOrganization: true,
|
|
APIServerStorageType: "unified",
|
|
UnifiedStorageConfig: map[string]setting.UnifiedStorageConfig{
|
|
"users.iam.grafana.app": {
|
|
DualWriterMode: mode,
|
|
},
|
|
},
|
|
EnableFeatureToggles: []string{
|
|
featuremgmt.FlagGrafanaAPIServerWithExperimentalAPIs,
|
|
featuremgmt.FlagKubernetesAuthnMutation,
|
|
featuremgmt.FlagKubernetesUsersApi,
|
|
},
|
|
})
|
|
|
|
t.Cleanup(func() {
|
|
helper.Shutdown()
|
|
})
|
|
|
|
setupUsers(t, helper)
|
|
|
|
// Populate lastSeenAt for sorting comparison
|
|
now := time.Date(2025, 1, 1, 12, 0, 0, 0, time.UTC)
|
|
updateLastSeenAt(t, helper, "alice", now.Add(-30*time.Minute), mode)
|
|
updateLastSeenAt(t, helper, "bob", now.Add(-1*time.Minute), mode)
|
|
updateLastSeenAt(t, helper, "charlie", now.Add(-2*time.Hour), mode)
|
|
updateLastSeenAt(t, helper, "testuser-editor", now.Add(-40*time.Minute), mode)
|
|
updateLastSeenAt(t, helper, "testuser-viewer", now.Add(-90*time.Minute), mode)
|
|
|
|
fields := []string{"login", "email", "name", "lastSeenAt"}
|
|
for _, field := range fields {
|
|
for _, order := range []string{"asc", "desc"} {
|
|
t.Run(fmt.Sprintf("compare %s %s", field, order), func(t *testing.T) {
|
|
// Legacy API uses "name" for Name/Title, "login" for Login, "email" for Email.
|
|
// "lastSeenAt" maps to "lastSeenAtAge" in legacy.
|
|
legacySort := field
|
|
if field == "lastSeenAt" {
|
|
legacySort = "lastSeenAtAge"
|
|
}
|
|
legacySort += "-" + order
|
|
|
|
newSort := field
|
|
if order == "desc" {
|
|
newSort = "-" + field
|
|
}
|
|
|
|
legacyRes := searchUsersLegacy(t, helper, "TestUser", legacySort)
|
|
newRes := searchUsersWithSort(t, helper, "TestUser", newSort)
|
|
|
|
require.Equal(t, len(legacyRes), len(newRes.Hits))
|
|
for i := range legacyRes {
|
|
require.Equal(t, legacyRes[i].Login, newRes.Hits[i].Login, "Mismatch at index %d for sort %s", i, newSort)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestIntegrationUserSearch_Paging(t *testing.T) {
|
|
testutil.SkipIntegrationTestInShortMode(t)
|
|
|
|
modes := []rest.DualWriterMode{rest.Mode0, rest.Mode1, rest.Mode5}
|
|
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,
|
|
RBACSingleOrganization: true,
|
|
APIServerStorageType: "unified",
|
|
UnifiedStorageConfig: map[string]setting.UnifiedStorageConfig{
|
|
"users.iam.grafana.app": {
|
|
DualWriterMode: mode,
|
|
},
|
|
},
|
|
EnableFeatureToggles: []string{
|
|
featuremgmt.FlagGrafanaAPIServerWithExperimentalAPIs,
|
|
featuremgmt.FlagKubernetesAuthnMutation,
|
|
featuremgmt.FlagKubernetesUsersApi,
|
|
},
|
|
})
|
|
|
|
t.Cleanup(func() {
|
|
helper.Shutdown()
|
|
})
|
|
|
|
setupUsers(t, helper)
|
|
|
|
t.Run("paging with page and limit", func(t *testing.T) {
|
|
// There are 5 users matching "TestUser"
|
|
query := "TestUser"
|
|
|
|
// Page 1, Limit 2
|
|
res1 := searchUsersWithPaging(t, helper, query, 1, 2)
|
|
require.Equal(t, int64(5), res1.TotalHits)
|
|
require.Len(t, res1.Hits, 2)
|
|
|
|
// Page 2, Limit 2
|
|
res2 := searchUsersWithPaging(t, helper, query, 2, 2)
|
|
require.Equal(t, int64(5), res2.TotalHits)
|
|
require.Len(t, res2.Hits, 2)
|
|
|
|
// Page 3, Limit 2
|
|
res3 := searchUsersWithPaging(t, helper, query, 3, 2)
|
|
require.Equal(t, int64(5), res3.TotalHits)
|
|
require.Len(t, res3.Hits, 1)
|
|
|
|
seen := make(map[string]bool)
|
|
for _, h := range res1.Hits {
|
|
seen[h.Login] = true
|
|
}
|
|
for _, h := range res2.Hits {
|
|
require.False(t, seen[h.Login], "User %s seen in page 1 and 2", h.Login)
|
|
seen[h.Login] = true
|
|
}
|
|
for _, h := range res3.Hits {
|
|
require.False(t, seen[h.Login], "User %s seen in previous pages", h.Login)
|
|
seen[h.Login] = true
|
|
}
|
|
require.Len(t, seen, 5)
|
|
})
|
|
|
|
t.Run("paging with offset and limit", func(t *testing.T) {
|
|
// There are 5 users matching "TestUser"
|
|
query := "TestUser"
|
|
|
|
// Offset 0, Limit 2 (equivalent to Page 1)
|
|
res1 := searchUsersWithOffset(t, helper, query, 0, 2)
|
|
require.Equal(t, int64(5), res1.TotalHits)
|
|
require.Len(t, res1.Hits, 2)
|
|
|
|
// Offset 2, Limit 2 (equivalent to Page 2)
|
|
res2 := searchUsersWithOffset(t, helper, query, 2, 2)
|
|
require.Equal(t, int64(5), res2.TotalHits)
|
|
require.Len(t, res2.Hits, 2)
|
|
|
|
// Offset 4, Limit 2 (equivalent to Page 3)
|
|
res3 := searchUsersWithOffset(t, helper, query, 4, 2)
|
|
require.Equal(t, int64(5), res3.TotalHits)
|
|
require.Len(t, res3.Hits, 1)
|
|
|
|
// Verify uniqueness
|
|
seen := make(map[string]bool)
|
|
for _, h := range res1.Hits {
|
|
seen[h.Login] = true
|
|
}
|
|
for _, h := range res2.Hits {
|
|
require.False(t, seen[h.Login], "User %s seen in offset 0 and 2", h.Login)
|
|
seen[h.Login] = true
|
|
}
|
|
for _, h := range res3.Hits {
|
|
require.False(t, seen[h.Login], "User %s seen in previous offsets", h.Login)
|
|
seen[h.Login] = true
|
|
}
|
|
require.Len(t, seen, 5)
|
|
})
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestIntegrationUserSearch_AccessControl(t *testing.T) {
|
|
testutil.SkipIntegrationTestInShortMode(t)
|
|
|
|
modes := []rest.DualWriterMode{rest.Mode0, rest.Mode1, rest.Mode5}
|
|
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,
|
|
RBACSingleOrganization: true,
|
|
APIServerStorageType: "unified",
|
|
UnifiedStorageConfig: map[string]setting.UnifiedStorageConfig{
|
|
"users.iam.grafana.app": {
|
|
DualWriterMode: mode,
|
|
},
|
|
},
|
|
EnableFeatureToggles: []string{
|
|
featuremgmt.FlagGrafanaAPIServerWithExperimentalAPIs,
|
|
featuremgmt.FlagKubernetesAuthnMutation,
|
|
featuremgmt.FlagKubernetesUsersApi,
|
|
},
|
|
})
|
|
|
|
t.Cleanup(func() {
|
|
helper.Shutdown()
|
|
})
|
|
|
|
setupUsers(t, helper)
|
|
|
|
t.Run("accesscontrol=true includes permissions on hits", func(t *testing.T) {
|
|
res := searchUsersWithAccessControl(t, helper, "TestUser", true)
|
|
require.GreaterOrEqual(t, len(res.Hits), 1)
|
|
for _, hit := range res.Hits {
|
|
require.NotNil(t, hit.AccessControl, "expected AccessControl map on hit %s", hit.Login)
|
|
require.True(t, hit.AccessControl["org.users:read"], "admin should have org.users:read on %s", hit.Login)
|
|
}
|
|
})
|
|
|
|
t.Run("accesscontrol absent omits permissions from hits", func(t *testing.T) {
|
|
res := searchUsers(t, helper, "TestUser")
|
|
require.GreaterOrEqual(t, len(res.Hits), 1)
|
|
for _, hit := range res.Hits {
|
|
require.Empty(t, hit.AccessControl, "expected no AccessControl on hit %s when param absent", hit.Login)
|
|
}
|
|
})
|
|
|
|
t.Run("accesscontrol=false omits permissions from hits", func(t *testing.T) {
|
|
res := searchUsersWithAccessControl(t, helper, "TestUser", false)
|
|
require.GreaterOrEqual(t, len(res.Hits), 1)
|
|
for _, hit := range res.Hits {
|
|
require.Empty(t, hit.AccessControl, "expected no AccessControl on hit %s when param is false", hit.Login)
|
|
}
|
|
})
|
|
})
|
|
}
|
|
}
|
|
|
|
func setupUsers(t *testing.T, helper *apis.K8sTestHelper) {
|
|
ctx := context.Background()
|
|
userClient := helper.GetResourceClient(apis.ResourceClientArgs{
|
|
User: helper.Org1.Admin,
|
|
Namespace: helper.Namespacer(helper.Org1.Admin.Identity.GetOrgID()),
|
|
GVR: gvrUsers,
|
|
})
|
|
|
|
users := []iamv0.User{
|
|
{
|
|
ObjectMeta: metav1.ObjectMeta{
|
|
Name: "testuser-editor",
|
|
},
|
|
Spec: iamv0.UserSpec{
|
|
Title: "TestUser Editor",
|
|
Login: "testuser-editor",
|
|
Email: "testuser-editor@example.com",
|
|
Role: "Editor",
|
|
},
|
|
},
|
|
{
|
|
ObjectMeta: metav1.ObjectMeta{
|
|
Name: "testuser-viewer",
|
|
},
|
|
Spec: iamv0.UserSpec{
|
|
Title: "TestUser Viewer",
|
|
Login: "testuser-viewer",
|
|
Email: "testuser-viewer@example.com",
|
|
Role: "Viewer",
|
|
},
|
|
},
|
|
{
|
|
ObjectMeta: metav1.ObjectMeta{
|
|
Name: "alice",
|
|
},
|
|
Spec: iamv0.UserSpec{
|
|
Title: "TestUser Alice",
|
|
Login: "alice",
|
|
Email: "alice@example.com",
|
|
Role: "Viewer",
|
|
},
|
|
},
|
|
{
|
|
ObjectMeta: metav1.ObjectMeta{
|
|
Name: "bob",
|
|
},
|
|
Spec: iamv0.UserSpec{
|
|
Title: "TestUser Bob",
|
|
Login: "bob",
|
|
Email: "bob@example.com",
|
|
Role: "Viewer",
|
|
},
|
|
},
|
|
{
|
|
ObjectMeta: metav1.ObjectMeta{
|
|
Name: "charlie",
|
|
},
|
|
Spec: iamv0.UserSpec{
|
|
Title: "TestUser Charlie",
|
|
Login: "charlie",
|
|
Email: "charlie@example.com",
|
|
Role: "Viewer",
|
|
},
|
|
},
|
|
}
|
|
|
|
for _, u := range users {
|
|
uMap, err := runtime.DefaultUnstructuredConverter.ToUnstructured(&u)
|
|
require.NoError(t, err)
|
|
_, err = userClient.Resource.Create(ctx, &unstructured.Unstructured{Object: uMap}, metav1.CreateOptions{})
|
|
require.NoError(t, err)
|
|
}
|
|
|
|
// Wait for indexing
|
|
time.Sleep(2 * time.Second)
|
|
}
|
|
|
|
func searchUsers(t *testing.T, helper *apis.K8sTestHelper, query string) *iamv0.GetSearchUsersResponse {
|
|
return searchUsersWithSort(t, helper, query, "")
|
|
}
|
|
|
|
func searchUsersWithSort(t *testing.T, helper *apis.K8sTestHelper, query string, sort string) *iamv0.GetSearchUsersResponse {
|
|
q := url.Values{}
|
|
q.Set("query", query)
|
|
if sort != "" {
|
|
q.Set("sort", sort)
|
|
}
|
|
q.Set("limit", "100")
|
|
|
|
path := fmt.Sprintf("/apis/iam.grafana.app/v0alpha1/namespaces/default/searchUsers?%s", q.Encode())
|
|
|
|
res := &iamv0.GetSearchUsersResponse{}
|
|
rsp := apis.DoRequest(helper, apis.RequestParams{
|
|
User: helper.Org1.Admin,
|
|
Method: "GET",
|
|
Path: path,
|
|
}, res)
|
|
|
|
require.Equal(t, 200, rsp.Response.StatusCode)
|
|
return res
|
|
}
|
|
|
|
func searchUsersWithAccessControl(t *testing.T, helper *apis.K8sTestHelper, query string, accessControl bool) *iamv0.GetSearchUsersResponse {
|
|
q := url.Values{}
|
|
q.Set("query", query)
|
|
q.Set("limit", "100")
|
|
q.Set("accesscontrol", fmt.Sprintf("%t", accessControl))
|
|
|
|
path := fmt.Sprintf("/apis/iam.grafana.app/v0alpha1/namespaces/default/searchUsers?%s", q.Encode())
|
|
|
|
res := &iamv0.GetSearchUsersResponse{}
|
|
rsp := apis.DoRequest(helper, apis.RequestParams{
|
|
User: helper.Org1.Admin,
|
|
Method: "GET",
|
|
Path: path,
|
|
}, res)
|
|
|
|
require.Equal(t, 200, rsp.Response.StatusCode)
|
|
return res
|
|
}
|
|
|
|
func searchUsersWithPaging(t *testing.T, helper *apis.K8sTestHelper, query string, page, limit int) *iamv0.GetSearchUsersResponse {
|
|
q := url.Values{}
|
|
q.Set("query", query)
|
|
q.Set("page", fmt.Sprintf("%d", page))
|
|
q.Set("limit", fmt.Sprintf("%d", limit))
|
|
// Sort by login to ensure deterministic paging
|
|
q.Set("sort", "login")
|
|
|
|
path := fmt.Sprintf("/apis/iam.grafana.app/v0alpha1/namespaces/default/searchUsers?%s", q.Encode())
|
|
|
|
res := &iamv0.GetSearchUsersResponse{}
|
|
rsp := apis.DoRequest(helper, apis.RequestParams{
|
|
User: helper.Org1.Admin,
|
|
Method: "GET",
|
|
Path: path,
|
|
}, res)
|
|
|
|
require.Equal(t, 200, rsp.Response.StatusCode)
|
|
return res
|
|
}
|
|
|
|
func searchUsersWithOffset(t *testing.T, helper *apis.K8sTestHelper, query string, offset, limit int) *iamv0.GetSearchUsersResponse {
|
|
q := url.Values{}
|
|
q.Set("query", query)
|
|
q.Set("offset", fmt.Sprintf("%d", offset))
|
|
q.Set("limit", fmt.Sprintf("%d", limit))
|
|
// Sort by login to ensure deterministic paging
|
|
q.Set("sort", "login")
|
|
|
|
path := fmt.Sprintf("/apis/iam.grafana.app/v0alpha1/namespaces/default/searchUsers?%s", q.Encode())
|
|
|
|
res := &iamv0.GetSearchUsersResponse{}
|
|
rsp := apis.DoRequest(helper, apis.RequestParams{
|
|
User: helper.Org1.Admin,
|
|
Method: "GET",
|
|
Path: path,
|
|
}, res)
|
|
|
|
require.Equal(t, 200, rsp.Response.StatusCode)
|
|
return res
|
|
}
|
|
|
|
type LegacyUserSearchHit struct {
|
|
UserId int64 `json:"userId"`
|
|
Name string `json:"name"`
|
|
Login string `json:"login"`
|
|
Email string `json:"email"`
|
|
}
|
|
|
|
func searchUsersLegacy(t *testing.T, helper *apis.K8sTestHelper, query string, sort string) []LegacyUserSearchHit {
|
|
q := url.Values{}
|
|
q.Set("query", query)
|
|
|
|
if sort != "" {
|
|
q.Set("sort", sort)
|
|
}
|
|
q.Set("perpage", "100")
|
|
q.Set("page", "1")
|
|
|
|
path := fmt.Sprintf("/api/org/users/search?%s", q.Encode())
|
|
|
|
var res struct {
|
|
OrgUsers []LegacyUserSearchHit `json:"orgUsers"`
|
|
}
|
|
|
|
rsp := apis.DoRequest(helper, apis.RequestParams{
|
|
User: helper.Org1.Admin,
|
|
Method: "GET",
|
|
Path: path,
|
|
}, &res)
|
|
|
|
require.Equal(t, 200, rsp.Response.StatusCode)
|
|
return res.OrgUsers
|
|
}
|
|
|
|
// verifyOrder checks that the extracted values from hits are in the expected order.
|
|
// It filters hits to only include those with expected values, because search returns more results than just the test users.
|
|
// Like other users in the system that have been created by the test framework.
|
|
func verifyOrder(t *testing.T, hits []iamv0.GetSearchUsersUserHit, expectedValues []string, extractor func(iamv0.GetSearchUsersUserHit) string) {
|
|
// Filter hits to only include expected values
|
|
var actualValues []string
|
|
expectedSet := make(map[string]bool)
|
|
for _, v := range expectedValues {
|
|
expectedSet[v] = true
|
|
}
|
|
|
|
for _, h := range hits {
|
|
val := extractor(h)
|
|
if expectedSet[val] {
|
|
actualValues = append(actualValues, val)
|
|
}
|
|
}
|
|
|
|
require.Equal(t, expectedValues, actualValues)
|
|
}
|
|
|
|
func updateLastSeenAt(t *testing.T, helper *apis.K8sTestHelper, login string, lastSeen time.Time, mode rest.DualWriterMode) {
|
|
if mode < rest.Mode4 {
|
|
err := helper.GetEnv().SQLStore.WithDbSession(context.Background(), func(sess *db.Session) error {
|
|
_, err := sess.Table("user").Where("login = ?", login).Update(map[string]interface{}{
|
|
"last_seen_at": lastSeen,
|
|
})
|
|
return err
|
|
})
|
|
require.NoError(t, err)
|
|
}
|
|
|
|
// Use the new APIs to update the user resource status in Mode4+
|
|
if mode >= rest.Mode4 {
|
|
ctx := context.Background()
|
|
userClient := helper.GetResourceClient(apis.ResourceClientArgs{
|
|
User: helper.Org1.Admin,
|
|
Namespace: helper.Namespacer(helper.Org1.Admin.Identity.GetOrgID()),
|
|
GVR: gvrUsers,
|
|
})
|
|
|
|
u, err := userClient.Resource.Get(ctx, login, metav1.GetOptions{})
|
|
require.NoError(t, err)
|
|
|
|
err = unstructured.SetNestedField(u.Object, lastSeen.Unix(), "status", "lastSeenAt")
|
|
require.NoError(t, err)
|
|
|
|
_, err = userClient.Resource.Update(ctx, u, metav1.UpdateOptions{})
|
|
require.NoError(t, err)
|
|
}
|
|
}
|