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

Merge pull request #6067 from logto-io/gao-org-jit-sso

feat(core): organization jit sso apis
This commit is contained in:
Gao Sun 2024-06-21 09:54:33 +08:00 committed by GitHub
commit c1ffadeff6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 269 additions and 1 deletions

View file

@ -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) {

View file

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

View file

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

View file

@ -150,6 +150,9 @@ export default function organizationRoutes<T extends ManagementApiRouter>(
// 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);

View file

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

View file

@ -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<typeof createSsoConnectorApi>) => {
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]'
);
});
});
});

View file

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

View file

@ -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)
);