0
Fork 0
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:
simeng-li 2024-07-17 14:39:20 +08:00 committed by GitHub
parent 6c4f051cfe
commit 0a92bd2fdc
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 509 additions and 6 deletions

View file

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

View file

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

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

View file

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

View file

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