Continue making progress on SCIM groups

This commit is contained in:
Tuan Dang
2024-04-05 17:20:17 -07:00
parent f8ea421a0e
commit dc0d3b860e
9 changed files with 383 additions and 22 deletions
+130 -4
View File
@@ -206,7 +206,7 @@ export const registerScimRouter = async (server: FastifyZodProvider) => {
schema: { schema: {
body: z.object({ body: z.object({
schemas: z.array(z.string()), schemas: z.array(z.string()),
userName: z.string().trim().email(), userName: z.string().trim(),
name: z.object({ name: z.object({
familyName: z.string().trim(), familyName: z.string().trim(),
givenName: z.string().trim() givenName: z.string().trim()
@@ -227,7 +227,7 @@ export const registerScimRouter = async (server: FastifyZodProvider) => {
200: z.object({ 200: z.object({
schemas: z.array(z.string()), schemas: z.array(z.string()),
id: z.string().trim(), id: z.string().trim(),
userName: z.string().trim().email(), userName: z.string().trim(),
name: z.object({ name: z.object({
familyName: z.string().trim(), familyName: z.string().trim(),
givenName: 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({ server.route({
url: "/Groups", url: "/Groups",
method: "POST", method: "POST",
@@ -267,7 +289,7 @@ export const registerScimRouter = async (server: FastifyZodProvider) => {
body: z.object({ body: z.object({
schemas: z.array(z.string()), schemas: z.array(z.string()),
displayName: z.string().trim(), displayName: z.string().trim(),
members: z.array(z.any()).length(0) members: z.array(z.any()).length(0).optional() // okta-specific
}), }),
response: { response: {
200: z.object({ 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({ server.route({
url: "/Groups/:groupId", url: "/Groups/:groupId",
method: "GET", method: "GET",
@@ -358,7 +421,7 @@ export const registerScimRouter = async (server: FastifyZodProvider) => {
}, },
onRequest: verifyAuth([AuthMode.SCIM_TOKEN]), onRequest: verifyAuth([AuthMode.SCIM_TOKEN]),
handler: async (req) => { handler: async (req) => {
const group = await req.server.services.scim.updateScimGroupName({ const group = await req.server.services.scim.updateScimGroupNamePut({
groupId: req.params.groupId, groupId: req.params.groupId,
orgId: req.permission.orgId, orgId: req.permission.orgId,
displayName: req.body.displayName 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({ server.route({
url: "/Groups/:groupId", url: "/Groups/:groupId",
method: "DELETE", method: "DELETE",
+23 -2
View File
@@ -1,15 +1,35 @@
import { Knex } from "knex"; import { Knex } from "knex";
import { TDbClient } from "@app/db"; 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 { 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<typeof groupDALFactory>; export type TGroupDALFactory = ReturnType<typeof groupDALFactory>;
export const groupDALFactory = (db: TDbClient) => { export const groupDALFactory = (db: TDbClient) => {
const groupOrm = ormify(db, TableName.Groups); const groupOrm = ormify(db, TableName.Groups);
const findGroups = async (filter: TFindFilter<TGroups>, { offset, limit, sort, tx }: TFindOpt<TGroups> = {}) => {
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) => { const findByOrgId = async (orgId: string, tx?: Knex) => {
try { try {
const docs = await (tx || db)(TableName.Groups) const docs = await (tx || db)(TableName.Groups)
@@ -78,6 +98,7 @@ export const groupDALFactory = (db: TDbClient) => {
}; };
return { return {
findGroups,
findByOrgId, findByOrgId,
findAllGroupMembers, findAllGroupMembers,
...groupOrm ...groupOrm
+22 -3
View File
@@ -1,4 +1,4 @@
import { TListScimUsers, TScimUser } from "./scim-types"; import { TListScimGroups, TListScimUsers, TScimGroup, TScimUser } from "./scim-types";
export const buildScimUserList = ({ export const buildScimUserList = ({
scimUsers, scimUsers,
@@ -63,6 +63,24 @@ export const buildScimUser = ({
return scimUser; 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 = ({ export const buildScimGroup = ({
groupId, groupId,
name, name,
@@ -74,14 +92,15 @@ export const buildScimGroup = ({
value: string; value: string;
display: string; display: string;
}[]; }[];
}) => { }): TScimGroup => {
const scimGroup = { const scimGroup = {
schemas: ["urn:ietf:params:scim:schemas:core:2.0:Group"], schemas: ["urn:ietf:params:scim:schemas:core:2.0:Group"],
id: groupId, id: groupId,
displayName: name, displayName: name,
members, members,
meta: { meta: {
resourceType: "Group" resourceType: "Group",
location: null
} }
}; };
+141 -6
View File
@@ -2,7 +2,7 @@ import { ForbiddenError } from "@casl/ability";
import slugify from "@sindresorhus/slugify"; import slugify from "@sindresorhus/slugify";
import jwt from "jsonwebtoken"; 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 { TGroupDALFactory } from "@app/ee/services/group/group-dal";
import { TScimDALFactory } from "@app/ee/services/scim/scim-dal"; import { TScimDALFactory } from "@app/ee/services/scim/scim-dal";
import { getConfig } from "@app/lib/config/env"; 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 { TLicenseServiceFactory } from "../license/license-service";
import { OrgPermissionActions, OrgPermissionSubjects } from "../permission/org-permission"; import { OrgPermissionActions, OrgPermissionSubjects } from "../permission/org-permission";
import { TPermissionServiceFactory } from "../permission/permission-service"; import { TPermissionServiceFactory } from "../permission/permission-service";
import { buildScimGroup, buildScimUser, buildScimUserList } from "./scim-fns"; import { buildScimGroup, buildScimGroupList, buildScimUser, buildScimUserList } from "./scim-fns";
import { import {
TCreateScimGroupDTO, TCreateScimGroupDTO,
TCreateScimTokenDTO, TCreateScimTokenDTO,
TCreateScimUserDTO, TCreateScimUserDTO,
TDeleteScimGroupDTO, TDeleteScimGroupDTO,
TDeleteScimTokenDTO, TDeleteScimTokenDTO,
TDeleteScimUserDTO,
TGetScimGroupDTO, TGetScimGroupDTO,
TGetScimUserDTO, TGetScimUserDTO,
TListScimGroupsDTO,
TListScimUsers, TListScimUsers,
TListScimUsersDTO, TListScimUsersDTO,
TReplaceScimUserDTO, TReplaceScimUserDTO,
TScimTokenJwtPayload, TScimTokenJwtPayload,
TUpdateScimGroupNameDTO, TUpdateScimGroupNamePatchDTO,
TUpdateScimGroupNamePutDTO,
TUpdateScimUserDTO TUpdateScimUserDTO
} from "./scim-types"; } from "./scim-types";
@@ -46,7 +49,7 @@ type TScimServiceFactoryDep = {
>; >;
projectDAL: Pick<TProjectDALFactory, "find">; projectDAL: Pick<TProjectDALFactory, "find">;
projectMembershipDAL: Pick<TProjectMembershipDALFactory, "find" | "delete">; projectMembershipDAL: Pick<TProjectMembershipDALFactory, "find" | "delete">;
groupDAL: Pick<TGroupDALFactory, "create" | "findOne" | "findAllGroupMembers" | "update" | "delete">; groupDAL: Pick<TGroupDALFactory, "create" | "findOne" | "findAllGroupMembers" | "update" | "delete" | "findGroups">;
licenseService: Pick<TLicenseServiceFactory, "getPlan">; licenseService: Pick<TLicenseServiceFactory, "getPlan">;
permissionService: Pick<TPermissionServiceFactory, "getOrgPermission">; permissionService: Pick<TPermissionServiceFactory, "getOrgPermission">;
smtpService: TSmtpService; 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 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({ const group = await groupDAL.create({
name: displayName, name: displayName,
slug: slugify(`${displayName}-${alphaNumericNanoId(4)}`), 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( const [group] = await groupDAL.update(
{ {
id: groupId, 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 deleteScimGroup = async ({ groupId, orgId }: TDeleteScimGroupDTO) => {
const [group] = await groupDAL.delete({ const [group] = await groupDAL.delete({
id: groupId, id: groupId,
@@ -547,10 +679,13 @@ export const scimServiceFactory = ({
createScimUser, createScimUser,
updateScimUser, updateScimUser,
replaceScimUser, replaceScimUser,
deleteScimUser,
listScimGroups,
createScimGroup, createScimGroup,
getScimGroup, getScimGroup,
deleteScimGroup, deleteScimGroup,
updateScimGroupName, updateScimGroupNamePut,
updateScimGroupNamePatch,
fnValidateScimToken fnValidateScimToken
}; };
}; };
+61 -1
View File
@@ -59,6 +59,25 @@ export type TReplaceScimUserDTO = {
orgId: string; 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 = { export type TCreateScimGroupDTO = {
displayName: string; displayName: string;
orgId: string; orgId: string;
@@ -69,12 +88,39 @@ export type TGetScimGroupDTO = {
orgId: string; orgId: string;
}; };
export type TUpdateScimGroupNameDTO = { export type TUpdateScimGroupNamePutDTO = {
groupId: string; groupId: string;
orgId: string; orgId: string;
displayName: 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 = { export type TDeleteScimGroupDTO = {
groupId: string; groupId: string;
orgId: string; orgId: string;
@@ -107,3 +153,17 @@ export type TScimUser = {
location: null; location: null;
}; };
}; };
export type TScimGroup = {
schemas: string[];
id: string;
displayName: string;
members: {
value: string;
display: string;
}[];
meta: {
resourceType: string;
location: null;
};
};
@@ -16,7 +16,7 @@ Prerequisites:
<Steps> <Steps>
<Step title="Create a SCIM token in Infisical"> <Step title="Create a SCIM token in Infisical">
In Infisical, head to your Organization Settings > Authentication > SCIM Configuration and 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) ![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) ![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.
</Step> </Step>
</Steps> </Steps>
+3 -3
View File
@@ -16,7 +16,7 @@ Prerequisites:
<Steps> <Steps>
<Step title="Create a SCIM token in Infisical"> <Step title="Create a SCIM token in Infisical">
In Infisical, head to your Organization Settings > Authentication > SCIM Configuration and 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) ![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. - SCIM connector base URL: Input the **SCIM URL** from Step 1.
- Unique identifier field for users: Input `email`. - 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`. - Authentication Mode: `HTTP Header`.
![SCIM Okta](/images/platform/scim/okta/scim-okta-config.png) ![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) ![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.
</Step> </Step>
</Steps> </Steps>
@@ -10,7 +10,7 @@ description: "Learn how to provision users for Infisical via SCIM."
then you should contact [email protected] to purchase an enterprise license to use it. then you should contact [email protected] to purchase an enterprise license to use it.
</Info> </Info>
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. - 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. - Deprovisioning: The SCIM provider instructs Infisical to remove user(s) from an organization in Infisical.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 308 KiB

After

Width:  |  Height:  |  Size: 323 KiB