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:
commit
c1ffadeff6
8 changed files with 269 additions and 1 deletions
|
@ -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) {
|
||||
|
|
|
@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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]'
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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;
|
13
packages/schemas/tables/organization_jit_sso_connectors.sql
Normal file
13
packages/schemas/tables/organization_jit_sso_connectors.sql
Normal 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)
|
||||
);
|
Loading…
Add table
Reference in a new issue