0
Fork 0
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:
wangsijie 2023-01-06 15:37:00 +08:00 committed by GitHub
parent d3e8ea142a
commit f371a61460
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
14 changed files with 235 additions and 21 deletions

View 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);
}
};

View file

@ -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,
});

View file

@ -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);
});
});

View file

@ -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();
}
);
}

View file

@ -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
},
};

View file

@ -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',
},
};

View file

@ -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
},
};

View file

@ -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
},
};

View file

@ -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
},
};

View file

@ -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
},
};

View file

@ -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
},
};

View file

@ -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
},
};

View file

@ -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;

View file

@ -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)
);