Skip to content

Commit

Permalink
Merge pull request #6071 from logto-io/gao-org-app-role-apis
Browse files Browse the repository at this point in the history
feat(core): organization app role apis
  • Loading branch information
gao-sun authored Jun 21, 2024
2 parents 9f72a45 + 07da791 commit ec95536
Show file tree
Hide file tree
Showing 12 changed files with 484 additions and 31 deletions.
5 changes: 5 additions & 0 deletions packages/core/src/queries/organization/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ import SchemaQueries from '#src/utils/SchemaQueries.js';
import { conditionalSql, convertToIdentifiers } from '#src/utils/sql.js';

import { EmailDomainQueries } from './email-domains.js';
import { RoleApplicationRelationQueries } from './role-application-relations.js';
import { RoleUserRelationQueries } from './role-user-relations.js';
import { SsoConnectorQueries } from './sso-connectors.js';
import { UserRelationQueries } from './user-relations.js';
Expand Down Expand Up @@ -284,6 +285,7 @@ export default class OrganizationQueries extends SchemaQueries<
),
/** Queries for organization - user relations. */
users: new UserRelationQueries(this.pool),
// TODO: Rename to `usersRoles`
/** Queries for organization - organization role - user relations. */
rolesUsers: new RoleUserRelationQueries(this.pool),
/** Queries for organization - application relations. */
Expand All @@ -293,6 +295,9 @@ export default class OrganizationQueries extends SchemaQueries<
Organizations,
Applications
),
// TODO: Rename to `appsRoles`
/** Queries for organization - organization role - application relations. */
rolesApps: new RoleApplicationRelationQueries(this.pool),
invitationsRoles: new TwoRelationsQueries(
this.pool,
OrganizationInvitationRoleRelations.table,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import {
Organizations,
OrganizationRoles,
Applications,
OrganizationRoleApplicationRelations,
} from '@logto/schemas';
import { type CommonQueryMethods, sql } from '@silverhand/slonik';

import RelationQueries from '#src/utils/RelationQueries.js';
import { convertToIdentifiers } from '#src/utils/sql.js';

export class RoleApplicationRelationQueries extends RelationQueries<
[typeof Organizations, typeof OrganizationRoles, typeof Applications]
> {
constructor(pool: CommonQueryMethods) {
super(
pool,
OrganizationRoleApplicationRelations.table,
Organizations,
OrganizationRoles,
Applications
);
}

/** Replace the roles of an application in an organization. */
async replace(organizationId: string, applicationId: string, roleIds: readonly string[]) {
const applications = convertToIdentifiers(Applications);
const relations = convertToIdentifiers(OrganizationRoleApplicationRelations);

return this.pool.transaction(async (transaction) => {
// Lock application
await transaction.query(sql`
select 1
from ${applications.table}
where ${applications.fields.id} = ${applicationId}
for update
`);

// Delete old relations
await transaction.query(sql`
delete from ${relations.table}
where ${relations.fields.organizationId} = ${organizationId}
and ${relations.fields.applicationId} = ${applicationId}
`);

// Insert new relations
if (roleIds.length === 0) {
return;
}

await transaction.query(sql`
insert into ${relations.table} (
${relations.fields.organizationId},
${relations.fields.applicationId},
${relations.fields.organizationRoleId}
)
values ${sql.join(
roleIds.map((roleId) => sql`(${organizationId}, ${applicationId}, ${roleId})`),
sql`, `
)}
`);
});
}
}
16 changes: 8 additions & 8 deletions packages/core/src/queries/organization/role-user-relations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,7 @@ export class RoleUserRelationQueries extends RelationQueries<
return this.pool.transaction(async (transaction) => {
// Lock user
await transaction.query(sql`
select id
select 1
from ${users.table}
where ${users.fields.id} = ${userId}
for update
Expand All @@ -92,8 +92,8 @@ export class RoleUserRelationQueries extends RelationQueries<
// Delete old relations
await transaction.query(sql`
delete from ${relations.table}
where ${relations.fields.userId} = ${userId}
and ${relations.fields.organizationId} = ${organizationId}
where ${relations.fields.organizationId} = ${organizationId}
and ${relations.fields.userId} = ${userId}
`);

// Insert new relations
Expand All @@ -103,14 +103,14 @@ export class RoleUserRelationQueries extends RelationQueries<

await transaction.query(sql`
insert into ${relations.table} (
${relations.fields.userId},
${relations.fields.organizationId},
${relations.fields.userId},
${relations.fields.organizationRoleId}
)
values ${sql.join(
roleIds.map((roleId) => sql`(${userId}, ${organizationId}, ${roleId})`),
sql`, `
)}
values ${sql.join(
roleIds.map((roleId) => sql`(${organizationId}, ${userId}, ${roleId})`),
sql`, `
)}
`);
});
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
import { OrganizationRoles } from '@logto/schemas';
import type Router from 'koa-router';
import { z } from 'zod';

import RequestError from '#src/errors/RequestError/index.js';
import koaGuard from '#src/middleware/koa-guard.js';
import { type WithHookContext } from '#src/middleware/koa-management-api-hooks.js';
import koaPagination from '#src/middleware/koa-pagination.js';
import type OrganizationQueries from '#src/queries/organization/index.js';

// Consider building a class to handle these relations. See `index.user-role-relations.ts` for more information.
export default function applicationRoleRelationRoutes(
router: Router<unknown, WithHookContext>,
organizations: OrganizationQueries
) {
const params = Object.freeze({
id: z.string().min(1),
applicationId: z.string().min(1),
} as const);
const pathname = '/:id/applications/:applicationId/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, applicationId } = ctx.guard.params;

// Ensure membership
if (!(await organizations.relations.apps.exists({ organizationId: id, applicationId }))) {
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, applicationId } = ctx.guard.params;

const [totalCount, entities] = await organizations.relations.rolesApps.getEntities(
OrganizationRoles,
{
organizationId: id,
applicationId,
}
);

ctx.pagination.totalCount = totalCount;
ctx.body = entities;
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, applicationId } = ctx.guard.params;
const { organizationRoleIds } = ctx.guard.body;

await organizations.relations.rolesApps.insert(
...organizationRoleIds.map((organizationRoleId) => ({
organizationId: id,
applicationId,
organizationRoleId,
}))
);

ctx.status = 201;
return next();
}
);

router.put(
pathname,
koaGuard({
params: z.object(params),
body: z.object({
organizationRoleIds: z.string().min(1).array().nonempty(),
}),
status: [204, 422],
}),
async (ctx, next) => {
const { id, applicationId } = ctx.guard.params;
const { organizationRoleIds } = ctx.guard.body;

await organizations.relations.rolesApps.replace(id, applicationId, organizationRoleIds);

ctx.status = 204;
return next();
}
);

router.delete(
`${pathname}/:organizationRoleId`,
koaGuard({
params: z.object({ ...params, organizationRoleId: z.string().min(1) }),
status: [204, 422, 404],
}),
async (ctx, next) => {
const { id, applicationId, organizationRoleId } = ctx.guard.params;

await organizations.relations.rolesApps.delete({
organizationId: id,
applicationId,
organizationRoleId,
});

ctx.status = 204;
return next();
}
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,81 @@
}
}
}
},
"/api/organizations/{id}/applications/{applicationId}/roles": {
"get": {
"summary": "Get organization application roles",
"description": "Get roles associated with the application in the organization.",
"responses": {
"200": {
"description": "A list of roles."
}
}
},
"post": {
"summary": "Add organization application role",
"description": "Add a role to the application in the organization.",
"requestBody": {
"content": {
"application/json": {
"schema": {
"properties": {
"organizationRoleIds": {
"description": "The role ID to add."
}
}
}
}
}
},
"responses": {
"201": {
"description": "The role was added successfully."
},
"422": {
"description": "The role could not be added. Some of the roles may not exist."
}
}
},
"put": {
"summary": "Replace organization application roles",
"description": "Replace all roles associated with the application in the organization with the given data.",
"requestBody": {
"content": {
"application/json": {
"schema": {
"properties": {
"organizationRoleIds": {
"description": "An array of role IDs to replace existing roles."
}
}
}
}
}
},
"responses": {
"204": {
"description": "The roles were replaced successfully."
},
"422": {
"description": "The roles could not be replaced. Some of the roles may not exist."
}
}
}
},
"/api/organizations/{id}/applications/{applicationId}/roles/{organizationRoleId}": {
"delete": {
"summary": "Remove organization application role",
"description": "Remove a role from the application in the organization.",
"responses": {
"204": {
"description": "The role was removed from the application in the organization successfully."
},
"422": {
"description": "The role could not be removed. The role may not exist."
}
}
}
}
}
}
7 changes: 6 additions & 1 deletion packages/core/src/routes/organization/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import { parseSearchOptions } from '#src/utils/search.js';

import { type ManagementApiRouter, type RouterInitArgs } from '../types.js';

import applicationRoleRelationRoutes from './index.application-role-relations.js';
import emailDomainRoutes from './index.jit.email-domains.js';
import userRoleRelationRoutes from './index.user-role-relations.js';
import organizationInvitationRoutes from './invitations.js';
Expand Down Expand Up @@ -113,6 +114,7 @@ export default function organizationRoutes<T extends ManagementApiRouter>(
}
);

// MARK: Organization - user role relation routes
router.post(
'/:id/users/roles',
koaGuard({
Expand Down Expand Up @@ -140,11 +142,14 @@ export default function organizationRoutes<T extends ManagementApiRouter>(

userRoleRelationRoutes(router, organizations);

// MARK: Organization - application relation routes
if (EnvSet.values.isDevFeaturesEnabled) {
// MARK: Organization - application relation routes
router.addRelationRoutes(organizations.relations.apps, undefined, {
hookEvent: 'Organization.Membership.Updated',
});

// MARK: Organization - application role relation routes
applicationRoleRelationRoutes(router, organizations);
}

// MARK: Just-in-time provisioning
Expand Down
Loading

0 comments on commit ec95536

Please sign in to comment.