0
Fork 0
mirror of https://github.com/logto-io/logto.git synced 2025-03-24 22:41:28 -05:00

Merge pull request #6064 from logto-io/gao-init-org-app-apis

feat(core): init organization app apis
This commit is contained in:
Gao Sun 2024-06-20 09:58:09 +08:00 committed by GitHub
commit 5362772f6d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
19 changed files with 405 additions and 40 deletions

View file

@ -22,6 +22,8 @@ import {
Resources,
Users,
OrganizationJitRoles,
OrganizationApplicationRelations,
Applications,
} from '@logto/schemas';
import { sql, type CommonQueryMethods } from '@silverhand/slonik';
@ -283,6 +285,13 @@ export default class OrganizationQueries extends SchemaQueries<
users: new UserRelationQueries(this.pool),
/** Queries for organization - organization role - user relations. */
rolesUsers: new RoleUserRelationQueries(this.pool),
/** Queries for organization - application relations. */
apps: new TwoRelationsQueries(
this.pool,
OrganizationApplicationRelations.table,
Organizations,
Applications
),
invitationsRoles: new TwoRelationsQueries(
this.pool,
OrganizationInvitationRoleRelations.table,

View file

@ -0,0 +1,82 @@
{
"tags": [
{
"name": "Organization applications",
"description": "Manage organization - application relationships. An application can be associated with one or more organizations in order to grant organization access to the application.\n\nCurrently, only machine-to-machine applications can be associated with organizations."
}
],
"paths": {
"/api/organizations/{id}/applications": {
"get": {
"summary": "Get organization applications",
"description": "Get applications associated with the organization.",
"responses": {
"200": {
"description": "A list of applications."
}
}
},
"post": {
"summary": "Add organization application",
"description": "Add an application to the organization.",
"requestBody": {
"content": {
"application/json": {
"schema": {
"properties": {
"applicationIds": {
"description": "The application IDs to add."
}
}
}
}
}
},
"responses": {
"201": {
"description": "The application was added successfully."
},
"422": {
"description": "The application could not be added. Some of the applications may not exist."
}
}
},
"put": {
"summary": "Replace organization applications",
"description": "Replace all applications associated with the organization with the given data.",
"requestBody": {
"content": {
"application/json": {
"schema": {
"properties": {
"applicationIds": {
"description": "An array of application IDs to replace existing applications."
}
}
}
}
}
},
"responses": {
"204": {
"description": "The applications were replaced successfully."
},
"422": {
"description": "The applications could not be replaced. Some of the applications may not exist."
}
}
}
},
"/api/organizations/{id}/applications/{applicationId}": {
"delete": {
"summary": "Remove organization application",
"description": "Remove an application from the organization.",
"responses": {
"204": {
"description": "The application was removed from the organization successfully."
}
}
}
}
}
}

View file

@ -59,6 +59,9 @@
"responses": {
"204": {
"description": "The organization roles were replaced successfully."
},
"422": {
"description": "The organization roles could not be replaced. Some of the organization roles may not exist."
}
}
}

View file

@ -7,6 +7,7 @@ import {
import { yes } from '@silverhand/essentials';
import { z } from 'zod';
import { EnvSet } from '#src/env-set/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';
@ -139,6 +140,13 @@ export default function organizationRoutes<T extends ManagementApiRouter>(
userRoleRelationRoutes(router, organizations);
// MARK: Organization - application relation routes
if (EnvSet.values.isDevFeaturesEnabled) {
router.addRelationRoutes(organizations.relations.apps, undefined, {
hookEvent: 'Organization.Membership.Updated',
});
}
// MARK: Just-in-time provisioning
emailDomainRoutes(router, organizations);
router.addRelationRoutes(organizations.jit.roles, 'jit/roles', { isPaginationOptional: true });

View file

@ -112,7 +112,7 @@ const isManagementApiRouter = ({ stack }: Router) =>
// Add more components here to cover more ID parameters in paths. For example, if there is a
// path `/foo/:barBazId`, then add `bar-baz` to the array.
const identifiableEntityNames = [
const identifiableEntityNames = Object.freeze([
'key',
'connector-factory',
'factory',
@ -131,7 +131,10 @@ const identifiableEntityNames = [
'organization-role',
'organization-scope',
'organization-invitation',
];
]);
/** Additional tags that cannot be inferred from the path. */
const additionalTags = Object.freeze(['Organization applications']);
/**
* Attach the `/swagger.json` route which returns the generated OpenAPI document for the
@ -229,7 +232,7 @@ export default function swaggerRoutes<T extends AnonymousRouter, R extends Route
{}
),
},
tags: [...tags].map((tag) => ({ name: tag })),
tags: [...tags, ...additionalTags].map((tag) => ({ name: tag })),
};
const data = supplementDocuments.reduce<OpenAPIV3.Document>(

View file

@ -142,7 +142,9 @@ export const validateSupplement = (
for (const { name } of supplementTags) {
if (!originalTags.has(name)) {
throw new TypeError(`Supplement document contains extra tag \`${name}\`.`);
throw new TypeError(
`Supplement document contains extra tag \`${name}\`. If you want to add a new tag, please add it to the \`additionalTags\` array in the main swagger route file.`
);
}
}
}

View file

@ -159,6 +159,7 @@ export default class SchemaRouter<
*
* - `GET /:id/[pathname]`: Get the entities of the relation with pagination.
* - `POST /:id/[pathname]`: Add entities to the relation.
* - `PUT /:id/[pathname]`: Replace the entities in the relation.
* - `DELETE /:id/[pathname]/:relationSchemaId`: Remove an entity from the relation set.
* The `:relationSchemaId` is the entity ID in the relation schema.
*

View file

@ -49,3 +49,74 @@ export class ApiFactory<
await authedAdminApi.delete(this.path + '/' + id);
}
}
type RelationApiFactoryConfig = {
/** The base path of the API. */
basePath: string;
/**
* The path of the relation. It will to be appended to the base path and the ID of the parent.
*
* @example
* If the base path is `organizations` and the relation path is `applications`, the final paths
* will be `organizations/:id/applications` and `organizations/:id/applications/:applicationId`.
*/
relationPath: string;
/**
* The key name of the relation IDs. It will be used in the request body.
*
* @example
* If the key name is `applicationIds`, the request body will be
* `{ applicationIds: ['id1', 'id2'] }`.
*/
relationKey: string;
};
export class RelationApiFactory<RelationSchema extends Record<string, unknown>> {
constructor(public readonly config: RelationApiFactoryConfig) {}
get basePath(): string {
return this.config.basePath;
}
get relationPath(): string {
return this.config.relationPath;
}
get relationKey(): string {
return this.config.relationKey;
}
async getList(id: string, page?: number, pageSize?: number): Promise<RelationSchema[]> {
const searchParams = new URLSearchParams();
if (page) {
searchParams.append('page', String(page));
}
if (pageSize) {
searchParams.append('page_size', String(pageSize));
}
return transform(
await authedAdminApi
.get(`${this.basePath}/${id}/${this.relationPath}`, { searchParams })
.json<RelationSchema[]>()
);
}
async add(id: string, relationIds: string[]): Promise<void> {
await authedAdminApi.post(`${this.basePath}/${id}/${this.relationPath}`, {
json: { [this.relationKey]: relationIds },
});
}
async delete(id: string, relationId: string): Promise<void> {
await authedAdminApi.delete(`${this.basePath}/${id}/${this.relationPath}/${relationId}`);
}
async replace(id: string, relationIds: string[]): Promise<void> {
await authedAdminApi.put(`${this.basePath}/${id}/${this.relationPath}`, {
json: { [this.relationKey]: relationIds },
});
}
}

View file

@ -1,8 +1,15 @@
import { type OrganizationRole, type OrganizationJitEmailDomain } from '@logto/schemas';
import { authedAdminApi } from './api.js';
import { RelationApiFactory } from './factory.js';
export class OrganizationJitApi {
roles = new RelationApiFactory<OrganizationRole>({
basePath: 'organizations',
relationPath: 'jit/roles',
relationKey: 'organizationRoleIds',
});
constructor(public path: string) {}
async getEmailDomains(
@ -36,20 +43,4 @@ export class OrganizationJitApi {
async replaceEmailDomains(id: string, emailDomains: string[]): Promise<void> {
await authedAdminApi.put(`${this.path}/${id}/jit/email-domains`, { json: { emailDomains } });
}
async getRoles(id: string): Promise<OrganizationRole[]> {
return authedAdminApi.get(`${this.path}/${id}/jit/roles`).json<OrganizationRole[]>();
}
async addRole(id: string, organizationRoleIds: string[]): Promise<void> {
await authedAdminApi.post(`${this.path}/${id}/jit/roles`, { json: { organizationRoleIds } });
}
async deleteRole(id: string, organizationRoleId: string): Promise<void> {
await authedAdminApi.delete(`${this.path}/${id}/jit/roles/${organizationRoleId}`);
}
async replaceRoles(id: string, organizationRoleIds: string[]): Promise<void> {
await authedAdminApi.put(`${this.path}/${id}/jit/roles`, { json: { organizationRoleIds } });
}
}

View file

@ -6,10 +6,11 @@ import {
type OrganizationWithFeatured,
type OrganizationScope,
type CreateOrganization,
type Application,
} from '@logto/schemas';
import { authedAdminApi } from './api.js';
import { ApiFactory } from './factory.js';
import { ApiFactory, RelationApiFactory } from './factory.js';
import { OrganizationJitApi } from './organization-jit.js';
type Query = {
@ -20,6 +21,11 @@ type Query = {
export class OrganizationApi extends ApiFactory<Organization, Omit<CreateOrganization, 'id'>> {
jit = new OrganizationJitApi(this.path);
applications = new RelationApiFactory<Application>({
basePath: 'organizations',
relationPath: 'applications',
relationKey: 'applicationIds',
});
constructor() {
super('organizations');

View file

@ -28,8 +28,8 @@ describe('organization just-in-time provisioning', () => {
)
);
await Promise.all([
organizationApi.jit.addRole(organizations[0].id, [roles[0].id, roles[1].id]),
organizationApi.jit.addRole(organizations[1].id, [roles[0].id]),
organizationApi.jit.roles.add(organizations[0].id, [roles[0].id, roles[1].id]),
organizationApi.jit.roles.add(organizations[1].id, [roles[0].id]),
]);
const email = randomString() + '@' + emailDomain;

View file

@ -1,4 +1,5 @@
import {
ApplicationType,
RoleType,
hookEventGuard,
hookEvents,
@ -10,8 +11,10 @@ import { assert } from '@silverhand/essentials';
import { z } from 'zod';
import { authedAdminApi } from '#src/api/api.js';
import { createApplication, deleteApplication } from '#src/api/application.js';
import { createResource } from '#src/api/resource.js';
import { createScope } from '#src/api/scope.js';
import { isDevFeaturesEnabled } from '#src/constants.js';
import { WebHookApiTest } from '#src/helpers/hook.js';
import {
OrganizationApiTest,
@ -214,6 +217,7 @@ describe('organization data hook events', () => {
/* eslint-disable @silverhand/fp/no-let */
let organizationId: string;
let userId: string;
let applicationId: string;
/* eslint-enable @silverhand/fp/no-let */
const organizationApi = new OrganizationApiTest();
@ -226,10 +230,12 @@ describe('organization data hook events', () => {
});
const user = await userApi.create({ name: generateName() });
const application = await createApplication(generateName(), ApplicationType.MachineToMachine);
/* eslint-disable @silverhand/fp/no-mutation */
organizationId = organization.id;
userId = user.id;
applicationId = application.id;
/* eslint-enable @silverhand/fp/no-mutation */
const organizationCreateHook = await getWebhookResult('POST /organizations');
@ -237,15 +243,30 @@ describe('organization data hook events', () => {
});
afterAll(async () => {
await userApi.cleanUp();
// eslint-disable-next-line @typescript-eslint/no-empty-function
await Promise.all([userApi.cleanUp(), deleteApplication(applicationId).catch(() => {})]);
});
it.each(organizationDataHookTestCases)(
'test case %#: %p',
async ({ route, event, method, endpoint, payload, hookPayload }) => {
// TODO: Remove this check
if (route.includes('applications') && !isDevFeaturesEnabled) {
return;
}
await authedAdminApi[method](
endpoint.replace('{organizationId}', organizationId).replace('{userId}', userId),
{ json: JSON.parse(JSON.stringify(payload).replace('{userId}', userId)) }
endpoint
.replace('{organizationId}', organizationId)
.replace('{userId}', userId)
.replace('{applicationId}', applicationId),
{
json: JSON.parse(
JSON.stringify(payload)
.replace('{userId}', userId)
.replace('{applicationId}', applicationId)
),
}
);
const hook = await getWebhookResult(route);
expect(hook?.payload.event).toBe(event);

View file

@ -129,6 +129,30 @@ export const organizationDataHookTestCases: TestCase[] = [
payload: {},
hookPayload: { organizationId: expect.any(String) },
},
{
route: 'POST /organizations/:id/applications',
event: 'Organization.Membership.Updated',
method: 'post',
endpoint: `organizations/{organizationId}/applications`,
payload: { applicationIds: ['{applicationId}'] },
hookPayload: { organizationId: expect.any(String) },
},
{
route: 'PUT /organizations/:id/applications',
event: 'Organization.Membership.Updated',
method: 'put',
endpoint: `organizations/{organizationId}/applications`,
payload: { applicationIds: ['{applicationId}'] },
hookPayload: { organizationId: expect.any(String) },
},
{
route: 'DELETE /organizations/:id/applications/:applicationId',
event: 'Organization.Membership.Updated',
method: 'delete',
endpoint: `organizations/{organizationId}/applications/{applicationId}`,
payload: {},
hookPayload: { organizationId: expect.any(String) },
},
{
route: 'DELETE /organizations/:id',
event: 'Organization.Deleted',

View file

@ -62,8 +62,8 @@ describe('organization just-in-time provisioning', () => {
)
);
await Promise.all([
organizationApi.jit.addRole(organizations[0].id, [roles[0].id, roles[1].id]),
organizationApi.jit.addRole(organizations[1].id, [roles[0].id]),
organizationApi.jit.roles.add(organizations[0].id, [roles[0].id, roles[1].id]),
organizationApi.jit.roles.add(organizations[1].id, [roles[0].id]),
]);
const email = randomString() + '@' + emailDomain;

View file

@ -0,0 +1,88 @@
import assert from 'node:assert';
import { ApplicationType, type Application } from '@logto/schemas';
import { HTTPError } from 'ky';
import {
createApplication as createApplicationApi,
deleteApplication,
} from '#src/api/application.js';
import { OrganizationApiTest } from '#src/helpers/organization.js';
import { devFeatureTest, generateTestName } from '#src/utils.js';
// TODO: Remove this prefix
devFeatureTest.describe('organization application APIs', () => {
describe('organization - application relations', () => {
const organizationApi = new OrganizationApiTest();
const applications: Application[] = [];
const createApplication = async (...args: Parameters<typeof createApplicationApi>) => {
const created = await createApplicationApi(...args);
// eslint-disable-next-line @silverhand/fp/no-mutating-methods
applications.push(created);
return created;
};
afterEach(async () => {
await Promise.all([
organizationApi.cleanUp(),
// eslint-disable-next-line @typescript-eslint/no-empty-function
...applications.map(async ({ id }) => deleteApplication(id).catch(() => {})),
]);
});
it('should fail when try to add empty application list', async () => {
const organization = await organizationApi.create({ name: 'test' });
const response = await organizationApi.applications
.add(organization.id, [])
.catch((error: unknown) => error);
expect(response instanceof HTTPError && response.response.status).toBe(400);
});
it('should fail when try to add application to an organization that does not exist', async () => {
const response = await organizationApi.applications
.add('0', ['0'])
.catch((error: unknown) => error);
assert(response instanceof HTTPError);
expect(response.response.status).toBe(422);
expect(await response.response.json()).toMatchObject(
expect.objectContaining({ code: 'entity.relation_foreign_key_not_found' })
);
});
it('should be able to add and delete organization application', async () => {
const organization = await organizationApi.create({ name: 'test' });
const application = await createApplication(
generateTestName(),
ApplicationType.MachineToMachine
);
await organizationApi.applications.add(organization.id, [application.id]);
expect(await organizationApi.applications.getList(organization.id)).toContainEqual(
application
);
await organizationApi.applications.delete(organization.id, application.id);
expect(await organizationApi.applications.getList(organization.id)).not.toContainEqual(
application
);
});
it('should fail when try to delete application from an organization that does not exist', async () => {
const response = await organizationApi.applications
.delete('0', '0')
.catch((error: unknown) => error);
assert(response instanceof HTTPError);
expect(response.response.status).toBe(404);
});
it('should fail when try to add application that is not machine-to-machine', async () => {
const organization = await organizationApi.create({ name: 'test' });
const application = await createApplication(generateTestName(), ApplicationType.Native);
const response = await organizationApi.applications
.add(organization.id, [application.id])
.catch((error: unknown) => error);
expect(response instanceof HTTPError && response.response.status).toBe(422);
});
});
});

View file

@ -88,13 +88,13 @@ describe('organization just-in-time provisioning', () => {
name: `jit-role:${randomString()}`,
});
await organizationApi.jit.addRole(organization.id, [organizationRoleId]);
await expect(organizationApi.jit.getRoles(organization.id)).resolves.toMatchObject([
await organizationApi.jit.roles.add(organization.id, [organizationRoleId]);
await expect(organizationApi.jit.roles.getList(organization.id)).resolves.toMatchObject([
{ id: organizationRoleId },
]);
await organizationApi.jit.deleteRole(organization.id, organizationRoleId);
await expect(organizationApi.jit.getRoles(organization.id)).resolves.toEqual([]);
await organizationApi.jit.roles.delete(organization.id, organizationRoleId);
await expect(organizationApi.jit.roles.getList(organization.id)).resolves.toEqual([]);
});
it('should have no pagination', async () => {
@ -107,12 +107,12 @@ describe('organization just-in-time provisioning', () => {
)
);
await organizationApi.jit.replaceRoles(
await organizationApi.jit.roles.replace(
organization.id,
organizationRoles.map(({ id }) => id)
);
await expect(organizationApi.jit.getRoles(organization.id)).resolves.toEqual(
await expect(organizationApi.jit.roles.getList(organization.id)).resolves.toEqual(
expect.arrayContaining(organizationRoles.map(({ id }) => expect.objectContaining({ id })))
);
});
@ -122,7 +122,7 @@ describe('organization just-in-time provisioning', () => {
const organizationRoleId = randomId();
await expect(
organizationApi.jit.deleteRole(organization.id, organizationRoleId)
organizationApi.jit.roles.delete(organization.id, organizationRoleId)
).rejects.toMatchInlineSnapshot('[HTTPError: Request failed with status code 404 Not Found]');
});
@ -131,7 +131,7 @@ describe('organization just-in-time provisioning', () => {
const organizationRoleId = randomId();
await expect(
organizationApi.jit.addRole(organization.id, [organizationRoleId])
organizationApi.jit.roles.add(organization.id, [organizationRoleId])
).rejects.toMatchInlineSnapshot(
'[HTTPError: Request failed with status code 422 Unprocessable Entity]'
);
@ -148,9 +148,9 @@ describe('organization just-in-time provisioning', () => {
}),
]);
await organizationApi.jit.addRole(organization.id, [organizationRoles[0].id]);
await organizationApi.jit.roles.add(organization.id, [organizationRoles[0].id]);
await expect(
organizationApi.jit.addRole(organization.id, [
organizationApi.jit.roles.add(organization.id, [
organizationRoles[0].id,
organizationRoles[1].id,
])
@ -167,11 +167,11 @@ describe('organization just-in-time provisioning', () => {
)
);
await organizationApi.jit.replaceRoles(
await organizationApi.jit.roles.replace(
organization.id,
organizationRoles.map(({ id }) => id)
);
await expect(organizationApi.jit.getRoles(organization.id)).resolves.toEqual(
await expect(organizationApi.jit.roles.getList(organization.id)).resolves.toEqual(
expect.arrayContaining(organizationRoles.map(({ id }) => expect.objectContaining({ id })))
);
});

View file

@ -0,0 +1,37 @@
import { sql } from '@silverhand/slonik';
import type { AlterationScript } from '../lib/types/alteration.js';
import { applyTableRls, dropTableRls } from './utils/1704934999-tables.js';
const alteration: AlterationScript = {
up: async (pool) => {
await pool.query(sql`
create function check_application_type(application_id varchar(21), target_type application_type) returns boolean as
$$ begin
return (select type from applications where id = application_id) = target_type;
end; $$ language plpgsql;
create table organization_application_relations (
tenant_id varchar(21) not null
references tenants (id) on update cascade on delete cascade,
organization_id varchar(21) not null
references organizations (id) on update cascade on delete cascade,
application_id varchar(21) not null
references applications (id) on update cascade on delete cascade,
primary key (tenant_id, organization_id, application_id),
constraint application_type
check (check_application_type(application_id, 'MachineToMachine'))
);
`);
await applyTableRls(pool, 'organization_application_relations');
},
down: async (pool) => {
await dropTableRls(pool, 'organization_application_relations');
await pool.query(sql`
drop table organization_application_relations;
drop function check_application_type;
`);
},
};
export default alteration;

View file

@ -33,3 +33,8 @@ create unique index applications__protected_app_metadata_custom_domain
on applications (
(protected_app_metadata->'customDomains'->0->>'domain')
);
create function check_application_type(application_id varchar(21), target_type application_type) returns boolean as
$$ begin
return (select type from applications where id = application_id) = target_type;
end; $$ language plpgsql;

View file

@ -0,0 +1,14 @@
/* init_order = 2 */
/** The relations between organizations and applications. It indicates membership of applications in organizations. For now only machine-to-machine applications are supported. */
create table organization_application_relations (
tenant_id varchar(21) not null
references tenants (id) on update cascade on delete cascade,
organization_id varchar(21) not null
references organizations (id) on update cascade on delete cascade,
application_id varchar(21) not null
references applications (id) on update cascade on delete cascade,
primary key (tenant_id, organization_id, application_id),
constraint application_type
check (check_application_type(application_id, 'MachineToMachine'))
);