From 4928322cdba44bed0d4cd1a93f18cb87cac35663 Mon Sep 17 00:00:00 2001 From: = Date: Wed, 25 Sep 2024 21:37:25 +0530 Subject: [PATCH] feat: added saml parsing attributes and injecting to metadata of a user in org scoped --- .../20240924100329_identity-metadata.ts | 13 +++---- backend/src/db/schemas/identity-metadata.ts | 5 ++- backend/src/ee/routes/v1/saml-router.ts | 12 +++++- .../src/ee/services/license/license-fns.ts | 4 +- .../ee/services/permission/permission-dal.ts | 36 +++++++++++++++++- .../services/permission/permission-service.ts | 31 +++++++++++++-- .../services/permission/project-permission.ts | 20 +--------- .../saml-config/saml-config-service.ts | 38 ++++++++++++++++++- .../services/saml-config/saml-config-types.ts | 1 + backend/src/lib/fn/array.ts | 18 +++++++++ backend/src/server/routes/index.ts | 1 + .../src/services/identity/identity-org-dal.ts | 10 ++--- .../src/services/identity/identity-service.ts | 10 +++-- .../org-membership/org-membership-dal.ts | 10 ++--- backend/src/services/org/org-service.ts | 14 +++---- 15 files changed, 164 insertions(+), 59 deletions(-) diff --git a/backend/src/db/migrations/20240924100329_identity-metadata.ts b/backend/src/db/migrations/20240924100329_identity-metadata.ts index 3dce7b113..962c1d350 100644 --- a/backend/src/db/migrations/20240924100329_identity-metadata.ts +++ b/backend/src/db/migrations/20240924100329_identity-metadata.ts @@ -8,13 +8,12 @@ export async function up(knex: Knex): Promise { tb.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid()); tb.string("key").notNullable(); tb.string("value").notNullable(); - tb.uuid("userOrgMembershipId"); - tb.foreign("userOrgMembershipId").references("id").inTable(TableName.OrgMembership).onDelete("CASCADE"); - tb.uuid("identityOrgMembershipId"); - tb.foreign("identityOrgMembershipId") - .references("id") - .inTable(TableName.IdentityOrgMembership) - .onDelete("CASCADE"); + tb.uuid("orgId").notNullable(); + tb.foreign("orgId").references("id").inTable(TableName.Organization).onDelete("CASCADE"); + tb.uuid("userId"); + tb.foreign("userId").references("id").inTable(TableName.Users).onDelete("CASCADE"); + tb.uuid("identityId"); + tb.foreign("identityId").references("id").inTable(TableName.Identity).onDelete("CASCADE"); tb.timestamps(true, true, true); }); } diff --git a/backend/src/db/schemas/identity-metadata.ts b/backend/src/db/schemas/identity-metadata.ts index 1adde49ec..de1cec104 100644 --- a/backend/src/db/schemas/identity-metadata.ts +++ b/backend/src/db/schemas/identity-metadata.ts @@ -11,8 +11,9 @@ export const IdentityMetadataSchema = z.object({ id: z.string().uuid(), key: z.string(), value: z.string(), - userOrgMembershipId: z.string().uuid().nullable().optional(), - identityOrgMembershipId: z.string().uuid().nullable().optional(), + orgId: z.string().uuid(), + userId: z.string().uuid().nullable().optional(), + identityId: z.string().uuid().nullable().optional(), createdAt: z.date(), updatedAt: z.date() }); diff --git a/backend/src/ee/routes/v1/saml-router.ts b/backend/src/ee/routes/v1/saml-router.ts index 3d18a34cc..d96eb7c1f 100644 --- a/backend/src/ee/routes/v1/saml-router.ts +++ b/backend/src/ee/routes/v1/saml-router.ts @@ -100,6 +100,7 @@ export const registerSamlRouter = async (server: FastifyZodProvider) => { async (req, profile, cb) => { try { if (!profile) throw new BadRequestError({ message: "Missing profile" }); + const email = profile?.email ?? // entra sends data in this format @@ -123,6 +124,14 @@ export const registerSamlRouter = async (server: FastifyZodProvider) => { ); } + const userMetadata = Object.keys(profile.attributes || {}) + .map((key) => { + // for the ones like in format: http://schemas.xmlsoap.org/ws/2005/05/identity/claims/email + const formatedKey = key.startsWith("http") ? key.split("/").at(-1) || "" : key; + return { key: formatedKey, value: String((profile.attributes as Record)[key]) }; + }) + .filter((el) => el.key && !["email", "firstName", "lastName"].includes(el.key)); + const { isUserCompleted, providerAuthToken } = await server.services.saml.samlLogin({ externalId: profile.nameID, email, @@ -130,7 +139,8 @@ export const registerSamlRouter = async (server: FastifyZodProvider) => { lastName: lastName as string, relayState: (req.body as { RelayState?: string }).RelayState, authProvider: (req as unknown as FastifyRequest).ssoConfig?.authProvider as string, - orgId: (req as unknown as FastifyRequest).ssoConfig?.orgId as string + orgId: (req as unknown as FastifyRequest).ssoConfig?.orgId as string, + metadata: userMetadata }); cb(null, { isUserCompleted, providerAuthToken }); } catch (error) { diff --git a/backend/src/ee/services/license/license-fns.ts b/backend/src/ee/services/license/license-fns.ts index fa67b72d1..2a60fb03e 100644 --- a/backend/src/ee/services/license/license-fns.ts +++ b/backend/src/ee/services/license/license-fns.ts @@ -21,14 +21,14 @@ export const getDefaultOnPremFeatures = (): TFeatureSet => ({ secretVersioning: true, pitRecovery: false, ipAllowlisting: false, - rbac: false, + rbac: true, customRateLimits: false, customAlerts: false, auditLogs: false, auditLogsRetentionDays: 0, auditLogStreams: false, auditLogStreamLimit: 3, - samlSSO: false, + samlSSO: true, oidcSSO: false, scim: false, ldap: false, diff --git a/backend/src/ee/services/permission/permission-dal.ts b/backend/src/ee/services/permission/permission-dal.ts index a8f63be88..0a7abf704 100644 --- a/backend/src/ee/services/permission/permission-dal.ts +++ b/backend/src/ee/services/permission/permission-dal.ts @@ -168,6 +168,11 @@ export const permissionDALFactory = (db: TDbClient) => { }) .join(TableName.Project, `${TableName.Project}.id`, db.raw("?", [projectId])) .join(TableName.Organization, `${TableName.Project}.orgId`, `${TableName.Organization}.id`) + .leftJoin(TableName.IdentityMetadata, (queryBuilder) => { + void queryBuilder + .on(`${TableName.Users}.id`, `${TableName.IdentityMetadata}.userId`) + .andOn(`${TableName.Organization}.id`, `${TableName.IdentityMetadata}.orgId`); + }) .select( db.ref("id").withSchema(TableName.Users).as("userId"), db.ref("username").withSchema(TableName.Users).as("username"), @@ -258,6 +263,9 @@ export const permissionDALFactory = (db: TDbClient) => { .withSchema(TableName.ProjectUserAdditionalPrivilege) .as("userAdditionalPrivilegesTemporaryAccessEndTime"), // general + db.ref("id").withSchema(TableName.IdentityMetadata).as("metadataId"), + db.ref("key").withSchema(TableName.IdentityMetadata).as("metadataKey"), + db.ref("value").withSchema(TableName.IdentityMetadata).as("metadataValue"), db.ref("authEnforced").withSchema(TableName.Organization).as("orgAuthEnforced"), db.ref("orgId").withSchema(TableName.Project), db.ref("id").withSchema(TableName.Project).as("projectId") @@ -357,6 +365,15 @@ export const permissionDALFactory = (db: TDbClient) => { temporaryAccessEndTime: userAdditionalPrivilegesTemporaryAccessEndTime, isTemporary: userAdditionalPrivilegesIsTemporary }) + }, + { + key: "metadataId", + label: "metadata" as const, + mapper: ({ metadataKey, metadataValue, metadataId }) => ({ + id: metadataId, + key: metadataKey, + value: metadataValue + }) } ] }); @@ -419,6 +436,11 @@ export const permissionDALFactory = (db: TDbClient) => { `${TableName.IdentityProjectMembership}.projectId`, `${TableName.Project}.id` ) + .leftJoin(TableName.IdentityMetadata, (queryBuilder) => { + void queryBuilder + .on(`${TableName.Identity}.id`, `${TableName.IdentityMetadata}.identityId`) + .andOn(`${TableName.Project}.orgId`, `${TableName.IdentityMetadata}.orgId`); + }) .where("identityId", identityId) .where(`${TableName.IdentityProjectMembership}.projectId`, projectId) .select(selectAllTableCols(TableName.IdentityProjectMembershipRole)) @@ -448,7 +470,10 @@ export const permissionDALFactory = (db: TDbClient) => { db .ref("temporaryAccessEndTime") .withSchema(TableName.IdentityProjectAdditionalPrivilege) - .as("identityApTemporaryAccessEndTime") + .as("identityApTemporaryAccessEndTime"), + db.ref("id").withSchema(TableName.IdentityMetadata).as("metadataId"), + db.ref("key").withSchema(TableName.IdentityMetadata).as("metadataKey"), + db.ref("value").withSchema(TableName.IdentityMetadata).as("metadataValue") ); const permission = sqlNestRelationships({ @@ -495,6 +520,15 @@ export const permissionDALFactory = (db: TDbClient) => { temporaryAccessStartTime: identityApTemporaryAccessStartTime, isTemporary: identityApIsTemporary }) + }, + { + key: "metadataId", + label: "metadata" as const, + mapper: ({ metadataKey, metadataValue, metadataId }) => ({ + id: metadataId, + key: metadataKey, + value: metadataValue + }) } ] }); diff --git a/backend/src/ee/services/permission/permission-service.ts b/backend/src/ee/services/permission/permission-service.ts index b4bbaa99c..af17f08b6 100644 --- a/backend/src/ee/services/permission/permission-service.ts +++ b/backend/src/ee/services/permission/permission-service.ts @@ -12,6 +12,7 @@ import { } from "@app/db/schemas"; import { conditionsMatcher } from "@app/lib/casl"; import { BadRequestError, ForbiddenRequestError, NotFoundError } from "@app/lib/errors"; +import { objectify } from "@app/lib/fn"; import { ActorAuthMethod, ActorType } from "@app/services/auth/auth-type"; import { TOrgRoleDALFactory } from "@app/services/org/org-role-dal"; import { TProjectDALFactory } from "@app/services/project/project-dal"; @@ -223,9 +224,20 @@ export const permissionServiceFactory = ({ })) || []; const rules = buildProjectPermissionRules(rolePermissions.concat(additionalPrivileges)); - const templatedRules = handlebars.compile(JSON.stringify(rules), { data: false }); + const templatedRules = handlebars.compile(JSON.stringify(rules), { data: false, strict: true }); + const metadataKeyValuePair = objectify( + userProjectPermission.metadata, + (i) => i.key, + (i) => i.value + ); const interpolateRules = templatedRules( - { identity: { id: userProjectPermission.userId, username: userProjectPermission.username } }, + { + identity: { + id: userProjectPermission.userId, + username: userProjectPermission.username, + metadata: metadataKeyValuePair + } + }, { data: false } ); const permission = createMongoAbility( @@ -275,9 +287,20 @@ export const permissionServiceFactory = ({ })) || []; const rules = buildProjectPermissionRules(rolePermissions.concat(additionalPrivileges)); - const templatedRules = handlebars.compile(JSON.stringify(rules), { data: false }); + const templatedRules = handlebars.compile(JSON.stringify(rules), { data: false, strict: true }); + const metadataKeyValuePair = objectify( + identityProjectPermission.metadata, + (i) => i.key, + (i) => i.value + ); const interpolateRules = templatedRules( - { identity: { id: identityProjectPermission.identityId, username: identityProjectPermission.username } }, + { + identity: { + id: identityProjectPermission.identityId, + username: identityProjectPermission.username, + metadata: metadataKeyValuePair + } + }, { data: false } ); const permission = createMongoAbility( diff --git a/backend/src/ee/services/permission/project-permission.ts b/backend/src/ee/services/permission/project-permission.ts index 9b206bd9d..71cd78821 100644 --- a/backend/src/ee/services/permission/project-permission.ts +++ b/backend/src/ee/services/permission/project-permission.ts @@ -117,10 +117,7 @@ const SecretConditionSchema = z .object({ [PermissionConditionOperators.$EQ]: PermissionConditionSchema[PermissionConditionOperators.$EQ], [PermissionConditionOperators.$NEQ]: PermissionConditionSchema[PermissionConditionOperators.$NEQ], - [PermissionConditionOperators.$IN]: PermissionConditionSchema[PermissionConditionOperators.$IN], - [PermissionConditionOperators.$ALL]: PermissionConditionSchema[PermissionConditionOperators.$ALL], - [PermissionConditionOperators.$REGEX]: PermissionConditionSchema[PermissionConditionOperators.$REGEX], - [PermissionConditionOperators.$GLOB]: PermissionConditionSchema[PermissionConditionOperators.$GLOB] + [PermissionConditionOperators.$IN]: PermissionConditionSchema[PermissionConditionOperators.$IN] }) .partial() ]), @@ -131,21 +128,6 @@ const SecretConditionSchema = z [PermissionConditionOperators.$EQ]: PermissionConditionSchema[PermissionConditionOperators.$EQ], [PermissionConditionOperators.$NEQ]: PermissionConditionSchema[PermissionConditionOperators.$NEQ], [PermissionConditionOperators.$IN]: PermissionConditionSchema[PermissionConditionOperators.$IN], - [PermissionConditionOperators.$ALL]: PermissionConditionSchema[PermissionConditionOperators.$ALL], - [PermissionConditionOperators.$REGEX]: PermissionConditionSchema[PermissionConditionOperators.$REGEX], - [PermissionConditionOperators.$GLOB]: PermissionConditionSchema[PermissionConditionOperators.$GLOB] - }) - .partial() - ]), - secretName: z.union([ - z.string(), - z - .object({ - [PermissionConditionOperators.$EQ]: PermissionConditionSchema[PermissionConditionOperators.$EQ], - [PermissionConditionOperators.$NEQ]: PermissionConditionSchema[PermissionConditionOperators.$NEQ], - [PermissionConditionOperators.$IN]: PermissionConditionSchema[PermissionConditionOperators.$IN], - [PermissionConditionOperators.$ALL]: PermissionConditionSchema[PermissionConditionOperators.$ALL], - [PermissionConditionOperators.$REGEX]: PermissionConditionSchema[PermissionConditionOperators.$REGEX], [PermissionConditionOperators.$GLOB]: PermissionConditionSchema[PermissionConditionOperators.$GLOB] }) .partial() diff --git a/backend/src/ee/services/saml-config/saml-config-service.ts b/backend/src/ee/services/saml-config/saml-config-service.ts index dd184f01f..0c979d023 100644 --- a/backend/src/ee/services/saml-config/saml-config-service.ts +++ b/backend/src/ee/services/saml-config/saml-config-service.ts @@ -23,6 +23,7 @@ import { BadRequestError, ForbiddenRequestError, NotFoundError } from "@app/lib/ import { AuthTokenType } from "@app/services/auth/auth-type"; import { TAuthTokenServiceFactory } from "@app/services/auth-token/auth-token-service"; import { TokenType } from "@app/services/auth-token/auth-token-types"; +import { TIdentityMetadataDALFactory } from "@app/services/identity/identity-metadata-dal"; import { TOrgBotDALFactory } from "@app/services/org/org-bot-dal"; import { TOrgDALFactory } from "@app/services/org/org-dal"; import { TOrgMembershipDALFactory } from "@app/services/org-membership/org-membership-dal"; @@ -51,6 +52,8 @@ type TSamlConfigServiceFactoryDep = { TOrgDALFactory, "createMembership" | "updateMembershipById" | "findMembership" | "findOrgById" | "findOne" | "updateById" >; + + identityMetadataDAL: Pick; orgMembershipDAL: Pick; orgBotDAL: Pick; permissionService: Pick; @@ -71,7 +74,8 @@ export const samlConfigServiceFactory = ({ permissionService, licenseService, tokenService, - smtpService + smtpService, + identityMetadataDAL }: TSamlConfigServiceFactoryDep) => { const createSamlCfg = async ({ cert, @@ -332,7 +336,8 @@ export const samlConfigServiceFactory = ({ lastName, authProvider, orgId, - relayState + relayState, + metadata }: TSamlLoginDTO) => { const appCfg = getConfig(); const serverCfg = await getServerCfg(); @@ -386,6 +391,21 @@ export const samlConfigServiceFactory = ({ ); } + if (metadata && foundUser.id) { + await identityMetadataDAL.delete({ userId: foundUser.id, orgId }, tx); + if (metadata.length) { + await identityMetadataDAL.insertMany( + metadata.map(({ key, value }) => ({ + userId: foundUser.id, + orgId, + key, + value + })), + tx + ); + } + } + return foundUser; }); } else { @@ -474,6 +494,20 @@ export const samlConfigServiceFactory = ({ ); } + if (metadata && newUser.id) { + await identityMetadataDAL.delete({ userId: newUser.id, orgId }, tx); + if (metadata.length) { + await identityMetadataDAL.insertMany( + metadata.map(({ key, value }) => ({ + userId: newUser?.id, + orgId, + key, + value + })), + tx + ); + } + } return newUser; }); } diff --git a/backend/src/ee/services/saml-config/saml-config-types.ts b/backend/src/ee/services/saml-config/saml-config-types.ts index 92ee32b5c..96cb91035 100644 --- a/backend/src/ee/services/saml-config/saml-config-types.ts +++ b/backend/src/ee/services/saml-config/saml-config-types.ts @@ -53,4 +53,5 @@ export type TSamlLoginDTO = { orgId: string; // saml thingy relayState?: string; + metadata?: { key: string; value: string }[]; }; diff --git a/backend/src/lib/fn/array.ts b/backend/src/lib/fn/array.ts index 00819ceb8..959d01aef 100644 --- a/backend/src/lib/fn/array.ts +++ b/backend/src/lib/fn/array.ts @@ -52,3 +52,21 @@ export const unique = (array: readonly T[ ); return Object.values(valueMap); }; + +/** + * Convert an array to a dictionary by mapping each item + * into a dictionary key & value + */ +export const objectify = ( + array: readonly T[], + getKey: (item: T) => Key, + getValue: (item: T) => Value = (item) => item as unknown as Value +): Record => { + return array.reduce( + (acc, item) => { + acc[getKey(item)] = getValue(item); + return acc; + }, + {} as Record + ); +}; diff --git a/backend/src/server/routes/index.ts b/backend/src/server/routes/index.ts index da6e25c3e..64b242c40 100644 --- a/backend/src/server/routes/index.ts +++ b/backend/src/server/routes/index.ts @@ -388,6 +388,7 @@ export const registerRoutes = async ( const tokenService = tokenServiceFactory({ tokenDAL: authTokenDAL, userDAL, orgMembershipDAL }); const samlService = samlConfigServiceFactory({ + identityMetadataDAL, permissionService, orgBotDAL, orgDAL, diff --git a/backend/src/services/identity/identity-org-dal.ts b/backend/src/services/identity/identity-org-dal.ts index fbf8cb57b..0422a4b5d 100644 --- a/backend/src/services/identity/identity-org-dal.ts +++ b/backend/src/services/identity/identity-org-dal.ts @@ -56,11 +56,11 @@ export const identityOrgDALFactory = (db: TDbClient) => { queryBuilder.on(`${TableName.IdentityOrgMembership}.identityId`, `${TableName.Identity}.id`); }) .leftJoin(TableName.OrgRoles, `${TableName.IdentityOrgMembership}.roleId`, `${TableName.OrgRoles}.id`) - .leftJoin( - TableName.IdentityMetadata, - `${TableName.IdentityMetadata}.identityOrgMembershipId`, - `${TableName.IdentityOrgMembership}.id` - ) + .leftJoin(TableName.IdentityMetadata, (queryBuilder) => { + void queryBuilder + .on(`${TableName.IdentityOrgMembership}.identityId`, `${TableName.IdentityMetadata}.identityId`) + .andOn(`${TableName.IdentityOrgMembership}.orgId`, `${TableName.IdentityMetadata}.orgId`); + }) .select(selectAllTableCols(TableName.IdentityOrgMembership)) // cr stands for custom role .select(db.ref("id").as("crId").withSchema(TableName.OrgRoles)) diff --git a/backend/src/services/identity/identity-service.ts b/backend/src/services/identity/identity-service.ts index 770eefb26..cb244898f 100644 --- a/backend/src/services/identity/identity-service.ts +++ b/backend/src/services/identity/identity-service.ts @@ -73,7 +73,7 @@ export const identityServiceFactory = ({ const identity = await identityDAL.transaction(async (tx) => { const newIdentity = await identityDAL.create({ name }, tx); - const identityOrgMembership = await identityOrgMembershipDAL.create( + await identityOrgMembershipDAL.create( { identityId: newIdentity.id, orgId, @@ -85,7 +85,8 @@ export const identityServiceFactory = ({ if (metadata && metadata.length) { await identityMetadataDAL.insertMany( metadata.map(({ key, value }) => ({ - identityOrgMembershipId: identityOrgMembership.id, + identityId: newIdentity.id, + orgId, key, value })), @@ -159,11 +160,12 @@ export const identityServiceFactory = ({ ); } if (metadata) { - await identityMetadataDAL.delete({ identityOrgMembershipId: identityOrgMembership.id }, tx); + await identityMetadataDAL.delete({ orgId: identityOrgMembership.orgId, identityId: id }, tx); if (metadata.length) { await identityMetadataDAL.insertMany( metadata.map(({ key, value }) => ({ - identityOrgMembershipId: identityOrgMembership.id, + identityId: newIdentity.id, + orgId: identityOrgMembership.orgId, key, value })), diff --git a/backend/src/services/org-membership/org-membership-dal.ts b/backend/src/services/org-membership/org-membership-dal.ts index 5f24ca628..68b117202 100644 --- a/backend/src/services/org-membership/org-membership-dal.ts +++ b/backend/src/services/org-membership/org-membership-dal.ts @@ -19,11 +19,11 @@ export const orgMembershipDALFactory = (db: TDbClient) => { `${TableName.UserEncryptionKey}.userId`, `${TableName.Users}.id` ) - .leftJoin( - TableName.IdentityMetadata, - `${TableName.IdentityMetadata}.userOrgMembershipId`, - `${TableName.OrgMembership}.id` - ) + .leftJoin(TableName.IdentityMetadata, (queryBuilder) => { + void queryBuilder + .on(`${TableName.OrgMembership}.userId`, `${TableName.IdentityMetadata}.userId`) + .andOn(`${TableName.OrgMembership}.orgId`, `${TableName.IdentityMetadata}.orgId`); + }) .select( db.ref("id").withSchema(TableName.OrgMembership), db.ref("inviteEmail").withSchema(TableName.OrgMembership), diff --git a/backend/src/services/org/org-service.ts b/backend/src/services/org/org-service.ts index 09e5345a8..02c634e27 100644 --- a/backend/src/services/org/org-service.ts +++ b/backend/src/services/org/org-service.ts @@ -79,7 +79,7 @@ type TOrgServiceFactoryDep = { "findProjectMembershipsByUserId" | "delete" | "create" | "find" | "insertMany" | "transaction" >; projectKeyDAL: Pick; - orgMembershipDAL: Pick; + orgMembershipDAL: Pick; incidentContactDAL: TIncidentContactsDALFactory; samlConfigDAL: Pick; smtpService: TSmtpService; @@ -413,11 +413,10 @@ export const orgServiceFactory = ({ const { permission } = await permissionService.getUserOrgPermission(userId, orgId, actorAuthMethod, actorOrgId); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Edit, OrgPermissionSubjects.Member); - const foundMembership = await orgMembershipDAL.findOne({ - id: membershipId, - orgId - }); + const foundMembership = await orgMembershipDAL.findById(membershipId); if (!foundMembership) throw new NotFoundError({ message: "Failed to find organization membership" }); + if (foundMembership.orgId !== orgId) + throw new UnauthorizedError({ message: "Updated org member doesn't belong to the organization" }); if (foundMembership.userId === userId) throw new UnauthorizedError({ message: "Cannot update own organization membership" }); @@ -444,11 +443,12 @@ export const orgServiceFactory = ({ ); if (metadata) { - await identityMetadataDAL.delete({ userOrgMembershipId: updatedOrgMembership.id }, tx); + await identityMetadataDAL.delete({ userId: updatedOrgMembership.userId, orgId }, tx); if (metadata.length) { await identityMetadataDAL.insertMany( metadata.map(({ key, value }) => ({ - userOrgMembershipId: updatedOrgMembership.id, + userId: updatedOrgMembership.userId, + orgId, key, value })),