mirror of
https://github.com/logto-io/logto.git
synced 2024-12-16 20:26:19 -05:00
feat(core): add the new user provision (#6253)
add the new user provision
This commit is contained in:
parent
6c4f051cfe
commit
0a92bd2fdc
5 changed files with 509 additions and 6 deletions
|
@ -0,0 +1,119 @@
|
|||
import {
|
||||
adminConsoleApplicationId,
|
||||
adminTenantId,
|
||||
type CreateUser,
|
||||
InteractionEvent,
|
||||
SignInIdentifier,
|
||||
SignInMode,
|
||||
type User,
|
||||
VerificationType,
|
||||
} from '@logto/schemas';
|
||||
import { createMockUtils, pickDefault } from '@logto/shared/esm';
|
||||
|
||||
import { mockSignInExperience } from '#src/__mocks__/sign-in-experience.js';
|
||||
import { type InsertUserResult } from '#src/libraries/user.js';
|
||||
import { createMockLogContext } from '#src/test-utils/koa-audit-log.js';
|
||||
import { createMockProvider } from '#src/test-utils/oidc-provider.js';
|
||||
import { MockTenant } from '#src/test-utils/tenant.js';
|
||||
import { createContextWithRouteParameters } from '#src/utils/test-utils.js';
|
||||
|
||||
import { CodeVerification } from './verifications/code-verification.js';
|
||||
|
||||
const { jest } = import.meta;
|
||||
const { mockEsm } = createMockUtils(jest);
|
||||
|
||||
mockEsm('#src/utils/tenant.js', () => ({
|
||||
getTenantId: () => [adminTenantId],
|
||||
}));
|
||||
|
||||
const mockEmail = 'foo@bar.com';
|
||||
const userQueries = {
|
||||
hasActiveUsers: jest.fn().mockResolvedValue(false),
|
||||
hasUserWithEmail: jest.fn().mockResolvedValue(false),
|
||||
hasUserWithPhone: jest.fn().mockResolvedValue(false),
|
||||
hasUserWithIdentity: jest.fn().mockResolvedValue(false),
|
||||
};
|
||||
const userLibraries = {
|
||||
generateUserId: jest.fn().mockResolvedValue('uid'),
|
||||
insertUser: jest.fn(async (user: CreateUser): Promise<InsertUserResult> => [user as User]),
|
||||
provisionOrganizations: jest.fn(),
|
||||
};
|
||||
const ssoConnectors = {
|
||||
getAvailableSsoConnectors: jest.fn().mockResolvedValue([]),
|
||||
};
|
||||
const signInExperiences = {
|
||||
findDefaultSignInExperience: jest.fn().mockResolvedValue({
|
||||
...mockSignInExperience,
|
||||
signUp: {
|
||||
identifiers: [SignInIdentifier.Email],
|
||||
password: false,
|
||||
verify: true,
|
||||
},
|
||||
}),
|
||||
updateDefaultSignInExperience: jest.fn(),
|
||||
};
|
||||
|
||||
const mockProviderInteractionDetails = jest
|
||||
.fn()
|
||||
.mockResolvedValue({ params: { client_id: adminConsoleApplicationId } });
|
||||
|
||||
const ExperienceInteraction = await pickDefault(import('./experience-interaction.js'));
|
||||
|
||||
describe('ExperienceInteraction class', () => {
|
||||
const tenant = new MockTenant(
|
||||
createMockProvider(mockProviderInteractionDetails),
|
||||
{
|
||||
users: userQueries,
|
||||
signInExperiences,
|
||||
},
|
||||
undefined,
|
||||
{ users: userLibraries, ssoConnectors }
|
||||
);
|
||||
const ctx = {
|
||||
...createContextWithRouteParameters(),
|
||||
...createMockLogContext(),
|
||||
};
|
||||
const { libraries, queries } = tenant;
|
||||
|
||||
const emailVerificationRecord = new CodeVerification(libraries, queries, {
|
||||
id: 'mock_email_verification_id',
|
||||
type: VerificationType.VerificationCode,
|
||||
identifier: {
|
||||
type: SignInIdentifier.Email,
|
||||
value: mockEmail,
|
||||
},
|
||||
interactionEvent: InteractionEvent.Register,
|
||||
verified: true,
|
||||
});
|
||||
|
||||
beforeAll(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('new user registration', () => {
|
||||
it('First admin user provisioning', async () => {
|
||||
const experienceInteraction = new ExperienceInteraction(ctx, tenant);
|
||||
|
||||
await experienceInteraction.setInteractionEvent(InteractionEvent.Register);
|
||||
experienceInteraction.setVerificationRecord(emailVerificationRecord);
|
||||
await experienceInteraction.identifyUser(emailVerificationRecord.id);
|
||||
|
||||
expect(userLibraries.insertUser).toHaveBeenCalledWith(
|
||||
{
|
||||
id: 'uid',
|
||||
primaryEmail: mockEmail,
|
||||
},
|
||||
['user', 'default:admin']
|
||||
);
|
||||
|
||||
expect(signInExperiences.updateDefaultSignInExperience).toHaveBeenCalledWith({
|
||||
signInMode: SignInMode.SignIn,
|
||||
});
|
||||
|
||||
expect(userLibraries.provisionOrganizations).toHaveBeenCalledWith({
|
||||
userId: 'uid',
|
||||
email: mockEmail,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
|
@ -11,6 +11,7 @@ import assertThat from '#src/utils/assert-that.js';
|
|||
|
||||
import { interactionProfileGuard, type Interaction, type InteractionProfile } from '../types.js';
|
||||
|
||||
import { ProvisionLibrary } from './provision-library.js';
|
||||
import { getNewUserProfileFromVerificationRecord, toUserSocialIdentityData } from './utils.js';
|
||||
import { ProfileValidator } from './validators/profile-validator.js';
|
||||
import { SignInExperienceValidator } from './validators/sign-in-experience-validator.js';
|
||||
|
@ -44,13 +45,14 @@ const interactionStorageGuard = z.object({
|
|||
export default class ExperienceInteraction {
|
||||
public readonly signInExperienceValidator: SignInExperienceValidator;
|
||||
public readonly profileValidator: ProfileValidator;
|
||||
public readonly provisionLibrary: ProvisionLibrary;
|
||||
|
||||
/** The user verification record list for the current interaction. */
|
||||
private readonly verificationRecords = new Map<VerificationType, VerificationRecord>();
|
||||
/** The userId of the user for the current interaction. Only available once the user is identified. */
|
||||
private userId?: string;
|
||||
/** The user provided profile data in the current interaction that needs to be stored to database. */
|
||||
private readonly profile?: Record<string, unknown>; // TODO: Fix the type
|
||||
private readonly profile?: InteractionProfile;
|
||||
/** The interaction event for the current interaction. */
|
||||
#interactionEvent?: InteractionEvent;
|
||||
|
||||
|
@ -63,11 +65,12 @@ export default class ExperienceInteraction {
|
|||
constructor(
|
||||
private readonly ctx: WithLogContext,
|
||||
private readonly tenant: TenantContext,
|
||||
public interactionDetails?: Interaction
|
||||
interactionDetails?: Interaction
|
||||
) {
|
||||
const { libraries, queries } = tenant;
|
||||
|
||||
this.signInExperienceValidator = new SignInExperienceValidator(libraries, queries);
|
||||
this.provisionLibrary = new ProvisionLibrary(tenant, ctx);
|
||||
|
||||
if (!interactionDetails) {
|
||||
this.profileValidator = new ProfileValidator(libraries, queries);
|
||||
|
@ -294,19 +297,25 @@ export default class ExperienceInteraction {
|
|||
|
||||
await this.profileValidator.guardProfileUniquenessAcrossUsers(newProfile);
|
||||
|
||||
// TODO: new user provisioning and hooks
|
||||
|
||||
const { socialIdentity, enterpriseSsoIdentity, ...rest } = newProfile;
|
||||
|
||||
const { isCreatingFirstAdminUser, initialUserRoles, customData } =
|
||||
await this.provisionLibrary.getUserProvisionContext(newProfile);
|
||||
|
||||
const [user] = await insertUser(
|
||||
{
|
||||
id: await generateUserId(),
|
||||
...rest,
|
||||
...conditional(socialIdentity && { identities: toUserSocialIdentityData(socialIdentity) }),
|
||||
...conditional(customData && { customData }),
|
||||
},
|
||||
[]
|
||||
initialUserRoles
|
||||
);
|
||||
|
||||
if (isCreatingFirstAdminUser) {
|
||||
await this.provisionLibrary.adminUserProvision(user);
|
||||
}
|
||||
|
||||
if (enterpriseSsoIdentity) {
|
||||
await userSsoIdentitiesQueries.insert({
|
||||
id: generateStandardId(),
|
||||
|
@ -315,6 +324,10 @@ export default class ExperienceInteraction {
|
|||
});
|
||||
}
|
||||
|
||||
await this.provisionLibrary.newUserJtiOrganizationProvision(user.id, newProfile);
|
||||
|
||||
// TODO: new user hooks
|
||||
|
||||
this.userId = user.id;
|
||||
}
|
||||
}
|
||||
|
|
196
packages/core/src/routes/experience/classes/provision-library.ts
Normal file
196
packages/core/src/routes/experience/classes/provision-library.ts
Normal file
|
@ -0,0 +1,196 @@
|
|||
import {
|
||||
adminConsoleApplicationId,
|
||||
adminTenantId,
|
||||
AdminTenantRole,
|
||||
defaultManagementApiAdminName,
|
||||
defaultTenantId,
|
||||
getTenantOrganizationId,
|
||||
getTenantRole,
|
||||
OrganizationInvitationStatus,
|
||||
SignInMode,
|
||||
TenantRole,
|
||||
userOnboardingDataKey,
|
||||
type User,
|
||||
type UserOnboardingData,
|
||||
} from '@logto/schemas';
|
||||
import { conditionalArray } from '@silverhand/essentials';
|
||||
|
||||
import { EnvSet } from '#src/env-set/index.js';
|
||||
import { type WithLogContext } from '#src/middleware/koa-audit-log.js';
|
||||
import type TenantContext from '#src/tenants/TenantContext.js';
|
||||
import { getTenantId } from '#src/utils/tenant.js';
|
||||
|
||||
import { type InteractionProfile } from '../types.js';
|
||||
|
||||
type OrganizationProvisionPayload =
|
||||
| {
|
||||
userId: string;
|
||||
email: string;
|
||||
}
|
||||
| {
|
||||
userId: string;
|
||||
ssoConnectorId: string;
|
||||
};
|
||||
|
||||
export class ProvisionLibrary {
|
||||
constructor(
|
||||
private readonly tenantContext: TenantContext,
|
||||
private readonly ctx: WithLogContext
|
||||
) {}
|
||||
|
||||
/**
|
||||
* This method is used to get the provision context for a new user registration.
|
||||
* It will return the provision context based on the current tenant and the request context.
|
||||
*/
|
||||
async getUserProvisionContext(profile: InteractionProfile): Promise<{
|
||||
/** Admin user provisioning flag */
|
||||
isCreatingFirstAdminUser: boolean;
|
||||
/** Initial user roles for admin tenant users */
|
||||
initialUserRoles: string[];
|
||||
/** Skip onboarding flow if the new user has pending Cloud invitations */
|
||||
customData?: { [userOnboardingDataKey]: UserOnboardingData };
|
||||
}> {
|
||||
const {
|
||||
provider,
|
||||
queries: {
|
||||
users: { hasActiveUsers },
|
||||
organizations: organizationsQueries,
|
||||
},
|
||||
} = this.tenantContext;
|
||||
|
||||
const { req, res, URL } = this.ctx;
|
||||
|
||||
const [interactionDetails, [currentTenantId]] = await Promise.all([
|
||||
provider.interactionDetails(req, res),
|
||||
getTenantId(URL),
|
||||
]);
|
||||
|
||||
const {
|
||||
params: { client_id },
|
||||
} = interactionDetails;
|
||||
|
||||
const isAdminTenant = currentTenantId === adminTenantId;
|
||||
const isAdminConsoleApp = String(client_id) === adminConsoleApplicationId;
|
||||
|
||||
const { isCloud, isIntegrationTest } = EnvSet.values;
|
||||
|
||||
/**
|
||||
* Only allow creating the first admin user when
|
||||
*
|
||||
* - it's in OSS or integration tests
|
||||
* - it's in the admin tenant
|
||||
* - the client_id is the admin console application
|
||||
* - there are no active users in the tenant
|
||||
*/
|
||||
const isCreatingFirstAdminUser =
|
||||
(!isCloud || isIntegrationTest) &&
|
||||
isAdminTenant &&
|
||||
isAdminConsoleApp &&
|
||||
!(await hasActiveUsers());
|
||||
|
||||
const invitations =
|
||||
isCloud && profile.primaryEmail
|
||||
? await organizationsQueries.invitations.findEntities({
|
||||
invitee: profile.primaryEmail,
|
||||
})
|
||||
: [];
|
||||
|
||||
const hasPendingInvitations = invitations.some(
|
||||
(invitation) => invitation.status === OrganizationInvitationStatus.Pending
|
||||
);
|
||||
|
||||
const initialUserRoles = this.getInitialUserRoles(
|
||||
isAdminTenant,
|
||||
isCreatingFirstAdminUser,
|
||||
isCloud
|
||||
);
|
||||
|
||||
// Skip onboarding flow if the new user has pending Cloud invitations
|
||||
const customData = hasPendingInvitations
|
||||
? {
|
||||
[userOnboardingDataKey]: {
|
||||
isOnboardingDone: true,
|
||||
} satisfies UserOnboardingData,
|
||||
}
|
||||
: undefined;
|
||||
|
||||
return {
|
||||
isCreatingFirstAdminUser,
|
||||
initialUserRoles,
|
||||
customData,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* First admin user provision
|
||||
*
|
||||
* - For OSS, update the default sign-in experience to "sign-in only" once the first admin has been created.
|
||||
* - Add the user to the default organization and assign the admin role.
|
||||
*/
|
||||
async adminUserProvision({ id }: User) {
|
||||
const { isCloud } = EnvSet.values;
|
||||
const {
|
||||
queries: { signInExperiences, organizations },
|
||||
} = this.tenantContext;
|
||||
|
||||
// In OSS, we need to limit sign-in experience to "sign-in only" once
|
||||
// the first admin has been create since we don't want other unexpected registrations
|
||||
await signInExperiences.updateDefaultSignInExperience({
|
||||
signInMode: isCloud ? SignInMode.SignInAndRegister : SignInMode.SignIn,
|
||||
});
|
||||
|
||||
const organizationId = getTenantOrganizationId(defaultTenantId);
|
||||
await organizations.relations.users.insert({ organizationId, userId: id });
|
||||
await organizations.relations.usersRoles.insert({
|
||||
organizationId,
|
||||
userId: id,
|
||||
organizationRoleId: getTenantRole(TenantRole.Admin).id,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Provision the organization for a new user
|
||||
*
|
||||
* - If the user has an enterprise SSO identity, provision the organization based on the SSO connector
|
||||
* - Otherwise, provision the organization based on the primary email
|
||||
*/
|
||||
async newUserJtiOrganizationProvision(
|
||||
userId: string,
|
||||
{ primaryEmail, enterpriseSsoIdentity }: InteractionProfile
|
||||
) {
|
||||
if (enterpriseSsoIdentity) {
|
||||
return this.jitOrganizationProvision({
|
||||
userId,
|
||||
ssoConnectorId: enterpriseSsoIdentity.ssoConnectorId,
|
||||
});
|
||||
}
|
||||
if (primaryEmail) {
|
||||
return this.jitOrganizationProvision({
|
||||
userId,
|
||||
email: primaryEmail,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private async jitOrganizationProvision(payload: OrganizationProvisionPayload) {
|
||||
const {
|
||||
libraries: { users: usersLibraries },
|
||||
} = this.tenantContext;
|
||||
|
||||
const provisionedOrganizations = await usersLibraries.provisionOrganizations(payload);
|
||||
|
||||
// TODO: trigger hooks event
|
||||
|
||||
return provisionedOrganizations;
|
||||
}
|
||||
|
||||
private readonly getInitialUserRoles = (
|
||||
isInAdminTenant: boolean,
|
||||
isCreatingFirstAdminUser: boolean,
|
||||
isCloud: boolean
|
||||
) =>
|
||||
conditionalArray<string>(
|
||||
isInAdminTenant && AdminTenantRole.User,
|
||||
isCreatingFirstAdminUser && !isCloud && defaultManagementApiAdminName // OSS uses the legacy Management API user role
|
||||
);
|
||||
}
|
|
@ -0,0 +1,175 @@
|
|||
import { ConnectorType } from '@logto/connector-kit';
|
||||
import { SignInIdentifier } from '@logto/schemas';
|
||||
|
||||
import { deleteUser, getUserOrganizations } from '#src/api/admin-user.js';
|
||||
import { updateSignInExperience } from '#src/api/sign-in-experience.js';
|
||||
import { SsoConnectorApi } from '#src/api/sso-connector.js';
|
||||
import {
|
||||
clearConnectorsByTypes,
|
||||
setEmailConnector,
|
||||
setSmsConnector,
|
||||
} from '#src/helpers/connector.js';
|
||||
import {
|
||||
registerNewUserWithVerificationCode,
|
||||
signInWithEnterpriseSso,
|
||||
signInWithVerificationCode,
|
||||
} from '#src/helpers/experience/index.js';
|
||||
import { OrganizationApiTest } from '#src/helpers/organization.js';
|
||||
import { enableAllVerificationCodeSignInMethods } from '#src/helpers/sign-in-experience.js';
|
||||
import { devFeatureTest, generateEmail, randomString } from '#src/utils.js';
|
||||
|
||||
devFeatureTest.describe('organization just-in-time provisioning', () => {
|
||||
const organizationApi = new OrganizationApiTest();
|
||||
const ssoConnectorApi = new SsoConnectorApi();
|
||||
|
||||
beforeAll(async () => {
|
||||
await clearConnectorsByTypes([ConnectorType.Email]);
|
||||
await Promise.all([setEmailConnector(), setSmsConnector()]);
|
||||
|
||||
await enableAllVerificationCodeSignInMethods({
|
||||
identifiers: [SignInIdentifier.Email],
|
||||
password: false,
|
||||
verify: true,
|
||||
});
|
||||
|
||||
await updateSignInExperience({
|
||||
singleSignOnEnabled: true,
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all([organizationApi.cleanUp(), ssoConnectorApi.cleanUp()]);
|
||||
});
|
||||
|
||||
const prepare = async (emailDomain = `foo-${randomString()}.com`, ssoConnectorId?: string) => {
|
||||
const organizations = await Promise.all([
|
||||
organizationApi.create({ name: 'foo' }),
|
||||
organizationApi.create({ name: 'bar' }),
|
||||
organizationApi.create({ name: 'baz' }),
|
||||
]);
|
||||
const roles = await Promise.all([
|
||||
organizationApi.roleApi.create({ name: randomString() }),
|
||||
organizationApi.roleApi.create({ name: randomString() }),
|
||||
]);
|
||||
await Promise.all(
|
||||
organizations.map(async (organization) => {
|
||||
if (emailDomain) {
|
||||
await organizationApi.jit.addEmailDomain(organization.id, emailDomain);
|
||||
}
|
||||
|
||||
if (ssoConnectorId) {
|
||||
await organizationApi.jit.ssoConnectors.add(organization.id, [ssoConnectorId]);
|
||||
}
|
||||
})
|
||||
);
|
||||
await Promise.all([
|
||||
organizationApi.jit.roles.add(organizations[0].id, [roles[0].id, roles[1].id]),
|
||||
organizationApi.jit.roles.add(organizations[1].id, [roles[0].id]),
|
||||
]);
|
||||
return {
|
||||
organizations,
|
||||
roles,
|
||||
emailDomain,
|
||||
expectOrganizations: () =>
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
id: organizations[0].id,
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
||||
organizationRoles: expect.arrayContaining([
|
||||
expect.objectContaining({ id: roles[0].id }),
|
||||
expect.objectContaining({ id: roles[1].id }),
|
||||
]),
|
||||
}),
|
||||
expect.objectContaining({
|
||||
id: organizations[1].id,
|
||||
organizationRoles: [expect.objectContaining({ id: roles[0].id })],
|
||||
}),
|
||||
expect.objectContaining({
|
||||
id: organizations[2].id,
|
||||
organizationRoles: [],
|
||||
}),
|
||||
]),
|
||||
};
|
||||
};
|
||||
|
||||
it('should automatically provision a user with matched email', async () => {
|
||||
const { emailDomain, expectOrganizations } = await prepare();
|
||||
const email = randomString() + '@' + emailDomain;
|
||||
|
||||
const userId = await registerNewUserWithVerificationCode({
|
||||
type: SignInIdentifier.Email,
|
||||
value: email,
|
||||
});
|
||||
|
||||
const userOrganizations = await getUserOrganizations(userId);
|
||||
expect(userOrganizations).toEqual(expectOrganizations());
|
||||
|
||||
await deleteUser(userId);
|
||||
});
|
||||
|
||||
it('should not provision a user with the matched email from a SSO identity', async () => {
|
||||
const organization = await organizationApi.create({ name: 'sso_foo' });
|
||||
const domain = 'sso_example.com';
|
||||
await organizationApi.jit.addEmailDomain(organization.id, domain);
|
||||
const connector = await ssoConnectorApi.createMockOidcConnector([domain]);
|
||||
|
||||
const userId = await signInWithEnterpriseSso(
|
||||
connector.id,
|
||||
{
|
||||
sub: randomString(),
|
||||
email: generateEmail(domain),
|
||||
email_verified: true,
|
||||
},
|
||||
true
|
||||
);
|
||||
|
||||
const userOrganizations = await getUserOrganizations(userId);
|
||||
expect(userOrganizations).toEqual([]);
|
||||
await deleteUser(userId);
|
||||
});
|
||||
|
||||
it('should not provision an existing user with the matched email when sign-in', async () => {
|
||||
const emailDomain = `foo-${randomString()}.com`;
|
||||
const email = randomString() + '@' + emailDomain;
|
||||
const userId = await registerNewUserWithVerificationCode({
|
||||
type: SignInIdentifier.Email,
|
||||
value: email,
|
||||
});
|
||||
|
||||
await prepare(emailDomain);
|
||||
|
||||
await signInWithVerificationCode({
|
||||
type: SignInIdentifier.Email,
|
||||
value: email,
|
||||
});
|
||||
|
||||
const userOrganizations = await getUserOrganizations(userId);
|
||||
expect(userOrganizations).toEqual([]);
|
||||
await deleteUser(userId);
|
||||
});
|
||||
|
||||
it('should provision a user with the matched sso connector', async () => {
|
||||
const organization = await organizationApi.create({ name: 'sso_foo' });
|
||||
const domain = 'sso_example.com';
|
||||
const connector = await ssoConnectorApi.createMockOidcConnector([domain]);
|
||||
await organizationApi.jit.ssoConnectors.add(organization.id, [connector.id]);
|
||||
|
||||
const userId = await signInWithEnterpriseSso(
|
||||
connector.id,
|
||||
{
|
||||
sub: randomString(),
|
||||
email: generateEmail(domain),
|
||||
email_verified: true,
|
||||
},
|
||||
true
|
||||
);
|
||||
|
||||
const userOrganizations = await getUserOrganizations(userId);
|
||||
|
||||
expect(userOrganizations).toEqual(
|
||||
expect.arrayContaining([expect.objectContaining({ id: organization.id })])
|
||||
);
|
||||
|
||||
await deleteUser(userId);
|
||||
});
|
||||
});
|
|
@ -180,7 +180,7 @@ describe('organization just-in-time provisioning', () => {
|
|||
await deleteUser(userId);
|
||||
});
|
||||
|
||||
it('should not automatically provision an existing user when the user is an existing user', async () => {
|
||||
it('should not automatically provision an existing user', async () => {
|
||||
const emailDomain = `foo-${randomString()}.com`;
|
||||
const email = randomString() + '@' + emailDomain;
|
||||
const { client } = await registerWithEmail(email);
|
||||
|
|
Loading…
Reference in a new issue