-
-
Notifications
You must be signed in to change notification settings - Fork 440
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat(core): add the new user provision (#6253)
add the new user provision
- Loading branch information
Showing
5 changed files
with
509 additions
and
6 deletions.
There are no files selected for viewing
119 changes: 119 additions & 0 deletions
119
packages/core/src/routes/experience/classes/experience-interaction.test.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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, | ||
}); | ||
}); | ||
}); | ||
}); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
196 changes: 196 additions & 0 deletions
196
packages/core/src/routes/experience/classes/provision-library.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 | ||
); | ||
} |
Oops, something went wrong.