diff --git a/packages/core/package.json b/packages/core/package.json index 7da597766..3a26fc355 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -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", diff --git a/packages/core/src/queries/organization-roles.ts b/packages/core/src/queries/organization-roles.ts new file mode 100644 index 000000000..1ba7c111f --- /dev/null +++ b/packages/core/src/queries/organization-roles.ts @@ -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); + } +} diff --git a/packages/core/src/routes/init.ts b/packages/core/src/routes/init.ts index 60016a1e8..b4385ade0 100644 --- a/packages/core/src/routes/init.ts +++ b/packages/core/src/routes/init.ts @@ -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); diff --git a/packages/core/src/routes/organization-roles.ts b/packages/core/src/routes/organization-roles.ts new file mode 100644 index 000000000..cecada6bd --- /dev/null +++ b/packages/core/src/routes/organization-roles.ts @@ -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 + ): Promise> { + 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( + ...[ + originalRouter, + { + queries: { organizationRoles, organizationRoleScopeRelations }, + }, + ]: RouterInitArgs +) { + 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 & { scopeIds: string[] }; + + const createGuard: z.ZodType = + 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()); +} diff --git a/packages/core/src/tenants/Queries.ts b/packages/core/src/tenants/Queries.ts index 8a860537b..f841a2ba6 100644 --- a/packages/core/src/tenants/Queries.ts +++ b/packages/core/src/tenants/Queries.ts @@ -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, diff --git a/packages/core/src/utils/RelationQueries.ts b/packages/core/src/utils/RelationQueries.ts new file mode 100644 index 000000000..7d2c48488 --- /dev/null +++ b/packages/core/src/utils/RelationQueries.ts @@ -0,0 +1,108 @@ +import pluralize from 'pluralize'; +import { sql, type CommonQueryMethods } from 'slonik'; + +type AtLeast2 = `${T['length']}` extends '0' | '1' ? never : T; + +type RemoveLiteral = T extends L ? Exclude : 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>, + Length = AtLeast2['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 + ) { + 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) { + 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( + forRelation: L, + where: Record, unknown> + ) { + throw new Error('Not implemented'); + } +} diff --git a/packages/core/src/utils/SchemaRouter.ts b/packages/core/src/utils/SchemaRouter.ts index a0c10adae..efbc7ad38 100644 --- a/packages/core/src/utils/SchemaRouter.ts +++ b/packages/core/src/utils/SchemaRouter.ts @@ -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 { + public readonly config: SchemaRouterConfig; + constructor( public readonly schema: GeneratedSchema, - public readonly actions: SchemaActions + public readonly actions: SchemaActions, + config: DeepPartial = {} ) { 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>( + { + 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>, - 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>, + 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(); + } + ); + } } } diff --git a/packages/integration-tests/src/api/organization-role.ts b/packages/integration-tests/src/api/organization-role.ts new file mode 100644 index 000000000..98dfc6f1a --- /dev/null +++ b/packages/integration-tests/src/api/organization-role.ts @@ -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(); diff --git a/packages/integration-tests/src/tests/api/organization-role.test.ts b/packages/integration-tests/src/tests/api/organization-role.test.ts new file mode 100644 index 000000000..ad0759b37 --- /dev/null +++ b/packages/integration-tests/src/tests/api/organization-role.test.ts @@ -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); + }); +}); diff --git a/packages/schemas/src/foundations/schemas.ts b/packages/schemas/src/foundations/schemas.ts index e7152efa3..6b9669991 100644 --- a/packages/schemas/src/foundations/schemas.ts +++ b/packages/schemas/src/foundations/schemas.ts @@ -21,9 +21,11 @@ export type GeneratedSchema< Key extends string, CreateSchema extends Partial>, Schema extends SchemaLike, + Table extends string = string, + TableSingular extends string = string, > = Readonly<{ - table: string; - tableSingular: string; + table: Table; + tableSingular: TableSingular; fields: { [key in Key]: string; }; diff --git a/packages/schemas/src/gen/schema.ts b/packages/schemas/src/gen/schema.ts index 74e45eaa3..236256863 100644 --- a/packages/schemas/src/gen/schema.ts +++ b/packages/schemas/src/gen/schema.ts @@ -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: {', diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d9f20f967..89a1dc11e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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==}