mirror of
https://github.com/logto-io/logto.git
synced 2024-12-16 20:26:19 -05:00
feat(core): add search and pagination to role scopes (#2925)
This commit is contained in:
parent
9ec3a8fcb7
commit
6d874d5250
8 changed files with 276 additions and 143 deletions
|
@ -25,7 +25,12 @@ const buildResourceConditions = (search: Search) => {
|
|||
};
|
||||
|
||||
export const createScopeQueries = (pool: CommonQueryMethods) => {
|
||||
const findScopes = async (resourceId: string, search: Search, limit?: number, offset?: number) =>
|
||||
const searchScopesByResourceId = async (
|
||||
resourceId: string,
|
||||
search: Search,
|
||||
limit?: number,
|
||||
offset?: number
|
||||
) =>
|
||||
pool.any<Scope>(sql`
|
||||
select ${sql.join(Object.values(fields), sql`, `)}
|
||||
from ${table}
|
||||
|
@ -35,7 +40,22 @@ export const createScopeQueries = (pool: CommonQueryMethods) => {
|
|||
${conditionalSql(offset, (value) => sql`offset ${value}`)}
|
||||
`);
|
||||
|
||||
const countScopes = async (resourceId: string, search: Search) =>
|
||||
const searchScopesByScopeIds = async (
|
||||
scopeIds: string[],
|
||||
search: Search,
|
||||
limit?: number,
|
||||
offset?: number
|
||||
) =>
|
||||
pool.any<Scope>(sql`
|
||||
select ${sql.join(Object.values(fields), sql`, `)}
|
||||
from ${table}
|
||||
where ${fields.id} in (${scopeIds.length > 0 ? sql.join(scopeIds, sql`, `) : sql`null`})
|
||||
${buildResourceConditions(search)}
|
||||
${conditionalSql(limit, (value) => sql`limit ${value}`)}
|
||||
${conditionalSql(offset, (value) => sql`offset ${value}`)}
|
||||
`);
|
||||
|
||||
const countScopesByResourceId = async (resourceId: string, search: Search) =>
|
||||
pool.one<{ count: number }>(sql`
|
||||
select count(*)
|
||||
from ${table}
|
||||
|
@ -43,6 +63,14 @@ export const createScopeQueries = (pool: CommonQueryMethods) => {
|
|||
${buildResourceConditions(search)}
|
||||
`);
|
||||
|
||||
const countScopesByScopeIds = async (scopeIds: string[], search: Search) =>
|
||||
pool.one<{ count: number }>(sql`
|
||||
select count(*)
|
||||
from ${table}
|
||||
where ${fields.id} in (${scopeIds.length > 0 ? sql.join(scopeIds, sql`, `) : sql`null`})
|
||||
${buildResourceConditions(search)}
|
||||
`);
|
||||
|
||||
const findScopeByNameAndResourceId = async (
|
||||
name: string,
|
||||
resourceId: string,
|
||||
|
@ -116,8 +144,10 @@ export const createScopeQueries = (pool: CommonQueryMethods) => {
|
|||
};
|
||||
|
||||
return {
|
||||
findScopes,
|
||||
countScopes,
|
||||
searchScopesByResourceId,
|
||||
searchScopesByScopeIds,
|
||||
countScopesByResourceId,
|
||||
countScopesByScopeIds,
|
||||
findScopeByNameAndResourceId,
|
||||
findScopesByResourceId,
|
||||
findScopesByResourceIds,
|
||||
|
|
|
@ -18,6 +18,7 @@ import logRoutes from './log.js';
|
|||
import phraseRoutes from './phrase.js';
|
||||
import resourceRoutes from './resource.js';
|
||||
import roleRoutes from './role.js';
|
||||
import roleScopeRoutes from './role.scope.js';
|
||||
import settingRoutes from './setting.js';
|
||||
import signInExperiencesRoutes from './sign-in-experience/index.js';
|
||||
import statusRoutes from './status.js';
|
||||
|
@ -41,6 +42,7 @@ const createRouters = (tenant: TenantContext) => {
|
|||
adminUserRoleRoutes(managementRouter, tenant);
|
||||
logRoutes(managementRouter, tenant);
|
||||
roleRoutes(managementRouter, tenant);
|
||||
roleScopeRoutes(managementRouter, tenant);
|
||||
dashboardRoutes(managementRouter, tenant);
|
||||
customPhraseRoutes(managementRouter, tenant);
|
||||
hookRoutes(managementRouter, tenant);
|
||||
|
|
|
@ -28,7 +28,8 @@ const { findResourceById } = resources;
|
|||
|
||||
const scopes = {
|
||||
findScopesByResourceId: async () => [mockScope],
|
||||
findScopes: async () => [mockScope],
|
||||
searchScopesByResourceId: async () => [mockScope],
|
||||
countScopesByResourceId: async () => ({ count: 1 }),
|
||||
insertScope: jest.fn(async () => mockScope),
|
||||
updateScopeById: jest.fn(async () => mockScope),
|
||||
deleteScopeById: jest.fn(),
|
||||
|
|
|
@ -28,9 +28,9 @@ export default function resourceRoutes<T extends AuthedRouter>(
|
|||
deleteResourceById,
|
||||
},
|
||||
scopes: {
|
||||
countScopes,
|
||||
countScopesByResourceId,
|
||||
deleteScopeById,
|
||||
findScopes,
|
||||
searchScopesByResourceId,
|
||||
findScopeByNameAndResourceId,
|
||||
insertScope,
|
||||
updateScopeById,
|
||||
|
@ -137,28 +137,22 @@ export default function resourceRoutes<T extends AuthedRouter>(
|
|||
|
||||
router.get(
|
||||
'/resources/:resourceId/scopes',
|
||||
koaPagination({ isOptional: true }),
|
||||
koaPagination(),
|
||||
koaGuard({ params: object({ resourceId: string().min(1) }) }),
|
||||
async (ctx, next) => {
|
||||
const {
|
||||
params: { resourceId },
|
||||
} = ctx.guard;
|
||||
const { limit, offset, disabled } = ctx.pagination;
|
||||
const { limit, offset } = ctx.pagination;
|
||||
const { searchParams } = ctx.request.URL;
|
||||
|
||||
return tryThat(
|
||||
async () => {
|
||||
const search = parseSearchParamsForSearch(searchParams);
|
||||
|
||||
if (disabled) {
|
||||
ctx.body = await findScopes(resourceId, search);
|
||||
|
||||
return next();
|
||||
}
|
||||
|
||||
const [{ count }, roles] = await Promise.all([
|
||||
countScopes(resourceId, search),
|
||||
findScopes(resourceId, search, limit, offset),
|
||||
countScopesByResourceId(resourceId, search),
|
||||
searchScopesByResourceId(resourceId, search, limit, offset),
|
||||
]);
|
||||
|
||||
// Return totalCount to pagination middleware
|
||||
|
|
95
packages/core/src/routes/role.scope.test.ts
Normal file
95
packages/core/src/routes/role.scope.test.ts
Normal file
|
@ -0,0 +1,95 @@
|
|||
import type { Role } from '@logto/schemas';
|
||||
import { pickDefault } from '@logto/shared/esm';
|
||||
|
||||
import { mockRole, mockScope, mockResource } from '#src/__mocks__/index.js';
|
||||
import { MockTenant } from '#src/test-utils/tenant.js';
|
||||
import { createRequester } from '#src/utils/test-utils.js';
|
||||
|
||||
const { jest } = import.meta;
|
||||
|
||||
const roles = {
|
||||
findRoles: jest.fn(async (): Promise<Role[]> => [mockRole]),
|
||||
countRoles: jest.fn(async () => ({ count: 10 })),
|
||||
insertRole: jest.fn(async (data) => ({
|
||||
...data,
|
||||
id: mockRole.id,
|
||||
})),
|
||||
findRoleById: jest.fn(),
|
||||
updateRoleById: jest.fn(async (id, data) => ({
|
||||
...mockRole,
|
||||
...data,
|
||||
})),
|
||||
findRolesByRoleIds: jest.fn(),
|
||||
};
|
||||
const { findRoleById } = roles;
|
||||
|
||||
const scopes = {
|
||||
findScopeById: jest.fn(),
|
||||
findScopesByIds: jest.fn(),
|
||||
countScopesByScopeIds: jest.fn(async () => ({ count: 1 })),
|
||||
searchScopesByScopeIds: jest.fn(async () => [mockScope]),
|
||||
};
|
||||
const { findScopesByIds } = scopes;
|
||||
|
||||
const resources = {
|
||||
findResourcesByIds: jest.fn(async () => [mockResource]),
|
||||
};
|
||||
|
||||
const rolesScopes = {
|
||||
insertRolesScopes: jest.fn(),
|
||||
findRolesScopesByRoleId: jest.fn(),
|
||||
deleteRolesScope: jest.fn(),
|
||||
};
|
||||
const { insertRolesScopes, findRolesScopesByRoleId } = rolesScopes;
|
||||
|
||||
const users = {
|
||||
findUserById: jest.fn(),
|
||||
};
|
||||
|
||||
const roleRoutes = await pickDefault(import('./role.scope.js'));
|
||||
|
||||
const tenantContext = new MockTenant(undefined, {
|
||||
users,
|
||||
rolesScopes,
|
||||
resources,
|
||||
scopes,
|
||||
roles,
|
||||
});
|
||||
|
||||
describe('role scope routes', () => {
|
||||
const roleRequester = createRequester({ authedRoutes: roleRoutes, tenantContext });
|
||||
|
||||
it('GET /roles/:id/scopes', async () => {
|
||||
findRoleById.mockResolvedValueOnce(mockRole);
|
||||
findRolesScopesByRoleId.mockResolvedValueOnce([]);
|
||||
findScopesByIds.mockResolvedValueOnce([mockScope]);
|
||||
const response = await roleRequester.get(`/roles/${mockRole.id}/scopes`);
|
||||
expect(response.status).toEqual(200);
|
||||
expect(response.body).toEqual([
|
||||
{
|
||||
...mockScope,
|
||||
resource: mockResource,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('POST /roles/:id/scopes', async () => {
|
||||
findRoleById.mockResolvedValueOnce(mockRole);
|
||||
findRolesScopesByRoleId.mockResolvedValue([]);
|
||||
findScopesByIds.mockResolvedValueOnce([]);
|
||||
const response = await roleRequester.post(`/roles/${mockRole.id}/scopes`).send({
|
||||
scopeIds: [mockScope.id],
|
||||
});
|
||||
expect(response.status).toEqual(200);
|
||||
expect(insertRolesScopes).toHaveBeenCalledWith([
|
||||
{ roleId: mockRole.id, scopeId: mockScope.id },
|
||||
]);
|
||||
});
|
||||
|
||||
it('DELETE /roles/:id/scopes/:scopeId', async () => {
|
||||
findRoleById.mockResolvedValueOnce(mockRole);
|
||||
findRolesScopesByRoleId.mockResolvedValueOnce([]);
|
||||
const response = await roleRequester.delete(`/roles/${mockRole.id}/scopes/${mockScope.id}`);
|
||||
expect(response.status).toEqual(204);
|
||||
});
|
||||
});
|
132
packages/core/src/routes/role.scope.ts
Normal file
132
packages/core/src/routes/role.scope.ts
Normal file
|
@ -0,0 +1,132 @@
|
|||
import type { ScopeResponse } from '@logto/schemas';
|
||||
import { tryThat } from '@logto/shared';
|
||||
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 assertThat from '#src/utils/assert-that.js';
|
||||
import { parseSearchParamsForSearch } from '#src/utils/search.js';
|
||||
|
||||
import type { AuthedRouter, RouterInitArgs } from './types.js';
|
||||
|
||||
export default function roleScopeRoutes<T extends AuthedRouter>(
|
||||
...[router, { queries }]: RouterInitArgs<T>
|
||||
) {
|
||||
const {
|
||||
resources: { findResourcesByIds },
|
||||
rolesScopes: { deleteRolesScope, findRolesScopesByRoleId, insertRolesScopes },
|
||||
roles: { findRoleById },
|
||||
scopes: { findScopeById, findScopesByIds, countScopesByScopeIds, searchScopesByScopeIds },
|
||||
} = queries;
|
||||
|
||||
router.get(
|
||||
'/roles/:id/scopes',
|
||||
koaPagination(),
|
||||
koaGuard({
|
||||
params: object({ id: string().min(1) }),
|
||||
}),
|
||||
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 rolesScopes = await findRolesScopesByRoleId(id);
|
||||
const scopeIds = rolesScopes.map(({ scopeId }) => scopeId);
|
||||
|
||||
const [{ count }, scopes] = await Promise.all([
|
||||
countScopesByScopeIds(scopeIds, search),
|
||||
searchScopesByScopeIds(scopeIds, search, limit, offset),
|
||||
]);
|
||||
|
||||
const resources = await findResourcesByIds(scopes.map(({ resourceId }) => resourceId));
|
||||
const result: ScopeResponse[] = scopes.map((scope) => {
|
||||
const resource = resources.find(({ id }) => scope.resourceId);
|
||||
|
||||
assertThat(resource, new Error(`Cannot find resource for id ${scope.resourceId}`));
|
||||
|
||||
return {
|
||||
...scope,
|
||||
resource,
|
||||
};
|
||||
});
|
||||
|
||||
// Return totalCount to pagination middleware
|
||||
ctx.pagination.totalCount = count;
|
||||
ctx.body = result;
|
||||
|
||||
return next();
|
||||
},
|
||||
(error) => {
|
||||
if (error instanceof TypeError) {
|
||||
throw new RequestError(
|
||||
{ code: 'request.invalid_input', details: error.message },
|
||||
error
|
||||
);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
router.post(
|
||||
'/roles/:id/scopes',
|
||||
koaGuard({
|
||||
params: object({ id: string().min(1) }),
|
||||
body: object({ scopeIds: string().min(1).array() }),
|
||||
}),
|
||||
async (ctx, next) => {
|
||||
const {
|
||||
params: { id },
|
||||
body: { scopeIds },
|
||||
} = ctx.guard;
|
||||
|
||||
await findRoleById(id);
|
||||
const rolesScopes = await findRolesScopesByRoleId(id);
|
||||
|
||||
for (const scopeId of scopeIds) {
|
||||
assertThat(
|
||||
!rolesScopes.some(({ scopeId: _scopeId }) => _scopeId === scopeId),
|
||||
new RequestError({
|
||||
code: 'role.scope_exists',
|
||||
status: 422,
|
||||
scopeId,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
await Promise.all(scopeIds.map(async (scopeId) => findScopeById(scopeId)));
|
||||
await insertRolesScopes(scopeIds.map((scopeId) => ({ roleId: id, scopeId })));
|
||||
|
||||
const newRolesScopes = await findRolesScopesByRoleId(id);
|
||||
const scopes = await findScopesByIds(newRolesScopes.map(({ scopeId }) => scopeId));
|
||||
ctx.body = scopes;
|
||||
|
||||
return next();
|
||||
}
|
||||
);
|
||||
|
||||
router.delete(
|
||||
'/roles/:id/scopes/:scopeId',
|
||||
koaGuard({
|
||||
params: object({ id: string().min(1), scopeId: string().min(1) }),
|
||||
}),
|
||||
async (ctx, next) => {
|
||||
const {
|
||||
params: { id, scopeId },
|
||||
} = ctx.guard;
|
||||
await deleteRolesScope(id, scopeId);
|
||||
ctx.status = 204;
|
||||
|
||||
return next();
|
||||
}
|
||||
);
|
||||
}
|
|
@ -28,9 +28,8 @@ const { findRoleByRoleName, findRoleById, deleteRoleById } = roles;
|
|||
|
||||
const scopes = {
|
||||
findScopeById: jest.fn(),
|
||||
findScopesByIds: jest.fn(),
|
||||
};
|
||||
const { findScopeById, findScopesByIds } = scopes;
|
||||
const { findScopeById } = scopes;
|
||||
|
||||
const resources = {
|
||||
findResourcesByIds: jest.fn(async () => [mockResource]),
|
||||
|
@ -38,10 +37,9 @@ const resources = {
|
|||
|
||||
const rolesScopes = {
|
||||
insertRolesScopes: jest.fn(),
|
||||
findRolesScopesByRoleId: jest.fn(),
|
||||
deleteRolesScope: jest.fn(),
|
||||
};
|
||||
const { insertRolesScopes, findRolesScopesByRoleId } = rolesScopes;
|
||||
const { insertRolesScopes } = rolesScopes;
|
||||
|
||||
const users = {
|
||||
findUsersByIds: jest.fn(),
|
||||
|
@ -156,40 +154,6 @@ describe('role routes', () => {
|
|||
expect(deleteRoleById).toHaveBeenCalledWith(mockRole.id);
|
||||
});
|
||||
|
||||
it('GET /roles/:id/scopes', async () => {
|
||||
findRoleById.mockResolvedValueOnce(mockRole);
|
||||
findRolesScopesByRoleId.mockResolvedValueOnce([]);
|
||||
findScopesByIds.mockResolvedValueOnce([mockScope]);
|
||||
const response = await roleRequester.get(`/roles/${mockRole.id}/scopes`);
|
||||
expect(response.status).toEqual(200);
|
||||
expect(response.body).toEqual([
|
||||
{
|
||||
...mockScope,
|
||||
resource: mockResource,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('POST /roles/:id/scopes', async () => {
|
||||
findRoleById.mockResolvedValueOnce(mockRole);
|
||||
findRolesScopesByRoleId.mockResolvedValue([]);
|
||||
findScopesByIds.mockResolvedValueOnce([]);
|
||||
const response = await roleRequester.post(`/roles/${mockRole.id}/scopes`).send({
|
||||
scopeIds: [mockScope.id],
|
||||
});
|
||||
expect(response.status).toEqual(200);
|
||||
expect(insertRolesScopes).toHaveBeenCalledWith([
|
||||
{ roleId: mockRole.id, scopeId: mockScope.id },
|
||||
]);
|
||||
});
|
||||
|
||||
it('DELETE /roles/:id/scopes/:scopeId', async () => {
|
||||
findRoleById.mockResolvedValueOnce(mockRole);
|
||||
findRolesScopesByRoleId.mockResolvedValueOnce([]);
|
||||
const response = await roleRequester.delete(`/roles/${mockRole.id}/scopes/${mockScope.id}`);
|
||||
expect(response.status).toEqual(204);
|
||||
});
|
||||
|
||||
it('GET /roles/:id/users', async () => {
|
||||
findRoleById.mockResolvedValueOnce(mockRole);
|
||||
findUsersRolesByRoleId.mockResolvedValueOnce([]);
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
import { buildIdGenerator } from '@logto/core-kit';
|
||||
import type { RoleResponse, ScopeResponse } from '@logto/schemas';
|
||||
import type { RoleResponse } from '@logto/schemas';
|
||||
import { userInfoSelectFields, Roles } from '@logto/schemas';
|
||||
import { tryThat } from '@logto/shared';
|
||||
import { pick } from '@silverhand/essentials';
|
||||
|
@ -19,8 +19,7 @@ export default function roleRoutes<T extends AuthedRouter>(
|
|||
...[router, { queries }]: RouterInitArgs<T>
|
||||
) {
|
||||
const {
|
||||
resources: { findResourcesByIds },
|
||||
rolesScopes: { deleteRolesScope, findRolesScopesByRoleId, insertRolesScopes },
|
||||
rolesScopes: { insertRolesScopes },
|
||||
roles: {
|
||||
countRoles,
|
||||
deleteRoleById,
|
||||
|
@ -30,7 +29,7 @@ export default function roleRoutes<T extends AuthedRouter>(
|
|||
insertRole,
|
||||
updateRoleById,
|
||||
},
|
||||
scopes: { findScopeById, findScopesByIds },
|
||||
scopes: { findScopeById },
|
||||
users: { findUserById, findUsersByIds },
|
||||
usersRoles: {
|
||||
countUsersRolesByRoleId,
|
||||
|
@ -189,90 +188,6 @@ export default function roleRoutes<T extends AuthedRouter>(
|
|||
}
|
||||
);
|
||||
|
||||
router.get(
|
||||
'/roles/:id/scopes',
|
||||
koaGuard({
|
||||
params: object({ id: string().min(1) }),
|
||||
}),
|
||||
async (ctx, next) => {
|
||||
const {
|
||||
params: { id },
|
||||
} = ctx.guard;
|
||||
|
||||
await findRoleById(id);
|
||||
const rolesScopes = await findRolesScopesByRoleId(id);
|
||||
const scopes = await findScopesByIds(rolesScopes.map(({ scopeId }) => scopeId));
|
||||
const resources = await findResourcesByIds(scopes.map(({ resourceId }) => resourceId));
|
||||
const result: ScopeResponse[] = scopes.map((scope) => {
|
||||
const resource = resources.find(({ id }) => scope.resourceId);
|
||||
|
||||
assertThat(resource, new Error(`Cannot find resource for id ${scope.resourceId}`));
|
||||
|
||||
return {
|
||||
...scope,
|
||||
resource,
|
||||
};
|
||||
});
|
||||
|
||||
ctx.body = result;
|
||||
|
||||
return next();
|
||||
}
|
||||
);
|
||||
|
||||
router.post(
|
||||
'/roles/:id/scopes',
|
||||
koaGuard({
|
||||
params: object({ id: string().min(1) }),
|
||||
body: object({ scopeIds: string().min(1).array() }),
|
||||
}),
|
||||
async (ctx, next) => {
|
||||
const {
|
||||
params: { id },
|
||||
body: { scopeIds },
|
||||
} = ctx.guard;
|
||||
|
||||
await findRoleById(id);
|
||||
const rolesScopes = await findRolesScopesByRoleId(id);
|
||||
|
||||
for (const scopeId of scopeIds) {
|
||||
assertThat(
|
||||
!rolesScopes.some(({ scopeId: _scopeId }) => _scopeId === scopeId),
|
||||
new RequestError({
|
||||
code: 'role.scope_exists',
|
||||
status: 422,
|
||||
scopeId,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
await Promise.all(scopeIds.map(async (scopeId) => findScopeById(scopeId)));
|
||||
await insertRolesScopes(scopeIds.map((scopeId) => ({ roleId: id, scopeId })));
|
||||
|
||||
const newRolesScopes = await findRolesScopesByRoleId(id);
|
||||
const scopes = await findScopesByIds(newRolesScopes.map(({ scopeId }) => scopeId));
|
||||
ctx.body = scopes;
|
||||
|
||||
return next();
|
||||
}
|
||||
);
|
||||
|
||||
router.delete(
|
||||
'/roles/:id/scopes/:scopeId',
|
||||
koaGuard({
|
||||
params: object({ id: string().min(1), scopeId: string().min(1) }),
|
||||
}),
|
||||
async (ctx, next) => {
|
||||
const {
|
||||
params: { id, scopeId },
|
||||
} = ctx.guard;
|
||||
await deleteRolesScope(id, scopeId);
|
||||
ctx.status = 204;
|
||||
|
||||
return next();
|
||||
}
|
||||
);
|
||||
|
||||
router.get(
|
||||
'/roles/:id/users',
|
||||
koaGuard({
|
||||
|
|
Loading…
Reference in a new issue