Files
grafana/pkg/tests/apis/iam/user_search_integration_test.go
Misi e0dbb966fc 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>
2026-03-12 13:42:22 +01:00

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)
}
}