diff --git a/packages/core/src/queries/roles-scopes.ts b/packages/core/src/queries/roles-scopes.ts new file mode 100644 index 000000000..8193a0568 --- /dev/null +++ b/packages/core/src/queries/roles-scopes.ts @@ -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(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); + } +}; diff --git a/packages/core/src/queries/scope.ts b/packages/core/src/queries/scope.ts index f9562968e..707e63aa6 100644 --- a/packages/core/src/queries/scope.ts +++ b/packages/core/src/queries/scope.ts @@ -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(sql` + select ${sql.join(Object.values(fields), sql`, `)} + from ${table} + where ${fields.id} in (${sql.join(scopeIds, sql`, `)}) + `) + : []; + export const insertScope = buildInsertInto(Scopes, { returning: true, }); diff --git a/packages/core/src/routes/role.test.ts b/packages/core/src/routes/role.test.ts index 34dd8d07e..ad38c72e7 100644 --- a/packages/core/src/routes/role.test.ts +++ b/packages/core/src/routes/role.test.ts @@ -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); + }); }); diff --git a/packages/core/src/routes/role.ts b/packages/core/src/routes/role.ts index 9cb1e35c6..772f1a476 100644 --- a/packages/core/src/routes/role.ts +++ b/packages/core/src/routes/role.ts @@ -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(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(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(); + } + ); } diff --git a/packages/phrases/src/locales/de/errors.ts b/packages/phrases/src/locales/de/errors.ts index 88c2c890e..9a52b5e9f 100644 --- a/packages/phrases/src/locales/de/errors.ts +++ b/packages/phrases/src/locales/de/errors.ts @@ -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 }, }; diff --git a/packages/phrases/src/locales/en/errors.ts b/packages/phrases/src/locales/en/errors.ts index f2252a246..df24c6ebb 100644 --- a/packages/phrases/src/locales/en/errors.ts +++ b/packages/phrases/src/locales/en/errors.ts @@ -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', }, }; diff --git a/packages/phrases/src/locales/fr/errors.ts b/packages/phrases/src/locales/fr/errors.ts index 36bf1a6fc..22f8fb869 100644 --- a/packages/phrases/src/locales/fr/errors.ts +++ b/packages/phrases/src/locales/fr/errors.ts @@ -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 }, }; diff --git a/packages/phrases/src/locales/ko/errors.ts b/packages/phrases/src/locales/ko/errors.ts index 5ce6e516e..be4903c1a 100644 --- a/packages/phrases/src/locales/ko/errors.ts +++ b/packages/phrases/src/locales/ko/errors.ts @@ -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 }, }; diff --git a/packages/phrases/src/locales/pt-br/errors.ts b/packages/phrases/src/locales/pt-br/errors.ts index 748bce7fd..73d2c9510 100644 --- a/packages/phrases/src/locales/pt-br/errors.ts +++ b/packages/phrases/src/locales/pt-br/errors.ts @@ -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 }, }; diff --git a/packages/phrases/src/locales/pt-pt/errors.ts b/packages/phrases/src/locales/pt-pt/errors.ts index a14806ce4..17110ac89 100644 --- a/packages/phrases/src/locales/pt-pt/errors.ts +++ b/packages/phrases/src/locales/pt-pt/errors.ts @@ -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 }, }; diff --git a/packages/phrases/src/locales/tr-tr/errors.ts b/packages/phrases/src/locales/tr-tr/errors.ts index 28f0ca11f..505b231fe 100644 --- a/packages/phrases/src/locales/tr-tr/errors.ts +++ b/packages/phrases/src/locales/tr-tr/errors.ts @@ -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 }, }; diff --git a/packages/phrases/src/locales/zh-cn/errors.ts b/packages/phrases/src/locales/zh-cn/errors.ts index ee7fd7b07..d9e530d1c 100644 --- a/packages/phrases/src/locales/zh-cn/errors.ts +++ b/packages/phrases/src/locales/zh-cn/errors.ts @@ -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 }, }; diff --git a/packages/schemas/alterations/next-1672901841-roles-and-scopes-not-null.ts b/packages/schemas/alterations/next-1672901841-roles-and-scopes-not-null.ts new file mode 100644 index 000000000..26d07b5bf --- /dev/null +++ b/packages/schemas/alterations/next-1672901841-roles-and-scopes-not-null.ts @@ -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; diff --git a/packages/schemas/tables/scopesroles.sql b/packages/schemas/tables/scopesroles.sql index b659da2f4..227f7271b 100644 --- a/packages/schemas/tables/scopesroles.sql +++ b/packages/schemas/tables/scopesroles.sql @@ -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) );