From 0d8263682d7b634ddf6012671d2bd7014e795925 Mon Sep 17 00:00:00 2001 From: Gao Sun Date: Thu, 20 Jun 2024 11:35:04 +0800 Subject: [PATCH] feat(core): organization jit sso apis --- .../core/src/queries/organization/index.ts | 8 ++ .../organization/index.jit.roles.openapi.json | 3 + .../index.jit.sso-connectors.openapi.json | 84 ++++++++++++ .../core/src/routes/organization/index.ts | 3 + .../src/api/organization-jit.ts | 6 + .../api/organization/organization-jit.test.ts | 122 +++++++++++++++++- ...8786576-organization-jit-sso-connectors.ts | 31 +++++ .../organization_jit_sso_connectors.sql | 13 ++ 8 files changed, 269 insertions(+), 1 deletion(-) create mode 100644 packages/core/src/routes/organization/index.jit.sso-connectors.openapi.json create mode 100644 packages/schemas/alterations/next-1718786576-organization-jit-sso-connectors.ts create mode 100644 packages/schemas/tables/organization_jit_sso_connectors.sql diff --git a/packages/core/src/queries/organization/index.ts b/packages/core/src/queries/organization/index.ts index baf8f3806b8..1d9d5a923b7 100644 --- a/packages/core/src/queries/organization/index.ts +++ b/packages/core/src/queries/organization/index.ts @@ -24,6 +24,8 @@ import { OrganizationJitRoles, OrganizationApplicationRelations, Applications, + OrganizationJitSsoConnectors, + SsoConnectors, } from '@logto/schemas'; import { sql, type CommonQueryMethods } from '@silverhand/slonik'; @@ -309,6 +311,12 @@ export default class OrganizationQueries extends SchemaQueries< Organizations, OrganizationRoles ), + ssoConnectors: new TwoRelationsQueries( + this.pool, + OrganizationJitSsoConnectors.table, + Organizations, + SsoConnectors + ), }; constructor(pool: CommonQueryMethods) { diff --git a/packages/core/src/routes/organization/index.jit.roles.openapi.json b/packages/core/src/routes/organization/index.jit.roles.openapi.json index 605ed4bbf00..b0f21f4f223 100644 --- a/packages/core/src/routes/organization/index.jit.roles.openapi.json +++ b/packages/core/src/routes/organization/index.jit.roles.openapi.json @@ -73,6 +73,9 @@ "responses": { "204": { "description": "The organization role was removed successfully." + }, + "422": { + "description": "The organization role could not be removed. The organization role may not exist." } } } diff --git a/packages/core/src/routes/organization/index.jit.sso-connectors.openapi.json b/packages/core/src/routes/organization/index.jit.sso-connectors.openapi.json new file mode 100644 index 00000000000..88567fbcef3 --- /dev/null +++ b/packages/core/src/routes/organization/index.jit.sso-connectors.openapi.json @@ -0,0 +1,84 @@ +{ + "tags": [ + { + "name": "Organizations" + } + ], + "paths": { + "/api/organizations/{id}/jit/sso-connectors": { + "get": { + "summary": "Get organization JIT SSO connectors", + "description": "Get enterprise SSO connectors for just-in-time provisioning of users in the organization.", + "responses": { + "200": { + "description": "A list of SSO connectors." + } + } + }, + "post": { + "summary": "Add organization JIT SSO connectors", + "description": "Add new enterprise SSO connectors for just-in-time provisioning of users in the organization.", + "requestBody": { + "content": { + "application/json": { + "schema": { + "properties": { + "ssoConnectorIds": { + "description": "The SSO connector IDs to add." + } + } + } + } + } + }, + "responses": { + "201": { + "description": "The SSO connectors were added successfully." + }, + "422": { + "description": "The SSO connectors could not be added. Some of the SSO connectors may not exist." + } + } + }, + "put": { + "summary": "Replace organization JIT SSO connectors", + "description": "Replace all enterprise SSO connectors for just-in-time provisioning of users in the organization with the given data.", + "requestBody": { + "content": { + "application/json": { + "schema": { + "properties": { + "ssoConnectorIds": { + "description": "An array of SSO connector IDs to replace existing SSO connectors." + } + } + } + } + } + }, + "responses": { + "204": { + "description": "The SSO connectors were replaced successfully." + }, + "422": { + "description": "The SSO connectors could not be replaced. Some of the SSO connectors may not exist." + } + } + } + }, + "/api/organizations/{id}/jit/sso-connectors/{ssoConnectorId}": { + "delete": { + "summary": "Remove organization JIT SSO connector", + "description": "Remove an enterprise SSO connector for just-in-time provisioning of users in the organization.", + "responses": { + "204": { + "description": "The SSO connector was removed successfully." + }, + "422": { + "description": "The SSO connector could not be removed. The SSO connector may not exist." + } + } + } + } + } +} diff --git a/packages/core/src/routes/organization/index.ts b/packages/core/src/routes/organization/index.ts index 96339b49dc4..bbffee6b627 100644 --- a/packages/core/src/routes/organization/index.ts +++ b/packages/core/src/routes/organization/index.ts @@ -150,6 +150,9 @@ export default function organizationRoutes( // MARK: Just-in-time provisioning emailDomainRoutes(router, organizations); router.addRelationRoutes(organizations.jit.roles, 'jit/roles', { isPaginationOptional: true }); + router.addRelationRoutes(organizations.jit.ssoConnectors, 'jit/sso-connectors', { + isPaginationOptional: true, + }); // MARK: Mount sub-routes organizationRoleRoutes(...args); diff --git a/packages/integration-tests/src/api/organization-jit.ts b/packages/integration-tests/src/api/organization-jit.ts index 203e34db984..9f7b82df6c6 100644 --- a/packages/integration-tests/src/api/organization-jit.ts +++ b/packages/integration-tests/src/api/organization-jit.ts @@ -10,6 +10,12 @@ export class OrganizationJitApi { relationKey: 'organizationRoleIds', }); + ssoConnectors = new RelationApiFactory({ + basePath: 'organizations', + relationPath: 'jit/sso-connectors', + relationKey: 'ssoConnectorIds', + }); + constructor(public path: string) {} async getEmailDomains( diff --git a/packages/integration-tests/src/tests/api/organization/organization-jit.test.ts b/packages/integration-tests/src/tests/api/organization/organization-jit.test.ts index 3197f3ca793..ec150ffcb58 100644 --- a/packages/integration-tests/src/tests/api/organization/organization-jit.test.ts +++ b/packages/integration-tests/src/tests/api/organization/organization-jit.test.ts @@ -1,5 +1,11 @@ +import { type SsoConnector } from '@logto/schemas'; import { generateStandardId } from '@logto/shared'; +import { providerNames } from '#src/__mocks__/sso-connectors-mock.js'; +import { + createSsoConnector as createSsoConnectorApi, + deleteSsoConnectorById, +} from '#src/api/sso-connector.js'; import { OrganizationApiTest } from '#src/helpers/organization.js'; import { randomString } from '#src/utils.js'; @@ -7,9 +13,20 @@ const randomId = () => generateStandardId(6); describe('organization just-in-time provisioning', () => { const organizationApi = new OrganizationApiTest(); + const ssoConnectors: SsoConnector[] = []; + const createSsoConnector = async (...args: Parameters) => { + const ssoConnector = await createSsoConnectorApi(...args); + // eslint-disable-next-line @silverhand/fp/no-mutating-methods + ssoConnectors.push(ssoConnector); + return ssoConnector; + }; afterEach(async () => { - await organizationApi.cleanUp(); + await Promise.all([ + organizationApi.cleanUp(), + // eslint-disable-next-line @typescript-eslint/no-empty-function + ssoConnectors.map(async ({ id }) => deleteSsoConnectorById(id).catch(() => {})), + ]); }); describe('email domains', () => { @@ -176,4 +193,107 @@ describe('organization just-in-time provisioning', () => { ); }); }); + + describe('sso connectors', () => { + it('should add and delete sso connectors', async () => { + const organization = await organizationApi.create({ name: `jit-sso:${randomString()}` }); + const ssoConnector = await createSsoConnector({ + providerName: providerNames[0], + connectorName: `My dude:${randomString()}`, + }); + + await organizationApi.jit.ssoConnectors.add(organization.id, [ssoConnector.id]); + await expect( + organizationApi.jit.ssoConnectors.getList(organization.id) + ).resolves.toMatchObject([{ id: ssoConnector.id }]); + + await organizationApi.jit.ssoConnectors.delete(organization.id, ssoConnector.id); + await expect(organizationApi.jit.ssoConnectors.getList(organization.id)).resolves.toEqual([]); + }); + + it('should have no pagination', async () => { + const organization = await organizationApi.create({ name: `jit-sso:${randomString()}` }); + const ssoConnectors = await Promise.all( + Array.from({ length: 30 }, async () => + createSsoConnector({ + providerName: providerNames[0], + connectorName: `My dude:${randomString()}`, + }) + ) + ); + + await organizationApi.jit.ssoConnectors.replace( + organization.id, + ssoConnectors.map(({ id }) => id) + ); + + await expect(organizationApi.jit.ssoConnectors.getList(organization.id)).resolves.toEqual( + expect.arrayContaining(ssoConnectors.map(({ id }) => expect.objectContaining({ id }))) + ); + }); + + it('should return 404 when deleting a non-existent sso connector', async () => { + const organization = await organizationApi.create({ name: `jit-sso:${randomString()}` }); + const ssoConnectorId = randomId(); + + await expect( + organizationApi.jit.ssoConnectors.delete(organization.id, ssoConnectorId) + ).rejects.toMatchInlineSnapshot('[HTTPError: Request failed with status code 404 Not Found]'); + }); + + it('should return 422 when adding a non-existent sso connector', async () => { + const organization = await organizationApi.create({ name: `jit-sso:${randomString()}` }); + const ssoConnectorId = randomId(); + + await expect( + organizationApi.jit.ssoConnectors.add(organization.id, [ssoConnectorId]) + ).rejects.toMatchInlineSnapshot( + '[HTTPError: Request failed with status code 422 Unprocessable Entity]' + ); + }); + + it('should do nothing when adding an sso connector that already exists', async () => { + const organization = await organizationApi.create({ name: `jit-sso:${randomString()}` }); + const ssoConnector = await createSsoConnector({ + providerName: providerNames[0], + connectorName: `My dude:${randomString()}`, + }); + + await organizationApi.jit.ssoConnectors.add(organization.id, [ssoConnector.id]); + await expect( + organizationApi.jit.ssoConnectors.add(organization.id, [ssoConnector.id]) + ).resolves.toBeUndefined(); + }); + + it('should be able to replace sso connectors', async () => { + const organization = await organizationApi.create({ name: `jit-sso:${randomString()}` }); + const ssoConnectors = await Promise.all( + Array.from({ length: 2 }, async () => + createSsoConnector({ + providerName: providerNames[0], + connectorName: `My dude:${randomString()}`, + }) + ) + ); + + await organizationApi.jit.ssoConnectors.replace( + organization.id, + ssoConnectors.map(({ id }) => id) + ); + await expect(organizationApi.jit.ssoConnectors.getList(organization.id)).resolves.toEqual( + expect.arrayContaining(ssoConnectors.map(({ id }) => expect.objectContaining({ id }))) + ); + }); + + it('should return 422 when replacing with a non-existent sso connector', async () => { + const organization = await organizationApi.create({ name: `jit-sso:${randomString()}` }); + const ssoConnectorId = randomId(); + + await expect( + organizationApi.jit.ssoConnectors.replace(organization.id, [ssoConnectorId]) + ).rejects.toMatchInlineSnapshot( + '[HTTPError: Request failed with status code 422 Unprocessable Entity]' + ); + }); + }); }); diff --git a/packages/schemas/alterations/next-1718786576-organization-jit-sso-connectors.ts b/packages/schemas/alterations/next-1718786576-organization-jit-sso-connectors.ts new file mode 100644 index 00000000000..a1067c4d07d --- /dev/null +++ b/packages/schemas/alterations/next-1718786576-organization-jit-sso-connectors.ts @@ -0,0 +1,31 @@ +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 table organization_jit_sso_connectors ( + tenant_id varchar(21) not null + references tenants (id) on update cascade on delete cascade, + /** The ID of the organization. */ + organization_id varchar(21) not null + references organizations (id) on update cascade on delete cascade, + sso_connector_id varchar(128) not null + references sso_connectors (id) on update cascade on delete cascade, + primary key (tenant_id, organization_id, sso_connector_id) + ); + `); + await applyTableRls(pool, 'organization_jit_sso_connectors'); + }, + down: async (pool) => { + await dropTableRls(pool, 'organization_jit_sso_connectors'); + await pool.query(sql` + drop table organization_jit_sso_connectors; + `); + }, +}; + +export default alteration; diff --git a/packages/schemas/tables/organization_jit_sso_connectors.sql b/packages/schemas/tables/organization_jit_sso_connectors.sql new file mode 100644 index 00000000000..9ef7fb5ba08 --- /dev/null +++ b/packages/schemas/tables/organization_jit_sso_connectors.sql @@ -0,0 +1,13 @@ +/* init_order = 2 */ + +/** The enterprise SSO connectors that will automatically assign users into an organization when they are authenticated via the SSO connector for the first time. */ +create table organization_jit_sso_connectors ( + tenant_id varchar(21) not null + references tenants (id) on update cascade on delete cascade, + /** The ID of the organization. */ + organization_id varchar(21) not null + references organizations (id) on update cascade on delete cascade, + sso_connector_id varchar(128) not null + references sso_connectors (id) on update cascade on delete cascade, + primary key (tenant_id, organization_id, sso_connector_id) +);