From 36916704be1a6a0eb54d811f6c4428ce3a2f85bd Mon Sep 17 00:00:00 2001 From: carlosmonastyrski Date: Fri, 2 May 2025 11:14:43 -0300 Subject: [PATCH 01/35] Add groups to ssh hosts allowed principals --- ...-groups-to-ssh-host-login-user-mappings.ts | 22 +++ .../schemas/ssh-host-login-user-mappings.ts | 3 +- backend/src/ee/services/group/group-dal.ts | 17 ++ .../group/user-group-membership-dal.ts | 3 +- .../ee/services/permission/permission-dal.ts | 7 +- .../services/permission/permission-service.ts | 31 +++- .../src/ee/services/ssh-host/ssh-host-dal.ts | 23 ++- .../ee/services/ssh-host/ssh-host-schema.ts | 23 ++- .../ee/services/ssh-host/ssh-host-service.ts | 93 +++++++++- .../ee/services/ssh-host/ssh-host-types.ts | 6 +- backend/src/lib/api-docs/constants.ts | 4 +- backend/src/server/routes/index.ts | 2 + .../secret-v2-bridge/secret-v2-bridge-dal.ts | 2 +- frontend/src/hooks/api/sshHost/types.ts | 3 + .../SshHostsPage/components/SshHostModal.tsx | 172 ++++++++++++------ .../SshHostsPage/components/SshHostsTable.tsx | 52 +++++- 16 files changed, 381 insertions(+), 82 deletions(-) create mode 100644 backend/src/db/migrations/20250501164905_add-groups-to-ssh-host-login-user-mappings.ts diff --git a/backend/src/db/migrations/20250501164905_add-groups-to-ssh-host-login-user-mappings.ts b/backend/src/db/migrations/20250501164905_add-groups-to-ssh-host-login-user-mappings.ts new file mode 100644 index 000000000..4f08146f9 --- /dev/null +++ b/backend/src/db/migrations/20250501164905_add-groups-to-ssh-host-login-user-mappings.ts @@ -0,0 +1,22 @@ +import { Knex } from "knex"; + +import { TableName } from "../schemas"; + +export async function up(knex: Knex): Promise { + if (!(await knex.schema.hasColumn(TableName.SshHostLoginUserMapping, "groupId"))) { + await knex.schema.alterTable(TableName.SshHostLoginUserMapping, (t) => { + t.uuid("groupId").nullable(); + t.foreign("groupId").references("id").inTable(TableName.Groups).onDelete("CASCADE"); + t.unique(["sshHostLoginUserId", "groupId"]); + }); + } +} + +export async function down(knex: Knex): Promise { + if (await knex.schema.hasColumn(TableName.SshHostLoginUserMapping, "groupId")) { + await knex.schema.alterTable(TableName.SshHostLoginUserMapping, (t) => { + t.dropUnique(["sshHostLoginUserId", "groupId"]); + t.dropColumn("groupId"); + }); + } +} diff --git a/backend/src/db/schemas/ssh-host-login-user-mappings.ts b/backend/src/db/schemas/ssh-host-login-user-mappings.ts index 6edb0d5a3..fd5fa460c 100644 --- a/backend/src/db/schemas/ssh-host-login-user-mappings.ts +++ b/backend/src/db/schemas/ssh-host-login-user-mappings.ts @@ -12,7 +12,8 @@ export const SshHostLoginUserMappingsSchema = z.object({ createdAt: z.date(), updatedAt: z.date(), sshHostLoginUserId: z.string().uuid(), - userId: z.string().uuid().nullable().optional() + userId: z.string().uuid().nullable().optional(), + groupId: z.string().uuid().nullable().optional() }); export type TSshHostLoginUserMappings = z.infer; diff --git a/backend/src/ee/services/group/group-dal.ts b/backend/src/ee/services/group/group-dal.ts index 59f82c05d..12a70a15e 100644 --- a/backend/src/ee/services/group/group-dal.ts +++ b/backend/src/ee/services/group/group-dal.ts @@ -157,10 +157,27 @@ export const groupDALFactory = (db: TDbClient) => { } }; + const findGroupsByProjectId = async (projectId: string, tx?: Knex) => { + try { + const docs = await (tx || db.replicaNode())(TableName.Groups) + .leftJoin( + TableName.GroupProjectMembership, + `${TableName.Groups}.id`, + `${TableName.GroupProjectMembership}.groupId` + ) + .where(`${TableName.GroupProjectMembership}.projectId`, projectId) + .select(selectAllTableCols(TableName.Groups)); + return docs; + } catch (error) { + throw new DatabaseError({ error, name: "Find groups by project id" }); + } + }; + return { findGroups, findByOrgId, findAllGroupPossibleMembers, + findGroupsByProjectId, ...groupOrm }; }; diff --git a/backend/src/ee/services/group/user-group-membership-dal.ts b/backend/src/ee/services/group/user-group-membership-dal.ts index be654b338..5ee97e457 100644 --- a/backend/src/ee/services/group/user-group-membership-dal.ts +++ b/backend/src/ee/services/group/user-group-membership-dal.ts @@ -176,7 +176,8 @@ export const userGroupMembershipDALFactory = (db: TDbClient) => { db.ref("name").withSchema(TableName.Groups).as("groupName"), db.ref("id").withSchema(TableName.OrgMembership).as("orgMembershipId"), db.ref("firstName").withSchema(TableName.Users).as("firstName"), - db.ref("lastName").withSchema(TableName.Users).as("lastName") + db.ref("lastName").withSchema(TableName.Users).as("lastName"), + db.ref("slug").withSchema(TableName.Groups).as("groupSlug") ); return docs; diff --git a/backend/src/ee/services/permission/permission-dal.ts b/backend/src/ee/services/permission/permission-dal.ts index 891d7193e..7a17108a2 100644 --- a/backend/src/ee/services/permission/permission-dal.ts +++ b/backend/src/ee/services/permission/permission-dal.ts @@ -132,7 +132,7 @@ export const permissionDALFactory = (db: TDbClient) => { } }; - const getProjectGroupPermissions = async (projectId: string) => { + const getProjectGroupPermissions = async (projectId: string, filterGroupId?: string) => { try { const docs = await db .replicaNode()(TableName.GroupProjectMembership) @@ -148,6 +148,11 @@ export const permissionDALFactory = (db: TDbClient) => { `groupCustomRoles.id` ) .where(`${TableName.GroupProjectMembership}.projectId`, "=", projectId) + .where((bd) => { + if (filterGroupId) { + void bd.where(`${TableName.GroupProjectMembership}.groupId`, "=", filterGroupId); + } + }) .select( db.ref("id").withSchema(TableName.GroupProjectMembership).as("membershipId"), db.ref("id").withSchema(TableName.Groups).as("groupId"), diff --git a/backend/src/ee/services/permission/permission-service.ts b/backend/src/ee/services/permission/permission-service.ts index 3d2f96f82..40988ac80 100644 --- a/backend/src/ee/services/permission/permission-service.ts +++ b/backend/src/ee/services/permission/permission-service.ts @@ -625,6 +625,34 @@ export const permissionServiceFactory = ({ return { permission }; }; + const checkGroupProjectPermission = async ({ + groupId, + projectId, + checkPermissions + }: { + groupId: string; + projectId: string; + checkPermissions: ProjectPermissionSet; + }) => { + const rawGroupProjectPermissions = await permissionDAL.getProjectGroupPermissions(projectId, groupId); + const groupPermissions = rawGroupProjectPermissions.map((groupProjectPermission) => { + const rolePermissions = + groupProjectPermission.roles?.map(({ role, permissions }) => ({ role, permissions })) || []; + const rules = buildProjectPermissionRules(rolePermissions); + const permission = createMongoAbility(rules, { + conditionsMatcher + }); + + return { + permission, + id: groupProjectPermission.groupId, + name: groupProjectPermission.username, + membershipId: groupProjectPermission.id + }; + }); + return groupPermissions.some((groupPermission) => groupPermission.permission.can(...checkPermissions)); + }; + return { getUserOrgPermission, getOrgPermission, @@ -634,6 +662,7 @@ export const permissionServiceFactory = ({ getOrgPermissionByRole, getProjectPermissionByRole, buildOrgPermission, - buildProjectPermissionRules + buildProjectPermissionRules, + checkGroupProjectPermission }; }; diff --git a/backend/src/ee/services/ssh-host/ssh-host-dal.ts b/backend/src/ee/services/ssh-host/ssh-host-dal.ts index 3c9755e65..347dac697 100644 --- a/backend/src/ee/services/ssh-host/ssh-host-dal.ts +++ b/backend/src/ee/services/ssh-host/ssh-host-dal.ts @@ -27,8 +27,17 @@ export const sshHostDALFactory = (db: TDbClient) => { `${TableName.SshHostLoginUserMapping}.sshHostLoginUserId` ) .leftJoin(TableName.Users, `${TableName.Users}.id`, `${TableName.SshHostLoginUserMapping}.userId`) + .leftJoin( + TableName.UserGroupMembership, + `${TableName.UserGroupMembership}.groupId`, + `${TableName.SshHostLoginUserMapping}.groupId` + ) .whereIn(`${TableName.SshHost}.projectId`, projectIds) - .andWhere(`${TableName.SshHostLoginUserMapping}.userId`, userId) + .andWhere((bd) => { + void bd + .where(`${TableName.SshHostLoginUserMapping}.userId`, userId) + .orWhere(`${TableName.UserGroupMembership}.userId`, userId); + }) .select( db.ref("id").withSchema(TableName.SshHost).as("sshHostId"), db.ref("projectId").withSchema(TableName.SshHost), @@ -85,6 +94,7 @@ export const sshHostDALFactory = (db: TDbClient) => { `${TableName.SshHostLoginUserMapping}.sshHostLoginUserId` ) .leftJoin(TableName.Users, `${TableName.SshHostLoginUserMapping}.userId`, `${TableName.Users}.id`) + .leftJoin(TableName.Groups, `${TableName.SshHostLoginUserMapping}.groupId`, `${TableName.Groups}.id`) .where(`${TableName.SshHost}.projectId`, projectId) .select( db.ref("id").withSchema(TableName.SshHost).as("sshHostId"), @@ -96,6 +106,7 @@ export const sshHostDALFactory = (db: TDbClient) => { db.ref("loginUser").withSchema(TableName.SshHostLoginUser), db.ref("username").withSchema(TableName.Users), db.ref("userId").withSchema(TableName.SshHostLoginUserMapping), + db.ref("slug").withSchema(TableName.Groups).as("groupSlug"), db.ref("userSshCaId").withSchema(TableName.SshHost), db.ref("hostSshCaId").withSchema(TableName.SshHost) ) @@ -113,7 +124,8 @@ export const sshHostDALFactory = (db: TDbClient) => { const loginMappings = Object.entries(loginMappingGrouped).map(([loginUser, entries]) => ({ loginUser, allowedPrincipals: { - usernames: unique(entries.map((e) => e.username)).filter(Boolean) + usernames: unique(entries.map((e) => e.username)).filter(Boolean), + groups: unique(entries.map((e) => e.groupSlug)).filter(Boolean) } })); @@ -144,6 +156,7 @@ export const sshHostDALFactory = (db: TDbClient) => { `${TableName.SshHostLoginUserMapping}.sshHostLoginUserId` ) .leftJoin(TableName.Users, `${TableName.SshHostLoginUserMapping}.userId`, `${TableName.Users}.id`) + .leftJoin(TableName.Groups, `${TableName.SshHostLoginUserMapping}.groupId`, `${TableName.Groups}.id`) .where(`${TableName.SshHost}.id`, sshHostId) .select( db.ref("id").withSchema(TableName.SshHost).as("sshHostId"), @@ -156,7 +169,8 @@ export const sshHostDALFactory = (db: TDbClient) => { db.ref("username").withSchema(TableName.Users), db.ref("userId").withSchema(TableName.SshHostLoginUserMapping), db.ref("userSshCaId").withSchema(TableName.SshHost), - db.ref("hostSshCaId").withSchema(TableName.SshHost) + db.ref("hostSshCaId").withSchema(TableName.SshHost), + db.ref("slug").withSchema(TableName.Groups).as("groupSlug") ); if (rows.length === 0) return null; @@ -171,7 +185,8 @@ export const sshHostDALFactory = (db: TDbClient) => { const loginMappings = Object.entries(loginMappingGrouped).map(([loginUser, entries]) => ({ loginUser, allowedPrincipals: { - usernames: unique(entries.map((e) => e.username)).filter(Boolean) + usernames: unique(entries.map((e) => e.username)).filter(Boolean), + groups: unique(entries.map((e) => e.groupSlug)).filter(Boolean) } })); diff --git a/backend/src/ee/services/ssh-host/ssh-host-schema.ts b/backend/src/ee/services/ssh-host/ssh-host-schema.ts index a9b674991..c8acb37bf 100644 --- a/backend/src/ee/services/ssh-host/ssh-host-schema.ts +++ b/backend/src/ee/services/ssh-host/ssh-host-schema.ts @@ -15,7 +15,24 @@ export const sanitizedSshHost = SshHostsSchema.pick({ export const loginMappingSchema = z.object({ loginUser: z.string().trim(), - allowedPrincipals: z.object({ - usernames: z.array(z.string().trim()).transform((usernames) => Array.from(new Set(usernames))) - }) + allowedPrincipals: z + .object({ + usernames: z + .array(z.string().trim()) + .transform((usernames) => Array.from(new Set(usernames))) + .optional(), + groups: z + .array(z.string().trim()) + .transform((groups) => Array.from(new Set(groups))) + .optional() + }) + .refine( + (data) => { + return (data.usernames && data.usernames.length > 0) || (data.groups && data.groups.length > 0); + }, + { + message: "At least one username or group must be provided", + path: ["allowedPrincipals"] + } + ) }); diff --git a/backend/src/ee/services/ssh-host/ssh-host-service.ts b/backend/src/ee/services/ssh-host/ssh-host-service.ts index 92f1f5236..fa8a453ca 100644 --- a/backend/src/ee/services/ssh-host/ssh-host-service.ts +++ b/backend/src/ee/services/ssh-host/ssh-host-service.ts @@ -1,6 +1,7 @@ import { ForbiddenError, subject } from "@casl/ability"; import { ActionProjectType, ProjectType } from "@app/db/schemas"; +import { TGroupDALFactory } from "@app/ee/services/group/group-dal"; import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service"; import { ProjectPermissionSshHostActions, ProjectPermissionSub } from "@app/ee/services/permission/project-permission"; import { TSshCertificateAuthorityDALFactory } from "@app/ee/services/ssh/ssh-certificate-authority-dal"; @@ -19,6 +20,7 @@ import { TProjectDALFactory } from "@app/services/project/project-dal"; import { TProjectSshConfigDALFactory } from "@app/services/project/project-ssh-config-dal"; import { TUserDALFactory } from "@app/services/user/user-dal"; +import { TUserGroupMembershipDALFactory } from "../group/user-group-membership-dal"; import { convertActorToPrincipals, createSshCert, @@ -38,12 +40,14 @@ import { type TSshHostServiceFactoryDep = { userDAL: Pick; + groupDAL: Pick; projectDAL: Pick; projectSshConfigDAL: Pick; sshCertificateAuthorityDAL: Pick; sshCertificateAuthoritySecretDAL: Pick; sshCertificateDAL: Pick; sshCertificateBodyDAL: Pick; + userGroupMembershipDAL: Pick; sshHostDAL: Pick< TSshHostDALFactory, | "transaction" @@ -57,7 +61,10 @@ type TSshHostServiceFactoryDep = { >; sshHostLoginUserDAL: TSshHostLoginUserDALFactory; sshHostLoginUserMappingDAL: TSshHostLoginUserMappingDALFactory; - permissionService: Pick; + permissionService: Pick< + TPermissionServiceFactory, + "getProjectPermission" | "getUserProjectPermission" | "checkGroupProjectPermission" + >; kmsService: Pick; }; @@ -65,6 +72,8 @@ export type TSshHostServiceFactory = ReturnType; export const sshHostServiceFactory = ({ userDAL, + userGroupMembershipDAL, + groupDAL, projectDAL, projectSshConfigDAL, sshCertificateAuthorityDAL, @@ -212,7 +221,7 @@ export const sshHostServiceFactory = ({ tx ); - if (allowedPrincipals.usernames.length > 0) { + if (allowedPrincipals.usernames && allowedPrincipals.usernames.length > 0) { const users = await userDAL.find( { $in: { @@ -251,6 +260,42 @@ export const sshHostServiceFactory = ({ tx ); } + + if (allowedPrincipals.groups && allowedPrincipals.groups.length > 0) { + const groups = await groupDAL.findGroupsByProjectId(projectId); + + const foundGroupSlugs = new Set(groups.map((g) => g.slug)); + + for (const slug of allowedPrincipals.groups) { + if (!foundGroupSlugs.has(slug)) { + throw new BadRequestError({ + message: `Invalid group slug: ${slug}` + }); + } + } + + for await (const group of groups) { + // check that each group has access to the SSH project and have read access to hosts + const hasPermission = await permissionService.checkGroupProjectPermission({ + groupId: group.id, + projectId, + checkPermissions: [ProjectPermissionSshHostActions.Read, ProjectPermissionSub.SshHosts] + }); + if (!hasPermission) { + throw new BadRequestError({ + message: `Group ${group.slug} does not have access to the SSH project` + }); + } + } + + await sshHostLoginUserMappingDAL.insertMany( + groups.map((group) => ({ + sshHostLoginUserId: sshHostLoginUser.id, + groupId: group.id + })), + tx + ); + } } const newSshHostWithLoginMappings = await sshHostDAL.findSshHostByIdWithLoginMappings(host.id, tx); @@ -319,7 +364,7 @@ export const sshHostServiceFactory = ({ tx ); - if (allowedPrincipals.usernames.length > 0) { + if (allowedPrincipals.usernames && allowedPrincipals.usernames.length > 0) { const users = await userDAL.find( { $in: { @@ -357,6 +402,42 @@ export const sshHostServiceFactory = ({ tx ); } + + if (allowedPrincipals.groups && allowedPrincipals.groups.length > 0) { + const groups = await groupDAL.findGroupsByProjectId(host.projectId); + + const foundGroupSlugs = new Set(groups.map((g) => g.slug)); + + for (const slug of allowedPrincipals.groups) { + if (!foundGroupSlugs.has(slug)) { + throw new BadRequestError({ + message: `Invalid group slug: ${slug}` + }); + } + } + + for await (const group of groups) { + // check that each group has access to the SSH project and have read access to hosts + const hasPermission = await permissionService.checkGroupProjectPermission({ + groupId: group.id, + projectId: host.projectId, + checkPermissions: [ProjectPermissionSshHostActions.Read, ProjectPermissionSub.SshHosts] + }); + if (!hasPermission) { + throw new BadRequestError({ + message: `Group ${group.slug} does not have access to the SSH project` + }); + } + } + + await sshHostLoginUserMappingDAL.insertMany( + groups.map((group) => ({ + sshHostLoginUserId: sshHostLoginUser.id, + groupId: group.id + })), + tx + ); + } } } } @@ -460,10 +541,14 @@ export const sshHostServiceFactory = ({ userDAL }); + const userGroups = await userGroupMembershipDAL.findGroupMembershipsByUserIdInOrg(actorId, actorOrgId); + const userGroupSlugs = userGroups.map((g) => g.groupSlug); + const mapping = host.loginMappings.find( (m) => m.loginUser === loginUser && - m.allowedPrincipals.usernames.some((allowed) => internalPrincipals.includes(allowed)) + (m.allowedPrincipals.usernames.some((allowed) => internalPrincipals.includes(allowed)) || + m.allowedPrincipals.groups.some((allowed) => userGroupSlugs.includes(allowed))) ); if (!mapping) { diff --git a/backend/src/ee/services/ssh-host/ssh-host-types.ts b/backend/src/ee/services/ssh-host/ssh-host-types.ts index a4826cd72..4586d66af 100644 --- a/backend/src/ee/services/ssh-host/ssh-host-types.ts +++ b/backend/src/ee/services/ssh-host/ssh-host-types.ts @@ -10,7 +10,8 @@ export type TCreateSshHostDTO = { loginMappings: { loginUser: string; allowedPrincipals: { - usernames: string[]; + usernames?: string[]; + groups?: string[]; }; }[]; userSshCaId?: string; @@ -26,7 +27,8 @@ export type TUpdateSshHostDTO = { loginMappings?: { loginUser: string; allowedPrincipals: { - usernames: string[]; + usernames?: string[]; + groups?: string[]; }; }[]; } & Omit; diff --git a/backend/src/lib/api-docs/constants.ts b/backend/src/lib/api-docs/constants.ts index 37a3d3679..e0e4de5df 100644 --- a/backend/src/lib/api-docs/constants.ts +++ b/backend/src/lib/api-docs/constants.ts @@ -1395,7 +1395,7 @@ export const SSH_HOSTS = { loginUser: "A login user on the remote machine (e.g. 'ec2-user', 'deploy', 'admin')", allowedPrincipals: "A list of allowed principals that can log in as the login user.", loginMappings: - "A list of login mappings for the SSH host. Each login mapping contains a login user and a list of corresponding allowed principals being usernames of users in the Infisical SSH project.", + "A list of login mappings for the SSH host. Each login mapping contains a login user and a list of corresponding allowed principals being usernames of users or groups slugs in the Infisical SSH project.", userSshCaId: "The ID of the SSH CA to use for user certificates. If not specified, the default user SSH CA will be used if it exists.", hostSshCaId: @@ -1410,7 +1410,7 @@ export const SSH_HOSTS = { loginUser: "A login user on the remote machine (e.g. 'ec2-user', 'deploy', 'admin')", allowedPrincipals: "A list of allowed principals that can log in as the login user.", loginMappings: - "A list of login mappings for the SSH host. Each login mapping contains a login user and a list of corresponding allowed principals being usernames of users in the Infisical SSH project." + "A list of login mappings for the SSH host. Each login mapping contains a login user and a list of corresponding allowed principals being usernames of users or groups slugs in the Infisical SSH project." }, DELETE: { sshHostId: "The ID of the SSH host to delete." diff --git a/backend/src/server/routes/index.ts b/backend/src/server/routes/index.ts index a71a69c20..0cabc0291 100644 --- a/backend/src/server/routes/index.ts +++ b/backend/src/server/routes/index.ts @@ -836,6 +836,8 @@ export const registerRoutes = async ( const sshHostService = sshHostServiceFactory({ userDAL, + groupDAL, + userGroupMembershipDAL, projectDAL, projectSshConfigDAL, sshCertificateAuthorityDAL, diff --git a/backend/src/services/secret-v2-bridge/secret-v2-bridge-dal.ts b/backend/src/services/secret-v2-bridge/secret-v2-bridge-dal.ts index 6ab348520..cd2773172 100644 --- a/backend/src/services/secret-v2-bridge/secret-v2-bridge-dal.ts +++ b/backend/src/services/secret-v2-bridge/secret-v2-bridge-dal.ts @@ -7,6 +7,7 @@ import { ProjectType, SecretsV2Schema, SecretType, TableName, TSecretsV2, TSecre import { TKeyStoreFactory } from "@app/keystore/keystore"; import { getConfig } from "@app/lib/config/env"; import { generateCacheKeyFromData } from "@app/lib/crypto/cache"; +import { applyJitter } from "@app/lib/dates"; import { BadRequestError, DatabaseError, NotFoundError } from "@app/lib/errors"; import { buildFindFilter, @@ -22,7 +23,6 @@ import type { TFindSecretsByFolderIdsFilter, TGetSecretsDTO } from "@app/services/secret-v2-bridge/secret-v2-bridge-types"; -import { applyJitter } from "@app/lib/dates"; export const SecretServiceCacheKeys = { get productKey() { diff --git a/frontend/src/hooks/api/sshHost/types.ts b/frontend/src/hooks/api/sshHost/types.ts index ebeb5130a..2170bf415 100644 --- a/frontend/src/hooks/api/sshHost/types.ts +++ b/frontend/src/hooks/api/sshHost/types.ts @@ -9,6 +9,7 @@ export type TSshHost = { loginUser: string; allowedPrincipals: { usernames: string[]; + groups: string[]; }; }[]; }; @@ -23,6 +24,7 @@ export type TCreateSshHostDTO = { loginUser: string; allowedPrincipals: { usernames: string[]; + groups: string[]; }; }[]; }; @@ -37,6 +39,7 @@ export type TUpdateSshHostDTO = { loginUser: string; allowedPrincipals: { usernames: string[]; + groups: string[]; }; }[]; }; diff --git a/frontend/src/pages/ssh/SshHostsPage/components/SshHostModal.tsx b/frontend/src/pages/ssh/SshHostsPage/components/SshHostModal.tsx index ede0a35a0..122234c92 100644 --- a/frontend/src/pages/ssh/SshHostsPage/components/SshHostModal.tsx +++ b/frontend/src/pages/ssh/SshHostsPage/components/SshHostModal.tsx @@ -23,6 +23,7 @@ import { useCreateSshHost, useGetSshHostById, useGetWorkspaceUsers, + useListWorkspaceGroups, useListWorkspaceSshHosts, useUpdateSshHost } from "@app/hooks/api"; @@ -48,7 +49,14 @@ const schema = z loginMappings: z .object({ loginUser: z.string().trim().min(1), - allowedPrincipals: z.array(z.string().trim()).default([]) + principals: z + .array( + z.object({ + type: z.enum(["user", "group"]), + value: z.string().trim() + }) + ) + .default([]) }) .array() .default([]) @@ -62,6 +70,7 @@ export const SshHostModal = ({ popUp, handlePopUpToggle }: Props) => { const projectId = currentWorkspace?.id || ""; const { data: sshHosts } = useListWorkspaceSshHosts(currentWorkspace.id); const { data: members = [] } = useGetWorkspaceUsers(projectId); + const { data: groups = [] } = useListWorkspaceGroups(projectId); const [expandedMappings, setExpandedMappings] = useState>({}); const { data: sshHost } = useGetSshHostById( @@ -101,7 +110,16 @@ export const SshHostModal = ({ popUp, handlePopUpToggle }: Props) => { userCertTtl: sshHost.userCertTtl, loginMappings: sshHost.loginMappings.map(({ loginUser, allowedPrincipals }) => ({ loginUser, - allowedPrincipals: allowedPrincipals.usernames + principals: [ + ...(allowedPrincipals.usernames || []).map((username) => ({ + type: "user" as const, + value: username + })), + ...(allowedPrincipals.groups || []).map((group) => ({ + type: "group" as const, + value: group + })) + ] })) }); @@ -151,18 +169,28 @@ export const SshHostModal = ({ popUp, handlePopUpToggle }: Props) => { } } + const transformedLoginMappings = loginMappings.map(({ loginUser, principals }) => { + const usernames = principals.filter((p) => p.type === "user").map((p) => p.value); + + const groupNames = principals.filter((p) => p.type === "group").map((p) => p.value); + + return { + loginUser, + allowedPrincipals: { + usernames, + groups: groupNames + } + }; + }); + console.log(transformedLoginMappings); + if (sshHost) { await updateMutateAsync({ sshHostId: sshHost.id, hostname, alias: trimmedAlias, userCertTtl, - loginMappings: loginMappings.map(({ loginUser, allowedPrincipals }) => ({ - loginUser, - allowedPrincipals: { - usernames: allowedPrincipals - } - })) + loginMappings: transformedLoginMappings }); } else { await createMutateAsync({ @@ -170,12 +198,7 @@ export const SshHostModal = ({ popUp, handlePopUpToggle }: Props) => { hostname, alias: trimmedAlias, userCertTtl, - loginMappings: loginMappings.map(({ loginUser, allowedPrincipals }) => ({ - loginUser, - allowedPrincipals: { - usernames: allowedPrincipals - } - })) + loginMappings: transformedLoginMappings }); } @@ -202,6 +225,15 @@ export const SshHostModal = ({ popUp, handlePopUpToggle }: Props) => { })); }; + const isPrincipalDuplicate = ( + mappingIndex: number, + principalType: string, + principalValue: string + ) => { + const principals = getValues(`loginMappings.${mappingIndex}.principals`) || []; + return principals.some((p) => p.type === principalType && p.value === principalValue); + }; + return ( { variant="outline_bg" onClick={() => { const newIndex = loginMappingsFormFields.fields.length; - loginMappingsFormFields.append({ loginUser: "", allowedPrincipals: [""] }); + loginMappingsFormFields.append({ loginUser: "", principals: [] }); setExpandedMappings((prev) => ({ ...prev, [newIndex]: true @@ -360,8 +392,11 @@ export const SshHostModal = ({ popUp, handlePopUpToggle }: Props) => { size="xs" variant="outline_bg" onClick={() => { - const current = getValues(`loginMappings.${i}.allowedPrincipals`) ?? []; - setValue(`loginMappings.${i}.allowedPrincipals`, [...current, ""]); + const current = getValues(`loginMappings.${i}.principals`) ?? []; + setValue(`loginMappings.${i}.principals`, [ + ...current, + { type: "user", value: "" } + ]); }} > Add Principal @@ -369,43 +404,72 @@ export const SshHostModal = ({ popUp, handlePopUpToggle }: Props) => { (
- {(value.length === 0 ? [""] : value).map( - (principal: string, principalIndex: number) => ( -
-
- -
+ {value.map((principal, principalIndex) => ( +
+
+ +
+
+ +
+
{
- ) - )} +
+ ))} {error && {error.message}}
)} diff --git a/frontend/src/pages/ssh/SshHostsPage/components/SshHostsTable.tsx b/frontend/src/pages/ssh/SshHostsPage/components/SshHostsTable.tsx index 6efe40b36..02b7f3d9c 100644 --- a/frontend/src/pages/ssh/SshHostsPage/components/SshHostsTable.tsx +++ b/frontend/src/pages/ssh/SshHostsPage/components/SshHostsTable.tsx @@ -3,7 +3,9 @@ import { faEllipsis, faPencil, faServer, - faTrash + faTrash, + faUser, + faUsers } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import FileSaver from "file-saver"; @@ -12,6 +14,7 @@ import { twMerge } from "tailwind-merge"; import { createNotification } from "@app/components/notifications"; import { ProjectPermissionCan } from "@app/components/permissions"; import { + Badge, DropdownMenu, DropdownMenuContent, DropdownMenuItem, @@ -91,13 +94,46 @@ export const SshHostsTable = ({ handlePopUpOpen }: Props) => { None ) : ( host.loginMappings.map(({ loginUser, allowedPrincipals }) => ( -
-
{loginUser}
- {allowedPrincipals.usernames.map((username) => ( -
- └─ {username} -
- ))} +
+
{loginUser}
+
+ {allowedPrincipals.usernames.map((username) => ( +
+
+ └─ +
+
+ + {username} + user +
+
+ ))} + {allowedPrincipals.groups.map((group) => ( +
+
+ └─ +
+
+ + {group} + group +
+
+ ))} +
)) )} From 7d0574087c389afc765059ad05d41a5e4f487d1b Mon Sep 17 00:00:00 2001 From: carlosmonastyrski Date: Fri, 2 May 2025 13:36:05 -0300 Subject: [PATCH 02/35] Add groups to ssh hosts allowed principals bot improvements --- backend/src/ee/services/group/group-dal.ts | 6 +---- .../ee/services/ssh-host/ssh-host-service.ts | 17 ++++++------ .../SshHostsPage/components/SshHostModal.tsx | 27 ++++++++++--------- 3 files changed, 24 insertions(+), 26 deletions(-) diff --git a/backend/src/ee/services/group/group-dal.ts b/backend/src/ee/services/group/group-dal.ts index 12a70a15e..294ee019b 100644 --- a/backend/src/ee/services/group/group-dal.ts +++ b/backend/src/ee/services/group/group-dal.ts @@ -160,11 +160,7 @@ export const groupDALFactory = (db: TDbClient) => { const findGroupsByProjectId = async (projectId: string, tx?: Knex) => { try { const docs = await (tx || db.replicaNode())(TableName.Groups) - .leftJoin( - TableName.GroupProjectMembership, - `${TableName.Groups}.id`, - `${TableName.GroupProjectMembership}.groupId` - ) + .join(TableName.GroupProjectMembership, `${TableName.Groups}.id`, `${TableName.GroupProjectMembership}.groupId`) .where(`${TableName.GroupProjectMembership}.projectId`, projectId) .select(selectAllTableCols(TableName.Groups)); return docs; diff --git a/backend/src/ee/services/ssh-host/ssh-host-service.ts b/backend/src/ee/services/ssh-host/ssh-host-service.ts index fa8a453ca..348865b3a 100644 --- a/backend/src/ee/services/ssh-host/ssh-host-service.ts +++ b/backend/src/ee/services/ssh-host/ssh-host-service.ts @@ -262,16 +262,15 @@ export const sshHostServiceFactory = ({ } if (allowedPrincipals.groups && allowedPrincipals.groups.length > 0) { - const groups = await groupDAL.findGroupsByProjectId(projectId); + const projectGroups = await groupDAL.findGroupsByProjectId(projectId); + const groups = projectGroups.filter((g) => allowedPrincipals.groups?.includes(g.slug)); - const foundGroupSlugs = new Set(groups.map((g) => g.slug)); - - for (const slug of allowedPrincipals.groups) { - if (!foundGroupSlugs.has(slug)) { - throw new BadRequestError({ - message: `Invalid group slug: ${slug}` - }); - } + if (groups.length !== allowedPrincipals.groups?.length) { + throw new BadRequestError({ + message: `Invalid group slugs: ${allowedPrincipals.groups + .filter((g) => !projectGroups.some((pg) => pg.slug === g)) + .join(", ")}` + }); } for await (const group of groups) { diff --git a/frontend/src/pages/ssh/SshHostsPage/components/SshHostModal.tsx b/frontend/src/pages/ssh/SshHostsPage/components/SshHostModal.tsx index 122234c92..63f837606 100644 --- a/frontend/src/pages/ssh/SshHostsPage/components/SshHostModal.tsx +++ b/frontend/src/pages/ssh/SshHostsPage/components/SshHostModal.tsx @@ -49,11 +49,11 @@ const schema = z loginMappings: z .object({ loginUser: z.string().trim().min(1), - principals: z + allowedPrincipals: z .array( z.object({ type: z.enum(["user", "group"]), - value: z.string().trim() + value: z.string().trim().min(1) }) ) .default([]) @@ -110,7 +110,7 @@ export const SshHostModal = ({ popUp, handlePopUpToggle }: Props) => { userCertTtl: sshHost.userCertTtl, loginMappings: sshHost.loginMappings.map(({ loginUser, allowedPrincipals }) => ({ loginUser, - principals: [ + allowedPrincipals: [ ...(allowedPrincipals.usernames || []).map((username) => ({ type: "user" as const, value: username @@ -169,10 +169,14 @@ export const SshHostModal = ({ popUp, handlePopUpToggle }: Props) => { } } - const transformedLoginMappings = loginMappings.map(({ loginUser, principals }) => { - const usernames = principals.filter((p) => p.type === "user").map((p) => p.value); + const transformedLoginMappings = loginMappings.map(({ loginUser, allowedPrincipals }) => { + const usernames = allowedPrincipals + .filter((p) => p.type === "user" && p.value) + .map((p) => p.value); - const groupNames = principals.filter((p) => p.type === "group").map((p) => p.value); + const groupNames = allowedPrincipals + .filter((p) => p.type === "group" && p.value) + .map((p) => p.value); return { loginUser, @@ -182,7 +186,6 @@ export const SshHostModal = ({ popUp, handlePopUpToggle }: Props) => { } }; }); - console.log(transformedLoginMappings); if (sshHost) { await updateMutateAsync({ @@ -230,7 +233,7 @@ export const SshHostModal = ({ popUp, handlePopUpToggle }: Props) => { principalType: string, principalValue: string ) => { - const principals = getValues(`loginMappings.${mappingIndex}.principals`) || []; + const principals = getValues(`loginMappings.${mappingIndex}.allowedPrincipals`) || []; return principals.some((p) => p.type === principalType && p.value === principalValue); }; @@ -297,7 +300,7 @@ export const SshHostModal = ({ popUp, handlePopUpToggle }: Props) => { variant="outline_bg" onClick={() => { const newIndex = loginMappingsFormFields.fields.length; - loginMappingsFormFields.append({ loginUser: "", principals: [] }); + loginMappingsFormFields.append({ loginUser: "", allowedPrincipals: [] }); setExpandedMappings((prev) => ({ ...prev, [newIndex]: true @@ -392,8 +395,8 @@ export const SshHostModal = ({ popUp, handlePopUpToggle }: Props) => { size="xs" variant="outline_bg" onClick={() => { - const current = getValues(`loginMappings.${i}.principals`) ?? []; - setValue(`loginMappings.${i}.principals`, [ + const current = getValues(`loginMappings.${i}.allowedPrincipals`) ?? []; + setValue(`loginMappings.${i}.allowedPrincipals`, [ ...current, { type: "user", value: "" } ]); @@ -404,7 +407,7 @@ export const SshHostModal = ({ popUp, handlePopUpToggle }: Props) => {
(
{value.map((principal, principalIndex) => ( From 3ea450e94a1fdb81dc302103017e136ac2233c09 Mon Sep 17 00:00:00 2001 From: carlosmonastyrski Date: Fri, 2 May 2025 13:41:53 -0300 Subject: [PATCH 03/35] Add groups to ssh hosts allowed principals fix delete principal row issue --- .../src/pages/ssh/SshHostsPage/components/SshHostModal.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/frontend/src/pages/ssh/SshHostsPage/components/SshHostModal.tsx b/frontend/src/pages/ssh/SshHostsPage/components/SshHostModal.tsx index 63f837606..570633ce1 100644 --- a/frontend/src/pages/ssh/SshHostsPage/components/SshHostModal.tsx +++ b/frontend/src/pages/ssh/SshHostsPage/components/SshHostModal.tsx @@ -412,7 +412,7 @@ export const SshHostModal = ({ popUp, handlePopUpToggle }: Props) => {
{value.map((principal, principalIndex) => (
@@ -482,7 +482,7 @@ export const SshHostModal = ({ popUp, handlePopUpToggle }: Props) => { const newPrincipals = value.filter( (_, idx) => idx !== principalIndex ); - onChange(newPrincipals); + onChange([...newPrincipals]); }} > From bf85df7e3606ad8bdfa240c641d60a376e4b599f Mon Sep 17 00:00:00 2001 From: carlosmonastyrski Date: Tue, 6 May 2025 08:37:19 -0300 Subject: [PATCH 04/35] Fix SSH table UI user groups issues --- .../components/SshHostGroupModal.tsx | 160 +++++++++--------- .../SshHostsPage/components/SshHostsTable.tsx | 3 +- 2 files changed, 81 insertions(+), 82 deletions(-) diff --git a/frontend/src/pages/ssh/SshHostsPage/components/SshHostGroupModal.tsx b/frontend/src/pages/ssh/SshHostsPage/components/SshHostGroupModal.tsx index 6cd99c796..081af4cbf 100644 --- a/frontend/src/pages/ssh/SshHostsPage/components/SshHostGroupModal.tsx +++ b/frontend/src/pages/ssh/SshHostsPage/components/SshHostGroupModal.tsx @@ -351,88 +351,86 @@ export const SshHostGroupModal = ({ popUp, handlePopUpToggle }: Props) => { name={`loginMappings.${i}.allowedPrincipals`} render={({ field: { value = [], onChange }, fieldState: { error } }) => (
- {(value.length === 0 ? [{ type: "user", value: "" }] : value).map( - (principal, principalIndex) => ( -
-
- -
-
- -
-
- { - const newPrincipals = value.filter( - (_, idx) => idx !== principalIndex - ); - onChange(newPrincipals.length ? newPrincipals : []); - }} - > - - -
+ {value.map((principal, principalIndex) => ( +
+
+
- ) - )} +
+ +
+
+ { + const newPrincipals = value.filter( + (_, idx) => idx !== principalIndex + ); + onChange(newPrincipals.length ? newPrincipals : []); + }} + > + + +
+
+ ))} {error && {error.message}}
)} diff --git a/frontend/src/pages/ssh/SshHostsPage/components/SshHostsTable.tsx b/frontend/src/pages/ssh/SshHostsPage/components/SshHostsTable.tsx index fd43697ec..e3c53860e 100644 --- a/frontend/src/pages/ssh/SshHostsPage/components/SshHostsTable.tsx +++ b/frontend/src/pages/ssh/SshHostsPage/components/SshHostsTable.tsx @@ -131,7 +131,8 @@ export const SshHostsTable = ({ handlePopUpOpen }: Props) => { const filteredGroups = allowedPrincipals.groups?.filter( (g) => !existing.groups?.has(g) ); - return (filteredGroups?.length ?? 0) > 0 + return ((filteredGroups?.length || filteredUsernames?.length) ?? 0) > + 0 ? { loginUser, source: LoginMappingSource.HOST_GROUP, From b908893a68da5f3a5b55195d78217a5b7b3ff59c Mon Sep 17 00:00:00 2001 From: Daniel Hougaard Date: Thu, 8 May 2025 07:49:23 +0400 Subject: [PATCH 05/35] feat(identities): ldap auth --- backend/src/@types/fastify.d.ts | 14 +- backend/src/@types/knex.d.ts | 10 + .../20250507003056_identity-ldap-auth.ts | 40 ++ backend/src/db/schemas/identity-ldap-auths.ts | 33 + backend/src/db/schemas/models.ts | 4 +- .../ee/services/audit-log/audit-log-types.ts | 71 ++ .../services/ldap-config/ldap-config-types.ts | 5 + .../src/ee/services/ldap-config/ldap-fns.ts | 4 +- backend/src/lib/api-docs/constants.ts | 1 + backend/src/lib/logger/logger.ts | 4 +- backend/src/server/routes/index.ts | 14 + .../routes/v1/identity-ldap-auth-router.ts | 465 +++++++++++++ backend/src/server/routes/v1/index.ts | 2 + .../identity-ldap-auth-dal.ts | 11 + .../identity-ldap-auth-service.ts | 547 +++++++++++++++ .../identity-ldap-auth-types.ts | 58 ++ backend/src/services/identity/identity-fns.ts | 7 +- .../src/services/identity/identity-org-dal.ts | 33 +- .../src/hooks/api/auditLogs/constants.tsx | 9 +- frontend/src/hooks/api/auditLogs/enums.tsx | 8 +- .../src/hooks/api/identities/constants.tsx | 1 + frontend/src/hooks/api/identities/enums.tsx | 1 + frontend/src/hooks/api/identities/index.tsx | 51 +- .../src/hooks/api/identities/mutations.tsx | 118 ++++ frontend/src/hooks/api/identities/queries.tsx | 22 + frontend/src/hooks/api/identities/types.ts | 69 ++ .../IdentityAuthMethodModalContent.tsx | 12 + .../IdentitySection/IdentityLdapAuthForm.tsx | 630 ++++++++++++++++++ .../AccessManagementPage/route.tsx | 2 +- .../ViewIdentityAuthModal.tsx | 7 + .../ViewIdentityLdapAuthContent.tsx | 106 +++ .../IdentityDetailsByIDPage/route.tsx | 2 +- 32 files changed, 2296 insertions(+), 65 deletions(-) create mode 100644 backend/src/db/migrations/20250507003056_identity-ldap-auth.ts create mode 100644 backend/src/db/schemas/identity-ldap-auths.ts create mode 100644 backend/src/server/routes/v1/identity-ldap-auth-router.ts create mode 100644 backend/src/services/identity-ldap-auth/identity-ldap-auth-dal.ts create mode 100644 backend/src/services/identity-ldap-auth/identity-ldap-auth-service.ts create mode 100644 backend/src/services/identity-ldap-auth/identity-ldap-auth-types.ts create mode 100644 frontend/src/pages/organization/AccessManagementPage/components/OrgIdentityTab/components/IdentitySection/IdentityLdapAuthForm.tsx create mode 100644 frontend/src/pages/organization/IdentityDetailsByIDPage/components/ViewIdentityAuthModal/ViewIdentityLdapAuthContent.tsx diff --git a/backend/src/@types/fastify.d.ts b/backend/src/@types/fastify.d.ts index 6ec542c6b..74571e2d0 100644 --- a/backend/src/@types/fastify.d.ts +++ b/backend/src/@types/fastify.d.ts @@ -66,6 +66,8 @@ import { TIdentityAzureAuthServiceFactory } from "@app/services/identity-azure-a import { TIdentityGcpAuthServiceFactory } from "@app/services/identity-gcp-auth/identity-gcp-auth-service"; import { TIdentityJwtAuthServiceFactory } from "@app/services/identity-jwt-auth/identity-jwt-auth-service"; import { TIdentityKubernetesAuthServiceFactory } from "@app/services/identity-kubernetes-auth/identity-kubernetes-auth-service"; +import { TIdentityLdapAuthServiceFactory } from "@app/services/identity-ldap-auth/identity-ldap-auth-service"; +import { TAllowedFields } from "@app/services/identity-ldap-auth/identity-ldap-auth-types"; import { TIdentityOidcAuthServiceFactory } from "@app/services/identity-oidc-auth/identity-oidc-auth-service"; import { TIdentityProjectServiceFactory } from "@app/services/identity-project/identity-project-service"; import { TIdentityTokenAuthServiceFactory } from "@app/services/identity-token-auth/identity-token-auth-service"; @@ -146,6 +148,13 @@ declare module "fastify" { providerAuthToken: string; externalProviderAccessToken?: string; }; + passportMachineIdentity: { + identityId: string; + user: { + uid: string; + mail: string; + }; + }; kmipUser: { projectId: string; clientId: string; @@ -153,7 +162,9 @@ declare module "fastify" { }; auditLogInfo: Pick; ssoConfig: Awaited>; - ldapConfig: Awaited>; + ldapConfig: Awaited> & { + allowedFields?: TAllowedFields[]; + }; } interface FastifyInstance { @@ -199,6 +210,7 @@ declare module "fastify" { identityAzureAuth: TIdentityAzureAuthServiceFactory; identityOidcAuth: TIdentityOidcAuthServiceFactory; identityJwtAuth: TIdentityJwtAuthServiceFactory; + identityLdapAuth: TIdentityLdapAuthServiceFactory; accessApprovalPolicy: TAccessApprovalPolicyServiceFactory; accessApprovalRequest: TAccessApprovalRequestServiceFactory; secretApprovalPolicy: TSecretApprovalPolicyServiceFactory; diff --git a/backend/src/@types/knex.d.ts b/backend/src/@types/knex.d.ts index 13f3bc306..c26f1128e 100644 --- a/backend/src/@types/knex.d.ts +++ b/backend/src/@types/knex.d.ts @@ -432,6 +432,11 @@ import { TWorkflowIntegrationsInsert, TWorkflowIntegrationsUpdate } from "@app/db/schemas"; +import { + TIdentityLdapAuths, + TIdentityLdapAuthsInsert, + TIdentityLdapAuthsUpdate +} from "@app/db/schemas/identity-ldap-auths"; import { TMicrosoftTeamsIntegrations, TMicrosoftTeamsIntegrationsInsert, @@ -735,6 +740,11 @@ declare module "knex/types/tables" { TIdentityJwtAuthsInsert, TIdentityJwtAuthsUpdate >; + [TableName.IdentityLdapAuth]: KnexOriginal.CompositeTableType< + TIdentityLdapAuths, + TIdentityLdapAuthsInsert, + TIdentityLdapAuthsUpdate + >; [TableName.IdentityUaClientSecret]: KnexOriginal.CompositeTableType< TIdentityUaClientSecrets, TIdentityUaClientSecretsInsert, diff --git a/backend/src/db/migrations/20250507003056_identity-ldap-auth.ts b/backend/src/db/migrations/20250507003056_identity-ldap-auth.ts new file mode 100644 index 000000000..db7ba5281 --- /dev/null +++ b/backend/src/db/migrations/20250507003056_identity-ldap-auth.ts @@ -0,0 +1,40 @@ +import { Knex } from "knex"; + +import { TableName } from "../schemas"; +import { createOnUpdateTrigger, dropOnUpdateTrigger } from "../utils"; + +export async function up(knex: Knex): Promise { + if (!(await knex.schema.hasTable(TableName.IdentityLdapAuth))) { + await knex.schema.createTable(TableName.IdentityLdapAuth, (t) => { + t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid()); + + t.bigInteger("accessTokenTTL").defaultTo(7200).notNullable(); + t.bigInteger("accessTokenMaxTTL").defaultTo(7200).notNullable(); + t.bigInteger("accessTokenNumUsesLimit").defaultTo(0).notNullable(); + t.jsonb("accessTokenTrustedIps").notNullable(); + + t.uuid("identityId").notNullable().unique(); + t.foreign("identityId").references("id").inTable(TableName.Identity).onDelete("CASCADE"); + + t.binary("encryptedBindDN").notNullable(); + t.binary("encryptedBindPass").notNullable(); + t.binary("encryptedLdapCaCertificate").nullable(); + + t.string("url").notNullable(); + t.string("searchBase").notNullable(); + t.string("searchFilter").notNullable(); + t.string("uniqueAttribute").notNullable(); + + t.jsonb("allowedFields").nullable(); + + t.timestamps(true, true, true); + }); + } + + await createOnUpdateTrigger(knex, TableName.IdentityLdapAuth); +} + +export async function down(knex: Knex): Promise { + await knex.schema.dropTableIfExists(TableName.IdentityLdapAuth); + await dropOnUpdateTrigger(knex, TableName.IdentityLdapAuth); +} diff --git a/backend/src/db/schemas/identity-ldap-auths.ts b/backend/src/db/schemas/identity-ldap-auths.ts new file mode 100644 index 000000000..e843648bc --- /dev/null +++ b/backend/src/db/schemas/identity-ldap-auths.ts @@ -0,0 +1,33 @@ +// Code generated by automation script, DO NOT EDIT. +// Automated by pulling database and generating zod schema +// To update. Just run npm run generate:schema +// Written by akhilmhdh. + +import { z } from "zod"; + +import { zodBuffer } from "@app/lib/zod"; + +import { TImmutableDBKeys } from "./models"; + +export const IdentityLdapAuthsSchema = z.object({ + id: z.string().uuid(), + accessTokenTTL: z.coerce.number().default(7200), + accessTokenMaxTTL: z.coerce.number().default(7200), + accessTokenNumUsesLimit: z.coerce.number().default(0), + accessTokenTrustedIps: z.unknown(), + identityId: z.string().uuid(), + encryptedBindDN: zodBuffer, + encryptedBindPass: zodBuffer, + encryptedLdapCaCertificate: zodBuffer.nullable().optional(), + url: z.string(), + searchBase: z.string(), + searchFilter: z.string(), + uniqueAttribute: z.string(), + allowedFields: z.unknown().nullable().optional(), + createdAt: z.date(), + updatedAt: z.date() +}); + +export type TIdentityLdapAuths = z.infer; +export type TIdentityLdapAuthsInsert = Omit, TImmutableDBKeys>; +export type TIdentityLdapAuthsUpdate = Partial, TImmutableDBKeys>>; diff --git a/backend/src/db/schemas/models.ts b/backend/src/db/schemas/models.ts index 7fd77da6c..b7580a110 100644 --- a/backend/src/db/schemas/models.ts +++ b/backend/src/db/schemas/models.ts @@ -80,6 +80,7 @@ export enum TableName { IdentityAwsAuth = "identity_aws_auths", IdentityOidcAuth = "identity_oidc_auths", IdentityJwtAuth = "identity_jwt_auths", + IdentityLdapAuth = "identity_ldap_auths", IdentityOrgMembership = "identity_org_memberships", IdentityProjectMembership = "identity_project_memberships", IdentityProjectMembershipRole = "identity_project_membership_role", @@ -227,7 +228,8 @@ export enum IdentityAuthMethod { AWS_AUTH = "aws-auth", AZURE_AUTH = "azure-auth", OIDC_AUTH = "oidc-auth", - JWT_AUTH = "jwt-auth" + JWT_AUTH = "jwt-auth", + LDAP_AUTH = "ldap-auth" } export enum ProjectType { diff --git a/backend/src/ee/services/audit-log/audit-log-types.ts b/backend/src/ee/services/audit-log/audit-log-types.ts index d7cad74be..92f4d54a7 100644 --- a/backend/src/ee/services/audit-log/audit-log-types.ts +++ b/backend/src/ee/services/audit-log/audit-log-types.ts @@ -34,6 +34,7 @@ import { WorkflowIntegration } from "@app/services/workflow-integration/workflow import { KmipPermission } from "../kmip/kmip-enum"; import { ApprovalStatus } from "../secret-approval-request/secret-approval-request-types"; +import { TAllowedFields } from "@app/services/identity-ldap-auth/identity-ldap-auth-types"; export type TListProjectAuditLogDTO = { filter: { @@ -119,44 +120,60 @@ export enum EventType { CREATE_TOKEN_IDENTITY_TOKEN_AUTH = "create-token-identity-token-auth", UPDATE_TOKEN_IDENTITY_TOKEN_AUTH = "update-token-identity-token-auth", GET_TOKENS_IDENTITY_TOKEN_AUTH = "get-tokens-identity-token-auth", + ADD_IDENTITY_TOKEN_AUTH = "add-identity-token-auth", UPDATE_IDENTITY_TOKEN_AUTH = "update-identity-token-auth", GET_IDENTITY_TOKEN_AUTH = "get-identity-token-auth", REVOKE_IDENTITY_TOKEN_AUTH = "revoke-identity-token-auth", + LOGIN_IDENTITY_KUBERNETES_AUTH = "login-identity-kubernetes-auth", ADD_IDENTITY_KUBERNETES_AUTH = "add-identity-kubernetes-auth", UPDATE_IDENTITY_KUBENETES_AUTH = "update-identity-kubernetes-auth", GET_IDENTITY_KUBERNETES_AUTH = "get-identity-kubernetes-auth", REVOKE_IDENTITY_KUBERNETES_AUTH = "revoke-identity-kubernetes-auth", + LOGIN_IDENTITY_OIDC_AUTH = "login-identity-oidc-auth", ADD_IDENTITY_OIDC_AUTH = "add-identity-oidc-auth", UPDATE_IDENTITY_OIDC_AUTH = "update-identity-oidc-auth", GET_IDENTITY_OIDC_AUTH = "get-identity-oidc-auth", REVOKE_IDENTITY_OIDC_AUTH = "revoke-identity-oidc-auth", + LOGIN_IDENTITY_JWT_AUTH = "login-identity-jwt-auth", ADD_IDENTITY_JWT_AUTH = "add-identity-jwt-auth", UPDATE_IDENTITY_JWT_AUTH = "update-identity-jwt-auth", GET_IDENTITY_JWT_AUTH = "get-identity-jwt-auth", REVOKE_IDENTITY_JWT_AUTH = "revoke-identity-jwt-auth", + CREATE_IDENTITY_UNIVERSAL_AUTH_CLIENT_SECRET = "create-identity-universal-auth-client-secret", REVOKE_IDENTITY_UNIVERSAL_AUTH_CLIENT_SECRET = "revoke-identity-universal-auth-client-secret", + GET_IDENTITY_UNIVERSAL_AUTH_CLIENT_SECRETS = "get-identity-universal-auth-client-secret", GET_IDENTITY_UNIVERSAL_AUTH_CLIENT_SECRET_BY_ID = "get-identity-universal-auth-client-secret-by-id", + LOGIN_IDENTITY_GCP_AUTH = "login-identity-gcp-auth", ADD_IDENTITY_GCP_AUTH = "add-identity-gcp-auth", UPDATE_IDENTITY_GCP_AUTH = "update-identity-gcp-auth", REVOKE_IDENTITY_GCP_AUTH = "revoke-identity-gcp-auth", GET_IDENTITY_GCP_AUTH = "get-identity-gcp-auth", + LOGIN_IDENTITY_AWS_AUTH = "login-identity-aws-auth", ADD_IDENTITY_AWS_AUTH = "add-identity-aws-auth", UPDATE_IDENTITY_AWS_AUTH = "update-identity-aws-auth", REVOKE_IDENTITY_AWS_AUTH = "revoke-identity-aws-auth", GET_IDENTITY_AWS_AUTH = "get-identity-aws-auth", + LOGIN_IDENTITY_AZURE_AUTH = "login-identity-azure-auth", ADD_IDENTITY_AZURE_AUTH = "add-identity-azure-auth", UPDATE_IDENTITY_AZURE_AUTH = "update-identity-azure-auth", GET_IDENTITY_AZURE_AUTH = "get-identity-azure-auth", REVOKE_IDENTITY_AZURE_AUTH = "revoke-identity-azure-auth", + + LOGIN_IDENTITY_LDAP_AUTH = "login-identity-ldap-auth", + ADD_IDENTITY_LDAP_AUTH = "add-identity-ldap-auth", + UPDATE_IDENTITY_LDAP_AUTH = "update-identity-ldap-auth", + GET_IDENTITY_LDAP_AUTH = "get-identity-ldap-auth", + REVOKE_IDENTITY_LDAP_AUTH = "revoke-identity-ldap-auth", + CREATE_ENVIRONMENT = "create-environment", UPDATE_ENVIRONMENT = "update-environment", DELETE_ENVIRONMENT = "delete-environment", @@ -1034,6 +1051,55 @@ interface GetIdentityAzureAuthEvent { }; } +interface LoginIdentityLdapAuthEvent { + type: EventType.LOGIN_IDENTITY_LDAP_AUTH; + metadata: { + identityId: string; + ldapUsername: string; + ldapEmail: string; + }; +} + +interface AddIdentityLdapAuthEvent { + type: EventType.ADD_IDENTITY_LDAP_AUTH; + metadata: { + identityId: string; + accessTokenTTL?: number; + accessTokenMaxTTL?: number; + accessTokenNumUsesLimit?: number; + accessTokenTrustedIps?: Array; + allowedFields?: TAllowedFields[]; + url: string; + }; +} + +interface UpdateIdentityLdapAuthEvent { + type: EventType.UPDATE_IDENTITY_LDAP_AUTH; + metadata: { + identityId: string; + accessTokenTTL?: number; + accessTokenMaxTTL?: number; + accessTokenNumUsesLimit?: number; + accessTokenTrustedIps?: Array; + allowedFields?: TAllowedFields[]; + url?: string; + }; +} + +interface GetIdentityLdapAuthEvent { + type: EventType.GET_IDENTITY_LDAP_AUTH; + metadata: { + identityId: string; + }; +} + +interface RevokeIdentityLdapAuthEvent { + type: EventType.REVOKE_IDENTITY_LDAP_AUTH; + metadata: { + identityId: string; + }; +} + interface LoginIdentityOidcAuthEvent { type: EventType.LOGIN_IDENTITY_OIDC_AUTH; metadata: { @@ -2785,6 +2851,11 @@ export type Event = | UpdateIdentityJwtAuthEvent | GetIdentityJwtAuthEvent | DeleteIdentityJwtAuthEvent + | LoginIdentityLdapAuthEvent + | AddIdentityLdapAuthEvent + | UpdateIdentityLdapAuthEvent + | GetIdentityLdapAuthEvent + | RevokeIdentityLdapAuthEvent | CreateEnvironmentEvent | GetEnvironmentEvent | UpdateEnvironmentEvent diff --git a/backend/src/ee/services/ldap-config/ldap-config-types.ts b/backend/src/ee/services/ldap-config/ldap-config-types.ts index 86f4bf0d5..941335fa4 100644 --- a/backend/src/ee/services/ldap-config/ldap-config-types.ts +++ b/backend/src/ee/services/ldap-config/ldap-config-types.ts @@ -14,6 +14,11 @@ export type TLDAPConfig = { caCert: string; }; +export type TTestLDAPConfigDTO = Omit< + TLDAPConfig, + "organization" | "id" | "groupSearchBase" | "groupSearchFilter" | "isActive" | "uniqueUserAttribute" | "searchBase" +>; + export type TCreateLdapCfgDTO = { orgId: string; isActive: boolean; diff --git a/backend/src/ee/services/ldap-config/ldap-fns.ts b/backend/src/ee/services/ldap-config/ldap-fns.ts index 44af718ed..ab23bdb45 100644 --- a/backend/src/ee/services/ldap-config/ldap-fns.ts +++ b/backend/src/ee/services/ldap-config/ldap-fns.ts @@ -2,7 +2,7 @@ import ldapjs from "ldapjs"; import { logger } from "@app/lib/logger"; -import { TLDAPConfig } from "./ldap-config-types"; +import { TLDAPConfig, TTestLDAPConfigDTO } from "./ldap-config-types"; export const isValidLdapFilter = (filter: string) => { try { @@ -20,7 +20,7 @@ export const isValidLdapFilter = (filter: string) => { * @param ldapConfig - The LDAP configuration to test * @returns {Boolean} isConnected - Whether or not the connection was successful */ -export const testLDAPConfig = async (ldapConfig: TLDAPConfig): Promise => { +export const testLDAPConfig = async (ldapConfig: TTestLDAPConfigDTO): Promise => { return new Promise((resolve) => { const ldapClient = ldapjs.createClient({ url: ldapConfig.url, diff --git a/backend/src/lib/api-docs/constants.ts b/backend/src/lib/api-docs/constants.ts index ae6bbbcab..62ba2c974 100644 --- a/backend/src/lib/api-docs/constants.ts +++ b/backend/src/lib/api-docs/constants.ts @@ -18,6 +18,7 @@ export enum ApiDocsTags { KubernetesAuth = "Kubernetes Auth", JwtAuth = "JWT Auth", OidcAuth = "OIDC Auth", + LdapAuth = "LDAP Auth", Groups = "Groups", Organizations = "Organizations", Projects = "Projects", diff --git a/backend/src/lib/logger/logger.ts b/backend/src/lib/logger/logger.ts index 170a0285f..afde8ef97 100644 --- a/backend/src/lib/logger/logger.ts +++ b/backend/src/lib/logger/logger.ts @@ -84,7 +84,9 @@ const redactedKeys = [ "secrets", "key", "password", - "config" + "config", + "bindPass", + "bindDN" ]; const UNKNOWN_REQUEST_ID = "UNKNOWN_REQUEST_ID"; diff --git a/backend/src/server/routes/index.ts b/backend/src/server/routes/index.ts index 03e23a69d..87b043451 100644 --- a/backend/src/server/routes/index.ts +++ b/backend/src/server/routes/index.ts @@ -160,6 +160,8 @@ import { identityJwtAuthDALFactory } from "@app/services/identity-jwt-auth/ident import { identityJwtAuthServiceFactory } from "@app/services/identity-jwt-auth/identity-jwt-auth-service"; import { identityKubernetesAuthDALFactory } from "@app/services/identity-kubernetes-auth/identity-kubernetes-auth-dal"; import { identityKubernetesAuthServiceFactory } from "@app/services/identity-kubernetes-auth/identity-kubernetes-auth-service"; +import { identityLdapAuthDALFactory } from "@app/services/identity-ldap-auth/identity-ldap-auth-dal"; +import { identityLdapAuthServiceFactory } from "@app/services/identity-ldap-auth/identity-ldap-auth-service"; import { identityOidcAuthDALFactory } from "@app/services/identity-oidc-auth/identity-oidc-auth-dal"; import { identityOidcAuthServiceFactory } from "@app/services/identity-oidc-auth/identity-oidc-auth-service"; import { identityProjectDALFactory } from "@app/services/identity-project/identity-project-dal"; @@ -353,6 +355,7 @@ export const registerRoutes = async ( const identityOidcAuthDAL = identityOidcAuthDALFactory(db); const identityJwtAuthDAL = identityJwtAuthDALFactory(db); const identityAzureAuthDAL = identityAzureAuthDALFactory(db); + const identityLdapAuthDAL = identityLdapAuthDALFactory(db); const auditLogDAL = auditLogDALFactory(auditLogDb ?? db); const auditLogStreamDAL = auditLogStreamDALFactory(db); @@ -1438,6 +1441,16 @@ export const registerRoutes = async ( kmsService }); + const identityLdapAuthService = identityLdapAuthServiceFactory({ + identityLdapAuthDAL, + permissionService, + kmsService, + identityAccessTokenDAL, + identityOrgMembershipDAL, + licenseService, + identityDAL + }); + const gatewayService = gatewayServiceFactory({ permissionService, gatewayDAL, @@ -1698,6 +1711,7 @@ export const registerRoutes = async ( identityAzureAuth: identityAzureAuthService, identityOidcAuth: identityOidcAuthService, identityJwtAuth: identityJwtAuthService, + identityLdapAuth: identityLdapAuthService, accessApprovalPolicy: accessApprovalPolicyService, accessApprovalRequest: accessApprovalRequestService, secretApprovalPolicy: secretApprovalPolicyService, diff --git a/backend/src/server/routes/v1/identity-ldap-auth-router.ts b/backend/src/server/routes/v1/identity-ldap-auth-router.ts new file mode 100644 index 000000000..bfc195f0a --- /dev/null +++ b/backend/src/server/routes/v1/identity-ldap-auth-router.ts @@ -0,0 +1,465 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +/* eslint-disable @typescript-eslint/no-unsafe-return */ +/* eslint-disable @typescript-eslint/no-unsafe-member-access */ +/* eslint-disable @typescript-eslint/no-unsafe-assignment */ +/* eslint-disable @typescript-eslint/no-unsafe-call */ +/* eslint-disable @typescript-eslint/no-unsafe-argument */ +// All the any rules are disabled because passport typesense with fastify is really poor + +import { Authenticator } from "@fastify/passport"; +import fastifySession from "@fastify/session"; +import { FastifyRequest } from "fastify"; +import { IncomingMessage } from "http"; +import LdapStrategy from "passport-ldapauth"; +import { z } from "zod"; + +import { IdentityLdapAuthsSchema } from "@app/db/schemas/identity-ldap-auths"; +import { EventType } from "@app/ee/services/audit-log/audit-log-types"; +import { ApiDocsTags } from "@app/lib/api-docs"; +import { getConfig } from "@app/lib/config/env"; +import { UnauthorizedError } from "@app/lib/errors"; +import { logger } from "@app/lib/logger"; +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"; +import { TIdentityTrustedIp } from "@app/services/identity/identity-types"; +import { AllowedFieldsSchema } from "@app/services/identity-ldap-auth/identity-ldap-auth-types"; +import { isSuperAdmin } from "@app/services/super-admin/super-admin-fns"; + +export const registerIdentityLdapAuthRouter = async (server: FastifyZodProvider) => { + const appCfg = getConfig(); + const passport = new Authenticator({ key: "ldap-identity-auth", userProperty: "passportMachineIdentity" }); + await server.register(fastifySession, { secret: appCfg.COOKIE_SECRET_SIGN_KEY }); + await server.register(passport.initialize()); + await server.register(passport.secureSession()); + + const getLdapPassportOpts = (req: FastifyRequest, done: any) => { + const { identityId } = req.body as { + identityId: string; + }; + + process.nextTick(async () => { + try { + const { ldapConfig, opts } = await server.services.identityLdapAuth.getLdapConfig(identityId); + req.ldapConfig = { + ...ldapConfig, + isActive: true, + groupSearchBase: "", + groupSearchFilter: "" + }; + + done(null, opts); + } catch (err) { + logger.error(err, "Error in LDAP verification callback"); + done(err); + } + }); + }; + + passport.use( + new LdapStrategy( + getLdapPassportOpts as any, + // eslint-disable-next-line + async (req: IncomingMessage, user, cb) => { + try { + const requestBody = (req as unknown as FastifyRequest).body as { + username: string; + password: string; + identityId: string; + }; + + if (!requestBody.username || !requestBody.password) { + return cb(new UnauthorizedError({ message: "Invalid request. Missing username or password." }), false); + } + + if (!requestBody.identityId) { + return cb(new UnauthorizedError({ message: "Invalid request. Missing identity ID." }), false); + } + + const { ldapConfig } = req as unknown as FastifyRequest; + + if (ldapConfig.allowedFields) { + for (const field of ldapConfig.allowedFields) { + if (!user[field.key]) { + return cb( + new UnauthorizedError({ message: `Invalid request. Missing field ${field.key} on user.` }), + false + ); + } + + const value = field.value.split(","); + + if (!value.includes(user[field.key])) { + return cb( + new UnauthorizedError({ + message: `Invalid request. User field '${field.key}' does not match required fields.` + }), + false + ); + } + } + } + + return cb(null, { identityId: requestBody.identityId, user }); + } catch (error) { + logger.error(error, "Error in LDAP verification callback"); + return cb(error, false); + } + } + ) + ); + + server.route({ + method: "POST", + url: "/ldap-auth/login", + config: { + rateLimit: writeLimit + }, + schema: { + hide: false, + tags: [ApiDocsTags.LdapAuth], + description: "Login with LDAP Auth", + body: z.object({ + identityId: z.string().trim(), + username: z.string(), + password: z.string() + }), + response: { + 200: z.object({ + accessToken: z.string(), + expiresIn: z.coerce.number(), + accessTokenMaxTTL: z.coerce.number(), + tokenType: z.literal("Bearer") + }) + } + }, + preValidation: passport.authenticate("ldapauth", { + failWithError: true, + session: false + }) as any, + + errorHandler: (error) => { + if (error.name === "AuthenticationError") { + throw new UnauthorizedError({ message: "Invalid credentials" }); + } + + throw error; + }, + + handler: async (req) => { + if ( + !req.passportMachineIdentity?.identityId || + !req.passportMachineIdentity.user.mail || + !req.passportMachineIdentity.user.uid + ) { + throw new UnauthorizedError({ message: "Invalid request. Missing identity ID or LDAP entry details." }); + } + + const { identityId, user } = req.passportMachineIdentity; + + const { accessToken, identityLdapAuth, identityMembershipOrg } = await server.services.identityLdapAuth.login({ + identityId + }); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + orgId: identityMembershipOrg?.orgId, + event: { + type: EventType.LOGIN_IDENTITY_LDAP_AUTH, + metadata: { + identityId, + ldapEmail: user.mail, + ldapUsername: user.uid + } + } + }); + + return { + accessToken, + tokenType: "Bearer" as const, + expiresIn: identityLdapAuth.accessTokenTTL, + accessTokenMaxTTL: identityLdapAuth.accessTokenMaxTTL + }; + } + }); + + server.route({ + method: "POST", + url: "/ldap-auth/identities/:identityId", + config: { + rateLimit: writeLimit + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + schema: { + hide: false, + tags: [ApiDocsTags.LdapAuth], + description: "Attach LDAP Auth configuration onto identity", + security: [ + { + bearerAuth: [] + } + ], + params: z.object({ + identityId: z.string().trim() + }), + body: z + .object({ + url: z.string().trim().min(1), + bindDN: z.string().trim().min(1), + bindPass: z.string().trim().min(1), + searchBase: z.string().trim().min(1), + uniqueAttribute: z.string().trim().min(1).default("uidNumber"), + searchFilter: z.string().trim().min(1).default("(uid={{username}})"), + allowedFields: AllowedFieldsSchema.array().optional(), + ldapCaCertificate: z.string().trim().optional(), + + accessTokenTrustedIps: z + .object({ + ipAddress: z.string().trim() + }) + .array() + .min(1) + .default([{ ipAddress: "0.0.0.0/0" }, { ipAddress: "::/0" }]), + accessTokenTTL: z.number().int().min(0).max(315360000).default(2592000), + accessTokenMaxTTL: z.number().int().min(1).max(315360000).default(2592000), + accessTokenNumUsesLimit: z.number().int().min(0).default(0) + }) + .refine( + (val) => val.accessTokenTTL <= val.accessTokenMaxTTL, + "Access Token TTL cannot be greater than Access Token Max TTL." + ), + response: { + 200: z.object({ + identityLdapAuth: IdentityLdapAuthsSchema.omit({ + encryptedBindDN: true, + encryptedBindPass: true, + encryptedLdapCaCertificate: true + }) + }) + } + }, + handler: async (req) => { + const identityLdapAuth = await server.services.identityLdapAuth.attachLdapAuth({ + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + ...req.body, + identityId: req.params.identityId, + isActorSuperAdmin: isSuperAdmin(req.auth) + }); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + orgId: req.permission.orgId, + event: { + type: EventType.ADD_IDENTITY_LDAP_AUTH, + metadata: { + identityId: req.params.identityId, + url: identityLdapAuth.url, + accessTokenMaxTTL: identityLdapAuth.accessTokenMaxTTL, + accessTokenTTL: identityLdapAuth.accessTokenTTL, + accessTokenNumUsesLimit: identityLdapAuth.accessTokenNumUsesLimit, + allowedFields: req.body.allowedFields + } + } + }); + + return { identityLdapAuth }; + } + }); + + server.route({ + method: "PATCH", + url: "/ldap-auth/identities/:identityId", + config: { + rateLimit: writeLimit + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + schema: { + hide: false, + tags: [ApiDocsTags.LdapAuth], + description: "Update LDAP Auth configuration on identity", + security: [ + { + bearerAuth: [] + } + ], + params: z.object({ + identityId: z.string() + }), + body: z + .object({ + url: z.string().trim().min(1), + bindDN: z.string().trim().min(1), + bindPass: z.string().trim().min(1), + searchBase: z.string().trim().min(1), + uniqueAttribute: z.string().trim().min(1).default("uidNumber"), + searchFilter: z.string().trim().min(1).default("(uid={{username}})"), + allowedFields: AllowedFieldsSchema.array().optional(), + accessTokenTrustedIps: z + .object({ + ipAddress: z.string().trim() + }) + .array() + .min(1) + .optional(), + accessTokenTTL: z.number().int().min(0).max(315360000).optional(), + accessTokenNumUsesLimit: z.number().int().min(0).optional(), + accessTokenMaxTTL: z.number().int().max(315360000).min(0).optional() + }) + .refine( + (val) => (val.accessTokenMaxTTL && val.accessTokenTTL ? val.accessTokenTTL <= val.accessTokenMaxTTL : true), + "Access Token TTL cannot be greater than Access Token Max TTL." + ), + response: { + 200: z.object({ + identityLdapAuth: IdentityLdapAuthsSchema.omit({ + encryptedBindDN: true, + encryptedBindPass: true, + encryptedLdapCaCertificate: true + }) + }) + } + }, + handler: async (req) => { + const identityLdapAuth = await server.services.identityLdapAuth.updateLdapAuth({ + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + ...req.body, + identityId: req.params.identityId + }); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + orgId: req.permission.orgId, + event: { + type: EventType.UPDATE_IDENTITY_LDAP_AUTH, + metadata: { + identityId: req.params.identityId, + url: identityLdapAuth.url, + accessTokenMaxTTL: identityLdapAuth.accessTokenMaxTTL, + accessTokenTTL: identityLdapAuth.accessTokenTTL, + accessTokenNumUsesLimit: identityLdapAuth.accessTokenNumUsesLimit, + accessTokenTrustedIps: identityLdapAuth.accessTokenTrustedIps as TIdentityTrustedIp[], + allowedFields: req.body.allowedFields + } + } + }); + + return { identityLdapAuth }; + } + }); + + server.route({ + method: "GET", + url: "/ldap-auth/identities/:identityId", + config: { + rateLimit: readLimit + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + schema: { + hide: false, + tags: [ApiDocsTags.LdapAuth], + description: "Retrieve LDAP Auth configuration on identity", + security: [ + { + bearerAuth: [] + } + ], + params: z.object({ + identityId: z.string() + }), + response: { + 200: z.object({ + identityLdapAuth: IdentityLdapAuthsSchema.omit({ + encryptedBindDN: true, + encryptedBindPass: true, + encryptedLdapCaCertificate: true + }).extend({ + bindDN: z.string(), + bindPass: z.string(), + ldapCaCertificate: z.string().optional() + }) + }) + } + }, + handler: async (req) => { + const identityLdapAuth = await server.services.identityLdapAuth.getLdapAuth({ + identityId: req.params.identityId, + actor: req.permission.type, + actorId: req.permission.id, + actorOrgId: req.permission.orgId, + actorAuthMethod: req.permission.authMethod + }); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + orgId: req.permission.orgId, + event: { + type: EventType.GET_IDENTITY_LDAP_AUTH, + metadata: { + identityId: identityLdapAuth.identityId + } + } + }); + + return { identityLdapAuth }; + } + }); + + server.route({ + method: "DELETE", + url: "/ldap-auth/identities/:identityId", + config: { + rateLimit: writeLimit + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + schema: { + hide: false, + tags: [ApiDocsTags.LdapAuth], + description: "Delete LDAP Auth configuration on identity", + security: [ + { + bearerAuth: [] + } + ], + params: z.object({ + identityId: z.string() + }), + response: { + 200: z.object({ + identityLdapAuth: IdentityLdapAuthsSchema.omit({ + encryptedBindDN: true, + encryptedBindPass: true, + encryptedLdapCaCertificate: true + }) + }) + } + }, + handler: async (req) => { + const identityLdapAuth = await server.services.identityLdapAuth.revokeIdentityLdapAuth({ + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + identityId: req.params.identityId + }); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + orgId: req.permission.orgId, + event: { + type: EventType.REVOKE_IDENTITY_LDAP_AUTH, + metadata: { + identityId: identityLdapAuth.identityId + } + } + }); + + return { identityLdapAuth }; + } + }); +}; diff --git a/backend/src/server/routes/v1/index.ts b/backend/src/server/routes/v1/index.ts index a50299555..b1a49f815 100644 --- a/backend/src/server/routes/v1/index.ts +++ b/backend/src/server/routes/v1/index.ts @@ -19,6 +19,7 @@ import { registerIdentityAzureAuthRouter } from "./identity-azure-auth-router"; import { registerIdentityGcpAuthRouter } from "./identity-gcp-auth-router"; import { registerIdentityJwtAuthRouter } from "./identity-jwt-auth-router"; import { registerIdentityKubernetesRouter } from "./identity-kubernetes-auth-router"; +import { registerIdentityLdapAuthRouter } from "./identity-ldap-auth-router"; import { registerIdentityOidcAuthRouter } from "./identity-oidc-auth-router"; import { registerIdentityRouter } from "./identity-router"; import { registerIdentityTokenAuthRouter } from "./identity-token-auth-router"; @@ -63,6 +64,7 @@ export const registerV1Routes = async (server: FastifyZodProvider) => { await authRouter.register(registerIdentityAzureAuthRouter); await authRouter.register(registerIdentityOidcAuthRouter); await authRouter.register(registerIdentityJwtAuthRouter); + await authRouter.register(registerIdentityLdapAuthRouter); }, { prefix: "/auth" } ); diff --git a/backend/src/services/identity-ldap-auth/identity-ldap-auth-dal.ts b/backend/src/services/identity-ldap-auth/identity-ldap-auth-dal.ts new file mode 100644 index 000000000..0d998dbe9 --- /dev/null +++ b/backend/src/services/identity-ldap-auth/identity-ldap-auth-dal.ts @@ -0,0 +1,11 @@ +import { TDbClient } from "@app/db"; +import { TableName } from "@app/db/schemas"; +import { ormify } from "@app/lib/knex"; + +export type TIdentityLdapAuthDALFactory = ReturnType; + +export const identityLdapAuthDALFactory = (db: TDbClient) => { + const ldapAuthOrm = ormify(db, TableName.IdentityLdapAuth); + + return ldapAuthOrm; +}; diff --git a/backend/src/services/identity-ldap-auth/identity-ldap-auth-service.ts b/backend/src/services/identity-ldap-auth/identity-ldap-auth-service.ts new file mode 100644 index 000000000..6b55d1917 --- /dev/null +++ b/backend/src/services/identity-ldap-auth/identity-ldap-auth-service.ts @@ -0,0 +1,547 @@ +/* eslint-disable @typescript-eslint/no-unsafe-assignment */ +import { ForbiddenError } from "@casl/ability"; +import jwt from "jsonwebtoken"; + +import { IdentityAuthMethod } from "@app/db/schemas"; +import { testLDAPConfig } from "@app/ee/services/ldap-config/ldap-fns"; +import { TLicenseServiceFactory } from "@app/ee/services/license/license-service"; +import { OrgPermissionIdentityActions, OrgPermissionSubjects } from "@app/ee/services/permission/org-permission"; +import { + constructPermissionErrorMessage, + validatePrivilegeChangeOperation +} from "@app/ee/services/permission/permission-fns"; +import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service"; +import { getConfig } from "@app/lib/config/env"; +import { BadRequestError, NotFoundError, PermissionBoundaryError } from "@app/lib/errors"; +import { extractIPDetails, isValidIpOrCidr } from "@app/lib/ip"; + +import { ActorType, AuthTokenType } from "../auth/auth-type"; +import { TIdentityDALFactory } from "../identity/identity-dal"; +import { TIdentityOrgDALFactory } from "../identity/identity-org-dal"; +import { TIdentityAccessTokenDALFactory } from "../identity-access-token/identity-access-token-dal"; +import { TIdentityAccessTokenJwtPayload } from "../identity-access-token/identity-access-token-types"; +import { TKmsServiceFactory } from "../kms/kms-service"; +import { KmsDataKey } from "../kms/kms-types"; +import { validateIdentityUpdateForSuperAdminPrivileges } from "../super-admin/super-admin-fns"; +import { TIdentityLdapAuthDALFactory } from "./identity-ldap-auth-dal"; +import { + AllowedFieldsSchema, + TAttachLdapAuthDTO, + TGetLdapAuthDTO, + TLoginLdapAuthDTO, + TRevokeLdapAuthDTO, + TUpdateLdapAuthDTO +} from "./identity-ldap-auth-types"; + +type TIdentityLdapAuthServiceFactoryDep = { + identityAccessTokenDAL: Pick; + identityLdapAuthDAL: Pick< + TIdentityLdapAuthDALFactory, + "findOne" | "transaction" | "create" | "updateById" | "delete" + >; + identityOrgMembershipDAL: Pick; + licenseService: Pick; + permissionService: Pick; + kmsService: TKmsServiceFactory; + identityDAL: TIdentityDALFactory; +}; + +export type TIdentityLdapAuthServiceFactory = ReturnType; + +export const identityLdapAuthServiceFactory = ({ + identityAccessTokenDAL, + identityDAL, + identityLdapAuthDAL, + identityOrgMembershipDAL, + licenseService, + permissionService, + kmsService +}: TIdentityLdapAuthServiceFactoryDep) => { + const getLdapConfig = async (identityId: string) => { + const identity = await identityDAL.findOne({ id: identityId }); + if (!identity) throw new NotFoundError({ message: `Identity with ID '${identityId}' not found` }); + + const identityOrgMembership = await identityOrgMembershipDAL.findOne({ identityId: identity.id }); + if (!identityOrgMembership) throw new NotFoundError({ message: `Identity with ID '${identityId}' not found` }); + + const ldapAuth = await identityLdapAuthDAL.findOne({ identityId: identity.id }); + if (!ldapAuth) throw new NotFoundError({ message: `LDAP auth with ID '${identityId}' not found` }); + + const parsedAllowedFields = ldapAuth.allowedFields + ? AllowedFieldsSchema.array().parse(ldapAuth.allowedFields) + : undefined; + + const { decryptor } = await kmsService.createCipherPairWithDataKey({ + type: KmsDataKey.Organization, + orgId: identityOrgMembership.orgId + }); + + const bindDN = decryptor({ cipherTextBlob: ldapAuth.encryptedBindDN }).toString(); + const bindPass = decryptor({ cipherTextBlob: ldapAuth.encryptedBindPass }).toString(); + const ldapCaCertificate = ldapAuth.encryptedLdapCaCertificate + ? decryptor({ cipherTextBlob: ldapAuth.encryptedLdapCaCertificate }).toString() + : undefined; + + const ldapConfig = { + id: ldapAuth.id, + organization: identityOrgMembership.orgId, + url: ldapAuth.url, + bindDN, + bindPass, + uniqueUserAttribute: ldapAuth.uniqueAttribute, + searchBase: ldapAuth.searchBase, + searchFilter: ldapAuth.searchFilter, + caCert: ldapCaCertificate || "", + allowedFields: parsedAllowedFields + }; + + const opts = { + server: { + url: ldapAuth.url, + bindDN, + bindCredentials: bindPass, + uniqueUserAttribute: ldapAuth.uniqueAttribute, + searchBase: ldapAuth.searchBase, + searchFilter: ldapAuth.searchFilter || "(uid={{username}})", + ...(ldapCaCertificate + ? { + tlsOptions: { + ca: [ldapCaCertificate] + } + } + : {}) + }, + passReqToCallback: true + }; + + return { opts, ldapConfig }; + }; + + const login = async ({ identityId }: TLoginLdapAuthDTO) => { + const identityMembershipOrg = await identityOrgMembershipDAL.findOne({ identityId }); + + if (!identityMembershipOrg) { + throw new NotFoundError({ message: `Failed to find identity with ID ${identityId}` }); + } + + const identityLdapAuth = await identityLdapAuthDAL.findOne({ identityId }); + + if (!identityLdapAuth) { + throw new NotFoundError({ message: `Failed to find LDAP auth for identity with ID ${identityId}` }); + } + + const plan = await licenseService.getPlan(identityMembershipOrg.orgId); + if (!plan.ldap) { + throw new BadRequestError({ + message: + "Failed to login to identity due to plan restriction. Upgrade plan to login to use LDAP authentication." + }); + } + + const identityAccessToken = await identityLdapAuthDAL.transaction(async (tx) => { + const newToken = await identityAccessTokenDAL.create( + { + identityId: identityLdapAuth.identityId, + isAccessTokenRevoked: false, + accessTokenTTL: identityLdapAuth.accessTokenTTL, + accessTokenMaxTTL: identityLdapAuth.accessTokenMaxTTL, + accessTokenNumUses: 0, + accessTokenNumUsesLimit: identityLdapAuth.accessTokenNumUsesLimit, + authMethod: IdentityAuthMethod.LDAP_AUTH + }, + tx + ); + return newToken; + }); + + const appCfg = getConfig(); + const accessToken = jwt.sign( + { + identityId: identityLdapAuth.identityId, + identityAccessTokenId: identityAccessToken.id, + authTokenType: AuthTokenType.IDENTITY_ACCESS_TOKEN + } as TIdentityAccessTokenJwtPayload, + appCfg.AUTH_SECRET, + // akhilmhdh: for non-expiry tokens you should not even set the value, including undefined. Even for undefined jsonwebtoken throws error + Number(identityAccessToken.accessTokenTTL) === 0 + ? undefined + : { + expiresIn: Number(identityAccessToken.accessTokenTTL) + } + ); + + return { accessToken, identityLdapAuth, identityAccessToken, identityMembershipOrg }; + }; + + const attachLdapAuth = async ({ + identityId, + url, + searchBase, + searchFilter, + uniqueAttribute, + bindDN, + bindPass, + ldapCaCertificate, + accessTokenTTL, + accessTokenMaxTTL, + accessTokenNumUsesLimit, + accessTokenTrustedIps, + actorId, + actorAuthMethod, + actor, + actorOrgId, + isActorSuperAdmin, + allowedFields + }: TAttachLdapAuthDTO) => { + await validateIdentityUpdateForSuperAdminPrivileges(identityId, isActorSuperAdmin); + + const identityMembershipOrg = await identityOrgMembershipDAL.findOne({ identityId }); + if (!identityMembershipOrg) throw new NotFoundError({ message: `Failed to find identity with ID ${identityId}` }); + + if (identityMembershipOrg.identity.authMethods.includes(IdentityAuthMethod.LDAP_AUTH)) { + throw new BadRequestError({ + message: "Failed to add LDAP Auth to already configured identity" + }); + } + + if (accessTokenMaxTTL > 0 && accessTokenTTL > accessTokenMaxTTL) { + throw new BadRequestError({ message: "Access token TTL cannot be greater than max TTL" }); + } + + const { permission } = await permissionService.getOrgPermission( + actor, + actorId, + identityMembershipOrg.orgId, + actorAuthMethod, + actorOrgId + ); + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionIdentityActions.Create, OrgPermissionSubjects.Identity); + const plan = await licenseService.getPlan(identityMembershipOrg.orgId); + + if (!plan.ldap) { + throw new BadRequestError({ + message: "Failed to add LDAP Auth to identity due to plan restriction. Upgrade plan to add LDAP Auth." + }); + } + + const reformattedAccessTokenTrustedIps = accessTokenTrustedIps.map((accessTokenTrustedIp) => { + if ( + !plan.ipAllowlisting && + accessTokenTrustedIp.ipAddress !== "0.0.0.0/0" && + accessTokenTrustedIp.ipAddress !== "::/0" + ) + throw new BadRequestError({ + message: + "Failed to add IP access range to access token due to plan restriction. Upgrade plan to add IP access range." + }); + if (!isValidIpOrCidr(accessTokenTrustedIp.ipAddress)) + throw new BadRequestError({ + message: "The IP is not a valid IPv4, IPv6, or CIDR block" + }); + return extractIPDetails(accessTokenTrustedIp.ipAddress); + }); + + const identityLdapAuth = await identityLdapAuthDAL.transaction(async (tx) => { + const { encryptor } = await kmsService.createCipherPairWithDataKey({ + type: KmsDataKey.Organization, + orgId: identityMembershipOrg.orgId + }); + + const { cipherTextBlob: encryptedBindPass } = encryptor({ + plainText: Buffer.from(bindPass) + }); + + let encryptedLdapCaCertificate: Buffer | undefined; + if (ldapCaCertificate) { + const { cipherTextBlob: encryptedCertificate } = encryptor({ + plainText: Buffer.from(ldapCaCertificate) + }); + + encryptedLdapCaCertificate = encryptedCertificate; + } + + const { cipherTextBlob: encryptedBindDN } = encryptor({ + plainText: Buffer.from(bindDN) + }); + + if (allowedFields) AllowedFieldsSchema.array().parse(allowedFields); + + const isConnected = await testLDAPConfig({ + bindDN, + bindPass, + caCert: ldapCaCertificate || "", + url + }); + + if (!isConnected) { + throw new BadRequestError({ + message: + "Failed to connect to LDAP server. Please ensure that the LDAP server is running and your credentials are correct." + }); + } + + const doc = await identityLdapAuthDAL.create( + { + identityId: identityMembershipOrg.identityId, + encryptedBindDN, + encryptedBindPass, + searchBase, + searchFilter, + uniqueAttribute, + url, + encryptedLdapCaCertificate, + accessTokenMaxTTL, + accessTokenTTL, + accessTokenNumUsesLimit, + accessTokenTrustedIps: JSON.stringify(reformattedAccessTokenTrustedIps), + allowedFields: allowedFields ? JSON.stringify(allowedFields) : undefined + }, + tx + ); + return doc; + }); + return { ...identityLdapAuth, orgId: identityMembershipOrg.orgId }; + }; + + const updateLdapAuth = async ({ + identityId, + url, + searchBase, + searchFilter, + uniqueAttribute, + bindDN, + bindPass, + ldapCaCertificate, + allowedFields, + accessTokenTTL, + accessTokenMaxTTL, + accessTokenNumUsesLimit, + accessTokenTrustedIps, + actorId, + actorAuthMethod, + actor, + actorOrgId + }: TUpdateLdapAuthDTO) => { + const identityMembershipOrg = await identityOrgMembershipDAL.findOne({ identityId }); + if (!identityMembershipOrg) throw new NotFoundError({ message: `Failed to find identity with ID ${identityId}` }); + + if (!identityMembershipOrg.identity.authMethods.includes(IdentityAuthMethod.LDAP_AUTH)) { + throw new NotFoundError({ + message: "The identity does not have LDAP Auth attached" + }); + } + + const identityLdapAuth = await identityLdapAuthDAL.findOne({ identityId }); + + if ( + (accessTokenMaxTTL || identityLdapAuth.accessTokenMaxTTL) > 0 && + (accessTokenTTL || identityLdapAuth.accessTokenTTL) > (accessTokenMaxTTL || identityLdapAuth.accessTokenMaxTTL) + ) { + throw new BadRequestError({ message: "Access token TTL cannot be greater than max TTL" }); + } + + const { permission } = await permissionService.getOrgPermission( + actor, + actorId, + identityMembershipOrg.orgId, + actorAuthMethod, + actorOrgId + ); + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionIdentityActions.Edit, OrgPermissionSubjects.Identity); + + const plan = await licenseService.getPlan(identityMembershipOrg.orgId); + + if (!plan.ldap) { + throw new BadRequestError({ + message: "Failed to update LDAP Auth due to plan restriction. Upgrade plan to update LDAP Auth." + }); + } + + const reformattedAccessTokenTrustedIps = accessTokenTrustedIps?.map((accessTokenTrustedIp) => { + if ( + !plan.ipAllowlisting && + accessTokenTrustedIp.ipAddress !== "0.0.0.0/0" && + accessTokenTrustedIp.ipAddress !== "::/0" + ) + throw new BadRequestError({ + message: + "Failed to add IP access range to access token due to plan restriction. Upgrade plan to add IP access range." + }); + if (!isValidIpOrCidr(accessTokenTrustedIp.ipAddress)) + throw new BadRequestError({ + message: "The IP is not a valid IPv4, IPv6, or CIDR block" + }); + return extractIPDetails(accessTokenTrustedIp.ipAddress); + }); + + const { encryptor } = await kmsService.createCipherPairWithDataKey({ + type: KmsDataKey.Organization, + orgId: identityMembershipOrg.orgId + }); + + let encryptedBindPass: Buffer | undefined; + if (bindPass) { + const { cipherTextBlob: bindPassCiphertext } = encryptor({ + plainText: Buffer.from(bindPass) + }); + + encryptedBindPass = bindPassCiphertext; + } + + let encryptedLdapCaCertificate: Buffer | undefined; + if (ldapCaCertificate) { + const { cipherTextBlob: ldapCaCertificateCiphertext } = encryptor({ + plainText: Buffer.from(ldapCaCertificate) + }); + + encryptedLdapCaCertificate = ldapCaCertificateCiphertext; + } + + let encryptedBindDN: Buffer | undefined; + if (bindDN) { + const { cipherTextBlob: bindDNCiphertext } = encryptor({ + plainText: Buffer.from(bindDN) + }); + + encryptedBindDN = bindDNCiphertext; + } + + const { ldapConfig } = await getLdapConfig(identityId); + + const isConnected = await testLDAPConfig({ + bindDN: bindDN || ldapConfig.bindDN, + bindPass: bindPass || ldapConfig.bindPass, + caCert: ldapCaCertificate || ldapConfig.caCert, + url: url || ldapConfig.url + }); + + if (!isConnected) { + throw new BadRequestError({ + message: + "Failed to connect to LDAP server. Please ensure that the LDAP server is running and your credentials are correct." + }); + } + + const updatedLdapAuth = await identityLdapAuthDAL.updateById(identityLdapAuth.id, { + url, + searchBase, + searchFilter, + uniqueAttribute, + encryptedBindDN, + encryptedBindPass, + encryptedLdapCaCertificate, + allowedFields: allowedFields ? JSON.stringify(allowedFields) : undefined, + accessTokenMaxTTL, + accessTokenTTL, + accessTokenNumUsesLimit, + accessTokenTrustedIps: reformattedAccessTokenTrustedIps + ? JSON.stringify(reformattedAccessTokenTrustedIps) + : undefined + }); + + return { ...updatedLdapAuth, orgId: identityMembershipOrg.orgId }; + }; + + const getLdapAuth = async ({ identityId, actorId, actor, actorAuthMethod, actorOrgId }: TGetLdapAuthDTO) => { + const identityMembershipOrg = await identityOrgMembershipDAL.findOne({ identityId }); + if (!identityMembershipOrg) throw new NotFoundError({ message: `Failed to find identity with ID ${identityId}` }); + + if (!identityMembershipOrg.identity.authMethods.includes(IdentityAuthMethod.LDAP_AUTH)) { + throw new BadRequestError({ + message: "The identity does not have LDAP Auth attached" + }); + } + + const ldapIdentityAuth = await identityLdapAuthDAL.findOne({ identityId }); + + const { permission } = await permissionService.getOrgPermission( + actor, + actorId, + identityMembershipOrg.orgId, + actorAuthMethod, + actorOrgId + ); + + const { decryptor } = await kmsService.createCipherPairWithDataKey({ + type: KmsDataKey.Organization, + orgId: identityMembershipOrg.orgId + }); + + const bindDN = decryptor({ cipherTextBlob: ldapIdentityAuth.encryptedBindDN }).toString(); + const bindPass = decryptor({ cipherTextBlob: ldapIdentityAuth.encryptedBindPass }).toString(); + const ldapCaCertificate = ldapIdentityAuth.encryptedLdapCaCertificate + ? decryptor({ cipherTextBlob: ldapIdentityAuth.encryptedLdapCaCertificate }).toString() + : undefined; + + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionIdentityActions.Read, OrgPermissionSubjects.Identity); + return { ...ldapIdentityAuth, orgId: identityMembershipOrg.orgId, bindDN, bindPass, ldapCaCertificate }; + }; + + const revokeIdentityLdapAuth = async ({ + identityId, + actorId, + actor, + actorAuthMethod, + actorOrgId + }: TRevokeLdapAuthDTO) => { + const identityMembershipOrg = await identityOrgMembershipDAL.findOne({ identityId }); + if (!identityMembershipOrg) throw new NotFoundError({ message: `Failed to find identity with ID ${identityId}` }); + if (!identityMembershipOrg.identity.authMethods.includes(IdentityAuthMethod.LDAP_AUTH)) { + throw new BadRequestError({ + message: "The identity does not have LDAP Auth attached" + }); + } + const { permission, membership } = await permissionService.getOrgPermission( + actor, + actorId, + identityMembershipOrg.orgId, + actorAuthMethod, + actorOrgId + ); + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionIdentityActions.Edit, OrgPermissionSubjects.Identity); + + const { permission: rolePermission } = await permissionService.getOrgPermission( + ActorType.IDENTITY, + identityMembershipOrg.identityId, + identityMembershipOrg.orgId, + actorAuthMethod, + actorOrgId + ); + + const permissionBoundary = validatePrivilegeChangeOperation( + membership.shouldUseNewPrivilegeSystem, + OrgPermissionIdentityActions.RevokeAuth, + OrgPermissionSubjects.Identity, + permission, + rolePermission + ); + + if (!permissionBoundary.isValid) + throw new PermissionBoundaryError({ + message: constructPermissionErrorMessage( + "Failed to revoke LDAP auth of identity with more privileged role", + membership.shouldUseNewPrivilegeSystem, + OrgPermissionIdentityActions.RevokeAuth, + OrgPermissionSubjects.Identity + ), + details: { missingPermissions: permissionBoundary.missingPermissions } + }); + + const revokedIdentityLdapAuth = await identityLdapAuthDAL.transaction(async (tx) => { + const [deletedLdapAuth] = await identityLdapAuthDAL.delete({ identityId }, tx); + await identityAccessTokenDAL.delete({ identityId, authMethod: IdentityAuthMethod.LDAP_AUTH }, tx); + + return { ...deletedLdapAuth, orgId: identityMembershipOrg.orgId }; + }); + return revokedIdentityLdapAuth; + }; + + return { + attachLdapAuth, + getLdapConfig, + updateLdapAuth, + login, + revokeIdentityLdapAuth, + getLdapAuth + }; +}; diff --git a/backend/src/services/identity-ldap-auth/identity-ldap-auth-types.ts b/backend/src/services/identity-ldap-auth/identity-ldap-auth-types.ts new file mode 100644 index 000000000..cba6acbcb --- /dev/null +++ b/backend/src/services/identity-ldap-auth/identity-ldap-auth-types.ts @@ -0,0 +1,58 @@ +import { z } from "zod"; + +import { TProjectPermission } from "@app/lib/types"; + +export const AllowedFieldsSchema = z.object({ + key: z.string().trim(), + value: z + .string() + .trim() + .transform((val) => val.replace(/\s/g, "")) +}); + +export type TAllowedFields = z.infer; + +export type TAttachLdapAuthDTO = { + identityId: string; + url: string; + searchBase: string; + searchFilter: string; + uniqueAttribute: string; + bindDN: string; + bindPass: string; + ldapCaCertificate?: string; + allowedFields?: TAllowedFields[]; + accessTokenTTL: number; + accessTokenMaxTTL: number; + accessTokenNumUsesLimit: number; + accessTokenTrustedIps: { ipAddress: string }[]; + isActorSuperAdmin?: boolean; +} & Omit; + +export type TUpdateLdapAuthDTO = { + identityId: string; + url?: string; + searchBase?: string; + searchFilter?: string; + uniqueAttribute?: string; + bindDN?: string; + bindPass?: string; + allowedFields?: TAllowedFields[]; + ldapCaCertificate?: string; + accessTokenTTL?: number; + accessTokenMaxTTL?: number; + accessTokenNumUsesLimit?: number; + accessTokenTrustedIps?: { ipAddress: string }[]; +} & Omit; + +export type TGetLdapAuthDTO = { + identityId: string; +} & Omit; + +export type TLoginLdapAuthDTO = { + identityId: string; +}; + +export type TRevokeLdapAuthDTO = { + identityId: string; +} & Omit; diff --git a/backend/src/services/identity/identity-fns.ts b/backend/src/services/identity/identity-fns.ts index 2d77e6544..6c77618e4 100644 --- a/backend/src/services/identity/identity-fns.ts +++ b/backend/src/services/identity/identity-fns.ts @@ -8,7 +8,8 @@ export const buildAuthMethods = ({ oidcId, azureId, tokenId, - jwtId + jwtId, + ldapId }: { uaId?: string; gcpId?: string; @@ -18,6 +19,7 @@ export const buildAuthMethods = ({ azureId?: string; tokenId?: string; jwtId?: string; + ldapId?: string; }) => { return [ ...[uaId ? IdentityAuthMethod.UNIVERSAL_AUTH : null], @@ -27,6 +29,7 @@ export const buildAuthMethods = ({ ...[oidcId ? IdentityAuthMethod.OIDC_AUTH : null], ...[azureId ? IdentityAuthMethod.AZURE_AUTH : null], ...[tokenId ? IdentityAuthMethod.TOKEN_AUTH : null], - ...[jwtId ? IdentityAuthMethod.JWT_AUTH : null] + ...[jwtId ? IdentityAuthMethod.JWT_AUTH : null], + ...[ldapId ? IdentityAuthMethod.LDAP_AUTH : null] ].filter((authMethod) => authMethod) as IdentityAuthMethod[]; }; diff --git a/backend/src/services/identity/identity-org-dal.ts b/backend/src/services/identity/identity-org-dal.ts index dbae59bbe..8b5032945 100644 --- a/backend/src/services/identity/identity-org-dal.ts +++ b/backend/src/services/identity/identity-org-dal.ts @@ -14,6 +14,7 @@ import { TIdentityUniversalAuths, TOrgRoles } from "@app/db/schemas"; +import { TIdentityLdapAuths } from "@app/db/schemas/identity-ldap-auths"; import { BadRequestError, DatabaseError } from "@app/lib/errors"; import { ormify, selectAllTableCols, sqlNestRelationships } from "@app/lib/knex"; import { buildKnexFilterForSearchResource } from "@app/lib/search-resource/db"; @@ -81,6 +82,11 @@ export const identityOrgDALFactory = (db: TDbClient) => { `${TableName.IdentityOrgMembership}.identityId`, `${TableName.IdentityJwtAuth}.identityId` ) + .leftJoin( + TableName.IdentityLdapAuth, + `${TableName.IdentityOrgMembership}.identityId`, + `${TableName.IdentityLdapAuth}.identityId` + ) .select( selectAllTableCols(TableName.IdentityOrgMembership), @@ -93,7 +99,7 @@ export const identityOrgDALFactory = (db: TDbClient) => { db.ref("id").as("azureId").withSchema(TableName.IdentityAzureAuth), db.ref("id").as("tokenId").withSchema(TableName.IdentityTokenAuth), db.ref("id").as("jwtId").withSchema(TableName.IdentityJwtAuth), - + db.ref("id").as("ldapId").withSchema(TableName.IdentityLdapAuth), db.ref("name").withSchema(TableName.Identity) ); @@ -200,6 +206,12 @@ export const identityOrgDALFactory = (db: TDbClient) => { "paginatedIdentity.identityId", `${TableName.IdentityJwtAuth}.identityId` ) + .leftJoin( + TableName.IdentityLdapAuth, + "paginatedIdentity.identityId", + `${TableName.IdentityLdapAuth}.identityId` + ) + .select( db.ref("id").withSchema("paginatedIdentity"), db.ref("role").withSchema("paginatedIdentity"), @@ -217,7 +229,8 @@ export const identityOrgDALFactory = (db: TDbClient) => { db.ref("id").as("oidcId").withSchema(TableName.IdentityOidcAuth), db.ref("id").as("azureId").withSchema(TableName.IdentityAzureAuth), db.ref("id").as("tokenId").withSchema(TableName.IdentityTokenAuth), - db.ref("id").as("jwtId").withSchema(TableName.IdentityJwtAuth) + db.ref("id").as("jwtId").withSchema(TableName.IdentityJwtAuth), + db.ref("id").as("ldapId").withSchema(TableName.IdentityLdapAuth) ) // cr stands for custom role .select(db.ref("id").as("crId").withSchema(TableName.OrgRoles)) @@ -259,6 +272,7 @@ export const identityOrgDALFactory = (db: TDbClient) => { oidcId, azureId, tokenId, + ldapId, createdAt, updatedAt }) => ({ @@ -290,7 +304,8 @@ export const identityOrgDALFactory = (db: TDbClient) => { oidcId, azureId, tokenId, - jwtId + jwtId, + ldapId }) } }), @@ -406,6 +421,11 @@ export const identityOrgDALFactory = (db: TDbClient) => { `${TableName.IdentityOrgMembership}.identityId`, `${TableName.IdentityJwtAuth}.identityId` ) + .leftJoin( + TableName.IdentityLdapAuth, + `${TableName.IdentityOrgMembership}.identityId`, + `${TableName.IdentityLdapAuth}.identityId` + ) .select( db.ref("id").withSchema(TableName.IdentityOrgMembership), db.ref("total_count").withSchema("searchedIdentities"), @@ -424,7 +444,8 @@ export const identityOrgDALFactory = (db: TDbClient) => { db.ref("id").as("oidcId").withSchema(TableName.IdentityOidcAuth), db.ref("id").as("azureId").withSchema(TableName.IdentityAzureAuth), db.ref("id").as("tokenId").withSchema(TableName.IdentityTokenAuth), - db.ref("id").as("jwtId").withSchema(TableName.IdentityJwtAuth) + db.ref("id").as("jwtId").withSchema(TableName.IdentityJwtAuth), + db.ref("id").as("ldapId").withSchema(TableName.IdentityLdapAuth) ) // cr stands for custom role .select(db.ref("id").as("crId").withSchema(TableName.OrgRoles)) @@ -467,6 +488,7 @@ export const identityOrgDALFactory = (db: TDbClient) => { oidcId, azureId, tokenId, + ldapId, createdAt, updatedAt }) => ({ @@ -498,7 +520,8 @@ export const identityOrgDALFactory = (db: TDbClient) => { oidcId, azureId, tokenId, - jwtId + jwtId, + ldapId }) } }), diff --git a/frontend/src/hooks/api/auditLogs/constants.tsx b/frontend/src/hooks/api/auditLogs/constants.tsx index 36daed4b7..d48d8ae80 100644 --- a/frontend/src/hooks/api/auditLogs/constants.tsx +++ b/frontend/src/hooks/api/auditLogs/constants.tsx @@ -182,7 +182,14 @@ export const eventToNameMap: { [K in EventType]: string } = { "Microsoft Teams Workflow Integration Check Installation Status", [EventType.MICROSOFT_TEAMS_WORKFLOW_INTEGRATION_GET_TEAMS]: "Get Microsoft Teams tenant teams", [EventType.MICROSOFT_TEAMS_WORKFLOW_INTEGRATION_GET]: "Get Microsoft Teams Workflow Integration", - [EventType.MICROSOFT_TEAMS_WORKFLOW_INTEGRATION_LIST]: "List Microsoft Teams Workflow Integration" + [EventType.MICROSOFT_TEAMS_WORKFLOW_INTEGRATION_LIST]: + "List Microsoft Teams Workflow Integration", + + [EventType.LOGIN_IDENTITY_LDAP_AUTH]: "Identity login via LDAP Auth", + [EventType.ADD_IDENTITY_LDAP_AUTH]: "Attached LDAP Auth to identity", + [EventType.UPDATE_IDENTITY_LDAP_AUTH]: "Updated LDAP Auth for identity", + [EventType.GET_IDENTITY_LDAP_AUTH]: "Retrieved LDAP Auth for identity", + [EventType.REVOKE_IDENTITY_LDAP_AUTH]: "Revoked LDAP Auth for identity" }; export const userAgentTTypeoNameMap: { [K in UserAgentType]: string } = { diff --git a/frontend/src/hooks/api/auditLogs/enums.tsx b/frontend/src/hooks/api/auditLogs/enums.tsx index ac57def4f..59ae35d45 100644 --- a/frontend/src/hooks/api/auditLogs/enums.tsx +++ b/frontend/src/hooks/api/auditLogs/enums.tsx @@ -176,5 +176,11 @@ export enum EventType { MICROSOFT_TEAMS_WORKFLOW_INTEGRATION_CHECK_INSTALLATION_STATUS = "microsoft-teams-workflow-integration-check-installation-status", MICROSOFT_TEAMS_WORKFLOW_INTEGRATION_GET_TEAMS = "microsoft-teams-workflow-integration-get-teams", MICROSOFT_TEAMS_WORKFLOW_INTEGRATION_GET = "microsoft-teams-workflow-integration-get", - MICROSOFT_TEAMS_WORKFLOW_INTEGRATION_LIST = "microsoft-teams-workflow-integration-list" + MICROSOFT_TEAMS_WORKFLOW_INTEGRATION_LIST = "microsoft-teams-workflow-integration-list", + + LOGIN_IDENTITY_LDAP_AUTH = "login-identity-ldap-auth", + ADD_IDENTITY_LDAP_AUTH = "add-identity-ldap-auth", + UPDATE_IDENTITY_LDAP_AUTH = "update-identity-ldap-auth", + GET_IDENTITY_LDAP_AUTH = "get-identity-ldap-auth", + REVOKE_IDENTITY_LDAP_AUTH = "revoke-identity-ldap-auth" } diff --git a/frontend/src/hooks/api/identities/constants.tsx b/frontend/src/hooks/api/identities/constants.tsx index c11d7dc11..97acd6dfc 100644 --- a/frontend/src/hooks/api/identities/constants.tsx +++ b/frontend/src/hooks/api/identities/constants.tsx @@ -8,5 +8,6 @@ export const identityAuthToNameMap: { [I in IdentityAuthMethod]: string } = { [IdentityAuthMethod.AWS_AUTH]: "AWS Auth", [IdentityAuthMethod.AZURE_AUTH]: "Azure Auth", [IdentityAuthMethod.OIDC_AUTH]: "OIDC Auth", + [IdentityAuthMethod.LDAP_AUTH]: "LDAP Auth", [IdentityAuthMethod.JWT_AUTH]: "JWT Auth" }; diff --git a/frontend/src/hooks/api/identities/enums.tsx b/frontend/src/hooks/api/identities/enums.tsx index 415492e00..8a8d99fae 100644 --- a/frontend/src/hooks/api/identities/enums.tsx +++ b/frontend/src/hooks/api/identities/enums.tsx @@ -6,6 +6,7 @@ export enum IdentityAuthMethod { AWS_AUTH = "aws-auth", AZURE_AUTH = "azure-auth", OIDC_AUTH = "oidc-auth", + LDAP_AUTH = "ldap-auth", JWT_AUTH = "jwt-auth" } diff --git a/frontend/src/hooks/api/identities/index.tsx b/frontend/src/hooks/api/identities/index.tsx index f3b9fa012..bf49387ac 100644 --- a/frontend/src/hooks/api/identities/index.tsx +++ b/frontend/src/hooks/api/identities/index.tsx @@ -1,51 +1,4 @@ export { identityAuthToNameMap } from "./constants"; export { IdentityAuthMethod } from "./enums"; -export { - useAddIdentityAwsAuth, - useAddIdentityAzureAuth, - useAddIdentityGcpAuth, - useAddIdentityJwtAuth, - useAddIdentityKubernetesAuth, - useAddIdentityOidcAuth, - useAddIdentityTokenAuth, - useAddIdentityUniversalAuth, - useCreateIdentity, - useCreateIdentityUniversalAuthClientSecret, - useCreateTokenIdentityTokenAuth, - useDeleteIdentity, - useDeleteIdentityAwsAuth, - useDeleteIdentityAzureAuth, - useDeleteIdentityGcpAuth, - useDeleteIdentityJwtAuth, - useDeleteIdentityKubernetesAuth, - useDeleteIdentityOidcAuth, - useDeleteIdentityTokenAuth, - useDeleteIdentityUniversalAuth, - useRevokeIdentityTokenAuthToken, - useRevokeIdentityUniversalAuthClientSecret, - useUpdateIdentity, - useUpdateIdentityAwsAuth, - useUpdateIdentityAzureAuth, - useUpdateIdentityGcpAuth, - useUpdateIdentityJwtAuth, - useUpdateIdentityKubernetesAuth, - useUpdateIdentityOidcAuth, - useUpdateIdentityTokenAuth, - useUpdateIdentityTokenAuthToken, - useUpdateIdentityUniversalAuth -} from "./mutations"; -export { - useGetIdentityAwsAuth, - useGetIdentityAzureAuth, - useGetIdentityById, - useGetIdentityGcpAuth, - useGetIdentityJwtAuth, - useGetIdentityKubernetesAuth, - useGetIdentityOidcAuth, - useGetIdentityProjectMemberships, - useGetIdentityTokenAuth, - useGetIdentityTokensTokenAuth, - useGetIdentityUniversalAuth, - useGetIdentityUniversalAuthClientSecrets, - useSearchIdentities -} from "./queries"; +export * from "./mutations"; +export * from "./queries"; diff --git a/frontend/src/hooks/api/identities/mutations.tsx b/frontend/src/hooks/api/identities/mutations.tsx index d68595ad5..7d9b4fbcb 100644 --- a/frontend/src/hooks/api/identities/mutations.tsx +++ b/frontend/src/hooks/api/identities/mutations.tsx @@ -10,6 +10,7 @@ import { AddIdentityGcpAuthDTO, AddIdentityJwtAuthDTO, AddIdentityKubernetesAuthDTO, + AddIdentityLdapAuthDTO, AddIdentityOidcAuthDTO, AddIdentityTokenAuthDTO, AddIdentityUniversalAuthDTO, @@ -25,6 +26,7 @@ import { DeleteIdentityGcpAuthDTO, DeleteIdentityJwtAuthDTO, DeleteIdentityKubernetesAuthDTO, + DeleteIdentityLdapAuthDTO, DeleteIdentityOidcAuthDTO, DeleteIdentityTokenAuthDTO, DeleteIdentityUniversalAuthClientSecretDTO, @@ -36,6 +38,7 @@ import { IdentityGcpAuth, IdentityJwtAuth, IdentityKubernetesAuth, + IdentityLdapAuth, IdentityOidcAuth, IdentityTokenAuth, IdentityUniversalAuth, @@ -47,6 +50,7 @@ import { UpdateIdentityGcpAuthDTO, UpdateIdentityJwtAuthDTO, UpdateIdentityKubernetesAuthDTO, + UpdateIdentityLdapAuthDTO, UpdateIdentityOidcAuthDTO, UpdateIdentityTokenAuthDTO, UpdateIdentityUniversalAuthDTO, @@ -1049,3 +1053,117 @@ export const useRevokeIdentityTokenAuthToken = () => { } }); }; + +export const useAddIdentityLdapAuth = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async ({ + identityId, + url, + bindDN, + bindPass, + searchBase, + searchFilter, + uniqueAttribute, + ldapCaCertificate, + allowedFields, + accessTokenTTL, + accessTokenMaxTTL, + accessTokenNumUsesLimit, + accessTokenTrustedIps + }) => { + const { data } = await apiRequest.post<{ identityLdapAuth: IdentityLdapAuth }>( + `/api/v1/auth/ldap-auth/identities/${identityId}`, + { + url, + bindDN, + bindPass, + searchBase, + searchFilter, + uniqueAttribute, + ldapCaCertificate, + allowedFields, + accessTokenTTL, + accessTokenMaxTTL, + accessTokenNumUsesLimit, + accessTokenTrustedIps + } + ); + return data.identityLdapAuth; + }, + onSuccess: (_, { identityId, organizationId }) => { + queryClient.invalidateQueries({ + queryKey: organizationKeys.getOrgIdentityMemberships(organizationId) + }); + queryClient.invalidateQueries({ queryKey: identitiesKeys.getIdentityById(identityId) }); + queryClient.invalidateQueries({ + queryKey: identitiesKeys.getIdentityLdapAuth(identityId) + }); + } + }); +}; + +export const useUpdateIdentityLdapAuth = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async ({ + identityId, + url, + bindDN, + bindPass, + searchBase, + searchFilter, + uniqueAttribute, + ldapCaCertificate, + allowedFields, + accessTokenTTL, + accessTokenMaxTTL, + accessTokenNumUsesLimit, + accessTokenTrustedIps + }) => { + const { data } = await apiRequest.patch<{ identityLdapAuth: IdentityLdapAuth }>( + `/api/v1/auth/ldap-auth/identities/${identityId}`, + { + url, + bindDN, + bindPass, + searchBase, + searchFilter, + uniqueAttribute, + ldapCaCertificate, + allowedFields, + accessTokenTTL, + accessTokenMaxTTL, + accessTokenNumUsesLimit, + accessTokenTrustedIps + } + ); + return data.identityLdapAuth; + }, + onSuccess: (_, { identityId, organizationId }) => { + queryClient.invalidateQueries({ + queryKey: organizationKeys.getOrgIdentityMemberships(organizationId) + }); + queryClient.invalidateQueries({ queryKey: identitiesKeys.getIdentityById(identityId) }); + } + }); +}; + +export const useDeleteIdentityLdapAuth = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async ({ identityId }) => { + const { data } = await apiRequest.delete(`/api/v1/auth/ldap-auth/identities/${identityId}`); + return data.identityLdapAuth; + }, + onSuccess: (_, { organizationId, identityId }) => { + queryClient.invalidateQueries({ + queryKey: organizationKeys.getOrgIdentityMemberships(organizationId) + }); + queryClient.invalidateQueries({ queryKey: identitiesKeys.getIdentityById(identityId) }); + queryClient.invalidateQueries({ + queryKey: identitiesKeys.getIdentityLdapAuth(identityId) + }); + } + }); +}; diff --git a/frontend/src/hooks/api/identities/queries.tsx b/frontend/src/hooks/api/identities/queries.tsx index 6b8a1d1cc..74ac4b214 100644 --- a/frontend/src/hooks/api/identities/queries.tsx +++ b/frontend/src/hooks/api/identities/queries.tsx @@ -11,6 +11,7 @@ import { IdentityGcpAuth, IdentityJwtAuth, IdentityKubernetesAuth, + IdentityLdapAuth, IdentityMembership, IdentityMembershipOrg, IdentityOidcAuth, @@ -34,6 +35,7 @@ export const identitiesKeys = { getIdentityAzureAuth: (identityId: string) => [{ identityId }, "identity-azure-auth"] as const, getIdentityTokenAuth: (identityId: string) => [{ identityId }, "identity-token-auth"] as const, getIdentityJwtAuth: (identityId: string) => [{ identityId }, "identity-jwt-auth"] as const, + getIdentityLdapAuth: (identityId: string) => [{ identityId }, "identity-ldap-auth"] as const, getIdentityTokensTokenAuth: (identityId: string) => [{ identityId }, "identity-tokens-token-auth"] as const, getIdentityProjectMemberships: (identityId: string) => @@ -231,6 +233,26 @@ export const useGetIdentityTokenAuth = ( }); }; +export const useGetIdentityLdapAuth = ( + identityId: string, + options?: TReactQueryOptions["options"] +) => { + return useQuery({ + queryKey: identitiesKeys.getIdentityLdapAuth(identityId), + queryFn: async () => { + const { + data: { identityLdapAuth } + } = await apiRequest.get<{ identityLdapAuth: IdentityLdapAuth }>( + `/api/v1/auth/ldap-auth/identities/${identityId}` + ); + return identityLdapAuth; + }, + staleTime: 0, + gcTime: 0, + ...options, + enabled: Boolean(identityId) && (options?.enabled ?? true) + }); +}; export const useGetIdentityTokensTokenAuth = (identityId: string) => { return useQuery({ enabled: Boolean(identityId), diff --git a/frontend/src/hooks/api/identities/types.ts b/frontend/src/hooks/api/identities/types.ts index ca06219aa..0ad6e9316 100644 --- a/frontend/src/hooks/api/identities/types.ts +++ b/frontend/src/hooks/api/identities/types.ts @@ -425,6 +425,75 @@ export type IdentityTokenAuth = { accessTokenTrustedIps: IdentityTrustedIp[]; }; +export type AddIdentityLdapAuthDTO = { + organizationId: string; + identityId: string; + url: string; + bindDN: string; + bindPass: string; + searchBase: string; + searchFilter: string; + uniqueAttribute: string; + ldapCaCertificate?: string; + allowedFields?: { + key: string; + value: string; + }[]; + accessTokenTTL: number; + accessTokenMaxTTL: number; + accessTokenNumUsesLimit: number; + accessTokenTrustedIps: { + ipAddress: string; + }[]; +}; + +export type UpdateIdentityLdapAuthDTO = { + identityId: string; + organizationId: string; + url?: string; + bindDN?: string; + bindPass?: string; + searchBase?: string; + searchFilter?: string; + uniqueAttribute?: string; + ldapCaCertificate?: string; + allowedFields?: { + key: string; + value: string; + }[]; + accessTokenTTL?: number; + accessTokenMaxTTL?: number; + accessTokenNumUsesLimit?: number; + accessTokenTrustedIps?: { + ipAddress: string; + }[]; +}; + +export type DeleteIdentityLdapAuthDTO = { + organizationId: string; + identityId: string; +}; + +export type IdentityLdapAuth = { + url: string; + bindDN: string; + bindPass: string; + searchBase: string; + searchFilter: string; + uniqueAttribute: string; + ldapCaCertificate?: string; + allowedFields: { + key: string; + value: string; + }[]; + + identityId: string; + accessTokenTTL: number; + accessTokenMaxTTL: number; + accessTokenNumUsesLimit: number; + accessTokenTrustedIps: IdentityTrustedIp[]; +}; + export type AddIdentityTokenAuthDTO = { organizationId: string; identityId: string; diff --git a/frontend/src/pages/organization/AccessManagementPage/components/OrgIdentityTab/components/IdentitySection/IdentityAuthMethodModalContent.tsx b/frontend/src/pages/organization/AccessManagementPage/components/OrgIdentityTab/components/IdentitySection/IdentityAuthMethodModalContent.tsx index 1380ebb39..0444b3bd1 100644 --- a/frontend/src/pages/organization/AccessManagementPage/components/OrgIdentityTab/components/IdentitySection/IdentityAuthMethodModalContent.tsx +++ b/frontend/src/pages/organization/AccessManagementPage/components/OrgIdentityTab/components/IdentitySection/IdentityAuthMethodModalContent.tsx @@ -13,6 +13,7 @@ import { IdentityAzureAuthForm } from "./IdentityAzureAuthForm"; import { IdentityGcpAuthForm } from "./IdentityGcpAuthForm"; import { IdentityJwtAuthForm } from "./IdentityJwtAuthForm"; import { IdentityKubernetesAuthForm } from "./IdentityKubernetesAuthForm"; +import { IdentityLdapAuthForm } from "./IdentityLdapAuthForm"; import { IdentityOidcAuthForm } from "./IdentityOidcAuthForm"; import { IdentityTokenAuthForm } from "./IdentityTokenAuthForm"; import { IdentityUniversalAuthForm } from "./IdentityUniversalAuthForm"; @@ -46,6 +47,7 @@ const identityAuthMethods = [ { label: "AWS Auth", value: IdentityAuthMethod.AWS_AUTH }, { label: "Azure Auth", value: IdentityAuthMethod.AZURE_AUTH }, { label: "OIDC Auth", value: IdentityAuthMethod.OIDC_AUTH }, + { label: "LDAP Auth", value: IdentityAuthMethod.LDAP_AUTH }, { label: "JWT Auth", value: IdentityAuthMethod.JWT_AUTH @@ -186,6 +188,16 @@ export const IdentityAuthMethodModalContent = ({ handlePopUpToggle={handlePopUpToggle} /> ) + }, + + [IdentityAuthMethod.LDAP_AUTH]: { + render: () => ( + + ) } }; diff --git a/frontend/src/pages/organization/AccessManagementPage/components/OrgIdentityTab/components/IdentitySection/IdentityLdapAuthForm.tsx b/frontend/src/pages/organization/AccessManagementPage/components/OrgIdentityTab/components/IdentitySection/IdentityLdapAuthForm.tsx new file mode 100644 index 000000000..8da222295 --- /dev/null +++ b/frontend/src/pages/organization/AccessManagementPage/components/OrgIdentityTab/components/IdentitySection/IdentityLdapAuthForm.tsx @@ -0,0 +1,630 @@ +import { useEffect, useState } from "react"; +import { Controller, useFieldArray, useForm } from "react-hook-form"; +import { faPlus, faQuestionCircle, faXmark } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { z } from "zod"; + +import { createNotification } from "@app/components/notifications"; +import { + Button, + FormControl, + IconButton, + Input, + Tab, + TabList, + TabPanel, + Tabs, + TextArea, + Tooltip +} from "@app/components/v2"; +import { useOrganization, useSubscription } from "@app/context"; +import { + useAddIdentityLdapAuth, + useGetIdentityLdapAuth, + useUpdateIdentityLdapAuth +} from "@app/hooks/api"; +import { IdentityTrustedIp } from "@app/hooks/api/identities/types"; +import { UsePopUpState } from "@app/hooks/usePopUp"; + +import { IdentityFormTab } from "./types"; + +const schema = z + .object({ + url: z.string().min(1), + bindDN: z.string(), + bindPass: z.string(), + searchBase: z.string(), + uniqueAttribute: z.string(), // defaults to uidNumber + searchFilter: z.string(), // defaults to (uid={{username}}) + ldapCaCertificate: z + .string() + .optional() + .transform((val) => val || undefined), + allowedFields: z + .object({ + key: z.string().trim(), + value: z + .string() + .trim() + .transform((val) => val.replace(/\s/g, "")) + }) + .array() + .optional(), + + accessTokenTTL: z.string().refine((val) => Number(val) <= 315360000, { + message: "Access Token TTL cannot be greater than 315360000" + }), + accessTokenMaxTTL: z.string().refine((val) => Number(val) <= 315360000, { + message: "Access Token Max TTL cannot be greater than 315360000" + }), + accessTokenNumUsesLimit: z.string(), + accessTokenTrustedIps: z + .array( + z.object({ + ipAddress: z.string().max(50) + }) + ) + .min(1) + }) + .required(); + +export type FormData = z.infer; + +type Props = { + handlePopUpOpen: (popUpName: keyof UsePopUpState<["upgradePlan"]>) => void; + handlePopUpToggle: ( + popUpName: keyof UsePopUpState<["identityAuthMethod"]>, + state?: boolean + ) => void; + identityId?: string; + isUpdate?: boolean; +}; + +export const IdentityLdapAuthForm = ({ + handlePopUpOpen, + handlePopUpToggle, + identityId, + isUpdate +}: Props) => { + const { currentOrg } = useOrganization(); + const orgId = currentOrg?.id || ""; + const { subscription } = useSubscription(); + + const { mutateAsync: addMutateAsync } = useAddIdentityLdapAuth(); + const { mutateAsync: updateMutateAsync } = useUpdateIdentityLdapAuth(); + const [tabValue, setTabValue] = useState(IdentityFormTab.Configuration); + + const { data } = useGetIdentityLdapAuth(identityId ?? "", { + enabled: isUpdate + }); + + const { + control, + handleSubmit, + reset, + + formState: { isSubmitting } + } = useForm({ + resolver: zodResolver(schema), + defaultValues: { + url: "", + bindDN: "", + bindPass: "", + searchBase: "", + uniqueAttribute: "uidNumber", + searchFilter: "(uid={{username}})", + accessTokenTTL: "2592000", + accessTokenMaxTTL: "2592000", + accessTokenNumUsesLimit: "0", + accessTokenTrustedIps: [{ ipAddress: "0.0.0.0/0" }, { ipAddress: "::/0" }] + } + }); + + const { + fields: accessTokenTrustedIpsFields, + append: appendAccessTokenTrustedIp, + remove: removeAccessTokenTrustedIp + } = useFieldArray({ control, name: "accessTokenTrustedIps" }); + + const { + fields: allowedFieldsFields, + append: appendAllowedField, + remove: removeAllowedField + } = useFieldArray({ control, name: "allowedFields" }); + + useEffect(() => { + if (data) { + reset({ + url: data.url, + bindDN: data.bindDN, + bindPass: data.bindPass, + searchBase: data.searchBase, + uniqueAttribute: data.uniqueAttribute, + searchFilter: data.searchFilter, + ldapCaCertificate: data.ldapCaCertificate || undefined, + allowedFields: data.allowedFields, + accessTokenTTL: String(data.accessTokenTTL), + accessTokenMaxTTL: String(data.accessTokenMaxTTL), + accessTokenNumUsesLimit: String(data.accessTokenNumUsesLimit), + accessTokenTrustedIps: data.accessTokenTrustedIps.map( + ({ ipAddress, prefix }: IdentityTrustedIp) => { + return { + ipAddress: `${ipAddress}${prefix !== undefined ? `/${prefix}` : ""}` + }; + } + ) + }); + } else { + reset({ + url: "", + bindDN: "", + bindPass: "", + searchBase: "", + uniqueAttribute: "uidNumber", + searchFilter: "(uid={{username}})", + ldapCaCertificate: undefined, + allowedFields: [], + accessTokenTTL: "2592000", + accessTokenMaxTTL: "2592000", + accessTokenNumUsesLimit: "0", + accessTokenTrustedIps: [{ ipAddress: "0.0.0.0/0" }, { ipAddress: "::/0" }] + }); + } + }, [data]); + + useEffect(() => { + if (!subscription?.ldap) { + handlePopUpOpen("upgradePlan"); + handlePopUpToggle("identityAuthMethod", false); + } + }, [subscription]); + + const onFormSubmit = async ({ + url, + bindDN, + bindPass, + searchBase, + uniqueAttribute, + searchFilter, + ldapCaCertificate, + allowedFields, + accessTokenTTL, + accessTokenMaxTTL, + accessTokenNumUsesLimit, + accessTokenTrustedIps + }: FormData) => { + try { + if (!identityId) return; + + if (data) { + await updateMutateAsync({ + organizationId: orgId, + identityId, + url, + bindDN, + bindPass, + searchBase, + searchFilter, + uniqueAttribute, + ldapCaCertificate, + allowedFields, + accessTokenTTL: Number(accessTokenTTL), + accessTokenMaxTTL: Number(accessTokenMaxTTL), + accessTokenNumUsesLimit: Number(accessTokenNumUsesLimit), + accessTokenTrustedIps + }); + } else { + await addMutateAsync({ + organizationId: orgId, + identityId, + url, + bindDN, + bindPass, + searchBase, + searchFilter, + uniqueAttribute, + ldapCaCertificate, + allowedFields, + accessTokenTTL: Number(accessTokenTTL), + accessTokenMaxTTL: Number(accessTokenMaxTTL), + accessTokenNumUsesLimit: Number(accessTokenNumUsesLimit), + accessTokenTrustedIps + }); + } + + handlePopUpToggle("identityAuthMethod", false); + + createNotification({ + text: `Successfully ${isUpdate ? "updated" : "configured"} auth method`, + type: "success" + }); + + reset(); + } catch { + createNotification({ + text: `Failed to ${isUpdate ? "update" : "configure"} identity`, + type: "error" + }); + } + }; + + return ( +
{ + setTabValue( + [ + "url", + "bindDN", + "bindPass", + "searchBase", + "searchFilter", + "uniqueAttribute", + "accessTokenTTL", + "accessTokenMaxTTL", + "accessTokenNumUsesLimit" + ].includes(Object.keys(fields)[0]) + ? IdentityFormTab.Configuration + : IdentityFormTab.Advanced + ); + })} + > + setTabValue(value as IdentityFormTab)}> + + Configuration + Advanced + + + ( + + + + )} + /> + ( + + + + )} + /> + ( + + + + )} + /> + ( + + + + )} + /> + + ( + + + + )} + /> + + ( + + + + )} + /> + + ( + + + + )} + /> + ( + + + + )} + /> + ( + + + + )} + /> + + + ( + +