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
+26 -5
View File
@@ -3,6 +3,7 @@ import { z } from "zod";
import { EventType } from "@app/ee/services/audit-log/audit-log-types"; import { EventType } from "@app/ee/services/audit-log/audit-log-types";
import { SshCertKeyAlgorithm } from "@app/ee/services/ssh-certificate/ssh-certificate-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 { 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 { isValidHostname } from "@app/ee/services/ssh-host/ssh-host-validators";
import { SSH_HOSTS } from "@app/lib/api-docs"; import { SSH_HOSTS } from "@app/lib/api-docs";
import { ms } from "@app/lib/ms"; import { ms } from "@app/lib/ms";
@@ -24,7 +25,11 @@ export const registerSshHostRouter = async (server: FastifyZodProvider) => {
response: { response: {
200: z.array( 200: z.array(
sanitizedSshHost.extend({ 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: { response: {
200: sanitizedSshHost.extend({ 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: { response: {
200: sanitizedSshHost.extend({ 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: { response: {
200: sanitizedSshHost.extend({ 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: { response: {
200: sanitizedSshHost.extend({ 200: sanitizedSshHost.extend({
loginMappings: z.array(loginMappingSchema) loginMappings: loginMappingSchema
.extend({
source: z.nativeEnum(LoginMappingSource)
})
.array()
}) })
} }
}, },
@@ -28,7 +28,8 @@ export const getDefaultOnPremFeatures = () => {
has_used_trial: true, has_used_trial: true,
secretApproval: true, secretApproval: true,
secretRotation: true, secretRotation: true,
caCrl: false caCrl: false,
sshHostGroups: false
}; };
}; };
@@ -10,6 +10,7 @@ export const BillingPlanRows = {
CustomAlerts: { name: "Custom alerts", field: "customAlerts" }, CustomAlerts: { name: "Custom alerts", field: "customAlerts" },
AuditLogs: { name: "Audit logs", field: "auditLogs" }, AuditLogs: { name: "Audit logs", field: "auditLogs" },
SamlSSO: { name: "SAML SSO", field: "samlSSO" }, SamlSSO: { name: "SAML SSO", field: "samlSSO" },
SshHostGroups: { name: "SSH Host Groups", field: "sshHostGroups" },
Hsm: { name: "Hardware Security Module (HSM)", field: "hsm" }, Hsm: { name: "Hardware Security Module (HSM)", field: "hsm" },
OidcSSO: { name: "OIDC SSO", field: "oidcSSO" }, OidcSSO: { name: "OIDC SSO", field: "oidcSSO" },
SecretApproval: { name: "Secret approvals", field: "secretApproval" }, SecretApproval: { name: "Secret approvals", field: "secretApproval" },
@@ -53,7 +53,8 @@ export const getDefaultOnPremFeatures = (): TFeatureSet => ({
enforceMfa: false, enforceMfa: false,
projectTemplates: false, projectTemplates: false,
kmip: false, kmip: false,
gateway: false gateway: false,
sshHostGroups: false
}); });
export const setupLicenseRequestWithStore = (baseURL: string, refreshUrl: string, licenseKey: string) => { export const setupLicenseRequestWithStore = (baseURL: string, refreshUrl: string, licenseKey: string) => {
@@ -71,6 +71,7 @@ export type TFeatureSet = {
projectTemplates: false; projectTemplates: false;
kmip: false; kmip: false;
gateway: false; gateway: false;
sshHostGroups: false;
}; };
export type TOrgPlansTableDTO = { export type TOrgPlansTableDTO = {
@@ -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 { 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 { 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 { 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 { TUserDALFactory } from "@app/services/user/user-dal";
import { TLicenseServiceFactory } from "../license/license-service";
import { createSshLoginMappings } from "../ssh-host/ssh-host-fns"; import { createSshLoginMappings } from "../ssh-host/ssh-host-fns";
import { import {
TAddHostToSshHostGroupDTO, TAddHostToSshHostGroupDTO,
@@ -23,7 +25,8 @@ import {
} from "./ssh-host-group-types"; } from "./ssh-host-group-types";
type TSshHostGroupServiceFactoryDep = { type TSshHostGroupServiceFactoryDep = {
sshHostDAL: TSshHostDALFactory; // TODO: Pick projectDAL: Pick<TProjectDALFactory, "findById" | "find">;
sshHostDAL: Pick<TSshHostDALFactory, "findSshHostByIdWithLoginMappings">;
sshHostGroupDAL: Pick< sshHostGroupDAL: Pick<
TSshHostGroupDALFactory, TSshHostGroupDALFactory,
| "create" | "create"
@@ -33,24 +36,29 @@ type TSshHostGroupServiceFactoryDep = {
| "transaction" | "transaction"
| "findSshHostGroupByIdWithLoginMappings" | "findSshHostGroupByIdWithLoginMappings"
| "findAllSshHostsInGroup" | "findAllSshHostsInGroup"
| "findOne"
| "find"
>; >;
sshHostGroupMembershipDAL: TSshHostGroupMembershipDALFactory; // TODO: Pick sshHostGroupMembershipDAL: Pick<TSshHostGroupMembershipDALFactory, "create" | "deleteById" | "findOne">;
sshHostLoginUserDAL: Pick<TSshHostLoginUserDALFactory, "create" | "transaction" | "delete">; sshHostLoginUserDAL: Pick<TSshHostLoginUserDALFactory, "create" | "transaction" | "delete">;
sshHostLoginUserMappingDAL: Pick<TSshHostLoginUserMappingDALFactory, "insertMany">; sshHostLoginUserMappingDAL: Pick<TSshHostLoginUserMappingDALFactory, "insertMany">;
userDAL: Pick<TUserDALFactory, "find">; userDAL: Pick<TUserDALFactory, "find">;
permissionService: Pick<TPermissionServiceFactory, "getProjectPermission" | "getUserProjectPermission">; permissionService: Pick<TPermissionServiceFactory, "getProjectPermission" | "getUserProjectPermission">;
licenseService: Pick<TLicenseServiceFactory, "getPlan">;
}; };
export type TSshHostGroupServiceFactory = ReturnType<typeof sshHostGroupServiceFactory>; export type TSshHostGroupServiceFactory = ReturnType<typeof sshHostGroupServiceFactory>;
export const sshHostGroupServiceFactory = ({ export const sshHostGroupServiceFactory = ({
projectDAL,
sshHostDAL, sshHostDAL,
sshHostGroupDAL, sshHostGroupDAL,
sshHostGroupMembershipDAL, sshHostGroupMembershipDAL,
sshHostLoginUserDAL, sshHostLoginUserDAL,
sshHostLoginUserMappingDAL, sshHostLoginUserMappingDAL,
userDAL, userDAL,
permissionService permissionService,
licenseService
}: TSshHostGroupServiceFactoryDep) => { }: TSshHostGroupServiceFactoryDep) => {
const createSshHostGroup = async ({ const createSshHostGroup = async ({
projectId, projectId,
@@ -72,11 +80,38 @@ export const sshHostGroupServiceFactory = ({
ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Create, ProjectPermissionSub.SshHostGroups); 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) => { 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( const sshHostGroup = await sshHostGroupDAL.create(
{ {
projectId, projectId,
name // TODO: check that this is unique across the whole org name
}, },
tx tx
); );
@@ -131,6 +166,12 @@ export const sshHostGroupServiceFactory = ({
ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Edit, ProjectPermissionSub.SshHostGroups); 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) => { const updatedSshHostGroup = await sshHostGroupDAL.transaction(async (tx) => {
await sshHostGroupDAL.updateById( await sshHostGroupDAL.updateById(
sshHostGroupId, sshHostGroupId,
@@ -280,8 +321,7 @@ export const sshHostGroupServiceFactory = ({
actionProjectType: ActionProjectType.SSH actionProjectType: ActionProjectType.SSH
}); });
ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Read, ProjectPermissionSub.SshHostGroups); ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Edit, ProjectPermissionSub.SshHostGroups);
// TODO: look over permissioning
await sshHostGroupMembershipDAL.create({ sshHostGroupId, sshHostId: hostId }); await sshHostGroupMembershipDAL.create({ sshHostGroupId, sshHostId: hostId });
@@ -296,10 +336,6 @@ export const sshHostGroupServiceFactory = ({
actorAuthMethod, actorAuthMethod,
actorOrgId actorOrgId
}: TRemoveHostFromSshHostGroupDTO) => { }: TRemoveHostFromSshHostGroupDTO) => {
console.log("removeHostFromSshHostGroup args: ", {
sshHostGroupId,
hostId
});
const sshHostGroup = await sshHostGroupDAL.findSshHostGroupByIdWithLoginMappings(sshHostGroupId); const sshHostGroup = await sshHostGroupDAL.findSshHostGroupByIdWithLoginMappings(sshHostGroupId);
if (!sshHostGroup) throw new NotFoundError({ message: `SSH host group with ID '${sshHostGroupId}' not found` }); 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); ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Edit, ProjectPermissionSub.SshHostGroups);
// TODO: look over permissioning
const sshHostGroupMembership = await sshHostGroupMembershipDAL.findOne({ const sshHostGroupMembership = await sshHostGroupMembershipDAL.findOne({
sshHostGroupId, sshHostGroupId,
@@ -339,10 +374,6 @@ export const sshHostGroupServiceFactory = ({
}); });
} }
console.log("boom: ", {
sshHostGroupMembership
});
await sshHostGroupMembershipDAL.deleteById(sshHostGroupMembership.id); await sshHostGroupMembershipDAL.deleteById(sshHostGroupMembership.id);
return { sshHostGroup, sshHost }; return { sshHostGroup, sshHost };
@@ -1,5 +1,5 @@
import { TLoginMapping } from "@app/ee/services/ssh-host/ssh-host-types"; 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 = { export type TCreateSshHostGroupDTO = {
name: string; name: string;
@@ -15,29 +15,30 @@ export type TUpdateSshHostGroupDTO = {
usernames: string[]; usernames: string[];
}; };
}[]; }[];
} & TGenericPermission; } & Omit<TProjectPermission, "projectId">;
export type TGetSshHostGroupDTO = { export type TGetSshHostGroupDTO = {
sshHostGroupId: string; sshHostGroupId: string;
} & TGenericPermission; } & Omit<TProjectPermission, "projectId">;
export type TDeleteSshHostGroupDTO = { export type TDeleteSshHostGroupDTO = {
sshHostGroupId: string; sshHostGroupId: string;
} & TGenericPermission; } & Omit<TProjectPermission, "projectId">;
export type TListSshHostGroupHostsDTO = { export type TListSshHostGroupHostsDTO = {
sshHostGroupId: string; sshHostGroupId: string;
filter?: EHostGroupMembershipFilter; filter?: EHostGroupMembershipFilter;
} & TGenericPermission; } & Omit<TProjectPermission, "projectId">;
export type TAddHostToSshHostGroupDTO = { export type TAddHostToSshHostGroupDTO = {
sshHostGroupId: string; sshHostGroupId: string;
hostId: string; hostId: string;
} & TGenericPermission; } & Omit<TProjectPermission, "projectId">;
export type TRemoveHostFromSshHostGroupDTO = { export type TRemoveHostFromSshHostGroupDTO = {
sshHostGroupId: string; sshHostGroupId: string;
hostId: string; hostId: string;
} & TGenericPermission; } & Omit<TProjectPermission, "projectId">;
export enum EHostGroupMembershipFilter { export enum EHostGroupMembershipFilter {
GROUP_MEMBERS = "group-members", GROUP_MEMBERS = "group-members",
+139 -24
View File
@@ -6,6 +6,8 @@ import { DatabaseError } from "@app/lib/errors";
import { groupBy, unique } from "@app/lib/fn"; import { groupBy, unique } from "@app/lib/fn";
import { ormify } from "@app/lib/knex"; import { ormify } from "@app/lib/knex";
import { LoginMappingSource } from "./ssh-host-types";
export type TSshHostDALFactory = ReturnType<typeof sshHostDALFactory>; export type TSshHostDALFactory = ReturnType<typeof sshHostDALFactory>;
export const sshHostDALFactory = (db: TDbClient) => { export const sshHostDALFactory = (db: TDbClient) => {
@@ -13,20 +15,22 @@ export const sshHostDALFactory = (db: TDbClient) => {
const findUserAccessibleSshHosts = async (projectIds: string[], userId: string, tx?: Knex) => { const findUserAccessibleSshHosts = async (projectIds: string[], userId: string, tx?: Knex) => {
try { 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) { if (!user) {
throw new DatabaseError({ name: `${TableName.Users}: UserNotFound`, error: new Error("User not found") }); 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.SshHostLoginUser, `${TableName.SshHost}.id`, `${TableName.SshHostLoginUser}.sshHostId`)
.leftJoin( .leftJoin(
TableName.SshHostLoginUserMapping, TableName.SshHostLoginUserMapping,
`${TableName.SshHostLoginUser}.id`, `${TableName.SshHostLoginUser}.id`,
`${TableName.SshHostLoginUserMapping}.sshHostLoginUserId` `${TableName.SshHostLoginUserMapping}.sshHostLoginUserId`
) )
.leftJoin(TableName.Users, `${TableName.Users}.id`, `${TableName.SshHostLoginUserMapping}.userId`)
.whereIn(`${TableName.SshHost}.projectId`, projectIds) .whereIn(`${TableName.SshHost}.projectId`, projectIds)
.andWhere(`${TableName.SshHostLoginUserMapping}.userId`, userId) .andWhere(`${TableName.SshHostLoginUserMapping}.userId`, userId)
.select( .select(
@@ -37,26 +41,66 @@ export const sshHostDALFactory = (db: TDbClient) => {
db.ref("userCertTtl").withSchema(TableName.SshHost), db.ref("userCertTtl").withSchema(TableName.SshHost),
db.ref("hostCertTtl").withSchema(TableName.SshHost), db.ref("hostCertTtl").withSchema(TableName.SshHost),
db.ref("loginUser").withSchema(TableName.SshHostLoginUser), 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("userSshCaId").withSchema(TableName.SshHost),
db.ref("hostSshCaId").withSchema(TableName.SshHost) db.ref("hostSshCaId").withSchema(TableName.SshHost)
) );
.orderBy(`${TableName.SshHost}.updatedAt`, "desc");
const grouped = groupBy(rows, (r) => r.sshHostId); // get hosts where user has login mappings via host groups
return Object.values(grouped).map((hostRows) => { 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 } = const { sshHostId, hostname, alias, userCertTtl, hostCertTtl, userSshCaId, hostSshCaId, projectId } =
hostRows[0]; hostRows[0];
const loginMappingGrouped = groupBy(hostRows, (r) => r.loginUser); const loginMappingGrouped = groupBy(hostRows, (r) => `${r.loginUser}|${r.source}`);
const loginMappings = Object.entries(loginMappingGrouped).map(([key]) => {
const loginMappings = Object.entries(loginMappingGrouped).map(([loginUser]) => ({ const [loginUser, source] = key.split("|");
loginUser, return {
allowedPrincipals: { loginUser,
usernames: [user.username] allowedPrincipals: {
} usernames: [user.username]
})); },
source: source as LoginMappingSource
};
});
return { return {
id: sshHostId, id: sshHostId,
@@ -101,20 +145,57 @@ export const sshHostDALFactory = (db: TDbClient) => {
) )
.orderBy(`${TableName.SshHost}.updatedAt`, "desc"); .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); const hostsGrouped = groupBy(rows, (r) => r.sshHostId);
return Object.values(hostsGrouped).map((hostRows) => { return Object.values(hostsGrouped).map((hostRows) => {
const { sshHostId, hostname, alias, userCertTtl, hostCertTtl, userSshCaId, hostSshCaId } = hostRows[0]; const { sshHostId, hostname, alias, userCertTtl, hostCertTtl, userSshCaId, hostSshCaId } = hostRows[0];
// direct login mappings
const loginMappingGrouped = groupBy( const loginMappingGrouped = groupBy(
hostRows.filter((r) => r.loginUser), hostRows.filter((r) => r.loginUser),
(r) => r.loginUser (r) => r.loginUser
); );
const loginMappings = Object.entries(loginMappingGrouped).map(([loginUser, entries]) => ({ const directMappings = Object.entries(loginMappingGrouped).map(([loginUser, entries]) => ({
loginUser, loginUser,
allowedPrincipals: { allowedPrincipals: {
usernames: unique(entries.map((e) => e.username)).filter(Boolean) 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 { return {
@@ -124,7 +205,7 @@ export const sshHostDALFactory = (db: TDbClient) => {
projectId, projectId,
userCertTtl, userCertTtl,
hostCertTtl, hostCertTtl,
loginMappings, loginMappings: [...directMappings, ...groupMappings],
userSshCaId, userSshCaId,
hostSshCaId hostSshCaId
}; };
@@ -163,16 +244,50 @@ export const sshHostDALFactory = (db: TDbClient) => {
const { sshHostId: id, projectId, hostname, alias, userCertTtl, hostCertTtl, userSshCaId, hostSshCaId } = rows[0]; 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), rows.filter((r) => r.loginUser),
(r) => r.loginUser (r) => r.loginUser
); );
const loginMappings = Object.entries(loginMappingGrouped).map(([loginUser, entries]) => ({ const directMappings = Object.entries(directGrouped).map(([loginUser, entries]) => ({
loginUser, loginUser,
allowedPrincipals: { allowedPrincipals: {
usernames: unique(entries.map((e) => e.username)).filter(Boolean) 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 { return {
@@ -182,7 +297,7 @@ export const sshHostDALFactory = (db: TDbClient) => {
alias, alias,
userCertTtl, userCertTtl,
hostCertTtl, hostCertTtl,
loginMappings, loginMappings: [...directMappings, ...groupMappings],
userSshCaId, userSshCaId,
hostSshCaId hostSshCaId
}; };
@@ -16,6 +16,11 @@ export type TLoginMapping = {
}; };
}; };
export enum LoginMappingSource {
HOST = "host",
HOST_GROUP = "hostGroup"
}
export type TCreateSshHostDTO = { export type TCreateSshHostDTO = {
hostname: string; hostname: string;
alias?: string; alias?: string;
+3 -1
View File
@@ -855,13 +855,15 @@ export const registerRoutes = async (
}); });
const sshHostGroupService = sshHostGroupServiceFactory({ const sshHostGroupService = sshHostGroupServiceFactory({
projectDAL,
sshHostDAL, sshHostDAL,
sshHostGroupDAL, sshHostGroupDAL,
sshHostGroupMembershipDAL, sshHostGroupMembershipDAL,
sshHostLoginUserDAL, sshHostLoginUserDAL,
sshHostLoginUserMappingDAL, sshHostLoginUserMappingDAL,
userDAL, userDAL,
permissionService permissionService,
licenseService
}); });
const certificateAuthorityService = certificateAuthorityServiceFactory({ const certificateAuthorityService = certificateAuthorityServiceFactory({
@@ -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 { sanitizedSshCertificate } from "@app/ee/services/ssh-certificate/ssh-certificate-schema";
import { sanitizedSshCertificateTemplate } from "@app/ee/services/ssh-certificate-template/ssh-certificate-template-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 { 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 { sanitizedSshHostGroup } from "@app/ee/services/ssh-host-group/ssh-host-group-schema";
import { ApiDocsTags, PROJECTS } from "@app/lib/api-docs"; import { ApiDocsTags, PROJECTS } from "@app/lib/api-docs";
import { readLimit, writeLimit } from "@app/server/config/rateLimiter"; import { readLimit, writeLimit } from "@app/server/config/rateLimiter";
@@ -632,7 +633,11 @@ export const registerProjectRouter = async (server: FastifyZodProvider) => {
200: z.object({ 200: z.object({
hosts: z.array( hosts: z.array(
sanitizedSshHost.extend({ 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({ 200: z.object({
groups: z.array( groups: z.array(
sanitizedSshHostGroup.extend({ sanitizedSshHostGroup.extend({
loginMappings: z.array(loginMappingSchema), loginMappings: loginMappingSchema.array(),
hostCount: z.number() hostCount: z.number()
}) })
) )
@@ -0,0 +1,4 @@
---
title: "Add Host"
openapi: "POST /api/v1/ssh/host-groups/{sshHostGroupId}/hosts"
---
@@ -0,0 +1,4 @@
---
title: "Create"
openapi: "POST /api/v1/ssh/host-groups"
---
@@ -0,0 +1,4 @@
---
title: "Delete"
openapi: "DELETE /api/v1/ssh/host-groups/{sshHostGroupId}"
---
@@ -0,0 +1,4 @@
---
title: "List Hosts"
openapi: "GET /api/v1/ssh/host-groups/{sshHostGroupId}/hosts"
---
@@ -0,0 +1,4 @@
---
title: "List"
openapi: "GET /api/v2/workspace/{projectId}/ssh-host-groups"
---
@@ -0,0 +1,4 @@
---
title: "Retrieve"
openapi: "GET /api/v1/ssh/host-groups/{sshHostGroupId}"
---
@@ -0,0 +1,4 @@
---
title: "Remove Host"
openapi: "DELETE /api/v1/ssh/host-groups/{sshHostGroupId}/hosts/{sshHostId}"
---
@@ -0,0 +1,4 @@
---
title: "Update"
openapi: "PATCH /api/v1/ssh/host-groups/{sshHostGroupId}"
---
@@ -0,0 +1,4 @@
---
title: "Create"
openapi: "POST /api/v1/ssh/hosts"
---
@@ -0,0 +1,4 @@
---
title: "Delete"
openapi: "DELETE /api/v1/ssh/hosts/{sshHostId}"
---
@@ -0,0 +1,4 @@
---
title: "Issue Host Certificate"
openapi: "POST /api/v1/ssh/hosts/{sshHostId}/issue-host-cert"
---
@@ -0,0 +1,4 @@
---
title: "Issue User Certificate"
openapi: "POST /api/v1/ssh/hosts/{sshHostId}/issue-user-cert"
---
@@ -0,0 +1,4 @@
---
title: "List My Hosts"
openapi: "GET /api/v1/ssh/hosts/"
---
@@ -0,0 +1,4 @@
---
title: "List"
openapi: "GET /api/v2/workspace/{projectId}/ssh-hosts"
---
@@ -0,0 +1,4 @@
---
title: "Read Host CA Public Key"
openapi: "GET /api/v1/ssh/hosts/{sshHostId}/host-ca-public-key"
---
@@ -0,0 +1,4 @@
---
title: "Read User CA Public Key"
openapi: "GET /api/v1/ssh/hosts/{sshHostId}/user-ca-public-key"
---
@@ -0,0 +1,4 @@
---
title: "Retrieve"
openapi: "GET /api/v1/ssh/hosts/{sshHostId}"
---
@@ -0,0 +1,4 @@
---
title: "Update"
openapi: "PATCH /api/v1/ssh/hosts/{sshHostId}"
---
@@ -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 `[email protected]` 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>
@@ -1,6 +1,6 @@
--- ---
title: "Infisical SSH" title: "Infisical SSH"
sidebarTitle: "Infisical SSH" sidebarTitle: "Overview"
description: "Learn how to securely provision user SSH access to your infrastructure using SSH certificates." 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

+37 -3
View File
@@ -118,7 +118,13 @@
"documentation/platform/pki/alerting" "documentation/platform/pki/alerting"
] ]
}, },
"documentation/platform/ssh", {
"group": "Infisical SSH",
"pages": [
"documentation/platform/ssh/overview",
"documentation/platform/ssh/host-groups"
]
},
{ {
"group": "Key Management (KMS)", "group": "Key Management (KMS)",
"pages": [ "pages": [
@@ -887,8 +893,8 @@
] ]
}, },
{ {
"group": "LDAP Password", "group": "LDAP Password",
"pages": [ "pages": [
"api-reference/endpoints/secret-rotations/ldap-password/create", "api-reference/endpoints/secret-rotations/ldap-password/create",
"api-reference/endpoints/secret-rotations/ldap-password/delete", "api-reference/endpoints/secret-rotations/ldap-password/delete",
"api-reference/endpoints/secret-rotations/ldap-password/get-by-id", "api-reference/endpoints/secret-rotations/ldap-password/get-by-id",
@@ -1414,6 +1420,34 @@
{ {
"group": "Infisical SSH", "group": "Infisical SSH",
"pages": [ "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", "group": "Certificates",
"pages": [ "pages": [
+6
View File
@@ -1,3 +1,8 @@
export enum LoginMappingSource {
HOST = "host",
HOST_GROUP = "hostGroup"
}
export type TSshHost = { export type TSshHost = {
id: string; id: string;
projectId: string; projectId: string;
@@ -10,6 +15,7 @@ export type TSshHost = {
allowedPrincipals: { allowedPrincipals: {
usernames: string[]; usernames: string[];
}; };
source: LoginMappingSource;
}[]; }[];
}; };
+1 -1
View File
@@ -39,7 +39,7 @@ export type TDeleteSshHostGroupDTO = {
}; };
export type TListSshHostGroupHostsResponse = { export type TListSshHostGroupHostsResponse = {
hosts: TSshHost[]; hosts: TSshHost & { joinedGroupAt: string; isPartOfGroup: boolean }[];
totalCount: number; totalCount: number;
}; };
@@ -24,6 +24,7 @@ export type SubscriptionPlan = {
workspacesUsed: number; workspacesUsed: number;
environmentLimit: number; environmentLimit: number;
samlSSO: boolean; samlSSO: boolean;
sshHostGroups: boolean;
secretAccessInsights: boolean; secretAccessInsights: boolean;
hsm: boolean; hsm: boolean;
oidcSSO: boolean; oidcSSO: boolean;
@@ -234,6 +234,7 @@ export const projectRoleFormSchema = z.object({
}) })
.array() .array()
.default([]), .default([]),
[ProjectPermissionSub.SshHostGroups]: GeneralPolicyActionSchema.array().default([]),
[ProjectPermissionSub.SecretApproval]: GeneralPolicyActionSchema.array().default([]), [ProjectPermissionSub.SecretApproval]: GeneralPolicyActionSchema.array().default([]),
[ProjectPermissionSub.SecretRollback]: SecretRollbackPolicyActionSchema.array().default([]), [ProjectPermissionSub.SecretRollback]: SecretRollbackPolicyActionSchema.array().default([]),
[ProjectPermissionSub.Project]: WorkspacePolicyActionSchema.array().default([]), [ProjectPermissionSub.Project]: WorkspacePolicyActionSchema.array().default([]),
@@ -380,7 +381,8 @@ export const rolePermission2Form = (permissions: TProjectPermission[] = []) => {
ProjectPermissionSub.Kms, ProjectPermissionSub.Kms,
ProjectPermissionSub.SshCertificateTemplates, ProjectPermissionSub.SshCertificateTemplates,
ProjectPermissionSub.SshCertificateAuthorities, ProjectPermissionSub.SshCertificateAuthorities,
ProjectPermissionSub.SshCertificates ProjectPermissionSub.SshCertificates,
ProjectPermissionSub.SshHostGroups
].includes(subject) ].includes(subject)
) { ) {
// from above statement we are sure it won't be undefined // 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 } { 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]: { [ProjectPermissionSub.PkiCollections]: {
title: "PKI Collections", title: "PKI Collections",
actions: [ actions: [
@@ -1,10 +1,11 @@
import { faPlus } from "@fortawesome/free-solid-svg-icons"; import { faPlus } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { UpgradePlanModal } from "@app/components/license/UpgradePlanModal";
import { createNotification } from "@app/components/notifications"; import { createNotification } from "@app/components/notifications";
import { ProjectPermissionCan } from "@app/components/permissions"; import { ProjectPermissionCan } from "@app/components/permissions";
import { DeleteActionModal, IconButton } from "@app/components/v2"; 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 { usePopUp } from "@app/hooks";
import { useRemoveHostFromSshHostGroup } from "@app/hooks/api"; import { useRemoveHostFromSshHostGroup } from "@app/hooks/api";
@@ -16,13 +17,28 @@ type Props = {
}; };
export const SshHostGroupHostsSection = ({ sshHostGroupId }: Props) => { export const SshHostGroupHostsSection = ({ sshHostGroupId }: Props) => {
const { subscription } = useSubscription();
const { popUp, handlePopUpOpen, handlePopUpClose, handlePopUpToggle } = usePopUp([ const { popUp, handlePopUpOpen, handlePopUpClose, handlePopUpToggle } = usePopUp([
"removeHostFromSshHostGroup", "removeHostFromSshHostGroup",
"addHostGroupMembers" "addHostGroupMembers",
"upgradePlan"
] as const); ] as const);
const { mutateAsync: removeHostFromGroup } = useRemoveHostFromSshHostGroup(); 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) => { const onRemoveSshHostSubmit = async (sshHostId: string) => {
try { try {
await removeHostFromGroup({ 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="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"> <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> <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) => ( {(isAllowed) => (
<IconButton <IconButton
ariaLabel="add host" ariaLabel="add host"
variant="plain" variant="plain"
className="group relative" className="group relative"
onClick={() => onClick={() => handleAddSshHostModal()}
handlePopUpOpen("addHostGroupMembers", {
sshHostGroupId
})
}
isDisabled={!isAllowed} isDisabled={!isAllowed}
> >
<FontAwesomeIcon icon={faPlus} /> <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> </div>
); );
}; };
@@ -42,6 +42,7 @@ export const SshHostGroupHostsTable = ({ sshHostGroupId, handlePopUpOpen }: Prop
<Tr> <Tr>
<Th>Alias</Th> <Th>Alias</Th>
<Th>Hostname</Th> <Th>Hostname</Th>
<Th>Added On</Th>
<Th /> <Th />
</Tr> </Tr>
</THead> </THead>
@@ -53,6 +54,7 @@ export const SshHostGroupHostsTable = ({ sshHostGroupId, handlePopUpOpen }: Prop
<Tr className="h-10" key={`host-${host.id}`}> <Tr className="h-10" key={`host-${host.id}`}>
<Td>{host.alias ?? "-"}</Td> <Td>{host.alias ?? "-"}</Td>
<Td>{host.hostname}</Td> <Td>{host.hostname}</Td>
<Td>{new Date(host.joinedGroupAt).toLocaleDateString()}</Td>
<Td className="flex justify-end"> <Td className="flex justify-end">
<ProjectPermissionCan <ProjectPermissionCan
I={ProjectPermissionActions.Edit} I={ProjectPermissionActions.Edit}
@@ -1,10 +1,11 @@
import { faPlus } from "@fortawesome/free-solid-svg-icons"; import { faPlus } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { UpgradePlanModal } from "@app/components/license/UpgradePlanModal";
import { createNotification } from "@app/components/notifications"; import { createNotification } from "@app/components/notifications";
import { ProjectPermissionCan } from "@app/components/permissions"; import { ProjectPermissionCan } from "@app/components/permissions";
import { Button, DeleteActionModal } from "@app/components/v2"; 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 { useDeleteSshHostGroup } from "@app/hooks/api";
import { usePopUp } from "@app/hooks/usePopUp"; import { usePopUp } from "@app/hooks/usePopUp";
@@ -12,13 +13,26 @@ import { SshHostGroupModal } from "./SshHostGroupModal";
import { SshHostGroupsTable } from "./SshHostGroupsTable"; import { SshHostGroupsTable } from "./SshHostGroupsTable";
export const SshHostGroupsSection = () => { export const SshHostGroupsSection = () => {
const { subscription } = useSubscription();
const { mutateAsync: deleteSshHostGroup } = useDeleteSshHostGroup(); const { mutateAsync: deleteSshHostGroup } = useDeleteSshHostGroup();
const { popUp, handlePopUpOpen, handlePopUpClose, handlePopUpToggle } = usePopUp([ const { popUp, handlePopUpOpen, handlePopUpClose, handlePopUpToggle } = usePopUp([
"sshHostGroup", "sshHostGroup",
"deleteSshHostGroup" "deleteSshHostGroup",
"upgradePlan"
] as const); ] 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) => { const onRemoveSshHostGroupSubmit = async (sshHostGroupId: string) => {
try { try {
const hostGroup = await deleteSshHostGroup({ sshHostGroupId }); const hostGroup = await deleteSshHostGroup({ sshHostGroupId });
@@ -48,7 +62,7 @@ export const SshHostGroupsSection = () => {
colorSchema="primary" colorSchema="primary"
type="submit" type="submit"
leftIcon={<FontAwesomeIcon icon={faPlus} />} leftIcon={<FontAwesomeIcon icon={faPlus} />}
onClick={() => handlePopUpOpen("sshHostGroup")} onClick={() => handleAddSshHostGroupModal()}
isDisabled={!isAllowed} isDisabled={!isAllowed}
> >
Add Group 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> </div>
); );
}; };
@@ -43,7 +43,7 @@ export const SshHostGroupsTable = ({ handlePopUpOpen }: Props) => {
<THead> <THead>
<Tr> <Tr>
<Th>Name</Th> <Th>Name</Th>
<Th># Hosts</Th> <Th># Hosts in Group</Th>
<Th>Login User - Authorized Principals Mapping</Th> <Th>Login User - Authorized Principals Mapping</Th>
<Th /> <Th />
</Tr> </Tr>
@@ -26,6 +26,7 @@ import {
useListWorkspaceSshHosts, useListWorkspaceSshHosts,
useUpdateSshHost useUpdateSshHost
} from "@app/hooks/api"; } from "@app/hooks/api";
import { LoginMappingSource } from "@app/hooks/api/sshHost/types";
import { UsePopUpState } from "@app/hooks/usePopUp"; import { UsePopUpState } from "@app/hooks/usePopUp";
type Props = { type Props = {
@@ -48,7 +49,8 @@ const schema = z
loginMappings: z loginMappings: z
.object({ .object({
loginUser: z.string().trim().min(1), 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() .array()
.default([]) .default([])
@@ -99,9 +101,10 @@ export const SshHostModal = ({ popUp, handlePopUpToggle }: Props) => {
hostname: sshHost.hostname, hostname: sshHost.hostname,
alias: sshHost.alias ?? "", alias: sshHost.alias ?? "",
userCertTtl: sshHost.userCertTtl, userCertTtl: sshHost.userCertTtl,
loginMappings: sshHost.loginMappings.map(({ loginUser, allowedPrincipals }) => ({ loginMappings: sshHost.loginMappings.map(({ loginUser, allowedPrincipals, source }) => ({
loginUser, loginUser,
allowedPrincipals: allowedPrincipals.usernames allowedPrincipals: allowedPrincipals.usernames,
source
})) }))
}); });
@@ -122,6 +125,11 @@ export const SshHostModal = ({ popUp, handlePopUpToggle }: Props) => {
try { try {
if (!projectId) return; 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 // check if there is already a different host with the same hostname
const existingHostnames = const existingHostnames =
sshHosts?.filter((h) => h.id !== sshHost?.id).map((h) => h.hostname) || []; sshHosts?.filter((h) => h.id !== sshHost?.id).map((h) => h.hostname) || [];
@@ -157,7 +165,7 @@ export const SshHostModal = ({ popUp, handlePopUpToggle }: Props) => {
hostname, hostname,
alias: trimmedAlias, alias: trimmedAlias,
userCertTtl, userCertTtl,
loginMappings: loginMappings.map(({ loginUser, allowedPrincipals }) => ({ loginMappings: hostLoginMappings.map(({ loginUser, allowedPrincipals }) => ({
loginUser, loginUser,
allowedPrincipals: { allowedPrincipals: {
usernames: allowedPrincipals usernames: allowedPrincipals
@@ -170,7 +178,7 @@ export const SshHostModal = ({ popUp, handlePopUpToggle }: Props) => {
hostname, hostname,
alias: trimmedAlias, alias: trimmedAlias,
userCertTtl, userCertTtl,
loginMappings: loginMappings.map(({ loginUser, allowedPrincipals }) => ({ loginMappings: hostLoginMappings.map(({ loginUser, allowedPrincipals }) => ({
loginUser, loginUser,
allowedPrincipals: { allowedPrincipals: {
usernames: allowedPrincipals usernames: allowedPrincipals
@@ -265,7 +273,11 @@ export const SshHostModal = ({ popUp, handlePopUpToggle }: Props) => {
variant="outline_bg" variant="outline_bg"
onClick={() => { onClick={() => {
const newIndex = loginMappingsFormFields.fields.length; const newIndex = loginMappingsFormFields.fields.length;
loginMappingsFormFields.append({ loginUser: "", allowedPrincipals: [""] }); loginMappingsFormFields.append({
loginUser: "",
allowedPrincipals: [""],
source: LoginMappingSource.HOST
});
setExpandedMappings((prev) => ({ setExpandedMappings((prev) => ({
...prev, ...prev,
[newIndex]: true [newIndex]: true
@@ -298,6 +310,12 @@ export const SshHostModal = ({ popUp, handlePopUpToggle }: Props) => {
render={({ field }) => ( render={({ field }) => (
<span className="text-sm font-medium leading-tight"> <span className="text-sm font-medium leading-tight">
{field.value || "New Login Mapping"} {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> </span>
)} )}
/> />
@@ -306,6 +324,9 @@ export const SshHostModal = ({ popUp, handlePopUpToggle }: Props) => {
ariaLabel="delete login mapping" ariaLabel="delete login mapping"
variant="plain" variant="plain"
onClick={() => loginMappingsFormFields.remove(i)} onClick={() => loginMappingsFormFields.remove(i)}
isDisabled={
loginMappingsFormFields.fields[i].source === LoginMappingSource.HOST_GROUP
}
> >
<FontAwesomeIcon icon={faTrash} /> <FontAwesomeIcon icon={faTrash} />
</IconButton> </IconButton>
@@ -327,7 +348,17 @@ export const SshHostModal = ({ popUp, handlePopUpToggle }: Props) => {
<Input <Input
{...field} {...field}
placeholder="ec2-user" placeholder="ec2-user"
isDisabled={
loginMappingsFormFields.fields[i].source ===
LoginMappingSource.HOST_GROUP
}
onChange={(e) => { onChange={(e) => {
if (
loginMappingsFormFields.fields[i].source ===
LoginMappingSource.HOST_GROUP
)
return;
const newValue = e.target.value; const newValue = e.target.value;
const loginMappings = getValues("loginMappings"); const loginMappings = getValues("loginMappings");
const isDuplicate = loginMappings.some( const isDuplicate = loginMappings.some(
@@ -355,17 +386,20 @@ export const SshHostModal = ({ popUp, handlePopUpToggle }: Props) => {
label="Allowed Principals" label="Allowed Principals"
className="text-xs text-mineshaft-400" className="text-xs text-mineshaft-400"
/> />
<Button {loginMappingsFormFields.fields[i].source === LoginMappingSource.HOST && (
leftIcon={<FontAwesomeIcon icon={faPlus} />} <Button
size="xs" leftIcon={<FontAwesomeIcon icon={faPlus} />}
variant="outline_bg" size="xs"
onClick={() => { variant="outline_bg"
const current = getValues(`loginMappings.${i}.allowedPrincipals`) ?? []; onClick={() => {
setValue(`loginMappings.${i}.allowedPrincipals`, [...current, ""]); const current =
}} getValues(`loginMappings.${i}.allowedPrincipals`) ?? [];
> setValue(`loginMappings.${i}.allowedPrincipals`, [...current, ""]);
Add Principal }}
</Button> >
Add Principal
</Button>
)}
</div> </div>
<Controller <Controller
control={control} control={control}
@@ -382,6 +416,12 @@ export const SshHostModal = ({ popUp, handlePopUpToggle }: Props) => {
<Select <Select
value={principal} value={principal}
onValueChange={(newValue) => { onValueChange={(newValue) => {
if (
loginMappingsFormFields.fields[i].source ===
LoginMappingSource.HOST_GROUP
)
return;
if (value.includes(newValue)) { if (value.includes(newValue)) {
createNotification({ createNotification({
text: "This principal is already added", text: "This principal is already added",
@@ -395,6 +435,10 @@ export const SshHostModal = ({ popUp, handlePopUpToggle }: Props) => {
}} }}
placeholder="Select a member" placeholder="Select a member"
className="w-full" className="w-full"
isDisabled={
loginMappingsFormFields.fields[i].source ===
LoginMappingSource.HOST_GROUP
}
> >
{members.map((member) => ( {members.map((member) => (
<SelectItem <SelectItem
@@ -412,11 +456,21 @@ export const SshHostModal = ({ popUp, handlePopUpToggle }: Props) => {
variant="plain" variant="plain"
className="h-9" className="h-9"
onClick={() => { onClick={() => {
if (
loginMappingsFormFields.fields[i].source ===
LoginMappingSource.HOST_GROUP
)
return;
const newPrincipals = value.filter( const newPrincipals = value.filter(
(_, idx) => idx !== principalIndex (_, idx) => idx !== principalIndex
); );
onChange(newPrincipals); onChange(newPrincipals);
}} }}
isDisabled={
loginMappingsFormFields.fields[i].source ===
LoginMappingSource.HOST_GROUP
}
> >
<FontAwesomeIcon icon={faTrash} /> <FontAwesomeIcon icon={faTrash} />
</IconButton> </IconButton>
@@ -29,6 +29,7 @@ import {
} from "@app/components/v2"; } from "@app/components/v2";
import { ProjectPermissionActions, ProjectPermissionSub, useWorkspace } from "@app/context"; import { ProjectPermissionActions, ProjectPermissionSub, useWorkspace } from "@app/context";
import { fetchSshHostUserCaPublicKey, useListWorkspaceSshHosts } from "@app/hooks/api"; import { fetchSshHostUserCaPublicKey, useListWorkspaceSshHosts } from "@app/hooks/api";
import { LoginMappingSource } from "@app/hooks/api/sshHost/types";
import { UsePopUpState } from "@app/hooks/usePopUp"; import { UsePopUpState } from "@app/hooks/usePopUp";
type Props = { type Props = {
@@ -90,9 +91,16 @@ export const SshHostsTable = ({ handlePopUpOpen }: Props) => {
{host.loginMappings.length === 0 ? ( {host.loginMappings.length === 0 ? (
<span className="italic text-mineshaft-400">None</span> <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 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) => ( {allowedPrincipals.usernames.map((username) => (
<div key={`${host.id}-${loginUser}-${username}`} className="ml-4"> <div key={`${host.id}-${loginUser}-${username}`} className="ml-4">
└─ {username} └─ {username}