0
Fork 0
mirror of https://github.com/logto-io/logto.git synced 2024-12-16 20:26:19 -05:00

feat(core): organization role APIs

This commit is contained in:
Gao Sun 2023-10-13 18:57:34 +08:00
parent d5a87623de
commit 57af573fe1
No known key found for this signature in database
GPG key ID: 13EBE123E4773688
12 changed files with 484 additions and 66 deletions

View file

@ -72,6 +72,7 @@
"otplib": "^12.0.1",
"p-retry": "^6.0.0",
"pg-protocol": "^1.6.0",
"pluralize": "^8.0.0",
"qrcode": "^1.5.3",
"redis": "^4.6.5",
"roarr": "^7.11.0",
@ -99,6 +100,7 @@
"@types/koa__cors": "^4.0.0",
"@types/node": "^18.11.18",
"@types/oidc-provider": "^8.0.0",
"@types/pluralize": "^0.0.31",
"@types/qrcode": "^1.5.2",
"@types/semver": "^7.3.12",
"@types/sinon": "^10.0.13",

View file

@ -0,0 +1,20 @@
import {
type OrganizationRoleKeys,
OrganizationRoles,
type CreateOrganizationRole,
type OrganizationRole,
} from '@logto/schemas';
import { type CommonQueryMethods } from 'slonik';
import SchemaQueries from '#src/utils/SchemaQueries.js';
/** Class of queries for roles in the organization template. */
export default class OrganizationRoleQueries extends SchemaQueries<
OrganizationRoleKeys,
CreateOrganizationRole,
OrganizationRole
> {
constructor(pool: CommonQueryMethods) {
super(pool, OrganizationRoles);
}
}

View file

@ -25,6 +25,7 @@ import hookRoutes from './hook.js';
import interactionRoutes from './interaction/index.js';
import logRoutes from './log.js';
import logtoConfigRoutes from './logto-config.js';
import organizationRoleRoutes from './organization-roles.js';
import organizationScopeRoutes from './organization-scopes.js';
import organizationRoutes from './organizations.js';
import resourceRoutes from './resource.js';
@ -67,6 +68,7 @@ const createRouters = (tenant: TenantContext) => {
domainRoutes(managementRouter, tenant);
organizationRoutes(managementRouter, tenant);
organizationScopeRoutes(managementRouter, tenant);
organizationRoleRoutes(managementRouter, tenant);
const anonymousRouter: AnonymousRouter = new Router();
wellKnownRoutes(anonymousRouter, tenant);

View file

@ -0,0 +1,83 @@
import {
type CreateOrganizationRole,
type OrganizationRole,
type OrganizationRoleKeys,
OrganizationRoles,
} from '@logto/schemas';
import { UniqueIntegrityConstraintViolationError } from 'slonik';
import { z } from 'zod';
import RequestError from '#src/errors/RequestError/index.js';
import koaGuard from '#src/middleware/koa-guard.js';
import SchemaRouter, { SchemaActions } from '#src/utils/SchemaRouter.js';
import { type AuthedRouter, type RouterInitArgs } from './types.js';
class OrganizationRoleActions extends SchemaActions<
OrganizationRoleKeys,
CreateOrganizationRole,
OrganizationRole
> {
override async post(
data: Omit<CreateOrganizationRole, 'id'>
): Promise<Readonly<OrganizationRole>> {
try {
return await super.post(data);
} catch (error: unknown) {
if (error instanceof UniqueIntegrityConstraintViolationError) {
throw new RequestError({ code: 'entity.duplicate_value_of_unique_field', field: 'name' });
}
throw error;
}
}
}
export default function organizationRoleRoutes<T extends AuthedRouter>(
...[
originalRouter,
{
queries: { organizationRoles, organizationRoleScopeRelations },
},
]: RouterInitArgs<T>
) {
const actions = new OrganizationRoleActions(organizationRoles);
const router = new SchemaRouter(OrganizationRoles, actions, { disabled: { post: true } });
/** Allows to carry an initial set of scopes for creating a new organization role. */
type CreateOrganizationRolePayload = Omit<CreateOrganizationRole, 'id'> & { scopeIds: string[] };
const createGuard: z.ZodType<CreateOrganizationRolePayload, z.ZodTypeDef, unknown> =
OrganizationRoles.createGuard
.omit({
id: true,
})
.extend({
scopeIds: z.array(z.string()).default([]),
});
router.post(
'/',
koaGuard({
body: createGuard,
response: OrganizationRoles.guard,
status: [201, 422],
}),
async (ctx, next) => {
const { scopeIds, ...data } = ctx.guard.body;
const role = await actions.post(data);
if (scopeIds.length > 0) {
await organizationRoleScopeRelations.insert(
...scopeIds.map<[string, string]>((id) => [role.id, id])
);
}
ctx.body = role;
ctx.status = 201;
return next();
}
);
originalRouter.use(router.routes());
}

View file

@ -1,3 +1,8 @@
import {
OrganizationRoleScopeRelations,
OrganizationRoles,
OrganizationScopes,
} from '@logto/schemas';
import type { CommonQueryMethods } from 'slonik';
import { type WellKnownCache } from '#src/caches/well-known.js';
@ -11,6 +16,7 @@ import { createHooksQueries } from '#src/queries/hooks.js';
import { createLogQueries } from '#src/queries/log.js';
import { createLogtoConfigQueries } from '#src/queries/logto-config.js';
import { createOidcModelInstanceQueries } from '#src/queries/oidc-model-instance.js';
import OrganizationRoleQueries from '#src/queries/organization-roles.js';
import OrganizationScopeQueries from '#src/queries/organization-scopes.js';
import OrganizationQueries from '#src/queries/organizations.js';
import { createPasscodeQueries } from '#src/queries/passcode.js';
@ -22,6 +28,7 @@ import { createSignInExperienceQueries } from '#src/queries/sign-in-experience.j
import { createUserQueries } from '#src/queries/user.js';
import { createUsersRolesQueries } from '#src/queries/users-roles.js';
import { createVerificationStatusQueries } from '#src/queries/verification-status.js';
import RelationQueries from '#src/utils/RelationQueries.js';
export default class Queries {
applications = createApplicationQueries(this.pool);
@ -46,6 +53,14 @@ export default class Queries {
organizations = new OrganizationQueries(this.pool);
/** Organization template scope queries. */
organizationScopes = new OrganizationScopeQueries(this.pool);
/** Organization template role queries. */
organizationRoles = new OrganizationRoleQueries(this.pool);
organizationRoleScopeRelations = new RelationQueries(
this.pool,
OrganizationRoleScopeRelations.table,
OrganizationRoles.table,
OrganizationScopes.table
);
constructor(
public readonly pool: CommonQueryMethods,

View file

@ -0,0 +1,108 @@
import pluralize from 'pluralize';
import { sql, type CommonQueryMethods } from 'slonik';
type AtLeast2<T extends unknown[]> = `${T['length']}` extends '0' | '1' ? never : T;
type RemoveLiteral<T extends string, L extends string> = T extends L ? Exclude<T, L> : T;
/**
* Query class for relation tables that connect several tables by their entry ids.
*
* @example
* Let's say we have two tables `users` and `groups` and a relation table
* `user_group_relations`. Then we can create a `RelationQueries` instance like this:
*
* ```ts
* const userGroupRelations = new RelationQueries(pool, 'user_group_relations', 'users', 'groups');
* ```
*
* To insert a new relation, we can use the {@link RelationQueries.insert} method:
*
* ```ts
* await userGroupRelations.insert(['user-id-1', 'group-id-1']);
* // Insert multiple relations at once
* await userGroupRelations.insert(
* ['user-id-1', 'group-id-1'],
* ['user-id-2', 'group-id-1']
* );
* ```
*
* To get all entries for a specific table, we can use the {@link RelationQueries.getEntries} method:
*
* ```ts
* await userGroupRelations.getEntries('users', { group_id: 'group-id-1' });
* ```
*
* This will return all entries for the `users` table that are connected to the
* group with the id `group-id-1`.
*/
export default class RelationQueries<
SnakeCaseRelations extends Array<Lowercase<string>>,
Length = AtLeast2<SnakeCaseRelations>['length'],
> {
protected get table() {
return sql.identifier([this.relationTable]);
}
public readonly relations: SnakeCaseRelations;
/**
* @param pool The database pool.
* @param relationTable The name of the relation table.
* @param relations The names of the tables that are connected by the relation table.
*/
constructor(
public readonly pool: CommonQueryMethods,
public readonly relationTable: string,
...relations: Readonly<SnakeCaseRelations>
) {
this.relations = relations;
}
/**
* Insert new entries into the relation table.
*
* Each entry must contain the same number of ids as the number of relations, and
* the order of the ids must match the order of the relations.
*
* @example
* ```ts
* const userGroupRelations = new RelationQueries(pool, 'user_group_relations', 'users', 'groups');
*
* userGroupRelations.insert(['user-id-1', 'group-id-1']);
* // Insert multiple relations at once
* userGroupRelations.insert(
* ['user-id-1', 'group-id-1'],
* ['user-id-2', 'group-id-1']
* );
* ```
*
* @param data Entries to insert.
* @returns A Promise that resolves to the query result.
*/
async insert(...data: ReadonlyArray<string[] & { length: Length }>) {
return this.pool.query(sql`
insert into ${this.table} (${sql.join(
this.relations.map((relation) => sql.identifier([pluralize(relation, 1) + '_id'])),
sql`, `
)})
values ${sql.join(
data.map(
(relation) =>
sql`(${sql.join(
relation.map((id) => sql`${id}`),
sql`, `
)})`
),
sql`, `
)};
`);
}
async getEntries<L extends SnakeCaseRelations[number]>(
forRelation: L,
where: Record<RemoveLiteral<SnakeCaseRelations[number], L>, unknown>
) {
throw new Error('Not implemented');
}
}

View file

@ -1,5 +1,7 @@
import { type SchemaLike, type GeneratedSchema, type Guard } from '@logto/schemas';
import { generateStandardId, type OmitAutoSetFields } from '@logto/shared';
import { type DeepPartial } from '@silverhand/essentials';
import deepmerge from 'deepmerge';
import Router, { type IRouterParamContext } from 'koa-router';
import { z } from 'zod';
@ -76,6 +78,22 @@ export class SchemaActions<
}
}
type SchemaRouterConfig = {
/** Disable certain routes for the router. */
disabled: {
/** Disable `GET /` route. */
get: boolean;
/** Disable `POST /` route. */
post: boolean;
/** Disable `GET /:id` route. */
getById: boolean;
/** Disable `PATCH /:id` route. */
patchById: boolean;
/** Disable `DELETE /:id` route. */
deleteById: boolean;
};
};
/**
* A standard RESTful router for a schema.
*
@ -98,77 +116,105 @@ export default class SchemaRouter<
StateT = unknown,
CustomT extends IRouterParamContext = IRouterParamContext,
> extends Router<StateT, CustomT> {
public readonly config: SchemaRouterConfig;
constructor(
public readonly schema: GeneratedSchema<Key, CreateSchema, Schema>,
public readonly actions: SchemaActions<Key, CreateSchema, Schema>
public readonly actions: SchemaActions<Key, CreateSchema, Schema>,
config: DeepPartial<SchemaRouterConfig> = {}
) {
super({ prefix: '/' + schema.table.replaceAll('_', '-') });
this.get(
'/',
koaPagination(),
koaGuard({ response: schema.guard.array(), status: [200] }),
async (ctx, next) => {
const [count, entities] = await actions.get(ctx.pagination);
ctx.pagination.totalCount = count;
ctx.body = entities;
return next();
}
this.config = deepmerge<SchemaRouterConfig, DeepPartial<SchemaRouterConfig>>(
{
disabled: {
get: false,
post: false,
getById: false,
patchById: false,
deleteById: false,
},
},
config
);
this.post(
'/',
koaGuard({
// eslint-disable-next-line no-restricted-syntax -- `.omit()` doesn't play well for generic types
body: schema.createGuard.omit({ id: true }) as Guard<Omit<CreateSchema, 'id'>>,
response: schema.guard,
status: [201, 422],
}),
async (ctx, next) => {
ctx.body = await actions.post(ctx.guard.body);
ctx.status = 201;
return next();
}
);
const { disabled } = this.config;
this.get(
'/:id',
koaGuard({
params: z.object({ id: z.string().min(1) }),
response: schema.guard,
status: [200, 404],
}),
async (ctx, next) => {
ctx.body = await actions.getById(ctx.guard.params.id);
return next();
}
);
if (!disabled.get) {
this.get(
'/',
koaPagination(),
koaGuard({ response: schema.guard.array(), status: [200] }),
async (ctx, next) => {
const [count, entities] = await actions.get(ctx.pagination);
ctx.pagination.totalCount = count;
ctx.body = entities;
return next();
}
);
}
this.patch(
'/:id',
koaGuard({
params: z.object({ id: z.string().min(1) }),
body: schema.updateGuard,
response: schema.guard,
status: [200, 404],
}),
async (ctx, next) => {
ctx.body = await actions.patchById(ctx.guard.params.id, ctx.guard.body);
return next();
}
);
if (!disabled.post) {
this.post(
'/',
koaGuard({
// eslint-disable-next-line no-restricted-syntax -- `.omit()` doesn't play well for generic types
body: schema.createGuard.omit({ id: true }) as Guard<Omit<CreateSchema, 'id'>>,
response: schema.guard,
status: [201],
}),
async (ctx, next) => {
ctx.body = await actions.post(ctx.guard.body);
ctx.status = 201;
return next();
}
);
}
this.delete(
'/:id',
koaGuard({
params: z.object({ id: z.string().min(1) }),
status: [204, 404],
}),
async (ctx, next) => {
await actions.deleteById(ctx.guard.params.id);
ctx.status = 204;
return next();
}
);
if (!disabled.getById) {
this.get(
'/:id',
koaGuard({
params: z.object({ id: z.string().min(1) }),
response: schema.guard,
status: [200, 404],
}),
async (ctx, next) => {
ctx.body = await actions.getById(ctx.guard.params.id);
return next();
}
);
}
if (!disabled.patchById) {
this.patch(
'/:id',
koaGuard({
params: z.object({ id: z.string().min(1) }),
body: schema.updateGuard,
response: schema.guard,
status: [200, 404],
}),
async (ctx, next) => {
ctx.body = await actions.patchById(ctx.guard.params.id, ctx.guard.body);
return next();
}
);
}
if (!disabled.deleteById) {
this.delete(
'/:id',
koaGuard({
params: z.object({ id: z.string().min(1) }),
status: [204, 404],
}),
async (ctx, next) => {
await actions.deleteById(ctx.guard.params.id);
ctx.status = 204;
return next();
}
);
}
}
}

View file

@ -0,0 +1,14 @@
import { type OrganizationRole } from '@logto/schemas';
import { ApiFactory } from './factory.js';
class OrganizationRoleApi extends ApiFactory<
OrganizationRole,
{ name: string; description?: string; scopeIds?: string[] }
> {
constructor() {
super('organization-roles');
}
}
export const roleApi = new OrganizationRoleApi();

View file

@ -0,0 +1,115 @@
import assert from 'node:assert';
import { generateStandardId } from '@logto/shared';
import { isKeyInObject } from '@silverhand/essentials';
import { HTTPError } from 'got';
import { roleApi } from '#src/api/organization-role.js';
import { scopeApi } from '#src/api/organization-scope.js';
const randomId = () => generateStandardId(4);
describe('organization roles', () => {
it('should fail if the name of the new organization role already exists', async () => {
const name = 'test' + randomId();
await roleApi.create({ name });
const response = await roleApi.create({ name }).catch((error: unknown) => error);
assert(response instanceof HTTPError);
const { statusCode, body: raw } = response.response;
const body: unknown = JSON.parse(String(raw));
expect(statusCode).toBe(400);
expect(isKeyInObject(body, 'code') && body.code).toBe('entity.duplicate_value_of_unique_field');
});
it('should be able to create a role with some scopes', async () => {
const name = 'test' + randomId();
const [scope1, scope2] = await Promise.all([
scopeApi.create({ name: 'test' + randomId() }),
scopeApi.create({ name: 'test' + randomId() }),
]);
const scopeIds = [scope1.id, scope2.id];
const role = await roleApi.create({ name, scopeIds });
expect(role).toStrictEqual(
expect.objectContaining({
name,
})
);
// TODO: Check scopes under a role after API is implemented
await Promise.all([scopeApi.delete(scope1.id), scopeApi.delete(scope2.id)]);
});
it('should get organization roles successfully', async () => {
const [name1, name2] = ['test' + randomId(), 'test' + randomId()];
await roleApi.create({ name: name1, description: 'A test organization role.' });
await roleApi.create({ name: name2 });
const roles = await roleApi.getList();
expect(roles).toContainEqual(
expect.objectContaining({ name: name1, description: 'A test organization role.' })
);
expect(roles).toContainEqual(expect.objectContaining({ name: name2, description: null }));
});
it('should get organization roles with pagination', async () => {
// Add 20 roles to exceed the default page size
await Promise.all(
Array.from({ length: 30 }).map(async () => roleApi.create({ name: 'test' + randomId() }))
);
const roles = await roleApi.getList();
expect(roles).toHaveLength(20);
const roles2 = await roleApi.getList(
new URLSearchParams({
page: '2',
page_size: '10',
})
);
expect(roles2.length).toBeGreaterThanOrEqual(10);
expect(roles2[0]?.id).not.toBeFalsy();
expect(roles2[0]?.id).toBe(roles[10]?.id);
});
it('should be able to create and get organization roles by id', async () => {
const createdRole = await roleApi.create({ name: 'test' + randomId() });
const role = await roleApi.get(createdRole.id);
expect(role).toStrictEqual(createdRole);
});
it('should fail when try to get an organization role that does not exist', async () => {
const response = await roleApi.get('0').catch((error: unknown) => error);
expect(response instanceof HTTPError && response.response.statusCode).toBe(404);
});
it('should be able to update organization role', async () => {
const createdRole = await roleApi.create({ name: 'test' + randomId() });
const newName = 'test' + randomId();
const role = await roleApi.update(createdRole.id, {
name: newName,
description: 'test description.',
});
expect(role).toStrictEqual({
...createdRole,
name: newName,
description: 'test description.',
});
});
it('should be able to delete organization role', async () => {
const createdRole = await roleApi.create({ name: 'test' + randomId() });
await roleApi.delete(createdRole.id);
const response = await roleApi.get(createdRole.id).catch((error: unknown) => error);
expect(response instanceof HTTPError && response.response.statusCode).toBe(404);
});
it('should fail when try to delete an organization role that does not exist', async () => {
const response = await roleApi.delete('0').catch((error: unknown) => error);
expect(response instanceof HTTPError && response.response.statusCode).toBe(404);
});
});

View file

@ -21,9 +21,11 @@ export type GeneratedSchema<
Key extends string,
CreateSchema extends Partial<SchemaLike<Key>>,
Schema extends SchemaLike<Key>,
Table extends string = string,
TableSingular extends string = string,
> = Readonly<{
table: string;
tableSingular: string;
table: Table;
tableSingular: TableSingular;
fields: {
[key in Key]: string;
};

View file

@ -126,7 +126,13 @@ export const generateSchema = ({ name, comments, fields }: TableWithType) => {
'',
`export const ${camelcase(name, {
pascalCase: true,
})}: GeneratedSchema<${modelName}Keys, ${databaseEntryType}, ${modelName}> = Object.freeze({`,
})}: GeneratedSchema<
${modelName}Keys,
${databaseEntryType},
${modelName},
'${name}',
'${pluralize(name, 1)}'
> = Object.freeze({`,
` table: '${name}',`,
` tableSingular: '${pluralize(name, 1)}',`,
' fields: {',

View file

@ -3262,6 +3262,9 @@ importers:
pg-protocol:
specifier: ^1.6.0
version: 1.6.0
pluralize:
specifier: ^8.0.0
version: 8.0.0
qrcode:
specifier: ^1.5.3
version: 1.5.3
@ -3338,6 +3341,9 @@ importers:
'@types/oidc-provider':
specifier: ^8.0.0
version: 8.0.0
'@types/pluralize':
specifier: ^0.0.31
version: 0.0.31
'@types/qrcode':
specifier: ^1.5.2
version: 1.5.2
@ -17150,7 +17156,6 @@ packages:
/pluralize@8.0.0:
resolution: {integrity: sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA==}
engines: {node: '>=4'}
dev: true
/pngjs@5.0.0:
resolution: {integrity: sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==}