diff --git a/backend/package-lock.json b/backend/package-lock.json index 088e3b73e..d860616f1 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -79,6 +79,8 @@ "posthog-node": "^3.6.2", "probot": "^13.0.0", "safe-regex": "^2.1.1", + "scim-patch": "^0.8.3", + "scim2-parse-filter": "^0.2.10", "smee-client": "^2.0.0", "tedious": "^18.2.1", "tweetnacl": "^1.0.3", @@ -15495,6 +15497,34 @@ "resolved": "https://registry.npmjs.org/sax/-/sax-1.3.0.tgz", "integrity": "sha512-0s+oAmw9zLl1V1cS9BtZN7JAd0cW5e0QH4W3LWEK6a4LaLEA2OTpGYWDY+6XasBLtz6wkm3u1xRw95mRuJ59WA==" }, + "node_modules/scim-patch": { + "version": "0.8.3", + "resolved": "https://registry.npmjs.org/scim-patch/-/scim-patch-0.8.3.tgz", + "integrity": "sha512-3d0wD4THAt03Zgi08kCQwc+lvPJ2v4wwk41b0xViVa4gLYSgRUCmGkJNBsaE+yoKg0fufTOJCcSrufZaqYn/og==", + "dependencies": { + "@types/node": "^22.0.0", + "fast-deep-equal": "3.1.3", + "scim2-parse-filter": "0.2.10" + } + }, + "node_modules/scim-patch/node_modules/@types/node": { + "version": "22.5.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.5.1.tgz", + "integrity": "sha512-KkHsxej0j9IW1KKOOAA/XBA0z08UFSrRQHErzEfA3Vgq57eXIMYboIlHJuYIfd+lwCQjtKqUu3UnmKbtUc9yRw==", + "dependencies": { + "undici-types": "~6.19.2" + } + }, + "node_modules/scim-patch/node_modules/undici-types": { + "version": "6.19.8", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.19.8.tgz", + "integrity": "sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw==" + }, + "node_modules/scim2-parse-filter": { + "version": "0.2.10", + "resolved": "https://registry.npmjs.org/scim2-parse-filter/-/scim2-parse-filter-0.2.10.tgz", + "integrity": "sha512-k5TgGSuQEbR4jXRgw/GPAYVL9fMp1pWA2abLF5z3q9IGWSuZTqbrZBOSUezvc+rtViXr+czSZjg3eAN4QSTvxQ==" + }, "node_modules/secure-json-parse": { "version": "2.7.0", "resolved": "https://registry.npmjs.org/secure-json-parse/-/secure-json-parse-2.7.0.tgz", diff --git a/backend/package.json b/backend/package.json index fadec14ee..967ccfb78 100644 --- a/backend/package.json +++ b/backend/package.json @@ -177,6 +177,8 @@ "posthog-node": "^3.6.2", "probot": "^13.0.0", "safe-regex": "^2.1.1", + "scim-patch": "^0.8.3", + "scim2-parse-filter": "^0.2.10", "smee-client": "^2.0.0", "tedious": "^18.2.1", "tweetnacl": "^1.0.3", diff --git a/backend/src/ee/routes/v1/scim-router.ts b/backend/src/ee/routes/v1/scim-router.ts index 45e89de4a..427c77fa7 100644 --- a/backend/src/ee/routes/v1/scim-router.ts +++ b/backend/src/ee/routes/v1/scim-router.ts @@ -5,22 +5,47 @@ import { readLimit, writeLimit } from "@app/server/config/rateLimiter"; import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; import { AuthMode } from "@app/services/auth/auth-type"; -export const registerScimRouter = async (server: FastifyZodProvider) => { - server.addContentTypeParser("application/scim+json", { parseAs: "string" }, (_, body, done) => { - try { - const strBody = body instanceof Buffer ? body.toString() : body; - if (!strBody) { - done(null, undefined); - return; - } - const json: unknown = JSON.parse(strBody); - done(null, json); - } catch (err) { - const error = err as Error; - done(error, undefined); - } - }); +const ScimUserSchema = z.object({ + schemas: z.array(z.string()), + id: z.string().trim(), + userName: z.string().trim(), + name: z + .object({ + familyName: z.string().trim().optional(), + givenName: z.string().trim().optional() + }) + .optional(), + emails: z + .array( + z.object({ + primary: z.boolean(), + value: z.string().email(), + type: z.string().trim() + }) + ) + .optional(), + displayName: z.string().trim(), + active: z.boolean() +}); +const ScimGroupSchema = 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().optional() + }) + ) + .optional(), + meta: z.object({ + resourceType: z.string().trim() + }) +}); + +export const registerScimRouter = async (server: FastifyZodProvider) => { server.route({ url: "/scim-tokens", method: "POST", @@ -127,25 +152,7 @@ export const registerScimRouter = async (server: FastifyZodProvider) => { }), response: { 200: z.object({ - Resources: z.array( - z.object({ - id: z.string().trim(), - userName: z.string().trim(), - name: z.object({ - familyName: z.string().trim(), - givenName: z.string().trim() - }), - emails: z.array( - z.object({ - primary: z.boolean(), - value: z.string(), - type: z.string().trim() - }) - ), - displayName: z.string().trim(), - active: z.boolean() - }) - ), + Resources: z.array(ScimUserSchema), itemsPerPage: z.number(), schemas: z.array(z.string()), startIndex: z.number(), @@ -173,30 +180,7 @@ export const registerScimRouter = async (server: FastifyZodProvider) => { orgMembershipId: z.string().trim() }), response: { - 201: z.object({ - schemas: z.array(z.string()), - id: z.string().trim(), - userName: z.string().trim(), - name: z.object({ - familyName: z.string().trim(), - givenName: z.string().trim() - }), - emails: z.array( - z.object({ - primary: z.boolean(), - value: z.string(), - type: z.string().trim() - }) - ), - displayName: z.string().trim(), - active: z.boolean(), - groups: z.array( - z.object({ - value: z.string().trim(), - display: z.string().trim() - }) - ) - }) + 200: ScimUserSchema } }, onRequest: verifyAuth([AuthMode.SCIM_TOKEN]), @@ -216,10 +200,12 @@ export const registerScimRouter = async (server: FastifyZodProvider) => { body: z.object({ schemas: z.array(z.string()), userName: z.string().trim(), - name: z.object({ - familyName: z.string().trim(), - givenName: z.string().trim() - }), + name: z + .object({ + familyName: z.string().trim().optional(), + givenName: z.string().trim().optional() + }) + .optional(), emails: z .array( z.object({ @@ -229,28 +215,10 @@ export const registerScimRouter = async (server: FastifyZodProvider) => { }) ) .optional(), - // displayName: z.string().trim(), - active: z.boolean() + active: z.boolean().default(true) }), response: { - 200: z.object({ - schemas: z.array(z.string()), - id: z.string().trim(), - userName: z.string().trim(), - name: z.object({ - familyName: z.string().trim(), - givenName: z.string().trim() - }), - emails: z.array( - z.object({ - primary: z.boolean(), - value: z.string().email(), - type: z.string().trim() - }) - ), - displayName: z.string().trim(), - active: z.boolean() - }) + 200: ScimUserSchema } }, onRequest: verifyAuth([AuthMode.SCIM_TOKEN]), @@ -260,8 +228,8 @@ export const registerScimRouter = async (server: FastifyZodProvider) => { const user = await req.server.services.scim.createScimUser({ externalId: req.body.userName, email: primaryEmail, - firstName: req.body.name.givenName, - lastName: req.body.name.familyName, + firstName: req.body?.name?.givenName, + lastName: req.body?.name?.familyName, orgId: req.permission.orgId }); @@ -291,6 +259,116 @@ export const registerScimRouter = async (server: FastifyZodProvider) => { } }); + server.route({ + url: "/Users/:orgMembershipId", + method: "PUT", + schema: { + params: z.object({ + orgMembershipId: z.string().trim() + }), + body: z.object({ + schemas: z.array(z.string()), + id: z.string().trim(), + userName: z.string().trim(), + name: z + .object({ + familyName: z.string().trim().optional(), + givenName: z.string().trim().optional() + }) + .optional(), + displayName: z.string().trim(), + emails: z + .array( + z.object({ + primary: z.boolean(), + value: z.string().email(), + type: z.string().trim() + }) + ) + .optional(), + active: z.boolean() + }), + response: { + 200: z.object({ + schemas: z.array(z.string()), + id: z.string().trim(), + userName: z.string().trim(), + name: z.object({ + familyName: z.string().trim(), + givenName: z.string().trim() + }), + emails: z.array( + z.object({ + primary: z.boolean(), + value: z.string().email(), + type: z.string().trim() + }) + ), + displayName: z.string().trim(), + active: z.boolean() + }) + } + }, + onRequest: verifyAuth([AuthMode.SCIM_TOKEN]), + handler: async (req) => { + const primaryEmail = req.body.emails?.find((email) => email.primary)?.value; + const user = await req.server.services.scim.replaceScimUser({ + orgMembershipId: req.params.orgMembershipId, + orgId: req.permission.orgId, + firstName: req.body?.name?.givenName, + lastName: req.body?.name?.familyName, + active: req.body?.active, + email: primaryEmail, + externalId: req.body.userName + }); + return user; + } + }); + + server.route({ + url: "/Users/:orgMembershipId", + method: "PATCH", + schema: { + params: z.object({ + orgMembershipId: z.string().trim() + }), + body: z.object({ + schemas: z.array(z.string()), + Operations: z.array( + z.union([ + z.object({ + op: z.union([z.literal("remove"), z.literal("Remove")]), + path: z.string().trim(), + value: z + .object({ + value: z.string() + }) + .array() + .optional() + }), + z.object({ + op: z.union([z.literal("add"), z.literal("Add"), z.literal("replace"), z.literal("Replace")]), + path: z.string().trim().optional(), + value: z.any().optional() + }) + ]) + ) + }), + response: { + 200: ScimUserSchema + } + }, + onRequest: verifyAuth([AuthMode.SCIM_TOKEN]), + handler: async (req) => { + const user = await req.server.services.scim.updateScimUser({ + orgMembershipId: req.params.orgMembershipId, + orgId: req.permission.orgId, + operations: req.body.Operations + }); + + return user; + } + }); server.route({ url: "/Groups", method: "POST", @@ -305,25 +383,10 @@ export const registerScimRouter = async (server: FastifyZodProvider) => { display: z.string() }) ) - .optional() // okta-specific + .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() - }) - ) - .optional(), - meta: z.object({ - resourceType: z.string().trim() - }) - }) + 200: ScimGroupSchema } }, onRequest: verifyAuth([AuthMode.SCIM_TOKEN]), @@ -344,26 +407,12 @@ export const registerScimRouter = async (server: FastifyZodProvider) => { querystring: z.object({ startIndex: z.coerce.number().default(1), count: z.coerce.number().default(20), - filter: z.string().trim().optional() + filter: z.string().trim().optional(), + excludedAttributes: 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.object({ - value: z.string(), - display: z.string() - }) - ), - meta: z.object({ - resourceType: z.string().trim() - }) - }) - ), + Resources: z.array(ScimGroupSchema), itemsPerPage: z.number(), schemas: z.array(z.string()), startIndex: z.number(), @@ -377,7 +426,8 @@ export const registerScimRouter = async (server: FastifyZodProvider) => { orgId: req.permission.orgId, startIndex: req.query.startIndex, filter: req.query.filter, - limit: req.query.count + limit: req.query.count, + isMembersExcluded: req.query.excludedAttributes === "members" }); return groups; @@ -392,20 +442,7 @@ export const registerScimRouter = async (server: FastifyZodProvider) => { groupId: z.string().trim() }), 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() - }) - }) + 200: ScimGroupSchema } }, onRequest: verifyAuth([AuthMode.SCIM_TOKEN]), @@ -414,6 +451,7 @@ export const registerScimRouter = async (server: FastifyZodProvider) => { groupId: req.params.groupId, orgId: req.permission.orgId }); + return group; } }); @@ -437,25 +475,12 @@ export const registerScimRouter = async (server: FastifyZodProvider) => { ) }), 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() - }) - }) + 200: ScimGroupSchema } }, onRequest: verifyAuth([AuthMode.SCIM_TOKEN]), handler: async (req) => { - const group = await req.server.services.scim.updateScimGroupNamePut({ + const group = await req.server.services.scim.replaceScimGroup({ groupId: req.params.groupId, orgId: req.permission.orgId, ...req.body @@ -476,55 +501,35 @@ export const registerScimRouter = async (server: FastifyZodProvider) => { schemas: z.array(z.string()), Operations: z.array( z.union([ - z.object({ - op: z.union([z.literal("replace"), z.literal("Replace")]), - value: z.object({ - id: z.string().trim(), - displayName: z.string().trim() - }) - }), z.object({ op: z.union([z.literal("remove"), z.literal("Remove")]), - path: z.string().trim() + path: z.string().trim(), + value: z + .object({ + value: z.string() + }) + .array() + .optional() }), z.object({ - op: z.union([z.literal("add"), z.literal("Add")]), - path: z.string().trim(), - value: z.array( - z.object({ - value: z.string().trim(), - display: z.string().trim().optional() - }) - ) + op: z.union([z.literal("add"), z.literal("Add"), z.literal("replace"), z.literal("Replace")]), + path: z.string().trim().optional(), + value: z.any() }) ]) ) }), 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() - }) - }) + 200: ScimGroupSchema } }, onRequest: verifyAuth([AuthMode.SCIM_TOKEN]), handler: async (req) => { - const group = await req.server.services.scim.updateScimGroupNamePatch({ + const group = await req.server.services.scim.updateScimGroup({ groupId: req.params.groupId, orgId: req.permission.orgId, operations: req.body.Operations }); - return group; } }); @@ -550,60 +555,4 @@ export const registerScimRouter = async (server: FastifyZodProvider) => { return group; } }); - - server.route({ - url: "/Users/:orgMembershipId", - method: "PUT", - schema: { - params: z.object({ - orgMembershipId: z.string().trim() - }), - body: z.object({ - schemas: z.array(z.string()), - id: z.string().trim(), - userName: z.string().trim(), - name: z.object({ - familyName: z.string().trim(), - givenName: z.string().trim() - }), - displayName: z.string().trim(), - active: z.boolean() - }), - response: { - 200: z.object({ - schemas: z.array(z.string()), - id: z.string().trim(), - userName: z.string().trim(), - name: z.object({ - familyName: z.string().trim(), - givenName: z.string().trim() - }), - emails: z.array( - z.object({ - primary: z.boolean(), - value: z.string().email(), - type: z.string().trim() - }) - ), - displayName: z.string().trim(), - active: z.boolean(), - groups: z.array( - z.object({ - value: z.string().trim(), - display: z.string().trim() - }) - ) - }) - } - }, - onRequest: verifyAuth([AuthMode.SCIM_TOKEN]), - handler: async (req) => { - const user = await req.server.services.scim.replaceScimUser({ - orgMembershipId: req.params.orgMembershipId, - orgId: req.permission.orgId, - active: req.body.active - }); - return user; - } - }); }; diff --git a/backend/src/ee/services/scim/scim-fns.ts b/backend/src/ee/services/scim/scim-fns.ts index b7454dc90..3ade1a117 100644 --- a/backend/src/ee/services/scim/scim-fns.ts +++ b/backend/src/ee/services/scim/scim-fns.ts @@ -44,19 +44,18 @@ export const buildScimUser = ({ email, firstName, lastName, - groups = [], - active + active, + createdAt, + updatedAt }: { orgMembershipId: string; username: string; email?: string | null; firstName: string | null | undefined; lastName: string | null | undefined; - groups?: { - value: string; - display: string; - }[]; active: boolean; + createdAt: Date; + updatedAt: Date; }): TScimUser => { const scimUser = { schemas: ["urn:ietf:params:scim:schemas:core:2.0:User"], @@ -78,10 +77,10 @@ export const buildScimUser = ({ ] : [], active, - groups, meta: { resourceType: "User", - location: null + created: createdAt, + lastModified: updatedAt } }; @@ -109,14 +108,18 @@ export const buildScimGroupList = ({ export const buildScimGroup = ({ groupId, name, - members + members, + updatedAt, + createdAt }: { groupId: string; name: string; members: { value: string; - display: string; + display?: string; }[]; + createdAt: Date; + updatedAt: Date; }): TScimGroup => { const scimGroup = { schemas: ["urn:ietf:params:scim:schemas:core:2.0:Group"], @@ -125,7 +128,8 @@ export const buildScimGroup = ({ members, meta: { resourceType: "Group", - location: null + created: createdAt, + lastModified: updatedAt } }; diff --git a/backend/src/ee/services/scim/scim-service.ts b/backend/src/ee/services/scim/scim-service.ts index 0cb9e3e82..9352d09cc 100644 --- a/backend/src/ee/services/scim/scim-service.ts +++ b/backend/src/ee/services/scim/scim-service.ts @@ -1,6 +1,7 @@ import { ForbiddenError } from "@casl/ability"; import slugify from "@sindresorhus/slugify"; import jwt from "jsonwebtoken"; +import { scimPatch } from "scim-patch"; import { OrgMembershipRole, OrgMembershipStatus, TableName, TOrgMemberships, TUsers } from "@app/db/schemas"; import { TGroupDALFactory } from "@app/ee/services/group/group-dal"; @@ -9,7 +10,6 @@ import { TUserGroupMembershipDALFactory } from "@app/ee/services/group/user-grou import { TScimDALFactory } from "@app/ee/services/scim/scim-dal"; import { getConfig } from "@app/lib/config/env"; import { BadRequestError, ScimRequestError, UnauthorizedError } from "@app/lib/errors"; -import { logger } from "@app/lib/logger"; import { alphaNumericNanoId } from "@app/lib/nanoid"; import { TOrgPermission } from "@app/lib/types"; import { AuthTokenType } from "@app/services/auth/auth-type"; @@ -32,14 +32,7 @@ import { TLicenseServiceFactory } from "../license/license-service"; import { OrgPermissionActions, OrgPermissionSubjects } from "../permission/org-permission"; import { TPermissionServiceFactory } from "../permission/permission-service"; import { TProjectUserAdditionalPrivilegeDALFactory } from "../project-user-additional-privilege/project-user-additional-privilege-dal"; -import { - buildScimGroup, - buildScimGroupList, - buildScimUser, - buildScimUserList, - extractScimValueFromPath, - parseScimFilter -} from "./scim-fns"; +import { buildScimGroup, buildScimGroupList, buildScimUser, buildScimUserList, parseScimFilter } from "./scim-fns"; import { TCreateScimGroupDTO, TCreateScimTokenDTO, @@ -64,12 +57,18 @@ type TScimServiceFactoryDep = { scimDAL: Pick; userDAL: Pick< TUserDALFactory, - "find" | "findOne" | "create" | "transaction" | "findUserEncKeyByUserIdsBatch" | "findById" + "find" | "findOne" | "create" | "transaction" | "findUserEncKeyByUserIdsBatch" | "findById" | "updateById" >; - userAliasDAL: Pick; + userAliasDAL: Pick; orgDAL: Pick< TOrgDALFactory, - "createMembership" | "findById" | "findMembership" | "deleteMembershipById" | "transaction" | "updateMembershipById" + | "createMembership" + | "findById" + | "findMembership" + | "findMembershipWithScimFilter" + | "deleteMembershipById" + | "transaction" + | "updateMembershipById" >; orgMembershipDAL: Pick; projectDAL: Pick; @@ -193,7 +192,12 @@ export const scimServiceFactory = ({ }; // SCIM server endpoints - const listScimUsers = async ({ startIndex, limit, filter, orgId }: TListScimUsersDTO): Promise => { + const listScimUsers = async ({ + startIndex = 0, + limit = 100, + filter, + orgId + }: TListScimUsersDTO): Promise => { const org = await orgDAL.findById(orgId); if (!org.scimEnabled) @@ -207,23 +211,20 @@ export const scimServiceFactory = ({ ...(limit && { limit }) }; - const users = await orgDAL.findMembership( - { - [`${TableName.OrgMembership}.orgId` as "id"]: orgId, - ...parseScimFilter(filter) - }, - findOpts - ); + const users = await orgDAL.findMembershipWithScimFilter(orgId, filter, findOpts); - const scimUsers = users.map(({ id, externalId, username, firstName, lastName, email, isActive }) => - buildScimUser({ - orgMembershipId: id ?? "", - username: externalId ?? username, - firstName: firstName ?? "", - lastName: lastName ?? "", - email, - active: isActive - }) + const scimUsers = users.map( + ({ id, externalId, username, firstName, lastName, email, isActive, createdAt, updatedAt }) => + buildScimUser({ + orgMembershipId: id ?? "", + username: externalId ?? username, + firstName: firstName ?? "", + lastName: lastName ?? "", + email, + active: isActive, + createdAt, + updatedAt + }) ); return buildScimUserList({ @@ -258,11 +259,6 @@ export const scimServiceFactory = ({ status: 403 }); - const groupMembershipsInOrg = await userGroupMembershipDAL.findGroupMembershipsByUserIdInOrg( - membership.userId, - orgId - ); - return buildScimUser({ orgMembershipId: membership.id, username: membership.externalId ?? membership.username, @@ -270,10 +266,8 @@ export const scimServiceFactory = ({ firstName: membership.firstName, lastName: membership.lastName, active: membership.isActive, - groups: groupMembershipsInOrg.map((group) => ({ - value: group.groupId, - display: group.groupName - })) + createdAt: membership.createdAt, + updatedAt: membership.updatedAt }); }; @@ -322,7 +316,7 @@ export const scimServiceFactory = ({ userId: userAlias.userId, inviteEmail: email, orgId, - role: OrgMembershipRole.Member, + role: OrgMembershipRole.NoAccess, status: user.isAccepted ? OrgMembershipStatus.Accepted : OrgMembershipStatus.Invited, // if user is fully completed, then set status to accepted, otherwise set it to invited so we can update it later isActive: true }, @@ -349,7 +343,11 @@ export const scimServiceFactory = ({ } if (!user) { - const uniqueUsername = await normalizeUsername(`${firstName}-${lastName}`, userDAL); + const uniqueUsername = await normalizeUsername( + // external id is username + `${firstName}-${lastName}`, + userDAL + ); user = await userDAL.create( { username: serverCfg.trustSamlEmails ? email : uniqueUsername, @@ -430,10 +428,13 @@ export const scimServiceFactory = ({ firstName: createdUser.firstName, lastName: createdUser.lastName, email: createdUser.email ?? "", - active: createdOrgMembership.isActive + active: createdOrgMembership.isActive, + createdAt: createdOrgMembership.createdAt, + updatedAt: createdOrgMembership.updatedAt }); }; + // partial const updateScimUser = async ({ orgMembershipId, orgId, operations }: TUpdateScimUserDTO) => { const [membership] = await orgDAL .findMembership({ @@ -459,37 +460,52 @@ export const scimServiceFactory = ({ status: 403 }); - let active = true; - - operations.forEach((operation) => { - if (operation.op.toLowerCase() === "replace") { - if (operation.path === "active" && operation.value === "False") { - // azure scim op format - active = false; - } else if (typeof operation.value === "object" && operation.value.active === false) { - // okta scim op format - active = false; - } - } - }); - - if (!active) { - await orgMembershipDAL.updateById(membership.id, { - isActive: false - }); - } - - return buildScimUser({ + const scimUser = buildScimUser({ orgMembershipId: membership.id, - username: membership.externalId ?? membership.username, email: membership.email, - firstName: membership.firstName, lastName: membership.lastName, - active + firstName: membership.firstName, + active: membership.isActive, + username: membership.externalId ?? membership.username, + createdAt: membership.createdAt, + updatedAt: membership.updatedAt }); + scimPatch(scimUser, operations); + + const serverCfg = await getServerCfg(); + await userDAL.transaction(async (tx) => { + await orgMembershipDAL.updateById( + membership.id, + { + isActive: scimUser.active + }, + tx + ); + const hasEmailChanged = scimUser.emails[0].value !== membership.email; + await userDAL.updateById( + membership.userId, + { + firstName: scimUser.name.givenName, + email: scimUser.emails[0].value, + lastName: scimUser.name.familyName, + isEmailVerified: hasEmailChanged ? serverCfg.trustSamlEmails : true + }, + tx + ); + }); + + return scimUser; }; - const replaceScimUser = async ({ orgMembershipId, active, orgId }: TReplaceScimUserDTO) => { + const replaceScimUser = async ({ + orgMembershipId, + active, + orgId, + lastName, + firstName, + email, + externalId + }: TReplaceScimUserDTO) => { const [membership] = await orgDAL .findMembership({ [`${TableName.OrgMembership}.id` as "id"]: orgMembershipId, @@ -514,26 +530,47 @@ export const scimServiceFactory = ({ status: 403 }); - await orgMembershipDAL.updateById(membership.id, { - isActive: active + const serverCfg = await getServerCfg(); + await userDAL.transaction(async (tx) => { + await userAliasDAL.update( + { + orgId, + aliasType: UserAliasType.SAML, + userId: membership.userId + }, + { + externalId + }, + tx + ); + await orgMembershipDAL.updateById( + membership.id, + { + isActive: active + }, + tx + ); + await userDAL.updateById( + membership.userId, + { + firstName, + email, + lastName, + isEmailVerified: serverCfg.trustSamlEmails + }, + tx + ); }); - const groupMembershipsInOrg = await userGroupMembershipDAL.findGroupMembershipsByUserIdInOrg( - membership.userId, - orgId - ); - return buildScimUser({ orgMembershipId: membership.id, - username: membership.externalId ?? membership.username, + username: externalId, email: membership.email, firstName: membership.firstName, lastName: membership.lastName, active, - groups: groupMembershipsInOrg.map((group) => ({ - value: group.groupId, - display: group.groupName - })) + createdAt: membership.createdAt, + updatedAt: membership.updatedAt }); }; @@ -570,7 +607,7 @@ export const scimServiceFactory = ({ return {}; // intentionally return empty object upon success }; - const listScimGroups = async ({ orgId, startIndex, limit, filter }: TListScimGroupsDTO) => { + const listScimGroups = async ({ orgId, startIndex, limit, filter, isMembersExcluded }: TListScimGroupsDTO) => { const plan = await licenseService.getPlan(orgId); if (!plan.groups) throw new BadRequestError({ @@ -603,6 +640,21 @@ export const scimServiceFactory = ({ ); const scimGroups: TScimGroup[] = []; + if (isMembersExcluded) { + return buildScimGroupList({ + scimGroups: groups.map((group) => + buildScimGroup({ + groupId: group.id, + name: group.name, + members: [], + createdAt: group.createdAt, + updatedAt: group.updatedAt + }) + ), + startIndex, + limit + }); + } for await (const group of groups) { const members = await userGroupMembershipDAL.findGroupMembershipsByGroupIdInOrg(group.id, orgId); @@ -612,7 +664,9 @@ export const scimServiceFactory = ({ members: members.map((member) => ({ value: member.orgMembershipId, display: `${member.firstName ?? ""} ${member.lastName ?? ""}` - })) + })), + createdAt: group.createdAt, + updatedAt: group.updatedAt }); scimGroups.push(scimGroup); } @@ -696,7 +750,9 @@ export const scimServiceFactory = ({ members: orgMemberships.map(({ id, firstName, lastName }) => ({ value: id, display: `${firstName} ${lastName}` - })) + })), + createdAt: newGroup.group.createdAt, + updatedAt: newGroup.group.updatedAt }); }; @@ -739,31 +795,17 @@ export const scimServiceFactory = ({ members: orgMemberships.map(({ id, firstName, lastName }) => ({ value: id, display: `${firstName} ${lastName}` - })) + })), + createdAt: group.createdAt, + updatedAt: group.updatedAt }); }; - const updateScimGroupNamePut = async ({ groupId, orgId, displayName, members }: TUpdateScimGroupNamePutDTO) => { - const plan = await licenseService.getPlan(orgId); - if (!plan.groups) - throw new BadRequestError({ - message: "Failed to update SCIM group due to plan restriction. Upgrade plan to update SCIM group." - }); - - const org = await orgDAL.findById(orgId); - if (!org) { - throw new ScimRequestError({ - detail: "Organization Not Found", - status: 404 - }); - } - - if (!org.scimEnabled) - throw new ScimRequestError({ - detail: "SCIM is disabled for the organization", - status: 403 - }); - + const $replaceGroupDAL = async ( + groupId: string, + orgId: string, + { displayName, members = [] }: { displayName: string; members: { value: string }[] } + ) => { const updatedGroup = await groupDAL.transaction(async (tx) => { const [group] = await groupDAL.update( { @@ -782,74 +824,96 @@ export const scimServiceFactory = ({ }); } - if (members) { - const orgMemberships = await orgMembershipDAL.find({ - $in: { - id: members.map((member) => member.value) - } + const orgMemberships = members.length + ? await orgMembershipDAL.find({ + $in: { + id: members.map((member) => member.value) + } + }) + : []; + + const membersIdsSet = new Set(orgMemberships.map((orgMembership) => orgMembership.userId)); + const userGroupMembers = await userGroupMembershipDAL.find({ + groupId: group.id + }); + const directMemberUserIds = userGroupMembers.filter((el) => !el.isPending).map((membership) => membership.userId); + + const pendingGroupAdditionsUserIds = userGroupMembers + .filter((el) => el.isPending) + .map((pendingGroupAddition) => pendingGroupAddition.userId); + + const allMembersUserIds = directMemberUserIds.concat(pendingGroupAdditionsUserIds); + const allMembersUserIdsSet = new Set(allMembersUserIds); + + const toAddUserIds = orgMemberships.filter((member) => !allMembersUserIdsSet.has(member.userId as string)); + const toRemoveUserIds = allMembersUserIds.filter((userId) => !membersIdsSet.has(userId)); + + if (toAddUserIds.length) { + await addUsersToGroupByUserIds({ + group, + userIds: toAddUserIds.map((member) => member.userId as string), + userDAL, + userGroupMembershipDAL, + orgDAL, + groupProjectDAL, + projectKeyDAL, + projectDAL, + projectBotDAL, + tx }); + } - const membersIdsSet = new Set(orgMemberships.map((orgMembership) => orgMembership.userId)); - - const directMemberUserIds = ( - await userGroupMembershipDAL.find({ - groupId: group.id, - isPending: false - }) - ).map((membership) => membership.userId); - - const pendingGroupAdditionsUserIds = ( - await userGroupMembershipDAL.find({ - groupId: group.id, - isPending: true - }) - ).map((pendingGroupAddition) => pendingGroupAddition.userId); - - const allMembersUserIds = directMemberUserIds.concat(pendingGroupAdditionsUserIds); - const allMembersUserIdsSet = new Set(allMembersUserIds); - - const toAddUserIds = orgMemberships.filter((member) => !allMembersUserIdsSet.has(member.userId as string)); - const toRemoveUserIds = allMembersUserIds.filter((userId) => !membersIdsSet.has(userId)); - - if (toAddUserIds.length) { - await addUsersToGroupByUserIds({ - group, - userIds: toAddUserIds.map((member) => member.userId as string), - userDAL, - userGroupMembershipDAL, - orgDAL, - groupProjectDAL, - projectKeyDAL, - projectDAL, - projectBotDAL, - tx - }); - } - - if (toRemoveUserIds.length) { - await removeUsersFromGroupByUserIds({ - group, - userIds: toRemoveUserIds, - userDAL, - userGroupMembershipDAL, - groupProjectDAL, - projectKeyDAL, - tx - }); - } + if (toRemoveUserIds.length) { + await removeUsersFromGroupByUserIds({ + group, + userIds: toRemoveUserIds, + userDAL, + userGroupMembershipDAL, + groupProjectDAL, + projectKeyDAL, + tx + }); } return group; }); + return updatedGroup; + }; + + const replaceScimGroup = async ({ groupId, orgId, displayName, members }: TUpdateScimGroupNamePutDTO) => { + const plan = await licenseService.getPlan(orgId); + if (!plan.groups) + throw new BadRequestError({ + message: "Failed to update SCIM group due to plan restriction. Upgrade plan to update SCIM group." + }); + + const org = await orgDAL.findById(orgId); + if (!org) { + throw new ScimRequestError({ + detail: "Organization Not Found", + status: 404 + }); + } + + if (!org.scimEnabled) + throw new ScimRequestError({ + detail: "SCIM is disabled for the organization", + status: 403 + }); + + const updatedGroup = await $replaceGroupDAL(groupId, orgId, { displayName, members }); + return buildScimGroup({ groupId: updatedGroup.id, name: updatedGroup.name, - members + members, + updatedAt: updatedGroup.updatedAt, + createdAt: updatedGroup.createdAt }); }; - const updateScimGroupNamePatch = async ({ groupId, orgId, operations }: TUpdateScimGroupNamePatchDTO) => { + const updateScimGroup = async ({ groupId, orgId, operations }: TUpdateScimGroupNamePatchDTO) => { const plan = await licenseService.getPlan(orgId); if (!plan.groups) throw new BadRequestError({ @@ -871,7 +935,7 @@ export const scimServiceFactory = ({ status: 403 }); - let group = await groupDAL.findOne({ + const group = await groupDAL.findOne({ id: groupId, orgId }); @@ -883,64 +947,28 @@ export const scimServiceFactory = ({ }); } - for await (const operation of operations) { - if (operation.op === "replace" || operation.op === "Replace") { - group = await groupDAL.updateById(group.id, { - name: operation.value.displayName - }); - } else if (operation.op === "add" || operation.op === "Add") { - try { - const orgMemberships = await orgMembershipDAL.find({ - $in: { - id: operation.value.map((member) => member.value) - } - }); - - await addUsersToGroupByUserIds({ - group, - userIds: orgMemberships.map((membership) => membership.userId as string), - userDAL, - userGroupMembershipDAL, - orgDAL, - groupProjectDAL, - projectKeyDAL, - projectDAL, - projectBotDAL - }); - } catch { - logger.info("Repeat SCIM user-group add operation"); - } - } else if (operation.op === "remove" || operation.op === "Remove") { - const orgMembershipId = extractScimValueFromPath(operation.path); - if (!orgMembershipId) throw new ScimRequestError({ detail: "Invalid path value", status: 400 }); - const orgMembership = await orgMembershipDAL.findById(orgMembershipId); - if (!orgMembership) throw new ScimRequestError({ detail: "Org Membership Not Found", status: 400 }); - await removeUsersFromGroupByUserIds({ - group, - userIds: [orgMembership.userId as string], - userDAL, - userGroupMembershipDAL, - groupProjectDAL, - projectKeyDAL - }); - } else { - throw new ScimRequestError({ - detail: "Invalid Operation", - status: 400 - }); - } - } - const members = await userGroupMembershipDAL.findGroupMembershipsByGroupIdInOrg(group.id, orgId); - - return buildScimGroup({ + const scimGroup = buildScimGroup({ groupId: group.id, name: group.name, members: members.map((member) => ({ + value: member.orgMembershipId + })), + createdAt: group.createdAt, + updatedAt: group.updatedAt + }); + scimPatch(scimGroup, operations); + // remove members is a weird case not following scim convention + await $replaceGroupDAL(groupId, orgId, { displayName: scimGroup.displayName, members: scimGroup.members }); + + const updatedScimMembers = await userGroupMembershipDAL.findGroupMembershipsByGroupIdInOrg(group.id, orgId); + return { + ...scimGroup, + members: updatedScimMembers.map((member) => ({ value: member.orgMembershipId, display: `${member.firstName ?? ""} ${member.lastName ?? ""}` })) - }); + }; }; const deleteScimGroup = async ({ groupId, orgId }: TDeleteScimGroupDTO) => { @@ -1016,8 +1044,8 @@ export const scimServiceFactory = ({ createScimGroup, getScimGroup, deleteScimGroup, - updateScimGroupNamePut, - updateScimGroupNamePatch, + replaceScimGroup, + updateScimGroup, fnValidateScimToken }; }; diff --git a/backend/src/ee/services/scim/scim-types.ts b/backend/src/ee/services/scim/scim-types.ts index 410b6557c..5099e4ca0 100644 --- a/backend/src/ee/services/scim/scim-types.ts +++ b/backend/src/ee/services/scim/scim-types.ts @@ -1,3 +1,5 @@ +import { ScimPatchOperation } from "scim-patch"; + import { TOrgPermission } from "@app/lib/types"; export type TCreateScimTokenDTO = { @@ -34,29 +36,25 @@ export type TGetScimUserDTO = { export type TCreateScimUserDTO = { externalId: string; email?: string; - firstName: string; - lastName: string; + firstName?: string; + lastName?: string; orgId: string; }; export type TUpdateScimUserDTO = { orgMembershipId: string; orgId: string; - operations: { - op: string; - path?: string; - value?: - | string - | { - active: boolean; - }; - }[]; + operations: ScimPatchOperation[]; }; export type TReplaceScimUserDTO = { orgMembershipId: string; active: boolean; orgId: string; + email?: string; + firstName?: string; + lastName?: string; + externalId: string; }; export type TDeleteScimUserDTO = { @@ -69,6 +67,7 @@ export type TListScimGroupsDTO = { filter?: string; limit: number; orgId: string; + isMembersExcluded?: boolean; }; export type TListScimGroups = { @@ -107,31 +106,7 @@ export type TUpdateScimGroupNamePutDTO = { export type TUpdateScimGroupNamePatchDTO = { groupId: string; orgId: string; - operations: (TRemoveOp | TReplaceOp | TAddOp)[]; -}; - -// akhilmhdh: I know, this is done due to lack of time. Need to change later to support as normalized rather than like this -// Forgive akhil blame tony -type TReplaceOp = { - op: "replace" | "Replace"; - value: { - id: string; - displayName: string; - }; -}; - -type TRemoveOp = { - op: "remove" | "Remove"; - path: string; -}; - -type TAddOp = { - op: "add" | "Add"; - path: string; - value: { - value: string; - display?: string; - }[]; + operations: ScimPatchOperation[]; }; export type TDeleteScimGroupDTO = { @@ -160,13 +135,10 @@ export type TScimUser = { type: string; }[]; active: boolean; - groups: { - value: string; - display: string; - }[]; meta: { resourceType: string; - location: null; + created: Date; + lastModified: Date; }; }; @@ -176,10 +148,11 @@ export type TScimGroup = { displayName: string; members: { value: string; - display: string; + display?: string; }[]; meta: { resourceType: string; - location: null; + created: Date; + lastModified: Date; }; }; diff --git a/backend/src/lib/knex/scim.ts b/backend/src/lib/knex/scim.ts new file mode 100644 index 000000000..530a7d45a --- /dev/null +++ b/backend/src/lib/knex/scim.ts @@ -0,0 +1,121 @@ +import { Knex } from "knex"; +import { Compare, Filter, parse } from "scim2-parse-filter"; + +const appendParentToGroupingOperator = (parentPath: string, filter: Filter) => { + if (filter.op !== "[]" && filter.op !== "and" && filter.op !== "or" && filter.op !== "not") { + return { ...filter, attrPath: `${parentPath}.${(filter as Compare).attrPath}` }; + } + return filter; +}; + +export const generateKnexQueryFromScim = ( + rootQuery: Knex.QueryBuilder, + rootScimFilter: string, + getAttributeField: (attr: string) => string | null +) => { + const scimRootFilterAst = parse(rootScimFilter); + const stack = [ + { + scimFilterAst: scimRootFilterAst, + query: rootQuery + } + ]; + + while (stack.length) { + const { scimFilterAst, query } = stack.pop()!; + switch (scimFilterAst.op) { + case "eq": { + const attrPath = getAttributeField(scimFilterAst.attrPath); + if (attrPath) void query.where(attrPath, scimFilterAst.compValue); + break; + } + case "pr": { + const attrPath = getAttributeField(scimFilterAst.attrPath); + if (attrPath) void query.whereNotNull(attrPath); + break; + } + case "gt": { + const attrPath = getAttributeField(scimFilterAst.attrPath); + if (attrPath) void query.where(attrPath, ">", scimFilterAst.compValue); + break; + } + case "ge": { + const attrPath = getAttributeField(scimFilterAst.attrPath); + if (attrPath) void query.where(attrPath, ">=", scimFilterAst.compValue); + break; + } + case "lt": { + const attrPath = getAttributeField(scimFilterAst.attrPath); + if (attrPath) void query.where(attrPath, "<", scimFilterAst.compValue); + break; + } + case "le": { + const attrPath = getAttributeField(scimFilterAst.attrPath); + if (attrPath) void query.where(attrPath, "<=", scimFilterAst.compValue); + break; + } + case "sw": { + const attrPath = getAttributeField(scimFilterAst.attrPath); + if (attrPath) void query.whereILike(attrPath, `${scimFilterAst.compValue}%`); + break; + } + case "ew": { + const attrPath = getAttributeField(scimFilterAst.attrPath); + if (attrPath) void query.whereILike(attrPath, `%${scimFilterAst.compValue}`); + break; + } + case "co": { + const attrPath = getAttributeField(scimFilterAst.attrPath); + if (attrPath) void query.whereILike(attrPath, `%${scimFilterAst.compValue}%`); + break; + } + case "ne": { + const attrPath = getAttributeField(scimFilterAst.attrPath); + if (attrPath) void query.whereNot(attrPath, "=", scimFilterAst.compValue); + break; + } + case "and": { + void query.andWhere((subQueryBuilder) => { + scimFilterAst.filters.forEach((el) => { + stack.push({ + query: subQueryBuilder, + scimFilterAst: el + }); + }); + }); + break; + } + case "or": { + void query.orWhere((subQueryBuilder) => { + scimFilterAst.filters.forEach((el) => { + stack.push({ + query: subQueryBuilder, + scimFilterAst: el + }); + }); + }); + break; + } + case "not": { + void query.whereNot((subQueryBuilder) => { + stack.push({ + query: subQueryBuilder, + scimFilterAst: scimFilterAst.filter + }); + }); + break; + } + case "[]": { + void query.whereNot((subQueryBuilder) => { + stack.push({ + query: subQueryBuilder, + scimFilterAst: appendParentToGroupingOperator(scimFilterAst.attrPath, scimFilterAst.valFilter) + }); + }); + break; + } + default: + break; + } + } +}; diff --git a/backend/src/server/app.ts b/backend/src/server/app.ts index ee8acec0f..1ab8bc3fd 100644 --- a/backend/src/server/app.ts +++ b/backend/src/server/app.ts @@ -49,6 +49,21 @@ export const main = async ({ db, smtp, logger, queue, keyStore }: TMain) => { server.setValidatorCompiler(validatorCompiler); server.setSerializerCompiler(serializerCompiler); + server.addContentTypeParser("application/scim+json", { parseAs: "string" }, (_, body, done) => { + try { + const strBody = body instanceof Buffer ? body.toString() : body; + if (!strBody) { + done(null, undefined); + return; + } + const json: unknown = JSON.parse(strBody); + done(null, json); + } catch (err) { + const error = err as Error; + done(error, undefined); + } + }); + try { await server.register(cookie, { secret: appCfg.COOKIE_SECRET_SIGN_KEY diff --git a/backend/src/services/org/org-dal.ts b/backend/src/services/org/org-dal.ts index d7c6ba31a..d84dd1afd 100644 --- a/backend/src/services/org/org-dal.ts +++ b/backend/src/services/org/org-dal.ts @@ -12,6 +12,7 @@ import { } from "@app/db/schemas"; import { DatabaseError } from "@app/lib/errors"; import { buildFindFilter, ormify, selectAllTableCols, TFindFilter, TFindOpt, withTransaction } from "@app/lib/knex"; +import { generateKnexQueryFromScim } from "@app/lib/knex/scim"; export type TOrgDALFactory = ReturnType; @@ -280,6 +281,67 @@ export const orgDALFactory = (db: TDbClient) => { .select( selectAllTableCols(TableName.OrgMembership), db.ref("email").withSchema(TableName.Users), + db.ref("isEmailVerified").withSchema(TableName.Users), + db.ref("username").withSchema(TableName.Users), + db.ref("firstName").withSchema(TableName.Users), + db.ref("lastName").withSchema(TableName.Users), + db.ref("scimEnabled").withSchema(TableName.Organization), + db.ref("externalId").withSchema(TableName.UserAliases) + ) + .where({ isGhost: false }); + + if (limit) void query.limit(limit); + if (offset) void query.offset(offset); + if (sort) { + void query.orderBy(sort.map(([column, order, nulls]) => ({ column: column as string, order, nulls }))); + } + const res = await query; + return res; + } catch (error) { + throw new DatabaseError({ error, name: "Find one" }); + } + }; + + const findMembershipWithScimFilter = async ( + orgId: string, + scimFilter: string | undefined, + { offset, limit, sort, tx }: TFindOpt = {} + ) => { + try { + const query = (tx || db.replicaNode())(TableName.OrgMembership) + // eslint-disable-next-line + .where(`${TableName.OrgMembership}.orgId`, orgId) + .where((qb) => { + if (scimFilter) { + void generateKnexQueryFromScim(qb, scimFilter, (attrPath) => { + switch (attrPath) { + case "active": + return `${TableName.OrgMembership}.isActive`; + case "userName": + return `${TableName.UserAliases}.externalId`; + case "name.givenName": + return `${TableName.Users}.firstName`; + case "name.familyName": + return `${TableName.Users}.lastName`; + case "email.value": + return `${TableName.Users}.email`; + default: + return null; + } + }); + } + }) + .join(TableName.Users, `${TableName.Users}.id`, `${TableName.OrgMembership}.userId`) + .join(TableName.Organization, `${TableName.Organization}.id`, `${TableName.OrgMembership}.orgId`) + .leftJoin(TableName.UserAliases, function joinUserAlias() { + this.on(`${TableName.UserAliases}.userId`, "=", `${TableName.OrgMembership}.userId`) + .andOn(`${TableName.UserAliases}.orgId`, "=", `${TableName.OrgMembership}.orgId`) + .andOn(`${TableName.UserAliases}.aliasType`, "=", (tx || db).raw("?", ["saml"])); + }) + .select( + selectAllTableCols(TableName.OrgMembership), + db.ref("email").withSchema(TableName.Users), + db.ref("isEmailVerified").withSchema(TableName.Users), db.ref("username").withSchema(TableName.Users), db.ref("firstName").withSchema(TableName.Users), db.ref("lastName").withSchema(TableName.Users), @@ -314,6 +376,7 @@ export const orgDALFactory = (db: TDbClient) => { updateById, deleteById, findMembership, + findMembershipWithScimFilter, createMembership, updateMembershipById, deleteMembershipById,