mirror of
https://github.com/logto-io/logto.git
synced 2024-12-16 20:26:19 -05:00
feat(core): role-scope api (#2826)
This commit is contained in:
parent
d3e8ea142a
commit
f371a61460
14 changed files with 235 additions and 21 deletions
36
packages/core/src/queries/roles-scopes.ts
Normal file
36
packages/core/src/queries/roles-scopes.ts
Normal file
|
@ -0,0 +1,36 @@
|
|||
import type { RolesScope } from '@logto/schemas';
|
||||
import { RolesScopes } from '@logto/schemas';
|
||||
import { convertToIdentifiers } from '@logto/shared';
|
||||
import { sql } from 'slonik';
|
||||
|
||||
import envSet from '#src/env-set/index.js';
|
||||
import { DeletionError } from '#src/errors/SlonikError/index.js';
|
||||
|
||||
const { table, fields } = convertToIdentifiers(RolesScopes);
|
||||
|
||||
export const insertRolesScopes = async (rolesScopes: RolesScope[]) =>
|
||||
envSet.pool.query(sql`
|
||||
insert into ${table} (${fields.scopeId}, ${fields.roleId}) values
|
||||
${sql.join(
|
||||
rolesScopes.map(({ scopeId, roleId }) => sql`(${scopeId}, ${roleId})`),
|
||||
sql`, `
|
||||
)}
|
||||
`);
|
||||
|
||||
export const findRolesScopesByRoleId = async (roleId: string) =>
|
||||
envSet.pool.any<RolesScope>(sql`
|
||||
select ${sql.join(Object.values(fields), sql`,`)}
|
||||
from ${table}
|
||||
where ${fields.roleId}=${roleId}
|
||||
`);
|
||||
|
||||
export const deleteRolesScope = async (roleId: string, scopeId: string) => {
|
||||
const { rowCount } = await envSet.pool.query(sql`
|
||||
delete from ${table}
|
||||
where ${fields.scopeId} = ${scopeId} and ${fields.roleId} = ${roleId}
|
||||
`);
|
||||
|
||||
if (rowCount < 1) {
|
||||
throw new DeletionError(RolesScopes.table);
|
||||
}
|
||||
};
|
|
@ -26,6 +26,15 @@ export const findScopesByResourceIds = async (resourceIds: string[]) =>
|
|||
where ${fields.resourceId} in (${sql.join(resourceIds, sql`, `)})
|
||||
`);
|
||||
|
||||
export const findScopesByIds = async (scopeIds: string[]) =>
|
||||
scopeIds.length > 0
|
||||
? envSet.pool.any<Scope>(sql`
|
||||
select ${sql.join(Object.values(fields), sql`, `)}
|
||||
from ${table}
|
||||
where ${fields.id} in (${sql.join(scopeIds, sql`, `)})
|
||||
`)
|
||||
: [];
|
||||
|
||||
export const insertScope = buildInsertInto<CreateScope, Scope>(Scopes, {
|
||||
returning: true,
|
||||
});
|
||||
|
|
|
@ -1,12 +1,12 @@
|
|||
import type { Role } from '@logto/schemas';
|
||||
import { pickDefault, createMockUtils } from '@logto/shared/esm';
|
||||
|
||||
import { mockRole } from '#src/__mocks__/index.js';
|
||||
import { mockRole, mockScope } from '#src/__mocks__/index.js';
|
||||
import { createRequester } from '#src/utils/test-utils.js';
|
||||
|
||||
const { jest } = import.meta;
|
||||
|
||||
const { mockEsm } = createMockUtils(jest);
|
||||
const { mockEsm, mockEsmWithActual } = createMockUtils(jest);
|
||||
|
||||
const { findRoleByRoleName, findRoleById, deleteRoleById } = mockEsm(
|
||||
'#src/queries/roles.js',
|
||||
|
@ -25,6 +25,18 @@ const { findRoleByRoleName, findRoleById, deleteRoleById } = mockEsm(
|
|||
})),
|
||||
})
|
||||
);
|
||||
const { findScopeById, findScopesByIds } = await mockEsmWithActual('#src/queries/scope.js', () => ({
|
||||
findScopeById: jest.fn(),
|
||||
findScopesByIds: jest.fn(),
|
||||
}));
|
||||
const { insertRolesScopes, findRolesScopesByRoleId } = await mockEsmWithActual(
|
||||
'#src/queries/roles-scopes.js',
|
||||
() => ({
|
||||
insertRolesScopes: jest.fn(),
|
||||
findRolesScopesByRoleId: jest.fn(),
|
||||
deleteRolesScope: jest.fn(),
|
||||
})
|
||||
);
|
||||
const roleRoutes = await pickDefault(import('./role.js'));
|
||||
|
||||
describe('role routes', () => {
|
||||
|
@ -45,6 +57,19 @@ describe('role routes', () => {
|
|||
expect(findRoleByRoleName).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('POST /roles with scopeIds', async () => {
|
||||
const { name, description } = mockRole;
|
||||
|
||||
const response = await roleRequester
|
||||
.post('/roles')
|
||||
.send({ name, description, scopeIds: [mockScope.id] });
|
||||
expect(response.status).toEqual(200);
|
||||
expect(response.body).toEqual(mockRole);
|
||||
expect(findRoleByRoleName).toHaveBeenCalled();
|
||||
expect(findScopeById).toHaveBeenCalledWith(mockScope.id);
|
||||
expect(insertRolesScopes).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('GET /roles/:id', async () => {
|
||||
findRoleById.mockResolvedValueOnce(mockRole);
|
||||
const response = await roleRequester.get(`/roles/${mockRole.id}`);
|
||||
|
@ -80,4 +105,32 @@ describe('role routes', () => {
|
|||
expect(response.status).toEqual(204);
|
||||
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]);
|
||||
});
|
||||
|
||||
it('POST /roles/:id/scopes', async () => {
|
||||
findRoleById.mockResolvedValueOnce(mockRole);
|
||||
findRolesScopesByRoleId.mockResolvedValueOnce([]);
|
||||
const response = await roleRequester.post(`/roles/${mockRole.id}/scopes`).send({
|
||||
scopeIds: [mockScope.id],
|
||||
});
|
||||
expect(response.status).toEqual(201);
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
|
|
@ -1,8 +1,14 @@
|
|||
import { buildIdGenerator } from '@logto/core-kit';
|
||||
import { Roles } from '@logto/schemas';
|
||||
import { object, string } from 'zod';
|
||||
import { object, string, z } from 'zod';
|
||||
|
||||
import RequestError from '#src/errors/RequestError/index.js';
|
||||
import koaGuard from '#src/middleware/koa-guard.js';
|
||||
import {
|
||||
deleteRolesScope,
|
||||
findRolesScopesByRoleId,
|
||||
insertRolesScopes,
|
||||
} from '#src/queries/roles-scopes.js';
|
||||
import {
|
||||
deleteRoleById,
|
||||
findAllRoles,
|
||||
|
@ -11,6 +17,7 @@ import {
|
|||
insertRole,
|
||||
updateRoleById,
|
||||
} from '#src/queries/roles.js';
|
||||
import { findScopeById, findScopesByIds } from '#src/queries/scope.js';
|
||||
import assertThat from '#src/utils/assert-that.js';
|
||||
|
||||
import type { AuthedRouter } from './types.js';
|
||||
|
@ -27,21 +34,34 @@ export default function roleRoutes<T extends AuthedRouter>(router: T) {
|
|||
router.post(
|
||||
'/roles',
|
||||
koaGuard({
|
||||
body: Roles.createGuard.omit({ id: true }),
|
||||
body: Roles.createGuard
|
||||
.omit({ id: true })
|
||||
.extend({ scopeIds: z.string().min(1).array().optional() }),
|
||||
}),
|
||||
async (ctx, next) => {
|
||||
const {
|
||||
body,
|
||||
body: { name },
|
||||
} = ctx.guard;
|
||||
const { body } = ctx.guard;
|
||||
const { scopeIds, ...roleBody } = body;
|
||||
|
||||
assertThat(!(await findRoleByRoleName(name)), 'role.name_in_use');
|
||||
assertThat(
|
||||
!(await findRoleByRoleName(roleBody.name)),
|
||||
new RequestError({
|
||||
code: 'role.name_in_use',
|
||||
name: roleBody.name,
|
||||
})
|
||||
);
|
||||
|
||||
ctx.body = await insertRole({
|
||||
...body,
|
||||
const role = await insertRole({
|
||||
...roleBody,
|
||||
id: roleId(),
|
||||
});
|
||||
|
||||
if (scopeIds) {
|
||||
await Promise.all(scopeIds.map(async (scopeId) => findScopeById(scopeId)));
|
||||
await insertRolesScopes(scopeIds.map((scopeId) => ({ roleId: role.id, scopeId })));
|
||||
}
|
||||
|
||||
ctx.body = role;
|
||||
|
||||
return next();
|
||||
}
|
||||
);
|
||||
|
@ -98,4 +118,72 @@ export default function roleRoutes<T extends AuthedRouter>(router: T) {
|
|||
return next();
|
||||
}
|
||||
);
|
||||
|
||||
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);
|
||||
ctx.body = await findScopesByIds(rolesScopes.map(({ scopeId }) => scopeId));
|
||||
|
||||
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 })));
|
||||
ctx.status = 201;
|
||||
|
||||
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();
|
||||
}
|
||||
);
|
||||
}
|
||||
|
|
|
@ -176,7 +176,8 @@ const errors = {
|
|||
invalid_type: 'Der Log Typ ist ungültig.',
|
||||
},
|
||||
role: {
|
||||
name_in_use: 'This role name is already in use', // UNTRANSLATED
|
||||
name_in_use: 'This role name {{name}} is already in use', // UNTRANSLATED
|
||||
scope_exists: 'The scope id {{scopeId}} has already been added to this role', // UNTRANSLATED
|
||||
},
|
||||
};
|
||||
|
||||
|
|
|
@ -175,7 +175,8 @@ const errors = {
|
|||
invalid_type: 'The log type is invalid.',
|
||||
},
|
||||
role: {
|
||||
name_in_use: 'This role name isalready in use',
|
||||
name_in_use: 'This role name {{name}} is already in use',
|
||||
scope_exists: 'The scope id {{scopeId}} has already been added to this role',
|
||||
},
|
||||
};
|
||||
|
||||
|
|
|
@ -182,7 +182,8 @@ const errors = {
|
|||
invalid_type: 'The log type is invalid.', // UNTRANSLATED
|
||||
},
|
||||
role: {
|
||||
name_in_use: 'This role name isalready in use', // UNTRANSLATED
|
||||
name_in_use: 'This role name {{name}} is already in use', // UNTRANSLATED
|
||||
scope_exists: 'The scope id {{scopeId}} has already been added to this role', // UNTRANSLATED
|
||||
},
|
||||
};
|
||||
|
||||
|
|
|
@ -169,7 +169,8 @@ const errors = {
|
|||
invalid_type: '로그 종류가 유효하지 않아요.',
|
||||
},
|
||||
role: {
|
||||
name_in_use: 'This role name isalready in use', // UNTRANSLATED
|
||||
name_in_use: 'This role name {{name}} is already in use', // UNTRANSLATED
|
||||
scope_exists: 'The scope id {{scopeId}} has already been added to this role', // UNTRANSLATED
|
||||
},
|
||||
};
|
||||
|
||||
|
|
|
@ -183,7 +183,8 @@ const errors = {
|
|||
invalid_type: 'O tipo de registro é inválido.',
|
||||
},
|
||||
role: {
|
||||
name_in_use: 'This role name isalready in use', // UNTRANSLATED
|
||||
name_in_use: 'This role name {{name}} is already in use', // UNTRANSLATED
|
||||
scope_exists: 'The scope id {{scopeId}} has already been added to this role', // UNTRANSLATED
|
||||
},
|
||||
};
|
||||
|
||||
|
|
|
@ -177,7 +177,8 @@ const errors = {
|
|||
invalid_type: 'The log type is invalid.', // UNTRANSLATED
|
||||
},
|
||||
role: {
|
||||
name_in_use: 'This role name isalready in use', // UNTRANSLATED
|
||||
name_in_use: 'This role name {{name}} is already in use', // UNTRANSLATED
|
||||
scope_exists: 'The scope id {{scopeId}} has already been added to this role', // UNTRANSLATED
|
||||
},
|
||||
};
|
||||
|
||||
|
|
|
@ -177,7 +177,8 @@ const errors = {
|
|||
invalid_type: 'The log type is invalid.', // UNTRANSLATED
|
||||
},
|
||||
role: {
|
||||
name_in_use: 'This role name isalready in use', // UNTRANSLATED
|
||||
name_in_use: 'This role name {{name}} is already in use', // UNTRANSLATED
|
||||
scope_exists: 'The scope id {{scopeId}} has already been added to this role', // UNTRANSLATED
|
||||
},
|
||||
};
|
||||
|
||||
|
|
|
@ -158,7 +158,8 @@ const errors = {
|
|||
invalid_type: 'The log type is invalid.', // UNTRANSLATED
|
||||
},
|
||||
role: {
|
||||
name_in_use: 'This role name isalready in use', // UNTRANSLATED
|
||||
name_in_use: 'This role name {{name}} is already in use', // UNTRANSLATED
|
||||
scope_exists: 'The scope id {{scopeId}} has already been added to this role', // UNTRANSLATED
|
||||
},
|
||||
};
|
||||
|
||||
|
|
|
@ -0,0 +1,20 @@
|
|||
import { sql } from 'slonik';
|
||||
|
||||
import type { AlterationScript } from '../lib/types/alteration.js';
|
||||
|
||||
const alteration: AlterationScript = {
|
||||
up: async (pool) => {
|
||||
await pool.query(sql`
|
||||
ALTER TABLE roles_scopes ALTER COLUMN role_id SET NOT NULL;
|
||||
ALTER TABLE roles_scopes ALTER COLUMN scope_id SET NOT NULL;
|
||||
`);
|
||||
},
|
||||
down: async (pool) => {
|
||||
await pool.query(sql`
|
||||
ALTER TABLE roles_scopes ALTER COLUMN role_id DROP NOT NULL;
|
||||
ALTER TABLE roles_scopes ALTER COLUMN scope_id DROP NOT NULL;
|
||||
`);
|
||||
},
|
||||
};
|
||||
|
||||
export default alteration;
|
|
@ -1,5 +1,5 @@
|
|||
create table roles_scopes (
|
||||
role_id varchar(21) references roles (id) on update cascade on delete cascade,
|
||||
scope_id varchar(21) references scopes (id) on update cascade on delete cascade,
|
||||
role_id varchar(21) not null references roles (id) on update cascade on delete cascade,
|
||||
scope_id varchar(21) not null references scopes (id) on update cascade on delete cascade,
|
||||
primary key (role_id, scope_id)
|
||||
);
|
||||
|
|
Loading…
Reference in a new issue