diff --git a/backend/src/ee/routes/v1/scim-router.ts b/backend/src/ee/routes/v1/scim-router.ts index ddb0b39be..8c2415cce 100644 --- a/backend/src/ee/routes/v1/scim-router.ts +++ b/backend/src/ee/routes/v1/scim-router.ts @@ -206,7 +206,7 @@ export const registerScimRouter = async (server: FastifyZodProvider) => { schema: { body: z.object({ schemas: z.array(z.string()), - userName: z.string().trim().email(), + userName: z.string().trim(), name: z.object({ familyName: z.string().trim(), givenName: z.string().trim() @@ -227,7 +227,7 @@ export const registerScimRouter = async (server: FastifyZodProvider) => { 200: z.object({ schemas: z.array(z.string()), id: z.string().trim(), - userName: z.string().trim().email(), + userName: z.string().trim(), name: z.object({ familyName: z.string().trim(), givenName: z.string().trim() @@ -260,6 +260,28 @@ export const registerScimRouter = async (server: FastifyZodProvider) => { } }); + server.route({ + url: "/Users/:userId", + method: "DELETE", + schema: { + params: z.object({ + userId: z.string().trim() + }), + response: { + 200: z.object({}) + } + }, + onRequest: verifyAuth([AuthMode.SCIM_TOKEN]), + handler: async (req) => { + const user = await req.server.services.scim.deleteScimUser({ + userId: req.params.userId, + orgId: req.permission.orgId + }); + + return user; + } + }); + server.route({ url: "/Groups", method: "POST", @@ -267,7 +289,7 @@ export const registerScimRouter = async (server: FastifyZodProvider) => { body: z.object({ schemas: z.array(z.string()), displayName: z.string().trim(), - members: z.array(z.any()).length(0) + members: z.array(z.any()).length(0).optional() // okta-specific }), response: { 200: z.object({ @@ -292,6 +314,47 @@ export const registerScimRouter = async (server: FastifyZodProvider) => { } }); + server.route({ + url: "/Groups", + method: "GET", + schema: { + querystring: z.object({ + startIndex: z.coerce.number().default(1), + count: z.coerce.number().default(20), + filter: z.string().trim().optional() + }), + response: { + 200: z.object({ + Resources: z.array( + z.object({ + schemas: z.array(z.string()), + id: z.string().trim(), + displayName: z.string().trim(), + members: z.array(z.any()).length(0), + meta: z.object({ + resourceType: z.string().trim() + }) + }) + ), + itemsPerPage: z.number(), + schemas: z.array(z.string()), + startIndex: z.number(), + totalResults: z.number() + }) + } + }, + onRequest: verifyAuth([AuthMode.SCIM_TOKEN]), + handler: async (req) => { + const groups = await req.server.services.scim.listScimGroups({ + orgId: req.permission.orgId, + offset: req.query.startIndex, + limit: req.query.count + }); + + return groups; + } + }); + server.route({ url: "/Groups/:groupId", method: "GET", @@ -358,7 +421,7 @@ export const registerScimRouter = async (server: FastifyZodProvider) => { }, onRequest: verifyAuth([AuthMode.SCIM_TOKEN]), handler: async (req) => { - const group = await req.server.services.scim.updateScimGroupName({ + const group = await req.server.services.scim.updateScimGroupNamePut({ groupId: req.params.groupId, orgId: req.permission.orgId, displayName: req.body.displayName @@ -368,6 +431,69 @@ export const registerScimRouter = async (server: FastifyZodProvider) => { } }); + server.route({ + url: "/Groups/:groupId", + method: "PATCH", + schema: { + params: z.object({ + groupId: z.string().trim() + }), + body: z.object({ + schemas: z.array(z.string()), + Operations: z.array( + z.union([ + z.object({ + op: z.literal("replace"), + value: z.object({ + id: z.string().trim(), + displayName: z.string().trim() + }) + }), + z.object({ + op: z.literal("remove"), + path: z.string().trim() + }), + z.object({ + op: z.literal("add"), + value: z.object({ + value: z.string().trim(), + display: z.string().trim().optional() + }) + }) + ]) + ) + }), + response: { + 200: z.object({ + schemas: z.array(z.string()), + id: z.string().trim(), + displayName: z.string().trim(), + members: z.array( + z.object({ + value: z.string(), + display: z.string() + }) + ), + meta: z.object({ + resourceType: z.string().trim() + }) + }) + } + }, + onRequest: verifyAuth([AuthMode.SCIM_TOKEN]), + handler: async (req) => { + console.log("PATCH /Groups/:groupId req.body: ", req.body); + console.log("PATCH /Groups/:groupId req.body: ", req.body.Operations[0]); + const group = await req.server.services.scim.updateScimGroupNamePatch({ + groupId: req.params.groupId, + orgId: req.permission.orgId, + operations: req.body.Operations + }); + + return group; + } + }); + server.route({ url: "/Groups/:groupId", method: "DELETE", diff --git a/backend/src/ee/services/group/group-dal.ts b/backend/src/ee/services/group/group-dal.ts index a37f877f1..66b1d1d48 100644 --- a/backend/src/ee/services/group/group-dal.ts +++ b/backend/src/ee/services/group/group-dal.ts @@ -1,15 +1,35 @@ import { Knex } from "knex"; import { TDbClient } from "@app/db"; -import { TableName } from "@app/db/schemas"; +import { TableName, TGroups } from "@app/db/schemas"; import { DatabaseError } from "@app/lib/errors"; -import { ormify, selectAllTableCols } from "@app/lib/knex"; +import { buildFindFilter, ormify, selectAllTableCols, TFindFilter, TFindOpt } from "@app/lib/knex"; export type TGroupDALFactory = ReturnType; export const groupDALFactory = (db: TDbClient) => { const groupOrm = ormify(db, TableName.Groups); + const findGroups = async (filter: TFindFilter, { offset, limit, sort, tx }: TFindOpt = {}) => { + try { + const query = (tx || db)(TableName.Groups) + // eslint-disable-next-line + .where(buildFindFilter(filter)) + .select(selectAllTableCols(TableName.Groups)); + + if (limit) void query.limit(limit); + if (offset) void query.limit(offset); + if (sort) { + void query.orderBy(sort.map(([column, order, nulls]) => ({ column: column as string, order, nulls }))); + } + + const res = await query; + return res; + } catch (err) { + throw new DatabaseError({ error: err, name: "Find groups" }); + } + }; + const findByOrgId = async (orgId: string, tx?: Knex) => { try { const docs = await (tx || db)(TableName.Groups) @@ -78,6 +98,7 @@ export const groupDALFactory = (db: TDbClient) => { }; return { + findGroups, findByOrgId, findAllGroupMembers, ...groupOrm diff --git a/backend/src/ee/services/scim/scim-fns.ts b/backend/src/ee/services/scim/scim-fns.ts index bb777e62d..e816cffcf 100644 --- a/backend/src/ee/services/scim/scim-fns.ts +++ b/backend/src/ee/services/scim/scim-fns.ts @@ -1,4 +1,4 @@ -import { TListScimUsers, TScimUser } from "./scim-types"; +import { TListScimGroups, TListScimUsers, TScimGroup, TScimUser } from "./scim-types"; export const buildScimUserList = ({ scimUsers, @@ -63,6 +63,24 @@ export const buildScimUser = ({ return scimUser; }; +export const buildScimGroupList = ({ + scimGroups, + offset, + limit +}: { + scimGroups: TScimGroup[]; + offset: number; + limit: number; +}): TListScimGroups => { + return { + Resources: scimGroups, + itemsPerPage: limit, + schemas: ["urn:ietf:params:scim:api:messages:2.0:ListResponse"], + startIndex: offset, + totalResults: scimGroups.length + }; +}; + export const buildScimGroup = ({ groupId, name, @@ -74,14 +92,15 @@ export const buildScimGroup = ({ value: string; display: string; }[]; -}) => { +}): TScimGroup => { const scimGroup = { schemas: ["urn:ietf:params:scim:schemas:core:2.0:Group"], id: groupId, displayName: name, members, meta: { - resourceType: "Group" + resourceType: "Group", + location: null } }; diff --git a/backend/src/ee/services/scim/scim-service.ts b/backend/src/ee/services/scim/scim-service.ts index b43e8a5a1..b8994d5d0 100644 --- a/backend/src/ee/services/scim/scim-service.ts +++ b/backend/src/ee/services/scim/scim-service.ts @@ -2,7 +2,7 @@ import { ForbiddenError } from "@casl/ability"; import slugify from "@sindresorhus/slugify"; import jwt from "jsonwebtoken"; -import { OrgMembershipRole, OrgMembershipStatus, TableName } from "@app/db/schemas"; +import { OrgMembershipRole, OrgMembershipStatus, TableName, TGroups } from "@app/db/schemas"; import { TGroupDALFactory } from "@app/ee/services/group/group-dal"; import { TScimDALFactory } from "@app/ee/services/scim/scim-dal"; import { getConfig } from "@app/lib/config/env"; @@ -20,20 +20,23 @@ import { TUserDALFactory } from "@app/services/user/user-dal"; import { TLicenseServiceFactory } from "../license/license-service"; import { OrgPermissionActions, OrgPermissionSubjects } from "../permission/org-permission"; import { TPermissionServiceFactory } from "../permission/permission-service"; -import { buildScimGroup, buildScimUser, buildScimUserList } from "./scim-fns"; +import { buildScimGroup, buildScimGroupList, buildScimUser, buildScimUserList } from "./scim-fns"; import { TCreateScimGroupDTO, TCreateScimTokenDTO, TCreateScimUserDTO, TDeleteScimGroupDTO, TDeleteScimTokenDTO, + TDeleteScimUserDTO, TGetScimGroupDTO, TGetScimUserDTO, + TListScimGroupsDTO, TListScimUsers, TListScimUsersDTO, TReplaceScimUserDTO, TScimTokenJwtPayload, - TUpdateScimGroupNameDTO, + TUpdateScimGroupNamePatchDTO, + TUpdateScimGroupNamePutDTO, TUpdateScimUserDTO } from "./scim-types"; @@ -46,7 +49,7 @@ type TScimServiceFactoryDep = { >; projectDAL: Pick; projectMembershipDAL: Pick; - groupDAL: Pick; + groupDAL: Pick; licenseService: Pick; permissionService: Pick; smtpService: TSmtpService; @@ -432,7 +435,80 @@ export const scimServiceFactory = ({ }); }; + const deleteScimUser = async ({ userId, orgId }: TDeleteScimUserDTO) => { + const [membership] = await orgDAL + .findMembership({ + userId, + [`${TableName.OrgMembership}.orgId` as "id"]: orgId + }) + .catch(() => { + throw new ScimRequestError({ + detail: "User not found", + status: 404 + }); + }); + + if (!membership) + throw new ScimRequestError({ + detail: "User not found", + status: 404 + }); + + if (!membership.scimEnabled) { + throw new ScimRequestError({ + detail: "SCIM is disabled for the organization", + status: 403 + }); + } + + await deleteOrgMembership({ + orgMembershipId: membership.id, + orgId: membership.orgId, + orgDAL, + projectDAL, + projectMembershipDAL + }); + + return {}; // intentionally return empty object upon success + }; + + const listScimGroups = async ({ orgId, offset, limit }: TListScimGroupsDTO) => { + const org = await orgDAL.findById(orgId); + + if (!org.scimEnabled) + throw new ScimRequestError({ + detail: "SCIM is disabled for the organization", + status: 403 + }); + + const groups = await groupDAL.findGroups({ + orgId + }); + + const scimGroups = groups.map((group) => + buildScimGroup({ + groupId: group.id, + name: group.name, + members: [] + }) + ); + + return buildScimGroupList({ + scimGroups, + offset, + limit + }); + }; + const createScimGroup = async ({ displayName, orgId }: TCreateScimGroupDTO) => { + const org = await orgDAL.findById(orgId); + + if (!org.scimEnabled) + throw new ScimRequestError({ + detail: "SCIM is disabled for the organization", + status: 403 + }); + const group = await groupDAL.create({ name: displayName, slug: slugify(`${displayName}-${alphaNumericNanoId(4)}`), @@ -474,7 +550,7 @@ export const scimServiceFactory = ({ }); }; - const updateScimGroupName = async ({ groupId, orgId, displayName }: TUpdateScimGroupNameDTO) => { + const updateScimGroupNamePut = async ({ groupId, orgId, displayName }: TUpdateScimGroupNamePutDTO) => { const [group] = await groupDAL.update( { id: groupId, @@ -499,6 +575,62 @@ export const scimServiceFactory = ({ }); }; + // TODO: add support for add/remove op + const updateScimGroupNamePatch = async ({ groupId, orgId, operations }: TUpdateScimGroupNamePatchDTO) => { + const org = await orgDAL.findById(orgId); + + if (!org.scimEnabled) + throw new ScimRequestError({ + detail: "SCIM is disabled for the organization", + status: 403 + }); + + let group: TGroups | undefined; + for await (const operation of operations) { + switch (operation.op) { + case "replace": { + await groupDAL.update( + { + id: groupId, + orgId + }, + { + name: operation.value.displayName + } + ); + break; + } + case "add": { + // TODO + break; + } + case "remove": { + // TODO + break; + } + default: { + throw new ScimRequestError({ + detail: "Invalid Operation", + status: 400 + }); + } + } + } + + if (!group) { + throw new ScimRequestError({ + detail: "Group Not Found", + status: 404 + }); + } + + return buildScimGroup({ + groupId: group.id, + name: group.name, + members: [] + }); + }; + const deleteScimGroup = async ({ groupId, orgId }: TDeleteScimGroupDTO) => { const [group] = await groupDAL.delete({ id: groupId, @@ -547,10 +679,13 @@ export const scimServiceFactory = ({ createScimUser, updateScimUser, replaceScimUser, + deleteScimUser, + listScimGroups, createScimGroup, getScimGroup, deleteScimGroup, - updateScimGroupName, + updateScimGroupNamePut, + updateScimGroupNamePatch, fnValidateScimToken }; }; diff --git a/backend/src/ee/services/scim/scim-types.ts b/backend/src/ee/services/scim/scim-types.ts index f0ed84caa..fc5df0b2e 100644 --- a/backend/src/ee/services/scim/scim-types.ts +++ b/backend/src/ee/services/scim/scim-types.ts @@ -59,6 +59,25 @@ export type TReplaceScimUserDTO = { orgId: string; }; +export type TDeleteScimUserDTO = { + userId: string; + orgId: string; +}; + +export type TListScimGroupsDTO = { + offset: number; + limit: number; + orgId: string; +}; + +export type TListScimGroups = { + schemas: ["urn:ietf:params:scim:api:messages:2.0:ListResponse"]; + totalResults: number; + Resources: TScimGroup[]; + itemsPerPage: number; + startIndex: number; +}; + export type TCreateScimGroupDTO = { displayName: string; orgId: string; @@ -69,12 +88,39 @@ export type TGetScimGroupDTO = { orgId: string; }; -export type TUpdateScimGroupNameDTO = { +export type TUpdateScimGroupNamePutDTO = { groupId: string; orgId: string; displayName: string; }; +export type TUpdateScimGroupNamePatchDTO = { + groupId: string; + orgId: string; + operations: (TRemoveOp | TReplaceOp | TAddOp)[]; +}; + +type TReplaceOp = { + op: "replace"; + value: { + id: string; + displayName: string; + }; +}; + +type TRemoveOp = { + op: "remove"; + path: string; +}; + +type TAddOp = { + op: "add"; + value: { + value: string; + display?: string; + }; +}; + export type TDeleteScimGroupDTO = { groupId: string; orgId: string; @@ -107,3 +153,17 @@ export type TScimUser = { location: null; }; }; + +export type TScimGroup = { + schemas: string[]; + id: string; + displayName: string; + members: { + value: string; + display: string; + }[]; + meta: { + resourceType: string; + location: null; + }; +}; diff --git a/docs/documentation/platform/scim/jumpcloud.mdx b/docs/documentation/platform/scim/jumpcloud.mdx index cf8d1aa88..ce4542035 100644 --- a/docs/documentation/platform/scim/jumpcloud.mdx +++ b/docs/documentation/platform/scim/jumpcloud.mdx @@ -16,7 +16,7 @@ Prerequisites: In Infisical, head to your Organization Settings > Authentication > SCIM Configuration and - press the **Enable SCIM provisioning** toggle to allow JumpCloud to provision/deprovision users for your organization. + press the **Enable SCIM provisioning** toggle to allow JumpCloud to provision/deprovision users and user groups for your organization. ![SCIM enable provisioning](/images/platform/scim/scim-enable-provisioning.png) @@ -49,7 +49,7 @@ Prerequisites: ![SCIM JumpCloud](/images/platform/scim/jumpcloud/scim-jumpcloud-test-connection.png) - Now JumpCloud can provision/deprovision users to/from your organization in Infisical. + Now JumpCloud can provision/deprovision users and user groups to/from your organization in Infisical. diff --git a/docs/documentation/platform/scim/okta.mdx b/docs/documentation/platform/scim/okta.mdx index e5d5bb48b..6b0bf6ccf 100644 --- a/docs/documentation/platform/scim/okta.mdx +++ b/docs/documentation/platform/scim/okta.mdx @@ -16,7 +16,7 @@ Prerequisites: In Infisical, head to your Organization Settings > Authentication > SCIM Configuration and - press the **Enable SCIM provisioning** toggle to allow Okta to provision/deprovision users for your organization. + press the **Enable SCIM provisioning** toggle to allow Okta to provision/deprovision users and user groups for your organization. ![SCIM enable provisioning](/images/platform/scim/scim-enable-provisioning.png) @@ -38,7 +38,7 @@ Prerequisites: - SCIM connector base URL: Input the **SCIM URL** from Step 1. - Unique identifier field for users: Input `email`. - - Supported provisioning actions: Select **Push New Users** and **Push Profile Updates**. + - Supported provisioning actions: Select **Push New Users**, **Push Profile Updates**, and **Push Groups**. - Authentication Mode: `HTTP Header`. ![SCIM Okta](/images/platform/scim/okta/scim-okta-config.png) @@ -55,7 +55,7 @@ Prerequisites: ![SCIM Okta](/images/platform/scim/okta/scim-okta-app-settings.png) - Now Okta can provision/deprovision users to/from your organization in Infisical. + Now Okta can provision/deprovision users and user groups to/from your organization in Infisical. diff --git a/docs/documentation/platform/scim/overview.mdx b/docs/documentation/platform/scim/overview.mdx index 2bc73d810..232df95a1 100644 --- a/docs/documentation/platform/scim/overview.mdx +++ b/docs/documentation/platform/scim/overview.mdx @@ -10,7 +10,7 @@ description: "Learn how to provision users for Infisical via SCIM." then you should contact sales@infisical.com to purchase an enterprise license to use it. -You can configure your organization in Infisical to have members be provisioned/deprovisioned using [SCIM](https://scim.cloud/#Implementations2) via providers like Okta, Azure, JumpCloud, etc. +You can configure your organization in Infisical to have users and user groups be provisioned/deprovisioned using [SCIM](https://scim.cloud/#Implementations2) via providers like Okta, Azure, JumpCloud, etc. - Provisioning: The SCIM provider pushes user information to Infisical. If the user exists in Infisical, Infisical sends an email invitation to add them to the relevant organization in Infisical; if not, Infisical initializes a new user and sends them an email invitation to finish setting up their account in the organization. - Deprovisioning: The SCIM provider instructs Infisical to remove user(s) from an organization in Infisical. diff --git a/docs/images/platform/scim/okta/scim-okta-config.png b/docs/images/platform/scim/okta/scim-okta-config.png index b20ceddca..bca1a25eb 100644 Binary files a/docs/images/platform/scim/okta/scim-okta-config.png and b/docs/images/platform/scim/okta/scim-okta-config.png differ