mirror of
https://github.com/zitadel/zitadel.git
synced 2025-08-12 03:37:34 +00:00
feat: list users scim v2 endpoint (#9187)
# Which Problems Are Solved - Adds support for the list users SCIM v2 endpoint # How the Problems Are Solved - Adds support for the list users SCIM v2 endpoints under `GET /scim/v2/{orgID}/Users` and `POST /scim/v2/{orgID}/Users/.search` # Additional Changes - adds a new function `SearchUserMetadataForUsers` to the query layer to query a metadata keyset for given user ids - adds a new function `NewUserMetadataExistsQuery` to the query layer to query a given metadata key value pair exists - adds a new function `CountUsers` to the query layer to count users without reading any rows - handle `ErrorAlreadyExists` as scim errors `uniqueness` - adds `NumberLessOrEqual` and `NumberGreaterOrEqual` query comparison methods - adds `BytesQuery` with `BytesEquals` and `BytesNotEquals` query comparison methods # Additional Context Part of #8140 Supported fields for scim filters: * `meta.created` * `meta.lastModified` * `id` * `username` * `name.familyName` * `name.givenName` * `emails` and `emails.value` * `active` only eq and ne * `externalId` only eq and ne
This commit is contained in:
@@ -21,6 +21,7 @@ import (
|
||||
"github.com/zitadel/zitadel/internal/api/scim/schemas"
|
||||
"github.com/zitadel/zitadel/internal/integration"
|
||||
"github.com/zitadel/zitadel/internal/integration/scim"
|
||||
"github.com/zitadel/zitadel/internal/test"
|
||||
"github.com/zitadel/zitadel/pkg/grpc/management"
|
||||
"github.com/zitadel/zitadel/pkg/grpc/user/v2"
|
||||
)
|
||||
@@ -55,6 +56,104 @@ var (
|
||||
|
||||
//go:embed testdata/users_create_test_invalid_timezone.json
|
||||
invalidTimeZoneUserJson []byte
|
||||
|
||||
fullUser = &resources.ScimUser{
|
||||
ExternalID: "701984",
|
||||
UserName: "bjensen@example.com",
|
||||
Name: &resources.ScimUserName{
|
||||
Formatted: "Babs Jensen", // DisplayName takes precedence in Zitadel
|
||||
FamilyName: "Jensen",
|
||||
GivenName: "Barbara",
|
||||
MiddleName: "Jane",
|
||||
HonorificPrefix: "Ms.",
|
||||
HonorificSuffix: "III",
|
||||
},
|
||||
DisplayName: "Babs Jensen",
|
||||
NickName: "Babs",
|
||||
ProfileUrl: test.Must(schemas.ParseHTTPURL("http://login.example.com/bjensen")),
|
||||
Emails: []*resources.ScimEmail{
|
||||
{
|
||||
Value: "bjensen@example.com",
|
||||
Primary: true,
|
||||
},
|
||||
},
|
||||
Addresses: []*resources.ScimAddress{
|
||||
{
|
||||
Type: "work",
|
||||
StreetAddress: "100 Universal City Plaza",
|
||||
Locality: "Hollywood",
|
||||
Region: "CA",
|
||||
PostalCode: "91608",
|
||||
Country: "USA",
|
||||
Formatted: "100 Universal City Plaza\nHollywood, CA 91608 USA",
|
||||
Primary: true,
|
||||
},
|
||||
{
|
||||
Type: "home",
|
||||
StreetAddress: "456 Hollywood Blvd",
|
||||
Locality: "Hollywood",
|
||||
Region: "CA",
|
||||
PostalCode: "91608",
|
||||
Country: "USA",
|
||||
Formatted: "456 Hollywood Blvd\nHollywood, CA 91608 USA",
|
||||
},
|
||||
},
|
||||
PhoneNumbers: []*resources.ScimPhoneNumber{
|
||||
{
|
||||
Value: "+415555555555",
|
||||
Primary: true,
|
||||
},
|
||||
},
|
||||
Ims: []*resources.ScimIms{
|
||||
{
|
||||
Value: "someaimhandle",
|
||||
Type: "aim",
|
||||
},
|
||||
{
|
||||
Value: "twitterhandle",
|
||||
Type: "X",
|
||||
},
|
||||
},
|
||||
Photos: []*resources.ScimPhoto{
|
||||
{
|
||||
Value: *test.Must(schemas.ParseHTTPURL("https://photos.example.com/profilephoto/72930000000Ccne/F")),
|
||||
Type: "photo",
|
||||
},
|
||||
},
|
||||
Roles: []*resources.ScimRole{
|
||||
{
|
||||
Value: "my-role-1",
|
||||
Display: "Rolle 1",
|
||||
Type: "main-role",
|
||||
Primary: true,
|
||||
},
|
||||
{
|
||||
Value: "my-role-2",
|
||||
Display: "Rolle 2",
|
||||
Type: "secondary-role",
|
||||
Primary: false,
|
||||
},
|
||||
},
|
||||
Entitlements: []*resources.ScimEntitlement{
|
||||
{
|
||||
Value: "my-entitlement-1",
|
||||
Display: "Entitlement 1",
|
||||
Type: "main-entitlement",
|
||||
Primary: true,
|
||||
},
|
||||
{
|
||||
Value: "my-entitlement-2",
|
||||
Display: "Entitlement 2",
|
||||
Type: "secondary-entitlement",
|
||||
Primary: false,
|
||||
},
|
||||
},
|
||||
Title: "Tour Guide",
|
||||
PreferredLanguage: language.MustParse("en-US"),
|
||||
Locale: "en-US",
|
||||
Timezone: "America/Los_Angeles",
|
||||
Active: gu.Ptr(true),
|
||||
}
|
||||
)
|
||||
|
||||
func TestCreateUser(t *testing.T) {
|
||||
@@ -95,103 +194,7 @@ func TestCreateUser(t *testing.T) {
|
||||
{
|
||||
name: "full user",
|
||||
body: fullUserJson,
|
||||
want: &resources.ScimUser{
|
||||
ExternalID: "701984",
|
||||
UserName: "bjensen@example.com",
|
||||
Name: &resources.ScimUserName{
|
||||
Formatted: "Babs Jensen", // DisplayName takes precedence in Zitadel
|
||||
FamilyName: "Jensen",
|
||||
GivenName: "Barbara",
|
||||
MiddleName: "Jane",
|
||||
HonorificPrefix: "Ms.",
|
||||
HonorificSuffix: "III",
|
||||
},
|
||||
DisplayName: "Babs Jensen",
|
||||
NickName: "Babs",
|
||||
ProfileUrl: integration.Must(schemas.ParseHTTPURL("http://login.example.com/bjensen")),
|
||||
Emails: []*resources.ScimEmail{
|
||||
{
|
||||
Value: "bjensen@example.com",
|
||||
Primary: true,
|
||||
},
|
||||
},
|
||||
Addresses: []*resources.ScimAddress{
|
||||
{
|
||||
Type: "work",
|
||||
StreetAddress: "100 Universal City Plaza",
|
||||
Locality: "Hollywood",
|
||||
Region: "CA",
|
||||
PostalCode: "91608",
|
||||
Country: "USA",
|
||||
Formatted: "100 Universal City Plaza\nHollywood, CA 91608 USA",
|
||||
Primary: true,
|
||||
},
|
||||
{
|
||||
Type: "home",
|
||||
StreetAddress: "456 Hollywood Blvd",
|
||||
Locality: "Hollywood",
|
||||
Region: "CA",
|
||||
PostalCode: "91608",
|
||||
Country: "USA",
|
||||
Formatted: "456 Hollywood Blvd\nHollywood, CA 91608 USA",
|
||||
},
|
||||
},
|
||||
PhoneNumbers: []*resources.ScimPhoneNumber{
|
||||
{
|
||||
Value: "+415555555555",
|
||||
Primary: true,
|
||||
},
|
||||
},
|
||||
Ims: []*resources.ScimIms{
|
||||
{
|
||||
Value: "someaimhandle",
|
||||
Type: "aim",
|
||||
},
|
||||
{
|
||||
Value: "twitterhandle",
|
||||
Type: "X",
|
||||
},
|
||||
},
|
||||
Photos: []*resources.ScimPhoto{
|
||||
{
|
||||
Value: *integration.Must(schemas.ParseHTTPURL("https://photos.example.com/profilephoto/72930000000Ccne/F")),
|
||||
Type: "photo",
|
||||
},
|
||||
},
|
||||
Roles: []*resources.ScimRole{
|
||||
{
|
||||
Value: "my-role-1",
|
||||
Display: "Rolle 1",
|
||||
Type: "main-role",
|
||||
Primary: true,
|
||||
},
|
||||
{
|
||||
Value: "my-role-2",
|
||||
Display: "Rolle 2",
|
||||
Type: "secondary-role",
|
||||
Primary: false,
|
||||
},
|
||||
},
|
||||
Entitlements: []*resources.ScimEntitlement{
|
||||
{
|
||||
Value: "my-entitlement-1",
|
||||
Display: "Entitlement 1",
|
||||
Type: "main-entitlement",
|
||||
Primary: true,
|
||||
},
|
||||
{
|
||||
Value: "my-entitlement-2",
|
||||
Display: "Entitlement 2",
|
||||
Type: "secondary-entitlement",
|
||||
Primary: false,
|
||||
},
|
||||
},
|
||||
Title: "Tour Guide",
|
||||
PreferredLanguage: language.MustParse("en-US"),
|
||||
Locale: "en-US",
|
||||
Timezone: "America/Los_Angeles",
|
||||
Active: gu.Ptr(true),
|
||||
},
|
||||
want: fullUser,
|
||||
},
|
||||
{
|
||||
name: "missing userName",
|
||||
@@ -290,7 +293,7 @@ func TestCreateUser(t *testing.T) {
|
||||
assert.Nil(t, createdUser.Password)
|
||||
|
||||
if tt.want != nil {
|
||||
if !integration.PartiallyDeepEqual(tt.want, createdUser) {
|
||||
if !test.PartiallyDeepEqual(tt.want, createdUser) {
|
||||
t.Errorf("CreateUser() got = %v, want %v", createdUser, tt.want)
|
||||
}
|
||||
|
||||
@@ -299,7 +302,7 @@ func TestCreateUser(t *testing.T) {
|
||||
// ensure the user is really stored and not just returned to the caller
|
||||
fetchedUser, err := Instance.Client.SCIM.Users.Get(CTX, Instance.DefaultOrg.Id, createdUser.ID)
|
||||
require.NoError(ttt, err)
|
||||
if !integration.PartiallyDeepEqual(tt.want, fetchedUser) {
|
||||
if !test.PartiallyDeepEqual(tt.want, fetchedUser) {
|
||||
ttt.Errorf("GetUser() got = %v, want %v", fetchedUser, tt.want)
|
||||
}
|
||||
}, retryDuration, tick)
|
||||
@@ -315,6 +318,7 @@ func TestCreateUser_duplicate(t *testing.T) {
|
||||
_, err = Instance.Client.SCIM.Users.Create(CTX, Instance.DefaultOrg.Id, minimalUserJson)
|
||||
scimErr := scim.RequireScimError(t, http.StatusConflict, err)
|
||||
assert.Equal(t, "User already exists", scimErr.Error.Detail)
|
||||
assert.Equal(t, "uniqueness", scimErr.Error.ScimType)
|
||||
|
||||
_, err = Instance.Client.UserV2.DeleteUser(CTX, &user.DeleteUserRequest{UserId: createdUser.ID})
|
||||
require.NoError(t, err)
|
||||
@@ -341,19 +345,19 @@ func TestCreateUser_metadata(t *testing.T) {
|
||||
mdMap[md.Result[i].Key] = string(md.Result[i].Value)
|
||||
}
|
||||
|
||||
integration.AssertMapContains(tt, mdMap, "urn:zitadel:scim:name.honorificPrefix", "Ms.")
|
||||
integration.AssertMapContains(tt, mdMap, "urn:zitadel:scim:timezone", "America/Los_Angeles")
|
||||
integration.AssertMapContains(tt, mdMap, "urn:zitadel:scim:photos", `[{"value":"https://photos.example.com/profilephoto/72930000000Ccne/F","type":"photo"},{"value":"https://photos.example.com/profilephoto/72930000000Ccne/T","type":"thumbnail"}]`)
|
||||
integration.AssertMapContains(tt, mdMap, "urn:zitadel:scim:addresses", `[{"type":"work","streetAddress":"100 Universal City Plaza","locality":"Hollywood","region":"CA","postalCode":"91608","country":"USA","formatted":"100 Universal City Plaza\nHollywood, CA 91608 USA","primary":true},{"type":"home","streetAddress":"456 Hollywood Blvd","locality":"Hollywood","region":"CA","postalCode":"91608","country":"USA","formatted":"456 Hollywood Blvd\nHollywood, CA 91608 USA"}]`)
|
||||
integration.AssertMapContains(tt, mdMap, "urn:zitadel:scim:entitlements", `[{"value":"my-entitlement-1","display":"Entitlement 1","type":"main-entitlement","primary":true},{"value":"my-entitlement-2","display":"Entitlement 2","type":"secondary-entitlement"}]`)
|
||||
integration.AssertMapContains(tt, mdMap, "urn:zitadel:scim:externalId", "701984")
|
||||
integration.AssertMapContains(tt, mdMap, "urn:zitadel:scim:name.middleName", "Jane")
|
||||
integration.AssertMapContains(tt, mdMap, "urn:zitadel:scim:name.honorificSuffix", "III")
|
||||
integration.AssertMapContains(tt, mdMap, "urn:zitadel:scim:profileURL", "http://login.example.com/bjensen")
|
||||
integration.AssertMapContains(tt, mdMap, "urn:zitadel:scim:title", "Tour Guide")
|
||||
integration.AssertMapContains(tt, mdMap, "urn:zitadel:scim:locale", "en-US")
|
||||
integration.AssertMapContains(tt, mdMap, "urn:zitadel:scim:ims", `[{"value":"someaimhandle","type":"aim"},{"value":"twitterhandle","type":"X"}]`)
|
||||
integration.AssertMapContains(tt, mdMap, "urn:zitadel:scim:roles", `[{"value":"my-role-1","display":"Rolle 1","type":"main-role","primary":true},{"value":"my-role-2","display":"Rolle 2","type":"secondary-role"}]`)
|
||||
test.AssertMapContains(tt, mdMap, "urn:zitadel:scim:name.honorificPrefix", "Ms.")
|
||||
test.AssertMapContains(tt, mdMap, "urn:zitadel:scim:timezone", "America/Los_Angeles")
|
||||
test.AssertMapContains(tt, mdMap, "urn:zitadel:scim:photos", `[{"value":"https://photos.example.com/profilephoto/72930000000Ccne/F","type":"photo"},{"value":"https://photos.example.com/profilephoto/72930000000Ccne/T","type":"thumbnail"}]`)
|
||||
test.AssertMapContains(tt, mdMap, "urn:zitadel:scim:addresses", `[{"type":"work","streetAddress":"100 Universal City Plaza","locality":"Hollywood","region":"CA","postalCode":"91608","country":"USA","formatted":"100 Universal City Plaza\nHollywood, CA 91608 USA","primary":true},{"type":"home","streetAddress":"456 Hollywood Blvd","locality":"Hollywood","region":"CA","postalCode":"91608","country":"USA","formatted":"456 Hollywood Blvd\nHollywood, CA 91608 USA"}]`)
|
||||
test.AssertMapContains(tt, mdMap, "urn:zitadel:scim:entitlements", `[{"value":"my-entitlement-1","display":"Entitlement 1","type":"main-entitlement","primary":true},{"value":"my-entitlement-2","display":"Entitlement 2","type":"secondary-entitlement"}]`)
|
||||
test.AssertMapContains(tt, mdMap, "urn:zitadel:scim:externalId", "701984")
|
||||
test.AssertMapContains(tt, mdMap, "urn:zitadel:scim:name.middleName", "Jane")
|
||||
test.AssertMapContains(tt, mdMap, "urn:zitadel:scim:name.honorificSuffix", "III")
|
||||
test.AssertMapContains(tt, mdMap, "urn:zitadel:scim:profileURL", "http://login.example.com/bjensen")
|
||||
test.AssertMapContains(tt, mdMap, "urn:zitadel:scim:title", "Tour Guide")
|
||||
test.AssertMapContains(tt, mdMap, "urn:zitadel:scim:locale", "en-US")
|
||||
test.AssertMapContains(tt, mdMap, "urn:zitadel:scim:ims", `[{"value":"someaimhandle","type":"aim"},{"value":"twitterhandle","type":"X"}]`)
|
||||
test.AssertMapContains(tt, mdMap, "urn:zitadel:scim:roles", `[{"value":"my-role-1","display":"Rolle 1","type":"main-role","primary":true},{"value":"my-role-2","display":"Rolle 2","type":"secondary-role"}]`)
|
||||
}, retryDuration, tick)
|
||||
}
|
||||
|
||||
|
@@ -19,6 +19,7 @@ import (
|
||||
"github.com/zitadel/zitadel/internal/api/scim/schemas"
|
||||
"github.com/zitadel/zitadel/internal/integration"
|
||||
"github.com/zitadel/zitadel/internal/integration/scim"
|
||||
"github.com/zitadel/zitadel/internal/test"
|
||||
"github.com/zitadel/zitadel/pkg/grpc/management"
|
||||
"github.com/zitadel/zitadel/pkg/grpc/user/v2"
|
||||
)
|
||||
@@ -93,7 +94,7 @@ func TestGetUser(t *testing.T) {
|
||||
},
|
||||
DisplayName: "Babs Jensen",
|
||||
NickName: "Babs",
|
||||
ProfileUrl: integration.Must(schemas.ParseHTTPURL("http://login.example.com/bjensen")),
|
||||
ProfileUrl: test.Must(schemas.ParseHTTPURL("http://login.example.com/bjensen")),
|
||||
Title: "Tour Guide",
|
||||
PreferredLanguage: language.Make("en-US"),
|
||||
Locale: "en-US",
|
||||
@@ -144,11 +145,11 @@ func TestGetUser(t *testing.T) {
|
||||
},
|
||||
Photos: []*resources.ScimPhoto{
|
||||
{
|
||||
Value: *integration.Must(schemas.ParseHTTPURL("https://photos.example.com/profilephoto/72930000000Ccne/F")),
|
||||
Value: *test.Must(schemas.ParseHTTPURL("https://photos.example.com/profilephoto/72930000000Ccne/F")),
|
||||
Type: "photo",
|
||||
},
|
||||
{
|
||||
Value: *integration.Must(schemas.ParseHTTPURL("https://photos.example.com/profilephoto/72930000000Ccne/T")),
|
||||
Value: *test.Must(schemas.ParseHTTPURL("https://photos.example.com/profilephoto/72930000000Ccne/T")),
|
||||
Type: "thumbnail",
|
||||
},
|
||||
},
|
||||
@@ -256,7 +257,7 @@ func TestGetUser(t *testing.T) {
|
||||
assert.Equal(ttt, schemas.ScimResourceTypeSingular("User"), fetchedUser.Resource.Meta.ResourceType)
|
||||
assert.Equal(ttt, "http://"+Instance.Host()+path.Join(schemas.HandlerPrefix, Instance.DefaultOrg.Id, "Users", fetchedUser.ID), fetchedUser.Resource.Meta.Location)
|
||||
assert.Nil(ttt, fetchedUser.Password)
|
||||
if !integration.PartiallyDeepEqual(tt.want, fetchedUser) {
|
||||
if !test.PartiallyDeepEqual(tt.want, fetchedUser) {
|
||||
ttt.Errorf("GetUser() got = %#v, want %#v", fetchedUser, tt.want)
|
||||
}
|
||||
}, retryDuration, tick)
|
||||
|
492
internal/api/scim/integration_test/users_list_test.go
Normal file
492
internal/api/scim/integration_test/users_list_test.go
Normal file
@@ -0,0 +1,492 @@
|
||||
//go:build integration
|
||||
|
||||
package integration_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/brianvoe/gofakeit/v6"
|
||||
"github.com/muhlemmer/gu"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/zitadel/zitadel/internal/api/scim/resources"
|
||||
"github.com/zitadel/zitadel/internal/api/scim/schemas"
|
||||
"github.com/zitadel/zitadel/internal/integration"
|
||||
"github.com/zitadel/zitadel/internal/integration/scim"
|
||||
"github.com/zitadel/zitadel/internal/test"
|
||||
"github.com/zitadel/zitadel/pkg/grpc/management"
|
||||
"github.com/zitadel/zitadel/pkg/grpc/object/v2"
|
||||
user_v2 "github.com/zitadel/zitadel/pkg/grpc/user/v2"
|
||||
)
|
||||
|
||||
var totalCountOfHumanUsers = 13
|
||||
|
||||
func TestListUser(t *testing.T) {
|
||||
createdUserIDs := createUsers(t, CTX, Instance.DefaultOrg.Id)
|
||||
defer func() {
|
||||
// only the full user needs to be deleted, all others have random identification data
|
||||
// fullUser is always the first one.
|
||||
_, err := Instance.Client.UserV2.DeleteUser(CTX, &user_v2.DeleteUserRequest{
|
||||
UserId: createdUserIDs[0],
|
||||
})
|
||||
require.NoError(t, err)
|
||||
}()
|
||||
|
||||
// secondary organization with same set of users,
|
||||
// these should never be modified.
|
||||
// This allows testing list requests without filters.
|
||||
iamOwnerCtx := Instance.WithAuthorization(CTX, integration.UserTypeIAMOwner)
|
||||
secondaryOrg := Instance.CreateOrganization(iamOwnerCtx, gofakeit.Name(), gofakeit.Email())
|
||||
secondaryOrgCreatedUserIDs := createUsers(t, iamOwnerCtx, secondaryOrg.OrganizationId)
|
||||
|
||||
testsInitializedUtc := time.Now().UTC()
|
||||
|
||||
// Wait one second to ensure a change in the least significant value of the timestamp.
|
||||
time.Sleep(time.Second)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
ctx context.Context
|
||||
orgID string
|
||||
req *scim.ListRequest
|
||||
prepare func(require.TestingT) *scim.ListRequest
|
||||
wantErr bool
|
||||
errorStatus int
|
||||
errorType string
|
||||
assert func(assert.TestingT, *scim.ListResponse[*resources.ScimUser])
|
||||
cleanup func(require.TestingT)
|
||||
}{
|
||||
{
|
||||
name: "not authenticated",
|
||||
ctx: context.Background(),
|
||||
req: new(scim.ListRequest),
|
||||
wantErr: true,
|
||||
errorStatus: http.StatusUnauthorized,
|
||||
},
|
||||
{
|
||||
name: "no permissions",
|
||||
ctx: Instance.WithAuthorization(CTX, integration.UserTypeNoPermission),
|
||||
req: new(scim.ListRequest),
|
||||
wantErr: true,
|
||||
errorStatus: http.StatusNotFound,
|
||||
},
|
||||
{
|
||||
name: "unknown sort order",
|
||||
req: &scim.ListRequest{
|
||||
SortBy: gu.Ptr("id"),
|
||||
SortOrder: gu.Ptr(scim.ListRequestSortOrder("fooBar")),
|
||||
},
|
||||
wantErr: true,
|
||||
errorType: "invalidValue",
|
||||
},
|
||||
{
|
||||
name: "unknown sort field",
|
||||
req: &scim.ListRequest{
|
||||
SortBy: gu.Ptr("fooBar"),
|
||||
},
|
||||
wantErr: true,
|
||||
errorType: "invalidValue",
|
||||
},
|
||||
{
|
||||
name: "unknown filter field",
|
||||
req: &scim.ListRequest{
|
||||
Filter: gu.Ptr(`fooBar eq "10"`),
|
||||
},
|
||||
wantErr: true,
|
||||
errorType: "invalidFilter",
|
||||
},
|
||||
{
|
||||
name: "invalid filter",
|
||||
req: &scim.ListRequest{
|
||||
Filter: gu.Ptr(`fooBarBaz`),
|
||||
},
|
||||
wantErr: true,
|
||||
errorType: "invalidFilter",
|
||||
},
|
||||
{
|
||||
name: "list users without filter",
|
||||
// use other org, modifications of users happens only on primary org
|
||||
orgID: secondaryOrg.OrganizationId,
|
||||
ctx: iamOwnerCtx,
|
||||
req: new(scim.ListRequest),
|
||||
assert: func(t assert.TestingT, resp *scim.ListResponse[*resources.ScimUser]) {
|
||||
assert.Equal(t, 100, resp.ItemsPerPage)
|
||||
assert.Equal(t, totalCountOfHumanUsers, resp.TotalResults)
|
||||
assert.Equal(t, 1, resp.StartIndex)
|
||||
assert.Len(t, resp.Resources, totalCountOfHumanUsers)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "list paged sorted users without filter",
|
||||
// use other org, modifications of users happens only on primary org
|
||||
orgID: secondaryOrg.OrganizationId,
|
||||
ctx: iamOwnerCtx,
|
||||
req: &scim.ListRequest{
|
||||
Count: gu.Ptr(2),
|
||||
StartIndex: gu.Ptr(5),
|
||||
SortOrder: gu.Ptr(scim.ListRequestSortOrderAsc),
|
||||
SortBy: gu.Ptr("username"),
|
||||
},
|
||||
assert: func(t assert.TestingT, resp *scim.ListResponse[*resources.ScimUser]) {
|
||||
assert.Equal(t, 2, resp.ItemsPerPage)
|
||||
assert.Equal(t, totalCountOfHumanUsers, resp.TotalResults)
|
||||
assert.Equal(t, 5, resp.StartIndex)
|
||||
assert.Len(t, resp.Resources, 2)
|
||||
assert.True(t, strings.HasPrefix(resp.Resources[0].UserName, "scim-username-1: "))
|
||||
assert.True(t, strings.HasPrefix(resp.Resources[1].UserName, "scim-username-2: "))
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "list users with simple filter",
|
||||
req: &scim.ListRequest{
|
||||
Filter: gu.Ptr(`username sw "scim-username-1"`),
|
||||
},
|
||||
assert: func(t assert.TestingT, resp *scim.ListResponse[*resources.ScimUser]) {
|
||||
assert.Equal(t, 100, resp.ItemsPerPage)
|
||||
assert.Equal(t, 2, resp.TotalResults)
|
||||
assert.Equal(t, 1, resp.StartIndex)
|
||||
assert.Len(t, resp.Resources, 2)
|
||||
for _, resource := range resp.Resources {
|
||||
assert.True(t, strings.HasPrefix(resource.UserName, "scim-username-1"))
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "list paged sorted users with filter",
|
||||
req: &scim.ListRequest{
|
||||
Count: gu.Ptr(5),
|
||||
StartIndex: gu.Ptr(1),
|
||||
SortOrder: gu.Ptr(scim.ListRequestSortOrderAsc),
|
||||
SortBy: gu.Ptr("username"),
|
||||
Filter: gu.Ptr(`emails sw "scim-email-1" and emails ew "@example.com"`),
|
||||
},
|
||||
assert: func(t assert.TestingT, resp *scim.ListResponse[*resources.ScimUser]) {
|
||||
assert.Equal(t, 5, resp.ItemsPerPage)
|
||||
assert.Equal(t, 2, resp.TotalResults)
|
||||
assert.Equal(t, 1, resp.StartIndex)
|
||||
assert.Len(t, resp.Resources, 2)
|
||||
for _, resource := range resp.Resources {
|
||||
assert.True(t, strings.HasPrefix(resource.UserName, "scim-username-1"))
|
||||
assert.Len(t, resource.Emails, 1)
|
||||
assert.True(t, strings.HasPrefix(resource.Emails[0].Value, "scim-email-1"))
|
||||
assert.True(t, strings.HasSuffix(resource.Emails[0].Value, "@example.com"))
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "list paged sorted users with filter as post",
|
||||
req: &scim.ListRequest{
|
||||
Count: gu.Ptr(5),
|
||||
StartIndex: gu.Ptr(1),
|
||||
SortOrder: gu.Ptr(scim.ListRequestSortOrderAsc),
|
||||
SortBy: gu.Ptr("username"),
|
||||
Filter: gu.Ptr(`emails sw "scim-email-1" and emails ew "@example.com"`),
|
||||
SendAsPost: true,
|
||||
},
|
||||
assert: func(t assert.TestingT, resp *scim.ListResponse[*resources.ScimUser]) {
|
||||
assert.Equal(t, 5, resp.ItemsPerPage)
|
||||
assert.Equal(t, 2, resp.TotalResults)
|
||||
assert.Equal(t, 1, resp.StartIndex)
|
||||
assert.Len(t, resp.Resources, 2)
|
||||
for _, resource := range resp.Resources {
|
||||
assert.True(t, strings.HasPrefix(resource.UserName, "scim-username-1"))
|
||||
assert.Len(t, resource.Emails, 1)
|
||||
assert.True(t, strings.HasPrefix(resource.Emails[0].Value, "scim-email-1"))
|
||||
assert.True(t, strings.HasSuffix(resource.Emails[0].Value, "@example.com"))
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "count users without filter",
|
||||
// use other org, modifications of users happens only on primary org
|
||||
orgID: secondaryOrg.OrganizationId,
|
||||
ctx: iamOwnerCtx,
|
||||
prepare: func(t require.TestingT) *scim.ListRequest {
|
||||
return &scim.ListRequest{
|
||||
Count: gu.Ptr(0),
|
||||
}
|
||||
},
|
||||
assert: func(t assert.TestingT, resp *scim.ListResponse[*resources.ScimUser]) {
|
||||
assert.Equal(t, 0, resp.ItemsPerPage)
|
||||
assert.Equal(t, totalCountOfHumanUsers, resp.TotalResults)
|
||||
assert.Equal(t, 1, resp.StartIndex)
|
||||
assert.Len(t, resp.Resources, 0)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "list users with active filter",
|
||||
req: &scim.ListRequest{
|
||||
Filter: gu.Ptr(`active eq false`),
|
||||
},
|
||||
assert: func(t assert.TestingT, resp *scim.ListResponse[*resources.ScimUser]) {
|
||||
assert.Equal(t, 100, resp.ItemsPerPage)
|
||||
assert.Equal(t, 1, resp.TotalResults)
|
||||
assert.Equal(t, 1, resp.StartIndex)
|
||||
assert.Len(t, resp.Resources, 1)
|
||||
assert.True(t, strings.HasPrefix(resp.Resources[0].UserName, "scim-username-0"))
|
||||
assert.False(t, *resp.Resources[0].Active)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "list users with externalid filter",
|
||||
req: &scim.ListRequest{
|
||||
Filter: gu.Ptr(`externalid eq "701984"`),
|
||||
},
|
||||
assert: func(t assert.TestingT, resp *scim.ListResponse[*resources.ScimUser]) {
|
||||
assert.Equal(t, 100, resp.ItemsPerPage)
|
||||
assert.Equal(t, 1, resp.TotalResults)
|
||||
assert.Equal(t, 1, resp.StartIndex)
|
||||
assert.Len(t, resp.Resources, 1)
|
||||
assert.Equal(t, resp.Resources[0].ExternalID, "701984")
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "list users with externalid filter invalid operator",
|
||||
req: &scim.ListRequest{
|
||||
Filter: gu.Ptr(`externalid pr`),
|
||||
},
|
||||
wantErr: true,
|
||||
errorType: "invalidFilter",
|
||||
},
|
||||
{
|
||||
name: "list users with externalid complex filter",
|
||||
req: &scim.ListRequest{
|
||||
Filter: gu.Ptr(`externalid eq "701984" and username eq "bjensen@example.com"`),
|
||||
},
|
||||
assert: func(t assert.TestingT, resp *scim.ListResponse[*resources.ScimUser]) {
|
||||
assert.Equal(t, 100, resp.ItemsPerPage)
|
||||
assert.Equal(t, 1, resp.TotalResults)
|
||||
assert.Equal(t, 1, resp.StartIndex)
|
||||
assert.Len(t, resp.Resources, 1)
|
||||
assert.Equal(t, resp.Resources[0].UserName, "bjensen@example.com")
|
||||
assert.Equal(t, resp.Resources[0].ExternalID, "701984")
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "count users with filter",
|
||||
req: &scim.ListRequest{
|
||||
Count: gu.Ptr(0),
|
||||
Filter: gu.Ptr(`emails sw "scim-email-1" and emails ew "@example.com"`),
|
||||
},
|
||||
assert: func(t assert.TestingT, resp *scim.ListResponse[*resources.ScimUser]) {
|
||||
assert.Equal(t, 0, resp.ItemsPerPage)
|
||||
assert.Equal(t, 2, resp.TotalResults)
|
||||
assert.Equal(t, 1, resp.StartIndex)
|
||||
assert.Len(t, resp.Resources, 0)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "list users with modification date filter",
|
||||
prepare: func(t require.TestingT) *scim.ListRequest {
|
||||
userID := createdUserIDs[len(createdUserIDs)-1] // use the last entry, as we use the others for other assertions
|
||||
_, err := Instance.Client.UserV2.UpdateHumanUser(CTX, &user_v2.UpdateHumanUserRequest{
|
||||
UserId: userID,
|
||||
|
||||
Profile: &user_v2.SetHumanProfile{
|
||||
GivenName: "scim-user-given-name-modified-0: " + gofakeit.FirstName(),
|
||||
FamilyName: "scim-user-family-name-modified-0: " + gofakeit.LastName(),
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
return &scim.ListRequest{
|
||||
// filter by id too to exclude other random users
|
||||
Filter: gu.Ptr(fmt.Sprintf(`id eq "%s" and meta.LASTMODIFIED gt "%s"`, userID, testsInitializedUtc.Format(time.RFC3339))),
|
||||
}
|
||||
},
|
||||
assert: func(t assert.TestingT, resp *scim.ListResponse[*resources.ScimUser]) {
|
||||
assert.Len(t, resp.Resources, 1)
|
||||
assert.Equal(t, resp.Resources[0].ID, createdUserIDs[len(createdUserIDs)-1])
|
||||
assert.True(t, strings.HasPrefix(resp.Resources[0].Name.FamilyName, "scim-user-family-name-modified-0:"))
|
||||
assert.True(t, strings.HasPrefix(resp.Resources[0].Name.GivenName, "scim-user-given-name-modified-0:"))
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "list users with creation date filter",
|
||||
prepare: func(t require.TestingT) *scim.ListRequest {
|
||||
resp := createHumanUser(t, CTX, Instance.DefaultOrg.Id, 100)
|
||||
return &scim.ListRequest{
|
||||
Filter: gu.Ptr(fmt.Sprintf(`id eq "%s" and meta.created gt "%s"`, resp.UserId, testsInitializedUtc.Format(time.RFC3339))),
|
||||
}
|
||||
},
|
||||
assert: func(t assert.TestingT, resp *scim.ListResponse[*resources.ScimUser]) {
|
||||
assert.Len(t, resp.Resources, 1)
|
||||
assert.True(t, strings.HasPrefix(resp.Resources[0].UserName, "scim-username-100:"))
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "validate returned objects",
|
||||
req: &scim.ListRequest{
|
||||
Filter: gu.Ptr(fmt.Sprintf(`id eq "%s"`, createdUserIDs[0])),
|
||||
},
|
||||
assert: func(t assert.TestingT, resp *scim.ListResponse[*resources.ScimUser]) {
|
||||
assert.Len(t, resp.Resources, 1)
|
||||
if !test.PartiallyDeepEqual(fullUser, resp.Resources[0]) {
|
||||
t.Errorf("got = %#v, want %#v", resp.Resources[0], fullUser)
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "do not return user of other org",
|
||||
req: &scim.ListRequest{
|
||||
Filter: gu.Ptr(fmt.Sprintf(`id eq "%s"`, secondaryOrgCreatedUserIDs[0])),
|
||||
},
|
||||
assert: func(t assert.TestingT, resp *scim.ListResponse[*resources.ScimUser]) {
|
||||
assert.Len(t, resp.Resources, 0)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "do not count user of other org",
|
||||
prepare: func(t require.TestingT) *scim.ListRequest {
|
||||
iamOwnerCtx := Instance.WithAuthorization(CTX, integration.UserTypeIAMOwner)
|
||||
org := Instance.CreateOrganization(iamOwnerCtx, gofakeit.Name(), gofakeit.Email())
|
||||
resp := createHumanUser(t, iamOwnerCtx, org.OrganizationId, 102)
|
||||
|
||||
return &scim.ListRequest{
|
||||
Count: gu.Ptr(0),
|
||||
Filter: gu.Ptr(fmt.Sprintf(`id eq "%s"`, resp.UserId)),
|
||||
}
|
||||
},
|
||||
assert: func(t assert.TestingT, resp *scim.ListResponse[*resources.ScimUser]) {
|
||||
assert.Len(t, resp.Resources, 0)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "scoped externalID",
|
||||
prepare: func(t require.TestingT) *scim.ListRequest {
|
||||
resp := createHumanUser(t, CTX, Instance.DefaultOrg.Id, 102)
|
||||
|
||||
// set provisioning domain of service user
|
||||
_, err := Instance.Client.Mgmt.SetUserMetadata(CTX, &management.SetUserMetadataRequest{
|
||||
Id: Instance.Users.Get(integration.UserTypeOrgOwner).ID,
|
||||
Key: "urn:zitadel:scim:provisioning_domain",
|
||||
Value: []byte("fooBar"),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// set externalID for provisioning domain
|
||||
_, err = Instance.Client.Mgmt.SetUserMetadata(CTX, &management.SetUserMetadataRequest{
|
||||
Id: resp.UserId,
|
||||
Key: "urn:zitadel:scim:fooBar:externalId",
|
||||
Value: []byte("100-scopedExternalId"),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
return &scim.ListRequest{
|
||||
Filter: gu.Ptr(fmt.Sprintf(`id eq "%s"`, resp.UserId)),
|
||||
}
|
||||
},
|
||||
assert: func(t assert.TestingT, resp *scim.ListResponse[*resources.ScimUser]) {
|
||||
assert.Len(t, resp.Resources, 1)
|
||||
assert.Equal(t, resp.Resources[0].ExternalID, "100-scopedExternalId")
|
||||
},
|
||||
cleanup: func(t require.TestingT) {
|
||||
// delete provisioning domain of service user
|
||||
_, err := Instance.Client.Mgmt.RemoveUserMetadata(CTX, &management.RemoveUserMetadataRequest{
|
||||
Id: Instance.Users.Get(integration.UserTypeOrgOwner).ID,
|
||||
Key: "urn:zitadel:scim:provisioning_domain",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
},
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if tt.ctx == nil {
|
||||
tt.ctx = CTX
|
||||
}
|
||||
|
||||
if tt.prepare != nil {
|
||||
tt.req = tt.prepare(t)
|
||||
}
|
||||
|
||||
if tt.orgID == "" {
|
||||
tt.orgID = Instance.DefaultOrg.Id
|
||||
}
|
||||
|
||||
retryDuration, tick := integration.WaitForAndTickWithMaxDuration(tt.ctx, time.Minute)
|
||||
require.EventuallyWithT(t, func(ttt *assert.CollectT) {
|
||||
listResp, err := Instance.Client.SCIM.Users.List(tt.ctx, tt.orgID, tt.req)
|
||||
if tt.wantErr {
|
||||
statusCode := tt.errorStatus
|
||||
if statusCode == 0 {
|
||||
statusCode = http.StatusBadRequest
|
||||
}
|
||||
|
||||
scimErr := scim.RequireScimError(ttt, statusCode, err)
|
||||
if tt.errorType != "" {
|
||||
assert.Equal(t, tt.errorType, scimErr.Error.ScimType)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.EqualValues(ttt, []schemas.ScimSchemaType{"urn:ietf:params:scim:api:messages:2.0:ListResponse"}, listResp.Schemas)
|
||||
if tt.assert != nil {
|
||||
tt.assert(ttt, listResp)
|
||||
}
|
||||
}, retryDuration, tick)
|
||||
|
||||
if tt.cleanup != nil {
|
||||
tt.cleanup(t)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func createUsers(t *testing.T, ctx context.Context, orgID string) []string {
|
||||
count := totalCountOfHumanUsers - 1 // zitadel admin is always created by default
|
||||
createdUserIDs := make([]string, 0, count)
|
||||
|
||||
// create the full scim user if on primary org
|
||||
if orgID == Instance.DefaultOrg.Id {
|
||||
fullUserCreatedResp, err := Instance.Client.SCIM.Users.Create(ctx, orgID, fullUserJson)
|
||||
require.NoError(t, err)
|
||||
createdUserIDs = append(createdUserIDs, fullUserCreatedResp.ID)
|
||||
count--
|
||||
}
|
||||
|
||||
// set the first user inactive
|
||||
resp := createHumanUser(t, ctx, orgID, 0)
|
||||
_, err := Instance.Client.UserV2.DeactivateUser(ctx, &user_v2.DeactivateUserRequest{
|
||||
UserId: resp.UserId,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
createdUserIDs = append(createdUserIDs, resp.UserId)
|
||||
|
||||
for i := 1; i < count; i++ {
|
||||
resp = createHumanUser(t, ctx, orgID, i)
|
||||
createdUserIDs = append(createdUserIDs, resp.UserId)
|
||||
}
|
||||
|
||||
return createdUserIDs
|
||||
}
|
||||
|
||||
func createHumanUser(t require.TestingT, ctx context.Context, orgID string, i int) *user_v2.AddHumanUserResponse {
|
||||
// create remaining minimal users with faker data
|
||||
// no need to clean these up as identification attributes change each time
|
||||
resp, err := Instance.Client.UserV2.AddHumanUser(ctx, &user_v2.AddHumanUserRequest{
|
||||
Organization: &object.Organization{
|
||||
Org: &object.Organization_OrgId{
|
||||
OrgId: orgID,
|
||||
},
|
||||
},
|
||||
Username: gu.Ptr(fmt.Sprintf("scim-username-%d: %s", i, gofakeit.Username())),
|
||||
Profile: &user_v2.SetHumanProfile{
|
||||
GivenName: fmt.Sprintf("scim-givenname-%d: %s", i, gofakeit.FirstName()),
|
||||
FamilyName: fmt.Sprintf("scim-familyname-%d: %s", i, gofakeit.LastName()),
|
||||
PreferredLanguage: gu.Ptr("en-US"),
|
||||
Gender: gu.Ptr(user_v2.Gender_GENDER_MALE),
|
||||
},
|
||||
Email: &user_v2.SetHumanEmail{
|
||||
Email: fmt.Sprintf("scim-email-%d-%d@example.com", i, gofakeit.Number(0, 1_000_000)),
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
return resp
|
||||
}
|
@@ -19,6 +19,7 @@ import (
|
||||
"github.com/zitadel/zitadel/internal/api/scim/schemas"
|
||||
"github.com/zitadel/zitadel/internal/integration"
|
||||
"github.com/zitadel/zitadel/internal/integration/scim"
|
||||
"github.com/zitadel/zitadel/internal/test"
|
||||
"github.com/zitadel/zitadel/pkg/grpc/management"
|
||||
"github.com/zitadel/zitadel/pkg/grpc/user/v2"
|
||||
)
|
||||
@@ -78,7 +79,7 @@ func TestReplaceUser(t *testing.T) {
|
||||
},
|
||||
DisplayName: "Babs Jensen-updated",
|
||||
NickName: "Babs-updated",
|
||||
ProfileUrl: integration.Must(schemas.ParseHTTPURL("http://login.example.com/bjensen-updated")),
|
||||
ProfileUrl: test.Must(schemas.ParseHTTPURL("http://login.example.com/bjensen-updated")),
|
||||
Emails: []*resources.ScimEmail{
|
||||
{
|
||||
Value: "bjensen-replaced-full@example.com",
|
||||
@@ -124,11 +125,11 @@ func TestReplaceUser(t *testing.T) {
|
||||
},
|
||||
Photos: []*resources.ScimPhoto{
|
||||
{
|
||||
Value: *integration.Must(schemas.ParseHTTPURL("https://photos.example.com/profilephoto/72930000000Ccne/F-updated")),
|
||||
Value: *test.Must(schemas.ParseHTTPURL("https://photos.example.com/profilephoto/72930000000Ccne/F-updated")),
|
||||
Type: "photo-updated",
|
||||
},
|
||||
{
|
||||
Value: *integration.Must(schemas.ParseHTTPURL("https://photos.example.com/profilephoto/72930000000Ccne/T-updated")),
|
||||
Value: *test.Must(schemas.ParseHTTPURL("https://photos.example.com/profilephoto/72930000000Ccne/T-updated")),
|
||||
Type: "thumbnail-updated",
|
||||
},
|
||||
},
|
||||
@@ -247,7 +248,7 @@ func TestReplaceUser(t *testing.T) {
|
||||
assert.Equal(t, "http://"+Instance.Host()+path.Join(schemas.HandlerPrefix, Instance.DefaultOrg.Id, "Users", createdUser.ID), replacedUser.Resource.Meta.Location)
|
||||
assert.Nil(t, createdUser.Password)
|
||||
|
||||
if !integration.PartiallyDeepEqual(tt.want, replacedUser) {
|
||||
if !test.PartiallyDeepEqual(tt.want, replacedUser) {
|
||||
t.Errorf("ReplaceUser() got = %#v, want %#v", replacedUser, tt.want)
|
||||
}
|
||||
|
||||
@@ -256,7 +257,7 @@ func TestReplaceUser(t *testing.T) {
|
||||
// ensure the user is really stored and not just returned to the caller
|
||||
fetchedUser, err := Instance.Client.SCIM.Users.Get(CTX, Instance.DefaultOrg.Id, replacedUser.ID)
|
||||
require.NoError(ttt, err)
|
||||
if !integration.PartiallyDeepEqual(tt.want, fetchedUser) {
|
||||
if !test.PartiallyDeepEqual(tt.want, fetchedUser) {
|
||||
ttt.Errorf("GetUser() got = %#v, want %#v", fetchedUser, tt.want)
|
||||
}
|
||||
}, retryDuration, tick)
|
||||
@@ -316,8 +317,8 @@ func TestReplaceUser_scopedExternalID(t *testing.T) {
|
||||
}
|
||||
|
||||
// both external IDs should be present on the user
|
||||
integration.AssertMapContains(tt, mdMap, "urn:zitadel:scim:externalId", "701984")
|
||||
integration.AssertMapContains(tt, mdMap, "urn:zitadel:scim:fooBazz:externalId", "replaced-external-id")
|
||||
test.AssertMapContains(tt, mdMap, "urn:zitadel:scim:externalId", "701984")
|
||||
test.AssertMapContains(tt, mdMap, "urn:zitadel:scim:fooBazz:externalId", "replaced-external-id")
|
||||
}, retryDuration, tick)
|
||||
|
||||
_, err = Instance.Client.UserV2.DeleteUser(CTX, &user.DeleteUserRequest{UserId: createdUser.ID})
|
||||
|
Reference in New Issue
Block a user