From 242595fceb5013d9e4e687601c4f26701c8e5dcc Mon Sep 17 00:00:00 2001 From: mohammad riyaz Date: Sun, 24 Nov 2024 21:05:39 +0530 Subject: [PATCH 001/162] changed getCaCerts key from ca-cert -> ca-certs --- frontend/src/hooks/api/ca/queries.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/hooks/api/ca/queries.tsx b/frontend/src/hooks/api/ca/queries.tsx index a1e633776..da010c866 100644 --- a/frontend/src/hooks/api/ca/queries.tsx +++ b/frontend/src/hooks/api/ca/queries.tsx @@ -7,7 +7,7 @@ import { TCertificateAuthority } from "./types"; export const caKeys = { getCaById: (caId: string) => [{ caId }, "ca"], - getCaCerts: (caId: string) => [{ caId }, "ca-cert"], + getCaCerts: (caId: string) => [{ caId }, "ca-certs"], getCaCrls: (caId: string) => [{ caId }, "ca-crls"], getCaCert: (caId: string) => [{ caId }, "ca-cert"], getCaCsr: (caId: string) => [{ caId }, "ca-csr"], From 4fc8c509ac2ea76ec4c1876affdd97af971e9342 Mon Sep 17 00:00:00 2001 From: Tuan Dang Date: Mon, 2 Dec 2024 22:37:23 -0800 Subject: [PATCH 002/162] 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([ From 07d9398aad621726e42773fe034ffdc1866cdbcc Mon Sep 17 00:00:00 2001 From: Tuan Dang Date: Tue, 3 Dec 2024 17:38:23 -0800 Subject: [PATCH 003/162] Add permissioning to SSH, add publicKey return for SSH CA, polish --- .../v1/ssh-certificate-authority-router.ts | 15 ++- .../v1/ssh-certificate-template-router.ts | 20 +++- backend/src/ee/routes/v1/ssh-router.ts | 53 +++++---- .../ee/services/permission/org-permission.ts | 30 ++++-- .../ssh-certificate-template-dal.ts | 27 ++++- .../ssh-certificate-template-service.ts | 35 ++++-- .../ssh/ssh-certificate-authority-fns.ts | 32 +++++- .../ssh/ssh-certificate-authority-service.ts | 101 +++++++++++++----- .../ssh/ssh-certificate-authority-types.ts | 7 +- backend/src/lib/api-docs/constants.ts | 23 ++++ .../context/OrgPermissionContext/index.tsx | 5 +- .../src/context/OrgPermissionContext/types.ts | 11 +- frontend/src/context/index.tsx | 1 + .../src/hooks/api/organization/queries.tsx | 2 +- frontend/src/hooks/api/ssh-ca/types.ts | 2 + .../components/OrgRoleModifySection.utils.ts | 15 ++- .../Org/RolePage/components/RoleModal.tsx | 6 -- .../RolePermissionRow.tsx | 13 ++- .../RolePermissionsSection.tsx | 10 +- .../src/views/Org/SshCaPage/SshCaPage.tsx | 2 + .../components/SshCaDetailsSection.tsx | 36 ++++++- .../SshCertificateTemplateModal.tsx | 2 +- .../SshCertificateTemplatesSection.tsx | 4 +- .../SshCertificateTemplatesTable.tsx | 29 ++--- .../Org/SshPage/components/SshCaModal.tsx | 22 +++- 25 files changed, 397 insertions(+), 106 deletions(-) diff --git a/backend/src/ee/routes/v1/ssh-certificate-authority-router.ts b/backend/src/ee/routes/v1/ssh-certificate-authority-router.ts index 0bee089ee..4f4e166d0 100644 --- a/backend/src/ee/routes/v1/ssh-certificate-authority-router.ts +++ b/backend/src/ee/routes/v1/ssh-certificate-authority-router.ts @@ -29,7 +29,9 @@ export const registerSshCaRouter = async (server: FastifyZodProvider) => { }), response: { 200: z.object({ - ca: sanitizedSshCa + ca: sanitizedSshCa.extend({ + publicKey: z.string() + }) }) } }, @@ -74,7 +76,9 @@ export const registerSshCaRouter = async (server: FastifyZodProvider) => { }), response: { 200: z.object({ - ca: sanitizedSshCa + ca: sanitizedSshCa.extend({ + publicKey: z.string() + }) }) } }, @@ -109,7 +113,7 @@ export const registerSshCaRouter = async (server: FastifyZodProvider) => { method: "PATCH", url: "/:sshCaId", config: { - rateLimit: readLimit + rateLimit: writeLimit }, onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), schema: { @@ -118,6 +122,7 @@ export const registerSshCaRouter = async (server: FastifyZodProvider) => { sshCaId: z.string().trim().describe(SSH_CERTIFICATE_AUTHORITIES.UPDATE.sshCaId) }), body: z.object({ + friendlyName: z.string().optional().describe(SSH_CERTIFICATE_AUTHORITIES.UPDATE.friendlyName), status: z .enum([SshCaStatus.ACTIVE, SshCaStatus.DISABLED]) .optional() @@ -125,7 +130,9 @@ export const registerSshCaRouter = async (server: FastifyZodProvider) => { }), response: { 200: z.object({ - ca: sanitizedSshCa + ca: sanitizedSshCa.extend({ + publicKey: z.string() + }) }) } }, diff --git a/backend/src/ee/routes/v1/ssh-certificate-template-router.ts b/backend/src/ee/routes/v1/ssh-certificate-template-router.ts index 90619d3b7..8ab0b57d9 100644 --- a/backend/src/ee/routes/v1/ssh-certificate-template-router.ts +++ b/backend/src/ee/routes/v1/ssh-certificate-template-router.ts @@ -1,3 +1,4 @@ +import slugify from "@sindresorhus/slugify"; import ms from "ms"; import { z } from "zod"; @@ -61,7 +62,14 @@ export const registerSshCertificateTemplateRouter = async (server: FastifyZodPro schema: { body: z.object({ sshCaId: z.string().describe(SSH_CERTIFICATE_TEMPLATES.CREATE.sshCaId), - name: z.string().min(1).describe(SSH_CERTIFICATE_TEMPLATES.CREATE.name), + name: z + .string() + .min(1) + .max(36) + .refine((v) => slugify(v) === v, { + message: "Name must be a valid slug" + }) + .describe(SSH_CERTIFICATE_TEMPLATES.CREATE.name), ttl: z .string() .refine((val) => ms(val) > 0, "TTL must be a positive number") @@ -128,7 +136,15 @@ export const registerSshCertificateTemplateRouter = async (server: FastifyZodPro }, schema: { body: z.object({ - name: z.string().min(1).optional().describe(SSH_CERTIFICATE_TEMPLATES.UPDATE.name), + name: z + .string() + .min(1) + .max(36) + .refine((v) => slugify(v) === v, { + message: "Slug must be a valid slug" + }) + .optional() + .describe(SSH_CERTIFICATE_TEMPLATES.UPDATE.name), ttl: z .string() .refine((val) => ms(val) > 0, "TTL must be a positive number") diff --git a/backend/src/ee/routes/v1/ssh-router.ts b/backend/src/ee/routes/v1/ssh-router.ts index f3661dc04..566cbb423 100644 --- a/backend/src/ee/routes/v1/ssh-router.ts +++ b/backend/src/ee/routes/v1/ssh-router.ts @@ -3,7 +3,7 @@ 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 { SSH_CERTIFICATE_AUTHORITIES } from "@app/lib/api-docs"; import { writeLimit } from "@app/server/config/rateLimiter"; import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; import { AuthMode } from "@app/services/auth/auth-type"; @@ -20,21 +20,27 @@ export const registerSshRouter = async (server: FastifyZodProvider) => { 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"), + templateName: z.string().trim().describe(SSH_CERTIFICATE_AUTHORITIES.SIGN_SSH_KEY.templateName), + publicKey: z.string().trim().describe(SSH_CERTIFICATE_AUTHORITIES.SIGN_SSH_KEY.publicKey), + certType: z + .nativeEnum(SshCertType) + .default(SshCertType.USER) + .describe(SSH_CERTIFICATE_AUTHORITIES.SIGN_SSH_KEY.certType), + principals: z + .array(z.string().transform((val) => val.trim())) + .nonempty("Principals array must not be empty") + .describe(SSH_CERTIFICATE_AUTHORITIES.SIGN_SSH_KEY.principals), ttl: z .string() .refine((val) => ms(val) > 0, "TTL must be a positive number") .optional() - .describe(CERTIFICATE_TEMPLATES.CREATE.ttl), - keyId: z.string().optional() + .describe(SSH_CERTIFICATE_AUTHORITIES.SIGN_SSH_KEY.ttl), + keyId: z.string().trim().optional().describe(SSH_CERTIFICATE_AUTHORITIES.SIGN_SSH_KEY.keyId) }), response: { 200: z.object({ - serialNumber: z.string(), - signedKey: z.string() + serialNumber: z.string().describe(SSH_CERTIFICATE_AUTHORITIES.SIGN_SSH_KEY.serialNumber), + signedKey: z.string().describe(SSH_CERTIFICATE_AUTHORITIES.SIGN_SSH_KEY.signedKey) }) } }, @@ -80,26 +86,35 @@ export const registerSshRouter = async (server: FastifyZodProvider) => { schema: { description: "Issue SSH credentials (certificate + key)", body: z.object({ - name: z.string(), // name of SSH certificate template + templateName: z.string().trim().describe(SSH_CERTIFICATE_AUTHORITIES.ISSUE_SSH_CREDENTIALS.templateName), 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"), + .describe(SSH_CERTIFICATE_AUTHORITIES.ISSUE_SSH_CREDENTIALS.keyAlgorithm), + certType: z + .nativeEnum(SshCertType) + .default(SshCertType.USER) + .describe(SSH_CERTIFICATE_AUTHORITIES.ISSUE_SSH_CREDENTIALS.certType), + principals: z + .array(z.string().transform((val) => val.trim())) + .nonempty("Principals array must not be empty") + .describe(SSH_CERTIFICATE_AUTHORITIES.ISSUE_SSH_CREDENTIALS.principals), ttl: z .string() .refine((val) => ms(val) > 0, "TTL must be a positive number") .optional() - .describe(CERTIFICATE_TEMPLATES.CREATE.ttl), - keyId: z.string().optional() + .describe(SSH_CERTIFICATE_AUTHORITIES.ISSUE_SSH_CREDENTIALS.ttl), + keyId: z.string().trim().optional() }), response: { 200: z.object({ - serialNumber: z.string(), - signedKey: z.string(), - privateKey: z.string(), - keyAlgorithm: z.nativeEnum(CertKeyAlgorithm) + serialNumber: z.string().describe(SSH_CERTIFICATE_AUTHORITIES.ISSUE_SSH_CREDENTIALS.serialNumber), + signedKey: z.string().describe(SSH_CERTIFICATE_AUTHORITIES.ISSUE_SSH_CREDENTIALS.signedKey), + privateKey: z.string().describe(SSH_CERTIFICATE_AUTHORITIES.ISSUE_SSH_CREDENTIALS.privateKey), + publicKey: z.string().describe(SSH_CERTIFICATE_AUTHORITIES.ISSUE_SSH_CREDENTIALS.publicKey), + keyAlgorithm: z + .nativeEnum(CertKeyAlgorithm) + .describe(SSH_CERTIFICATE_AUTHORITIES.ISSUE_SSH_CREDENTIALS.keyAlgorithm) }) } }, diff --git a/backend/src/ee/services/permission/org-permission.ts b/backend/src/ee/services/permission/org-permission.ts index 8f549f750..dffdce7bf 100644 --- a/backend/src/ee/services/permission/org-permission.ts +++ b/backend/src/ee/services/permission/org-permission.ts @@ -7,6 +7,15 @@ export enum OrgPermissionActions { Delete = "delete" } +export enum OrgPermissionSshCertificateTemplateActions { + Read = "read", + Create = "create", + Edit = "edit", + Delete = "delete", + SignSshKey = "sign-ssh-key", + IssueSshCredentials = "issue-ssh-credentials" +} + export enum OrgPermissionAdminConsoleAction { AccessAllProjects = "access-all-projects" } @@ -50,7 +59,7 @@ export type OrgPermissionSet = | [OrgPermissionActions, OrgPermissionSubjects.ProjectTemplates] | [OrgPermissionAdminConsoleAction, OrgPermissionSubjects.AdminConsole] | [OrgPermissionActions, OrgPermissionSubjects.SshCertificateAuthorities] - | [OrgPermissionActions, OrgPermissionSubjects.SshCertificateTemplates]; + | [OrgPermissionSshCertificateTemplateActions, OrgPermissionSubjects.SshCertificateTemplates]; const buildAdminPermission = () => { const { can, rules } = new AbilityBuilder>(createMongoAbility); @@ -132,10 +141,17 @@ const buildAdminPermission = () => { 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( + [ + OrgPermissionSshCertificateTemplateActions.Read, + OrgPermissionSshCertificateTemplateActions.Create, + OrgPermissionSshCertificateTemplateActions.Edit, + OrgPermissionSshCertificateTemplateActions.Delete, + OrgPermissionSshCertificateTemplateActions.SignSshKey, + OrgPermissionSshCertificateTemplateActions.IssueSshCredentials + ], + OrgPermissionSubjects.SshCertificateTemplates + ); can(OrgPermissionAdminConsoleAction.AccessAllProjects, OrgPermissionSubjects.AdminConsole); @@ -168,7 +184,9 @@ const buildMemberPermission = () => { can(OrgPermissionActions.Read, OrgPermissionSubjects.AuditLogs); can(OrgPermissionActions.Read, OrgPermissionSubjects.SshCertificateAuthorities); - can(OrgPermissionActions.Read, OrgPermissionSubjects.SshCertificateTemplates); + can(OrgPermissionSshCertificateTemplateActions.Read, OrgPermissionSubjects.SshCertificateTemplates); + can(OrgPermissionSshCertificateTemplateActions.SignSshKey, OrgPermissionSubjects.SshCertificateTemplates); + can(OrgPermissionSshCertificateTemplateActions.IssueSshCredentials, 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 index 09848f8ca..62b6323ca 100644 --- 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 @@ -34,5 +34,30 @@ export const sshCertificateTemplateDALFactory = (db: TDbClient) => { } }; - return { ...sshCertificateTemplateOrm, getById }; + const getByName = async (name: string, orgId: 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}.name`, "=", name) + .where(`${TableName.Organization}.id`, "=", orgId) + .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 name" }); + } + }; + + return { ...sshCertificateTemplateOrm, getById, getByName }; }; 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 index d730df0ba..357a9a0db 100644 --- 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 @@ -1,7 +1,10 @@ import { ForbiddenError } from "@casl/ability"; import ms from "ms"; -import { OrgPermissionActions, OrgPermissionSubjects } from "@app/ee/services/permission/org-permission"; +import { + OrgPermissionSshCertificateTemplateActions, + OrgPermissionSubjects +} from "@app/ee/services/permission/org-permission"; import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service"; import { BadRequestError, NotFoundError } from "@app/lib/errors"; @@ -58,10 +61,17 @@ export const sshCertificateTemplateServiceFactory = ({ ); ForbiddenError.from(permission).throwUnlessCan( - OrgPermissionActions.Create, + OrgPermissionSshCertificateTemplateActions.Create, OrgPermissionSubjects.SshCertificateTemplates ); + const existingTemplate = await sshCertificateTemplateDAL.getByName(name, ca.orgId); + if (existingTemplate) { + throw new BadRequestError({ + message: `SSH certificate template with name ${name} already exists` + }); + } + if (ms(ttl) > ms(maxTTL)) { throw new BadRequestError({ message: "TTL cannot be greater than max TTL" @@ -114,10 +124,19 @@ export const sshCertificateTemplateServiceFactory = ({ ); ForbiddenError.from(permission).throwUnlessCan( - OrgPermissionActions.Edit, - OrgPermissionSubjects.SshCertificateAuthorities + OrgPermissionSshCertificateTemplateActions.Edit, + OrgPermissionSubjects.SshCertificateTemplates ); + if (name) { + const existingTemplate = await sshCertificateTemplateDAL.getByName(name, actorOrgId); + if (existingTemplate) { + throw new BadRequestError({ + message: `SSH certificate template with name ${name} already exists` + }); + } + } + if (ms(ttl || certTemplate.ttl) > ms(maxTTL || certTemplate.maxTTL)) { throw new BadRequestError({ message: "TTL cannot be greater than max TTL" @@ -164,8 +183,8 @@ export const sshCertificateTemplateServiceFactory = ({ ); ForbiddenError.from(permission).throwUnlessCan( - OrgPermissionActions.Delete, - OrgPermissionSubjects.SshCertificateAuthorities + OrgPermissionSshCertificateTemplateActions.Delete, + OrgPermissionSubjects.SshCertificateTemplates ); await sshCertificateTemplateDAL.deleteById(certificateTemplate.id); @@ -190,8 +209,8 @@ export const sshCertificateTemplateServiceFactory = ({ ); ForbiddenError.from(permission).throwUnlessCan( - OrgPermissionActions.Read, - OrgPermissionSubjects.SshCertificateAuthorities + OrgPermissionSshCertificateTemplateActions.Read, + OrgPermissionSubjects.SshCertificateTemplates ); return certTemplate; diff --git a/backend/src/ee/services/ssh/ssh-certificate-authority-fns.ts b/backend/src/ee/services/ssh/ssh-certificate-authority-fns.ts index a371d2d82..a299b8120 100644 --- a/backend/src/ee/services/ssh/ssh-certificate-authority-fns.ts +++ b/backend/src/ee/services/ssh/ssh-certificate-authority-fns.ts @@ -67,6 +67,32 @@ export const createSshKeyPair = (keyAlgorithm: CertKeyAlgorithm, comment: string return { publicKey, privateKey }; }; +/** + * Return the SSH public key for the given SSH private key. + * @param privateKey - The SSH private key to get the public key for + */ +export const getSshPublicKey = (privateKey: string) => { + const uniqueId = crypto.randomBytes(8).toString("hex"); + const privateKeyFile = `ssh_key_${uniqueId}`; + const publicKeyFile = `${privateKeyFile}.pub`; + + if (fs.existsSync(publicKeyFile)) fs.unlinkSync(publicKeyFile); + if (fs.existsSync(privateKeyFile)) fs.unlinkSync(privateKeyFile); + + fs.writeFileSync(privateKeyFile, privateKey); + fs.chmodSync(privateKeyFile, 0o600); + + const command = `ssh-keygen -y -f ${privateKeyFile} > ${publicKeyFile}`; + execSync(command); + + const publicKey = fs.readFileSync(publicKeyFile, "utf8"); + + fs.unlinkSync(privateKeyFile); + fs.unlinkSync(publicKeyFile); + + return publicKey; +}; + /** * Validate the requested SSH certificate type based on the SSH certificate template configuration. * @param template - The SSH certificate template configuration @@ -160,9 +186,11 @@ export const createSshCert = ({ caPrivateKey, userPublicKey, keyId, principals, const uniqueId = crypto.randomBytes(8).toString("hex"); const publicKeyFile = `user_key_${uniqueId}.pub`; const privateKeyFile = `ssh_ca_key_${uniqueId}`; + const signedPublicKeyFile = `user_key_${uniqueId}-cert.pub`; if (fs.existsSync(publicKeyFile)) fs.unlinkSync(publicKeyFile); if (fs.existsSync(privateKeyFile)) fs.unlinkSync(privateKeyFile); + if (fs.existsSync(signedPublicKeyFile)) fs.unlinkSync(signedPublicKeyFile); // write public and private keys to temp files fs.writeFileSync(publicKeyFile, userPublicKey); @@ -170,7 +198,6 @@ export const createSshCert = ({ caPrivateKey, userPublicKey, keyId, principals, fs.chmodSync(privateKeyFile, 0o600); const serialNumber = createSshCertSerialNumber(); - console.log("signSshKey serialNumber: ", serialNumber); const certOptions = [ `-s ${privateKeyFile}`, // path to SSH CA private key @@ -189,10 +216,11 @@ export const createSshCert = ({ caPrivateKey, userPublicKey, keyId, principals, // Execute the signing process execSync(command); - const signedPublicKey = fs.readFileSync(publicKeyFile, "utf8"); + const signedPublicKey = fs.readFileSync(signedPublicKeyFile, "utf8"); fs.unlinkSync(publicKeyFile); fs.unlinkSync(privateKeyFile); + fs.unlinkSync(signedPublicKeyFile); return { serialNumber, signedPublicKey }; }; diff --git a/backend/src/ee/services/ssh/ssh-certificate-authority-service.ts b/backend/src/ee/services/ssh/ssh-certificate-authority-service.ts index b324dda94..93b927011 100644 --- a/backend/src/ee/services/ssh/ssh-certificate-authority-service.ts +++ b/backend/src/ee/services/ssh/ssh-certificate-authority-service.ts @@ -1,6 +1,10 @@ import { ForbiddenError } from "@casl/ability"; -import { OrgPermissionActions, OrgPermissionSubjects } from "@app/ee/services/permission/org-permission"; +import { + OrgPermissionActions, + OrgPermissionSshCertificateTemplateActions, + 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"; @@ -12,6 +16,7 @@ import { TProjectDALFactory } from "@app/services/project/project-dal"; import { createSshCert, createSshKeyPair, + getSshPublicKey, validateSshCertificatePrincipals, validateSshCertificateTtl, validateSshCertificateType @@ -33,7 +38,7 @@ type TSshCertificateAuthorityServiceFactoryDep = { "transaction" | "create" | "findById" | "updateById" | "deleteById" | "findOne" >; sshCertificateAuthoritySecretDAL: Pick; - sshCertificateTemplateDAL: Pick; + sshCertificateTemplateDAL: Pick; projectDAL: Pick; kmsService: Pick; permissionService: Pick; @@ -76,14 +81,14 @@ export const sshCertificateAuthorityServiceFactory = ({ const ca = await sshCertificateAuthorityDAL.create( { orgId: actorOrgId, - friendlyName: friendlyName || "", + friendlyName, status: SshCaStatus.ACTIVE, keyAlgorithm }, tx ); - const { privateKey } = createSshKeyPair(keyAlgorithm, ca.friendlyName); + const { publicKey, privateKey } = createSshKeyPair(keyAlgorithm, ca.friendlyName); const orgKmsKeyId = await kmsService.getOrgKmsKeyId(actorOrgId); const kmsEncryptor = await kmsService.encryptWithKmsKey({ @@ -102,7 +107,7 @@ export const sshCertificateAuthorityServiceFactory = ({ tx ); - return ca; + return { ...ca, publicKey }; }); return newCa; @@ -118,7 +123,7 @@ export const sshCertificateAuthorityServiceFactory = ({ const { permission } = await permissionService.getOrgPermission( actor, actorId, - actorOrgId, + ca.orgId, actorAuthMethod, actorOrgId ); @@ -128,21 +133,43 @@ export const sshCertificateAuthorityServiceFactory = ({ OrgPermissionSubjects.SshCertificateAuthorities ); - return ca; + const sshCaSecret = await sshCertificateAuthoritySecretDAL.findOne({ sshCaId: ca.id }); + + // decrypt secret + const orgKmsKeyId = await kmsService.getOrgKmsKeyId(actorOrgId); + const kmsDecryptor = await kmsService.decryptWithKmsKey({ + kmsId: orgKmsKeyId + }); + + const decryptedCaPrivateKey = await kmsDecryptor({ + cipherTextBlob: sshCaSecret.encryptedPrivateKey + }); + + const publicKey = getSshPublicKey(decryptedCaPrivateKey.toString("utf-8")); + + return { ...ca, publicKey }; }; /** * Update SSH CA with id [caId] * Note: Used to enable/disable CA */ - const updateSshCaById = async ({ caId, status, actor, actorId, actorAuthMethod, actorOrgId }: TUpdateSshCaDTO) => { + const updateSshCaById = async ({ + caId, + friendlyName, + 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, + ca.orgId, actorAuthMethod, actorOrgId ); @@ -152,9 +179,23 @@ export const sshCertificateAuthorityServiceFactory = ({ OrgPermissionSubjects.SshCertificateAuthorities ); - const updatedCa = await sshCertificateAuthorityDAL.updateById(caId, { status }); + const updatedCa = await sshCertificateAuthorityDAL.updateById(caId, { friendlyName, status }); - return updatedCa; + const sshCaSecret = await sshCertificateAuthoritySecretDAL.findOne({ sshCaId: ca.id }); + + // decrypt secret + const orgKmsKeyId = await kmsService.getOrgKmsKeyId(actorOrgId); + const kmsDecryptor = await kmsService.decryptWithKmsKey({ + kmsId: orgKmsKeyId + }); + + const decryptedCaPrivateKey = await kmsDecryptor({ + cipherTextBlob: sshCaSecret.encryptedPrivateKey + }); + + const publicKey = getSshPublicKey(decryptedCaPrivateKey.toString("utf-8")); + + return { ...updatedCa, publicKey }; }; /** @@ -167,7 +208,7 @@ export const sshCertificateAuthorityServiceFactory = ({ const { permission } = await permissionService.getOrgPermission( actor, actorId, - actorOrgId, + ca.orgId, actorAuthMethod, actorOrgId ); @@ -184,10 +225,10 @@ export const sshCertificateAuthorityServiceFactory = ({ /** * 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]. + * SSH public key is signed using CA behind SSH certificate with name [templateName]. */ const issueSshCreds = async ({ - name, + templateName, keyAlgorithm, certType, principals, @@ -198,7 +239,13 @@ export const sshCertificateAuthorityServiceFactory = ({ actorAuthMethod, actorOrgId }: TIssueSshCredsDTO) => { - // TODO: proper permission check + const sshCertificateTemplate = await sshCertificateTemplateDAL.getByName(templateName, actorOrgId); + if (!sshCertificateTemplate) { + throw new NotFoundError({ + message: "No SSH certificate template found with specified name" + }); + } + const { permission } = await permissionService.getOrgPermission( actor, actorId, @@ -208,13 +255,10 @@ export const sshCertificateAuthorityServiceFactory = ({ ); ForbiddenError.from(permission).throwUnlessCan( - OrgPermissionActions.Create, + OrgPermissionSshCertificateTemplateActions.IssueSshCredentials, 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); @@ -266,10 +310,10 @@ export const sshCertificateAuthorityServiceFactory = ({ /** * Return SSH certificate by signing SSH public key [publicKey] - * using CA behind SSH certificate template with name [name] + * using CA behind SSH certificate template with name [templateName] */ const signSshKey = async ({ - name, + templateName, publicKey, certType, principals, @@ -280,7 +324,13 @@ export const sshCertificateAuthorityServiceFactory = ({ actorAuthMethod, actorOrgId }: TSignSshKeyDTO) => { - // TODO: proper permission check + const sshCertificateTemplate = await sshCertificateTemplateDAL.getByName(templateName, actorOrgId); + if (!sshCertificateTemplate) { + throw new NotFoundError({ + message: "No SSH certificate template found with specified name" + }); + } + const { permission } = await permissionService.getOrgPermission( actor, actorId, @@ -290,13 +340,10 @@ export const sshCertificateAuthorityServiceFactory = ({ ); ForbiddenError.from(permission).throwUnlessCan( - OrgPermissionActions.Create, + OrgPermissionSshCertificateTemplateActions.SignSshKey, 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); @@ -354,7 +401,7 @@ export const sshCertificateAuthorityServiceFactory = ({ ); ForbiddenError.from(permission).throwUnlessCan( - OrgPermissionActions.Read, + OrgPermissionSshCertificateTemplateActions.Read, OrgPermissionSubjects.SshCertificateTemplates ); diff --git a/backend/src/ee/services/ssh/ssh-certificate-authority-types.ts b/backend/src/ee/services/ssh/ssh-certificate-authority-types.ts index d91f292e4..ba4e5aa54 100644 --- a/backend/src/ee/services/ssh/ssh-certificate-authority-types.ts +++ b/backend/src/ee/services/ssh/ssh-certificate-authority-types.ts @@ -12,7 +12,7 @@ export enum SshCertType { } export type TCreateSshCaDTO = { - friendlyName?: string; + friendlyName: string; keyAlgorithm: CertKeyAlgorithm; } & Omit; @@ -22,6 +22,7 @@ export type TGetSshCaDTO = { export type TUpdateSshCaDTO = { caId: string; + friendlyName?: string; status?: SshCaStatus; } & Omit; @@ -30,7 +31,7 @@ export type TDeleteSshCaDTO = { } & Omit; export type TIssueSshCredsDTO = { - name: string; // name of SSH certificate template + templateName: string; keyAlgorithm: CertKeyAlgorithm; certType: SshCertType; principals: string[]; @@ -39,7 +40,7 @@ export type TIssueSshCredsDTO = { } & Omit; export type TSignSshKeyDTO = { - name: string; // name of SSH certificate template + templateName: string; publicKey: string; certType: SshCertType; principals: string[]; diff --git a/backend/src/lib/api-docs/constants.ts b/backend/src/lib/api-docs/constants.ts index 87ffddbe8..f1fce544b 100644 --- a/backend/src/lib/api-docs/constants.ts +++ b/backend/src/lib/api-docs/constants.ts @@ -1148,6 +1148,7 @@ export const SSH_CERTIFICATE_AUTHORITIES = { }, UPDATE: { sshCaId: "The ID of the SSH CA to update.", + friendlyName: "A friendly name for the SSH CA to update to.", status: "The status of the SSH CA to update to. This can be one of active or disabled." }, DELETE: { @@ -1155,6 +1156,28 @@ export const SSH_CERTIFICATE_AUTHORITIES = { }, GET_CERTIFICATE_TEMPLATES: { sshCaId: "The ID of the SSH CA to get the certificate templates for." + }, + SIGN_SSH_KEY: { + templateName: "The name of the SSH certificate template to sign the SSH public key with.", + publicKey: "The SSH public key to sign.", + certType: "The type of certificate to issue. This can be one of user or host.", + principals: "The list of principals (usernames, hostnames) to include in the certificate.", + ttl: "The time to live for the certificate such as 1m, 1h, 1d, ... If not specified, the default TTL for the template will be used.", + keyId: "The key ID to include in the certificate. If not specified, a default key ID will be generated.", + serialNumber: "The serial number of the issued SSH certificate.", + signedKey: "The SSH certificate or signed SSH public key." + }, + ISSUE_SSH_CREDENTIALS: { + templateName: "The name of the SSH certificate template to issue the SSH credentials with.", + keyAlgorithm: "The type of public key algorithm and size, in bits, of the key pair for the SSH CA.", + certType: "The type of certificate to issue. This can be one of user or host.", + principals: "The list of principals (usernames, hostnames) to include in the certificate.", + ttl: "The time to live for the certificate such as 1m, 1h, 1d, ... If not specified, the default TTL for the template will be used.", + keyId: "The key ID to include in the certificate. If not specified, a default key ID will be generated.", + serialNumber: "The serial number of the issued SSH certificate.", + signedKey: "The SSH certificate or signed SSH public key.", + privateKey: "The private key corresponding to the issued SSH certificate.", + publicKey: "The public key of the issued SSH certificate." } }; diff --git a/frontend/src/context/OrgPermissionContext/index.tsx b/frontend/src/context/OrgPermissionContext/index.tsx index 730fe55b1..10d7703b8 100644 --- a/frontend/src/context/OrgPermissionContext/index.tsx +++ b/frontend/src/context/OrgPermissionContext/index.tsx @@ -1,3 +1,6 @@ export { OrgPermissionProvider, useOrgPermission } from "./OrgPermissionContext"; export type { TOrgPermission } from "./types"; -export { OrgPermissionActions, OrgPermissionSubjects } from "./types"; +export { + OrgPermissionActions, + OrgPermissionSshCertificateTemplateActions, + OrgPermissionSubjects} from "./types"; diff --git a/frontend/src/context/OrgPermissionContext/types.ts b/frontend/src/context/OrgPermissionContext/types.ts index fb65358ea..4678de7c4 100644 --- a/frontend/src/context/OrgPermissionContext/types.ts +++ b/frontend/src/context/OrgPermissionContext/types.ts @@ -7,6 +7,15 @@ export enum OrgPermissionActions { Delete = "delete" } +export enum OrgPermissionSshCertificateTemplateActions { + Read = "read", + Create = "create", + Edit = "edit", + Delete = "delete", + SignSshKey = "sign-ssh-key", + IssueSshCredentials = "issue-ssh-credentials" +} + export enum OrgPermissionSubjects { Workspace = "workspace", Role = "role", @@ -51,6 +60,6 @@ export type OrgPermissionSet = | [OrgPermissionActions, OrgPermissionSubjects.AuditLogs] | [OrgPermissionActions, OrgPermissionSubjects.ProjectTemplates] | [OrgPermissionActions, OrgPermissionSubjects.SshCertificateAuthorities] - | [OrgPermissionActions, OrgPermissionSubjects.SshCertificateTemplates]; + | [OrgPermissionSshCertificateTemplateActions, OrgPermissionSubjects.SshCertificateTemplates]; export type TOrgPermission = MongoAbility; diff --git a/frontend/src/context/index.tsx b/frontend/src/context/index.tsx index 91dae5d2d..83e771111 100644 --- a/frontend/src/context/index.tsx +++ b/frontend/src/context/index.tsx @@ -4,6 +4,7 @@ export type { TOrgPermission } from "./OrgPermissionContext"; export { OrgPermissionActions, OrgPermissionProvider, + OrgPermissionSshCertificateTemplateActions, OrgPermissionSubjects, useOrgPermission } from "./OrgPermissionContext"; diff --git a/frontend/src/hooks/api/organization/queries.tsx b/frontend/src/hooks/api/organization/queries.tsx index 3fa515a46..f34c0ead1 100644 --- a/frontend/src/hooks/api/organization/queries.tsx +++ b/frontend/src/hooks/api/organization/queries.tsx @@ -504,7 +504,7 @@ export const useListOrgSshCas = ({ orgId }: { orgId: string }) => { queryFn: async () => { const { data: { cas } - } = await apiRequest.get<{ cas: TSshCertificateAuthority[] }>( + } = await apiRequest.get<{ cas: Omit[] }>( `/api/v1/organization/${orgId}/ssh-cas` ); return cas; diff --git a/frontend/src/hooks/api/ssh-ca/types.ts b/frontend/src/hooks/api/ssh-ca/types.ts index 1ccfe485b..a1539ef31 100644 --- a/frontend/src/hooks/api/ssh-ca/types.ts +++ b/frontend/src/hooks/api/ssh-ca/types.ts @@ -9,6 +9,7 @@ export type TSshCertificateAuthority = { keyAlgorithm: CertKeyAlgorithm; createdAt: string; updatedAt: string; + publicKey: string; }; export type TCreateSshCaDTO = { @@ -18,6 +19,7 @@ export type TCreateSshCaDTO = { export type TUpdateSshCaDTO = { caId: string; + friendlyName?: string; status?: SshCaStatus; }; diff --git a/frontend/src/views/Org/RolePage/components/OrgRoleModifySection.utils.ts b/frontend/src/views/Org/RolePage/components/OrgRoleModifySection.utils.ts index aa8c4d7ec..dad753002 100644 --- a/frontend/src/views/Org/RolePage/components/OrgRoleModifySection.utils.ts +++ b/frontend/src/views/Org/RolePage/components/OrgRoleModifySection.utils.ts @@ -13,6 +13,17 @@ const generalPermissionSchema = z }) .optional(); +const sshCertificateTemplatePermissionSchmea = z + .object({ + read: z.boolean().optional(), + edit: z.boolean().optional(), + delete: z.boolean().optional(), + create: z.boolean().optional(), + "sign-ssh-key": z.boolean().optional(), + "issue-ssh-credentials": z.boolean().optional() + }) + .optional(); + const adminConsolePermissionSchmea = z .object({ "access-all-projects": z.boolean().optional() @@ -49,7 +60,9 @@ export const formSchema = z.object({ identity: generalPermissionSchema, "organization-admin-console": adminConsolePermissionSchmea, [OrgPermissionSubjects.Kms]: generalPermissionSchema, - [OrgPermissionSubjects.ProjectTemplates]: generalPermissionSchema + [OrgPermissionSubjects.ProjectTemplates]: generalPermissionSchema, + [OrgPermissionSubjects.SshCertificateAuthorities]: generalPermissionSchema, + [OrgPermissionSubjects.SshCertificateTemplates]: sshCertificateTemplatePermissionSchmea }) .optional() }); diff --git a/frontend/src/views/Org/RolePage/components/RoleModal.tsx b/frontend/src/views/Org/RolePage/components/RoleModal.tsx index ab909d931..e41e99e56 100644 --- a/frontend/src/views/Org/RolePage/components/RoleModal.tsx +++ b/frontend/src/views/Org/RolePage/components/RoleModal.tsx @@ -70,12 +70,6 @@ export const RoleModal = ({ popUp, handlePopUpToggle }: Props) => { const onFormSubmit = async ({ name, description, slug }: FormData) => { try { - console.log("onFormSubmit args: ", { - name, - description, - slug - }); - if (!orgId) return; if (role) { diff --git a/frontend/src/views/Org/RolePage/components/RolePermissionsSection/RolePermissionRow.tsx b/frontend/src/views/Org/RolePage/components/RolePermissionsSection/RolePermissionRow.tsx index bfb5678ea..400a7b6bb 100644 --- a/frontend/src/views/Org/RolePage/components/RolePermissionsSection/RolePermissionRow.tsx +++ b/frontend/src/views/Org/RolePage/components/RolePermissionsSection/RolePermissionRow.tsx @@ -51,6 +51,15 @@ const PROJECT_TEMPLATES_PERMISSIONS = [ { action: "delete", label: "Remove" } ] as const; +const SSH_CERTIFICATE_TEMPLATES_PERMISSIONS = [ + { action: "read", label: "Read" }, + { action: "create", label: "Create" }, + { action: "edit", label: "Modify" }, + { action: "delete", label: "Remove" }, + { action: "sign-ssh-key", label: "Sign SSH Key" }, + { action: "issue-ssh-credentials", label: "Issue SSH Credentials" } +] as const; + const getPermissionList = (option: string) => { switch (option) { case "secret-scanning": @@ -63,6 +72,8 @@ const getPermissionList = (option: string) => { return MEMBERS_PERMISSIONS; case OrgPermissionSubjects.ProjectTemplates: return PROJECT_TEMPLATES_PERMISSIONS; + case OrgPermissionSubjects.SshCertificateTemplates: + return SSH_CERTIFICATE_TEMPLATES_PERMISSIONS; default: return PERMISSIONS; } @@ -97,7 +108,7 @@ export const RolePermissionRow = ({ isEditable, title, formName, control, setVal const selectedPermissionCategory = useMemo(() => { const actions = Object.keys(rule || {}) as Array; - const totalActions = PERMISSIONS.length; + const totalActions = getPermissionList(formName).length; const score = actions.map((key) => (rule?.[key] ? 1 : 0)).reduce((a, b) => a + b, 0 as number); if (isCustom) return Permission.Custom; diff --git a/frontend/src/views/Org/RolePage/components/RolePermissionsSection/RolePermissionsSection.tsx b/frontend/src/views/Org/RolePage/components/RolePermissionsSection/RolePermissionsSection.tsx index 976b35636..ddf2ec097 100644 --- a/frontend/src/views/Org/RolePage/components/RolePermissionsSection/RolePermissionsSection.tsx +++ b/frontend/src/views/Org/RolePage/components/RolePermissionsSection/RolePermissionsSection.tsx @@ -69,7 +69,15 @@ const SIMPLE_PERMISSION_OPTIONS = [ title: "External KMS", formName: OrgPermissionSubjects.Kms }, - { title: "Project Templates", formName: OrgPermissionSubjects.ProjectTemplates } + { title: "Project Templates", formName: OrgPermissionSubjects.ProjectTemplates }, + { + title: "SSH Certificate Authorities", + formName: OrgPermissionSubjects.SshCertificateAuthorities + }, + { + title: "SSH Certificate Templates", + formName: OrgPermissionSubjects.SshCertificateTemplates + } ] as const; type Props = { diff --git a/frontend/src/views/Org/SshCaPage/SshCaPage.tsx b/frontend/src/views/Org/SshCaPage/SshCaPage.tsx index b81478e4f..e0619d5a4 100644 --- a/frontend/src/views/Org/SshCaPage/SshCaPage.tsx +++ b/frontend/src/views/Org/SshCaPage/SshCaPage.tsx @@ -26,6 +26,7 @@ import { withPermission } from "@app/hoc"; import { useDeleteSshCa, useGetSshCaById } from "@app/hooks/api"; import { usePopUp } from "@app/hooks/usePopUp"; +import { SshCaModal } from "../SshPage/components/SshCaModal"; import { SshCaDetailsSection, SshCertificateTemplatesSection } from "./components"; export const SshCaPage = withPermission( @@ -123,6 +124,7 @@ export const SshCaPage = withPermission(
)} + { const [copyTextId, isCopyingId, setCopyTextId] = useTimedReset({ initialState: "Copy ID to clipboard" }); + const [downloadText, isDownloading, setDownloadText] = useTimedReset({ + initialState: "Save public key" + }); const { data: ca } = useGetSshCaById(caId); + const downloadTxtFile = (filename: string, content: string) => { + const blob = new Blob([content], { type: "text/plain;charset=utf-8" }); + FileSaver.saveAs(blob, filename); + }; + return ca ? (
-

CA Details

+

SSH CA Details

{

Status

{caStatusToNameMap[ca.status]}

-
+

Key Algorithm

{certKeyAlgorithmToNameMap[ca.keyAlgorithm]}

+
+

Public Key

+
+

{ca.publicKey.substring(0, 20)}...

+
+ + { + setDownloadText("Saved"); + downloadTxtFile("ssh_ca.pub", ca.publicKey); + }} + > + + + +
+
+
) : ( diff --git a/frontend/src/views/Org/SshCaPage/components/SshCertificateTemplateModal.tsx b/frontend/src/views/Org/SshCaPage/components/SshCertificateTemplateModal.tsx index acfb523fe..779ee65c2 100644 --- a/frontend/src/views/Org/SshCaPage/components/SshCertificateTemplateModal.tsx +++ b/frontend/src/views/Org/SshCaPage/components/SshCertificateTemplateModal.tsx @@ -185,7 +185,7 @@ export const SshCertificateTemplateModal = ({ popUp, handlePopUpToggle, sshCaId errorText={error?.message} isRequired > - + )} /> diff --git a/frontend/src/views/Org/SshCaPage/components/SshCertificateTemplatesSection.tsx b/frontend/src/views/Org/SshCaPage/components/SshCertificateTemplatesSection.tsx index a518e96f4..ecf19d22f 100644 --- a/frontend/src/views/Org/SshCaPage/components/SshCertificateTemplatesSection.tsx +++ b/frontend/src/views/Org/SshCaPage/components/SshCertificateTemplatesSection.tsx @@ -4,7 +4,7 @@ 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 { OrgPermissionSshCertificateTemplateActions,OrgPermissionSubjects } from "@app/context"; import { usePopUp } from "@app/hooks"; import { useDeleteSshCertTemplate } from "@app/hooks/api"; @@ -50,7 +50,7 @@ export const SshCertificateTemplatesSection = ({ caId }: Props) => {

Certificate Templates

{(isAllowed) => ( diff --git a/frontend/src/views/Org/SshCaPage/components/SshCertificateTemplatesTable.tsx b/frontend/src/views/Org/SshCaPage/components/SshCertificateTemplatesTable.tsx index 2365990fa..a761772b3 100644 --- a/frontend/src/views/Org/SshCaPage/components/SshCertificateTemplatesTable.tsx +++ b/frontend/src/views/Org/SshCaPage/components/SshCertificateTemplatesTable.tsx @@ -19,7 +19,7 @@ import { Tooltip, Tr } from "@app/components/v2"; -import { OrgPermissionActions, OrgPermissionSubjects } from "@app/context"; +import { OrgPermissionSshCertificateTemplateActions,OrgPermissionSubjects } from "@app/context"; import { useGetSshCaCertTemplates } from "@app/hooks/api"; import { UsePopUpState } from "@app/hooks/usePopUp"; @@ -66,18 +66,23 @@ export const SshCertificateTemplatesTable = ({ handlePopUpOpen, sshCaId }: Props
- - handlePopUpOpen("sshCertificateTemplate", { - id: certificateTemplate.id - }) - } - icon={} - > - Edit Template - + + handlePopUpOpen("sshCertificateTemplate", { + id: certificateTemplate.id + }) + } + icon={} + > + Edit Template + + + {(isAllowed) => ( diff --git a/frontend/src/views/Org/SshPage/components/SshCaModal.tsx b/frontend/src/views/Org/SshPage/components/SshCaModal.tsx index ec6c17239..d57633801 100644 --- a/frontend/src/views/Org/SshPage/components/SshCaModal.tsx +++ b/frontend/src/views/Org/SshPage/components/SshCaModal.tsx @@ -1,3 +1,4 @@ +import { useEffect } from "react"; import { Controller, useForm } from "react-hook-form"; import { zodResolver } from "@hookform/resolvers/zod"; import { z } from "zod"; @@ -55,15 +56,28 @@ export const SshCaModal = ({ popUp, handlePopUpToggle }: Props) => { } }); + useEffect(() => { + if (ca) { + reset({ + friendlyName: ca.friendlyName, + keyAlgorithm: ca.keyAlgorithm + }); + } else { + reset({ + friendlyName: "", + keyAlgorithm: CertKeyAlgorithm.RSA_2048 + }); + } + }, [ca]); + const onFormSubmit = async ({ friendlyName, keyAlgorithm }: FormData) => { try { if (ca) { - // update await updateMutateAsync({ - caId: ca.id + caId: ca.id, + friendlyName }); } else { - // create await createMutateAsync({ friendlyName, keyAlgorithm @@ -112,7 +126,7 @@ export const SshCaModal = ({ popUp, handlePopUpToggle }: Props) => { errorText={error?.message} isRequired > - + )} /> From 3b2173a0988975ddc8202a6629ecda32bcc37830 Mon Sep 17 00:00:00 2001 From: Tuan Dang Date: Tue, 3 Dec 2024 18:36:32 -0800 Subject: [PATCH 004/162] Add issue SSH certificate modal --- frontend/src/hooks/api/ssh-ca/enums.tsx | 5 + frontend/src/hooks/api/ssh-ca/index.tsx | 2 +- frontend/src/hooks/api/ssh-ca/mutations.tsx | 12 +- frontend/src/hooks/api/ssh-ca/types.ts | 19 +- .../src/views/Org/SshCaPage/SshCaPage.tsx | 10 +- .../components/SshCertificateContent.tsx | 174 +++++++++++ .../components/SshCertificateModal.tsx | 274 ++++++++++++++++++ .../SshCertificateTemplatesSection.tsx | 5 +- .../SshCertificateTemplatesTable.tsx | 26 +- .../views/Org/SshCaPage/components/index.tsx | 1 + 10 files changed, 513 insertions(+), 15 deletions(-) create mode 100644 frontend/src/views/Org/SshCaPage/components/SshCertificateContent.tsx create mode 100644 frontend/src/views/Org/SshCaPage/components/SshCertificateModal.tsx diff --git a/frontend/src/hooks/api/ssh-ca/enums.tsx b/frontend/src/hooks/api/ssh-ca/enums.tsx index 859890bbf..3b5e949a9 100644 --- a/frontend/src/hooks/api/ssh-ca/enums.tsx +++ b/frontend/src/hooks/api/ssh-ca/enums.tsx @@ -2,3 +2,8 @@ export enum SshCaStatus { ACTIVE = "active", DISABLED = "disabled" } + +export enum SshCertType { + USER = "user", + HOST = "host" +} diff --git a/frontend/src/hooks/api/ssh-ca/index.tsx b/frontend/src/hooks/api/ssh-ca/index.tsx index 58ed4cd73..70727225d 100644 --- a/frontend/src/hooks/api/ssh-ca/index.tsx +++ b/frontend/src/hooks/api/ssh-ca/index.tsx @@ -1,3 +1,3 @@ export { SshCaStatus } from "./enums"; -export { useCreateSshCa, useDeleteSshCa,useUpdateSshCa } from "./mutations"; +export { useCreateSshCa, useDeleteSshCa, useIssueSshCreds,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 index 4d3dcc736..9b2fa65e3 100644 --- a/frontend/src/hooks/api/ssh-ca/mutations.tsx +++ b/frontend/src/hooks/api/ssh-ca/mutations.tsx @@ -1,4 +1,3 @@ - import { useMutation, useQueryClient } from "@tanstack/react-query"; import { apiRequest } from "@app/config/request"; @@ -7,6 +6,8 @@ import { organizationKeys } from "../organization/queries"; import { TCreateSshCaDTO, TDeleteSshCaDTO, + TIssueSshCredsDTO, + TIssueSshCredsResponse, TSshCertificateAuthority, TUpdateSshCaDTO} from "./types"; @@ -59,3 +60,12 @@ export const useDeleteSshCa = () => { } }); }; + +export const useIssueSshCreds = () => { + return useMutation({ + mutationFn: async (body) => { + const { data } = await apiRequest.post("/api/v1/ssh/issue", body); + return data; + } + }); +}; diff --git a/frontend/src/hooks/api/ssh-ca/types.ts b/frontend/src/hooks/api/ssh-ca/types.ts index a1539ef31..f263bb489 100644 --- a/frontend/src/hooks/api/ssh-ca/types.ts +++ b/frontend/src/hooks/api/ssh-ca/types.ts @@ -1,5 +1,5 @@ import { CertKeyAlgorithm } from "../certificates/enums"; -import { SshCaStatus } from "./enums"; +import { SshCaStatus, SshCertType } from "./enums"; export type TSshCertificateAuthority = { id: string; @@ -26,3 +26,20 @@ export type TUpdateSshCaDTO = { export type TDeleteSshCaDTO = { caId: string; }; + +export type TIssueSshCredsDTO = { + templateName: string; + keyAlgorithm: CertKeyAlgorithm; + certType: SshCertType; + principals: string[]; + ttl?: string; + keyId?: string; +}; + +export type TIssueSshCredsResponse = { + serialNumber: string; + signedKey: string; + privateKey: string; + publicKey: string; + keyAlgorithm: CertKeyAlgorithm; +}; diff --git a/frontend/src/views/Org/SshCaPage/SshCaPage.tsx b/frontend/src/views/Org/SshCaPage/SshCaPage.tsx index e0619d5a4..34a869faa 100644 --- a/frontend/src/views/Org/SshCaPage/SshCaPage.tsx +++ b/frontend/src/views/Org/SshCaPage/SshCaPage.tsx @@ -5,7 +5,7 @@ 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 { OrgPermissionCan } from "@app/components/permissions"; import { Button, DeleteActionModal, @@ -15,13 +15,7 @@ import { DropdownMenuTrigger, Tooltip } from "@app/components/v2"; -import { - OrgPermissionActions, - OrgPermissionSubjects, - ProjectPermissionActions, - ProjectPermissionSub, - useOrganization -} from "@app/context"; +import { OrgPermissionActions, OrgPermissionSubjects, useOrganization } from "@app/context"; import { withPermission } from "@app/hoc"; import { useDeleteSshCa, useGetSshCaById } from "@app/hooks/api"; import { usePopUp } from "@app/hooks/usePopUp"; diff --git a/frontend/src/views/Org/SshCaPage/components/SshCertificateContent.tsx b/frontend/src/views/Org/SshCaPage/components/SshCertificateContent.tsx new file mode 100644 index 000000000..a71a2601d --- /dev/null +++ b/frontend/src/views/Org/SshCaPage/components/SshCertificateContent.tsx @@ -0,0 +1,174 @@ +import { faCheck, faCopy, faDownload } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import FileSaver from "file-saver"; + +import { IconButton, Tooltip } from "@app/components/v2"; +import { useTimedReset } from "@app/hooks"; + +type Props = { + serialNumber: string; + signedKey: string; + privateKey: string; + publicKey: string; +}; + +export const SshCertificateContent = ({ + serialNumber, + signedKey, + privateKey, + publicKey +}: Props) => { + const [copyTextSerialNumber, isCopyingSerialNumber, setCopyTextSerialNumber] = + useTimedReset({ + initialState: "Copy to clipboard" + }); + const [copyTextCertificate, isCopyingCertificate, setCopyTextCertificate] = useTimedReset( + { + initialState: "Copy to clipboard" + } + ); + + const [copyTextCertificateSk, isCopyingCertificateSk, setCopyTextCertificateSk] = + useTimedReset({ + initialState: "Copy to clipboard" + }); + + const [copyTextCertificatePk, isCopyingCertificatePk, setCopyTextCertificatePk] = + useTimedReset({ + initialState: "Copy to clipboard" + }); + + const downloadTxtFile = (filename: string, content: string) => { + const blob = new Blob([content], { type: "text/plain;charset=utf-8" }); + FileSaver.saveAs(blob, filename); + }; + + return ( +
+

Serial Number

+
+

{serialNumber}

+ + { + navigator.clipboard.writeText(serialNumber); + setCopyTextSerialNumber("Copied"); + }} + > + + + +
+
+

SSH Certificate / Signed Key

+
+ + { + navigator.clipboard.writeText(signedKey); + setCopyTextCertificate("Copied"); + }} + > + + + + + { + downloadTxtFile("user_key-cert.pub", signedKey); + }} + > + + + +
+
+
+

{signedKey}

+
+ {privateKey && ( + <> +
+

Private Key

+
+ + { + navigator.clipboard.writeText(privateKey); + setCopyTextCertificateSk("Copied"); + }} + > + + + + + { + downloadTxtFile("user_key", privateKey); + }} + > + + + +
+
+
+

{privateKey}

+
+ + )} + {publicKey && ( + <> +
+

Public Key

+
+ + { + navigator.clipboard.writeText(publicKey); + setCopyTextCertificatePk("Copied"); + }} + > + + + + + { + downloadTxtFile("user_key.pub", publicKey); + }} + > + + + +
+
+
+

{publicKey}

+
+ + )} +
+ ); +}; diff --git a/frontend/src/views/Org/SshCaPage/components/SshCertificateModal.tsx b/frontend/src/views/Org/SshCaPage/components/SshCertificateModal.tsx new file mode 100644 index 000000000..c5ac70e1d --- /dev/null +++ b/frontend/src/views/Org/SshCaPage/components/SshCertificateModal.tsx @@ -0,0 +1,274 @@ +import { useEffect, useState } 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 +} from "@app/components/v2"; +import { useGetSshCaCertTemplates,useIssueSshCreds } from "@app/hooks/api"; +import { certKeyAlgorithms } from "@app/hooks/api/certificates/constants"; +import { CertKeyAlgorithm } from "@app/hooks/api/certificates/enums"; +import { SshCertType } from "@app/hooks/api/ssh-ca/enums"; +import { UsePopUpState } from "@app/hooks/usePopUp"; + +import { SshCertificateContent } from "./SshCertificateContent"; + +/** + * // NOTE (dangtony98): current UI only supports SSH certificate + * issuance via /issue endpoint but should extend to also support + * /sign endpoint as this is already supported in the backend + */ + +const schema = z.object({ + templateName: z.string(), + keyAlgorithm: z.enum([ + CertKeyAlgorithm.RSA_2048, + CertKeyAlgorithm.RSA_4096, + CertKeyAlgorithm.ECDSA_P256, + CertKeyAlgorithm.ECDSA_P384 + ]), + certType: z.nativeEnum(SshCertType), + principals: z.string(), + ttl: z.string().optional(), + keyId: z.string().optional() +}); + +export type FormData = z.infer; + +type Props = { + popUp: UsePopUpState<["sshCertificate"]>; + handlePopUpToggle: (popUpName: keyof UsePopUpState<["sshCertificate"]>, state?: boolean) => void; +}; + +type TSshCertificateDetails = { + serialNumber: string; + privateKey: string; + publicKey: string; + signedKey: string; +}; + +export const SshCertificateModal = ({ popUp, handlePopUpToggle }: Props) => { + const [certificateDetails, setCertificateDetails] = useState(null); + const { mutateAsync: issueSshCreds } = useIssueSshCreds(); + + const popUpData = popUp?.sshCertificate?.data as { sshCaId: string; templateName: string }; + + const { data: templatesData } = useGetSshCaCertTemplates(popUpData?.sshCaId || ""); + + const { + control, + handleSubmit, + reset, + formState: { isSubmitting }, + setValue + } = useForm({ + resolver: zodResolver(schema), + defaultValues: { + keyAlgorithm: CertKeyAlgorithm.RSA_2048, + certType: SshCertType.USER + } + }); + + useEffect(() => { + if (popUpData) { + setValue("templateName", popUpData.templateName); + } + }, [popUpData]); + + const onFormSubmit = async ({ + templateName, + keyAlgorithm, + certType, + principals, + ttl, + keyId + }: FormData) => { + try { + const { serialNumber, publicKey, privateKey, signedKey } = await issueSshCreds({ + templateName, + keyAlgorithm, + certType, + principals: principals.split(",").map((user) => user.trim()), + ttl, + keyId + }); + + reset(); + + setCertificateDetails({ + serialNumber, + privateKey, + publicKey, + signedKey + }); + + createNotification({ + text: "Successfully created SSH certificate", + type: "success" + }); + } catch (err) { + console.error(err); + createNotification({ + text: "Failed to create SSH certificate", + type: "error" + }); + } + }; + + return ( + { + handlePopUpToggle("sshCertificate", isOpen); + reset(); + setCertificateDetails(null); + }} + > + + {!certificateDetails ? ( +
+ ( + + + + )} + /> + ( + + + + )} + /> + ( + + + + )} + /> + ( + + + + )} + /> + ( + + + + )} + /> + ( + + + + )} + /> +
+ + +
+ + ) : ( + + )} +
+
+ ); +}; diff --git a/frontend/src/views/Org/SshCaPage/components/SshCertificateTemplatesSection.tsx b/frontend/src/views/Org/SshCaPage/components/SshCertificateTemplatesSection.tsx index ecf19d22f..75db07ff6 100644 --- a/frontend/src/views/Org/SshCaPage/components/SshCertificateTemplatesSection.tsx +++ b/frontend/src/views/Org/SshCaPage/components/SshCertificateTemplatesSection.tsx @@ -4,10 +4,11 @@ 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 { OrgPermissionSshCertificateTemplateActions,OrgPermissionSubjects } from "@app/context"; +import { OrgPermissionSshCertificateTemplateActions, OrgPermissionSubjects } from "@app/context"; import { usePopUp } from "@app/hooks"; import { useDeleteSshCertTemplate } from "@app/hooks/api"; +import { SshCertificateModal } from "./SshCertificateModal"; import { SshCertificateTemplateModal } from "./SshCertificateTemplateModal"; import { SshCertificateTemplatesTable } from "./SshCertificateTemplatesTable"; @@ -18,6 +19,7 @@ type Props = { export const SshCertificateTemplatesSection = ({ caId }: Props) => { const { popUp, handlePopUpOpen, handlePopUpClose, handlePopUpToggle } = usePopUp([ "sshCertificateTemplate", + "sshCertificate", "deleteSshCertificateTemplate", "upgradePlan" ] as const); @@ -69,6 +71,7 @@ export const SshCertificateTemplatesSection = ({ caId }: Props) => {
+ , data?: { id?: string; name?: string; + sshCaId?: string; + templateName?: string; } ) => void; }; @@ -66,6 +68,24 @@ export const SshCertificateTemplatesTable = ({ handlePopUpOpen, sshCaId }: Props + + { + handlePopUpOpen("sshCertificate", { + sshCaId, + templateName: certificateTemplate.name + }); + }} + icon={ + + } + > + Issue SSH Certificate + + Date: Tue, 3 Dec 2024 22:38:29 -0800 Subject: [PATCH 005/162] Fix type issues --- .../ssh/ssh-certificate-authority-service.ts | 2 - backend/src/server/routes/index.ts | 1 - .../components/OrgRoleModifySection.utils.ts | 2 +- .../RolePermissionRow.tsx | 11 -- .../RolePermissionsSection.tsx | 10 +- .../SshCertificateTemplateRow.tsx | 137 ++++++++++++++++++ 6 files changed, 144 insertions(+), 19 deletions(-) create mode 100644 frontend/src/views/Org/RolePage/components/RolePermissionsSection/SshCertificateTemplateRow.tsx diff --git a/backend/src/ee/services/ssh/ssh-certificate-authority-service.ts b/backend/src/ee/services/ssh/ssh-certificate-authority-service.ts index 93b927011..27fd3b554 100644 --- a/backend/src/ee/services/ssh/ssh-certificate-authority-service.ts +++ b/backend/src/ee/services/ssh/ssh-certificate-authority-service.ts @@ -11,7 +11,6 @@ import { TSshCertificateAuthoritySecretDALFactory } from "@app/ee/services/ssh/s 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, @@ -39,7 +38,6 @@ type TSshCertificateAuthorityServiceFactoryDep = { >; sshCertificateAuthoritySecretDAL: Pick; sshCertificateTemplateDAL: Pick; - projectDAL: Pick; kmsService: Pick; permissionService: Pick; }; diff --git a/backend/src/server/routes/index.ts b/backend/src/server/routes/index.ts index 8659c31b7..187753d54 100644 --- a/backend/src/server/routes/index.ts +++ b/backend/src/server/routes/index.ts @@ -808,7 +808,6 @@ export const registerRoutes = async ( projectRoleDAL, folderDAL, licenseService, - sshCertificateAuthorityDAL, certificateAuthorityDAL, certificateDAL, pkiAlertDAL, diff --git a/frontend/src/views/Org/RolePage/components/OrgRoleModifySection.utils.ts b/frontend/src/views/Org/RolePage/components/OrgRoleModifySection.utils.ts index dad753002..14fb5e585 100644 --- a/frontend/src/views/Org/RolePage/components/OrgRoleModifySection.utils.ts +++ b/frontend/src/views/Org/RolePage/components/OrgRoleModifySection.utils.ts @@ -62,7 +62,7 @@ export const formSchema = z.object({ [OrgPermissionSubjects.Kms]: generalPermissionSchema, [OrgPermissionSubjects.ProjectTemplates]: generalPermissionSchema, [OrgPermissionSubjects.SshCertificateAuthorities]: generalPermissionSchema, - [OrgPermissionSubjects.SshCertificateTemplates]: sshCertificateTemplatePermissionSchmea + "ssh-certificate-templates": sshCertificateTemplatePermissionSchmea }) .optional() }); diff --git a/frontend/src/views/Org/RolePage/components/RolePermissionsSection/RolePermissionRow.tsx b/frontend/src/views/Org/RolePage/components/RolePermissionsSection/RolePermissionRow.tsx index 400a7b6bb..3a50dc976 100644 --- a/frontend/src/views/Org/RolePage/components/RolePermissionsSection/RolePermissionRow.tsx +++ b/frontend/src/views/Org/RolePage/components/RolePermissionsSection/RolePermissionRow.tsx @@ -51,15 +51,6 @@ const PROJECT_TEMPLATES_PERMISSIONS = [ { action: "delete", label: "Remove" } ] as const; -const SSH_CERTIFICATE_TEMPLATES_PERMISSIONS = [ - { action: "read", label: "Read" }, - { action: "create", label: "Create" }, - { action: "edit", label: "Modify" }, - { action: "delete", label: "Remove" }, - { action: "sign-ssh-key", label: "Sign SSH Key" }, - { action: "issue-ssh-credentials", label: "Issue SSH Credentials" } -] as const; - const getPermissionList = (option: string) => { switch (option) { case "secret-scanning": @@ -72,8 +63,6 @@ const getPermissionList = (option: string) => { return MEMBERS_PERMISSIONS; case OrgPermissionSubjects.ProjectTemplates: return PROJECT_TEMPLATES_PERMISSIONS; - case OrgPermissionSubjects.SshCertificateTemplates: - return SSH_CERTIFICATE_TEMPLATES_PERMISSIONS; default: return PERMISSIONS; } diff --git a/frontend/src/views/Org/RolePage/components/RolePermissionsSection/RolePermissionsSection.tsx b/frontend/src/views/Org/RolePage/components/RolePermissionsSection/RolePermissionsSection.tsx index ddf2ec097..46310566c 100644 --- a/frontend/src/views/Org/RolePage/components/RolePermissionsSection/RolePermissionsSection.tsx +++ b/frontend/src/views/Org/RolePage/components/RolePermissionsSection/RolePermissionsSection.tsx @@ -15,6 +15,7 @@ import { import { OrgPermissionAdminConsoleRow } from "./OrgPermissionAdminConsoleRow"; import { OrgRoleWorkspaceRow } from "./OrgRoleWorkspaceRow"; import { RolePermissionRow } from "./RolePermissionRow"; +import { SshCertificateTemplateRow } from "./SshCertificateTemplateRow"; const SIMPLE_PERMISSION_OPTIONS = [ { @@ -73,10 +74,6 @@ const SIMPLE_PERMISSION_OPTIONS = [ { title: "SSH Certificate Authorities", formName: OrgPermissionSubjects.SshCertificateAuthorities - }, - { - title: "SSH Certificate Templates", - formName: OrgPermissionSubjects.SshCertificateTemplates } ] as const; @@ -172,6 +169,11 @@ export const RolePermissionsSection = ({ roleId }: Props) => { /> ); })} + ; + control: Control; +}; + +enum Permission { + NoAccess = "no-access", + Custom = "custom" +} + +const PERMISSION_ACTIONS = [ + { action: "read", label: "Read" }, + { action: "create", label: "Create" }, + { action: "edit", label: "Modify" }, + { action: "delete", label: "Remove" }, + { action: "sign-ssh-key", label: "Sign SSH Key" }, + { action: "issue-ssh-credentials", label: "Issue SSH Credentials" } +] as const; + +export const SshCertificateTemplateRow = ({ isEditable, control, setValue }: Props) => { + const [isRowExpanded, setIsRowExpanded] = useToggle(); + const [isCustom, setIsCustom] = useToggle(); + + const rule = useWatch({ + control, + name: "permissions.ssh-certificate-templates" + }); + + const selectedPermissionCategory = useMemo(() => { + if (rule?.create) { + return Permission.Custom; + } + return Permission.NoAccess; + }, [rule, isCustom]); + + useEffect(() => { + if (selectedPermissionCategory === Permission.Custom) setIsCustom.on(); + else setIsCustom.off(); + }, [selectedPermissionCategory]); + + useEffect(() => { + const isRowCustom = selectedPermissionCategory === Permission.Custom; + if (isRowCustom) { + setIsRowExpanded.on(); + } + }, []); + + const handlePermissionChange = (val: Permission) => { + if (!val) return; + if (val === Permission.Custom) { + setIsRowExpanded.on(); + setIsCustom.on(); + return; + } + setIsCustom.off(); + + if (val === Permission.NoAccess) { + setValue("permissions.workspace", { create: false }, { shouldDirty: true }); + } + }; + + return ( + <> + setIsRowExpanded.toggle()} + > + + + + SSH Certificate Templates + + + + + {isRowExpanded && ( + + +
+ {PERMISSION_ACTIONS.map(({ action, label }) => { + return ( + ( + { + if (!isEditable) { + createNotification({ + type: "error", + text: "Failed to update default role" + }); + return; + } + field.onChange(e); + }} + id={`permissions.${OrgPermissionSubjects.SshCertificateTemplates}.${action}`} + > + {label} + + )} + /> + ); + })} +
+ + + )} + + ); +}; From 8edfa9ad0b24169c32a560c22f7ee2f78d8bc3bc Mon Sep 17 00:00:00 2001 From: Tuan Dang Date: Tue, 3 Dec 2024 23:22:04 -0800 Subject: [PATCH 006/162] Improve requested user/host validation for ssh certificate template --- .../ssh-certificate-template-service.ts | 2 +- .../ssh/ssh-certificate-authority-fns.ts | 70 ++++++++++++++----- 2 files changed, 54 insertions(+), 18 deletions(-) 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 index 357a9a0db..88e3259b2 100644 --- 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 @@ -130,7 +130,7 @@ export const sshCertificateTemplateServiceFactory = ({ if (name) { const existingTemplate = await sshCertificateTemplateDAL.getByName(name, actorOrgId); - if (existingTemplate) { + if (existingTemplate && existingTemplate.id !== id) { throw new BadRequestError({ message: `SSH certificate template with name ${name} already exists` }); diff --git a/backend/src/ee/services/ssh/ssh-certificate-authority-fns.ts b/backend/src/ee/services/ssh/ssh-certificate-authority-fns.ts index a299b8120..7491141e4 100644 --- a/backend/src/ee/services/ssh/ssh-certificate-authority-fns.ts +++ b/backend/src/ee/services/ssh/ssh-certificate-authority-fns.ts @@ -122,34 +122,70 @@ export const validateSshCertificatePrincipals = ( ) => { 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); + if (template.allowedUsers.length === 0) { + throw new BadRequestError({ + message: "No allowed users are configured in the SSH certificate template." + }); + } + + const allowsAllUsers = template.allowedUsers.includes("*") ?? false; + + principals.forEach((principal) => { + if (principal === "*") { + throw new BadRequestError({ + message: `Principal '*' is not allowed for user certificates.` + }); + } + if (allowsAllUsers && !isValidUserPattern(principal)) { + throw new BadRequestError({ + message: `Principal '${principal}' does not match a valid user pattern.` + }); + } + if (!allowsAllUsers && !template.allowedUsers.includes(principal)) { + throw new BadRequestError({ + message: `Principal '${principal}' is not in the list of allowed users.` + }); + } }); + break; } case SshCertType.HOST: { - const allowsAllHosts = template.allowedHosts?.includes("*") ?? false; - return principals.every((principal) => { - if (principal.includes("*")) return false; - if (allowsAllHosts) return isValidHostPattern(principal); + if (template.allowedHosts.length === 0) { + throw new BadRequestError({ + message: "No allowed hosts are configured in the SSH certificate template." + }); + } - // Validate against allowed domains - return ( - isValidHostPattern(principal) && - template.allowedHosts?.some((allowedHost) => { + const allowsAllHosts = template.allowedHosts.includes("*") ?? false; + + principals.forEach((principal) => { + if (principal.includes("*")) { + throw new BadRequestError({ + message: `Principal '${principal}' with wildcards is not allowed for host certificates.` + }); + } + if (allowsAllHosts && !isValidHostPattern(principal)) { + throw new BadRequestError({ + message: `Principal '${principal}' does not match a valid host pattern.` + }); + } + + if ( + !allowsAllHosts && + !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; }) - ); + ) { + throw new BadRequestError({ + message: `Principal '${principal}' is not in the list of allowed hosts or domains.` + }); + } }); + break; } default: throw new BadRequestError({ From 20a9fc113cc529b54bb12cf36e4582066c3f0c8e Mon Sep 17 00:00:00 2001 From: Tuan Dang Date: Tue, 3 Dec 2024 23:23:39 -0800 Subject: [PATCH 007/162] Update ttl field label on ssh template modal --- .../SshCaPage/components/SshCertificateTemplateModal.tsx | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/frontend/src/views/Org/SshCaPage/components/SshCertificateTemplateModal.tsx b/frontend/src/views/Org/SshCaPage/components/SshCertificateTemplateModal.tsx index 779ee65c2..1090b4b43 100644 --- a/frontend/src/views/Org/SshCaPage/components/SshCertificateTemplateModal.tsx +++ b/frontend/src/views/Org/SshCaPage/components/SshCertificateTemplateModal.tsx @@ -12,14 +12,16 @@ import { ModalContent, Select, SelectItem, - Switch} from "@app/components/v2"; + Switch +} from "@app/components/v2"; import { useOrganization } from "@app/context"; import { useCreateSshCertTemplate, useGetSshCaById, useGetSshCertTemplate, useListOrgSshCas, - useUpdateSshCertTemplate} from "@app/hooks/api"; + useUpdateSshCertTemplate +} from "@app/hooks/api"; import { UsePopUpState } from "@app/hooks/usePopUp"; const schema = z.object({ @@ -248,7 +250,7 @@ export const SshCertificateTemplateModal = ({ popUp, handlePopUpToggle, sshCaId name="ttl" render={({ field, fieldState: { error } }) => ( Date: Tue, 3 Dec 2024 23:25:22 -0800 Subject: [PATCH 008/162] Add openssh dependency onto production Dockerfile --- backend/Dockerfile | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/backend/Dockerfile b/backend/Dockerfile index 0bb358ee0..5db874e75 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -7,7 +7,8 @@ WORKDIR /app RUN apk --update add \ python3 \ make \ - g++ + g++ \ + openssh # install dependencies for TDS driver (required for SAP ASE dynamic secrets) RUN apk add --no-cache \ From a5a1f572845a49c2f64d195b5c5b1686cba5c181 Mon Sep 17 00:00:00 2001 From: Tuan Dang Date: Wed, 4 Dec 2024 18:38:14 -0800 Subject: [PATCH 009/162] Fix issued ssh cert defaul ttl --- backend/src/ee/services/ssh/ssh-certificate-authority-fns.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/src/ee/services/ssh/ssh-certificate-authority-fns.ts b/backend/src/ee/services/ssh/ssh-certificate-authority-fns.ts index 7491141e4..63ef9e7e2 100644 --- a/backend/src/ee/services/ssh/ssh-certificate-authority-fns.ts +++ b/backend/src/ee/services/ssh/ssh-certificate-authority-fns.ts @@ -203,7 +203,7 @@ export const validateSshCertificatePrincipals = ( export const validateSshCertificateTtl = (template: TSshCertificateTemplates, ttl: string | undefined) => { if (!ttl) { // use default template ttl - return ms(template.ttl); + return ms(template.ttl) / 1000; } if (ms(ttl) > ms(template.maxTTL)) { From 5b618b07fa8a8313eb5a7e450e3f50059a2a5e39 Mon Sep 17 00:00:00 2001 From: Tuan Dang Date: Wed, 4 Dec 2024 20:13:30 -0800 Subject: [PATCH 010/162] Add sign SSH key operation to frontend --- frontend/src/hooks/api/ssh-ca/index.tsx | 7 +- frontend/src/hooks/api/ssh-ca/mutations.tsx | 11 ++ frontend/src/hooks/api/ssh-ca/types.ts | 14 ++ .../components/SshCertificateContent.tsx | 6 +- .../components/SshCertificateModal.tsx | 167 +++++++++++++----- 5 files changed, 152 insertions(+), 53 deletions(-) diff --git a/frontend/src/hooks/api/ssh-ca/index.tsx b/frontend/src/hooks/api/ssh-ca/index.tsx index 70727225d..0bdd4eb61 100644 --- a/frontend/src/hooks/api/ssh-ca/index.tsx +++ b/frontend/src/hooks/api/ssh-ca/index.tsx @@ -1,3 +1,8 @@ export { SshCaStatus } from "./enums"; -export { useCreateSshCa, useDeleteSshCa, useIssueSshCreds,useUpdateSshCa } from "./mutations"; +export { + useCreateSshCa, + useDeleteSshCa, + useIssueSshCreds, + useSignSshKey, + 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 index 9b2fa65e3..a2eb61ed4 100644 --- a/frontend/src/hooks/api/ssh-ca/mutations.tsx +++ b/frontend/src/hooks/api/ssh-ca/mutations.tsx @@ -8,6 +8,8 @@ import { TDeleteSshCaDTO, TIssueSshCredsDTO, TIssueSshCredsResponse, + TSignSshKeyDTO, + TSignSshKeyResponse, TSshCertificateAuthority, TUpdateSshCaDTO} from "./types"; @@ -61,6 +63,15 @@ export const useDeleteSshCa = () => { }); }; +export const useSignSshKey = () => { + return useMutation({ + mutationFn: async (body) => { + const { data } = await apiRequest.post("/api/v1/ssh/sign", body); + return data; + } + }); +}; + export const useIssueSshCreds = () => { return useMutation({ mutationFn: async (body) => { diff --git a/frontend/src/hooks/api/ssh-ca/types.ts b/frontend/src/hooks/api/ssh-ca/types.ts index f263bb489..454e0f41a 100644 --- a/frontend/src/hooks/api/ssh-ca/types.ts +++ b/frontend/src/hooks/api/ssh-ca/types.ts @@ -27,6 +27,20 @@ export type TDeleteSshCaDTO = { caId: string; }; +export type TSignSshKeyDTO = { + templateName: string; + publicKey?: string; + certType: SshCertType; + principals: string[]; + ttl?: string; + keyId?: string; +}; + +export type TSignSshKeyResponse = { + serialNumber: string; + signedKey: string; +}; + export type TIssueSshCredsDTO = { templateName: string; keyAlgorithm: CertKeyAlgorithm; diff --git a/frontend/src/views/Org/SshCaPage/components/SshCertificateContent.tsx b/frontend/src/views/Org/SshCaPage/components/SshCertificateContent.tsx index a71a2601d..fb462742e 100644 --- a/frontend/src/views/Org/SshCaPage/components/SshCertificateContent.tsx +++ b/frontend/src/views/Org/SshCaPage/components/SshCertificateContent.tsx @@ -8,8 +8,8 @@ import { useTimedReset } from "@app/hooks"; type Props = { serialNumber: string; signedKey: string; - privateKey: string; - publicKey: string; + privateKey?: string; + publicKey?: string; }; export const SshCertificateContent = ({ @@ -119,7 +119,7 @@ export const SshCertificateContent = ({ colorSchema="secondary" className="group relative ml-2" onClick={() => { - downloadTxtFile("user_key", privateKey); + downloadTxtFile("user_key.pem", privateKey); }} > diff --git a/frontend/src/views/Org/SshCaPage/components/SshCertificateModal.tsx b/frontend/src/views/Org/SshCaPage/components/SshCertificateModal.tsx index c5ac70e1d..aa9ccbc88 100644 --- a/frontend/src/views/Org/SshCaPage/components/SshCertificateModal.tsx +++ b/frontend/src/views/Org/SshCaPage/components/SshCertificateModal.tsx @@ -13,7 +13,7 @@ import { Select, SelectItem } from "@app/components/v2"; -import { useGetSshCaCertTemplates,useIssueSshCreds } from "@app/hooks/api"; +import { useGetSshCaCertTemplates, useIssueSshCreds, useSignSshKey } from "@app/hooks/api"; import { certKeyAlgorithms } from "@app/hooks/api/certificates/constants"; import { CertKeyAlgorithm } from "@app/hooks/api/certificates/enums"; import { SshCertType } from "@app/hooks/api/ssh-ca/enums"; @@ -29,6 +29,7 @@ import { SshCertificateContent } from "./SshCertificateContent"; const schema = z.object({ templateName: z.string(), + publicKey: z.string().optional(), keyAlgorithm: z.enum([ CertKeyAlgorithm.RSA_2048, CertKeyAlgorithm.RSA_4096, @@ -50,13 +51,23 @@ type Props = { type TSshCertificateDetails = { serialNumber: string; - privateKey: string; - publicKey: string; signedKey: string; + privateKey?: string; + publicKey?: string; }; +enum SshCertificateOperation { + SIGN_SSH_KEY = "sign-ssh-key", + ISSUE_SSH_CREDS = "issue-ssh-creds" +} + export const SshCertificateModal = ({ popUp, handlePopUpToggle }: Props) => { + const [operation, setOperation] = useState( + SshCertificateOperation.SIGN_SSH_KEY + ); const [certificateDetails, setCertificateDetails] = useState(null); + + const { mutateAsync: signSshKey } = useSignSshKey(); const { mutateAsync: issueSshCreds } = useIssueSshCreds(); const popUpData = popUp?.sshCertificate?.data as { sshCaId: string; templateName: string }; @@ -87,29 +98,53 @@ export const SshCertificateModal = ({ popUp, handlePopUpToggle }: Props) => { templateName, keyAlgorithm, certType, + publicKey: existingPublicKey, principals, ttl, keyId }: FormData) => { try { - const { serialNumber, publicKey, privateKey, signedKey } = await issueSshCreds({ - templateName, - keyAlgorithm, - certType, - principals: principals.split(",").map((user) => user.trim()), - ttl, - keyId - }); + switch (operation) { + case SshCertificateOperation.SIGN_SSH_KEY: { + const { serialNumber, signedKey } = await signSshKey({ + templateName, + publicKey: existingPublicKey, + certType, + principals: principals.split(",").map((user) => user.trim()), + ttl, + keyId + }); + setCertificateDetails({ + serialNumber, + signedKey + }); + break; + } + case SshCertificateOperation.ISSUE_SSH_CREDS: { + const { serialNumber, publicKey, privateKey, signedKey } = await issueSshCreds({ + templateName, + keyAlgorithm, + certType, + principals: principals.split(",").map((user) => user.trim()), + ttl, + keyId + }); + + setCertificateDetails({ + serialNumber, + privateKey, + publicKey, + signedKey + }); + break; + } + default: { + break; + } + } reset(); - setCertificateDetails({ - serialNumber, - privateKey, - publicKey, - signedKey - }); - createNotification({ text: "Successfully created SSH certificate", type: "success" @@ -162,20 +197,21 @@ export const SshCertificateModal = ({ popUp, handlePopUpToggle }: Props) => { )} /> - ( - - - - )} - /> + + + { )} /> + {operation === SshCertificateOperation.SIGN_SSH_KEY && ( + ( + + + + )} + /> + )} + {operation === SshCertificateOperation.ISSUE_SSH_CREDS && ( + ( + + + + )} + /> + )} ( + name="principals" + render={({ field, fieldState: { error } }) => ( - + )} /> + Date: Thu, 5 Dec 2024 23:12:37 +0800 Subject: [PATCH 011/162] feat: k8 operator namespace installation --- docs/integrations/platforms/kubernetes.mdx | 24 ++++++++++++++++ helm-charts/secrets-operator/Chart.yaml | 4 +-- .../templates/deployment.yaml | 6 +++- .../templates/manager-rbac.yaml | 18 ++++++++++++ helm-charts/secrets-operator/values.yaml | 28 ++++++++++--------- k8-operator/main.go | 12 ++++++-- 6 files changed, 74 insertions(+), 18 deletions(-) diff --git a/docs/integrations/platforms/kubernetes.mdx b/docs/integrations/platforms/kubernetes.mdx index 8ea24d65f..8f42b4dde 100644 --- a/docs/integrations/platforms/kubernetes.mdx +++ b/docs/integrations/platforms/kubernetes.mdx @@ -41,6 +41,30 @@ The operator can be install via [Helm](https://helm.sh) or [kubectl](https://git helm install --generate-name infisical-helm-charts/secrets-operator --version=0.1.4 --set controllerManager.manager.image.tag=v0.2.0 ``` + **Namespace-scoped Installation** + + The operator can be configured to watch and manage secrets in a specific namespace instead of having cluster-wide access. + + ```bash + helm install operator infisical-helm-charts/secrets-operator \ + --namespace your-namespace \ + --set scopedNamespace=your-namespace \ + --set scopedRBAC=true + ``` + + When scoped to a namespace, the operator will: + + - Only watch InfisicalSecrets in the specified namespace + - Only create/update Kubernetes secrets in that namespace + - Only access deployments in that namespace + + The default configuration gives cluster-wide access: + + ```yaml + scopedNamespace: "" # Empty for cluster-wide access + scopedRBAC: false # Cluster-wide permissions + ``` + For production deployments, it is highly recommended to set the version of the Kubernetes operator manually instead of pointing to the latest version. diff --git a/helm-charts/secrets-operator/Chart.yaml b/helm-charts/secrets-operator/Chart.yaml index 8ff17cdaa..f212ce4eb 100644 --- a/helm-charts/secrets-operator/Chart.yaml +++ b/helm-charts/secrets-operator/Chart.yaml @@ -13,9 +13,9 @@ type: application # This is the chart version. This version number should be incremented each time you make changes # to the chart and its templates, including the app version. # Versions are expected to follow Semantic Versioning (https://semver.org/) -version: v0.7.4 +version: v0.7.5 # This is the version number of the application being deployed. This version number should be # incremented each time you make changes to the application. Versions are not expected to # follow Semantic Versioning. They should reflect the version the application is using. # It is recommended to use it with quotes. -appVersion: "v0.7.4" +appVersion: "v0.7.5" diff --git a/helm-charts/secrets-operator/templates/deployment.yaml b/helm-charts/secrets-operator/templates/deployment.yaml index ec02df12d..e67db7b3f 100644 --- a/helm-charts/secrets-operator/templates/deployment.yaml +++ b/helm-charts/secrets-operator/templates/deployment.yaml @@ -54,7 +54,11 @@ spec: 10 }} securityContext: {{- toYaml .Values.controllerManager.kubeRbacProxy.containerSecurityContext | nindent 10 }} - - args: {{- toYaml .Values.controllerManager.manager.args | nindent 8 }} + - args: + {{- toYaml .Values.controllerManager.manager.args | nindent 8 }} + {{- if and .Values.scopedNamespace .Values.scopedRBAC }} + - --namespace={{ .Values.scopedNamespace }} + {{- end }} command: - /manager env: diff --git a/helm-charts/secrets-operator/templates/manager-rbac.yaml b/helm-charts/secrets-operator/templates/manager-rbac.yaml index ca6fd36e1..33f00198f 100644 --- a/helm-charts/secrets-operator/templates/manager-rbac.yaml +++ b/helm-charts/secrets-operator/templates/manager-rbac.yaml @@ -1,7 +1,14 @@ apiVersion: rbac.authorization.k8s.io/v1 +{{- if and .Values.scopedNamespace .Values.scopedRBAC }} +kind: Role +{{- else }} kind: ClusterRole +{{- end }} metadata: name: {{ include "secrets-operator.fullname" . }}-manager-role + {{- if and .Values.scopedNamespace .Values.scopedRBAC }} + namespace: {{ .Values.scopedNamespace | quote }} + {{- end }} labels: {{- include "secrets-operator.labels" . | nindent 4 }} rules: @@ -72,9 +79,16 @@ rules: - update --- apiVersion: rbac.authorization.k8s.io/v1 +{{- if and .Values.scopedNamespace .Values.scopedRBAC }} +kind: RoleBinding +{{- else }} kind: ClusterRoleBinding +{{- end }} metadata: name: {{ include "secrets-operator.fullname" . }}-manager-rolebinding + {{- if and .Values.scopedNamespace .Values.scopedRBAC }} + namespace: {{ .Values.scopedNamespace | quote }} + {{- end }} labels: app.kubernetes.io/component: rbac app.kubernetes.io/created-by: k8-operator @@ -82,7 +96,11 @@ metadata: {{- include "secrets-operator.labels" . | nindent 4 }} roleRef: apiGroup: rbac.authorization.k8s.io + {{- if and .Values.scopedNamespace .Values.scopedRBAC }} + kind: Role + {{- else }} kind: ClusterRole + {{- end }} name: '{{ include "secrets-operator.fullname" . }}-manager-role' subjects: - kind: ServiceAccount diff --git a/helm-charts/secrets-operator/values.yaml b/helm-charts/secrets-operator/values.yaml index c2ad28f2b..fcd3739e8 100644 --- a/helm-charts/secrets-operator/values.yaml +++ b/helm-charts/secrets-operator/values.yaml @@ -1,15 +1,15 @@ controllerManager: kubeRbacProxy: args: - - --secure-listen-address=0.0.0.0:8443 - - --upstream=http://127.0.0.1:8080/ - - --logtostderr=true - - --v=0 + - --secure-listen-address=0.0.0.0:8443 + - --upstream=http://127.0.0.1:8080/ + - --logtostderr=true + - --v=0 containerSecurityContext: allowPrivilegeEscalation: false capabilities: drop: - - ALL + - ALL image: repository: gcr.io/kubebuilder/kube-rbac-proxy tag: v0.15.0 @@ -22,14 +22,14 @@ controllerManager: memory: 64Mi manager: args: - - --health-probe-bind-address=:8081 - - --metrics-bind-address=127.0.0.1:8080 - - --leader-elect + - --health-probe-bind-address=:8081 + - --metrics-bind-address=127.0.0.1:8080 + - --leader-elect containerSecurityContext: allowPrivilegeEscalation: false capabilities: drop: - - ALL + - ALL image: repository: infisical/kubernetes-operator tag: v0.7.4 @@ -46,10 +46,12 @@ controllerManager: nodeSelector: {} tolerations: [] kubernetesClusterDomain: cluster.local +scopedNamespace: "" +scopedRBAC: false metricsService: ports: - - name: https - port: 8443 - protocol: TCP - targetPort: https + - name: https + port: 8443 + protocol: TCP + targetPort: https type: ClusterIP diff --git a/k8-operator/main.go b/k8-operator/main.go index 50c0cda00..4fb64c7f4 100644 --- a/k8-operator/main.go +++ b/k8-operator/main.go @@ -36,8 +36,10 @@ func main() { var metricsAddr string var enableLeaderElection bool var probeAddr string + var namespace string flag.StringVar(&metricsAddr, "metrics-bind-address", ":8080", "The address the metric endpoint binds to.") flag.StringVar(&probeAddr, "health-probe-bind-address", ":8081", "The address the probe endpoint binds to.") + flag.StringVar(&namespace, "namespace", "", "Watch InfisicalSecrets scoped in the provided namespace only") flag.BoolVar(&enableLeaderElection, "leader-elect", false, "Enable leader election for controller manager. "+ "Enabling this will ensure there is only one active controller manager.") @@ -49,7 +51,7 @@ func main() { ctrl.SetLogger(zap.New(zap.UseFlagOptions(&opts))) - mgr, err := ctrl.NewManager(ctrl.GetConfigOrDie(), ctrl.Options{ + ctrlOpts := ctrl.Options{ Scheme: scheme, MetricsBindAddress: metricsAddr, Port: 9443, @@ -67,7 +69,13 @@ func main() { // if you are doing or is intended to do any operation such as perform cleanups // after the manager stops then its usage might be unsafe. // LeaderElectionReleaseOnCancel: true, - }) + } + + if namespace != "" { + ctrlOpts.Namespace = namespace + } + + mgr, err := ctrl.NewManager(ctrl.GetConfigOrDie(), ctrlOpts) if err != nil { setupLog.Error(err, "unable to start manager") os.Exit(1) From 6e720c2f64e1e91df63810158c89aedc6384a6c0 Mon Sep 17 00:00:00 2001 From: Tuan Dang Date: Thu, 5 Dec 2024 23:01:28 -0800 Subject: [PATCH 012/162] Add SSH certificate tab + data structure --- backend/src/@types/knex.d.ts | 8 + .../db/migrations/20241130015511_ssh-mgmt.ts | 27 +++- backend/src/db/schemas/index.ts | 1 + backend/src/db/schemas/models.ts | 1 + backend/src/db/schemas/ssh-certificates.ts | 27 ++++ .../ee/services/permission/org-permission.ts | 37 ++--- .../ssh-certificate-template-service.ts | 13 +- .../ssh-certificate/ssh-certificate-dal.ts | 38 +++++ .../ssh-certificate/ssh-certificate-schema.ts | 12 ++ .../ssh/ssh-certificate-authority-service.ts | 59 ++++++-- backend/src/lib/api-docs/constants.ts | 8 + backend/src/server/routes/index.ts | 7 +- .../server/routes/v1/organization-router.ts | 69 +++++++++ backend/src/services/org/org-service.ts | 78 +++++++++- backend/src/services/org/org-types.ts | 5 + .../src/context/OrgPermissionContext/types.ts | 4 +- frontend/src/hooks/api/organization/index.ts | 2 + .../src/hooks/api/organization/queries.tsx | 54 ++++++- .../api/ssh-ca/{enums.tsx => constants.tsx} | 5 + frontend/src/hooks/api/ssh-ca/index.tsx | 5 +- frontend/src/hooks/api/ssh-ca/mutations.tsx | 11 +- frontend/src/hooks/api/ssh-ca/types.ts | 13 +- frontend/src/pages/org/[id]/ssh/index.tsx | 3 - .../components/OrgRoleModifySection.utils.ts | 8 + .../RolePermissionRow.tsx | 7 + .../RolePermissionsSection.tsx | 14 +- .../SshCertificateTemplateRow.tsx | 137 ------------------ .../components/SshCertificateContent.tsx | 2 +- .../components/SshCertificateModal.tsx | 13 +- frontend/src/views/Org/SshPage/SshPage.tsx | 39 ++++- .../components/SshCertificatesSection.tsx | 36 +++++ .../components/SshCertificatesTable.tsx | 70 +++++++++ .../views/Org/SshPage/components/index.tsx | 1 + 33 files changed, 604 insertions(+), 210 deletions(-) create mode 100644 backend/src/db/schemas/ssh-certificates.ts create mode 100644 backend/src/ee/services/ssh-certificate/ssh-certificate-dal.ts create mode 100644 backend/src/ee/services/ssh-certificate/ssh-certificate-schema.ts rename frontend/src/hooks/api/ssh-ca/{enums.tsx => constants.tsx} (50%) delete mode 100644 frontend/src/views/Org/RolePage/components/RolePermissionsSection/SshCertificateTemplateRow.tsx create mode 100644 frontend/src/views/Org/SshPage/components/SshCertificatesSection.tsx create mode 100644 frontend/src/views/Org/SshPage/components/SshCertificatesTable.tsx diff --git a/backend/src/@types/knex.d.ts b/backend/src/@types/knex.d.ts index b6485536a..c0a2c0eaa 100644 --- a/backend/src/@types/knex.d.ts +++ b/backend/src/@types/knex.d.ts @@ -317,6 +317,9 @@ import { TSshCertificateAuthoritySecrets, TSshCertificateAuthoritySecretsInsert, TSshCertificateAuthoritySecretsUpdate, + TSshCertificates, + TSshCertificatesInsert, + TSshCertificatesUpdate, TSshCertificateTemplates, TSshCertificateTemplatesInsert, TSshCertificateTemplatesUpdate, @@ -396,6 +399,11 @@ declare module "knex/types/tables" { TSshCertificateTemplatesInsert, TSshCertificateTemplatesUpdate >; + [TableName.SshCertificate]: KnexOriginal.CompositeTableType< + TSshCertificates, + TSshCertificatesInsert, + TSshCertificatesUpdate + >; [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 index 2d1681ca6..a96f49a4d 100644 --- a/backend/src/db/migrations/20241130015511_ssh-mgmt.ts +++ b/backend/src/db/migrations/20241130015511_ssh-mgmt.ts @@ -34,7 +34,7 @@ export async function up(knex: Knex): Promise { 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("name").notNullable(); t.string("ttl").notNullable(); t.string("maxTTL").notNullable(); t.specificType("allowedUsers", "text[]").notNullable(); @@ -45,9 +45,34 @@ export async function up(knex: Knex): Promise { }); await createOnUpdateTrigger(knex, TableName.SshCertificateTemplate); } + + if (!(await knex.schema.hasTable(TableName.SshCertificate))) { + await knex.schema.createTable(TableName.SshCertificate, (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.uuid("sshCertificateTemplateId"); + t.foreign("sshCertificateTemplateId") + .references("id") + .inTable(TableName.SshCertificateTemplate) + .onDelete("SET NULL"); + t.string("serialNumber").notNullable().unique(); + t.string("certType").notNullable(); // user or host + t.text("publicKey").notNullable(); // public key in OpenSSH format + t.specificType("principals", "text[]").notNullable(); + t.string("keyId").notNullable(); + t.datetime("notBefore").notNullable(); + t.datetime("notAfter").notNullable(); + }); + await createOnUpdateTrigger(knex, TableName.SshCertificateTemplate); + } } export async function down(knex: Knex): Promise { + await knex.schema.dropTableIfExists(TableName.SshCertificate); + await dropOnUpdateTrigger(knex, TableName.SshCertificate); + await knex.schema.dropTableIfExists(TableName.SshCertificateTemplate); await dropOnUpdateTrigger(knex, TableName.SshCertificateTemplate); diff --git a/backend/src/db/schemas/index.ts b/backend/src/db/schemas/index.ts index 2572f2bd3..348c39c70 100644 --- a/backend/src/db/schemas/index.ts +++ b/backend/src/db/schemas/index.ts @@ -108,6 +108,7 @@ export * from "./slack-integrations"; export * from "./ssh-certificate-authorities"; export * from "./ssh-certificate-authority-secrets"; export * from "./ssh-certificate-templates"; +export * from "./ssh-certificates"; 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 156d6196e..b7de13188 100644 --- a/backend/src/db/schemas/models.ts +++ b/backend/src/db/schemas/models.ts @@ -5,6 +5,7 @@ export enum TableName { SshCertificateAuthority = "ssh_certificate_authorities", SshCertificateAuthoritySecret = "ssh_certificate_authority_secrets", SshCertificateTemplate = "ssh_certificate_templates", + SshCertificate = "ssh_certificates", CertificateAuthority = "certificate_authorities", CertificateTemplateEstConfig = "certificate_template_est_configs", CertificateAuthorityCert = "certificate_authority_certs", diff --git a/backend/src/db/schemas/ssh-certificates.ts b/backend/src/db/schemas/ssh-certificates.ts new file mode 100644 index 000000000..238a71e00 --- /dev/null +++ b/backend/src/db/schemas/ssh-certificates.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 { TImmutableDBKeys } from "./models"; + +export const SshCertificatesSchema = z.object({ + id: z.string().uuid(), + createdAt: z.date(), + updatedAt: z.date(), + sshCaId: z.string().uuid(), + sshCertificateTemplateId: z.string().uuid().nullable().optional(), + serialNumber: z.string(), + certType: z.string(), + publicKey: z.string(), + principals: z.string().array(), + keyId: z.string(), + notBefore: z.date(), + notAfter: z.date() +}); + +export type TSshCertificates = z.infer; +export type TSshCertificatesInsert = Omit, TImmutableDBKeys>; +export type TSshCertificatesUpdate = Partial, TImmutableDBKeys>>; diff --git a/backend/src/ee/services/permission/org-permission.ts b/backend/src/ee/services/permission/org-permission.ts index dffdce7bf..89c4459d8 100644 --- a/backend/src/ee/services/permission/org-permission.ts +++ b/backend/src/ee/services/permission/org-permission.ts @@ -7,15 +7,6 @@ export enum OrgPermissionActions { Delete = "delete" } -export enum OrgPermissionSshCertificateTemplateActions { - Read = "read", - Create = "create", - Edit = "edit", - Delete = "delete", - SignSshKey = "sign-ssh-key", - IssueSshCredentials = "issue-ssh-credentials" -} - export enum OrgPermissionAdminConsoleAction { AccessAllProjects = "access-all-projects" } @@ -37,6 +28,7 @@ export enum OrgPermissionSubjects { AdminConsole = "organization-admin-console", AuditLogs = "audit-logs", ProjectTemplates = "project-templates", + SshCertificates = "ssh-certificates", SshCertificateAuthorities = "ssh-certificate-authorities", SshCertificateTemplates = "ssh-certificate-templates" } @@ -59,7 +51,8 @@ export type OrgPermissionSet = | [OrgPermissionActions, OrgPermissionSubjects.ProjectTemplates] | [OrgPermissionAdminConsoleAction, OrgPermissionSubjects.AdminConsole] | [OrgPermissionActions, OrgPermissionSubjects.SshCertificateAuthorities] - | [OrgPermissionSshCertificateTemplateActions, OrgPermissionSubjects.SshCertificateTemplates]; + | [OrgPermissionActions, OrgPermissionSubjects.SshCertificates] + | [OrgPermissionActions, OrgPermissionSubjects.SshCertificateTemplates]; const buildAdminPermission = () => { const { can, rules } = new AbilityBuilder>(createMongoAbility); @@ -136,22 +129,18 @@ const buildAdminPermission = () => { can(OrgPermissionActions.Edit, OrgPermissionSubjects.ProjectTemplates); can(OrgPermissionActions.Delete, OrgPermissionSubjects.ProjectTemplates); + can(OrgPermissionActions.Read, OrgPermissionSubjects.SshCertificates); + can(OrgPermissionActions.Create, OrgPermissionSubjects.SshCertificates); + can(OrgPermissionActions.Read, OrgPermissionSubjects.SshCertificateAuthorities); can(OrgPermissionActions.Create, OrgPermissionSubjects.SshCertificateAuthorities); can(OrgPermissionActions.Edit, OrgPermissionSubjects.SshCertificateAuthorities); can(OrgPermissionActions.Delete, OrgPermissionSubjects.SshCertificateAuthorities); - can( - [ - OrgPermissionSshCertificateTemplateActions.Read, - OrgPermissionSshCertificateTemplateActions.Create, - OrgPermissionSshCertificateTemplateActions.Edit, - OrgPermissionSshCertificateTemplateActions.Delete, - OrgPermissionSshCertificateTemplateActions.SignSshKey, - OrgPermissionSshCertificateTemplateActions.IssueSshCredentials - ], - OrgPermissionSubjects.SshCertificateTemplates - ); + 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); @@ -184,9 +173,9 @@ const buildMemberPermission = () => { can(OrgPermissionActions.Read, OrgPermissionSubjects.AuditLogs); can(OrgPermissionActions.Read, OrgPermissionSubjects.SshCertificateAuthorities); - can(OrgPermissionSshCertificateTemplateActions.Read, OrgPermissionSubjects.SshCertificateTemplates); - can(OrgPermissionSshCertificateTemplateActions.SignSshKey, OrgPermissionSubjects.SshCertificateTemplates); - can(OrgPermissionSshCertificateTemplateActions.IssueSshCredentials, OrgPermissionSubjects.SshCertificateTemplates); + can(OrgPermissionActions.Read, OrgPermissionSubjects.SshCertificates); + can(OrgPermissionActions.Create, OrgPermissionSubjects.SshCertificates); + can(OrgPermissionActions.Read, OrgPermissionSubjects.SshCertificateTemplates); return rules; }; 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 index 88e3259b2..8553eae5c 100644 --- 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 @@ -1,10 +1,7 @@ import { ForbiddenError } from "@casl/ability"; import ms from "ms"; -import { - OrgPermissionSshCertificateTemplateActions, - OrgPermissionSubjects -} from "@app/ee/services/permission/org-permission"; +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"; @@ -61,7 +58,7 @@ export const sshCertificateTemplateServiceFactory = ({ ); ForbiddenError.from(permission).throwUnlessCan( - OrgPermissionSshCertificateTemplateActions.Create, + OrgPermissionActions.Create, OrgPermissionSubjects.SshCertificateTemplates ); @@ -124,7 +121,7 @@ export const sshCertificateTemplateServiceFactory = ({ ); ForbiddenError.from(permission).throwUnlessCan( - OrgPermissionSshCertificateTemplateActions.Edit, + OrgPermissionActions.Edit, OrgPermissionSubjects.SshCertificateTemplates ); @@ -183,7 +180,7 @@ export const sshCertificateTemplateServiceFactory = ({ ); ForbiddenError.from(permission).throwUnlessCan( - OrgPermissionSshCertificateTemplateActions.Delete, + OrgPermissionActions.Delete, OrgPermissionSubjects.SshCertificateTemplates ); @@ -209,7 +206,7 @@ export const sshCertificateTemplateServiceFactory = ({ ); ForbiddenError.from(permission).throwUnlessCan( - OrgPermissionSshCertificateTemplateActions.Read, + OrgPermissionActions.Read, OrgPermissionSubjects.SshCertificateTemplates ); diff --git a/backend/src/ee/services/ssh-certificate/ssh-certificate-dal.ts b/backend/src/ee/services/ssh-certificate/ssh-certificate-dal.ts new file mode 100644 index 000000000..95cc4766e --- /dev/null +++ b/backend/src/ee/services/ssh-certificate/ssh-certificate-dal.ts @@ -0,0 +1,38 @@ +import { TDbClient } from "@app/db"; +import { TableName } from "@app/db/schemas"; +import { DatabaseError } from "@app/lib/errors"; +import { ormify } from "@app/lib/knex"; + +export type TSshCertificateDALFactory = ReturnType; + +export const sshCertificateDALFactory = (db: TDbClient) => { + const sshCertificateOrm = ormify(db, TableName.SshCertificate); + + const countSshCertificatesInOrg = async (orgId: string) => { + try { + interface CountResult { + count: string; + } + + const query = db + .replicaNode()(TableName.SshCertificate) + .join( + TableName.SshCertificateAuthority, + `${TableName.SshCertificate}.sshCaId`, + `${TableName.SshCertificateAuthority}.id` + ) + .join(TableName.Organization, `${TableName.SshCertificateAuthority}.orgId`, `${TableName.Organization}.id`) + .where(`${TableName.Organization}.id`, orgId); + + const count = await query.count("*").first(); + + return parseInt((count as unknown as CountResult).count || "0", 10); + } catch (error) { + throw new DatabaseError({ error, name: "Count all SSH certificates in organization" }); + } + }; + return { + ...sshCertificateOrm, + countSshCertificatesInOrg + }; +}; diff --git a/backend/src/ee/services/ssh-certificate/ssh-certificate-schema.ts b/backend/src/ee/services/ssh-certificate/ssh-certificate-schema.ts new file mode 100644 index 000000000..27f6ef7ed --- /dev/null +++ b/backend/src/ee/services/ssh-certificate/ssh-certificate-schema.ts @@ -0,0 +1,12 @@ +import { SshCertificatesSchema } from "@app/db/schemas"; + +export const sanitizedSshCertificate = SshCertificatesSchema.pick({ + id: true, + sshCaId: true, + sshCertificateTemplateId: true, + serialNumber: true, + certType: true, + publicKey: true, + principals: true, + keyId: true +}); diff --git a/backend/src/ee/services/ssh/ssh-certificate-authority-service.ts b/backend/src/ee/services/ssh/ssh-certificate-authority-service.ts index 27fd3b554..690a71a7b 100644 --- a/backend/src/ee/services/ssh/ssh-certificate-authority-service.ts +++ b/backend/src/ee/services/ssh/ssh-certificate-authority-service.ts @@ -1,15 +1,12 @@ import { ForbiddenError } from "@casl/ability"; -import { - OrgPermissionActions, - OrgPermissionSshCertificateTemplateActions, - OrgPermissionSubjects -} from "@app/ee/services/permission/org-permission"; +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 { TSshCertificateDALFactory } from "@app/ee/services/ssh-certificate/ssh-certificate-dal"; import { TSshCertificateTemplateDALFactory } from "@app/ee/services/ssh-certificate-template/ssh-certificate-template-dal"; -import { NotFoundError } from "@app/lib/errors"; +import { BadRequestError, NotFoundError } from "@app/lib/errors"; import { TKmsServiceFactory } from "@app/services/kms/kms-service"; import { @@ -38,6 +35,7 @@ type TSshCertificateAuthorityServiceFactoryDep = { >; sshCertificateAuthoritySecretDAL: Pick; sshCertificateTemplateDAL: Pick; + sshCertificateDAL: Pick; kmsService: Pick; permissionService: Pick; }; @@ -48,6 +46,7 @@ export const sshCertificateAuthorityServiceFactory = ({ sshCertificateAuthorityDAL, sshCertificateAuthoritySecretDAL, sshCertificateTemplateDAL, + sshCertificateDAL, kmsService, permissionService }: TSshCertificateAuthorityServiceFactoryDep) => { @@ -252,10 +251,13 @@ export const sshCertificateAuthorityServiceFactory = ({ actorOrgId ); - ForbiddenError.from(permission).throwUnlessCan( - OrgPermissionSshCertificateTemplateActions.IssueSshCredentials, - OrgPermissionSubjects.SshCertificateTemplates - ); + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Create, OrgPermissionSubjects.SshCertificates); + + if (sshCertificateTemplate.caStatus === SshCaStatus.DISABLED) { + throw new BadRequestError({ + message: "SSH CA is disabled" + }); + } // validate if the requested [certType] is allowed under the template configuration validateSshCertificateType(sshCertificateTemplate, certType); @@ -295,6 +297,18 @@ export const sshCertificateAuthorityServiceFactory = ({ certType }); + await sshCertificateDAL.create({ + sshCaId: sshCertificateTemplate.sshCaId, + sshCertificateTemplateId: sshCertificateTemplate.id, + serialNumber, + certType, + publicKey, + principals, + keyId, + notBefore: new Date(), + notAfter: new Date(Date.now() + ttl * 1000) + }); + return { serialNumber, signedPublicKey, @@ -337,10 +351,13 @@ export const sshCertificateAuthorityServiceFactory = ({ actorOrgId ); - ForbiddenError.from(permission).throwUnlessCan( - OrgPermissionSshCertificateTemplateActions.SignSshKey, - OrgPermissionSubjects.SshCertificateTemplates - ); + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Create, OrgPermissionSubjects.SshCertificates); + + if (sshCertificateTemplate.caStatus === SshCaStatus.DISABLED) { + throw new BadRequestError({ + message: "SSH CA is disabled" + }); + } // validate if the requested [certType] is allowed under the template configuration validateSshCertificateType(sshCertificateTemplate, certType); @@ -377,6 +394,18 @@ export const sshCertificateAuthorityServiceFactory = ({ certType }); + await sshCertificateDAL.create({ + sshCaId: sshCertificateTemplate.sshCaId, + sshCertificateTemplateId: sshCertificateTemplate.id, + serialNumber, + certType, + publicKey, + principals, + keyId, + notBefore: new Date(), + notAfter: new Date(Date.now() + ttl * 1000) + }); + return { serialNumber, signedPublicKey, certificateTemplate: sshCertificateTemplate, ttl, keyId }; }; @@ -399,7 +428,7 @@ export const sshCertificateAuthorityServiceFactory = ({ ); ForbiddenError.from(permission).throwUnlessCan( - OrgPermissionSshCertificateTemplateActions.Read, + OrgPermissionActions.Read, OrgPermissionSubjects.SshCertificateTemplates ); diff --git a/backend/src/lib/api-docs/constants.ts b/backend/src/lib/api-docs/constants.ts index f1fce544b..d881a5233 100644 --- a/backend/src/lib/api-docs/constants.ts +++ b/backend/src/lib/api-docs/constants.ts @@ -387,6 +387,14 @@ export const ORGANIZATIONS = { }, LIST_SSH_CAS: { organizationId: "The ID of the organization to list SSH CAs for." + }, + LIST_SSH_CERTIFICATES: { + organizationId: "The ID of the organization to list SSH certificates for.", + offset: "The offset to start from. If you enter 10, it will start from the 10th SSH certificate.", + limit: "The number of SSH certificates to return." + }, + LIST_SSH_CERTIFICATE_TEMPLATES: { + organizationId: "The ID of the organization to list SSH certificate templates for." } } as const; diff --git a/backend/src/server/routes/index.ts b/backend/src/server/routes/index.ts index 187753d54..02ba4eba2 100644 --- a/backend/src/server/routes/index.ts +++ b/backend/src/server/routes/index.ts @@ -78,6 +78,7 @@ import { snapshotSecretV2DALFactory } from "@app/ee/services/secret-snapshot/sna 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 { sshCertificateDALFactory } from "@app/ee/services/ssh-certificate/ssh-certificate-dal"; 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"; @@ -347,6 +348,7 @@ export const registerRoutes = async ( const dynamicSecretDAL = dynamicSecretDALFactory(db); const dynamicSecretLeaseDAL = dynamicSecretLeaseDALFactory(db); + const sshCertificateDAL = sshCertificateDALFactory(db); const sshCertificateAuthorityDAL = sshCertificateAuthorityDALFactory(db); const sshCertificateAuthoritySecretDAL = sshCertificateAuthoritySecretDALFactory(db); const sshCertificateTemplateDAL = sshCertificateTemplateDALFactory(db); @@ -564,7 +566,9 @@ export const registerRoutes = async ( orgBotDAL, oidcConfigDAL, projectBotService, - sshCertificateAuthorityDAL + sshCertificateAuthorityDAL, + sshCertificateDAL, + sshCertificateTemplateDAL }); const signupService = authSignupServiceFactory({ tokenService, @@ -716,6 +720,7 @@ export const registerRoutes = async ( sshCertificateAuthorityDAL, sshCertificateAuthoritySecretDAL, sshCertificateTemplateDAL, + sshCertificateDAL, kmsService, permissionService }); diff --git a/backend/src/server/routes/v1/organization-router.ts b/backend/src/server/routes/v1/organization-router.ts index d6ab7f36c..c1b8881e9 100644 --- a/backend/src/server/routes/v1/organization-router.ts +++ b/backend/src/server/routes/v1/organization-router.ts @@ -12,6 +12,8 @@ import { } 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 { sanitizedSshCertificate } from "@app/ee/services/ssh-certificate/ssh-certificate-schema"; +import { sanitizedSshCertificateTemplate } from "@app/ee/services/ssh-certificate-template/ssh-certificate-template-schema"; import { AUDIT_LOGS, ORGANIZATIONS } from "@app/lib/api-docs"; import { getLastMidnightDateISO } from "@app/lib/fn"; import { readLimit, writeLimit } from "@app/server/config/rateLimiter"; @@ -406,6 +408,73 @@ export const registerOrgRouter = async (server: FastifyZodProvider) => { } }); + server.route({ + method: "GET", + url: "/:organizationId/ssh-certificates", + config: { + rateLimit: readLimit + }, + schema: { + params: z.object({ + organizationId: z.string().trim().describe(ORGANIZATIONS.LIST_SSH_CAS.organizationId) + }), + querystring: z.object({ + offset: z.coerce.number().default(0).describe(ORGANIZATIONS.LIST_SSH_CERTIFICATES.offset), + limit: z.coerce.number().default(25).describe(ORGANIZATIONS.LIST_SSH_CERTIFICATES.limit) + }), + response: { + 200: z.object({ + certificates: z.array(sanitizedSshCertificate), + totalCount: z.number() // TODO + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const { certificates, totalCount } = await server.services.org.listOrgSshCertificates({ + actorId: req.permission.id, + actorOrgId: req.permission.orgId, + actorAuthMethod: req.permission.authMethod, + actor: req.permission.type, + orgId: req.params.organizationId, + offset: req.query.offset, + limit: req.query.limit + }); + + return { certificates, totalCount }; + } + }); + + server.route({ + method: "GET", + url: "/:organizationId/ssh-certificate-templates", + config: { + rateLimit: readLimit + }, + schema: { + params: z.object({ + organizationId: z.string().trim().describe(ORGANIZATIONS.LIST_SSH_CERTIFICATE_TEMPLATES.organizationId) + }), + response: { + 200: z.object({ + certificateTemplates: z.array(sanitizedSshCertificateTemplate) + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const { certificateTemplates } = await server.services.org.listOrgSshCertificateTemplates({ + actorId: req.permission.id, + actorOrgId: req.permission.orgId, + actorAuthMethod: req.permission.authMethod, + actor: req.permission.type, + orgId: req.params.organizationId + }); + + return { certificateTemplates }; + } + }); + server.route({ method: "GET", url: "/:organizationId/ssh-cas", diff --git a/backend/src/services/org/org-service.ts b/backend/src/services/org/org-service.ts index b6bd95101..0871b9766 100644 --- a/backend/src/services/org/org-service.ts +++ b/backend/src/services/org/org-service.ts @@ -25,6 +25,8 @@ import { ProjectPermissionActions, ProjectPermissionSub } from "@app/ee/services 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 { TSshCertificateDALFactory } from "@app/ee/services/ssh-certificate/ssh-certificate-dal"; +import { TSshCertificateTemplateDALFactory } from "@app/ee/services/ssh-certificate-template/ssh-certificate-template-dal"; import { getConfig } from "@app/lib/config/env"; import { generateAsymmetricKeyPair } from "@app/lib/crypto"; import { generateSymmetricKey, infisicalSymmetricDecrypt, infisicalSymmetricEncypt } from "@app/lib/crypto/encryption"; @@ -64,6 +66,8 @@ import { TGetOrgMembershipDTO, TInviteUserToOrgDTO, TListOrgSshCasDTO, + TListOrgSshCertificatesDTO, + TListOrgSshCertificateTemplatesDTO, TListProjectMembershipsByOrgMembershipIdDTO, TUpdateOrgDTO, TUpdateOrgMembershipDTO, @@ -101,6 +105,8 @@ type TOrgServiceFactoryDep = { projectUserMembershipRoleDAL: Pick; projectBotService: Pick; sshCertificateAuthorityDAL: Pick; + sshCertificateDAL: Pick; + sshCertificateTemplateDAL: Pick; }; export type TOrgServiceFactory = ReturnType; @@ -129,6 +135,8 @@ export const orgServiceFactory = ({ projectUserMembershipRoleDAL, identityMetadataDAL, sshCertificateAuthorityDAL, + sshCertificateDAL, + sshCertificateTemplateDAL, projectBotService }: TOrgServiceFactoryDep) => { /* @@ -1132,7 +1140,7 @@ export const orgServiceFactory = ({ }; /** - * Return list of SSH CAs for project + * Return list of SSH CAs for organization */ const listOrgSshCas = async ({ actorId, actorOrgId, actorAuthMethod, actor, orgId }: TListOrgSshCasDTO) => { const { permission } = await permissionService.getOrgPermission(actor, actorId, orgId, actorAuthMethod, actorOrgId); @@ -1152,6 +1160,70 @@ export const orgServiceFactory = ({ return cas; }; + /** + * Return list of SSH certificates for organization + */ + const listOrgSshCertificates = async ({ + limit = 25, + offset = 0, + actorId, + actorOrgId, + actorAuthMethod, + actor, + orgId + }: TListOrgSshCertificatesDTO) => { + const { permission } = await permissionService.getOrgPermission(actor, actorId, orgId, actorAuthMethod, actorOrgId); + + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Read, OrgPermissionSubjects.SshCertificates); + + const cas = await sshCertificateAuthorityDAL.find({ + orgId + }); + + const certificates = await sshCertificateDAL.find( + { + $in: { + sshCaId: cas.map((ca) => ca.id) + } + }, + { offset, limit, sort: [["updatedAt", "desc"]] } + ); + + const count = await sshCertificateDAL.countSshCertificatesInOrg(orgId); + + return { certificates, totalCount: count }; + }; + + /** + * Return list of SSH certificate templates for organization + */ + const listOrgSshCertificateTemplates = async ({ + actorId, + actorOrgId, + actorAuthMethod, + actor, + orgId + }: TListOrgSshCertificateTemplatesDTO) => { + const { permission } = await permissionService.getOrgPermission(actor, actorId, orgId, actorAuthMethod, actorOrgId); + + ForbiddenError.from(permission).throwUnlessCan( + OrgPermissionActions.Read, + OrgPermissionSubjects.SshCertificateTemplates + ); + + const cas = await sshCertificateAuthorityDAL.find({ + orgId + }); + + const certificateTemplates = await sshCertificateTemplateDAL.find({ + $in: { + sshCaId: cas.map((ca) => ca.id) + } + }); + + return { certificateTemplates }; + }; + return { findOrganizationById, findAllOrgMembers, @@ -1174,6 +1246,8 @@ export const orgServiceFactory = ({ getOrgGroups, listProjectMembershipsByOrgMembershipId, findOrgBySlug, - listOrgSshCas + listOrgSshCas, + listOrgSshCertificates, + listOrgSshCertificateTemplates }; }; diff --git a/backend/src/services/org/org-types.ts b/backend/src/services/org/org-types.ts index 66fe6ac2e..868d3345c 100644 --- a/backend/src/services/org/org-types.ts +++ b/backend/src/services/org/org-types.ts @@ -76,6 +76,11 @@ export type TListProjectMembershipsByOrgMembershipIdDTO = { } & TOrgPermission; export type TListOrgSshCasDTO = TOrgPermission; +export type TListOrgSshCertificateTemplatesDTO = TOrgPermission; +export type TListOrgSshCertificatesDTO = { + offset: number; + limit: number; +} & TOrgPermission; export enum OrgAuthMethod { OIDC = "oidc", diff --git a/frontend/src/context/OrgPermissionContext/types.ts b/frontend/src/context/OrgPermissionContext/types.ts index 4678de7c4..3f3eb46e5 100644 --- a/frontend/src/context/OrgPermissionContext/types.ts +++ b/frontend/src/context/OrgPermissionContext/types.ts @@ -34,7 +34,8 @@ export enum OrgPermissionSubjects { AuditLogs = "audit-logs", ProjectTemplates = "project-templates", SshCertificateAuthorities = "ssh-certificate-authorities", - SshCertificateTemplates = "ssh-certificate-templates" + SshCertificateTemplates = "ssh-certificate-templates", + SshCertificates = "ssh-certificates" } export enum OrgPermissionAdminConsoleAction { @@ -60,6 +61,7 @@ export type OrgPermissionSet = | [OrgPermissionActions, OrgPermissionSubjects.AuditLogs] | [OrgPermissionActions, OrgPermissionSubjects.ProjectTemplates] | [OrgPermissionActions, OrgPermissionSubjects.SshCertificateAuthorities] + | [OrgPermissionActions, OrgPermissionSubjects.SshCertificates] | [OrgPermissionSshCertificateTemplateActions, OrgPermissionSubjects.SshCertificateTemplates]; export type TOrgPermission = MongoAbility; diff --git a/frontend/src/hooks/api/organization/index.ts b/frontend/src/hooks/api/organization/index.ts index 622a600b4..477b3bd8b 100644 --- a/frontend/src/hooks/api/organization/index.ts +++ b/frontend/src/hooks/api/organization/index.ts @@ -20,5 +20,7 @@ export { useGetOrgTaxIds, useGetOrgTrialUrl, useListOrgSshCas, + useListOrgSshCertificates, + useListOrgSshCertificateTemplates, useUpdateOrg, useUpdateOrgBillingDetails} from "./queries"; diff --git a/frontend/src/hooks/api/organization/queries.tsx b/frontend/src/hooks/api/organization/queries.tsx index f34c0ead1..9f4c83c1c 100644 --- a/frontend/src/hooks/api/organization/queries.tsx +++ b/frontend/src/hooks/api/organization/queries.tsx @@ -4,7 +4,8 @@ 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 { TSshCertificate,TSshCertificateAuthority } from "../ssh-ca/types"; +import { TSshCertificateTemplate } from "../sshCertificateTemplates/types"; import { IntegrationAuth } from "../types"; import { BillingDetails, @@ -43,7 +44,11 @@ export const organizationKeys = { [...organizationKeys.getOrgIdentityMemberships(orgId), params] as const, getOrgGroups: (orgId: string) => [{ orgId }, "organization-groups"] as const, getOrgIntegrationAuths: (orgId: string) => [{ orgId }, "integration-auths"] as const, - getOrgSshCas: ({ orgId }: { orgId: string }) => [{ orgId }, "org-ssh-cas"] as const + getOrgSshCas: ({ orgId }: { orgId: string }) => [{ orgId }, "org-ssh-cas"] as const, + allOrgSshCertificates: () => ["org-ssh-certificates"] as const, + specificOrgSshCertificates: ({ offset, limit }: { offset: number; limit: number }) => + [...organizationKeys.allOrgSshCertificates(), { offset, limit }] as const, + getOrgSshCertificateTemplates: () => ["org-ssh-certificate-templates"] as const }; export const fetchOrganizations = async () => { @@ -512,3 +517,48 @@ export const useListOrgSshCas = ({ orgId }: { orgId: string }) => { enabled: Boolean(orgId) }); }; + +export const useListOrgSshCertificates = ({ + orgId, + offset, + limit +}: { + orgId: string; + offset: number; + limit: number; +}) => { + return useQuery({ + queryKey: organizationKeys.specificOrgSshCertificates({ + offset, + limit + }), + queryFn: async () => { + const params = new URLSearchParams({ + offset: String(offset), + limit: String(limit) + }); + + const { data } = await apiRequest.get<{ + certificates: TSshCertificate[]; + totalCount: number; + }>(`/api/v1/organization/${orgId}/ssh-certificates`, { + params + }); + return data; + }, + enabled: Boolean(orgId) + }); +}; + +export const useListOrgSshCertificateTemplates = ({ orgId }: { orgId: string }) => { + return useQuery({ + queryKey: organizationKeys.getOrgSshCertificateTemplates(), + queryFn: async () => { + const { data } = await apiRequest.get<{ certificateTemplates: TSshCertificateTemplate[] }>( + `/api/v1/organization/${orgId}/ssh-certificate-templates` + ); + return data; + }, + enabled: Boolean(orgId) + }); +}; diff --git a/frontend/src/hooks/api/ssh-ca/enums.tsx b/frontend/src/hooks/api/ssh-ca/constants.tsx similarity index 50% rename from frontend/src/hooks/api/ssh-ca/enums.tsx rename to frontend/src/hooks/api/ssh-ca/constants.tsx index 3b5e949a9..2742a7bfa 100644 --- a/frontend/src/hooks/api/ssh-ca/enums.tsx +++ b/frontend/src/hooks/api/ssh-ca/constants.tsx @@ -7,3 +7,8 @@ export enum SshCertType { USER = "user", HOST = "host" } + +export const sshCertTypeToNameMap: { [K in SshCertType]: string } = { + [SshCertType.USER]: "User", + [SshCertType.HOST]: "Host" +}; diff --git a/frontend/src/hooks/api/ssh-ca/index.tsx b/frontend/src/hooks/api/ssh-ca/index.tsx index 0bdd4eb61..8fc57654b 100644 --- a/frontend/src/hooks/api/ssh-ca/index.tsx +++ b/frontend/src/hooks/api/ssh-ca/index.tsx @@ -1,8 +1,9 @@ -export { SshCaStatus } from "./enums"; +export { SshCaStatus } from "./constants"; export { useCreateSshCa, useDeleteSshCa, useIssueSshCreds, useSignSshKey, - useUpdateSshCa} from "./mutations"; + 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 index a2eb61ed4..811e09d18 100644 --- a/frontend/src/hooks/api/ssh-ca/mutations.tsx +++ b/frontend/src/hooks/api/ssh-ca/mutations.tsx @@ -11,7 +11,8 @@ import { TSignSshKeyDTO, TSignSshKeyResponse, TSshCertificateAuthority, - TUpdateSshCaDTO} from "./types"; + TUpdateSshCaDTO +} from "./types"; export const sshCaKeys = { getSshCaById: (caId: string) => [{ caId }, "ssh-ca"] @@ -64,19 +65,27 @@ export const useDeleteSshCa = () => { }; export const useSignSshKey = () => { + const queryClient = useQueryClient(); return useMutation({ mutationFn: async (body) => { const { data } = await apiRequest.post("/api/v1/ssh/sign", body); return data; + }, + onSuccess: () => { + queryClient.invalidateQueries(organizationKeys.allOrgSshCertificates()); } }); }; export const useIssueSshCreds = () => { + const queryClient = useQueryClient(); return useMutation({ mutationFn: async (body) => { const { data } = await apiRequest.post("/api/v1/ssh/issue", body); return data; + }, + onSuccess: () => { + queryClient.invalidateQueries(organizationKeys.allOrgSshCertificates()); } }); }; diff --git a/frontend/src/hooks/api/ssh-ca/types.ts b/frontend/src/hooks/api/ssh-ca/types.ts index 454e0f41a..747802c32 100644 --- a/frontend/src/hooks/api/ssh-ca/types.ts +++ b/frontend/src/hooks/api/ssh-ca/types.ts @@ -1,5 +1,16 @@ import { CertKeyAlgorithm } from "../certificates/enums"; -import { SshCaStatus, SshCertType } from "./enums"; +import { SshCaStatus, SshCertType } from "./constants"; + +export type TSshCertificate = { + id: string; + sshCaId: string; + sshCertificateTemplateId: string; + serialNumber: string; + certType: SshCertType; + publicKey: string; + principals: string[]; + keyId: string; +}; export type TSshCertificateAuthority = { id: string; diff --git a/frontend/src/pages/org/[id]/ssh/index.tsx b/frontend/src/pages/org/[id]/ssh/index.tsx index 1117806a8..ccd2001b7 100644 --- a/frontend/src/pages/org/[id]/ssh/index.tsx +++ b/frontend/src/pages/org/[id]/ssh/index.tsx @@ -3,11 +3,8 @@ import Head from "next/head"; import { SshPage } from "@app/views/Org/SshPage"; -// TODO: update meta tags - const Ssh = () => { const { t } = useTranslation(); - return ( <> diff --git a/frontend/src/views/Org/RolePage/components/OrgRoleModifySection.utils.ts b/frontend/src/views/Org/RolePage/components/OrgRoleModifySection.utils.ts index 14fb5e585..aea52d8c3 100644 --- a/frontend/src/views/Org/RolePage/components/OrgRoleModifySection.utils.ts +++ b/frontend/src/views/Org/RolePage/components/OrgRoleModifySection.utils.ts @@ -13,6 +13,13 @@ const generalPermissionSchema = z }) .optional(); +const sshCertificateSchema = z + .object({ + read: z.boolean().optional(), + create: z.boolean().optional() + }) + .optional(); + const sshCertificateTemplatePermissionSchmea = z .object({ read: z.boolean().optional(), @@ -62,6 +69,7 @@ export const formSchema = z.object({ [OrgPermissionSubjects.Kms]: generalPermissionSchema, [OrgPermissionSubjects.ProjectTemplates]: generalPermissionSchema, [OrgPermissionSubjects.SshCertificateAuthorities]: generalPermissionSchema, + [OrgPermissionSubjects.SshCertificates]: sshCertificateSchema, "ssh-certificate-templates": sshCertificateTemplatePermissionSchmea }) .optional() diff --git a/frontend/src/views/Org/RolePage/components/RolePermissionsSection/RolePermissionRow.tsx b/frontend/src/views/Org/RolePage/components/RolePermissionsSection/RolePermissionRow.tsx index 3a50dc976..4a5699ec6 100644 --- a/frontend/src/views/Org/RolePage/components/RolePermissionsSection/RolePermissionRow.tsx +++ b/frontend/src/views/Org/RolePage/components/RolePermissionsSection/RolePermissionRow.tsx @@ -51,6 +51,11 @@ const PROJECT_TEMPLATES_PERMISSIONS = [ { action: "delete", label: "Remove" } ] as const; +const SSH_CERTIFICATES_PERMISSIONS = [ + { action: "read", label: "View" }, + { action: "create", label: "Create" } +] as const; + const getPermissionList = (option: string) => { switch (option) { case "secret-scanning": @@ -63,6 +68,8 @@ const getPermissionList = (option: string) => { return MEMBERS_PERMISSIONS; case OrgPermissionSubjects.ProjectTemplates: return PROJECT_TEMPLATES_PERMISSIONS; + case OrgPermissionSubjects.SshCertificates: + return SSH_CERTIFICATES_PERMISSIONS; default: return PERMISSIONS; } diff --git a/frontend/src/views/Org/RolePage/components/RolePermissionsSection/RolePermissionsSection.tsx b/frontend/src/views/Org/RolePage/components/RolePermissionsSection/RolePermissionsSection.tsx index 46310566c..f4fcc84f9 100644 --- a/frontend/src/views/Org/RolePage/components/RolePermissionsSection/RolePermissionsSection.tsx +++ b/frontend/src/views/Org/RolePage/components/RolePermissionsSection/RolePermissionsSection.tsx @@ -15,7 +15,6 @@ import { import { OrgPermissionAdminConsoleRow } from "./OrgPermissionAdminConsoleRow"; import { OrgRoleWorkspaceRow } from "./OrgRoleWorkspaceRow"; import { RolePermissionRow } from "./RolePermissionRow"; -import { SshCertificateTemplateRow } from "./SshCertificateTemplateRow"; const SIMPLE_PERMISSION_OPTIONS = [ { @@ -74,6 +73,14 @@ const SIMPLE_PERMISSION_OPTIONS = [ { title: "SSH Certificate Authorities", formName: OrgPermissionSubjects.SshCertificateAuthorities + }, + { + title: "SSH Certificates", + formName: OrgPermissionSubjects.SshCertificates + }, + { + title: "SSH Certificate Templates", + formName: OrgPermissionSubjects.SshCertificateTemplates } ] as const; @@ -169,11 +176,6 @@ export const RolePermissionsSection = ({ roleId }: Props) => { /> ); })} - ; - control: Control; -}; - -enum Permission { - NoAccess = "no-access", - Custom = "custom" -} - -const PERMISSION_ACTIONS = [ - { action: "read", label: "Read" }, - { action: "create", label: "Create" }, - { action: "edit", label: "Modify" }, - { action: "delete", label: "Remove" }, - { action: "sign-ssh-key", label: "Sign SSH Key" }, - { action: "issue-ssh-credentials", label: "Issue SSH Credentials" } -] as const; - -export const SshCertificateTemplateRow = ({ isEditable, control, setValue }: Props) => { - const [isRowExpanded, setIsRowExpanded] = useToggle(); - const [isCustom, setIsCustom] = useToggle(); - - const rule = useWatch({ - control, - name: "permissions.ssh-certificate-templates" - }); - - const selectedPermissionCategory = useMemo(() => { - if (rule?.create) { - return Permission.Custom; - } - return Permission.NoAccess; - }, [rule, isCustom]); - - useEffect(() => { - if (selectedPermissionCategory === Permission.Custom) setIsCustom.on(); - else setIsCustom.off(); - }, [selectedPermissionCategory]); - - useEffect(() => { - const isRowCustom = selectedPermissionCategory === Permission.Custom; - if (isRowCustom) { - setIsRowExpanded.on(); - } - }, []); - - const handlePermissionChange = (val: Permission) => { - if (!val) return; - if (val === Permission.Custom) { - setIsRowExpanded.on(); - setIsCustom.on(); - return; - } - setIsCustom.off(); - - if (val === Permission.NoAccess) { - setValue("permissions.workspace", { create: false }, { shouldDirty: true }); - } - }; - - return ( - <> - setIsRowExpanded.toggle()} - > - - - - SSH Certificate Templates - - - - - {isRowExpanded && ( - - -
- {PERMISSION_ACTIONS.map(({ action, label }) => { - return ( - ( - { - if (!isEditable) { - createNotification({ - type: "error", - text: "Failed to update default role" - }); - return; - } - field.onChange(e); - }} - id={`permissions.${OrgPermissionSubjects.SshCertificateTemplates}.${action}`} - > - {label} - - )} - /> - ); - })} -
- - - )} - - ); -}; diff --git a/frontend/src/views/Org/SshCaPage/components/SshCertificateContent.tsx b/frontend/src/views/Org/SshCaPage/components/SshCertificateContent.tsx index fb462742e..83aff5730 100644 --- a/frontend/src/views/Org/SshCaPage/components/SshCertificateContent.tsx +++ b/frontend/src/views/Org/SshCaPage/components/SshCertificateContent.tsx @@ -164,7 +164,7 @@ export const SshCertificateContent = ({ -
+

{publicKey}

diff --git a/frontend/src/views/Org/SshCaPage/components/SshCertificateModal.tsx b/frontend/src/views/Org/SshCaPage/components/SshCertificateModal.tsx index aa9ccbc88..0aa8cdaa8 100644 --- a/frontend/src/views/Org/SshCaPage/components/SshCertificateModal.tsx +++ b/frontend/src/views/Org/SshCaPage/components/SshCertificateModal.tsx @@ -13,7 +13,8 @@ import { Select, SelectItem } from "@app/components/v2"; -import { useGetSshCaCertTemplates, useIssueSshCreds, useSignSshKey } from "@app/hooks/api"; +import { useOrganization } from "@app/context"; +import { useIssueSshCreds, useListOrgSshCertificateTemplates, useSignSshKey } from "@app/hooks/api"; import { certKeyAlgorithms } from "@app/hooks/api/certificates/constants"; import { CertKeyAlgorithm } from "@app/hooks/api/certificates/enums"; import { SshCertType } from "@app/hooks/api/ssh-ca/enums"; @@ -62,6 +63,7 @@ enum SshCertificateOperation { } export const SshCertificateModal = ({ popUp, handlePopUpToggle }: Props) => { + const { currentOrg } = useOrganization(); const [operation, setOperation] = useState( SshCertificateOperation.SIGN_SSH_KEY ); @@ -72,7 +74,9 @@ export const SshCertificateModal = ({ popUp, handlePopUpToggle }: Props) => { const popUpData = popUp?.sshCertificate?.data as { sshCaId: string; templateName: string }; - const { data: templatesData } = useGetSshCaCertTemplates(popUpData?.sshCaId || ""); + const { data: templatesData } = useListOrgSshCertificateTemplates({ + orgId: currentOrg?.id || "" + }); const { control, @@ -91,6 +95,8 @@ export const SshCertificateModal = ({ popUp, handlePopUpToggle }: Props) => { useEffect(() => { if (popUpData) { setValue("templateName", popUpData.templateName); + } else if (templatesData && templatesData.certificateTemplates.length > 0) { + setValue("templateName", templatesData.certificateTemplates[0].name); } }, [popUpData]); @@ -114,6 +120,7 @@ export const SshCertificateModal = ({ popUp, handlePopUpToggle }: Props) => { ttl, keyId }); + setCertificateDetails({ serialNumber, signedKey @@ -186,7 +193,7 @@ export const SshCertificateModal = ({ popUp, handlePopUpToggle }: Props) => { {...field} onValueChange={(e) => onChange(e)} className="w-full" - isDisabled + isDisabled={Boolean(popUpData?.sshCaId)} > {(templatesData?.certificateTemplates || []).map(({ id, name }) => ( diff --git a/frontend/src/views/Org/SshPage/SshPage.tsx b/frontend/src/views/Org/SshPage/SshPage.tsx index 653973faf..ff373749a 100644 --- a/frontend/src/views/Org/SshPage/SshPage.tsx +++ b/frontend/src/views/Org/SshPage/SshPage.tsx @@ -1,7 +1,15 @@ +import { motion } from "framer-motion"; + +import { Tab, TabList, TabPanel, Tabs } from "@app/components/v2"; import { OrgPermissionActions, OrgPermissionSubjects } from "@app/context"; import { withPermission } from "@app/hoc"; -import { SshCaSection } from "./components"; +import { SshCaSection, SshCertificatesSection } from "./components"; + +enum TabSections { + SshCa = "ssh-certificate-authorities", + SshCertificates = "ssh-certificates" +} export const SshPage = withPermission( () => { @@ -9,7 +17,34 @@ export const SshPage = withPermission(

SSH

- + + + SSH Certificates + Certificate Authorities + + + + + + + + + + + +
); diff --git a/frontend/src/views/Org/SshPage/components/SshCertificatesSection.tsx b/frontend/src/views/Org/SshPage/components/SshCertificatesSection.tsx new file mode 100644 index 000000000..49b4cfc4a --- /dev/null +++ b/frontend/src/views/Org/SshPage/components/SshCertificatesSection.tsx @@ -0,0 +1,36 @@ +import { faPlus } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; + +import { OrgPermissionCan } from "@app/components/permissions"; +import { Button } from "@app/components/v2"; +import { OrgPermissionActions, OrgPermissionSubjects } from "@app/context"; +import { usePopUp } from "@app/hooks/usePopUp"; + +import { SshCertificateModal } from "../../SshCaPage/components/SshCertificateModal"; +import { SshCertificatesTable } from "./SshCertificatesTable"; + +export const SshCertificatesSection = () => { + const { popUp, handlePopUpOpen, handlePopUpToggle } = usePopUp(["sshCertificate"] as const); + return ( +
+
+

Certificates

+ + {(isAllowed) => ( + + )} + +
+ + +
+ ); +}; diff --git a/frontend/src/views/Org/SshPage/components/SshCertificatesTable.tsx b/frontend/src/views/Org/SshPage/components/SshCertificatesTable.tsx new file mode 100644 index 000000000..4ef0e78ab --- /dev/null +++ b/frontend/src/views/Org/SshPage/components/SshCertificatesTable.tsx @@ -0,0 +1,70 @@ +import { useState } from "react"; +import { faCertificate } from "@fortawesome/free-solid-svg-icons"; + +import { + EmptyState, + Pagination, + Table, + TableContainer, + TableSkeleton, + TBody, + Td, + Th, + THead, + Tr} from "@app/components/v2"; +import { useOrganization } from "@app/context"; +import { useListOrgSshCertificates } from "@app/hooks/api"; +import { sshCertTypeToNameMap } from "@app/hooks/api/ssh-ca/constants"; + +const PER_PAGE_INIT = 25; + +export const SshCertificatesTable = () => { + const { currentOrg } = useOrganization(); + const [page, setPage] = useState(1); + const [perPage, setPerPage] = useState(PER_PAGE_INIT); + + const { data, isLoading } = useListOrgSshCertificates({ + orgId: currentOrg?.id ?? "", + offset: (page - 1) * perPage, + limit: perPage + }); + + return ( + + + + + + + + + + + {isLoading && } + {!isLoading && + data?.certificates?.map((certificate) => { + return ( + + + + + + ); + })} + +
Serial NumberCertificate TypePrincipals
{certificate.serialNumber}{sshCertTypeToNameMap[certificate.certType]}{certificate.principals.join(", ")}
+ {!isLoading && data?.totalCount !== undefined && data.totalCount >= PER_PAGE_INIT && ( + setPage(newPage)} + onChangePerPage={(newPerPage) => setPerPage(newPerPage)} + /> + )} + {!isLoading && !data?.certificates?.length && ( + + )} +
+ ); +}; diff --git a/frontend/src/views/Org/SshPage/components/index.tsx b/frontend/src/views/Org/SshPage/components/index.tsx index 0ba2a04c7..efb307725 100644 --- a/frontend/src/views/Org/SshPage/components/index.tsx +++ b/frontend/src/views/Org/SshPage/components/index.tsx @@ -1 +1,2 @@ export { SshCaSection } from "./SshCaSection"; +export { SshCertificatesSection } from "./SshCertificatesSection"; From ff3d8c896bcb2509cf891499d962c6057037bb9e Mon Sep 17 00:00:00 2001 From: Tuan Dang Date: Thu, 5 Dec 2024 23:06:04 -0800 Subject: [PATCH 013/162] Fix frontend lint issues --- frontend/src/context/OrgPermissionContext/types.ts | 9 --------- .../components/OrgRoleModifySection.utils.ts | 13 +------------ 2 files changed, 1 insertion(+), 21 deletions(-) diff --git a/frontend/src/context/OrgPermissionContext/types.ts b/frontend/src/context/OrgPermissionContext/types.ts index 3f3eb46e5..c0bdd8029 100644 --- a/frontend/src/context/OrgPermissionContext/types.ts +++ b/frontend/src/context/OrgPermissionContext/types.ts @@ -7,15 +7,6 @@ export enum OrgPermissionActions { Delete = "delete" } -export enum OrgPermissionSshCertificateTemplateActions { - Read = "read", - Create = "create", - Edit = "edit", - Delete = "delete", - SignSshKey = "sign-ssh-key", - IssueSshCredentials = "issue-ssh-credentials" -} - export enum OrgPermissionSubjects { Workspace = "workspace", Role = "role", diff --git a/frontend/src/views/Org/RolePage/components/OrgRoleModifySection.utils.ts b/frontend/src/views/Org/RolePage/components/OrgRoleModifySection.utils.ts index aea52d8c3..b155e21cb 100644 --- a/frontend/src/views/Org/RolePage/components/OrgRoleModifySection.utils.ts +++ b/frontend/src/views/Org/RolePage/components/OrgRoleModifySection.utils.ts @@ -20,17 +20,6 @@ const sshCertificateSchema = z }) .optional(); -const sshCertificateTemplatePermissionSchmea = z - .object({ - read: z.boolean().optional(), - edit: z.boolean().optional(), - delete: z.boolean().optional(), - create: z.boolean().optional(), - "sign-ssh-key": z.boolean().optional(), - "issue-ssh-credentials": z.boolean().optional() - }) - .optional(); - const adminConsolePermissionSchmea = z .object({ "access-all-projects": z.boolean().optional() @@ -70,7 +59,7 @@ export const formSchema = z.object({ [OrgPermissionSubjects.ProjectTemplates]: generalPermissionSchema, [OrgPermissionSubjects.SshCertificateAuthorities]: generalPermissionSchema, [OrgPermissionSubjects.SshCertificates]: sshCertificateSchema, - "ssh-certificate-templates": sshCertificateTemplatePermissionSchmea + [OrgPermissionSubjects.SshCertificateTemplates]: generalPermissionSchema }) .optional() }); From 82a4b89bb5ee4b98b286d995bc9084e1ac68530d Mon Sep 17 00:00:00 2001 From: Tuan Dang Date: Thu, 5 Dec 2024 23:09:04 -0800 Subject: [PATCH 014/162] Fix invalid file path for ssh --- .../src/views/Org/SshCaPage/components/SshCertificateModal.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/views/Org/SshCaPage/components/SshCertificateModal.tsx b/frontend/src/views/Org/SshCaPage/components/SshCertificateModal.tsx index 0aa8cdaa8..3238160e9 100644 --- a/frontend/src/views/Org/SshCaPage/components/SshCertificateModal.tsx +++ b/frontend/src/views/Org/SshCaPage/components/SshCertificateModal.tsx @@ -17,7 +17,7 @@ import { useOrganization } from "@app/context"; import { useIssueSshCreds, useListOrgSshCertificateTemplates, useSignSshKey } from "@app/hooks/api"; import { certKeyAlgorithms } from "@app/hooks/api/certificates/constants"; import { CertKeyAlgorithm } from "@app/hooks/api/certificates/enums"; -import { SshCertType } from "@app/hooks/api/ssh-ca/enums"; +import { SshCertType } from "@app/hooks/api/ssh-ca/constants"; import { UsePopUpState } from "@app/hooks/usePopUp"; import { SshCertificateContent } from "./SshCertificateContent"; From ec1ce3dc06ef5876bc0ac8635ca1d467c0031eaa Mon Sep 17 00:00:00 2001 From: Tuan Dang Date: Thu, 5 Dec 2024 23:16:31 -0800 Subject: [PATCH 015/162] Fix type issues --- frontend/src/context/OrgPermissionContext/index.tsx | 5 +---- frontend/src/context/OrgPermissionContext/types.ts | 2 +- frontend/src/context/index.tsx | 1 - .../RolePage/components/OrgRoleModifySection.utils.ts | 9 +-------- .../RolePermissionsSection/RolePermissionRow.tsx | 7 ------- .../components/SshCertificateTemplatesSection.tsx | 4 ++-- .../components/SshCertificateTemplatesTable.tsx | 10 +++++----- .../Org/SshPage/components/SshCertificatesTable.tsx | 5 ++++- 8 files changed, 14 insertions(+), 29 deletions(-) diff --git a/frontend/src/context/OrgPermissionContext/index.tsx b/frontend/src/context/OrgPermissionContext/index.tsx index 10d7703b8..730fe55b1 100644 --- a/frontend/src/context/OrgPermissionContext/index.tsx +++ b/frontend/src/context/OrgPermissionContext/index.tsx @@ -1,6 +1,3 @@ export { OrgPermissionProvider, useOrgPermission } from "./OrgPermissionContext"; export type { TOrgPermission } from "./types"; -export { - OrgPermissionActions, - OrgPermissionSshCertificateTemplateActions, - OrgPermissionSubjects} from "./types"; +export { OrgPermissionActions, OrgPermissionSubjects } from "./types"; diff --git a/frontend/src/context/OrgPermissionContext/types.ts b/frontend/src/context/OrgPermissionContext/types.ts index c0bdd8029..ae2c95736 100644 --- a/frontend/src/context/OrgPermissionContext/types.ts +++ b/frontend/src/context/OrgPermissionContext/types.ts @@ -53,6 +53,6 @@ export type OrgPermissionSet = | [OrgPermissionActions, OrgPermissionSubjects.ProjectTemplates] | [OrgPermissionActions, OrgPermissionSubjects.SshCertificateAuthorities] | [OrgPermissionActions, OrgPermissionSubjects.SshCertificates] - | [OrgPermissionSshCertificateTemplateActions, OrgPermissionSubjects.SshCertificateTemplates]; + | [OrgPermissionActions, OrgPermissionSubjects.SshCertificateTemplates]; export type TOrgPermission = MongoAbility; diff --git a/frontend/src/context/index.tsx b/frontend/src/context/index.tsx index 83e771111..91dae5d2d 100644 --- a/frontend/src/context/index.tsx +++ b/frontend/src/context/index.tsx @@ -4,7 +4,6 @@ export type { TOrgPermission } from "./OrgPermissionContext"; export { OrgPermissionActions, OrgPermissionProvider, - OrgPermissionSshCertificateTemplateActions, OrgPermissionSubjects, useOrgPermission } from "./OrgPermissionContext"; diff --git a/frontend/src/views/Org/RolePage/components/OrgRoleModifySection.utils.ts b/frontend/src/views/Org/RolePage/components/OrgRoleModifySection.utils.ts index b155e21cb..b9da49ca7 100644 --- a/frontend/src/views/Org/RolePage/components/OrgRoleModifySection.utils.ts +++ b/frontend/src/views/Org/RolePage/components/OrgRoleModifySection.utils.ts @@ -13,13 +13,6 @@ const generalPermissionSchema = z }) .optional(); -const sshCertificateSchema = z - .object({ - read: z.boolean().optional(), - create: z.boolean().optional() - }) - .optional(); - const adminConsolePermissionSchmea = z .object({ "access-all-projects": z.boolean().optional() @@ -58,7 +51,7 @@ export const formSchema = z.object({ [OrgPermissionSubjects.Kms]: generalPermissionSchema, [OrgPermissionSubjects.ProjectTemplates]: generalPermissionSchema, [OrgPermissionSubjects.SshCertificateAuthorities]: generalPermissionSchema, - [OrgPermissionSubjects.SshCertificates]: sshCertificateSchema, + [OrgPermissionSubjects.SshCertificates]: generalPermissionSchema, [OrgPermissionSubjects.SshCertificateTemplates]: generalPermissionSchema }) .optional() diff --git a/frontend/src/views/Org/RolePage/components/RolePermissionsSection/RolePermissionRow.tsx b/frontend/src/views/Org/RolePage/components/RolePermissionsSection/RolePermissionRow.tsx index 4a5699ec6..3a50dc976 100644 --- a/frontend/src/views/Org/RolePage/components/RolePermissionsSection/RolePermissionRow.tsx +++ b/frontend/src/views/Org/RolePage/components/RolePermissionsSection/RolePermissionRow.tsx @@ -51,11 +51,6 @@ const PROJECT_TEMPLATES_PERMISSIONS = [ { action: "delete", label: "Remove" } ] as const; -const SSH_CERTIFICATES_PERMISSIONS = [ - { action: "read", label: "View" }, - { action: "create", label: "Create" } -] as const; - const getPermissionList = (option: string) => { switch (option) { case "secret-scanning": @@ -68,8 +63,6 @@ const getPermissionList = (option: string) => { return MEMBERS_PERMISSIONS; case OrgPermissionSubjects.ProjectTemplates: return PROJECT_TEMPLATES_PERMISSIONS; - case OrgPermissionSubjects.SshCertificates: - return SSH_CERTIFICATES_PERMISSIONS; default: return PERMISSIONS; } diff --git a/frontend/src/views/Org/SshCaPage/components/SshCertificateTemplatesSection.tsx b/frontend/src/views/Org/SshCaPage/components/SshCertificateTemplatesSection.tsx index 75db07ff6..9a66f6622 100644 --- a/frontend/src/views/Org/SshCaPage/components/SshCertificateTemplatesSection.tsx +++ b/frontend/src/views/Org/SshCaPage/components/SshCertificateTemplatesSection.tsx @@ -4,7 +4,7 @@ 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 { OrgPermissionSshCertificateTemplateActions, OrgPermissionSubjects } from "@app/context"; +import { OrgPermissionActions, OrgPermissionSubjects } from "@app/context"; import { usePopUp } from "@app/hooks"; import { useDeleteSshCertTemplate } from "@app/hooks/api"; @@ -52,7 +52,7 @@ export const SshCertificateTemplatesSection = ({ caId }: Props) => {

Certificate Templates

{(isAllowed) => ( diff --git a/frontend/src/views/Org/SshCaPage/components/SshCertificateTemplatesTable.tsx b/frontend/src/views/Org/SshCaPage/components/SshCertificateTemplatesTable.tsx index 62cf1a8b4..8104a6442 100644 --- a/frontend/src/views/Org/SshCaPage/components/SshCertificateTemplatesTable.tsx +++ b/frontend/src/views/Org/SshCaPage/components/SshCertificateTemplatesTable.tsx @@ -1,4 +1,4 @@ -import { faCertificate,faEllipsis, faFileAlt, faTrash } from "@fortawesome/free-solid-svg-icons"; +import { faCertificate, faEllipsis, faFileAlt, faTrash } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { twMerge } from "tailwind-merge"; @@ -19,7 +19,7 @@ import { Tooltip, Tr } from "@app/components/v2"; -import { OrgPermissionSshCertificateTemplateActions, OrgPermissionSubjects } from "@app/context"; +import { OrgPermissionActions, OrgPermissionSubjects } from "@app/context"; import { useGetSshCaCertTemplates } from "@app/hooks/api"; import { UsePopUpState } from "@app/hooks/usePopUp"; @@ -69,7 +69,7 @@ export const SshCertificateTemplatesTable = ({ handlePopUpOpen, sshCaId }: Props {(isAllowed) => ( diff --git a/frontend/src/views/Org/SshPage/components/SshCertificatesTable.tsx b/frontend/src/views/Org/SshPage/components/SshCertificatesTable.tsx index 4ef0e78ab..c696919bb 100644 --- a/frontend/src/views/Org/SshPage/components/SshCertificatesTable.tsx +++ b/frontend/src/views/Org/SshPage/components/SshCertificatesTable.tsx @@ -11,7 +11,8 @@ import { Td, Th, THead, - Tr} from "@app/components/v2"; + Tr +} from "@app/components/v2"; import { useOrganization } from "@app/context"; import { useListOrgSshCertificates } from "@app/hooks/api"; import { sshCertTypeToNameMap } from "@app/hooks/api/ssh-ca/constants"; @@ -29,6 +30,8 @@ export const SshCertificatesTable = () => { limit: perPage }); + console.log("SSH Certificates Table data: ", data); + return ( From 3c588beebee352d849885327a5e2f5ca32d8b3d8 Mon Sep 17 00:00:00 2001 From: McPizza Date: Sun, 8 Dec 2024 14:02:33 +0100 Subject: [PATCH 016/162] improvement: Slug Validation Errors (#2788) * improvement: Slug Validation Errors --- .../src/ee/routes/v1/dynamic-secret-router.ts | 12 +----- backend/src/ee/routes/v1/group-router.ts | 21 ++-------- ...ity-project-additional-privilege-router.ts | 40 +++---------------- backend/src/ee/routes/v1/org-role-router.ts | 29 +++++--------- .../src/ee/routes/v1/project-role-router.ts | 27 +++---------- .../ee/routes/v1/project-template-router.ts | 35 +++++++--------- .../v1/user-additional-privilege-router.ts | 23 ++--------- ...ity-project-additional-privilege-router.ts | 24 ++--------- .../src/ee/routes/v2/project-role-router.ts | 27 +++---------- backend/src/server/lib/schemas.ts | 23 +++++++++++ backend/src/server/routes/v1/cmek-router.ts | 12 +----- .../external-group-org-role-mapping-router.ts | 10 +---- .../server/routes/v1/organization-router.ts | 18 ++------- .../server/routes/v1/project-env-router.ts | 19 ++------- .../src/server/routes/v1/secret-tag-router.ts | 20 ++-------- backend/src/server/routes/v1/slack-router.ts | 17 ++------ .../src/server/routes/v2/project-router.ts | 36 ++++------------- .../tags/CreateTagModal/CreateTagModal.tsx | 10 +---- frontend/src/hooks/api/kms/types.ts | 11 ++--- frontend/src/lib/schemas/slugSchema.ts | 29 +++++++++----- .../Org/RolePage/components/RoleModal.tsx | 3 +- .../Project/KmsPage/components/CmekModal.tsx | 11 +---- .../Project/RolePage/components/RoleModal.tsx | 3 +- .../SlackIntegrationForm.tsx | 10 +---- .../ProjectTemplateEditRoleForm.tsx | 2 +- .../ProjectTemplateEnvironmentsForm.tsx | 2 +- .../ProjectTemplateDetailsModal.tsx | 12 +----- .../AddEnvironmentModal.tsx | 30 +++++++------- .../UpdateEnvironmentModal.tsx | 23 ++++------- .../SecretTagsSection/AddSecretTagModal.tsx | 6 +-- 30 files changed, 160 insertions(+), 385 deletions(-) create mode 100644 backend/src/server/lib/schemas.ts diff --git a/backend/src/ee/routes/v1/dynamic-secret-router.ts b/backend/src/ee/routes/v1/dynamic-secret-router.ts index 4b1566c55..1d24c0578 100644 --- a/backend/src/ee/routes/v1/dynamic-secret-router.ts +++ b/backend/src/ee/routes/v1/dynamic-secret-router.ts @@ -1,4 +1,3 @@ -import slugify from "@sindresorhus/slugify"; import ms from "ms"; import { z } from "zod"; @@ -8,6 +7,7 @@ import { DYNAMIC_SECRETS } from "@app/lib/api-docs"; import { daysToMillisecond } from "@app/lib/dates"; import { removeTrailingSlash } from "@app/lib/fn"; import { readLimit, writeLimit } from "@app/server/config/rateLimiter"; +import { slugSchema } from "@app/server/lib/schemas"; import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; import { SanitizedDynamicSecretSchema } from "@app/server/routes/sanitizedSchemas"; import { AuthMode } from "@app/services/auth/auth-type"; @@ -48,15 +48,7 @@ export const registerDynamicSecretRouter = async (server: FastifyZodProvider) => .nullable(), path: z.string().describe(DYNAMIC_SECRETS.CREATE.path).trim().default("/").transform(removeTrailingSlash), environmentSlug: z.string().describe(DYNAMIC_SECRETS.CREATE.environmentSlug).min(1), - name: z - .string() - .describe(DYNAMIC_SECRETS.CREATE.name) - .min(1) - .toLowerCase() - .max(64) - .refine((v) => slugify(v) === v, { - message: "Slug must be a valid" - }) + name: slugSchema({ min: 1, max: 64, field: "Name" }).describe(DYNAMIC_SECRETS.CREATE.name) }), response: { 200: z.object({ diff --git a/backend/src/ee/routes/v1/group-router.ts b/backend/src/ee/routes/v1/group-router.ts index 780e5ec00..b2f1762d1 100644 --- a/backend/src/ee/routes/v1/group-router.ts +++ b/backend/src/ee/routes/v1/group-router.ts @@ -1,8 +1,8 @@ -import slugify from "@sindresorhus/slugify"; import { z } from "zod"; import { GroupsSchema, OrgMembershipRole, UsersSchema } from "@app/db/schemas"; import { GROUPS } from "@app/lib/api-docs"; +import { slugSchema } from "@app/server/lib/schemas"; import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; import { AuthMode } from "@app/services/auth/auth-type"; @@ -14,15 +14,7 @@ export const registerGroupRouter = async (server: FastifyZodProvider) => { schema: { body: z.object({ name: z.string().trim().min(1).max(50).describe(GROUPS.CREATE.name), - slug: z - .string() - .min(5) - .max(36) - .refine((v) => slugify(v) === v, { - message: "Slug must be a valid slug" - }) - .optional() - .describe(GROUPS.CREATE.slug), + slug: slugSchema({ min: 5, max: 36 }).optional().describe(GROUPS.CREATE.slug), role: z.string().trim().min(1).default(OrgMembershipRole.NoAccess).describe(GROUPS.CREATE.role) }), response: { @@ -100,14 +92,7 @@ export const registerGroupRouter = async (server: FastifyZodProvider) => { body: z .object({ name: z.string().trim().min(1).describe(GROUPS.UPDATE.name), - slug: z - .string() - .min(5) - .max(36) - .refine((v) => slugify(v) === v, { - message: "Slug must be a valid slug" - }) - .describe(GROUPS.UPDATE.slug), + slug: slugSchema({ min: 5, max: 36 }).describe(GROUPS.UPDATE.slug), role: z.string().trim().min(1).describe(GROUPS.UPDATE.role) }) .partial(), diff --git a/backend/src/ee/routes/v1/identity-project-additional-privilege-router.ts b/backend/src/ee/routes/v1/identity-project-additional-privilege-router.ts index d342f95ce..1eadb4051 100644 --- a/backend/src/ee/routes/v1/identity-project-additional-privilege-router.ts +++ b/backend/src/ee/routes/v1/identity-project-additional-privilege-router.ts @@ -8,6 +8,7 @@ import { IDENTITY_ADDITIONAL_PRIVILEGE } from "@app/lib/api-docs"; import { UnauthorizedError } from "@app/lib/errors"; import { alphaNumericNanoId } from "@app/lib/nanoid"; import { readLimit, writeLimit } from "@app/server/config/rateLimiter"; +import { slugSchema } from "@app/server/lib/schemas"; import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; import { ProjectPermissionSchema, @@ -33,17 +34,7 @@ export const registerIdentityProjectAdditionalPrivilegeRouter = async (server: F body: z.object({ identityId: z.string().min(1).describe(IDENTITY_ADDITIONAL_PRIVILEGE.CREATE.identityId), projectSlug: z.string().min(1).describe(IDENTITY_ADDITIONAL_PRIVILEGE.CREATE.projectSlug), - slug: z - .string() - .min(1) - .max(60) - .trim() - .refine((val) => val.toLowerCase() === val, "Must be lowercase") - .refine((v) => slugify(v) === v, { - message: "Slug must be a valid slug" - }) - .optional() - .describe(IDENTITY_ADDITIONAL_PRIVILEGE.CREATE.slug), + slug: slugSchema({ min: 1, max: 60 }).optional().describe(IDENTITY_ADDITIONAL_PRIVILEGE.CREATE.slug), permissions: ProjectPermissionSchema.array() .describe(IDENTITY_ADDITIONAL_PRIVILEGE.CREATE.permissions) .optional(), @@ -77,7 +68,7 @@ export const registerIdentityProjectAdditionalPrivilegeRouter = async (server: F actorOrgId: req.permission.orgId, actorAuthMethod: req.permission.authMethod, ...req.body, - slug: req.body.slug ? slugify(req.body.slug) : slugify(alphaNumericNanoId(12)), + slug: req.body.slug ?? slugify(alphaNumericNanoId(12)), isTemporary: false, // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore-error this is valid ts @@ -103,17 +94,7 @@ export const registerIdentityProjectAdditionalPrivilegeRouter = async (server: F body: z.object({ identityId: z.string().min(1).describe(IDENTITY_ADDITIONAL_PRIVILEGE.CREATE.identityId), projectSlug: z.string().min(1).describe(IDENTITY_ADDITIONAL_PRIVILEGE.CREATE.projectSlug), - slug: z - .string() - .min(1) - .max(60) - .trim() - .refine((val) => val.toLowerCase() === val, "Must be lowercase") - .refine((v) => slugify(v) === v, { - message: "Slug must be a valid slug" - }) - .optional() - .describe(IDENTITY_ADDITIONAL_PRIVILEGE.CREATE.slug), + slug: slugSchema({ min: 1, max: 60 }).optional().describe(IDENTITY_ADDITIONAL_PRIVILEGE.CREATE.slug), permissions: ProjectPermissionSchema.array() .describe(IDENTITY_ADDITIONAL_PRIVILEGE.CREATE.permissions) .optional(), @@ -159,7 +140,7 @@ export const registerIdentityProjectAdditionalPrivilegeRouter = async (server: F actorOrgId: req.permission.orgId, actorAuthMethod: req.permission.authMethod, ...req.body, - slug: req.body.slug ? slugify(req.body.slug) : slugify(alphaNumericNanoId(12)), + slug: req.body.slug ?? slugify(alphaNumericNanoId(12)), isTemporary: true, // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore-error this is valid ts @@ -189,16 +170,7 @@ export const registerIdentityProjectAdditionalPrivilegeRouter = async (server: F projectSlug: z.string().min(1).describe(IDENTITY_ADDITIONAL_PRIVILEGE.UPDATE.projectSlug), privilegeDetails: z .object({ - slug: z - .string() - .min(1) - .max(60) - .trim() - .refine((val) => val.toLowerCase() === val, "Must be lowercase") - .refine((v) => slugify(v) === v, { - message: "Slug must be a valid slug" - }) - .describe(IDENTITY_ADDITIONAL_PRIVILEGE.UPDATE.newSlug), + slug: slugSchema({ min: 1, max: 60 }).describe(IDENTITY_ADDITIONAL_PRIVILEGE.UPDATE.newSlug), permissions: ProjectPermissionSchema.array().describe(IDENTITY_ADDITIONAL_PRIVILEGE.UPDATE.permissions), privilegePermission: ProjectSpecificPrivilegePermissionSchema.describe( IDENTITY_ADDITIONAL_PRIVILEGE.UPDATE.privilegePermission diff --git a/backend/src/ee/routes/v1/org-role-router.ts b/backend/src/ee/routes/v1/org-role-router.ts index 232f4b0b5..30f31c545 100644 --- a/backend/src/ee/routes/v1/org-role-router.ts +++ b/backend/src/ee/routes/v1/org-role-router.ts @@ -1,8 +1,8 @@ -import slugify from "@sindresorhus/slugify"; import { z } from "zod"; import { OrgMembershipRole, OrgMembershipsSchema, OrgRolesSchema } from "@app/db/schemas"; import { readLimit, writeLimit } from "@app/server/config/rateLimiter"; +import { slugSchema } from "@app/server/lib/schemas"; import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; import { AuthMode } from "@app/services/auth/auth-type"; @@ -18,17 +18,10 @@ export const registerOrgRoleRouter = async (server: FastifyZodProvider) => { organizationId: z.string().trim() }), body: z.object({ - slug: z - .string() - .min(1) - .trim() - .refine( - (val) => !Object.values(OrgMembershipRole).includes(val as OrgMembershipRole), - "Please choose a different slug, the slug you have entered is reserved" - ) - .refine((v) => slugify(v) === v, { - message: "Slug must be a valid" - }), + slug: slugSchema({ min: 1, max: 64 }).refine( + (val) => !Object.values(OrgMembershipRole).includes(val as OrgMembershipRole), + "Please choose a different slug, the slug you have entered is reserved" + ), name: z.string().trim(), description: z.string().trim().optional(), permissions: z.any().array() @@ -94,17 +87,13 @@ export const registerOrgRoleRouter = async (server: FastifyZodProvider) => { roleId: z.string().trim() }), body: z.object({ - slug: z - .string() - .trim() - .optional() + // TODO: Switch to slugSchema after verifying correct methods with Akhil - Omar 11/24 + slug: slugSchema({ min: 1, max: 64 }) .refine( - (val) => typeof val !== "undefined" && !Object.keys(OrgMembershipRole).includes(val), + (val) => !Object.keys(OrgMembershipRole).includes(val), "Please choose a different slug, the slug you have entered is reserved." ) - .refine((val) => typeof val === "undefined" || slugify(val) === val, { - message: "Slug must be a valid" - }), + .optional(), name: z.string().trim().optional(), description: z.string().trim().optional(), permissions: z.any().array().optional() diff --git a/backend/src/ee/routes/v1/project-role-router.ts b/backend/src/ee/routes/v1/project-role-router.ts index ba2c0aa9f..0fa35ab1d 100644 --- a/backend/src/ee/routes/v1/project-role-router.ts +++ b/backend/src/ee/routes/v1/project-role-router.ts @@ -1,5 +1,4 @@ import { packRules } from "@casl/ability/extra"; -import slugify from "@sindresorhus/slugify"; import { z } from "zod"; import { ProjectMembershipRole, ProjectMembershipsSchema, ProjectRolesSchema } from "@app/db/schemas"; @@ -9,6 +8,7 @@ import { } from "@app/ee/services/permission/project-permission"; import { PROJECT_ROLE } from "@app/lib/api-docs"; import { readLimit, writeLimit } from "@app/server/config/rateLimiter"; +import { slugSchema } from "@app/server/lib/schemas"; import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; import { SanitizedRoleSchemaV1 } from "@app/server/routes/sanitizedSchemas"; import { AuthMode } from "@app/services/auth/auth-type"; @@ -32,18 +32,11 @@ export const registerProjectRoleRouter = async (server: FastifyZodProvider) => { projectSlug: z.string().trim().describe(PROJECT_ROLE.CREATE.projectSlug) }), body: z.object({ - slug: z - .string() - .toLowerCase() - .trim() - .min(1) + slug: slugSchema({ max: 64 }) .refine( (val) => !Object.values(ProjectMembershipRole).includes(val as ProjectMembershipRole), "Please choose a different slug, the slug you have entered is reserved" ) - .refine((v) => slugify(v) === v, { - message: "Slug must be a valid" - }) .describe(PROJECT_ROLE.CREATE.slug), name: z.string().min(1).trim().describe(PROJECT_ROLE.CREATE.name), description: z.string().trim().optional().describe(PROJECT_ROLE.CREATE.description), @@ -94,21 +87,13 @@ export const registerProjectRoleRouter = async (server: FastifyZodProvider) => { roleId: z.string().trim().describe(PROJECT_ROLE.UPDATE.roleId) }), body: z.object({ - slug: z - .string() - .toLowerCase() - .trim() - .optional() - .describe(PROJECT_ROLE.UPDATE.slug) + slug: slugSchema({ max: 64 }) .refine( - (val) => - typeof val === "undefined" || - !Object.values(ProjectMembershipRole).includes(val as ProjectMembershipRole), + (val) => !Object.values(ProjectMembershipRole).includes(val as ProjectMembershipRole), "Please choose a different slug, the slug you have entered is reserved" ) - .refine((val) => typeof val === "undefined" || slugify(val) === val, { - message: "Slug must be a valid" - }), + .describe(PROJECT_ROLE.UPDATE.slug) + .optional(), name: z.string().trim().optional().describe(PROJECT_ROLE.UPDATE.name), description: z.string().trim().optional().describe(PROJECT_ROLE.UPDATE.description), permissions: ProjectPermissionV1Schema.array().describe(PROJECT_ROLE.UPDATE.permissions).optional() diff --git a/backend/src/ee/routes/v1/project-template-router.ts b/backend/src/ee/routes/v1/project-template-router.ts index 5b115ab4e..60f93d65d 100644 --- a/backend/src/ee/routes/v1/project-template-router.ts +++ b/backend/src/ee/routes/v1/project-template-router.ts @@ -1,4 +1,3 @@ -import slugify from "@sindresorhus/slugify"; import { z } from "zod"; import { ProjectMembershipRole, ProjectTemplatesSchema } from "@app/db/schemas"; @@ -8,22 +7,13 @@ import { ProjectTemplateDefaultEnvironments } from "@app/ee/services/project-tem import { isInfisicalProjectTemplate } from "@app/ee/services/project-template/project-template-fns"; import { ProjectTemplates } from "@app/lib/api-docs"; import { readLimit, writeLimit } from "@app/server/config/rateLimiter"; +import { slugSchema } from "@app/server/lib/schemas"; import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; import { UnpackedPermissionSchema } from "@app/server/routes/santizedSchemas/permission"; import { AuthMode } from "@app/services/auth/auth-type"; const MAX_JSON_SIZE_LIMIT_IN_BYTES = 32_768; -const SlugSchema = z - .string() - .trim() - .min(1) - .max(32) - .refine((val) => val.toLowerCase() === val, "Must be lowercase") - .refine((v) => slugify(v) === v, { - message: "Must be valid slug format" - }); - const isReservedRoleSlug = (slug: string) => Object.values(ProjectMembershipRole).includes(slug as ProjectMembershipRole); @@ -34,14 +24,14 @@ const SanitizedProjectTemplateSchema = ProjectTemplatesSchema.extend({ roles: z .object({ name: z.string().trim().min(1), - slug: SlugSchema, + slug: slugSchema(), permissions: UnpackedPermissionSchema.array() }) .array(), environments: z .object({ name: z.string().trim().min(1), - slug: SlugSchema, + slug: slugSchema(), position: z.number().min(1) }) .array() @@ -50,7 +40,7 @@ const SanitizedProjectTemplateSchema = ProjectTemplatesSchema.extend({ const ProjectTemplateRolesSchema = z .object({ name: z.string().trim().min(1), - slug: SlugSchema, + slug: slugSchema(), permissions: ProjectPermissionV2Schema.array() }) .array() @@ -78,7 +68,7 @@ const ProjectTemplateRolesSchema = z const ProjectTemplateEnvironmentsSchema = z .object({ name: z.string().trim().min(1), - slug: SlugSchema, + slug: slugSchema(), position: z.number().min(1) }) .array() @@ -188,9 +178,11 @@ export const registerProjectTemplateRouter = async (server: FastifyZodProvider) schema: { description: "Create a project template.", body: z.object({ - name: SlugSchema.refine((val) => !isInfisicalProjectTemplate(val), { - message: `The requested project template name is reserved.` - }).describe(ProjectTemplates.CREATE.name), + name: slugSchema({ field: "name" }) + .refine((val) => !isInfisicalProjectTemplate(val), { + message: `The requested project template name is reserved.` + }) + .describe(ProjectTemplates.CREATE.name), description: z.string().max(256).trim().optional().describe(ProjectTemplates.CREATE.description), roles: ProjectTemplateRolesSchema.default([]).describe(ProjectTemplates.CREATE.roles), environments: ProjectTemplateEnvironmentsSchema.default(ProjectTemplateDefaultEnvironments).describe( @@ -230,9 +222,10 @@ export const registerProjectTemplateRouter = async (server: FastifyZodProvider) description: "Update a project template.", params: z.object({ templateId: z.string().uuid().describe(ProjectTemplates.UPDATE.templateId) }), body: z.object({ - name: SlugSchema.refine((val) => !isInfisicalProjectTemplate(val), { - message: `The requested project template name is reserved.` - }) + name: slugSchema({ field: "name" }) + .refine((val) => !isInfisicalProjectTemplate(val), { + message: `The requested project template name is reserved.` + }) .optional() .describe(ProjectTemplates.UPDATE.name), description: z.string().max(256).trim().optional().describe(ProjectTemplates.UPDATE.description), diff --git a/backend/src/ee/routes/v1/user-additional-privilege-router.ts b/backend/src/ee/routes/v1/user-additional-privilege-router.ts index e58a6335b..bb3e179dd 100644 --- a/backend/src/ee/routes/v1/user-additional-privilege-router.ts +++ b/backend/src/ee/routes/v1/user-additional-privilege-router.ts @@ -7,6 +7,7 @@ import { ProjectUserAdditionalPrivilegeTemporaryMode } from "@app/ee/services/pr import { PROJECT_USER_ADDITIONAL_PRIVILEGE } from "@app/lib/api-docs"; import { alphaNumericNanoId } from "@app/lib/nanoid"; import { readLimit, writeLimit } from "@app/server/config/rateLimiter"; +import { slugSchema } from "@app/server/lib/schemas"; import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; import { SanitizedUserProjectAdditionalPrivilegeSchema } from "@app/server/routes/santizedSchemas/user-additional-privilege"; import { AuthMode } from "@app/services/auth/auth-type"; @@ -21,17 +22,7 @@ export const registerUserAdditionalPrivilegeRouter = async (server: FastifyZodPr schema: { body: z.object({ projectMembershipId: z.string().min(1).describe(PROJECT_USER_ADDITIONAL_PRIVILEGE.CREATE.projectMembershipId), - slug: z - .string() - .min(1) - .max(60) - .trim() - .refine((v) => v.toLowerCase() === v, "Slug must be lowercase") - .refine((v) => slugify(v) === v, { - message: "Slug must be a valid slug" - }) - .optional() - .describe(PROJECT_USER_ADDITIONAL_PRIVILEGE.CREATE.slug), + slug: slugSchema({ min: 1, max: 60 }).optional().describe(PROJECT_USER_ADDITIONAL_PRIVILEGE.CREATE.slug), permissions: ProjectPermissionV2Schema.array().describe(PROJECT_USER_ADDITIONAL_PRIVILEGE.CREATE.permissions), type: z.discriminatedUnion("isTemporary", [ z.object({ @@ -87,15 +78,7 @@ export const registerUserAdditionalPrivilegeRouter = async (server: FastifyZodPr }), body: z .object({ - slug: z - .string() - .max(60) - .trim() - .refine((v) => v.toLowerCase() === v, "Slug must be lowercase") - .refine((v) => slugify(v) === v, { - message: "Slug must be a valid slug" - }) - .describe(PROJECT_USER_ADDITIONAL_PRIVILEGE.UPDATE.slug), + slug: slugSchema({ min: 1, max: 60 }).describe(PROJECT_USER_ADDITIONAL_PRIVILEGE.UPDATE.slug), permissions: ProjectPermissionV2Schema.array() .optional() .describe(PROJECT_USER_ADDITIONAL_PRIVILEGE.UPDATE.permissions), diff --git a/backend/src/ee/routes/v2/identity-project-additional-privilege-router.ts b/backend/src/ee/routes/v2/identity-project-additional-privilege-router.ts index 5df03f68d..7934c3f90 100644 --- a/backend/src/ee/routes/v2/identity-project-additional-privilege-router.ts +++ b/backend/src/ee/routes/v2/identity-project-additional-privilege-router.ts @@ -7,6 +7,7 @@ import { ProjectPermissionV2Schema } from "@app/ee/services/permission/project-p import { IDENTITY_ADDITIONAL_PRIVILEGE_V2 } from "@app/lib/api-docs"; import { alphaNumericNanoId } from "@app/lib/nanoid"; import { readLimit, writeLimit } from "@app/server/config/rateLimiter"; +import { slugSchema } from "@app/server/lib/schemas"; import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; import { SanitizedIdentityPrivilegeSchema } from "@app/server/routes/santizedSchemas/identitiy-additional-privilege"; import { AuthMode } from "@app/services/auth/auth-type"; @@ -28,17 +29,7 @@ export const registerIdentityProjectAdditionalPrivilegeRouter = async (server: F body: z.object({ identityId: z.string().min(1).describe(IDENTITY_ADDITIONAL_PRIVILEGE_V2.CREATE.identityId), projectId: z.string().min(1).describe(IDENTITY_ADDITIONAL_PRIVILEGE_V2.CREATE.projectId), - slug: z - .string() - .min(1) - .max(60) - .trim() - .refine((val) => val.toLowerCase() === val, "Must be lowercase") - .refine((v) => slugify(v) === v, { - message: "Slug must be a valid slug" - }) - .optional() - .describe(IDENTITY_ADDITIONAL_PRIVILEGE_V2.CREATE.slug), + slug: slugSchema({ min: 1, max: 60 }).optional().describe(IDENTITY_ADDITIONAL_PRIVILEGE_V2.CREATE.slug), permissions: ProjectPermissionV2Schema.array().describe(IDENTITY_ADDITIONAL_PRIVILEGE_V2.CREATE.permission), type: z.discriminatedUnion("isTemporary", [ z.object({ @@ -100,16 +91,7 @@ export const registerIdentityProjectAdditionalPrivilegeRouter = async (server: F id: z.string().trim().describe(IDENTITY_ADDITIONAL_PRIVILEGE_V2.UPDATE.id) }), body: z.object({ - slug: z - .string() - .min(1) - .max(60) - .trim() - .refine((val) => val.toLowerCase() === val, "Must be lowercase") - .refine((v) => slugify(v) === v, { - message: "Slug must be a valid slug" - }) - .describe(IDENTITY_ADDITIONAL_PRIVILEGE_V2.UPDATE.slug), + slug: slugSchema({ min: 1, max: 60 }).describe(IDENTITY_ADDITIONAL_PRIVILEGE_V2.UPDATE.slug), permissions: ProjectPermissionV2Schema.array() .optional() .describe(IDENTITY_ADDITIONAL_PRIVILEGE_V2.UPDATE.privilegePermission), diff --git a/backend/src/ee/routes/v2/project-role-router.ts b/backend/src/ee/routes/v2/project-role-router.ts index 70511ce87..0152104c6 100644 --- a/backend/src/ee/routes/v2/project-role-router.ts +++ b/backend/src/ee/routes/v2/project-role-router.ts @@ -1,11 +1,11 @@ import { packRules } from "@casl/ability/extra"; -import slugify from "@sindresorhus/slugify"; import { z } from "zod"; import { ProjectMembershipRole, ProjectRolesSchema } from "@app/db/schemas"; import { ProjectPermissionV2Schema } from "@app/ee/services/permission/project-permission"; import { PROJECT_ROLE } from "@app/lib/api-docs"; import { readLimit, writeLimit } from "@app/server/config/rateLimiter"; +import { slugSchema } from "@app/server/lib/schemas"; import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; import { SanitizedRoleSchema } from "@app/server/routes/sanitizedSchemas"; import { AuthMode } from "@app/services/auth/auth-type"; @@ -29,18 +29,11 @@ export const registerProjectRoleRouter = async (server: FastifyZodProvider) => { projectId: z.string().trim().describe(PROJECT_ROLE.CREATE.projectId) }), body: z.object({ - slug: z - .string() - .toLowerCase() - .trim() - .min(1) + slug: slugSchema({ min: 1, max: 64 }) .refine( (val) => !Object.values(ProjectMembershipRole).includes(val as ProjectMembershipRole), "Please choose a different slug, the slug you have entered is reserved" ) - .refine((v) => slugify(v) === v, { - message: "Slug must be a valid" - }) .describe(PROJECT_ROLE.CREATE.slug), name: z.string().min(1).trim().describe(PROJECT_ROLE.CREATE.name), description: z.string().trim().optional().describe(PROJECT_ROLE.CREATE.description), @@ -90,21 +83,13 @@ export const registerProjectRoleRouter = async (server: FastifyZodProvider) => { roleId: z.string().trim().describe(PROJECT_ROLE.UPDATE.roleId) }), body: z.object({ - slug: z - .string() - .toLowerCase() - .trim() - .optional() - .describe(PROJECT_ROLE.UPDATE.slug) + slug: slugSchema({ min: 1, max: 64 }) .refine( - (val) => - typeof val === "undefined" || - !Object.values(ProjectMembershipRole).includes(val as ProjectMembershipRole), + (val) => !Object.values(ProjectMembershipRole).includes(val as ProjectMembershipRole), "Please choose a different slug, the slug you have entered is reserved" ) - .refine((val) => typeof val === "undefined" || slugify(val) === val, { - message: "Slug must be a valid" - }), + .optional() + .describe(PROJECT_ROLE.UPDATE.slug), name: z.string().trim().optional().describe(PROJECT_ROLE.UPDATE.name), description: z.string().trim().optional().describe(PROJECT_ROLE.UPDATE.description), permissions: ProjectPermissionV2Schema.array().describe(PROJECT_ROLE.UPDATE.permissions).optional() diff --git a/backend/src/server/lib/schemas.ts b/backend/src/server/lib/schemas.ts new file mode 100644 index 000000000..ed97cb7d0 --- /dev/null +++ b/backend/src/server/lib/schemas.ts @@ -0,0 +1,23 @@ +import slugify from "@sindresorhus/slugify"; +import { z } from "zod"; + +interface SlugSchemaInputs { + min?: number; + max?: number; + field?: string; +} + +export const slugSchema = ({ min = 1, max = 32, field = "Slug" }: SlugSchemaInputs = {}) => { + return z + .string() + .trim() + .min(min, { + message: `${field} field must be at least ${min} lowercase character${min === 1 ? "" : "s"}` + }) + .max(max, { + message: `${field} field must be at most ${max} lowercase character${max === 1 ? "" : "s"}` + }) + .refine((v) => slugify(v, { lowercase: true }) === v, { + message: `${field} field can only contain lowercase letters, numbers, and hyphens` + }); +}; diff --git a/backend/src/server/routes/v1/cmek-router.ts b/backend/src/server/routes/v1/cmek-router.ts index 18d13e67f..e3982f3d6 100644 --- a/backend/src/server/routes/v1/cmek-router.ts +++ b/backend/src/server/routes/v1/cmek-router.ts @@ -1,4 +1,3 @@ -import slugify from "@sindresorhus/slugify"; import { z } from "zod"; import { InternalKmsSchema, KmsKeysSchema } from "@app/db/schemas"; @@ -8,19 +7,12 @@ import { getBase64SizeInBytes, isBase64 } from "@app/lib/base64"; import { SymmetricEncryption } from "@app/lib/crypto/cipher"; import { OrderByDirection } from "@app/lib/types"; import { readLimit, writeLimit } from "@app/server/config/rateLimiter"; +import { slugSchema } from "@app/server/lib/schemas"; import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; import { AuthMode } from "@app/services/auth/auth-type"; import { CmekOrderBy } from "@app/services/cmek/cmek-types"; -const keyNameSchema = z - .string() - .trim() - .min(1) - .max(32) - .toLowerCase() - .refine((v) => slugify(v) === v, { - message: "Name must be slug friendly" - }); +const keyNameSchema = slugSchema({ min: 1, max: 32, field: "Name" }); const keyDescriptionSchema = z.string().trim().max(500).optional(); const base64Schema = z.string().superRefine((val, ctx) => { diff --git a/backend/src/server/routes/v1/external-group-org-role-mapping-router.ts b/backend/src/server/routes/v1/external-group-org-role-mapping-router.ts index 032deda7d..67db5de6f 100644 --- a/backend/src/server/routes/v1/external-group-org-role-mapping-router.ts +++ b/backend/src/server/routes/v1/external-group-org-role-mapping-router.ts @@ -1,9 +1,9 @@ -import slugify from "@sindresorhus/slugify"; import { z } from "zod"; import { ExternalGroupOrgRoleMappingsSchema } from "@app/db/schemas/external-group-org-role-mappings"; import { EventType } from "@app/ee/services/audit-log/audit-log-types"; import { readLimit, writeLimit } from "@app/server/config/rateLimiter"; +import { slugSchema } from "@app/server/lib/schemas"; import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; import { AuthMode } from "@app/services/auth/auth-type"; @@ -48,13 +48,7 @@ export const registerExternalGroupOrgRoleMappingRouter = async (server: FastifyZ mappings: z .object({ groupName: z.string().trim().min(1), - roleSlug: z - .string() - .min(1) - .toLowerCase() - .refine((v) => slugify(v) === v, { - message: "Role must be a valid slug" - }) + roleSlug: slugSchema({ max: 64 }) }) .array() }), diff --git a/backend/src/server/routes/v1/organization-router.ts b/backend/src/server/routes/v1/organization-router.ts index 07f795779..1327faeb1 100644 --- a/backend/src/server/routes/v1/organization-router.ts +++ b/backend/src/server/routes/v1/organization-router.ts @@ -1,4 +1,3 @@ -import slugify from "@sindresorhus/slugify"; import { z } from "zod"; import { @@ -14,6 +13,7 @@ import { EventType, UserAgentType } from "@app/ee/services/audit-log/audit-log-t import { AUDIT_LOGS, ORGANIZATIONS } from "@app/lib/api-docs"; import { getLastMidnightDateISO } from "@app/lib/fn"; import { readLimit, writeLimit } from "@app/server/config/rateLimiter"; +import { slugSchema } from "@app/server/lib/schemas"; import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; import { ActorType, AuthMode, MfaMethod } from "@app/services/auth/auth-type"; @@ -243,22 +243,10 @@ export const registerOrgRouter = async (server: FastifyZodProvider) => { params: z.object({ organizationId: z.string().trim() }), body: z.object({ name: z.string().trim().max(64, { message: "Name must be 64 or fewer characters" }).optional(), - slug: z - .string() - .trim() - .max(64, { message: "Slug must be 64 or fewer characters" }) - .regex(/^[a-zA-Z0-9-]+$/, "Slug must only contain alphanumeric characters or hyphens") - .optional(), + slug: slugSchema({ max: 64 }).optional(), authEnforced: z.boolean().optional(), scimEnabled: z.boolean().optional(), - defaultMembershipRoleSlug: z - .string() - .min(1) - .trim() - .refine((v) => slugify(v) === v, { - message: "Membership role must be a valid slug" - }) - .optional(), + defaultMembershipRoleSlug: slugSchema({ max: 64, field: "Default Membership Role" }).optional(), enforceMfa: z.boolean().optional(), selectedMfaMethod: z.nativeEnum(MfaMethod).optional() }), diff --git a/backend/src/server/routes/v1/project-env-router.ts b/backend/src/server/routes/v1/project-env-router.ts index c5ded83e4..705016696 100644 --- a/backend/src/server/routes/v1/project-env-router.ts +++ b/backend/src/server/routes/v1/project-env-router.ts @@ -1,10 +1,10 @@ -import slugify from "@sindresorhus/slugify"; import { z } from "zod"; import { ProjectEnvironmentsSchema } from "@app/db/schemas"; import { EventType } from "@app/ee/services/audit-log/audit-log-types"; import { ENVIRONMENTS } from "@app/lib/api-docs"; import { readLimit, writeLimit } from "@app/server/config/rateLimiter"; +import { slugSchema } from "@app/server/lib/schemas"; import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; import { AuthMode } from "@app/services/auth/auth-type"; @@ -124,13 +124,7 @@ export const registerProjectEnvRouter = async (server: FastifyZodProvider) => { body: z.object({ name: z.string().trim().describe(ENVIRONMENTS.CREATE.name), position: z.number().min(1).optional().describe(ENVIRONMENTS.CREATE.position), - slug: z - .string() - .trim() - .refine((v) => slugify(v) === v, { - message: "Slug must be a valid slug" - }) - .describe(ENVIRONMENTS.CREATE.slug) + slug: slugSchema({ max: 64 }).describe(ENVIRONMENTS.CREATE.slug) }), response: { 200: z.object({ @@ -188,14 +182,7 @@ export const registerProjectEnvRouter = async (server: FastifyZodProvider) => { id: z.string().trim().describe(ENVIRONMENTS.UPDATE.id) }), body: z.object({ - slug: z - .string() - .trim() - .optional() - .refine((v) => !v || slugify(v) === v, { - message: "Slug must be a valid slug" - }) - .describe(ENVIRONMENTS.UPDATE.slug), + slug: slugSchema({ max: 64 }).optional().describe(ENVIRONMENTS.UPDATE.slug), name: z.string().trim().optional().describe(ENVIRONMENTS.UPDATE.name), position: z.number().optional().describe(ENVIRONMENTS.UPDATE.position) }), diff --git a/backend/src/server/routes/v1/secret-tag-router.ts b/backend/src/server/routes/v1/secret-tag-router.ts index 7d696999e..ed9837084 100644 --- a/backend/src/server/routes/v1/secret-tag-router.ts +++ b/backend/src/server/routes/v1/secret-tag-router.ts @@ -1,9 +1,9 @@ -import slugify from "@sindresorhus/slugify"; import { z } from "zod"; import { SecretTagsSchema } from "@app/db/schemas"; import { SECRET_TAGS } from "@app/lib/api-docs"; import { readLimit, writeLimit } from "@app/server/config/rateLimiter"; +import { slugSchema } from "@app/server/lib/schemas"; import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; import { AuthMode } from "@app/services/auth/auth-type"; @@ -111,14 +111,7 @@ export const registerSecretTagRouter = async (server: FastifyZodProvider) => { projectId: z.string().trim().describe(SECRET_TAGS.CREATE.projectId) }), body: z.object({ - slug: z - .string() - .toLowerCase() - .trim() - .describe(SECRET_TAGS.CREATE.slug) - .refine((v) => slugify(v) === v, { - message: "Invalid slug. Slug can only contain alphanumeric characters and hyphens." - }), + slug: slugSchema({ max: 64 }).describe(SECRET_TAGS.CREATE.slug), color: z.string().trim().describe(SECRET_TAGS.CREATE.color) }), response: { @@ -153,14 +146,7 @@ export const registerSecretTagRouter = async (server: FastifyZodProvider) => { tagId: z.string().trim().describe(SECRET_TAGS.UPDATE.tagId) }), body: z.object({ - slug: z - .string() - .toLowerCase() - .trim() - .describe(SECRET_TAGS.UPDATE.slug) - .refine((v) => slugify(v) === v, { - message: "Invalid slug. Slug can only contain alphanumeric characters and hyphens." - }), + slug: slugSchema({ max: 64 }).describe(SECRET_TAGS.UPDATE.slug), color: z.string().trim().describe(SECRET_TAGS.UPDATE.color) }), response: { diff --git a/backend/src/server/routes/v1/slack-router.ts b/backend/src/server/routes/v1/slack-router.ts index 0601e2d1f..f05aa18f0 100644 --- a/backend/src/server/routes/v1/slack-router.ts +++ b/backend/src/server/routes/v1/slack-router.ts @@ -1,10 +1,10 @@ -import slugify from "@sindresorhus/slugify"; import { z } from "zod"; import { SlackIntegrationsSchema, WorkflowIntegrationsSchema } from "@app/db/schemas"; import { EventType } from "@app/ee/services/audit-log/audit-log-types"; import { getConfig } from "@app/lib/config/env"; import { readLimit, writeLimit } from "@app/server/config/rateLimiter"; +import { slugSchema } from "@app/server/lib/schemas"; import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; import { AuthMode } from "@app/services/auth/auth-type"; @@ -35,12 +35,7 @@ export const registerSlackRouter = async (server: FastifyZodProvider) => { } ], querystring: z.object({ - slug: z - .string() - .trim() - .refine((v) => slugify(v) === v, { - message: "Slug must be a valid slug" - }), + slug: slugSchema({ max: 64 }), description: z.string().optional() }), response: { @@ -288,13 +283,7 @@ export const registerSlackRouter = async (server: FastifyZodProvider) => { id: z.string() }), body: z.object({ - slug: z - .string() - .trim() - .refine((v) => slugify(v) === v, { - message: "Slug must be a valid slug" - }) - .optional(), + slug: slugSchema({ max: 64 }).optional(), description: z.string().optional() }), response: { diff --git a/backend/src/server/routes/v2/project-router.ts b/backend/src/server/routes/v2/project-router.ts index 0e271eb0e..0df88e38c 100644 --- a/backend/src/server/routes/v2/project-router.ts +++ b/backend/src/server/routes/v2/project-router.ts @@ -1,4 +1,3 @@ -import slugify from "@sindresorhus/slugify"; import { z } from "zod"; import { @@ -12,6 +11,7 @@ import { EventType } from "@app/ee/services/audit-log/audit-log-types"; import { InfisicalProjectTemplate } from "@app/ee/services/project-template/project-template-types"; import { PROJECTS } from "@app/lib/api-docs"; import { readLimit, writeLimit } from "@app/server/config/rateLimiter"; +import { slugSchema } from "@app/server/lib/schemas"; import { getTelemetryDistinctId } from "@app/server/lib/telemetry"; import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; import { AuthMode } from "@app/services/auth/auth-type"; @@ -27,14 +27,6 @@ const projectWithEnv = SanitizedProjectSchema.extend({ environments: z.object({ name: z.string(), slug: z.string(), id: z.string() }).array() }); -const slugSchema = z - .string() - .min(5) - .max(36) - .refine((v) => slugify(v) === v, { - message: "Slug must be at least 5 character but no more than 36" - }); - export const registerProjectRouter = async (server: FastifyZodProvider) => { /* Get project key */ server.route({ @@ -162,21 +154,9 @@ export const registerProjectRouter = async (server: FastifyZodProvider) => { body: z.object({ projectName: z.string().trim().describe(PROJECTS.CREATE.projectName), projectDescription: z.string().trim().optional().describe(PROJECTS.CREATE.projectDescription), - slug: z - .string() - .min(5) - .max(36) - .refine((v) => slugify(v) === v, { - message: "Slug must be a valid slug" - }) - .optional() - .describe(PROJECTS.CREATE.slug), + slug: slugSchema({ min: 5, max: 36 }).optional().describe(PROJECTS.CREATE.slug), kmsKeyId: z.string().optional(), - template: z - .string() - .refine((v) => slugify(v) === v, { - message: "Template name must be in slug format" - }) + template: slugSchema({ field: "Template Name", max: 64 }) .optional() .default(InfisicalProjectTemplate.Default) .describe(PROJECTS.CREATE.template) @@ -244,7 +224,7 @@ export const registerProjectRouter = async (server: FastifyZodProvider) => { } ], params: z.object({ - slug: slugSchema.describe("The slug of the project to delete.") + slug: slugSchema({ min: 5, max: 36 }).describe("The slug of the project to delete.") }), response: { 200: SanitizedProjectSchema @@ -278,7 +258,7 @@ export const registerProjectRouter = async (server: FastifyZodProvider) => { }, schema: { params: z.object({ - slug: slugSchema.describe("The slug of the project to get.") + slug: slugSchema({ min: 5, max: 36 }).describe("The slug of the project to get.") }), response: { 200: projectWithEnv @@ -311,7 +291,7 @@ export const registerProjectRouter = async (server: FastifyZodProvider) => { }, schema: { params: z.object({ - slug: slugSchema.describe("The slug of the project to update.") + slug: slugSchema({ min: 5, max: 36 }).describe("The slug of the project to update.") }), body: z.object({ name: z.string().trim().optional().describe(PROJECTS.UPDATE.name), @@ -354,7 +334,7 @@ export const registerProjectRouter = async (server: FastifyZodProvider) => { }, schema: { params: z.object({ - slug: slugSchema.describe(PROJECTS.LIST_CAS.slug) + slug: slugSchema({ min: 5, max: 36 }).describe(PROJECTS.LIST_CAS.slug) }), querystring: z.object({ status: z.enum([CaStatus.ACTIVE, CaStatus.PENDING_CERTIFICATE]).optional().describe(PROJECTS.LIST_CAS.status), @@ -395,7 +375,7 @@ export const registerProjectRouter = async (server: FastifyZodProvider) => { }, schema: { params: z.object({ - slug: slugSchema.describe(PROJECTS.LIST_CERTIFICATES.slug) + slug: slugSchema({ min: 5, max: 36 }).describe(PROJECTS.LIST_CERTIFICATES.slug) }), querystring: z.object({ friendlyName: z.string().optional().describe(PROJECTS.LIST_CERTIFICATES.friendlyName), diff --git a/frontend/src/components/tags/CreateTagModal/CreateTagModal.tsx b/frontend/src/components/tags/CreateTagModal/CreateTagModal.tsx index a2fde465d..23389cbca 100644 --- a/frontend/src/components/tags/CreateTagModal/CreateTagModal.tsx +++ b/frontend/src/components/tags/CreateTagModal/CreateTagModal.tsx @@ -3,7 +3,6 @@ import { Controller, useForm } from "react-hook-form"; import { faCheck } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { zodResolver } from "@hookform/resolvers/zod"; -import slugify from "@sindresorhus/slugify"; import { z } from "zod"; import { createNotification } from "@app/components/notifications"; @@ -18,6 +17,7 @@ import { } from "@app/components/v2"; import { useWorkspace } from "@app/context"; import { useCreateWsTag } from "@app/hooks/api"; +import { slugSchema } from "@app/lib/schemas"; export const secretTagsColors = [ { @@ -88,13 +88,7 @@ type Props = { }; const createTagSchema = z.object({ - slug: z - .string() - .trim() - .toLowerCase() - .refine((v) => slugify(v) === v, { - message: "Invalid slug. Slug can only contain alphanumeric characters and hyphens." - }), + slug: slugSchema({ min: 1, field: "Tag Slug" }), color: z.string().trim() }); diff --git a/frontend/src/hooks/api/kms/types.ts b/frontend/src/hooks/api/kms/types.ts index 3e4b69880..51a972fc0 100644 --- a/frontend/src/hooks/api/kms/types.ts +++ b/frontend/src/hooks/api/kms/types.ts @@ -1,6 +1,7 @@ -import slugify from "@sindresorhus/slugify"; import { z } from "zod"; +import { slugSchema } from "@app/lib/schemas"; + export type Kms = { id: string; description: string; @@ -119,13 +120,7 @@ export const ExternalKmsInputSchema = z.discriminatedUnion("type", [ ]); export const AddExternalKmsSchema = z.object({ - name: z - .string() - .trim() - .min(1) - .refine((v) => slugify(v) === v, { - message: "Alias must be a valid slug" - }), + name: slugSchema({ min: 1, field: "Alias" }), description: z.string().trim().optional(), provider: ExternalKmsInputSchema }); diff --git a/frontend/src/lib/schemas/slugSchema.ts b/frontend/src/lib/schemas/slugSchema.ts index df74b99e3..ed97cb7d0 100644 --- a/frontend/src/lib/schemas/slugSchema.ts +++ b/frontend/src/lib/schemas/slugSchema.ts @@ -1,12 +1,23 @@ import slugify from "@sindresorhus/slugify"; import { z } from "zod"; -export const slugSchema = z - .string() - .trim() - .min(1) - .max(32) - .refine((val) => val.toLowerCase() === val, "Must be lowercase") - .refine((v) => slugify(v) === v, { - message: "Invalid slug format" - }); +interface SlugSchemaInputs { + min?: number; + max?: number; + field?: string; +} + +export const slugSchema = ({ min = 1, max = 32, field = "Slug" }: SlugSchemaInputs = {}) => { + return z + .string() + .trim() + .min(min, { + message: `${field} field must be at least ${min} lowercase character${min === 1 ? "" : "s"}` + }) + .max(max, { + message: `${field} field must be at most ${max} lowercase character${max === 1 ? "" : "s"}` + }) + .refine((v) => slugify(v, { lowercase: true }) === v, { + message: `${field} field can only contain lowercase letters, numbers, and hyphens` + }); +}; diff --git a/frontend/src/views/Org/RolePage/components/RoleModal.tsx b/frontend/src/views/Org/RolePage/components/RoleModal.tsx index ab909d931..da2ad89c5 100644 --- a/frontend/src/views/Org/RolePage/components/RoleModal.tsx +++ b/frontend/src/views/Org/RolePage/components/RoleModal.tsx @@ -9,12 +9,13 @@ import { Button, FormControl, Input, Modal, ModalContent } from "@app/components import { useOrganization } from "@app/context"; import { useCreateOrgRole, useGetOrgRole, useUpdateOrgRole } from "@app/hooks/api"; import { UsePopUpState } from "@app/hooks/usePopUp"; +import { slugSchema } from "@app/lib/schemas"; const schema = z .object({ name: z.string(), description: z.string(), - slug: z.string() + slug: slugSchema({ min: 1 }) }) .required(); diff --git a/frontend/src/views/Project/KmsPage/components/CmekModal.tsx b/frontend/src/views/Project/KmsPage/components/CmekModal.tsx index 4b6bd9f39..8735b047d 100644 --- a/frontend/src/views/Project/KmsPage/components/CmekModal.tsx +++ b/frontend/src/views/Project/KmsPage/components/CmekModal.tsx @@ -1,6 +1,5 @@ import { Controller, useForm } from "react-hook-form"; import { zodResolver } from "@hookform/resolvers/zod"; -import slugify from "@sindresorhus/slugify"; import { z } from "zod"; import { createNotification } from "@app/components/notifications"; @@ -17,16 +16,10 @@ import { } from "@app/components/v2"; import { useWorkspace } from "@app/context"; import { EncryptionAlgorithm, TCmek, useCreateCmek, useUpdateCmek } from "@app/hooks/api/cmeks"; +import { slugSchema } from "@app/lib/schemas"; const formSchema = z.object({ - name: z - .string() - .min(1) - .toLowerCase() - .max(32) - .refine((v) => slugify(v) === v, { - message: "Name must be in slug format" - }), + name: slugSchema({ min: 1, max: 32, field: "Name" }), description: z.string().max(500).optional(), encryptionAlgorithm: z.nativeEnum(EncryptionAlgorithm) }); diff --git a/frontend/src/views/Project/RolePage/components/RoleModal.tsx b/frontend/src/views/Project/RolePage/components/RoleModal.tsx index 5a87b4a61..cf8cab03b 100644 --- a/frontend/src/views/Project/RolePage/components/RoleModal.tsx +++ b/frontend/src/views/Project/RolePage/components/RoleModal.tsx @@ -13,12 +13,13 @@ import { useUpdateProjectRole } from "@app/hooks/api"; import { UsePopUpState } from "@app/hooks/usePopUp"; +import { slugSchema } from "@app/lib/schemas"; const schema = z .object({ name: z.string(), description: z.string(), - slug: z.string() + slug: slugSchema({ min: 1 }) }) .required(); diff --git a/frontend/src/views/Settings/OrgSettingsPage/components/OrgWorkflowIntegrationTab/SlackIntegrationForm.tsx b/frontend/src/views/Settings/OrgSettingsPage/components/OrgWorkflowIntegrationTab/SlackIntegrationForm.tsx index 281061db4..93c24586e 100644 --- a/frontend/src/views/Settings/OrgSettingsPage/components/OrgWorkflowIntegrationTab/SlackIntegrationForm.tsx +++ b/frontend/src/views/Settings/OrgSettingsPage/components/OrgWorkflowIntegrationTab/SlackIntegrationForm.tsx @@ -2,7 +2,6 @@ import { useEffect } from "react"; import { Controller, useForm } from "react-hook-form"; import { useRouter } from "next/router"; import { zodResolver } from "@hookform/resolvers/zod"; -import slugify from "@sindresorhus/slugify"; import axios from "axios"; import { z } from "zod"; @@ -15,6 +14,7 @@ import { useGetSlackIntegrationById, useUpdateSlackIntegration } from "@app/hooks/api"; +import { slugSchema } from "@app/lib/schemas"; type Props = { id?: string; @@ -22,13 +22,7 @@ type Props = { }; const slackFormSchema = z.object({ - slug: z - .string() - .trim() - .min(1) - .refine((v) => slugify(v) === v, { - message: "Alias must be a valid slug" - }), + slug: slugSchema({ min: 1, field: "Alias" }), description: z.string().optional() }); diff --git a/frontend/src/views/Settings/OrgSettingsPage/components/ProjectTemplatesTab/components/EditProjectTemplateSection/components/ProjectTemplateEditRoleForm.tsx b/frontend/src/views/Settings/OrgSettingsPage/components/ProjectTemplatesTab/components/EditProjectTemplateSection/components/ProjectTemplateEditRoleForm.tsx index 0e703798e..7cb6d5d09 100644 --- a/frontend/src/views/Settings/OrgSettingsPage/components/ProjectTemplatesTab/components/EditProjectTemplateSection/components/ProjectTemplateEditRoleForm.tsx +++ b/frontend/src/views/Settings/OrgSettingsPage/components/ProjectTemplatesTab/components/EditProjectTemplateSection/components/ProjectTemplateEditRoleForm.tsx @@ -31,7 +31,7 @@ type Props = { }; const formSchema = z.object({ - slug: slugSchema, + slug: slugSchema(), name: z.string().trim().min(1), permissions: projectRoleFormSchema.shape.permissions }); diff --git a/frontend/src/views/Settings/OrgSettingsPage/components/ProjectTemplatesTab/components/EditProjectTemplateSection/components/ProjectTemplateEnvironmentsForm.tsx b/frontend/src/views/Settings/OrgSettingsPage/components/ProjectTemplatesTab/components/EditProjectTemplateSection/components/ProjectTemplateEnvironmentsForm.tsx index 677a7c773..b72d12c73 100644 --- a/frontend/src/views/Settings/OrgSettingsPage/components/ProjectTemplatesTab/components/EditProjectTemplateSection/components/ProjectTemplateEnvironmentsForm.tsx +++ b/frontend/src/views/Settings/OrgSettingsPage/components/ProjectTemplatesTab/components/EditProjectTemplateSection/components/ProjectTemplateEnvironmentsForm.tsx @@ -32,7 +32,7 @@ const formSchema = z.object({ environments: z .object({ name: z.string().trim().min(1), - slug: slugSchema + slug: slugSchema({ min: 1, max: 32 }) }) .array() }); diff --git a/frontend/src/views/Settings/OrgSettingsPage/components/ProjectTemplatesTab/components/ProjectTemplateDetailsModal.tsx b/frontend/src/views/Settings/OrgSettingsPage/components/ProjectTemplatesTab/components/ProjectTemplateDetailsModal.tsx index 1f65df795..e601e0319 100644 --- a/frontend/src/views/Settings/OrgSettingsPage/components/ProjectTemplatesTab/components/ProjectTemplateDetailsModal.tsx +++ b/frontend/src/views/Settings/OrgSettingsPage/components/ProjectTemplatesTab/components/ProjectTemplateDetailsModal.tsx @@ -1,6 +1,5 @@ import { useForm } from "react-hook-form"; import { zodResolver } from "@hookform/resolvers/zod"; -import slugify from "@sindresorhus/slugify"; import { z } from "zod"; import { createNotification } from "@app/components/notifications"; @@ -18,17 +17,10 @@ import { useCreateProjectTemplate, useUpdateProjectTemplate } from "@app/hooks/api/projectTemplates"; +import { slugSchema } from "@app/lib/schemas"; const formSchema = z.object({ - name: z - .string() - .trim() - .min(1) - .max(32) - .toLowerCase() - .refine((v) => slugify(v) === v, { - message: "Name must be in slug format" - }), + name: slugSchema({ min: 1, max: 32, field: "Name" }), description: z.string().max(500).optional() }); diff --git a/frontend/src/views/Settings/ProjectSettingsPage/components/EnvironmentSection/AddEnvironmentModal.tsx b/frontend/src/views/Settings/ProjectSettingsPage/components/EnvironmentSection/AddEnvironmentModal.tsx index 68bbeb840..00f160e75 100644 --- a/frontend/src/views/Settings/ProjectSettingsPage/components/EnvironmentSection/AddEnvironmentModal.tsx +++ b/frontend/src/views/Settings/ProjectSettingsPage/components/EnvironmentSection/AddEnvironmentModal.tsx @@ -1,13 +1,13 @@ import { Controller, useForm } from "react-hook-form"; -import { yupResolver } from "@hookform/resolvers/yup"; -import slugify from "@sindresorhus/slugify"; -import * as yup from "yup"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { z } from "zod"; import { createNotification } from "@app/components/notifications"; import { Button, FormControl, Input, Modal, ModalContent } from "@app/components/v2"; import { useWorkspace } from "@app/context"; import { useCreateWsEnvironment } from "@app/hooks/api"; import { UsePopUpState } from "@app/hooks/usePopUp"; +import { slugSchema } from "@app/lib/schemas"; type Props = { popUp: UsePopUpState<["createEnv"]>; @@ -15,26 +15,20 @@ type Props = { handlePopUpToggle: (popUpName: keyof UsePopUpState<["createEnv"]>, state?: boolean) => void; }; -const schema = yup.object({ - environmentName: yup.string().label("Environment Name").required(), - environmentSlug: yup +const schema = z.object({ + environmentName: z .string() - .label("Environment Slug") - .test({ - test: (slug) => slugify(slug as string) === slug, - message: "Slug must be a valid slug" - }) - .required() + .min(1, { message: "Environment Name field must be at least 1 character" }), + environmentSlug: slugSchema() }); -export type FormData = yup.InferType; +export type FormData = z.infer; export const AddEnvironmentModal = ({ popUp, handlePopUpClose, handlePopUpToggle }: Props) => { - const { currentWorkspace } = useWorkspace(); const { mutateAsync, isLoading } = useCreateWsEnvironment(); const { control, handleSubmit, reset } = useForm({ - resolver: yupResolver(schema) + resolver: zodResolver(schema) }); const onFormSubmit = async ({ environmentName, environmentSlug }: FormData) => { @@ -112,7 +106,11 @@ export const AddEnvironmentModal = ({ popUp, handlePopUpClose, handlePopUpToggle Create - diff --git a/frontend/src/views/Settings/ProjectSettingsPage/components/EnvironmentSection/UpdateEnvironmentModal.tsx b/frontend/src/views/Settings/ProjectSettingsPage/components/EnvironmentSection/UpdateEnvironmentModal.tsx index c6b2152cc..ad11c2381 100644 --- a/frontend/src/views/Settings/ProjectSettingsPage/components/EnvironmentSection/UpdateEnvironmentModal.tsx +++ b/frontend/src/views/Settings/ProjectSettingsPage/components/EnvironmentSection/UpdateEnvironmentModal.tsx @@ -1,13 +1,13 @@ import { Controller, useForm } from "react-hook-form"; -import { yupResolver } from "@hookform/resolvers/yup"; -import slugify from "@sindresorhus/slugify"; -import * as yup from "yup"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { z } from "zod"; import { createNotification } from "@app/components/notifications"; import { Button, FormControl, Input, Modal, ModalContent } from "@app/components/v2"; import { useWorkspace } from "@app/context"; import { useUpdateWsEnvironment } from "@app/hooks/api"; import { UsePopUpState } from "@app/hooks/usePopUp"; +import { slugSchema } from "@app/lib/schemas"; type Props = { popUp: UsePopUpState<["updateEnv"]>; @@ -15,25 +15,18 @@ type Props = { handlePopUpToggle: (popUpName: keyof UsePopUpState<["updateEnv"]>, state?: boolean) => void; }; -const schema = yup.object({ - name: yup.string().label("Environment Name").required(), - slug: yup - .string() - .label("Environment Slug") - .test({ - test: (slug) => slugify(slug as string) === slug, - message: "Slug must be a valid slug" - }) - .required() +const schema = z.object({ + name: z.string(), + slug: slugSchema({ min: 1 }) }); -export type FormData = yup.InferType; +export type FormData = z.infer; export const UpdateEnvironmentModal = ({ popUp, handlePopUpClose, handlePopUpToggle }: Props) => { const { currentWorkspace } = useWorkspace(); const { mutateAsync, isLoading } = useUpdateWsEnvironment(); const { control, handleSubmit, reset } = useForm({ - resolver: yupResolver(schema), + resolver: zodResolver(schema), values: popUp.updateEnv.data as FormData }); diff --git a/frontend/src/views/Settings/ProjectSettingsPage/components/SecretTagsSection/AddSecretTagModal.tsx b/frontend/src/views/Settings/ProjectSettingsPage/components/SecretTagsSection/AddSecretTagModal.tsx index 75f6b69bf..96c667050 100644 --- a/frontend/src/views/Settings/ProjectSettingsPage/components/SecretTagsSection/AddSecretTagModal.tsx +++ b/frontend/src/views/Settings/ProjectSettingsPage/components/SecretTagsSection/AddSecretTagModal.tsx @@ -1,6 +1,5 @@ import { Controller, useForm } from "react-hook-form"; import { zodResolver } from "@hookform/resolvers/zod"; -import slugify from "@sindresorhus/slugify"; import { z } from "zod"; import { createNotification } from "@app/components/notifications"; @@ -8,11 +7,10 @@ import { Button, FormControl, Input, Modal, ModalClose, ModalContent } from "@ap import { useWorkspace } from "@app/context"; import { useCreateWsTag } from "@app/hooks/api"; import { UsePopUpState } from "@app/hooks/usePopUp"; +import { slugSchema } from "@app/lib/schemas"; const schema = z.object({ - slug: z.string().refine((v) => slugify(v) === v, { - message: "Invalid slug. Slug can only contain alphanumeric characters and hyphens." - }) + slug: slugSchema({ min: 1, field: "Tag Slug" }) }); export type FormData = z.infer; From c6482353909842da5049ac40f3623d906c7ca404 Mon Sep 17 00:00:00 2001 From: McPizza Date: Sun, 8 Dec 2024 19:13:54 +0100 Subject: [PATCH 017/162] hotfix: add missing package import (#2850) --- frontend/src/hooks/api/kms/types.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/frontend/src/hooks/api/kms/types.ts b/frontend/src/hooks/api/kms/types.ts index 51a972fc0..73b821b1a 100644 --- a/frontend/src/hooks/api/kms/types.ts +++ b/frontend/src/hooks/api/kms/types.ts @@ -1,3 +1,4 @@ +import slugify from "@sindresorhus/slugify"; import { z } from "zod"; import { slugSchema } from "@app/lib/schemas"; From 3f6b1fe3bdc4f845e73ecea2531b8a5c08f53dca Mon Sep 17 00:00:00 2001 From: Sheen Capadngan Date: Mon, 9 Dec 2024 13:17:04 +0800 Subject: [PATCH 018/162] misc: add ssl setting for pg boss --- backend/e2e-test/vitest-environment-knex.ts | 2 +- backend/src/main.ts | 6 +++++- backend/src/queue/queue-service.ts | 13 +++++++++++-- 3 files changed, 17 insertions(+), 4 deletions(-) diff --git a/backend/e2e-test/vitest-environment-knex.ts b/backend/e2e-test/vitest-environment-knex.ts index 66fd4dc75..58f2bffeb 100644 --- a/backend/e2e-test/vitest-environment-knex.ts +++ b/backend/e2e-test/vitest-environment-knex.ts @@ -53,7 +53,7 @@ export default { extension: "ts" }); const smtp = mockSmtpServer(); - const queue = queueServiceFactory(cfg.REDIS_URL, cfg.DB_CONNECTION_URI); + const queue = queueServiceFactory(cfg.REDIS_URL, { dbConnectionUrl: cfg.DB_CONNECTION_URI }); const keyStore = keyStoreFactory(cfg.REDIS_URL); const hsmModule = initializeHsmModule(); diff --git a/backend/src/main.ts b/backend/src/main.ts index ca85625c2..850298f89 100644 --- a/backend/src/main.ts +++ b/backend/src/main.ts @@ -57,7 +57,11 @@ const run = async () => { const smtp = smtpServiceFactory(formatSmtpConfig()); - const queue = queueServiceFactory(appCfg.REDIS_URL, appCfg.DB_CONNECTION_URI); + const queue = queueServiceFactory(appCfg.REDIS_URL, { + dbConnectionUrl: appCfg.DB_CONNECTION_URI, + dbRootCert: appCfg.DB_ROOT_CERT + }); + await queue.initialize(); const keyStore = keyStoreFactory(appCfg.REDIS_URL); diff --git a/backend/src/queue/queue-service.ts b/backend/src/queue/queue-service.ts index 8479a249c..051fe9cbd 100644 --- a/backend/src/queue/queue-service.ts +++ b/backend/src/queue/queue-service.ts @@ -187,7 +187,10 @@ export type TQueueJobTypes = { }; export type TQueueServiceFactory = ReturnType; -export const queueServiceFactory = (redisUrl: string, dbConnectionUrl: string) => { +export const queueServiceFactory = ( + redisUrl: string, + { dbConnectionUrl, dbRootCert }: { dbConnectionUrl: string; dbRootCert?: string } +) => { const connection = new Redis(redisUrl, { maxRetriesPerRequest: null }); const queueContainer = {} as Record< QueueName, @@ -198,7 +201,13 @@ export const queueServiceFactory = (redisUrl: string, dbConnectionUrl: string) = connectionString: dbConnectionUrl, archiveCompletedAfterSeconds: 60, archiveFailedAfterSeconds: 1000, // we want to keep failed jobs for a longer time so that it can be retried - deleteAfterSeconds: 30 + deleteAfterSeconds: 30, + ssl: dbRootCert + ? { + rejectUnauthorized: true, + ca: Buffer.from(dbRootCert, "base64").toString("ascii") + } + : false }); const queueContainerPg = {} as Record; From 42249726d4f29d73ffacc1ad471f491f470c6863 Mon Sep 17 00:00:00 2001 From: Tuan Dang Date: Sun, 8 Dec 2024 21:23:00 -0800 Subject: [PATCH 019/162] Make PR review adjustments, ssh ca public key endpoint, ssh cert template status --- .../db/migrations/20241130015511_ssh-mgmt.ts | 5 +- .../db/schemas/ssh-certificate-templates.ts | 1 + .../v1/ssh-certificate-authority-router.ts | 29 ++++- .../v1/ssh-certificate-template-router.ts | 3 + .../ee/services/audit-log/audit-log-types.ts | 2 + .../ssh-certificate-template-schema.ts | 1 + .../ssh-certificate-template-service.ts | 100 +++++++++++------- .../ssh-certificate-template-types.ts | 6 ++ .../ssh-certificate/ssh-certificate-schema.ts | 4 +- .../ssh/ssh-certificate-authority-fns.ts | 8 +- .../ssh/ssh-certificate-authority-service.ts | 39 +++++++ .../ssh/ssh-certificate-authority-types.ts | 4 + backend/src/lib/api-docs/constants.ts | 3 + .../server/routes/v1/organization-router.ts | 2 +- frontend/src/hooks/api/ca/constants.tsx | 3 +- frontend/src/hooks/api/ssh-ca/types.ts | 2 + .../api/sshCertificateTemplates/index.tsx | 4 +- .../api/sshCertificateTemplates/types.ts | 7 ++ .../components/SshCertificateModal.tsx | 80 ++++++++------ .../SshCertificateTemplatesSection.tsx | 67 +++++++++++- .../SshCertificateTemplatesTable.tsx | 61 ++++++++++- .../components/SshCertificatesTable.tsx | 28 +++-- .../components/SshCertificatesTable.utils.ts | 17 +++ 23 files changed, 380 insertions(+), 96 deletions(-) create mode 100644 frontend/src/views/Org/SshPage/components/SshCertificatesTable.utils.ts diff --git a/backend/src/db/migrations/20241130015511_ssh-mgmt.ts b/backend/src/db/migrations/20241130015511_ssh-mgmt.ts index a96f49a4d..ad38f8ad2 100644 --- a/backend/src/db/migrations/20241130015511_ssh-mgmt.ts +++ b/backend/src/db/migrations/20241130015511_ssh-mgmt.ts @@ -34,6 +34,7 @@ export async function up(knex: Knex): Promise { t.timestamps(true, true, true); t.uuid("sshCaId").notNullable(); t.foreign("sshCaId").references("id").inTable(TableName.SshCertificateAuthority).onDelete("CASCADE"); + t.string("status").notNullable(); // active / disabled t.string("name").notNullable(); t.string("ttl").notNullable(); t.string("maxTTL").notNullable(); @@ -51,7 +52,7 @@ export async function up(knex: Knex): Promise { 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.foreign("sshCaId").references("id").inTable(TableName.SshCertificateAuthority).onDelete("SET NULL"); t.uuid("sshCertificateTemplateId"); t.foreign("sshCertificateTemplateId") .references("id") @@ -65,7 +66,7 @@ export async function up(knex: Knex): Promise { t.datetime("notBefore").notNullable(); t.datetime("notAfter").notNullable(); }); - await createOnUpdateTrigger(knex, TableName.SshCertificateTemplate); + await createOnUpdateTrigger(knex, TableName.SshCertificate); } } diff --git a/backend/src/db/schemas/ssh-certificate-templates.ts b/backend/src/db/schemas/ssh-certificate-templates.ts index 875d66986..6c16c3942 100644 --- a/backend/src/db/schemas/ssh-certificate-templates.ts +++ b/backend/src/db/schemas/ssh-certificate-templates.ts @@ -12,6 +12,7 @@ export const SshCertificateTemplatesSchema = z.object({ createdAt: z.date(), updatedAt: z.date(), sshCaId: z.string().uuid(), + status: z.string(), name: z.string(), ttl: z.string(), maxTTL: z.string(), diff --git a/backend/src/ee/routes/v1/ssh-certificate-authority-router.ts b/backend/src/ee/routes/v1/ssh-certificate-authority-router.ts index 4f4e166d0..8c75ac4d4 100644 --- a/backend/src/ee/routes/v1/ssh-certificate-authority-router.ts +++ b/backend/src/ee/routes/v1/ssh-certificate-authority-router.ts @@ -109,6 +109,30 @@ export const registerSshCaRouter = async (server: FastifyZodProvider) => { } }); + server.route({ + method: "GET", + url: "/:sshCaId/public-key", + config: { + rateLimit: readLimit + }, + schema: { + description: "Get public key of SSH CA", + params: z.object({ + sshCaId: z.string().trim().describe(SSH_CERTIFICATE_AUTHORITIES.GET_PUBLIC_KEY.sshCaId) + }), + response: { + 200: z.string() + } + }, + handler: async (req) => { + const publicKey = await server.services.sshCertificateAuthority.getSshCaPublicKey({ + caId: req.params.sshCaId + }); + + return publicKey; + } + }); + server.route({ method: "PATCH", url: "/:sshCaId", @@ -123,10 +147,7 @@ export const registerSshCaRouter = async (server: FastifyZodProvider) => { }), body: z.object({ friendlyName: z.string().optional().describe(SSH_CERTIFICATE_AUTHORITIES.UPDATE.friendlyName), - status: z - .enum([SshCaStatus.ACTIVE, SshCaStatus.DISABLED]) - .optional() - .describe(SSH_CERTIFICATE_AUTHORITIES.UPDATE.status) + status: z.nativeEnum(SshCaStatus).optional().describe(SSH_CERTIFICATE_AUTHORITIES.UPDATE.status) }), response: { 200: z.object({ diff --git a/backend/src/ee/routes/v1/ssh-certificate-template-router.ts b/backend/src/ee/routes/v1/ssh-certificate-template-router.ts index 8ab0b57d9..7e828a588 100644 --- a/backend/src/ee/routes/v1/ssh-certificate-template-router.ts +++ b/backend/src/ee/routes/v1/ssh-certificate-template-router.ts @@ -4,6 +4,7 @@ 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 { SshCertTemplateStatus } from "@app/ee/services/ssh-certificate-template/ssh-certificate-template-types"; import { isValidHostPattern, isValidUserPattern @@ -136,6 +137,7 @@ export const registerSshCertificateTemplateRouter = async (server: FastifyZodPro }, schema: { body: z.object({ + status: z.nativeEnum(SshCertTemplateStatus).optional(), name: z .string() .min(1) @@ -191,6 +193,7 @@ export const registerSshCertificateTemplateRouter = async (server: FastifyZodPro event: { type: EventType.UPDATE_SSH_CERTIFICATE_TEMPLATE, metadata: { + status: certificateTemplate.status as SshCertTemplateStatus, certificateTemplateId: certificateTemplate.id, sshCaId: certificateTemplate.sshCaId, name: certificateTemplate.name, 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 991c46e1e..ac7188c47 100644 --- a/backend/src/ee/services/audit-log/audit-log-types.ts +++ b/backend/src/ee/services/audit-log/audit-log-types.ts @@ -3,6 +3,7 @@ import { TUpdateProjectTemplateDTO } from "@app/ee/services/project-template/project-template-types"; import { SshCaStatus, SshCertType } from "@app/ee/services/ssh/ssh-certificate-authority-types"; +import { SshCertTemplateStatus } from "@app/ee/services/ssh-certificate-template/ssh-certificate-template-types"; import { SymmetricEncryption } from "@app/lib/crypto/cipher"; import { TProjectPermission } from "@app/lib/types"; import { ActorType } from "@app/services/auth/auth-type"; @@ -1238,6 +1239,7 @@ interface UpdateSshCertificateTemplate { certificateTemplateId: string; sshCaId: string; name: string; + status: SshCertTemplateStatus; ttl: string; maxTTL: string; allowedUsers: string[]; 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 index 328530733..fb7a95203 100644 --- 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 @@ -3,6 +3,7 @@ import { SshCertificateTemplatesSchema } from "@app/db/schemas"; export const sanitizedSshCertificateTemplate = SshCertificateTemplatesSchema.pick({ id: true, sshCaId: true, + status: true, name: true, ttl: true, maxTTL: 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 index 8553eae5c..a415f42b8 100644 --- 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 @@ -8,6 +8,7 @@ import { BadRequestError, NotFoundError } from "@app/lib/errors"; import { TSshCertificateAuthorityDALFactory } from "../ssh/ssh-certificate-authority-dal"; import { TSshCertificateTemplateDALFactory } from "./ssh-certificate-template-dal"; import { + SshCertTemplateStatus, TCreateSshCertTemplateDTO, TDeleteSshCertTemplateDTO, TGetSshCertTemplateDTO, @@ -15,7 +16,10 @@ import { } from "./ssh-certificate-template-types"; type TSshCertificateTemplateServiceFactoryDep = { - sshCertificateTemplateDAL: TSshCertificateTemplateDALFactory; + sshCertificateTemplateDAL: Pick< + TSshCertificateTemplateDALFactory, + "transaction" | "getByName" | "create" | "updateById" | "deleteById" | "getById" + >; sshCertificateAuthorityDAL: Pick; permissionService: Pick; }; @@ -62,36 +66,45 @@ export const sshCertificateTemplateServiceFactory = ({ OrgPermissionSubjects.SshCertificateTemplates ); - const existingTemplate = await sshCertificateTemplateDAL.getByName(name, ca.orgId); - if (existingTemplate) { - throw new BadRequestError({ - message: `SSH certificate template with name ${name} already exists` - }); - } - 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 + const newCertificateTemplate = await sshCertificateTemplateDAL.transaction(async (tx) => { + const existingTemplate = await sshCertificateTemplateDAL.getByName(name, ca.orgId, tx); + if (existingTemplate) { + throw new BadRequestError({ + message: `SSH certificate template with name ${name} already exists` + }); + } + + const certificateTemplate = await sshCertificateTemplateDAL.create( + { + sshCaId, + name, + ttl, + maxTTL, + allowUserCertificates, + allowHostCertificates, + allowedUsers, + allowedHosts, + allowCustomKeyIds, + status: SshCertTemplateStatus.ACTIVE + }, + tx + ); + + return certificateTemplate; }); - return { certificateTemplate, ca }; + return { certificateTemplate: newCertificateTemplate, ca }; }; const updateSshCertTemplate = async ({ id, + status, name, ttl, maxTTL, @@ -125,34 +138,43 @@ export const sshCertificateTemplateServiceFactory = ({ OrgPermissionSubjects.SshCertificateTemplates ); - if (name) { - const existingTemplate = await sshCertificateTemplateDAL.getByName(name, actorOrgId); - if (existingTemplate && existingTemplate.id !== id) { + const updatedCertificateTemplate = await sshCertificateTemplateDAL.transaction(async (tx) => { + if (name) { + const existingTemplate = await sshCertificateTemplateDAL.getByName(name, actorOrgId, tx); + if (existingTemplate && existingTemplate.id !== id) { + throw new BadRequestError({ + message: `SSH certificate template with name ${name} already exists` + }); + } + } + + if (ms(ttl || certTemplate.ttl) > ms(maxTTL || certTemplate.maxTTL)) { throw new BadRequestError({ - message: `SSH certificate template with name ${name} already exists` + message: "TTL cannot be greater than max TTL" }); } - } - 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, + { + status, + name, + ttl, + maxTTL, + allowUserCertificates, + allowHostCertificates, + allowedUsers, + allowedHosts, + allowCustomKeyIds + }, + tx + ); - const certificateTemplate = await sshCertificateTemplateDAL.updateById(id, { - name, - ttl, - maxTTL, - allowUserCertificates, - allowHostCertificates, - allowedUsers, - allowedHosts, - allowCustomKeyIds + return certificateTemplate; }); return { - certificateTemplate, + certificateTemplate: updatedCertificateTemplate, orgId: certTemplate.orgId }; }; 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 index 1920f4ca5..64de1bf0c 100644 --- 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 @@ -1,5 +1,10 @@ import { TProjectPermission } from "@app/lib/types"; +export enum SshCertTemplateStatus { + ACTIVE = "active", + DISABLED = "disabled" +} + export type TCreateSshCertTemplateDTO = { sshCaId: string; name: string; @@ -14,6 +19,7 @@ export type TCreateSshCertTemplateDTO = { export type TUpdateSshCertTemplateDTO = { id: string; + status?: SshCertTemplateStatus; name?: string; ttl?: string; maxTTL?: string; diff --git a/backend/src/ee/services/ssh-certificate/ssh-certificate-schema.ts b/backend/src/ee/services/ssh-certificate/ssh-certificate-schema.ts index 27f6ef7ed..9c3b7a392 100644 --- a/backend/src/ee/services/ssh-certificate/ssh-certificate-schema.ts +++ b/backend/src/ee/services/ssh-certificate/ssh-certificate-schema.ts @@ -8,5 +8,7 @@ export const sanitizedSshCertificate = SshCertificatesSchema.pick({ certType: true, publicKey: true, principals: true, - keyId: true + keyId: true, + notBefore: true, + notAfter: true }); diff --git a/backend/src/ee/services/ssh/ssh-certificate-authority-fns.ts b/backend/src/ee/services/ssh/ssh-certificate-authority-fns.ts index 63ef9e7e2..82be81be6 100644 --- a/backend/src/ee/services/ssh/ssh-certificate-authority-fns.ts +++ b/backend/src/ee/services/ssh/ssh-certificate-authority-fns.ts @@ -53,7 +53,9 @@ export const createSshKeyPair = (keyAlgorithm: CertKeyAlgorithm, comment: string keyBits = "384"; break; default: - throw new Error("Failed to produce SSH CA key pair generation command due to unrecognized key algorithm"); + throw new BadRequestError({ + message: "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}"`); @@ -200,7 +202,7 @@ export const validateSshCertificatePrincipals = ( * @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) => { +export const validateSshCertificateTtl = (template: TSshCertificateTemplates, ttl?: string) => { if (!ttl) { // use default template ttl return ms(template.ttl) / 1000; @@ -249,6 +251,8 @@ export const createSshCert = ({ caPrivateKey, userPublicKey, keyId, principals, const command = `ssh-keygen ${certOptions}`; + console.log("executing command", command); + // Execute the signing process execSync(command); diff --git a/backend/src/ee/services/ssh/ssh-certificate-authority-service.ts b/backend/src/ee/services/ssh/ssh-certificate-authority-service.ts index 690a71a7b..18b3c712d 100644 --- a/backend/src/ee/services/ssh/ssh-certificate-authority-service.ts +++ b/backend/src/ee/services/ssh/ssh-certificate-authority-service.ts @@ -9,6 +9,7 @@ import { TSshCertificateTemplateDALFactory } from "@app/ee/services/ssh-certific import { BadRequestError, NotFoundError } from "@app/lib/errors"; import { TKmsServiceFactory } from "@app/services/kms/kms-service"; +import { SshCertTemplateStatus } from "../ssh-certificate-template/ssh-certificate-template-types"; import { createSshCert, createSshKeyPair, @@ -23,6 +24,7 @@ import { TDeleteSshCaDTO, TGetSshCaCertificateTemplatesDTO, TGetSshCaDTO, + TGetSshCaPublicKeyDTO, TIssueSshCredsDTO, TSignSshKeyDTO, TUpdateSshCaDTO @@ -147,6 +149,30 @@ export const sshCertificateAuthorityServiceFactory = ({ return { ...ca, publicKey }; }; + /** + * Return public key of SSH CA with id [caId] + */ + const getSshCaPublicKey = async ({ caId }: TGetSshCaPublicKeyDTO) => { + const ca = await sshCertificateAuthorityDAL.findById(caId); + if (!ca) throw new NotFoundError({ message: `SSH CA with ID '${caId}' not found` }); + + const sshCaSecret = await sshCertificateAuthoritySecretDAL.findOne({ sshCaId: ca.id }); + + // decrypt secret + const orgKmsKeyId = await kmsService.getOrgKmsKeyId(ca.orgId); + const kmsDecryptor = await kmsService.decryptWithKmsKey({ + kmsId: orgKmsKeyId + }); + + const decryptedCaPrivateKey = await kmsDecryptor({ + cipherTextBlob: sshCaSecret.encryptedPrivateKey + }); + + const publicKey = getSshPublicKey(decryptedCaPrivateKey.toString("utf-8")); + + return publicKey; + }; + /** * Update SSH CA with id [caId] * Note: Used to enable/disable CA @@ -259,6 +285,12 @@ export const sshCertificateAuthorityServiceFactory = ({ }); } + if (sshCertificateTemplate.status === SshCertTemplateStatus.DISABLED) { + throw new BadRequestError({ + message: "SSH certificate template is disabled" + }); + } + // validate if the requested [certType] is allowed under the template configuration validateSshCertificateType(sshCertificateTemplate, certType); @@ -359,6 +391,12 @@ export const sshCertificateAuthorityServiceFactory = ({ }); } + if (sshCertificateTemplate.status === SshCertTemplateStatus.DISABLED) { + throw new BadRequestError({ + message: "SSH certificate template is disabled" + }); + } + // validate if the requested [certType] is allowed under the template configuration validateSshCertificateType(sshCertificateTemplate, certType); @@ -445,6 +483,7 @@ export const sshCertificateAuthorityServiceFactory = ({ signSshKey, createSshCa, getSshCaById, + getSshCaPublicKey, 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 index ba4e5aa54..7d949bfa0 100644 --- a/backend/src/ee/services/ssh/ssh-certificate-authority-types.ts +++ b/backend/src/ee/services/ssh/ssh-certificate-authority-types.ts @@ -20,6 +20,10 @@ export type TGetSshCaDTO = { caId: string; } & Omit; +export type TGetSshCaPublicKeyDTO = { + caId: string; +}; + export type TUpdateSshCaDTO = { caId: string; friendlyName?: string; diff --git a/backend/src/lib/api-docs/constants.ts b/backend/src/lib/api-docs/constants.ts index d881a5233..500611d6f 100644 --- a/backend/src/lib/api-docs/constants.ts +++ b/backend/src/lib/api-docs/constants.ts @@ -1154,6 +1154,9 @@ export const SSH_CERTIFICATE_AUTHORITIES = { GET: { sshCaId: "The ID of the SSH CA to get." }, + GET_PUBLIC_KEY: { + sshCaId: "The ID of the SSH CA to get the public key for." + }, UPDATE: { sshCaId: "The ID of the SSH CA to update.", friendlyName: "A friendly name for the SSH CA to update to.", diff --git a/backend/src/server/routes/v1/organization-router.ts b/backend/src/server/routes/v1/organization-router.ts index c1b8881e9..c249bf2e5 100644 --- a/backend/src/server/routes/v1/organization-router.ts +++ b/backend/src/server/routes/v1/organization-router.ts @@ -425,7 +425,7 @@ export const registerOrgRouter = async (server: FastifyZodProvider) => { response: { 200: z.object({ certificates: z.array(sanitizedSshCertificate), - totalCount: z.number() // TODO + totalCount: z.number() }) } }, diff --git a/frontend/src/hooks/api/ca/constants.tsx b/frontend/src/hooks/api/ca/constants.tsx index 016f7d7b5..bbc75f90d 100644 --- a/frontend/src/hooks/api/ca/constants.tsx +++ b/frontend/src/hooks/api/ca/constants.tsx @@ -1,4 +1,5 @@ import { SshCaStatus } from "@app/hooks/api/ssh-ca"; +import { SshCertTemplateStatus } from "@app/hooks/api/sshCertificateTemplates"; import { CaStatus, CaType } from "./enums"; @@ -13,7 +14,7 @@ export const caStatusToNameMap: { [K in CaStatus]: string } = { [CaStatus.PENDING_CERTIFICATE]: "Pending Certificate" }; -export const getCaStatusBadgeVariant = (status: CaStatus | SshCaStatus) => { +export const getCaStatusBadgeVariant = (status: CaStatus | SshCaStatus | SshCertTemplateStatus) => { switch (status) { case CaStatus.ACTIVE: return "success"; diff --git a/frontend/src/hooks/api/ssh-ca/types.ts b/frontend/src/hooks/api/ssh-ca/types.ts index 747802c32..2873fafeb 100644 --- a/frontend/src/hooks/api/ssh-ca/types.ts +++ b/frontend/src/hooks/api/ssh-ca/types.ts @@ -10,6 +10,8 @@ export type TSshCertificate = { publicKey: string; principals: string[]; keyId: string; + notBefore: string; + notAfter: string; }; export type TSshCertificateAuthority = { diff --git a/frontend/src/hooks/api/sshCertificateTemplates/index.tsx b/frontend/src/hooks/api/sshCertificateTemplates/index.tsx index 89f7ebe14..9efe99c81 100644 --- a/frontend/src/hooks/api/sshCertificateTemplates/index.tsx +++ b/frontend/src/hooks/api/sshCertificateTemplates/index.tsx @@ -1,5 +1,7 @@ export { useCreateSshCertTemplate, useDeleteSshCertTemplate, - useUpdateSshCertTemplate} from "./mutations"; + useUpdateSshCertTemplate +} from "./mutations"; export { useGetSshCertTemplate } from "./queries"; +export * from "./types"; diff --git a/frontend/src/hooks/api/sshCertificateTemplates/types.ts b/frontend/src/hooks/api/sshCertificateTemplates/types.ts index fceaff56d..4099b4b8a 100644 --- a/frontend/src/hooks/api/sshCertificateTemplates/types.ts +++ b/frontend/src/hooks/api/sshCertificateTemplates/types.ts @@ -1,6 +1,12 @@ +export enum SshCertTemplateStatus { + ACTIVE = "active", + DISABLED = "disabled" +} + export type TSshCertificateTemplate = { id: string; sshCaId: string; + status: SshCertTemplateStatus; name: string; ttl: string; maxTTL: string; @@ -25,6 +31,7 @@ export type TCreateSshCertificateTemplateDTO = { export type TUpdateSshCertificateTemplateDTO = { id: string; + status?: SshCertTemplateStatus; name?: string; ttl?: string; maxTTL?: string; diff --git a/frontend/src/views/Org/SshCaPage/components/SshCertificateModal.tsx b/frontend/src/views/Org/SshCaPage/components/SshCertificateModal.tsx index 3238160e9..ea83a95bb 100644 --- a/frontend/src/views/Org/SshCaPage/components/SshCertificateModal.tsx +++ b/frontend/src/views/Org/SshCaPage/components/SshCertificateModal.tsx @@ -14,7 +14,12 @@ import { SelectItem } from "@app/components/v2"; import { useOrganization } from "@app/context"; -import { useIssueSshCreds, useListOrgSshCertificateTemplates, useSignSshKey } from "@app/hooks/api"; +import { + SshCertTemplateStatus, + useGetSshCertTemplate, + useIssueSshCreds, + useListOrgSshCertificateTemplates, + useSignSshKey} from "@app/hooks/api"; import { certKeyAlgorithms } from "@app/hooks/api/certificates/constants"; import { CertKeyAlgorithm } from "@app/hooks/api/certificates/enums"; import { SshCertType } from "@app/hooks/api/ssh-ca/constants"; @@ -22,14 +27,8 @@ import { UsePopUpState } from "@app/hooks/usePopUp"; import { SshCertificateContent } from "./SshCertificateContent"; -/** - * // NOTE (dangtony98): current UI only supports SSH certificate - * issuance via /issue endpoint but should extend to also support - * /sign endpoint as this is already supported in the backend - */ - const schema = z.object({ - templateName: z.string(), + templateId: z.string(), publicKey: z.string().optional(), keyAlgorithm: z.enum([ CertKeyAlgorithm.RSA_2048, @@ -72,7 +71,11 @@ export const SshCertificateModal = ({ popUp, handlePopUpToggle }: Props) => { const { mutateAsync: signSshKey } = useSignSshKey(); const { mutateAsync: issueSshCreds } = useIssueSshCreds(); - const popUpData = popUp?.sshCertificate?.data as { sshCaId: string; templateName: string }; + const popUpData = popUp?.sshCertificate?.data as { + sshCaId: string; + templateName: string; + templateId: string; + }; const { data: templatesData } = useListOrgSshCertificateTemplates({ orgId: currentOrg?.id || "" @@ -83,7 +86,8 @@ export const SshCertificateModal = ({ popUp, handlePopUpToggle }: Props) => { handleSubmit, reset, formState: { isSubmitting }, - setValue + setValue, + watch } = useForm({ resolver: zodResolver(schema), defaultValues: { @@ -92,16 +96,18 @@ export const SshCertificateModal = ({ popUp, handlePopUpToggle }: Props) => { } }); + const templateId = watch("templateId"); + const { data: templateData } = useGetSshCertTemplate(templateId); + useEffect(() => { if (popUpData) { - setValue("templateName", popUpData.templateName); + setValue("templateId", popUpData.templateId); } else if (templatesData && templatesData.certificateTemplates.length > 0) { - setValue("templateName", templatesData.certificateTemplates[0].name); + setValue("templateId", templatesData.certificateTemplates[0].id); } }, [popUpData]); const onFormSubmit = async ({ - templateName, keyAlgorithm, certType, publicKey: existingPublicKey, @@ -110,10 +116,12 @@ export const SshCertificateModal = ({ popUp, handlePopUpToggle }: Props) => { keyId }: FormData) => { try { + if (!templateData) return; + switch (operation) { case SshCertificateOperation.SIGN_SSH_KEY: { const { serialNumber, signedKey } = await signSshKey({ - templateName, + templateName: templateData.name, publicKey: existingPublicKey, certType, principals: principals.split(",").map((user) => user.trim()), @@ -129,7 +137,7 @@ export const SshCertificateModal = ({ popUp, handlePopUpToggle }: Props) => { } case SshCertificateOperation.ISSUE_SSH_CREDS: { const { serialNumber, publicKey, privateKey, signedKey } = await issueSshCreds({ - templateName, + templateName: templateData.name, keyAlgorithm, certType, principals: principals.split(",").map((user) => user.trim()), @@ -179,7 +187,7 @@ export const SshCertificateModal = ({ popUp, handlePopUpToggle }: Props) => {
( { className="w-full" isDisabled={Boolean(popUpData?.sshCaId)} > - {(templatesData?.certificateTemplates || []).map(({ id, name }) => ( - - {name} - - ))} + {(templatesData?.certificateTemplates || []) + .filter((template) => template.status === SshCertTemplateStatus.ACTIVE) + .map(({ id, name }) => ( + + {name} + + ))} )} @@ -235,8 +245,12 @@ export const SshCertificateModal = ({ popUp, handlePopUpToggle }: Props) => { onValueChange={(e) => onChange(e)} className="w-full" > - User - Host + {templateData && templateData.allowUserCertificates && ( + User + )} + {templateData && templateData.allowHostCertificates && ( + Host + )} )} @@ -308,15 +322,17 @@ export const SshCertificateModal = ({ popUp, handlePopUpToggle }: Props) => { )} /> - ( - - - - )} - /> + {templateData && templateData.allowCustomKeyIds && ( + ( + + + + )} + /> + )}
+ @@ -58,6 +74,11 @@ export const SshCertificateTemplatesTable = ({ handlePopUpOpen, sshCaId }: Props return ( +
NameStatus
{certificateTemplate.name} + + {caStatusToNameMap[certificateTemplate.status]} + + @@ -68,6 +89,36 @@ export const SshCertificateTemplatesTable = ({ handlePopUpOpen, sshCaId }: Props + + {(isAllowed) => ( + { + e.stopPropagation(); + handlePopUpOpen("sshCertificateTemplateStatus", { + certTemplateId: certificateTemplate.id, + status: + certificateTemplate.status === SshCertTemplateStatus.ACTIVE + ? SshCertTemplateStatus.DISABLED + : SshCertTemplateStatus.ACTIVE + }); + }} + disabled={!isAllowed} + icon={} + > + {`${ + certificateTemplate.status === SshCertTemplateStatus.ACTIVE + ? "Disable" + : "Enable" + } Template`} + + )} + } > - Issue SSH Certificate + Issue Certificate { limit: perPage }); - console.log("SSH Certificates Table data: ", data); - return ( - - + + + {isLoading && } {!isLoading && data?.certificates?.map((certificate) => { + const { variant, label } = getSshCertStatusBadgeDetails(certificate.notAfter); return ( - - + + + ); })} diff --git a/frontend/src/views/Org/SshPage/components/SshCertificatesTable.utils.ts b/frontend/src/views/Org/SshPage/components/SshCertificatesTable.utils.ts new file mode 100644 index 000000000..12b75aa08 --- /dev/null +++ b/frontend/src/views/Org/SshPage/components/SshCertificatesTable.utils.ts @@ -0,0 +1,17 @@ +export const getSshCertStatusBadgeDetails = (notAfter: string) => { + const currentDate = new Date().getTime(); + const notAfterDate = new Date(notAfter).getTime(); + + let variant: "success" | "primary" | "danger" = "success"; + let label = "Active"; + + if (notAfterDate > currentDate) { + variant = "success"; + label = "Active"; + } else { + variant = "danger"; + label = "Expired"; + } + + return { variant, label }; +}; From 40d69d46203f8758f8bc857ac3b3f78f297328fb Mon Sep 17 00:00:00 2001 From: = Date: Mon, 9 Dec 2024 19:15:17 +0530 Subject: [PATCH 020/162] feat: added endpoint to update integration auth --- backend/src/lib/api-docs/constants.ts | 5 + .../routes/v1/integration-auth-router.ts | 61 ++++++++ .../server/routes/v1/integration-router.ts | 4 +- .../integration-auth-service.ts | 145 ++++++++++++++++++ .../integration-auth-types.ts | 5 + .../integration/integration-service.ts | 6 +- .../services/integration/integration-types.ts | 2 + 7 files changed, 226 insertions(+), 2 deletions(-) diff --git a/backend/src/lib/api-docs/constants.ts b/backend/src/lib/api-docs/constants.ts index 99822da29..518654da1 100644 --- a/backend/src/lib/api-docs/constants.ts +++ b/backend/src/lib/api-docs/constants.ts @@ -1032,6 +1032,9 @@ export const INTEGRATION_AUTH = { DELETE_BY_ID: { integrationAuthId: "The ID of integration authentication object to delete." }, + UPDATE_BY_ID: { + integrationAuthId: "The ID of integration authentication object to update." + }, CREATE_ACCESS_TOKEN: { workspaceId: "The ID of the project to create the integration auth for.", integration: "The slug of integration for the auth object.", @@ -1088,11 +1091,13 @@ export const INTEGRATION = { }, UPDATE: { integrationId: "The ID of the integration object.", + region: "AWS region to sync secrets to.", app: "The name of the external integration providers app entity that you want to sync secrets with. Used in Netlify, GitHub, Vercel integrations.", appId: "The ID of the external integration providers app entity that you want to sync secrets with. Used in Netlify, GitHub, Vercel integrations.", isActive: "Whether the integration should be active or disabled.", secretPath: "The path of the secrets to sync secrets from.", + path: "Path to save the synced secrets. Used by Gitlab, AWS Parameter Store, Vault.", owner: "External integration providers service entity owner. Used in Github.", targetEnvironment: "The target environment of the integration provider. Used in cloudflare pages, TeamCity, Gitlab integrations.", diff --git a/backend/src/server/routes/v1/integration-auth-router.ts b/backend/src/server/routes/v1/integration-auth-router.ts index 575544cc7..ea7b6f917 100644 --- a/backend/src/server/routes/v1/integration-auth-router.ts +++ b/backend/src/server/routes/v1/integration-auth-router.ts @@ -82,6 +82,67 @@ export const registerIntegrationAuthRouter = async (server: FastifyZodProvider) } }); + server.route({ + method: "PATCH", + url: "/:integrationAuthId", + config: { + rateLimit: writeLimit + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + schema: { + description: "Update the integration authentication object required for syncing secrets.", + security: [ + { + bearerAuth: [] + } + ], + querystring: z.object({ + integrationAuthId: z.string().trim().describe(INTEGRATION_AUTH.UPDATE_BY_ID.integrationAuthId) + }), + body: z.object({ + integration: z.string().trim().optional().describe(INTEGRATION_AUTH.CREATE_ACCESS_TOKEN.integration), + accessId: z.string().trim().optional().describe(INTEGRATION_AUTH.CREATE_ACCESS_TOKEN.accessId), + accessToken: z.string().trim().optional().describe(INTEGRATION_AUTH.CREATE_ACCESS_TOKEN.accessToken), + awsAssumeIamRoleArn: z + .string() + .url() + .trim() + .optional() + .describe(INTEGRATION_AUTH.CREATE_ACCESS_TOKEN.awsAssumeIamRoleArn), + url: z.string().url().trim().optional().describe(INTEGRATION_AUTH.CREATE_ACCESS_TOKEN.url), + namespace: z.string().trim().optional().describe(INTEGRATION_AUTH.CREATE_ACCESS_TOKEN.namespace), + refreshToken: z.string().trim().optional().describe(INTEGRATION_AUTH.CREATE_ACCESS_TOKEN.refreshToken) + }), + response: { + 200: z.object({ + integrationAuth: integrationAuthPubSchema + }) + } + }, + handler: async (req) => { + const integrationAuth = await server.services.integrationAuth.updateIntegrationAuth({ + actorId: req.permission.id, + actor: req.permission.type, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + integrationAuthId: req.query.integrationAuthId, + ...req.body + }); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + projectId: integrationAuth.projectId, + event: { + type: EventType.AUTHORIZE_INTEGRATION, + metadata: { + integration: integrationAuth.integration + } + } + }); + return { integrationAuth }; + } + }); + server.route({ method: "DELETE", url: "/", diff --git a/backend/src/server/routes/v1/integration-router.ts b/backend/src/server/routes/v1/integration-router.ts index 40141e2c0..059d24463 100644 --- a/backend/src/server/routes/v1/integration-router.ts +++ b/backend/src/server/routes/v1/integration-router.ts @@ -141,7 +141,9 @@ export const registerIntegrationRouter = async (server: FastifyZodProvider) => { targetEnvironment: z.string().trim().optional().describe(INTEGRATION.UPDATE.targetEnvironment), owner: z.string().trim().optional().describe(INTEGRATION.UPDATE.owner), environment: z.string().trim().optional().describe(INTEGRATION.UPDATE.environment), - metadata: IntegrationMetadataSchema.optional() + path: z.string().trim().optional().describe(INTEGRATION.UPDATE.path), + metadata: IntegrationMetadataSchema.optional(), + region: z.string().trim().optional().describe(INTEGRATION.UPDATE.region) }), response: { 200: z.object({ diff --git a/backend/src/services/integration-auth/integration-auth-service.ts b/backend/src/services/integration-auth/integration-auth-service.ts index be1a8d53c..6768e12bf 100644 --- a/backend/src/services/integration-auth/integration-auth-service.ts +++ b/backend/src/services/integration-auth/integration-auth-service.ts @@ -55,6 +55,7 @@ import { TOctopusDeployVariableSet, TSaveIntegrationAccessTokenDTO, TTeamCityBuildConfig, + TUpdateIntegrationAuthDTO, TVercelBranches } from "./integration-auth-types"; import { getIntegrationOptions, Integrations, IntegrationUrls } from "./integration-list"; @@ -368,6 +369,149 @@ export const integrationAuthServiceFactory = ({ return integrationAuthDAL.create(updateDoc); }; + const updateIntegrationAuth = async ({ + integrationAuthId, + refreshToken, + actorId, + integration: newIntegration, + url, + actor, + actorOrgId, + actorAuthMethod, + accessId, + namespace, + accessToken, + awsAssumeIamRoleArn + }: TUpdateIntegrationAuthDTO) => { + const integrationAuth = await integrationAuthDAL.findById(integrationAuthId); + if (!integrationAuth) { + throw new NotFoundError({ message: `Integration auth with id ${integrationAuthId} not found.` }); + } + + const { permission } = await permissionService.getProjectPermission( + actor, + actorId, + integrationAuth.projectId, + actorAuthMethod, + actorOrgId + ); + ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Edit, ProjectPermissionSub.Integrations); + + const { projectId } = integrationAuth; + const integration = newIntegration || integrationAuth.integration; + if (!Object.values(Integrations).includes(integration as Integrations)) + throw new BadRequestError({ message: "Invalid integration" }); + const updateDoc: TIntegrationAuthsInsert = { + projectId, + integration, + namespace, + url, + algorithm: SecretEncryptionAlgo.AES_256_GCM, + keyEncoding: SecretKeyEncoding.UTF8, + ...(integration === Integrations.GCP_SECRET_MANAGER + ? { + metadata: { + authMethod: "serviceAccount" + } + } + : {}) + }; + + const { shouldUseSecretV2Bridge, botKey } = await projectBotService.getBotKey(projectId); + if (shouldUseSecretV2Bridge) { + const { encryptor: secretManagerEncryptor } = await kmsService.createCipherPairWithDataKey({ + type: KmsDataKey.SecretManager, + projectId + }); + if (refreshToken) { + const tokenDetails = await exchangeRefresh( + integration, + refreshToken, + url, + updateDoc.metadata as Record + ); + const refreshEncToken = secretManagerEncryptor({ + plainText: Buffer.from(tokenDetails.refreshToken) + }).cipherTextBlob; + updateDoc.encryptedRefresh = refreshEncToken; + + const accessEncToken = secretManagerEncryptor({ + plainText: Buffer.from(tokenDetails.accessToken) + }).cipherTextBlob; + updateDoc.encryptedAccess = accessEncToken; + updateDoc.accessExpiresAt = tokenDetails.accessExpiresAt; + } + + if (!refreshToken && (accessId || accessToken || awsAssumeIamRoleArn)) { + if (accessToken) { + const accessEncToken = secretManagerEncryptor({ + plainText: Buffer.from(accessToken) + }).cipherTextBlob; + updateDoc.encryptedAccess = accessEncToken; + updateDoc.encryptedAwsAssumeIamRoleArn = null; + } + if (accessId) { + const accessEncToken = secretManagerEncryptor({ + plainText: Buffer.from(accessId) + }).cipherTextBlob; + updateDoc.encryptedAccessId = accessEncToken; + updateDoc.encryptedAwsAssumeIamRoleArn = null; + } + if (awsAssumeIamRoleArn) { + const awsAssumeIamRoleArnEncrypted = secretManagerEncryptor({ + plainText: Buffer.from(awsAssumeIamRoleArn) + }).cipherTextBlob; + updateDoc.encryptedAwsAssumeIamRoleArn = awsAssumeIamRoleArnEncrypted; + updateDoc.encryptedAccess = null; + updateDoc.encryptedAccessId = null; + } + } + } else { + if (!botKey) throw new NotFoundError({ message: `Project bot key for project with ID '${projectId}' not found` }); + if (refreshToken) { + const tokenDetails = await exchangeRefresh( + integration, + refreshToken, + url, + updateDoc.metadata as Record + ); + const refreshEncToken = encryptSymmetric128BitHexKeyUTF8(tokenDetails.refreshToken, botKey); + updateDoc.refreshIV = refreshEncToken.iv; + updateDoc.refreshTag = refreshEncToken.tag; + updateDoc.refreshCiphertext = refreshEncToken.ciphertext; + const accessEncToken = encryptSymmetric128BitHexKeyUTF8(tokenDetails.accessToken, botKey); + updateDoc.accessIV = accessEncToken.iv; + updateDoc.accessTag = accessEncToken.tag; + updateDoc.accessCiphertext = accessEncToken.ciphertext; + + updateDoc.accessExpiresAt = tokenDetails.accessExpiresAt; + } + + if (!refreshToken && (accessId || accessToken || awsAssumeIamRoleArn)) { + if (accessToken) { + const accessEncToken = encryptSymmetric128BitHexKeyUTF8(accessToken, botKey); + updateDoc.accessIV = accessEncToken.iv; + updateDoc.accessTag = accessEncToken.tag; + updateDoc.accessCiphertext = accessEncToken.ciphertext; + } + if (accessId) { + const accessEncToken = encryptSymmetric128BitHexKeyUTF8(accessId, botKey); + updateDoc.accessIdIV = accessEncToken.iv; + updateDoc.accessIdTag = accessEncToken.tag; + updateDoc.accessIdCiphertext = accessEncToken.ciphertext; + } + if (awsAssumeIamRoleArn) { + const awsAssumeIamRoleArnEnc = encryptSymmetric128BitHexKeyUTF8(awsAssumeIamRoleArn, botKey); + updateDoc.awsAssumeIamRoleArnCipherText = awsAssumeIamRoleArnEnc.ciphertext; + updateDoc.awsAssumeIamRoleArnIV = awsAssumeIamRoleArnEnc.iv; + updateDoc.awsAssumeIamRoleArnTag = awsAssumeIamRoleArnEnc.tag; + } + } + } + + return integrationAuthDAL.updateById(integrationAuthId, updateDoc); + }; + // helper function const getIntegrationAccessToken = async ( integrationAuth: TIntegrationAuths, @@ -1615,6 +1759,7 @@ export const integrationAuthServiceFactory = ({ getIntegrationAuth, oauthExchange, saveIntegrationToken, + updateIntegrationAuth, deleteIntegrationAuthById, deleteIntegrationAuths, getIntegrationAuthTeams, diff --git a/backend/src/services/integration-auth/integration-auth-types.ts b/backend/src/services/integration-auth/integration-auth-types.ts index 80e8d6c36..3ffa6959a 100644 --- a/backend/src/services/integration-auth/integration-auth-types.ts +++ b/backend/src/services/integration-auth/integration-auth-types.ts @@ -22,6 +22,11 @@ export type TSaveIntegrationAccessTokenDTO = { awsAssumeIamRoleArn?: string; } & TProjectPermission; +export type TUpdateIntegrationAuthDTO = Omit & { + integrationAuthId: string; + integration?: string; +}; + export type TDeleteIntegrationAuthsDTO = TProjectPermission & { integration: string; projectId: string; diff --git a/backend/src/services/integration/integration-service.ts b/backend/src/services/integration/integration-service.ts index 1db10405d..a990b1ca6 100644 --- a/backend/src/services/integration/integration-service.ts +++ b/backend/src/services/integration/integration-service.ts @@ -151,7 +151,9 @@ export const integrationServiceFactory = ({ isActive, environment, secretPath, - metadata + region, + metadata, + path }: TUpdateIntegrationDTO) => { const integration = await integrationDAL.findById(id); if (!integration) throw new NotFoundError({ message: `Integration with ID '${id}' not found` }); @@ -192,7 +194,9 @@ export const integrationServiceFactory = ({ appId, targetEnvironment, owner, + region, secretPath, + path, metadata: { ...(integration.metadata as object), ...metadata diff --git a/backend/src/services/integration/integration-types.ts b/backend/src/services/integration/integration-types.ts index a27c4f6ac..f662affd8 100644 --- a/backend/src/services/integration/integration-types.ts +++ b/backend/src/services/integration/integration-types.ts @@ -49,6 +49,8 @@ export type TUpdateIntegrationDTO = { appId?: string; isActive?: boolean; secretPath?: string; + region?: string; + path?: string; targetEnvironment?: string; owner?: string; environment?: string; From 7d5aba258a6120de5a9eff3dfcf626d9dd95f397 Mon Sep 17 00:00:00 2001 From: McPizza Date: Mon, 9 Dec 2024 15:11:12 +0100 Subject: [PATCH 021/162] improvement: Add email footer with instance URL --- backend/src/services/smtp/smtp-service.ts | 7 ++++ .../accessApprovalRequest.handlebars | 2 + .../accessSecretRequestBypassed.handlebars | 9 ++++- .../smtp/templates/emailMfa.handlebars | 3 +- .../templates/emailVerification.handlebars | 2 + .../templates/externalImportFailed.handlebars | 1 + .../externalImportStarted.handlebars | 2 + .../externalImportSuccessful.handlebars | 2 + .../historicalSecretLeakIncident.handlebars | 28 +++++++------- .../integrationSyncFailed.handlebars | 2 + .../smtp/templates/newDevice.handlebars | 7 +++- .../organizationInvitation.handlebars | 2 + .../smtp/templates/passwordReset.handlebars | 18 +++++---- .../templates/pkiExpirationAlert.handlebars | 2 + .../templates/scimUserProvisioned.handlebars | 18 +++++---- ...ecretApprovalRequestNeedsReview.handlebars | 2 + .../templates/secretLeakIncident.handlebars | 38 ++++++++++--------- .../smtp/templates/secretReminder.handlebars | 2 + .../signupEmailVerification.handlebars | 18 +++++---- .../smtp/templates/unlockAccount.handlebars | 2 + .../templates/workspaceInvitation.handlebars | 2 + 21 files changed, 108 insertions(+), 61 deletions(-) diff --git a/backend/src/services/smtp/smtp-service.ts b/backend/src/services/smtp/smtp-service.ts index bdf2fe18c..9ead50bb7 100644 --- a/backend/src/services/smtp/smtp-service.ts +++ b/backend/src/services/smtp/smtp-service.ts @@ -53,6 +53,13 @@ export const smtpServiceFactory = (cfg: TSmtpConfig) => { const smtp = createTransport(cfg); const isSmtpOn = Boolean(cfg.host); + handlebars.registerHelper("emailFooter", () => { + const { isCloud, SITE_URL } = getConfig(); + const cloudFooterHtml = `

Infisical - a tool for managing secrets in your organization. Learn more

`; + const selfHostedFooterHtml = `

Email sent via Infisical at ${SITE_URL}

`; + return new handlebars.SafeString(isCloud ? cloudFooterHtml : selfHostedFooterHtml); + }); + const sendMail = async ({ substitutions, recipients, template, subjectLine }: TSmtpSendMail) => { const appCfg = getConfig(); const html = await fs.readFile(path.resolve(__dirname, "./templates/", template), "utf8"); diff --git a/backend/src/services/smtp/templates/accessApprovalRequest.handlebars b/backend/src/services/smtp/templates/accessApprovalRequest.handlebars index 82c66ce5f..3c0811a1c 100644 --- a/backend/src/services/smtp/templates/accessApprovalRequest.handlebars +++ b/backend/src/services/smtp/templates/accessApprovalRequest.handlebars @@ -45,6 +45,8 @@ View the request and approve or deny it here.

+ + {{emailFooter}} \ No newline at end of file diff --git a/backend/src/services/smtp/templates/accessSecretRequestBypassed.handlebars b/backend/src/services/smtp/templates/accessSecretRequestBypassed.handlebars index 3313d352f..8c82df289 100644 --- a/backend/src/services/smtp/templates/accessSecretRequestBypassed.handlebars +++ b/backend/src/services/smtp/templates/accessSecretRequestBypassed.handlebars @@ -11,8 +11,11 @@

A secret approval request has been bypassed in the project "{{projectName}}".

- {{requesterFullName}} ({{requesterEmail}}) has merged - a secret to environment {{environment}} at secret path {{secretPath}} + {{requesterFullName}} + ({{requesterEmail}}) has merged a secret to environment + {{environment}} + at secret path + {{secretPath}} without obtaining the required approvals.

@@ -24,5 +27,7 @@ To review this action, please visit the request panel here.

+ + {{emailFooter}} \ No newline at end of file diff --git a/backend/src/services/smtp/templates/emailMfa.handlebars b/backend/src/services/smtp/templates/emailMfa.handlebars index 936195c34..4c948b08c 100644 --- a/backend/src/services/smtp/templates/emailMfa.handlebars +++ b/backend/src/services/smtp/templates/emailMfa.handlebars @@ -1,4 +1,3 @@ - @@ -14,6 +13,8 @@

{{code}}

The MFA code will be valid for 2 minutes.

Not you? Contact {{#if isCloud}}Infisical{{else}}your administrator{{/if}} immediately.

+ + {{emailFooter}} \ No newline at end of file diff --git a/backend/src/services/smtp/templates/emailVerification.handlebars b/backend/src/services/smtp/templates/emailVerification.handlebars index ad9694d5c..4a989626e 100644 --- a/backend/src/services/smtp/templates/emailVerification.handlebars +++ b/backend/src/services/smtp/templates/emailVerification.handlebars @@ -10,6 +10,8 @@

Confirm your email address

Your confirmation code is below — enter it in the browser window where you've started confirming your email.

{{code}}

+ + {{emailFooter}} \ No newline at end of file diff --git a/backend/src/services/smtp/templates/externalImportFailed.handlebars b/backend/src/services/smtp/templates/externalImportFailed.handlebars index c7869af27..1755052c1 100644 --- a/backend/src/services/smtp/templates/externalImportFailed.handlebars +++ b/backend/src/services/smtp/templates/externalImportFailed.handlebars @@ -16,6 +16,7 @@

Error: {{error}}

+ {{emailFooter}} \ No newline at end of file diff --git a/backend/src/services/smtp/templates/externalImportStarted.handlebars b/backend/src/services/smtp/templates/externalImportStarted.handlebars index 551f972cc..90026f762 100644 --- a/backend/src/services/smtp/templates/externalImportStarted.handlebars +++ b/backend/src/services/smtp/templates/externalImportStarted.handlebars @@ -12,6 +12,8 @@ {{provider}} to Infisical is in progress. The import process may take up to 30 minutes, and you will receive once the import has finished or if it fails.

+ + {{emailFooter}} \ No newline at end of file diff --git a/backend/src/services/smtp/templates/externalImportSuccessful.handlebars b/backend/src/services/smtp/templates/externalImportSuccessful.handlebars index 51a1c465e..a918e9ec7 100644 --- a/backend/src/services/smtp/templates/externalImportSuccessful.handlebars +++ b/backend/src/services/smtp/templates/externalImportSuccessful.handlebars @@ -9,6 +9,8 @@

An import from {{provider}} to Infisical was successful

An import from {{provider}} was successful. Your data is now available in Infisical.

+ + {{emailFooter}} \ No newline at end of file diff --git a/backend/src/services/smtp/templates/historicalSecretLeakIncident.handlebars b/backend/src/services/smtp/templates/historicalSecretLeakIncident.handlebars index 0798538fb..4a918ee0d 100644 --- a/backend/src/services/smtp/templates/historicalSecretLeakIncident.handlebars +++ b/backend/src/services/smtp/templates/historicalSecretLeakIncident.handlebars @@ -1,21 +1,21 @@ - - - - - Incident alert: secrets potentially leaked - + + + + Incident alert: secrets potentially leaked + - -

Infisical has uncovered {{numberOfSecrets}} secret(s) from historical commits to your repo

-

View leaked secrets

+ +

Infisical has uncovered {{numberOfSecrets}} secret(s) from historical commits to your repo

+

View leaked secrets

-

If these are production secrets, please rotate them immediately.

+

If these are production secrets, please rotate them immediately.

-

Once you have taken action, be sure to update the status of the risk in your Infisical - dashboard.

- +

Once you have taken action, be sure to update the status of the risk in your + Infisical dashboard.

+ + {{emailFooter}} + \ No newline at end of file diff --git a/backend/src/services/smtp/templates/integrationSyncFailed.handlebars b/backend/src/services/smtp/templates/integrationSyncFailed.handlebars index 5c5d76693..2aff820fa 100644 --- a/backend/src/services/smtp/templates/integrationSyncFailed.handlebars +++ b/backend/src/services/smtp/templates/integrationSyncFailed.handlebars @@ -26,6 +26,8 @@ {{#if syncMessage}}

Reason: {{syncMessage}}

{{/if}} + + {{emailFooter}} \ No newline at end of file diff --git a/backend/src/services/smtp/templates/newDevice.handlebars b/backend/src/services/smtp/templates/newDevice.handlebars index 6c7f2e9f6..197e0b7a7 100644 --- a/backend/src/services/smtp/templates/newDevice.handlebars +++ b/backend/src/services/smtp/templates/newDevice.handlebars @@ -1,4 +1,3 @@ - @@ -13,7 +12,11 @@

Timestamp: {{timestamp}}

IP address: {{ip}}

User agent: {{userAgent}}

-

If you believe that this login is suspicious, please contact {{#if isCloud}}Infisical{{else}}your administrator{{/if}} or reset your password immediately.

+

If you believe that this login is suspicious, please contact + {{#if isCloud}}Infisical{{else}}your administrator{{/if}} + or reset your password immediately.

+ + {{emailFooter}} \ No newline at end of file diff --git a/backend/src/services/smtp/templates/organizationInvitation.handlebars b/backend/src/services/smtp/templates/organizationInvitation.handlebars index c3ac9556d..da429477b 100644 --- a/backend/src/services/smtp/templates/organizationInvitation.handlebars +++ b/backend/src/services/smtp/templates/organizationInvitation.handlebars @@ -12,5 +12,7 @@ Click to join

What is Infisical?

Infisical is an easy-to-use end-to-end encrypted tool that enables developers to sync and manage their secrets and configs.

+ + {{emailFooter}} diff --git a/backend/src/services/smtp/templates/passwordReset.handlebars b/backend/src/services/smtp/templates/passwordReset.handlebars index 6499a629c..1cb2ae8ce 100644 --- a/backend/src/services/smtp/templates/passwordReset.handlebars +++ b/backend/src/services/smtp/templates/passwordReset.handlebars @@ -1,14 +1,16 @@ - - - - + + + Account Recovery - - + +

Reset your password

Someone requested a password reset.

Reset password -

If you didn't initiate this request, please contact {{#if isCloud}}us immediately at team@infisical.com.{{else}}your administrator immediately.{{/if}}

- +

If you didn't initiate this request, please contact + {{#if isCloud}}us immediately at team@infisical.com.{{else}}your administrator immediately.{{/if}}

+ + {{emailFooter}} + \ No newline at end of file diff --git a/backend/src/services/smtp/templates/pkiExpirationAlert.handlebars b/backend/src/services/smtp/templates/pkiExpirationAlert.handlebars index 77d2543ae..f9013e24d 100644 --- a/backend/src/services/smtp/templates/pkiExpirationAlert.handlebars +++ b/backend/src/services/smtp/templates/pkiExpirationAlert.handlebars @@ -27,5 +27,7 @@

Please take necessary actions to renew these items before they expire.

For more details, please log in to your Infisical account and check your PKI management section.

+ + {{emailFooter}} \ No newline at end of file diff --git a/backend/src/services/smtp/templates/scimUserProvisioned.handlebars b/backend/src/services/smtp/templates/scimUserProvisioned.handlebars index b1482aa17..ba04d7201 100644 --- a/backend/src/services/smtp/templates/scimUserProvisioned.handlebars +++ b/backend/src/services/smtp/templates/scimUserProvisioned.handlebars @@ -1,16 +1,18 @@ - - - - + + + Organization Invitation - - + +

Join your organization on Infisical

You've been invited to join the Infisical organization — {{organizationName}}

Join now

What is Infisical?

-

Infisical is an easy-to-use end-to-end encrypted tool that enables developers to sync and manage their secrets and configs.

- +

Infisical is an easy-to-use end-to-end encrypted tool that enables developers to sync and manage their secrets + and configs.

+ + {{emailFooter}} + \ No newline at end of file diff --git a/backend/src/services/smtp/templates/secretApprovalRequestNeedsReview.handlebars b/backend/src/services/smtp/templates/secretApprovalRequestNeedsReview.handlebars index 9dd6fe747..c12c08460 100644 --- a/backend/src/services/smtp/templates/secretApprovalRequestNeedsReview.handlebars +++ b/backend/src/services/smtp/templates/secretApprovalRequestNeedsReview.handlebars @@ -17,6 +17,8 @@ View the request and approve or deny it here.

+ + {{emailFooter}} \ No newline at end of file diff --git a/backend/src/services/smtp/templates/secretLeakIncident.handlebars b/backend/src/services/smtp/templates/secretLeakIncident.handlebars index c3c5f353a..d0d9a617c 100644 --- a/backend/src/services/smtp/templates/secretLeakIncident.handlebars +++ b/backend/src/services/smtp/templates/secretLeakIncident.handlebars @@ -1,25 +1,27 @@ - - - - - Incident alert: secret leaked - + + + + Incident alert: secret leaked + - -

Infisical has uncovered {{numberOfSecrets}} secret(s) from your recent push

-

View leaked secrets

-

You are receiving this notification because one or more secret leaks have been detected in a recent commit pushed - by {{pusher_name}} ({{pusher_email}}). If - these are test secrets, please add `infisical-scan:ignore` at the end of the line containing the secret as comment - in the given programming. This will prevent future notifications from being sent out for those secret(s).

+ +

Infisical has uncovered {{numberOfSecrets}} secret(s) from your recent push

+

View leaked secrets

+

You are receiving this notification because one or more secret leaks have been detected in a recent commit pushed + by + {{pusher_name}} + ({{pusher_email}}). If these are test secrets, please add `infisical-scan:ignore` at the end of the line + containing the secret as comment in the given programming. This will prevent future notifications from being sent + out for those secret(s).

-

If these are production secrets, please rotate them immediately.

+

If these are production secrets, please rotate them immediately.

-

Once you have taken action, be sure to update the status of the risk in your Infisical - dashboard.

- +

Once you have taken action, be sure to update the status of the risk in your + Infisical dashboard.

+ + {{emailFooter}} + \ No newline at end of file diff --git a/backend/src/services/smtp/templates/secretReminder.handlebars b/backend/src/services/smtp/templates/secretReminder.handlebars index 2a0efcac8..d64c4bf42 100644 --- a/backend/src/services/smtp/templates/secretReminder.handlebars +++ b/backend/src/services/smtp/templates/secretReminder.handlebars @@ -13,6 +13,8 @@ {{#if reminderNote}}

Here's the note included with the reminder: {{reminderNote}}

{{/if}} + + {{emailFooter}} \ No newline at end of file diff --git a/backend/src/services/smtp/templates/signupEmailVerification.handlebars b/backend/src/services/smtp/templates/signupEmailVerification.handlebars index 3ba18619f..39f47ae48 100644 --- a/backend/src/services/smtp/templates/signupEmailVerification.handlebars +++ b/backend/src/services/smtp/templates/signupEmailVerification.handlebars @@ -1,17 +1,19 @@ - - - - + + + Code - + - +

Confirm your email address

Your confirmation code is below — enter it in the browser window where you've started signing up for Infisical.

{{code}}

-

Questions about setting up Infisical? {{#if isCloud}}Email us at support@infisical.com{{else}}Contact your administrator{{/if}}.

- +

Questions about setting up Infisical? + {{#if isCloud}}Email us at support@infisical.com{{else}}Contact your administrator{{/if}}.

+ + {{emailFooter}} + \ No newline at end of file diff --git a/backend/src/services/smtp/templates/unlockAccount.handlebars b/backend/src/services/smtp/templates/unlockAccount.handlebars index 36664be87..b65cb5625 100644 --- a/backend/src/services/smtp/templates/unlockAccount.handlebars +++ b/backend/src/services/smtp/templates/unlockAccount.handlebars @@ -11,6 +11,8 @@

Your account has been temporarily locked due to multiple failed login attempts. To unlock your account, follow the link here

If these attempts were not made by you, reset your password immediately.

+ + {{emailFooter}} \ No newline at end of file diff --git a/backend/src/services/smtp/templates/workspaceInvitation.handlebars b/backend/src/services/smtp/templates/workspaceInvitation.handlebars index b82b8b2c2..fde75a6d6 100644 --- a/backend/src/services/smtp/templates/workspaceInvitation.handlebars +++ b/backend/src/services/smtp/templates/workspaceInvitation.handlebars @@ -11,5 +11,7 @@

What is Infisical?

Infisical is an easy-to-use end-to-end encrypted tool that enables developers to sync and manage their secrets and configs.

+ + {{emailFooter}} From 826916399bb3a95d969327dd0c83f91313e3789c Mon Sep 17 00:00:00 2001 From: = Date: Mon, 9 Dec 2024 20:16:34 +0530 Subject: [PATCH 022/162] feat: changed integration option to nativeEnum in zod and added audit log event --- backend/src/ee/services/audit-log/audit-log-types.ts | 9 +++++++++ backend/src/server/routes/v1/integration-auth-router.ts | 5 +++-- .../integration-auth/integration-auth-service.ts | 3 +-- 3 files changed, 13 insertions(+), 4 deletions(-) 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..601436d1b 100644 --- a/backend/src/ee/services/audit-log/audit-log-types.ts +++ b/backend/src/ee/services/audit-log/audit-log-types.ts @@ -60,6 +60,7 @@ export enum EventType { DELETE_SECRETS = "delete-secrets", GET_WORKSPACE_KEY = "get-workspace-key", AUTHORIZE_INTEGRATION = "authorize-integration", + UPDATE_INTEGRATION_AUTH = "update-integration-auth", UNAUTHORIZE_INTEGRATION = "unauthorize-integration", CREATE_INTEGRATION = "create-integration", DELETE_INTEGRATION = "delete-integration", @@ -357,6 +358,13 @@ interface AuthorizeIntegrationEvent { }; } +interface UpdateIntegrationAuthEvent { + type: EventType.UPDATE_INTEGRATION_AUTH; + metadata: { + integration: string; + }; +} + interface UnauthorizeIntegrationEvent { type: EventType.UNAUTHORIZE_INTEGRATION; metadata: { @@ -1680,6 +1688,7 @@ export type Event = | DeleteSecretBatchEvent | GetWorkspaceKeyEvent | AuthorizeIntegrationEvent + | UpdateIntegrationAuthEvent | UnauthorizeIntegrationEvent | CreateIntegrationEvent | DeleteIntegrationEvent diff --git a/backend/src/server/routes/v1/integration-auth-router.ts b/backend/src/server/routes/v1/integration-auth-router.ts index ea7b6f917..5e652283c 100644 --- a/backend/src/server/routes/v1/integration-auth-router.ts +++ b/backend/src/server/routes/v1/integration-auth-router.ts @@ -6,6 +6,7 @@ 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 { OctopusDeployScope } from "@app/services/integration-auth/integration-auth-types"; +import { Integrations } from "@app/services/integration-auth/integration-list"; import { integrationAuthPubSchema } from "../sanitizedSchemas"; @@ -100,7 +101,7 @@ export const registerIntegrationAuthRouter = async (server: FastifyZodProvider) integrationAuthId: z.string().trim().describe(INTEGRATION_AUTH.UPDATE_BY_ID.integrationAuthId) }), body: z.object({ - integration: z.string().trim().optional().describe(INTEGRATION_AUTH.CREATE_ACCESS_TOKEN.integration), + integration: z.nativeEnum(Integrations).optional().describe(INTEGRATION_AUTH.CREATE_ACCESS_TOKEN.integration), accessId: z.string().trim().optional().describe(INTEGRATION_AUTH.CREATE_ACCESS_TOKEN.accessId), accessToken: z.string().trim().optional().describe(INTEGRATION_AUTH.CREATE_ACCESS_TOKEN.accessToken), awsAssumeIamRoleArn: z @@ -133,7 +134,7 @@ export const registerIntegrationAuthRouter = async (server: FastifyZodProvider) ...req.auditLogInfo, projectId: integrationAuth.projectId, event: { - type: EventType.AUTHORIZE_INTEGRATION, + type: EventType.UPDATE_INTEGRATION_AUTH, metadata: { integration: integrationAuth.integration } diff --git a/backend/src/services/integration-auth/integration-auth-service.ts b/backend/src/services/integration-auth/integration-auth-service.ts index 6768e12bf..42a3f038b 100644 --- a/backend/src/services/integration-auth/integration-auth-service.ts +++ b/backend/src/services/integration-auth/integration-auth-service.ts @@ -399,8 +399,7 @@ export const integrationAuthServiceFactory = ({ const { projectId } = integrationAuth; const integration = newIntegration || integrationAuth.integration; - if (!Object.values(Integrations).includes(integration as Integrations)) - throw new BadRequestError({ message: "Invalid integration" }); + const updateDoc: TIntegrationAuthsInsert = { projectId, integration, From a808b6d4a0145cb0667b30a570793e129541ed92 Mon Sep 17 00:00:00 2001 From: = Date: Mon, 9 Dec 2024 20:24:30 +0530 Subject: [PATCH 023/162] feat: added new audit log event in ui --- frontend/src/hooks/api/auditLogs/constants.tsx | 1 + frontend/src/hooks/api/auditLogs/enums.tsx | 1 + 2 files changed, 2 insertions(+) diff --git a/frontend/src/hooks/api/auditLogs/constants.tsx b/frontend/src/hooks/api/auditLogs/constants.tsx index 404592908..a75767108 100644 --- a/frontend/src/hooks/api/auditLogs/constants.tsx +++ b/frontend/src/hooks/api/auditLogs/constants.tsx @@ -8,6 +8,7 @@ export const eventToNameMap: { [K in EventType]: string } = { [EventType.DELETE_SECRET]: "Delete secret", [EventType.GET_WORKSPACE_KEY]: "Read project key", [EventType.AUTHORIZE_INTEGRATION]: "Authorize integration", + [EventType.UPDATE_INTEGRATION_AUTH]: "Update integration auth", [EventType.UNAUTHORIZE_INTEGRATION]: "Unauthorize integration", [EventType.CREATE_INTEGRATION]: "Create integration", [EventType.DELETE_INTEGRATION]: "Delete integration", diff --git a/frontend/src/hooks/api/auditLogs/enums.tsx b/frontend/src/hooks/api/auditLogs/enums.tsx index 1db55d739..0b0c44d7b 100644 --- a/frontend/src/hooks/api/auditLogs/enums.tsx +++ b/frontend/src/hooks/api/auditLogs/enums.tsx @@ -23,6 +23,7 @@ export enum EventType { DELETE_SECRET = "delete-secret", GET_WORKSPACE_KEY = "get-workspace-key", AUTHORIZE_INTEGRATION = "authorize-integration", + UPDATE_INTEGRATION_AUTH = "update-integration-auth", UNAUTHORIZE_INTEGRATION = "unauthorize-integration", CREATE_INTEGRATION = "create-integration", DELETE_INTEGRATION = "delete-integration", From 84c26581a6466f55aa31b99b48efcbf38377bf05 Mon Sep 17 00:00:00 2001 From: Sheen Capadngan Date: Tue, 10 Dec 2024 02:41:04 +0800 Subject: [PATCH 024/162] feat: jwt auth setup --- backend/src/@types/fastify.d.ts | 2 + backend/src/@types/knex.d.ts | 7 + .../20241209144123_add-identity-jwt-auth.ts | 34 +++++ backend/src/db/schemas/identity-jwt-auths.ts | 33 +++++ backend/src/db/schemas/index.ts | 1 + backend/src/db/schemas/models.ts | 4 +- backend/src/lib/api-docs/constants.ts | 24 +++ backend/src/server/routes/index.ts | 13 ++ .../routes/v1/identity-jwt-auth-router.ts | 86 +++++++++++ backend/src/server/routes/v1/index.ts | 2 + .../identity-jwt-auth-dal.ts | 11 ++ .../identity-jwt-auth-service.ts | 137 ++++++++++++++++++ .../identity-jwt-auth-types.ts | 22 +++ .../identity-jwt-auth-validators.ts | 25 ++++ 14 files changed, 400 insertions(+), 1 deletion(-) create mode 100644 backend/src/db/migrations/20241209144123_add-identity-jwt-auth.ts create mode 100644 backend/src/db/schemas/identity-jwt-auths.ts create mode 100644 backend/src/server/routes/v1/identity-jwt-auth-router.ts create mode 100644 backend/src/services/identity-jwt-auth/identity-jwt-auth-dal.ts create mode 100644 backend/src/services/identity-jwt-auth/identity-jwt-auth-service.ts create mode 100644 backend/src/services/identity-jwt-auth/identity-jwt-auth-types.ts create mode 100644 backend/src/services/identity-jwt-auth/identity-jwt-auth-validators.ts diff --git a/backend/src/@types/fastify.d.ts b/backend/src/@types/fastify.d.ts index 4221eadcb..8ff12069a 100644 --- a/backend/src/@types/fastify.d.ts +++ b/backend/src/@types/fastify.d.ts @@ -52,6 +52,7 @@ import { TIdentityAccessTokenServiceFactory } from "@app/services/identity-acces import { TIdentityAwsAuthServiceFactory } from "@app/services/identity-aws-auth/identity-aws-auth-service"; import { TIdentityAzureAuthServiceFactory } from "@app/services/identity-azure-auth/identity-azure-auth-service"; import { TIdentityGcpAuthServiceFactory } from "@app/services/identity-gcp-auth/identity-gcp-auth-service"; +import { TIdentityJwtAuthServiceFactory } from "@app/services/identity-jwt-auth/identity-jwt-auth-service"; import { TIdentityKubernetesAuthServiceFactory } from "@app/services/identity-kubernetes-auth/identity-kubernetes-auth-service"; import { TIdentityOidcAuthServiceFactory } from "@app/services/identity-oidc-auth/identity-oidc-auth-service"; import { TIdentityProjectServiceFactory } from "@app/services/identity-project/identity-project-service"; @@ -162,6 +163,7 @@ declare module "fastify" { identityAwsAuth: TIdentityAwsAuthServiceFactory; identityAzureAuth: TIdentityAzureAuthServiceFactory; identityOidcAuth: TIdentityOidcAuthServiceFactory; + identityJwtAuth: TIdentityJwtAuthServiceFactory; accessApprovalPolicy: TAccessApprovalPolicyServiceFactory; accessApprovalRequest: TAccessApprovalRequestServiceFactory; secretApprovalPolicy: TSecretApprovalPolicyServiceFactory; diff --git a/backend/src/@types/knex.d.ts b/backend/src/@types/knex.d.ts index f5c44ff79..ff3268ab5 100644 --- a/backend/src/@types/knex.d.ts +++ b/backend/src/@types/knex.d.ts @@ -98,6 +98,8 @@ import { TIdentityGcpAuths, TIdentityGcpAuthsInsert, TIdentityGcpAuthsUpdate, + TIdentityJwtAuths, + TIdentityJwtAuthsUpdate, TIdentityKubernetesAuths, TIdentityKubernetesAuthsInsert, TIdentityKubernetesAuthsUpdate, @@ -590,6 +592,11 @@ declare module "knex/types/tables" { TIdentityOidcAuthsInsert, TIdentityOidcAuthsUpdate >; + [TableName.IdentityJwtAuth]: KnexOriginal.CompositeTableType< + TIdentityJwtAuths, + TIdentityJwtAuthsInsert, + TIdentityJwtAuthsUpdate + >; [TableName.IdentityUaClientSecret]: KnexOriginal.CompositeTableType< TIdentityUaClientSecrets, TIdentityUaClientSecretsInsert, diff --git a/backend/src/db/migrations/20241209144123_add-identity-jwt-auth.ts b/backend/src/db/migrations/20241209144123_add-identity-jwt-auth.ts new file mode 100644 index 000000000..2e7ac4b63 --- /dev/null +++ b/backend/src/db/migrations/20241209144123_add-identity-jwt-auth.ts @@ -0,0 +1,34 @@ +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.IdentityJwtAuth))) { + await knex.schema.createTable(TableName.IdentityJwtAuth, (t) => { + t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid()); + t.bigInteger("accessTokenTTL").defaultTo(7200).notNullable(); + t.bigInteger("accessTokenMaxTTL").defaultTo(7200).notNullable(); + t.bigInteger("accessTokenNumUsesLimit").defaultTo(0).notNullable(); + t.jsonb("accessTokenTrustedIps").notNullable(); + t.uuid("identityId").notNullable().unique(); + t.foreign("identityId").references("id").inTable(TableName.Identity).onDelete("CASCADE"); + t.string("configurationType").notNullable(); + t.string("jwksUrl"); + t.binary("encryptedJwksCaCert"); + t.binary("encryptedPublicKeys"); + t.string("boundIssuer"); + t.string("boundAudiences"); + t.jsonb("boundClaims"); + t.string("boundSubject"); + t.timestamps(true, true, true); + }); + + await createOnUpdateTrigger(knex, TableName.IdentityJwtAuth); + } +} + +export async function down(knex: Knex): Promise { + await knex.schema.dropTableIfExists(TableName.IdentityJwtAuth); + await dropOnUpdateTrigger(knex, TableName.IdentityJwtAuth); +} diff --git a/backend/src/db/schemas/identity-jwt-auths.ts b/backend/src/db/schemas/identity-jwt-auths.ts new file mode 100644 index 000000000..a67fa186e --- /dev/null +++ b/backend/src/db/schemas/identity-jwt-auths.ts @@ -0,0 +1,33 @@ +// 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 IdentityJwtAuthsSchema = z.object({ + id: z.string().uuid(), + accessTokenTTL: z.coerce.number().default(7200), + accessTokenMaxTTL: z.coerce.number().default(7200), + accessTokenNumUsesLimit: z.coerce.number().default(0), + accessTokenTrustedIps: z.unknown(), + identityId: z.string().uuid(), + configurationType: z.string(), + jwksUrl: z.string().nullable().optional(), + encryptedJwksCaCert: zodBuffer.nullable().optional(), + encryptedPublicKeys: zodBuffer.nullable().optional(), + boundIssuer: z.string().nullable().optional(), + boundAudiences: z.string().nullable().optional(), + boundClaims: z.unknown().nullable().optional(), + boundSubject: z.string().nullable().optional(), + createdAt: z.date(), + updatedAt: z.date() +}); + +export type TIdentityJwtAuths = z.infer; +export type TIdentityJwtAuthsInsert = Omit, TImmutableDBKeys>; +export type TIdentityJwtAuthsUpdate = Partial, TImmutableDBKeys>>; diff --git a/backend/src/db/schemas/index.ts b/backend/src/db/schemas/index.ts index 74741a8ff..bd26610d9 100644 --- a/backend/src/db/schemas/index.ts +++ b/backend/src/db/schemas/index.ts @@ -30,6 +30,7 @@ export * from "./identity-access-tokens"; export * from "./identity-aws-auths"; export * from "./identity-azure-auths"; export * from "./identity-gcp-auths"; +export * from "./identity-jwt-auths"; export * from "./identity-kubernetes-auths"; export * from "./identity-metadata"; export * from "./identity-oidc-auths"; diff --git a/backend/src/db/schemas/models.ts b/backend/src/db/schemas/models.ts index 171931f7e..5ec686140 100644 --- a/backend/src/db/schemas/models.ts +++ b/backend/src/db/schemas/models.ts @@ -68,6 +68,7 @@ export enum TableName { IdentityUaClientSecret = "identity_ua_client_secrets", IdentityAwsAuth = "identity_aws_auths", IdentityOidcAuth = "identity_oidc_auths", + IdentityJwtAuth = "identity_jwt_auths", IdentityOrgMembership = "identity_org_memberships", IdentityProjectMembership = "identity_project_memberships", IdentityProjectMembershipRole = "identity_project_membership_role", @@ -196,5 +197,6 @@ export enum IdentityAuthMethod { GCP_AUTH = "gcp-auth", AWS_AUTH = "aws-auth", AZURE_AUTH = "azure-auth", - OIDC_AUTH = "oidc-auth" + OIDC_AUTH = "oidc-auth", + JWT_AUTH = "jwt-auth" } diff --git a/backend/src/lib/api-docs/constants.ts b/backend/src/lib/api-docs/constants.ts index 99822da29..1debf8d60 100644 --- a/backend/src/lib/api-docs/constants.ts +++ b/backend/src/lib/api-docs/constants.ts @@ -349,6 +349,30 @@ export const OIDC_AUTH = { } } as const; +export const JWT_AUTH = { + LOGIN: { + identityId: "The ID of the identity to login." + }, + ATTACH: { + identityId: "The ID of the identity to attach the configuration onto.", + caCert: "The PEM-encoded CA cert for establishing secure communication with the Identity Provider endpoints.", + configurationType: "The configuration for validating JWTs. Must be one of: 'jwks', 'static'", + jwksUrl: + "The URL of the JWKS endpoint. Required if configurationType is 'jwks'. This endpoint must serve JSON Web Key Sets (JWKS) containing the public keys used to verify JWT signatures.", + jwksCaCert: "The PEM-encoded CA certificate for validating the TLS connection to the JWKS endpoint.", + publicKeys: + "A list of PEM-encoded public keys used to verify JWT signatures. Required if configurationType is 'static'. Each key must be in RSA or ECDSA format and properly PEM-encoded with BEGIN/END markers.", + boundIssuer: "The unique identifier of the identity provider issuing the JWT.", + boundAudiences: "The list of intended recipients.", + boundClaims: "The attributes that should be present in the JWT for it to be valid.", + boundSubject: "The expected principal that is the subject of the JWT.", + accessTokenTrustedIps: "The IPs or CIDR ranges that access tokens can be used from.", + accessTokenTTL: "The lifetime for an access token in seconds.", + accessTokenMaxTTL: "The maximum lifetime for an access token in seconds.", + accessTokenNumUsesLimit: "The maximum number of times that an access token can be used." + } +} as const; + export const ORGANIZATIONS = { LIST_USER_MEMBERSHIPS: { organizationId: "The ID of the organization to get memberships from." diff --git a/backend/src/server/routes/index.ts b/backend/src/server/routes/index.ts index 4f07579bd..f8f5550fe 100644 --- a/backend/src/server/routes/index.ts +++ b/backend/src/server/routes/index.ts @@ -121,6 +121,8 @@ import { identityAzureAuthDALFactory } from "@app/services/identity-azure-auth/i import { identityAzureAuthServiceFactory } from "@app/services/identity-azure-auth/identity-azure-auth-service"; import { identityGcpAuthDALFactory } from "@app/services/identity-gcp-auth/identity-gcp-auth-dal"; import { identityGcpAuthServiceFactory } from "@app/services/identity-gcp-auth/identity-gcp-auth-service"; +import { identityJwtAuthDALFactory } from "@app/services/identity-jwt-auth/identity-jwt-auth-dal"; +import { identityJwtAuthServiceFactory } from "@app/services/identity-jwt-auth/identity-jwt-auth-service"; import { identityKubernetesAuthDALFactory } from "@app/services/identity-kubernetes-auth/identity-kubernetes-auth-dal"; import { identityKubernetesAuthServiceFactory } from "@app/services/identity-kubernetes-auth/identity-kubernetes-auth-service"; import { identityOidcAuthDALFactory } from "@app/services/identity-oidc-auth/identity-oidc-auth-dal"; @@ -298,6 +300,7 @@ export const registerRoutes = async ( const identityAwsAuthDAL = identityAwsAuthDALFactory(db); const identityGcpAuthDAL = identityGcpAuthDALFactory(db); const identityOidcAuthDAL = identityOidcAuthDALFactory(db); + const identityJwtAuthDAL = identityJwtAuthDALFactory(db); const identityAzureAuthDAL = identityAzureAuthDALFactory(db); const auditLogDAL = auditLogDALFactory(auditLogDb ?? db); @@ -1180,6 +1183,15 @@ export const registerRoutes = async ( orgBotDAL }); + const identityJwtAuthService = identityJwtAuthServiceFactory({ + identityJwtAuthDAL, + permissionService, + identityAccessTokenDAL, + identityOrgMembershipDAL, + licenseService, + kmsService + }); + const dynamicSecretProviders = buildDynamicSecretProviders(); const dynamicSecretQueueService = dynamicSecretLeaseQueueServiceFactory({ queueService, @@ -1342,6 +1354,7 @@ export const registerRoutes = async ( identityAwsAuth: identityAwsAuthService, identityAzureAuth: identityAzureAuthService, identityOidcAuth: identityOidcAuthService, + identityJwtAuth: identityJwtAuthService, accessApprovalPolicy: accessApprovalPolicyService, accessApprovalRequest: accessApprovalRequestService, secretApprovalPolicy: secretApprovalPolicyService, diff --git a/backend/src/server/routes/v1/identity-jwt-auth-router.ts b/backend/src/server/routes/v1/identity-jwt-auth-router.ts new file mode 100644 index 000000000..6c9d2ae4a --- /dev/null +++ b/backend/src/server/routes/v1/identity-jwt-auth-router.ts @@ -0,0 +1,86 @@ +import { z } from "zod"; + +import { IdentityJwtAuthsSchema } from "@app/db/schemas"; +import { JWT_AUTH } from "@app/lib/api-docs"; +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 { JwtConfigurationType } from "@app/services/identity-jwt-auth/identity-jwt-auth-types"; +import { + validateJwtAuthAudiencesField, + validateJwtBoundClaimsField +} from "@app/services/identity-jwt-auth/identity-jwt-auth-validators"; + +const IdentityJwtAuthResponseSchema = IdentityJwtAuthsSchema.omit({ + encryptedJwksCaCert: true, + encryptedPublicKeys: true +}).extend({ + jwksCaCert: z.string(), + publicKeys: z.string() +}); + +export const registerIdentityJwtAuthRouter = async (server: FastifyZodProvider) => { + server.route({ + method: "POST", + url: "/jwt-auth/identities/:identityId", + config: { + rateLimit: writeLimit + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + schema: { + description: "Attach JWT Auth configuration onto identity", + security: [ + { + bearerAuth: [] + } + ], + params: z.object({ + identityId: z.string().trim().describe(JWT_AUTH.ATTACH.identityId) + }), + body: z.object({ + configurationType: z.nativeEnum(JwtConfigurationType).describe(JWT_AUTH.ATTACH.configurationType), + jwksUrl: z.string().describe(JWT_AUTH.ATTACH.jwksUrl), + jwksCaCert: z.string().describe(JWT_AUTH.ATTACH.jwksCaCert), + publicKeys: z.string().array().describe(JWT_AUTH.ATTACH.publicKeys), + boundIssuer: z.string().min(1).describe(JWT_AUTH.ATTACH.boundIssuer), + boundAudiences: validateJwtAuthAudiencesField.describe(JWT_AUTH.ATTACH.boundAudiences), + boundClaims: validateJwtBoundClaimsField.describe(JWT_AUTH.ATTACH.boundClaims), + boundSubject: z.string().optional().default("").describe(JWT_AUTH.ATTACH.boundSubject), + accessTokenTrustedIps: z + .object({ + ipAddress: z.string().trim() + }) + .array() + .min(1) + .default([{ ipAddress: "0.0.0.0/0" }, { ipAddress: "::/0" }]) + .describe(JWT_AUTH.ATTACH.accessTokenTrustedIps), + accessTokenTTL: z + .number() + .int() + .min(1) + .max(315360000) + .refine((value) => value !== 0, { + message: "accessTokenTTL must have a non zero number" + }) + .default(2592000) + .describe(JWT_AUTH.ATTACH.accessTokenTTL), + accessTokenMaxTTL: z + .number() + .int() + .max(315360000) + .refine((value) => value !== 0, { + message: "accessTokenMaxTTL must have a non zero number" + }) + .default(2592000) + .describe(JWT_AUTH.ATTACH.accessTokenMaxTTL), + accessTokenNumUsesLimit: z.number().int().min(0).default(0).describe(JWT_AUTH.ATTACH.accessTokenNumUsesLimit) + }), + response: { + 200: z.object({ + identityJwtAuth: IdentityJwtAuthResponseSchema + }) + } + }, + handler: async (req) => {} + }); +}; diff --git a/backend/src/server/routes/v1/index.ts b/backend/src/server/routes/v1/index.ts index f9edfc18c..a04f77b7a 100644 --- a/backend/src/server/routes/v1/index.ts +++ b/backend/src/server/routes/v1/index.ts @@ -12,6 +12,7 @@ import { registerIdentityAccessTokenRouter } from "./identity-access-token-route import { registerIdentityAwsAuthRouter } from "./identity-aws-iam-auth-router"; import { registerIdentityAzureAuthRouter } from "./identity-azure-auth-router"; import { registerIdentityGcpAuthRouter } from "./identity-gcp-auth-router"; +import { registerIdentityJwtAuthRouter } from "./identity-jwt-auth-router"; import { registerIdentityKubernetesRouter } from "./identity-kubernetes-auth-router"; import { registerIdentityOidcAuthRouter } from "./identity-oidc-auth-router"; import { registerIdentityRouter } from "./identity-router"; @@ -54,6 +55,7 @@ export const registerV1Routes = async (server: FastifyZodProvider) => { await authRouter.register(registerIdentityAwsAuthRouter); await authRouter.register(registerIdentityAzureAuthRouter); await authRouter.register(registerIdentityOidcAuthRouter); + await authRouter.register(registerIdentityJwtAuthRouter); }, { prefix: "/auth" } ); diff --git a/backend/src/services/identity-jwt-auth/identity-jwt-auth-dal.ts b/backend/src/services/identity-jwt-auth/identity-jwt-auth-dal.ts new file mode 100644 index 000000000..5e6d13be6 --- /dev/null +++ b/backend/src/services/identity-jwt-auth/identity-jwt-auth-dal.ts @@ -0,0 +1,11 @@ +import { TDbClient } from "@app/db"; +import { TableName } from "@app/db/schemas"; +import { ormify } from "@app/lib/knex"; + +export type TIdentityJwtAuthDALFactory = ReturnType; + +export const identityJwtAuthDALFactory = (db: TDbClient) => { + const jwtAuthOrm = ormify(db, TableName.IdentityJwtAuth); + + return jwtAuthOrm; +}; diff --git a/backend/src/services/identity-jwt-auth/identity-jwt-auth-service.ts b/backend/src/services/identity-jwt-auth/identity-jwt-auth-service.ts new file mode 100644 index 000000000..c61ae6769 --- /dev/null +++ b/backend/src/services/identity-jwt-auth/identity-jwt-auth-service.ts @@ -0,0 +1,137 @@ +import { ForbiddenError } from "@casl/ability"; + +import { IdentityAuthMethod } from "@app/db/schemas"; +import { TLicenseServiceFactory } from "@app/ee/services/license/license-service"; +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 { extractIPDetails, isValidIpOrCidr } from "@app/lib/ip"; + +import { TIdentityOrgDALFactory } from "../identity/identity-org-dal"; +import { TIdentityAccessTokenDALFactory } from "../identity-access-token/identity-access-token-dal"; +import { TKmsServiceFactory } from "../kms/kms-service"; +import { KmsDataKey } from "../kms/kms-types"; +import { TIdentityJwtAuthDALFactory } from "./identity-jwt-auth-dal"; +import { TAttachJwtAuthDTO } from "./identity-jwt-auth-types"; + +type TIdentityJwtAuthServiceFactoryDep = { + identityJwtAuthDAL: TIdentityJwtAuthDALFactory; + identityOrgMembershipDAL: Pick; + identityAccessTokenDAL: Pick; + permissionService: Pick; + licenseService: Pick; + kmsService: Pick; +}; + +export type TIdentityJwtAuthServiceFactory = ReturnType; + +export const identityJwtAuthServiceFactory = ({ + identityJwtAuthDAL, + identityOrgMembershipDAL, + permissionService, + licenseService, + kmsService +}: TIdentityJwtAuthServiceFactoryDep) => { + const attachJwtAuth = async ({ + identityId, + configurationType, + jwksUrl, + jwksCaCert, + publicKeys, + boundIssuer, + boundAudiences, + boundClaims, + boundSubject, + accessTokenTTL, + accessTokenMaxTTL, + accessTokenNumUsesLimit, + accessTokenTrustedIps, + actorId, + actorAuthMethod, + actor, + actorOrgId + }: TAttachJwtAuthDTO) => { + const identityMembershipOrg = await identityOrgMembershipDAL.findOne({ identityId }); + if (!identityMembershipOrg) { + if (!identityMembershipOrg) throw new NotFoundError({ message: `Failed to find identity with ID ${identityId}` }); + } + if (identityMembershipOrg.identity.authMethods.includes(IdentityAuthMethod.JWT_AUTH)) { + throw new BadRequestError({ + message: "Failed to add JWT Auth to already configured identity" + }); + } + + if (accessTokenMaxTTL > 0 && accessTokenTTL > accessTokenMaxTTL) { + throw new BadRequestError({ message: "Access token TTL cannot be greater than max TTL" }); + } + + const { permission } = await permissionService.getOrgPermission( + actor, + actorId, + identityMembershipOrg.orgId, + actorAuthMethod, + actorOrgId + ); + + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Create, OrgPermissionSubjects.Identity); + + const plan = await licenseService.getPlan(identityMembershipOrg.orgId); + const reformattedAccessTokenTrustedIps = accessTokenTrustedIps.map((accessTokenTrustedIp) => { + if ( + !plan.ipAllowlisting && + accessTokenTrustedIp.ipAddress !== "0.0.0.0/0" && + accessTokenTrustedIp.ipAddress !== "::/0" + ) + throw new BadRequestError({ + message: + "Failed to add IP access range to access token due to plan restriction. Upgrade plan to add IP access range." + }); + if (!isValidIpOrCidr(accessTokenTrustedIp.ipAddress)) + throw new BadRequestError({ + message: "The IP is not a valid IPv4, IPv6, or CIDR block" + }); + return extractIPDetails(accessTokenTrustedIp.ipAddress); + }); + + const { encryptor: orgDataKeyEncryptor } = await kmsService.createCipherPairWithDataKey({ + type: KmsDataKey.Organization, + orgId: actorOrgId + }); + + const { cipherTextBlob: encryptedJwksCaCert } = orgDataKeyEncryptor({ + plainText: Buffer.from(jwksCaCert) + }); + + const { cipherTextBlob: encryptedPublicKeys } = orgDataKeyEncryptor({ + plainText: Buffer.from(publicKeys.join(",")) + }); + + const identityJwtAuth = await identityJwtAuthDAL.transaction(async (tx) => { + const doc = await identityJwtAuthDAL.create( + { + identityId: identityMembershipOrg.identityId, + configurationType, + jwksUrl, + encryptedJwksCaCert, + encryptedPublicKeys, + boundIssuer, + boundAudiences, + boundClaims, + boundSubject, + accessTokenMaxTTL, + accessTokenTTL, + accessTokenNumUsesLimit, + accessTokenTrustedIps: JSON.stringify(reformattedAccessTokenTrustedIps) + }, + tx + ); + + return doc; + }); + return { ...identityJwtAuth, orgId: identityMembershipOrg.orgId, jwksCaCert, publicKeys }; + }; + + return { + attachJwtAuth + }; +}; diff --git a/backend/src/services/identity-jwt-auth/identity-jwt-auth-types.ts b/backend/src/services/identity-jwt-auth/identity-jwt-auth-types.ts new file mode 100644 index 000000000..e06c56437 --- /dev/null +++ b/backend/src/services/identity-jwt-auth/identity-jwt-auth-types.ts @@ -0,0 +1,22 @@ +import { TProjectPermission } from "@app/lib/types"; + +export enum JwtConfigurationType { + JWKS = "jwks", + STATIC = "static" +} + +export type TAttachJwtAuthDTO = { + identityId: string; + configurationType: JwtConfigurationType; + jwksUrl: string; + jwksCaCert: string; + publicKeys: string[]; + boundIssuer: string; + boundAudiences: string; + boundClaims: Record; + boundSubject: string; + accessTokenTTL: number; + accessTokenMaxTTL: number; + accessTokenNumUsesLimit: number; + accessTokenTrustedIps: { ipAddress: string }[]; +} & Omit; diff --git a/backend/src/services/identity-jwt-auth/identity-jwt-auth-validators.ts b/backend/src/services/identity-jwt-auth/identity-jwt-auth-validators.ts new file mode 100644 index 000000000..515c2ac7e --- /dev/null +++ b/backend/src/services/identity-jwt-auth/identity-jwt-auth-validators.ts @@ -0,0 +1,25 @@ +import { z } from "zod"; + +export const validateJwtAuthAudiencesField = z + .string() + .trim() + .default("") + .transform((data) => { + if (data === "") return ""; + return data + .split(",") + .map((id) => id.trim()) + .join(", "); + }); + +export const validateJwtBoundClaimsField = z.record(z.string()).transform((data) => { + const formattedClaims: Record = {}; + Object.keys(data).forEach((key) => { + formattedClaims[key] = data[key] + .split(",") + .map((id) => id.trim()) + .join(", "); + }); + + return formattedClaims; +}); From 97f85fa8d94a72f8cf3dcd16f8104272be1c4a06 Mon Sep 17 00:00:00 2001 From: McPizza Date: Mon, 9 Dec 2024 20:03:45 +0100 Subject: [PATCH 025/162] fix(Approval Workflows): Workflows keep approval history after deletion (#2834) * improvement: Approval Workflows can be deleted while maintaining history Co-authored-by: Daniel Hougaard --- ...5840_allow-disabling-approval-workflows.ts | 59 +++++++++++++++++++ .../db/schemas/access-approval-policies.ts | 3 +- .../db/schemas/secret-approval-policies.ts | 3 +- .../v1/access-approval-request-router.ts | 3 +- .../v1/secret-approval-request-router.ts | 6 +- .../access-approval-policy-dal.ts | 7 ++- .../access-approval-policy-service.ts | 48 +++++++++++++-- .../access-approval-request-dal.ts | 15 +++-- .../access-approval-request-service.ts | 9 +++ .../secret-approval-policy-dal.ts | 7 ++- .../secret-approval-policy-service.ts | 22 +++++-- .../secret-approval-request-dal.ts | 12 +++- .../secret-approval-request-service.ts | 26 ++++++-- backend/src/server/routes/index.ts | 8 ++- .../src/hooks/api/accessApproval/types.ts | 7 ++- .../AccessApprovalRequest.tsx | 4 +- 16 files changed, 203 insertions(+), 36 deletions(-) create mode 100644 backend/src/db/migrations/20241203165840_allow-disabling-approval-workflows.ts diff --git a/backend/src/db/migrations/20241203165840_allow-disabling-approval-workflows.ts b/backend/src/db/migrations/20241203165840_allow-disabling-approval-workflows.ts new file mode 100644 index 000000000..c7fb6fe39 --- /dev/null +++ b/backend/src/db/migrations/20241203165840_allow-disabling-approval-workflows.ts @@ -0,0 +1,59 @@ +import { Knex } from "knex"; + +import { TableName } from "../schemas"; + +export async function up(knex: Knex): Promise { + const hasAccessApprovalPolicyDeletedAtColumn = await knex.schema.hasColumn( + TableName.AccessApprovalPolicy, + "deletedAt" + ); + const hasSecretApprovalPolicyDeletedAtColumn = await knex.schema.hasColumn( + TableName.SecretApprovalPolicy, + "deletedAt" + ); + + if (!hasAccessApprovalPolicyDeletedAtColumn) { + await knex.schema.alterTable(TableName.AccessApprovalPolicy, (t) => { + t.timestamp("deletedAt"); + }); + } + if (!hasSecretApprovalPolicyDeletedAtColumn) { + await knex.schema.alterTable(TableName.SecretApprovalPolicy, (t) => { + t.timestamp("deletedAt"); + }); + } + + await knex.schema.alterTable(TableName.AccessApprovalRequest, (t) => { + t.dropForeign(["privilegeId"]); + + // Add the new foreign key constraint with ON DELETE SET NULL + t.foreign("privilegeId").references("id").inTable(TableName.ProjectUserAdditionalPrivilege).onDelete("SET NULL"); + }); +} + +export async function down(knex: Knex): Promise { + const hasAccessApprovalPolicyDeletedAtColumn = await knex.schema.hasColumn( + TableName.AccessApprovalPolicy, + "deletedAt" + ); + const hasSecretApprovalPolicyDeletedAtColumn = await knex.schema.hasColumn( + TableName.SecretApprovalPolicy, + "deletedAt" + ); + + if (hasAccessApprovalPolicyDeletedAtColumn) { + await knex.schema.alterTable(TableName.AccessApprovalPolicy, (t) => { + t.dropColumn("deletedAt"); + }); + } + if (hasSecretApprovalPolicyDeletedAtColumn) { + await knex.schema.alterTable(TableName.SecretApprovalPolicy, (t) => { + t.dropColumn("deletedAt"); + }); + } + + await knex.schema.alterTable(TableName.AccessApprovalRequest, (t) => { + t.dropForeign(["privilegeId"]); + t.foreign("privilegeId").references("id").inTable(TableName.ProjectUserAdditionalPrivilege).onDelete("CASCADE"); + }); +} diff --git a/backend/src/db/schemas/access-approval-policies.ts b/backend/src/db/schemas/access-approval-policies.ts index f4c525a4f..3650face9 100644 --- a/backend/src/db/schemas/access-approval-policies.ts +++ b/backend/src/db/schemas/access-approval-policies.ts @@ -15,7 +15,8 @@ export const AccessApprovalPoliciesSchema = z.object({ envId: z.string().uuid(), createdAt: z.date(), updatedAt: z.date(), - enforcementLevel: z.string().default("hard") + enforcementLevel: z.string().default("hard"), + deletedAt: z.date().nullable().optional() }); export type TAccessApprovalPolicies = z.infer; diff --git a/backend/src/db/schemas/secret-approval-policies.ts b/backend/src/db/schemas/secret-approval-policies.ts index 94aeba050..06ae3e5c4 100644 --- a/backend/src/db/schemas/secret-approval-policies.ts +++ b/backend/src/db/schemas/secret-approval-policies.ts @@ -15,7 +15,8 @@ export const SecretApprovalPoliciesSchema = z.object({ envId: z.string().uuid(), createdAt: z.date(), updatedAt: z.date(), - enforcementLevel: z.string().default("hard") + enforcementLevel: z.string().default("hard"), + deletedAt: z.date().nullable().optional() }); export type TSecretApprovalPolicies = z.infer; diff --git a/backend/src/ee/routes/v1/access-approval-request-router.ts b/backend/src/ee/routes/v1/access-approval-request-router.ts index 7dbb62fc2..4aa26eb36 100644 --- a/backend/src/ee/routes/v1/access-approval-request-router.ts +++ b/backend/src/ee/routes/v1/access-approval-request-router.ts @@ -109,7 +109,8 @@ export const registerAccessApprovalRequestRouter = async (server: FastifyZodProv approvers: z.string().array(), secretPath: z.string().nullish(), envId: z.string(), - enforcementLevel: z.string() + enforcementLevel: z.string(), + deletedAt: z.date().nullish() }), reviewers: z .object({ diff --git a/backend/src/ee/routes/v1/secret-approval-request-router.ts b/backend/src/ee/routes/v1/secret-approval-request-router.ts index 5fbf784f6..e1c56583c 100644 --- a/backend/src/ee/routes/v1/secret-approval-request-router.ts +++ b/backend/src/ee/routes/v1/secret-approval-request-router.ts @@ -52,7 +52,8 @@ export const registerSecretApprovalRequestRouter = async (server: FastifyZodProv }) .array(), secretPath: z.string().optional().nullable(), - enforcementLevel: z.string() + enforcementLevel: z.string(), + deletedAt: z.date().nullish() }), committerUser: approvalRequestUser, commits: z.object({ op: z.string(), secretId: z.string().nullable().optional() }).array(), @@ -260,7 +261,8 @@ export const registerSecretApprovalRequestRouter = async (server: FastifyZodProv approvals: z.number(), approvers: approvalRequestUser.array(), secretPath: z.string().optional().nullable(), - enforcementLevel: z.string() + enforcementLevel: z.string(), + deletedAt: z.date().nullish() }), environment: z.string(), statusChangedByUser: approvalRequestUser.optional(), diff --git a/backend/src/ee/services/access-approval-policy/access-approval-policy-dal.ts b/backend/src/ee/services/access-approval-policy/access-approval-policy-dal.ts index 220701410..e14451498 100644 --- a/backend/src/ee/services/access-approval-policy/access-approval-policy-dal.ts +++ b/backend/src/ee/services/access-approval-policy/access-approval-policy-dal.ts @@ -139,5 +139,10 @@ export const accessApprovalPolicyDALFactory = (db: TDbClient) => { } }; - return { ...accessApprovalPolicyOrm, find, findById }; + const softDeleteById = async (policyId: string, tx?: Knex) => { + const softDeletedPolicy = await accessApprovalPolicyOrm.updateById(policyId, { deletedAt: new Date() }, tx); + return softDeletedPolicy; + }; + + return { ...accessApprovalPolicyOrm, find, findById, softDeleteById }; }; diff --git a/backend/src/ee/services/access-approval-policy/access-approval-policy-service.ts b/backend/src/ee/services/access-approval-policy/access-approval-policy-service.ts index ee7cf2572..24436e695 100644 --- a/backend/src/ee/services/access-approval-policy/access-approval-policy-service.ts +++ b/backend/src/ee/services/access-approval-policy/access-approval-policy-service.ts @@ -8,7 +8,11 @@ import { TProjectEnvDALFactory } from "@app/services/project-env/project-env-dal import { TProjectMembershipDALFactory } from "@app/services/project-membership/project-membership-dal"; import { TUserDALFactory } from "@app/services/user/user-dal"; +import { TAccessApprovalRequestDALFactory } from "../access-approval-request/access-approval-request-dal"; +import { TAccessApprovalRequestReviewerDALFactory } from "../access-approval-request/access-approval-request-reviewer-dal"; +import { ApprovalStatus } from "../access-approval-request/access-approval-request-types"; import { TGroupDALFactory } from "../group/group-dal"; +import { TProjectUserAdditionalPrivilegeDALFactory } from "../project-user-additional-privilege/project-user-additional-privilege-dal"; import { TAccessApprovalPolicyApproverDALFactory } from "./access-approval-policy-approver-dal"; import { TAccessApprovalPolicyDALFactory } from "./access-approval-policy-dal"; import { @@ -21,7 +25,7 @@ import { TUpdateAccessApprovalPolicy } from "./access-approval-policy-types"; -type TSecretApprovalPolicyServiceFactoryDep = { +type TAccessApprovalPolicyServiceFactoryDep = { projectDAL: TProjectDALFactory; permissionService: Pick; accessApprovalPolicyDAL: TAccessApprovalPolicyDALFactory; @@ -30,6 +34,9 @@ type TSecretApprovalPolicyServiceFactoryDep = { projectMembershipDAL: Pick; groupDAL: TGroupDALFactory; userDAL: Pick; + accessApprovalRequestDAL: Pick; + additionalPrivilegeDAL: Pick; + accessApprovalRequestReviewerDAL: Pick; }; export type TAccessApprovalPolicyServiceFactory = ReturnType; @@ -41,8 +48,11 @@ export const accessApprovalPolicyServiceFactory = ({ permissionService, projectEnvDAL, projectDAL, - userDAL -}: TSecretApprovalPolicyServiceFactoryDep) => { + userDAL, + accessApprovalRequestDAL, + additionalPrivilegeDAL, + accessApprovalRequestReviewerDAL +}: TAccessApprovalPolicyServiceFactoryDep) => { const createAccessApprovalPolicy = async ({ name, actor, @@ -189,7 +199,7 @@ export const accessApprovalPolicyServiceFactory = ({ ); // ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Read, ProjectPermissionSub.SecretApproval); - const accessApprovalPolicies = await accessApprovalPolicyDAL.find({ projectId: project.id }); + const accessApprovalPolicies = await accessApprovalPolicyDAL.find({ projectId: project.id, deletedAt: null }); return accessApprovalPolicies; }; @@ -326,7 +336,29 @@ export const accessApprovalPolicyServiceFactory = ({ ProjectPermissionSub.SecretApproval ); - await accessApprovalPolicyDAL.deleteById(policyId); + await accessApprovalPolicyDAL.transaction(async (tx) => { + await accessApprovalPolicyDAL.softDeleteById(policyId, tx); + const allAccessApprovalRequests = await accessApprovalRequestDAL.find({ policyId }); + + if (allAccessApprovalRequests.length) { + const accessApprovalRequestsIds = allAccessApprovalRequests.map((request) => request.id); + + const privilegeIdsArray = allAccessApprovalRequests + .map((request) => request.privilegeId) + .filter((id): id is string => id != null); + + if (privilegeIdsArray.length) { + await additionalPrivilegeDAL.delete({ $in: { id: privilegeIdsArray } }, tx); + } + + await accessApprovalRequestReviewerDAL.update( + { $in: { id: accessApprovalRequestsIds }, status: ApprovalStatus.PENDING }, + { status: ApprovalStatus.REJECTED }, + tx + ); + } + }); + return policy; }; @@ -356,7 +388,11 @@ export const accessApprovalPolicyServiceFactory = ({ const environment = await projectEnvDAL.findOne({ projectId: project.id, slug: envSlug }); if (!environment) throw new NotFoundError({ message: `Environment with slug '${envSlug}' not found` }); - const policies = await accessApprovalPolicyDAL.find({ envId: environment.id, projectId: project.id }); + const policies = await accessApprovalPolicyDAL.find({ + envId: environment.id, + projectId: project.id, + deletedAt: null + }); if (!policies) throw new NotFoundError({ message: `No policies found in environment with slug '${envSlug}'` }); return { count: policies.length }; diff --git a/backend/src/ee/services/access-approval-request/access-approval-request-dal.ts b/backend/src/ee/services/access-approval-request/access-approval-request-dal.ts index 8784d05e2..c1ccedff7 100644 --- a/backend/src/ee/services/access-approval-request/access-approval-request-dal.ts +++ b/backend/src/ee/services/access-approval-request/access-approval-request-dal.ts @@ -61,7 +61,8 @@ export const accessApprovalRequestDALFactory = (db: TDbClient) => { db.ref("approvals").withSchema(TableName.AccessApprovalPolicy).as("policyApprovals"), db.ref("secretPath").withSchema(TableName.AccessApprovalPolicy).as("policySecretPath"), db.ref("enforcementLevel").withSchema(TableName.AccessApprovalPolicy).as("policyEnforcementLevel"), - db.ref("envId").withSchema(TableName.AccessApprovalPolicy).as("policyEnvId") + db.ref("envId").withSchema(TableName.AccessApprovalPolicy).as("policyEnvId"), + db.ref("deletedAt").withSchema(TableName.AccessApprovalPolicy).as("policyDeletedAt") ) .select(db.ref("approverUserId").withSchema(TableName.AccessApprovalPolicyApprover)) @@ -118,7 +119,8 @@ export const accessApprovalRequestDALFactory = (db: TDbClient) => { approvals: doc.policyApprovals, secretPath: doc.policySecretPath, enforcementLevel: doc.policyEnforcementLevel, - envId: doc.policyEnvId + envId: doc.policyEnvId, + deletedAt: doc.policyDeletedAt }, requestedByUser: { userId: doc.requestedByUserId, @@ -141,7 +143,7 @@ export const accessApprovalRequestDALFactory = (db: TDbClient) => { } : null, - isApproved: !!doc.privilegeId + isApproved: !!doc.policyDeletedAt || !!doc.privilegeId }), childrenMapper: [ { @@ -252,7 +254,8 @@ export const accessApprovalRequestDALFactory = (db: TDbClient) => { tx.ref("slug").withSchema(TableName.Environment).as("environment"), tx.ref("secretPath").withSchema(TableName.AccessApprovalPolicy).as("policySecretPath"), tx.ref("enforcementLevel").withSchema(TableName.AccessApprovalPolicy).as("policyEnforcementLevel"), - tx.ref("approvals").withSchema(TableName.AccessApprovalPolicy).as("policyApprovals") + tx.ref("approvals").withSchema(TableName.AccessApprovalPolicy).as("policyApprovals"), + tx.ref("deletedAt").withSchema(TableName.AccessApprovalPolicy).as("policyDeletedAt") ); const findById = async (id: string, tx?: Knex) => { @@ -271,7 +274,8 @@ export const accessApprovalRequestDALFactory = (db: TDbClient) => { name: el.policyName, approvals: el.policyApprovals, secretPath: el.policySecretPath, - enforcementLevel: el.policyEnforcementLevel + enforcementLevel: el.policyEnforcementLevel, + deletedAt: el.policyDeletedAt }, requestedByUser: { userId: el.requestedByUserId, @@ -363,6 +367,7 @@ export const accessApprovalRequestDALFactory = (db: TDbClient) => { ) .where(`${TableName.Environment}.projectId`, projectId) + .where(`${TableName.AccessApprovalPolicy}.deletedAt`, null) .select(selectAllTableCols(TableName.AccessApprovalRequest)) .select(db.ref("status").withSchema(TableName.AccessApprovalRequestReviewer).as("reviewerStatus")) .select(db.ref("reviewerUserId").withSchema(TableName.AccessApprovalRequestReviewer).as("reviewerUserId")); diff --git a/backend/src/ee/services/access-approval-request/access-approval-request-service.ts b/backend/src/ee/services/access-approval-request/access-approval-request-service.ts index 14accff41..b8475c446 100644 --- a/backend/src/ee/services/access-approval-request/access-approval-request-service.ts +++ b/backend/src/ee/services/access-approval-request/access-approval-request-service.ts @@ -130,6 +130,9 @@ export const accessApprovalRequestServiceFactory = ({ message: `No policy in environment with slug '${environment.slug}' and with secret path '${secretPath}' was found.` }); } + if (policy.deletedAt) { + throw new BadRequestError({ message: "The policy linked to this request has been deleted" }); + } const approverIds: string[] = []; const approverGroupIds: string[] = []; @@ -309,6 +312,12 @@ export const accessApprovalRequestServiceFactory = ({ } const { policy } = accessApprovalRequest; + if (policy.deletedAt) { + throw new BadRequestError({ + message: "The policy associated with this access request has been deleted." + }); + } + const { membership, hasRole } = await permissionService.getProjectPermission( actor, actorId, diff --git a/backend/src/ee/services/secret-approval-policy/secret-approval-policy-dal.ts b/backend/src/ee/services/secret-approval-policy/secret-approval-policy-dal.ts index bb77660aa..6644b14b8 100644 --- a/backend/src/ee/services/secret-approval-policy/secret-approval-policy-dal.ts +++ b/backend/src/ee/services/secret-approval-policy/secret-approval-policy-dal.ts @@ -177,5 +177,10 @@ export const secretApprovalPolicyDALFactory = (db: TDbClient) => { } }; - return { ...secretApprovalPolicyOrm, findById, find }; + const softDeleteById = async (policyId: string, tx?: Knex) => { + const softDeletedPolicy = await secretApprovalPolicyOrm.updateById(policyId, { deletedAt: new Date() }, tx); + return softDeletedPolicy; + }; + + return { ...secretApprovalPolicyOrm, findById, find, softDeleteById }; }; diff --git a/backend/src/ee/services/secret-approval-policy/secret-approval-policy-service.ts b/backend/src/ee/services/secret-approval-policy/secret-approval-policy-service.ts index cb3452685..4e7bf6d15 100644 --- a/backend/src/ee/services/secret-approval-policy/secret-approval-policy-service.ts +++ b/backend/src/ee/services/secret-approval-policy/secret-approval-policy-service.ts @@ -11,6 +11,8 @@ import { TUserDALFactory } from "@app/services/user/user-dal"; import { ApproverType } from "../access-approval-policy/access-approval-policy-types"; import { TLicenseServiceFactory } from "../license/license-service"; +import { TSecretApprovalRequestDALFactory } from "../secret-approval-request/secret-approval-request-dal"; +import { RequestState } from "../secret-approval-request/secret-approval-request-types"; import { TSecretApprovalPolicyApproverDALFactory } from "./secret-approval-policy-approver-dal"; import { TSecretApprovalPolicyDALFactory } from "./secret-approval-policy-dal"; import { @@ -34,6 +36,7 @@ type TSecretApprovalPolicyServiceFactoryDep = { userDAL: Pick; secretApprovalPolicyApproverDAL: TSecretApprovalPolicyApproverDALFactory; licenseService: Pick; + secretApprovalRequestDAL: Pick; }; export type TSecretApprovalPolicyServiceFactory = ReturnType; @@ -44,7 +47,8 @@ export const secretApprovalPolicyServiceFactory = ({ secretApprovalPolicyApproverDAL, projectEnvDAL, userDAL, - licenseService + licenseService, + secretApprovalRequestDAL }: TSecretApprovalPolicyServiceFactoryDep) => { const createSecretApprovalPolicy = async ({ name, @@ -301,8 +305,16 @@ export const secretApprovalPolicyServiceFactory = ({ }); } - await secretApprovalPolicyDAL.deleteById(secretPolicyId); - return sapPolicy; + const deletedPolicy = await secretApprovalPolicyDAL.transaction(async (tx) => { + await secretApprovalRequestDAL.update( + { policyId: secretPolicyId, status: RequestState.Open }, + { status: RequestState.Closed }, + tx + ); + const updatedPolicy = await secretApprovalPolicyDAL.softDeleteById(secretPolicyId, tx); + return updatedPolicy; + }); + return { ...deletedPolicy, projectId: sapPolicy.projectId, environment: sapPolicy.environment }; }; const getSecretApprovalPolicyByProjectId = async ({ @@ -321,7 +333,7 @@ export const secretApprovalPolicyServiceFactory = ({ ); ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Read, ProjectPermissionSub.SecretApproval); - const sapPolicies = await secretApprovalPolicyDAL.find({ projectId }); + const sapPolicies = await secretApprovalPolicyDAL.find({ projectId, deletedAt: null }); return sapPolicies; }; @@ -334,7 +346,7 @@ export const secretApprovalPolicyServiceFactory = ({ }); } - const policies = await secretApprovalPolicyDAL.find({ envId: env.id }); + const policies = await secretApprovalPolicyDAL.find({ envId: env.id, deletedAt: null }); if (!policies.length) return; // this will filter policies either without scoped to secret path or the one that matches with secret path const policiesFilteredByPath = policies.filter( diff --git a/backend/src/ee/services/secret-approval-request/secret-approval-request-dal.ts b/backend/src/ee/services/secret-approval-request/secret-approval-request-dal.ts index 803b9464c..f842359bc 100644 --- a/backend/src/ee/services/secret-approval-request/secret-approval-request-dal.ts +++ b/backend/src/ee/services/secret-approval-request/secret-approval-request-dal.ts @@ -111,7 +111,8 @@ export const secretApprovalRequestDALFactory = (db: TDbClient) => { tx.ref("secretPath").withSchema(TableName.SecretApprovalPolicy).as("policySecretPath"), tx.ref("envId").withSchema(TableName.SecretApprovalPolicy).as("policyEnvId"), tx.ref("enforcementLevel").withSchema(TableName.SecretApprovalPolicy).as("policyEnforcementLevel"), - tx.ref("approvals").withSchema(TableName.SecretApprovalPolicy).as("policyApprovals") + tx.ref("approvals").withSchema(TableName.SecretApprovalPolicy).as("policyApprovals"), + tx.ref("deletedAt").withSchema(TableName.SecretApprovalPolicy).as("policyDeletedAt") ); const findById = async (id: string, tx?: Knex) => { @@ -147,7 +148,8 @@ export const secretApprovalRequestDALFactory = (db: TDbClient) => { approvals: el.policyApprovals, secretPath: el.policySecretPath, enforcementLevel: el.policyEnforcementLevel, - envId: el.policyEnvId + envId: el.policyEnvId, + deletedAt: el.policyDeletedAt } }), childrenMapper: [ @@ -222,6 +224,11 @@ export const secretApprovalRequestDALFactory = (db: TDbClient) => { `${TableName.SecretApprovalRequest}.policyId`, `${TableName.SecretApprovalPolicyApprover}.policyId` ) + .join( + TableName.SecretApprovalPolicy, + `${TableName.SecretApprovalRequest}.policyId`, + `${TableName.SecretApprovalPolicy}.id` + ) .where({ projectId }) .andWhere( (bd) => @@ -229,6 +236,7 @@ export const secretApprovalRequestDALFactory = (db: TDbClient) => { .where(`${TableName.SecretApprovalPolicyApprover}.approverUserId`, userId) .orWhere(`${TableName.SecretApprovalRequest}.committerUserId`, userId) ) + .andWhere((bd) => void bd.where(`${TableName.SecretApprovalPolicy}.deletedAt`, null)) .select("status", `${TableName.SecretApprovalRequest}.id`) .groupBy(`${TableName.SecretApprovalRequest}.id`, "status") .count("status") diff --git a/backend/src/ee/services/secret-approval-request/secret-approval-request-service.ts b/backend/src/ee/services/secret-approval-request/secret-approval-request-service.ts index a39f44fd6..e1c75b3f9 100644 --- a/backend/src/ee/services/secret-approval-request/secret-approval-request-service.ts +++ b/backend/src/ee/services/secret-approval-request/secret-approval-request-service.ts @@ -232,10 +232,10 @@ export const secretApprovalRequestServiceFactory = ({ type: KmsDataKey.SecretManager, projectId }); - const encrypedSecrets = await secretApprovalRequestSecretDAL.findByRequestIdBridgeSecretV2( + const encryptedSecrets = await secretApprovalRequestSecretDAL.findByRequestIdBridgeSecretV2( secretApprovalRequest.id ); - secrets = encrypedSecrets.map((el) => ({ + secrets = encryptedSecrets.map((el) => ({ ...el, secretKey: el.key, id: el.id, @@ -274,8 +274,8 @@ export const secretApprovalRequestServiceFactory = ({ })); } else { if (!botKey) throw new NotFoundError({ message: `Project bot key not found`, name: "BotKeyNotFound" }); // CLI depends on this error message. TODO(daniel): Make API check for name BotKeyNotFound instead of message - const encrypedSecrets = await secretApprovalRequestSecretDAL.findByRequestId(secretApprovalRequest.id); - secrets = encrypedSecrets.map((el) => ({ + const encryptedSecrets = await secretApprovalRequestSecretDAL.findByRequestId(secretApprovalRequest.id); + secrets = encryptedSecrets.map((el) => ({ ...el, ...decryptSecretWithBot(el, botKey), secret: el.secret @@ -323,6 +323,12 @@ export const secretApprovalRequestServiceFactory = ({ } const { policy } = secretApprovalRequest; + if (policy.deletedAt) { + throw new BadRequestError({ + message: "The policy associated with this secret approval request has been deleted." + }); + } + const { hasRole } = await permissionService.getProjectPermission( ActorType.USER, actorId, @@ -383,6 +389,12 @@ export const secretApprovalRequestServiceFactory = ({ } const { policy } = secretApprovalRequest; + if (policy.deletedAt) { + throw new BadRequestError({ + message: "The policy associated with this secret approval request has been deleted." + }); + } + const { hasRole } = await permissionService.getProjectPermission( ActorType.USER, actorId, @@ -433,6 +445,12 @@ export const secretApprovalRequestServiceFactory = ({ } const { policy, folderId, projectId } = secretApprovalRequest; + if (policy.deletedAt) { + throw new BadRequestError({ + message: "The policy associated with this secret approval request has been deleted." + }); + } + const { hasRole } = await permissionService.getProjectPermission( ActorType.USER, actorId, diff --git a/backend/src/server/routes/index.ts b/backend/src/server/routes/index.ts index 4f07579bd..8dbae554f 100644 --- a/backend/src/server/routes/index.ts +++ b/backend/src/server/routes/index.ts @@ -414,7 +414,8 @@ export const registerRoutes = async ( permissionService, secretApprovalPolicyDAL, licenseService, - userDAL + userDAL, + secretApprovalRequestDAL }); const tokenService = tokenServiceFactory({ tokenDAL: authTokenDAL, userDAL, orgMembershipDAL }); @@ -994,7 +995,10 @@ export const registerRoutes = async ( projectEnvDAL, projectMembershipDAL, projectDAL, - userDAL + userDAL, + accessApprovalRequestDAL, + additionalPrivilegeDAL: projectUserAdditionalPrivilegeDAL, + accessApprovalRequestReviewerDAL }); const accessApprovalRequestService = accessApprovalRequestServiceFactory({ diff --git a/frontend/src/hooks/api/accessApproval/types.ts b/frontend/src/hooks/api/accessApproval/types.ts index 6df257590..bd6173d91 100644 --- a/frontend/src/hooks/api/accessApproval/types.ts +++ b/frontend/src/hooks/api/accessApproval/types.ts @@ -18,15 +18,15 @@ export type TAccessApprovalPolicy = { approvers?: Approver[]; }; -export enum ApproverType{ +export enum ApproverType { User = "user", Group = "group" } -export type Approver ={ +export type Approver = { id: string; type: ApproverType; -} +}; export type TAccessApprovalRequest = { id: string; @@ -70,6 +70,7 @@ export type TAccessApprovalRequest = { secretPath?: string | null; envId: string; enforcementLevel: EnforcementLevel; + deletedAt: Date | null; }; reviewers: { diff --git a/frontend/src/views/SecretApprovalPage/components/AccessApprovalRequest/AccessApprovalRequest.tsx b/frontend/src/views/SecretApprovalPage/components/AccessApprovalRequest/AccessApprovalRequest.tsx index 93e906373..42e576f06 100644 --- a/frontend/src/views/SecretApprovalPage/components/AccessApprovalRequest/AccessApprovalRequest.tsx +++ b/frontend/src/views/SecretApprovalPage/components/AccessApprovalRequest/AccessApprovalRequest.tsx @@ -130,12 +130,14 @@ export const AccessApprovalRequest = ({ if (statusFilter === "open") return requests?.filter( (request) => + !request.policy.deletedAt && !request.isApproved && !request.reviewers.some((reviewer) => reviewer.status === ApprovalStatus.REJECTED) ); if (statusFilter === "close") return requests?.filter( (request) => + request.policy.deletedAt || request.isApproved || request.reviewers.some((reviewer) => reviewer.status === ApprovalStatus.REJECTED) ); @@ -144,8 +146,6 @@ export const AccessApprovalRequest = ({ }, [requests, statusFilter, requestedByFilter, envFilter]); const generateRequestDetails = (request: TAccessApprovalRequest) => { - console.log(request); - const isReviewedByUser = request.reviewers.findIndex(({ member }) => member === user.id) !== -1; const isRejectedByAnyone = request.reviewers.some( ({ status }) => status === ApprovalStatus.REJECTED From 3c954ea2578fb9bae91f4ea38096da01b71fe4ff Mon Sep 17 00:00:00 2001 From: McPizza Date: Mon, 9 Dec 2024 21:46:56 +0100 Subject: [PATCH 026/162] set all instances to show URL --- backend/src/services/smtp/smtp-service.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/backend/src/services/smtp/smtp-service.ts b/backend/src/services/smtp/smtp-service.ts index 9ead50bb7..a2ed85749 100644 --- a/backend/src/services/smtp/smtp-service.ts +++ b/backend/src/services/smtp/smtp-service.ts @@ -54,10 +54,10 @@ export const smtpServiceFactory = (cfg: TSmtpConfig) => { const isSmtpOn = Boolean(cfg.host); handlebars.registerHelper("emailFooter", () => { - const { isCloud, SITE_URL } = getConfig(); - const cloudFooterHtml = `

Infisical - a tool for managing secrets in your organization. Learn more

`; - const selfHostedFooterHtml = `

Email sent via Infisical at ${SITE_URL}

`; - return new handlebars.SafeString(isCloud ? cloudFooterHtml : selfHostedFooterHtml); + const { SITE_URL } = getConfig(); + return new handlebars.SafeString( + `

Email sent via Infisical at ${SITE_URL}

` + ); }); const sendMail = async ({ substitutions, recipients, template, subjectLine }: TSmtpSendMail) => { From d2b909b72b8fb0426c7b6cf6a3ca603802670768 Mon Sep 17 00:00:00 2001 From: Daniel Hougaard Date: Tue, 10 Dec 2024 04:01:17 +0400 Subject: [PATCH 027/162] fix(dashboard): pasting secrets into create secret modal --- frontend/src/helpers/parseEnvVar.ts | 23 +++++++++++++++---- .../CreateSecretForm/CreateSecretForm.tsx | 17 +++++++++++--- .../CreateSecretForm/CreateSecretForm.tsx | 17 +++++++++++--- 3 files changed, 47 insertions(+), 10 deletions(-) diff --git a/frontend/src/helpers/parseEnvVar.ts b/frontend/src/helpers/parseEnvVar.ts index 27640b515..6b056d5e9 100644 --- a/frontend/src/helpers/parseEnvVar.ts +++ b/frontend/src/helpers/parseEnvVar.ts @@ -1,14 +1,29 @@ /** Extracts the key and value from a passed in env string based on the provided delimiters. */ export const getKeyValue = (pastedContent: string, delimiters: string[]) => { - const foundDelimiter = delimiters.find((delimiter) => pastedContent.includes(delimiter)); + if (!pastedContent) { + return { key: "", value: "" }; + } - if (!foundDelimiter) { + let firstDelimiterIndex = -1; + let foundDelimiter = ""; + + delimiters.forEach((delimiter) => { + const index = pastedContent.indexOf(delimiter); + if (index !== -1 && (firstDelimiterIndex === -1 || index < firstDelimiterIndex)) { + firstDelimiterIndex = index; + foundDelimiter = delimiter; + } + }); + + if (firstDelimiterIndex === -1) { return { key: pastedContent.trim(), value: "" }; } - const [key, value] = pastedContent.split(foundDelimiter); + const key = pastedContent.substring(0, firstDelimiterIndex); + const value = pastedContent.substring(firstDelimiterIndex + foundDelimiter.length); + return { key: key.trim(), - value: (value ?? "").trim() + value: value.trim() }; }; diff --git a/frontend/src/views/SecretMainPage/components/CreateSecretForm/CreateSecretForm.tsx b/frontend/src/views/SecretMainPage/components/CreateSecretForm/CreateSecretForm.tsx index 53b00f34a..17c99ac8c 100644 --- a/frontend/src/views/SecretMainPage/components/CreateSecretForm/CreateSecretForm.tsx +++ b/frontend/src/views/SecretMainPage/components/CreateSecretForm/CreateSecretForm.tsx @@ -46,6 +46,7 @@ export const CreateSecretForm = ({ control, reset, setValue, + watch, formState: { errors, isSubmitting } } = useForm({ resolver: zodResolver(typeSchema) }); const { closePopUp } = usePopUpAction(); @@ -59,6 +60,9 @@ export const CreateSecretForm = ({ canReadTags ? workspaceId : "" ); + const secretValue = watch("value"); + const secretKey = watch("key"); + const slugSchema = z.string().trim().toLowerCase().min(1); const createNewTag = async (slug: string) => { // TODO: Replace with slugSchema generic @@ -108,13 +112,20 @@ export const CreateSecretForm = ({ }; const handlePaste = (e: ClipboardEvent) => { - e.preventDefault(); const delimitters = [":", "="]; const pastedContent = e.clipboardData.getData("text"); const { key, value } = getKeyValue(pastedContent, delimitters); - setValue("key", key); - setValue("value", value); + if (!secretKey) { + setValue("key", key); + } + if (!secretValue) { + setValue("value", value); + } + + if (!secretKey) { + e.preventDefault(); + } }; return ( diff --git a/frontend/src/views/SecretOverviewPage/components/CreateSecretForm/CreateSecretForm.tsx b/frontend/src/views/SecretOverviewPage/components/CreateSecretForm/CreateSecretForm.tsx index 36e430205..ce44c9881 100644 --- a/frontend/src/views/SecretOverviewPage/components/CreateSecretForm/CreateSecretForm.tsx +++ b/frontend/src/views/SecretOverviewPage/components/CreateSecretForm/CreateSecretForm.tsx @@ -46,6 +46,7 @@ export const CreateSecretForm = ({ secretPath = "/", onClose }: Props) => { control, reset, setValue, + watch, formState: { isSubmitting, errors } } = useForm({ resolver: zodResolver(typeSchema) }); @@ -61,6 +62,9 @@ export const CreateSecretForm = ({ secretPath = "/", onClose }: Props) => { canReadTags ? workspaceId : "" ); + const secretValue = watch("value"); + const secretKey = watch("key"); + const handleFormSubmit = async ({ key, value, environments: selectedEnv, tags }: TFormSchema) => { const promises = selectedEnv.map(async (env) => { const environment = env.slug; @@ -152,13 +156,20 @@ export const CreateSecretForm = ({ secretPath = "/", onClose }: Props) => { }; const handlePaste = (e: ClipboardEvent) => { - e.preventDefault(); const delimitters = [":", "="]; const pastedContent = e.clipboardData.getData("text"); const { key, value } = getKeyValue(pastedContent, delimitters); - setValue("key", key); - setValue("value", value); + if (!secretKey) { + setValue("key", key); + } + if (!secretValue) { + setValue("value", value); + } + + if (!secretKey) { + e.preventDefault(); + } }; const createWsTag = useCreateWsTag(); From 8fdc438940ddcace27dcdf079a40ef3a1cbc4f48 Mon Sep 17 00:00:00 2001 From: Daniel Hougaard Date: Tue, 10 Dec 2024 07:32:09 +0400 Subject: [PATCH 028/162] feat: remove plain and move to pylon --- backend/package-lock.json | 29 ------- backend/package.json | 1 - backend/src/lib/config/env.ts | 3 +- backend/src/server/routes/index.ts | 3 +- .../routes/v1/user-engagement-router.ts | 2 +- .../user-engagement-service.ts | 79 +++++-------------- 6 files changed, 22 insertions(+), 95 deletions(-) diff --git a/backend/package-lock.json b/backend/package-lock.json index 2fba00120..9d0a3c8c9 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -49,7 +49,6 @@ "@sindresorhus/slugify": "1.1.0", "@slack/oauth": "^3.0.1", "@slack/web-api": "^7.3.4", - "@team-plain/typescript-sdk": "^4.6.1", "@ucast/mongo2js": "^1.3.4", "ajv": "^8.12.0", "argon2": "^0.31.2", @@ -5678,14 +5677,6 @@ "uuid": "dist/bin/uuid" } }, - "node_modules/@graphql-typed-document-node/core": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/@graphql-typed-document-node/core/-/core-3.2.0.tgz", - "integrity": "sha512-mB9oAsNCm9aM3/SOv4YtBMqZbYj10R7dkq8byBqxGY/ncFwhf2oQzMV+LCRlWoDSEBJ3COiR1yeDvMtsoOsuFQ==", - "peerDependencies": { - "graphql": "^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" - } - }, "node_modules/@grpc/grpc-js": { "version": "1.12.2", "resolved": "https://registry.npmjs.org/@grpc/grpc-js/-/grpc-js-1.12.2.tgz", @@ -9970,18 +9961,6 @@ "optional": true, "peer": true }, - "node_modules/@team-plain/typescript-sdk": { - "version": "4.6.1", - "resolved": "https://registry.npmjs.org/@team-plain/typescript-sdk/-/typescript-sdk-4.6.1.tgz", - "integrity": "sha512-Uy9QJXu9U7bJb6WXL9sArGk7FXPpzdqBd6q8tAF1vexTm8fbTJRqcikTKxGtZmNADt+C2SapH3cApM4oHpO4lQ==", - "dependencies": { - "@graphql-typed-document-node/core": "^3.2.0", - "ajv": "^8.12.0", - "ajv-formats": "^2.1.1", - "graphql": "^16.6.0", - "zod": "3.22.4" - } - }, "node_modules/@techteamer/ocsp": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/@techteamer/ocsp/-/ocsp-1.0.1.tgz", @@ -15180,14 +15159,6 @@ "integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==", "dev": true }, - "node_modules/graphql": { - "version": "16.9.0", - "resolved": "https://registry.npmjs.org/graphql/-/graphql-16.9.0.tgz", - "integrity": "sha512-GGTKBX4SD7Wdb8mqeDLni2oaRGYQWjWHGKPQ24ZMnUtKfcsVoiv4uX8+LJr1K6U5VW2Lu1BwJnj7uiori0YtRw==", - "engines": { - "node": "^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0" - } - }, "node_modules/gtoken": { "version": "7.1.0", "resolved": "https://registry.npmjs.org/gtoken/-/gtoken-7.1.0.tgz", diff --git a/backend/package.json b/backend/package.json index 0dafc475c..a7321f67d 100644 --- a/backend/package.json +++ b/backend/package.json @@ -157,7 +157,6 @@ "@sindresorhus/slugify": "1.1.0", "@slack/oauth": "^3.0.1", "@slack/web-api": "^7.3.4", - "@team-plain/typescript-sdk": "^4.6.1", "@ucast/mongo2js": "^1.3.4", "ajv": "^8.12.0", "argon2": "^0.31.2", diff --git a/backend/src/lib/config/env.ts b/backend/src/lib/config/env.ts index 66c5f3d98..7bb95468a 100644 --- a/backend/src/lib/config/env.ts +++ b/backend/src/lib/config/env.ts @@ -166,8 +166,7 @@ const envSchema = z OTEL_COLLECTOR_BASIC_AUTH_PASSWORD: zpStr(z.string().optional()), OTEL_EXPORT_TYPE: z.enum(["prometheus", "otlp"]).optional(), - PLAIN_API_KEY: zpStr(z.string().optional()), - PLAIN_WISH_LABEL_IDS: zpStr(z.string().optional()), + PYLON_API_KEY: zpStr(z.string().optional()), DISABLE_AUDIT_LOG_GENERATION: zodStrBool.default("false"), SSL_CLIENT_CERTIFICATE_HEADER_KEY: zpStr(z.string().optional()).default("x-ssl-client-cert"), WORKFLOW_SLACK_CLIENT_ID: zpStr(z.string().optional()), diff --git a/backend/src/server/routes/index.ts b/backend/src/server/routes/index.ts index 8dbae554f..806b3575c 100644 --- a/backend/src/server/routes/index.ts +++ b/backend/src/server/routes/index.ts @@ -1242,7 +1242,8 @@ export const registerRoutes = async ( }); const userEngagementService = userEngagementServiceFactory({ - userDAL + userDAL, + orgDAL }); const slackService = slackServiceFactory({ diff --git a/backend/src/server/routes/v1/user-engagement-router.ts b/backend/src/server/routes/v1/user-engagement-router.ts index e3ce6532e..1a13dbc6e 100644 --- a/backend/src/server/routes/v1/user-engagement-router.ts +++ b/backend/src/server/routes/v1/user-engagement-router.ts @@ -21,7 +21,7 @@ export const registerUserEngagementRouter = async (server: FastifyZodProvider) = }, onRequest: verifyAuth([AuthMode.JWT]), handler: async (req) => { - return server.services.userEngagement.createUserWish(req.permission.id, req.body.text); + return server.services.userEngagement.createUserWish(req.permission.id, req.permission.orgId, req.body.text); } }); }; diff --git a/backend/src/services/user-engagement/user-engagement-service.ts b/backend/src/services/user-engagement/user-engagement-service.ts index 5d7b54929..b14672903 100644 --- a/backend/src/services/user-engagement/user-engagement-service.ts +++ b/backend/src/services/user-engagement/user-engagement-service.ts @@ -1,87 +1,44 @@ -import { PlainClient } from "@team-plain/typescript-sdk"; +import axios from "axios"; import { getConfig } from "@app/lib/config/env"; import { InternalServerError } from "@app/lib/errors"; +import { TOrgDALFactory } from "../org/org-dal"; import { TUserDALFactory } from "../user/user-dal"; type TUserEngagementServiceFactoryDep = { userDAL: Pick; + orgDAL: Pick; }; export type TUserEngagementServiceFactory = ReturnType; -export const userEngagementServiceFactory = ({ userDAL }: TUserEngagementServiceFactoryDep) => { - const createUserWish = async (userId: string, text: string) => { +export const userEngagementServiceFactory = ({ userDAL, orgDAL }: TUserEngagementServiceFactoryDep) => { + const createUserWish = async (userId: string, orgId: string, text: string) => { const user = await userDAL.findById(userId); + const org = await orgDAL.findById(orgId); const appCfg = getConfig(); - if (!appCfg.PLAIN_API_KEY) { + if (!appCfg.PYLON_API_KEY) { throw new InternalServerError({ - message: "Plain is not configured." + message: "Pylon is not configured." }); } - const client = new PlainClient({ - apiKey: appCfg.PLAIN_API_KEY - }); - - const customerUpsertRes = await client.upsertCustomer({ - identifier: { - emailAddress: user.email - }, - onCreate: { - fullName: `${user.firstName} ${user.lastName}`, - shortName: user.firstName, - email: { - email: user.email as string, - isVerified: user.isEmailVerified as boolean - }, - - externalId: user.id - }, - - onUpdate: { - fullName: { - value: `${user.firstName} ${user.lastName}` - }, - shortName: { - value: user.firstName - }, - email: { - email: user.email as string, - isVerified: user.isEmailVerified as boolean - }, - externalId: { - value: user.id - } + const request = axios.create({ + baseURL: "https://api.usepylon.com", + headers: { + Authorization: `Bearer ${appCfg.PYLON_API_KEY}` } }); - if (customerUpsertRes.error) { - throw new InternalServerError({ message: customerUpsertRes.error.message }); - } - - const createThreadRes = await client.createThread({ - title: "Wish", - customerIdentifier: { - externalId: customerUpsertRes.data.customer.externalId - }, - components: [ - { - componentText: { - text - } - } - ], - labelTypeIds: appCfg.PLAIN_WISH_LABEL_IDS?.split(",") + await request.post("/issues", { + title: `New Wish From: ${user.firstName} ${user.lastName} (${org.name})`, + body_html: text, + requester_email: user.email, + requester_name: `${user.firstName} ${user.lastName} (${org.name})`, + tags: ["wish"] }); - - if (createThreadRes.error) { - throw new InternalServerError({ - message: createThreadRes.error.message - }); - } }; return { createUserWish From 5d9b99bee7d467823ba9fba15eddce034924ca13 Mon Sep 17 00:00:00 2001 From: Daniel Hougaard Date: Tue, 10 Dec 2024 07:47:36 +0400 Subject: [PATCH 029/162] Update NewProjectModal.tsx --- frontend/src/components/v2/projects/NewProjectModal.tsx | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/frontend/src/components/v2/projects/NewProjectModal.tsx b/frontend/src/components/v2/projects/NewProjectModal.tsx index 8f2cf79e8..1662b07ff 100644 --- a/frontend/src/components/v2/projects/NewProjectModal.tsx +++ b/frontend/src/components/v2/projects/NewProjectModal.tsx @@ -36,7 +36,8 @@ import { fetchOrgUsers, useAddUserToWsNonE2EE, useCreateWorkspace, - useGetExternalKmsList + useGetExternalKmsList, + useGetUserWorkspaces } from "@app/hooks/api"; import { INTERNAL_KMS_KEY_ID } from "@app/hooks/api/kms/types"; import { InfisicalProjectTemplate, useListProjectTemplates } from "@app/hooks/api/projectTemplates"; @@ -68,6 +69,7 @@ const NewProjectForm = ({ onOpenChange }: NewProjectFormProps) => { const { permission } = useOrgPermission(); const { user } = useUser(); const createWs = useCreateWorkspace(); + const { refetch: refetchWorkspaces } = useGetUserWorkspaces(); const addUsersToProject = useAddUserToWsNonE2EE(); const { subscription } = useSubscription(); @@ -137,8 +139,8 @@ const NewProjectForm = ({ onOpenChange }: NewProjectFormProps) => { orgId: currentOrg.id }); } - // eslint-disable-next-line no-promise-executor-return -- We do this because the function returns too fast, which sometimes causes an error when the user is redirected. - await new Promise((resolve) => setTimeout(resolve, 2_000)); + + await refetchWorkspaces(); createNotification({ text: "Project created", type: "success" }); reset(); From 7cf297344b3b2ca18a427b07dd2c3c081514b876 Mon Sep 17 00:00:00 2001 From: Tuan Dang Date: Mon, 9 Dec 2024 21:36:42 -0800 Subject: [PATCH 030/162] Move ssh back to project level --- backend/src/@types/knex.d.ts | 8 + .../db/migrations/20241130015511_ssh-mgmt.ts | 20 +- backend/src/db/schemas/index.ts | 1 + backend/src/db/schemas/models.ts | 1 + .../db/schemas/ssh-certificate-authorities.ts | 2 +- .../src/db/schemas/ssh-certificate-bodies.ts | 22 ++ backend/src/db/schemas/ssh-certificates.ts | 1 - .../v1/ssh-certificate-authority-router.ts | 11 +- .../v1/ssh-certificate-template-router.ts | 10 +- backend/src/ee/routes/v1/ssh-router.ts | 2 + .../ee/services/permission/org-permission.ts | 28 +-- .../services/permission/project-permission.ts | 41 +++- .../ssh-certificate-template-dal.ts | 15 +- .../ssh-certificate-template-service.ts | 42 ++-- .../ssh-certificate-body-dal.ts | 10 + .../ssh-certificate/ssh-certificate-dal.ts | 10 +- .../ssh/ssh-certificate-authority-schema.ts | 2 +- .../ssh/ssh-certificate-authority-service.ts | 231 +++++++++++------- .../ssh/ssh-certificate-authority-types.ts | 16 +- backend/src/lib/api-docs/constants.ts | 24 +- backend/src/server/routes/index.ts | 11 +- .../server/routes/v1/organization-router.ts | 100 -------- .../src/server/routes/v2/project-router.ts | 100 ++++++++ backend/src/services/org/org-service.ts | 102 +------- backend/src/services/org/org-types.ts | 7 - .../src/services/project/project-service.ts | 124 ++++++++++ backend/src/services/project/project-types.ts | 7 + .../src/context/OrgPermissionContext/types.ts | 10 +- .../context/ProjectPermissionContext/types.ts | 6 + frontend/src/hooks/api/organization/index.ts | 6 +- .../src/hooks/api/organization/queries.tsx | 69 +----- frontend/src/hooks/api/ssh-ca/mutations.tsx | 22 +- frontend/src/hooks/api/ssh-ca/types.ts | 6 +- frontend/src/hooks/api/workspace/index.tsx | 3 + frontend/src/hooks/api/workspace/queries.tsx | 63 +++++ .../src/hooks/api/workspace/query-keys.tsx | 16 +- frontend/src/layouts/AppLayout/AppLayout.tsx | 20 +- frontend/src/pages/org/[id]/ssh/index.tsx | 26 -- .../[id]/ssh/ca/[caId]/index.tsx | 2 +- frontend/src/pages/project/[id]/ssh/index.tsx | 23 ++ .../components/OrgRoleModifySection.utils.ts | 5 +- .../RolePermissionsSection.tsx | 14 +- .../ProjectRoleModifySection.utils.tsx | 35 +++ .../{Org => Project}/SshCaPage/SshCaPage.tsx | 27 +- .../components/SshCaDetailsSection.tsx | 14 +- .../components/SshCertificateContent.tsx | 0 .../components/SshCertificateModal.tsx | 17 +- .../SshCertificateTemplateModal.tsx | 10 +- .../SshCertificateTemplatesSection.tsx | 15 +- .../SshCertificateTemplatesTable.tsx | 38 +-- .../SshCaPage/components/index.tsx | 0 .../{Org => Project}/SshCaPage/index.tsx | 0 .../{Org => Project}/SshPage/SshPage.tsx | 8 +- .../SshPage/components/SshCaModal.tsx | 6 + .../SshPage/components/SshCaSection.tsx | 12 +- .../SshPage/components/SshCaTable.tsx | 30 ++- .../components/SshCertificatesSection.tsx | 11 +- .../components/SshCertificatesTable.tsx | 10 +- .../components/SshCertificatesTable.utils.ts | 0 .../SshPage/components/index.tsx | 0 .../views/{Org => Project}/SshPage/index.tsx | 0 61 files changed, 837 insertions(+), 635 deletions(-) create mode 100644 backend/src/db/schemas/ssh-certificate-bodies.ts create mode 100644 backend/src/ee/services/ssh-certificate/ssh-certificate-body-dal.ts delete mode 100644 frontend/src/pages/org/[id]/ssh/index.tsx rename frontend/src/pages/{org => project}/[id]/ssh/ca/[caId]/index.tsx (84%) create mode 100644 frontend/src/pages/project/[id]/ssh/index.tsx rename frontend/src/views/{Org => Project}/SshCaPage/SshCaPage.tsx (84%) rename frontend/src/views/{Org => Project}/SshCaPage/components/SshCaDetailsSection.tsx (91%) rename frontend/src/views/{Org => Project}/SshCaPage/components/SshCertificateContent.tsx (100%) rename frontend/src/views/{Org => Project}/SshCaPage/components/SshCertificateModal.tsx (96%) rename frontend/src/views/{Org => Project}/SshCaPage/components/SshCertificateTemplateModal.tsx (98%) rename frontend/src/views/{Org => Project}/SshCaPage/components/SshCertificateTemplatesSection.tsx (93%) rename frontend/src/views/{Org => Project}/SshCaPage/components/SshCertificateTemplatesTable.tsx (85%) rename frontend/src/views/{Org => Project}/SshCaPage/components/index.tsx (100%) rename frontend/src/views/{Org => Project}/SshCaPage/index.tsx (100%) rename frontend/src/views/{Org => Project}/SshPage/SshPage.tsx (86%) rename frontend/src/views/{Org => Project}/SshPage/components/SshCaModal.tsx (96%) rename frontend/src/views/{Org => Project}/SshPage/components/SshCaSection.tsx (92%) rename frontend/src/views/{Org => Project}/SshPage/components/SshCaTable.tsx (84%) rename frontend/src/views/{Org => Project}/SshPage/components/SshCertificatesSection.tsx (79%) rename frontend/src/views/{Org => Project}/SshPage/components/SshCertificatesTable.tsx (89%) rename frontend/src/views/{Org => Project}/SshPage/components/SshCertificatesTable.utils.ts (100%) rename frontend/src/views/{Org => Project}/SshPage/components/index.tsx (100%) rename frontend/src/views/{Org => Project}/SshPage/index.tsx (100%) diff --git a/backend/src/@types/knex.d.ts b/backend/src/@types/knex.d.ts index c0a2c0eaa..0fa1f3c2e 100644 --- a/backend/src/@types/knex.d.ts +++ b/backend/src/@types/knex.d.ts @@ -317,6 +317,9 @@ import { TSshCertificateAuthoritySecrets, TSshCertificateAuthoritySecretsInsert, TSshCertificateAuthoritySecretsUpdate, + TSshCertificateBodies, + TSshCertificateBodiesInsert, + TSshCertificateBodiesUpdate, TSshCertificates, TSshCertificatesInsert, TSshCertificatesUpdate, @@ -404,6 +407,11 @@ declare module "knex/types/tables" { TSshCertificatesInsert, TSshCertificatesUpdate >; + [TableName.SshCertificateBody]: KnexOriginal.CompositeTableType< + TSshCertificateBodies, + TSshCertificateBodiesInsert, + TSshCertificateBodiesUpdate + >; [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 index ad38f8ad2..92831d382 100644 --- a/backend/src/db/migrations/20241130015511_ssh-mgmt.ts +++ b/backend/src/db/migrations/20241130015511_ssh-mgmt.ts @@ -8,8 +8,8 @@ export async function up(knex: Knex): Promise { 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("projectId").notNullable(); + t.foreign("projectId").references("id").inTable(TableName.Project).onDelete("CASCADE"); t.string("status").notNullable(); // active / disabled t.string("friendlyName").notNullable(); t.string("keyAlgorithm").notNullable(); @@ -60,7 +60,6 @@ export async function up(knex: Knex): Promise { .onDelete("SET NULL"); t.string("serialNumber").notNullable().unique(); t.string("certType").notNullable(); // user or host - t.text("publicKey").notNullable(); // public key in OpenSSH format t.specificType("principals", "text[]").notNullable(); t.string("keyId").notNullable(); t.datetime("notBefore").notNullable(); @@ -68,9 +67,24 @@ export async function up(knex: Knex): Promise { }); await createOnUpdateTrigger(knex, TableName.SshCertificate); } + + if (!(await knex.schema.hasTable(TableName.SshCertificateBody))) { + await knex.schema.createTable(TableName.SshCertificateBody, (t) => { + t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid()); + t.timestamps(true, true, true); + t.uuid("sshCertId").notNullable().unique(); + t.foreign("sshCertId").references("id").inTable(TableName.SshCertificate).onDelete("CASCADE"); + t.binary("encryptedCertificate").notNullable(); + }); + + await createOnUpdateTrigger(knex, TableName.SshCertificateBody); + } } export async function down(knex: Knex): Promise { + await knex.schema.dropTableIfExists(TableName.SshCertificateBody); + await dropOnUpdateTrigger(knex, TableName.SshCertificateBody); + await knex.schema.dropTableIfExists(TableName.SshCertificate); await dropOnUpdateTrigger(knex, TableName.SshCertificate); diff --git a/backend/src/db/schemas/index.ts b/backend/src/db/schemas/index.ts index 348c39c70..c5dcecfd8 100644 --- a/backend/src/db/schemas/index.ts +++ b/backend/src/db/schemas/index.ts @@ -107,6 +107,7 @@ export * from "./service-tokens"; export * from "./slack-integrations"; export * from "./ssh-certificate-authorities"; export * from "./ssh-certificate-authority-secrets"; +export * from "./ssh-certificate-bodies"; export * from "./ssh-certificate-templates"; export * from "./ssh-certificates"; export * from "./super-admin"; diff --git a/backend/src/db/schemas/models.ts b/backend/src/db/schemas/models.ts index b7de13188..0f2c1ae49 100644 --- a/backend/src/db/schemas/models.ts +++ b/backend/src/db/schemas/models.ts @@ -6,6 +6,7 @@ export enum TableName { SshCertificateAuthoritySecret = "ssh_certificate_authority_secrets", SshCertificateTemplate = "ssh_certificate_templates", SshCertificate = "ssh_certificates", + SshCertificateBody = "ssh_certificate_bodies", 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 index d70b09a8a..81e789288 100644 --- a/backend/src/db/schemas/ssh-certificate-authorities.ts +++ b/backend/src/db/schemas/ssh-certificate-authorities.ts @@ -11,7 +11,7 @@ export const SshCertificateAuthoritiesSchema = z.object({ id: z.string().uuid(), createdAt: z.date(), updatedAt: z.date(), - orgId: z.string().uuid(), + projectId: z.string(), status: z.string(), friendlyName: z.string(), keyAlgorithm: z.string() diff --git a/backend/src/db/schemas/ssh-certificate-bodies.ts b/backend/src/db/schemas/ssh-certificate-bodies.ts new file mode 100644 index 000000000..baafb773b --- /dev/null +++ b/backend/src/db/schemas/ssh-certificate-bodies.ts @@ -0,0 +1,22 @@ +// 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 SshCertificateBodiesSchema = z.object({ + id: z.string().uuid(), + createdAt: z.date(), + updatedAt: z.date(), + sshCertId: z.string().uuid(), + encryptedCertificate: zodBuffer +}); + +export type TSshCertificateBodies = z.infer; +export type TSshCertificateBodiesInsert = Omit, TImmutableDBKeys>; +export type TSshCertificateBodiesUpdate = Partial, TImmutableDBKeys>>; diff --git a/backend/src/db/schemas/ssh-certificates.ts b/backend/src/db/schemas/ssh-certificates.ts index 238a71e00..6fe5bc261 100644 --- a/backend/src/db/schemas/ssh-certificates.ts +++ b/backend/src/db/schemas/ssh-certificates.ts @@ -15,7 +15,6 @@ export const SshCertificatesSchema = z.object({ sshCertificateTemplateId: z.string().uuid().nullable().optional(), serialNumber: z.string(), certType: z.string(), - publicKey: z.string(), principals: z.string().array(), keyId: z.string(), notBefore: z.date(), diff --git a/backend/src/ee/routes/v1/ssh-certificate-authority-router.ts b/backend/src/ee/routes/v1/ssh-certificate-authority-router.ts index 8c75ac4d4..ab80888d7 100644 --- a/backend/src/ee/routes/v1/ssh-certificate-authority-router.ts +++ b/backend/src/ee/routes/v1/ssh-certificate-authority-router.ts @@ -21,6 +21,7 @@ export const registerSshCaRouter = async (server: FastifyZodProvider) => { schema: { description: "Create SSH CA", body: z.object({ + projectId: z.string().describe(SSH_CERTIFICATE_AUTHORITIES.CREATE.projectId), friendlyName: z.string().describe(SSH_CERTIFICATE_AUTHORITIES.CREATE.friendlyName), keyAlgorithm: z .nativeEnum(CertKeyAlgorithm) @@ -46,7 +47,7 @@ export const registerSshCaRouter = async (server: FastifyZodProvider) => { await server.services.auditLog.createAuditLog({ ...req.auditLogInfo, - orgId: ca.orgId, + projectId: ca.projectId, event: { type: EventType.CREATE_SSH_CA, metadata: { @@ -93,7 +94,7 @@ export const registerSshCaRouter = async (server: FastifyZodProvider) => { await server.services.auditLog.createAuditLog({ ...req.auditLogInfo, - orgId: ca.orgId, + projectId: ca.projectId, event: { type: EventType.GET_SSH_CA, metadata: { @@ -169,7 +170,7 @@ export const registerSshCaRouter = async (server: FastifyZodProvider) => { await server.services.auditLog.createAuditLog({ ...req.auditLogInfo, - orgId: ca.orgId, + projectId: ca.projectId, event: { type: EventType.UPDATE_SSH_CA, metadata: { @@ -215,7 +216,7 @@ export const registerSshCaRouter = async (server: FastifyZodProvider) => { await server.services.auditLog.createAuditLog({ ...req.auditLogInfo, - orgId: ca.orgId, + projectId: ca.projectId, event: { type: EventType.DELETE_SSH_CA, metadata: { @@ -260,7 +261,7 @@ export const registerSshCaRouter = async (server: FastifyZodProvider) => { await server.services.auditLog.createAuditLog({ ...req.auditLogInfo, - orgId: ca.orgId, + projectId: ca.projectId, event: { type: EventType.GET_SSH_CA_CERTIFICATE_TEMPLATES, metadata: { diff --git a/backend/src/ee/routes/v1/ssh-certificate-template-router.ts b/backend/src/ee/routes/v1/ssh-certificate-template-router.ts index 7e828a588..14e1e0dc7 100644 --- a/backend/src/ee/routes/v1/ssh-certificate-template-router.ts +++ b/backend/src/ee/routes/v1/ssh-certificate-template-router.ts @@ -41,7 +41,7 @@ export const registerSshCertificateTemplateRouter = async (server: FastifyZodPro await server.services.auditLog.createAuditLog({ ...req.auditLogInfo, - orgId: certificateTemplate.orgId, + projectId: certificateTemplate.projectId, event: { type: EventType.GET_SSH_CERTIFICATE_TEMPLATE, metadata: { @@ -107,7 +107,7 @@ export const registerSshCertificateTemplateRouter = async (server: FastifyZodPro await server.services.auditLog.createAuditLog({ ...req.auditLogInfo, - orgId: ca.orgId, + projectId: ca.projectId, event: { type: EventType.CREATE_SSH_CERTIFICATE_TEMPLATE, metadata: { @@ -178,7 +178,7 @@ export const registerSshCertificateTemplateRouter = async (server: FastifyZodPro }, onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), handler: async (req) => { - const { certificateTemplate, orgId } = await server.services.sshCertificateTemplate.updateSshCertTemplate({ + const { certificateTemplate, projectId } = await server.services.sshCertificateTemplate.updateSshCertTemplate({ ...req.body, id: req.params.certificateTemplateId, actor: req.permission.type, @@ -189,7 +189,7 @@ export const registerSshCertificateTemplateRouter = async (server: FastifyZodPro await server.services.auditLog.createAuditLog({ ...req.auditLogInfo, - orgId, + projectId, event: { type: EventType.UPDATE_SSH_CERTIFICATE_TEMPLATE, metadata: { @@ -238,7 +238,7 @@ export const registerSshCertificateTemplateRouter = async (server: FastifyZodPro await server.services.auditLog.createAuditLog({ ...req.auditLogInfo, - orgId: certificateTemplate.orgId, + projectId: certificateTemplate.projectId, event: { type: EventType.DELETE_SSH_CERTIFICATE_TEMPLATE, metadata: { diff --git a/backend/src/ee/routes/v1/ssh-router.ts b/backend/src/ee/routes/v1/ssh-router.ts index 566cbb423..8a7226014 100644 --- a/backend/src/ee/routes/v1/ssh-router.ts +++ b/backend/src/ee/routes/v1/ssh-router.ts @@ -20,6 +20,7 @@ export const registerSshRouter = async (server: FastifyZodProvider) => { schema: { description: "Sign SSH public key", body: z.object({ + projectId: z.string().trim().describe(SSH_CERTIFICATE_AUTHORITIES.SIGN_SSH_KEY.projectId), templateName: z.string().trim().describe(SSH_CERTIFICATE_AUTHORITIES.SIGN_SSH_KEY.templateName), publicKey: z.string().trim().describe(SSH_CERTIFICATE_AUTHORITIES.SIGN_SSH_KEY.publicKey), certType: z @@ -86,6 +87,7 @@ export const registerSshRouter = async (server: FastifyZodProvider) => { schema: { description: "Issue SSH credentials (certificate + key)", body: z.object({ + projectId: z.string().trim().describe(SSH_CERTIFICATE_AUTHORITIES.ISSUE_SSH_CREDENTIALS.projectId), templateName: z.string().trim().describe(SSH_CERTIFICATE_AUTHORITIES.ISSUE_SSH_CREDENTIALS.templateName), keyAlgorithm: z .nativeEnum(CertKeyAlgorithm) diff --git a/backend/src/ee/services/permission/org-permission.ts b/backend/src/ee/services/permission/org-permission.ts index 89c4459d8..aac45b2d5 100644 --- a/backend/src/ee/services/permission/org-permission.ts +++ b/backend/src/ee/services/permission/org-permission.ts @@ -27,10 +27,7 @@ export enum OrgPermissionSubjects { Kms = "kms", AdminConsole = "organization-admin-console", AuditLogs = "audit-logs", - ProjectTemplates = "project-templates", - SshCertificates = "ssh-certificates", - SshCertificateAuthorities = "ssh-certificate-authorities", - SshCertificateTemplates = "ssh-certificate-templates" + ProjectTemplates = "project-templates" } export type OrgPermissionSet = @@ -49,10 +46,7 @@ export type OrgPermissionSet = | [OrgPermissionActions, OrgPermissionSubjects.Kms] | [OrgPermissionActions, OrgPermissionSubjects.AuditLogs] | [OrgPermissionActions, OrgPermissionSubjects.ProjectTemplates] - | [OrgPermissionAdminConsoleAction, OrgPermissionSubjects.AdminConsole] - | [OrgPermissionActions, OrgPermissionSubjects.SshCertificateAuthorities] - | [OrgPermissionActions, OrgPermissionSubjects.SshCertificates] - | [OrgPermissionActions, OrgPermissionSubjects.SshCertificateTemplates]; + | [OrgPermissionAdminConsoleAction, OrgPermissionSubjects.AdminConsole]; const buildAdminPermission = () => { const { can, rules } = new AbilityBuilder>(createMongoAbility); @@ -129,19 +123,6 @@ const buildAdminPermission = () => { can(OrgPermissionActions.Edit, OrgPermissionSubjects.ProjectTemplates); can(OrgPermissionActions.Delete, OrgPermissionSubjects.ProjectTemplates); - can(OrgPermissionActions.Read, OrgPermissionSubjects.SshCertificates); - can(OrgPermissionActions.Create, OrgPermissionSubjects.SshCertificates); - - 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; @@ -172,11 +153,6 @@ const buildMemberPermission = () => { can(OrgPermissionActions.Read, OrgPermissionSubjects.AuditLogs); - can(OrgPermissionActions.Read, OrgPermissionSubjects.SshCertificateAuthorities); - can(OrgPermissionActions.Read, OrgPermissionSubjects.SshCertificates); - can(OrgPermissionActions.Create, OrgPermissionSubjects.SshCertificates); - can(OrgPermissionActions.Read, OrgPermissionSubjects.SshCertificateTemplates); - return rules; }; diff --git a/backend/src/ee/services/permission/project-permission.ts b/backend/src/ee/services/permission/project-permission.ts index c6e574fb1..145ec0d7f 100644 --- a/backend/src/ee/services/permission/project-permission.ts +++ b/backend/src/ee/services/permission/project-permission.ts @@ -54,6 +54,9 @@ export enum ProjectPermissionSub { CertificateAuthorities = "certificate-authorities", Certificates = "certificates", CertificateTemplates = "certificate-templates", + SshCertificateAuthorities = "ssh-certificate-authorities", + SshCertificates = "ssh-certificates", + SshCertificateTemplates = "ssh-certificate-templates", PkiAlerts = "pki-alerts", PkiCollections = "pki-collections", Kms = "kms", @@ -125,6 +128,9 @@ export type ProjectPermissionSet = | [ProjectPermissionActions, ProjectPermissionSub.CertificateAuthorities] | [ProjectPermissionActions, ProjectPermissionSub.Certificates] | [ProjectPermissionActions, ProjectPermissionSub.CertificateTemplates] + | [ProjectPermissionActions, ProjectPermissionSub.SshCertificateAuthorities] + | [ProjectPermissionActions, ProjectPermissionSub.SshCertificates] + | [ProjectPermissionActions, ProjectPermissionSub.SshCertificateTemplates] | [ProjectPermissionActions, ProjectPermissionSub.PkiAlerts] | [ProjectPermissionActions, ProjectPermissionSub.PkiCollections] | [ProjectPermissionCmekActions, ProjectPermissionSub.Cmek] @@ -322,6 +328,28 @@ const GeneralPermissionSchema = [ "Describe what action an entity can take." ) }), + z.object({ + subject: z + .literal(ProjectPermissionSub.SshCertificateAuthorities) + .describe("The entity this permission pertains to."), + action: CASL_ACTION_SCHEMA_NATIVE_ENUM(ProjectPermissionActions).describe( + "Describe what action an entity can take." + ) + }), + z.object({ + subject: z.literal(ProjectPermissionSub.SshCertificates).describe("The entity this permission pertains to."), + action: CASL_ACTION_SCHEMA_NATIVE_ENUM(ProjectPermissionActions).describe( + "Describe what action an entity can take." + ) + }), + z.object({ + subject: z + .literal(ProjectPermissionSub.SshCertificateTemplates) + .describe("The entity this permission pertains to."), + action: CASL_ACTION_SCHEMA_NATIVE_ENUM(ProjectPermissionActions).describe( + "Describe what action an entity can take." + ) + }), z.object({ subject: z.literal(ProjectPermissionSub.PkiAlerts).describe("The entity this permission pertains to."), action: CASL_ACTION_SCHEMA_NATIVE_ENUM(ProjectPermissionActions).describe( @@ -448,7 +476,10 @@ const buildAdminPermissionRules = () => { ProjectPermissionSub.Certificates, ProjectPermissionSub.CertificateTemplates, ProjectPermissionSub.PkiAlerts, - ProjectPermissionSub.PkiCollections + ProjectPermissionSub.PkiCollections, + ProjectPermissionSub.SshCertificateAuthorities, + ProjectPermissionSub.SshCertificates, + ProjectPermissionSub.SshCertificateTemplates ].forEach((el) => { can( [ @@ -633,6 +664,11 @@ const buildMemberPermissionRules = () => { can([ProjectPermissionActions.Read], ProjectPermissionSub.PkiAlerts); can([ProjectPermissionActions.Read], ProjectPermissionSub.PkiCollections); + can([ProjectPermissionActions.Read], ProjectPermissionSub.SshCertificateAuthorities); + can([ProjectPermissionActions.Read], ProjectPermissionSub.SshCertificates); + can([ProjectPermissionActions.Create], ProjectPermissionSub.SshCertificates); + can([ProjectPermissionActions.Read], ProjectPermissionSub.SshCertificateTemplates); + can( [ ProjectPermissionCmekActions.Create, @@ -675,6 +711,9 @@ const buildViewerPermissionRules = () => { can(ProjectPermissionActions.Read, ProjectPermissionSub.CertificateAuthorities); can(ProjectPermissionActions.Read, ProjectPermissionSub.Certificates); can(ProjectPermissionCmekActions.Read, ProjectPermissionSub.Cmek); + can(ProjectPermissionActions.Read, ProjectPermissionSub.SshCertificateAuthorities); + can(ProjectPermissionActions.Read, ProjectPermissionSub.SshCertificates); + can(ProjectPermissionActions.Read, ProjectPermissionSub.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 index 62b6323ca..b8afa0df2 100644 --- 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 @@ -18,11 +18,11 @@ export const sshCertificateTemplateDALFactory = (db: TDbClient) => { `${TableName.SshCertificateAuthority}.id`, `${TableName.SshCertificateTemplate}.sshCaId` ) - .join(TableName.Organization, `${TableName.Organization}.id`, `${TableName.SshCertificateAuthority}.orgId`) + .join(TableName.Project, `${TableName.Project}.id`, `${TableName.SshCertificateAuthority}.projectId`) .where(`${TableName.SshCertificateTemplate}.id`, "=", id) .select(selectAllTableCols(TableName.SshCertificateTemplate)) .select( - db.ref("orgId").withSchema(TableName.SshCertificateAuthority), + db.ref("projectId").withSchema(TableName.SshCertificateAuthority), db.ref("friendlyName").as("caName").withSchema(TableName.SshCertificateAuthority), db.ref("status").as("caStatus").withSchema(TableName.SshCertificateAuthority) ) @@ -34,7 +34,10 @@ export const sshCertificateTemplateDALFactory = (db: TDbClient) => { } }; - const getByName = async (name: string, orgId: string, tx?: Knex) => { + /** + * Returns the SSH certificate template named [name] within project with id [projectId] + */ + const getByName = async (name: string, projectId: string, tx?: Knex) => { try { const certTemplate = await (tx || db.replicaNode())(TableName.SshCertificateTemplate) .join( @@ -42,12 +45,12 @@ export const sshCertificateTemplateDALFactory = (db: TDbClient) => { `${TableName.SshCertificateAuthority}.id`, `${TableName.SshCertificateTemplate}.sshCaId` ) - .join(TableName.Organization, `${TableName.Organization}.id`, `${TableName.SshCertificateAuthority}.orgId`) + .join(TableName.Project, `${TableName.Project}.id`, `${TableName.SshCertificateAuthority}.projectId`) .where(`${TableName.SshCertificateTemplate}.name`, "=", name) - .where(`${TableName.Organization}.id`, "=", orgId) + .where(`${TableName.Project}.id`, "=", projectId) .select(selectAllTableCols(TableName.SshCertificateTemplate)) .select( - db.ref("orgId").withSchema(TableName.SshCertificateAuthority), + db.ref("projectId").withSchema(TableName.SshCertificateAuthority), db.ref("friendlyName").as("caName").withSchema(TableName.SshCertificateAuthority), db.ref("status").as("caStatus").withSchema(TableName.SshCertificateAuthority) ) 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 index a415f42b8..e68eb991a 100644 --- 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 @@ -1,8 +1,8 @@ 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 { ProjectPermissionActions, ProjectPermissionSub } from "@app/ee/services/permission/project-permission"; import { BadRequestError, NotFoundError } from "@app/lib/errors"; import { TSshCertificateAuthorityDALFactory } from "../ssh/ssh-certificate-authority-dal"; @@ -21,7 +21,7 @@ type TSshCertificateTemplateServiceFactoryDep = { "transaction" | "getByName" | "create" | "updateById" | "deleteById" | "getById" >; sshCertificateAuthorityDAL: Pick; - permissionService: Pick; + permissionService: Pick; }; export type TSshCertificateTemplateServiceFactory = ReturnType; @@ -53,17 +53,17 @@ export const sshCertificateTemplateServiceFactory = ({ }); } - const { permission } = await permissionService.getOrgPermission( + const { permission } = await permissionService.getProjectPermission( actor, actorId, - ca.orgId, + ca.projectId, actorAuthMethod, actorOrgId ); ForbiddenError.from(permission).throwUnlessCan( - OrgPermissionActions.Create, - OrgPermissionSubjects.SshCertificateTemplates + ProjectPermissionActions.Create, + ProjectPermissionSub.SshCertificateTemplates ); if (ms(ttl) > ms(maxTTL)) { @@ -73,7 +73,7 @@ export const sshCertificateTemplateServiceFactory = ({ } const newCertificateTemplate = await sshCertificateTemplateDAL.transaction(async (tx) => { - const existingTemplate = await sshCertificateTemplateDAL.getByName(name, ca.orgId, tx); + const existingTemplate = await sshCertificateTemplateDAL.getByName(name, ca.projectId, tx); if (existingTemplate) { throw new BadRequestError({ message: `SSH certificate template with name ${name} already exists` @@ -125,22 +125,22 @@ export const sshCertificateTemplateServiceFactory = ({ }); } - const { permission } = await permissionService.getOrgPermission( + const { permission } = await permissionService.getProjectPermission( actor, actorId, - certTemplate.orgId, + certTemplate.projectId, actorAuthMethod, actorOrgId ); ForbiddenError.from(permission).throwUnlessCan( - OrgPermissionActions.Edit, - OrgPermissionSubjects.SshCertificateTemplates + ProjectPermissionActions.Edit, + ProjectPermissionSub.SshCertificateTemplates ); const updatedCertificateTemplate = await sshCertificateTemplateDAL.transaction(async (tx) => { if (name) { - const existingTemplate = await sshCertificateTemplateDAL.getByName(name, actorOrgId, tx); + const existingTemplate = await sshCertificateTemplateDAL.getByName(name, certTemplate.projectId, tx); if (existingTemplate && existingTemplate.id !== id) { throw new BadRequestError({ message: `SSH certificate template with name ${name} already exists` @@ -175,7 +175,7 @@ export const sshCertificateTemplateServiceFactory = ({ return { certificateTemplate: updatedCertificateTemplate, - orgId: certTemplate.orgId + projectId: certTemplate.projectId }; }; @@ -193,17 +193,17 @@ export const sshCertificateTemplateServiceFactory = ({ }); } - const { permission } = await permissionService.getOrgPermission( + const { permission } = await permissionService.getProjectPermission( actor, actorId, - certificateTemplate.orgId, + certificateTemplate.projectId, actorAuthMethod, actorOrgId ); ForbiddenError.from(permission).throwUnlessCan( - OrgPermissionActions.Delete, - OrgPermissionSubjects.SshCertificateTemplates + ProjectPermissionActions.Delete, + ProjectPermissionSub.SshCertificateTemplates ); await sshCertificateTemplateDAL.deleteById(certificateTemplate.id); @@ -219,17 +219,17 @@ export const sshCertificateTemplateServiceFactory = ({ }); } - const { permission } = await permissionService.getOrgPermission( + const { permission } = await permissionService.getProjectPermission( actor, actorId, - certTemplate.orgId, + certTemplate.projectId, actorAuthMethod, actorOrgId ); ForbiddenError.from(permission).throwUnlessCan( - OrgPermissionActions.Read, - OrgPermissionSubjects.SshCertificateTemplates + ProjectPermissionActions.Read, + ProjectPermissionSub.SshCertificateTemplates ); return certTemplate; diff --git a/backend/src/ee/services/ssh-certificate/ssh-certificate-body-dal.ts b/backend/src/ee/services/ssh-certificate/ssh-certificate-body-dal.ts new file mode 100644 index 000000000..c3d16a39e --- /dev/null +++ b/backend/src/ee/services/ssh-certificate/ssh-certificate-body-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 TSshCertificateBodyDALFactory = ReturnType; + +export const sshCertificateBodyDALFactory = (db: TDbClient) => { + const sshCertificateBodyOrm = ormify(db, TableName.SshCertificateBody); + return sshCertificateBodyOrm; +}; diff --git a/backend/src/ee/services/ssh-certificate/ssh-certificate-dal.ts b/backend/src/ee/services/ssh-certificate/ssh-certificate-dal.ts index 95cc4766e..9c5bd1d3e 100644 --- a/backend/src/ee/services/ssh-certificate/ssh-certificate-dal.ts +++ b/backend/src/ee/services/ssh-certificate/ssh-certificate-dal.ts @@ -8,7 +8,7 @@ export type TSshCertificateDALFactory = ReturnType { const sshCertificateOrm = ormify(db, TableName.SshCertificate); - const countSshCertificatesInOrg = async (orgId: string) => { + const countSshCertificatesInProject = async (projectId: string) => { try { interface CountResult { count: string; @@ -21,18 +21,18 @@ export const sshCertificateDALFactory = (db: TDbClient) => { `${TableName.SshCertificate}.sshCaId`, `${TableName.SshCertificateAuthority}.id` ) - .join(TableName.Organization, `${TableName.SshCertificateAuthority}.orgId`, `${TableName.Organization}.id`) - .where(`${TableName.Organization}.id`, orgId); + .join(TableName.Project, `${TableName.SshCertificateAuthority}.projectId`, `${TableName.Project}.id`) + .where(`${TableName.Project}.id`, projectId); const count = await query.count("*").first(); return parseInt((count as unknown as CountResult).count || "0", 10); } catch (error) { - throw new DatabaseError({ error, name: "Count all SSH certificates in organization" }); + throw new DatabaseError({ error, name: "Count all SSH certificates in project" }); } }; return { ...sshCertificateOrm, - countSshCertificatesInOrg + countSshCertificatesInProject }; }; diff --git a/backend/src/ee/services/ssh/ssh-certificate-authority-schema.ts b/backend/src/ee/services/ssh/ssh-certificate-authority-schema.ts index 82561dda6..9ff76efbc 100644 --- a/backend/src/ee/services/ssh/ssh-certificate-authority-schema.ts +++ b/backend/src/ee/services/ssh/ssh-certificate-authority-schema.ts @@ -2,7 +2,7 @@ import { SshCertificateAuthoritiesSchema } from "@app/db/schemas"; export const sanitizedSshCa = SshCertificateAuthoritiesSchema.pick({ id: true, - orgId: true, + projectId: true, friendlyName: true, status: true, keyAlgorithm: true diff --git a/backend/src/ee/services/ssh/ssh-certificate-authority-service.ts b/backend/src/ee/services/ssh/ssh-certificate-authority-service.ts index 18b3c712d..2844407b2 100644 --- a/backend/src/ee/services/ssh/ssh-certificate-authority-service.ts +++ b/backend/src/ee/services/ssh/ssh-certificate-authority-service.ts @@ -1,13 +1,15 @@ 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 { ProjectPermissionActions, ProjectPermissionSub } from "@app/ee/services/permission/project-permission"; import { TSshCertificateAuthorityDALFactory } from "@app/ee/services/ssh/ssh-certificate-authority-dal"; import { TSshCertificateAuthoritySecretDALFactory } from "@app/ee/services/ssh/ssh-certificate-authority-secret-dal"; +import { TSshCertificateBodyDALFactory } from "@app/ee/services/ssh-certificate/ssh-certificate-body-dal"; import { TSshCertificateDALFactory } from "@app/ee/services/ssh-certificate/ssh-certificate-dal"; import { TSshCertificateTemplateDALFactory } from "@app/ee/services/ssh-certificate-template/ssh-certificate-template-dal"; import { BadRequestError, NotFoundError } from "@app/lib/errors"; import { TKmsServiceFactory } from "@app/services/kms/kms-service"; +import { KmsDataKey } from "@app/services/kms/kms-types"; import { SshCertTemplateStatus } from "../ssh-certificate-template/ssh-certificate-template-types"; import { @@ -37,18 +39,25 @@ type TSshCertificateAuthorityServiceFactoryDep = { >; sshCertificateAuthoritySecretDAL: Pick; sshCertificateTemplateDAL: Pick; - sshCertificateDAL: Pick; - kmsService: Pick; - permissionService: Pick; + sshCertificateDAL: Pick; + sshCertificateBodyDAL: Pick; + kmsService: Pick< + TKmsServiceFactory, + "generateKmsKey" | "encryptWithKmsKey" | "decryptWithKmsKey" | "getOrgKmsKeyId" | "createCipherPairWithDataKey" + >; + permissionService: Pick; }; export type TSshCertificateAuthorityServiceFactory = ReturnType; +// TODO: secretManagerEncryptor -> sshEncryptor (cc akhil) + export const sshCertificateAuthorityServiceFactory = ({ sshCertificateAuthorityDAL, sshCertificateAuthoritySecretDAL, sshCertificateTemplateDAL, sshCertificateDAL, + sshCertificateBodyDAL, kmsService, permissionService }: TSshCertificateAuthorityServiceFactoryDep) => { @@ -56,6 +65,7 @@ export const sshCertificateAuthorityServiceFactory = ({ * Generates a new SSH CA */ const createSshCa = async ({ + projectId, friendlyName, keyAlgorithm, actorId, @@ -63,23 +73,23 @@ export const sshCertificateAuthorityServiceFactory = ({ actor, actorOrgId }: TCreateSshCaDTO) => { - const { permission } = await permissionService.getOrgPermission( + const { permission } = await permissionService.getProjectPermission( actor, actorId, - actorOrgId, + projectId, actorAuthMethod, actorOrgId ); ForbiddenError.from(permission).throwUnlessCan( - OrgPermissionActions.Create, - OrgPermissionSubjects.SshCertificateAuthorities + ProjectPermissionActions.Create, + ProjectPermissionSub.SshCertificateAuthorities ); const newCa = await sshCertificateAuthorityDAL.transaction(async (tx) => { const ca = await sshCertificateAuthorityDAL.create( { - orgId: actorOrgId, + projectId, friendlyName, status: SshCaStatus.ACTIVE, keyAlgorithm @@ -89,19 +99,16 @@ export const sshCertificateAuthorityServiceFactory = ({ const { publicKey, 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") + // TODO: update to sshEncryptor + const { encryptor: secretManagerEncryptor } = await kmsService.createCipherPairWithDataKey({ + type: KmsDataKey.SecretManager, + projectId }); await sshCertificateAuthoritySecretDAL.create( { sshCaId: ca.id, - encryptedPrivateKey + encryptedPrivateKey: secretManagerEncryptor({ plainText: Buffer.from(privateKey, "utf8") }).cipherTextBlob }, tx ); @@ -119,28 +126,28 @@ export const sshCertificateAuthorityServiceFactory = ({ const ca = await sshCertificateAuthorityDAL.findById(caId); if (!ca) throw new NotFoundError({ message: `SSH CA with ID '${caId}' not found` }); - const { permission } = await permissionService.getOrgPermission( + const { permission } = await permissionService.getProjectPermission( actor, actorId, - ca.orgId, + ca.projectId, actorAuthMethod, actorOrgId ); ForbiddenError.from(permission).throwUnlessCan( - OrgPermissionActions.Read, - OrgPermissionSubjects.SshCertificateAuthorities + ProjectPermissionActions.Read, + ProjectPermissionSub.SshCertificateAuthorities ); const sshCaSecret = await sshCertificateAuthoritySecretDAL.findOne({ sshCaId: ca.id }); - // decrypt secret - const orgKmsKeyId = await kmsService.getOrgKmsKeyId(actorOrgId); - const kmsDecryptor = await kmsService.decryptWithKmsKey({ - kmsId: orgKmsKeyId + // TODO: update to sshDecryptor + const { decryptor: secretManagerDecryptor } = await kmsService.createCipherPairWithDataKey({ + type: KmsDataKey.SecretManager, + projectId: ca.projectId }); - const decryptedCaPrivateKey = await kmsDecryptor({ + const decryptedCaPrivateKey = secretManagerDecryptor({ cipherTextBlob: sshCaSecret.encryptedPrivateKey }); @@ -158,13 +165,13 @@ export const sshCertificateAuthorityServiceFactory = ({ const sshCaSecret = await sshCertificateAuthoritySecretDAL.findOne({ sshCaId: ca.id }); - // decrypt secret - const orgKmsKeyId = await kmsService.getOrgKmsKeyId(ca.orgId); - const kmsDecryptor = await kmsService.decryptWithKmsKey({ - kmsId: orgKmsKeyId + // TODO: update to sshDecryptor + const { decryptor: secretManagerDecryptor } = await kmsService.createCipherPairWithDataKey({ + type: KmsDataKey.SecretManager, + projectId: ca.projectId }); - const decryptedCaPrivateKey = await kmsDecryptor({ + const decryptedCaPrivateKey = secretManagerDecryptor({ cipherTextBlob: sshCaSecret.encryptedPrivateKey }); @@ -189,30 +196,30 @@ export const sshCertificateAuthorityServiceFactory = ({ const ca = await sshCertificateAuthorityDAL.findById(caId); if (!ca) throw new NotFoundError({ message: `SSH CA with ID '${caId}' not found` }); - const { permission } = await permissionService.getOrgPermission( + const { permission } = await permissionService.getProjectPermission( actor, actorId, - ca.orgId, + ca.projectId, actorAuthMethod, actorOrgId ); ForbiddenError.from(permission).throwUnlessCan( - OrgPermissionActions.Edit, - OrgPermissionSubjects.SshCertificateAuthorities + ProjectPermissionActions.Edit, + ProjectPermissionSub.SshCertificateAuthorities ); const updatedCa = await sshCertificateAuthorityDAL.updateById(caId, { friendlyName, status }); const sshCaSecret = await sshCertificateAuthoritySecretDAL.findOne({ sshCaId: ca.id }); - // decrypt secret - const orgKmsKeyId = await kmsService.getOrgKmsKeyId(actorOrgId); - const kmsDecryptor = await kmsService.decryptWithKmsKey({ - kmsId: orgKmsKeyId + // TODO: update to sshDecryptor + const { decryptor: secretManagerDecryptor } = await kmsService.createCipherPairWithDataKey({ + type: KmsDataKey.SecretManager, + projectId: ca.projectId }); - const decryptedCaPrivateKey = await kmsDecryptor({ + const decryptedCaPrivateKey = secretManagerDecryptor({ cipherTextBlob: sshCaSecret.encryptedPrivateKey }); @@ -228,17 +235,17 @@ export const sshCertificateAuthorityServiceFactory = ({ const ca = await sshCertificateAuthorityDAL.findById(caId); if (!ca) throw new NotFoundError({ message: `SSH CA with ID '${caId}' not found` }); - const { permission } = await permissionService.getOrgPermission( + const { permission } = await permissionService.getProjectPermission( actor, actorId, - ca.orgId, + ca.projectId, actorAuthMethod, actorOrgId ); ForbiddenError.from(permission).throwUnlessCan( - OrgPermissionActions.Delete, - OrgPermissionSubjects.SshCertificateAuthorities + ProjectPermissionActions.Delete, + ProjectPermissionSub.SshCertificateAuthorities ); const deletedCa = await sshCertificateAuthorityDAL.deleteById(caId); @@ -251,6 +258,7 @@ export const sshCertificateAuthorityServiceFactory = ({ * SSH public key is signed using CA behind SSH certificate with name [templateName]. */ const issueSshCreds = async ({ + projectId, templateName, keyAlgorithm, certType, @@ -262,22 +270,25 @@ export const sshCertificateAuthorityServiceFactory = ({ actorAuthMethod, actorOrgId }: TIssueSshCredsDTO) => { - const sshCertificateTemplate = await sshCertificateTemplateDAL.getByName(templateName, actorOrgId); + const sshCertificateTemplate = await sshCertificateTemplateDAL.getByName(templateName, projectId); if (!sshCertificateTemplate) { throw new NotFoundError({ message: "No SSH certificate template found with specified name" }); } - const { permission } = await permissionService.getOrgPermission( + const { permission } = await permissionService.getProjectPermission( actor, actorId, - actorOrgId, + sshCertificateTemplate.projectId, actorAuthMethod, actorOrgId ); - ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Create, OrgPermissionSubjects.SshCertificates); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionActions.Create, + ProjectPermissionSub.SshCertificates + ); if (sshCertificateTemplate.caStatus === SshCaStatus.DISABLED) { throw new BadRequestError({ @@ -307,13 +318,13 @@ export const sshCertificateAuthorityServiceFactory = ({ const sshCaSecret = await sshCertificateAuthoritySecretDAL.findOne({ sshCaId: sshCertificateTemplate.sshCaId }); - // decrypt secret - const orgKmsKeyId = await kmsService.getOrgKmsKeyId(actorOrgId); - const kmsDecryptor = await kmsService.decryptWithKmsKey({ - kmsId: orgKmsKeyId + // TODO: update to sshDecryptor + const { decryptor: secretManagerDecryptor } = await kmsService.createCipherPairWithDataKey({ + type: KmsDataKey.SecretManager, + projectId }); - const decryptedCaPrivateKey = await kmsDecryptor({ + const decryptedCaPrivateKey = secretManagerDecryptor({ cipherTextBlob: sshCaSecret.encryptedPrivateKey }); @@ -329,16 +340,38 @@ export const sshCertificateAuthorityServiceFactory = ({ certType }); - await sshCertificateDAL.create({ - sshCaId: sshCertificateTemplate.sshCaId, - sshCertificateTemplateId: sshCertificateTemplate.id, - serialNumber, - certType, - publicKey, - principals, - keyId, - notBefore: new Date(), - notAfter: new Date(Date.now() + ttl * 1000) + // TODO: update to sshEncryptor + const { encryptor: secretManagerEncryptor } = await kmsService.createCipherPairWithDataKey({ + type: KmsDataKey.SecretManager, + projectId: sshCertificateTemplate.projectId + }); + + const encryptedCertificate = secretManagerEncryptor({ + plainText: Buffer.from(signedPublicKey, "utf8") + }).cipherTextBlob; + + await sshCertificateDAL.transaction(async (tx) => { + const cert = await sshCertificateDAL.create( + { + sshCaId: sshCertificateTemplate.sshCaId, + sshCertificateTemplateId: sshCertificateTemplate.id, + serialNumber, + certType, + principals, + keyId, + notBefore: new Date(), + notAfter: new Date(Date.now() + ttl * 1000) + }, + tx + ); + + await sshCertificateBodyDAL.create( + { + sshCertId: cert.id, + encryptedCertificate + }, + tx + ); }); return { @@ -357,6 +390,7 @@ export const sshCertificateAuthorityServiceFactory = ({ * using CA behind SSH certificate template with name [templateName] */ const signSshKey = async ({ + projectId, templateName, publicKey, certType, @@ -368,22 +402,25 @@ export const sshCertificateAuthorityServiceFactory = ({ actorAuthMethod, actorOrgId }: TSignSshKeyDTO) => { - const sshCertificateTemplate = await sshCertificateTemplateDAL.getByName(templateName, actorOrgId); + const sshCertificateTemplate = await sshCertificateTemplateDAL.getByName(templateName, projectId); if (!sshCertificateTemplate) { throw new NotFoundError({ message: "No SSH certificate template found with specified name" }); } - const { permission } = await permissionService.getOrgPermission( + const { permission } = await permissionService.getProjectPermission( actor, actorId, - actorOrgId, + sshCertificateTemplate.projectId, actorAuthMethod, actorOrgId ); - ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Create, OrgPermissionSubjects.SshCertificates); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionActions.Create, + ProjectPermissionSub.SshCertificates + ); if (sshCertificateTemplate.caStatus === SshCaStatus.DISABLED) { throw new BadRequestError({ @@ -413,13 +450,13 @@ export const sshCertificateAuthorityServiceFactory = ({ const sshCaSecret = await sshCertificateAuthoritySecretDAL.findOne({ sshCaId: sshCertificateTemplate.sshCaId }); - // decrypt secret - const orgKmsKeyId = await kmsService.getOrgKmsKeyId(actorOrgId); - const kmsDecryptor = await kmsService.decryptWithKmsKey({ - kmsId: orgKmsKeyId + // TODO: update to sshDecryptor + const { decryptor: secretManagerDecryptor } = await kmsService.createCipherPairWithDataKey({ + type: KmsDataKey.SecretManager, + projectId }); - const decryptedCaPrivateKey = await kmsDecryptor({ + const decryptedCaPrivateKey = secretManagerDecryptor({ cipherTextBlob: sshCaSecret.encryptedPrivateKey }); @@ -432,16 +469,38 @@ export const sshCertificateAuthorityServiceFactory = ({ certType }); - await sshCertificateDAL.create({ - sshCaId: sshCertificateTemplate.sshCaId, - sshCertificateTemplateId: sshCertificateTemplate.id, - serialNumber, - certType, - publicKey, - principals, - keyId, - notBefore: new Date(), - notAfter: new Date(Date.now() + ttl * 1000) + // TODO: update to sshEncryptor + const { encryptor: secretManagerEncryptor } = await kmsService.createCipherPairWithDataKey({ + type: KmsDataKey.SecretManager, + projectId: sshCertificateTemplate.projectId + }); + + const encryptedCertificate = secretManagerEncryptor({ + plainText: Buffer.from(signedPublicKey, "utf8") + }).cipherTextBlob; + + await sshCertificateDAL.transaction(async (tx) => { + const cert = await sshCertificateDAL.create( + { + sshCaId: sshCertificateTemplate.sshCaId, + sshCertificateTemplateId: sshCertificateTemplate.id, + serialNumber, + certType, + principals, + keyId, + notBefore: new Date(), + notAfter: new Date(Date.now() + ttl * 1000) + }, + tx + ); + + await sshCertificateBodyDAL.create( + { + sshCertId: cert.id, + encryptedCertificate + }, + tx + ); }); return { serialNumber, signedPublicKey, certificateTemplate: sshCertificateTemplate, ttl, keyId }; @@ -457,17 +516,17 @@ export const sshCertificateAuthorityServiceFactory = ({ const ca = await sshCertificateAuthorityDAL.findById(caId); if (!ca) throw new NotFoundError({ message: `SSH CA with ID '${caId}' not found` }); - const { permission } = await permissionService.getOrgPermission( + const { permission } = await permissionService.getProjectPermission( actor, actorId, - actorOrgId, + ca.projectId, actorAuthMethod, actorOrgId ); ForbiddenError.from(permission).throwUnlessCan( - OrgPermissionActions.Read, - OrgPermissionSubjects.SshCertificateTemplates + ProjectPermissionActions.Read, + ProjectPermissionSub.SshCertificateTemplates ); const certificateTemplates = await sshCertificateTemplateDAL.find({ sshCaId: caId }); diff --git a/backend/src/ee/services/ssh/ssh-certificate-authority-types.ts b/backend/src/ee/services/ssh/ssh-certificate-authority-types.ts index 7d949bfa0..0f94f5037 100644 --- a/backend/src/ee/services/ssh/ssh-certificate-authority-types.ts +++ b/backend/src/ee/services/ssh/ssh-certificate-authority-types.ts @@ -1,4 +1,4 @@ -import { TOrgPermission } from "@app/lib/types"; +import { TProjectPermission } from "@app/lib/types"; import { CertKeyAlgorithm } from "@app/services/certificate/certificate-types"; export enum SshCaStatus { @@ -14,11 +14,11 @@ export enum SshCertType { export type TCreateSshCaDTO = { friendlyName: string; keyAlgorithm: CertKeyAlgorithm; -} & Omit; +} & TProjectPermission; export type TGetSshCaDTO = { caId: string; -} & Omit; +} & Omit; export type TGetSshCaPublicKeyDTO = { caId: string; @@ -28,11 +28,11 @@ export type TUpdateSshCaDTO = { caId: string; friendlyName?: string; status?: SshCaStatus; -} & Omit; +} & Omit; export type TDeleteSshCaDTO = { caId: string; -} & Omit; +} & Omit; export type TIssueSshCredsDTO = { templateName: string; @@ -41,7 +41,7 @@ export type TIssueSshCredsDTO = { principals: string[]; ttl?: string; keyId?: string; -} & Omit; +} & TProjectPermission; export type TSignSshKeyDTO = { templateName: string; @@ -50,11 +50,11 @@ export type TSignSshKeyDTO = { principals: string[]; ttl?: string; keyId?: string; -} & Omit; +} & TProjectPermission; export type TGetSshCaCertificateTemplatesDTO = { caId: string; -} & Omit; +} & Omit; export type TCreateSshCertDTO = { caPrivateKey: string; diff --git a/backend/src/lib/api-docs/constants.ts b/backend/src/lib/api-docs/constants.ts index 500611d6f..c4902dc27 100644 --- a/backend/src/lib/api-docs/constants.ts +++ b/backend/src/lib/api-docs/constants.ts @@ -384,17 +384,6 @@ 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." - }, - LIST_SSH_CERTIFICATES: { - organizationId: "The ID of the organization to list SSH certificates for.", - offset: "The offset to start from. If you enter 10, it will start from the 10th SSH certificate.", - limit: "The number of SSH certificates to return." - }, - LIST_SSH_CERTIFICATE_TEMPLATES: { - organizationId: "The ID of the organization to list SSH certificate templates for." } } as const; @@ -455,7 +444,15 @@ export const PROJECTS = { 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." + projectId: "The ID of the project to list SSH CAs for." + }, + LIST_SSH_CERTIFICATES: { + projectId: "The ID of the project to list SSH certificates for.", + offset: "The offset to start from. If you enter 10, it will start from the 10th SSH certificate.", + limit: "The number of SSH certificates to return." + }, + LIST_SSH_CERTIFICATE_TEMPLATES: { + projectId: "The ID of the project to list SSH certificate templates for." }, LIST_CAS: { slug: "The slug of the project to list CAs for.", @@ -1148,6 +1145,7 @@ export const AUDIT_LOG_STREAMS = { export const SSH_CERTIFICATE_AUTHORITIES = { CREATE: { + projectId: "The ID of the project to create the SSH CA in.", 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." }, @@ -1169,6 +1167,7 @@ export const SSH_CERTIFICATE_AUTHORITIES = { sshCaId: "The ID of the SSH CA to get the certificate templates for." }, SIGN_SSH_KEY: { + projectId: "The ID of the project to sign the SSH public key for.", templateName: "The name of the SSH certificate template to sign the SSH public key with.", publicKey: "The SSH public key to sign.", certType: "The type of certificate to issue. This can be one of user or host.", @@ -1179,6 +1178,7 @@ export const SSH_CERTIFICATE_AUTHORITIES = { signedKey: "The SSH certificate or signed SSH public key." }, ISSUE_SSH_CREDENTIALS: { + projectId: "The ID of the project to issue the SSH credentials for.", templateName: "The name of the SSH certificate template to issue the SSH credentials with.", keyAlgorithm: "The type of public key algorithm and size, in bits, of the key pair for the SSH CA.", certType: "The type of certificate to issue. This can be one of user or host.", diff --git a/backend/src/server/routes/index.ts b/backend/src/server/routes/index.ts index 02ba4eba2..709d47abd 100644 --- a/backend/src/server/routes/index.ts +++ b/backend/src/server/routes/index.ts @@ -78,6 +78,7 @@ import { snapshotSecretV2DALFactory } from "@app/ee/services/secret-snapshot/sna 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 { sshCertificateBodyDALFactory } from "@app/ee/services/ssh-certificate/ssh-certificate-body-dal"; import { sshCertificateDALFactory } from "@app/ee/services/ssh-certificate/ssh-certificate-dal"; 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"; @@ -349,6 +350,7 @@ export const registerRoutes = async ( const dynamicSecretLeaseDAL = dynamicSecretLeaseDALFactory(db); const sshCertificateDAL = sshCertificateDALFactory(db); + const sshCertificateBodyDAL = sshCertificateBodyDALFactory(db); const sshCertificateAuthorityDAL = sshCertificateAuthorityDALFactory(db); const sshCertificateAuthoritySecretDAL = sshCertificateAuthoritySecretDALFactory(db); const sshCertificateTemplateDAL = sshCertificateTemplateDALFactory(db); @@ -565,10 +567,7 @@ export const registerRoutes = async ( groupDAL, orgBotDAL, oidcConfigDAL, - projectBotService, - sshCertificateAuthorityDAL, - sshCertificateDAL, - sshCertificateTemplateDAL + projectBotService }); const signupService = authSignupServiceFactory({ tokenService, @@ -721,6 +720,7 @@ export const registerRoutes = async ( sshCertificateAuthoritySecretDAL, sshCertificateTemplateDAL, sshCertificateDAL, + sshCertificateBodyDAL, kmsService, permissionService }); @@ -817,6 +817,9 @@ export const registerRoutes = async ( certificateDAL, pkiAlertDAL, pkiCollectionDAL, + sshCertificateAuthorityDAL, + sshCertificateDAL, + sshCertificateTemplateDAL, projectUserMembershipRoleDAL, identityProjectMembershipRoleDAL, keyStore, diff --git a/backend/src/server/routes/v1/organization-router.ts b/backend/src/server/routes/v1/organization-router.ts index c249bf2e5..07f795779 100644 --- a/backend/src/server/routes/v1/organization-router.ts +++ b/backend/src/server/routes/v1/organization-router.ts @@ -11,9 +11,6 @@ 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 { sanitizedSshCertificate } from "@app/ee/services/ssh-certificate/ssh-certificate-schema"; -import { sanitizedSshCertificateTemplate } from "@app/ee/services/ssh-certificate-template/ssh-certificate-template-schema"; import { AUDIT_LOGS, ORGANIZATIONS } from "@app/lib/api-docs"; import { getLastMidnightDateISO } from "@app/lib/fn"; import { readLimit, writeLimit } from "@app/server/config/rateLimiter"; @@ -407,101 +404,4 @@ export const registerOrgRouter = async (server: FastifyZodProvider) => { return { groups }; } }); - - server.route({ - method: "GET", - url: "/:organizationId/ssh-certificates", - config: { - rateLimit: readLimit - }, - schema: { - params: z.object({ - organizationId: z.string().trim().describe(ORGANIZATIONS.LIST_SSH_CAS.organizationId) - }), - querystring: z.object({ - offset: z.coerce.number().default(0).describe(ORGANIZATIONS.LIST_SSH_CERTIFICATES.offset), - limit: z.coerce.number().default(25).describe(ORGANIZATIONS.LIST_SSH_CERTIFICATES.limit) - }), - response: { - 200: z.object({ - certificates: z.array(sanitizedSshCertificate), - totalCount: z.number() - }) - } - }, - onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), - handler: async (req) => { - const { certificates, totalCount } = await server.services.org.listOrgSshCertificates({ - actorId: req.permission.id, - actorOrgId: req.permission.orgId, - actorAuthMethod: req.permission.authMethod, - actor: req.permission.type, - orgId: req.params.organizationId, - offset: req.query.offset, - limit: req.query.limit - }); - - return { certificates, totalCount }; - } - }); - - server.route({ - method: "GET", - url: "/:organizationId/ssh-certificate-templates", - config: { - rateLimit: readLimit - }, - schema: { - params: z.object({ - organizationId: z.string().trim().describe(ORGANIZATIONS.LIST_SSH_CERTIFICATE_TEMPLATES.organizationId) - }), - response: { - 200: z.object({ - certificateTemplates: z.array(sanitizedSshCertificateTemplate) - }) - } - }, - onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), - handler: async (req) => { - const { certificateTemplates } = await server.services.org.listOrgSshCertificateTemplates({ - actorId: req.permission.id, - actorOrgId: req.permission.orgId, - actorAuthMethod: req.permission.authMethod, - actor: req.permission.type, - orgId: req.params.organizationId - }); - - return { certificateTemplates }; - } - }); - - 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/server/routes/v2/project-router.ts b/backend/src/server/routes/v2/project-router.ts index 0e271eb0e..2ab3621ed 100644 --- a/backend/src/server/routes/v2/project-router.ts +++ b/backend/src/server/routes/v2/project-router.ts @@ -10,6 +10,9 @@ import { } from "@app/db/schemas"; import { EventType } from "@app/ee/services/audit-log/audit-log-types"; import { InfisicalProjectTemplate } from "@app/ee/services/project-template/project-template-types"; +import { sanitizedSshCa } from "@app/ee/services/ssh/ssh-certificate-authority-schema"; +import { sanitizedSshCertificate } from "@app/ee/services/ssh-certificate/ssh-certificate-schema"; +import { sanitizedSshCertificateTemplate } from "@app/ee/services/ssh-certificate-template/ssh-certificate-template-schema"; import { PROJECTS } from "@app/lib/api-docs"; import { readLimit, writeLimit } from "@app/server/config/rateLimiter"; import { getTelemetryDistinctId } from "@app/server/lib/telemetry"; @@ -517,4 +520,101 @@ export const registerProjectRouter = async (server: FastifyZodProvider) => { return { certificateTemplates }; } }); + + server.route({ + method: "GET", + url: "/:projectId/ssh-certificates", + config: { + rateLimit: readLimit + }, + schema: { + params: z.object({ + projectId: z.string().trim().describe(PROJECTS.LIST_SSH_CAS.projectId) + }), + querystring: z.object({ + offset: z.coerce.number().default(0).describe(PROJECTS.LIST_SSH_CERTIFICATES.offset), + limit: z.coerce.number().default(25).describe(PROJECTS.LIST_SSH_CERTIFICATES.limit) + }), + response: { + 200: z.object({ + certificates: z.array(sanitizedSshCertificate), + totalCount: z.number() + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const { certificates, totalCount } = await server.services.project.listProjectSshCertificates({ + actorId: req.permission.id, + actorOrgId: req.permission.orgId, + actorAuthMethod: req.permission.authMethod, + actor: req.permission.type, + projectId: req.params.projectId, + offset: req.query.offset, + limit: req.query.limit + }); + + return { certificates, totalCount }; + } + }); + + server.route({ + method: "GET", + url: "/:projectId/ssh-certificate-templates", + config: { + rateLimit: readLimit + }, + schema: { + params: z.object({ + projectId: z.string().trim().describe(PROJECTS.LIST_SSH_CERTIFICATE_TEMPLATES.projectId) + }), + response: { + 200: z.object({ + certificateTemplates: z.array(sanitizedSshCertificateTemplate) + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const { certificateTemplates } = await server.services.project.listProjectSshCertificateTemplates({ + actorId: req.permission.id, + actorOrgId: req.permission.orgId, + actorAuthMethod: req.permission.authMethod, + actor: req.permission.type, + projectId: req.params.projectId + }); + + return { certificateTemplates }; + } + }); + + server.route({ + method: "GET", + url: "/:projectId/ssh-cas", + config: { + rateLimit: readLimit + }, + schema: { + params: z.object({ + projectId: z.string().trim().describe(PROJECTS.LIST_SSH_CAS.projectId) + }), + response: { + 200: z.object({ + cas: z.array(sanitizedSshCa) + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const cas = await server.services.project.listProjectSshCas({ + actorId: req.permission.id, + actorOrgId: req.permission.orgId, + actorAuthMethod: req.permission.authMethod, + actor: req.permission.type, + projectId: req.params.projectId + }); + + return { cas }; + } + }); }; diff --git a/backend/src/services/org/org-service.ts b/backend/src/services/org/org-service.ts index 0871b9766..9741220f8 100644 --- a/backend/src/services/org/org-service.ts +++ b/backend/src/services/org/org-service.ts @@ -24,9 +24,6 @@ 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 { TSshCertificateDALFactory } from "@app/ee/services/ssh-certificate/ssh-certificate-dal"; -import { TSshCertificateTemplateDALFactory } from "@app/ee/services/ssh-certificate-template/ssh-certificate-template-dal"; import { getConfig } from "@app/lib/config/env"; import { generateAsymmetricKeyPair } from "@app/lib/crypto"; import { generateSymmetricKey, infisicalSymmetricDecrypt, infisicalSymmetricEncypt } from "@app/lib/crypto/encryption"; @@ -65,9 +62,6 @@ import { TGetOrgGroupsDTO, TGetOrgMembershipDTO, TInviteUserToOrgDTO, - TListOrgSshCasDTO, - TListOrgSshCertificatesDTO, - TListOrgSshCertificateTemplatesDTO, TListProjectMembershipsByOrgMembershipIdDTO, TUpdateOrgDTO, TUpdateOrgMembershipDTO, @@ -104,9 +98,6 @@ type TOrgServiceFactoryDep = { projectBotDAL: Pick; projectUserMembershipRoleDAL: Pick; projectBotService: Pick; - sshCertificateAuthorityDAL: Pick; - sshCertificateDAL: Pick; - sshCertificateTemplateDAL: Pick; }; export type TOrgServiceFactory = ReturnType; @@ -134,9 +125,6 @@ export const orgServiceFactory = ({ projectBotDAL, projectUserMembershipRoleDAL, identityMetadataDAL, - sshCertificateAuthorityDAL, - sshCertificateDAL, - sshCertificateTemplateDAL, projectBotService }: TOrgServiceFactoryDep) => { /* @@ -1139,91 +1127,6 @@ export const orgServiceFactory = ({ return incidentContact; }; - /** - * Return list of SSH CAs for organization - */ - 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 list of SSH certificates for organization - */ - const listOrgSshCertificates = async ({ - limit = 25, - offset = 0, - actorId, - actorOrgId, - actorAuthMethod, - actor, - orgId - }: TListOrgSshCertificatesDTO) => { - const { permission } = await permissionService.getOrgPermission(actor, actorId, orgId, actorAuthMethod, actorOrgId); - - ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Read, OrgPermissionSubjects.SshCertificates); - - const cas = await sshCertificateAuthorityDAL.find({ - orgId - }); - - const certificates = await sshCertificateDAL.find( - { - $in: { - sshCaId: cas.map((ca) => ca.id) - } - }, - { offset, limit, sort: [["updatedAt", "desc"]] } - ); - - const count = await sshCertificateDAL.countSshCertificatesInOrg(orgId); - - return { certificates, totalCount: count }; - }; - - /** - * Return list of SSH certificate templates for organization - */ - const listOrgSshCertificateTemplates = async ({ - actorId, - actorOrgId, - actorAuthMethod, - actor, - orgId - }: TListOrgSshCertificateTemplatesDTO) => { - const { permission } = await permissionService.getOrgPermission(actor, actorId, orgId, actorAuthMethod, actorOrgId); - - ForbiddenError.from(permission).throwUnlessCan( - OrgPermissionActions.Read, - OrgPermissionSubjects.SshCertificateTemplates - ); - - const cas = await sshCertificateAuthorityDAL.find({ - orgId - }); - - const certificateTemplates = await sshCertificateTemplateDAL.find({ - $in: { - sshCaId: cas.map((ca) => ca.id) - } - }); - - return { certificateTemplates }; - }; - return { findOrganizationById, findAllOrgMembers, @@ -1245,9 +1148,6 @@ export const orgServiceFactory = ({ deleteIncidentContact, getOrgGroups, listProjectMembershipsByOrgMembershipId, - findOrgBySlug, - listOrgSshCas, - listOrgSshCertificates, - listOrgSshCertificateTemplates + findOrgBySlug }; }; diff --git a/backend/src/services/org/org-types.ts b/backend/src/services/org/org-types.ts index 868d3345c..05df9429e 100644 --- a/backend/src/services/org/org-types.ts +++ b/backend/src/services/org/org-types.ts @@ -75,13 +75,6 @@ export type TListProjectMembershipsByOrgMembershipIdDTO = { orgMembershipId: string; } & TOrgPermission; -export type TListOrgSshCasDTO = TOrgPermission; -export type TListOrgSshCertificateTemplatesDTO = TOrgPermission; -export type TListOrgSshCertificatesDTO = { - offset: number; - limit: number; -} & TOrgPermission; - export enum OrgAuthMethod { OIDC = "oidc", SAML = "saml" diff --git a/backend/src/services/project/project-service.ts b/backend/src/services/project/project-service.ts index 53e934716..49ee43eda 100644 --- a/backend/src/services/project/project-service.ts +++ b/backend/src/services/project/project-service.ts @@ -8,6 +8,9 @@ import { TPermissionServiceFactory } from "@app/ee/services/permission/permissio import { ProjectPermissionActions, ProjectPermissionSub } from "@app/ee/services/permission/project-permission"; import { TProjectTemplateServiceFactory } from "@app/ee/services/project-template/project-template-service"; import { InfisicalProjectTemplate } from "@app/ee/services/project-template/project-template-types"; +import { TSshCertificateAuthorityDALFactory } from "@app/ee/services/ssh/ssh-certificate-authority-dal"; +import { TSshCertificateDALFactory } from "@app/ee/services/ssh-certificate/ssh-certificate-dal"; +import { TSshCertificateTemplateDALFactory } from "@app/ee/services/ssh-certificate-template/ssh-certificate-template-dal"; import { TKeyStoreFactory } from "@app/keystore/keystore"; import { isAtLeastAsPrivileged } from "@app/lib/casl"; import { infisicalSymmetricEncypt } from "@app/lib/crypto/encryption"; @@ -53,6 +56,9 @@ import { TListProjectCertificateTemplatesDTO, TListProjectCertsDTO, TListProjectsDTO, + TListProjectSshCasDTO, + TListProjectSshCertificatesDTO, + TListProjectSshCertificateTemplatesDTO, TLoadProjectKmsBackupDTO, TToggleProjectAutoCapitalizationDTO, TUpdateAuditLogsRetentionDTO, @@ -90,6 +96,9 @@ type TProjectServiceFactoryDep = { certificateTemplateDAL: Pick; pkiAlertDAL: Pick; pkiCollectionDAL: Pick; + sshCertificateAuthorityDAL: Pick; + sshCertificateDAL: Pick; + sshCertificateTemplateDAL: Pick; permissionService: TPermissionServiceFactory; orgService: Pick; licenseService: Pick; @@ -133,6 +142,9 @@ export const projectServiceFactory = ({ certificateTemplateDAL, pkiCollectionDAL, pkiAlertDAL, + sshCertificateAuthorityDAL, + sshCertificateDAL, + sshCertificateTemplateDAL, keyStore, kmsService, projectBotDAL, @@ -859,6 +871,115 @@ export const projectServiceFactory = ({ }; }; + /** + * Return list of SSH CAs for project + */ + const listProjectSshCas = async ({ + actorId, + actorOrgId, + actorAuthMethod, + actor, + projectId + }: TListProjectSshCasDTO) => { + const { permission } = await permissionService.getProjectPermission( + actor, + actorId, + projectId, + actorAuthMethod, + actorOrgId + ); + + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionActions.Read, + ProjectPermissionSub.SshCertificateAuthorities + ); + + const cas = await sshCertificateAuthorityDAL.find( + { + projectId + }, + { sort: [["updatedAt", "desc"]] } + ); + + return cas; + }; + + /** + * Return list of SSH certificates for organization + */ + const listProjectSshCertificates = async ({ + limit = 25, + offset = 0, + actorId, + actorOrgId, + actorAuthMethod, + actor, + projectId + }: TListProjectSshCertificatesDTO) => { + const { permission } = await permissionService.getProjectPermission( + actor, + actorId, + projectId, + actorAuthMethod, + actorOrgId + ); + + ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Read, ProjectPermissionSub.SshCertificates); + + const cas = await sshCertificateAuthorityDAL.find({ + projectId + }); + + const certificates = await sshCertificateDAL.find( + { + $in: { + sshCaId: cas.map((ca) => ca.id) + } + }, + { offset, limit, sort: [["updatedAt", "desc"]] } + ); + + const count = await sshCertificateDAL.countSshCertificatesInProject(projectId); + + return { certificates, totalCount: count }; + }; + + /** + * Return list of SSH certificate templates for organization + */ + const listProjectSshCertificateTemplates = async ({ + actorId, + actorOrgId, + actorAuthMethod, + actor, + projectId + }: TListProjectSshCertificateTemplatesDTO) => { + const { permission } = await permissionService.getProjectPermission( + actor, + actorId, + projectId, + actorAuthMethod, + actorOrgId + ); + + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionActions.Read, + ProjectPermissionSub.SshCertificateTemplates + ); + + const cas = await sshCertificateAuthorityDAL.find({ + projectId + }); + + const certificateTemplates = await sshCertificateTemplateDAL.find({ + $in: { + sshCaId: cas.map((ca) => ca.id) + } + }); + + return { certificateTemplates }; + }; + const updateProjectKmsKey = async ({ projectId, kms, @@ -1092,6 +1213,9 @@ export const projectServiceFactory = ({ listProjectAlerts, listProjectPkiCollections, listProjectCertificateTemplates, + listProjectSshCas, + listProjectSshCertificates, + listProjectSshCertificateTemplates, updateVersionLimit, updateAuditLogsRetention, updateProjectKmsKey, diff --git a/backend/src/services/project/project-types.ts b/backend/src/services/project/project-types.ts index b826f2a6a..6e9d85eb0 100644 --- a/backend/src/services/project/project-types.ts +++ b/backend/src/services/project/project-types.ts @@ -130,6 +130,13 @@ export type TGetProjectKmsKey = TProjectPermission; export type TListProjectCertificateTemplatesDTO = TProjectPermission; +export type TListProjectSshCasDTO = TProjectPermission; +export type TListProjectSshCertificateTemplatesDTO = TProjectPermission; +export type TListProjectSshCertificatesDTO = { + offset: number; + limit: number; +} & TProjectPermission; + export type TGetProjectSlackConfig = TProjectPermission; export type TUpdateProjectSlackConfig = { diff --git a/frontend/src/context/OrgPermissionContext/types.ts b/frontend/src/context/OrgPermissionContext/types.ts index ae2c95736..41a2e7e3c 100644 --- a/frontend/src/context/OrgPermissionContext/types.ts +++ b/frontend/src/context/OrgPermissionContext/types.ts @@ -23,10 +23,7 @@ export enum OrgPermissionSubjects { Kms = "kms", AdminConsole = "organization-admin-console", AuditLogs = "audit-logs", - ProjectTemplates = "project-templates", - SshCertificateAuthorities = "ssh-certificate-authorities", - SshCertificateTemplates = "ssh-certificate-templates", - SshCertificates = "ssh-certificates" + ProjectTemplates = "project-templates" } export enum OrgPermissionAdminConsoleAction { @@ -50,9 +47,6 @@ export type OrgPermissionSet = | [OrgPermissionActions, OrgPermissionSubjects.Kms] | [OrgPermissionAdminConsoleAction, OrgPermissionSubjects.AdminConsole] | [OrgPermissionActions, OrgPermissionSubjects.AuditLogs] - | [OrgPermissionActions, OrgPermissionSubjects.ProjectTemplates] - | [OrgPermissionActions, OrgPermissionSubjects.SshCertificateAuthorities] - | [OrgPermissionActions, OrgPermissionSubjects.SshCertificates] - | [OrgPermissionActions, OrgPermissionSubjects.SshCertificateTemplates]; + | [OrgPermissionActions, OrgPermissionSubjects.ProjectTemplates]; export type TOrgPermission = MongoAbility; diff --git a/frontend/src/context/ProjectPermissionContext/types.ts b/frontend/src/context/ProjectPermissionContext/types.ts index 8f10d5f21..e3fe9732b 100644 --- a/frontend/src/context/ProjectPermissionContext/types.ts +++ b/frontend/src/context/ProjectPermissionContext/types.ts @@ -81,6 +81,9 @@ export enum ProjectPermissionSub { CertificateAuthorities = "certificate-authorities", Certificates = "certificates", CertificateTemplates = "certificate-templates", + SshCertificateAuthorities = "ssh-certificate-authorities", + SshCertificateTemplates = "ssh-certificate-templates", + SshCertificates = "ssh-certificates", PkiAlerts = "pki-alerts", PkiCollections = "pki-collections", Kms = "kms", @@ -155,6 +158,9 @@ export type ProjectPermissionSet = | [ProjectPermissionActions, ProjectPermissionSub.CertificateAuthorities] | [ProjectPermissionActions, ProjectPermissionSub.Certificates] | [ProjectPermissionActions, ProjectPermissionSub.CertificateTemplates] + | [ProjectPermissionActions, ProjectPermissionSub.SshCertificateAuthorities] + | [ProjectPermissionActions, ProjectPermissionSub.SshCertificateTemplates] + | [ProjectPermissionActions, ProjectPermissionSub.SshCertificates] | [ProjectPermissionActions, ProjectPermissionSub.PkiAlerts] | [ProjectPermissionActions, ProjectPermissionSub.PkiCollections] | [ProjectPermissionActions.Delete, ProjectPermissionSub.Project] diff --git a/frontend/src/hooks/api/organization/index.ts b/frontend/src/hooks/api/organization/index.ts index 477b3bd8b..fece19e5f 100644 --- a/frontend/src/hooks/api/organization/index.ts +++ b/frontend/src/hooks/api/organization/index.ts @@ -19,8 +19,6 @@ export { useGetOrgPmtMethods, useGetOrgTaxIds, useGetOrgTrialUrl, - useListOrgSshCas, - useListOrgSshCertificates, - useListOrgSshCertificateTemplates, 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 9f4c83c1c..4923177ba 100644 --- a/frontend/src/hooks/api/organization/queries.tsx +++ b/frontend/src/hooks/api/organization/queries.tsx @@ -4,8 +4,6 @@ import { apiRequest } from "@app/config/request"; import { OrderByDirection } from "@app/hooks/api/generic/types"; import { TGroupOrgMembership } from "../groups/types"; -import { TSshCertificate,TSshCertificateAuthority } from "../ssh-ca/types"; -import { TSshCertificateTemplate } from "../sshCertificateTemplates/types"; import { IntegrationAuth } from "../types"; import { BillingDetails, @@ -43,12 +41,7 @@ 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, - getOrgSshCas: ({ orgId }: { orgId: string }) => [{ orgId }, "org-ssh-cas"] as const, - allOrgSshCertificates: () => ["org-ssh-certificates"] as const, - specificOrgSshCertificates: ({ offset, limit }: { offset: number; limit: number }) => - [...organizationKeys.allOrgSshCertificates(), { offset, limit }] as const, - getOrgSshCertificateTemplates: () => ["org-ssh-certificate-templates"] as const + getOrgIntegrationAuths: (orgId: string) => [{ orgId }, "integration-auths"] as const }; export const fetchOrganizations = async () => { @@ -502,63 +495,3 @@ 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: Omit[] }>( - `/api/v1/organization/${orgId}/ssh-cas` - ); - return cas; - }, - enabled: Boolean(orgId) - }); -}; - -export const useListOrgSshCertificates = ({ - orgId, - offset, - limit -}: { - orgId: string; - offset: number; - limit: number; -}) => { - return useQuery({ - queryKey: organizationKeys.specificOrgSshCertificates({ - offset, - limit - }), - queryFn: async () => { - const params = new URLSearchParams({ - offset: String(offset), - limit: String(limit) - }); - - const { data } = await apiRequest.get<{ - certificates: TSshCertificate[]; - totalCount: number; - }>(`/api/v1/organization/${orgId}/ssh-certificates`, { - params - }); - return data; - }, - enabled: Boolean(orgId) - }); -}; - -export const useListOrgSshCertificateTemplates = ({ orgId }: { orgId: string }) => { - return useQuery({ - queryKey: organizationKeys.getOrgSshCertificateTemplates(), - queryFn: async () => { - const { data } = await apiRequest.get<{ certificateTemplates: TSshCertificateTemplate[] }>( - `/api/v1/organization/${orgId}/ssh-certificate-templates` - ); - return data; - }, - enabled: Boolean(orgId) - }); -}; diff --git a/frontend/src/hooks/api/ssh-ca/mutations.tsx b/frontend/src/hooks/api/ssh-ca/mutations.tsx index 811e09d18..e8c5731b6 100644 --- a/frontend/src/hooks/api/ssh-ca/mutations.tsx +++ b/frontend/src/hooks/api/ssh-ca/mutations.tsx @@ -2,7 +2,7 @@ import { useMutation, useQueryClient } from "@tanstack/react-query"; import { apiRequest } from "@app/config/request"; -import { organizationKeys } from "../organization/queries"; +import { workspaceKeys } from "../workspace/query-keys"; import { TCreateSshCaDTO, TDeleteSshCaDTO, @@ -27,8 +27,8 @@ export const useCreateSshCa = () => { } = await apiRequest.post<{ ca: TSshCertificateAuthority }>("/api/v1/ssh/ca/", body); return ca; }, - onSuccess: ({ orgId }) => { - queryClient.invalidateQueries(organizationKeys.getOrgSshCas({ orgId })); + onSuccess: ({ projectId }) => { + queryClient.invalidateQueries(workspaceKeys.getWorkspaceSshCas(projectId)); } }); }; @@ -42,8 +42,8 @@ export const useUpdateSshCa = () => { } = await apiRequest.patch<{ ca: TSshCertificateAuthority }>(`/api/v1/ssh/ca/${caId}`, body); return ca; }, - onSuccess: ({ orgId }, { caId }) => { - queryClient.invalidateQueries(organizationKeys.getOrgSshCas({ orgId })); + onSuccess: ({ projectId }, { caId }) => { + queryClient.invalidateQueries(workspaceKeys.getWorkspaceSshCas(projectId)); queryClient.invalidateQueries(sshCaKeys.getSshCaById(caId)); } }); @@ -58,8 +58,8 @@ export const useDeleteSshCa = () => { } = await apiRequest.delete<{ ca: TSshCertificateAuthority }>(`/api/v1/ssh/ca/${caId}`); return ca; }, - onSuccess: ({ orgId }) => { - queryClient.invalidateQueries(organizationKeys.getOrgSshCas({ orgId })); + onSuccess: ({ projectId }) => { + queryClient.invalidateQueries(workspaceKeys.getWorkspaceSshCas(projectId)); } }); }; @@ -71,8 +71,8 @@ export const useSignSshKey = () => { const { data } = await apiRequest.post("/api/v1/ssh/sign", body); return data; }, - onSuccess: () => { - queryClient.invalidateQueries(organizationKeys.allOrgSshCertificates()); + onSuccess: (_, { projectId }) => { + queryClient.invalidateQueries(workspaceKeys.allWorkspaceSshCertificates(projectId)); } }); }; @@ -84,8 +84,8 @@ export const useIssueSshCreds = () => { const { data } = await apiRequest.post("/api/v1/ssh/issue", body); return data; }, - onSuccess: () => { - queryClient.invalidateQueries(organizationKeys.allOrgSshCertificates()); + onSuccess: (_, { projectId }) => { + queryClient.invalidateQueries(workspaceKeys.allWorkspaceSshCertificates(projectId)); } }); }; diff --git a/frontend/src/hooks/api/ssh-ca/types.ts b/frontend/src/hooks/api/ssh-ca/types.ts index 2873fafeb..06089fffc 100644 --- a/frontend/src/hooks/api/ssh-ca/types.ts +++ b/frontend/src/hooks/api/ssh-ca/types.ts @@ -7,7 +7,6 @@ export type TSshCertificate = { sshCertificateTemplateId: string; serialNumber: string; certType: SshCertType; - publicKey: string; principals: string[]; keyId: string; notBefore: string; @@ -16,7 +15,7 @@ export type TSshCertificate = { export type TSshCertificateAuthority = { id: string; - orgId: string; + projectId: string; status: SshCaStatus; friendlyName: string; keyAlgorithm: CertKeyAlgorithm; @@ -26,6 +25,7 @@ export type TSshCertificateAuthority = { }; export type TCreateSshCaDTO = { + projectId: string; friendlyName?: string; keyAlgorithm: CertKeyAlgorithm; }; @@ -41,6 +41,7 @@ export type TDeleteSshCaDTO = { }; export type TSignSshKeyDTO = { + projectId: string; templateName: string; publicKey?: string; certType: SshCertType; @@ -55,6 +56,7 @@ export type TSignSshKeyResponse = { }; export type TIssueSshCredsDTO = { + projectId: string; templateName: string; keyAlgorithm: CertKeyAlgorithm; certType: SshCertType; diff --git a/frontend/src/hooks/api/workspace/index.tsx b/frontend/src/hooks/api/workspace/index.tsx index cd652970a..665462e7d 100644 --- a/frontend/src/hooks/api/workspace/index.tsx +++ b/frontend/src/hooks/api/workspace/index.tsx @@ -32,6 +32,9 @@ export { useListWorkspaceGroups, useListWorkspacePkiAlerts, useListWorkspacePkiCollections, + useListWorkspaceSshCas, + useListWorkspaceSshCertificates, + useListWorkspaceSshCertificateTemplates, useNameWorkspaceSecrets, useToggleAutoCapitalization, useUpdateIdentityWorkspaceRole, diff --git a/frontend/src/hooks/api/workspace/queries.tsx b/frontend/src/hooks/api/workspace/queries.tsx index ec887a401..3385344cd 100644 --- a/frontend/src/hooks/api/workspace/queries.tsx +++ b/frontend/src/hooks/api/workspace/queries.tsx @@ -15,6 +15,8 @@ import { TIntegration } from "../integrations/types"; import { TPkiAlert } from "../pkiAlerts/types"; import { TPkiCollection } from "../pkiCollections/types"; import { EncryptedSecret } from "../secrets/types"; +import { TSshCertificate, TSshCertificateAuthority } from "../ssh-ca/types"; +import { TSshCertificateTemplate } from "../sshCertificateTemplates/types"; import { userKeys } from "../users/query-keys"; import { TWorkspaceUser } from "../users/types"; import { ProjectSlackConfig } from "../workflowIntegrations/types"; @@ -713,6 +715,67 @@ export const useListWorkspaceCertificateTemplates = ({ workspaceId }: { workspac }); }; +export const useListWorkspaceSshCertificates = ({ + offset, + limit, + projectId +}: { + offset: number; + limit: number; + projectId: string; +}) => { + return useQuery({ + queryKey: workspaceKeys.specificWorkspaceSshCertificates({ + offset, + limit, + projectId + }), + queryFn: async () => { + const params = new URLSearchParams({ + offset: String(offset), + limit: String(limit) + }); + + const { data } = await apiRequest.get<{ + certificates: TSshCertificate[]; + totalCount: number; + }>(`/api/v2/workspace/${projectId}/ssh-certificates`, { + params + }); + return data; + }, + enabled: Boolean(projectId) + }); +}; + +export const useListWorkspaceSshCas = (projectId: string) => { + return useQuery({ + queryKey: workspaceKeys.getWorkspaceSshCas(projectId), + queryFn: async () => { + const { + data: { cas } + } = await apiRequest.get<{ cas: Omit[] }>( + `/api/v2/workspace/${projectId}/ssh-cas` + ); + return cas; + }, + enabled: Boolean(projectId) + }); +}; + +export const useListWorkspaceSshCertificateTemplates = (projectId: string) => { + return useQuery({ + queryKey: workspaceKeys.getWorkspaceSshCertificateTemplates(projectId), + queryFn: async () => { + const { data } = await apiRequest.get<{ certificateTemplates: TSshCertificateTemplate[] }>( + `/api/v2/workspace/${projectId}/ssh-certificate-templates` + ); + return data; + }, + enabled: Boolean(projectId) + }); +}; + export const useGetWorkspaceSlackConfig = ({ workspaceId }: { workspaceId: string }) => { return useQuery({ queryKey: workspaceKeys.getWorkspaceSlackConfig(workspaceId), diff --git a/frontend/src/hooks/api/workspace/query-keys.tsx b/frontend/src/hooks/api/workspace/query-keys.tsx index f5a02ec2b..bec32ceeb 100644 --- a/frontend/src/hooks/api/workspace/query-keys.tsx +++ b/frontend/src/hooks/api/workspace/query-keys.tsx @@ -52,5 +52,19 @@ export const workspaceKeys = { getWorkspaceCertificateTemplates: (workspaceId: string) => [{ workspaceId }, "workspace-certificate-templates"] as const, getWorkspaceSlackConfig: (workspaceId: string) => - [{ workspaceId }, "workspace-slack-config"] as const + [{ workspaceId }, "workspace-slack-config"] as const, + getWorkspaceSshCas: (projectId: string) => [{ projectId }, "workspace-ssh-cas"] as const, + allWorkspaceSshCertificates: (projectId: string) => + [{ projectId }, "workspace-ssh-certificates"] as const, + specificWorkspaceSshCertificates: ({ + offset, + limit, + projectId + }: { + offset: number; + limit: number; + projectId: string; + }) => [...workspaceKeys.allWorkspaceSshCertificates(projectId), { offset, limit }] as const, + getWorkspaceSshCertificateTemplates: (projectId: string) => + [{ projectId }, "workspace-ssh-certificate-templates"] as const }; diff --git a/frontend/src/layouts/AppLayout/AppLayout.tsx b/frontend/src/layouts/AppLayout/AppLayout.tsx index 575695b2c..b7ff48788 100644 --- a/frontend/src/layouts/AppLayout/AppLayout.tsx +++ b/frontend/src/layouts/AppLayout/AppLayout.tsx @@ -424,6 +424,16 @@ export const AppLayout = ({ children }: LayoutProps) => { + + + + SSH + + + { - - - - SSH - - - { - const { t } = useTranslation(); - return ( - <> - - {t("common.head-title", { title: t("approval.title") })} - - - - - -
- -
- - ); -}; - -export default Ssh; - -Ssh.requireAuth = true; diff --git a/frontend/src/pages/org/[id]/ssh/ca/[caId]/index.tsx b/frontend/src/pages/project/[id]/ssh/ca/[caId]/index.tsx similarity index 84% rename from frontend/src/pages/org/[id]/ssh/ca/[caId]/index.tsx rename to frontend/src/pages/project/[id]/ssh/ca/[caId]/index.tsx index de06fac46..cdaa4d0b2 100644 --- a/frontend/src/pages/org/[id]/ssh/ca/[caId]/index.tsx +++ b/frontend/src/pages/project/[id]/ssh/ca/[caId]/index.tsx @@ -1,7 +1,7 @@ /* eslint-disable @typescript-eslint/no-unused-vars */ import Head from "next/head"; -import { SshCaPage } from "@app/views/Org/SshCaPage"; +import { SshCaPage } from "@app/views/Project/SshCaPage"; export default function SshCa() { return ( diff --git a/frontend/src/pages/project/[id]/ssh/index.tsx b/frontend/src/pages/project/[id]/ssh/index.tsx new file mode 100644 index 000000000..f574566c1 --- /dev/null +++ b/frontend/src/pages/project/[id]/ssh/index.tsx @@ -0,0 +1,23 @@ +import { useTranslation } from "react-i18next"; +import Head from "next/head"; + +import { SshPage } from "@app/views/Project/SshPage"; + +const Ssh = () => { + const { t } = useTranslation(); + + return ( +
+ + {t("common.head-title", { title: "Certificates" })} + + + + +
+ ); +}; + +export default Ssh; + +Ssh.requireAuth = true; diff --git a/frontend/src/views/Org/RolePage/components/OrgRoleModifySection.utils.ts b/frontend/src/views/Org/RolePage/components/OrgRoleModifySection.utils.ts index b9da49ca7..aa8c4d7ec 100644 --- a/frontend/src/views/Org/RolePage/components/OrgRoleModifySection.utils.ts +++ b/frontend/src/views/Org/RolePage/components/OrgRoleModifySection.utils.ts @@ -49,10 +49,7 @@ export const formSchema = z.object({ identity: generalPermissionSchema, "organization-admin-console": adminConsolePermissionSchmea, [OrgPermissionSubjects.Kms]: generalPermissionSchema, - [OrgPermissionSubjects.ProjectTemplates]: generalPermissionSchema, - [OrgPermissionSubjects.SshCertificateAuthorities]: generalPermissionSchema, - [OrgPermissionSubjects.SshCertificates]: generalPermissionSchema, - [OrgPermissionSubjects.SshCertificateTemplates]: generalPermissionSchema + [OrgPermissionSubjects.ProjectTemplates]: generalPermissionSchema }) .optional() }); diff --git a/frontend/src/views/Org/RolePage/components/RolePermissionsSection/RolePermissionsSection.tsx b/frontend/src/views/Org/RolePage/components/RolePermissionsSection/RolePermissionsSection.tsx index f4fcc84f9..976b35636 100644 --- a/frontend/src/views/Org/RolePage/components/RolePermissionsSection/RolePermissionsSection.tsx +++ b/frontend/src/views/Org/RolePage/components/RolePermissionsSection/RolePermissionsSection.tsx @@ -69,19 +69,7 @@ const SIMPLE_PERMISSION_OPTIONS = [ title: "External KMS", formName: OrgPermissionSubjects.Kms }, - { title: "Project Templates", formName: OrgPermissionSubjects.ProjectTemplates }, - { - title: "SSH Certificate Authorities", - formName: OrgPermissionSubjects.SshCertificateAuthorities - }, - { - title: "SSH Certificates", - formName: OrgPermissionSubjects.SshCertificates - }, - { - title: "SSH Certificate Templates", - formName: OrgPermissionSubjects.SshCertificateTemplates - } + { title: "Project Templates", formName: OrgPermissionSubjects.ProjectTemplates } ] as const; type Props = { diff --git a/frontend/src/views/Project/RolePage/components/RolePermissionsSection/ProjectRoleModifySection.utils.tsx b/frontend/src/views/Project/RolePage/components/RolePermissionsSection/ProjectRoleModifySection.utils.tsx index e298281f8..3c1310de2 100644 --- a/frontend/src/views/Project/RolePage/components/RolePermissionsSection/ProjectRoleModifySection.utils.tsx +++ b/frontend/src/views/Project/RolePage/components/RolePermissionsSection/ProjectRoleModifySection.utils.tsx @@ -121,6 +121,11 @@ export const projectRoleFormSchema = z.object({ [ProjectPermissionSub.PkiAlerts]: GeneralPolicyActionSchema.array().default([]), [ProjectPermissionSub.PkiCollections]: GeneralPolicyActionSchema.array().default([]), [ProjectPermissionSub.CertificateTemplates]: GeneralPolicyActionSchema.array().default([]), + [ProjectPermissionSub.SshCertificateAuthorities]: GeneralPolicyActionSchema.array().default( + [] + ), + [ProjectPermissionSub.SshCertificates]: GeneralPolicyActionSchema.array().default([]), + [ProjectPermissionSub.SshCertificateTemplates]: GeneralPolicyActionSchema.array().default([]), [ProjectPermissionSub.SecretApproval]: GeneralPolicyActionSchema.array().default([]), [ProjectPermissionSub.SecretRollback]: SecretRollbackPolicyActionSchema.array().default([]), [ProjectPermissionSub.Project]: WorkspacePolicyActionSchema.array().default([]), @@ -203,6 +208,9 @@ export const rolePermission2Form = (permissions: TProjectPermission[] = []) => { ProjectPermissionSub.PkiAlerts, ProjectPermissionSub.PkiCollections, ProjectPermissionSub.CertificateTemplates, + ProjectPermissionSub.SshCertificateAuthorities, + ProjectPermissionSub.SshCertificates, + ProjectPermissionSub.SshCertificateTemplates, ProjectPermissionSub.SecretApproval, ProjectPermissionSub.Tags, ProjectPermissionSub.SecretRotation, @@ -589,6 +597,33 @@ export const PROJECT_PERMISSION_OBJECT: TProjectPermissionObject = { { label: "Remove", value: "delete" } ] }, + [ProjectPermissionSub.SshCertificateAuthorities]: { + title: "SSH Certificate Authorities", + actions: [ + { label: "Read", value: "read" }, + { label: "Create", value: "create" }, + { label: "Modify", value: "edit" }, + { label: "Remove", value: "delete" } + ] + }, + [ProjectPermissionSub.SshCertificates]: { + title: "SSH Certificates", + actions: [ + { label: "Read", value: "read" }, + { label: "Create", value: "create" }, + { label: "Modify", value: "edit" }, + { label: "Remove", value: "delete" } + ] + }, + [ProjectPermissionSub.SshCertificateTemplates]: { + title: "SSH Certificate Templates", + actions: [ + { label: "Read", value: "read" }, + { label: "Create", value: "create" }, + { label: "Modify", value: "edit" }, + { label: "Remove", value: "delete" } + ] + }, [ProjectPermissionSub.PkiCollections]: { title: "PKI Collections", actions: [ diff --git a/frontend/src/views/Org/SshCaPage/SshCaPage.tsx b/frontend/src/views/Project/SshCaPage/SshCaPage.tsx similarity index 84% rename from frontend/src/views/Org/SshCaPage/SshCaPage.tsx rename to frontend/src/views/Project/SshCaPage/SshCaPage.tsx index 34a869faa..97ca965a4 100644 --- a/frontend/src/views/Org/SshCaPage/SshCaPage.tsx +++ b/frontend/src/views/Project/SshCaPage/SshCaPage.tsx @@ -5,7 +5,7 @@ import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { twMerge } from "tailwind-merge"; import { createNotification } from "@app/components/notifications"; -import { OrgPermissionCan } from "@app/components/permissions"; +import { ProjectPermissionCan } from "@app/components/permissions"; import { Button, DeleteActionModal, @@ -15,17 +15,18 @@ import { DropdownMenuTrigger, Tooltip } from "@app/components/v2"; -import { OrgPermissionActions, OrgPermissionSubjects, useOrganization } from "@app/context"; -import { withPermission } from "@app/hoc"; +import { ProjectPermissionActions, ProjectPermissionSub, useWorkspace } from "@app/context"; +import { withProjectPermission } from "@app/hoc"; import { useDeleteSshCa, useGetSshCaById } from "@app/hooks/api"; import { usePopUp } from "@app/hooks/usePopUp"; import { SshCaModal } from "../SshPage/components/SshCaModal"; import { SshCaDetailsSection, SshCertificateTemplatesSection } from "./components"; -export const SshCaPage = withPermission( +export const SshCaPage = withProjectPermission( () => { - const { currentOrg } = useOrganization(); + const { currentWorkspace } = useWorkspace(); + const projectId = currentWorkspace?.id || ""; const router = useRouter(); const caId = router.query.caId as string; const { data } = useGetSshCaById(caId); @@ -39,7 +40,7 @@ export const SshCaPage = withPermission( const onRemoveCaSubmit = async (caIdToDelete: string) => { try { - if (!currentOrg?.id) return; + if (!projectId) return; await deleteSshCa({ caId: caIdToDelete }); @@ -49,7 +50,7 @@ export const SshCaPage = withPermission( }); handlePopUpClose("deleteSshCa"); - router.push(`/org/${currentOrg.id}/ssh`); + router.push(`/project/${projectId}/ssh`); } catch (err) { console.error(err); createNotification({ @@ -67,7 +68,7 @@ export const SshCaPage = withPermission( variant="link" type="submit" leftIcon={} - onClick={() => router.push(`/org/${currentOrg?.id}/ssh`)} + onClick={() => router.push(`/project/${projectId}/ssh`)} className="mb-4" > SSH Certificate Authorities @@ -83,9 +84,9 @@ export const SshCaPage = withPermission( - {(isAllowed) => ( )} - + @@ -131,5 +132,5 @@ export const SshCaPage = withPermission( ); }, - { action: OrgPermissionActions.Read, subject: OrgPermissionSubjects.SshCertificateAuthorities } + { action: ProjectPermissionActions.Read, subject: ProjectPermissionSub.SshCertificateAuthorities } ); diff --git a/frontend/src/views/Org/SshCaPage/components/SshCaDetailsSection.tsx b/frontend/src/views/Project/SshCaPage/components/SshCaDetailsSection.tsx similarity index 91% rename from frontend/src/views/Org/SshCaPage/components/SshCaDetailsSection.tsx rename to frontend/src/views/Project/SshCaPage/components/SshCaDetailsSection.tsx index 475478124..812ac02fe 100644 --- a/frontend/src/views/Org/SshCaPage/components/SshCaDetailsSection.tsx +++ b/frontend/src/views/Project/SshCaPage/components/SshCaDetailsSection.tsx @@ -1,10 +1,10 @@ -import { faCheck, faCopy, faDownload,faPencil } from "@fortawesome/free-solid-svg-icons"; +import { faCheck, faCopy, faDownload, faPencil } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import FileSaver from "file-saver"; -import { OrgPermissionCan } from "@app/components/permissions"; +import { ProjectPermissionCan } from "@app/components/permissions"; import { IconButton, Tooltip } from "@app/components/v2"; -import { OrgPermissionActions, OrgPermissionSubjects } from "@app/context"; +import { ProjectPermissionActions, ProjectPermissionSub } from "@app/context"; import { useTimedReset } from "@app/hooks"; import { useGetSshCaById } from "@app/hooks/api"; import { caStatusToNameMap } from "@app/hooks/api/ca/constants"; @@ -35,9 +35,9 @@ export const SshCaDetailsSection = ({ caId, handlePopUpOpen }: Props) => {

SSH CA Details

- {(isAllowed) => { return ( @@ -59,7 +59,7 @@ export const SshCaDetailsSection = ({ caId, handlePopUpOpen }: Props) => { ); }} - +
diff --git a/frontend/src/views/Org/SshCaPage/components/SshCertificateContent.tsx b/frontend/src/views/Project/SshCaPage/components/SshCertificateContent.tsx similarity index 100% rename from frontend/src/views/Org/SshCaPage/components/SshCertificateContent.tsx rename to frontend/src/views/Project/SshCaPage/components/SshCertificateContent.tsx diff --git a/frontend/src/views/Org/SshCaPage/components/SshCertificateModal.tsx b/frontend/src/views/Project/SshCaPage/components/SshCertificateModal.tsx similarity index 96% rename from frontend/src/views/Org/SshCaPage/components/SshCertificateModal.tsx rename to frontend/src/views/Project/SshCaPage/components/SshCertificateModal.tsx index ea83a95bb..9495ab141 100644 --- a/frontend/src/views/Org/SshCaPage/components/SshCertificateModal.tsx +++ b/frontend/src/views/Project/SshCaPage/components/SshCertificateModal.tsx @@ -13,13 +13,14 @@ import { Select, SelectItem } from "@app/components/v2"; -import { useOrganization } from "@app/context"; +import { useWorkspace } from "@app/context"; import { SshCertTemplateStatus, useGetSshCertTemplate, useIssueSshCreds, - useListOrgSshCertificateTemplates, - useSignSshKey} from "@app/hooks/api"; + useListWorkspaceSshCertificateTemplates, + useSignSshKey +} from "@app/hooks/api"; import { certKeyAlgorithms } from "@app/hooks/api/certificates/constants"; import { CertKeyAlgorithm } from "@app/hooks/api/certificates/enums"; import { SshCertType } from "@app/hooks/api/ssh-ca/constants"; @@ -62,7 +63,8 @@ enum SshCertificateOperation { } export const SshCertificateModal = ({ popUp, handlePopUpToggle }: Props) => { - const { currentOrg } = useOrganization(); + const { currentWorkspace } = useWorkspace(); + const projectId = currentWorkspace?.id || ""; const [operation, setOperation] = useState( SshCertificateOperation.SIGN_SSH_KEY ); @@ -77,9 +79,7 @@ export const SshCertificateModal = ({ popUp, handlePopUpToggle }: Props) => { templateId: string; }; - const { data: templatesData } = useListOrgSshCertificateTemplates({ - orgId: currentOrg?.id || "" - }); + const { data: templatesData } = useListWorkspaceSshCertificateTemplates(projectId); const { control, @@ -117,10 +117,12 @@ export const SshCertificateModal = ({ popUp, handlePopUpToggle }: Props) => { }: FormData) => { try { if (!templateData) return; + if (!projectId) return; switch (operation) { case SshCertificateOperation.SIGN_SSH_KEY: { const { serialNumber, signedKey } = await signSshKey({ + projectId: currentWorkspace?.id || "", templateName: templateData.name, publicKey: existingPublicKey, certType, @@ -137,6 +139,7 @@ export const SshCertificateModal = ({ popUp, handlePopUpToggle }: Props) => { } case SshCertificateOperation.ISSUE_SSH_CREDS: { const { serialNumber, publicKey, privateKey, signedKey } = await issueSshCreds({ + projectId, templateName: templateData.name, keyAlgorithm, certType, diff --git a/frontend/src/views/Org/SshCaPage/components/SshCertificateTemplateModal.tsx b/frontend/src/views/Project/SshCaPage/components/SshCertificateTemplateModal.tsx similarity index 98% rename from frontend/src/views/Org/SshCaPage/components/SshCertificateTemplateModal.tsx rename to frontend/src/views/Project/SshCaPage/components/SshCertificateTemplateModal.tsx index 1090b4b43..7bdcd319c 100644 --- a/frontend/src/views/Org/SshCaPage/components/SshCertificateTemplateModal.tsx +++ b/frontend/src/views/Project/SshCaPage/components/SshCertificateTemplateModal.tsx @@ -14,12 +14,12 @@ import { SelectItem, Switch } from "@app/components/v2"; -import { useOrganization } from "@app/context"; +import { useWorkspace } from "@app/context"; import { useCreateSshCertTemplate, useGetSshCaById, useGetSshCertTemplate, - useListOrgSshCas, + useListWorkspaceSshCas, useUpdateSshCertTemplate } from "@app/hooks/api"; import { UsePopUpState } from "@app/hooks/usePopUp"; @@ -48,7 +48,7 @@ type Props = { }; export const SshCertificateTemplateModal = ({ popUp, handlePopUpToggle, sshCaId }: Props) => { - const { currentOrg } = useOrganization(); + const { currentWorkspace } = useWorkspace(); const { data: ca } = useGetSshCaById(sshCaId); @@ -56,9 +56,7 @@ export const SshCertificateTemplateModal = ({ popUp, handlePopUpToggle, sshCaId (popUp?.sshCertificateTemplate?.data as { id: string })?.id || "" ); - const { data: cas } = useListOrgSshCas({ - orgId: currentOrg?.id ?? "" - }); + const { data: cas } = useListWorkspaceSshCas(currentWorkspace?.id || ""); const { mutateAsync: createSshCertTemplate } = useCreateSshCertTemplate(); const { mutateAsync: updateSshCertTemplate } = useUpdateSshCertTemplate(); diff --git a/frontend/src/views/Org/SshCaPage/components/SshCertificateTemplatesSection.tsx b/frontend/src/views/Project/SshCaPage/components/SshCertificateTemplatesSection.tsx similarity index 93% rename from frontend/src/views/Org/SshCaPage/components/SshCertificateTemplatesSection.tsx rename to frontend/src/views/Project/SshCaPage/components/SshCertificateTemplatesSection.tsx index f82ab7442..b6bdde96c 100644 --- a/frontend/src/views/Org/SshCaPage/components/SshCertificateTemplatesSection.tsx +++ b/frontend/src/views/Project/SshCaPage/components/SshCertificateTemplatesSection.tsx @@ -2,14 +2,15 @@ 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 { ProjectPermissionCan } from "@app/components/permissions"; import { DeleteActionModal, IconButton } from "@app/components/v2"; -import { OrgPermissionActions, OrgPermissionSubjects } from "@app/context"; +import { ProjectPermissionActions, ProjectPermissionSub } from "@app/context"; import { usePopUp } from "@app/hooks"; import { SshCertTemplateStatus, useDeleteSshCertTemplate, - useUpdateSshCertTemplate} from "@app/hooks/api"; + useUpdateSshCertTemplate +} from "@app/hooks/api"; import { SshCertificateModal } from "./SshCertificateModal"; import { SshCertificateTemplateModal } from "./SshCertificateTemplateModal"; @@ -85,9 +86,9 @@ export const SshCertificateTemplatesSection = ({ caId }: Props) => {

Certificate Templates

- {(isAllowed) => ( { )} - +
diff --git a/frontend/src/views/Org/SshCaPage/components/SshCertificateTemplatesTable.tsx b/frontend/src/views/Project/SshCaPage/components/SshCertificateTemplatesTable.tsx similarity index 85% rename from frontend/src/views/Org/SshCaPage/components/SshCertificateTemplatesTable.tsx rename to frontend/src/views/Project/SshCaPage/components/SshCertificateTemplatesTable.tsx index 2fefc898d..bc8e751fc 100644 --- a/frontend/src/views/Org/SshCaPage/components/SshCertificateTemplatesTable.tsx +++ b/frontend/src/views/Project/SshCaPage/components/SshCertificateTemplatesTable.tsx @@ -8,7 +8,7 @@ import { import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { twMerge } from "tailwind-merge"; -import { OrgPermissionCan } from "@app/components/permissions"; +import { ProjectPermissionCan } from "@app/components/permissions"; import { Badge, DropdownMenu, @@ -26,8 +26,8 @@ import { Tooltip, Tr } from "@app/components/v2"; -import { OrgPermissionActions, OrgPermissionSubjects } from "@app/context"; -import { SshCertTemplateStatus,useGetSshCaCertTemplates } from "@app/hooks/api"; +import { ProjectPermissionActions, ProjectPermissionSub } from "@app/context"; +import { SshCertTemplateStatus, useGetSshCaCertTemplates } from "@app/hooks/api"; import { caStatusToNameMap, getCaStatusBadgeVariant } from "@app/hooks/api/ca/constants"; import { UsePopUpState } from "@app/hooks/usePopUp"; @@ -89,9 +89,9 @@ export const SshCertificateTemplatesTable = ({ handlePopUpOpen, sshCaId }: Props
- {(isAllowed) => ( )} - - + { @@ -136,10 +136,10 @@ export const SshCertificateTemplatesTable = ({ handlePopUpOpen, sshCaId }: Props > Issue Certificate - - + @@ -151,10 +151,10 @@ export const SshCertificateTemplatesTable = ({ handlePopUpOpen, sshCaId }: Props > Edit Template - - + {(isAllowed) => ( )} - + diff --git a/frontend/src/views/Org/SshCaPage/components/index.tsx b/frontend/src/views/Project/SshCaPage/components/index.tsx similarity index 100% rename from frontend/src/views/Org/SshCaPage/components/index.tsx rename to frontend/src/views/Project/SshCaPage/components/index.tsx diff --git a/frontend/src/views/Org/SshCaPage/index.tsx b/frontend/src/views/Project/SshCaPage/index.tsx similarity index 100% rename from frontend/src/views/Org/SshCaPage/index.tsx rename to frontend/src/views/Project/SshCaPage/index.tsx diff --git a/frontend/src/views/Org/SshPage/SshPage.tsx b/frontend/src/views/Project/SshPage/SshPage.tsx similarity index 86% rename from frontend/src/views/Org/SshPage/SshPage.tsx rename to frontend/src/views/Project/SshPage/SshPage.tsx index ff373749a..294170fb5 100644 --- a/frontend/src/views/Org/SshPage/SshPage.tsx +++ b/frontend/src/views/Project/SshPage/SshPage.tsx @@ -1,8 +1,8 @@ import { motion } from "framer-motion"; import { Tab, TabList, TabPanel, Tabs } from "@app/components/v2"; -import { OrgPermissionActions, OrgPermissionSubjects } from "@app/context"; -import { withPermission } from "@app/hoc"; +import { ProjectPermissionActions, ProjectPermissionSub } from "@app/context"; +import { withProjectPermission } from "@app/hoc"; import { SshCaSection, SshCertificatesSection } from "./components"; @@ -11,7 +11,7 @@ enum TabSections { SshCertificates = "ssh-certificates" } -export const SshPage = withPermission( +export const SshPage = withProjectPermission( () => { return (
@@ -49,5 +49,5 @@ export const SshPage = withPermission(
); }, - { action: OrgPermissionActions.Read, subject: OrgPermissionSubjects.SshCertificateAuthorities } + { action: ProjectPermissionActions.Read, subject: ProjectPermissionSub.SshCertificateAuthorities } ); diff --git a/frontend/src/views/Org/SshPage/components/SshCaModal.tsx b/frontend/src/views/Project/SshPage/components/SshCaModal.tsx similarity index 96% rename from frontend/src/views/Org/SshPage/components/SshCaModal.tsx rename to frontend/src/views/Project/SshPage/components/SshCaModal.tsx index d57633801..f0a2eb685 100644 --- a/frontend/src/views/Org/SshPage/components/SshCaModal.tsx +++ b/frontend/src/views/Project/SshPage/components/SshCaModal.tsx @@ -13,6 +13,7 @@ import { Select, SelectItem } from "@app/components/v2"; +import { useWorkspace } from "@app/context"; import { useCreateSshCa, useGetSshCaById, useUpdateSshCa } from "@app/hooks/api"; import { certKeyAlgorithms } from "@app/hooks/api/certificates/constants"; import { CertKeyAlgorithm } from "@app/hooks/api/certificates/enums"; @@ -38,6 +39,8 @@ const schema = z export type FormData = z.infer; export const SshCaModal = ({ popUp, handlePopUpToggle }: Props) => { + const { currentWorkspace } = useWorkspace(); + const projectId = currentWorkspace?.id || ""; const { data: ca } = useGetSshCaById((popUp?.sshCa?.data as { caId: string })?.caId || ""); const { mutateAsync: createMutateAsync } = useCreateSshCa(); @@ -72,6 +75,8 @@ export const SshCaModal = ({ popUp, handlePopUpToggle }: Props) => { const onFormSubmit = async ({ friendlyName, keyAlgorithm }: FormData) => { try { + if (!projectId) return; + if (ca) { await updateMutateAsync({ caId: ca.id, @@ -79,6 +84,7 @@ export const SshCaModal = ({ popUp, handlePopUpToggle }: Props) => { }); } else { await createMutateAsync({ + projectId, friendlyName, keyAlgorithm }); diff --git a/frontend/src/views/Org/SshPage/components/SshCaSection.tsx b/frontend/src/views/Project/SshPage/components/SshCaSection.tsx similarity index 92% rename from frontend/src/views/Org/SshPage/components/SshCaSection.tsx rename to frontend/src/views/Project/SshPage/components/SshCaSection.tsx index c25ac7eae..98855a450 100644 --- a/frontend/src/views/Org/SshPage/components/SshCaSection.tsx +++ b/frontend/src/views/Project/SshPage/components/SshCaSection.tsx @@ -2,9 +2,9 @@ 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 { ProjectPermissionCan } from "@app/components/permissions"; import { Button, DeleteActionModal } from "@app/components/v2"; -import { OrgPermissionActions, OrgPermissionSubjects } from "@app/context"; +import { ProjectPermissionActions, ProjectPermissionSub } from "@app/context"; import { SshCaStatus, useDeleteSshCa, useUpdateSshCa } from "@app/hooks/api"; import { usePopUp } from "@app/hooks/usePopUp"; @@ -64,9 +64,9 @@ export const SshCaSection = () => {

Certificate Authorities

- {(isAllowed) => ( )} - +
diff --git a/frontend/src/views/Org/SshPage/components/SshCaTable.tsx b/frontend/src/views/Project/SshPage/components/SshCaTable.tsx similarity index 84% rename from frontend/src/views/Org/SshPage/components/SshCaTable.tsx rename to frontend/src/views/Project/SshPage/components/SshCaTable.tsx index 1fe9dd91d..ab2d33dcf 100644 --- a/frontend/src/views/Org/SshPage/components/SshCaTable.tsx +++ b/frontend/src/views/Project/SshPage/components/SshCaTable.tsx @@ -3,7 +3,7 @@ import { faBan, faCertificate, faEllipsis, faTrash } from "@fortawesome/free-sol import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { twMerge } from "tailwind-merge"; -import { OrgPermissionCan } from "@app/components/permissions"; +import { ProjectPermissionCan } from "@app/components/permissions"; import { Badge, DropdownMenu, @@ -21,8 +21,8 @@ import { Tooltip, Tr } from "@app/components/v2"; -import { OrgPermissionActions, OrgPermissionSubjects, useOrganization } from "@app/context"; -import { SshCaStatus , useListOrgSshCas } from "@app/hooks/api"; +import { ProjectPermissionActions, ProjectPermissionSub,useWorkspace } from "@app/context"; +import { SshCaStatus, useListWorkspaceSshCas } from "@app/hooks/api"; import { caStatusToNameMap, getCaStatusBadgeVariant } from "@app/hooks/api/ca/constants"; import { UsePopUpState } from "@app/hooks/usePopUp"; @@ -35,10 +35,8 @@ type Props = { export const SshCaTable = ({ handlePopUpOpen }: Props) => { const router = useRouter(); - const { currentOrg } = useOrganization(); - const { data, isLoading } = useListOrgSshCas({ - orgId: currentOrg?.id ?? "" - }); + const { currentWorkspace } = useWorkspace(); + const { data, isLoading } = useListWorkspaceSshCas(currentWorkspace?.id || ""); return (
@@ -61,7 +59,7 @@ export const SshCaTable = ({ handlePopUpOpen }: Props) => {
router.push(`/org/${currentOrg?.id}/ssh/ca/${ca.id}`)} + onClick={() => router.push(`/project/${currentWorkspace?.id}/ssh/ca/${ca.id}`)} > diff --git a/frontend/src/views/Org/SshPage/components/SshCertificatesSection.tsx b/frontend/src/views/Project/SshPage/components/SshCertificatesSection.tsx similarity index 79% rename from frontend/src/views/Org/SshPage/components/SshCertificatesSection.tsx rename to frontend/src/views/Project/SshPage/components/SshCertificatesSection.tsx index 49b4cfc4a..5d2e7f5fe 100644 --- a/frontend/src/views/Org/SshPage/components/SshCertificatesSection.tsx +++ b/frontend/src/views/Project/SshPage/components/SshCertificatesSection.tsx @@ -1,9 +1,9 @@ import { faPlus } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; -import { OrgPermissionCan } from "@app/components/permissions"; +import { ProjectPermissionCan } from "@app/components/permissions"; import { Button } from "@app/components/v2"; -import { OrgPermissionActions, OrgPermissionSubjects } from "@app/context"; +import { ProjectPermissionActions, ProjectPermissionSub } from "@app/context"; import { usePopUp } from "@app/hooks/usePopUp"; import { SshCertificateModal } from "../../SshCaPage/components/SshCertificateModal"; @@ -15,7 +15,10 @@ export const SshCertificatesSection = () => {

Certificates

- + {(isAllowed) => ( )} - +
diff --git a/frontend/src/views/Org/SshPage/components/SshCertificatesTable.tsx b/frontend/src/views/Project/SshPage/components/SshCertificatesTable.tsx similarity index 89% rename from frontend/src/views/Org/SshPage/components/SshCertificatesTable.tsx rename to frontend/src/views/Project/SshPage/components/SshCertificatesTable.tsx index c7435826b..c8310cc71 100644 --- a/frontend/src/views/Org/SshPage/components/SshCertificatesTable.tsx +++ b/frontend/src/views/Project/SshPage/components/SshCertificatesTable.tsx @@ -15,20 +15,20 @@ import { THead, Tr } from "@app/components/v2"; -import { useOrganization } from "@app/context"; -import { useListOrgSshCertificates } from "@app/hooks/api"; +import { useWorkspace } from "@app/context"; +import { useListWorkspaceSshCertificates } from "@app/hooks/api"; import { getSshCertStatusBadgeDetails } from "./SshCertificatesTable.utils"; const PER_PAGE_INIT = 25; export const SshCertificatesTable = () => { - const { currentOrg } = useOrganization(); + const { currentWorkspace } = useWorkspace(); const [page, setPage] = useState(1); const [perPage, setPerPage] = useState(PER_PAGE_INIT); - const { data, isLoading } = useListOrgSshCertificates({ - orgId: currentOrg?.id ?? "", + const { data, isLoading } = useListWorkspaceSshCertificates({ + projectId: currentWorkspace?.id || "", offset: (page - 1) * perPage, limit: perPage }); diff --git a/frontend/src/views/Org/SshPage/components/SshCertificatesTable.utils.ts b/frontend/src/views/Project/SshPage/components/SshCertificatesTable.utils.ts similarity index 100% rename from frontend/src/views/Org/SshPage/components/SshCertificatesTable.utils.ts rename to frontend/src/views/Project/SshPage/components/SshCertificatesTable.utils.ts diff --git a/frontend/src/views/Org/SshPage/components/index.tsx b/frontend/src/views/Project/SshPage/components/index.tsx similarity index 100% rename from frontend/src/views/Org/SshPage/components/index.tsx rename to frontend/src/views/Project/SshPage/components/index.tsx diff --git a/frontend/src/views/Org/SshPage/index.tsx b/frontend/src/views/Project/SshPage/index.tsx similarity index 100% rename from frontend/src/views/Org/SshPage/index.tsx rename to frontend/src/views/Project/SshPage/index.tsx From 48174e25002087a4e3f2c72fc41277b17f53bfbb Mon Sep 17 00:00:00 2001 From: Tuan Dang Date: Mon, 9 Dec 2024 22:22:54 -0800 Subject: [PATCH 031/162] security + performance improvements to ssh fns --- .../ssh/ssh-certificate-authority-fns.ts | 152 ++++++++++-------- .../ssh/ssh-certificate-authority-service.ts | 14 +- 2 files changed, 88 insertions(+), 78 deletions(-) diff --git a/backend/src/ee/services/ssh/ssh-certificate-authority-fns.ts b/backend/src/ee/services/ssh/ssh-certificate-authority-fns.ts index 82be81be6..cb1549bff 100644 --- a/backend/src/ee/services/ssh/ssh-certificate-authority-fns.ts +++ b/backend/src/ee/services/ssh/ssh-certificate-authority-fns.ts @@ -1,7 +1,10 @@ -import { execSync } from "child_process"; +import { execFile } from "child_process"; import crypto from "crypto"; -import fs from "fs"; +import { promises as fs } from "fs"; import ms from "ms"; +import os from "os"; +import path from "path"; +import { promisify } from "util"; import { TSshCertificateTemplates } from "@app/db/schemas"; import { BadRequestError } from "@app/lib/errors"; @@ -13,6 +16,8 @@ import { } from "../ssh-certificate-template/ssh-certificate-template-validators"; import { SshCertType, TCreateSshCertDTO } from "./ssh-certificate-authority-types"; +const execFileAsync = promisify(execFile); + /* eslint-disable no-bitwise */ export const createSshCertSerialNumber = () => { const randomBytes = crypto.randomBytes(8); // 8 bytes = 64 bits @@ -23,17 +28,17 @@ export const createSshCertSerialNumber = () => { /** * 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. + * @param keyAlgorithm - The key algorithm to use for generating the SSH key pair + * @param comment - The comment to use for the SSH key pair + * @returns The public and private keys for the SSH key pair */ -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 +export const createSshKeyPair = async (keyAlgorithm: CertKeyAlgorithm, comment: string) => { + const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "ssh-key-")); + const privateKeyFile = path.join(tempDir, "id_key"); const publicKeyFile = `${privateKeyFile}.pub`; - if (fs.existsSync(publicKeyFile)) fs.unlinkSync(publicKeyFile); - if (fs.existsSync(privateKeyFile)) fs.unlinkSync(privateKeyFile); - - let keyType = ""; - let keyBits = ""; + let keyType: string; + let keyBits: string; switch (keyAlgorithm) { case CertKeyAlgorithm.RSA_2048: @@ -58,41 +63,40 @@ export const createSshKeyPair = (keyAlgorithm: CertKeyAlgorithm, comment: string }); } - execSync(`ssh-keygen -t ${keyType} -b ${keyBits} -f ${privateKeyFile} -N '' -C "${comment}"`); + try { + // Generate the SSH key pair + // The "-N ''" sets an empty passphrase + // The keys are created in the temporary directory + await execFileAsync("ssh-keygen", ["-t", keyType, "-b", keyBits, "-f", privateKeyFile, "-N", "", "-C", comment]); - const publicKey = fs.readFileSync(publicKeyFile, "utf8"); - const privateKey = fs.readFileSync(privateKeyFile, "utf8"); + // Read the generated keys + const publicKey = await fs.readFile(publicKeyFile, "utf8"); + const privateKey = await fs.readFile(privateKeyFile, "utf8"); - fs.unlinkSync(privateKeyFile); - fs.unlinkSync(publicKeyFile); - - return { publicKey, privateKey }; + return { publicKey, privateKey }; + } finally { + // Cleanup the temporary directory and all its contents + await fs.rm(tempDir, { recursive: true, force: true }).catch(() => {}); + } }; /** * Return the SSH public key for the given SSH private key. * @param privateKey - The SSH private key to get the public key for */ -export const getSshPublicKey = (privateKey: string) => { - const uniqueId = crypto.randomBytes(8).toString("hex"); - const privateKeyFile = `ssh_key_${uniqueId}`; - const publicKeyFile = `${privateKeyFile}.pub`; +export const getSshPublicKey = async (privateKey: string) => { + const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "ssh-key-")); + const privateKeyFile = path.join(tempDir, "id_key"); + try { + await fs.writeFile(privateKeyFile, privateKey, { mode: 0o600 }); - if (fs.existsSync(publicKeyFile)) fs.unlinkSync(publicKeyFile); - if (fs.existsSync(privateKeyFile)) fs.unlinkSync(privateKeyFile); - - fs.writeFileSync(privateKeyFile, privateKey); - fs.chmodSync(privateKeyFile, 0o600); - - const command = `ssh-keygen -y -f ${privateKeyFile} > ${publicKeyFile}`; - execSync(command); - - const publicKey = fs.readFileSync(publicKeyFile, "utf8"); - - fs.unlinkSync(privateKeyFile); - fs.unlinkSync(publicKeyFile); - - return publicKey; + // Run ssh-keygen to extract the public key + const { stdout } = await execFileAsync("ssh-keygen", ["-y", "-f", privateKeyFile], { encoding: "utf8" }); + return stdout.trim(); + } finally { + // Ensure that files and the temporary directory are cleaned up + await fs.rm(tempDir, { recursive: true, force: true }).catch(() => {}); + } }; /** @@ -220,47 +224,53 @@ export const validateSshCertificateTtl = (template: TSshCertificateTemplates, tt /** * 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}`; - const signedPublicKeyFile = `user_key_${uniqueId}-cert.pub`; +export const createSshCert = async ({ + caPrivateKey, + userPublicKey, + keyId, + principals, + ttl, + certType +}: TCreateSshCertDTO) => { + const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "ssh-cert-")); - if (fs.existsSync(publicKeyFile)) fs.unlinkSync(publicKeyFile); - if (fs.existsSync(privateKeyFile)) fs.unlinkSync(privateKeyFile); - if (fs.existsSync(signedPublicKeyFile)) fs.unlinkSync(signedPublicKeyFile); - - // write public and private keys to temp files - fs.writeFileSync(publicKeyFile, userPublicKey); - fs.writeFileSync(privateKeyFile, caPrivateKey); - fs.chmodSync(privateKeyFile, 0o600); + const publicKeyFile = path.join(tempDir, "user_key.pub"); + const privateKeyFile = path.join(tempDir, "ca_key"); + const signedPublicKeyFile = path.join(tempDir, "user_key-cert.pub"); const serialNumber = createSshCertSerialNumber(); - 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(" "); + // Build `ssh-keygen` arguments for signing + // Using an array avoids shell injection issues + const sshKeygenArgs = [ + certType === "host" ? "-h" : null, // host certificate if needed + "-s", + privateKeyFile, // path to SSH CA private key + "-I", + keyId, // identity (key ID) + "-n", + principals.join(","), // principals + "-V", + `+${ttl}s`, // validity (TTL in seconds) + "-z", + serialNumber, // serial number + publicKeyFile // public key file to sign + ].filter(Boolean) as string[]; - const command = `ssh-keygen ${certOptions}`; + try { + // Write public and private keys to the temp directory + await fs.writeFile(publicKeyFile, userPublicKey, { mode: 0o600 }); + await fs.writeFile(privateKeyFile, caPrivateKey, { mode: 0o600 }); - console.log("executing command", command); + // Execute the signing process + await execFileAsync("ssh-keygen", sshKeygenArgs, { encoding: "utf8" }); - // Execute the signing process - execSync(command); + // Read the signed public key from the generated cert file + const signedPublicKey = await fs.readFile(signedPublicKeyFile, "utf8"); - const signedPublicKey = fs.readFileSync(signedPublicKeyFile, "utf8"); - - fs.unlinkSync(publicKeyFile); - fs.unlinkSync(privateKeyFile); - fs.unlinkSync(signedPublicKeyFile); - - return { serialNumber, signedPublicKey }; + return { serialNumber, signedPublicKey }; + } finally { + // Cleanup the temporary directory and all its contents + await fs.rm(tempDir, { recursive: true, force: true }).catch(() => {}); + } }; diff --git a/backend/src/ee/services/ssh/ssh-certificate-authority-service.ts b/backend/src/ee/services/ssh/ssh-certificate-authority-service.ts index 2844407b2..145c35353 100644 --- a/backend/src/ee/services/ssh/ssh-certificate-authority-service.ts +++ b/backend/src/ee/services/ssh/ssh-certificate-authority-service.ts @@ -97,7 +97,7 @@ export const sshCertificateAuthorityServiceFactory = ({ tx ); - const { publicKey, privateKey } = createSshKeyPair(keyAlgorithm, ca.friendlyName); + const { publicKey, privateKey } = await createSshKeyPair(keyAlgorithm, ca.friendlyName); // TODO: update to sshEncryptor const { encryptor: secretManagerEncryptor } = await kmsService.createCipherPairWithDataKey({ @@ -151,7 +151,7 @@ export const sshCertificateAuthorityServiceFactory = ({ cipherTextBlob: sshCaSecret.encryptedPrivateKey }); - const publicKey = getSshPublicKey(decryptedCaPrivateKey.toString("utf-8")); + const publicKey = await getSshPublicKey(decryptedCaPrivateKey.toString("utf-8")); return { ...ca, publicKey }; }; @@ -175,7 +175,7 @@ export const sshCertificateAuthorityServiceFactory = ({ cipherTextBlob: sshCaSecret.encryptedPrivateKey }); - const publicKey = getSshPublicKey(decryptedCaPrivateKey.toString("utf-8")); + const publicKey = await getSshPublicKey(decryptedCaPrivateKey.toString("utf-8")); return publicKey; }; @@ -223,7 +223,7 @@ export const sshCertificateAuthorityServiceFactory = ({ cipherTextBlob: sshCaSecret.encryptedPrivateKey }); - const publicKey = getSshPublicKey(decryptedCaPrivateKey.toString("utf-8")); + const publicKey = await getSshPublicKey(decryptedCaPrivateKey.toString("utf-8")); return { ...updatedCa, publicKey }; }; @@ -329,9 +329,9 @@ export const sshCertificateAuthorityServiceFactory = ({ }); // create user key pair - const { publicKey, privateKey } = createSshKeyPair(keyAlgorithm, "Client Key"); + const { publicKey, privateKey } = await createSshKeyPair(keyAlgorithm, "Client Key"); - const { serialNumber, signedPublicKey } = createSshCert({ + const { serialNumber, signedPublicKey } = await createSshCert({ caPrivateKey: decryptedCaPrivateKey.toString("utf8"), userPublicKey: publicKey, keyId, @@ -460,7 +460,7 @@ export const sshCertificateAuthorityServiceFactory = ({ cipherTextBlob: sshCaSecret.encryptedPrivateKey }); - const { serialNumber, signedPublicKey } = createSshCert({ + const { serialNumber, signedPublicKey } = await createSshCert({ caPrivateKey: decryptedCaPrivateKey.toString("utf8"), userPublicKey: publicKey, keyId, From e32716c2584c18c5bad59106d5be4c725ce28a47 Mon Sep 17 00:00:00 2001 From: McPizza Date: Tue, 10 Dec 2024 14:10:14 +0100 Subject: [PATCH 032/162] improvement: Better group member management (#2851) * improvement: Better org member management --- backend/src/ee/routes/v1/group-router.ts | 7 +- backend/src/ee/services/group/group-dal.ts | 31 ++- .../src/ee/services/group/group-service.ts | 14 +- backend/src/ee/services/group/group-types.ts | 6 + backend/src/lib/api-docs/constants.ts | 4 +- frontend/src/hooks/api/groups/index.tsx | 15 +- frontend/src/hooks/api/groups/mutations.tsx | 6 +- frontend/src/hooks/api/groups/queries.tsx | 38 ++-- frontend/src/hooks/api/groups/types.ts | 17 +- .../pages/org/[id]/groups/[groupId]/index.tsx | 19 ++ .../src/views/Org/GroupPage/GroupPage.tsx | 175 ++++++++++++++++ .../components/AddGroupMemberModal.tsx} | 65 +++--- .../components/GroupCreateUpdateModal.tsx | 192 +++++++++++++++++ .../components/GroupDetailsSection.tsx | 88 ++++++++ .../GroupMembersSection.tsx | 90 ++++++++ .../GroupMembersSection/GroupMembersTable.tsx | 195 ++++++++++++++++++ .../GroupMembershipRow.tsx | 53 +++++ .../components/GroupMembersSection/index.tsx | 1 + .../views/Org/GroupPage/components/index.tsx | 1 + frontend/src/views/Org/GroupPage/index.tsx | 1 + .../OrgGroupsSection/OrgGroupsSection.tsx | 2 - .../OrgGroupsSection/OrgGroupsTable.tsx | 50 ++--- 22 files changed, 974 insertions(+), 96 deletions(-) create mode 100644 frontend/src/pages/org/[id]/groups/[groupId]/index.tsx create mode 100644 frontend/src/views/Org/GroupPage/GroupPage.tsx rename frontend/src/views/Org/{MembersPage/components/OrgGroupsTab/components/OrgGroupsSection/OrgGroupMembersModal.tsx => GroupPage/components/AddGroupMemberModal.tsx} (71%) create mode 100644 frontend/src/views/Org/GroupPage/components/GroupCreateUpdateModal.tsx create mode 100644 frontend/src/views/Org/GroupPage/components/GroupDetailsSection.tsx create mode 100644 frontend/src/views/Org/GroupPage/components/GroupMembersSection/GroupMembersSection.tsx create mode 100644 frontend/src/views/Org/GroupPage/components/GroupMembersSection/GroupMembersTable.tsx create mode 100644 frontend/src/views/Org/GroupPage/components/GroupMembersSection/GroupMembershipRow.tsx create mode 100644 frontend/src/views/Org/GroupPage/components/GroupMembersSection/index.tsx create mode 100644 frontend/src/views/Org/GroupPage/components/index.tsx create mode 100644 frontend/src/views/Org/GroupPage/index.tsx diff --git a/backend/src/ee/routes/v1/group-router.ts b/backend/src/ee/routes/v1/group-router.ts index b2f1762d1..67f955ecb 100644 --- a/backend/src/ee/routes/v1/group-router.ts +++ b/backend/src/ee/routes/v1/group-router.ts @@ -1,6 +1,7 @@ import { z } from "zod"; import { GroupsSchema, OrgMembershipRole, UsersSchema } from "@app/db/schemas"; +import { EFilterReturnedUsers } from "@app/ee/services/group/group-types"; import { GROUPS } from "@app/lib/api-docs"; import { slugSchema } from "@app/server/lib/schemas"; import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; @@ -151,7 +152,8 @@ export const registerGroupRouter = async (server: FastifyZodProvider) => { offset: z.coerce.number().min(0).max(100).default(0).describe(GROUPS.LIST_USERS.offset), limit: z.coerce.number().min(1).max(100).default(10).describe(GROUPS.LIST_USERS.limit), username: z.string().trim().optional().describe(GROUPS.LIST_USERS.username), - search: z.string().trim().optional().describe(GROUPS.LIST_USERS.search) + search: z.string().trim().optional().describe(GROUPS.LIST_USERS.search), + filter: z.nativeEnum(EFilterReturnedUsers).optional().describe(GROUPS.LIST_USERS.filterUsers) }), response: { 200: z.object({ @@ -164,7 +166,8 @@ export const registerGroupRouter = async (server: FastifyZodProvider) => { }) .merge( z.object({ - isPartOfGroup: z.boolean() + isPartOfGroup: z.boolean(), + joinedGroupAt: z.date().nullable() }) ) .array(), diff --git a/backend/src/ee/services/group/group-dal.ts b/backend/src/ee/services/group/group-dal.ts index 5e25f6113..fc38a2a9b 100644 --- a/backend/src/ee/services/group/group-dal.ts +++ b/backend/src/ee/services/group/group-dal.ts @@ -5,6 +5,8 @@ import { TableName, TGroups } from "@app/db/schemas"; import { DatabaseError } from "@app/lib/errors"; import { buildFindFilter, ormify, selectAllTableCols, TFindFilter, TFindOpt } from "@app/lib/knex"; +import { EFilterReturnedUsers } from "./group-types"; + export type TGroupDALFactory = ReturnType; export const groupDALFactory = (db: TDbClient) => { @@ -66,7 +68,8 @@ export const groupDALFactory = (db: TDbClient) => { offset = 0, limit, username, // depreciated in favor of search - search + search, + filter }: { orgId: string; groupId: string; @@ -74,6 +77,7 @@ export const groupDALFactory = (db: TDbClient) => { limit?: number; username?: string; search?: string; + filter?: EFilterReturnedUsers; }) => { try { const query = db @@ -90,6 +94,7 @@ export const groupDALFactory = (db: TDbClient) => { .select( db.ref("id").withSchema(TableName.OrgMembership), db.ref("groupId").withSchema(TableName.UserGroupMembership), + db.ref("createdAt").withSchema(TableName.UserGroupMembership).as("joinedGroupAt"), db.ref("email").withSchema(TableName.Users), db.ref("username").withSchema(TableName.Users), db.ref("firstName").withSchema(TableName.Users), @@ -111,17 +116,37 @@ export const groupDALFactory = (db: TDbClient) => { void query.andWhere(`${TableName.Users}.username`, "ilike", `%${username}%`); } + switch (filter) { + case EFilterReturnedUsers.EXISTING_MEMBERS: + void query.andWhere(`${TableName.UserGroupMembership}.createdAt`, "is not", null); + break; + case EFilterReturnedUsers.NON_MEMBERS: + void query.andWhere(`${TableName.UserGroupMembership}.createdAt`, "is", null); + break; + default: + break; + } + const members = await query; return { members: members.map( - ({ email, username: memberUsername, firstName, lastName, userId, groupId: memberGroupId }) => ({ + ({ + email, + username: memberUsername, + firstName, + lastName, + userId, + groupId: memberGroupId, + joinedGroupAt + }) => ({ id: userId, email, username: memberUsername, firstName, lastName, - isPartOfGroup: !!memberGroupId + isPartOfGroup: !!memberGroupId, + joinedGroupAt }) ), // @ts-expect-error col select is raw and not strongly typed diff --git a/backend/src/ee/services/group/group-service.ts b/backend/src/ee/services/group/group-service.ts index 7e7139a6b..68c48524b 100644 --- a/backend/src/ee/services/group/group-service.ts +++ b/backend/src/ee/services/group/group-service.ts @@ -222,7 +222,8 @@ export const groupServiceFactory = ({ actorId, actorAuthMethod, actorOrgId, - search + search, + filter }: TListGroupUsersDTO) => { if (!actorOrgId) throw new UnauthorizedError({ message: "No organization ID provided in request" }); @@ -251,7 +252,8 @@ export const groupServiceFactory = ({ offset, limit, username, - search + search, + filter }); return { users: members, totalCount }; @@ -283,8 +285,8 @@ export const groupServiceFactory = ({ const { permission: groupRolePermission } = await permissionService.getOrgPermissionByRole(group.role, actorOrgId); // check if user has broader or equal to privileges than group - const hasRequiredPriviledges = isAtLeastAsPrivileged(permission, groupRolePermission); - if (!hasRequiredPriviledges) + const hasRequiredPrivileges = isAtLeastAsPrivileged(permission, groupRolePermission); + if (!hasRequiredPrivileges) throw new ForbiddenRequestError({ message: "Failed to add user to more privileged group" }); const user = await userDAL.findOne({ username }); @@ -338,8 +340,8 @@ export const groupServiceFactory = ({ const { permission: groupRolePermission } = await permissionService.getOrgPermissionByRole(group.role, actorOrgId); // check if user has broader or equal to privileges than group - const hasRequiredPriviledges = isAtLeastAsPrivileged(permission, groupRolePermission); - if (!hasRequiredPriviledges) + const hasRequiredPrivileges = isAtLeastAsPrivileged(permission, groupRolePermission); + if (!hasRequiredPrivileges) throw new ForbiddenRequestError({ message: "Failed to delete user from more privileged group" }); const user = await userDAL.findOne({ username }); diff --git a/backend/src/ee/services/group/group-types.ts b/backend/src/ee/services/group/group-types.ts index a6eb4782b..9424075ca 100644 --- a/backend/src/ee/services/group/group-types.ts +++ b/backend/src/ee/services/group/group-types.ts @@ -39,6 +39,7 @@ export type TListGroupUsersDTO = { limit: number; username?: string; search?: string; + filter?: EFilterReturnedUsers; } & TGenericPermission; export type TAddUserToGroupDTO = { @@ -101,3 +102,8 @@ export type TConvertPendingGroupAdditionsToGroupMemberships = { projectBotDAL: Pick; tx?: Knex; }; + +export enum EFilterReturnedUsers { + EXISTING_MEMBERS = "existingMembers", + NON_MEMBERS = "nonMembers" +} diff --git a/backend/src/lib/api-docs/constants.ts b/backend/src/lib/api-docs/constants.ts index 518654da1..711837326 100644 --- a/backend/src/lib/api-docs/constants.ts +++ b/backend/src/lib/api-docs/constants.ts @@ -19,7 +19,9 @@ export const GROUPS = { offset: "The offset to start from. If you enter 10, it will start from the 10th user.", limit: "The number of users to return.", username: "The username to search for.", - search: "The text string that user email or name will be filtered by." + search: "The text string that user email or name will be filtered by.", + filterUsers: + "Whether to filter the list of returned users. 'existingMembers' will only return existing users in the group, 'nonMembers' will only return users not in the group, undefined will return all users in the organization." }, ADD_USER: { id: "The ID of the group to add the user to.", diff --git a/frontend/src/hooks/api/groups/index.tsx b/frontend/src/hooks/api/groups/index.tsx index 26b38d3a4..c23a55832 100644 --- a/frontend/src/hooks/api/groups/index.tsx +++ b/frontend/src/hooks/api/groups/index.tsx @@ -1,9 +1,8 @@ export { - useAddUserToGroup, - useCreateGroup, - useDeleteGroup, - useRemoveUserFromGroup, - useUpdateGroup} from "./mutations"; -export { - useListGroupUsers -} from "./queries"; \ No newline at end of file + useAddUserToGroup, + useCreateGroup, + useDeleteGroup, + useRemoveUserFromGroup, + useUpdateGroup +} from "./mutations"; +export { useGetGroupById, useListGroupUsers } from "./queries"; diff --git a/frontend/src/hooks/api/groups/mutations.tsx b/frontend/src/hooks/api/groups/mutations.tsx index 445ae10bc..2f5c5984c 100644 --- a/frontend/src/hooks/api/groups/mutations.tsx +++ b/frontend/src/hooks/api/groups/mutations.tsx @@ -56,8 +56,9 @@ export const useUpdateGroup = () => { return group; }, - onSuccess: ({ orgId }) => { + onSuccess: ({ orgId, id: groupId }) => { queryClient.invalidateQueries(organizationKeys.getOrgGroups(orgId)); + queryClient.invalidateQueries(groupKeys.getGroupById(groupId)); } }); }; @@ -70,8 +71,9 @@ export const useDeleteGroup = () => { return group; }, - onSuccess: ({ orgId }) => { + onSuccess: ({ orgId, id: groupId }) => { queryClient.invalidateQueries(organizationKeys.getOrgGroups(orgId)); + queryClient.invalidateQueries(groupKeys.getGroupById(groupId)); } }); }; diff --git a/frontend/src/hooks/api/groups/queries.tsx b/frontend/src/hooks/api/groups/queries.tsx index b239b0a61..dc3791db7 100644 --- a/frontend/src/hooks/api/groups/queries.tsx +++ b/frontend/src/hooks/api/groups/queries.tsx @@ -2,7 +2,10 @@ import { useQuery } from "@tanstack/react-query"; import { apiRequest } from "@app/config/request"; +import { EFilterReturnedUsers, TGroup, TGroupUser } from "./types"; + export const groupKeys = { + getGroupById: (groupId: string) => [{ groupId }, "group"] as const, allGroupUserMemberships: () => ["group-user-memberships"] as const, forGroupUserMemberships: (slug: string) => [...groupKeys.allGroupUserMemberships(), slug] as const, @@ -10,22 +13,27 @@ export const groupKeys = { slug, offset, limit, - search + search, + filter }: { slug: string; offset: number; limit: number; search: string; - }) => [...groupKeys.forGroupUserMemberships(slug), { offset, limit, search }] as const + filter?: EFilterReturnedUsers; + }) => [...groupKeys.forGroupUserMemberships(slug), { offset, limit, search, filter }] as const }; -type TUser = { - id: string; - email: string; - username: string; - firstName: string; - lastName: string; - isPartOfGroup: boolean; +export const useGetGroupById = (groupId: string) => { + return useQuery({ + enabled: Boolean(groupId), + queryKey: groupKeys.getGroupById(groupId), + queryFn: async () => { + const { data } = await apiRequest.get(`/api/v1/groups/${groupId}`); + + return { group: data }; + } + }); }; export const useListGroupUsers = ({ @@ -33,20 +41,23 @@ export const useListGroupUsers = ({ groupSlug, offset = 0, limit = 10, - search + search, + filter }: { id: string; groupSlug: string; offset: number; limit: number; search: string; + filter?: EFilterReturnedUsers; }) => { return useQuery({ queryKey: groupKeys.specificGroupUserMemberships({ slug: groupSlug, offset, limit, - search + search, + filter }), enabled: Boolean(groupSlug), keepPreviousData: true, @@ -54,10 +65,11 @@ export const useListGroupUsers = ({ const params = new URLSearchParams({ offset: String(offset), limit: String(limit), - search + search, + ...(filter && { filter }) }); - const { data } = await apiRequest.get<{ users: TUser[]; totalCount: number }>( + const { data } = await apiRequest.get<{ users: TGroupUser[]; totalCount: number }>( `/api/v1/groups/${id}/users`, { params diff --git a/frontend/src/hooks/api/groups/types.ts b/frontend/src/hooks/api/groups/types.ts index 3f69b9a0e..6bc82b39e 100644 --- a/frontend/src/hooks/api/groups/types.ts +++ b/frontend/src/hooks/api/groups/types.ts @@ -11,7 +11,7 @@ export type TGroup = { name: string; slug: string; orgId: string; - createAt: string; + createdAt: string; updatedAt: string; role: string; }; @@ -41,3 +41,18 @@ export type TGroupWithProjectMemberships = { slug: string; orgId: string; }; + +export type TGroupUser = { + id: string; + email: string; + username: string; + firstName: string; + lastName: string; + isPartOfGroup: boolean; + joinedGroupAt: Date; +}; + +export enum EFilterReturnedUsers { + EXISTING_MEMBERS = "existingMembers", + NON_MEMBERS = "nonMembers" +} diff --git a/frontend/src/pages/org/[id]/groups/[groupId]/index.tsx b/frontend/src/pages/org/[id]/groups/[groupId]/index.tsx new file mode 100644 index 000000000..e193d9bd5 --- /dev/null +++ b/frontend/src/pages/org/[id]/groups/[groupId]/index.tsx @@ -0,0 +1,19 @@ +import { useTranslation } from "react-i18next"; +import Head from "next/head"; + +import { GroupPage } from "@app/views/Org/GroupPage"; + +export default function Group() { + const { t } = useTranslation(); + return ( + <> + + {t("common.head-title", { title: t("settings.org.title") })} + + + + + ); +} + +Group.requireAuth = true; diff --git a/frontend/src/views/Org/GroupPage/GroupPage.tsx b/frontend/src/views/Org/GroupPage/GroupPage.tsx new file mode 100644 index 000000000..acde15760 --- /dev/null +++ b/frontend/src/views/Org/GroupPage/GroupPage.tsx @@ -0,0 +1,175 @@ +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 } from "@app/components/permissions"; +import { + Button, + DeleteActionModal, + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, + Spinner, + Tooltip, + UpgradePlanModal +} from "@app/components/v2"; +import { OrgPermissionActions, OrgPermissionSubjects, useOrganization } from "@app/context"; +import { withPermission } from "@app/hoc"; +import { useDeleteGroup } from "@app/hooks/api"; +import { useGetGroupById } from "@app/hooks/api/groups/queries"; +import { usePopUp } from "@app/hooks/usePopUp"; +import { TabSections } from "@app/views/Org/Types"; + +import { GroupCreateUpdateModal } from "./components/GroupCreateUpdateModal"; +import { GroupMembersSection } from "./components/GroupMembersSection"; +import { GroupDetailsSection } from "./components"; + +export const GroupPage = withPermission( + () => { + const router = useRouter(); + const groupId = router.query.groupId as string; + const { currentOrg } = useOrganization(); + const orgId = currentOrg?.id || ""; + + const { data, isLoading } = useGetGroupById(groupId); + + const { mutateAsync: deleteMutateAsync } = useDeleteGroup(); + + const { popUp, handlePopUpOpen, handlePopUpClose, handlePopUpToggle } = usePopUp([ + "groupCreateUpdate", + "deleteGroup", + "upgradePlan" + ] as const); + + const onDeleteGroupSubmit = async ({ name, id }: { name: string; id: string }) => { + try { + await deleteMutateAsync({ + id + }); + createNotification({ + text: `Successfully deleted the ${name} group`, + type: "success" + }); + router.push(`/org/${orgId}/members?selectedTab=${TabSections.Groups}`); + } catch (err) { + console.error(err); + createNotification({ + text: `Failed to delete the ${name} group`, + type: "error" + }); + } + + handlePopUpClose("deleteGroup"); + }; + + if (isLoading) return ; + + return ( +
+ {data && ( +
+ +
+

{data.group.name}

+ + +
+ + + +
+
+ + + {(isAllowed) => ( + { + handlePopUpOpen("groupCreateUpdate", { + groupId, + name: data.group.name, + slug: data.group.slug, + role: data.group.role + }); + }} + disabled={!isAllowed} + > + Edit Group + + )} + + + {(isAllowed) => ( + { + handlePopUpOpen("deleteGroup", { + id: groupId, + name: data.group.name + }); + }} + disabled={!isAllowed} + > + Delete Group + + )} + + +
+
+
+
+ +
+ +
+
+ )} + + handlePopUpToggle("deleteGroup", isOpen)} + deleteKey="confirm" + onDeleteApproved={() => + onDeleteGroupSubmit(popUp?.deleteGroup?.data as { name: string; id: string }) + } + /> + handlePopUpToggle("upgradePlan", isOpen)} + text={(popUp.upgradePlan?.data as { description: string })?.description} + /> +
+ ); + }, + { action: OrgPermissionActions.Read, subject: OrgPermissionSubjects.Groups } +); diff --git a/frontend/src/views/Org/MembersPage/components/OrgGroupsTab/components/OrgGroupsSection/OrgGroupMembersModal.tsx b/frontend/src/views/Org/GroupPage/components/AddGroupMemberModal.tsx similarity index 71% rename from frontend/src/views/Org/MembersPage/components/OrgGroupsTab/components/OrgGroupsSection/OrgGroupMembersModal.tsx rename to frontend/src/views/Org/GroupPage/components/AddGroupMemberModal.tsx index e7f38318a..ab81aa445 100644 --- a/frontend/src/views/Org/MembersPage/components/OrgGroupsTab/components/OrgGroupsSection/OrgGroupMembersModal.tsx +++ b/frontend/src/views/Org/GroupPage/components/AddGroupMemberModal.tsx @@ -22,21 +22,22 @@ import { } from "@app/components/v2"; import { OrgPermissionActions, OrgPermissionSubjects } from "@app/context"; import { useDebounce, useResetPageHelper } from "@app/hooks"; -import { useAddUserToGroup, useListGroupUsers, useRemoveUserFromGroup } from "@app/hooks/api"; +import { useAddUserToGroup, useListGroupUsers } from "@app/hooks/api"; +import { EFilterReturnedUsers } from "@app/hooks/api/groups/types"; import { UsePopUpState } from "@app/hooks/usePopUp"; type Props = { - popUp: UsePopUpState<["groupMembers"]>; - handlePopUpToggle: (popUpName: keyof UsePopUpState<["groupMembers"]>, state?: boolean) => void; + popUp: UsePopUpState<["addGroupMembers"]>; + handlePopUpToggle: (popUpName: keyof UsePopUpState<["addGroupMembers"]>, state?: boolean) => void; }; -export const OrgGroupMembersModal = ({ popUp, handlePopUpToggle }: Props) => { +export const AddGroupMembersModal = ({ popUp, handlePopUpToggle }: Props) => { const [page, setPage] = useState(1); const [perPage, setPerPage] = useState(10); const [searchMemberFilter, setSearchMemberFilter] = useState(""); const [debouncedSearch] = useDebounce(searchMemberFilter); - const popUpData = popUp?.groupMembers?.data as { + const popUpData = popUp?.addGroupMembers?.data as { groupId: string; slug: string; }; @@ -47,7 +48,8 @@ export const OrgGroupMembersModal = ({ popUp, handlePopUpToggle }: Props) => { groupSlug: popUpData?.slug, offset, limit: perPage, - search: debouncedSearch + search: debouncedSearch, + filter: EFilterReturnedUsers.NON_MEMBERS }); const { totalCount = 0 } = data ?? {}; @@ -58,36 +60,31 @@ export const OrgGroupMembersModal = ({ popUp, handlePopUpToggle }: Props) => { setPage }); - const { mutateAsync: assignMutateAsync } = useAddUserToGroup(); - const { mutateAsync: unassignMutateAsync } = useRemoveUserFromGroup(); + const { mutateAsync: addUserToGroupMutateAsync } = useAddUserToGroup(); - const handleAssignment = async (username: string, assign: boolean) => { + const handleAddMember = async (username: string) => { try { - if (!popUpData?.slug) return; - - if (assign) { - await assignMutateAsync({ - groupId: popUpData.groupId, - username, - slug: popUpData.slug - }); - } else { - await unassignMutateAsync({ - groupId: popUpData.groupId, - username, - slug: popUpData.slug + if (!popUpData?.slug) { + createNotification({ + text: "Some data is missing, please refresh the page and try again", + type: "error" }); + return; } + await addUserToGroupMutateAsync({ + groupId: popUpData.groupId, + username, + slug: popUpData.slug + }); + createNotification({ - text: `Successfully ${assign ? "assigned" : "removed"} user ${ - assign ? "to" : "from" - } group`, + text: "Successfully assigned user to the group", type: "success" }); } catch (err) { createNotification({ - text: `Failed to ${assign ? "assign" : "remove"} user ${assign ? "to" : "from"} group`, + text: "Failed to assign user to the group", type: "error" }); } @@ -95,12 +92,12 @@ export const OrgGroupMembersModal = ({ popUp, handlePopUpToggle }: Props) => { return ( { - handlePopUpToggle("groupMembers", isOpen); + handlePopUpToggle("addGroupMembers", isOpen); }} > - + setSearchMemberFilter(e.target.value)} @@ -118,7 +115,7 @@ export const OrgGroupMembersModal = ({ popUp, handlePopUpToggle }: Props) => {
{isLoading && } {!isLoading && - data?.users?.map(({ id, firstName, lastName, username, isPartOfGroup }) => { + data?.users?.map(({ id, firstName, lastName, username }) => { return ( + + + + + + ); +}; diff --git a/frontend/src/views/Org/GroupPage/components/GroupMembersSection/index.tsx b/frontend/src/views/Org/GroupPage/components/GroupMembersSection/index.tsx new file mode 100644 index 000000000..70c696609 --- /dev/null +++ b/frontend/src/views/Org/GroupPage/components/GroupMembersSection/index.tsx @@ -0,0 +1 @@ +export { GroupMembersSection } from "./GroupMembersSection"; diff --git a/frontend/src/views/Org/GroupPage/components/index.tsx b/frontend/src/views/Org/GroupPage/components/index.tsx new file mode 100644 index 000000000..003c47910 --- /dev/null +++ b/frontend/src/views/Org/GroupPage/components/index.tsx @@ -0,0 +1 @@ +export { GroupDetailsSection } from "./GroupDetailsSection"; diff --git a/frontend/src/views/Org/GroupPage/index.tsx b/frontend/src/views/Org/GroupPage/index.tsx new file mode 100644 index 000000000..3dec23a1c --- /dev/null +++ b/frontend/src/views/Org/GroupPage/index.tsx @@ -0,0 +1 @@ +export { GroupPage } from "./GroupPage"; diff --git a/frontend/src/views/Org/MembersPage/components/OrgGroupsTab/components/OrgGroupsSection/OrgGroupsSection.tsx b/frontend/src/views/Org/MembersPage/components/OrgGroupsTab/components/OrgGroupsSection/OrgGroupsSection.tsx index f72adf61f..9c3949150 100644 --- a/frontend/src/views/Org/MembersPage/components/OrgGroupsTab/components/OrgGroupsSection/OrgGroupsSection.tsx +++ b/frontend/src/views/Org/MembersPage/components/OrgGroupsTab/components/OrgGroupsSection/OrgGroupsSection.tsx @@ -8,7 +8,6 @@ import { OrgPermissionActions, OrgPermissionSubjects, useSubscription } from "@a import { useDeleteGroup } from "@app/hooks/api"; import { usePopUp } from "@app/hooks/usePopUp"; -import { OrgGroupMembersModal } from "./OrgGroupMembersModal"; import { OrgGroupModal } from "./OrgGroupModal"; import { OrgGroupsTable } from "./OrgGroupsTable"; @@ -78,7 +77,6 @@ export const OrgGroupsSection = () => { handlePopUpClose={handlePopUpClose} handlePopUpToggle={handlePopUpToggle} /> - { + const router = useRouter(); const { currentOrg } = useOrganization(); const orgId = currentOrg?.id || ""; const { isLoading, data: groups = [] } = useGetOrganizationGroups(orgId); @@ -223,7 +225,11 @@ export const OrgGroupsTable = ({ handlePopUpOpen }: Props) => { .slice(offset, perPage * page) .map(({ id, name, slug, role, customRole }) => { return ( - + router.push(`/org/${orgId}/groups/${id}`)} + className="h-10 cursor-pointer transition-colors duration-100 hover:bg-mineshaft-700" + key={`org-group-${id}`} + >
Serial NumberCertificate Type PrincipalsStatusNot BeforeNot After
{certificate.serialNumber}{sshCertTypeToNameMap[certificate.certType]} {certificate.principals.join(", ")} + {label} + + {certificate.notBefore + ? format(new Date(certificate.notBefore), "yyyy-MM-dd") + : "-"} + + {certificate.notAfter + ? format(new Date(certificate.notAfter), "yyyy-MM-dd") + : "-"} +
{ca.friendlyName} @@ -81,9 +79,9 @@ export const SshCaTable = ({ handlePopUpOpen }: Props) => { {(ca.status === SshCaStatus.ACTIVE || ca.status === SshCaStatus.DISABLED) && ( - {(isAllowed) => ( { } SSH CA`} )} - + )} - {(isAllowed) => ( { Delete SSH CA )} - +
@@ -138,9 +135,9 @@ export const OrgGroupMembersModal = ({ popUp, handlePopUpToggle }: Props) => { colorSchema="primary" variant="outline_bg" type="submit" - onClick={() => handleAssignment(username, !isPartOfGroup)} + onClick={() => handleAddMember(username)} > - {isPartOfGroup ? "Unassign" : "Assign"} + Assign ); }} @@ -162,7 +159,9 @@ export const OrgGroupMembersModal = ({ popUp, handlePopUpToggle }: Props) => { )} {!isLoading && !data?.users?.length && ( )} diff --git a/frontend/src/views/Org/GroupPage/components/GroupCreateUpdateModal.tsx b/frontend/src/views/Org/GroupPage/components/GroupCreateUpdateModal.tsx new file mode 100644 index 000000000..39187f3cb --- /dev/null +++ b/frontend/src/views/Org/GroupPage/components/GroupCreateUpdateModal.tsx @@ -0,0 +1,192 @@ +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, + FilterableSelect, + FormControl, + Input, + Modal, + ModalContent +} from "@app/components/v2"; +import { useOrganization } from "@app/context"; +import { findOrgMembershipRole } from "@app/helpers/roles"; +import { useCreateGroup, useGetOrgRoles, useUpdateGroup } from "@app/hooks/api"; +import { UsePopUpState } from "@app/hooks/usePopUp"; + +const GroupFormSchema = z.object({ + name: z.string().min(1, "Name cannot be empty").max(50, "Name must be 50 characters or fewer"), + slug: z + .string() + .min(5, "Slug must be at least 5 characters long") + .max(36, "Slug must be 36 characters or fewer"), + role: z.object({ name: z.string(), slug: z.string() }) +}); + +export type TGroupFormData = z.infer; + +type Props = { + popUp: UsePopUpState<["groupCreateUpdate"]>; + handlePopUpClose: (popUpName: keyof UsePopUpState<["groupCreateUpdate"]>) => void; + handlePopUpToggle: ( + popUpName: keyof UsePopUpState<["groupCreateUpdate"]>, + state?: boolean + ) => void; +}; + +export const GroupCreateUpdateModal = ({ popUp, handlePopUpClose, handlePopUpToggle }: Props) => { + const { currentOrg } = useOrganization(); + const { data: roles } = useGetOrgRoles(currentOrg?.id || ""); + const { mutateAsync: createMutateAsync, isLoading: createIsLoading } = useCreateGroup(); + const { mutateAsync: updateMutateAsync, isLoading: updateIsLoading } = useUpdateGroup(); + + const { control, handleSubmit, reset } = useForm({ + resolver: zodResolver(GroupFormSchema) + }); + + useEffect(() => { + const group = popUp?.groupCreateUpdate?.data as { + groupId: string; + name: string; + slug: string; + role: string; + customRole: { + name: string; + slug: string; + }; + }; + + if (!roles?.length) return; + + if (group) { + reset({ + name: group.name, + slug: group.slug, + role: group?.customRole ?? findOrgMembershipRole(roles, group.role) + }); + } else { + reset({ + name: "", + slug: "", + role: findOrgMembershipRole(roles, currentOrg!.defaultMembershipRole) + }); + } + }, [popUp?.groupCreateUpdate?.data, roles]); + + const onGroupModalSubmit = async ({ name, slug, role }: TGroupFormData) => { + try { + if (!currentOrg?.id) return; + + const group = popUp?.groupCreateUpdate?.data as { + groupId: string; + name: string; + slug: string; + }; + + if (group) { + await updateMutateAsync({ + id: group.groupId, + name, + slug, + role: role.slug || undefined + }); + } else { + await createMutateAsync({ + name, + slug, + organizationId: currentOrg.id, + role: role.slug || undefined + }); + } + handlePopUpToggle("groupCreateUpdate", false); + reset(); + + createNotification({ + text: `Successfully ${popUp?.groupCreateUpdate?.data ? "updated" : "created"} group`, + type: "success" + }); + } catch (err) { + createNotification({ + text: `Failed to ${popUp?.groupCreateUpdate?.data ? "updated" : "created"} group`, + type: "error" + }); + } + }; + + return ( + { + handlePopUpToggle("groupCreateUpdate", isOpen); + reset(); + }} + > + + + ( + + + + )} + /> + ( + + + + )} + /> + ( + + option.slug} + getOptionLabel={(option) => option.name} + /> + + )} + /> +
+ + +
+ +
+
+ ); +}; diff --git a/frontend/src/views/Org/GroupPage/components/GroupDetailsSection.tsx b/frontend/src/views/Org/GroupPage/components/GroupDetailsSection.tsx new file mode 100644 index 000000000..624cc7241 --- /dev/null +++ b/frontend/src/views/Org/GroupPage/components/GroupDetailsSection.tsx @@ -0,0 +1,88 @@ +import { faPencil } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; + +import { OrgPermissionCan } from "@app/components/permissions"; +import { IconButton, Spinner, Tooltip } from "@app/components/v2"; +import { CopyButton } from "@app/components/v2/CopyButton"; +import { OrgPermissionActions, OrgPermissionSubjects } from "@app/context"; +import { useGetGroupById } from "@app/hooks/api/"; +import { UsePopUpState } from "@app/hooks/usePopUp"; + +type Props = { + groupId: string; + handlePopUpOpen: (popUpName: keyof UsePopUpState<["groupCreateUpdate"]>, data?: {}) => void; +}; + +export const GroupDetailsSection = ({ groupId, handlePopUpOpen }: Props) => { + const { data, isLoading } = useGetGroupById(groupId); + + if (isLoading) return ; + + return data ? ( +
+
+

Group Details

+ + {(isAllowed) => { + return ( + + { + handlePopUpOpen("groupCreateUpdate", { + groupId, + name: data.group.name, + slug: data.group.slug, + role: data.group.role + }); + }} + > + + + + ); + }} + +
+
+
+

Group ID

+
+

{data.group.id}

+ +
+
+
+

Name

+

{data.group.name}

+
+
+

Slug

+
+

{data.group.slug}

+ +
+
+
+

Organization Role

+

{data.group.role}

+
+
+

Created At

+

+ {new Date(data.group.createdAt).toLocaleString()} +

+
+
+
+ ) : ( +
+
+

Group data not found

+
+
+ ); +}; diff --git a/frontend/src/views/Org/GroupPage/components/GroupMembersSection/GroupMembersSection.tsx b/frontend/src/views/Org/GroupPage/components/GroupMembersSection/GroupMembersSection.tsx new file mode 100644 index 000000000..08de6c724 --- /dev/null +++ b/frontend/src/views/Org/GroupPage/components/GroupMembersSection/GroupMembersSection.tsx @@ -0,0 +1,90 @@ +import { faPlus } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; + +import { createNotification } from "@app/components/notifications"; +import { DeleteActionModal, IconButton } from "@app/components/v2"; +import { useRemoveUserFromGroup } from "@app/hooks/api"; +import { usePopUp } from "@app/hooks/usePopUp"; + +import { AddGroupMembersModal } from "../AddGroupMemberModal"; +import { GroupMembersTable } from "./GroupMembersTable"; + +type Props = { + groupId: string; + groupSlug: string; +}; + +export const GroupMembersSection = ({ groupId, groupSlug }: Props) => { + const { popUp, handlePopUpOpen, handlePopUpToggle } = usePopUp([ + "addGroupMembers", + "removeMemberFromGroup" + ] as const); + + const { mutateAsync: removeUserFromGroupMutateAsync } = useRemoveUserFromGroup(); + const handleRemoveUserFromGroup = async (username: string) => { + try { + await removeUserFromGroupMutateAsync({ + groupId, + username, + slug: groupSlug + }); + + createNotification({ + text: `Successfully removed user ${username} from the group`, + type: "success" + }); + + handlePopUpToggle("removeMemberFromGroup", false); + } catch (err) { + createNotification({ + text: `Failed to remove user ${username} from the group`, + type: "error" + }); + } + }; + + return ( +
+
+

Group Members

+ { + handlePopUpOpen("addGroupMembers", { + groupId, + slug: groupSlug + }); + }} + > + + +
+
+ +
+ + handlePopUpToggle("removeMemberFromGroup", isOpen)} + deleteKey="confirm" + onDeleteApproved={() => { + const userData = popUp?.removeMemberFromGroup?.data as { + username: string; + id: string; + }; + + return handleRemoveUserFromGroup(userData.username); + }} + /> +
+ ); +}; diff --git a/frontend/src/views/Org/GroupPage/components/GroupMembersSection/GroupMembersTable.tsx b/frontend/src/views/Org/GroupPage/components/GroupMembersSection/GroupMembersTable.tsx new file mode 100644 index 000000000..2423fd6d5 --- /dev/null +++ b/frontend/src/views/Org/GroupPage/components/GroupMembersSection/GroupMembersTable.tsx @@ -0,0 +1,195 @@ +import { useMemo } from "react"; +import { + faArrowDown, + faArrowUp, + faFolder, + faMagnifyingGlass, + faSearch +} from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; + +import { OrgPermissionCan } from "@app/components/permissions"; +import { + Button, + EmptyState, + IconButton, + Input, + Pagination, + Table, + TableContainer, + TableSkeleton, + TBody, + Th, + THead, + Tr +} from "@app/components/v2"; +import { OrgPermissionActions, OrgPermissionSubjects } from "@app/context"; +import { usePagination, useResetPageHelper } from "@app/hooks"; +import { useListGroupUsers } from "@app/hooks/api"; +import { OrderByDirection } from "@app/hooks/api/generic/types"; +import { EFilterReturnedUsers } from "@app/hooks/api/groups/types"; +import { UsePopUpState } from "@app/hooks/usePopUp"; + +import { GroupMembershipRow } from "./GroupMembershipRow"; + +type Props = { + groupId: string; + groupSlug: string; + handlePopUpOpen: ( + popUpName: keyof UsePopUpState<["removeMemberFromGroup", "addGroupMembers"]>, + data?: {} + ) => void; +}; + +enum GroupMembersOrderBy { + Name = "name" +} + +export const GroupMembersTable = ({ groupId, groupSlug, handlePopUpOpen }: Props) => { + const { + search, + setSearch, + setPage, + page, + perPage, + setPerPage, + offset, + orderDirection, + toggleOrderDirection + } = usePagination(GroupMembersOrderBy.Name, { initPerPage: 10 }); + + const { data: groupMemberships, isLoading } = useListGroupUsers({ + id: groupId, + groupSlug, + offset, + limit: perPage, + search, + filter: EFilterReturnedUsers.EXISTING_MEMBERS + }); + + const filteredGroupMemberships = useMemo(() => { + return groupMemberships && groupMemberships?.users + ? groupMemberships?.users + ?.filter((membership) => { + const userSearchString = `${membership.firstName && membership.firstName} ${ + membership.lastName && membership.lastName + } ${membership.email && membership.email} ${ + membership.username && membership.username + }`; + return userSearchString.toLowerCase().includes(search.trim().toLowerCase()); + }) + .sort((a, b) => { + const [membershipOne, membershipTwo] = + orderDirection === OrderByDirection.ASC ? [a, b] : [b, a]; + + const membershipOneComparisonString = membershipOne.firstName + ? membershipOne.firstName + : membershipOne.email; + + const membershipTwoComparisonString = membershipTwo.firstName + ? membershipTwo.firstName + : membershipTwo.email; + + const comparison = membershipOneComparisonString + .toLowerCase() + .localeCompare(membershipTwoComparisonString.toLowerCase()); + + return comparison; + }) + : []; + }, [groupMemberships, orderDirection, search]); + + useResetPageHelper({ + totalCount: filteredGroupMemberships?.length, + offset, + setPage + }); + + return ( +
+ setSearch(e.target.value)} + leftIcon={} + placeholder="Search users..." + /> + + + + + + + + + + + {isLoading && } + {!isLoading && + filteredGroupMemberships.slice(offset, perPage * page).map((userGroupMembership) => { + return ( + + ); + })} + +
+
+ Name + + + +
+
EmailAdded On +
+ {Boolean(filteredGroupMemberships.length) && ( + + )} + {!isLoading && !filteredGroupMemberships?.length && ( + + )} + {!groupMemberships?.users.length && ( + + {(isAllowed) => ( +
+ +
+ )} +
+ )} +
+
+ ); +}; diff --git a/frontend/src/views/Org/GroupPage/components/GroupMembersSection/GroupMembershipRow.tsx b/frontend/src/views/Org/GroupPage/components/GroupMembersSection/GroupMembershipRow.tsx new file mode 100644 index 000000000..943a6574e --- /dev/null +++ b/frontend/src/views/Org/GroupPage/components/GroupMembersSection/GroupMembershipRow.tsx @@ -0,0 +1,53 @@ +import { faUserMinus } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; + +import { OrgPermissionCan } from "@app/components/permissions"; +import { IconButton, Td, Tooltip, Tr } from "@app/components/v2"; +import { OrgPermissionActions, OrgPermissionSubjects } from "@app/context"; +import { TGroupUser } from "@app/hooks/api/groups/types"; +import { UsePopUpState } from "@app/hooks/usePopUp"; + +type Props = { + user: TGroupUser; + handlePopUpOpen: (popUpName: keyof UsePopUpState<["removeMemberFromGroup"]>, data?: {}) => void; +}; + +export const GroupMembershipRow = ({ + user: { firstName, lastName, username, joinedGroupAt, email, id }, + handlePopUpOpen +}: Props) => { + return ( +
+

{`${firstName ?? "-"} ${lastName ?? ""}`}

+
+

{email}

+
+ +

{new Date(joinedGroupAt).toLocaleDateString()}

+
+
+ + {(isAllowed) => { + return ( + + handlePopUpOpen("removeMemberFromGroup", { username })} + variant="plain" + colorSchema="danger" + > + + + + ); + }} + +
{name} {slug} @@ -277,30 +283,7 @@ export const OrgGroupsTable = ({ handlePopUpOpen }: Props) => { - {(isAllowed) => ( - { - e.stopPropagation(); - handlePopUpOpen("groupMembers", { - groupId: id, - slug - }); - }} - disabled={!isAllowed} - > - Manage Users - - )} - - {(isAllowed) => ( { )} + + {(isAllowed) => ( + router.push(`/org/${orgId}/groups/${id}`)} + disabled={!isAllowed} + > + Manage Members + + )} + Date: Tue, 10 Dec 2024 23:10:44 +0800 Subject: [PATCH 033/162] feat: finished crud endpoints --- .../20241209144123_add-identity-jwt-auth.ts | 14 +- backend/src/db/schemas/identity-jwt-auths.ts | 14 +- .../ee/services/audit-log/audit-log-types.ts | 60 +++ backend/src/lib/api-docs/constants.ts | 26 +- .../routes/v1/identity-jwt-auth-router.ts | 356 ++++++++++++++++-- .../identity-jwt-auth-service.ts | 216 ++++++++++- .../identity-jwt-auth-types.ts | 24 ++ 7 files changed, 650 insertions(+), 60 deletions(-) diff --git a/backend/src/db/migrations/20241209144123_add-identity-jwt-auth.ts b/backend/src/db/migrations/20241209144123_add-identity-jwt-auth.ts index 2e7ac4b63..03594b77c 100644 --- a/backend/src/db/migrations/20241209144123_add-identity-jwt-auth.ts +++ b/backend/src/db/migrations/20241209144123_add-identity-jwt-auth.ts @@ -14,13 +14,13 @@ export async function up(knex: Knex): Promise { t.uuid("identityId").notNullable().unique(); t.foreign("identityId").references("id").inTable(TableName.Identity).onDelete("CASCADE"); t.string("configurationType").notNullable(); - t.string("jwksUrl"); - t.binary("encryptedJwksCaCert"); - t.binary("encryptedPublicKeys"); - t.string("boundIssuer"); - t.string("boundAudiences"); - t.jsonb("boundClaims"); - t.string("boundSubject"); + t.string("jwksUrl").notNullable(); + t.binary("encryptedJwksCaCert").notNullable(); + t.binary("encryptedPublicKeys").notNullable(); + t.string("boundIssuer").notNullable(); + t.string("boundAudiences").notNullable(); + t.jsonb("boundClaims").notNullable(); + t.string("boundSubject").notNullable(); t.timestamps(true, true, true); }); diff --git a/backend/src/db/schemas/identity-jwt-auths.ts b/backend/src/db/schemas/identity-jwt-auths.ts index a67fa186e..1d3ea9c03 100644 --- a/backend/src/db/schemas/identity-jwt-auths.ts +++ b/backend/src/db/schemas/identity-jwt-auths.ts @@ -17,13 +17,13 @@ export const IdentityJwtAuthsSchema = z.object({ accessTokenTrustedIps: z.unknown(), identityId: z.string().uuid(), configurationType: z.string(), - jwksUrl: z.string().nullable().optional(), - encryptedJwksCaCert: zodBuffer.nullable().optional(), - encryptedPublicKeys: zodBuffer.nullable().optional(), - boundIssuer: z.string().nullable().optional(), - boundAudiences: z.string().nullable().optional(), - boundClaims: z.unknown().nullable().optional(), - boundSubject: z.string().nullable().optional(), + jwksUrl: z.string(), + encryptedJwksCaCert: zodBuffer, + encryptedPublicKeys: zodBuffer, + boundIssuer: z.string(), + boundAudiences: z.string(), + boundClaims: z.unknown(), + boundSubject: z.string(), createdAt: z.date(), updatedAt: z.date() }); 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..4e747e4bb 100644 --- a/backend/src/ee/services/audit-log/audit-log-types.ts +++ b/backend/src/ee/services/audit-log/audit-log-types.ts @@ -94,6 +94,10 @@ export enum EventType { UPDATE_IDENTITY_OIDC_AUTH = "update-identity-oidc-auth", GET_IDENTITY_OIDC_AUTH = "get-identity-oidc-auth", REVOKE_IDENTITY_OIDC_AUTH = "revoke-identity-oidc-auth", + ADD_IDENTITY_JWT_AUTH = "add-identity-jwt-auth", + UPDATE_IDENTITY_JWT_AUTH = "update-identity-jwt-auth", + GET_IDENTITY_JWT_AUTH = "get-identity-jwt-auth", + REVOKE_IDENTITY_JWT_AUTH = "revoke-identity-jwt-auth", CREATE_IDENTITY_UNIVERSAL_AUTH_CLIENT_SECRET = "create-identity-universal-auth-client-secret", REVOKE_IDENTITY_UNIVERSAL_AUTH_CLIENT_SECRET = "revoke-identity-universal-auth-client-secret", GET_IDENTITY_UNIVERSAL_AUTH_CLIENT_SECRETS = "get-identity-universal-auth-client-secret", @@ -895,6 +899,58 @@ interface GetIdentityOidcAuthEvent { }; } +interface AddIdentityJwtAuthEvent { + type: EventType.ADD_IDENTITY_JWT_AUTH; + metadata: { + identityId: string; + configurationType: string; + jwksUrl?: string; + jwksCaCert: string; + publicKeys: string[]; + boundIssuer: string; + boundAudiences: string; + boundClaims: Record; + boundSubject: string; + accessTokenTTL: number; + accessTokenMaxTTL: number; + accessTokenNumUsesLimit: number; + accessTokenTrustedIps: Array; + }; +} + +interface UpdateIdentityJwtAuthEvent { + type: EventType.UPDATE_IDENTITY_JWT_AUTH; + metadata: { + identityId: string; + configurationType?: string; + jwksUrl?: string; + jwksCaCert?: string; + publicKeys?: string[]; + boundIssuer?: string; + boundAudiences?: string; + boundClaims?: Record; + boundSubject?: string; + accessTokenTTL?: number; + accessTokenMaxTTL?: number; + accessTokenNumUsesLimit?: number; + accessTokenTrustedIps?: Array; + }; +} + +interface DeleteIdentityJwtAuthEvent { + type: EventType.REVOKE_IDENTITY_JWT_AUTH; + metadata: { + identityId: string; + }; +} + +interface GetIdentityJwtAuthEvent { + type: EventType.GET_IDENTITY_JWT_AUTH; + metadata: { + identityId: string; + }; +} + interface CreateEnvironmentEvent { type: EventType.CREATE_ENVIRONMENT; metadata: { @@ -1733,6 +1789,10 @@ export type Event = | DeleteIdentityOidcAuthEvent | UpdateIdentityOidcAuthEvent | GetIdentityOidcAuthEvent + | AddIdentityJwtAuthEvent + | UpdateIdentityJwtAuthEvent + | GetIdentityJwtAuthEvent + | DeleteIdentityJwtAuthEvent | CreateEnvironmentEvent | GetEnvironmentEvent | UpdateEnvironmentEvent diff --git a/backend/src/lib/api-docs/constants.ts b/backend/src/lib/api-docs/constants.ts index 1debf8d60..4a2e0cdc8 100644 --- a/backend/src/lib/api-docs/constants.ts +++ b/backend/src/lib/api-docs/constants.ts @@ -355,14 +355,13 @@ export const JWT_AUTH = { }, ATTACH: { identityId: "The ID of the identity to attach the configuration onto.", - caCert: "The PEM-encoded CA cert for establishing secure communication with the Identity Provider endpoints.", configurationType: "The configuration for validating JWTs. Must be one of: 'jwks', 'static'", jwksUrl: "The URL of the JWKS endpoint. Required if configurationType is 'jwks'. This endpoint must serve JSON Web Key Sets (JWKS) containing the public keys used to verify JWT signatures.", jwksCaCert: "The PEM-encoded CA certificate for validating the TLS connection to the JWKS endpoint.", publicKeys: "A list of PEM-encoded public keys used to verify JWT signatures. Required if configurationType is 'static'. Each key must be in RSA or ECDSA format and properly PEM-encoded with BEGIN/END markers.", - boundIssuer: "The unique identifier of the identity provider issuing the JWT.", + boundIssuer: "The unique identifier of the JWT provider.", boundAudiences: "The list of intended recipients.", boundClaims: "The attributes that should be present in the JWT for it to be valid.", boundSubject: "The expected principal that is the subject of the JWT.", @@ -370,6 +369,29 @@ export const JWT_AUTH = { accessTokenTTL: "The lifetime for an access token in seconds.", accessTokenMaxTTL: "The maximum lifetime for an access token in seconds.", accessTokenNumUsesLimit: "The maximum number of times that an access token can be used." + }, + UPDATE: { + identityId: "The ID of the identity to update the auth method for.", + configurationType: "The new configuration for validating JWTs. Must be one of: 'jwks', 'static'", + jwksUrl: + "The new URL of the JWKS endpoint. This endpoint must serve JSON Web Key Sets (JWKS) containing the public keys used to verify JWT signatures.", + jwksCaCert: "The new PEM-encoded CA certificate for validating the TLS connection to the JWKS endpoint.", + publicKeys: + "A new list of PEM-encoded public keys used to verify JWT signatures. Each key must be in RSA or ECDSA format and properly PEM-encoded with BEGIN/END markers.", + boundIssuer: "The new unique identifier of the JWT provider.", + boundAudiences: "The new list of intended recipients.", + boundClaims: "The new attributes that should be present in the JWT for it to be valid.", + boundSubject: "The new expected principal that is the subject of the JWT.", + accessTokenTrustedIps: "The new IPs or CIDR ranges that access tokens can be used from.", + accessTokenTTL: "The new lifetime for an access token in seconds.", + accessTokenMaxTTL: "The new maximum lifetime for an access token in seconds.", + accessTokenNumUsesLimit: "The new maximum number of times that an access token can be used." + }, + RETRIEVE: { + identityId: "The ID of the identity to retrieve the auth method for." + }, + REVOKE: { + identityId: "The ID of the identity to revoke the auth method for." } } as const; diff --git a/backend/src/server/routes/v1/identity-jwt-auth-router.ts b/backend/src/server/routes/v1/identity-jwt-auth-router.ts index 6c9d2ae4a..758df922a 100644 --- a/backend/src/server/routes/v1/identity-jwt-auth-router.ts +++ b/backend/src/server/routes/v1/identity-jwt-auth-router.ts @@ -1,10 +1,12 @@ import { z } from "zod"; import { IdentityJwtAuthsSchema } from "@app/db/schemas"; +import { EventType } from "@app/ee/services/audit-log/audit-log-types"; import { JWT_AUTH } from "@app/lib/api-docs"; -import { writeLimit } from "@app/server/config/rateLimiter"; +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 { TIdentityTrustedIp } from "@app/services/identity/identity-types"; import { JwtConfigurationType } from "@app/services/identity-jwt-auth/identity-jwt-auth-types"; import { validateJwtAuthAudiencesField, @@ -16,7 +18,7 @@ const IdentityJwtAuthResponseSchema = IdentityJwtAuthsSchema.omit({ encryptedPublicKeys: true }).extend({ jwksCaCert: z.string(), - publicKeys: z.string() + publicKeys: z.string().array() }); export const registerIdentityJwtAuthRouter = async (server: FastifyZodProvider) => { @@ -37,43 +39,246 @@ export const registerIdentityJwtAuthRouter = async (server: FastifyZodProvider) params: z.object({ identityId: z.string().trim().describe(JWT_AUTH.ATTACH.identityId) }), - body: z.object({ - configurationType: z.nativeEnum(JwtConfigurationType).describe(JWT_AUTH.ATTACH.configurationType), - jwksUrl: z.string().describe(JWT_AUTH.ATTACH.jwksUrl), - jwksCaCert: z.string().describe(JWT_AUTH.ATTACH.jwksCaCert), - publicKeys: z.string().array().describe(JWT_AUTH.ATTACH.publicKeys), - boundIssuer: z.string().min(1).describe(JWT_AUTH.ATTACH.boundIssuer), - boundAudiences: validateJwtAuthAudiencesField.describe(JWT_AUTH.ATTACH.boundAudiences), - boundClaims: validateJwtBoundClaimsField.describe(JWT_AUTH.ATTACH.boundClaims), - boundSubject: z.string().optional().default("").describe(JWT_AUTH.ATTACH.boundSubject), - accessTokenTrustedIps: z - .object({ - ipAddress: z.string().trim() - }) - .array() - .min(1) - .default([{ ipAddress: "0.0.0.0/0" }, { ipAddress: "::/0" }]) - .describe(JWT_AUTH.ATTACH.accessTokenTrustedIps), - accessTokenTTL: z - .number() - .int() - .min(1) - .max(315360000) - .refine((value) => value !== 0, { - message: "accessTokenTTL must have a non zero number" - }) - .default(2592000) - .describe(JWT_AUTH.ATTACH.accessTokenTTL), - accessTokenMaxTTL: z - .number() - .int() - .max(315360000) - .refine((value) => value !== 0, { - message: "accessTokenMaxTTL must have a non zero number" - }) - .default(2592000) - .describe(JWT_AUTH.ATTACH.accessTokenMaxTTL), - accessTokenNumUsesLimit: z.number().int().min(0).default(0).describe(JWT_AUTH.ATTACH.accessTokenNumUsesLimit) + body: z + .object({ + configurationType: z.nativeEnum(JwtConfigurationType).describe(JWT_AUTH.ATTACH.configurationType), + jwksUrl: z.string().trim().default("").describe(JWT_AUTH.ATTACH.jwksUrl), + jwksCaCert: z.string().trim().default("").describe(JWT_AUTH.ATTACH.jwksCaCert), + publicKeys: z.string().min(1).array().describe(JWT_AUTH.ATTACH.publicKeys), + boundIssuer: z.string().trim().default("").describe(JWT_AUTH.ATTACH.boundIssuer), + boundAudiences: validateJwtAuthAudiencesField.describe(JWT_AUTH.ATTACH.boundAudiences), + boundClaims: validateJwtBoundClaimsField.describe(JWT_AUTH.ATTACH.boundClaims), + boundSubject: z.string().trim().default("").describe(JWT_AUTH.ATTACH.boundSubject), + accessTokenTrustedIps: z + .object({ + ipAddress: z.string().trim() + }) + .array() + .min(1) + .default([{ ipAddress: "0.0.0.0/0" }, { ipAddress: "::/0" }]) + .describe(JWT_AUTH.ATTACH.accessTokenTrustedIps), + accessTokenTTL: z + .number() + .int() + .min(1) + .max(315360000) + .refine((value) => value !== 0, { + message: "accessTokenTTL must have a non zero number" + }) + .default(2592000) + .describe(JWT_AUTH.ATTACH.accessTokenTTL), + accessTokenMaxTTL: z + .number() + .int() + .max(315360000) + .refine((value) => value !== 0, { + message: "accessTokenMaxTTL must have a non zero number" + }) + .default(2592000) + .describe(JWT_AUTH.ATTACH.accessTokenMaxTTL), + accessTokenNumUsesLimit: z.number().int().min(0).default(0).describe(JWT_AUTH.ATTACH.accessTokenNumUsesLimit) + }) + .superRefine((data, ctx) => { + if (data.configurationType === JwtConfigurationType.JWKS) { + if (!data.jwksUrl) { + ctx.addIssue({ + path: ["jwksUrl"], + message: "JWKS url is required", + code: z.ZodIssueCode.custom + }); + } + } else if (data.configurationType === JwtConfigurationType.STATIC) { + if (data.publicKeys.length === 0) { + ctx.addIssue({ + path: ["publicKeys"], + message: "public key is required", + code: z.ZodIssueCode.custom + }); + } + } + }), + + response: { + 200: z.object({ + identityJwtAuth: IdentityJwtAuthResponseSchema + }) + } + }, + handler: async (req) => { + const identityJwtAuth = await server.services.identityJwtAuth.attachJwtAuth({ + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + ...req.body, + identityId: req.params.identityId + }); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + orgId: identityJwtAuth.orgId, + event: { + type: EventType.ADD_IDENTITY_JWT_AUTH, + metadata: { + identityId: identityJwtAuth.identityId, + configurationType: identityJwtAuth.configurationType, + jwksUrl: identityJwtAuth.jwksUrl, + jwksCaCert: identityJwtAuth.jwksCaCert, + publicKeys: identityJwtAuth.publicKeys, + boundIssuer: identityJwtAuth.boundIssuer, + boundAudiences: identityJwtAuth.boundAudiences, + boundClaims: identityJwtAuth.boundClaims as Record, + boundSubject: identityJwtAuth.boundSubject, + accessTokenTTL: identityJwtAuth.accessTokenTTL, + accessTokenMaxTTL: identityJwtAuth.accessTokenMaxTTL, + accessTokenTrustedIps: identityJwtAuth.accessTokenTrustedIps as TIdentityTrustedIp[], + accessTokenNumUsesLimit: identityJwtAuth.accessTokenNumUsesLimit + } + } + }); + + return { + identityJwtAuth + }; + } + }); + + server.route({ + method: "PATCH", + url: "/jwt-auth/identities/:identityId", + config: { + rateLimit: writeLimit + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + schema: { + description: "Update JWT Auth configuration on identity", + security: [ + { + bearerAuth: [] + } + ], + params: z.object({ + identityId: z.string().trim().describe(JWT_AUTH.UPDATE.identityId) + }), + body: z + .object({ + configurationType: z.nativeEnum(JwtConfigurationType).describe(JWT_AUTH.UPDATE.configurationType), + jwksUrl: z.string().trim().describe(JWT_AUTH.UPDATE.jwksUrl), + jwksCaCert: z.string().trim().describe(JWT_AUTH.UPDATE.jwksCaCert), + publicKeys: z.string().array().describe(JWT_AUTH.UPDATE.publicKeys), + boundIssuer: z.string().trim().describe(JWT_AUTH.UPDATE.boundIssuer), + boundAudiences: validateJwtAuthAudiencesField.describe(JWT_AUTH.UPDATE.boundAudiences), + boundClaims: validateJwtBoundClaimsField.describe(JWT_AUTH.UPDATE.boundClaims), + boundSubject: z.string().trim().describe(JWT_AUTH.UPDATE.boundSubject), + accessTokenTrustedIps: z + .object({ + ipAddress: z.string().trim() + }) + .array() + .min(1) + .default([{ ipAddress: "0.0.0.0/0" }, { ipAddress: "::/0" }]) + .describe(JWT_AUTH.UPDATE.accessTokenTrustedIps), + accessTokenTTL: z + .number() + .int() + .min(1) + .max(315360000) + .refine((value) => value !== 0, { + message: "accessTokenTTL must have a non zero number" + }) + .default(2592000) + .describe(JWT_AUTH.UPDATE.accessTokenTTL), + accessTokenMaxTTL: z + .number() + .int() + .max(315360000) + .refine((value) => value !== 0, { + message: "accessTokenMaxTTL must have a non zero number" + }) + .default(2592000) + .describe(JWT_AUTH.UPDATE.accessTokenMaxTTL), + + accessTokenNumUsesLimit: z.number().int().min(0).default(0).describe(JWT_AUTH.UPDATE.accessTokenNumUsesLimit) + }) + .partial() + .superRefine((data, ctx) => { + if (data.configurationType === JwtConfigurationType.JWKS) { + if (!data.jwksUrl) { + ctx.addIssue({ + path: ["jwksUrl"], + message: "JWKS url is required", + code: z.ZodIssueCode.custom + }); + } + } else if (data.configurationType === JwtConfigurationType.STATIC) { + if (data.publicKeys?.length === 0) { + ctx.addIssue({ + path: ["publicKeys"], + message: "public key is required", + code: z.ZodIssueCode.custom + }); + } + } + }), + response: { + 200: z.object({ + identityJwtAuth: IdentityJwtAuthResponseSchema + }) + } + }, + handler: async (req) => { + const identityJwtAuth = await server.services.identityJwtAuth.updateJwtAuth({ + actor: req.permission.type, + actorId: req.permission.id, + actorOrgId: req.permission.orgId, + actorAuthMethod: req.permission.authMethod, + ...req.body, + identityId: req.params.identityId + }); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + orgId: identityJwtAuth.orgId, + event: { + type: EventType.UPDATE_IDENTITY_JWT_AUTH, + metadata: { + identityId: identityJwtAuth.identityId, + configurationType: identityJwtAuth.configurationType, + jwksUrl: identityJwtAuth.jwksUrl, + jwksCaCert: identityJwtAuth.jwksCaCert, + publicKeys: identityJwtAuth.publicKeys, + boundIssuer: identityJwtAuth.boundIssuer, + boundAudiences: identityJwtAuth.boundAudiences, + boundClaims: identityJwtAuth.boundClaims as Record, + boundSubject: identityJwtAuth.boundSubject, + accessTokenTTL: identityJwtAuth.accessTokenTTL, + accessTokenMaxTTL: identityJwtAuth.accessTokenMaxTTL, + accessTokenTrustedIps: identityJwtAuth.accessTokenTrustedIps as TIdentityTrustedIp[], + accessTokenNumUsesLimit: identityJwtAuth.accessTokenNumUsesLimit + } + } + }); + + return { identityJwtAuth }; + } + }); + + server.route({ + method: "GET", + url: "/jwt-auth/identities/:identityId", + config: { + rateLimit: readLimit + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + schema: { + description: "Retrieve JWT Auth configuration on identity", + security: [ + { + bearerAuth: [] + } + ], + params: z.object({ + identityId: z.string().describe(JWT_AUTH.RETRIEVE.identityId) }), response: { 200: z.object({ @@ -81,6 +286,77 @@ export const registerIdentityJwtAuthRouter = async (server: FastifyZodProvider) }) } }, - handler: async (req) => {} + handler: async (req) => { + const identityJwtAuth = await server.services.identityJwtAuth.getJwtAuth({ + identityId: req.params.identityId, + actor: req.permission.type, + actorId: req.permission.id, + actorOrgId: req.permission.orgId, + actorAuthMethod: req.permission.authMethod + }); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + orgId: identityJwtAuth.orgId, + event: { + type: EventType.GET_IDENTITY_JWT_AUTH, + metadata: { + identityId: identityJwtAuth.identityId + } + } + }); + + return { identityJwtAuth }; + } + }); + + server.route({ + method: "DELETE", + url: "/jwt-auth/identities/:identityId", + config: { + rateLimit: writeLimit + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + schema: { + description: "Delete JWT Auth configuration on identity", + security: [ + { + bearerAuth: [] + } + ], + params: z.object({ + identityId: z.string().describe(JWT_AUTH.REVOKE.identityId) + }), + response: { + 200: z.object({ + identityJwtAuth: IdentityJwtAuthResponseSchema.omit({ + publicKeys: true, + jwksCaCert: true + }) + }) + } + }, + handler: async (req) => { + const identityJwtAuth = await server.services.identityJwtAuth.revokeJwtAuth({ + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + identityId: req.params.identityId + }); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + orgId: identityJwtAuth.orgId, + event: { + type: EventType.REVOKE_IDENTITY_JWT_AUTH, + metadata: { + identityId: identityJwtAuth.identityId + } + } + }); + + return { identityJwtAuth }; + } }); }; diff --git a/backend/src/services/identity-jwt-auth/identity-jwt-auth-service.ts b/backend/src/services/identity-jwt-auth/identity-jwt-auth-service.ts index c61ae6769..70ee1ef11 100644 --- a/backend/src/services/identity-jwt-auth/identity-jwt-auth-service.ts +++ b/backend/src/services/identity-jwt-auth/identity-jwt-auth-service.ts @@ -1,18 +1,20 @@ import { ForbiddenError } from "@casl/ability"; -import { IdentityAuthMethod } from "@app/db/schemas"; +import { IdentityAuthMethod, TIdentityJwtAuthsUpdate } from "@app/db/schemas"; import { TLicenseServiceFactory } from "@app/ee/services/license/license-service"; 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 { isAtLeastAsPrivileged } from "@app/lib/casl"; +import { BadRequestError, ForbiddenRequestError, NotFoundError } from "@app/lib/errors"; import { extractIPDetails, isValidIpOrCidr } from "@app/lib/ip"; +import { ActorType } from "../auth/auth-type"; import { TIdentityOrgDALFactory } from "../identity/identity-org-dal"; import { TIdentityAccessTokenDALFactory } from "../identity-access-token/identity-access-token-dal"; import { TKmsServiceFactory } from "../kms/kms-service"; import { KmsDataKey } from "../kms/kms-types"; import { TIdentityJwtAuthDALFactory } from "./identity-jwt-auth-dal"; -import { TAttachJwtAuthDTO } from "./identity-jwt-auth-types"; +import { TAttachJwtAuthDTO, TGetJwtAuthDTO, TRevokeJwtAuthDTO, TUpdateJwtAuthDTO } from "./identity-jwt-auth-types"; type TIdentityJwtAuthServiceFactoryDep = { identityJwtAuthDAL: TIdentityJwtAuthDALFactory; @@ -30,6 +32,7 @@ export const identityJwtAuthServiceFactory = ({ identityOrgMembershipDAL, permissionService, licenseService, + identityAccessTokenDAL, kmsService }: TIdentityJwtAuthServiceFactoryDep) => { const attachJwtAuth = async ({ @@ -131,7 +134,212 @@ export const identityJwtAuthServiceFactory = ({ return { ...identityJwtAuth, orgId: identityMembershipOrg.orgId, jwksCaCert, publicKeys }; }; + const updateJwtAuth = async ({ + identityId, + configurationType, + jwksUrl, + jwksCaCert, + publicKeys, + boundIssuer, + boundAudiences, + boundClaims, + boundSubject, + accessTokenTTL, + accessTokenMaxTTL, + accessTokenNumUsesLimit, + accessTokenTrustedIps, + actorId, + actorAuthMethod, + actor, + actorOrgId + }: TUpdateJwtAuthDTO) => { + const identityMembershipOrg = await identityOrgMembershipDAL.findOne({ identityId }); + if (!identityMembershipOrg) throw new NotFoundError({ message: `Failed to find identity with ID ${identityId}` }); + + if (!identityMembershipOrg.identity.authMethods.includes(IdentityAuthMethod.JWT_AUTH)) { + throw new BadRequestError({ + message: "Failed to update JWT Auth" + }); + } + + const identityJwtAuth = await identityJwtAuthDAL.findOne({ identityId }); + + if ( + (accessTokenMaxTTL || identityJwtAuth.accessTokenMaxTTL) > 0 && + (accessTokenTTL || identityJwtAuth.accessTokenMaxTTL) > (accessTokenMaxTTL || identityJwtAuth.accessTokenMaxTTL) + ) { + throw new BadRequestError({ message: "Access token TTL cannot be greater than max TTL" }); + } + + const { permission } = await permissionService.getOrgPermission( + actor, + actorId, + identityMembershipOrg.orgId, + actorAuthMethod, + actorOrgId + ); + + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Edit, OrgPermissionSubjects.Identity); + + const plan = await licenseService.getPlan(identityMembershipOrg.orgId); + const reformattedAccessTokenTrustedIps = accessTokenTrustedIps?.map((accessTokenTrustedIp) => { + if ( + !plan.ipAllowlisting && + accessTokenTrustedIp.ipAddress !== "0.0.0.0/0" && + accessTokenTrustedIp.ipAddress !== "::/0" + ) + throw new BadRequestError({ + message: + "Failed to add IP access range to access token due to plan restriction. Upgrade plan to add IP access range." + }); + if (!isValidIpOrCidr(accessTokenTrustedIp.ipAddress)) + throw new BadRequestError({ + message: "The IP is not a valid IPv4, IPv6, or CIDR block" + }); + return extractIPDetails(accessTokenTrustedIp.ipAddress); + }); + + const updateQuery: TIdentityJwtAuthsUpdate = { + boundIssuer, + configurationType, + jwksUrl, + boundAudiences, + boundClaims, + boundSubject, + accessTokenMaxTTL, + accessTokenTTL, + accessTokenNumUsesLimit, + accessTokenTrustedIps: reformattedAccessTokenTrustedIps + ? JSON.stringify(reformattedAccessTokenTrustedIps) + : undefined + }; + + const { encryptor: orgDataKeyEncryptor, decryptor: orgDataKeyDecryptor } = + await kmsService.createCipherPairWithDataKey({ + type: KmsDataKey.Organization, + orgId: actorOrgId + }); + + if (jwksCaCert) { + const { cipherTextBlob: encryptedJwksCaCert } = orgDataKeyEncryptor({ + plainText: Buffer.from(jwksCaCert) + }); + + updateQuery.encryptedJwksCaCert = encryptedJwksCaCert; + } + + if (publicKeys) { + const { cipherTextBlob: encryptedPublicKeys } = orgDataKeyEncryptor({ + plainText: Buffer.from(publicKeys.join(",")) + }); + + updateQuery.encryptedPublicKeys = encryptedPublicKeys; + } + + const updatedJwtAuth = await identityJwtAuthDAL.updateById(identityJwtAuth.id, updateQuery); + const decryptedJwksCaCert = orgDataKeyDecryptor({ cipherTextBlob: updatedJwtAuth.encryptedJwksCaCert }).toString(); + const decryptedPublicKeys = orgDataKeyDecryptor({ cipherTextBlob: updatedJwtAuth.encryptedPublicKeys }) + .toString() + .split(","); + + return { + ...updatedJwtAuth, + orgId: identityMembershipOrg.orgId, + jwksCaCert: decryptedJwksCaCert, + publicKeys: decryptedPublicKeys + }; + }; + + const getJwtAuth = async ({ identityId, actorId, actor, actorAuthMethod, actorOrgId }: TGetJwtAuthDTO) => { + const identityMembershipOrg = await identityOrgMembershipDAL.findOne({ identityId }); + if (!identityMembershipOrg) throw new NotFoundError({ message: `Failed to find identity with ID ${identityId}` }); + + if (!identityMembershipOrg.identity.authMethods.includes(IdentityAuthMethod.JWT_AUTH)) { + throw new BadRequestError({ + message: "The identity does not have JWT Auth attached" + }); + } + + const { permission } = await permissionService.getOrgPermission( + actor, + actorId, + identityMembershipOrg.orgId, + actorAuthMethod, + actorOrgId + ); + + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Read, OrgPermissionSubjects.Identity); + + const identityJwtAuth = await identityJwtAuthDAL.findOne({ identityId }); + + const { decryptor: orgDataKeyDecryptor } = await kmsService.createCipherPairWithDataKey({ + type: KmsDataKey.Organization, + orgId: actorOrgId + }); + + const decryptedJwksCaCert = orgDataKeyDecryptor({ cipherTextBlob: identityJwtAuth.encryptedJwksCaCert }).toString(); + const decryptedPublicKeys = orgDataKeyDecryptor({ cipherTextBlob: identityJwtAuth.encryptedPublicKeys }) + .toString() + .split(","); + + return { + ...identityJwtAuth, + orgId: identityMembershipOrg.orgId, + jwksCaCert: decryptedJwksCaCert, + publicKeys: decryptedPublicKeys + }; + }; + + const revokeJwtAuth = async ({ identityId, actorId, actor, actorAuthMethod, actorOrgId }: TRevokeJwtAuthDTO) => { + const identityMembershipOrg = await identityOrgMembershipDAL.findOne({ identityId }); + if (!identityMembershipOrg) { + throw new NotFoundError({ message: "Failed to find identity" }); + } + + if (!identityMembershipOrg.identity.authMethods.includes(IdentityAuthMethod.JWT_AUTH)) { + throw new BadRequestError({ + message: "The identity does not have JWT auth" + }); + } + + const { permission } = await permissionService.getOrgPermission( + actor, + actorId, + identityMembershipOrg.orgId, + actorAuthMethod, + actorOrgId + ); + + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Edit, OrgPermissionSubjects.Identity); + + const { permission: rolePermission } = await permissionService.getOrgPermission( + ActorType.IDENTITY, + identityMembershipOrg.identityId, + identityMembershipOrg.orgId, + actorAuthMethod, + actorOrgId + ); + + if (!isAtLeastAsPrivileged(permission, rolePermission)) { + throw new ForbiddenRequestError({ + message: "Failed to revoke JWT auth of identity with more privileged role" + }); + } + + const revokedIdentityJwtAuth = await identityJwtAuthDAL.transaction(async (tx) => { + const deletedJwtAuth = await identityJwtAuthDAL.delete({ identityId }, tx); + await identityAccessTokenDAL.delete({ identityId, authMethod: IdentityAuthMethod.JWT_AUTH }, tx); + + return { ...deletedJwtAuth?.[0], orgId: identityMembershipOrg.orgId }; + }); + + return revokedIdentityJwtAuth; + }; + return { - attachJwtAuth + attachJwtAuth, + updateJwtAuth, + getJwtAuth, + revokeJwtAuth }; }; diff --git a/backend/src/services/identity-jwt-auth/identity-jwt-auth-types.ts b/backend/src/services/identity-jwt-auth/identity-jwt-auth-types.ts index e06c56437..7edfb62dc 100644 --- a/backend/src/services/identity-jwt-auth/identity-jwt-auth-types.ts +++ b/backend/src/services/identity-jwt-auth/identity-jwt-auth-types.ts @@ -20,3 +20,27 @@ export type TAttachJwtAuthDTO = { accessTokenNumUsesLimit: number; accessTokenTrustedIps: { ipAddress: string }[]; } & Omit; + +export type TUpdateJwtAuthDTO = { + identityId: string; + configurationType?: JwtConfigurationType; + jwksUrl?: string; + jwksCaCert?: string; + publicKeys?: string[]; + boundIssuer?: string; + boundAudiences?: string; + boundClaims?: Record; + boundSubject?: string; + accessTokenTTL?: number; + accessTokenMaxTTL?: number; + accessTokenNumUsesLimit?: number; + accessTokenTrustedIps?: { ipAddress: string }[]; +} & Omit; + +export type TGetJwtAuthDTO = { + identityId: string; +} & Omit; + +export type TRevokeJwtAuthDTO = { + identityId: string; +} & Omit; From 56aab172d3efce8fc9fbdb30fe09a75b468888a0 Mon Sep 17 00:00:00 2001 From: Sheen Capadngan Date: Wed, 11 Dec 2024 00:05:31 +0800 Subject: [PATCH 034/162] feat: added logic for jwt auth login --- .../ee/services/audit-log/audit-log-types.ts | 11 ++ .../routes/v1/identity-jwt-auth-router.ts | 49 +++++ .../identity-jwt-auth-fns.ts | 4 + .../identity-jwt-auth-service.ts | 176 +++++++++++++++++- .../identity-jwt-auth-types.ts | 5 + 5 files changed, 242 insertions(+), 3 deletions(-) create mode 100644 backend/src/services/identity-jwt-auth/identity-jwt-auth-fns.ts 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 4e747e4bb..ec1a2a904 100644 --- a/backend/src/ee/services/audit-log/audit-log-types.ts +++ b/backend/src/ee/services/audit-log/audit-log-types.ts @@ -94,6 +94,7 @@ export enum EventType { UPDATE_IDENTITY_OIDC_AUTH = "update-identity-oidc-auth", GET_IDENTITY_OIDC_AUTH = "get-identity-oidc-auth", REVOKE_IDENTITY_OIDC_AUTH = "revoke-identity-oidc-auth", + LOGIN_IDENTITY_JWT_AUTH = "login-identity-jwt-auth", ADD_IDENTITY_JWT_AUTH = "add-identity-jwt-auth", UPDATE_IDENTITY_JWT_AUTH = "update-identity-jwt-auth", GET_IDENTITY_JWT_AUTH = "get-identity-jwt-auth", @@ -899,6 +900,15 @@ interface GetIdentityOidcAuthEvent { }; } +interface LoginIdentityJwtAuthEvent { + type: EventType.LOGIN_IDENTITY_JWT_AUTH; + metadata: { + identityId: string; + identityJwtAuthId: string; + identityAccessTokenId: string; + }; +} + interface AddIdentityJwtAuthEvent { type: EventType.ADD_IDENTITY_JWT_AUTH; metadata: { @@ -1789,6 +1799,7 @@ export type Event = | DeleteIdentityOidcAuthEvent | UpdateIdentityOidcAuthEvent | GetIdentityOidcAuthEvent + | LoginIdentityJwtAuthEvent | AddIdentityJwtAuthEvent | UpdateIdentityJwtAuthEvent | GetIdentityJwtAuthEvent diff --git a/backend/src/server/routes/v1/identity-jwt-auth-router.ts b/backend/src/server/routes/v1/identity-jwt-auth-router.ts index 758df922a..c1032cfe4 100644 --- a/backend/src/server/routes/v1/identity-jwt-auth-router.ts +++ b/backend/src/server/routes/v1/identity-jwt-auth-router.ts @@ -22,6 +22,55 @@ const IdentityJwtAuthResponseSchema = IdentityJwtAuthsSchema.omit({ }); export const registerIdentityJwtAuthRouter = async (server: FastifyZodProvider) => { + server.route({ + method: "POST", + url: "/jwt-auth/login", + config: { + rateLimit: writeLimit + }, + schema: { + description: "Login with JWT Auth", + body: z.object({ + identityId: z.string().trim().describe(JWT_AUTH.LOGIN.identityId), + jwt: z.string().trim() + }), + response: { + 200: z.object({ + accessToken: z.string(), + expiresIn: z.coerce.number(), + accessTokenMaxTTL: z.coerce.number(), + tokenType: z.literal("Bearer") + }) + } + }, + handler: async (req) => { + const { identityJwtAuth, accessToken, identityAccessToken, identityMembershipOrg } = + await server.services.identityJwtAuth.login({ + identityId: req.body.identityId, + jwt: req.body.jwt + }); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + orgId: identityMembershipOrg?.orgId, + event: { + type: EventType.LOGIN_IDENTITY_JWT_AUTH, + metadata: { + identityId: identityJwtAuth.identityId, + identityAccessTokenId: identityAccessToken.id, + identityJwtAuthId: identityJwtAuth.id + } + } + }); + return { + accessToken, + tokenType: "Bearer" as const, + expiresIn: identityJwtAuth.accessTokenTTL, + accessTokenMaxTTL: identityJwtAuth.accessTokenMaxTTL + }; + } + }); + server.route({ method: "POST", url: "/jwt-auth/identities/:identityId", diff --git a/backend/src/services/identity-jwt-auth/identity-jwt-auth-fns.ts b/backend/src/services/identity-jwt-auth/identity-jwt-auth-fns.ts new file mode 100644 index 000000000..bcbff5f0e --- /dev/null +++ b/backend/src/services/identity-jwt-auth/identity-jwt-auth-fns.ts @@ -0,0 +1,4 @@ +import picomatch from "picomatch"; + +export const doesFieldValueMatchJwtPolicy = (fieldValue: string, policyValue: string) => + policyValue === fieldValue || picomatch.isMatch(fieldValue, policyValue); diff --git a/backend/src/services/identity-jwt-auth/identity-jwt-auth-service.ts b/backend/src/services/identity-jwt-auth/identity-jwt-auth-service.ts index 70ee1ef11..f0618b715 100644 --- a/backend/src/services/identity-jwt-auth/identity-jwt-auth-service.ts +++ b/backend/src/services/identity-jwt-auth/identity-jwt-auth-service.ts @@ -1,20 +1,33 @@ import { ForbiddenError } from "@casl/ability"; +import https from "https"; +import jwt, { JsonWebTokenError } from "jsonwebtoken"; +import { JwksClient } from "jwks-rsa"; import { IdentityAuthMethod, TIdentityJwtAuthsUpdate } from "@app/db/schemas"; import { TLicenseServiceFactory } from "@app/ee/services/license/license-service"; import { OrgPermissionActions, OrgPermissionSubjects } from "@app/ee/services/permission/org-permission"; import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service"; import { isAtLeastAsPrivileged } from "@app/lib/casl"; -import { BadRequestError, ForbiddenRequestError, NotFoundError } from "@app/lib/errors"; +import { getConfig } from "@app/lib/config/env"; +import { BadRequestError, ForbiddenRequestError, NotFoundError, UnauthorizedError } from "@app/lib/errors"; import { extractIPDetails, isValidIpOrCidr } from "@app/lib/ip"; -import { ActorType } from "../auth/auth-type"; +import { ActorType, AuthTokenType } from "../auth/auth-type"; import { TIdentityOrgDALFactory } from "../identity/identity-org-dal"; import { TIdentityAccessTokenDALFactory } from "../identity-access-token/identity-access-token-dal"; +import { TIdentityAccessTokenJwtPayload } from "../identity-access-token/identity-access-token-types"; import { TKmsServiceFactory } from "../kms/kms-service"; import { KmsDataKey } from "../kms/kms-types"; import { TIdentityJwtAuthDALFactory } from "./identity-jwt-auth-dal"; -import { TAttachJwtAuthDTO, TGetJwtAuthDTO, TRevokeJwtAuthDTO, TUpdateJwtAuthDTO } from "./identity-jwt-auth-types"; +import { doesFieldValueMatchJwtPolicy } from "./identity-jwt-auth-fns"; +import { + JwtConfigurationType, + TAttachJwtAuthDTO, + TGetJwtAuthDTO, + TLoginJwtAuthDTO, + TRevokeJwtAuthDTO, + TUpdateJwtAuthDTO +} from "./identity-jwt-auth-types"; type TIdentityJwtAuthServiceFactoryDep = { identityJwtAuthDAL: TIdentityJwtAuthDALFactory; @@ -35,6 +48,162 @@ export const identityJwtAuthServiceFactory = ({ identityAccessTokenDAL, kmsService }: TIdentityJwtAuthServiceFactoryDep) => { + const login = async ({ identityId, jwt: jwtValue }: TLoginJwtAuthDTO) => { + const identityJwtAuth = await identityJwtAuthDAL.findOne({ identityId }); + if (!identityJwtAuth) { + throw new NotFoundError({ message: "JWT auth method not found for identity, did you configure JWT auth?" }); + } + + const identityMembershipOrg = await identityOrgMembershipDAL.findOne({ + identityId: identityJwtAuth.identityId + }); + if (!identityMembershipOrg) { + throw new NotFoundError({ + message: `Identity organization membership for identity with ID '${identityJwtAuth.identityId}' not found` + }); + } + + const { decryptor: orgDataKeyDecryptor } = await kmsService.createCipherPairWithDataKey({ + type: KmsDataKey.Organization, + orgId: identityMembershipOrg.orgId + }); + + const decodedToken = jwt.decode(jwtValue, { complete: true }); + if (!decodedToken) { + throw new UnauthorizedError({ + message: "Invalid JWT" + }); + } + + let tokenData: Record = {}; + + if (identityJwtAuth.configurationType === JwtConfigurationType.JWKS) { + const decryptedJwksCaCert = orgDataKeyDecryptor({ + cipherTextBlob: identityJwtAuth.encryptedJwksCaCert + }).toString(); + const requestAgent = new https.Agent({ ca: decryptedJwksCaCert, rejectUnauthorized: !!decryptedJwksCaCert }); + const client = new JwksClient({ + jwksUri: identityJwtAuth.jwksUrl, + requestAgent + }); + + const { kid } = decodedToken.header; + const jwtSigningKey = await client.getSigningKey(kid); + + try { + tokenData = jwt.verify(jwtValue, jwtSigningKey.getPublicKey()) as Record; + } catch (error) { + if (error instanceof jwt.JsonWebTokenError) { + throw new UnauthorizedError({ + message: `Access denied: ${error.message}` + }); + } + + throw error; + } + } else { + const decryptedPublicKeys = orgDataKeyDecryptor({ cipherTextBlob: identityJwtAuth.encryptedPublicKeys }) + .toString() + .split(","); + + const errors: string[] = []; + let isMatchAnyKey = false; + for (const publicKey of decryptedPublicKeys) { + try { + tokenData = jwt.verify(jwtValue, publicKey) as Record; + isMatchAnyKey = true; + } catch (error) { + if (error instanceof JsonWebTokenError) { + errors.push(error.message); + } + } + } + + if (!isMatchAnyKey) { + throw new UnauthorizedError({ + message: `Access denied: JWT verification failed with all keys. Errors - ${errors.join("; ")}` + }); + } + } + + if (identityJwtAuth.boundIssuer) { + if (!doesFieldValueMatchJwtPolicy(tokenData.iss, identityJwtAuth.boundIssuer)) { + throw new ForbiddenRequestError({ + message: "Access denied: issuer mismatch." + }); + } + } + + if (identityJwtAuth.boundSubject) { + if (!doesFieldValueMatchJwtPolicy(tokenData.sub, identityJwtAuth.boundSubject)) { + throw new ForbiddenRequestError({ + message: "Access denied: subject not allowed." + }); + } + } + + if (identityJwtAuth.boundAudiences) { + if ( + !identityJwtAuth.boundAudiences + .split(", ") + .some((policyValue) => doesFieldValueMatchJwtPolicy(tokenData.aud, policyValue)) + ) { + throw new UnauthorizedError({ + message: "Access denied: audience not allowed." + }); + } + } + + if (identityJwtAuth.boundClaims) { + Object.keys(identityJwtAuth.boundClaims).forEach((claimKey) => { + const claimValue = (identityJwtAuth.boundClaims as Record)[claimKey]; + // handle both single and multi-valued claims + if ( + !claimValue.split(", ").some((claimEntry) => doesFieldValueMatchJwtPolicy(tokenData[claimKey], claimEntry)) + ) { + throw new UnauthorizedError({ + message: "Access denied: claim mismatch." + }); + } + }); + } + + const identityAccessToken = await identityJwtAuthDAL.transaction(async (tx) => { + const newToken = await identityAccessTokenDAL.create( + { + identityId: identityJwtAuth.identityId, + isAccessTokenRevoked: false, + accessTokenTTL: identityJwtAuth.accessTokenTTL, + accessTokenMaxTTL: identityJwtAuth.accessTokenMaxTTL, + accessTokenNumUses: 0, + accessTokenNumUsesLimit: identityJwtAuth.accessTokenNumUsesLimit, + authMethod: IdentityAuthMethod.JWT_AUTH + }, + tx + ); + + return newToken; + }); + + const appCfg = getConfig(); + const accessToken = jwt.sign( + { + identityId: identityJwtAuth.identityId, + identityAccessTokenId: identityAccessToken.id, + authTokenType: AuthTokenType.IDENTITY_ACCESS_TOKEN + } as TIdentityAccessTokenJwtPayload, + appCfg.AUTH_SECRET, + { + expiresIn: + Number(identityAccessToken.accessTokenMaxTTL) === 0 + ? undefined + : Number(identityAccessToken.accessTokenMaxTTL) + } + ); + + return { accessToken, identityJwtAuth, identityAccessToken, identityMembershipOrg }; + }; + const attachJwtAuth = async ({ identityId, configurationType, @@ -337,6 +506,7 @@ export const identityJwtAuthServiceFactory = ({ }; return { + login, attachJwtAuth, updateJwtAuth, getJwtAuth, diff --git a/backend/src/services/identity-jwt-auth/identity-jwt-auth-types.ts b/backend/src/services/identity-jwt-auth/identity-jwt-auth-types.ts index 7edfb62dc..a6881f0e5 100644 --- a/backend/src/services/identity-jwt-auth/identity-jwt-auth-types.ts +++ b/backend/src/services/identity-jwt-auth/identity-jwt-auth-types.ts @@ -44,3 +44,8 @@ export type TGetJwtAuthDTO = { export type TRevokeJwtAuthDTO = { identityId: string; } & Omit; + +export type TLoginJwtAuthDTO = { + identityId: string; + jwt: string; +}; From cc3d132f5d355ab1e1987421e3f9bc277ec76b95 Mon Sep 17 00:00:00 2001 From: McPizza Date: Tue, 10 Dec 2024 20:07:23 +0100 Subject: [PATCH 035/162] feat(integrations): New CircleCI Context Sync --- .../routes/v1/integration-auth-router.ts | 34 +++ .../integration-auth/integration-app-list.ts | 48 ++++ .../integration-auth/integration-app-types.ts | 5 + .../integration-auth-service.ts | 37 +++ .../integration-auth-types.ts | 12 + .../integration-auth/integration-list.ts | 12 + .../integration-sync-secret.ts | 90 +++++++ frontend/public/data/frequentConstants.ts | 1 + .../src/hooks/api/integrationAuth/index.tsx | 1 + .../src/hooks/api/integrationAuth/queries.tsx | 21 +- .../src/hooks/api/integrationAuth/types.ts | 5 + .../circleci-context/authorize.tsx | 101 ++++++++ .../integrations/circleci-context/create.tsx | 245 ++++++++++++++++++ .../IntegrationConnectionSection.tsx | 12 +- .../IntegrationPage.utils.tsx | 3 + .../components/IntegrationDetails.tsx | 8 + 16 files changed, 633 insertions(+), 2 deletions(-) create mode 100644 backend/src/services/integration-auth/integration-app-types.ts create mode 100644 frontend/src/pages/integrations/circleci-context/authorize.tsx create mode 100644 frontend/src/pages/integrations/circleci-context/create.tsx diff --git a/backend/src/server/routes/v1/integration-auth-router.ts b/backend/src/server/routes/v1/integration-auth-router.ts index 575544cc7..36589c500 100644 --- a/backend/src/server/routes/v1/integration-auth-router.ts +++ b/backend/src/server/routes/v1/integration-auth-router.ts @@ -1123,4 +1123,38 @@ export const registerIntegrationAuthRouter = async (server: FastifyZodProvider) return { spaces }; } }); + + server.route({ + method: "GET", + url: "/:integrationAuthId/circleci/organizations", + config: { + rateLimit: readLimit + }, + onRequest: verifyAuth([AuthMode.JWT]), + schema: { + params: z.object({ + integrationAuthId: z.string().trim() + }), + response: { + 200: z.object({ + organizations: z + .object({ + name: z.string(), + slug: z.string() + }) + .array() + }) + } + }, + handler: async (req) => { + const organizations = await server.services.integrationAuth.getCircleCIOrganizations({ + actorId: req.permission.id, + actor: req.permission.type, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + id: req.params.integrationAuthId + }); + return { organizations }; + } + }); }; diff --git a/backend/src/services/integration-auth/integration-app-list.ts b/backend/src/services/integration-auth/integration-app-list.ts index 3b8078cc1..2dfa65890 100644 --- a/backend/src/services/integration-auth/integration-app-list.ts +++ b/backend/src/services/integration-auth/integration-app-list.ts @@ -8,6 +8,7 @@ import { getConfig } from "@app/lib/config/env"; import { request } from "@app/lib/config/request"; import { NotFoundError } from "@app/lib/errors"; +import { TCircleCIContext } from "./integration-app-types"; import { IntegrationAuthMetadataSchema, TIntegrationAuthMetadata } from "./integration-auth-schema"; import { Integrations, IntegrationUrls } from "./integration-list"; @@ -489,6 +490,47 @@ const getAppsCircleCI = async ({ accessToken }: { accessToken: string }) => { return apps; }; +/** + * Return list of contexts for CircleCI_Context integration + */ +const getAppsCircleCIContexts = async ({ accessToken, orgSlug }: { accessToken: string; orgSlug: string }) => { + type NextPageToken = string | null | undefined; + + type CircleCIContextResponse = { + items: TCircleCIContext[]; + next_page_token: NextPageToken; + }; + + const contexts: TCircleCIContext[] = []; + + let nextPageToken: NextPageToken; + + while (nextPageToken !== null) { + const res = ( + await request.get(`${IntegrationUrls.CIRCLECI_CONTEXT_API_URL}/v2/context`, { + headers: { + "Circle-Token": accessToken, + "Accept-Encoding": "application/json" + }, + params: new URLSearchParams({ + "owner-slug": orgSlug, + ...(nextPageToken ? { "page-token": nextPageToken } : {}) + }) + }) + ).data; + + contexts.push(...res.items); + nextPageToken = res.next_page_token; + } + + const apps = contexts?.map((context) => ({ + name: context.name, + appId: context.id + })); + + return apps; +}; + /** * Return list of projects for Databricks integration */ @@ -1195,6 +1237,12 @@ export const getApps = async ({ accessToken }); + case Integrations.CIRCLECI_CONTEXT: + return getAppsCircleCIContexts({ + accessToken, + orgSlug: workspaceSlug as string + }); + case Integrations.DATABRICKS: return getAppsDatabricks({ url, diff --git a/backend/src/services/integration-auth/integration-app-types.ts b/backend/src/services/integration-auth/integration-app-types.ts new file mode 100644 index 000000000..1ddd2e4d2 --- /dev/null +++ b/backend/src/services/integration-auth/integration-app-types.ts @@ -0,0 +1,5 @@ +export type TCircleCIContext = { + id: string; + name: string; + created_at: string; +}; diff --git a/backend/src/services/integration-auth/integration-auth-service.ts b/backend/src/services/integration-auth/integration-auth-service.ts index be1a8d53c..cbbffa431 100644 --- a/backend/src/services/integration-auth/integration-auth-service.ts +++ b/backend/src/services/integration-auth/integration-auth-service.ts @@ -25,6 +25,7 @@ import { TBitbucketEnvironment, TBitbucketWorkspace, TChecklyGroups, + TCircleCIOrganization, TDeleteIntegrationAuthByIdDTO, TDeleteIntegrationAuthsDTO, TDuplicateGithubIntegrationAuthDTO, @@ -36,6 +37,7 @@ import { TIntegrationAuthBitbucketEnvironmentsDTO, TIntegrationAuthBitbucketWorkspaceDTO, TIntegrationAuthChecklyGroupsDTO, + TIntegrationAuthCircleCIOrganizationDTO, TIntegrationAuthGithubEnvsDTO, TIntegrationAuthGithubOrgsDTO, TIntegrationAuthHerokuPipelinesDTO, @@ -1427,6 +1429,40 @@ export const integrationAuthServiceFactory = ({ return []; }; + const getCircleCIOrganizations = async ({ + actorId, + actor, + actorOrgId, + actorAuthMethod, + id + }: TIntegrationAuthCircleCIOrganizationDTO) => { + const integrationAuth = await integrationAuthDAL.findById(id); + if (!integrationAuth) throw new NotFoundError({ message: `Integration auth with ID '${id}' not found` }); + + const { permission } = await permissionService.getProjectPermission( + actor, + actorId, + integrationAuth.projectId, + actorAuthMethod, + actorOrgId + ); + ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Read, ProjectPermissionSub.Integrations); + const { shouldUseSecretV2Bridge, botKey } = await projectBotService.getBotKey(integrationAuth.projectId); + const { accessToken } = await getIntegrationAccessToken(integrationAuth, shouldUseSecretV2Bridge, botKey); + + const { data }: { data: TCircleCIOrganization[] } = await request.get( + `${IntegrationUrls.CIRCLECI_CONTEXT_API_URL}/v2/me/collaborations`, + { + headers: { + "Circle-Token": `${accessToken}`, + "Accept-Encoding": "application/json" + } + } + ); + + return data; + }; + const deleteIntegrationAuths = async ({ projectId, integration, @@ -1638,6 +1674,7 @@ export const integrationAuthServiceFactory = ({ getTeamcityBuildConfigs, getBitbucketWorkspaces, getBitbucketEnvironments, + getCircleCIOrganizations, getIntegrationAccessToken, duplicateIntegrationAuth, getOctopusDeploySpaces, diff --git a/backend/src/services/integration-auth/integration-auth-types.ts b/backend/src/services/integration-auth/integration-auth-types.ts index 80e8d6c36..bd977333a 100644 --- a/backend/src/services/integration-auth/integration-auth-types.ts +++ b/backend/src/services/integration-auth/integration-auth-types.ts @@ -123,6 +123,10 @@ export type TGetIntegrationAuthTeamCityBuildConfigDTO = { appId: string; } & Omit; +export type TIntegrationAuthCircleCIOrganizationDTO = { + id: string; +} & Omit; + export type TVercelBranches = { ref: string; lastCommit: string; @@ -184,6 +188,14 @@ export type TTeamCityBuildConfig = { webUrl: string; }; +export type TCircleCIOrganization = { + id: string; + vcsType: string; + name: string; + avatarUrl: string; + slug: string; +}; + export type TIntegrationsWithEnvironment = TIntegrations & { environment?: | { diff --git a/backend/src/services/integration-auth/integration-list.ts b/backend/src/services/integration-auth/integration-list.ts index 45cbdaea9..2c7e7e288 100644 --- a/backend/src/services/integration-auth/integration-list.ts +++ b/backend/src/services/integration-auth/integration-list.ts @@ -15,6 +15,7 @@ export enum Integrations { FLYIO = "flyio", LARAVELFORGE = "laravel-forge", CIRCLECI = "circleci", + CIRCLECI_CONTEXT = "circleci-context", DATABRICKS = "databricks", TRAVISCI = "travisci", TEAMCITY = "teamcity", @@ -76,6 +77,8 @@ export enum IntegrationUrls { RAILWAY_API_URL = "https://backboard.railway.app/graphql/v2", FLYIO_API_URL = "https://api.fly.io/graphql", CIRCLECI_API_URL = "https://circleci.com/api", + // eslint-disable-next-line + CIRCLECI_CONTEXT_API_URL = "https://circleci.com/api", DATABRICKS_API_URL = "https:/xxxx.com/api", TRAVISCI_API_URL = "https://api.travis-ci.com", SUPABASE_API_URL = "https://api.supabase.com", @@ -226,6 +229,15 @@ export const getIntegrationOptions = async () => { clientId: "", docsLink: "" }, + { + name: "Circle CI Contexts", + slug: "circleci-context", + image: "Circle CI.png", + isAvailable: true, + type: "pat", + clientId: "", + docsLink: "" + }, { name: "Databricks", slug: "databricks", diff --git a/backend/src/services/integration-auth/integration-sync-secret.ts b/backend/src/services/integration-auth/integration-sync-secret.ts index c147150f0..ed917b936 100644 --- a/backend/src/services/integration-auth/integration-sync-secret.ts +++ b/backend/src/services/integration-auth/integration-sync-secret.ts @@ -2343,6 +2343,89 @@ const syncSecretsCircleCI = async ({ ); }; +/** + * Sync/push [secrets] to CircleCI Context + */ +const syncSecretsCircleCIContext = async ({ + integration, + secrets, + accessToken +}: { + integration: TIntegrations; + secrets: Record; + accessToken: string; +}) => { + // sync secrets to CircleCI + await Promise.all( + Object.keys(secrets).map(async (key) => + request.put( + `${IntegrationUrls.CIRCLECI_API_URL}/v2/context/${integration.appId}/environment-variable/${key}`, + { + value: secrets[key].value + }, + { + headers: { + "Circle-Token": accessToken, + "Content-Type": "application/json" + } + } + ) + ) + ); + + // get secrets from CircleCI + const getSecretsRes = async () => { + type EnvVars = { + variable: string; + created_at: string; + updated_at: string; + context_id: string; + }; + + type ResponseSchema = { + items: EnvVars[]; + next_page_token: string | null; + }; + + let nextPageToken: string | null | undefined; + const envVars: EnvVars[] = []; + + while (nextPageToken !== null) { + const res = await request.get( + `${IntegrationUrls.CIRCLECI_API_URL}/v2/context/${integration.appId}/environment-variable`, + { + headers: { + "Circle-Token": accessToken, + "Accept-Encoding": "application/json" + } + } + ); + + envVars.push(...res.data.items); + nextPageToken = res.data.next_page_token; + } + + return envVars; + }; + + // delete secrets from CircleCI + await Promise.all( + (await getSecretsRes()).map(async (sec) => { + if (!(sec.variable in secrets)) { + return request.delete( + `${IntegrationUrls.CIRCLECI_API_URL}/v2/context/${integration.appId}/environment-variable/${sec.variable}`, + { + headers: { + "Circle-Token": accessToken, + "Content-Type": "application/json" + } + } + ); + } + }) + ); +}; + /** * Sync/push [secrets] to Databricks project */ @@ -4433,6 +4516,13 @@ export const syncIntegrationSecrets = async ({ accessToken }); break; + case Integrations.CIRCLECI_CONTEXT: + await syncSecretsCircleCIContext({ + integration, + secrets, + accessToken + }); + break; case Integrations.DATABRICKS: await syncSecretsDatabricks({ integration, diff --git a/frontend/public/data/frequentConstants.ts b/frontend/public/data/frequentConstants.ts index 51c006424..5d496db75 100644 --- a/frontend/public/data/frequentConstants.ts +++ b/frontend/public/data/frequentConstants.ts @@ -16,6 +16,7 @@ const integrationSlugNameMapping: Mapping = { railway: "Railway", flyio: "Fly.io", circleci: "CircleCI", + "circleci-context": "CircleCI Context", databricks: "Databricks", travisci: "TravisCI", supabase: "Supabase", diff --git a/frontend/src/hooks/api/integrationAuth/index.tsx b/frontend/src/hooks/api/integrationAuth/index.tsx index 0ae3511de..e7ee5928a 100644 --- a/frontend/src/hooks/api/integrationAuth/index.tsx +++ b/frontend/src/hooks/api/integrationAuth/index.tsx @@ -7,6 +7,7 @@ export { useGetIntegrationAuthBitBucketWorkspaces, useGetIntegrationAuthById, useGetIntegrationAuthChecklyGroups, + useGetIntegrationAuthCircleCIOrganizations, useGetIntegrationAuthGithubEnvs, useGetIntegrationAuthGithubOrgs, useGetIntegrationAuthNorthflankSecretGroups, diff --git a/frontend/src/hooks/api/integrationAuth/queries.tsx b/frontend/src/hooks/api/integrationAuth/queries.tsx index e5f928158..84a50ae1f 100644 --- a/frontend/src/hooks/api/integrationAuth/queries.tsx +++ b/frontend/src/hooks/api/integrationAuth/queries.tsx @@ -8,6 +8,7 @@ import { BitBucketEnvironment, BitBucketWorkspace, ChecklyGroup, + CircleCIOrganization, Environment, HerokuPipelineCoupling, IntegrationAuth, @@ -128,7 +129,9 @@ const integrationAuthKeys = { integrationAuthId, ...params }: TGetIntegrationAuthOctopusDeployScopeValuesDTO) => - [{ integrationAuthId }, "getIntegrationAuthOctopusDeployScopeValues", params] as const + [{ integrationAuthId }, "getIntegrationAuthOctopusDeployScopeValues", params] as const, + getIntegrationAuthCircleCIOrganizations: (integrationAuthId: string) => + [{ integrationAuthId }, "getIntegrationAuthCircleCIOrganizations"] as const }; const fetchIntegrationAuthById = async (integrationAuthId: string) => { @@ -510,6 +513,15 @@ const fetchIntegrationAuthOctopusDeployScopeValues = async ({ return data; }; +const fetchIntegrationAuthCircleCIOrganizations = async (integrationAuthId: string) => { + const { + data: { organizations } + } = await apiRequest.get<{ + organizations: CircleCIOrganization[]; + }>(`/api/v1/integration-auth/${integrationAuthId}/circleci/organizations`); + return organizations; +}; + export const useGetIntegrationAuthById = (integrationAuthId: string) => { return useQuery({ queryKey: integrationAuthKeys.getIntegrationAuthById(integrationAuthId), @@ -884,6 +896,13 @@ export const useGetIntegrationAuthTeamCityBuildConfigs = ({ }); }; +export const useGetIntegrationAuthCircleCIOrganizations = (integrationAuthId: string) => { + return useQuery({ + queryKey: integrationAuthKeys.getIntegrationAuthCircleCIOrganizations(integrationAuthId), + queryFn: () => fetchIntegrationAuthCircleCIOrganizations(integrationAuthId) + }); +}; + export const useAuthorizeIntegration = () => { const queryClient = useQueryClient(); diff --git a/frontend/src/hooks/api/integrationAuth/types.ts b/frontend/src/hooks/api/integrationAuth/types.ts index 58a643dff..7a1d469b9 100644 --- a/frontend/src/hooks/api/integrationAuth/types.ts +++ b/frontend/src/hooks/api/integrationAuth/types.ts @@ -105,6 +105,11 @@ export enum OctopusDeployScope { // tenant, variable set } +export type CircleCIOrganization = { + name: string; + slug: string; +}; + export type TGetIntegrationAuthOctopusDeployScopeValuesDTO = { integrationAuthId: string; spaceId: string; diff --git a/frontend/src/pages/integrations/circleci-context/authorize.tsx b/frontend/src/pages/integrations/circleci-context/authorize.tsx new file mode 100644 index 000000000..67252c240 --- /dev/null +++ b/frontend/src/pages/integrations/circleci-context/authorize.tsx @@ -0,0 +1,101 @@ +import { useState } from "react"; +import Head from "next/head"; +import Image from "next/image"; +import Link from "next/link"; +import { useRouter } from "next/router"; +import { faArrowUpRightFromSquare, faBookOpen } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; + +import { Button, Card, CardTitle, FormControl, Input } from "@app/components/v2"; +import { useSaveIntegrationAccessToken } from "@app/hooks/api"; + +export default function CircleCIContextCreateIntegrationPage() { + const router = useRouter(); + const { mutateAsync } = useSaveIntegrationAccessToken(); + + const [apiKey, setApiKey] = useState(""); + const [apiKeyErrorText, setApiKeyErrorText] = useState(""); + const [isLoading, setIsLoading] = useState(false); + + const handleButtonClick = async () => { + try { + setApiKeyErrorText(""); + if (apiKey.length === 0) { + setApiKeyErrorText("API Key cannot be blank"); + return; + } + + setIsLoading(true); + + const integrationAuth = await mutateAsync({ + workspaceId: localStorage.getItem("projectData.id"), + integration: "circleci-context", + accessToken: apiKey + }); + + setIsLoading(false); + + router.push(`/integrations/circleci-context/create?integrationAuthId=${integrationAuth.id}`); + } catch (err) { + console.error(err); + } + }; + + return ( +
+ + Authorize CircleCI Context Integration + + + + +
+
+ CircleCI logo +
+ CircleCI Context Integration + + +
+ + Docs + +
+
+ +
+
+ + setApiKey(e.target.value)} /> + + +
+
+ ); +} + +CircleCIContextCreateIntegrationPage.requireAuth = true; diff --git a/frontend/src/pages/integrations/circleci-context/create.tsx b/frontend/src/pages/integrations/circleci-context/create.tsx new file mode 100644 index 000000000..820490e32 --- /dev/null +++ b/frontend/src/pages/integrations/circleci-context/create.tsx @@ -0,0 +1,245 @@ +import { useEffect } from "react"; +import { Controller, useForm } from "react-hook-form"; +import Image from "next/image"; +import Link from "next/link"; +import { useRouter } from "next/router"; +import { faArrowUpRightFromSquare, faBookOpen } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { z } from "zod"; + +import { createNotification } from "@app/components/notifications"; +import { + Button, + Card, + CardTitle, + FilterableSelect, + FormControl, + Input, + Spinner +} from "@app/components/v2"; +import { useWorkspace } from "@app/context"; +import { useCreateIntegration } from "@app/hooks/api"; +import { + useGetIntegrationAuthApps, + useGetIntegrationAuthCircleCIOrganizations +} from "@app/hooks/api/integrationAuth"; + +const formSchema = z.object({ + secretPath: z.string().default("/"), + sourceEnvironment: z.object({ name: z.string(), slug: z.string() }), + targetOrg: z.object({ name: z.string(), slug: z.string() }), + targetContext: z.object({ name: z.string(), appId: z.string() }) +}); + +type TFormData = z.infer; + +export default function CircleCIContextCreateIntegrationPage() { + const router = useRouter(); + const { mutateAsync, isLoading: isCreatingIntegration } = useCreateIntegration(); + const { currentWorkspace, isLoading: isProjectLoading } = useWorkspace(); + + const integrationAuthId = router.query.integrationAuthId as string; + + const { watch, control, reset, handleSubmit } = useForm({ + resolver: zodResolver(formSchema), + defaultValues: { + secretPath: "/", + sourceEnvironment: currentWorkspace?.environments[0] + } + }); + + const circleCiOrg = watch("targetOrg"); + + const { data: circleCIOrganizations, isLoading: isCircleCIOrganizationsLoading } = + useGetIntegrationAuthCircleCIOrganizations(integrationAuthId); + + const { data: circleCIContexts } = useGetIntegrationAuthApps( + { + integrationAuthId, + workspaceSlug: circleCiOrg?.slug + }, + + { enabled: Boolean(circleCiOrg?.slug) } + ); + + const onSubmit = async ({ + sourceEnvironment, + secretPath, + targetOrg, + targetContext + }: TFormData) => { + try { + await mutateAsync({ + integrationAuthId, + isActive: true, + sourceEnvironment: sourceEnvironment.slug, + app: targetContext.name, + appId: targetContext.appId, + owner: targetOrg.slug, + secretPath + }); + + createNotification({ + type: "success", + text: "Successfully created integration" + }); + router.push(`/integrations/${currentWorkspace?.id}`); + } catch (err) { + createNotification({ + type: "error", + text: "Failed to create integration" + }); + console.error(err); + } + }; + + useEffect(() => { + if (!circleCIContexts || !circleCIOrganizations || !currentWorkspace) return; + + reset({ + targetOrg: circleCIOrganizations[0], + targetContext: circleCIContexts[0] + }); + }, [circleCIOrganizations, circleCIContexts, currentWorkspace]); + + if (isProjectLoading || isCircleCIOrganizationsLoading) + return ( +
+ +
+ ); + + return ( +
+ + +
+
+ CircleCI logo + + CircleCI Context Integration +
+ + +
+ + Docs + +
+ +
+
+ ( + + option.slug} + value={value} + getOptionLabel={(option) => option.name} + onChange={onChange} + options={currentWorkspace?.environments} + placeholder="Select a project environment" + isDisabled={!currentWorkspace?.environments.length} + /> + + )} + /> + ( + + + + )} + /> + ( + + option.slug} + value={value} + getOptionLabel={(option) => option.name} + onChange={onChange} + options={circleCIOrganizations} + placeholder={ + circleCIOrganizations?.length + ? "Select an organization..." + : "No organizations found..." + } + isDisabled={!circleCIOrganizations?.length} + /> + + )} + /> + ( + + option.appId!} + getOptionLabel={(option) => option.name} + onChange={onChange} + options={circleCIContexts} + placeholder={ + circleCIContexts?.length ? "Select a context..." : "No contexts found..." + } + isDisabled={!circleCIContexts?.length} + /> + + )} + /> + + +
+
+ ); +} + +CircleCIContextCreateIntegrationPage.requireAuth = true; diff --git a/frontend/src/views/IntegrationsPage/IntegrationDetailsPage/components/IntegrationConnectionSection.tsx b/frontend/src/views/IntegrationsPage/IntegrationDetailsPage/components/IntegrationConnectionSection.tsx index 778cdd00a..2bd7ee61d 100644 --- a/frontend/src/views/IntegrationsPage/IntegrationDetailsPage/components/IntegrationConnectionSection.tsx +++ b/frontend/src/views/IntegrationsPage/IntegrationDetailsPage/components/IntegrationConnectionSection.tsx @@ -46,6 +46,8 @@ export const IntegrationConnectionSection = ({ integration }: Props) => { case "qovery": return integration.scope; case "circleci": + case "circleci-context": + return "Context"; case "terraform-cloud": return "Project"; case "aws-secret-manager": @@ -77,7 +79,6 @@ export const IntegrationConnectionSection = ({ integration }: Props) => { return `${integration.owner}`; } return `${integration.owner}/${integration.app}`; - case "aws-parameter-store": case "rundeck": return `${integration.path}`; @@ -155,6 +156,15 @@ export const IntegrationConnectionSection = ({ integration }: Props) => { ); } + if (integration.integration === "circleci-context" && integration.owner) { + return ( +
+ +
{integration.owner}
+
+ ); + } + if (integration.integration === "terraform-cloud" && integration.targetService) { return (
diff --git a/frontend/src/views/IntegrationsPage/IntegrationPage.utils.tsx b/frontend/src/views/IntegrationsPage/IntegrationPage.utils.tsx index e1a1ff6fb..298cb9372 100644 --- a/frontend/src/views/IntegrationsPage/IntegrationPage.utils.tsx +++ b/frontend/src/views/IntegrationsPage/IntegrationPage.utils.tsx @@ -120,6 +120,9 @@ export const redirectForProviderAuth = (integrationOption: TCloudIntegration) => case "circleci": link = `${window.location.origin}/integrations/circleci/authorize`; break; + case "circleci-context": + link = `${window.location.origin}/integrations/circleci-context/authorize`; + break; case "databricks": link = `${window.location.origin}/integrations/databricks/authorize`; break; diff --git a/frontend/src/views/IntegrationsPage/components/IntegrationsSection/components/IntegrationDetails.tsx b/frontend/src/views/IntegrationsPage/components/IntegrationsSection/components/IntegrationDetails.tsx index f785ca745..95b4c9d84 100644 --- a/frontend/src/views/IntegrationsPage/components/IntegrationsSection/components/IntegrationDetails.tsx +++ b/frontend/src/views/IntegrationsPage/components/IntegrationsSection/components/IntegrationDetails.tsx @@ -15,6 +15,7 @@ export const getIntegrationDestination = (integration: TIntegration) => (["aws-parameter-store", "rundeck"].includes(integration.integration) && `${integration.path}`) || (integration.scope?.startsWith("github-") && `${integration.owner}/${integration.app}`) || integration.app || + (integration.integration === "circleci-context" && `${integration.owner}`) || "-"; export const IntegrationDetails = ({ integration }: Props) => { @@ -53,6 +54,7 @@ export const IntegrationDetails = ({ integration }: Props) => { label={ (integration.integration === "qovery" && integration?.scope) || (integration.integration === "circleci" && "Project") || + (integration.integration === "circleci-context" && "Context") || (integration.integration === "bitbucket" && "Repository") || (integration.integration === "octopus-deploy" && "Project") || (integration.integration === "aws-secret-manager" && "Secret") || @@ -110,6 +112,12 @@ export const IntegrationDetails = ({ integration }: Props) => {
{integration.owner}
)} + {integration.integration === "circleci-context" && integration.owner && ( +
+ +
{integration.owner}
+
+ )} {integration.integration === "terraform-cloud" && integration.targetService && (
From a730b163182a6b5ad470a1cb2e44a7b9035e4400 Mon Sep 17 00:00:00 2001 From: McPizza Date: Tue, 10 Dec 2024 20:12:55 +0100 Subject: [PATCH 036/162] fix circleCI name spacing --- .../services/integration-auth/integration-list.ts | 8 ++++---- .../integrations/{Circle CI.png => CircleCI.png} | Bin .../integrations/circleci-context/authorize.tsx | 2 +- .../pages/integrations/circleci-context/create.tsx | 4 ++-- .../src/pages/integrations/circleci/authorize.tsx | 2 +- frontend/src/pages/integrations/circleci/create.tsx | 2 +- 6 files changed, 9 insertions(+), 9 deletions(-) rename frontend/public/images/integrations/{Circle CI.png => CircleCI.png} (100%) diff --git a/backend/src/services/integration-auth/integration-list.ts b/backend/src/services/integration-auth/integration-list.ts index 2c7e7e288..cba923648 100644 --- a/backend/src/services/integration-auth/integration-list.ts +++ b/backend/src/services/integration-auth/integration-list.ts @@ -221,18 +221,18 @@ export const getIntegrationOptions = async () => { docsLink: "" }, { - name: "Circle CI", + name: "CircleCI", slug: "circleci", - image: "Circle CI.png", + image: "CircleCI.png", isAvailable: true, type: "pat", clientId: "", docsLink: "" }, { - name: "Circle CI Contexts", + name: "CircleCI Contexts", slug: "circleci-context", - image: "Circle CI.png", + image: "CircleCI.png", isAvailable: true, type: "pat", clientId: "", diff --git a/frontend/public/images/integrations/Circle CI.png b/frontend/public/images/integrations/CircleCI.png similarity index 100% rename from frontend/public/images/integrations/Circle CI.png rename to frontend/public/images/integrations/CircleCI.png diff --git a/frontend/src/pages/integrations/circleci-context/authorize.tsx b/frontend/src/pages/integrations/circleci-context/authorize.tsx index 67252c240..ec2cd33a0 100644 --- a/frontend/src/pages/integrations/circleci-context/authorize.tsx +++ b/frontend/src/pages/integrations/circleci-context/authorize.tsx @@ -55,7 +55,7 @@ export default function CircleCIContextCreateIntegrationPage() {
CircleCI logo
CircleCI logo -
+
Docs
CircleCI logo
CircleCI logo Date: Tue, 10 Dec 2024 11:34:08 -0800 Subject: [PATCH 037/162] Add suggested PR review improvements, better validation on ssh cert template modal --- .../v1/ssh-certificate-template-router.ts | 65 +++++++------- .../src/services/project/project-service.ts | 4 +- .../components/SshCertificateModal.tsx | 1 - .../SshCertificateTemplateModal.tsx | 86 ++++++++++++++++--- .../SshCertificateTemplatesSection.tsx | 8 +- .../SshCertificateTemplatesTable.tsx | 7 +- 6 files changed, 119 insertions(+), 52 deletions(-) diff --git a/backend/src/ee/routes/v1/ssh-certificate-template-router.ts b/backend/src/ee/routes/v1/ssh-certificate-template-router.ts index 14e1e0dc7..f94b9d50a 100644 --- a/backend/src/ee/routes/v1/ssh-certificate-template-router.ts +++ b/backend/src/ee/routes/v1/ssh-certificate-template-router.ts @@ -61,36 +61,41 @@ export const registerSshCertificateTemplateRouter = async (server: FastifyZodPro rateLimit: writeLimit }, schema: { - body: z.object({ - sshCaId: z.string().describe(SSH_CERTIFICATE_TEMPLATES.CREATE.sshCaId), - name: z - .string() - .min(1) - .max(36) - .refine((v) => slugify(v) === v, { - message: "Name must be a valid slug" - }) - .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) - }), + body: z + .object({ + sshCaId: z.string().describe(SSH_CERTIFICATE_TEMPLATES.CREATE.sshCaId), + name: z + .string() + .min(1) + .max(36) + .refine((v) => slugify(v) === v, { + message: "Name must be a valid slug" + }) + .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) + }) + .refine((data) => ms(data.maxTTL) > ms(data.ttl), { + message: "Max TLL must be greater than TTL", + path: ["maxTTL"] + }), response: { 200: sanitizedSshCertificateTemplate } diff --git a/backend/src/services/project/project-service.ts b/backend/src/services/project/project-service.ts index 49ee43eda..c2ba53430 100644 --- a/backend/src/services/project/project-service.ts +++ b/backend/src/services/project/project-service.ts @@ -905,7 +905,7 @@ export const projectServiceFactory = ({ }; /** - * Return list of SSH certificates for organization + * Return list of SSH certificates for project */ const listProjectSshCertificates = async ({ limit = 25, @@ -945,7 +945,7 @@ export const projectServiceFactory = ({ }; /** - * Return list of SSH certificate templates for organization + * Return list of SSH certificate templates for project */ const listProjectSshCertificateTemplates = async ({ actorId, diff --git a/frontend/src/views/Project/SshCaPage/components/SshCertificateModal.tsx b/frontend/src/views/Project/SshCaPage/components/SshCertificateModal.tsx index 9495ab141..5c027316a 100644 --- a/frontend/src/views/Project/SshCaPage/components/SshCertificateModal.tsx +++ b/frontend/src/views/Project/SshCaPage/components/SshCertificateModal.tsx @@ -75,7 +75,6 @@ export const SshCertificateModal = ({ popUp, handlePopUpToggle }: Props) => { const popUpData = popUp?.sshCertificate?.data as { sshCaId: string; - templateName: string; templateId: string; }; diff --git a/frontend/src/views/Project/SshCaPage/components/SshCertificateTemplateModal.tsx b/frontend/src/views/Project/SshCaPage/components/SshCertificateTemplateModal.tsx index 7bdcd319c..b193ae257 100644 --- a/frontend/src/views/Project/SshCaPage/components/SshCertificateTemplateModal.tsx +++ b/frontend/src/views/Project/SshCaPage/components/SshCertificateTemplateModal.tsx @@ -1,6 +1,8 @@ import { useEffect } from "react"; import { Controller, useForm } from "react-hook-form"; import { zodResolver } from "@hookform/resolvers/zod"; +import slugify from "@sindresorhus/slugify"; +import ms from "ms"; import { z } from "zod"; import { createNotification } from "@app/components/notifications"; @@ -24,17 +26,79 @@ import { } 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) -}); +// 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); +}; + +const schema = z + .object({ + sshCaId: z.string(), + name: z + .string() + .trim() + .toLowerCase() + .min(1) + .max(36) + .refine((v) => slugify(v) === v, { + message: "Invalid name. Name can only contain alphanumeric characters and hyphens." + }), + ttl: z + .string() + .trim() + .refine( + (val) => ms(val) > 0, + "TTL must be a valid time string such as 2 days, 1d, 2h 1y, ..." + ) + .default("1h"), + maxTTL: z + .string() + .trim() + .refine( + (val) => ms(val) > 0, + "Max TTL must be a valid time string such as 2 days, 1d, 2h 1y, ..." + ) + .default("30d"), + allowedUsers: z.string().refine( + (val) => { + const trimmed = val.trim(); + if (trimmed === "") return true; + const users = trimmed.split(",").map((u) => u.trim()); + return users.every(isValidUserPattern); + }, + { + message: "Invalid user pattern in allowedUsers" + } + ), + allowedHosts: z.string().refine( + (val) => { + const trimmed = val.trim(); + if (trimmed === "") return true; + const users = trimmed.split(",").map((u) => u.trim()); + return users.every(isValidHostPattern); + }, + { + message: "Invalid host pattern in allowedHosts" + } + ), + allowUserCertificates: z.boolean().optional().default(false), + allowHostCertificates: z.boolean().optional().default(false), + allowCustomKeyIds: z.boolean().optional().default(false) + }) + .refine((data) => ms(data.maxTTL) > ms(data.ttl), { + message: "Max TLL must be greater than TTL", + path: ["maxTTL"] + }); export type FormData = z.infer; diff --git a/frontend/src/views/Project/SshCaPage/components/SshCertificateTemplatesSection.tsx b/frontend/src/views/Project/SshCaPage/components/SshCertificateTemplatesSection.tsx index b6bdde96c..f3fa88c93 100644 --- a/frontend/src/views/Project/SshCaPage/components/SshCertificateTemplatesSection.tsx +++ b/frontend/src/views/Project/SshCaPage/components/SshCertificateTemplatesSection.tsx @@ -54,14 +54,14 @@ export const SshCertificateTemplatesSection = ({ caId }: Props) => { }; const onUpdateSshCaStatus = async ({ - certTemplateId, + templateId, status }: { - certTemplateId: string; + templateId: string; status: SshCertTemplateStatus; }) => { try { - await updateSshCertTemplate({ id: certTemplateId, status }); + await updateSshCertTemplate({ id: templateId, status }); await createNotification({ text: `Successfully ${ @@ -144,7 +144,7 @@ export const SshCertificateTemplatesSection = ({ caId }: Props) => { onDeleteApproved={() => onUpdateSshCaStatus( popUp?.sshCertificateTemplateStatus?.data as { - certTemplateId: string; + templateId: string; status: SshCertTemplateStatus; } ) diff --git a/frontend/src/views/Project/SshCaPage/components/SshCertificateTemplatesTable.tsx b/frontend/src/views/Project/SshCaPage/components/SshCertificateTemplatesTable.tsx index bc8e751fc..9a41340e9 100644 --- a/frontend/src/views/Project/SshCaPage/components/SshCertificateTemplatesTable.tsx +++ b/frontend/src/views/Project/SshCaPage/components/SshCertificateTemplatesTable.tsx @@ -47,9 +47,8 @@ type Props = { id?: string; name?: string; sshCaId?: string; - certTemplateId?: string; status?: SshCertTemplateStatus; - templateName?: string; + templateId?: string; } ) => void; }; @@ -101,7 +100,7 @@ export const SshCertificateTemplatesTable = ({ handlePopUpOpen, sshCaId }: Props onClick={(e) => { e.stopPropagation(); handlePopUpOpen("sshCertificateTemplateStatus", { - certTemplateId: certificateTemplate.id, + templateId: certificateTemplate.id, status: certificateTemplate.status === SshCertTemplateStatus.ACTIVE ? SshCertTemplateStatus.DISABLED @@ -127,7 +126,7 @@ export const SshCertificateTemplatesTable = ({ handlePopUpOpen, sshCaId }: Props onClick={() => { handlePopUpOpen("sshCertificate", { sshCaId, - templateName: certificateTemplate.name + templateId: certificateTemplate.id }); }} icon={ From 9d9f6ec26883679894ebf656e8f8305a4e6c1006 Mon Sep 17 00:00:00 2001 From: Sheen Capadngan Date: Wed, 11 Dec 2024 03:40:21 +0800 Subject: [PATCH 038/162] misc: initial ui work --- backend/src/services/identity/identity-fns.ts | 7 +- .../src/services/identity/identity-org-dal.ts | 19 +- .../src/hooks/api/identities/constants.tsx | 3 +- frontend/src/hooks/api/identities/enums.tsx | 8 +- frontend/src/hooks/api/identities/index.tsx | 10 +- .../src/hooks/api/identities/mutations.tsx | 116 +++ frontend/src/hooks/api/identities/queries.tsx | 29 + frontend/src/hooks/api/identities/types.ts | 61 +- .../IdentityAuthMethodModalContent.tsx | 25 +- .../IdentitySection/IdentityJwtAuthForm.tsx | 670 ++++++++++++++++++ 10 files changed, 937 insertions(+), 11 deletions(-) create mode 100644 frontend/src/views/Org/MembersPage/components/OrgIdentityTab/components/IdentitySection/IdentityJwtAuthForm.tsx diff --git a/backend/src/services/identity/identity-fns.ts b/backend/src/services/identity/identity-fns.ts index 49cf4d119..2d77e6544 100644 --- a/backend/src/services/identity/identity-fns.ts +++ b/backend/src/services/identity/identity-fns.ts @@ -7,7 +7,8 @@ export const buildAuthMethods = ({ kubernetesId, oidcId, azureId, - tokenId + tokenId, + jwtId }: { uaId?: string; gcpId?: string; @@ -16,6 +17,7 @@ export const buildAuthMethods = ({ oidcId?: string; azureId?: string; tokenId?: string; + jwtId?: string; }) => { return [ ...[uaId ? IdentityAuthMethod.UNIVERSAL_AUTH : null], @@ -24,6 +26,7 @@ export const buildAuthMethods = ({ ...[kubernetesId ? IdentityAuthMethod.KUBERNETES_AUTH : null], ...[oidcId ? IdentityAuthMethod.OIDC_AUTH : null], ...[azureId ? IdentityAuthMethod.AZURE_AUTH : null], - ...[tokenId ? IdentityAuthMethod.TOKEN_AUTH : null] + ...[tokenId ? IdentityAuthMethod.TOKEN_AUTH : null], + ...[jwtId ? IdentityAuthMethod.JWT_AUTH : null] ].filter((authMethod) => authMethod) as IdentityAuthMethod[]; }; diff --git a/backend/src/services/identity/identity-org-dal.ts b/backend/src/services/identity/identity-org-dal.ts index bbdf96a2b..92a6795d0 100644 --- a/backend/src/services/identity/identity-org-dal.ts +++ b/backend/src/services/identity/identity-org-dal.ts @@ -6,6 +6,7 @@ import { TIdentityAwsAuths, TIdentityAzureAuths, TIdentityGcpAuths, + TIdentityJwtAuths, TIdentityKubernetesAuths, TIdentityOidcAuths, TIdentityOrgMemberships, @@ -70,6 +71,11 @@ export const identityOrgDALFactory = (db: TDbClient) => { `${TableName.IdentityOrgMembership}.identityId`, `${TableName.IdentityTokenAuth}.identityId` ) + .leftJoin( + TableName.IdentityJwtAuth, + `${TableName.IdentityOrgMembership}.identityId`, + `${TableName.IdentityJwtAuth}.identityId` + ) .select( selectAllTableCols(TableName.IdentityOrgMembership), @@ -81,6 +87,7 @@ export const identityOrgDALFactory = (db: TDbClient) => { db.ref("id").as("oidcId").withSchema(TableName.IdentityOidcAuth), db.ref("id").as("azureId").withSchema(TableName.IdentityAzureAuth), db.ref("id").as("tokenId").withSchema(TableName.IdentityTokenAuth), + db.ref("id").as("jwtId").withSchema(TableName.IdentityJwtAuth), db.ref("name").withSchema(TableName.Identity) ); @@ -183,6 +190,11 @@ export const identityOrgDALFactory = (db: TDbClient) => { "paginatedIdentity.identityId", `${TableName.IdentityTokenAuth}.identityId` ) + .leftJoin( + TableName.IdentityJwtAuth, + "paginatedIdentity.identityId", + `${TableName.IdentityJwtAuth}.identityId` + ) .select( db.ref("id").withSchema("paginatedIdentity"), @@ -200,7 +212,8 @@ export const identityOrgDALFactory = (db: TDbClient) => { db.ref("id").as("kubernetesId").withSchema(TableName.IdentityKubernetesAuth), db.ref("id").as("oidcId").withSchema(TableName.IdentityOidcAuth), db.ref("id").as("azureId").withSchema(TableName.IdentityAzureAuth), - db.ref("id").as("tokenId").withSchema(TableName.IdentityTokenAuth) + db.ref("id").as("tokenId").withSchema(TableName.IdentityTokenAuth), + db.ref("id").as("jwtId").withSchema(TableName.IdentityJwtAuth) ) // cr stands for custom role .select(db.ref("id").as("crId").withSchema(TableName.OrgRoles)) @@ -237,6 +250,7 @@ export const identityOrgDALFactory = (db: TDbClient) => { uaId, awsId, gcpId, + jwtId, kubernetesId, oidcId, azureId, @@ -271,7 +285,8 @@ export const identityOrgDALFactory = (db: TDbClient) => { kubernetesId, oidcId, azureId, - tokenId + tokenId, + jwtId }) } }), diff --git a/frontend/src/hooks/api/identities/constants.tsx b/frontend/src/hooks/api/identities/constants.tsx index 0c57ee82c..c11d7dc11 100644 --- a/frontend/src/hooks/api/identities/constants.tsx +++ b/frontend/src/hooks/api/identities/constants.tsx @@ -7,5 +7,6 @@ export const identityAuthToNameMap: { [I in IdentityAuthMethod]: string } = { [IdentityAuthMethod.GCP_AUTH]: "GCP Auth", [IdentityAuthMethod.AWS_AUTH]: "AWS Auth", [IdentityAuthMethod.AZURE_AUTH]: "Azure Auth", - [IdentityAuthMethod.OIDC_AUTH]: "OIDC Auth" + [IdentityAuthMethod.OIDC_AUTH]: "OIDC Auth", + [IdentityAuthMethod.JWT_AUTH]: "JWT Auth" }; diff --git a/frontend/src/hooks/api/identities/enums.tsx b/frontend/src/hooks/api/identities/enums.tsx index 5e445521a..415492e00 100644 --- a/frontend/src/hooks/api/identities/enums.tsx +++ b/frontend/src/hooks/api/identities/enums.tsx @@ -5,5 +5,11 @@ export enum IdentityAuthMethod { GCP_AUTH = "gcp-auth", AWS_AUTH = "aws-auth", AZURE_AUTH = "azure-auth", - OIDC_AUTH = "oidc-auth" + OIDC_AUTH = "oidc-auth", + JWT_AUTH = "jwt-auth" +} + +export enum IdentityJwtConfigurationType { + JWKS = "jwks", + STATIC = "static" } diff --git a/frontend/src/hooks/api/identities/index.tsx b/frontend/src/hooks/api/identities/index.tsx index 5c7bcc3e7..261556752 100644 --- a/frontend/src/hooks/api/identities/index.tsx +++ b/frontend/src/hooks/api/identities/index.tsx @@ -4,6 +4,7 @@ export { useAddIdentityAwsAuth, useAddIdentityAzureAuth, useAddIdentityGcpAuth, + useAddIdentityJwtAuth, useAddIdentityKubernetesAuth, useAddIdentityOidcAuth, useAddIdentityTokenAuth, @@ -15,6 +16,7 @@ export { useDeleteIdentityAwsAuth, useDeleteIdentityAzureAuth, useDeleteIdentityGcpAuth, + useDeleteIdentityJwtAuth, useDeleteIdentityKubernetesAuth, useDeleteIdentityOidcAuth, useDeleteIdentityTokenAuth, @@ -25,20 +27,24 @@ export { useUpdateIdentityAwsAuth, useUpdateIdentityAzureAuth, useUpdateIdentityGcpAuth, + useUpdateIdentityJwtAuth, useUpdateIdentityKubernetesAuth, useUpdateIdentityOidcAuth, useUpdateIdentityTokenAuth, useUpdateIdentityTokenAuthToken, - useUpdateIdentityUniversalAuth} from "./mutations"; + useUpdateIdentityUniversalAuth +} from "./mutations"; export { useGetIdentityAwsAuth, useGetIdentityAzureAuth, useGetIdentityById, useGetIdentityGcpAuth, + useGetIdentityJwtAuth, useGetIdentityKubernetesAuth, useGetIdentityOidcAuth, useGetIdentityProjectMemberships, useGetIdentityTokenAuth, useGetIdentityTokensTokenAuth, useGetIdentityUniversalAuth, - useGetIdentityUniversalAuthClientSecrets} from "./queries"; + useGetIdentityUniversalAuthClientSecrets +} from "./queries"; diff --git a/frontend/src/hooks/api/identities/mutations.tsx b/frontend/src/hooks/api/identities/mutations.tsx index 21c4c560e..8daaae236 100644 --- a/frontend/src/hooks/api/identities/mutations.tsx +++ b/frontend/src/hooks/api/identities/mutations.tsx @@ -8,6 +8,7 @@ import { AddIdentityAwsAuthDTO, AddIdentityAzureAuthDTO, AddIdentityGcpAuthDTO, + AddIdentityJwtAuthDTO, AddIdentityKubernetesAuthDTO, AddIdentityOidcAuthDTO, AddIdentityTokenAuthDTO, @@ -22,6 +23,7 @@ import { DeleteIdentityAzureAuthDTO, DeleteIdentityDTO, DeleteIdentityGcpAuthDTO, + DeleteIdentityJwtAuthDTO, DeleteIdentityKubernetesAuthDTO, DeleteIdentityOidcAuthDTO, DeleteIdentityTokenAuthDTO, @@ -32,6 +34,7 @@ import { IdentityAwsAuth, IdentityAzureAuth, IdentityGcpAuth, + IdentityJwtAuth, IdentityKubernetesAuth, IdentityOidcAuth, IdentityTokenAuth, @@ -42,6 +45,7 @@ import { UpdateIdentityAzureAuthDTO, UpdateIdentityDTO, UpdateIdentityGcpAuthDTO, + UpdateIdentityJwtAuthDTO, UpdateIdentityKubernetesAuthDTO, UpdateIdentityOidcAuthDTO, UpdateIdentityTokenAuthDTO, @@ -518,6 +522,118 @@ export const useDeleteIdentityOidcAuth = () => { } }); }; +export const useUpdateIdentityJwtAuth = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async ({ + identityId, + configurationType, + jwksUrl, + jwksCaCert, + publicKeys, + accessTokenTTL, + accessTokenMaxTTL, + accessTokenNumUsesLimit, + accessTokenTrustedIps, + boundIssuer, + boundAudiences, + boundClaims, + boundSubject + }) => { + const { + data: { identityJwtAuth } + } = await apiRequest.patch<{ identityJwtAuth: IdentityJwtAuth }>( + `/api/v1/auth/jwt-auth/identities/${identityId}`, + { + configurationType, + jwksUrl, + jwksCaCert, + publicKeys, + boundIssuer, + boundAudiences, + boundClaims, + boundSubject, + accessTokenTTL, + accessTokenMaxTTL, + accessTokenNumUsesLimit, + accessTokenTrustedIps + } + ); + + return identityJwtAuth; + }, + onSuccess: (_, { identityId, organizationId }) => { + queryClient.invalidateQueries(organizationKeys.getOrgIdentityMemberships(organizationId)); + queryClient.invalidateQueries(identitiesKeys.getIdentityById(identityId)); + queryClient.invalidateQueries(identitiesKeys.getIdentityJwtAuth(identityId)); + } + }); +}; + +export const useAddIdentityJwtAuth = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async ({ + identityId, + configurationType, + jwksUrl, + jwksCaCert, + publicKeys, + boundIssuer, + boundAudiences, + boundClaims, + boundSubject, + accessTokenTTL, + accessTokenMaxTTL, + accessTokenNumUsesLimit, + accessTokenTrustedIps + }) => { + const { + data: { identityJwtAuth } + } = await apiRequest.post<{ identityJwtAuth: IdentityJwtAuth }>( + `/api/v1/auth/jwt-auth/identities/${identityId}`, + { + configurationType, + jwksUrl, + jwksCaCert, + publicKeys, + boundIssuer, + boundAudiences, + boundClaims, + boundSubject, + accessTokenTTL, + accessTokenMaxTTL, + accessTokenNumUsesLimit, + accessTokenTrustedIps + } + ); + + return identityJwtAuth; + }, + onSuccess: (_, { identityId, organizationId }) => { + queryClient.invalidateQueries(organizationKeys.getOrgIdentityMemberships(organizationId)); + queryClient.invalidateQueries(identitiesKeys.getIdentityById(identityId)); + queryClient.invalidateQueries(identitiesKeys.getIdentityJwtAuth(identityId)); + } + }); +}; + +export const useDeleteIdentityJwtAuth = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async ({ identityId }) => { + const { + data: { identityJwtAuth } + } = await apiRequest.delete(`/api/v1/auth/jwt-auth/identities/${identityId}`); + return identityJwtAuth; + }, + onSuccess: (_, { organizationId, identityId }) => { + queryClient.invalidateQueries(organizationKeys.getOrgIdentityMemberships(organizationId)); + queryClient.invalidateQueries(identitiesKeys.getIdentityById(identityId)); + queryClient.invalidateQueries(identitiesKeys.getIdentityJwtAuth(identityId)); + } + }); +}; export const useAddIdentityAzureAuth = () => { const queryClient = useQueryClient(); diff --git a/frontend/src/hooks/api/identities/queries.tsx b/frontend/src/hooks/api/identities/queries.tsx index c5c442407..49136614e 100644 --- a/frontend/src/hooks/api/identities/queries.tsx +++ b/frontend/src/hooks/api/identities/queries.tsx @@ -8,6 +8,7 @@ import { IdentityAwsAuth, IdentityAzureAuth, IdentityGcpAuth, + IdentityJwtAuth, IdentityKubernetesAuth, IdentityMembership, IdentityMembershipOrg, @@ -29,6 +30,7 @@ export const identitiesKeys = { getIdentityAwsAuth: (identityId: string) => [{ identityId }, "identity-aws-auth"] as const, getIdentityAzureAuth: (identityId: string) => [{ identityId }, "identity-azure-auth"] as const, getIdentityTokenAuth: (identityId: string) => [{ identityId }, "identity-token-auth"] as const, + getIdentityJwtAuth: (identityId: string) => [{ identityId }, "identity-jwt-auth"] as const, getIdentityTokensTokenAuth: (identityId: string) => [{ identityId }, "identity-tokens-token-auth"] as const, getIdentityProjectMemberships: (identityId: string) => @@ -276,3 +278,30 @@ export const useGetIdentityOidcAuth = ( enabled: Boolean(identityId) && (options?.enabled ?? true) }); }; + +export const useGetIdentityJwtAuth = ( + identityId: string, + options?: UseQueryOptions< + IdentityJwtAuth, + unknown, + IdentityJwtAuth, + ReturnType + > +) => { + return useQuery({ + queryKey: identitiesKeys.getIdentityJwtAuth(identityId), + queryFn: async () => { + const { + data: { identityJwtAuth } + } = await apiRequest.get<{ identityJwtAuth: IdentityJwtAuth }>( + `/api/v1/auth/jwt-auth/identities/${identityId}` + ); + + return identityJwtAuth; + }, + staleTime: 0, + cacheTime: 0, + ...options, + enabled: Boolean(identityId) && (options?.enabled ?? true) + }); +}; diff --git a/frontend/src/hooks/api/identities/types.ts b/frontend/src/hooks/api/identities/types.ts index 559a01974..9100589d9 100644 --- a/frontend/src/hooks/api/identities/types.ts +++ b/frontend/src/hooks/api/identities/types.ts @@ -1,6 +1,6 @@ import { TOrgRole } from "../roles/types"; import { ProjectUserMembershipTemporaryMode, Workspace } from "../workspace/types"; -import { IdentityAuthMethod } from "./enums"; +import { IdentityAuthMethod, IdentityJwtConfigurationType } from "./enums"; export type IdentityTrustedIp = { id: string; @@ -446,6 +446,65 @@ export type DeleteIdentityTokenAuthDTO = { identityId: string; }; +export type IdentityJwtAuth = { + identityId: string; + configurationType: IdentityJwtConfigurationType; + jwksUrl: string; + jwksCaCert: string; + publicKeys: string[]; + boundIssuer: string; + boundAudiences: string; + boundClaims: Record; + boundSubject: string; + accessTokenTTL: number; + accessTokenMaxTTL: number; + accessTokenNumUsesLimit: number; + accessTokenTrustedIps: IdentityTrustedIp[]; +}; + +export type AddIdentityJwtAuthDTO = { + organizationId: string; + identityId: string; + configurationType: string; + jwksUrl?: string; + jwksCaCert: string; + publicKeys?: string[]; + boundIssuer: string; + boundAudiences: string; + boundClaims: Record; + boundSubject: string; + accessTokenTTL: number; + accessTokenMaxTTL: number; + accessTokenNumUsesLimit: number; + accessTokenTrustedIps: { + ipAddress: string; + }[]; +}; + +export type UpdateIdentityJwtAuthDTO = { + organizationId: string; + identityId: string; + configurationType?: string; + jwksUrl?: string; + jwksCaCert?: string; + publicKeys?: string[]; + boundIssuer?: string; + boundAudiences?: string; + boundClaims?: Record; + boundSubject?: string; + accessTokenTTL?: number; + accessTokenMaxTTL?: number; + accessTokenNumUsesLimit?: number; + accessTokenTrustedIps?: { + ipAddress: string; + }[]; +}; + +export type DeleteIdentityJwtAuthDTO = { + organizationId: string; + identityId: string; +}; + export type CreateTokenIdentityTokenAuthDTO = { identityId: string; name: string; diff --git a/frontend/src/views/Org/MembersPage/components/OrgIdentityTab/components/IdentitySection/IdentityAuthMethodModalContent.tsx b/frontend/src/views/Org/MembersPage/components/OrgIdentityTab/components/IdentitySection/IdentityAuthMethodModalContent.tsx index 8852af872..fe03e5e68 100644 --- a/frontend/src/views/Org/MembersPage/components/OrgIdentityTab/components/IdentitySection/IdentityAuthMethodModalContent.tsx +++ b/frontend/src/views/Org/MembersPage/components/OrgIdentityTab/components/IdentitySection/IdentityAuthMethodModalContent.tsx @@ -23,12 +23,17 @@ import { useDeleteIdentityTokenAuth, useDeleteIdentityUniversalAuth } from "@app/hooks/api"; -import { IdentityAuthMethod, identityAuthToNameMap } from "@app/hooks/api/identities"; +import { + IdentityAuthMethod, + identityAuthToNameMap, + useDeleteIdentityJwtAuth +} from "@app/hooks/api/identities"; import { UsePopUpState } from "@app/hooks/usePopUp"; import { IdentityAwsAuthForm } from "./IdentityAwsAuthForm"; import { IdentityAzureAuthForm } from "./IdentityAzureAuthForm"; import { IdentityGcpAuthForm } from "./IdentityGcpAuthForm"; +import { IdentityJwtAuthForm } from "./IdentityJwtAuthForm"; import { IdentityKubernetesAuthForm } from "./IdentityKubernetesAuthForm"; import { IdentityOidcAuthForm } from "./IdentityOidcAuthForm"; import { IdentityTokenAuthForm } from "./IdentityTokenAuthForm"; @@ -68,7 +73,11 @@ const identityAuthMethods = [ { label: "GCP Auth", value: IdentityAuthMethod.GCP_AUTH }, { label: "AWS Auth", value: IdentityAuthMethod.AWS_AUTH }, { label: "Azure Auth", value: IdentityAuthMethod.AZURE_AUTH }, - { label: "OIDC Auth", value: IdentityAuthMethod.OIDC_AUTH } + { label: "OIDC Auth", value: IdentityAuthMethod.OIDC_AUTH }, + { + label: "JWT Auth", + value: IdentityAuthMethod.JWT_AUTH + } ]; const schema = yup @@ -100,6 +109,7 @@ export const IdentityAuthMethodModalContent = ({ const { mutateAsync: revokeAwsAuth } = useDeleteIdentityAwsAuth(); const { mutateAsync: revokeAzureAuth } = useDeleteIdentityAzureAuth(); const { mutateAsync: revokeOidcAuth } = useDeleteIdentityOidcAuth(); + const { mutateAsync: revokeJwtAuth } = useDeleteIdentityJwtAuth(); const { control, watch } = useForm({ resolver: yupResolver(schema), @@ -216,6 +226,17 @@ export const IdentityAuthMethodModalContent = ({ handlePopUpToggle={handlePopUpToggle} /> ) + }, + + [IdentityAuthMethod.JWT_AUTH]: { + revokeMethod: revokeJwtAuth, + render: () => ( + + ) } }; diff --git a/frontend/src/views/Org/MembersPage/components/OrgIdentityTab/components/IdentitySection/IdentityJwtAuthForm.tsx b/frontend/src/views/Org/MembersPage/components/OrgIdentityTab/components/IdentitySection/IdentityJwtAuthForm.tsx new file mode 100644 index 000000000..5785f23e6 --- /dev/null +++ b/frontend/src/views/Org/MembersPage/components/OrgIdentityTab/components/IdentitySection/IdentityJwtAuthForm.tsx @@ -0,0 +1,670 @@ +import { useEffect } from "react"; +import { Controller, useFieldArray, useForm } from "react-hook-form"; +import { faQuestionCircle } from "@fortawesome/free-regular-svg-icons"; +import { faPlus, faXmark } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { z } from "zod"; + +import { createNotification } from "@app/components/notifications"; +import { + Button, + FormControl, + IconButton, + Input, + Select, + SelectItem, + TextArea, + Tooltip +} from "@app/components/v2"; +import { useOrganization, useSubscription } from "@app/context"; +import { useAddIdentityJwtAuth, useUpdateIdentityJwtAuth } from "@app/hooks/api"; +import { IdentityAuthMethod } from "@app/hooks/api/identities"; +import { IdentityJwtConfigurationType } from "@app/hooks/api/identities/enums"; +import { useGetIdentityJwtAuth } from "@app/hooks/api/identities/queries"; +import { IdentityTrustedIp } from "@app/hooks/api/identities/types"; +import { UsePopUpState } from "@app/hooks/usePopUp"; + +const commonSchema = z.object({ + accessTokenTrustedIps: z + .array( + z.object({ + ipAddress: z.string().max(50) + }) + ) + .min(1), + accessTokenTTL: z.string().refine((val) => Number(val) <= 315360000, { + message: "Access Token TTL cannot be greater than 315360000" + }), + accessTokenMaxTTL: z.string().refine((val) => Number(val) <= 315360000, { + message: "Access Token Max TTL cannot be greater than 315360000" + }), + accessTokenNumUsesLimit: z.string(), + boundIssuer: z.string().trim().default(""), + boundAudiences: z.string().optional().default(""), + boundClaims: z.array( + z.object({ + key: z.string(), + value: z.string() + }) + ), + boundSubject: z.string().optional().default("") +}); + +const schema = z.discriminatedUnion("configurationType", [ + z + .object({ + configurationType: z.literal(IdentityJwtConfigurationType.JWKS), + jwksUrl: z.string().trim().url(), + jwksCaCert: z.string().trim().default(""), + publicKeys: z + .object({ + value: z.string() + }) + .array() + .optional() + }) + .merge(commonSchema), + z + .object({ + configurationType: z.literal(IdentityJwtConfigurationType.STATIC), + jwksUrl: z.string().trim().optional(), + jwksCaCert: z.string().trim().optional().default(""), + publicKeys: z + .object({ + value: z.string().min(1) + }) + .array() + .min(1) + }) + .merge(commonSchema) +]); + +export type FormData = z.infer; + +type Props = { + handlePopUpOpen: (popUpName: keyof UsePopUpState<["upgradePlan"]>) => void; + handlePopUpToggle: ( + popUpName: keyof UsePopUpState<["identityAuthMethod", "revokeAuthMethod"]>, + state?: boolean + ) => void; + identityAuthMethodData: { + identityId: string; + name: string; + configuredAuthMethods?: IdentityAuthMethod[]; + authMethod?: IdentityAuthMethod; + }; +}; + +export const IdentityJwtAuthForm = ({ + handlePopUpOpen, + handlePopUpToggle, + identityAuthMethodData +}: Props) => { + const { currentOrg } = useOrganization(); + const orgId = currentOrg?.id || ""; + const { subscription } = useSubscription(); + + const { mutateAsync: addMutateAsync } = useAddIdentityJwtAuth(); + const { mutateAsync: updateMutateAsync } = useUpdateIdentityJwtAuth(); + + const isUpdate = identityAuthMethodData?.configuredAuthMethods?.includes( + identityAuthMethodData.authMethod! || "" + ); + const { data } = useGetIdentityJwtAuth(identityAuthMethodData?.identityId ?? "", { + enabled: isUpdate + }); + + const { + watch, + control, + handleSubmit, + reset, + setValue, + formState: { isSubmitting } + } = useForm({ + resolver: zodResolver(schema), + defaultValues: { + accessTokenTTL: "2592000", + accessTokenMaxTTL: "2592000", + accessTokenNumUsesLimit: "0", + accessTokenTrustedIps: [{ ipAddress: "0.0.0.0/0" }, { ipAddress: "::/0" }], + configurationType: IdentityJwtConfigurationType.JWKS + } + }); + + const selectedConfigurationType = watch("configurationType") as IdentityJwtConfigurationType; + + const { + fields: publicKeyFields, + append: appendPublicKeyFields, + remove: removePublicKeyFields + } = useFieldArray({ + control, + name: "publicKeys" + }); + + const { + fields: boundClaimsFields, + append: appendBoundClaimField, + remove: removeBoundClaimField + } = useFieldArray({ + control, + name: "boundClaims" + }); + + const { + fields: accessTokenTrustedIpsFields, + append: appendAccessTokenTrustedIp, + remove: removeAccessTokenTrustedIp + } = useFieldArray({ control, name: "accessTokenTrustedIps" }); + + useEffect(() => { + if (data) { + reset({ + configurationType: data.configurationType, + jwksUrl: data.jwksUrl, + jwksCaCert: data.jwksCaCert, + publicKeys: data.publicKeys.map((pk) => ({ + value: pk + })), + boundIssuer: data.boundIssuer, + boundAudiences: data.boundAudiences, + boundClaims: Object.entries(data.boundClaims).map(([key, value]) => ({ + key, + value + })), + boundSubject: data.boundSubject, + accessTokenTTL: String(data.accessTokenTTL), + accessTokenMaxTTL: String(data.accessTokenMaxTTL), + accessTokenNumUsesLimit: String(data.accessTokenNumUsesLimit), + accessTokenTrustedIps: data.accessTokenTrustedIps.map( + ({ ipAddress, prefix }: IdentityTrustedIp) => { + return { + ipAddress: `${ipAddress}${prefix !== undefined ? `/${prefix}` : ""}` + }; + } + ) + }); + } else { + reset({ + configurationType: IdentityJwtConfigurationType.JWKS, + jwksUrl: "", + jwksCaCert: "", + boundIssuer: "", + boundAudiences: "", + boundClaims: [], + boundSubject: "", + accessTokenTTL: "2592000", + accessTokenMaxTTL: "2592000", + accessTokenNumUsesLimit: "0", + accessTokenTrustedIps: [{ ipAddress: "0.0.0.0/0" }, { ipAddress: "::/0" }] + }); + } + }, [data]); + + const onFormSubmit = async ({ + accessTokenTrustedIps, + accessTokenTTL, + accessTokenMaxTTL, + accessTokenNumUsesLimit, + configurationType, + jwksUrl, + jwksCaCert, + publicKeys, + boundIssuer, + boundAudiences, + boundClaims, + boundSubject + }: FormData) => { + try { + if (!identityAuthMethodData) { + return; + } + + if (data) { + await updateMutateAsync({ + identityId: identityAuthMethodData.identityId, + organizationId: orgId, + configurationType, + jwksUrl, + jwksCaCert, + publicKeys: publicKeys?.map((field) => field.value).filter(Boolean), + boundIssuer, + boundAudiences, + boundClaims: Object.fromEntries(boundClaims.map((entry) => [entry.key, entry.value])), + boundSubject, + accessTokenTTL: Number(accessTokenTTL), + accessTokenMaxTTL: Number(accessTokenMaxTTL), + accessTokenNumUsesLimit: Number(accessTokenNumUsesLimit), + accessTokenTrustedIps + }); + } else { + await addMutateAsync({ + identityId: identityAuthMethodData.identityId, + configurationType, + jwksUrl, + jwksCaCert, + publicKeys: publicKeys?.map((field) => field.value).filter(Boolean), + boundIssuer, + boundAudiences, + boundClaims: Object.fromEntries(boundClaims.map((entry) => [entry.key, entry.value])), + boundSubject, + organizationId: orgId, + accessTokenTTL: Number(accessTokenTTL), + accessTokenMaxTTL: Number(accessTokenMaxTTL), + accessTokenNumUsesLimit: Number(accessTokenNumUsesLimit), + accessTokenTrustedIps + }); + } + + handlePopUpToggle("identityAuthMethod", false); + + createNotification({ + text: `Successfully ${isUpdate ? "updated" : "configured"} auth method`, + type: "success" + }); + + reset(); + } catch (err) { + createNotification({ + text: `Failed to ${isUpdate ? "update" : "configure"} identity`, + type: "error" + }); + } + }; + + return ( +
+ ( + + + + )} + /> + {selectedConfigurationType === IdentityJwtConfigurationType.JWKS && ( + <> + ( + + + + )} + /> + ( + +