Skip to content

Commit

Permalink
feat(core): add the new user provision (#6253)
Browse files Browse the repository at this point in the history
add the new user provision
  • Loading branch information
simeng-li authored Jul 17, 2024
1 parent 6c4f051 commit 0a92bd2
Show file tree
Hide file tree
Showing 5 changed files with 509 additions and 6 deletions.
Original file line number Diff line number Diff line change
@@ -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 = '[email protected]';
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,
});
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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;

Expand All @@ -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);
Expand Down Expand Up @@ -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(),
Expand All @@ -315,6 +324,10 @@ export default class ExperienceInteraction {
});
}

await this.provisionLibrary.newUserJtiOrganizationProvision(user.id, newProfile);

// TODO: new user hooks

this.userId = user.id;
}
}
196 changes: 196 additions & 0 deletions packages/core/src/routes/experience/classes/provision-library.ts
Original file line number Diff line number Diff line change
@@ -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
);
}
Loading

0 comments on commit 0a92bd2

Please sign in to comment.