From 4fc8c509ac2ea76ec4c1876affdd97af971e9342 Mon Sep 17 00:00:00 2001 From: Tuan Dang Date: Mon, 2 Dec 2024 22:37:23 -0800 Subject: [PATCH] Finish preliminary loop on SSH certificates --- backend/Dockerfile.dev | 3 +- backend/src/@types/fastify.d.ts | 4 + backend/src/@types/knex.d.ts | 24 ++ .../db/migrations/20241130015511_ssh-mgmt.ts | 59 +++ backend/src/db/schemas/index.ts | 3 + backend/src/db/schemas/models.ts | 3 + .../db/schemas/ssh-certificate-authorities.ts | 24 ++ .../ssh-certificate-authority-secrets.ts | 27 ++ .../db/schemas/ssh-certificate-templates.ts | 29 ++ backend/src/ee/routes/v1/index.ts | 12 + .../v1/ssh-certificate-authority-router.ts | 250 ++++++++++++ .../v1/ssh-certificate-template-router.ts | 234 +++++++++++ backend/src/ee/routes/v1/ssh-router.ts | 141 +++++++ .../ee/services/audit-log/audit-log-types.ts | 134 +++++++ .../ee/services/permission/org-permission.ts | 21 +- .../ssh-certificate-template-dal.ts | 38 ++ .../ssh-certificate-template-schema.ts | 14 + .../ssh-certificate-template-service.ts | 206 ++++++++++ .../ssh-certificate-template-types.ts | 33 ++ .../ssh-certificate-template-validators.ts | 14 + .../ssh/ssh-certificate-authority-dal.ts | 10 + .../ssh/ssh-certificate-authority-fns.ts | 198 +++++++++ .../ssh/ssh-certificate-authority-schema.ts | 9 + .../ssh-certificate-authority-secret-dal.ts | 10 + .../ssh/ssh-certificate-authority-service.ts | 378 ++++++++++++++++++ .../ssh/ssh-certificate-authority-types.ts | 61 +++ backend/src/lib/api-docs/constants.ts | 57 +++ backend/src/server/routes/index.ts | 29 +- .../server/routes/v1/organization-router.ts | 31 ++ .../certificate-authority-fns.ts | 2 +- backend/src/services/org/org-service.ts | 28 +- backend/src/services/org/org-types.ts | 2 + .../src/context/OrgPermissionContext/types.ts | 8 +- frontend/src/hooks/api/ca/constants.tsx | 4 +- frontend/src/hooks/api/index.tsx | 2 + frontend/src/hooks/api/organization/index.ts | 4 +- .../src/hooks/api/organization/queries.tsx | 19 +- frontend/src/hooks/api/ssh-ca/enums.tsx | 4 + frontend/src/hooks/api/ssh-ca/index.tsx | 3 + frontend/src/hooks/api/ssh-ca/mutations.tsx | 61 +++ frontend/src/hooks/api/ssh-ca/queries.tsx | 37 ++ frontend/src/hooks/api/ssh-ca/types.ts | 26 ++ .../api/sshCertificateTemplates/index.tsx | 5 + .../api/sshCertificateTemplates/mutations.tsx | 59 +++ .../api/sshCertificateTemplates/queries.tsx | 22 + .../api/sshCertificateTemplates/types.ts | 40 ++ frontend/src/layouts/AppLayout/AppLayout.tsx | 10 + .../pages/org/[id]/ssh/ca/[caId]/index.tsx | 18 + frontend/src/pages/org/[id]/ssh/index.tsx | 29 ++ .../src/views/Org/SshCaPage/SshCaPage.tsx | 139 +++++++ .../components/SshCaDetailsSection.tsx | 94 +++++ .../SshCertificateTemplateModal.tsx | 347 ++++++++++++++++ .../SshCertificateTemplatesSection.tsx | 92 +++++ .../SshCertificateTemplatesTable.tsx | 118 ++++++ .../views/Org/SshCaPage/components/index.tsx | 2 + frontend/src/views/Org/SshCaPage/index.tsx | 1 + frontend/src/views/Org/SshPage/SshPage.tsx | 18 + .../Org/SshPage/components/SshCaModal.tsx | 167 ++++++++ .../Org/SshPage/components/SshCaSection.tsx | 120 ++++++ .../Org/SshPage/components/SshCaTable.tsx | 153 +++++++ .../views/Org/SshPage/components/index.tsx | 1 + frontend/src/views/Org/SshPage/index.tsx | 1 + .../components/CaTab/components/CaModal.tsx | 6 +- .../components/CaTab/components/CaTable.tsx | 7 +- .../CertificateTemplatesSection.tsx | 6 +- 65 files changed, 3685 insertions(+), 26 deletions(-) create mode 100644 backend/src/db/migrations/20241130015511_ssh-mgmt.ts create mode 100644 backend/src/db/schemas/ssh-certificate-authorities.ts create mode 100644 backend/src/db/schemas/ssh-certificate-authority-secrets.ts create mode 100644 backend/src/db/schemas/ssh-certificate-templates.ts create mode 100644 backend/src/ee/routes/v1/ssh-certificate-authority-router.ts create mode 100644 backend/src/ee/routes/v1/ssh-certificate-template-router.ts create mode 100644 backend/src/ee/routes/v1/ssh-router.ts create mode 100644 backend/src/ee/services/ssh-certificate-template/ssh-certificate-template-dal.ts create mode 100644 backend/src/ee/services/ssh-certificate-template/ssh-certificate-template-schema.ts create mode 100644 backend/src/ee/services/ssh-certificate-template/ssh-certificate-template-service.ts create mode 100644 backend/src/ee/services/ssh-certificate-template/ssh-certificate-template-types.ts create mode 100644 backend/src/ee/services/ssh-certificate-template/ssh-certificate-template-validators.ts create mode 100644 backend/src/ee/services/ssh/ssh-certificate-authority-dal.ts create mode 100644 backend/src/ee/services/ssh/ssh-certificate-authority-fns.ts create mode 100644 backend/src/ee/services/ssh/ssh-certificate-authority-schema.ts create mode 100644 backend/src/ee/services/ssh/ssh-certificate-authority-secret-dal.ts create mode 100644 backend/src/ee/services/ssh/ssh-certificate-authority-service.ts create mode 100644 backend/src/ee/services/ssh/ssh-certificate-authority-types.ts create mode 100644 frontend/src/hooks/api/ssh-ca/enums.tsx create mode 100644 frontend/src/hooks/api/ssh-ca/index.tsx create mode 100644 frontend/src/hooks/api/ssh-ca/mutations.tsx create mode 100644 frontend/src/hooks/api/ssh-ca/queries.tsx create mode 100644 frontend/src/hooks/api/ssh-ca/types.ts create mode 100644 frontend/src/hooks/api/sshCertificateTemplates/index.tsx create mode 100644 frontend/src/hooks/api/sshCertificateTemplates/mutations.tsx create mode 100644 frontend/src/hooks/api/sshCertificateTemplates/queries.tsx create mode 100644 frontend/src/hooks/api/sshCertificateTemplates/types.ts create mode 100644 frontend/src/pages/org/[id]/ssh/ca/[caId]/index.tsx create mode 100644 frontend/src/pages/org/[id]/ssh/index.tsx create mode 100644 frontend/src/views/Org/SshCaPage/SshCaPage.tsx create mode 100644 frontend/src/views/Org/SshCaPage/components/SshCaDetailsSection.tsx create mode 100644 frontend/src/views/Org/SshCaPage/components/SshCertificateTemplateModal.tsx create mode 100644 frontend/src/views/Org/SshCaPage/components/SshCertificateTemplatesSection.tsx create mode 100644 frontend/src/views/Org/SshCaPage/components/SshCertificateTemplatesTable.tsx create mode 100644 frontend/src/views/Org/SshCaPage/components/index.tsx create mode 100644 frontend/src/views/Org/SshCaPage/index.tsx create mode 100644 frontend/src/views/Org/SshPage/SshPage.tsx create mode 100644 frontend/src/views/Org/SshPage/components/SshCaModal.tsx create mode 100644 frontend/src/views/Org/SshPage/components/SshCaSection.tsx create mode 100644 frontend/src/views/Org/SshPage/components/SshCaTable.tsx create mode 100644 frontend/src/views/Org/SshPage/components/index.tsx create mode 100644 frontend/src/views/Org/SshPage/index.tsx diff --git a/backend/Dockerfile.dev b/backend/Dockerfile.dev index 3eda2ad03..0d1c16574 100644 --- a/backend/Dockerfile.dev +++ b/backend/Dockerfile.dev @@ -17,7 +17,8 @@ RUN apk --update add \ openssl-dev \ python3 \ make \ - g++ + g++ \ + openssh # install dependencies for TDS driver (required for SAP ASE dynamic secrets) RUN apk add --no-cache \ diff --git a/backend/src/@types/fastify.d.ts b/backend/src/@types/fastify.d.ts index 2843648da..c2e9f2293 100644 --- a/backend/src/@types/fastify.d.ts +++ b/backend/src/@types/fastify.d.ts @@ -29,6 +29,8 @@ import { TSecretApprovalRequestServiceFactory } from "@app/ee/services/secret-ap import { TSecretRotationServiceFactory } from "@app/ee/services/secret-rotation/secret-rotation-service"; import { TSecretScanningServiceFactory } from "@app/ee/services/secret-scanning/secret-scanning-service"; import { TSecretSnapshotServiceFactory } from "@app/ee/services/secret-snapshot/secret-snapshot-service"; +import { TSshCertificateAuthorityServiceFactory } from "@app/ee/services/ssh/ssh-certificate-authority-service"; +import { TSshCertificateTemplateServiceFactory } from "@app/ee/services/ssh-certificate-template/ssh-certificate-template-service"; import { TTrustedIpServiceFactory } from "@app/ee/services/trusted-ip/trusted-ip-service"; import { TAuthMode } from "@app/server/plugins/auth/inject-identity"; import { TApiKeyServiceFactory } from "@app/services/api-key/api-key-service"; @@ -168,6 +170,8 @@ declare module "fastify" { auditLogStream: TAuditLogStreamServiceFactory; certificate: TCertificateServiceFactory; certificateTemplate: TCertificateTemplateServiceFactory; + sshCertificateAuthority: TSshCertificateAuthorityServiceFactory; + sshCertificateTemplate: TSshCertificateTemplateServiceFactory; certificateAuthority: TCertificateAuthorityServiceFactory; certificateAuthorityCrl: TCertificateAuthorityCrlServiceFactory; certificateEst: TCertificateEstServiceFactory; diff --git a/backend/src/@types/knex.d.ts b/backend/src/@types/knex.d.ts index f5c44ff79..b6485536a 100644 --- a/backend/src/@types/knex.d.ts +++ b/backend/src/@types/knex.d.ts @@ -311,6 +311,15 @@ import { TSlackIntegrations, TSlackIntegrationsInsert, TSlackIntegrationsUpdate, + TSshCertificateAuthorities, + TSshCertificateAuthoritiesInsert, + TSshCertificateAuthoritiesUpdate, + TSshCertificateAuthoritySecrets, + TSshCertificateAuthoritySecretsInsert, + TSshCertificateAuthoritySecretsUpdate, + TSshCertificateTemplates, + TSshCertificateTemplatesInsert, + TSshCertificateTemplatesUpdate, TSuperAdmin, TSuperAdminInsert, TSuperAdminUpdate, @@ -372,6 +381,21 @@ declare module "knex/types/tables" { interface Tables { [TableName.Users]: KnexOriginal.CompositeTableType; [TableName.Groups]: KnexOriginal.CompositeTableType; + [TableName.SshCertificateAuthority]: KnexOriginal.CompositeTableType< + TSshCertificateAuthorities, + TSshCertificateAuthoritiesInsert, + TSshCertificateAuthoritiesUpdate + >; + [TableName.SshCertificateAuthoritySecret]: KnexOriginal.CompositeTableType< + TSshCertificateAuthoritySecrets, + TSshCertificateAuthoritySecretsInsert, + TSshCertificateAuthoritySecretsUpdate + >; + [TableName.SshCertificateTemplate]: KnexOriginal.CompositeTableType< + TSshCertificateTemplates, + TSshCertificateTemplatesInsert, + TSshCertificateTemplatesUpdate + >; [TableName.CertificateAuthority]: KnexOriginal.CompositeTableType< TCertificateAuthorities, TCertificateAuthoritiesInsert, diff --git a/backend/src/db/migrations/20241130015511_ssh-mgmt.ts b/backend/src/db/migrations/20241130015511_ssh-mgmt.ts new file mode 100644 index 000000000..2d1681ca6 --- /dev/null +++ b/backend/src/db/migrations/20241130015511_ssh-mgmt.ts @@ -0,0 +1,59 @@ +import { Knex } from "knex"; + +import { TableName } from "../schemas"; +import { createOnUpdateTrigger, dropOnUpdateTrigger } from "../utils"; + +export async function up(knex: Knex): Promise { + if (!(await knex.schema.hasTable(TableName.SshCertificateAuthority))) { + await knex.schema.createTable(TableName.SshCertificateAuthority, (t) => { + t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid()); + t.timestamps(true, true, true); + t.uuid("orgId").notNullable(); + t.foreign("orgId").references("id").inTable(TableName.Organization).onDelete("CASCADE"); + t.string("status").notNullable(); // active / disabled + t.string("friendlyName").notNullable(); + t.string("keyAlgorithm").notNullable(); + }); + await createOnUpdateTrigger(knex, TableName.SshCertificateAuthority); + } + + if (!(await knex.schema.hasTable(TableName.SshCertificateAuthoritySecret))) { + await knex.schema.createTable(TableName.SshCertificateAuthoritySecret, (t) => { + t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid()); + t.timestamps(true, true, true); + t.uuid("sshCaId").notNullable().unique(); + t.foreign("sshCaId").references("id").inTable(TableName.SshCertificateAuthority).onDelete("CASCADE"); + t.binary("encryptedPrivateKey").notNullable(); + }); + await createOnUpdateTrigger(knex, TableName.SshCertificateAuthoritySecret); + } + + if (!(await knex.schema.hasTable(TableName.SshCertificateTemplate))) { + await knex.schema.createTable(TableName.SshCertificateTemplate, (t) => { + t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid()); + t.timestamps(true, true, true); + t.uuid("sshCaId").notNullable(); + t.foreign("sshCaId").references("id").inTable(TableName.SshCertificateAuthority).onDelete("CASCADE"); + t.string("name").notNullable(); // note: how do we handle this being unique? across orgs? + t.string("ttl").notNullable(); + t.string("maxTTL").notNullable(); + t.specificType("allowedUsers", "text[]").notNullable(); + t.specificType("allowedHosts", "text[]").notNullable(); + t.boolean("allowUserCertificates").notNullable(); + t.boolean("allowHostCertificates").notNullable(); + t.boolean("allowCustomKeyIds").notNullable(); + }); + await createOnUpdateTrigger(knex, TableName.SshCertificateTemplate); + } +} + +export async function down(knex: Knex): Promise { + await knex.schema.dropTableIfExists(TableName.SshCertificateTemplate); + await dropOnUpdateTrigger(knex, TableName.SshCertificateTemplate); + + await knex.schema.dropTableIfExists(TableName.SshCertificateAuthoritySecret); + await dropOnUpdateTrigger(knex, TableName.SshCertificateAuthoritySecret); + + await knex.schema.dropTableIfExists(TableName.SshCertificateAuthority); + await dropOnUpdateTrigger(knex, TableName.SshCertificateAuthority); +} diff --git a/backend/src/db/schemas/index.ts b/backend/src/db/schemas/index.ts index 74741a8ff..2572f2bd3 100644 --- a/backend/src/db/schemas/index.ts +++ b/backend/src/db/schemas/index.ts @@ -105,6 +105,9 @@ export * from "./secrets"; export * from "./secrets-v2"; export * from "./service-tokens"; export * from "./slack-integrations"; +export * from "./ssh-certificate-authorities"; +export * from "./ssh-certificate-authority-secrets"; +export * from "./ssh-certificate-templates"; export * from "./super-admin"; export * from "./totp-configs"; export * from "./trusted-ips"; diff --git a/backend/src/db/schemas/models.ts b/backend/src/db/schemas/models.ts index 171931f7e..156d6196e 100644 --- a/backend/src/db/schemas/models.ts +++ b/backend/src/db/schemas/models.ts @@ -2,6 +2,9 @@ import { z } from "zod"; export enum TableName { Users = "users", + SshCertificateAuthority = "ssh_certificate_authorities", + SshCertificateAuthoritySecret = "ssh_certificate_authority_secrets", + SshCertificateTemplate = "ssh_certificate_templates", CertificateAuthority = "certificate_authorities", CertificateTemplateEstConfig = "certificate_template_est_configs", CertificateAuthorityCert = "certificate_authority_certs", diff --git a/backend/src/db/schemas/ssh-certificate-authorities.ts b/backend/src/db/schemas/ssh-certificate-authorities.ts new file mode 100644 index 000000000..d70b09a8a --- /dev/null +++ b/backend/src/db/schemas/ssh-certificate-authorities.ts @@ -0,0 +1,24 @@ +// Code generated by automation script, DO NOT EDIT. +// Automated by pulling database and generating zod schema +// To update. Just run npm run generate:schema +// Written by akhilmhdh. + +import { z } from "zod"; + +import { TImmutableDBKeys } from "./models"; + +export const SshCertificateAuthoritiesSchema = z.object({ + id: z.string().uuid(), + createdAt: z.date(), + updatedAt: z.date(), + orgId: z.string().uuid(), + status: z.string(), + friendlyName: z.string(), + keyAlgorithm: z.string() +}); + +export type TSshCertificateAuthorities = z.infer; +export type TSshCertificateAuthoritiesInsert = Omit, TImmutableDBKeys>; +export type TSshCertificateAuthoritiesUpdate = Partial< + Omit, TImmutableDBKeys> +>; diff --git a/backend/src/db/schemas/ssh-certificate-authority-secrets.ts b/backend/src/db/schemas/ssh-certificate-authority-secrets.ts new file mode 100644 index 000000000..934c10ab2 --- /dev/null +++ b/backend/src/db/schemas/ssh-certificate-authority-secrets.ts @@ -0,0 +1,27 @@ +// Code generated by automation script, DO NOT EDIT. +// Automated by pulling database and generating zod schema +// To update. Just run npm run generate:schema +// Written by akhilmhdh. + +import { z } from "zod"; + +import { zodBuffer } from "@app/lib/zod"; + +import { TImmutableDBKeys } from "./models"; + +export const SshCertificateAuthoritySecretsSchema = z.object({ + id: z.string().uuid(), + createdAt: z.date(), + updatedAt: z.date(), + sshCaId: z.string().uuid(), + encryptedPrivateKey: zodBuffer +}); + +export type TSshCertificateAuthoritySecrets = z.infer; +export type TSshCertificateAuthoritySecretsInsert = Omit< + z.input, + TImmutableDBKeys +>; +export type TSshCertificateAuthoritySecretsUpdate = Partial< + Omit, TImmutableDBKeys> +>; diff --git a/backend/src/db/schemas/ssh-certificate-templates.ts b/backend/src/db/schemas/ssh-certificate-templates.ts new file mode 100644 index 000000000..875d66986 --- /dev/null +++ b/backend/src/db/schemas/ssh-certificate-templates.ts @@ -0,0 +1,29 @@ +// Code generated by automation script, DO NOT EDIT. +// Automated by pulling database and generating zod schema +// To update. Just run npm run generate:schema +// Written by akhilmhdh. + +import { z } from "zod"; + +import { TImmutableDBKeys } from "./models"; + +export const SshCertificateTemplatesSchema = z.object({ + id: z.string().uuid(), + createdAt: z.date(), + updatedAt: z.date(), + sshCaId: z.string().uuid(), + name: z.string(), + ttl: z.string(), + maxTTL: z.string(), + allowedUsers: z.string().array(), + allowedHosts: z.string().array(), + allowUserCertificates: z.boolean(), + allowHostCertificates: z.boolean(), + allowCustomKeyIds: z.boolean() +}); + +export type TSshCertificateTemplates = z.infer; +export type TSshCertificateTemplatesInsert = Omit, TImmutableDBKeys>; +export type TSshCertificateTemplatesUpdate = Partial< + Omit, TImmutableDBKeys> +>; diff --git a/backend/src/ee/routes/v1/index.ts b/backend/src/ee/routes/v1/index.ts index 5e3a0eafe..c1d30b36f 100644 --- a/backend/src/ee/routes/v1/index.ts +++ b/backend/src/ee/routes/v1/index.ts @@ -25,6 +25,9 @@ import { registerSecretRotationRouter } from "./secret-rotation-router"; import { registerSecretScanningRouter } from "./secret-scanning-router"; import { registerSecretVersionRouter } from "./secret-version-router"; import { registerSnapshotRouter } from "./snapshot-router"; +import { registerSshCaRouter } from "./ssh-certificate-authority-router"; +import { registerSshCertificateTemplateRouter } from "./ssh-certificate-template-router"; +import { registerSshRouter } from "./ssh-router"; import { registerTrustedIpRouter } from "./trusted-ip-router"; import { registerUserAdditionalPrivilegeRouter } from "./user-additional-privilege-router"; @@ -68,6 +71,15 @@ export const registerV1EERoutes = async (server: FastifyZodProvider) => { { prefix: "/pki" } ); + await server.register( + async (sshRouter) => { + await sshRouter.register(registerSshRouter, { prefix: "/" }); + await sshRouter.register(registerSshCaRouter, { prefix: "/ca" }); + await sshRouter.register(registerSshCertificateTemplateRouter, { prefix: "/certificate-templates" }); + }, + { prefix: "/ssh" } + ); + await server.register( async (ssoRouter) => { await ssoRouter.register(registerSamlRouter); diff --git a/backend/src/ee/routes/v1/ssh-certificate-authority-router.ts b/backend/src/ee/routes/v1/ssh-certificate-authority-router.ts new file mode 100644 index 000000000..0bee089ee --- /dev/null +++ b/backend/src/ee/routes/v1/ssh-certificate-authority-router.ts @@ -0,0 +1,250 @@ +import { z } from "zod"; + +import { EventType } from "@app/ee/services/audit-log/audit-log-types"; +import { sanitizedSshCa } from "@app/ee/services/ssh/ssh-certificate-authority-schema"; +import { SshCaStatus } from "@app/ee/services/ssh/ssh-certificate-authority-types"; +import { sanitizedSshCertificateTemplate } from "@app/ee/services/ssh-certificate-template/ssh-certificate-template-schema"; +import { SSH_CERTIFICATE_AUTHORITIES } from "@app/lib/api-docs"; +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"; +import { CertKeyAlgorithm } from "@app/services/certificate/certificate-types"; + +export const registerSshCaRouter = async (server: FastifyZodProvider) => { + server.route({ + method: "POST", + url: "/", + config: { + rateLimit: writeLimit + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + schema: { + description: "Create SSH CA", + body: z.object({ + friendlyName: z.string().describe(SSH_CERTIFICATE_AUTHORITIES.CREATE.friendlyName), + keyAlgorithm: z + .nativeEnum(CertKeyAlgorithm) + .default(CertKeyAlgorithm.RSA_2048) + .describe(SSH_CERTIFICATE_AUTHORITIES.CREATE.keyAlgorithm) + }), + response: { + 200: z.object({ + ca: sanitizedSshCa + }) + } + }, + handler: async (req) => { + const ca = await server.services.sshCertificateAuthority.createSshCa({ + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + ...req.body + }); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + orgId: ca.orgId, + event: { + type: EventType.CREATE_SSH_CA, + metadata: { + sshCaId: ca.id, + friendlyName: ca.friendlyName + } + } + }); + + return { + ca + }; + } + }); + + server.route({ + method: "GET", + url: "/:sshCaId", + config: { + rateLimit: readLimit + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + schema: { + description: "Get SSH CA", + params: z.object({ + sshCaId: z.string().trim().describe(SSH_CERTIFICATE_AUTHORITIES.GET.sshCaId) + }), + response: { + 200: z.object({ + ca: sanitizedSshCa + }) + } + }, + handler: async (req) => { + const ca = await server.services.sshCertificateAuthority.getSshCaById({ + caId: req.params.sshCaId, + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId + }); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + orgId: ca.orgId, + event: { + type: EventType.GET_SSH_CA, + metadata: { + sshCaId: ca.id, + friendlyName: ca.friendlyName + } + } + }); + + return { + ca + }; + } + }); + + server.route({ + method: "PATCH", + url: "/:sshCaId", + config: { + rateLimit: readLimit + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + schema: { + description: "Update SSH CA", + params: z.object({ + sshCaId: z.string().trim().describe(SSH_CERTIFICATE_AUTHORITIES.UPDATE.sshCaId) + }), + body: z.object({ + status: z + .enum([SshCaStatus.ACTIVE, SshCaStatus.DISABLED]) + .optional() + .describe(SSH_CERTIFICATE_AUTHORITIES.UPDATE.status) + }), + response: { + 200: z.object({ + ca: sanitizedSshCa + }) + } + }, + handler: async (req) => { + const ca = await server.services.sshCertificateAuthority.updateSshCaById({ + caId: req.params.sshCaId, + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + ...req.body + }); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + orgId: ca.orgId, + event: { + type: EventType.UPDATE_SSH_CA, + metadata: { + sshCaId: ca.id, + friendlyName: ca.friendlyName, + status: ca.status as SshCaStatus + } + } + }); + + return { + ca + }; + } + }); + + server.route({ + method: "DELETE", + url: "/:sshCaId", + config: { + rateLimit: writeLimit + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + schema: { + description: "Delete SSH CA", + params: z.object({ + sshCaId: z.string().trim().describe(SSH_CERTIFICATE_AUTHORITIES.DELETE.sshCaId) + }), + response: { + 200: z.object({ + ca: sanitizedSshCa + }) + } + }, + handler: async (req) => { + const ca = await server.services.sshCertificateAuthority.deleteSshCaById({ + caId: req.params.sshCaId, + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId + }); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + orgId: ca.orgId, + event: { + type: EventType.DELETE_SSH_CA, + metadata: { + sshCaId: ca.id, + friendlyName: ca.friendlyName + } + } + }); + + return { + ca + }; + } + }); + + server.route({ + method: "GET", + url: "/:sshCaId/certificate-templates", + config: { + rateLimit: readLimit + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + schema: { + description: "Get list of certificate templates for the SSH CA", + params: z.object({ + sshCaId: z.string().trim().describe(SSH_CERTIFICATE_AUTHORITIES.GET_CERTIFICATE_TEMPLATES.sshCaId) + }), + response: { + 200: z.object({ + certificateTemplates: sanitizedSshCertificateTemplate.array() + }) + } + }, + handler: async (req) => { + const { certificateTemplates, ca } = await server.services.sshCertificateAuthority.getSshCaCertificateTemplates({ + caId: req.params.sshCaId, + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId + }); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + orgId: ca.orgId, + event: { + type: EventType.GET_SSH_CA_CERTIFICATE_TEMPLATES, + metadata: { + sshCaId: ca.id, + friendlyName: ca.friendlyName + } + } + }); + + return { + certificateTemplates + }; + } + }); +}; diff --git a/backend/src/ee/routes/v1/ssh-certificate-template-router.ts b/backend/src/ee/routes/v1/ssh-certificate-template-router.ts new file mode 100644 index 000000000..90619d3b7 --- /dev/null +++ b/backend/src/ee/routes/v1/ssh-certificate-template-router.ts @@ -0,0 +1,234 @@ +import ms from "ms"; +import { z } from "zod"; + +import { EventType } from "@app/ee/services/audit-log/audit-log-types"; +import { sanitizedSshCertificateTemplate } from "@app/ee/services/ssh-certificate-template/ssh-certificate-template-schema"; +import { + isValidHostPattern, + isValidUserPattern +} from "@app/ee/services/ssh-certificate-template/ssh-certificate-template-validators"; +import { SSH_CERTIFICATE_TEMPLATES } from "@app/lib/api-docs"; +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 registerSshCertificateTemplateRouter = async (server: FastifyZodProvider) => { + server.route({ + method: "GET", + url: "/:certificateTemplateId", + config: { + rateLimit: readLimit + }, + schema: { + params: z.object({ + certificateTemplateId: z.string().describe(SSH_CERTIFICATE_TEMPLATES.GET.certificateTemplateId) + }), + response: { + 200: sanitizedSshCertificateTemplate + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const certificateTemplate = await server.services.sshCertificateTemplate.getSshCertTemplate({ + id: req.params.certificateTemplateId, + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId + }); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + orgId: certificateTemplate.orgId, + event: { + type: EventType.GET_SSH_CERTIFICATE_TEMPLATE, + metadata: { + certificateTemplateId: certificateTemplate.id + } + } + }); + + return certificateTemplate; + } + }); + + server.route({ + method: "POST", + url: "/", + config: { + rateLimit: writeLimit + }, + schema: { + body: z.object({ + sshCaId: z.string().describe(SSH_CERTIFICATE_TEMPLATES.CREATE.sshCaId), + name: z.string().min(1).describe(SSH_CERTIFICATE_TEMPLATES.CREATE.name), + ttl: z + .string() + .refine((val) => ms(val) > 0, "TTL must be a positive number") + .default("1h") + .describe(SSH_CERTIFICATE_TEMPLATES.CREATE.ttl), + maxTTL: z + .string() + .refine((val) => ms(val) > 0, "Max TTL must be a positive number") + .default("30d") + .describe(SSH_CERTIFICATE_TEMPLATES.CREATE.maxTTL), + allowedUsers: z + .array(z.string().refine(isValidUserPattern, "Invalid user pattern")) + .describe(SSH_CERTIFICATE_TEMPLATES.CREATE.allowedUsers), + allowedHosts: z + .array(z.string().refine(isValidHostPattern, "Invalid host pattern")) + .describe(SSH_CERTIFICATE_TEMPLATES.CREATE.allowedHosts), + allowUserCertificates: z.boolean().describe(SSH_CERTIFICATE_TEMPLATES.CREATE.allowUserCertificates), + allowHostCertificates: z.boolean().describe(SSH_CERTIFICATE_TEMPLATES.CREATE.allowHostCertificates), + allowCustomKeyIds: z.boolean().describe(SSH_CERTIFICATE_TEMPLATES.CREATE.allowCustomKeyIds) + }), + response: { + 200: sanitizedSshCertificateTemplate + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const { certificateTemplate, ca } = await server.services.sshCertificateTemplate.createSshCertTemplate({ + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + ...req.body + }); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + orgId: ca.orgId, + 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 certificateTemplate; + } + }); + + server.route({ + method: "PATCH", + url: "/:certificateTemplateId", + config: { + rateLimit: writeLimit + }, + schema: { + body: z.object({ + name: z.string().min(1).optional().describe(SSH_CERTIFICATE_TEMPLATES.UPDATE.name), + ttl: z + .string() + .refine((val) => ms(val) > 0, "TTL must be a positive number") + .optional() + .describe(SSH_CERTIFICATE_TEMPLATES.UPDATE.ttl), + maxTTL: z + .string() + .refine((val) => ms(val) > 0, "Max TTL must be a positive number") + .optional() + .describe(SSH_CERTIFICATE_TEMPLATES.UPDATE.maxTTL), + allowedUsers: z + .array(z.string().refine(isValidUserPattern, "Invalid user pattern")) + .optional() + .describe(SSH_CERTIFICATE_TEMPLATES.UPDATE.allowedUsers), + allowedHosts: z + .array(z.string().refine(isValidHostPattern, "Invalid host pattern")) + .optional() + .describe(SSH_CERTIFICATE_TEMPLATES.UPDATE.allowedHosts), + allowUserCertificates: z.boolean().optional().describe(SSH_CERTIFICATE_TEMPLATES.UPDATE.allowUserCertificates), + allowHostCertificates: z.boolean().optional().describe(SSH_CERTIFICATE_TEMPLATES.UPDATE.allowHostCertificates), + allowCustomKeyIds: z.boolean().optional().describe(SSH_CERTIFICATE_TEMPLATES.UPDATE.allowCustomKeyIds) + }), + params: z.object({ + certificateTemplateId: z.string().describe(SSH_CERTIFICATE_TEMPLATES.UPDATE.certificateTemplateId) + }), + response: { + 200: sanitizedSshCertificateTemplate + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const { certificateTemplate, orgId } = await server.services.sshCertificateTemplate.updateSshCertTemplate({ + ...req.body, + id: req.params.certificateTemplateId, + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId + }); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + orgId, + event: { + type: EventType.UPDATE_SSH_CERTIFICATE_TEMPLATE, + metadata: { + certificateTemplateId: certificateTemplate.id, + sshCaId: certificateTemplate.sshCaId, + 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 certificateTemplate; + } + }); + + server.route({ + method: "DELETE", + url: "/:certificateTemplateId", + config: { + rateLimit: writeLimit + }, + schema: { + params: z.object({ + certificateTemplateId: z.string().describe(SSH_CERTIFICATE_TEMPLATES.DELETE.certificateTemplateId) + }), + response: { + 200: sanitizedSshCertificateTemplate + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const certificateTemplate = await server.services.sshCertificateTemplate.deleteSshCertTemplate({ + id: req.params.certificateTemplateId, + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId + }); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + orgId: certificateTemplate.orgId, + event: { + type: EventType.DELETE_SSH_CERTIFICATE_TEMPLATE, + metadata: { + certificateTemplateId: certificateTemplate.id + } + } + }); + + return certificateTemplate; + } + }); +}; diff --git a/backend/src/ee/routes/v1/ssh-router.ts b/backend/src/ee/routes/v1/ssh-router.ts new file mode 100644 index 000000000..f3661dc04 --- /dev/null +++ b/backend/src/ee/routes/v1/ssh-router.ts @@ -0,0 +1,141 @@ +import ms from "ms"; +import { z } from "zod"; + +import { EventType } from "@app/ee/services/audit-log/audit-log-types"; +import { SshCertType } from "@app/ee/services/ssh/ssh-certificate-authority-types"; +import { CERTIFICATE_AUTHORITIES, CERTIFICATE_TEMPLATES } from "@app/lib/api-docs"; // TODO: update to SSH CA +import { writeLimit } from "@app/server/config/rateLimiter"; +import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; +import { AuthMode } from "@app/services/auth/auth-type"; +import { CertKeyAlgorithm } from "@app/services/certificate/certificate-types"; + +export const registerSshRouter = async (server: FastifyZodProvider) => { + server.route({ + method: "POST", + url: "/sign", + config: { + rateLimit: writeLimit + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + schema: { + description: "Sign SSH public key", + body: z.object({ + name: z.string(), // name of SSH certificate template + publicKey: z.string(), + certType: z.nativeEnum(SshCertType).default(SshCertType.USER), + principals: z.array(z.string().transform((val) => val.trim())).nonempty("Principals array must not be empty"), + ttl: z + .string() + .refine((val) => ms(val) > 0, "TTL must be a positive number") + .optional() + .describe(CERTIFICATE_TEMPLATES.CREATE.ttl), + keyId: z.string().optional() + }), + response: { + 200: z.object({ + serialNumber: z.string(), + signedKey: z.string() + }) + } + }, + handler: async (req) => { + const { serialNumber, signedPublicKey, certificateTemplate, ttl, keyId } = + await server.services.sshCertificateAuthority.signSshKey({ + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + ...req.body + }); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + orgId: req.permission.orgId, + event: { + type: EventType.SIGN_SSH_KEY, + metadata: { + certificateTemplateId: certificateTemplate.id, + certType: req.body.certType, + principals: req.body.principals, + ttl: String(ttl), + keyId + } + } + }); + + return { + serialNumber, + signedKey: signedPublicKey + }; + } + }); + + server.route({ + method: "POST", + url: "/issue", + config: { + rateLimit: writeLimit + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + schema: { + description: "Issue SSH credentials (certificate + key)", + body: z.object({ + name: z.string(), // name of SSH certificate template + keyAlgorithm: z + .nativeEnum(CertKeyAlgorithm) + .default(CertKeyAlgorithm.RSA_2048) + .describe(CERTIFICATE_AUTHORITIES.CREATE.keyAlgorithm), + certType: z.nativeEnum(SshCertType).default(SshCertType.USER), + principals: z.array(z.string().transform((val) => val.trim())).nonempty("Principals array must not be empty"), + ttl: z + .string() + .refine((val) => ms(val) > 0, "TTL must be a positive number") + .optional() + .describe(CERTIFICATE_TEMPLATES.CREATE.ttl), + keyId: z.string().optional() + }), + response: { + 200: z.object({ + serialNumber: z.string(), + signedKey: z.string(), + privateKey: z.string(), + keyAlgorithm: z.nativeEnum(CertKeyAlgorithm) + }) + } + }, + handler: async (req) => { + const { serialNumber, signedPublicKey, privateKey, publicKey, certificateTemplate, ttl, keyId } = + await server.services.sshCertificateAuthority.issueSshCreds({ + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + ...req.body + }); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + orgId: req.permission.orgId, + event: { + type: EventType.ISSUE_SSH_CREDS, + metadata: { + certificateTemplateId: certificateTemplate.id, + keyAlgorithm: req.body.keyAlgorithm, + certType: req.body.certType, + principals: req.body.principals, + ttl: String(ttl), + keyId + } + } + }); + + return { + serialNumber, + signedKey: signedPublicKey, + privateKey, + publicKey, + keyAlgorithm: req.body.keyAlgorithm + }; + } + }); +}; diff --git a/backend/src/ee/services/audit-log/audit-log-types.ts b/backend/src/ee/services/audit-log/audit-log-types.ts index 51090e594..991c46e1e 100644 --- a/backend/src/ee/services/audit-log/audit-log-types.ts +++ b/backend/src/ee/services/audit-log/audit-log-types.ts @@ -2,9 +2,11 @@ import { TCreateProjectTemplateDTO, TUpdateProjectTemplateDTO } from "@app/ee/services/project-template/project-template-types"; +import { SshCaStatus, SshCertType } from "@app/ee/services/ssh/ssh-certificate-authority-types"; import { SymmetricEncryption } from "@app/lib/crypto/cipher"; import { TProjectPermission } from "@app/lib/types"; import { ActorType } from "@app/services/auth/auth-type"; +import { CertKeyAlgorithm } from "@app/services/certificate/certificate-types"; import { CaStatus } from "@app/services/certificate-authority/certificate-authority-types"; import { TIdentityTrustedIp } from "@app/services/identity/identity-types"; import { PkiItemType } from "@app/services/pki-collection/pki-collection-types"; @@ -137,6 +139,17 @@ export enum EventType { SECRET_APPROVAL_REQUEST = "secret-approval-request", SECRET_APPROVAL_CLOSED = "secret-approval-closed", SECRET_APPROVAL_REOPENED = "secret-approval-reopened", + SIGN_SSH_KEY = "sign-ssh-key", + ISSUE_SSH_CREDS = "issue-ssh-creds", + CREATE_SSH_CA = "create-ssh-certificate-authority", + GET_SSH_CA = "get-ssh-certificate-authority", + UPDATE_SSH_CA = "update-ssh-certificate-authority", + DELETE_SSH_CA = "delete-ssh-certificate-authority", + GET_SSH_CA_CERTIFICATE_TEMPLATES = "get-ssh-certificate-authority-certificate-templates", + CREATE_SSH_CERTIFICATE_TEMPLATE = "create-ssh-certificate-template", + UPDATE_SSH_CERTIFICATE_TEMPLATE = "update-ssh-certificate-template", + DELETE_SSH_CERTIFICATE_TEMPLATE = "delete-ssh-certificate-template", + GET_SSH_CERTIFICATE_TEMPLATE = "get-ssh-certificate-template", CREATE_CA = "create-certificate-authority", GET_CA = "get-certificate-authority", UPDATE_CA = "update-certificate-authority", @@ -1132,6 +1145,116 @@ interface SecretApprovalRequest { }; } +interface SignSshKey { + type: EventType.SIGN_SSH_KEY; + metadata: { + certificateTemplateId: string; + certType: SshCertType; + principals: string[]; + ttl: string; + keyId: string; + }; +} + +interface IssueSshCreds { + type: EventType.ISSUE_SSH_CREDS; + metadata: { + certificateTemplateId: string; + keyAlgorithm: CertKeyAlgorithm; + certType: SshCertType; + principals: string[]; + ttl: string; + keyId: string; + }; +} + +interface CreateSshCa { + type: EventType.CREATE_SSH_CA; + metadata: { + sshCaId: string; + friendlyName: string; + }; +} + +interface GetSshCa { + type: EventType.GET_SSH_CA; + metadata: { + sshCaId: string; + friendlyName: string; + }; +} + +interface UpdateSshCa { + type: EventType.UPDATE_SSH_CA; + metadata: { + sshCaId: string; + friendlyName: string; + status: SshCaStatus; + }; +} + +interface DeleteSshCa { + type: EventType.DELETE_SSH_CA; + metadata: { + sshCaId: string; + friendlyName: string; + }; +} + +interface GetSshCaCertificateTemplates { + type: EventType.GET_SSH_CA_CERTIFICATE_TEMPLATES; + metadata: { + sshCaId: string; + friendlyName: string; + }; +} + +interface CreateSshCertificateTemplate { + type: EventType.CREATE_SSH_CERTIFICATE_TEMPLATE; + metadata: { + certificateTemplateId: string; + sshCaId: string; + name: string; + ttl: string; + maxTTL: string; + allowedUsers: string[]; + allowedHosts: string[]; + allowUserCertificates: boolean; + allowHostCertificates: boolean; + allowCustomKeyIds: boolean; + }; +} + +interface GetSshCertificateTemplate { + type: EventType.GET_SSH_CERTIFICATE_TEMPLATE; + metadata: { + certificateTemplateId: string; + }; +} + +interface UpdateSshCertificateTemplate { + type: EventType.UPDATE_SSH_CERTIFICATE_TEMPLATE; + metadata: { + certificateTemplateId: string; + sshCaId: string; + name: string; + ttl: string; + maxTTL: string; + allowedUsers: string[]; + allowedHosts: string[]; + allowUserCertificates: boolean; + allowHostCertificates: boolean; + allowCustomKeyIds: boolean; + }; +} + +interface DeleteSshCertificateTemplate { + type: EventType.DELETE_SSH_CERTIFICATE_TEMPLATE; + metadata: { + certificateTemplateId: string; + }; +} + interface CreateCa { type: EventType.CREATE_CA; metadata: { @@ -1757,6 +1880,17 @@ export type Event = | SecretApprovalClosed | SecretApprovalRequest | SecretApprovalReopened + | SignSshKey + | IssueSshCreds + | CreateSshCa + | GetSshCa + | UpdateSshCa + | DeleteSshCa + | GetSshCaCertificateTemplates + | CreateSshCertificateTemplate + | UpdateSshCertificateTemplate + | GetSshCertificateTemplate + | DeleteSshCertificateTemplate | CreateCa | GetCa | UpdateCa diff --git a/backend/src/ee/services/permission/org-permission.ts b/backend/src/ee/services/permission/org-permission.ts index aac45b2d5..8f549f750 100644 --- a/backend/src/ee/services/permission/org-permission.ts +++ b/backend/src/ee/services/permission/org-permission.ts @@ -27,7 +27,9 @@ export enum OrgPermissionSubjects { Kms = "kms", AdminConsole = "organization-admin-console", AuditLogs = "audit-logs", - ProjectTemplates = "project-templates" + ProjectTemplates = "project-templates", + SshCertificateAuthorities = "ssh-certificate-authorities", + SshCertificateTemplates = "ssh-certificate-templates" } export type OrgPermissionSet = @@ -46,7 +48,9 @@ export type OrgPermissionSet = | [OrgPermissionActions, OrgPermissionSubjects.Kms] | [OrgPermissionActions, OrgPermissionSubjects.AuditLogs] | [OrgPermissionActions, OrgPermissionSubjects.ProjectTemplates] - | [OrgPermissionAdminConsoleAction, OrgPermissionSubjects.AdminConsole]; + | [OrgPermissionAdminConsoleAction, OrgPermissionSubjects.AdminConsole] + | [OrgPermissionActions, OrgPermissionSubjects.SshCertificateAuthorities] + | [OrgPermissionActions, OrgPermissionSubjects.SshCertificateTemplates]; const buildAdminPermission = () => { const { can, rules } = new AbilityBuilder>(createMongoAbility); @@ -123,6 +127,16 @@ const buildAdminPermission = () => { can(OrgPermissionActions.Edit, OrgPermissionSubjects.ProjectTemplates); can(OrgPermissionActions.Delete, OrgPermissionSubjects.ProjectTemplates); + can(OrgPermissionActions.Read, OrgPermissionSubjects.SshCertificateAuthorities); + can(OrgPermissionActions.Create, OrgPermissionSubjects.SshCertificateAuthorities); + can(OrgPermissionActions.Edit, OrgPermissionSubjects.SshCertificateAuthorities); + can(OrgPermissionActions.Delete, OrgPermissionSubjects.SshCertificateAuthorities); + + can(OrgPermissionActions.Read, OrgPermissionSubjects.SshCertificateTemplates); + can(OrgPermissionActions.Create, OrgPermissionSubjects.SshCertificateTemplates); + can(OrgPermissionActions.Edit, OrgPermissionSubjects.SshCertificateTemplates); + can(OrgPermissionActions.Delete, OrgPermissionSubjects.SshCertificateTemplates); + can(OrgPermissionAdminConsoleAction.AccessAllProjects, OrgPermissionSubjects.AdminConsole); return rules; @@ -153,6 +167,9 @@ const buildMemberPermission = () => { can(OrgPermissionActions.Read, OrgPermissionSubjects.AuditLogs); + can(OrgPermissionActions.Read, OrgPermissionSubjects.SshCertificateAuthorities); + can(OrgPermissionActions.Read, OrgPermissionSubjects.SshCertificateTemplates); + return rules; }; diff --git a/backend/src/ee/services/ssh-certificate-template/ssh-certificate-template-dal.ts b/backend/src/ee/services/ssh-certificate-template/ssh-certificate-template-dal.ts new file mode 100644 index 000000000..09848f8ca --- /dev/null +++ b/backend/src/ee/services/ssh-certificate-template/ssh-certificate-template-dal.ts @@ -0,0 +1,38 @@ +import { Knex } from "knex"; + +import { TDbClient } from "@app/db"; +import { TableName } from "@app/db/schemas"; +import { DatabaseError } from "@app/lib/errors"; +import { ormify, selectAllTableCols } from "@app/lib/knex"; + +export type TSshCertificateTemplateDALFactory = ReturnType; + +export const sshCertificateTemplateDALFactory = (db: TDbClient) => { + const sshCertificateTemplateOrm = ormify(db, TableName.SshCertificateTemplate); + + const getById = async (id: string, tx?: Knex) => { + try { + const certTemplate = await (tx || db.replicaNode())(TableName.SshCertificateTemplate) + .join( + TableName.SshCertificateAuthority, + `${TableName.SshCertificateAuthority}.id`, + `${TableName.SshCertificateTemplate}.sshCaId` + ) + .join(TableName.Organization, `${TableName.Organization}.id`, `${TableName.SshCertificateAuthority}.orgId`) + .where(`${TableName.SshCertificateTemplate}.id`, "=", id) + .select(selectAllTableCols(TableName.SshCertificateTemplate)) + .select( + db.ref("orgId").withSchema(TableName.SshCertificateAuthority), + db.ref("friendlyName").as("caName").withSchema(TableName.SshCertificateAuthority), + db.ref("status").as("caStatus").withSchema(TableName.SshCertificateAuthority) + ) + .first(); + + return certTemplate; + } catch (error) { + throw new DatabaseError({ error, name: "Get SSH certificate template by ID" }); + } + }; + + return { ...sshCertificateTemplateOrm, getById }; +}; diff --git a/backend/src/ee/services/ssh-certificate-template/ssh-certificate-template-schema.ts b/backend/src/ee/services/ssh-certificate-template/ssh-certificate-template-schema.ts new file mode 100644 index 000000000..328530733 --- /dev/null +++ b/backend/src/ee/services/ssh-certificate-template/ssh-certificate-template-schema.ts @@ -0,0 +1,14 @@ +import { SshCertificateTemplatesSchema } from "@app/db/schemas"; + +export const sanitizedSshCertificateTemplate = SshCertificateTemplatesSchema.pick({ + id: true, + sshCaId: true, + name: true, + ttl: true, + maxTTL: true, + allowedUsers: true, + allowedHosts: true, + allowCustomKeyIds: true, + allowUserCertificates: true, + allowHostCertificates: true +}); diff --git a/backend/src/ee/services/ssh-certificate-template/ssh-certificate-template-service.ts b/backend/src/ee/services/ssh-certificate-template/ssh-certificate-template-service.ts new file mode 100644 index 000000000..d730df0ba --- /dev/null +++ b/backend/src/ee/services/ssh-certificate-template/ssh-certificate-template-service.ts @@ -0,0 +1,206 @@ +import { ForbiddenError } from "@casl/ability"; +import ms from "ms"; + +import { OrgPermissionActions, OrgPermissionSubjects } from "@app/ee/services/permission/org-permission"; +import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service"; +import { BadRequestError, NotFoundError } from "@app/lib/errors"; + +import { TSshCertificateAuthorityDALFactory } from "../ssh/ssh-certificate-authority-dal"; +import { TSshCertificateTemplateDALFactory } from "./ssh-certificate-template-dal"; +import { + TCreateSshCertTemplateDTO, + TDeleteSshCertTemplateDTO, + TGetSshCertTemplateDTO, + TUpdateSshCertTemplateDTO +} from "./ssh-certificate-template-types"; + +type TSshCertificateTemplateServiceFactoryDep = { + sshCertificateTemplateDAL: TSshCertificateTemplateDALFactory; + sshCertificateAuthorityDAL: Pick; + permissionService: Pick; +}; + +export type TSshCertificateTemplateServiceFactory = ReturnType; + +export const sshCertificateTemplateServiceFactory = ({ + sshCertificateTemplateDAL, + sshCertificateAuthorityDAL, + permissionService +}: TSshCertificateTemplateServiceFactoryDep) => { + const createSshCertTemplate = async ({ + sshCaId, + name, + ttl, + maxTTL, + allowUserCertificates, + allowHostCertificates, + allowedUsers, + allowedHosts, + allowCustomKeyIds, + actorId, + actorAuthMethod, + actor, + actorOrgId + }: TCreateSshCertTemplateDTO) => { + const ca = await sshCertificateAuthorityDAL.findById(sshCaId); + if (!ca) { + throw new NotFoundError({ + message: `SSH CA with ID ${sshCaId} not found` + }); + } + + const { permission } = await permissionService.getOrgPermission( + actor, + actorId, + ca.orgId, + actorAuthMethod, + actorOrgId + ); + + ForbiddenError.from(permission).throwUnlessCan( + OrgPermissionActions.Create, + OrgPermissionSubjects.SshCertificateTemplates + ); + + if (ms(ttl) > ms(maxTTL)) { + throw new BadRequestError({ + message: "TTL cannot be greater than max TTL" + }); + } + + const certificateTemplate = await sshCertificateTemplateDAL.create({ + sshCaId, + name, + ttl, + maxTTL, + allowUserCertificates, + allowHostCertificates, + allowedUsers, + allowedHosts, + allowCustomKeyIds + }); + + return { certificateTemplate, ca }; + }; + + const updateSshCertTemplate = async ({ + id, + name, + ttl, + maxTTL, + allowUserCertificates, + allowHostCertificates, + allowedUsers, + allowedHosts, + allowCustomKeyIds, + actorId, + actorAuthMethod, + actor, + actorOrgId + }: TUpdateSshCertTemplateDTO) => { + const certTemplate = await sshCertificateTemplateDAL.getById(id); + if (!certTemplate) { + throw new NotFoundError({ + message: `SSH certificate template with ID ${id} not found` + }); + } + + const { permission } = await permissionService.getOrgPermission( + actor, + actorId, + certTemplate.orgId, + actorAuthMethod, + actorOrgId + ); + + ForbiddenError.from(permission).throwUnlessCan( + OrgPermissionActions.Edit, + OrgPermissionSubjects.SshCertificateAuthorities + ); + + if (ms(ttl || certTemplate.ttl) > ms(maxTTL || certTemplate.maxTTL)) { + throw new BadRequestError({ + message: "TTL cannot be greater than max TTL" + }); + } + + const certificateTemplate = await sshCertificateTemplateDAL.updateById(id, { + name, + ttl, + maxTTL, + allowUserCertificates, + allowHostCertificates, + allowedUsers, + allowedHosts, + allowCustomKeyIds + }); + + return { + certificateTemplate, + orgId: certTemplate.orgId + }; + }; + + const deleteSshCertTemplate = async ({ + id, + actorId, + actorAuthMethod, + actor, + actorOrgId + }: TDeleteSshCertTemplateDTO) => { + const certificateTemplate = await sshCertificateTemplateDAL.getById(id); + if (!certificateTemplate) { + throw new NotFoundError({ + message: `SSH certificate template with ID ${id} not found` + }); + } + + const { permission } = await permissionService.getOrgPermission( + actor, + actorId, + certificateTemplate.orgId, + actorAuthMethod, + actorOrgId + ); + + ForbiddenError.from(permission).throwUnlessCan( + OrgPermissionActions.Delete, + OrgPermissionSubjects.SshCertificateAuthorities + ); + + await sshCertificateTemplateDAL.deleteById(certificateTemplate.id); + + return certificateTemplate; + }; + + const getSshCertTemplate = async ({ id, actorId, actorAuthMethod, actor, actorOrgId }: TGetSshCertTemplateDTO) => { + const certTemplate = await sshCertificateTemplateDAL.getById(id); + if (!certTemplate) { + throw new NotFoundError({ + message: `SSH certificate template with ID ${id} not found` + }); + } + + const { permission } = await permissionService.getOrgPermission( + actor, + actorId, + certTemplate.orgId, + actorAuthMethod, + actorOrgId + ); + + ForbiddenError.from(permission).throwUnlessCan( + OrgPermissionActions.Read, + OrgPermissionSubjects.SshCertificateAuthorities + ); + + return certTemplate; + }; + + return { + createSshCertTemplate, + updateSshCertTemplate, + deleteSshCertTemplate, + getSshCertTemplate + }; +}; diff --git a/backend/src/ee/services/ssh-certificate-template/ssh-certificate-template-types.ts b/backend/src/ee/services/ssh-certificate-template/ssh-certificate-template-types.ts new file mode 100644 index 000000000..1920f4ca5 --- /dev/null +++ b/backend/src/ee/services/ssh-certificate-template/ssh-certificate-template-types.ts @@ -0,0 +1,33 @@ +import { TProjectPermission } from "@app/lib/types"; + +export type TCreateSshCertTemplateDTO = { + sshCaId: string; + name: string; + ttl: string; + maxTTL: string; + allowUserCertificates: boolean; + allowHostCertificates: boolean; + allowedUsers: string[]; + allowedHosts: string[]; + allowCustomKeyIds: boolean; +} & Omit; + +export type TUpdateSshCertTemplateDTO = { + id: string; + name?: string; + ttl?: string; + maxTTL?: string; + allowUserCertificates?: boolean; + allowHostCertificates?: boolean; + allowedUsers?: string[]; + allowedHosts?: string[]; + allowCustomKeyIds?: boolean; +} & Omit; + +export type TGetSshCertTemplateDTO = { + id: string; +} & Omit; + +export type TDeleteSshCertTemplateDTO = { + id: string; +} & Omit; diff --git a/backend/src/ee/services/ssh-certificate-template/ssh-certificate-template-validators.ts b/backend/src/ee/services/ssh-certificate-template/ssh-certificate-template-validators.ts new file mode 100644 index 000000000..373bbc640 --- /dev/null +++ b/backend/src/ee/services/ssh-certificate-template/ssh-certificate-template-validators.ts @@ -0,0 +1,14 @@ +// Validates usernames or wildcard (*) +export const isValidUserPattern = (value: string): boolean => { + // Matches valid Linux usernames or a wildcard (*) + const userRegex = /^(?:\*|[a-z_][a-z0-9_-]{0,31})$/; + return userRegex.test(value); +}; + +// Validates hostnames, wildcard domains, or IP addresses +export const isValidHostPattern = (value: string): boolean => { + // Matches FQDNs, wildcard domains (*.example.com), IPv4, and IPv6 addresses + const hostRegex = + /^(?:\*|\*\.[a-z0-9-]+(?:\.[a-z0-9-]+)*|[a-z0-9-]+(?:\.[a-z0-9-]+)*|\d{1,3}(\.\d{1,3}){3}|([a-fA-F0-9:]+:+)+[a-fA-F0-9]+(?:%[a-zA-Z0-9]+)?)$/; + return hostRegex.test(value); +}; diff --git a/backend/src/ee/services/ssh/ssh-certificate-authority-dal.ts b/backend/src/ee/services/ssh/ssh-certificate-authority-dal.ts new file mode 100644 index 000000000..c906efa91 --- /dev/null +++ b/backend/src/ee/services/ssh/ssh-certificate-authority-dal.ts @@ -0,0 +1,10 @@ +import { TDbClient } from "@app/db"; +import { TableName } from "@app/db/schemas"; +import { ormify } from "@app/lib/knex"; + +export type TSshCertificateAuthorityDALFactory = ReturnType; + +export const sshCertificateAuthorityDALFactory = (db: TDbClient) => { + const sshCaOrm = ormify(db, TableName.SshCertificateAuthority); + return sshCaOrm; +}; diff --git a/backend/src/ee/services/ssh/ssh-certificate-authority-fns.ts b/backend/src/ee/services/ssh/ssh-certificate-authority-fns.ts new file mode 100644 index 000000000..a371d2d82 --- /dev/null +++ b/backend/src/ee/services/ssh/ssh-certificate-authority-fns.ts @@ -0,0 +1,198 @@ +import { execSync } from "child_process"; +import crypto from "crypto"; +import fs from "fs"; +import ms from "ms"; + +import { TSshCertificateTemplates } from "@app/db/schemas"; +import { BadRequestError } from "@app/lib/errors"; +import { CertKeyAlgorithm } from "@app/services/certificate/certificate-types"; + +import { + isValidHostPattern, + isValidUserPattern +} from "../ssh-certificate-template/ssh-certificate-template-validators"; +import { SshCertType, TCreateSshCertDTO } from "./ssh-certificate-authority-types"; + +/* eslint-disable no-bitwise */ +export const createSshCertSerialNumber = () => { + const randomBytes = crypto.randomBytes(8); // 8 bytes = 64 bits + randomBytes[0] &= 0x7f; // Ensure the most significant bit is 0 (to stay within unsigned range) + return BigInt(`0x${randomBytes.toString("hex")}`).toString(10); // Convert to decimal +}; + +/** + * Return a pair of SSH CA keys based on the specified key algorithm [keyAlgorithm]. + * We use this function because the key format generated by `ssh-keygen` is unique. + */ +export const createSshKeyPair = (keyAlgorithm: CertKeyAlgorithm, comment: string) => { + const uniqueId = crypto.randomBytes(8).toString("hex"); // to avoid collions if high-volume key generation + const privateKeyFile = `ssh_key_${uniqueId}`; // temp key path + const publicKeyFile = `${privateKeyFile}.pub`; + + if (fs.existsSync(publicKeyFile)) fs.unlinkSync(publicKeyFile); + if (fs.existsSync(privateKeyFile)) fs.unlinkSync(privateKeyFile); + + let keyType = ""; + let keyBits = ""; + + switch (keyAlgorithm) { + case CertKeyAlgorithm.RSA_2048: + keyType = "rsa"; + keyBits = "2048"; + break; + case CertKeyAlgorithm.RSA_4096: + keyType = "rsa"; + keyBits = "4096"; + break; + case CertKeyAlgorithm.ECDSA_P256: + keyType = "ecdsa"; + keyBits = "256"; + break; + case CertKeyAlgorithm.ECDSA_P384: + keyType = "ecdsa"; + keyBits = "384"; + break; + default: + throw new Error("Failed to produce SSH CA key pair generation command due to unrecognized key algorithm"); + } + + execSync(`ssh-keygen -t ${keyType} -b ${keyBits} -f ${privateKeyFile} -N '' -C "${comment}"`); + + const publicKey = fs.readFileSync(publicKeyFile, "utf8"); + const privateKey = fs.readFileSync(privateKeyFile, "utf8"); + + fs.unlinkSync(privateKeyFile); + fs.unlinkSync(publicKeyFile); + + return { publicKey, privateKey }; +}; + +/** + * Validate the requested SSH certificate type based on the SSH certificate template configuration. + * @param template - The SSH certificate template configuration + * @param certType - The SSH certificate type + */ +export const validateSshCertificateType = (template: TSshCertificateTemplates, certType: SshCertType) => { + if (!template.allowUserCertificates && certType === SshCertType.USER) { + throw new BadRequestError({ message: "Failed to validate user certificate type due to template restriction" }); + } + + if (!template.allowHostCertificates && certType === SshCertType.HOST) { + throw new BadRequestError({ message: "Failed to validate host certificate type due to template restriction" }); + } +}; + +/** + * Validate the requested SSH certificate principals based on the SSH certificate template configuration. + * @param certType - The SSH certificate type + * @param template - The SSH certificate template configuration + * @param principals - The requested SSH certificate principals + * @returns The validated SSH certificate principals + */ +export const validateSshCertificatePrincipals = ( + certType: SshCertType, + template: TSshCertificateTemplates, + principals: string[] +) => { + switch (certType) { + case SshCertType.USER: { + const allowsAllUsers = template.allowedUsers?.includes("*") ?? false; + return principals.every((principal) => { + if (principal === "*") return false; + if (allowsAllUsers) return isValidUserPattern(principal); + return template.allowedUsers?.includes(principal); + }); + } + case SshCertType.HOST: { + const allowsAllHosts = template.allowedHosts?.includes("*") ?? false; + return principals.every((principal) => { + if (principal.includes("*")) return false; + if (allowsAllHosts) return isValidHostPattern(principal); + + // Validate against allowed domains + return ( + isValidHostPattern(principal) && + template.allowedHosts?.some((allowedHost) => { + if (allowedHost.startsWith("*.")) { + // Match subdomains of a wildcard domain + const baseDomain = allowedHost.slice(2); // Remove the leading "*." + return principal.endsWith(`.${baseDomain}`); + } + + // Exact match for non-wildcard domains + return principal === allowedHost; + }) + ); + }); + } + default: + throw new BadRequestError({ + message: "Failed to validate SSH certificate principals due to unrecognized requested certificate type" + }); + } +}; + +/** + * Validate the requested SSH certificate TTL based on the SSH certificate template configuration. + * @param template - The SSH certificate template configuration + * @param ttl - The TTL to validate + * @returns The TTL (in seconds) to use for issuing the SSH certificate + */ +export const validateSshCertificateTtl = (template: TSshCertificateTemplates, ttl: string | undefined) => { + if (!ttl) { + // use default template ttl + return ms(template.ttl); + } + + if (ms(ttl) > ms(template.maxTTL)) { + throw new BadRequestError({ + message: "Failed TTL validation due to TTL being greater than configured max TTL on template" + }); + } + + return ms(ttl) / 1000; +}; + +/** + * Create an SSH certificate for a user or host. + */ +export const createSshCert = ({ caPrivateKey, userPublicKey, keyId, principals, ttl, certType }: TCreateSshCertDTO) => { + const uniqueId = crypto.randomBytes(8).toString("hex"); + const publicKeyFile = `user_key_${uniqueId}.pub`; + const privateKeyFile = `ssh_ca_key_${uniqueId}`; + + if (fs.existsSync(publicKeyFile)) fs.unlinkSync(publicKeyFile); + if (fs.existsSync(privateKeyFile)) fs.unlinkSync(privateKeyFile); + + // write public and private keys to temp files + fs.writeFileSync(publicKeyFile, userPublicKey); + fs.writeFileSync(privateKeyFile, caPrivateKey); + fs.chmodSync(privateKeyFile, 0o600); + + const serialNumber = createSshCertSerialNumber(); + console.log("signSshKey serialNumber: ", serialNumber); + + const certOptions = [ + `-s ${privateKeyFile}`, // path to SSH CA private key + `-I "${keyId}"`, // identity for the issued certificate (key id) + `-n "${principals.join(",")}"`, // principal(s) that is user(s) or host(s) + `-V +${ttl}s`, // TTL in seconds (validity period) for the issue certificate + `-z ${serialNumber}`, // custom serial number for certificate + certType === "host" ? "-h" : "", // host certificate flag + publicKeyFile // path to signed [publicKey] + ] + .filter(Boolean) + .join(" "); + + const command = `ssh-keygen ${certOptions}`; + + // Execute the signing process + execSync(command); + + const signedPublicKey = fs.readFileSync(publicKeyFile, "utf8"); + + fs.unlinkSync(publicKeyFile); + fs.unlinkSync(privateKeyFile); + + return { serialNumber, signedPublicKey }; +}; diff --git a/backend/src/ee/services/ssh/ssh-certificate-authority-schema.ts b/backend/src/ee/services/ssh/ssh-certificate-authority-schema.ts new file mode 100644 index 000000000..82561dda6 --- /dev/null +++ b/backend/src/ee/services/ssh/ssh-certificate-authority-schema.ts @@ -0,0 +1,9 @@ +import { SshCertificateAuthoritiesSchema } from "@app/db/schemas"; + +export const sanitizedSshCa = SshCertificateAuthoritiesSchema.pick({ + id: true, + orgId: true, + friendlyName: true, + status: true, + keyAlgorithm: true +}); diff --git a/backend/src/ee/services/ssh/ssh-certificate-authority-secret-dal.ts b/backend/src/ee/services/ssh/ssh-certificate-authority-secret-dal.ts new file mode 100644 index 000000000..9423a0ff2 --- /dev/null +++ b/backend/src/ee/services/ssh/ssh-certificate-authority-secret-dal.ts @@ -0,0 +1,10 @@ +import { TDbClient } from "@app/db"; +import { TableName } from "@app/db/schemas"; +import { ormify } from "@app/lib/knex"; + +export type TSshCertificateAuthoritySecretDALFactory = ReturnType; + +export const sshCertificateAuthoritySecretDALFactory = (db: TDbClient) => { + const sshCaSecretOrm = ormify(db, TableName.SshCertificateAuthoritySecret); + return sshCaSecretOrm; +}; diff --git a/backend/src/ee/services/ssh/ssh-certificate-authority-service.ts b/backend/src/ee/services/ssh/ssh-certificate-authority-service.ts new file mode 100644 index 000000000..b324dda94 --- /dev/null +++ b/backend/src/ee/services/ssh/ssh-certificate-authority-service.ts @@ -0,0 +1,378 @@ +import { ForbiddenError } from "@casl/ability"; + +import { OrgPermissionActions, OrgPermissionSubjects } from "@app/ee/services/permission/org-permission"; +import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service"; +import { TSshCertificateAuthorityDALFactory } from "@app/ee/services/ssh/ssh-certificate-authority-dal"; +import { TSshCertificateAuthoritySecretDALFactory } from "@app/ee/services/ssh/ssh-certificate-authority-secret-dal"; +import { TSshCertificateTemplateDALFactory } from "@app/ee/services/ssh-certificate-template/ssh-certificate-template-dal"; +import { NotFoundError } from "@app/lib/errors"; +import { TKmsServiceFactory } from "@app/services/kms/kms-service"; +import { TProjectDALFactory } from "@app/services/project/project-dal"; + +import { + createSshCert, + createSshKeyPair, + validateSshCertificatePrincipals, + validateSshCertificateTtl, + validateSshCertificateType +} from "./ssh-certificate-authority-fns"; +import { + SshCaStatus, + TCreateSshCaDTO, + TDeleteSshCaDTO, + TGetSshCaCertificateTemplatesDTO, + TGetSshCaDTO, + TIssueSshCredsDTO, + TSignSshKeyDTO, + TUpdateSshCaDTO +} from "./ssh-certificate-authority-types"; + +type TSshCertificateAuthorityServiceFactoryDep = { + sshCertificateAuthorityDAL: Pick< + TSshCertificateAuthorityDALFactory, + "transaction" | "create" | "findById" | "updateById" | "deleteById" | "findOne" + >; + sshCertificateAuthoritySecretDAL: Pick; + sshCertificateTemplateDAL: Pick; + projectDAL: Pick; + kmsService: Pick; + permissionService: Pick; +}; + +export type TSshCertificateAuthorityServiceFactory = ReturnType; + +export const sshCertificateAuthorityServiceFactory = ({ + sshCertificateAuthorityDAL, + sshCertificateAuthoritySecretDAL, + sshCertificateTemplateDAL, + kmsService, + permissionService +}: TSshCertificateAuthorityServiceFactoryDep) => { + /** + * Generates a new SSH CA + */ + const createSshCa = async ({ + friendlyName, + keyAlgorithm, + actorId, + actorAuthMethod, + actor, + actorOrgId + }: TCreateSshCaDTO) => { + const { permission } = await permissionService.getOrgPermission( + actor, + actorId, + actorOrgId, + actorAuthMethod, + actorOrgId + ); + + ForbiddenError.from(permission).throwUnlessCan( + OrgPermissionActions.Create, + OrgPermissionSubjects.SshCertificateAuthorities + ); + + const newCa = await sshCertificateAuthorityDAL.transaction(async (tx) => { + const ca = await sshCertificateAuthorityDAL.create( + { + orgId: actorOrgId, + friendlyName: friendlyName || "", + status: SshCaStatus.ACTIVE, + keyAlgorithm + }, + tx + ); + + const { privateKey } = createSshKeyPair(keyAlgorithm, ca.friendlyName); + + const orgKmsKeyId = await kmsService.getOrgKmsKeyId(actorOrgId); + const kmsEncryptor = await kmsService.encryptWithKmsKey({ + kmsId: orgKmsKeyId + }); + + const { cipherTextBlob: encryptedPrivateKey } = await kmsEncryptor({ + plainText: Buffer.from(privateKey, "utf8") + }); + + await sshCertificateAuthoritySecretDAL.create( + { + sshCaId: ca.id, + encryptedPrivateKey + }, + tx + ); + + return ca; + }); + + return newCa; + }; + + /** + * Return SSH CA with id [caId] + */ + const getSshCaById = async ({ caId, actor, actorId, actorAuthMethod, actorOrgId }: TGetSshCaDTO) => { + const ca = await sshCertificateAuthorityDAL.findById(caId); + if (!ca) throw new NotFoundError({ message: `SSH CA with ID '${caId}' not found` }); + + const { permission } = await permissionService.getOrgPermission( + actor, + actorId, + actorOrgId, + actorAuthMethod, + actorOrgId + ); + + ForbiddenError.from(permission).throwUnlessCan( + OrgPermissionActions.Read, + OrgPermissionSubjects.SshCertificateAuthorities + ); + + return ca; + }; + + /** + * Update SSH CA with id [caId] + * Note: Used to enable/disable CA + */ + const updateSshCaById = async ({ caId, status, actor, actorId, actorAuthMethod, actorOrgId }: TUpdateSshCaDTO) => { + const ca = await sshCertificateAuthorityDAL.findById(caId); + if (!ca) throw new NotFoundError({ message: `SSH CA with ID '${caId}' not found` }); + + const { permission } = await permissionService.getOrgPermission( + actor, + actorId, + actorOrgId, + actorAuthMethod, + actorOrgId + ); + + ForbiddenError.from(permission).throwUnlessCan( + OrgPermissionActions.Edit, + OrgPermissionSubjects.SshCertificateAuthorities + ); + + const updatedCa = await sshCertificateAuthorityDAL.updateById(caId, { status }); + + return updatedCa; + }; + + /** + * Delete SSH CA with id [caId] + */ + const deleteSshCaById = async ({ caId, actor, actorId, actorAuthMethod, actorOrgId }: TDeleteSshCaDTO) => { + const ca = await sshCertificateAuthorityDAL.findById(caId); + if (!ca) throw new NotFoundError({ message: `SSH CA with ID '${caId}' not found` }); + + const { permission } = await permissionService.getOrgPermission( + actor, + actorId, + actorOrgId, + actorAuthMethod, + actorOrgId + ); + + ForbiddenError.from(permission).throwUnlessCan( + OrgPermissionActions.Delete, + OrgPermissionSubjects.SshCertificateAuthorities + ); + + const deletedCa = await sshCertificateAuthorityDAL.deleteById(caId); + + return deletedCa; + }; + + /** + * Return SSH certificate and corresponding new SSH public-private key pair where + * SSH public key is signed using CA behind SSH certificate with name [name]. + */ + const issueSshCreds = async ({ + name, + keyAlgorithm, + certType, + principals, + ttl: requestedTtl, + keyId: requestedKeyId, + actor, + actorId, + actorAuthMethod, + actorOrgId + }: TIssueSshCredsDTO) => { + // TODO: proper permission check + const { permission } = await permissionService.getOrgPermission( + actor, + actorId, + actorOrgId, + actorAuthMethod, + actorOrgId + ); + + ForbiddenError.from(permission).throwUnlessCan( + OrgPermissionActions.Create, + OrgPermissionSubjects.SshCertificateTemplates + ); + + // TODO: adjust to find within org + const sshCertificateTemplate = await sshCertificateTemplateDAL.findOne({ name }); + + // validate if the requested [certType] is allowed under the template configuration + validateSshCertificateType(sshCertificateTemplate, certType); + + // validate if the requested [principals] are valid for the given [certType] under the template configuration + validateSshCertificatePrincipals(certType, sshCertificateTemplate, principals); + + // validate if the requested TTL is valid under the template configuration + const ttl = validateSshCertificateTtl(sshCertificateTemplate, requestedTtl); + + // set [keyId] depending on if [allowCustomKeyIds] is true or false + const keyId = sshCertificateTemplate.allowCustomKeyIds + ? requestedKeyId ?? `${actor}-${actorId}` + : `${actor}-${actorId}`; + + const sshCaSecret = await sshCertificateAuthoritySecretDAL.findOne({ sshCaId: sshCertificateTemplate.sshCaId }); + + // decrypt secret + const orgKmsKeyId = await kmsService.getOrgKmsKeyId(actorOrgId); + const kmsDecryptor = await kmsService.decryptWithKmsKey({ + kmsId: orgKmsKeyId + }); + + const decryptedCaPrivateKey = await kmsDecryptor({ + cipherTextBlob: sshCaSecret.encryptedPrivateKey + }); + + // create user key pair + const { publicKey, privateKey } = createSshKeyPair(keyAlgorithm, "Client Key"); + + const { serialNumber, signedPublicKey } = createSshCert({ + caPrivateKey: decryptedCaPrivateKey.toString("utf8"), + userPublicKey: publicKey, + keyId, + principals, + ttl, + certType + }); + + return { + serialNumber, + signedPublicKey, + privateKey, + publicKey, + certificateTemplate: sshCertificateTemplate, + ttl, + keyId + }; + }; + + /** + * Return SSH certificate by signing SSH public key [publicKey] + * using CA behind SSH certificate template with name [name] + */ + const signSshKey = async ({ + name, + publicKey, + certType, + principals, + ttl: requestedTtl, + keyId: requestedKeyId, + actor, + actorId, + actorAuthMethod, + actorOrgId + }: TSignSshKeyDTO) => { + // TODO: proper permission check + const { permission } = await permissionService.getOrgPermission( + actor, + actorId, + actorOrgId, + actorAuthMethod, + actorOrgId + ); + + ForbiddenError.from(permission).throwUnlessCan( + OrgPermissionActions.Create, + OrgPermissionSubjects.SshCertificateTemplates + ); + + // TODO: adjust to find within org + const sshCertificateTemplate = await sshCertificateTemplateDAL.findOne({ name }); + + // validate if the requested [certType] is allowed under the template configuration + validateSshCertificateType(sshCertificateTemplate, certType); + + // validate if the requested [principals] are valid for the given [certType] under the template configuration + validateSshCertificatePrincipals(certType, sshCertificateTemplate, principals); + + // validate if the requested TTL is valid under the template configuration + const ttl = validateSshCertificateTtl(sshCertificateTemplate, requestedTtl); + + // set [keyId] depending on if [allowCustomKeyIds] is true or false + const keyId = sshCertificateTemplate.allowCustomKeyIds + ? requestedKeyId ?? `${actor}-${actorId}` + : `${actor}-${actorId}`; + + const sshCaSecret = await sshCertificateAuthoritySecretDAL.findOne({ sshCaId: sshCertificateTemplate.sshCaId }); + + // decrypt secret + const orgKmsKeyId = await kmsService.getOrgKmsKeyId(actorOrgId); + const kmsDecryptor = await kmsService.decryptWithKmsKey({ + kmsId: orgKmsKeyId + }); + + const decryptedCaPrivateKey = await kmsDecryptor({ + cipherTextBlob: sshCaSecret.encryptedPrivateKey + }); + + const { serialNumber, signedPublicKey } = createSshCert({ + caPrivateKey: decryptedCaPrivateKey.toString("utf8"), + userPublicKey: publicKey, + keyId, + principals, + ttl, + certType + }); + + return { serialNumber, signedPublicKey, certificateTemplate: sshCertificateTemplate, ttl, keyId }; + }; + + const getSshCaCertificateTemplates = async ({ + caId, + actor, + actorId, + actorAuthMethod, + actorOrgId + }: TGetSshCaCertificateTemplatesDTO) => { + const ca = await sshCertificateAuthorityDAL.findById(caId); + if (!ca) throw new NotFoundError({ message: `SSH CA with ID '${caId}' not found` }); + + const { permission } = await permissionService.getOrgPermission( + actor, + actorId, + actorOrgId, + actorAuthMethod, + actorOrgId + ); + + ForbiddenError.from(permission).throwUnlessCan( + OrgPermissionActions.Read, + OrgPermissionSubjects.SshCertificateTemplates + ); + + const certificateTemplates = await sshCertificateTemplateDAL.find({ sshCaId: caId }); + + return { + certificateTemplates, + ca + }; + }; + + return { + issueSshCreds, + signSshKey, + createSshCa, + getSshCaById, + updateSshCaById, + deleteSshCaById, + getSshCaCertificateTemplates + }; +}; diff --git a/backend/src/ee/services/ssh/ssh-certificate-authority-types.ts b/backend/src/ee/services/ssh/ssh-certificate-authority-types.ts new file mode 100644 index 000000000..d91f292e4 --- /dev/null +++ b/backend/src/ee/services/ssh/ssh-certificate-authority-types.ts @@ -0,0 +1,61 @@ +import { TOrgPermission } from "@app/lib/types"; +import { CertKeyAlgorithm } from "@app/services/certificate/certificate-types"; + +export enum SshCaStatus { + ACTIVE = "active", + DISABLED = "disabled" +} + +export enum SshCertType { + USER = "user", + HOST = "host" +} + +export type TCreateSshCaDTO = { + friendlyName?: string; + keyAlgorithm: CertKeyAlgorithm; +} & Omit; + +export type TGetSshCaDTO = { + caId: string; +} & Omit; + +export type TUpdateSshCaDTO = { + caId: string; + status?: SshCaStatus; +} & Omit; + +export type TDeleteSshCaDTO = { + caId: string; +} & Omit; + +export type TIssueSshCredsDTO = { + name: string; // name of SSH certificate template + keyAlgorithm: CertKeyAlgorithm; + certType: SshCertType; + principals: string[]; + ttl?: string; + keyId?: string; +} & Omit; + +export type TSignSshKeyDTO = { + name: string; // name of SSH certificate template + publicKey: string; + certType: SshCertType; + principals: string[]; + ttl?: string; + keyId?: string; +} & Omit; + +export type TGetSshCaCertificateTemplatesDTO = { + caId: string; +} & Omit; + +export type TCreateSshCertDTO = { + caPrivateKey: string; + userPublicKey: string; + keyId: string; + principals: string[]; + ttl: number; + certType: SshCertType; +}; diff --git a/backend/src/lib/api-docs/constants.ts b/backend/src/lib/api-docs/constants.ts index 99822da29..87ffddbe8 100644 --- a/backend/src/lib/api-docs/constants.ts +++ b/backend/src/lib/api-docs/constants.ts @@ -384,6 +384,9 @@ export const ORGANIZATIONS = { }, LIST_GROUPS: { organizationId: "The ID of the organization to list groups for." + }, + LIST_SSH_CAS: { + organizationId: "The ID of the organization to list SSH CAs for." } } as const; @@ -443,6 +446,9 @@ export const PROJECTS = { LIST_INTEGRATION_AUTHORIZATION: { workspaceId: "The ID of the project to list integration auths for." }, + LIST_SSH_CAS: { + slug: "The slug of the project to list SSH CAs for." + }, LIST_CAS: { slug: "The slug of the project to list CAs for.", status: "The status of the CA to filter by.", @@ -1132,6 +1138,57 @@ export const AUDIT_LOG_STREAMS = { } }; +export const SSH_CERTIFICATE_AUTHORITIES = { + CREATE: { + friendlyName: "A friendly name for the SSH CA.", + keyAlgorithm: "The type of public key algorithm and size, in bits, of the key pair for the SSH CA." + }, + GET: { + sshCaId: "The ID of the SSH CA to get." + }, + UPDATE: { + sshCaId: "The ID of the SSH CA to update.", + status: "The status of the SSH CA to update to. This can be one of active or disabled." + }, + DELETE: { + sshCaId: "The ID of the SSH CA to delete." + }, + GET_CERTIFICATE_TEMPLATES: { + sshCaId: "The ID of the SSH CA to get the certificate templates for." + } +}; + +export const SSH_CERTIFICATE_TEMPLATES = { + GET: { + certificateTemplateId: "The ID of the SSH certificate template to get." + }, + CREATE: { + sshCaId: "The ID of the SSH CA to associate the certificate template with.", + name: "The name of the certificate template.", + ttl: "The default time to live for issued certificates such as 1m, 1h, 1d, 1y, ...", + maxTTL: "The maximum time to live for issued certificates such as 1m, 1h, 1d, 1y, ...", + allowedUsers: "The list of allowed users for certificates issued under this template.", + allowedHosts: "The list of allowed hosts for certificates issued under this template.", + allowUserCertificates: "Whether or not to allow user certificates to be issued under this template.", + allowHostCertificates: "Whether or not to allow host certificates to be issued under this template.", + allowCustomKeyIds: "Whether or not to allow custom key IDs for certificates issued under this template." + }, + UPDATE: { + certificateTemplateId: "The ID of the SSH certificate template to update.", + name: "The name of the certificate template.", + ttl: "The default time to live for issued certificates such as 1m, 1h, 1d, 1y, ...", + maxTTL: "The maximum time to live for issued certificates such as 1m, 1h, 1d, 1y, ...", + allowedUsers: "The list of allowed users for certificates issued under this template.", + allowedHosts: "The list of allowed hosts for certificates issued under this template.", + allowUserCertificates: "Whether or not to allow user certificates to be issued under this template.", + allowHostCertificates: "Whether or not to allow host certificates to be issued under this template.", + allowCustomKeyIds: "Whether or not to allow custom key IDs for certificates issued under this template." + }, + DELETE: { + certificateTemplateId: "The ID of the SSH certificate template to delete." + } +}; + export const CERTIFICATE_AUTHORITIES = { CREATE: { projectSlug: "Slug of the project to create the CA in.", diff --git a/backend/src/server/routes/index.ts b/backend/src/server/routes/index.ts index b9f46627b..8659c31b7 100644 --- a/backend/src/server/routes/index.ts +++ b/backend/src/server/routes/index.ts @@ -75,6 +75,11 @@ import { snapshotDALFactory } from "@app/ee/services/secret-snapshot/snapshot-da import { snapshotFolderDALFactory } from "@app/ee/services/secret-snapshot/snapshot-folder-dal"; import { snapshotSecretDALFactory } from "@app/ee/services/secret-snapshot/snapshot-secret-dal"; import { snapshotSecretV2DALFactory } from "@app/ee/services/secret-snapshot/snapshot-secret-v2-dal"; +import { sshCertificateAuthorityDALFactory } from "@app/ee/services/ssh/ssh-certificate-authority-dal"; +import { sshCertificateAuthoritySecretDALFactory } from "@app/ee/services/ssh/ssh-certificate-authority-secret-dal"; +import { sshCertificateAuthorityServiceFactory } from "@app/ee/services/ssh/ssh-certificate-authority-service"; +import { sshCertificateTemplateDALFactory } from "@app/ee/services/ssh-certificate-template/ssh-certificate-template-dal"; +import { sshCertificateTemplateServiceFactory } from "@app/ee/services/ssh-certificate-template/ssh-certificate-template-service"; import { trustedIpDALFactory } from "@app/ee/services/trusted-ip/trusted-ip-dal"; import { trustedIpServiceFactory } from "@app/ee/services/trusted-ip/trusted-ip-service"; import { TKeyStoreFactory } from "@app/keystore/keystore"; @@ -342,6 +347,10 @@ export const registerRoutes = async ( const dynamicSecretDAL = dynamicSecretDALFactory(db); const dynamicSecretLeaseDAL = dynamicSecretLeaseDALFactory(db); + const sshCertificateAuthorityDAL = sshCertificateAuthorityDALFactory(db); + const sshCertificateAuthoritySecretDAL = sshCertificateAuthoritySecretDALFactory(db); + const sshCertificateTemplateDAL = sshCertificateTemplateDALFactory(db); + const kmsDAL = kmskeyDALFactory(db); const internalKmsDAL = internalKmsDALFactory(db); const externalKmsDAL = externalKmsDALFactory(db); @@ -554,7 +563,8 @@ export const registerRoutes = async ( groupDAL, orgBotDAL, oidcConfigDAL, - projectBotService + projectBotService, + sshCertificateAuthorityDAL }); const signupService = authSignupServiceFactory({ tokenService, @@ -702,6 +712,20 @@ export const registerRoutes = async ( queueService }); + const sshCertificateAuthorityService = sshCertificateAuthorityServiceFactory({ + sshCertificateAuthorityDAL, + sshCertificateAuthoritySecretDAL, + sshCertificateTemplateDAL, + kmsService, + permissionService + }); + + const sshCertificateTemplateService = sshCertificateTemplateServiceFactory({ + sshCertificateTemplateDAL, + sshCertificateAuthorityDAL, + permissionService + }); + const certificateAuthorityService = certificateAuthorityServiceFactory({ certificateAuthorityDAL, certificateAuthorityCertDAL, @@ -784,6 +808,7 @@ export const registerRoutes = async ( projectRoleDAL, folderDAL, licenseService, + sshCertificateAuthorityDAL, certificateAuthorityDAL, certificateDAL, pkiAlertDAL, @@ -1354,6 +1379,8 @@ export const registerRoutes = async ( auditLog: auditLogService, auditLogStream: auditLogStreamService, certificate: certificateService, + sshCertificateAuthority: sshCertificateAuthorityService, + sshCertificateTemplate: sshCertificateTemplateService, certificateAuthority: certificateAuthorityService, certificateTemplate: certificateTemplateService, certificateAuthorityCrl: certificateAuthorityCrlService, diff --git a/backend/src/server/routes/v1/organization-router.ts b/backend/src/server/routes/v1/organization-router.ts index 07f795779..d6ab7f36c 100644 --- a/backend/src/server/routes/v1/organization-router.ts +++ b/backend/src/server/routes/v1/organization-router.ts @@ -11,6 +11,7 @@ import { UsersSchema } from "@app/db/schemas"; import { EventType, UserAgentType } from "@app/ee/services/audit-log/audit-log-types"; +import { sanitizedSshCa } from "@app/ee/services/ssh/ssh-certificate-authority-schema"; import { AUDIT_LOGS, ORGANIZATIONS } from "@app/lib/api-docs"; import { getLastMidnightDateISO } from "@app/lib/fn"; import { readLimit, writeLimit } from "@app/server/config/rateLimiter"; @@ -404,4 +405,34 @@ export const registerOrgRouter = async (server: FastifyZodProvider) => { return { groups }; } }); + + server.route({ + method: "GET", + url: "/:organizationId/ssh-cas", + config: { + rateLimit: readLimit + }, + schema: { + params: z.object({ + organizationId: z.string().trim().describe(ORGANIZATIONS.LIST_SSH_CAS.organizationId) + }), + response: { + 200: z.object({ + cas: z.array(sanitizedSshCa) + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const cas = await server.services.org.listOrgSshCas({ + actorId: req.permission.id, + actorOrgId: req.permission.orgId, + actorAuthMethod: req.permission.authMethod, + actor: req.permission.type, + orgId: req.params.organizationId + }); + + return { cas }; + } + }); }; diff --git a/backend/src/services/certificate-authority/certificate-authority-fns.ts b/backend/src/services/certificate-authority/certificate-authority-fns.ts index efb582d88..d2c87e772 100644 --- a/backend/src/services/certificate-authority/certificate-authority-fns.ts +++ b/backend/src/services/certificate-authority/certificate-authority-fns.ts @@ -15,7 +15,7 @@ import { /* eslint-disable no-bitwise */ export const createSerialNumber = () => { - const randomBytes = crypto.randomBytes(20); + const randomBytes = crypto.randomBytes(20); // 20 bytes = 160 bits randomBytes[0] &= 0x7f; // ensure the first bit is 0 return randomBytes.toString("hex"); }; diff --git a/backend/src/services/org/org-service.ts b/backend/src/services/org/org-service.ts index 9741220f8..b6bd95101 100644 --- a/backend/src/services/org/org-service.ts +++ b/backend/src/services/org/org-service.ts @@ -24,6 +24,7 @@ import { TPermissionServiceFactory } from "@app/ee/services/permission/permissio import { ProjectPermissionActions, ProjectPermissionSub } from "@app/ee/services/permission/project-permission"; import { TProjectUserAdditionalPrivilegeDALFactory } from "@app/ee/services/project-user-additional-privilege/project-user-additional-privilege-dal"; import { TSamlConfigDALFactory } from "@app/ee/services/saml-config/saml-config-dal"; +import { TSshCertificateAuthorityDALFactory } from "@app/ee/services/ssh/ssh-certificate-authority-dal"; import { getConfig } from "@app/lib/config/env"; import { generateAsymmetricKeyPair } from "@app/lib/crypto"; import { generateSymmetricKey, infisicalSymmetricDecrypt, infisicalSymmetricEncypt } from "@app/lib/crypto/encryption"; @@ -62,6 +63,7 @@ import { TGetOrgGroupsDTO, TGetOrgMembershipDTO, TInviteUserToOrgDTO, + TListOrgSshCasDTO, TListProjectMembershipsByOrgMembershipIdDTO, TUpdateOrgDTO, TUpdateOrgMembershipDTO, @@ -98,6 +100,7 @@ type TOrgServiceFactoryDep = { projectBotDAL: Pick; projectUserMembershipRoleDAL: Pick; projectBotService: Pick; + sshCertificateAuthorityDAL: Pick; }; export type TOrgServiceFactory = ReturnType; @@ -125,6 +128,7 @@ export const orgServiceFactory = ({ projectBotDAL, projectUserMembershipRoleDAL, identityMetadataDAL, + sshCertificateAuthorityDAL, projectBotService }: TOrgServiceFactoryDep) => { /* @@ -1127,6 +1131,27 @@ export const orgServiceFactory = ({ return incidentContact; }; + /** + * Return list of SSH CAs for project + */ + const listOrgSshCas = async ({ actorId, actorOrgId, actorAuthMethod, actor, orgId }: TListOrgSshCasDTO) => { + const { permission } = await permissionService.getOrgPermission(actor, actorId, orgId, actorAuthMethod, actorOrgId); + + ForbiddenError.from(permission).throwUnlessCan( + OrgPermissionActions.Read, + OrgPermissionSubjects.SshCertificateAuthorities + ); + + const cas = await sshCertificateAuthorityDAL.find( + { + orgId + }, + { sort: [["updatedAt", "desc"]] } + ); + + return cas; + }; + return { findOrganizationById, findAllOrgMembers, @@ -1148,6 +1173,7 @@ export const orgServiceFactory = ({ deleteIncidentContact, getOrgGroups, listProjectMembershipsByOrgMembershipId, - findOrgBySlug + findOrgBySlug, + listOrgSshCas }; }; diff --git a/backend/src/services/org/org-types.ts b/backend/src/services/org/org-types.ts index 05df9429e..66fe6ac2e 100644 --- a/backend/src/services/org/org-types.ts +++ b/backend/src/services/org/org-types.ts @@ -75,6 +75,8 @@ export type TListProjectMembershipsByOrgMembershipIdDTO = { orgMembershipId: string; } & TOrgPermission; +export type TListOrgSshCasDTO = TOrgPermission; + export enum OrgAuthMethod { OIDC = "oidc", SAML = "saml" diff --git a/frontend/src/context/OrgPermissionContext/types.ts b/frontend/src/context/OrgPermissionContext/types.ts index 41a2e7e3c..fb65358ea 100644 --- a/frontend/src/context/OrgPermissionContext/types.ts +++ b/frontend/src/context/OrgPermissionContext/types.ts @@ -23,7 +23,9 @@ export enum OrgPermissionSubjects { Kms = "kms", AdminConsole = "organization-admin-console", AuditLogs = "audit-logs", - ProjectTemplates = "project-templates" + ProjectTemplates = "project-templates", + SshCertificateAuthorities = "ssh-certificate-authorities", + SshCertificateTemplates = "ssh-certificate-templates" } export enum OrgPermissionAdminConsoleAction { @@ -47,6 +49,8 @@ export type OrgPermissionSet = | [OrgPermissionActions, OrgPermissionSubjects.Kms] | [OrgPermissionAdminConsoleAction, OrgPermissionSubjects.AdminConsole] | [OrgPermissionActions, OrgPermissionSubjects.AuditLogs] - | [OrgPermissionActions, OrgPermissionSubjects.ProjectTemplates]; + | [OrgPermissionActions, OrgPermissionSubjects.ProjectTemplates] + | [OrgPermissionActions, OrgPermissionSubjects.SshCertificateAuthorities] + | [OrgPermissionActions, OrgPermissionSubjects.SshCertificateTemplates]; export type TOrgPermission = MongoAbility; diff --git a/frontend/src/hooks/api/ca/constants.tsx b/frontend/src/hooks/api/ca/constants.tsx index 9bb7b89d5..016f7d7b5 100644 --- a/frontend/src/hooks/api/ca/constants.tsx +++ b/frontend/src/hooks/api/ca/constants.tsx @@ -1,3 +1,5 @@ +import { SshCaStatus } from "@app/hooks/api/ssh-ca"; + import { CaStatus, CaType } from "./enums"; export const caTypeToNameMap: { [K in CaType]: string } = { @@ -11,7 +13,7 @@ export const caStatusToNameMap: { [K in CaStatus]: string } = { [CaStatus.PENDING_CERTIFICATE]: "Pending Certificate" }; -export const getCaStatusBadgeVariant = (status: CaStatus) => { +export const getCaStatusBadgeVariant = (status: CaStatus | SshCaStatus) => { switch (status) { case CaStatus.ACTIVE: return "success"; diff --git a/frontend/src/hooks/api/index.tsx b/frontend/src/hooks/api/index.tsx index 551822f09..19a6fcc06 100644 --- a/frontend/src/hooks/api/index.tsx +++ b/frontend/src/hooks/api/index.tsx @@ -38,6 +38,8 @@ export * from "./secretSharing"; export * from "./secretSnapshots"; export * from "./serverDetails"; export * from "./serviceTokens"; +export * from "./ssh-ca"; +export * from "./sshCertificateTemplates"; export * from "./ssoConfig"; export * from "./subscriptions"; export * from "./tags"; diff --git a/frontend/src/hooks/api/organization/index.ts b/frontend/src/hooks/api/organization/index.ts index fece19e5f..622a600b4 100644 --- a/frontend/src/hooks/api/organization/index.ts +++ b/frontend/src/hooks/api/organization/index.ts @@ -19,6 +19,6 @@ export { useGetOrgPmtMethods, useGetOrgTaxIds, useGetOrgTrialUrl, + useListOrgSshCas, useUpdateOrg, - useUpdateOrgBillingDetails -} from "./queries"; + useUpdateOrgBillingDetails} from "./queries"; diff --git a/frontend/src/hooks/api/organization/queries.tsx b/frontend/src/hooks/api/organization/queries.tsx index 4923177ba..3fa515a46 100644 --- a/frontend/src/hooks/api/organization/queries.tsx +++ b/frontend/src/hooks/api/organization/queries.tsx @@ -4,6 +4,7 @@ import { apiRequest } from "@app/config/request"; import { OrderByDirection } from "@app/hooks/api/generic/types"; import { TGroupOrgMembership } from "../groups/types"; +import { TSshCertificateAuthority } from "../ssh-ca/types"; import { IntegrationAuth } from "../types"; import { BillingDetails, @@ -41,7 +42,8 @@ export const organizationKeys = { }: TListOrgIdentitiesDTO) => [...organizationKeys.getOrgIdentityMemberships(orgId), params] as const, getOrgGroups: (orgId: string) => [{ orgId }, "organization-groups"] as const, - getOrgIntegrationAuths: (orgId: string) => [{ orgId }, "integration-auths"] as const + getOrgIntegrationAuths: (orgId: string) => [{ orgId }, "integration-auths"] as const, + getOrgSshCas: ({ orgId }: { orgId: string }) => [{ orgId }, "org-ssh-cas"] as const }; export const fetchOrganizations = async () => { @@ -495,3 +497,18 @@ export const useGetOrgIntegrationAuths = ( select }); }; + +export const useListOrgSshCas = ({ orgId }: { orgId: string }) => { + return useQuery({ + queryKey: organizationKeys.getOrgSshCas({ orgId }), + queryFn: async () => { + const { + data: { cas } + } = await apiRequest.get<{ cas: TSshCertificateAuthority[] }>( + `/api/v1/organization/${orgId}/ssh-cas` + ); + return cas; + }, + enabled: Boolean(orgId) + }); +}; diff --git a/frontend/src/hooks/api/ssh-ca/enums.tsx b/frontend/src/hooks/api/ssh-ca/enums.tsx new file mode 100644 index 000000000..859890bbf --- /dev/null +++ b/frontend/src/hooks/api/ssh-ca/enums.tsx @@ -0,0 +1,4 @@ +export enum SshCaStatus { + ACTIVE = "active", + DISABLED = "disabled" +} diff --git a/frontend/src/hooks/api/ssh-ca/index.tsx b/frontend/src/hooks/api/ssh-ca/index.tsx new file mode 100644 index 000000000..58ed4cd73 --- /dev/null +++ b/frontend/src/hooks/api/ssh-ca/index.tsx @@ -0,0 +1,3 @@ +export { SshCaStatus } from "./enums"; +export { useCreateSshCa, useDeleteSshCa,useUpdateSshCa } from "./mutations"; +export { useGetSshCaById, useGetSshCaCertTemplates } from "./queries"; diff --git a/frontend/src/hooks/api/ssh-ca/mutations.tsx b/frontend/src/hooks/api/ssh-ca/mutations.tsx new file mode 100644 index 000000000..4d3dcc736 --- /dev/null +++ b/frontend/src/hooks/api/ssh-ca/mutations.tsx @@ -0,0 +1,61 @@ + +import { useMutation, useQueryClient } from "@tanstack/react-query"; + +import { apiRequest } from "@app/config/request"; + +import { organizationKeys } from "../organization/queries"; +import { + TCreateSshCaDTO, + TDeleteSshCaDTO, + TSshCertificateAuthority, + TUpdateSshCaDTO} from "./types"; + +export const sshCaKeys = { + getSshCaById: (caId: string) => [{ caId }, "ssh-ca"] +}; + +export const useCreateSshCa = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async (body) => { + const { + data: { ca } + } = await apiRequest.post<{ ca: TSshCertificateAuthority }>("/api/v1/ssh/ca/", body); + return ca; + }, + onSuccess: ({ orgId }) => { + queryClient.invalidateQueries(organizationKeys.getOrgSshCas({ orgId })); + } + }); +}; + +export const useUpdateSshCa = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async ({ caId, ...body }) => { + const { + data: { ca } + } = await apiRequest.patch<{ ca: TSshCertificateAuthority }>(`/api/v1/ssh/ca/${caId}`, body); + return ca; + }, + onSuccess: ({ orgId }, { caId }) => { + queryClient.invalidateQueries(organizationKeys.getOrgSshCas({ orgId })); + queryClient.invalidateQueries(sshCaKeys.getSshCaById(caId)); + } + }); +}; + +export const useDeleteSshCa = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async ({ caId }) => { + const { + data: { ca } + } = await apiRequest.delete<{ ca: TSshCertificateAuthority }>(`/api/v1/ssh/ca/${caId}`); + return ca; + }, + onSuccess: ({ orgId }) => { + queryClient.invalidateQueries(organizationKeys.getOrgSshCas({ orgId })); + } + }); +}; diff --git a/frontend/src/hooks/api/ssh-ca/queries.tsx b/frontend/src/hooks/api/ssh-ca/queries.tsx new file mode 100644 index 000000000..b85bbcb35 --- /dev/null +++ b/frontend/src/hooks/api/ssh-ca/queries.tsx @@ -0,0 +1,37 @@ +import { useQuery } from "@tanstack/react-query"; + +import { apiRequest } from "@app/config/request"; + +import { TSshCertificateTemplate } from "../sshCertificateTemplates/types"; +import { TSshCertificateAuthority } from "./types"; + +export const sshCaKeys = { + getSshCaById: (caId: string) => [{ caId }, "ssh-ca"], + getSshCaCertTemplates: (caId: string) => [{ caId }, "ssh-ca-cert-templates"] +}; + +export const useGetSshCaById = (caId: string) => { + return useQuery({ + queryKey: sshCaKeys.getSshCaById(caId), + queryFn: async () => { + const { + data: { ca } + } = await apiRequest.get<{ ca: TSshCertificateAuthority }>(`/api/v1/ssh/ca/${caId}`); + return ca; + }, + enabled: Boolean(caId) + }); +}; + +export const useGetSshCaCertTemplates = (caId: string) => { + return useQuery({ + queryKey: sshCaKeys.getSshCaCertTemplates(caId), + queryFn: async () => { + const { data } = await apiRequest.get<{ + certificateTemplates: TSshCertificateTemplate[]; + }>(`/api/v1/ssh/ca/${caId}/certificate-templates`); + return data; + }, + enabled: Boolean(caId) + }); +}; diff --git a/frontend/src/hooks/api/ssh-ca/types.ts b/frontend/src/hooks/api/ssh-ca/types.ts new file mode 100644 index 000000000..1ccfe485b --- /dev/null +++ b/frontend/src/hooks/api/ssh-ca/types.ts @@ -0,0 +1,26 @@ +import { CertKeyAlgorithm } from "../certificates/enums"; +import { SshCaStatus } from "./enums"; + +export type TSshCertificateAuthority = { + id: string; + orgId: string; + status: SshCaStatus; + friendlyName: string; + keyAlgorithm: CertKeyAlgorithm; + createdAt: string; + updatedAt: string; +}; + +export type TCreateSshCaDTO = { + friendlyName?: string; + keyAlgorithm: CertKeyAlgorithm; +}; + +export type TUpdateSshCaDTO = { + caId: string; + status?: SshCaStatus; +}; + +export type TDeleteSshCaDTO = { + caId: string; +}; diff --git a/frontend/src/hooks/api/sshCertificateTemplates/index.tsx b/frontend/src/hooks/api/sshCertificateTemplates/index.tsx new file mode 100644 index 000000000..89f7ebe14 --- /dev/null +++ b/frontend/src/hooks/api/sshCertificateTemplates/index.tsx @@ -0,0 +1,5 @@ +export { + useCreateSshCertTemplate, + useDeleteSshCertTemplate, + useUpdateSshCertTemplate} from "./mutations"; +export { useGetSshCertTemplate } from "./queries"; diff --git a/frontend/src/hooks/api/sshCertificateTemplates/mutations.tsx b/frontend/src/hooks/api/sshCertificateTemplates/mutations.tsx new file mode 100644 index 000000000..91fb5eb53 --- /dev/null +++ b/frontend/src/hooks/api/sshCertificateTemplates/mutations.tsx @@ -0,0 +1,59 @@ +import { useMutation, useQueryClient } from "@tanstack/react-query"; + +import { apiRequest } from "@app/config/request"; + +import { sshCaKeys } from "../ssh-ca/queries"; +import { + TCreateSshCertificateTemplateDTO, + TDeleteSshCertificateTemplateDTO, + TSshCertificateTemplate, + TUpdateSshCertificateTemplateDTO +} from "./types"; + +export const useCreateSshCertTemplate = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async (data) => { + const { data: certificateTemplate } = await apiRequest.post( + "/api/v1/ssh/certificate-templates", + data + ); + return certificateTemplate; + }, + onSuccess: ({ sshCaId }) => { + queryClient.invalidateQueries(sshCaKeys.getSshCaCertTemplates(sshCaId)); + } + }); +}; + +export const useUpdateSshCertTemplate = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async (data) => { + const { data: certificateTemplate } = await apiRequest.patch( + `/api/v1/ssh/certificate-templates/${data.id}`, + data + ); + + return certificateTemplate; + }, + onSuccess: ({ sshCaId }) => { + queryClient.invalidateQueries(sshCaKeys.getSshCaCertTemplates(sshCaId)); + } + }); +}; + +export const useDeleteSshCertTemplate = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async (data) => { + const { data: certificateTemplate } = await apiRequest.delete( + `/api/v1/ssh/certificate-templates/${data.id}` + ); + return certificateTemplate; + }, + onSuccess: ({ sshCaId }) => { + queryClient.invalidateQueries(sshCaKeys.getSshCaCertTemplates(sshCaId)); + } + }); +}; diff --git a/frontend/src/hooks/api/sshCertificateTemplates/queries.tsx b/frontend/src/hooks/api/sshCertificateTemplates/queries.tsx new file mode 100644 index 000000000..b9b535916 --- /dev/null +++ b/frontend/src/hooks/api/sshCertificateTemplates/queries.tsx @@ -0,0 +1,22 @@ +import { useQuery } from "@tanstack/react-query"; + +import { apiRequest } from "@app/config/request"; + +import { TSshCertificateTemplate } from "./types"; + +export const certTemplateKeys = { + getSshCertTemplateById: (id: string) => [{ id }, "ssh-cert-template"] +}; + +export const useGetSshCertTemplate = (id: string) => { + return useQuery({ + queryKey: certTemplateKeys.getSshCertTemplateById(id), + queryFn: async () => { + const { data: certificateTemplate } = await apiRequest.get( + `/api/v1/ssh/certificate-templates/${id}` + ); + return certificateTemplate; + }, + enabled: Boolean(id) + }); +}; diff --git a/frontend/src/hooks/api/sshCertificateTemplates/types.ts b/frontend/src/hooks/api/sshCertificateTemplates/types.ts new file mode 100644 index 000000000..fceaff56d --- /dev/null +++ b/frontend/src/hooks/api/sshCertificateTemplates/types.ts @@ -0,0 +1,40 @@ +export type TSshCertificateTemplate = { + id: string; + sshCaId: string; + name: string; + ttl: string; + maxTTL: string; + allowedUsers: string[]; + allowedHosts: string[]; + allowUserCertificates: boolean; + allowHostCertificates: boolean; + allowCustomKeyIds: boolean; +}; + +export type TCreateSshCertificateTemplateDTO = { + sshCaId: string; + name: string; + ttl: string; + maxTTL: string; + allowedUsers: string[]; + allowedHosts: string[]; + allowUserCertificates: boolean; + allowHostCertificates: boolean; + allowCustomKeyIds: boolean; +}; + +export type TUpdateSshCertificateTemplateDTO = { + id: string; + name?: string; + ttl?: string; + maxTTL?: string; + allowedUsers?: string[]; + allowedHosts?: string[]; + allowUserCertificates?: boolean; + allowHostCertificates?: boolean; + allowCustomKeyIds?: boolean; +}; + +export type TDeleteSshCertificateTemplateDTO = { + id: string; +}; diff --git a/frontend/src/layouts/AppLayout/AppLayout.tsx b/frontend/src/layouts/AppLayout/AppLayout.tsx index 8c4f8e1c8..575695b2c 100644 --- a/frontend/src/layouts/AppLayout/AppLayout.tsx +++ b/frontend/src/layouts/AppLayout/AppLayout.tsx @@ -513,6 +513,16 @@ export const AppLayout = ({ children }: LayoutProps) => { + + + + SSH + + + + + SSH Certificate Authority + + + + + ); +} + +SshCa.requireAuth = true; diff --git a/frontend/src/pages/org/[id]/ssh/index.tsx b/frontend/src/pages/org/[id]/ssh/index.tsx new file mode 100644 index 000000000..1117806a8 --- /dev/null +++ b/frontend/src/pages/org/[id]/ssh/index.tsx @@ -0,0 +1,29 @@ +import { useTranslation } from "react-i18next"; +import Head from "next/head"; + +import { SshPage } from "@app/views/Org/SshPage"; + +// TODO: update meta tags + +const Ssh = () => { + const { t } = useTranslation(); + + return ( + <> + + {t("common.head-title", { title: t("approval.title") })} + + + + + +
+ +
+ + ); +}; + +export default Ssh; + +Ssh.requireAuth = true; diff --git a/frontend/src/views/Org/SshCaPage/SshCaPage.tsx b/frontend/src/views/Org/SshCaPage/SshCaPage.tsx new file mode 100644 index 000000000..b81478e4f --- /dev/null +++ b/frontend/src/views/Org/SshCaPage/SshCaPage.tsx @@ -0,0 +1,139 @@ +/* eslint-disable @typescript-eslint/no-unused-vars */ +import { useRouter } from "next/router"; +import { faChevronLeft, faEllipsis } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { twMerge } from "tailwind-merge"; + +import { createNotification } from "@app/components/notifications"; +import { OrgPermissionCan, ProjectPermissionCan } from "@app/components/permissions"; +import { + Button, + DeleteActionModal, + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, + Tooltip +} from "@app/components/v2"; +import { + OrgPermissionActions, + OrgPermissionSubjects, + ProjectPermissionActions, + ProjectPermissionSub, + useOrganization +} from "@app/context"; +import { withPermission } from "@app/hoc"; +import { useDeleteSshCa, useGetSshCaById } from "@app/hooks/api"; +import { usePopUp } from "@app/hooks/usePopUp"; + +import { SshCaDetailsSection, SshCertificateTemplatesSection } from "./components"; + +export const SshCaPage = withPermission( + () => { + const { currentOrg } = useOrganization(); + const router = useRouter(); + const caId = router.query.caId as string; + const { data } = useGetSshCaById(caId); + + const { mutateAsync: deleteSshCa } = useDeleteSshCa(); + + const { popUp, handlePopUpOpen, handlePopUpClose, handlePopUpToggle } = usePopUp([ + "sshCa", + "deleteSshCa" + ] as const); + + const onRemoveCaSubmit = async (caIdToDelete: string) => { + try { + if (!currentOrg?.id) return; + + await deleteSshCa({ caId: caIdToDelete }); + + await createNotification({ + text: "Successfully deleted SSH CA", + type: "success" + }); + + handlePopUpClose("deleteSshCa"); + router.push(`/org/${currentOrg.id}/ssh`); + } catch (err) { + console.error(err); + createNotification({ + text: "Failed to delete SSH CA", + type: "error" + }); + } + }; + + return ( +
+ {data && ( +
+ +
+

{data.friendlyName}

+ + +
+ + + +
+
+ + + {(isAllowed) => ( + + handlePopUpOpen("deleteSshCa", { + caId: data.id + }) + } + disabled={!isAllowed} + > + Delete SSH CA + + )} + + +
+
+
+
+ +
+
+ +
+
+
+ )} + handlePopUpToggle("deleteSshCa", isOpen)} + deleteKey="confirm" + onDeleteApproved={() => + onRemoveCaSubmit((popUp?.deleteSshCa?.data as { caId: string })?.caId) + } + /> +
+ ); + }, + { action: OrgPermissionActions.Read, subject: OrgPermissionSubjects.SshCertificateAuthorities } +); diff --git a/frontend/src/views/Org/SshCaPage/components/SshCaDetailsSection.tsx b/frontend/src/views/Org/SshCaPage/components/SshCaDetailsSection.tsx new file mode 100644 index 000000000..2e97addb9 --- /dev/null +++ b/frontend/src/views/Org/SshCaPage/components/SshCaDetailsSection.tsx @@ -0,0 +1,94 @@ +import { faCheck, faCopy, faPencil } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; + +import { OrgPermissionCan } from "@app/components/permissions"; +import { IconButton, Tooltip } from "@app/components/v2"; +import { OrgPermissionActions, OrgPermissionSubjects } from "@app/context"; +import { useTimedReset } from "@app/hooks"; +import { useGetSshCaById } from "@app/hooks/api"; +import { caStatusToNameMap } from "@app/hooks/api/ca/constants"; +import { certKeyAlgorithmToNameMap } from "@app/hooks/api/certificates/constants"; +import { UsePopUpState } from "@app/hooks/usePopUp"; + +type Props = { + caId: string; + handlePopUpOpen: (popUpName: keyof UsePopUpState<["sshCa"]>, data?: {}) => void; +}; + +export const SshCaDetailsSection = ({ caId, handlePopUpOpen }: Props) => { + const [copyTextId, isCopyingId, setCopyTextId] = useTimedReset({ + initialState: "Copy ID to clipboard" + }); + + const { data: ca } = useGetSshCaById(caId); + + return ca ? ( +
+
+

CA Details

+ + {(isAllowed) => { + return ( + + { + e.stopPropagation(); + handlePopUpOpen("sshCa", { + caId: ca.id + }); + }} + > + + + + ); + }} + +
+
+
+

SSH CA ID

+
+

{ca.id}

+
+ + { + navigator.clipboard.writeText(ca.id); + setCopyTextId("Copied"); + }} + > + + + +
+
+
+
+

Friendly Name

+

{ca.friendlyName}

+
+
+

Status

+

{caStatusToNameMap[ca.status]}

+
+
+

Key Algorithm

+

{certKeyAlgorithmToNameMap[ca.keyAlgorithm]}

+
+
+
+ ) : ( +
+ ); +}; diff --git a/frontend/src/views/Org/SshCaPage/components/SshCertificateTemplateModal.tsx b/frontend/src/views/Org/SshCaPage/components/SshCertificateTemplateModal.tsx new file mode 100644 index 000000000..acfb523fe --- /dev/null +++ b/frontend/src/views/Org/SshCaPage/components/SshCertificateTemplateModal.tsx @@ -0,0 +1,347 @@ +import { useEffect } from "react"; +import { Controller, useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { z } from "zod"; + +import { createNotification } from "@app/components/notifications"; +import { + Button, + FormControl, + Input, + Modal, + ModalContent, + Select, + SelectItem, + Switch} from "@app/components/v2"; +import { useOrganization } from "@app/context"; +import { + useCreateSshCertTemplate, + useGetSshCaById, + useGetSshCertTemplate, + useListOrgSshCas, + useUpdateSshCertTemplate} from "@app/hooks/api"; +import { UsePopUpState } from "@app/hooks/usePopUp"; + +const schema = z.object({ + sshCaId: z.string(), + name: z.string().min(1), + ttl: z.string().trim().min(1), + maxTTL: z.string().trim().min(1), + allowedUsers: z.string(), + allowedHosts: z.string(), + allowUserCertificates: z.boolean().optional().default(false), + allowHostCertificates: z.boolean().optional().default(false), + allowCustomKeyIds: z.boolean().optional().default(false) +}); + +export type FormData = z.infer; + +type Props = { + sshCaId: string; + popUp: UsePopUpState<["sshCertificateTemplate"]>; + handlePopUpToggle: ( + popUpName: keyof UsePopUpState<["sshCertificateTemplate"]>, + state?: boolean + ) => void; +}; + +export const SshCertificateTemplateModal = ({ popUp, handlePopUpToggle, sshCaId }: Props) => { + const { currentOrg } = useOrganization(); + + const { data: ca } = useGetSshCaById(sshCaId); + + const { data: certTemplate } = useGetSshCertTemplate( + (popUp?.sshCertificateTemplate?.data as { id: string })?.id || "" + ); + + const { data: cas } = useListOrgSshCas({ + orgId: currentOrg?.id ?? "" + }); + + const { mutateAsync: createSshCertTemplate } = useCreateSshCertTemplate(); + const { mutateAsync: updateSshCertTemplate } = useUpdateSshCertTemplate(); + + const { + control, + handleSubmit, + reset, + formState: { isSubmitting } + } = useForm({ + resolver: zodResolver(schema), + defaultValues: {} + }); + + useEffect(() => { + if (certTemplate) { + reset({ + sshCaId: certTemplate.sshCaId, + name: certTemplate.name, + ttl: certTemplate.ttl, + maxTTL: certTemplate.maxTTL, + allowedUsers: certTemplate.allowedUsers.join(", "), + allowedHosts: certTemplate.allowedHosts.join(", "), + allowUserCertificates: certTemplate.allowUserCertificates, + allowHostCertificates: certTemplate.allowHostCertificates, + allowCustomKeyIds: certTemplate.allowCustomKeyIds + }); + } else { + reset({ + sshCaId, + name: "", + ttl: "1h", + maxTTL: "30d", + allowedUsers: "", + allowedHosts: "", + allowUserCertificates: false, + allowHostCertificates: false, + allowCustomKeyIds: false + }); + } + }, [certTemplate, ca]); + + const onFormSubmit = async ({ + name, + ttl, + maxTTL, + allowUserCertificates, + allowHostCertificates, + allowedUsers, + allowedHosts, + allowCustomKeyIds + }: FormData) => { + try { + if (certTemplate) { + await updateSshCertTemplate({ + id: certTemplate.id, + name, + ttl, + maxTTL, + allowedUsers: allowedUsers ? allowedUsers.split(",").map((user) => user.trim()) : [], + allowedHosts: allowedHosts ? allowedHosts.split(",").map((host) => host.trim()) : [], + allowUserCertificates, + allowHostCertificates, + allowCustomKeyIds + }); + + createNotification({ + text: "Successfully updated SSH certificate template", + type: "success" + }); + } else { + await createSshCertTemplate({ + sshCaId, + name, + ttl, + maxTTL, + allowedUsers: allowedUsers ? allowedUsers.split(",").map((user) => user.trim()) : [], + allowedHosts: allowedHosts ? allowedHosts.split(",").map((host) => host.trim()) : [], + allowUserCertificates, + allowHostCertificates, + allowCustomKeyIds + }); + + createNotification({ + text: "Successfully created SSH certificate template", + type: "success" + }); + } + + reset(); + handlePopUpToggle("sshCertificateTemplate", false); + } catch (err) { + console.error(err); + createNotification({ + text: "Failed to save changes", + type: "error" + }); + } + }; + + return ( + { + handlePopUpToggle("sshCertificateTemplate", isOpen); + reset(); + }} + > + +
+ {certTemplate && ( + + + + )} + ( + + + + )} + /> + ( + + + + )} + /> + ( + + + + )} + /> + ( + + + + )} + /> + ( + + + + )} + /> + ( + + + + )} + /> + { + return ( + + field.onChange(value)} + isChecked={field.value} + > +

Allow User Certificates

+
+
+ ); + }} + /> + { + return ( + + field.onChange(value)} + isChecked={field.value} + > +

Allow Host Certificates

+
+
+ ); + }} + /> + { + return ( + + field.onChange(value)} + isChecked={field.value} + > +

Allow Custom Key IDs

+
+
+ ); + }} + /> +
+ + +
+ +
+
+ ); +}; diff --git a/frontend/src/views/Org/SshCaPage/components/SshCertificateTemplatesSection.tsx b/frontend/src/views/Org/SshCaPage/components/SshCertificateTemplatesSection.tsx new file mode 100644 index 000000000..a518e96f4 --- /dev/null +++ b/frontend/src/views/Org/SshCaPage/components/SshCertificateTemplatesSection.tsx @@ -0,0 +1,92 @@ +import { faPlus } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; + +import { createNotification } from "@app/components/notifications"; +import { OrgPermissionCan } from "@app/components/permissions"; +import { DeleteActionModal, IconButton } from "@app/components/v2"; +import { OrgPermissionActions, OrgPermissionSubjects } from "@app/context"; +import { usePopUp } from "@app/hooks"; +import { useDeleteSshCertTemplate } from "@app/hooks/api"; + +import { SshCertificateTemplateModal } from "./SshCertificateTemplateModal"; +import { SshCertificateTemplatesTable } from "./SshCertificateTemplatesTable"; + +type Props = { + caId: string; +}; + +export const SshCertificateTemplatesSection = ({ caId }: Props) => { + const { popUp, handlePopUpOpen, handlePopUpClose, handlePopUpToggle } = usePopUp([ + "sshCertificateTemplate", + "deleteSshCertificateTemplate", + "upgradePlan" + ] as const); + + const { mutateAsync: deleteSshCertTemplate } = useDeleteSshCertTemplate(); + + const onRemoveSshCertificateTemplateSubmit = async (id: string) => { + try { + await deleteSshCertTemplate({ + id + }); + + await createNotification({ + text: "Successfully deleted SSH certificate template", + type: "success" + }); + + handlePopUpClose("deleteSshCertificateTemplate"); + } catch (err) { + console.error(err); + createNotification({ + text: "Failed to delete SSH certificate template", + type: "error" + }); + } + }; + + return ( +
+
+

Certificate Templates

+ + {(isAllowed) => ( + handlePopUpOpen("sshCertificateTemplate")} + isDisabled={!isAllowed} + > + + + )} + +
+
+ +
+ + handlePopUpToggle("deleteSshCertificateTemplate", isOpen)} + deleteKey="confirm" + onDeleteApproved={() => + onRemoveSshCertificateTemplateSubmit( + (popUp?.deleteSshCertificateTemplate?.data as { id: string })?.id + ) + } + /> +
+ ); +}; diff --git a/frontend/src/views/Org/SshCaPage/components/SshCertificateTemplatesTable.tsx b/frontend/src/views/Org/SshCaPage/components/SshCertificateTemplatesTable.tsx new file mode 100644 index 000000000..2365990fa --- /dev/null +++ b/frontend/src/views/Org/SshCaPage/components/SshCertificateTemplatesTable.tsx @@ -0,0 +1,118 @@ +import { faEllipsis, faFileAlt, faTrash } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { twMerge } from "tailwind-merge"; + +import { OrgPermissionCan } from "@app/components/permissions"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, + EmptyState, + Table, + TableContainer, + TableSkeleton, + TBody, + Td, + Th, + THead, + Tooltip, + Tr +} from "@app/components/v2"; +import { OrgPermissionActions, OrgPermissionSubjects } from "@app/context"; +import { useGetSshCaCertTemplates } from "@app/hooks/api"; +import { UsePopUpState } from "@app/hooks/usePopUp"; + +type Props = { + sshCaId: string; + handlePopUpOpen: ( + popUpName: keyof UsePopUpState< + ["sshCertificateTemplate", "deleteSshCertificateTemplate", "upgradePlan"] + >, + data?: { + id?: string; + name?: string; + } + ) => void; +}; + +export const SshCertificateTemplatesTable = ({ handlePopUpOpen, sshCaId }: Props) => { + const { data, isLoading } = useGetSshCaCertTemplates(sshCaId); + + return ( +
+ + + + + + + + + {isLoading && } + {!isLoading && + data?.certificateTemplates.map((certificateTemplate) => { + return ( + + + + + ); + })} + +
Name +
{certificateTemplate.name} + + +
+ + + +
+
+ + + handlePopUpOpen("sshCertificateTemplate", { + id: certificateTemplate.id + }) + } + icon={} + > + Edit Template + + + {(isAllowed) => ( + } + onClick={() => + handlePopUpOpen("deleteSshCertificateTemplate", { + id: certificateTemplate.id, + name: certificateTemplate.name + }) + } + > + Delete Template + + )} + + +
+
+ {!isLoading && !data?.certificateTemplates?.length && ( + + )} +
+
+ ); +}; diff --git a/frontend/src/views/Org/SshCaPage/components/index.tsx b/frontend/src/views/Org/SshCaPage/components/index.tsx new file mode 100644 index 000000000..71589a1a3 --- /dev/null +++ b/frontend/src/views/Org/SshCaPage/components/index.tsx @@ -0,0 +1,2 @@ +export { SshCaDetailsSection } from "./SshCaDetailsSection"; +export { SshCertificateTemplatesSection } from "./SshCertificateTemplatesSection"; diff --git a/frontend/src/views/Org/SshCaPage/index.tsx b/frontend/src/views/Org/SshCaPage/index.tsx new file mode 100644 index 000000000..18da81c2b --- /dev/null +++ b/frontend/src/views/Org/SshCaPage/index.tsx @@ -0,0 +1 @@ +export { SshCaPage } from "./SshCaPage"; diff --git a/frontend/src/views/Org/SshPage/SshPage.tsx b/frontend/src/views/Org/SshPage/SshPage.tsx new file mode 100644 index 000000000..653973faf --- /dev/null +++ b/frontend/src/views/Org/SshPage/SshPage.tsx @@ -0,0 +1,18 @@ +import { OrgPermissionActions, OrgPermissionSubjects } from "@app/context"; +import { withPermission } from "@app/hoc"; + +import { SshCaSection } from "./components"; + +export const SshPage = withPermission( + () => { + return ( +
+
+

SSH

+ +
+
+ ); + }, + { action: OrgPermissionActions.Read, subject: OrgPermissionSubjects.SshCertificateAuthorities } +); diff --git a/frontend/src/views/Org/SshPage/components/SshCaModal.tsx b/frontend/src/views/Org/SshPage/components/SshCaModal.tsx new file mode 100644 index 000000000..ec6c17239 --- /dev/null +++ b/frontend/src/views/Org/SshPage/components/SshCaModal.tsx @@ -0,0 +1,167 @@ +import { Controller, useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { z } from "zod"; + +import { createNotification } from "@app/components/notifications"; +import { + Button, + FormControl, + Input, + Modal, + ModalContent, + Select, + SelectItem +} from "@app/components/v2"; +import { useCreateSshCa, useGetSshCaById, useUpdateSshCa } from "@app/hooks/api"; +import { certKeyAlgorithms } from "@app/hooks/api/certificates/constants"; +import { CertKeyAlgorithm } from "@app/hooks/api/certificates/enums"; +import { UsePopUpState } from "@app/hooks/usePopUp"; + +type Props = { + popUp: UsePopUpState<["sshCa"]>; + handlePopUpToggle: (popUpName: keyof UsePopUpState<["sshCa"]>, state?: boolean) => void; +}; + +const schema = z + .object({ + friendlyName: z.string(), + keyAlgorithm: z.enum([ + CertKeyAlgorithm.RSA_2048, + CertKeyAlgorithm.RSA_4096, + CertKeyAlgorithm.ECDSA_P256, + CertKeyAlgorithm.ECDSA_P384 + ]) + }) + .required(); + +export type FormData = z.infer; + +export const SshCaModal = ({ popUp, handlePopUpToggle }: Props) => { + const { data: ca } = useGetSshCaById((popUp?.sshCa?.data as { caId: string })?.caId || ""); + + const { mutateAsync: createMutateAsync } = useCreateSshCa(); + const { mutateAsync: updateMutateAsync } = useUpdateSshCa(); + + const { + control, + handleSubmit, + reset, + formState: { isSubmitting } + } = useForm({ + resolver: zodResolver(schema), + defaultValues: { + friendlyName: "", + keyAlgorithm: CertKeyAlgorithm.RSA_2048 + } + }); + + const onFormSubmit = async ({ friendlyName, keyAlgorithm }: FormData) => { + try { + if (ca) { + // update + await updateMutateAsync({ + caId: ca.id + }); + } else { + // create + await createMutateAsync({ + friendlyName, + keyAlgorithm + }); + } + + reset(); + handlePopUpToggle("sshCa", false); + + createNotification({ + text: `Successfully ${ca ? "updated" : "created"} SSH CA`, + type: "success" + }); + } catch (err) { + console.error(err); + createNotification({ + text: "Failed to create SSH CA", + type: "error" + }); + } + }; + + return ( + { + reset(); + handlePopUpToggle("sshCa", isOpen); + }} + > + +
+ {ca && ( + + + + )} + ( + + + + )} + /> + ( + + + + )} + /> +
+ + +
+ +
+
+ ); +}; diff --git a/frontend/src/views/Org/SshPage/components/SshCaSection.tsx b/frontend/src/views/Org/SshPage/components/SshCaSection.tsx new file mode 100644 index 000000000..c25ac7eae --- /dev/null +++ b/frontend/src/views/Org/SshPage/components/SshCaSection.tsx @@ -0,0 +1,120 @@ +import { faPlus } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; + +import { createNotification } from "@app/components/notifications"; +import { OrgPermissionCan } from "@app/components/permissions"; +import { Button, DeleteActionModal } from "@app/components/v2"; +import { OrgPermissionActions, OrgPermissionSubjects } from "@app/context"; +import { SshCaStatus, useDeleteSshCa, useUpdateSshCa } from "@app/hooks/api"; +import { usePopUp } from "@app/hooks/usePopUp"; + +import { SshCaModal } from "./SshCaModal"; +import { SshCaTable } from "./SshCaTable"; + +export const SshCaSection = () => { + const { mutateAsync: deleteSshCa } = useDeleteSshCa(); + const { mutateAsync: updateSshCa } = useUpdateSshCa(); + + const { popUp, handlePopUpOpen, handlePopUpClose, handlePopUpToggle } = usePopUp([ + "sshCa", + "deleteSshCa", + "sshCaStatus", // enable / disable + "upgradePlan" + ] as const); + + const onRemoveSshCaSubmit = async (caId: string) => { + try { + await deleteSshCa({ caId }); + + await createNotification({ + text: "Successfully deleted SSH CA", + type: "success" + }); + + handlePopUpClose("deleteSshCa"); + } catch (err) { + console.error(err); + createNotification({ + text: "Failed to delete SSH CA", + type: "error" + }); + } + }; + + const onUpdateSshCaStatus = async ({ caId, status }: { caId: string; status: SshCaStatus }) => { + try { + await updateSshCa({ caId, status }); + + await createNotification({ + text: `Successfully ${status === SshCaStatus.ACTIVE ? "enabled" : "disabled"} SSH CA`, + type: "success" + }); + + handlePopUpClose("sshCaStatus"); + } catch (err) { + console.error(err); + createNotification({ + text: `Failed to ${status === SshCaStatus.ACTIVE ? "enabled" : "disabled"} SSH CA`, + type: "error" + }); + } + }; + + return ( +
+
+

Certificate Authorities

+ + {(isAllowed) => ( + + )} + +
+ + + handlePopUpToggle("deleteSshCa", isOpen)} + deleteKey="confirm" + onDeleteApproved={() => + onRemoveSshCaSubmit((popUp?.deleteSshCa?.data as { caId: string })?.caId) + } + /> + handlePopUpToggle("sshCaStatus", isOpen)} + deleteKey="confirm" + onDeleteApproved={() => + onUpdateSshCaStatus(popUp?.sshCaStatus?.data as { caId: string; status: SshCaStatus }) + } + /> + {/* handlePopUpToggle("upgradePlan", isOpen)} + text={(popUp.upgradePlan?.data as { description: string })?.description} + /> */} +
+ ); +}; diff --git a/frontend/src/views/Org/SshPage/components/SshCaTable.tsx b/frontend/src/views/Org/SshPage/components/SshCaTable.tsx new file mode 100644 index 000000000..1fe9dd91d --- /dev/null +++ b/frontend/src/views/Org/SshPage/components/SshCaTable.tsx @@ -0,0 +1,153 @@ +import { useRouter } from "next/router"; +import { faBan, faCertificate, faEllipsis, faTrash } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { twMerge } from "tailwind-merge"; + +import { OrgPermissionCan } from "@app/components/permissions"; +import { + Badge, + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, + EmptyState, + Table, + TableContainer, + TableSkeleton, + TBody, + Td, + Th, + THead, + Tooltip, + Tr +} from "@app/components/v2"; +import { OrgPermissionActions, OrgPermissionSubjects, useOrganization } from "@app/context"; +import { SshCaStatus , useListOrgSshCas } from "@app/hooks/api"; +import { caStatusToNameMap, getCaStatusBadgeVariant } from "@app/hooks/api/ca/constants"; +import { UsePopUpState } from "@app/hooks/usePopUp"; + +type Props = { + handlePopUpOpen: ( + popUpName: keyof UsePopUpState<["deleteSshCa", "sshCaStatus"]>, + data?: {} + ) => void; +}; + +export const SshCaTable = ({ handlePopUpOpen }: Props) => { + const router = useRouter(); + const { currentOrg } = useOrganization(); + const { data, isLoading } = useListOrgSshCas({ + orgId: currentOrg?.id ?? "" + }); + + return ( +
+ + + + + + + + + + {isLoading && } + {!isLoading && + data && + data.length > 0 && + data.map((ca) => { + return ( + router.push(`/org/${currentOrg?.id}/ssh/ca/${ca.id}`)} + > + + + + + ); + })} + +
Friendly NameStatus +
{ca.friendlyName} + + {caStatusToNameMap[ca.status]} + + + + +
+ + + +
+
+ + {(ca.status === SshCaStatus.ACTIVE || + ca.status === SshCaStatus.DISABLED) && ( + + {(isAllowed) => ( + { + e.stopPropagation(); + handlePopUpOpen("sshCaStatus", { + caId: ca.id, + status: + ca.status === SshCaStatus.ACTIVE + ? SshCaStatus.DISABLED + : SshCaStatus.ACTIVE + }); + }} + disabled={!isAllowed} + icon={} + > + {`${ + ca.status === SshCaStatus.ACTIVE ? "Disable" : "Enable" + } SSH CA`} + + )} + + )} + + {(isAllowed) => ( + { + e.stopPropagation(); + handlePopUpOpen("deleteSshCa", { + caId: ca.id + }); + }} + disabled={!isAllowed} + icon={} + > + Delete SSH CA + + )} + + +
+
+ {!isLoading && data?.length === 0 && ( + + )} +
+
+ ); +}; diff --git a/frontend/src/views/Org/SshPage/components/index.tsx b/frontend/src/views/Org/SshPage/components/index.tsx new file mode 100644 index 000000000..0ba2a04c7 --- /dev/null +++ b/frontend/src/views/Org/SshPage/components/index.tsx @@ -0,0 +1 @@ +export { SshCaSection } from "./SshCaSection"; diff --git a/frontend/src/views/Org/SshPage/index.tsx b/frontend/src/views/Org/SshPage/index.tsx new file mode 100644 index 000000000..080bae38d --- /dev/null +++ b/frontend/src/views/Org/SshPage/index.tsx @@ -0,0 +1 @@ +export { SshPage } from "./SshPage"; diff --git a/frontend/src/views/Project/CertificatesPage/components/CaTab/components/CaModal.tsx b/frontend/src/views/Project/CertificatesPage/components/CaTab/components/CaModal.tsx index 1241ff1c9..c27d4dc39 100644 --- a/frontend/src/views/Project/CertificatesPage/components/CaTab/components/CaModal.tsx +++ b/frontend/src/views/Project/CertificatesPage/components/CaTab/components/CaModal.tsx @@ -17,7 +17,7 @@ import { // DatePicker } from "@app/components/v2"; import { useWorkspace } from "@app/context"; -import { CaType, useCreateCa, useGetCaById,useUpdateCa } from "@app/hooks/api/ca"; +import { CaType, useCreateCa, useGetCaById, useUpdateCa } from "@app/hooks/api/ca"; import { certKeyAlgorithms } from "@app/hooks/api/certificates/constants"; import { CertKeyAlgorithm } from "@app/hooks/api/certificates/enums"; import { UsePopUpState } from "@app/hooks/usePopUp"; @@ -72,7 +72,7 @@ export const CaModal = ({ popUp, handlePopUpToggle }: Props) => { // const [isStartDatePickerOpen, setIsStartDatePickerOpen] = useState(false); const { data: ca } = useGetCaById((popUp?.ca?.data as { caId: string })?.caId || ""); - + const { mutateAsync: createMutateAsync } = useCreateCa(); const { mutateAsync: updateMutateAsync } = useUpdateCa(); @@ -151,7 +151,7 @@ export const CaModal = ({ popUp, handlePopUpToggle }: Props) => { }: FormData) => { try { if (!currentWorkspace?.slug) return; - + if (ca) { // update await updateMutateAsync({ diff --git a/frontend/src/views/Project/CertificatesPage/components/CaTab/components/CaTable.tsx b/frontend/src/views/Project/CertificatesPage/components/CaTab/components/CaTable.tsx index 35c034f34..fa4d478fe 100644 --- a/frontend/src/views/Project/CertificatesPage/components/CaTab/components/CaTable.tsx +++ b/frontend/src/views/Project/CertificatesPage/components/CaTab/components/CaTable.tsx @@ -1,10 +1,5 @@ import { useRouter } from "next/router"; -import { - faBan, - faCertificate, - faEllipsis, - faTrash -} from "@fortawesome/free-solid-svg-icons"; +import { faBan, faCertificate, faEllipsis, faTrash } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { format } from "date-fns"; import { twMerge } from "tailwind-merge"; diff --git a/frontend/src/views/Project/CertificatesPage/components/CertificatesTab/components/CertificateTemplatesSection.tsx b/frontend/src/views/Project/CertificatesPage/components/CertificatesTab/components/CertificateTemplatesSection.tsx index b1cce918f..ad71c575d 100644 --- a/frontend/src/views/Project/CertificatesPage/components/CertificatesTab/components/CertificateTemplatesSection.tsx +++ b/frontend/src/views/Project/CertificatesPage/components/CertificatesTab/components/CertificateTemplatesSection.tsx @@ -1,7 +1,3 @@ -/** - * TODO (dangtony98): Reevaluate if this component should be in main - * CertificateTab or under CA page in the future. - */ import { faPlus } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; @@ -18,7 +14,7 @@ import { CertificateTemplatesTable } from "./CertificateTemplatesTable"; type Props = { caId: string; -} +}; export const CertificateTemplatesSection = ({ caId }: Props) => { const { popUp, handlePopUpOpen, handlePopUpClose, handlePopUpToggle } = usePopUp([