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 { buildInsertIntoWithPool } from '#src/database/insert-into.js';
|
||||||
import { EnvSet } from '#src/env-set/index.js';
|
import { EnvSet } from '#src/env-set/index.js';
|
||||||
import RequestError from '#src/errors/RequestError/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 { createUsersRolesQueries } from '#src/queries/users-roles.js';
|
||||||
import type Queries from '#src/tenants/Queries.js';
|
import type Queries from '#src/tenants/Queries.js';
|
||||||
import assertThat from '#src/utils/assert-that.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;
|
return user;
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
|
@ -49,6 +49,15 @@ export class EmailDomainQueries {
|
||||||
return [Number(count), rows];
|
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> {
|
async insert(organizationId: string, emailDomain: string): Promise<OrganizationEmailDomain> {
|
||||||
return this.#insert({
|
return this.#insert({
|
||||||
organizationId,
|
organizationId,
|
||||||
|
|
|
@ -137,6 +137,7 @@ export default function organizationRoutes<T extends ManagementApiRouter>(
|
||||||
|
|
||||||
userRoleRelationRoutes(router, organizations);
|
userRoleRelationRoutes(router, organizations);
|
||||||
|
|
||||||
|
// TODO: Remove this check when launching
|
||||||
if (EnvSet.values.isDevFeaturesEnabled) {
|
if (EnvSet.values.isDevFeaturesEnabled) {
|
||||||
emailDomainRoutes(router, organizations);
|
emailDomainRoutes(router, organizations);
|
||||||
}
|
}
|
||||||
|
|
|
@ -3,6 +3,7 @@ import type {
|
||||||
Identity,
|
Identity,
|
||||||
MfaFactor,
|
MfaFactor,
|
||||||
MfaVerification,
|
MfaVerification,
|
||||||
|
OrganizationWithRoles,
|
||||||
Role,
|
Role,
|
||||||
User,
|
User,
|
||||||
UserSsoIdentity,
|
UserSsoIdentity,
|
||||||
|
@ -123,3 +124,6 @@ export const createUserMfaVerification = async (userId: string, type: MfaFactor)
|
||||||
| { type: MfaFactor.TOTP; secret: string; secretQrCode: string }
|
| { type: MfaFactor.TOTP; secret: string; secretQrCode: string }
|
||||||
| { type: MfaFactor.BackupCode; codes: 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;
|
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 (
|
export const signInWithPassword = async (
|
||||||
payload: UsernamePasswordPayload | EmailPasswordPayload | PhonePasswordPayload
|
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,
|
setSmsConnector,
|
||||||
} from '#src/helpers/connector.js';
|
} from '#src/helpers/connector.js';
|
||||||
import { readConnectorMessage, expectRejects } from '#src/helpers/index.js';
|
import { readConnectorMessage, expectRejects } from '#src/helpers/index.js';
|
||||||
|
import { registerWithEmail } from '#src/helpers/interactions.js';
|
||||||
import {
|
import {
|
||||||
enableAllVerificationCodeSignInMethods,
|
enableAllVerificationCodeSignInMethods,
|
||||||
enableAllPasswordSignInMethods,
|
enableAllPasswordSignInMethods,
|
||||||
|
@ -75,37 +76,7 @@ describe('register with passwordless identifier', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
const { primaryEmail } = generateNewUserProfile({ primaryEmail: true });
|
const { primaryEmail } = generateNewUserProfile({ primaryEmail: true });
|
||||||
const client = await initClient();
|
const { client, id } = await registerWithEmail(primaryEmail);
|
||||||
|
|
||||||
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);
|
|
||||||
await logoutClient(client);
|
await logoutClient(client);
|
||||||
await deleteUser(id);
|
await deleteUser(id);
|
||||||
});
|
});
|
||||||
|
|
Loading…
Reference in a new issue