Complete preliminary ssh host group feature

This commit is contained in:
Tuan Dang
2025-04-30 18:14:31 -07:00
parent b06eeb0d40
commit b21b0b340b
46 changed files with 561 additions and 94 deletions

View File

@@ -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()
})
}
},

View File

@@ -28,7 +28,8 @@ export const getDefaultOnPremFeatures = () => {
has_used_trial: true,
secretApproval: true,
secretRotation: true,
caCrl: false
caCrl: false,
sshHostGroups: false
};
};

View File

@@ -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" },

View File

@@ -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) => {

View File

@@ -71,6 +71,7 @@ export type TFeatureSet = {
projectTemplates: false;
kmip: false;
gateway: false;
sshHostGroups: false;
};
export type TOrgPlansTableDTO = {

View File

@@ -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<TProjectDALFactory, "findById" | "find">;
sshHostDAL: Pick<TSshHostDALFactory, "findSshHostByIdWithLoginMappings">;
sshHostGroupDAL: Pick<
TSshHostGroupDALFactory,
| "create"
@@ -33,24 +36,29 @@ type TSshHostGroupServiceFactoryDep = {
| "transaction"
| "findSshHostGroupByIdWithLoginMappings"
| "findAllSshHostsInGroup"
| "findOne"
| "find"
>;
sshHostGroupMembershipDAL: TSshHostGroupMembershipDALFactory; // TODO: Pick
sshHostGroupMembershipDAL: Pick<TSshHostGroupMembershipDALFactory, "create" | "deleteById" | "findOne">;
sshHostLoginUserDAL: Pick<TSshHostLoginUserDALFactory, "create" | "transaction" | "delete">;
sshHostLoginUserMappingDAL: Pick<TSshHostLoginUserMappingDALFactory, "insertMany">;
userDAL: Pick<TUserDALFactory, "find">;
permissionService: Pick<TPermissionServiceFactory, "getProjectPermission" | "getUserProjectPermission">;
licenseService: Pick<TLicenseServiceFactory, "getPlan">;
};
export type TSshHostGroupServiceFactory = ReturnType<typeof sshHostGroupServiceFactory>;
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 };

View File

@@ -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<TProjectPermission, "projectId">;
export type TGetSshHostGroupDTO = {
sshHostGroupId: string;
} & TGenericPermission;
} & Omit<TProjectPermission, "projectId">;
export type TDeleteSshHostGroupDTO = {
sshHostGroupId: string;
} & TGenericPermission;
} & Omit<TProjectPermission, "projectId">;
export type TListSshHostGroupHostsDTO = {
sshHostGroupId: string;
filter?: EHostGroupMembershipFilter;
} & TGenericPermission;
} & Omit<TProjectPermission, "projectId">;
export type TAddHostToSshHostGroupDTO = {
sshHostGroupId: string;
hostId: string;
} & TGenericPermission;
} & Omit<TProjectPermission, "projectId">;
export type TRemoveHostFromSshHostGroupDTO = {
sshHostGroupId: string;
hostId: string;
} & TGenericPermission;
} & Omit<TProjectPermission, "projectId">;
export enum EHostGroupMembershipFilter {
GROUP_MEMBERS = "group-members",

View File

@@ -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<typeof sshHostDALFactory>;
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
};

View File

@@ -16,6 +16,11 @@ export type TLoginMapping = {
};
};
export enum LoginMappingSource {
HOST = "host",
HOST_GROUP = "hostGroup"
}
export type TCreateSshHostDTO = {
hostname: string;
alias?: string;

View File

@@ -855,13 +855,15 @@ export const registerRoutes = async (
});
const sshHostGroupService = sshHostGroupServiceFactory({
projectDAL,
sshHostDAL,
sshHostGroupDAL,
sshHostGroupMembershipDAL,
sshHostLoginUserDAL,
sshHostLoginUserMappingDAL,
userDAL,
permissionService
permissionService,
licenseService
});
const certificateAuthorityService = certificateAuthorityServiceFactory({

View File

@@ -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()
})
)

View File

@@ -0,0 +1,4 @@
---
title: "Add Host"
openapi: "POST /api/v1/ssh/host-groups/{sshHostGroupId}/hosts"
---

View File

@@ -0,0 +1,4 @@
---
title: "Create"
openapi: "POST /api/v1/ssh/host-groups"
---

View File

@@ -0,0 +1,4 @@
---
title: "Delete"
openapi: "DELETE /api/v1/ssh/host-groups/{sshHostGroupId}"
---

View File

@@ -0,0 +1,4 @@
---
title: "List Hosts"
openapi: "GET /api/v1/ssh/host-groups/{sshHostGroupId}/hosts"
---

View File

@@ -0,0 +1,4 @@
---
title: "List"
openapi: "GET /api/v2/workspace/{projectId}/ssh-host-groups"
---

View File

@@ -0,0 +1,4 @@
---
title: "Retrieve"
openapi: "GET /api/v1/ssh/host-groups/{sshHostGroupId}"
---

View File

@@ -0,0 +1,4 @@
---
title: "Remove Host"
openapi: "DELETE /api/v1/ssh/host-groups/{sshHostGroupId}/hosts/{sshHostId}"
---

View File

@@ -0,0 +1,4 @@
---
title: "Update"
openapi: "PATCH /api/v1/ssh/host-groups/{sshHostGroupId}"
---

View File

@@ -0,0 +1,4 @@
---
title: "Create"
openapi: "POST /api/v1/ssh/hosts"
---

View File

@@ -0,0 +1,4 @@
---
title: "Delete"
openapi: "DELETE /api/v1/ssh/hosts/{sshHostId}"
---

View File

@@ -0,0 +1,4 @@
---
title: "Issue Host Certificate"
openapi: "POST /api/v1/ssh/hosts/{sshHostId}/issue-host-cert"
---

View File

@@ -0,0 +1,4 @@
---
title: "Issue User Certificate"
openapi: "POST /api/v1/ssh/hosts/{sshHostId}/issue-user-cert"
---

View File

@@ -0,0 +1,4 @@
---
title: "List My Hosts"
openapi: "GET /api/v1/ssh/hosts/"
---

View File

@@ -0,0 +1,4 @@
---
title: "List"
openapi: "GET /api/v2/workspace/{projectId}/ssh-hosts"
---

View File

@@ -0,0 +1,4 @@
---
title: "Read Host CA Public Key"
openapi: "GET /api/v1/ssh/hosts/{sshHostId}/host-ca-public-key"
---

View File

@@ -0,0 +1,4 @@
---
title: "Read User CA Public Key"
openapi: "GET /api/v1/ssh/hosts/{sshHostId}/user-ca-public-key"
---

View File

@@ -0,0 +1,4 @@
---
title: "Retrieve"
openapi: "GET /api/v1/ssh/hosts/{sshHostId}"
---

View File

@@ -0,0 +1,4 @@
---
title: "Update"
openapi: "PATCH /api/v1/ssh/hosts/{sshHostId}"
---

View File

@@ -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.
<Steps>
<Step title="Create a host group">
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.
</Step>
<Step title="Add host(s) to 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)
</Step>
</Steps>

View File

@@ -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."
---

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 621 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 597 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 744 KiB

View File

@@ -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": [

View File

@@ -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;
}[];
};

View File

@@ -39,7 +39,7 @@ export type TDeleteSshHostGroupDTO = {
};
export type TListSshHostGroupHostsResponse = {
hosts: TSshHost[];
hosts: TSshHost & { joinedGroupAt: string; isPartOfGroup: boolean }[];
totalCount: number;
};

View File

@@ -24,6 +24,7 @@ export type SubscriptionPlan = {
workspacesUsed: number;
environmentLimit: number;
samlSSO: boolean;
sshHostGroups: boolean;
secretAccessInsights: boolean;
hsm: boolean;
oidcSSO: boolean;

View File

@@ -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: [

View File

@@ -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) => {
<div className="h-full rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
<div className="flex items-center justify-between border-b border-mineshaft-400 pb-4">
<h3 className="text-lg font-semibold text-mineshaft-100">SSH Hosts</h3>
<ProjectPermissionCan I={ProjectPermissionActions.Create} a={ProjectPermissionSub.SshHosts}>
<ProjectPermissionCan
I={ProjectPermissionActions.Edit}
a={ProjectPermissionSub.SshHostGroups}
>
{(isAllowed) => (
<IconButton
ariaLabel="add host"
variant="plain"
className="group relative"
onClick={() =>
handlePopUpOpen("addHostGroupMembers", {
sshHostGroupId
})
}
onClick={() => handleAddSshHostModal()}
isDisabled={!isAllowed}
>
<FontAwesomeIcon icon={faPlus} />
@@ -84,6 +99,11 @@ export const SshHostGroupHostsSection = ({ sshHostGroupId }: Props) => {
)
}
/>
<UpgradePlanModal
isOpen={popUp.upgradePlan.isOpen}
onOpenChange={(isOpen) => handlePopUpToggle("upgradePlan", isOpen)}
text={(popUp.upgradePlan?.data as { description: string })?.description}
/>
</div>
);
};

View File

@@ -42,6 +42,7 @@ export const SshHostGroupHostsTable = ({ sshHostGroupId, handlePopUpOpen }: Prop
<Tr>
<Th>Alias</Th>
<Th>Hostname</Th>
<Th>Added On</Th>
<Th />
</Tr>
</THead>
@@ -53,6 +54,7 @@ export const SshHostGroupHostsTable = ({ sshHostGroupId, handlePopUpOpen }: Prop
<Tr className="h-10" key={`host-${host.id}`}>
<Td>{host.alias ?? "-"}</Td>
<Td>{host.hostname}</Td>
<Td>{new Date(host.joinedGroupAt).toLocaleDateString()}</Td>
<Td className="flex justify-end">
<ProjectPermissionCan
I={ProjectPermissionActions.Edit}

View File

@@ -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 { Button, DeleteActionModal } from "@app/components/v2";
import { ProjectPermissionActions, ProjectPermissionSub } from "@app/context";
import { ProjectPermissionActions, ProjectPermissionSub, useSubscription } from "@app/context";
import { useDeleteSshHostGroup } from "@app/hooks/api";
import { usePopUp } from "@app/hooks/usePopUp";
@@ -12,13 +13,26 @@ import { SshHostGroupModal } from "./SshHostGroupModal";
import { SshHostGroupsTable } from "./SshHostGroupsTable";
export const SshHostGroupsSection = () => {
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={<FontAwesomeIcon icon={faPlus} />}
onClick={() => handlePopUpOpen("sshHostGroup")}
onClick={() => handleAddSshHostGroupModal()}
isDisabled={!isAllowed}
>
Add Group
@@ -69,6 +83,11 @@ export const SshHostGroupsSection = () => {
)
}
/>
<UpgradePlanModal
isOpen={popUp.upgradePlan.isOpen}
onOpenChange={(isOpen) => handlePopUpToggle("upgradePlan", isOpen)}
text={(popUp.upgradePlan?.data as { description: string })?.description}
/>
</div>
);
};

View File

@@ -43,7 +43,7 @@ export const SshHostGroupsTable = ({ handlePopUpOpen }: Props) => {
<THead>
<Tr>
<Th>Name</Th>
<Th># Hosts</Th>
<Th># Hosts in Group</Th>
<Th>Login User - Authorized Principals Mapping</Th>
<Th />
</Tr>

View File

@@ -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 }) => (
<span className="text-sm font-medium leading-tight">
{field.value || "New Login Mapping"}
{loginMappingsFormFields.fields[i].source ===
LoginMappingSource.HOST_GROUP && (
<span className="ml-2 text-xs text-mineshaft-400">
(inherited from host group)
</span>
)}
</span>
)}
/>
@@ -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
}
>
<FontAwesomeIcon icon={faTrash} />
</IconButton>
@@ -327,7 +348,17 @@ export const SshHostModal = ({ popUp, handlePopUpToggle }: Props) => {
<Input
{...field}
placeholder="ec2-user"
isDisabled={
loginMappingsFormFields.fields[i].source ===
LoginMappingSource.HOST_GROUP
}
onChange={(e) => {
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"
/>
<Button
leftIcon={<FontAwesomeIcon icon={faPlus} />}
size="xs"
variant="outline_bg"
onClick={() => {
const current = getValues(`loginMappings.${i}.allowedPrincipals`) ?? [];
setValue(`loginMappings.${i}.allowedPrincipals`, [...current, ""]);
}}
>
Add Principal
</Button>
{loginMappingsFormFields.fields[i].source === LoginMappingSource.HOST && (
<Button
leftIcon={<FontAwesomeIcon icon={faPlus} />}
size="xs"
variant="outline_bg"
onClick={() => {
const current =
getValues(`loginMappings.${i}.allowedPrincipals`) ?? [];
setValue(`loginMappings.${i}.allowedPrincipals`, [...current, ""]);
}}
>
Add Principal
</Button>
)}
</div>
<Controller
control={control}
@@ -382,6 +416,12 @@ export const SshHostModal = ({ popUp, handlePopUpToggle }: Props) => {
<Select
value={principal}
onValueChange={(newValue) => {
if (
loginMappingsFormFields.fields[i].source ===
LoginMappingSource.HOST_GROUP
)
return;
if (value.includes(newValue)) {
createNotification({
text: "This principal is already added",
@@ -395,6 +435,10 @@ export const SshHostModal = ({ popUp, handlePopUpToggle }: Props) => {
}}
placeholder="Select a member"
className="w-full"
isDisabled={
loginMappingsFormFields.fields[i].source ===
LoginMappingSource.HOST_GROUP
}
>
{members.map((member) => (
<SelectItem
@@ -412,11 +456,21 @@ export const SshHostModal = ({ popUp, handlePopUpToggle }: Props) => {
variant="plain"
className="h-9"
onClick={() => {
if (
loginMappingsFormFields.fields[i].source ===
LoginMappingSource.HOST_GROUP
)
return;
const newPrincipals = value.filter(
(_, idx) => idx !== principalIndex
);
onChange(newPrincipals);
}}
isDisabled={
loginMappingsFormFields.fields[i].source ===
LoginMappingSource.HOST_GROUP
}
>
<FontAwesomeIcon icon={faTrash} />
</IconButton>

View File

@@ -29,6 +29,7 @@ import {
} from "@app/components/v2";
import { ProjectPermissionActions, ProjectPermissionSub, useWorkspace } from "@app/context";
import { fetchSshHostUserCaPublicKey, useListWorkspaceSshHosts } from "@app/hooks/api";
import { LoginMappingSource } from "@app/hooks/api/sshHost/types";
import { UsePopUpState } from "@app/hooks/usePopUp";
type Props = {
@@ -90,9 +91,16 @@ export const SshHostsTable = ({ handlePopUpOpen }: Props) => {
{host.loginMappings.length === 0 ? (
<span className="italic text-mineshaft-400">None</span>
) : (
host.loginMappings.map(({ loginUser, allowedPrincipals }) => (
host.loginMappings.map(({ loginUser, allowedPrincipals, source }) => (
<div key={`${host.id}-${loginUser}`} className="mb-2">
<div className="text-mineshaft-200">{loginUser}</div>
<div className="text-mineshaft-200">
{loginUser}
{source === LoginMappingSource.HOST_GROUP && (
<span className="ml-2 text-xs text-mineshaft-400">
(inherited from host group)
</span>
)}
</div>
{allowedPrincipals.usernames.map((username) => (
<div key={`${host.id}-${loginUser}-${username}`} className="ml-4">
{username}