From ec6f1d39d878f97cc429994b17181655d8bf719c Mon Sep 17 00:00:00 2001 From: Gao Sun Date: Wed, 5 Jun 2024 18:17:39 +0800 Subject: [PATCH 1/2] refactor(core): reorg organization routes --- .../core/src/routes/organization/index.ts | 123 +--------------- .../organization/index.user-role-relations.ts | 134 ++++++++++++++++++ 2 files changed, 136 insertions(+), 121 deletions(-) create mode 100644 packages/core/src/routes/organization/index.user-role-relations.ts diff --git a/packages/core/src/routes/organization/index.ts b/packages/core/src/routes/organization/index.ts index 3d798b6e8..69aa6c9f4 100644 --- a/packages/core/src/routes/organization/index.ts +++ b/packages/core/src/routes/organization/index.ts @@ -1,15 +1,12 @@ import { - OrganizationRoles, type OrganizationWithFeatured, Organizations, featuredUserGuard, userWithOrganizationRolesGuard, - OrganizationScopes, } from '@logto/schemas'; import { yes } from '@silverhand/essentials'; import { z } from 'zod'; -import RequestError from '#src/errors/RequestError/index.js'; import koaGuard from '#src/middleware/koa-guard.js'; import koaPagination from '#src/middleware/koa-pagination.js'; import koaQuotaGuard from '#src/middleware/koa-quota-guard.js'; @@ -19,6 +16,7 @@ import { parseSearchOptions } from '#src/utils/search.js'; import { type ManagementApiRouter, type RouterInitArgs } from '../types.js'; +import userRoleRelationRoutes from './index.user-role-relations.js'; import organizationInvitationRoutes from './invitations.js'; import organizationRoleRoutes from './roles.js'; import organizationScopeRoutes from './scopes.js'; @@ -135,125 +133,8 @@ export default function organizationRoutes( } ); - // Manually add these routes since I don't want to over-engineer the `SchemaRouter` // MARK: Organization - user - organization role relation routes - const params = Object.freeze({ id: z.string().min(1), userId: z.string().min(1) } as const); - const pathname = '/:id/users/:userId/roles'; - - // The pathname of `.use()` will not match the end of the path, for example: - // `.use('/foo', ...)` will match both `/foo` and `/foo/bar`. - // See https://github.com/koajs/router/blob/02ad6eedf5ced6ec1eab2138380fd67c63e3f1d7/lib/router.js#L330-L333 - router.use(pathname, koaGuard({ params: z.object(params) }), async (ctx, next) => { - const { id, userId } = ctx.guard.params; - - // Ensure membership - if (!(await organizations.relations.users.exists(id, userId))) { - throw new RequestError({ code: 'organization.require_membership', status: 422 }); - } - - return next(); - }); - - router.get( - pathname, - koaPagination(), - koaGuard({ - params: z.object(params), - response: OrganizationRoles.guard.array(), - status: [200, 422], - }), - async (ctx, next) => { - const { id, userId } = ctx.guard.params; - - const [totalCount, entities] = await organizations.relations.rolesUsers.getEntities( - OrganizationRoles, - { - organizationId: id, - userId, - }, - ctx.pagination - ); - - ctx.pagination.totalCount = totalCount; - ctx.body = entities; - return next(); - } - ); - - router.put( - pathname, - koaGuard({ - params: z.object(params), - body: z.object({ organizationRoleIds: z.string().min(1).array() }), - status: [204, 422], - }), - async (ctx, next) => { - const { id, userId } = ctx.guard.params; - const { organizationRoleIds } = ctx.guard.body; - - await organizations.relations.rolesUsers.replace(id, userId, organizationRoleIds); - - ctx.status = 204; - return next(); - } - ); - - router.post( - pathname, - koaGuard({ - params: z.object(params), - body: z.object({ organizationRoleIds: z.string().min(1).array().nonempty() }), - status: [201, 422], - }), - async (ctx, next) => { - const { id, userId } = ctx.guard.params; - const { organizationRoleIds } = ctx.guard.body; - - await organizations.relations.rolesUsers.insert( - ...organizationRoleIds.map<[string, string, string]>((roleId) => [id, roleId, userId]) - ); - - ctx.status = 201; - return next(); - } - ); - - router.delete( - `${pathname}/:roleId`, - koaGuard({ - params: z.object({ ...params, roleId: z.string().min(1) }), - status: [204, 422, 404], - }), - async (ctx, next) => { - const { id, roleId, userId } = ctx.guard.params; - - await organizations.relations.rolesUsers.delete({ - organizationId: id, - organizationRoleId: roleId, - userId, - }); - - ctx.status = 204; - return next(); - } - ); - - router.get( - '/:id/users/:userId/scopes', - koaGuard({ - params: z.object(params), - response: z.array(OrganizationScopes.guard), - status: [200, 422], - }), - async (ctx, next) => { - const { id, userId } = ctx.guard.params; - - const scopes = await organizations.relations.rolesUsers.getUserScopes(id, userId); - - ctx.body = scopes; - return next(); - } - ); + userRoleRelationRoutes(router, organizations); // MARK: Mount sub-routes organizationRoleRoutes(...args); diff --git a/packages/core/src/routes/organization/index.user-role-relations.ts b/packages/core/src/routes/organization/index.user-role-relations.ts new file mode 100644 index 000000000..db2fa4d5f --- /dev/null +++ b/packages/core/src/routes/organization/index.user-role-relations.ts @@ -0,0 +1,134 @@ +import { OrganizationRoles, OrganizationScopes } from '@logto/schemas'; +import type Router from 'koa-router'; +import { type IRouterParamContext } from 'koa-router'; +import { z } from 'zod'; + +import RequestError from '#src/errors/RequestError/index.js'; +import koaGuard from '#src/middleware/koa-guard.js'; +import koaPagination from '#src/middleware/koa-pagination.js'; +import type OrganizationQueries from '#src/queries/organization/index.js'; + +// Manually add these routes since I don't want to over-engineer the `SchemaRouter` +export default function userRoleRelationRoutes( + router: Router, + organizations: OrganizationQueries +) { + // MARK: Organization - user - organization role relation routes + const params = Object.freeze({ id: z.string().min(1), userId: z.string().min(1) } as const); + const pathname = '/:id/users/:userId/roles'; + + // The pathname of `.use()` will not match the end of the path, for example: + // `.use('/foo', ...)` will match both `/foo` and `/foo/bar`. + // See https://github.com/koajs/router/blob/02ad6eedf5ced6ec1eab2138380fd67c63e3f1d7/lib/router.js#L330-L333 + router.use(pathname, koaGuard({ params: z.object(params) }), async (ctx, next) => { + const { id, userId } = ctx.guard.params; + + // Ensure membership + if (!(await organizations.relations.users.exists(id, userId))) { + throw new RequestError({ code: 'organization.require_membership', status: 422 }); + } + + return next(); + }); + + router.get( + pathname, + koaPagination(), + koaGuard({ + params: z.object(params), + response: OrganizationRoles.guard.array(), + status: [200, 422], + }), + async (ctx, next) => { + const { id, userId } = ctx.guard.params; + + const [totalCount, entities] = await organizations.relations.rolesUsers.getEntities( + OrganizationRoles, + { + organizationId: id, + userId, + }, + ctx.pagination + ); + + ctx.pagination.totalCount = totalCount; + ctx.body = entities; + return next(); + } + ); + + router.put( + pathname, + koaGuard({ + params: z.object(params), + body: z.object({ organizationRoleIds: z.string().min(1).array() }), + status: [204, 422], + }), + async (ctx, next) => { + const { id, userId } = ctx.guard.params; + const { organizationRoleIds } = ctx.guard.body; + + await organizations.relations.rolesUsers.replace(id, userId, organizationRoleIds); + + ctx.status = 204; + return next(); + } + ); + + router.post( + pathname, + koaGuard({ + params: z.object(params), + body: z.object({ organizationRoleIds: z.string().min(1).array().nonempty() }), + status: [201, 422], + }), + async (ctx, next) => { + const { id, userId } = ctx.guard.params; + const { organizationRoleIds } = ctx.guard.body; + + await organizations.relations.rolesUsers.insert( + ...organizationRoleIds.map<[string, string, string]>((roleId) => [id, roleId, userId]) + ); + + ctx.status = 201; + return next(); + } + ); + + router.delete( + `${pathname}/:roleId`, + koaGuard({ + params: z.object({ ...params, roleId: z.string().min(1) }), + status: [204, 422, 404], + }), + async (ctx, next) => { + const { id, roleId, userId } = ctx.guard.params; + + await organizations.relations.rolesUsers.delete({ + organizationId: id, + organizationRoleId: roleId, + userId, + }); + + ctx.status = 204; + return next(); + } + ); + + router.get( + '/:id/users/:userId/scopes', + koaGuard({ + params: z.object(params), + response: z.array(OrganizationScopes.guard), + status: [200, 422], + }), + async (ctx, next) => { + const { id, userId } = ctx.guard.params; + + const scopes = await organizations.relations.rolesUsers.getUserScopes(id, userId); + + ctx.body = scopes; + return next(); + } + ); +} From ce911309da89fd4de1afac221de763fbbeb0c591 Mon Sep 17 00:00:00 2001 From: Gao Sun Date: Wed, 5 Jun 2024 18:25:18 +0800 Subject: [PATCH 2/2] refactor(core): reorg organization queries --- .../core/src/queries/organization/index.ts | 3 +- .../organization/role-user-relations.ts | 117 ++++++++++++++++++ .../{relations.ts => user-relations.ts} | 114 +---------------- 3 files changed, 121 insertions(+), 113 deletions(-) create mode 100644 packages/core/src/queries/organization/role-user-relations.ts rename packages/core/src/queries/organization/{relations.ts => user-relations.ts} (58%) diff --git a/packages/core/src/queries/organization/index.ts b/packages/core/src/queries/organization/index.ts index 573c82411..5d7ddb31f 100644 --- a/packages/core/src/queries/organization/index.ts +++ b/packages/core/src/queries/organization/index.ts @@ -28,7 +28,8 @@ import { TwoRelationsQueries } from '#src/utils/RelationQueries.js'; import SchemaQueries from '#src/utils/SchemaQueries.js'; import { conditionalSql, convertToIdentifiers } from '#src/utils/sql.js'; -import { RoleUserRelationQueries, UserRelationQueries } from './relations.js'; +import { RoleUserRelationQueries } from './role-user-relations.js'; +import { UserRelationQueries } from './user-relations.js'; /** * The schema field keys that can be used for searching roles. diff --git a/packages/core/src/queries/organization/role-user-relations.ts b/packages/core/src/queries/organization/role-user-relations.ts new file mode 100644 index 000000000..5710bd110 --- /dev/null +++ b/packages/core/src/queries/organization/role-user-relations.ts @@ -0,0 +1,117 @@ +import { + Organizations, + OrganizationRoles, + OrganizationScopes, + OrganizationRoleScopeRelations, + Users, + OrganizationRoleUserRelations, + type OrganizationScope, + type ResourceScopeEntity, + Scopes, + OrganizationRoleResourceScopeRelations, + Resources, +} from '@logto/schemas'; +import { sql, type CommonQueryMethods } from '@silverhand/slonik'; + +import RelationQueries from '#src/utils/RelationQueries.js'; +import { conditionalSql, convertToIdentifiers } from '#src/utils/sql.js'; + +export class RoleUserRelationQueries extends RelationQueries< + [typeof Organizations, typeof OrganizationRoles, typeof Users] +> { + constructor(pool: CommonQueryMethods) { + super(pool, OrganizationRoleUserRelations.table, Organizations, OrganizationRoles, Users); + } + + /** Get the available scopes of a user in an organization. */ + async getUserScopes( + organizationId: string, + userId: string + ): Promise { + const { fields } = convertToIdentifiers(OrganizationRoleUserRelations, true); + const roleScopeRelations = convertToIdentifiers(OrganizationRoleScopeRelations, true); + const scopes = convertToIdentifiers(OrganizationScopes, true); + + return this.pool.any(sql` + select distinct on (${scopes.fields.id}) + ${sql.join(Object.values(scopes.fields), sql`, `)} + from ${this.table} + join ${roleScopeRelations.table} + on ${roleScopeRelations.fields.organizationRoleId} = ${fields.organizationRoleId} + join ${scopes.table} + on ${scopes.fields.id} = ${roleScopeRelations.fields.organizationScopeId} + where ${fields.organizationId} = ${organizationId} + and ${fields.userId} = ${userId} + `); + } + + /** + * Get the available resource scopes of a user in all organizations. + * If `organizationId` is provided, it will only search in that organization. + */ + async getUserResourceScopes( + userId: string, + resourceIndicator: string, + organizationId?: string + ): Promise { + const { fields } = convertToIdentifiers(OrganizationRoleUserRelations, true); + const roleScopeRelations = convertToIdentifiers(OrganizationRoleResourceScopeRelations, true); + const scopes = convertToIdentifiers(Scopes, true); + const resources = convertToIdentifiers(Resources, true); + + return this.pool.any(sql` + select distinct on (${scopes.fields.id}) + ${scopes.fields.id}, ${scopes.fields.name} + from ${this.table} + join ${roleScopeRelations.table} + on ${roleScopeRelations.fields.organizationRoleId} = ${fields.organizationRoleId} + join ${scopes.table} + on ${scopes.fields.id} = ${roleScopeRelations.fields.scopeId} + join ${resources.table} + on ${resources.fields.id} = ${scopes.fields.resourceId} + where ${fields.userId} = ${userId} + and ${resources.fields.indicator} = ${resourceIndicator} + ${conditionalSql(organizationId, (value) => sql`and ${fields.organizationId} = ${value}`)} + `); + } + + /** Replace the roles of a user in an organization. */ + async replace(organizationId: string, userId: string, roleIds: string[]) { + const users = convertToIdentifiers(Users); + const relations = convertToIdentifiers(OrganizationRoleUserRelations); + + return this.pool.transaction(async (transaction) => { + // Lock user + await transaction.query(sql` + select id + from ${users.table} + where ${users.fields.id} = ${userId} + for update + `); + + // Delete old relations + await transaction.query(sql` + delete from ${relations.table} + where ${relations.fields.userId} = ${userId} + and ${relations.fields.organizationId} = ${organizationId} + `); + + // Insert new relations + if (roleIds.length === 0) { + return; + } + + await transaction.query(sql` + insert into ${relations.table} ( + ${relations.fields.userId}, + ${relations.fields.organizationId}, + ${relations.fields.organizationRoleId} + ) + values ${sql.join( + roleIds.map((roleId) => sql`(${userId}, ${organizationId}, ${roleId})`), + sql`, ` + )} + `); + }); + } +} diff --git a/packages/core/src/queries/organization/relations.ts b/packages/core/src/queries/organization/user-relations.ts similarity index 58% rename from packages/core/src/queries/organization/relations.ts rename to packages/core/src/queries/organization/user-relations.ts index d78f99320..142783a0a 100644 --- a/packages/core/src/queries/organization/relations.ts +++ b/packages/core/src/queries/organization/user-relations.ts @@ -1,28 +1,18 @@ import { Organizations, OrganizationRoles, - OrganizationScopes, - OrganizationRoleScopeRelations, Users, OrganizationUserRelations, OrganizationRoleUserRelations, type OrganizationWithRoles, type UserWithOrganizationRoles, type FeaturedUser, - type OrganizationScope, - type ResourceScopeEntity, - Scopes, - OrganizationRoleResourceScopeRelations, - Resources, } from '@logto/schemas'; import { sql, type CommonQueryMethods } from '@silverhand/slonik'; import { type SearchOptions, buildSearchSql, expandFields } from '#src/database/utils.js'; -import RelationQueries, { - type GetEntitiesOptions, - TwoRelationsQueries, -} from '#src/utils/RelationQueries.js'; -import { conditionalSql, convertToIdentifiers } from '#src/utils/sql.js'; +import { type GetEntitiesOptions, TwoRelationsQueries } from '#src/utils/RelationQueries.js'; +import { convertToIdentifiers } from '#src/utils/sql.js'; import { type userSearchKeys } from '../user.js'; @@ -170,103 +160,3 @@ export class UserRelationQueries extends TwoRelationsQueries { - constructor(pool: CommonQueryMethods) { - super(pool, OrganizationRoleUserRelations.table, Organizations, OrganizationRoles, Users); - } - - /** Get the available scopes of a user in an organization. */ - async getUserScopes( - organizationId: string, - userId: string - ): Promise { - const { fields } = convertToIdentifiers(OrganizationRoleUserRelations, true); - const roleScopeRelations = convertToIdentifiers(OrganizationRoleScopeRelations, true); - const scopes = convertToIdentifiers(OrganizationScopes, true); - - return this.pool.any(sql` - select distinct on (${scopes.fields.id}) - ${sql.join(Object.values(scopes.fields), sql`, `)} - from ${this.table} - join ${roleScopeRelations.table} - on ${roleScopeRelations.fields.organizationRoleId} = ${fields.organizationRoleId} - join ${scopes.table} - on ${scopes.fields.id} = ${roleScopeRelations.fields.organizationScopeId} - where ${fields.organizationId} = ${organizationId} - and ${fields.userId} = ${userId} - `); - } - - /** - * Get the available resource scopes of a user in all organizations. - * If `organizationId` is provided, it will only search in that organization. - */ - async getUserResourceScopes( - userId: string, - resourceIndicator: string, - organizationId?: string - ): Promise { - const { fields } = convertToIdentifiers(OrganizationRoleUserRelations, true); - const roleScopeRelations = convertToIdentifiers(OrganizationRoleResourceScopeRelations, true); - const scopes = convertToIdentifiers(Scopes, true); - const resources = convertToIdentifiers(Resources, true); - - return this.pool.any(sql` - select distinct on (${scopes.fields.id}) - ${scopes.fields.id}, ${scopes.fields.name} - from ${this.table} - join ${roleScopeRelations.table} - on ${roleScopeRelations.fields.organizationRoleId} = ${fields.organizationRoleId} - join ${scopes.table} - on ${scopes.fields.id} = ${roleScopeRelations.fields.scopeId} - join ${resources.table} - on ${resources.fields.id} = ${scopes.fields.resourceId} - where ${fields.userId} = ${userId} - and ${resources.fields.indicator} = ${resourceIndicator} - ${conditionalSql(organizationId, (value) => sql`and ${fields.organizationId} = ${value}`)} - `); - } - - /** Replace the roles of a user in an organization. */ - async replace(organizationId: string, userId: string, roleIds: string[]) { - const users = convertToIdentifiers(Users); - const relations = convertToIdentifiers(OrganizationRoleUserRelations); - - return this.pool.transaction(async (transaction) => { - // Lock user - await transaction.query(sql` - select id - from ${users.table} - where ${users.fields.id} = ${userId} - for update - `); - - // Delete old relations - await transaction.query(sql` - delete from ${relations.table} - where ${relations.fields.userId} = ${userId} - and ${relations.fields.organizationId} = ${organizationId} - `); - - // Insert new relations - if (roleIds.length === 0) { - return; - } - - await transaction.query(sql` - insert into ${relations.table} ( - ${relations.fields.userId}, - ${relations.fields.organizationId}, - ${relations.fields.organizationRoleId} - ) - values ${sql.join( - roleIds.map((roleId) => sql`(${userId}, ${organizationId}, ${roleId})`), - sql`, ` - )} - `); - }); - } -}