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:
commit
0874b70433
5 changed files with 257 additions and 234 deletions
|
@ -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.
|
||||
|
|
117
packages/core/src/queries/organization/role-user-relations.ts
Normal file
117
packages/core/src/queries/organization/role-user-relations.ts
Normal 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`, `
|
||||
)}
|
||||
`);
|
||||
});
|
||||
}
|
||||
}
|
|
@ -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`, `
|
||||
)}
|
||||
`);
|
||||
});
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
|
|
|
@ -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();
|
||||
}
|
||||
);
|
||||
}
|
Loading…
Reference in a new issue