From 929dc059c34bc2d6e55d902ca34699abaee738dc Mon Sep 17 00:00:00 2001 From: = Date: Sat, 31 Aug 2024 23:32:22 +0530 Subject: [PATCH] feat: updated scim user endpoint --- backend/package-lock.json | 38 ++++- backend/package.json | 2 + backend/src/ee/routes/v1/scim-router.ts | 150 +++++++++--------- backend/src/ee/services/scim/scim-fns.ts | 9 +- backend/src/ee/services/scim/scim-service.ts | 158 ++++++++++++------- backend/src/ee/services/scim/scim-types.ts | 22 ++- backend/src/lib/knex/scim.ts | 122 ++++++++++++++ backend/src/server/app.ts | 15 ++ backend/src/services/org/org-dal.ts | 63 ++++++++ 9 files changed, 426 insertions(+), 153 deletions(-) create mode 100644 backend/src/lib/knex/scim.ts diff --git a/backend/package-lock.json b/backend/package-lock.json index b3eef6bd1..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", @@ -13040,12 +13042,12 @@ } }, "node_modules/micromatch": { - "version": "4.0.5", - "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.5.tgz", - "integrity": "sha512-DMy+ERcEW2q8Z2Po+WNXuw3c5YaUSFjAO5GsJqfEl7UjvtIuFKO6ZrKvcItdy98dwFI2N1tg3zNIdKaQT+aNdA==", + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", "dev": true, "dependencies": { - "braces": "^3.0.2", + "braces": "^3.0.3", "picomatch": "^2.3.1" }, "engines": { @@ -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..2c6d2e620 100644 --- a/backend/src/ee/routes/v1/scim-router.ts +++ b/backend/src/ee/routes/v1/scim-router.ts @@ -5,22 +5,30 @@ 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() +}); +export const registerScimRouter = async (server: FastifyZodProvider) => { server.route({ url: "/scim-tokens", method: "POST", @@ -127,25 +135,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,23 +163,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(), + 200: ScimUserSchema.extend({ groups: z.array( z.object({ value: z.string().trim(), @@ -216,10 +190,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 +205,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 +218,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 +249,44 @@ export const registerScimRouter = async (server: FastifyZodProvider) => { } }); + 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() + }), + 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() + }) + ]) + ) + }), + 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", diff --git a/backend/src/ee/services/scim/scim-fns.ts b/backend/src/ee/services/scim/scim-fns.ts index b7454dc90..ae03cace8 100644 --- a/backend/src/ee/services/scim/scim-fns.ts +++ b/backend/src/ee/services/scim/scim-fns.ts @@ -45,7 +45,9 @@ export const buildScimUser = ({ firstName, lastName, groups = [], - active + active, + createdAt, + updatedAt }: { orgMembershipId: string; username: string; @@ -57,6 +59,8 @@ export const buildScimUser = ({ display: string; }[]; active: boolean; + createdAt: Date; + updatedAt: Date; }): TScimUser => { const scimUser = { schemas: ["urn:ietf:params:scim:schemas:core:2.0:User"], @@ -81,7 +85,8 @@ export const buildScimUser = ({ groups, meta: { resourceType: "User", - 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..2df75450a 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"; @@ -64,12 +65,18 @@ type TScimServiceFactoryDep = { scimDAL: Pick; userDAL: Pick< TUserDALFactory, - "find" | "findOne" | "create" | "transaction" | "findUserEncKeyByUserIdsBatch" | "findById" + "find" | "findOne" | "create" | "transaction" | "findUserEncKeyByUserIdsBatch" | "findById" | "updateById" >; userAliasDAL: Pick; orgDAL: Pick< TOrgDALFactory, - "createMembership" | "findById" | "findMembership" | "deleteMembershipById" | "transaction" | "updateMembershipById" + | "createMembership" + | "findById" + | "findMembership" + | "findMembershipWithScimFilter" + | "deleteMembershipById" + | "transaction" + | "updateMembershipById" >; orgMembershipDAL: Pick; projectDAL: Pick; @@ -193,7 +200,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 +219,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({ @@ -273,7 +282,9 @@ export const scimServiceFactory = ({ groups: groupMembershipsInOrg.map((group) => ({ value: group.groupId, display: group.groupName - })) + })), + createdAt: membership.createdAt, + updatedAt: membership.updatedAt }); }; @@ -349,7 +360,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 +445,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 +477,51 @@ 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.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 + }: TReplaceScimUserDTO) => { const [membership] = await orgDAL .findMembership({ [`${TableName.OrgMembership}.id` as "id"]: orgMembershipId, @@ -514,15 +546,27 @@ export const scimServiceFactory = ({ status: 403 }); - await orgMembershipDAL.updateById(membership.id, { - isActive: active + const serverCfg = await getServerCfg(); + await userDAL.transaction(async (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, @@ -530,10 +574,8 @@ export const scimServiceFactory = ({ firstName: membership.firstName, lastName: membership.lastName, active, - groups: groupMembershipsInOrg.map((group) => ({ - value: group.groupId, - display: group.groupName - })) + createdAt: membership.createdAt, + updatedAt: membership.updatedAt }); }; diff --git a/backend/src/ee/services/scim/scim-types.ts b/backend/src/ee/services/scim/scim-types.ts index 410b6557c..153d8a1bc 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,24 @@ 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; }; export type TDeleteScimUserDTO = { @@ -166,7 +163,8 @@ export type TScimUser = { }[]; 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..81e87f656 --- /dev/null +++ b/backend/src/lib/knex/scim.ts @@ -0,0 +1,122 @@ +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); + console.log(attrPath, scimFilterAst.compValue); + 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,