Skip to content

Commit

Permalink
Merge pull request #6067 from logto-io/gao-org-jit-sso
Browse files Browse the repository at this point in the history
feat(core): organization jit sso apis
  • Loading branch information
gao-sun authored Jun 21, 2024
2 parents e89147a + 0d82636 commit c1ffade
Show file tree
Hide file tree
Showing 8 changed files with 269 additions and 1 deletion.
8 changes: 8 additions & 0 deletions packages/core/src/queries/organization/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@ import {
OrganizationJitRoles,
OrganizationApplicationRelations,
Applications,
OrganizationJitSsoConnectors,
SsoConnectors,
} from '@logto/schemas';
import { sql, type CommonQueryMethods } from '@silverhand/slonik';

Expand Down Expand Up @@ -309,6 +311,12 @@ export default class OrganizationQueries extends SchemaQueries<
Organizations,
OrganizationRoles
),
ssoConnectors: new TwoRelationsQueries(
this.pool,
OrganizationJitSsoConnectors.table,
Organizations,
SsoConnectors
),
};

constructor(pool: CommonQueryMethods) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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."
}
}
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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."
}
}
}
}
}
}
3 changes: 3 additions & 0 deletions packages/core/src/routes/organization/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
6 changes: 6 additions & 0 deletions packages/integration-tests/src/api/organization-jit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
Original file line number Diff line number Diff line change
@@ -1,15 +1,32 @@
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';

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', () => {
Expand Down Expand Up @@ -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]'
);
});
});
});
Original file line number Diff line number Diff line change
@@ -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 changes: 13 additions & 0 deletions packages/schemas/tables/organization_jit_sso_connectors.sql
Original file line number Diff line number Diff line change
@@ -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)
);

0 comments on commit c1ffade

Please sign in to comment.