Skip to content

Commit

Permalink
feat(core): jit organization roles (#6049)
Browse files Browse the repository at this point in the history
  • Loading branch information
gao-sun authored Jun 19, 2024
1 parent d49a5f4 commit 71ba7c4
Show file tree
Hide file tree
Showing 14 changed files with 152 additions and 32 deletions.
32 changes: 23 additions & 9 deletions packages/core/src/libraries/user.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import pRetry from 'p-retry';
import { buildInsertIntoWithPool } from '#src/database/insert-into.js';
import { EnvSet } from '#src/env-set/index.js';
import RequestError from '#src/errors/RequestError/index.js';
import { type JitOrganization } from '#src/queries/organization/email-domains.js';
import OrganizationQueries from '#src/queries/organization/index.js';
import { createUsersRolesQueries } from '#src/queries/users-roles.js';
import type Queries from '#src/tenants/Queries.js';
Expand Down Expand Up @@ -73,8 +74,8 @@ const converBindMfaToMfaVerification = (bindMfa: BindMfa): MfaVerification => {
export type InsertUserResult = [
User,
{
/** The organization IDs that the user has been provisioned into. */
organizationIds: readonly string[];
/** The organizations and organization roles that the user has been provisioned into. */
organizations: readonly JitOrganization[];
},
];

Expand Down Expand Up @@ -143,29 +144,42 @@ export const createUserLibrary = (queries: Queries) => {
}

// TODO: If the user's email is not verified, we should not provision the user into any organization.
const provisionOrganizations = async (): Promise<readonly string[]> => {
const provisionOrganizations = async (): Promise<readonly JitOrganization[]> => {
// Just-in-time organization provisioning
const userEmailDomain = data.primaryEmail?.split('@')[1];
if (userEmailDomain) {
const organizationQueries = new OrganizationQueries(connection);
const organizationIds =
await organizationQueries.jit.emailDomains.getOrganizationIdsByDomain(userEmailDomain);
const organizations = await organizationQueries.jit.emailDomains.getJitOrganizations(
userEmailDomain
);

if (organizationIds.length > 0) {
if (organizations.length > 0) {
await organizationQueries.relations.users.insert(
...organizationIds.map<[string, string]>((organizationId) => [
...organizations.map<[string, string]>(({ organizationId }) => [
organizationId,
user.id,
])
);
return organizationIds;

const data = organizations.flatMap(({ organizationId, organizationRoleIds }) =>
organizationRoleIds.map<[string, string, string]>((organizationRoleId) => [
organizationId,
organizationRoleId,
user.id,
])
);
if (data.length > 0) {
await organizationQueries.relations.rolesUsers.insert(...data);
}

return organizations;
}
}

return [];
};

return [user, { organizationIds: await provisionOrganizations() }];
return [user, { organizations: await provisionOrganizations() }];
});
};

Expand Down
27 changes: 23 additions & 4 deletions packages/core/src/queries/organization/email-domains.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import {
type OrganizationJitEmailDomain,
OrganizationJitEmailDomains,
type CreateOrganizationJitEmailDomain,
OrganizationJitRoles,
} from '@logto/schemas';
import { type CommonQueryMethods, sql } from '@silverhand/slonik';

Expand Down Expand Up @@ -49,13 +50,26 @@ export class EmailDomainQueries {
return [Number(count), rows];
}

async getOrganizationIdsByDomain(emailDomain: string): Promise<readonly string[]> {
const rows = await this.pool.any<Pick<OrganizationJitEmailDomain, 'organizationId'>>(sql`
select ${fields.organizationId}
/**
* Given an email domain, return the organizations and organization roles that need to be
* provisioned.
*/
async getJitOrganizations(emailDomain: string): Promise<readonly JitOrganization[]> {
const { fields } = convertToIdentifiers(OrganizationJitEmailDomains, true);
const organizationJitRoles = convertToIdentifiers(OrganizationJitRoles, true);
return this.pool.any<JitOrganization>(sql`
select
${fields.organizationId},
array_remove(
array_agg(${organizationJitRoles.fields.organizationRoleId}),
null
) as "organizationRoleIds"
from ${table}
left join ${organizationJitRoles.table}
on ${fields.organizationId} = ${organizationJitRoles.fields.organizationId}
where ${fields.emailDomain} = ${emailDomain}
group by ${fields.organizationId}
`);
return rows.map((row) => row.organizationId);
}

async insert(organizationId: string, emailDomain: string): Promise<OrganizationJitEmailDomain> {
Expand Down Expand Up @@ -111,3 +125,8 @@ export class EmailDomainQueries {
});
}
}

export type JitOrganization = {
organizationId: string;
organizationRoleIds: string[];
};
2 changes: 1 addition & 1 deletion packages/core/src/routes/admin-user/basics.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,7 @@ const usersLibraries = {
...mockUser,
...removeUndefinedKeys(user), // No undefined values will be returned from database
},
{ organizationIds: [] },
{ organizations: [] },
]
),
verifyUserPassword,
Expand Down
4 changes: 2 additions & 2 deletions packages/core/src/routes/admin-user/basics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -200,7 +200,7 @@ export default function adminUserBasicsRoutes<T extends ManagementApiRouter>(

const id = await generateUserId();

const [user, { organizationIds }] = await insertUser(
const [user, { organizations }] = await insertUser(
{
id,
primaryEmail,
Expand All @@ -221,7 +221,7 @@ export default function adminUserBasicsRoutes<T extends ManagementApiRouter>(
[]
);

for (const organizationId of organizationIds) {
for (const { organizationId } of organizations) {
ctx.appendDataHookContext('Organization.Membership.Updated', {
...buildManagementApiContext(ctx),
organizationId,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ const usersLibraries = {
...mockUser,
...removeUndefinedKeys(user), // No undefined values will be returned from database
},
{ organizationIds: [] },
{ organizations: [] },
]
),
} satisfies Partial<Libraries['users']>;
Expand Down
2 changes: 1 addition & 1 deletion packages/core/src/routes/admin-user/search.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ const usersLibraries = {
...mockUser,
...removeUndefinedKeys(user), // No undefined values will be returned from database
},
{ organizationIds: [] },
{ organizations: [] },
]
),
} satisfies Partial<Libraries['users']>;
Expand Down
2 changes: 1 addition & 1 deletion packages/core/src/routes/admin-user/social.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ const usersLibraries = {
...mockUser,
...removeUndefinedKeys(user), // No undefined values will be returned from database
},
{ organizationIds: [] },
{ organizations: [] },
]
),
} satisfies Partial<Libraries['users']>;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ const { hasActiveUsers, updateUserById } = userQueries;

const userLibraries = {
generateUserId: jest.fn().mockResolvedValue('uid'),
insertUser: jest.fn().mockResolvedValue([{}, { organizationIds: [] }]),
insertUser: jest.fn().mockResolvedValue([{}, { organizations: [] }]),
};
const { generateUserId, insertUser } = userLibraries;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@ const { hasActiveUsers, updateUserById, hasUserWithEmail, hasUserWithPhone } = u
const userLibraries = {
generateUserId: jest.fn().mockResolvedValue('uid'),
insertUser: jest.fn(
async (user: CreateUser): Promise<InsertUserResult> => [user as User, { organizationIds: [] }]
async (user: CreateUser): Promise<InsertUserResult> => [user as User, { organizations: [] }]
),
};
const { generateUserId, insertUser } = userLibraries;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -135,7 +135,7 @@ async function handleSubmitRegister(
(invitation) => invitation.status === OrganizationInvitationStatus.Pending
);

const [user, { organizationIds }] = await insertUser(
const [user, { organizations: provisionedOrganizations }] = await insertUser(
{
id,
...userProfile,
Expand Down Expand Up @@ -190,7 +190,7 @@ async function handleSubmitRegister(
ctx.assignInteractionHookResult({ userId: id });
ctx.appendDataHookContext('User.Created', { user });

for (const organizationId of organizationIds) {
for (const { organizationId } of provisionedOrganizations) {
ctx.appendDataHookContext('Organization.Membership.Updated', {
organizationId,
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ const updateUserSsoIdentityMock = jest.fn();
const insertUserSsoIdentityMock = jest.fn();
const updateUserMock = jest.fn();
const findUserByEmailMock = jest.fn();
const insertUserMock = jest.fn().mockResolvedValue([{ id: 'foo' }, { organizationIds: [] }]);
const insertUserMock = jest.fn().mockResolvedValue([{ id: 'foo' }, { organizations: [] }]);
const generateUserIdMock = jest.fn().mockResolvedValue('foo');
const getAvailableSsoConnectorsMock = jest.fn();

Expand Down Expand Up @@ -291,7 +291,7 @@ describe('Single sign on util methods tests', () => {

describe('registerWithSsoAuthentication tests', () => {
it('should register if no related user account found', async () => {
insertUserMock.mockResolvedValueOnce([{ id: 'foo' }, { organizationIds: [] }]);
insertUserMock.mockResolvedValueOnce([{ id: 'foo' }, { organizations: [] }]);

const { id } = await registerWithSsoAuthentication(mockContext, tenant, {
connectorId: wellConfiguredSsoConnector.id,
Expand Down
4 changes: 2 additions & 2 deletions packages/core/src/routes/interaction/utils/single-sign-on.ts
Original file line number Diff line number Diff line change
Expand Up @@ -309,15 +309,15 @@ export const registerWithSsoAuthentication = async (
};

// Insert new user
const [user, { organizationIds }] = await usersLibrary.insertUser(
const [user, { organizations }] = await usersLibrary.insertUser(
{
id: await usersLibrary.generateUserId(),
...syncingProfile,
lastSignInAt: Date.now(),
},
[]
);
for (const organizationId of organizationIds) {
for (const { organizationId } of organizations) {
ctx.appendDataHookContext('Organization.Membership.Updated', {
organizationId,
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,58 @@ describe('organization just-in-time provisioning', () => {
await Promise.all([organizationApi.cleanUp(), userApi.cleanUp()]);
});

it('should automatically provision a user to the organization with the matched email domain', async () => {
it('should automatically provision a user to the organizations with roles', async () => {
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() }),
]);
const emailDomain = 'foo.com';
await Promise.all(
organizations.map(async (organization) =>
organizationApi.jit.addEmailDomain(organization.id, emailDomain)
)
);
await Promise.all([
organizationApi.jit.addRole(organizations[0].id, [roles[0].id, roles[1].id]),
organizationApi.jit.addRole(organizations[1].id, [roles[0].id]),
]);

const email = randomString() + '@' + emailDomain;
const { id } = await userApi.create({ primaryEmail: email });

const userOrganizations = await getUserOrganizations(id);
expect(userOrganizations).toEqual(
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 to the organizations without roles', async () => {
const organizations = await Promise.all([
organizationApi.create({ name: 'foo' }),
organizationApi.create({ name: 'bar' }),
organizationApi.create({ name: 'baz' }),
]);
const emailDomain = 'foo.com';
await Promise.all(
Expand All @@ -28,7 +76,20 @@ describe('organization just-in-time provisioning', () => {

const userOrganizations = await getUserOrganizations(id);
expect(userOrganizations).toEqual(
expect.arrayContaining(organizations.map((item) => expect.objectContaining({ id: item.id })))
expect.arrayContaining([
expect.objectContaining({
id: organizations[0].id,
organizationRoles: [],
}),
expect.objectContaining({
id: organizations[1].id,
organizationRoles: [],
}),
expect.objectContaining({
id: organizations[2].id,
organizationRoles: [],
}),
])
);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -45,31 +45,57 @@ describe('organization just-in-time provisioning', () => {
});
});

it('should automatically provision a user to the organization with the matched email domain', async () => {
it('should automatically provision a user to the organization with roles', async () => {
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() }),
]);
const emailDomain = 'foo.com';
await Promise.all(
organizations.map(async (organization) =>
organizationApi.jit.addEmailDomain(organization.id, emailDomain)
)
);
await Promise.all([
organizationApi.jit.addRole(organizations[0].id, [roles[0].id, roles[1].id]),
organizationApi.jit.addRole(organizations[1].id, [roles[0].id]),
]);

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 })))
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: [],
}),
])
);

await logoutClient(client);
await deleteUser(id);
});

it('should automatically provision a user to the organization with the matched email from a SSO identity', async () => {
it('should automatically provision a user with the matched email to the organization 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);
Expand Down

0 comments on commit 71ba7c4

Please sign in to comment.