Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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
48 changes: 24 additions & 24 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,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<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(
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<OpenAPIV3.Document>;
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);
}
});
});
Loading