0
Fork 0
mirror of https://github.com/logto-io/logto.git synced 2024-12-30 20:33:54 -05:00

feat(core): add role-application related APIs (#4384)

* feat(core): add role-app related APIs

* chore(core): add search for GET role-application
This commit is contained in:
Darcy Ye 2023-09-11 14:04:37 +08:00 committed by GitHub
parent 709ba633b8
commit 285aa745e7
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
29 changed files with 710 additions and 204 deletions

View file

@ -58,6 +58,14 @@ export const mockScopeWithResource = {
resource: mockResource,
};
export const mockAdminApplicationRole: Role = {
tenantId: 'fake_tenant',
id: 'role_id',
name: 'admin',
description: 'admin application',
type: RoleType.MachineToMachine,
};
export const mockAdminUserRole: Role = {
tenantId: 'fake_tenant',
id: 'role_id',

View file

@ -1,7 +1,7 @@
import type { CreateApplication } from '@logto/schemas';
import type { Application, CreateApplication } from '@logto/schemas';
import { ApplicationType, Applications } from '@logto/schemas';
import type { OmitAutoSetFields } from '@logto/shared';
import { convertToIdentifiers } from '@logto/shared';
import { convertToIdentifiers, conditionalSql } from '@logto/shared';
import type { CommonQueryMethods } from 'slonik';
import { sql } from 'slonik';
@ -11,23 +11,45 @@ import { buildInsertIntoWithPool } from '#src/database/insert-into.js';
import { getTotalRowCountWithPool } from '#src/database/row-count.js';
import { buildUpdateWhereWithPool } from '#src/database/update-where.js';
import { DeletionError } from '#src/errors/SlonikError/index.js';
import { buildConditionsFromSearch } from '#src/utils/search.js';
import type { Search } from '#src/utils/search.js';
const { table, fields } = convertToIdentifiers(Applications);
const buildApplicationConditions = (search: Search) => {
const hasSearch = search.matches.length > 0;
const searchFields = [
Applications.fields.id,
Applications.fields.name,
Applications.fields.description,
];
return conditionalSql(
hasSearch,
() => sql`and ${buildConditionsFromSearch(search, searchFields)}`
);
};
export const createApplicationQueries = (pool: CommonQueryMethods) => {
const findTotalNumberOfApplications = async () => getTotalRowCountWithPool(pool)(table);
const findAllApplications = buildFindAllEntitiesWithPool(pool)(Applications, [
{ field: 'createdAt', order: 'desc' },
]);
const findApplicationById = buildFindEntityByIdWithPool(pool)(Applications);
const insertApplication = buildInsertIntoWithPool(pool)(Applications, {
returning: true,
});
const updateApplication = buildUpdateWhereWithPool(pool)(Applications, true);
const updateApplicationById = async (
id: string,
set: Partial<OmitAutoSetFields<CreateApplication>>
) => updateApplication({ set, where: { id }, jsonbMode: 'merge' });
const countNonM2mApplications = async () => {
const { count } = await pool.one<{ count: string }>(sql`
select count(*)
@ -37,6 +59,7 @@ export const createApplicationQueries = (pool: CommonQueryMethods) => {
return { count: Number(count) };
};
const countM2mApplications = async () => {
const { count } = await pool.one<{ count: string }>(sql`
select count(*)
@ -47,6 +70,43 @@ export const createApplicationQueries = (pool: CommonQueryMethods) => {
return { count: Number(count) };
};
const countM2mApplicationsByIds = async (search: Search, applicationIds: string[]) => {
if (applicationIds.length === 0) {
return { count: 0 };
}
const { count } = await pool.one<{ count: string }>(sql`
select count(*)
from ${table}
where ${fields.type} = ${ApplicationType.MachineToMachine}
and ${fields.id} in (${sql.join(applicationIds, sql`, `)})
${buildApplicationConditions(search)}
`);
return { count: Number(count) };
};
const findM2mApplicationsByIds = async (
search: Search,
limit: number,
offset: number,
applicationIds: string[]
) => {
if (applicationIds.length === 0) {
return [];
}
return pool.any<Application>(sql`
select ${sql.join(Object.values(fields), sql`, `)}
from ${table}
where ${fields.type} = ${ApplicationType.MachineToMachine}
and ${fields.id} in (${sql.join(applicationIds, sql`, `)})
${buildApplicationConditions(search)}
limit ${limit}
offset ${offset}
`);
};
const deleteApplicationById = async (id: string) => {
const { rowCount } = await pool.query(sql`
delete from ${table}
@ -67,6 +127,8 @@ export const createApplicationQueries = (pool: CommonQueryMethods) => {
updateApplicationById,
countNonM2mApplications,
countM2mApplications,
countM2mApplicationsByIds,
findM2mApplicationsByIds,
deleteApplicationById,
};
};

View file

@ -1,6 +1,7 @@
import type { ApplicationsRole, CreateApplicationsRole, Role } from '@logto/schemas';
import { Roles, ApplicationsRoles, RolesScopes } from '@logto/schemas';
import { convertToIdentifiers } from '@logto/shared';
import { type Nullable } from '@silverhand/essentials';
import type { CommonQueryMethods } from 'slonik';
import { sql } from 'slonik';
@ -10,6 +11,27 @@ const { table, fields } = convertToIdentifiers(ApplicationsRoles, true);
const { fields: insertFields } = convertToIdentifiers(ApplicationsRoles);
export const createApplicationsRolesQueries = (pool: CommonQueryMethods) => {
const findFirstApplicationsRolesByRoleIdAndApplicationIds = async (
roleId: string,
applicationIds: string[]
): Promise<Nullable<ApplicationsRole>> =>
applicationIds.length > 0
? pool.maybeOne<ApplicationsRole>(sql`
select ${sql.join(Object.values(fields), sql`,`)}
from ${table}
where ${fields.roleId}=${roleId}
and ${fields.applicationId} in (${sql.join(applicationIds, sql`, `)})
limit 1
`)
: null;
const countApplicationsRolesByRoleId = async (roleId: string) =>
pool.one<{ count: number }>(sql`
select count(*)
from ${table}
where ${fields.roleId}=${roleId}
`);
const findApplicationsRolesByApplicationId = async (applicationId: string) =>
pool.any<ApplicationsRole & { role: Role }>(sql`
select
@ -20,6 +42,14 @@ export const createApplicationsRolesQueries = (pool: CommonQueryMethods) => {
where ${fields.applicationId}=${applicationId}
`);
const findApplicationsRolesByRoleId = async (roleId: string) =>
pool.any<ApplicationsRole>(sql`
select
${sql.join(Object.values(fields), sql`,`)}
from ${table}
where ${fields.roleId}=${roleId}
`);
const insertApplicationsRoles = async (applicationsRoles: CreateApplicationsRole[]) =>
pool.query(sql`
insert into ${table} (${insertFields.id}, ${insertFields.applicationId}, ${
@ -45,7 +75,10 @@ export const createApplicationsRolesQueries = (pool: CommonQueryMethods) => {
};
return {
findFirstApplicationsRolesByRoleIdAndApplicationIds,
countApplicationsRolesByRoleId,
findApplicationsRolesByApplicationId,
findApplicationsRolesByRoleId,
insertApplicationsRoles,
deleteApplicationRole,
};

View file

@ -24,11 +24,11 @@ const buildRoleConditions = (search: Search) => {
);
};
export const defaultUserSearch = { matches: [], isCaseSensitive: false, joint: SearchJointMode.Or };
export const defaultSearch = { matches: [], isCaseSensitive: false, joint: SearchJointMode.Or };
export const createRolesQueries = (pool: CommonQueryMethods) => {
const countRoles = async (
search: Search = defaultUserSearch,
search: Search = defaultSearch,
{ excludeRoleIds = [], roleIds }: { excludeRoleIds?: string[]; roleIds?: string[] } = {}
) => {
const { count } = await pool.one<{ count: string }>(sql`

View file

@ -103,6 +103,31 @@ describe('user role routes', () => {
expect(deleteUsersRolesByUserIdAndRoleId).not.toHaveBeenCalled();
expect(insertUsersRoles).toHaveBeenCalledWith([]);
});
it('POST /users/:id/roles', async () => {
findUsersRolesByUserId.mockResolvedValueOnce([]);
const response = await roleRequester.post(`/users/${mockUser.id}/roles`).send({
roleIds: [mockAdminUserRole.id],
});
expect(response.status).toEqual(201);
expect(insertUsersRoles).toHaveBeenCalledWith([
{ id: mockId, userId: mockUser.id, roleId: mockAdminUserRole.id },
]);
});
it('PUT /users/:id/roles', async () => {
findUsersRolesByUserId.mockResolvedValueOnce([mockUserRole]);
const response = await roleRequester.put(`/users/${mockUser.id}/roles`).send({
roleIds: [mockAdminUserRole2.id],
});
expect(response.status).toEqual(200);
expect(deleteUsersRolesByUserIdAndRoleId).toHaveBeenCalledWith(
mockUser.id,
mockAdminUserRole.id
);
expect(insertUsersRoles).toHaveBeenCalledWith([
{ id: mockId, userId: mockUser.id, roleId: mockAdminUserRole2.id },
]);
});
it('DELETE /users/:id/roles/:roleId', async () => {
@ -116,3 +141,4 @@ describe('user role routes', () => {
);
});
});
});

View file

@ -0,0 +1,87 @@
import { pickDefault } from '@logto/shared/esm';
import { mockAdminApplicationRole, mockApplication } from '#src/__mocks__/index.js';
import { mockId, mockStandardId } from '#src/test-utils/nanoid.js';
import { createMockQuotaLibrary } from '#src/test-utils/quota.js';
import { MockTenant } from '#src/test-utils/tenant.js';
import { createRequester } from '#src/utils/test-utils.js';
const { jest } = import.meta;
await mockStandardId();
const roles = {
findRoleById: jest.fn(),
};
const { findRoleById } = roles;
const applications = {
findApplicationById: jest.fn(),
countM2mApplicationsByIds: jest.fn(async () => ({ count: 1 })),
findM2mApplicationsByIds: jest.fn(async () => [mockApplication]),
};
const applicationsRoles = {
findFirstApplicationsRolesByRoleIdAndApplicationIds: jest.fn(),
findApplicationsRolesByRoleId: jest.fn(),
insertApplicationsRoles: jest.fn(),
deleteApplicationRole: jest.fn(),
};
const {
findFirstApplicationsRolesByRoleIdAndApplicationIds,
findApplicationsRolesByRoleId,
insertApplicationsRoles,
deleteApplicationRole,
} = applicationsRoles;
const roleUserRoutes = await pickDefault(import('./role.application.js'));
const tenantContext = new MockTenant(
undefined,
{
applicationsRoles,
applications,
roles,
},
undefined,
{ quota: createMockQuotaLibrary() }
);
describe('role application routes', () => {
const roleUserRequester = createRequester({ authedRoutes: roleUserRoutes, tenantContext });
it('GET /roles/:id/applications', async () => {
findRoleById.mockResolvedValueOnce(mockAdminApplicationRole);
findApplicationsRolesByRoleId.mockResolvedValueOnce([]);
const response = await roleUserRequester.get(
`/roles/${mockAdminApplicationRole.id}/applications`
);
expect(response.status).toEqual(200);
expect(response.body[0]).toHaveProperty('id', mockApplication.id);
});
it('POST /roles/:id/applications', async () => {
findRoleById.mockResolvedValueOnce(mockAdminApplicationRole);
findFirstApplicationsRolesByRoleIdAndApplicationIds.mockResolvedValueOnce(null);
const response = await roleUserRequester
.post(`/roles/${mockAdminApplicationRole.id}/applications`)
.send({
applicationIds: [mockApplication.id],
});
expect(response.status).toEqual(201);
expect(insertApplicationsRoles).toHaveBeenCalledWith([
{ id: mockId, applicationId: mockApplication.id, roleId: mockAdminApplicationRole.id },
]);
});
it('DELETE /roles/:id/applications/:applicationId', async () => {
const response = await roleUserRequester.delete(
`/roles/${mockAdminApplicationRole.id}/applications/${mockApplication.id}`
);
expect(response.status).toEqual(204);
expect(deleteApplicationRole).toHaveBeenCalledWith(
mockApplication.id,
mockAdminApplicationRole.id
);
});
});

View file

@ -0,0 +1,132 @@
import { Applications } from '@logto/schemas';
import { generateStandardId } from '@logto/shared';
import { tryThat } from '@silverhand/essentials';
import { object, string } from 'zod';
import RequestError from '#src/errors/RequestError/index.js';
import koaGuard from '#src/middleware/koa-guard.js';
import koaPagination from '#src/middleware/koa-pagination.js';
import { parseSearchParamsForSearch } from '#src/utils/search.js';
import type { AuthedRouter, RouterInitArgs } from './types.js';
export default function roleApplicationRoutes<T extends AuthedRouter>(
...[router, { queries }]: RouterInitArgs<T>
) {
const {
roles: { findRoleById },
applications: { countM2mApplicationsByIds, findM2mApplicationsByIds, findApplicationById },
applicationsRoles: {
findFirstApplicationsRolesByRoleIdAndApplicationIds,
findApplicationsRolesByRoleId,
insertApplicationsRoles,
deleteApplicationRole,
},
} = queries;
router.get(
'/roles/:id/applications',
koaPagination(),
koaGuard({
params: object({ id: string().min(1) }),
response: Applications.guard.array(),
status: [200, 204, 400, 404],
}),
async (ctx, next) => {
const {
params: { id },
} = ctx.guard;
const { limit, offset } = ctx.pagination;
const { searchParams } = ctx.request.URL;
await findRoleById(id);
return tryThat(
async () => {
const search = parseSearchParamsForSearch(searchParams);
const applicationRoles = await findApplicationsRolesByRoleId(id);
const applicationIds = applicationRoles.map(({ applicationId }) => applicationId);
const [{ count }, applications] = await Promise.all([
countM2mApplicationsByIds(search, applicationIds),
findM2mApplicationsByIds(search, limit, offset, applicationIds),
]);
ctx.pagination.totalCount = count;
ctx.body = applications;
return next();
},
(error) => {
if (error instanceof TypeError) {
throw new RequestError(
{ code: 'request.invalid_input', details: error.message },
error
);
}
throw error;
}
);
}
);
router.post(
'/roles/:id/applications',
koaGuard({
params: object({ id: string().min(1) }),
body: object({ applicationIds: string().min(1).array().nonempty() }),
status: [201, 404, 422],
}),
async (ctx, next) => {
const {
params: { id },
body: { applicationIds },
} = ctx.guard;
await findRoleById(id);
const existingRecord = await findFirstApplicationsRolesByRoleIdAndApplicationIds(
id,
applicationIds
);
if (existingRecord) {
throw new RequestError({
code: 'role.application_exists',
status: 422,
userId: existingRecord.applicationId,
});
}
await Promise.all(
applicationIds.map(async (applicationId) => findApplicationById(applicationId))
);
await insertApplicationsRoles(
applicationIds.map((applicationId) => ({
id: generateStandardId(),
roleId: id,
applicationId,
}))
);
ctx.status = 201;
return next();
}
);
router.delete(
'/roles/:id/applications/:applicationId',
koaGuard({
params: object({ id: string().min(1), applicationId: string().min(1) }),
status: [204, 404],
}),
async (ctx, next) => {
const {
params: { id, applicationId },
} = ctx.guard;
await deleteApplicationRole(applicationId, id);
ctx.status = 204;
return next();
}
);
}

View file

@ -1,9 +1,8 @@
import type { Role } from '@logto/schemas';
import { RoleType } from '@logto/schemas';
import { pickDefault } from '@logto/shared/esm';
import { mockAdminUserRole, mockScope, mockUser, mockResource } from '#src/__mocks__/index.js';
import { mockId, mockStandardId } from '#src/test-utils/nanoid.js';
import { mockAdminUserRole, mockScope, mockUser } from '#src/__mocks__/index.js';
import { mockStandardId } from '#src/test-utils/nanoid.js';
import { createMockQuotaLibrary } from '#src/test-utils/quota.js';
import { MockTenant } from '#src/test-utils/tenant.js';
import { createRequester } from '#src/utils/test-utils.js';
@ -30,7 +29,6 @@ const roles = {
...data,
tenantId: 'fake_tenant',
})),
findRolesByRoleIds: jest.fn(),
};
const { findRoleByRoleName, findRoleById, deleteRoleById } = roles;
@ -39,36 +37,22 @@ const scopes = {
};
const { findScopeById } = scopes;
const resources = {
findResourcesByIds: jest.fn(async () => [mockResource]),
};
const rolesScopes = {
insertRolesScopes: jest.fn(),
deleteRolesScope: jest.fn(),
};
const { insertRolesScopes } = rolesScopes;
const users = {
findUsersByIds: jest.fn(),
findUserById: jest.fn(),
countUsers: jest.fn(async () => ({ count: 1 })),
findUsers: jest.fn(async () => [mockUser]),
};
const { findUsersByIds } = users;
const usersRoles = {
insertUsersRoles: jest.fn(),
countUsersRolesByRoleId: jest.fn(),
findUsersRolesByRoleId: jest.fn(),
deleteUsersRolesByUserIdAndRoleId: jest.fn(),
};
const {
insertUsersRoles,
findUsersRolesByRoleId,
deleteUsersRolesByUserIdAndRoleId,
countUsersRolesByRoleId,
} = usersRoles;
const { findUsersRolesByRoleId, countUsersRolesByRoleId } = usersRoles;
const roleRoutes = await pickDefault(import('./role.js'));
@ -78,7 +62,6 @@ const tenantContext = new MockTenant(
usersRoles,
users,
rolesScopes,
resources,
scopes,
roles,
},
@ -167,49 +150,4 @@ describe('role routes', () => {
expect(response.status).toEqual(204);
expect(deleteRoleById).toHaveBeenCalledWith(mockAdminUserRole.id);
});
it('GET /roles/:id/users', async () => {
findRoleById.mockResolvedValueOnce(mockAdminUserRole);
findUsersRolesByRoleId.mockResolvedValueOnce([]);
findUsersByIds.mockResolvedValueOnce([mockUser]);
const response = await roleRequester.get(`/roles/${mockAdminUserRole.id}/users`);
expect(response.status).toEqual(200);
expect(response.body[0]).toHaveProperty('id', mockUser.id);
});
describe('POST /roles/:id/users', () => {
it('Succeed', async () => {
findRoleById.mockResolvedValueOnce(mockAdminUserRole);
findUsersRolesByRoleId.mockResolvedValueOnce([]);
const response = await roleRequester.post(`/roles/${mockAdminUserRole.id}/users`).send({
userIds: [mockUser.id],
});
expect(response.status).toEqual(201);
expect(insertUsersRoles).toHaveBeenCalledWith([
{ id: mockId, userId: mockUser.id, roleId: mockAdminUserRole.id },
]);
});
it('Should throw error when trying to assign machine to machine role to users', async () => {
const mockMachineToMachineRole = { ...mockAdminUserRole, type: RoleType.MachineToMachine };
findRoleById.mockResolvedValueOnce(mockMachineToMachineRole);
const response = await roleRequester
.post(`/roles/${mockMachineToMachineRole.id}/users`)
.send({
userIds: [mockUser.id],
});
expect(response.status).toEqual(422);
});
});
it('DELETE /roles/:id/users/:userId', async () => {
const response = await roleRequester.delete(
`/roles/${mockAdminUserRole.id}/users/${mockUser.id}`
);
expect(response.status).toEqual(204);
expect(deleteUsersRolesByUserIdAndRoleId).toHaveBeenCalledWith(
mockUser.id,
mockAdminUserRole.id
);
});
});

View file

@ -1,13 +1,7 @@
import type { RoleResponse } from '@logto/schemas';
import {
userInfoSelectFields,
userProfileResponseGuard,
Roles,
RoleType,
Users,
} from '@logto/schemas';
import { Roles, Users } from '@logto/schemas';
import { generateStandardId } from '@logto/shared';
import { pick, tryThat } from '@silverhand/essentials';
import { tryThat } from '@silverhand/essentials';
import { object, string, z, number } from 'zod';
import RequestError from '#src/errors/RequestError/index.js';
@ -18,17 +12,15 @@ import koaRoleRlsErrorHandler from '#src/middleware/koa-role-rls-error-handler.j
import assertThat from '#src/utils/assert-that.js';
import { parseSearchParamsForSearch } from '#src/utils/search.js';
import roleApplicationRoutes from './role.application.js';
import roleUserRoutes from './role.user.js';
import type { AuthedRouter, RouterInitArgs } from './types.js';
export default function roleRoutes<T extends AuthedRouter>(
...[
router,
{
export default function roleRoutes<T extends AuthedRouter>(...[router, tenant]: RouterInitArgs<T>) {
const {
queries,
libraries: { quota },
},
]: RouterInitArgs<T>
) {
} = tenant;
const {
rolesScopes: { insertRolesScopes },
roles: {
@ -41,14 +33,8 @@ export default function roleRoutes<T extends AuthedRouter>(
updateRoleById,
},
scopes: { findScopeById },
users: { findUserById, findUsersByIds, countUsers, findUsers },
usersRoles: {
countUsersRolesByRoleId,
deleteUsersRolesByUserIdAndRoleId,
findUsersRolesByRoleId,
findUsersRolesByUserId,
insertUsersRoles,
},
users: { findUsersByIds },
usersRoles: { countUsersRolesByRoleId, findUsersRolesByRoleId, findUsersRolesByUserId },
} = queries;
router.use('/roles(/.*)?', koaRoleRlsErrorHandler());
@ -228,101 +214,6 @@ export default function roleRoutes<T extends AuthedRouter>(
}
);
router.get(
'/roles/:id/users',
koaPagination(),
koaGuard({
params: object({ id: string().min(1) }),
response: userProfileResponseGuard.array(),
status: [200, 400, 404],
}),
async (ctx, next) => {
const {
params: { id },
} = ctx.guard;
const { limit, offset } = ctx.pagination;
const { searchParams } = ctx.request.URL;
await findRoleById(id);
return tryThat(
async () => {
const search = parseSearchParamsForSearch(searchParams);
const usersRoles = await findUsersRolesByRoleId(id);
const userIds = usersRoles.map(({ userId }) => userId);
const [{ count }, users] = await Promise.all([
countUsers(search, undefined, userIds),
findUsers(limit, offset, search, undefined, userIds),
]);
ctx.pagination.totalCount = count;
ctx.body = users.map((user) => pick(user, ...userInfoSelectFields));
return next();
},
(error) => {
if (error instanceof TypeError) {
throw new RequestError(
{ code: 'request.invalid_input', details: error.message },
error
);
}
throw error;
}
);
}
);
router.post(
'/roles/:id/users',
koaGuard({
params: object({ id: string().min(1) }),
body: object({ userIds: string().min(1).array().nonempty() }),
status: [201, 404, 422],
}),
async (ctx, next) => {
const {
params: { id },
body: { userIds },
} = ctx.guard;
const role = await findRoleById(id);
assertThat(
role.type === RoleType.User,
new RequestError({ code: 'user.invalid_role_type', status: 422, roleId: role.id })
);
const usersRoles = await findUsersRolesByRoleId(id);
const existingUserIds = new Set(usersRoles.map(({ userId }) => userId));
const userIdsToAdd = userIds.filter((id) => !existingUserIds.has(id)); // Skip existing user ids.
if (userIdsToAdd.length > 0) {
await Promise.all(userIdsToAdd.map(async (userId) => findUserById(userId)));
await insertUsersRoles(
userIdsToAdd.map((userId) => ({ id: generateStandardId(), roleId: id, userId }))
);
}
ctx.status = 201;
return next();
}
);
router.delete(
'/roles/:id/users/:userId',
koaGuard({
params: object({ id: string().min(1), userId: string().min(1) }),
status: [204, 404],
}),
async (ctx, next) => {
const {
params: { id, userId },
} = ctx.guard;
await deleteUsersRolesByUserIdAndRoleId(userId, id);
ctx.status = 204;
return next();
}
);
roleUserRoutes(router, tenant);
roleApplicationRoutes(router, tenant);
}

View file

@ -0,0 +1,83 @@
import { pickDefault } from '@logto/shared/esm';
import { mockAdminUserRole, mockUser } from '#src/__mocks__/index.js';
import { mockId, mockStandardId } from '#src/test-utils/nanoid.js';
import { createMockQuotaLibrary } from '#src/test-utils/quota.js';
import { MockTenant } from '#src/test-utils/tenant.js';
import { createRequester } from '#src/utils/test-utils.js';
const { jest } = import.meta;
await mockStandardId();
const roles = {
findRoleById: jest.fn(),
};
const { findRoleById } = roles;
const users = {
findUserById: jest.fn(),
countUsers: jest.fn(async () => ({ count: 1 })),
findUsers: jest.fn(async () => [mockUser]),
};
const usersRoles = {
insertUsersRoles: jest.fn(),
findUsersRolesByRoleId: jest.fn(),
findFirstUsersRolesByRoleIdAndUserIds: jest.fn(),
deleteUsersRolesByUserIdAndRoleId: jest.fn(),
};
const {
insertUsersRoles,
findUsersRolesByRoleId,
deleteUsersRolesByUserIdAndRoleId,
findFirstUsersRolesByRoleIdAndUserIds,
} = usersRoles;
const roleUserRoutes = await pickDefault(import('./role.user.js'));
const tenantContext = new MockTenant(
undefined,
{
usersRoles,
users,
roles,
},
undefined,
{ quota: createMockQuotaLibrary() }
);
describe('role user routes', () => {
const roleUserRequester = createRequester({ authedRoutes: roleUserRoutes, tenantContext });
it('GET /roles/:id/users', async () => {
findRoleById.mockResolvedValueOnce(mockAdminUserRole);
findUsersRolesByRoleId.mockResolvedValueOnce([]);
const response = await roleUserRequester.get(`/roles/${mockAdminUserRole.id}/users`);
expect(response.status).toEqual(200);
expect(response.body[0]).toHaveProperty('id', mockUser.id);
});
it('POST /roles/:id/users', async () => {
findRoleById.mockResolvedValueOnce(mockAdminUserRole);
findFirstUsersRolesByRoleIdAndUserIds.mockResolvedValueOnce(null);
const response = await roleUserRequester.post(`/roles/${mockAdminUserRole.id}/users`).send({
userIds: [mockUser.id],
});
expect(response.status).toEqual(201);
expect(insertUsersRoles).toHaveBeenCalledWith([
{ id: mockId, userId: mockUser.id, roleId: mockAdminUserRole.id },
]);
});
it('DELETE /roles/:id/users/:userId', async () => {
const response = await roleUserRequester.delete(
`/roles/${mockAdminUserRole.id}/users/${mockUser.id}`
);
expect(response.status).toEqual(204);
expect(deleteUsersRolesByUserIdAndRoleId).toHaveBeenCalledWith(
mockUser.id,
mockAdminUserRole.id
);
});
});

View file

@ -0,0 +1,123 @@
import { userInfoSelectFields, userProfileResponseGuard } from '@logto/schemas';
import { generateStandardId } from '@logto/shared';
import { pick, tryThat } from '@silverhand/essentials';
import { object, string } from 'zod';
import RequestError from '#src/errors/RequestError/index.js';
import koaGuard from '#src/middleware/koa-guard.js';
import koaPagination from '#src/middleware/koa-pagination.js';
import { parseSearchParamsForSearch } from '#src/utils/search.js';
import type { AuthedRouter, RouterInitArgs } from './types.js';
export default function roleUserRoutes<T extends AuthedRouter>(
...[router, { queries }]: RouterInitArgs<T>
) {
const {
roles: { findRoleById },
users: { findUserById, countUsers, findUsers },
usersRoles: {
deleteUsersRolesByUserIdAndRoleId,
findFirstUsersRolesByRoleIdAndUserIds,
findUsersRolesByRoleId,
insertUsersRoles,
},
} = queries;
router.get(
'/roles/:id/users',
koaPagination(),
koaGuard({
params: object({ id: string().min(1) }),
response: userProfileResponseGuard.array(),
status: [200, 400, 404],
}),
async (ctx, next) => {
const {
params: { id },
} = ctx.guard;
const { limit, offset } = ctx.pagination;
const { searchParams } = ctx.request.URL;
await findRoleById(id);
return tryThat(
async () => {
const search = parseSearchParamsForSearch(searchParams);
const usersRoles = await findUsersRolesByRoleId(id);
const userIds = usersRoles.map(({ userId }) => userId);
const [{ count }, users] = await Promise.all([
countUsers(search, undefined, userIds),
findUsers(limit, offset, search, undefined, userIds),
]);
ctx.pagination.totalCount = count;
ctx.body = users.map((user) => pick(user, ...userInfoSelectFields));
return next();
},
(error) => {
if (error instanceof TypeError) {
throw new RequestError(
{ code: 'request.invalid_input', details: error.message },
error
);
}
throw error;
}
);
}
);
router.post(
'/roles/:id/users',
koaGuard({
params: object({ id: string().min(1) }),
body: object({ userIds: string().min(1).array().nonempty() }),
status: [201, 404, 422],
}),
async (ctx, next) => {
const {
params: { id },
body: { userIds },
} = ctx.guard;
await findRoleById(id);
const existingRecord = await findFirstUsersRolesByRoleIdAndUserIds(id, userIds);
if (existingRecord) {
throw new RequestError({
code: 'role.user_exists',
status: 422,
userId: existingRecord.userId,
});
}
await Promise.all(userIds.map(async (userId) => findUserById(userId)));
await insertUsersRoles(
userIds.map((userId) => ({ id: generateStandardId(), roleId: id, userId }))
);
ctx.status = 201;
return next();
}
);
router.delete(
'/roles/:id/users/:userId',
koaGuard({
params: object({ id: string().min(1), userId: string().min(1) }),
status: [204, 404],
}),
async (ctx, next) => {
const {
params: { id, userId },
} = ctx.guard;
await deleteUsersRolesByUserIdAndRoleId(userId, id);
ctx.status = 204;
return next();
}
);
}

View file

@ -1,4 +1,4 @@
import type { CreateRole, Role, Scope, User } from '@logto/schemas';
import type { CreateRole, Role, Scope, User, Application } from '@logto/schemas';
import { RoleType } from '@logto/schemas';
import { generateRoleName } from '#src/utils.js';
@ -65,3 +65,14 @@ export const assignUsersToRole = async (userIds: string[], roleId: string) =>
export const deleteUserFromRole = async (userId: string, roleId: string) =>
authedAdminApi.delete(`roles/${roleId}/users/${userId}`);
export const getRoleApplications = async (roleId: string) =>
authedAdminApi.get(`roles/${roleId}/applications`).json<Application[]>();
export const assignApplicationsToRole = async (applicationIds: string[], roleId: string) =>
authedAdminApi.post(`roles/${roleId}/applications`, {
json: { applicationIds },
});
export const deleteApplicationFromRole = async (applicationId: string, roleId: string) =>
authedAdminApi.delete(`roles/${roleId}/applications/${applicationId}`);

View file

@ -0,0 +1,95 @@
import { ApplicationType, RoleType } from '@logto/schemas';
import { generateStandardId } from '@logto/shared';
import { HTTPError } from 'got';
import { createApplication } from '#src/api/index.js';
import {
assignApplicationsToRole,
createRole,
deleteApplicationFromRole,
getRoleApplications,
} from '#src/api/role.js';
describe('roles applications', () => {
it('should get role applications successfully', async () => {
const role = await createRole({ type: RoleType.MachineToMachine });
const m2mApp = await createApplication(generateStandardId(), ApplicationType.MachineToMachine);
await assignApplicationsToRole([m2mApp.id], role.id);
const applications = await getRoleApplications(role.id);
expect(applications.length).toBe(1);
expect(applications[0]).toHaveProperty('id', m2mApp.id);
});
it('should return 404 if role not found', async () => {
const response = await getRoleApplications('not-found').catch((error: unknown) => error);
expect(response instanceof HTTPError && response.response.statusCode).toBe(404);
});
it('should assign applications to role successfully', async () => {
const role = await createRole({ type: RoleType.MachineToMachine });
const m2mApp1 = await createApplication(generateStandardId(), ApplicationType.MachineToMachine);
const m2mApp2 = await createApplication(generateStandardId(), ApplicationType.MachineToMachine);
await assignApplicationsToRole([m2mApp1.id, m2mApp2.id], role.id);
const applications = await getRoleApplications(role.id);
expect(applications.length).toBe(2);
});
it('should fail when try to assign empty applications', async () => {
const role = await createRole({ type: RoleType.MachineToMachine });
const response = await assignApplicationsToRole([], role.id).catch((error: unknown) => error);
expect(response instanceof HTTPError && response.response.statusCode).toBe(400);
});
it('should fail with invalid application input', async () => {
const role = await createRole({ type: RoleType.MachineToMachine });
const response = await assignApplicationsToRole([''], role.id).catch((error: unknown) => error);
expect(response instanceof HTTPError && response.response.statusCode).toBe(400);
});
it('should fail if role not found', async () => {
const m2mApp = await createApplication(generateStandardId(), ApplicationType.MachineToMachine);
const response = await assignApplicationsToRole([m2mApp.id], 'not-found').catch(
(error: unknown) => error
);
expect(response instanceof HTTPError && response.response.statusCode).toBe(404);
});
it('should fail if application not found', async () => {
const role = await createRole({ type: RoleType.MachineToMachine });
const response = await assignApplicationsToRole(['not-found'], role.id).catch(
(error: unknown) => error
);
expect(response instanceof HTTPError && response.response.statusCode).toBe(404);
});
it('should remove application from role successfully', async () => {
const role = await createRole({ type: RoleType.MachineToMachine });
const m2mApp = await createApplication(generateStandardId(), ApplicationType.MachineToMachine);
await assignApplicationsToRole([m2mApp.id], role.id);
const applications = await getRoleApplications(role.id);
expect(applications.length).toBe(1);
await deleteApplicationFromRole(m2mApp.id, role.id);
const newApplications = await getRoleApplications(role.id);
expect(newApplications.length).toBe(0);
});
it('should fail if role not found when trying to remove application from role', async () => {
const m2mApp = await createApplication(generateStandardId(), ApplicationType.MachineToMachine);
const response = await deleteApplicationFromRole(m2mApp.id, 'not-found').catch(
(error: unknown) => error
);
expect(response instanceof HTTPError && response.response.statusCode).toBe(404);
});
it('should fail if application not found when trying to remove application from role', async () => {
const role = await createRole({ type: RoleType.MachineToMachine });
const response = await deleteApplicationFromRole('not-found', role.id).catch(
(error: unknown) => error
);
expect(response instanceof HTTPError && response.response.statusCode).toBe(404);
});
});

View file

@ -36,7 +36,7 @@ describe('roles users', () => {
const m2mRole = await createRole({ type: RoleType.MachineToMachine });
const user = await createUser(generateNewUserProfile({}));
await expectRejects(assignUsersToRole([user.id], m2mRole.id), {
code: 'user.invalid_role_type',
code: 'entity.db_constraint_violated',
statusCode: 422,
});
const users = await getRoleUsers(m2mRole.id);

View file

@ -2,6 +2,8 @@ const role = {
name_in_use: 'Dieser Rollenname {{name}} wird bereits verwendet.',
scope_exists: 'Die Scope-ID {{scopeId}} wurde bereits zu dieser Rolle hinzugefügt.',
user_exists: 'Die Benutzer-ID {{userId}} wurde bereits zu dieser Rolle hinzugefügt.',
application_exists:
'Die Anwendungs-ID {{applicationId}} wurde bereits zu dieser Rolle hinzugefügt.',
default_role_missing:
'Einige der Standardrollennamen sind in der Datenbank nicht vorhanden. Bitte stellen Sie sicher, dass Sie zuerst Rollen erstellen.',
internal_role_violation:

View file

@ -2,6 +2,7 @@ const role = {
name_in_use: 'This role name {{name}} is already in use',
scope_exists: 'The scope id {{scopeId}} has already been added to this role',
user_exists: 'The user id {{userId}} is already been added to this role',
application_exists: 'The application id {{applicationId}} is already been added to this role',
default_role_missing:
'Some of the default roleNames does not exist in database, please ensure to create roles first',
internal_role_violation:

View file

@ -2,6 +2,7 @@ const role = {
name_in_use: 'Este nombre de rol {{name}} ya está en uso',
scope_exists: 'El id de alcance {{scopeId}} ya ha sido agregado a este rol',
user_exists: 'El id de usuario {{userId}} ya ha sido agregado a este rol',
application_exists: 'El id de aplicación {{applicationId}} ya ha sido agregado a este rol',
default_role_missing:
'Algunos de los nombres de roles predeterminados no existen en la base de datos, por favor asegúrese de crear los roles primero',
internal_role_violation:

View file

@ -2,6 +2,7 @@ const role = {
name_in_use: 'Ce nom de rôle {{name}} est déjà utilisé',
scope_exists: "L'identifiant de portée {{scopeId}} a déjà été ajouté à ce rôle",
user_exists: "L'identifiant d'utilisateur {{userId}} a déjà été ajouté à ce rôle",
application_exists: "L'identifiant d'application {{applicationId}} a déjà été ajouté à ce rôle",
default_role_missing:
"Certains noms de rôles par défaut n'existent pas dans la base de données, veuillez vous assurer de créer d'abord des rôles",
internal_role_violation:

View file

@ -2,6 +2,8 @@ const role = {
name_in_use: 'Il nome di ruolo {{name}} è già in uso',
scope_exists: "L'identificatore di ambito {{scopeId}} è già stato aggiunto a questo ruolo",
user_exists: "L'identificatore di utente {{userId}} è già stato aggiunto a questo ruolo",
application_exists:
"L'ID dell'applicazione {{applicationId}} è già stato aggiunto a questo ruolo",
default_role_missing:
'Alcuni dei nomi di ruolo predefiniti non esistono nel database, assicurati di creare prima i ruoli',
internal_role_violation:

View file

@ -2,6 +2,7 @@ const role = {
name_in_use: 'このロール名{{name}}はすでに使用されています',
scope_exists: 'スコープID {{scopeId}}はすでにこのロールに追加されています',
user_exists: 'ユーザーID{{userId}}はすでにこのロールに追加されています',
application_exists: 'アプリケーション ID {{applicationId}} はすでにこのロールに追加されています',
default_role_missing:
'データベースにデフォルトロール名が存在しないものがあります。ロールを作成してください',
internal_role_violation:

View file

@ -2,6 +2,7 @@ const role = {
name_in_use: '역할 이름 {{name}}이/가 이미 사용 중이에요.',
scope_exists: '범위 ID {{scopeId}}이/가 이미 이 역할에 추가되어 있어요.',
user_exists: '사용자 ID {{userId}}이/가 이미 이 역할에 추가되어 있어요.',
application_exists: '애플리케이션 ID {{applicationId}} 가 이미 이 역할에 추가되어 있어요.',
default_role_missing:
'기본 역할 이름의 일부가 데이터베이스에 존재하지 않아요. 먼저 역할을 생성해 주세요.',
internal_role_violation:

View file

@ -2,10 +2,11 @@ const role = {
name_in_use: 'Ta nazwa roli {{name}} jest już w użyciu',
scope_exists: 'Identyfikator zakresu {{scopeId}} został już dodany do tej roli',
user_exists: 'Identyfikator użytkownika {{userId}} został już dodany do tej roli',
application_exists: 'Identyfikator aplikacji {{applicationId}} został już dodany do tej roli',
default_role_missing:
'Niektóre z domyślnych nazw ról nie istnieją w bazie danych, upewnij się, że najpierw utworzysz role',
internal_role_violation:
'Możesz próbować zaktualizować lub usunąć rolę wewnętrzną, co jest zabronione przez Logto. Jeśli tworzysz nową rolę, spróbuj innej nazwy, która nie zaczyna się od "#internal:".',
'Możesz próbować zaktualizować lub usunąć rolę wewnętrzną, co jest zabronione przez Logto. Jeśli tworzysz nową rolę, spróbuj innej nazwy, która nie zaczyna się od "#internal:".',
};
export default Object.freeze(role);

View file

@ -2,6 +2,7 @@ const role = {
name_in_use: 'Este nome de papel {{name}} já está em uso',
scope_exists: 'O id de escopo {{scopeId}} já foi adicionado a este papel',
user_exists: 'O id de usuário {{userId}} já foi adicionado a este papel',
application_exists: 'O id do aplicativo {{applicationId}} já foi adicionado a este papel',
default_role_missing:
'Alguns dos nomes de função padrão não existem no banco de dados, certifique-se de criar funções primeiro',
internal_role_violation:

View file

@ -2,6 +2,7 @@ const role = {
name_in_use: 'Este nome de função {{name}} já está em uso',
scope_exists: 'O id do escopo {{scopeId}} já foi adicionado a esta função',
user_exists: 'O id do usuário {{userId}} já foi adicionado a esta função',
application_exists: 'O id de aplicação {{applicationId}} já foi adicionado a esta função',
default_role_missing:
'Alguns dos nomes de função padrão não existem no banco de dados, por favor, certifique-se de criar as funções primeiro',
internal_role_violation:

View file

@ -2,6 +2,7 @@ const role = {
name_in_use: 'Это имя роли {{name}} уже используется',
scope_exists: 'Идентификатор области действия {{scopeId}} уже был добавлен в эту роль',
user_exists: 'Идентификатор пользователя {{userId}} уже был добавлен в эту роль',
application_exists: 'Идентификатор приложения {{applicationId}} уже был добавлен в эту роль',
default_role_missing:
'Некоторые имена ролей по умолчанию отсутствуют в базе данных, пожалуйста, убедитесь в том, что сначала создали роли',
internal_role_violation:

View file

@ -2,6 +2,7 @@ const role = {
name_in_use: 'Bu rol adı {{name}} zaten kullanımda',
scope_exists: 'Bu kapsam kimliği {{scopeId}} zaten bu role eklendi',
user_exists: 'Bu kullanıcı kimliği {{userId}} zaten bu role eklendi',
application_exists: 'Bu uygulama kimliği {{applicationId}} zaten bu role eklendi',
default_role_missing:
'Varsayılan rol adlarından bazıları veritabanında mevcut değil, lütfen önce rolleri oluşturduğunuzdan emin olun',
internal_role_violation:

View file

@ -2,6 +2,7 @@ const role = {
name_in_use: '此角色名称 {{name}} 已被使用',
scope_exists: '作用域 ID {{scopeId}} 已添加到此角色',
user_exists: '用户 ID {{userId}} 已添加到此角色',
application_exists: '应用程序 ID {{applicationId}} 已添加到此角色',
default_role_missing: '某些默认角色名称在数据库中不存在,请确保先创建角色',
internal_role_violation:
'你可能正在尝试更新或删除 Logto 禁止的内部角色。如果你要创建新角色,请尝试使用不以“#internal:”开头的名称。',

View file

@ -2,6 +2,7 @@ const role = {
name_in_use: '此角色名稱 {{name}} 已被使用',
scope_exists: '作用域 ID {{scopeId}} 已添加到此角色',
user_exists: '用戶 ID {{userId}} 已添加到此角色',
application_exists: '應用程式 ID {{applicationId}} 已添加到此角色',
default_role_missing: '某些默認角色名稱在數據庫中不存在,請確保先創建角色',
internal_role_violation:
'你可能正在嘗試更新或刪除 Logto 禁止的內部角色。如果你要創建新角色,請嘗試使用不以“#internal:”開頭的名稱。',

View file

@ -2,6 +2,7 @@ const role = {
name_in_use: '此角色名稱 {{name}} 已被使用',
scope_exists: '作用域 ID {{scopeId}} 已添加到此角色',
user_exists: '用戶 ID {{userId}} 已添加到此角色',
application_exists: '已經將應用程式 ID {{applicationId}} 添加到此角色',
default_role_missing: '某些預設角色名稱在資料庫中不存在,請確保先創建角色',
internal_role_violation:
'你可能正在嘗試更新或刪除 Logto 禁止的內部角色。如果你要創建新角色,請嘗試使用不以“#internal:”開頭的名稱。',