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
@@ -28,7 +28,8 @@ export const getDefaultOnPremFeatures = () => {
has_used_trial: true,
secretApproval: true,
secretRotation: true,
caCrl: false
caCrl: false,
sshHostGroups: false
};
};
@@ -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" },
@@ -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) => {
@@ -71,6 +71,7 @@ export type TFeatureSet = {
projectTemplates: false;
kmip: false;
gateway: false;
sshHostGroups: false;
};
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 { 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 };
@@ -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",
+139 -24
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
};
@@ -16,6 +16,11 @@ export type TLoginMapping = {
};
};
export enum LoginMappingSource {
HOST = "host",
HOST_GROUP = "hostGroup"
}
export type TCreateSshHostDTO = {
hostname: string;
alias?: string;