0
Fork 0
mirror of https://github.com/logto-io/logto.git synced 2024-12-30 20:33:54 -05:00

Merge pull request #5991 from logto-io/gao-reorg-org-routes

refactor(core): reorg organization routes
This commit is contained in:
Gao Sun 2024-06-06 10:13:41 +08:00 committed by GitHub
commit 0874b70433
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 257 additions and 234 deletions

View file

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

View file

@ -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<readonly OrganizationScope[]> {
const { fields } = convertToIdentifiers(OrganizationRoleUserRelations, true);
const roleScopeRelations = convertToIdentifiers(OrganizationRoleScopeRelations, true);
const scopes = convertToIdentifiers(OrganizationScopes, true);
return this.pool.any<OrganizationScope>(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<readonly ResourceScopeEntity[]> {
const { fields } = convertToIdentifiers(OrganizationRoleUserRelations, true);
const roleScopeRelations = convertToIdentifiers(OrganizationRoleResourceScopeRelations, true);
const scopes = convertToIdentifiers(Scopes, true);
const resources = convertToIdentifiers(Resources, true);
return this.pool.any<ResourceScopeEntity>(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`, `
)}
`);
});
}
}

View file

@ -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<typeof Organization
`;
}
}
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<readonly OrganizationScope[]> {
const { fields } = convertToIdentifiers(OrganizationRoleUserRelations, true);
const roleScopeRelations = convertToIdentifiers(OrganizationRoleScopeRelations, true);
const scopes = convertToIdentifiers(OrganizationScopes, true);
return this.pool.any<OrganizationScope>(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<readonly ResourceScopeEntity[]> {
const { fields } = convertToIdentifiers(OrganizationRoleUserRelations, true);
const roleScopeRelations = convertToIdentifiers(OrganizationRoleResourceScopeRelations, true);
const scopes = convertToIdentifiers(Scopes, true);
const resources = convertToIdentifiers(Resources, true);
return this.pool.any<ResourceScopeEntity>(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`, `
)}
`);
});
}
}

View file

@ -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<T extends ManagementApiRouter>(
}
);
// 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);

View file

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