From d178605b14e134a7bd1b65fe8e7f19240eb3aea4 Mon Sep 17 00:00:00 2001 From: wangsijie Date: Mon, 14 Sep 2026 09:56:30 +0000 Subject: [PATCH 1/2] release(core): support user lookup by external identity --- .changeset/external-identity-user-lookup.md | 7 +++ .../src/routes/admin-user/search.openapi.json | 9 ++-- .../core/src/routes/admin-user/search.test.ts | 23 --------- packages/core/src/routes/admin-user/search.ts | 6 --- .../src/routes/swagger/utils/general.test.ts | 48 +++++++++---------- 5 files changed, 34 insertions(+), 59 deletions(-) create mode 100644 .changeset/external-identity-user-lookup.md diff --git a/.changeset/external-identity-user-lookup.md b/.changeset/external-identity-user-lookup.md new file mode 100644 index 000000000000..939ddf2e6cfe --- /dev/null +++ b/.changeset/external-identity-user-lookup.md @@ -0,0 +1,7 @@ +--- +"@logto/core": minor +--- + +support looking up users by external identity in the Management API + +`GET /api/users` now accepts `identityType`, `identityProvider`, and `identityId` query parameters for exact user lookup. Use `identityType=social` with a connector target (such as `dingtalk`), or `identityType=sso` with an enterprise SSO issuer, together with the user identifier issued by the external provider. The identity filter is combined with other search filters using AND logic diff --git a/packages/core/src/routes/admin-user/search.openapi.json b/packages/core/src/routes/admin-user/search.openapi.json index 6ba3afafc3d2..3acf8100ea23 100644 --- a/packages/core/src/routes/admin-user/search.openapi.json +++ b/packages/core/src/routes/admin-user/search.openapi.json @@ -9,8 +9,7 @@ "description": "The external identity type to use for an exact user lookup. Use `social` for a social connector identity or `sso` for an enterprise SSO identity. Must be provided together with `identityProvider` and `identityId`. External identity filters are combined with other user filters using AND logic.", "schema": { "type": "string", - "enum": ["social", "sso"], - "x-logto-dev-feature": true + "enum": ["social", "sso"] } }, { @@ -18,8 +17,7 @@ "name": "identityProvider", "description": "The connector target for a social identity (for example, `dingtalk`), or the issuer for an enterprise SSO identity. Must be provided together with `identityType` and `identityId`.", "schema": { - "type": "string", - "x-logto-dev-feature": true + "type": "string" } }, { @@ -27,8 +25,7 @@ "name": "identityId", "description": "The user identifier issued by the external identity provider, such as a DingTalk OpenID. Must be provided together with `identityType` and `identityProvider`.", "schema": { - "type": "string", - "x-logto-dev-feature": true + "type": "string" } } ], diff --git a/packages/core/src/routes/admin-user/search.test.ts b/packages/core/src/routes/admin-user/search.test.ts index c15b07bf8a03..c6da97d24fde 100644 --- a/packages/core/src/routes/admin-user/search.test.ts +++ b/packages/core/src/routes/admin-user/search.test.ts @@ -4,7 +4,6 @@ import { pickDefault } from '@logto/shared/esm'; import { removeUndefinedKeys } from '@silverhand/essentials'; import { mockUser, mockUserList, mockUserListResponse } from '#src/__mocks__/index.js'; -import { EnvSet } from '#src/env-set/index.js'; import { type InsertUserResult } from '#src/libraries/user.js'; import { type UserConditions } from '#src/queries/user.js'; import type Libraries from '#src/tenants/Libraries.js'; @@ -73,7 +72,6 @@ const usersLibraries = { } satisfies Partial; const adminUserRoutes = await pickDefault(import('./search.js')); -const originalIsDevFeaturesEnabled = EnvSet.values.isDevFeaturesEnabled; describe('adminUserRoutes', () => { const tenantContext = new MockTenant(undefined, mockedQueries, undefined, { @@ -81,18 +79,10 @@ describe('adminUserRoutes', () => { }); const userRequest = createRequester({ authedRoutes: adminUserRoutes, tenantContext }); - beforeEach(() => { - Reflect.set(EnvSet.values, 'isDevFeaturesEnabled', true); - }); - afterEach(() => { jest.clearAllMocks(); }); - afterAll(() => { - Reflect.set(EnvSet.values, 'isDevFeaturesEnabled', originalIsDevFeaturesEnabled); - }); - it('GET /users', async () => { const response = await userRequest.get('/users'); expect(response.status).toEqual(200); @@ -234,17 +224,4 @@ describe('adminUserRoutes', () => { expect(response.status).toEqual(400); expect(mockedQueries.users.countUsers).not.toHaveBeenCalled(); }); - - it('GET /users should reject identity lookup when dev features are disabled', async () => { - Reflect.set(EnvSet.values, 'isDevFeaturesEnabled', false); - - const response = await userRequest.get('/users').query({ - identityType: 'social', - identityProvider: 'dingtalk', - identityId: 'dingtalk-open-id', - }); - - expect(response.status).toEqual(400); - expect(mockedQueries.users.countUsers).not.toHaveBeenCalled(); - }); }); diff --git a/packages/core/src/routes/admin-user/search.ts b/packages/core/src/routes/admin-user/search.ts index 88affa3c3303..701c19523c5e 100644 --- a/packages/core/src/routes/admin-user/search.ts +++ b/packages/core/src/routes/admin-user/search.ts @@ -1,7 +1,6 @@ import { OrganizationUserRelations, UsersRoles } from '@logto/schemas'; import { type Nullable, tryThat, yes } from '@silverhand/essentials'; -import { EnvSet } from '#src/env-set/index.js'; import RequestError from '#src/errors/RequestError/index.js'; import koaGuard from '#src/middleware/koa-guard.js'; import koaPagination from '#src/middleware/koa-pagination.js'; @@ -48,11 +47,6 @@ const getIdentityCondition = (searchParams: URLSearchParams): UserConditions['id return undefined; } - // DEV: Look up users by external social or enterprise SSO identity. - if (!EnvSet.values.isDevFeaturesEnabled) { - throw new TypeError('External identity user lookup is not enabled.'); - } - if (!type || !provider || !identityId) { throw new TypeError( 'Parameters `identityType`, `identityProvider`, and `identityId` must be provided together and must not be empty.' diff --git a/packages/core/src/routes/swagger/utils/general.test.ts b/packages/core/src/routes/swagger/utils/general.test.ts index 2e419cc73a55..e63bd23d05fa 100644 --- a/packages/core/src/routes/swagger/utils/general.test.ts +++ b/packages/core/src/routes/swagger/utils/general.test.ts @@ -94,6 +94,11 @@ const createDevFeatureOperationDocument = (): DeepPartial => }, }); +const loadSearchDocument = async (): Promise> => + JSON.parse( + await fs.readFile(new URL('../../admin-user/search.openapi.json', import.meta.url), 'utf8') + ) as DeepPartial; + describe('swagger general utils', () => { afterEach(() => { Reflect.set(EnvSet.values, 'isCloud', originalIsCloud); @@ -201,29 +206,24 @@ describe('swagger general utils', () => { expect(document.paths).not.toHaveProperty('/api/dev'); }); - it('should expose external identity lookup parameters only when dev features are enabled', async () => { - const loadDocument = async () => - JSON.parse( - await fs.readFile(new URL('../../admin-user/search.openapi.json', import.meta.url), 'utf8') - ) as DeepPartial; - - setDevFeaturesEnabled(false); - const stableDocument = removeUnnecessaryOperations(await loadDocument()); - removeDevFeatureParameters(stableDocument); - removeDevFeatureSchemaProperties(stableDocument); - expect(stableDocument.paths?.['/api/users']?.get?.parameters).toEqual([]); - - setDevFeaturesEnabled(true); - const devDocument = removeUnnecessaryOperations(await loadDocument()); - removeDevFeatureParameters(devDocument); - removeDevFeatureSchemaProperties(devDocument); - expect(devDocument.paths?.['/api/users']?.get?.parameters).toEqual( - expect.arrayContaining([ - expect.objectContaining({ name: 'identityType' }), - expect.objectContaining({ name: 'identityProvider' }), - expect.objectContaining({ name: 'identityId' }), - ]) - ); - expect(JSON.stringify(devDocument)).not.toContain(devFeatureSchemaExtension); + it('should always expose external identity lookup parameters', async () => { + const source = await loadSearchDocument(); + + for (const isDevFeaturesEnabled of [false, true]) { + setDevFeaturesEnabled(isDevFeaturesEnabled); + // The pruning helpers mutate the input, so work on a fresh copy per state. + const document = JSON.parse(JSON.stringify(source)) as DeepPartial; + removeUnnecessaryOperations(document); + removeDevFeatureParameters(document); + removeDevFeatureSchemaProperties(document); + expect(document.paths?.['/api/users']?.get?.parameters).toEqual( + expect.arrayContaining([ + expect.objectContaining({ name: 'identityType' }), + expect.objectContaining({ name: 'identityProvider' }), + expect.objectContaining({ name: 'identityId' }), + ]) + ); + expect(JSON.stringify(document)).not.toContain(devFeatureSchemaExtension); + } }); }); From d9eb4d2047a4a04a0bdc3f678dcaaec4387b1397 Mon Sep 17 00:00:00 2001 From: wangsijie Date: Mon, 14 Sep 2026 11:38:55 +0000 Subject: [PATCH 2/2] test(core): simplify external identity lookup parameter test --- .../src/routes/swagger/utils/general.test.ts | 30 ++++++++----------- 1 file changed, 12 insertions(+), 18 deletions(-) diff --git a/packages/core/src/routes/swagger/utils/general.test.ts b/packages/core/src/routes/swagger/utils/general.test.ts index e63bd23d05fa..40a1b80bbd46 100644 --- a/packages/core/src/routes/swagger/utils/general.test.ts +++ b/packages/core/src/routes/swagger/utils/general.test.ts @@ -207,23 +207,17 @@ describe('swagger general utils', () => { }); it('should always expose external identity lookup parameters', async () => { - const source = await loadSearchDocument(); - - for (const isDevFeaturesEnabled of [false, true]) { - setDevFeaturesEnabled(isDevFeaturesEnabled); - // The pruning helpers mutate the input, so work on a fresh copy per state. - const document = JSON.parse(JSON.stringify(source)) as DeepPartial; - removeUnnecessaryOperations(document); - removeDevFeatureParameters(document); - removeDevFeatureSchemaProperties(document); - expect(document.paths?.['/api/users']?.get?.parameters).toEqual( - expect.arrayContaining([ - expect.objectContaining({ name: 'identityType' }), - expect.objectContaining({ name: 'identityProvider' }), - expect.objectContaining({ name: 'identityId' }), - ]) - ); - expect(JSON.stringify(document)).not.toContain(devFeatureSchemaExtension); - } + const document = await loadSearchDocument(); + removeUnnecessaryOperations(document); + removeDevFeatureParameters(document); + removeDevFeatureSchemaProperties(document); + expect(document.paths?.['/api/users']?.get?.parameters).toEqual( + expect.arrayContaining([ + expect.objectContaining({ name: 'identityType' }), + expect.objectContaining({ name: 'identityProvider' }), + expect.objectContaining({ name: 'identityId' }), + ]) + ); + expect(JSON.stringify(document)).not.toContain(devFeatureSchemaExtension); }); });