diff --git a/backend/src/ee/routes/v1/ssh-host-router.ts b/backend/src/ee/routes/v1/ssh-host-router.ts index 9db642d4d..739fec79a 100644 --- a/backend/src/ee/routes/v1/ssh-host-router.ts +++ b/backend/src/ee/routes/v1/ssh-host-router.ts @@ -3,6 +3,7 @@ import { z } from "zod"; import { EventType } from "@app/ee/services/audit-log/audit-log-types"; import { SshCertKeyAlgorithm } from "@app/ee/services/ssh-certificate/ssh-certificate-types"; import { loginMappingSchema, sanitizedSshHost } from "@app/ee/services/ssh-host/ssh-host-schema"; +import { LoginMappingSource } from "@app/ee/services/ssh-host/ssh-host-types"; import { isValidHostname } from "@app/ee/services/ssh-host/ssh-host-validators"; import { SSH_HOSTS } from "@app/lib/api-docs"; import { ms } from "@app/lib/ms"; @@ -24,7 +25,11 @@ export const registerSshHostRouter = async (server: FastifyZodProvider) => { response: { 200: z.array( sanitizedSshHost.extend({ - loginMappings: z.array(loginMappingSchema) + loginMappings: loginMappingSchema + .extend({ + source: z.nativeEnum(LoginMappingSource) + }) + .array() }) ) } @@ -54,7 +59,11 @@ export const registerSshHostRouter = async (server: FastifyZodProvider) => { }), response: { 200: sanitizedSshHost.extend({ - loginMappings: z.array(loginMappingSchema) + loginMappings: loginMappingSchema + .extend({ + source: z.nativeEnum(LoginMappingSource) + }) + .array() }) } }, @@ -119,7 +128,11 @@ export const registerSshHostRouter = async (server: FastifyZodProvider) => { }), response: { 200: sanitizedSshHost.extend({ - loginMappings: z.array(loginMappingSchema) + loginMappings: loginMappingSchema + .extend({ + source: z.nativeEnum(LoginMappingSource) + }) + .array() }) } }, @@ -192,7 +205,11 @@ export const registerSshHostRouter = async (server: FastifyZodProvider) => { }), response: { 200: sanitizedSshHost.extend({ - loginMappings: z.array(loginMappingSchema) + loginMappings: loginMappingSchema + .extend({ + source: z.nativeEnum(LoginMappingSource) + }) + .array() }) } }, @@ -240,7 +257,11 @@ export const registerSshHostRouter = async (server: FastifyZodProvider) => { }), response: { 200: sanitizedSshHost.extend({ - loginMappings: z.array(loginMappingSchema) + loginMappings: loginMappingSchema + .extend({ + source: z.nativeEnum(LoginMappingSource) + }) + .array() }) } }, diff --git a/backend/src/ee/services/license/__mocks__/license-fns.ts b/backend/src/ee/services/license/__mocks__/license-fns.ts index ca8fbcbc4..305c08d83 100644 --- a/backend/src/ee/services/license/__mocks__/license-fns.ts +++ b/backend/src/ee/services/license/__mocks__/license-fns.ts @@ -28,7 +28,8 @@ export const getDefaultOnPremFeatures = () => { has_used_trial: true, secretApproval: true, secretRotation: true, - caCrl: false + caCrl: false, + sshHostGroups: false }; }; diff --git a/backend/src/ee/services/license/licence-enums.ts b/backend/src/ee/services/license/licence-enums.ts index 047eb0a38..8812621f2 100644 --- a/backend/src/ee/services/license/licence-enums.ts +++ b/backend/src/ee/services/license/licence-enums.ts @@ -10,6 +10,7 @@ export const BillingPlanRows = { CustomAlerts: { name: "Custom alerts", field: "customAlerts" }, AuditLogs: { name: "Audit logs", field: "auditLogs" }, SamlSSO: { name: "SAML SSO", field: "samlSSO" }, + SshHostGroups: { name: "SSH Host Groups", field: "sshHostGroups" }, Hsm: { name: "Hardware Security Module (HSM)", field: "hsm" }, OidcSSO: { name: "OIDC SSO", field: "oidcSSO" }, SecretApproval: { name: "Secret approvals", field: "secretApproval" }, diff --git a/backend/src/ee/services/license/license-fns.ts b/backend/src/ee/services/license/license-fns.ts index 2870373c7..e347369b5 100644 --- a/backend/src/ee/services/license/license-fns.ts +++ b/backend/src/ee/services/license/license-fns.ts @@ -53,7 +53,8 @@ export const getDefaultOnPremFeatures = (): TFeatureSet => ({ enforceMfa: false, projectTemplates: false, kmip: false, - gateway: false + gateway: false, + sshHostGroups: false }); export const setupLicenseRequestWithStore = (baseURL: string, refreshUrl: string, licenseKey: string) => { diff --git a/backend/src/ee/services/license/license-types.ts b/backend/src/ee/services/license/license-types.ts index 923ef2eba..743350814 100644 --- a/backend/src/ee/services/license/license-types.ts +++ b/backend/src/ee/services/license/license-types.ts @@ -71,6 +71,7 @@ export type TFeatureSet = { projectTemplates: false; kmip: false; gateway: false; + sshHostGroups: false; }; export type TOrgPlansTableDTO = { 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 2d3580c2e..e88eee06e 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 @@ -8,9 +8,11 @@ import { TSshHostLoginUserMappingDALFactory } from "@app/ee/services/ssh-host/ss import { TSshHostLoginUserDALFactory } from "@app/ee/services/ssh-host/ssh-login-user-dal"; import { TSshHostGroupDALFactory } from "@app/ee/services/ssh-host-group/ssh-host-group-dal"; import { TSshHostGroupMembershipDALFactory } from "@app/ee/services/ssh-host-group/ssh-host-group-membership-dal"; -import { NotFoundError } from "@app/lib/errors"; +import { BadRequestError, NotFoundError } from "@app/lib/errors"; +import { TProjectDALFactory } from "@app/services/project/project-dal"; import { TUserDALFactory } from "@app/services/user/user-dal"; +import { TLicenseServiceFactory } from "../license/license-service"; import { createSshLoginMappings } from "../ssh-host/ssh-host-fns"; import { TAddHostToSshHostGroupDTO, @@ -23,7 +25,8 @@ import { } from "./ssh-host-group-types"; type TSshHostGroupServiceFactoryDep = { - sshHostDAL: TSshHostDALFactory; // TODO: Pick + projectDAL: Pick; + sshHostDAL: Pick; sshHostGroupDAL: Pick< TSshHostGroupDALFactory, | "create" @@ -33,24 +36,29 @@ type TSshHostGroupServiceFactoryDep = { | "transaction" | "findSshHostGroupByIdWithLoginMappings" | "findAllSshHostsInGroup" + | "findOne" + | "find" >; - sshHostGroupMembershipDAL: TSshHostGroupMembershipDALFactory; // TODO: Pick + sshHostGroupMembershipDAL: Pick; sshHostLoginUserDAL: Pick; sshHostLoginUserMappingDAL: Pick; userDAL: Pick; permissionService: Pick; + licenseService: Pick; }; export type TSshHostGroupServiceFactory = ReturnType; export const sshHostGroupServiceFactory = ({ + projectDAL, sshHostDAL, sshHostGroupDAL, sshHostGroupMembershipDAL, sshHostLoginUserDAL, sshHostLoginUserMappingDAL, userDAL, - permissionService + permissionService, + licenseService }: TSshHostGroupServiceFactoryDep) => { const createSshHostGroup = async ({ projectId, @@ -72,11 +80,38 @@ export const sshHostGroupServiceFactory = ({ ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Create, ProjectPermissionSub.SshHostGroups); + const plan = await licenseService.getPlan(actorOrgId); + if (!plan.sshHostGroups) + throw new BadRequestError({ + message: "Failed to create SSH host group due to plan restriction. Upgrade plan to create group." + }); + const newSshHostGroup = await sshHostGroupDAL.transaction(async (tx) => { + // (dangtony98): room to optimize check to ensure that + // the SSH host group name is unique across the whole org + const project = await projectDAL.findById(projectId); + if (!project) throw new NotFoundError({ message: `Project with ID '${projectId}' not found` }); + const projects = await projectDAL.find({ + orgId: project.orgId + }); + + const existingSshHostGroup = await sshHostGroupDAL.find({ + name, + $in: { + projectId: projects.map((p) => p.id) + } + }); + + if (existingSshHostGroup.length) { + throw new BadRequestError({ + message: `SSH host group with name '${name}' already exists in the organization` + }); + } + const sshHostGroup = await sshHostGroupDAL.create( { projectId, - name // TODO: check that this is unique across the whole org + name }, tx ); @@ -131,6 +166,12 @@ export const sshHostGroupServiceFactory = ({ ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Edit, ProjectPermissionSub.SshHostGroups); + const plan = await licenseService.getPlan(actorOrgId); + if (!plan.sshHostGroups) + throw new BadRequestError({ + message: "Failed to update SSH host group due to plan restriction. Upgrade plan to update group." + }); + const updatedSshHostGroup = await sshHostGroupDAL.transaction(async (tx) => { await sshHostGroupDAL.updateById( sshHostGroupId, @@ -280,8 +321,7 @@ export const sshHostGroupServiceFactory = ({ actionProjectType: ActionProjectType.SSH }); - ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Read, ProjectPermissionSub.SshHostGroups); - // TODO: look over permissioning + ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Edit, ProjectPermissionSub.SshHostGroups); await sshHostGroupMembershipDAL.create({ sshHostGroupId, sshHostId: hostId }); @@ -296,10 +336,6 @@ export const sshHostGroupServiceFactory = ({ actorAuthMethod, actorOrgId }: TRemoveHostFromSshHostGroupDTO) => { - console.log("removeHostFromSshHostGroup args: ", { - sshHostGroupId, - hostId - }); const sshHostGroup = await sshHostGroupDAL.findSshHostGroupByIdWithLoginMappings(sshHostGroupId); if (!sshHostGroup) throw new NotFoundError({ message: `SSH host group with ID '${sshHostGroupId}' not found` }); @@ -326,7 +362,6 @@ export const sshHostGroupServiceFactory = ({ }); ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Edit, ProjectPermissionSub.SshHostGroups); - // TODO: look over permissioning const sshHostGroupMembership = await sshHostGroupMembershipDAL.findOne({ sshHostGroupId, @@ -339,10 +374,6 @@ export const sshHostGroupServiceFactory = ({ }); } - console.log("boom: ", { - sshHostGroupMembership - }); - await sshHostGroupMembershipDAL.deleteById(sshHostGroupMembership.id); return { sshHostGroup, sshHost }; 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 e5c78cce4..3485b5d26 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 @@ -1,5 +1,5 @@ import { TLoginMapping } from "@app/ee/services/ssh-host/ssh-host-types"; -import { TGenericPermission, TProjectPermission } from "@app/lib/types"; +import { TProjectPermission } from "@app/lib/types"; export type TCreateSshHostGroupDTO = { name: string; @@ -15,29 +15,30 @@ export type TUpdateSshHostGroupDTO = { usernames: string[]; }; }[]; -} & TGenericPermission; +} & Omit; export type TGetSshHostGroupDTO = { sshHostGroupId: string; -} & TGenericPermission; +} & Omit; export type TDeleteSshHostGroupDTO = { sshHostGroupId: string; -} & TGenericPermission; +} & Omit; + export type TListSshHostGroupHostsDTO = { sshHostGroupId: string; filter?: EHostGroupMembershipFilter; -} & TGenericPermission; +} & Omit; export type TAddHostToSshHostGroupDTO = { sshHostGroupId: string; hostId: string; -} & TGenericPermission; +} & Omit; export type TRemoveHostFromSshHostGroupDTO = { sshHostGroupId: string; hostId: string; -} & TGenericPermission; +} & Omit; export enum EHostGroupMembershipFilter { GROUP_MEMBERS = "group-members", 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..f655fa9d1 100644 --- a/backend/src/ee/services/ssh-host/ssh-host-dal.ts +++ b/backend/src/ee/services/ssh-host/ssh-host-dal.ts @@ -6,6 +6,8 @@ import { DatabaseError } from "@app/lib/errors"; import { groupBy, unique } from "@app/lib/fn"; import { ormify } from "@app/lib/knex"; +import { LoginMappingSource } from "./ssh-host-types"; + export type TSshHostDALFactory = ReturnType; export const sshHostDALFactory = (db: TDbClient) => { @@ -13,20 +15,22 @@ export const sshHostDALFactory = (db: TDbClient) => { const findUserAccessibleSshHosts = async (projectIds: string[], userId: string, tx?: Knex) => { try { - const user = await (tx || db.replicaNode())(TableName.Users).where({ id: userId }).select("username").first(); + const knex = tx || db.replicaNode(); + + const user = await knex(TableName.Users).where({ id: userId }).select("username").first(); if (!user) { throw new DatabaseError({ name: `${TableName.Users}: UserNotFound`, error: new Error("User not found") }); } - const rows = await (tx || db.replicaNode())(TableName.SshHost) + // get hosts where user has direct login mappings + const directHostRows = await knex(TableName.SshHost) .leftJoin(TableName.SshHostLoginUser, `${TableName.SshHost}.id`, `${TableName.SshHostLoginUser}.sshHostId`) .leftJoin( TableName.SshHostLoginUserMapping, `${TableName.SshHostLoginUser}.id`, `${TableName.SshHostLoginUserMapping}.sshHostLoginUserId` ) - .leftJoin(TableName.Users, `${TableName.Users}.id`, `${TableName.SshHostLoginUserMapping}.userId`) .whereIn(`${TableName.SshHost}.projectId`, projectIds) .andWhere(`${TableName.SshHostLoginUserMapping}.userId`, userId) .select( @@ -37,26 +41,66 @@ export const sshHostDALFactory = (db: TDbClient) => { db.ref("userCertTtl").withSchema(TableName.SshHost), db.ref("hostCertTtl").withSchema(TableName.SshHost), db.ref("loginUser").withSchema(TableName.SshHostLoginUser), - db.ref("username").withSchema(TableName.Users), - db.ref("userId").withSchema(TableName.SshHostLoginUserMapping), db.ref("userSshCaId").withSchema(TableName.SshHost), db.ref("hostSshCaId").withSchema(TableName.SshHost) - ) - .orderBy(`${TableName.SshHost}.updatedAt`, "desc"); + ); - const grouped = groupBy(rows, (r) => r.sshHostId); - return Object.values(grouped).map((hostRows) => { + // get hosts where user has login mappings via host groups + const groupHostRows = await knex(TableName.SshHostGroupMembership) + .join( + TableName.SshHostLoginUser, + `${TableName.SshHostGroupMembership}.sshHostGroupId`, + `${TableName.SshHostLoginUser}.sshHostGroupId` + ) + .leftJoin( + TableName.SshHostLoginUserMapping, + `${TableName.SshHostLoginUser}.id`, + `${TableName.SshHostLoginUserMapping}.sshHostLoginUserId` + ) + .join(TableName.SshHost, `${TableName.SshHostGroupMembership}.sshHostId`, `${TableName.SshHost}.id`) + .whereIn(`${TableName.SshHost}.projectId`, projectIds) + .andWhere(`${TableName.SshHostLoginUserMapping}.userId`, userId) + .select( + db.ref("id").withSchema(TableName.SshHost).as("sshHostId"), + db.ref("projectId").withSchema(TableName.SshHost), + db.ref("hostname").withSchema(TableName.SshHost), + db.ref("alias").withSchema(TableName.SshHost), + db.ref("userCertTtl").withSchema(TableName.SshHost), + db.ref("hostCertTtl").withSchema(TableName.SshHost), + db.ref("loginUser").withSchema(TableName.SshHostLoginUser), + db.ref("userSshCaId").withSchema(TableName.SshHost), + db.ref("hostSshCaId").withSchema(TableName.SshHost) + ); + + const directHostRowsWithSource = directHostRows.map((row) => ({ + ...row, + source: LoginMappingSource.HOST + })); + + const groupHostRowsWithSource = groupHostRows.map((row) => ({ + ...row, + source: LoginMappingSource.HOST_GROUP + })); + + const mergedRows = [...directHostRowsWithSource, ...groupHostRowsWithSource]; + + const hostsGrouped = groupBy(mergedRows, (r) => r.sshHostId); + + return Object.values(hostsGrouped).map((hostRows) => { const { sshHostId, hostname, alias, userCertTtl, hostCertTtl, userSshCaId, hostSshCaId, projectId } = hostRows[0]; - const loginMappingGrouped = groupBy(hostRows, (r) => r.loginUser); - - const loginMappings = Object.entries(loginMappingGrouped).map(([loginUser]) => ({ - loginUser, - allowedPrincipals: { - usernames: [user.username] - } - })); + const loginMappingGrouped = groupBy(hostRows, (r) => `${r.loginUser}|${r.source}`); + const loginMappings = Object.entries(loginMappingGrouped).map(([key]) => { + const [loginUser, source] = key.split("|"); + return { + loginUser, + allowedPrincipals: { + usernames: [user.username] + }, + source: source as LoginMappingSource + }; + }); return { id: sshHostId, @@ -101,20 +145,57 @@ export const sshHostDALFactory = (db: TDbClient) => { ) .orderBy(`${TableName.SshHost}.updatedAt`, "desc"); + // process login mappings inherited from groups that hosts are part of + const hostIds = unique(rows.map((r) => r.sshHostId)).filter(Boolean); + const groupRows = await (tx || db.replicaNode())(TableName.SshHostGroupMembership) + .join( + TableName.SshHostLoginUser, + `${TableName.SshHostGroupMembership}.sshHostGroupId`, + `${TableName.SshHostLoginUser}.sshHostGroupId` + ) + .leftJoin( + TableName.SshHostLoginUserMapping, + `${TableName.SshHostLoginUser}.id`, + `${TableName.SshHostLoginUserMapping}.sshHostLoginUserId` + ) + .leftJoin(TableName.Users, `${TableName.SshHostLoginUserMapping}.userId`, `${TableName.Users}.id`) + .select( + db.ref("sshHostId").withSchema(TableName.SshHostGroupMembership), + db.ref("loginUser").withSchema(TableName.SshHostLoginUser), + db.ref("username").withSchema(TableName.Users) + ) + .whereIn(`${TableName.SshHostGroupMembership}.sshHostId`, hostIds); + + const groupedGroupMappings = groupBy(groupRows, (r) => r.sshHostId); + const hostsGrouped = groupBy(rows, (r) => r.sshHostId); return Object.values(hostsGrouped).map((hostRows) => { const { sshHostId, hostname, alias, userCertTtl, hostCertTtl, userSshCaId, hostSshCaId } = hostRows[0]; + // direct login mappings const loginMappingGrouped = groupBy( hostRows.filter((r) => r.loginUser), (r) => r.loginUser ); - const loginMappings = Object.entries(loginMappingGrouped).map(([loginUser, entries]) => ({ + const directMappings = Object.entries(loginMappingGrouped).map(([loginUser, entries]) => ({ loginUser, allowedPrincipals: { usernames: unique(entries.map((e) => e.username)).filter(Boolean) - } + }, + source: LoginMappingSource.HOST + })); + + // group-inherited login mappings + const inheritedGroupRows = groupedGroupMappings[sshHostId] || []; + const inheritedGrouped = groupBy(inheritedGroupRows, (r) => r.loginUser); + + const groupMappings = Object.entries(inheritedGrouped).map(([loginUser, entries]) => ({ + loginUser, + allowedPrincipals: { + usernames: unique(entries.map((e) => e.username)).filter(Boolean) + }, + source: LoginMappingSource.HOST_GROUP })); return { @@ -124,7 +205,7 @@ export const sshHostDALFactory = (db: TDbClient) => { projectId, userCertTtl, hostCertTtl, - loginMappings, + loginMappings: [...directMappings, ...groupMappings], userSshCaId, hostSshCaId }; @@ -163,16 +244,50 @@ export const sshHostDALFactory = (db: TDbClient) => { const { sshHostId: id, projectId, hostname, alias, userCertTtl, hostCertTtl, userSshCaId, hostSshCaId } = rows[0]; - const loginMappingGrouped = groupBy( + // direct login mappings + const directGrouped = groupBy( rows.filter((r) => r.loginUser), (r) => r.loginUser ); - const loginMappings = Object.entries(loginMappingGrouped).map(([loginUser, entries]) => ({ + const directMappings = Object.entries(directGrouped).map(([loginUser, entries]) => ({ loginUser, allowedPrincipals: { usernames: unique(entries.map((e) => e.username)).filter(Boolean) - } + }, + source: LoginMappingSource.HOST + })); + + // group login mappings + const groupRows = await (tx || db.replicaNode())(TableName.SshHostGroupMembership) + .join( + TableName.SshHostLoginUser, + `${TableName.SshHostGroupMembership}.sshHostGroupId`, + `${TableName.SshHostLoginUser}.sshHostGroupId` + ) + .leftJoin( + TableName.SshHostLoginUserMapping, + `${TableName.SshHostLoginUser}.id`, + `${TableName.SshHostLoginUserMapping}.sshHostLoginUserId` + ) + .leftJoin(TableName.Users, `${TableName.SshHostLoginUserMapping}.userId`, `${TableName.Users}.id`) + .where(`${TableName.SshHostGroupMembership}.sshHostId`, sshHostId) + .select( + db.ref("loginUser").withSchema(TableName.SshHostLoginUser), + db.ref("username").withSchema(TableName.Users) + ); + + const groupGrouped = groupBy( + groupRows.filter((r) => r.loginUser), + (r) => r.loginUser + ); + + const groupMappings = Object.entries(groupGrouped).map(([loginUser, entries]) => ({ + loginUser, + allowedPrincipals: { + usernames: unique(entries.map((e) => e.username)).filter(Boolean) + }, + source: LoginMappingSource.HOST_GROUP })); return { @@ -182,7 +297,7 @@ export const sshHostDALFactory = (db: TDbClient) => { alias, userCertTtl, hostCertTtl, - loginMappings, + loginMappings: [...directMappings, ...groupMappings], userSshCaId, hostSshCaId }; 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 7440300df..9846920b7 100644 --- a/backend/src/ee/services/ssh-host/ssh-host-types.ts +++ b/backend/src/ee/services/ssh-host/ssh-host-types.ts @@ -16,6 +16,11 @@ export type TLoginMapping = { }; }; +export enum LoginMappingSource { + HOST = "host", + HOST_GROUP = "hostGroup" +} + export type TCreateSshHostDTO = { hostname: string; alias?: string; diff --git a/backend/src/server/routes/index.ts b/backend/src/server/routes/index.ts index a53b24ec8..04c9482a5 100644 --- a/backend/src/server/routes/index.ts +++ b/backend/src/server/routes/index.ts @@ -855,13 +855,15 @@ export const registerRoutes = async ( }); const sshHostGroupService = sshHostGroupServiceFactory({ + projectDAL, sshHostDAL, sshHostGroupDAL, sshHostGroupMembershipDAL, sshHostLoginUserDAL, sshHostLoginUserMappingDAL, userDAL, - permissionService + permissionService, + licenseService }); const certificateAuthorityService = certificateAuthorityServiceFactory({ diff --git a/backend/src/server/routes/v2/project-router.ts b/backend/src/server/routes/v2/project-router.ts index d18034d0b..a223004a9 100644 --- a/backend/src/server/routes/v2/project-router.ts +++ b/backend/src/server/routes/v2/project-router.ts @@ -14,6 +14,7 @@ import { sanitizedSshCa } from "@app/ee/services/ssh/ssh-certificate-authority-s import { sanitizedSshCertificate } from "@app/ee/services/ssh-certificate/ssh-certificate-schema"; import { sanitizedSshCertificateTemplate } from "@app/ee/services/ssh-certificate-template/ssh-certificate-template-schema"; import { loginMappingSchema, sanitizedSshHost } from "@app/ee/services/ssh-host/ssh-host-schema"; +import { LoginMappingSource } from "@app/ee/services/ssh-host/ssh-host-types"; import { sanitizedSshHostGroup } from "@app/ee/services/ssh-host-group/ssh-host-group-schema"; import { ApiDocsTags, PROJECTS } from "@app/lib/api-docs"; import { readLimit, writeLimit } from "@app/server/config/rateLimiter"; @@ -632,7 +633,11 @@ export const registerProjectRouter = async (server: FastifyZodProvider) => { 200: z.object({ hosts: z.array( sanitizedSshHost.extend({ - loginMappings: z.array(loginMappingSchema) + loginMappings: loginMappingSchema + .extend({ + source: z.nativeEnum(LoginMappingSource) + }) + .array() }) ) }) @@ -666,7 +671,7 @@ export const registerProjectRouter = async (server: FastifyZodProvider) => { 200: z.object({ groups: z.array( sanitizedSshHostGroup.extend({ - loginMappings: z.array(loginMappingSchema), + loginMappings: loginMappingSchema.array(), hostCount: z.number() }) ) diff --git a/docs/api-reference/endpoints/ssh/groups/add-host.mdx b/docs/api-reference/endpoints/ssh/groups/add-host.mdx new file mode 100644 index 000000000..9f903eccd --- /dev/null +++ b/docs/api-reference/endpoints/ssh/groups/add-host.mdx @@ -0,0 +1,4 @@ +--- +title: "Add Host" +openapi: "POST /api/v1/ssh/host-groups/{sshHostGroupId}/hosts" +--- diff --git a/docs/api-reference/endpoints/ssh/groups/create.mdx b/docs/api-reference/endpoints/ssh/groups/create.mdx new file mode 100644 index 000000000..be4755d8e --- /dev/null +++ b/docs/api-reference/endpoints/ssh/groups/create.mdx @@ -0,0 +1,4 @@ +--- +title: "Create" +openapi: "POST /api/v1/ssh/host-groups" +--- diff --git a/docs/api-reference/endpoints/ssh/groups/delete.mdx b/docs/api-reference/endpoints/ssh/groups/delete.mdx new file mode 100644 index 000000000..19205f2eb --- /dev/null +++ b/docs/api-reference/endpoints/ssh/groups/delete.mdx @@ -0,0 +1,4 @@ +--- +title: "Delete" +openapi: "DELETE /api/v1/ssh/host-groups/{sshHostGroupId}" +--- diff --git a/docs/api-reference/endpoints/ssh/groups/list-hosts.mdx b/docs/api-reference/endpoints/ssh/groups/list-hosts.mdx new file mode 100644 index 000000000..2db1fd11f --- /dev/null +++ b/docs/api-reference/endpoints/ssh/groups/list-hosts.mdx @@ -0,0 +1,4 @@ +--- +title: "List Hosts" +openapi: "GET /api/v1/ssh/host-groups/{sshHostGroupId}/hosts" +--- diff --git a/docs/api-reference/endpoints/ssh/groups/list.mdx b/docs/api-reference/endpoints/ssh/groups/list.mdx new file mode 100644 index 000000000..089ab4096 --- /dev/null +++ b/docs/api-reference/endpoints/ssh/groups/list.mdx @@ -0,0 +1,4 @@ +--- +title: "List" +openapi: "GET /api/v2/workspace/{projectId}/ssh-host-groups" +--- diff --git a/docs/api-reference/endpoints/ssh/groups/read.mdx b/docs/api-reference/endpoints/ssh/groups/read.mdx new file mode 100644 index 000000000..060a75bce --- /dev/null +++ b/docs/api-reference/endpoints/ssh/groups/read.mdx @@ -0,0 +1,4 @@ +--- +title: "Retrieve" +openapi: "GET /api/v1/ssh/host-groups/{sshHostGroupId}" +--- diff --git a/docs/api-reference/endpoints/ssh/groups/remove-host.mdx b/docs/api-reference/endpoints/ssh/groups/remove-host.mdx new file mode 100644 index 000000000..6933e5c9f --- /dev/null +++ b/docs/api-reference/endpoints/ssh/groups/remove-host.mdx @@ -0,0 +1,4 @@ +--- +title: "Remove Host" +openapi: "DELETE /api/v1/ssh/host-groups/{sshHostGroupId}/hosts/{sshHostId}" +--- diff --git a/docs/api-reference/endpoints/ssh/groups/update.mdx b/docs/api-reference/endpoints/ssh/groups/update.mdx new file mode 100644 index 000000000..3e23bf4f9 --- /dev/null +++ b/docs/api-reference/endpoints/ssh/groups/update.mdx @@ -0,0 +1,4 @@ +--- +title: "Update" +openapi: "PATCH /api/v1/ssh/host-groups/{sshHostGroupId}" +--- diff --git a/docs/api-reference/endpoints/ssh/hosts/create.mdx b/docs/api-reference/endpoints/ssh/hosts/create.mdx new file mode 100644 index 000000000..5860b6b49 --- /dev/null +++ b/docs/api-reference/endpoints/ssh/hosts/create.mdx @@ -0,0 +1,4 @@ +--- +title: "Create" +openapi: "POST /api/v1/ssh/hosts" +--- diff --git a/docs/api-reference/endpoints/ssh/hosts/delete.mdx b/docs/api-reference/endpoints/ssh/hosts/delete.mdx new file mode 100644 index 000000000..c52129b33 --- /dev/null +++ b/docs/api-reference/endpoints/ssh/hosts/delete.mdx @@ -0,0 +1,4 @@ +--- +title: "Delete" +openapi: "DELETE /api/v1/ssh/hosts/{sshHostId}" +--- diff --git a/docs/api-reference/endpoints/ssh/hosts/issue-host-cert.mdx b/docs/api-reference/endpoints/ssh/hosts/issue-host-cert.mdx new file mode 100644 index 000000000..bb843e466 --- /dev/null +++ b/docs/api-reference/endpoints/ssh/hosts/issue-host-cert.mdx @@ -0,0 +1,4 @@ +--- +title: "Issue Host Certificate" +openapi: "POST /api/v1/ssh/hosts/{sshHostId}/issue-host-cert" +--- diff --git a/docs/api-reference/endpoints/ssh/hosts/issue-user-cert.mdx b/docs/api-reference/endpoints/ssh/hosts/issue-user-cert.mdx new file mode 100644 index 000000000..a16e03a65 --- /dev/null +++ b/docs/api-reference/endpoints/ssh/hosts/issue-user-cert.mdx @@ -0,0 +1,4 @@ +--- +title: "Issue User Certificate" +openapi: "POST /api/v1/ssh/hosts/{sshHostId}/issue-user-cert" +--- diff --git a/docs/api-reference/endpoints/ssh/hosts/list-my.mdx b/docs/api-reference/endpoints/ssh/hosts/list-my.mdx new file mode 100644 index 000000000..2b7ab51c0 --- /dev/null +++ b/docs/api-reference/endpoints/ssh/hosts/list-my.mdx @@ -0,0 +1,4 @@ +--- +title: "List My Hosts" +openapi: "GET /api/v1/ssh/hosts/" +--- diff --git a/docs/api-reference/endpoints/ssh/hosts/list.mdx b/docs/api-reference/endpoints/ssh/hosts/list.mdx new file mode 100644 index 000000000..3f61123e0 --- /dev/null +++ b/docs/api-reference/endpoints/ssh/hosts/list.mdx @@ -0,0 +1,4 @@ +--- +title: "List" +openapi: "GET /api/v2/workspace/{projectId}/ssh-hosts" +--- diff --git a/docs/api-reference/endpoints/ssh/hosts/read-host-ca-pk.mdx b/docs/api-reference/endpoints/ssh/hosts/read-host-ca-pk.mdx new file mode 100644 index 000000000..15ca53e9d --- /dev/null +++ b/docs/api-reference/endpoints/ssh/hosts/read-host-ca-pk.mdx @@ -0,0 +1,4 @@ +--- +title: "Read Host CA Public Key" +openapi: "GET /api/v1/ssh/hosts/{sshHostId}/host-ca-public-key" +--- diff --git a/docs/api-reference/endpoints/ssh/hosts/read-user-ca-pk.mdx b/docs/api-reference/endpoints/ssh/hosts/read-user-ca-pk.mdx new file mode 100644 index 000000000..a6f31e93f --- /dev/null +++ b/docs/api-reference/endpoints/ssh/hosts/read-user-ca-pk.mdx @@ -0,0 +1,4 @@ +--- +title: "Read User CA Public Key" +openapi: "GET /api/v1/ssh/hosts/{sshHostId}/user-ca-public-key" +--- diff --git a/docs/api-reference/endpoints/ssh/hosts/read.mdx b/docs/api-reference/endpoints/ssh/hosts/read.mdx new file mode 100644 index 000000000..112296a5a --- /dev/null +++ b/docs/api-reference/endpoints/ssh/hosts/read.mdx @@ -0,0 +1,4 @@ +--- +title: "Retrieve" +openapi: "GET /api/v1/ssh/hosts/{sshHostId}" +--- diff --git a/docs/api-reference/endpoints/ssh/hosts/update.mdx b/docs/api-reference/endpoints/ssh/hosts/update.mdx new file mode 100644 index 000000000..d5555b813 --- /dev/null +++ b/docs/api-reference/endpoints/ssh/hosts/update.mdx @@ -0,0 +1,4 @@ +--- +title: "Update" +openapi: "PATCH /api/v1/ssh/hosts/{sshHostId}" +--- diff --git a/docs/documentation/platform/ssh/host-groups.mdx b/docs/documentation/platform/ssh/host-groups.mdx new file mode 100644 index 000000000..5701b689d --- /dev/null +++ b/docs/documentation/platform/ssh/host-groups.mdx @@ -0,0 +1,56 @@ +--- +title: "Infisical SSH" +sidebarTitle: "Host Groups" +description: "Learn how to organize SSH hosts into groups and manage access policies at scale." +--- + +## Concept + +Infisical SSH lets you configure host groups to organize and manage multiple SSH hosts with shared access configuration. +These host groups can be created based on environments (`development`, `staging`, `production`), geographical regions (`us-east`, `eu-west`, `ap-northeast`), or functions (`web-servers`, `database-servers`, `worker-nodes`) to streamline access management across your infrastructure. + +Using a host group, you can define login mappings at the group level and have them be applied to all hosts assigned to that group. For example, you can specify that `john@acme.com` can login as `ubuntu` on all hosts assigned to the `production` host group. + +## Workflow + +The typical workflow for using Infisical SSH with host groups consists of the following steps: + +1. The administrator creates host groups based on logical groupings (environments, regions, functions, etc.). +2. The administrator configures login mappings at the host group level to define access policies. +3. The administrator registers remote hosts with Infisical using the Infisical CLI via the `infisical ssh add-host` command and assigns them to appropriate host groups either using the `--host-group` flag or by adding them to the host group via UI. +4. User(s) access the remote hosts using the Infisical CLI via the `infisical ssh connect` command, with access determined by the login mappings defined at both host and host group levels. + +## Admin Guide for Configuring Host Groups + +In the following steps, we'll walk through how to create and configure Host Groups in Infisical SSH, and how to add hosts to these groups. + + + + 1.1. Navigate to your Infisical SSH project and select the **Hosts** tab. + + 1.2. Click **Add Group** in the **Host Groups** section to create a new group. + + Enter a name (e.g., `production-servers` or `tokyo-region`) and login mapping(s) for the host group. + + A login mapping for a host group applies to all hosts assigned to the group and dictates what user(s) will be allowed access to the remote hosts + in that group under specific login user(s); in the allowed principals, you should select user(s) part of the Infisical SSH project that will + be allowed to login to the remote host as the login user. + + For instance, if you add a mapping to a host group with the login user `ec2-user` to some users John and Alice in Infisical, then they will be allowed to login to any remote host that is part of the group as `ec2-user` which is a system user that + exists on the remote host(s). + + ![ssh group add group](/images/platform/ssh/v2/ssh-group-add-group-1.png) + ![ssh group add group 2](/images/platform/ssh/v2/ssh-group-add-group-2.png) + + 1.3. Click **Add** to create the host group. + + + + + After creating the host group, you can assign a host to it from inside the host group page in the **SSH Hosts** section. Generally, this is where you'll manage the hosts in a group. + + ![ssh group add group - add host](/images/platform/ssh/v2/ssh-group-add-host-1.png) + ![ssh group add group - added host](/images/platform/ssh/v2/ssh-group-add-host-2.png) + + + diff --git a/docs/documentation/platform/ssh.mdx b/docs/documentation/platform/ssh/overview.mdx similarity index 99% rename from docs/documentation/platform/ssh.mdx rename to docs/documentation/platform/ssh/overview.mdx index 2bc433f75..e71eeabe1 100644 --- a/docs/documentation/platform/ssh.mdx +++ b/docs/documentation/platform/ssh/overview.mdx @@ -1,6 +1,6 @@ --- title: "Infisical SSH" -sidebarTitle: "Infisical SSH" +sidebarTitle: "Overview" description: "Learn how to securely provision user SSH access to your infrastructure using SSH certificates." --- diff --git a/docs/images/platform/ssh/v2/ssh-group-add-group-1.png b/docs/images/platform/ssh/v2/ssh-group-add-group-1.png new file mode 100644 index 000000000..be023335f Binary files /dev/null and b/docs/images/platform/ssh/v2/ssh-group-add-group-1.png differ diff --git a/docs/images/platform/ssh/v2/ssh-group-add-group-2.png b/docs/images/platform/ssh/v2/ssh-group-add-group-2.png new file mode 100644 index 000000000..0507d6e99 Binary files /dev/null and b/docs/images/platform/ssh/v2/ssh-group-add-group-2.png differ diff --git a/docs/images/platform/ssh/v2/ssh-group-add-host-1.png b/docs/images/platform/ssh/v2/ssh-group-add-host-1.png new file mode 100644 index 000000000..de8568f61 Binary files /dev/null and b/docs/images/platform/ssh/v2/ssh-group-add-host-1.png differ diff --git a/docs/images/platform/ssh/v2/ssh-group-add-host-2.png b/docs/images/platform/ssh/v2/ssh-group-add-host-2.png new file mode 100644 index 000000000..79143c4da Binary files /dev/null and b/docs/images/platform/ssh/v2/ssh-group-add-host-2.png differ diff --git a/docs/mint.json b/docs/mint.json index 69470e2b7..6b23fe70e 100644 --- a/docs/mint.json +++ b/docs/mint.json @@ -118,7 +118,13 @@ "documentation/platform/pki/alerting" ] }, - "documentation/platform/ssh", + { + "group": "Infisical SSH", + "pages": [ + "documentation/platform/ssh/overview", + "documentation/platform/ssh/host-groups" + ] + }, { "group": "Key Management (KMS)", "pages": [ @@ -887,8 +893,8 @@ ] }, { - "group": "LDAP Password", - "pages": [ + "group": "LDAP Password", + "pages": [ "api-reference/endpoints/secret-rotations/ldap-password/create", "api-reference/endpoints/secret-rotations/ldap-password/delete", "api-reference/endpoints/secret-rotations/ldap-password/get-by-id", @@ -1414,6 +1420,34 @@ { "group": "Infisical SSH", "pages": [ + { + "group": "Hosts", + "pages": [ + "api-reference/endpoints/ssh/hosts/list-my", + "api-reference/endpoints/ssh/hosts/list", + "api-reference/endpoints/ssh/hosts/create", + "api-reference/endpoints/ssh/hosts/read", + "api-reference/endpoints/ssh/hosts/update", + "api-reference/endpoints/ssh/hosts/delete", + "api-reference/endpoints/ssh/hosts/issue-host-cert", + "api-reference/endpoints/ssh/hosts/issue-user-cert", + "api-reference/endpoints/ssh/hosts/read-user-ca-pk", + "api-reference/endpoints/ssh/hosts/read-host-ca-pk" + ] + }, + { + "group": "Host Groups", + "pages": [ + "api-reference/endpoints/ssh/groups/list", + "api-reference/endpoints/ssh/groups/create", + "api-reference/endpoints/ssh/groups/read", + "api-reference/endpoints/ssh/groups/update", + "api-reference/endpoints/ssh/groups/delete", + "api-reference/endpoints/ssh/groups/add-host", + "api-reference/endpoints/ssh/groups/list-hosts", + "api-reference/endpoints/ssh/groups/remove-host" + ] + }, { "group": "Certificates", "pages": [ diff --git a/frontend/src/hooks/api/sshHost/types.ts b/frontend/src/hooks/api/sshHost/types.ts index ebeb5130a..0a8bfe22f 100644 --- a/frontend/src/hooks/api/sshHost/types.ts +++ b/frontend/src/hooks/api/sshHost/types.ts @@ -1,3 +1,8 @@ +export enum LoginMappingSource { + HOST = "host", + HOST_GROUP = "hostGroup" +} + export type TSshHost = { id: string; projectId: string; @@ -10,6 +15,7 @@ export type TSshHost = { allowedPrincipals: { usernames: string[]; }; + source: LoginMappingSource; }[]; }; diff --git a/frontend/src/hooks/api/sshHostGroup/types.ts b/frontend/src/hooks/api/sshHostGroup/types.ts index 70604e553..550930d1a 100644 --- a/frontend/src/hooks/api/sshHostGroup/types.ts +++ b/frontend/src/hooks/api/sshHostGroup/types.ts @@ -39,7 +39,7 @@ export type TDeleteSshHostGroupDTO = { }; export type TListSshHostGroupHostsResponse = { - hosts: TSshHost[]; + hosts: TSshHost & { joinedGroupAt: string; isPartOfGroup: boolean }[]; totalCount: number; }; diff --git a/frontend/src/hooks/api/subscriptions/types.ts b/frontend/src/hooks/api/subscriptions/types.ts index ab277ddc8..ec7b6a2dd 100644 --- a/frontend/src/hooks/api/subscriptions/types.ts +++ b/frontend/src/hooks/api/subscriptions/types.ts @@ -24,6 +24,7 @@ export type SubscriptionPlan = { workspacesUsed: number; environmentLimit: number; samlSSO: boolean; + sshHostGroups: boolean; secretAccessInsights: boolean; hsm: boolean; oidcSSO: boolean; diff --git a/frontend/src/pages/project/RoleDetailsBySlugPage/components/ProjectRoleModifySection.utils.tsx b/frontend/src/pages/project/RoleDetailsBySlugPage/components/ProjectRoleModifySection.utils.tsx index de2295940..3a1bdb1d2 100644 --- a/frontend/src/pages/project/RoleDetailsBySlugPage/components/ProjectRoleModifySection.utils.tsx +++ b/frontend/src/pages/project/RoleDetailsBySlugPage/components/ProjectRoleModifySection.utils.tsx @@ -234,6 +234,7 @@ export const projectRoleFormSchema = z.object({ }) .array() .default([]), + [ProjectPermissionSub.SshHostGroups]: GeneralPolicyActionSchema.array().default([]), [ProjectPermissionSub.SecretApproval]: GeneralPolicyActionSchema.array().default([]), [ProjectPermissionSub.SecretRollback]: SecretRollbackPolicyActionSchema.array().default([]), [ProjectPermissionSub.Project]: WorkspacePolicyActionSchema.array().default([]), @@ -380,7 +381,8 @@ export const rolePermission2Form = (permissions: TProjectPermission[] = []) => { ProjectPermissionSub.Kms, ProjectPermissionSub.SshCertificateTemplates, ProjectPermissionSub.SshCertificateAuthorities, - ProjectPermissionSub.SshCertificates + ProjectPermissionSub.SshCertificates, + ProjectPermissionSub.SshHostGroups ].includes(subject) ) { // from above statement we are sure it won't be undefined @@ -1064,6 +1066,15 @@ export const PROJECT_PERMISSION_OBJECT: TProjectPermissionObject = { { label: "Issue Host Certificate", value: ProjectPermissionSshHostActions.IssueHostCert } ] }, + [ProjectPermissionSub.SshHostGroups]: { + title: "SSH Host Groups", + actions: [ + { label: "Read", value: "read" }, + { label: "Create", value: "create" }, + { label: "Modify", value: "edit" }, + { label: "Remove", value: "delete" } + ] + }, [ProjectPermissionSub.PkiCollections]: { title: "PKI Collections", actions: [ diff --git a/frontend/src/pages/ssh/SshHostGroupDetailsByIDPage/components/SshHostGroupHostsSection.tsx b/frontend/src/pages/ssh/SshHostGroupDetailsByIDPage/components/SshHostGroupHostsSection.tsx index 05e047dc6..9f2487507 100644 --- a/frontend/src/pages/ssh/SshHostGroupDetailsByIDPage/components/SshHostGroupHostsSection.tsx +++ b/frontend/src/pages/ssh/SshHostGroupDetailsByIDPage/components/SshHostGroupHostsSection.tsx @@ -1,10 +1,11 @@ import { faPlus } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { UpgradePlanModal } from "@app/components/license/UpgradePlanModal"; import { createNotification } from "@app/components/notifications"; import { ProjectPermissionCan } from "@app/components/permissions"; import { DeleteActionModal, IconButton } from "@app/components/v2"; -import { ProjectPermissionActions, ProjectPermissionSub } from "@app/context"; +import { ProjectPermissionActions, ProjectPermissionSub, useSubscription } from "@app/context"; import { usePopUp } from "@app/hooks"; import { useRemoveHostFromSshHostGroup } from "@app/hooks/api"; @@ -16,13 +17,28 @@ type Props = { }; export const SshHostGroupHostsSection = ({ sshHostGroupId }: Props) => { + const { subscription } = useSubscription(); const { popUp, handlePopUpOpen, handlePopUpClose, handlePopUpToggle } = usePopUp([ "removeHostFromSshHostGroup", - "addHostGroupMembers" + "addHostGroupMembers", + "upgradePlan" ] as const); const { mutateAsync: removeHostFromGroup } = useRemoveHostFromSshHostGroup(); + const handleAddSshHostModal = () => { + if (!subscription?.sshHostGroups) { + handlePopUpOpen("upgradePlan", { + description: + "You can manage hosts more efficiently with SSH host groups if you upgrade your Infisical plan to an Enterprise license." + }); + } else { + handlePopUpOpen("addHostGroupMembers", { + sshHostGroupId + }); + } + }; + const onRemoveSshHostSubmit = async (sshHostId: string) => { try { await removeHostFromGroup({ @@ -49,17 +65,16 @@ export const SshHostGroupHostsSection = ({ sshHostGroupId }: Props) => {

SSH Hosts

- + {(isAllowed) => ( - handlePopUpOpen("addHostGroupMembers", { - sshHostGroupId - }) - } + onClick={() => handleAddSshHostModal()} isDisabled={!isAllowed} > @@ -84,6 +99,11 @@ export const SshHostGroupHostsSection = ({ sshHostGroupId }: Props) => { ) } /> + handlePopUpToggle("upgradePlan", isOpen)} + text={(popUp.upgradePlan?.data as { description: string })?.description} + />
); }; diff --git a/frontend/src/pages/ssh/SshHostGroupDetailsByIDPage/components/SshHostGroupHostsTable.tsx b/frontend/src/pages/ssh/SshHostGroupDetailsByIDPage/components/SshHostGroupHostsTable.tsx index 056a86f85..e9e283e12 100644 --- a/frontend/src/pages/ssh/SshHostGroupDetailsByIDPage/components/SshHostGroupHostsTable.tsx +++ b/frontend/src/pages/ssh/SshHostGroupDetailsByIDPage/components/SshHostGroupHostsTable.tsx @@ -42,6 +42,7 @@ export const SshHostGroupHostsTable = ({ sshHostGroupId, handlePopUpOpen }: Prop Alias Hostname + Added On @@ -53,6 +54,7 @@ export const SshHostGroupHostsTable = ({ sshHostGroupId, handlePopUpOpen }: Prop {host.alias ?? "-"} {host.hostname} + {new Date(host.joinedGroupAt).toLocaleDateString()} { + const { subscription } = useSubscription(); const { mutateAsync: deleteSshHostGroup } = useDeleteSshHostGroup(); const { popUp, handlePopUpOpen, handlePopUpClose, handlePopUpToggle } = usePopUp([ "sshHostGroup", - "deleteSshHostGroup" + "deleteSshHostGroup", + "upgradePlan" ] as const); + const handleAddSshHostGroupModal = () => { + if (!subscription?.sshHostGroups) { + handlePopUpOpen("upgradePlan", { + description: + "You can manage hosts more efficiently with SSH host groups if you upgrade your Infisical plan to an Enterprise license." + }); + } else { + handlePopUpOpen("sshHostGroup"); + } + }; + const onRemoveSshHostGroupSubmit = async (sshHostGroupId: string) => { try { const hostGroup = await deleteSshHostGroup({ sshHostGroupId }); @@ -48,7 +62,7 @@ export const SshHostGroupsSection = () => { colorSchema="primary" type="submit" leftIcon={} - onClick={() => handlePopUpOpen("sshHostGroup")} + onClick={() => handleAddSshHostGroupModal()} isDisabled={!isAllowed} > Add Group @@ -69,6 +83,11 @@ export const SshHostGroupsSection = () => { ) } /> + handlePopUpToggle("upgradePlan", isOpen)} + text={(popUp.upgradePlan?.data as { description: string })?.description} + />
); }; diff --git a/frontend/src/pages/ssh/SshHostsPage/components/SshHostGroupsTable.tsx b/frontend/src/pages/ssh/SshHostsPage/components/SshHostGroupsTable.tsx index a3ebed6a8..5845fc866 100644 --- a/frontend/src/pages/ssh/SshHostsPage/components/SshHostGroupsTable.tsx +++ b/frontend/src/pages/ssh/SshHostsPage/components/SshHostGroupsTable.tsx @@ -43,7 +43,7 @@ export const SshHostGroupsTable = ({ handlePopUpOpen }: Props) => { Name - # Hosts + # Hosts in Group Login User - Authorized Principals Mapping diff --git a/frontend/src/pages/ssh/SshHostsPage/components/SshHostModal.tsx b/frontend/src/pages/ssh/SshHostsPage/components/SshHostModal.tsx index ede0a35a0..c782cf176 100644 --- a/frontend/src/pages/ssh/SshHostsPage/components/SshHostModal.tsx +++ b/frontend/src/pages/ssh/SshHostsPage/components/SshHostModal.tsx @@ -26,6 +26,7 @@ import { useListWorkspaceSshHosts, useUpdateSshHost } from "@app/hooks/api"; +import { LoginMappingSource } from "@app/hooks/api/sshHost/types"; import { UsePopUpState } from "@app/hooks/usePopUp"; type Props = { @@ -48,7 +49,8 @@ const schema = z loginMappings: z .object({ loginUser: z.string().trim().min(1), - allowedPrincipals: z.array(z.string().trim()).default([]) + allowedPrincipals: z.array(z.string().trim()).default([]), + source: z.nativeEnum(LoginMappingSource) }) .array() .default([]) @@ -99,9 +101,10 @@ export const SshHostModal = ({ popUp, handlePopUpToggle }: Props) => { hostname: sshHost.hostname, alias: sshHost.alias ?? "", userCertTtl: sshHost.userCertTtl, - loginMappings: sshHost.loginMappings.map(({ loginUser, allowedPrincipals }) => ({ + loginMappings: sshHost.loginMappings.map(({ loginUser, allowedPrincipals, source }) => ({ loginUser, - allowedPrincipals: allowedPrincipals.usernames + allowedPrincipals: allowedPrincipals.usernames, + source })) }); @@ -122,6 +125,11 @@ export const SshHostModal = ({ popUp, handlePopUpToggle }: Props) => { try { if (!projectId) return; + // Filter out login mappings that are from host groups + const hostLoginMappings = loginMappings.filter( + (mapping) => mapping.source === LoginMappingSource.HOST + ); + // check if there is already a different host with the same hostname const existingHostnames = sshHosts?.filter((h) => h.id !== sshHost?.id).map((h) => h.hostname) || []; @@ -157,7 +165,7 @@ export const SshHostModal = ({ popUp, handlePopUpToggle }: Props) => { hostname, alias: trimmedAlias, userCertTtl, - loginMappings: loginMappings.map(({ loginUser, allowedPrincipals }) => ({ + loginMappings: hostLoginMappings.map(({ loginUser, allowedPrincipals }) => ({ loginUser, allowedPrincipals: { usernames: allowedPrincipals @@ -170,7 +178,7 @@ export const SshHostModal = ({ popUp, handlePopUpToggle }: Props) => { hostname, alias: trimmedAlias, userCertTtl, - loginMappings: loginMappings.map(({ loginUser, allowedPrincipals }) => ({ + loginMappings: hostLoginMappings.map(({ loginUser, allowedPrincipals }) => ({ loginUser, allowedPrincipals: { usernames: allowedPrincipals @@ -265,7 +273,11 @@ export const SshHostModal = ({ popUp, handlePopUpToggle }: Props) => { variant="outline_bg" onClick={() => { const newIndex = loginMappingsFormFields.fields.length; - loginMappingsFormFields.append({ loginUser: "", allowedPrincipals: [""] }); + loginMappingsFormFields.append({ + loginUser: "", + allowedPrincipals: [""], + source: LoginMappingSource.HOST + }); setExpandedMappings((prev) => ({ ...prev, [newIndex]: true @@ -298,6 +310,12 @@ export const SshHostModal = ({ popUp, handlePopUpToggle }: Props) => { render={({ field }) => ( {field.value || "New Login Mapping"} + {loginMappingsFormFields.fields[i].source === + LoginMappingSource.HOST_GROUP && ( + + (inherited from host group) + + )} )} /> @@ -306,6 +324,9 @@ export const SshHostModal = ({ popUp, handlePopUpToggle }: Props) => { ariaLabel="delete login mapping" variant="plain" onClick={() => loginMappingsFormFields.remove(i)} + isDisabled={ + loginMappingsFormFields.fields[i].source === LoginMappingSource.HOST_GROUP + } > @@ -327,7 +348,17 @@ export const SshHostModal = ({ popUp, handlePopUpToggle }: Props) => { { + if ( + loginMappingsFormFields.fields[i].source === + LoginMappingSource.HOST_GROUP + ) + return; + const newValue = e.target.value; const loginMappings = getValues("loginMappings"); const isDuplicate = loginMappings.some( @@ -355,17 +386,20 @@ export const SshHostModal = ({ popUp, handlePopUpToggle }: Props) => { label="Allowed Principals" className="text-xs text-mineshaft-400" /> - + {loginMappingsFormFields.fields[i].source === LoginMappingSource.HOST && ( + + )} {