preliminary ssh host structs, api, ui

This commit is contained in:
Tuan Dang
2025-04-05 22:25:06 -07:00
parent 18c8fc66ee
commit b8f04d6738
154 changed files with 1896 additions and 245 deletions
@@ -0,0 +1,113 @@
import { Knex } from "knex";
import { TDbClient } from "@app/db";
import { TableName } from "@app/db/schemas";
import { DatabaseError } from "@app/lib/errors";
import { groupBy, unique } from "@app/lib/fn";
import { ormify } from "@app/lib/knex";
export type TSshHostDALFactory = ReturnType<typeof sshHostDALFactory>;
export const sshHostDALFactory = (db: TDbClient) => {
const sshHostOrm = ormify(db, TableName.SshHost);
const findSshHostsWithLoginMappings = async (projectId: string, tx?: Knex) => {
try {
const rows = await (tx || db.replicaNode())(TableName.SshHost)
.leftJoin(
TableName.SshHostLoginMapping,
`${TableName.SshHost}.id`,
`${TableName.SshHostLoginMapping}.sshHostId`
)
.where(`${TableName.SshHost}.projectId`, projectId)
.select(
db.ref("id").withSchema(TableName.SshHost).as("sshHostId"),
db.ref("projectId").withSchema(TableName.SshHost),
db.ref("hostname").withSchema(TableName.SshHost),
db.ref("userCertTtl").withSchema(TableName.SshHost),
db.ref("hostCertTtl").withSchema(TableName.SshHost),
db.ref("loginUser").withSchema(TableName.SshHostLoginMapping),
db.ref("allowedPrincipals").withSchema(TableName.SshHostLoginMapping)
)
.orderBy(`${TableName.SshHost}.updatedAt`, "desc");
const hostsGrouped = groupBy(rows, (r) => r.sshHostId);
return Object.values(hostsGrouped).map((hostRows) => {
const { sshHostId, hostname, userCertTtl, hostCertTtl } = hostRows[0];
const loginMappingGrouped = groupBy(
hostRows.filter((r) => r.loginUser),
(r) => r.loginUser
);
const loginMappings = Object.entries(loginMappingGrouped).map(([loginUser, entries]) => ({
loginUser,
allowedPrincipals: unique(entries.flatMap((entry) => entry.allowedPrincipals ?? []))
}));
return {
id: sshHostId,
projectId,
hostname,
userCertTtl,
hostCertTtl,
loginMappings
};
});
} catch (error) {
throw new DatabaseError({ error, name: `${TableName.SshHost}: FindSshHostsWithLoginMappings` });
}
};
const findSshHostByIdWithLoginMappings = async (sshHostId: string, tx?: Knex) => {
try {
const rows = await (tx || db.replicaNode())(TableName.SshHost)
.leftJoin(
TableName.SshHostLoginMapping,
`${TableName.SshHost}.id`,
`${TableName.SshHostLoginMapping}.sshHostId`
)
.where(`${TableName.SshHost}.id`, sshHostId)
.select(
db.ref("id").withSchema(TableName.SshHost).as("sshHostId"),
db.ref("projectId").withSchema(TableName.SshHost),
db.ref("hostname").withSchema(TableName.SshHost),
db.ref("userCertTtl").withSchema(TableName.SshHost),
db.ref("hostCertTtl").withSchema(TableName.SshHost),
db.ref("loginUser").withSchema(TableName.SshHostLoginMapping),
db.ref("allowedPrincipals").withSchema(TableName.SshHostLoginMapping)
);
if (rows.length === 0) return null;
const { sshHostId: id, projectId, hostname, userCertTtl, hostCertTtl } = rows[0];
const loginMappingGrouped = groupBy(
rows.filter((r) => r.loginUser),
(r) => r.loginUser
);
const loginMappings = Object.entries(loginMappingGrouped).map(([loginUser, entries]) => ({
loginUser,
allowedPrincipals: unique(entries.flatMap((entry) => entry.allowedPrincipals ?? []))
}));
return {
id,
projectId,
hostname,
userCertTtl,
hostCertTtl,
loginMappings
};
} catch (error) {
throw new DatabaseError({ error, name: `${TableName.SshHost}: FindSshHostByIdWithLoginMappings` });
}
};
return {
...sshHostOrm,
findSshHostsWithLoginMappings,
findSshHostByIdWithLoginMappings
};
};
@@ -0,0 +1,10 @@
import { TDbClient } from "@app/db";
import { TableName } from "@app/db/schemas";
import { ormify } from "@app/lib/knex";
export type TSshHostLoginMappingDALFactory = ReturnType<typeof sshHostLoginMappingDALFactory>;
export const sshHostLoginMappingDALFactory = (db: TDbClient) => {
const sshHostLoginMappingOrm = ormify(db, TableName.SshHostLoginMapping);
return sshHostLoginMappingOrm;
};
@@ -0,0 +1,9 @@
import { SshHostsSchema } from "@app/db/schemas";
export const sanitizedSshHost = SshHostsSchema.pick({
id: true,
projectId: true,
hostname: true,
userCertTtl: true,
hostCertTtl: true
});
@@ -0,0 +1,215 @@
import { ForbiddenError } from "@casl/ability";
import { ActionProjectType } from "@app/db/schemas";
import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service";
import { ProjectPermissionActions, ProjectPermissionSub } from "@app/ee/services/permission/project-permission";
import { TSshHostDALFactory } from "@app/ee/services/ssh-host/ssh-host-dal";
import { TSshHostLoginMappingDALFactory } from "@app/ee/services/ssh-host/ssh-host-login-mapping-dal";
import { BadRequestError, NotFoundError } from "@app/lib/errors";
import { TCreateSshHostDTO, TDeleteSshHostDTO, TGetSshHostDTO, TUpdateSshHostDTO } from "./ssh-host-types";
type TSshCertificateAuthorityServiceFactoryDep = {
sshHostDAL: Pick<
TSshHostDALFactory,
"transaction" | "create" | "findById" | "updateById" | "deleteById" | "findOne" | "findSshHostByIdWithLoginMappings"
>;
sshHostLoginMappingDAL: Pick<
TSshHostLoginMappingDALFactory,
"transaction" | "create" | "findById" | "updateById" | "deleteById" | "findOne" | "insertMany" | "delete"
>;
permissionService: Pick<TPermissionServiceFactory, "getProjectPermission">;
};
export type TSshHostServiceFactory = ReturnType<typeof sshHostServiceFactory>;
/**
* Checklist:
* - check permissions across various roles to make sure it makes sense for the SSH hosts
*/
export const sshHostServiceFactory = ({
sshHostDAL,
sshHostLoginMappingDAL,
permissionService
}: TSshCertificateAuthorityServiceFactoryDep) => {
const createSshHost = async ({
projectId,
hostname,
userCertTtl,
hostCertTtl,
loginMappings,
actorId,
actorAuthMethod,
actor,
actorOrgId
}: TCreateSshHostDTO) => {
const { permission } = await permissionService.getProjectPermission({
actor,
actorId,
projectId,
actorAuthMethod,
actorOrgId,
actionProjectType: ActionProjectType.SSH
});
ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Create, ProjectPermissionSub.SshHosts);
const newSshHost = await sshHostDAL.transaction(async (tx) => {
const existingHost = await sshHostDAL.findOne(
{
projectId,
hostname
},
tx
);
if (existingHost) {
throw new BadRequestError({
message: `SSH host with hostname ${hostname} already exists`
});
}
const host = await sshHostDAL.create(
{
projectId,
hostname,
userCertTtl,
hostCertTtl
},
tx
);
await sshHostLoginMappingDAL.insertMany(
loginMappings.map(({ loginUser, allowedPrincipals }) => ({
sshHostId: host.id,
loginUser,
allowedPrincipals
})),
tx
);
const newSshHostWithLoginMappings = await sshHostDAL.findSshHostByIdWithLoginMappings(host.id, tx);
if (!newSshHostWithLoginMappings) {
throw new NotFoundError({ message: `SSH host with ID '${host.id}' not found` });
}
return newSshHostWithLoginMappings;
});
return newSshHost;
};
const updateSshHost = async ({
sshHostId,
hostname,
userCertTtl,
hostCertTtl,
loginMappings,
actorId,
actorAuthMethod,
actor,
actorOrgId
}: TUpdateSshHostDTO) => {
const host = await sshHostDAL.findById(sshHostId);
if (!host) throw new NotFoundError({ message: `SSH host with ID '${sshHostId}' not found` });
const { permission } = await permissionService.getProjectPermission({
actor,
actorId,
projectId: host.projectId,
actorAuthMethod,
actorOrgId,
actionProjectType: ActionProjectType.SSH
});
ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Edit, ProjectPermissionSub.SshHosts);
const updatedHost = await sshHostDAL.transaction(async (tx) => {
await sshHostDAL.updateById(
sshHostId,
{
hostname,
userCertTtl,
hostCertTtl
},
tx
);
if (loginMappings) {
await sshHostLoginMappingDAL.delete({ sshHostId }, tx);
if (loginMappings.length) {
await sshHostLoginMappingDAL.insertMany(
loginMappings.map(({ loginUser, allowedPrincipals }) => ({
sshHostId: host.id,
loginUser,
allowedPrincipals
})),
tx
);
}
}
const updatedHostWithLoginMappings = await sshHostDAL.findSshHostByIdWithLoginMappings(sshHostId, tx);
if (!updatedHostWithLoginMappings) {
throw new NotFoundError({ message: `SSH host with ID '${sshHostId}' not found` });
}
return updatedHostWithLoginMappings;
});
return updatedHost;
};
const deleteSshHost = async ({ sshHostId, actorId, actorAuthMethod, actor, actorOrgId }: TDeleteSshHostDTO) => {
const host = await sshHostDAL.findSshHostByIdWithLoginMappings(sshHostId);
if (!host) throw new NotFoundError({ message: `SSH host with ID '${sshHostId}' not found` });
const { permission } = await permissionService.getProjectPermission({
actor,
actorId,
projectId: host.projectId,
actorAuthMethod,
actorOrgId,
actionProjectType: ActionProjectType.SSH
});
ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Delete, ProjectPermissionSub.SshHosts);
await sshHostDAL.transaction(async (tx) => {
await sshHostLoginMappingDAL.delete({ sshHostId }, tx);
await sshHostDAL.deleteById(sshHostId, tx);
});
return host;
};
const getSshHost = async ({ sshHostId, actorId, actorAuthMethod, actor, actorOrgId }: TGetSshHostDTO) => {
const host = await sshHostDAL.findSshHostByIdWithLoginMappings(sshHostId);
if (!host) {
throw new NotFoundError({
message: `SSH host with ID ${sshHostId} not found`
});
}
const { permission } = await permissionService.getProjectPermission({
actor,
actorId,
projectId: host.projectId,
actorAuthMethod,
actorOrgId,
actionProjectType: ActionProjectType.SSH
});
ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Read, ProjectPermissionSub.SshHosts);
return host;
};
return {
createSshHost,
updateSshHost,
deleteSshHost,
getSshHost
};
};
@@ -0,0 +1,30 @@
import { TProjectPermission } from "@app/lib/types";
export type TCreateSshHostDTO = {
hostname: string;
userCertTtl: string;
hostCertTtl: string;
loginMappings: {
loginUser: string;
allowedPrincipals: string[];
}[];
} & TProjectPermission;
export type TUpdateSshHostDTO = {
sshHostId: string;
hostname?: string;
userCertTtl?: string;
hostCertTtl?: string;
loginMappings?: {
loginUser: string;
allowedPrincipals: string[];
}[];
} & Omit<TProjectPermission, "projectId">;
export type TGetSshHostDTO = {
sshHostId: string;
} & Omit<TProjectPermission, "projectId">;
export type TDeleteSshHostDTO = {
sshHostId: string;
} & Omit<TProjectPermission, "projectId">;
@@ -0,0 +1,15 @@
import { isFQDN } from "@app/lib/validator/validate-url";
export const isValidHostname = (value: string): boolean => {
if (typeof value !== "string") return false;
if (value.length > 255) return false;
// Only allow strict FQDNs, no wildcards or IPs
return isFQDN(value, {
require_tld: true,
allow_underscores: false,
allow_trailing_dot: false,
allow_numeric_tld: true,
allow_wildcard: false
});
};