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
+2
View File
@@ -32,6 +32,7 @@ import { registerSnapshotRouter } from "./snapshot-router";
import { registerSshCaRouter } from "./ssh-certificate-authority-router";
import { registerSshCertRouter } from "./ssh-certificate-router";
import { registerSshCertificateTemplateRouter } from "./ssh-certificate-template-router";
import { registerSshHostRouter } from "./ssh-host-router";
import { registerTrustedIpRouter } from "./trusted-ip-router";
import { registerUserAdditionalPrivilegeRouter } from "./user-additional-privilege-router";
@@ -82,6 +83,7 @@ export const registerV1EERoutes = async (server: FastifyZodProvider) => {
await sshRouter.register(registerSshCaRouter, { prefix: "/ca" });
await sshRouter.register(registerSshCertRouter, { prefix: "/certificates" });
await sshRouter.register(registerSshCertificateTemplateRouter, { prefix: "/certificate-templates" });
await sshRouter.register(registerSshHostRouter, { prefix: "/hosts" });
},
{ prefix: "/ssh" }
);
+265
View File
@@ -0,0 +1,265 @@
import { z } from "zod";
import { sanitizedSshHost } from "@app/ee/services/ssh-host/ssh-host-schema";
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";
import { readLimit, writeLimit } from "@app/server/config/rateLimiter";
import { verifyAuth } from "@app/server/plugins/auth/verify-auth";
import { AuthMode } from "@app/services/auth/auth-type";
export const registerSshHostRouter = async (server: FastifyZodProvider) => {
server.route({
method: "GET",
url: "/:sshHostId",
config: {
rateLimit: readLimit
},
schema: {
params: z.object({
sshHostId: z.string().describe(SSH_HOSTS.GET.sshHostId)
}),
response: {
200: sanitizedSshHost.extend({
loginMappings: z.array(
z.object({
loginUser: z.string(),
allowedPrincipals: z.array(z.string())
})
)
})
}
},
onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]),
handler: async (req) => {
const host = await server.services.sshHost.getSshHost({
sshHostId: req.params.sshHostId,
actor: req.permission.type,
actorId: req.permission.id,
actorAuthMethod: req.permission.authMethod,
actorOrgId: req.permission.orgId
});
// TODO: audit log
// await server.services.auditLog.createAuditLog({
// ...req.auditLogInfo,
// projectId: certificateTemplate.projectId,
// event: {
// type: EventType.GET_SSH_CERTIFICATE_TEMPLATE,
// metadata: {
// certificateTemplateId: certificateTemplate.id
// }
// }
// });
return host;
}
});
server.route({
method: "POST",
url: "/",
config: {
rateLimit: writeLimit
},
schema: {
description: "Create SSH Host",
body: z.object({
projectId: z.string().describe(SSH_HOSTS.CREATE.projectId),
hostname: z
.string()
.min(1)
.refine((v) => isValidHostname(v), {
message: "Hostname must be a valid hostname"
})
.describe(SSH_HOSTS.CREATE.hostname),
userCertTtl: z
.string()
.refine((val) => ms(val) > 0, "TTL must be a positive number")
.default("8h")
.describe(SSH_HOSTS.CREATE.userCertTtl),
hostCertTtl: z
.string()
.refine((val) => ms(val) > 0, "TTL must be a positive number")
.default("1y")
.describe(SSH_HOSTS.CREATE.hostCertTtl),
loginMappings: z
.object({
loginUser: z.string().describe(SSH_HOSTS.CREATE.loginUser), // TODO: reinforce validation
allowedPrincipals: z.array(z.string()).describe(SSH_HOSTS.CREATE.allowedPrincipals) // TODO: reinforce validation
})
.array()
.default([])
.describe(SSH_HOSTS.CREATE.loginMappings)
}),
response: {
200: sanitizedSshHost.extend({
loginMappings: z.array(
z.object({
loginUser: z.string(),
allowedPrincipals: z.array(z.string())
})
)
})
}
},
onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]),
handler: async (req) => {
const host = await server.services.sshHost.createSshHost({
...req.body,
actor: req.permission.type,
actorId: req.permission.id,
actorAuthMethod: req.permission.authMethod,
actorOrgId: req.permission.orgId
});
// TODO: audit log
// await server.services.auditLog.createAuditLog({
// ...req.auditLogInfo,
// projectId: ca.projectId,
// event: {
// type: EventType.CREATE_SSH_CERTIFICATE_TEMPLATE,
// metadata: {
// certificateTemplateId: certificateTemplate.id,
// sshCaId: ca.id,
// name: certificateTemplate.name,
// ttl: certificateTemplate.ttl,
// maxTTL: certificateTemplate.maxTTL,
// allowedUsers: certificateTemplate.allowedUsers,
// allowedHosts: certificateTemplate.allowedHosts,
// allowUserCertificates: certificateTemplate.allowUserCertificates,
// allowHostCertificates: certificateTemplate.allowHostCertificates,
// allowCustomKeyIds: certificateTemplate.allowCustomKeyIds
// }
// }
// });
return host;
}
});
server.route({
method: "PATCH",
url: "/:sshHostId",
config: {
rateLimit: writeLimit
},
onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]),
schema: {
description: "Update SSH Host",
params: z.object({
sshHostId: z.string().trim().describe(SSH_HOSTS.UPDATE.sshHostId)
}),
body: z.object({
hostname: z
.string()
.min(1)
.refine((v) => isValidHostname(v), {
message: "Hostname must be a valid hostname"
})
.describe(SSH_HOSTS.UPDATE.hostname),
userCertTtl: z
.string()
.refine((val) => ms(val) > 0, "TTL must be a positive number")
.default("1h")
.describe(SSH_HOSTS.UPDATE.userCertTtl),
hostCertTtl: z
.string()
.refine((val) => ms(val) > 0, "TTL must be a positive number")
.default("1y")
.describe(SSH_HOSTS.UPDATE.hostCertTtl),
loginMappings: z
.object({
loginUser: z.string().describe(SSH_HOSTS.CREATE.loginUser),
allowedPrincipals: z.array(z.string()).describe(SSH_HOSTS.CREATE.allowedPrincipals)
})
.array()
.optional()
.describe(SSH_HOSTS.CREATE.loginMappings)
}),
response: {
200: sanitizedSshHost.extend({
loginMappings: z.array(
z.object({
loginUser: z.string(),
allowedPrincipals: z.array(z.string())
})
)
})
}
},
handler: async (req) => {
const host = await server.services.sshHost.updateSshHost({
sshHostId: req.params.sshHostId,
actor: req.permission.type,
actorId: req.permission.id,
actorAuthMethod: req.permission.authMethod,
actorOrgId: req.permission.orgId,
...req.body
});
// TODO: audit log
// await server.services.auditLog.createAuditLog({
// ...req.auditLogInfo,
// projectId: ca.projectId,
// event: {
// type: EventType.UPDATE_SSH_CA,
// metadata: {
// sshCaId: ca.id,
// friendlyName: ca.friendlyName,
// status: ca.status as SshCaStatus
// }
// }
// });
return host;
}
});
server.route({
method: "DELETE",
url: "/:sshHostId",
config: {
rateLimit: writeLimit
},
schema: {
params: z.object({
sshHostId: z.string().describe(SSH_HOSTS.DELETE.sshHostId)
}),
response: {
200: sanitizedSshHost.extend({
loginMappings: z.array(
z.object({
loginUser: z.string(),
allowedPrincipals: z.array(z.string())
})
)
})
}
},
onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]),
handler: async (req) => {
const host = await server.services.sshHost.deleteSshHost({
sshHostId: req.params.sshHostId,
actor: req.permission.type,
actorId: req.permission.id,
actorAuthMethod: req.permission.authMethod,
actorOrgId: req.permission.orgId
});
// TODO: audit log
// await server.services.auditLog.createAuditLog({
// ...req.auditLogInfo,
// projectId: certificateTemplate.projectId,
// event: {
// type: EventType.DELETE_SSH_CERTIFICATE_TEMPLATE,
// metadata: {
// certificateTemplateId: certificateTemplate.id
// }
// }
// });
return host;
}
});
};
@@ -112,6 +112,7 @@ export enum ProjectPermissionSub {
SshCertificateAuthorities = "ssh-certificate-authorities",
SshCertificates = "ssh-certificates",
SshCertificateTemplates = "ssh-certificate-templates",
SshHosts = "ssh-hosts",
PkiAlerts = "pki-alerts",
PkiCollections = "pki-collections",
Kms = "kms",
@@ -195,6 +196,7 @@ export type ProjectPermissionSet =
| [ProjectPermissionActions, ProjectPermissionSub.SshCertificateAuthorities]
| [ProjectPermissionActions, ProjectPermissionSub.SshCertificates]
| [ProjectPermissionActions, ProjectPermissionSub.SshCertificateTemplates]
| [ProjectPermissionActions, ProjectPermissionSub.SshHosts]
| [ProjectPermissionActions, ProjectPermissionSub.PkiAlerts]
| [ProjectPermissionActions, ProjectPermissionSub.PkiCollections]
| [ProjectPermissionSecretSyncActions, ProjectPermissionSub.SecretSyncs]
@@ -418,6 +420,12 @@ const GeneralPermissionSchema = [
"Describe what action an entity can take."
)
}),
z.object({
subject: z.literal(ProjectPermissionSub.SshHosts).describe("The entity this permission pertains to."),
action: CASL_ACTION_SCHEMA_NATIVE_ENUM(ProjectPermissionActions).describe(
"Describe what action an entity can take."
)
}),
z.object({
subject: z.literal(ProjectPermissionSub.PkiAlerts).describe("The entity this permission pertains to."),
action: CASL_ACTION_SCHEMA_NATIVE_ENUM(ProjectPermissionActions).describe(
@@ -571,7 +579,8 @@ const buildAdminPermissionRules = () => {
ProjectPermissionSub.PkiCollections,
ProjectPermissionSub.SshCertificateAuthorities,
ProjectPermissionSub.SshCertificates,
ProjectPermissionSub.SshCertificateTemplates
ProjectPermissionSub.SshCertificateTemplates,
ProjectPermissionSub.SshHosts
].forEach((el) => {
can(
[
@@ -831,7 +840,7 @@ const buildMemberPermissionRules = () => {
can([ProjectPermissionActions.Read], ProjectPermissionSub.SshCertificates);
can([ProjectPermissionActions.Create], ProjectPermissionSub.SshCertificates);
can([ProjectPermissionActions.Read], ProjectPermissionSub.SshCertificateTemplates);
can([ProjectPermissionActions.Read], ProjectPermissionSub.SshHosts);
can(
[
ProjectPermissionCmekActions.Create,
@@ -892,6 +901,7 @@ const buildViewerPermissionRules = () => {
can(ProjectPermissionActions.Read, ProjectPermissionSub.SshCertificateAuthorities);
can(ProjectPermissionActions.Read, ProjectPermissionSub.SshCertificates);
can(ProjectPermissionActions.Read, ProjectPermissionSub.SshCertificateTemplates);
can(ProjectPermissionActions.Read, ProjectPermissionSub.SshHosts);
can(ProjectPermissionSecretSyncActions.Read, ProjectPermissionSub.SecretSyncs);
return rules;
@@ -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
});
};