Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changeset/external-identity-user-lookup.md
Original file line number Diff line number Diff line change
@@ -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
9 changes: 3 additions & 6 deletions packages/core/src/routes/admin-user/search.openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,26 +9,23 @@
"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"]
}
},
{
"in": "query",
"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"
Comment thread
wangsijie marked this conversation as resolved.
}
},
{
"in": "query",
"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"
}
}
],
Expand Down
23 changes: 0 additions & 23 deletions packages/core/src/routes/admin-user/search.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -73,26 +72,17 @@ const usersLibraries = {
} satisfies Partial<Libraries['users']>;

const adminUserRoutes = await pickDefault(import('./search.js'));
const originalIsDevFeaturesEnabled = EnvSet.values.isDevFeaturesEnabled;

describe('adminUserRoutes', () => {
const tenantContext = new MockTenant(undefined, mockedQueries, undefined, {
users: usersLibraries,
});
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);
Expand Down Expand Up @@ -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();
});
});
6 changes: 0 additions & 6 deletions packages/core/src/routes/admin-user/search.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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.'
Expand Down
30 changes: 12 additions & 18 deletions packages/core/src/routes/swagger/utils/general.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,11 @@ const createDevFeatureOperationDocument = (): DeepPartial<OpenAPIV3.Document> =>
},
});

const loadSearchDocument = async (): Promise<DeepPartial<OpenAPIV3.Document>> =>
JSON.parse(
await fs.readFile(new URL('../../admin-user/search.openapi.json', import.meta.url), 'utf8')
) as DeepPartial<OpenAPIV3.Document>;

describe('swagger general utils', () => {
afterEach(() => {
Reflect.set(EnvSet.values, 'isCloud', originalIsCloud);
Expand Down Expand Up @@ -201,29 +206,18 @@ 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<OpenAPIV3.Document>;

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(
it('should always expose external identity lookup parameters', async () => {
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(devDocument)).not.toContain(devFeatureSchemaExtension);
expect(JSON.stringify(document)).not.toContain(devFeatureSchemaExtension);
});
});
Loading