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 2458454da..1d33cafd6 100644 --- a/backend/src/ee/services/group/group-dal.ts +++ b/backend/src/ee/services/group/group-dal.ts @@ -157,10 +157,23 @@ export const groupDALFactory = (db: TDbClient) => { } }; + const findGroupsByProjectId = async (projectId: string, tx?: Knex) => { + try { + const docs = await (tx || db.replicaNode())(TableName.Groups) + .join(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 9653af942..a1acaeb21 100644 --- a/backend/src/ee/services/permission/permission-service.ts +++ b/backend/src/ee/services/permission/permission-service.ts @@ -630,6 +630,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, @@ -639,6 +667,7 @@ export const permissionServiceFactory = ({ getOrgPermissionByRole, getProjectPermissionByRole, buildOrgPermission, - buildProjectPermissionRules + buildProjectPermissionRules, + checkGroupProjectPermission }; }; diff --git a/backend/src/ee/services/ssh-host-group/ssh-host-group-dal.ts b/backend/src/ee/services/ssh-host-group/ssh-host-group-dal.ts index 08242d4cb..2f57cce3a 100644 --- a/backend/src/ee/services/ssh-host-group/ssh-host-group-dal.ts +++ b/backend/src/ee/services/ssh-host-group/ssh-host-group-dal.ts @@ -28,6 +28,7 @@ export const sshHostGroupDALFactory = (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.SshHostGroup}.projectId`, projectId) .select( db.ref("id").withSchema(TableName.SshHostGroup).as("sshHostGroupId"), @@ -35,7 +36,8 @@ export const sshHostGroupDALFactory = (db: TDbClient) => { db.ref("name").withSchema(TableName.SshHostGroup), db.ref("loginUser").withSchema(TableName.SshHostLoginUser), db.ref("username").withSchema(TableName.Users), - db.ref("userId").withSchema(TableName.SshHostLoginUserMapping) + db.ref("userId").withSchema(TableName.SshHostLoginUserMapping), + db.ref("slug").withSchema(TableName.Groups).as("groupSlug") ) .orderBy(`${TableName.SshHostGroup}.updatedAt`, "desc"); @@ -69,7 +71,8 @@ export const sshHostGroupDALFactory = (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) } })); return { @@ -99,6 +102,7 @@ export const sshHostGroupDALFactory = (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.SshHostGroup}.id`, sshHostGroupId) .select( db.ref("id").withSchema(TableName.SshHostGroup).as("sshHostGroupId"), @@ -106,7 +110,8 @@ export const sshHostGroupDALFactory = (db: TDbClient) => { db.ref("name").withSchema(TableName.SshHostGroup), db.ref("loginUser").withSchema(TableName.SshHostLoginUser), db.ref("username").withSchema(TableName.Users), - db.ref("userId").withSchema(TableName.SshHostLoginUserMapping) + db.ref("userId").withSchema(TableName.SshHostLoginUserMapping), + db.ref("slug").withSchema(TableName.Groups).as("groupSlug") ); if (rows.length === 0) return null; @@ -121,7 +126,8 @@ export const sshHostGroupDALFactory = (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-group/ssh-host-group-service.ts b/backend/src/ee/services/ssh-host-group/ssh-host-group-service.ts index 751116895..1eacc7602 100644 --- a/backend/src/ee/services/ssh-host-group/ssh-host-group-service.ts +++ b/backend/src/ee/services/ssh-host-group/ssh-host-group-service.ts @@ -12,6 +12,7 @@ import { BadRequestError, NotFoundError } from "@app/lib/errors"; import { TProjectDALFactory } from "@app/services/project/project-dal"; import { TUserDALFactory } from "@app/services/user/user-dal"; +import { TGroupDALFactory } from "../group/group-dal"; import { TLicenseServiceFactory } from "../license/license-service"; import { createSshLoginMappings } from "../ssh-host/ssh-host-fns"; import { @@ -43,8 +44,12 @@ type TSshHostGroupServiceFactoryDep = { sshHostLoginUserDAL: Pick; sshHostLoginUserMappingDAL: Pick; userDAL: Pick; - permissionService: Pick; + permissionService: Pick< + TPermissionServiceFactory, + "getProjectPermission" | "getUserProjectPermission" | "checkGroupProjectPermission" + >; licenseService: Pick; + groupDAL: Pick; }; export type TSshHostGroupServiceFactory = ReturnType; @@ -58,7 +63,8 @@ export const sshHostGroupServiceFactory = ({ sshHostLoginUserMappingDAL, userDAL, permissionService, - licenseService + licenseService, + groupDAL }: TSshHostGroupServiceFactoryDep) => { const createSshHostGroup = async ({ projectId, @@ -127,6 +133,7 @@ export const sshHostGroupServiceFactory = ({ loginMappings, sshHostLoginUserDAL, sshHostLoginUserMappingDAL, + groupDAL, userDAL, permissionService, projectId, @@ -194,6 +201,7 @@ export const sshHostGroupServiceFactory = ({ loginMappings, sshHostLoginUserDAL, sshHostLoginUserMappingDAL, + groupDAL, userDAL, permissionService, projectId: sshHostGroup.projectId, diff --git a/backend/src/ee/services/ssh-host-group/ssh-host-group-types.ts b/backend/src/ee/services/ssh-host-group/ssh-host-group-types.ts index 3485b5d26..52f805f02 100644 --- a/backend/src/ee/services/ssh-host-group/ssh-host-group-types.ts +++ b/backend/src/ee/services/ssh-host-group/ssh-host-group-types.ts @@ -9,12 +9,7 @@ export type TCreateSshHostGroupDTO = { export type TUpdateSshHostGroupDTO = { sshHostGroupId: string; name?: string; - loginMappings?: { - loginUser: string; - allowedPrincipals: { - usernames: string[]; - }; - }[]; + loginMappings?: TLoginMapping[]; } & Omit; export type TGetSshHostGroupDTO = { 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 e66f7da7a..3b8564ce2 100644 --- a/backend/src/ee/services/ssh-host/ssh-host-dal.ts +++ b/backend/src/ee/services/ssh-host/ssh-host-dal.ts @@ -31,8 +31,18 @@ export const sshHostDALFactory = (db: TDbClient) => { `${TableName.SshHostLoginUser}.id`, `${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), @@ -58,8 +68,17 @@ export const sshHostDALFactory = (db: TDbClient) => { `${TableName.SshHostLoginUserMapping}.sshHostLoginUserId` ) .join(TableName.SshHost, `${TableName.SshHostGroupMembership}.sshHostId`, `${TableName.SshHost}.id`) + .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), @@ -133,6 +152,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"), @@ -144,6 +164,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) ) @@ -163,10 +184,12 @@ 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`) .select( db.ref("sshHostId").withSchema(TableName.SshHostGroupMembership), db.ref("loginUser").withSchema(TableName.SshHostLoginUser), - db.ref("username").withSchema(TableName.Users) + db.ref("username").withSchema(TableName.Users), + db.ref("slug").withSchema(TableName.Groups).as("groupSlug") ) .whereIn(`${TableName.SshHostGroupMembership}.sshHostId`, hostIds); @@ -185,7 +208,8 @@ export const sshHostDALFactory = (db: TDbClient) => { const directMappings = 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) }, source: LoginMappingSource.HOST })); @@ -197,7 +221,8 @@ export const sshHostDALFactory = (db: TDbClient) => { const groupMappings = Object.entries(inheritedGrouped).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) }, source: LoginMappingSource.HOST_GROUP })); @@ -229,6 +254,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"), @@ -241,7 +267,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; @@ -257,7 +284,8 @@ export const sshHostDALFactory = (db: TDbClient) => { const directMappings = Object.entries(directGrouped).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) }, source: LoginMappingSource.HOST })); @@ -275,10 +303,12 @@ 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.SshHostGroupMembership}.sshHostId`, sshHostId) .select( db.ref("loginUser").withSchema(TableName.SshHostLoginUser), - db.ref("username").withSchema(TableName.Users) + db.ref("username").withSchema(TableName.Users), + db.ref("slug").withSchema(TableName.Groups).as("groupSlug") ); const groupGrouped = groupBy( @@ -289,7 +319,8 @@ export const sshHostDALFactory = (db: TDbClient) => { const groupMappings = Object.entries(groupGrouped).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) }, source: LoginMappingSource.HOST_GROUP })); diff --git a/backend/src/ee/services/ssh-host/ssh-host-fns.ts b/backend/src/ee/services/ssh-host/ssh-host-fns.ts index 9b9ce2642..dec15e093 100644 --- a/backend/src/ee/services/ssh-host/ssh-host-fns.ts +++ b/backend/src/ee/services/ssh-host/ssh-host-fns.ts @@ -3,6 +3,7 @@ import { Knex } from "knex"; import { ActionProjectType } from "@app/db/schemas"; import { BadRequestError } from "@app/lib/errors"; +import { ProjectPermissionSshHostActions, ProjectPermissionSub } from "../permission/project-permission"; import { TCreateSshLoginMappingsDTO } from "./ssh-host-types"; /** @@ -15,6 +16,7 @@ export const createSshLoginMappings = async ({ loginMappings, sshHostLoginUserDAL, sshHostLoginUserMappingDAL, + groupDAL, userDAL, permissionService, projectId, @@ -35,7 +37,7 @@ export const createSshLoginMappings = async ({ tx ); - if (allowedPrincipals.usernames.length > 0) { + if (allowedPrincipals.usernames && allowedPrincipals.usernames.length > 0) { const users = await userDAL.find( { $in: { @@ -74,6 +76,41 @@ export const createSshLoginMappings = async ({ tx ); } + + if (allowedPrincipals.groups && allowedPrincipals.groups.length > 0) { + const projectGroups = await groupDAL.findGroupsByProjectId(projectId); + const groups = projectGroups.filter((g) => allowedPrincipals.groups?.includes(g.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) { + // 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 + ); + } } }; 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 87f4862bb..79c74cd57 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, @@ -39,12 +41,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" @@ -58,7 +62,10 @@ type TSshHostServiceFactoryDep = { >; sshHostLoginUserDAL: TSshHostLoginUserDALFactory; sshHostLoginUserMappingDAL: TSshHostLoginUserMappingDALFactory; - permissionService: Pick; + permissionService: Pick< + TPermissionServiceFactory, + "getProjectPermission" | "getUserProjectPermission" | "checkGroupProjectPermission" + >; kmsService: Pick; }; @@ -66,6 +73,8 @@ export type TSshHostServiceFactory = ReturnType; export const sshHostServiceFactory = ({ userDAL, + userGroupMembershipDAL, + groupDAL, projectDAL, projectSshConfigDAL, sshCertificateAuthorityDAL, @@ -208,6 +217,7 @@ export const sshHostServiceFactory = ({ loginMappings, sshHostLoginUserDAL, sshHostLoginUserMappingDAL, + groupDAL, userDAL, permissionService, projectId, @@ -278,6 +288,7 @@ export const sshHostServiceFactory = ({ loginMappings, sshHostLoginUserDAL, sshHostLoginUserMappingDAL, + groupDAL, userDAL, permissionService, projectId: host.projectId, @@ -387,10 +398,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 9846920b7..c0a780fbb 100644 --- a/backend/src/ee/services/ssh-host/ssh-host-types.ts +++ b/backend/src/ee/services/ssh-host/ssh-host-types.ts @@ -7,12 +7,15 @@ import { TProjectPermission } from "@app/lib/types"; import { ActorAuthMethod } from "@app/services/auth/auth-type"; import { TUserDALFactory } from "@app/services/user/user-dal"; +import { TGroupDALFactory } from "../group/group-dal"; + export type TListSshHostsDTO = Omit; export type TLoginMapping = { loginUser: string; allowedPrincipals: { - usernames: string[]; + usernames?: string[]; + groups?: string[]; }; }; @@ -63,7 +66,8 @@ type BaseCreateSshLoginMappingsDTO = { sshHostLoginUserDAL: Pick; sshHostLoginUserMappingDAL: Pick; userDAL: Pick; - permissionService: Pick; + permissionService: Pick; + groupDAL: Pick; projectId: string; actorAuthMethod: ActorAuthMethod; actorOrgId: string; diff --git a/backend/src/lib/api-docs/constants.ts b/backend/src/lib/api-docs/constants.ts index 5c44ec4b1..6805575f2 100644 --- a/backend/src/lib/api-docs/constants.ts +++ b/backend/src/lib/api-docs/constants.ts @@ -1478,7 +1478,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: @@ -1493,7 +1493,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 545e0fb74..cb9931ec2 100644 --- a/backend/src/server/routes/index.ts +++ b/backend/src/server/routes/index.ts @@ -870,6 +870,8 @@ export const registerRoutes = async ( const sshHostService = sshHostServiceFactory({ userDAL, + groupDAL, + userGroupMembershipDAL, projectDAL, projectSshConfigDAL, sshCertificateAuthorityDAL, @@ -892,7 +894,8 @@ export const registerRoutes = async ( sshHostLoginUserMappingDAL, userDAL, permissionService, - licenseService + licenseService, + groupDAL }); const certificateAuthorityService = certificateAuthorityServiceFactory({ diff --git a/frontend/src/hooks/api/sshHost/types.ts b/frontend/src/hooks/api/sshHost/types.ts index e92ddeaa8..ff33664b1 100644 --- a/frontend/src/hooks/api/sshHost/types.ts +++ b/frontend/src/hooks/api/sshHost/types.ts @@ -6,7 +6,8 @@ export enum LoginMappingSource { export type TLoginMapping = { loginUser: string; allowedPrincipals: { - usernames: string[]; + usernames?: string[]; + groups?: string[]; }; source: LoginMappingSource; }; diff --git a/frontend/src/hooks/api/workspace/query-keys.tsx b/frontend/src/hooks/api/workspace/query-keys.tsx index 91fe95a04..335248a80 100644 --- a/frontend/src/hooks/api/workspace/query-keys.tsx +++ b/frontend/src/hooks/api/workspace/query-keys.tsx @@ -16,8 +16,11 @@ export const workspaceKeys = { type ? ["workspaces", { type }] : (["workspaces"] as const), getWorkspaceAuditLogs: (workspaceId: string) => [{ workspaceId }, "workspace-audit-logs"] as const, - getWorkspaceUsers: (workspaceId: string, includeGroupMembers?: boolean, roles?: string[]) => - [{ workspaceId, includeGroupMembers, roles }, "workspace-users"] as const, + getWorkspaceUsers: ( + workspaceId: string, + includeGroupMembers: boolean = false, + roles: string[] = [] + ) => [{ workspaceId, includeGroupMembers, roles }, "workspace-users"] as const, getWorkspaceUserDetails: (workspaceId: string, membershipId: string) => [{ workspaceId, membershipId }, "workspace-user-details"] as const, getWorkspaceIdentityMemberships: (workspaceId: string) => diff --git a/frontend/src/pages/organization/SettingsPage/route.tsx b/frontend/src/pages/organization/SettingsPage/route.tsx index 3f7beb4e8..e7fdb706a 100644 --- a/frontend/src/pages/organization/SettingsPage/route.tsx +++ b/frontend/src/pages/organization/SettingsPage/route.tsx @@ -1,33 +1,37 @@ -import { faHome } from "@fortawesome/free-solid-svg-icons"; -import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; -import { createFileRoute, linkOptions, stripSearchParams } from "@tanstack/react-router"; -import { zodValidator } from "@tanstack/zod-adapter"; -import { z } from "zod"; +import { faHome } from '@fortawesome/free-solid-svg-icons' +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' +import { + createFileRoute, + linkOptions, + stripSearchParams, +} from '@tanstack/react-router' +import { zodValidator } from '@tanstack/zod-adapter' +import { z } from 'zod' -import { SettingsPage } from "./SettingsPage"; +import { SettingsPage } from './SettingsPage' const SettingsPageQueryParams = z.object({ - selectedTab: z.string().catch("") -}); + selectedTab: z.string().catch(''), +}) export const Route = createFileRoute( - "/_authenticate/_inject-org-details/_org-layout/organization/settings/" + '/_authenticate/_inject-org-details/_org-layout/organization/settings/', )({ component: SettingsPage, validateSearch: zodValidator(SettingsPageQueryParams), search: { - middlewares: [stripSearchParams({ selectedTab: "" })] + middlewares: [stripSearchParams({ selectedTab: '' })], }, context: () => ({ breadcrumbs: [ { - label: "Home", + label: 'Home', icon: () => , - link: linkOptions({ to: "/" }) + link: linkOptions({ to: '/' }), }, { - label: "Settings" - } - ] - }) -}); + label: 'Settings', + }, + ], + }), +}) diff --git a/frontend/src/pages/project/RoleDetailsBySlugPage/components/SshHostPermissionConditions.tsx b/frontend/src/pages/project/RoleDetailsBySlugPage/components/SshHostPermissionConditions.tsx index b82d86d81..85cba0d94 100644 --- a/frontend/src/pages/project/RoleDetailsBySlugPage/components/SshHostPermissionConditions.tsx +++ b/frontend/src/pages/project/RoleDetailsBySlugPage/components/SshHostPermissionConditions.tsx @@ -125,7 +125,7 @@ export const SshHostPermissionConditions = ({ position = 0, isDisabled }: Props) errorText={error?.message} className="mb-0 flex-grow" > - + field.onChange(e.target.value.trim())} /> )} /> diff --git a/frontend/src/pages/ssh/SshHostsPage/components/SshHostGroupModal.tsx b/frontend/src/pages/ssh/SshHostsPage/components/SshHostGroupModal.tsx index de5d82311..84be5a96d 100644 --- a/frontend/src/pages/ssh/SshHostsPage/components/SshHostGroupModal.tsx +++ b/frontend/src/pages/ssh/SshHostsPage/components/SshHostGroupModal.tsx @@ -22,6 +22,7 @@ import { useCreateSshHostGroup, useGetSshHostGroupById, useGetWorkspaceUsers, + useListWorkspaceGroups, useListWorkspaceSshHostGroups, useUpdateSshHostGroup } from "@app/hooks/api"; @@ -38,7 +39,14 @@ const schema = z loginMappings: z .object({ loginUser: z.string().trim().min(1), - allowedPrincipals: z.array(z.string().trim()).default([]) + allowedPrincipals: z + .array( + z.object({ + type: z.enum(["user", "group"]), + value: z.string().trim().min(1) + }) + ) + .default([]) }) .array() .default([]) @@ -49,9 +57,10 @@ export type FormData = z.infer; export const SshHostGroupModal = ({ popUp, handlePopUpToggle }: Props) => { const { currentWorkspace } = useWorkspace(); - const projectId = currentWorkspace?.id || ""; - const { data: sshHostGroups } = useListWorkspaceSshHostGroups(currentWorkspace.id); + const projectId = currentWorkspace.id; + const { data: sshHostGroups } = useListWorkspaceSshHostGroups(projectId); const { data: members = [] } = useGetWorkspaceUsers(projectId); + const { data: groups = [] } = useListWorkspaceGroups(projectId); const [expandedMappings, setExpandedMappings] = useState>({}); const { data: sshHostGroup } = useGetSshHostGroupById( @@ -87,7 +96,16 @@ export const SshHostGroupModal = ({ popUp, handlePopUpToggle }: Props) => { name: sshHostGroup.name, loginMappings: sshHostGroup.loginMappings.map(({ loginUser, allowedPrincipals }) => ({ loginUser, - allowedPrincipals: allowedPrincipals.usernames + allowedPrincipals: [ + ...(allowedPrincipals.usernames || []).map((username) => ({ + type: "user" as const, + value: username + })), + ...(allowedPrincipals.groups || []).map((group) => ({ + type: "group" as const, + value: group + })) + ] })) }); @@ -118,27 +136,35 @@ export const SshHostGroupModal = ({ popUp, handlePopUpToggle }: Props) => { return; } + const transformedLoginMappings = loginMappings.map(({ loginUser, allowedPrincipals }) => { + const usernames = allowedPrincipals + .filter((p) => p.type === "user" && p.value) + .map((p) => p.value); + + const groupNames = allowedPrincipals + .filter((p) => p.type === "group" && p.value) + .map((p) => p.value); + + return { + loginUser, + allowedPrincipals: { + usernames, + groups: groupNames + } + }; + }); + if (sshHostGroup) { await updateMutateAsync({ sshHostGroupId: sshHostGroup.id, name, - loginMappings: loginMappings.map(({ loginUser, allowedPrincipals }) => ({ - loginUser, - allowedPrincipals: { - usernames: allowedPrincipals - } - })) + loginMappings: transformedLoginMappings }); } else { await createMutateAsync({ projectId, name, - loginMappings: loginMappings.map(({ loginUser, allowedPrincipals }) => ({ - loginUser, - allowedPrincipals: { - usernames: allowedPrincipals - } - })) + loginMappings: transformedLoginMappings }); } @@ -165,6 +191,15 @@ export const SshHostGroupModal = ({ popUp, handlePopUpToggle }: Props) => { })); }; + const isPrincipalDuplicate = ( + mappingIndex: number, + principalType: string, + principalValue: string + ) => { + const principals = getValues(`loginMappings.${mappingIndex}.allowedPrincipals`) || []; + 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: "", + allowedPrincipals: [{ type: "user", value: "" }] + }); setExpandedMappings((prev) => ({ ...prev, [newIndex]: true @@ -299,7 +337,10 @@ export const SshHostGroupModal = ({ popUp, handlePopUpToggle }: Props) => { variant="outline_bg" onClick={() => { const current = getValues(`loginMappings.${i}.allowedPrincipals`) ?? []; - setValue(`loginMappings.${i}.allowedPrincipals`, [...current, ""]); + setValue(`loginMappings.${i}.allowedPrincipals`, [ + ...current, + { type: "user", value: "" } + ]); }} > Add Principal @@ -310,40 +351,69 @@ export const SshHostGroupModal = ({ popUp, handlePopUpToggle }: Props) => { name={`loginMappings.${i}.allowedPrincipals`} render={({ field: { value = [], onChange }, fieldState: { error } }) => (
- {(value.length === 0 ? [""] : value).map( - (principal: string, principalIndex: number) => ( -
-
- -
+ {value.map((principal, principalIndex) => ( +
+
+ +
+
+ +
+
{ const newPrincipals = value.filter( (_, idx) => idx !== principalIndex ); - onChange(newPrincipals); + onChange(newPrincipals.length ? newPrincipals : []); }} >
- ) - )} +
+ ))} {error && {error.message}}
)} diff --git a/frontend/src/pages/ssh/SshHostsPage/components/SshHostGroupsSection.tsx b/frontend/src/pages/ssh/SshHostsPage/components/SshHostGroupsSection.tsx index 70036d88a..e2c731f97 100644 --- a/frontend/src/pages/ssh/SshHostsPage/components/SshHostGroupsSection.tsx +++ b/frontend/src/pages/ssh/SshHostsPage/components/SshHostGroupsSection.tsx @@ -1,4 +1,4 @@ -import { faPlus } from "@fortawesome/free-solid-svg-icons"; +import { faArrowUpRightFromSquare, faPlus } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { UpgradePlanModal } from "@app/components/license/UpgradePlanModal"; @@ -56,22 +56,38 @@ export const SshHostGroupsSection = () => {

Host Groups

- - {(isAllowed) => ( - - )} - +
+ + + Documentation{" "} + + + + + {(isAllowed) => ( + + )} + +
diff --git a/frontend/src/pages/ssh/SshHostsPage/components/SshHostGroupsTable.tsx b/frontend/src/pages/ssh/SshHostsPage/components/SshHostGroupsTable.tsx index 194d9e90e..59fcdfdb5 100644 --- a/frontend/src/pages/ssh/SshHostsPage/components/SshHostGroupsTable.tsx +++ b/frontend/src/pages/ssh/SshHostsPage/components/SshHostGroupsTable.tsx @@ -1,10 +1,18 @@ -import { faEllipsis, faPencil, faServer, faTrash } from "@fortawesome/free-solid-svg-icons"; +import { + faEllipsis, + faPencil, + faServer, + faTrash, + faUser, + faUsers +} from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { useNavigate } from "@tanstack/react-router"; import { twMerge } from "tailwind-merge"; import { ProjectPermissionCan } from "@app/components/permissions"; import { + Badge, DropdownMenu, DropdownMenuContent, DropdownMenuItem, @@ -77,9 +85,40 @@ export const SshHostGroupsTable = ({ handlePopUpOpen }: Props) => { group.loginMappings.map(({ loginUser, allowedPrincipals }) => (
{loginUser}
- {allowedPrincipals.usernames.map((username) => ( -
- └─ {username} + {allowedPrincipals.usernames?.map((username) => ( +
+
+ └─ +
+
+ + {username} + user +
+
+ ))} + {allowedPrincipals.groups?.map((allowedGroup) => ( +
+
+ └─ +
+
+ + {allowedGroup} + group +
))}
diff --git a/frontend/src/pages/ssh/SshHostsPage/components/SshHostModal.tsx b/frontend/src/pages/ssh/SshHostsPage/components/SshHostModal.tsx index 5ac53a16e..5619a75c4 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"; @@ -49,7 +50,14 @@ const schema = z loginMappings: z .object({ loginUser: z.string().trim().min(1), - allowedPrincipals: z.array(z.string().trim()).default([]), + allowedPrincipals: z + .array( + z.object({ + type: z.enum(["user", "group"]), + value: z.string().trim().min(1) + }) + ) + .default([]), source: z.nativeEnum(LoginMappingSource) }) .array() @@ -64,6 +72,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( @@ -103,7 +112,16 @@ export const SshHostModal = ({ popUp, handlePopUpToggle }: Props) => { userCertTtl: sshHost.userCertTtl, loginMappings: sshHost.loginMappings.map(({ loginUser, allowedPrincipals, source }) => ({ loginUser, - allowedPrincipals: allowedPrincipals.usernames, + allowedPrincipals: [ + ...(allowedPrincipals.usernames || []).map((username) => ({ + type: "user" as const, + value: username + })), + ...(allowedPrincipals.groups || []).map((group) => ({ + type: "group" as const, + value: group + })) + ], source })) }); @@ -159,18 +177,31 @@ export const SshHostModal = ({ popUp, handlePopUpToggle }: Props) => { } } + const transformedLoginMappings = hostLoginMappings.map(({ loginUser, allowedPrincipals }) => { + const usernames = allowedPrincipals + .filter((p) => p.type === "user" && p.value) + .map((p) => p.value); + + const groupNames = allowedPrincipals + .filter((p) => p.type === "group" && p.value) + .map((p) => p.value); + + return { + loginUser, + allowedPrincipals: { + usernames, + groups: groupNames + } + }; + }); + if (sshHost) { await updateMutateAsync({ sshHostId: sshHost.id, hostname, alias: trimmedAlias, userCertTtl, - loginMappings: hostLoginMappings.map(({ loginUser, allowedPrincipals }) => ({ - loginUser, - allowedPrincipals: { - usernames: allowedPrincipals - } - })) + loginMappings: transformedLoginMappings }); } else { await createMutateAsync({ @@ -178,12 +209,7 @@ export const SshHostModal = ({ popUp, handlePopUpToggle }: Props) => { hostname, alias: trimmedAlias, userCertTtl, - loginMappings: hostLoginMappings.map(({ loginUser, allowedPrincipals }) => ({ - loginUser, - allowedPrincipals: { - usernames: allowedPrincipals - } - })) + loginMappings: transformedLoginMappings }); } @@ -210,6 +236,15 @@ export const SshHostModal = ({ popUp, handlePopUpToggle }: Props) => { })); }; + const isPrincipalDuplicate = ( + mappingIndex: number, + principalType: string, + principalValue: string + ) => { + const principals = getValues(`loginMappings.${mappingIndex}.allowedPrincipals`) || []; + return principals.some((p) => p.type === principalType && p.value === principalValue); + }; + return ( { const newIndex = loginMappingsFormFields.fields.length; loginMappingsFormFields.append({ loginUser: "", - allowedPrincipals: [""], + allowedPrincipals: [], source: LoginMappingSource.HOST }); setExpandedMappings((prev) => ({ @@ -397,7 +432,10 @@ export const SshHostModal = ({ popUp, handlePopUpToggle }: Props) => { onClick={() => { const current = getValues(`loginMappings.${i}.allowedPrincipals`) ?? []; - setValue(`loginMappings.${i}.allowedPrincipals`, [...current, ""]); + setValue(`loginMappings.${i}.allowedPrincipals`, [ + ...current, + { type: "user", value: "" } + ]); }} > Add Principal @@ -409,50 +447,82 @@ export const SshHostModal = ({ popUp, handlePopUpToggle }: Props) => { name={`loginMappings.${i}.allowedPrincipals`} render={({ field: { value = [], onChange }, fieldState: { error } }) => (
- {(value.length === 0 ? [""] : value).map( - (principal: string, principalIndex: number) => ( -
-
- { + const newPrincipals = [...value]; + newPrincipals[principalIndex] = { + type: newType as "user" | "group", + value: "" + }; + onChange(newPrincipals); + }} + isDisabled={ + loginMappingsFormFields.fields[i].source === + LoginMappingSource.HOST_GROUP + } + > + User + Group + +
+
+ -
+ const newPrincipals = [...value]; + newPrincipals[principalIndex] = { + type: principal.type, + value: newValue + }; + onChange(newPrincipals); + }} + placeholder={`Select a ${principal.type}`} + className="w-full" + isDisabled={ + loginMappingsFormFields.fields[i].source === + LoginMappingSource.HOST_GROUP + } + > + {principal.type === "user" + ? members.map((member) => ( + + {member.user.username} + + )) + : groups.map((group) => ( + + {group.group.slug} + + ))} + +
+
{ const newPrincipals = value.filter( (_, idx) => idx !== principalIndex ); - onChange(newPrincipals); + onChange([...newPrincipals]); }} isDisabled={ loginMappingsFormFields.fields[i].source === @@ -478,8 +548,8 @@ export const SshHostModal = ({ popUp, handlePopUpToggle }: Props) => {
- ) - )} +
+ ))} {error && {error.message}}
)} diff --git a/frontend/src/pages/ssh/SshHostsPage/components/SshHostsSection.tsx b/frontend/src/pages/ssh/SshHostsPage/components/SshHostsSection.tsx index a3f9917ba..ed190db21 100644 --- a/frontend/src/pages/ssh/SshHostsPage/components/SshHostsSection.tsx +++ b/frontend/src/pages/ssh/SshHostsPage/components/SshHostsSection.tsx @@ -42,7 +42,7 @@ export const SshHostsSection = () => {

Hosts

-
+
{ const hostLoginUserToPrincipals = hostMappings.reduce( (acc, { loginUser, allowedPrincipals }) => { - acc[loginUser] = new Set(allowedPrincipals.usernames); + acc[loginUser] = { + users: new Set(allowedPrincipals.usernames), + groups: new Set(allowedPrincipals.groups) + }; return acc; }, - {} as Record> + {} as Record; groups: Set }> ); const entriesFromHost = hostMappings.map( ({ loginUser, allowedPrincipals }) => ({ loginUser, source: LoginMappingSource.HOST, - usernames: allowedPrincipals.usernames + users: allowedPrincipals.usernames, + groups: allowedPrincipals.groups }) ); const entriesFromGroup = groupMappings .map(({ loginUser, allowedPrincipals }) => { - const existing = hostLoginUserToPrincipals[loginUser] || new Set(); - const filteredUsernames = allowedPrincipals.usernames.filter( - (u) => !existing.has(u) + const existing = hostLoginUserToPrincipals[loginUser] || {}; + const filteredUsernames = allowedPrincipals.usernames?.filter( + (u) => !existing.users?.has(u) ); - return filteredUsernames.length > 0 + const filteredGroups = allowedPrincipals.groups?.filter( + (g) => !existing.groups?.has(g) + ); + return ((filteredGroups?.length || filteredUsernames?.length) ?? 0) > + 0 ? { loginUser, source: LoginMappingSource.HOST_GROUP, - usernames: filteredUsernames + users: filteredUsernames, + groups: filteredGroups } : null; }) .filter(Boolean) as { loginUser: string; source: LoginMappingSource; - usernames: string[]; + users: string[]; + groups: string[]; }[]; return [...entriesFromHost, ...entriesFromGroup] .sort((a, b) => a.loginUser.localeCompare(b.loginUser)) - .map(({ loginUser, usernames, source }) => ( + .map(({ loginUser, users, groups, source }) => (
{loginUser} @@ -147,12 +160,40 @@ export const SshHostsTable = ({ handlePopUpOpen }: Props) => { )}
- {usernames.map((username) => ( + {users?.map((username) => (
- └─ {username} +
+ └─ +
+
+ + {username} + user +
+
+ ))} + {groups?.map((group) => ( +
+
+ └─ +
+
+ + {group} + group +
))}