0
Fork 0
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:
Gao Sun 2024-06-08 10:21:38 +08:00 committed by GitHub
commit 3bed38ccc5
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 174 additions and 31 deletions

View file

@ -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;
});
};

View file

@ -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,

View file

@ -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);
}

View file

@ -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[]>();

View file

@ -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
) => {

View file

@ -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 })))
);
});
});

View file

@ -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);
});
});

View file

@ -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);
});