mirror of
https://github.com/logto-io/logto.git
synced 2024-12-30 20:33:54 -05:00
Merge pull request #5996 from logto-io/gao-org-domain-provision
feat(core): implement organization jit provisioning
This commit is contained in:
commit
3bed38ccc5
8 changed files with 174 additions and 31 deletions
|
@ -8,6 +8,7 @@ import pRetry from 'p-retry';
|
|||
import { buildInsertIntoWithPool } from '#src/database/insert-into.js';
|
||||
import { EnvSet } from '#src/env-set/index.js';
|
||||
import RequestError from '#src/errors/RequestError/index.js';
|
||||
import OrganizationQueries from '#src/queries/organization/index.js';
|
||||
import { createUsersRolesQueries } from '#src/queries/users-roles.js';
|
||||
import type Queries from '#src/tenants/Queries.js';
|
||||
import assertThat from '#src/utils/assert-that.js';
|
||||
|
@ -130,6 +131,22 @@ export const createUserLibrary = (queries: Queries) => {
|
|||
);
|
||||
}
|
||||
|
||||
// Just-in-time organization provisioning
|
||||
const userEmailDomain = data.primaryEmail?.split('@')[1];
|
||||
// TODO: Remove this check when launching
|
||||
if (EnvSet.values.isDevFeaturesEnabled && userEmailDomain) {
|
||||
const organizationQueries = new OrganizationQueries(connection);
|
||||
const organizationIds = await organizationQueries.emailDomains.getOrganizationIdsByDomain(
|
||||
userEmailDomain
|
||||
);
|
||||
|
||||
if (organizationIds.length > 0) {
|
||||
await organizationQueries.relations.users.insert(
|
||||
...organizationIds.map<[string, string]>((organizationId) => [organizationId, user.id])
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return user;
|
||||
});
|
||||
};
|
||||
|
|
|
@ -49,6 +49,15 @@ export class EmailDomainQueries {
|
|||
return [Number(count), rows];
|
||||
}
|
||||
|
||||
async getOrganizationIdsByDomain(emailDomain: string): Promise<readonly string[]> {
|
||||
const rows = await this.pool.any<Pick<OrganizationEmailDomain, 'organizationId'>>(sql`
|
||||
select ${fields.organizationId}
|
||||
from ${table}
|
||||
where ${fields.emailDomain} = ${emailDomain}
|
||||
`);
|
||||
return rows.map((row) => row.organizationId);
|
||||
}
|
||||
|
||||
async insert(organizationId: string, emailDomain: string): Promise<OrganizationEmailDomain> {
|
||||
return this.#insert({
|
||||
organizationId,
|
||||
|
|
|
@ -137,6 +137,7 @@ export default function organizationRoutes<T extends ManagementApiRouter>(
|
|||
|
||||
userRoleRelationRoutes(router, organizations);
|
||||
|
||||
// TODO: Remove this check when launching
|
||||
if (EnvSet.values.isDevFeaturesEnabled) {
|
||||
emailDomainRoutes(router, organizations);
|
||||
}
|
||||
|
|
|
@ -3,6 +3,7 @@ import type {
|
|||
Identity,
|
||||
MfaFactor,
|
||||
MfaVerification,
|
||||
OrganizationWithRoles,
|
||||
Role,
|
||||
User,
|
||||
UserSsoIdentity,
|
||||
|
@ -123,3 +124,6 @@ export const createUserMfaVerification = async (userId: string, type: MfaFactor)
|
|||
| { type: MfaFactor.TOTP; secret: string; secretQrCode: string }
|
||||
| { type: MfaFactor.BackupCode; codes: string[] }
|
||||
>();
|
||||
|
||||
export const getUserOrganizations = async (userId: string) =>
|
||||
authedAdminApi.get(`users/${userId}/organizations`).json<OrganizationWithRoles[]>();
|
||||
|
|
|
@ -36,6 +36,47 @@ export const registerNewUser = async (username: string, password: string) => {
|
|||
return userId;
|
||||
};
|
||||
|
||||
/**
|
||||
* Register a new user with email and verification code. Email connector must be enabled.
|
||||
*
|
||||
* @param email The email address of the user to register.
|
||||
* @returns The client and the user ID.
|
||||
*/
|
||||
export const registerWithEmail = async (email: string) => {
|
||||
const client = await initClient();
|
||||
|
||||
await client.successSend(putInteraction, {
|
||||
event: InteractionEvent.Register,
|
||||
});
|
||||
|
||||
await client.successSend(sendVerificationCode, {
|
||||
email,
|
||||
});
|
||||
|
||||
const verificationCodeRecord = await readConnectorMessage('Email');
|
||||
|
||||
expect(verificationCodeRecord).toMatchObject({
|
||||
address: email,
|
||||
type: InteractionEvent.Register,
|
||||
});
|
||||
|
||||
const { code } = verificationCodeRecord;
|
||||
|
||||
await client.successSend(patchInteractionIdentifiers, {
|
||||
email,
|
||||
verificationCode: code,
|
||||
});
|
||||
|
||||
await client.successSend(putInteractionProfile, {
|
||||
email,
|
||||
});
|
||||
|
||||
const { redirectTo } = await client.submitInteraction();
|
||||
const id = await processSession(client, redirectTo);
|
||||
|
||||
return { client, id };
|
||||
};
|
||||
|
||||
export const signInWithPassword = async (
|
||||
payload: UsernamePasswordPayload | EmailPasswordPayload | PhonePasswordPayload
|
||||
) => {
|
||||
|
|
|
@ -0,0 +1,34 @@
|
|||
import { getUserOrganizations } from '#src/api/index.js';
|
||||
import { OrganizationApiTest } from '#src/helpers/organization.js';
|
||||
import { UserApiTest } from '#src/helpers/user.js';
|
||||
import { randomString } from '#src/utils.js';
|
||||
|
||||
describe('organization just-in-time provisioning', () => {
|
||||
const organizationApi = new OrganizationApiTest();
|
||||
const userApi = new UserApiTest();
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all([organizationApi.cleanUp(), userApi.cleanUp()]);
|
||||
});
|
||||
|
||||
it('should automatically provision a user to the organization with the matched email domain', async () => {
|
||||
const organizations = await Promise.all([
|
||||
organizationApi.create({ name: 'foo' }),
|
||||
organizationApi.create({ name: 'bar' }),
|
||||
]);
|
||||
const emailDomain = 'foo.com';
|
||||
await Promise.all(
|
||||
organizations.map(async (organization) =>
|
||||
organizationApi.addEmailDomain(organization.id, emailDomain)
|
||||
)
|
||||
);
|
||||
|
||||
const email = randomString() + '@' + emailDomain;
|
||||
const { id } = await userApi.create({ primaryEmail: email });
|
||||
|
||||
const userOrganizations = await getUserOrganizations(id);
|
||||
expect(userOrganizations).toEqual(
|
||||
expect.arrayContaining(organizations.map((item) => expect.objectContaining({ id: item.id })))
|
||||
);
|
||||
});
|
||||
});
|
|
@ -0,0 +1,66 @@
|
|||
/**
|
||||
* @fileoverview
|
||||
* Tests for the organization just-in-time (JIT) provisioning when a user registers with a matched
|
||||
* email domain.
|
||||
*/
|
||||
|
||||
import { ConnectorType, SignInIdentifier } from '@logto/schemas';
|
||||
|
||||
import { deleteUser, getUserOrganizations } from '#src/api/index.js';
|
||||
import { logoutClient } from '#src/helpers/client.js';
|
||||
import {
|
||||
clearConnectorsByTypes,
|
||||
setEmailConnector,
|
||||
setSmsConnector,
|
||||
} from '#src/helpers/connector.js';
|
||||
import { registerWithEmail } from '#src/helpers/interactions.js';
|
||||
import { OrganizationApiTest } from '#src/helpers/organization.js';
|
||||
import {
|
||||
enableAllVerificationCodeSignInMethods,
|
||||
resetPasswordPolicy,
|
||||
} from '#src/helpers/sign-in-experience.js';
|
||||
import { randomString } from '#src/utils.js';
|
||||
|
||||
describe('organization just-in-time provisioning', () => {
|
||||
const organizationApi = new OrganizationApiTest();
|
||||
|
||||
afterEach(async () => {
|
||||
await organizationApi.cleanUp();
|
||||
});
|
||||
|
||||
beforeAll(async () => {
|
||||
await clearConnectorsByTypes([ConnectorType.Email, ConnectorType.Sms]);
|
||||
await Promise.all([setEmailConnector(), setSmsConnector()]);
|
||||
await resetPasswordPolicy();
|
||||
// Run it sequentially to avoid race condition
|
||||
await enableAllVerificationCodeSignInMethods({
|
||||
identifiers: [SignInIdentifier.Email],
|
||||
password: false,
|
||||
verify: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('should automatically provision a user to the organization with the matched email domain', async () => {
|
||||
const organizations = await Promise.all([
|
||||
organizationApi.create({ name: 'foo' }),
|
||||
organizationApi.create({ name: 'bar' }),
|
||||
]);
|
||||
const emailDomain = 'foo.com';
|
||||
await Promise.all(
|
||||
organizations.map(async (organization) =>
|
||||
organizationApi.addEmailDomain(organization.id, emailDomain)
|
||||
)
|
||||
);
|
||||
|
||||
const email = randomString() + '@' + emailDomain;
|
||||
const { client, id } = await registerWithEmail(email);
|
||||
|
||||
const userOrganizations = await getUserOrganizations(id);
|
||||
expect(userOrganizations).toEqual(
|
||||
expect.arrayContaining(organizations.map((item) => expect.objectContaining({ id: item.id })))
|
||||
);
|
||||
|
||||
await logoutClient(client);
|
||||
await deleteUser(id);
|
||||
});
|
||||
});
|
|
@ -18,6 +18,7 @@ import {
|
|||
setSmsConnector,
|
||||
} from '#src/helpers/connector.js';
|
||||
import { readConnectorMessage, expectRejects } from '#src/helpers/index.js';
|
||||
import { registerWithEmail } from '#src/helpers/interactions.js';
|
||||
import {
|
||||
enableAllVerificationCodeSignInMethods,
|
||||
enableAllPasswordSignInMethods,
|
||||
|
@ -75,37 +76,7 @@ describe('register with passwordless identifier', () => {
|
|||
});
|
||||
|
||||
const { primaryEmail } = generateNewUserProfile({ primaryEmail: true });
|
||||
const client = await initClient();
|
||||
|
||||
await client.successSend(putInteraction, {
|
||||
event: InteractionEvent.Register,
|
||||
});
|
||||
|
||||
await client.successSend(sendVerificationCode, {
|
||||
email: primaryEmail,
|
||||
});
|
||||
|
||||
const verificationCodeRecord = await readConnectorMessage('Email');
|
||||
|
||||
expect(verificationCodeRecord).toMatchObject({
|
||||
address: primaryEmail,
|
||||
type: InteractionEvent.Register,
|
||||
});
|
||||
|
||||
const { code } = verificationCodeRecord;
|
||||
|
||||
await client.successSend(patchInteractionIdentifiers, {
|
||||
email: primaryEmail,
|
||||
verificationCode: code,
|
||||
});
|
||||
|
||||
await client.successSend(putInteractionProfile, {
|
||||
email: primaryEmail,
|
||||
});
|
||||
|
||||
const { redirectTo } = await client.submitInteraction();
|
||||
|
||||
const id = await processSession(client, redirectTo);
|
||||
const { client, id } = await registerWithEmail(primaryEmail);
|
||||
await logoutClient(client);
|
||||
await deleteUser(id);
|
||||
});
|
||||
|
|
Loading…
Reference in a new issue