From b8516da90faa48bd987b59b0783c66ad2f206c14 Mon Sep 17 00:00:00 2001 From: Tuan Dang Date: Tue, 28 May 2024 17:00:48 -0700 Subject: [PATCH] Continue cert mgmt mvp --- backend/src/@types/fastify.d.ts | 2 + backend/src/@types/knex.d.ts | 20 + .../20240522010055_certificate-authority.ts | 61 ++ .../src/db/schemas/certificate-authorities.ts | 12 +- backend/src/db/schemas/certificate-certs.ts | 21 + backend/src/db/schemas/certificate-secrets.ts | 21 + backend/src/db/schemas/certificates.ts | 22 + backend/src/db/schemas/index.ts | 3 + backend/src/db/schemas/models.ts | 3 + .../services/permission/project-permission.ts | 27 +- backend/src/server/routes/index.ts | 45 +- .../routes/v1/certificate-authority-router.ts | 300 +++++++-- .../server/routes/v1/certificate-router.ts | 113 ++++ backend/src/server/routes/v1/index.ts | 9 +- .../src/server/routes/v2/project-router.ts | 45 +- .../certificate-authority-dal.ts | 39 +- .../certificate-authority-fns.ts | 12 + .../certificate-authority-service.ts | 583 +++++++++++++++--- .../certificate-authority-types.ts | 54 +- .../certificate-authority-validators.ts | 8 + .../certificate/certificate-cert-dal.ts | 10 + .../services/certificate/certificate-dal.ts | 10 + .../certificate/certificate-secret-dal.ts | 10 + .../certificate/certificate-service.ts | 102 +++ .../services/certificate/certificate-types.ts | 13 + .../src/services/project/project-service.ts | 75 ++- backend/src/services/project/project-types.ts | 2 + .../context/ProjectPermissionContext/types.ts | 6 +- frontend/src/hooks/api/ca/constants.tsx | 14 +- frontend/src/hooks/api/ca/enums.tsx | 8 +- frontend/src/hooks/api/ca/index.tsx | 11 +- frontend/src/hooks/api/ca/mutations.tsx | 106 +++- frontend/src/hooks/api/ca/queries.tsx | 54 ++ frontend/src/hooks/api/ca/types.ts | 73 ++- frontend/src/hooks/api/certificates/index.tsx | 2 + .../src/hooks/api/certificates/mutations.tsx | 26 + .../src/hooks/api/certificates/queries.tsx | 40 ++ frontend/src/hooks/api/certificates/types.ts | 7 + frontend/src/hooks/api/index.tsx | 1 + frontend/src/hooks/api/workspace/index.tsx | 1 + frontend/src/hooks/api/workspace/queries.tsx | 46 +- .../pages/project/[id]/certificates/index.tsx | 3 +- .../CertificatesPage/CertificatesPage.tsx | 21 +- .../CaTab/components/CaCertModal.tsx | 109 ++++ .../CaInstallCertModal/CaInstallCertModal.tsx | 328 ++++++++++ .../components/CaInstallCertModal/index.tsx | 1 + .../components/CaTab/components/CaModal.tsx | 328 +++++----- .../components/CaTab/components/CaSection.tsx | 197 +++--- .../components/CaTab/components/CaTable.tsx | 185 +++++- .../CertificatesTab/CertificatesTab.tsx | 17 + .../components/CertificateCertModal.tsx | 135 ++++ .../components/CertificateModal.tsx | 223 +++++++ .../components/CertificatesSection.tsx | 79 +++ .../components/CertificatesTable.tsx | 160 +++++ .../CertificatesTab/components/index.tsx | 1 + .../components/CertificatesTab/index.tsx | 1 + .../CertificatesPage/components/index.tsx | 1 + 57 files changed, 3339 insertions(+), 467 deletions(-) create mode 100644 backend/src/db/schemas/certificate-certs.ts create mode 100644 backend/src/db/schemas/certificate-secrets.ts create mode 100644 backend/src/db/schemas/certificates.ts create mode 100644 backend/src/server/routes/v1/certificate-router.ts create mode 100644 backend/src/services/certificate-authority/certificate-authority-validators.ts create mode 100644 backend/src/services/certificate/certificate-cert-dal.ts create mode 100644 backend/src/services/certificate/certificate-dal.ts create mode 100644 backend/src/services/certificate/certificate-secret-dal.ts create mode 100644 backend/src/services/certificate/certificate-service.ts create mode 100644 backend/src/services/certificate/certificate-types.ts create mode 100644 frontend/src/hooks/api/ca/queries.tsx create mode 100644 frontend/src/hooks/api/certificates/index.tsx create mode 100644 frontend/src/hooks/api/certificates/mutations.tsx create mode 100644 frontend/src/hooks/api/certificates/queries.tsx create mode 100644 frontend/src/hooks/api/certificates/types.ts create mode 100644 frontend/src/views/Project/CertificatesPage/components/CaTab/components/CaCertModal.tsx create mode 100644 frontend/src/views/Project/CertificatesPage/components/CaTab/components/CaInstallCertModal/CaInstallCertModal.tsx create mode 100644 frontend/src/views/Project/CertificatesPage/components/CaTab/components/CaInstallCertModal/index.tsx create mode 100644 frontend/src/views/Project/CertificatesPage/components/CertificatesTab/CertificatesTab.tsx create mode 100644 frontend/src/views/Project/CertificatesPage/components/CertificatesTab/components/CertificateCertModal.tsx create mode 100644 frontend/src/views/Project/CertificatesPage/components/CertificatesTab/components/CertificateModal.tsx create mode 100644 frontend/src/views/Project/CertificatesPage/components/CertificatesTab/components/CertificatesSection.tsx create mode 100644 frontend/src/views/Project/CertificatesPage/components/CertificatesTab/components/CertificatesTable.tsx create mode 100644 frontend/src/views/Project/CertificatesPage/components/CertificatesTab/components/index.tsx create mode 100644 frontend/src/views/Project/CertificatesPage/components/CertificatesTab/index.tsx diff --git a/backend/src/@types/fastify.d.ts b/backend/src/@types/fastify.d.ts index 38d49a807..a9651e9f7 100644 --- a/backend/src/@types/fastify.d.ts +++ b/backend/src/@types/fastify.d.ts @@ -29,6 +29,7 @@ import { TAuthPasswordFactory } from "@app/services/auth/auth-password-service"; import { TAuthSignupFactory } from "@app/services/auth/auth-signup-service"; import { ActorAuthMethod, ActorType } from "@app/services/auth/auth-type"; import { TAuthTokenServiceFactory } from "@app/services/auth-token/auth-token-service"; +import { TCertificateServiceFactory } from "@app/services/certificate/certificate-service"; import { TCertificateAuthorityServiceFactory } from "@app/services/certificate-authority/certificate-authority-service"; import { TGroupProjectServiceFactory } from "@app/services/group-project/group-project-service"; import { TIdentityServiceFactory } from "@app/services/identity/identity-service"; @@ -133,6 +134,7 @@ declare module "fastify" { ldap: TLdapConfigServiceFactory; auditLog: TAuditLogServiceFactory; auditLogStream: TAuditLogStreamServiceFactory; + certificate: TCertificateServiceFactory; certificateAuthority: TCertificateAuthorityServiceFactory; secretScanning: TSecretScanningServiceFactory; license: TLicenseServiceFactory; diff --git a/backend/src/@types/knex.d.ts b/backend/src/@types/knex.d.ts index cd4c9418c..bdc1c7138 100644 --- a/backend/src/@types/knex.d.ts +++ b/backend/src/@types/knex.d.ts @@ -41,6 +41,15 @@ import { TCertificateAuthoritySk, TCertificateAuthoritySkInsert, TCertificateAuthoritySkUpdate, + TCertificateCerts, + TCertificateCertsInsert, + TCertificateCertsUpdate, + TCertificates, + TCertificateSecrets, + TCertificateSecretsInsert, + TCertificateSecretsUpdate, + TCertificatesInsert, + TCertificatesUpdate, TDynamicSecretLeases, TDynamicSecretLeasesInsert, TDynamicSecretLeasesUpdate, @@ -264,6 +273,17 @@ declare module "knex/types/tables" { TCertificateAuthoritySkInsert, TCertificateAuthoritySkUpdate >; + [TableName.Certificate]: Knex.CompositeTableType; + [TableName.CertificateCert]: Knex.CompositeTableType< + TCertificateCerts, + TCertificateCertsInsert, + TCertificateCertsUpdate + >; + [TableName.CertificateSecret]: Knex.CompositeTableType< + TCertificateSecrets, + TCertificateSecretsInsert, + TCertificateSecretsUpdate + >; [TableName.UserGroupMembership]: Knex.CompositeTableType< TUserGroupMembership, TUserGroupMembershipInsert, diff --git a/backend/src/db/migrations/20240522010055_certificate-authority.ts b/backend/src/db/migrations/20240522010055_certificate-authority.ts index 860fb8a86..6a08878ad 100644 --- a/backend/src/db/migrations/20240522010055_certificate-authority.ts +++ b/backend/src/db/migrations/20240522010055_certificate-authority.ts @@ -14,11 +14,23 @@ export async function up(knex: Knex): Promise { t.string("projectId").notNullable(); t.foreign("projectId").references("id").inTable(TableName.Project).onDelete("CASCADE"); t.string("type").notNullable(); // root / intermediate + t.string("status").notNullable(); // active / pending-certificate + t.string("organization").notNullable(); + t.string("ou").notNullable(); + t.string("country").notNullable(); + t.string("province").notNullable(); + t.string("locality").notNullable(); + t.string("commonName").notNullable(); t.string("dn").notNullable(); + t.unique(["dn", "projectId"]); + t.integer("maxPathLength").nullable(); + t.datetime("notBefore").nullable(); + t.datetime("notAfter").nullable(); }); } if (!(await knex.schema.hasTable(TableName.CertificateAuthorityCert))) { + // table to keep track of certificates belonging to CA await knex.schema.createTable(TableName.CertificateAuthorityCert, (t) => { t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid()); t.timestamps(true, true, true); @@ -41,12 +53,61 @@ export async function up(knex: Knex): Promise { }); } + if (!(await knex.schema.hasTable(TableName.Certificate))) { + // TODO: consider adding name + await knex.schema.createTable(TableName.Certificate, (t) => { + t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid()); + t.timestamps(true, true, true); + t.uuid("caId").notNullable(); + t.foreign("caId").references("id").inTable(TableName.CertificateAuthority).onDelete("CASCADE"); + t.string("commonName").notNullable(); + t.datetime("notBefore").notNullable(); + t.datetime("notAfter").notNullable(); + }); + } + + if (!(await knex.schema.hasTable(TableName.CertificateCert))) { + await knex.schema.createTable(TableName.CertificateCert, (t) => { + t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid()); + t.timestamps(true, true, true); + t.uuid("certId").notNullable().unique(); + t.foreign("certId").references("id").inTable(TableName.Certificate).onDelete("CASCADE"); + t.text("certificate").notNullable(); // TODO: encrypt + t.text("certificateChain").notNullable(); // TODO: encrypt + }); + } + + if (!(await knex.schema.hasTable(TableName.CertificateSecret))) { + await knex.schema.createTable(TableName.CertificateSecret, (t) => { + t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid()); + t.timestamps(true, true, true); + t.uuid("certId").notNullable().unique(); + t.foreign("certId").references("id").inTable(TableName.Certificate).onDelete("CASCADE"); + t.text("pk").notNullable(); // TODO: encrypt + t.text("sk").notNullable(); // TODO: encrypt + }); + } + await createOnUpdateTrigger(knex, TableName.CertificateAuthority); await createOnUpdateTrigger(knex, TableName.CertificateAuthorityCert); await createOnUpdateTrigger(knex, TableName.CertificateAuthoritySk); + await createOnUpdateTrigger(knex, TableName.Certificate); + await createOnUpdateTrigger(knex, TableName.CertificateCert); + await createOnUpdateTrigger(knex, TableName.CertificateSecret); } export async function down(knex: Knex): Promise { + // certificates + await knex.schema.dropTableIfExists(TableName.CertificateSecret); + await dropOnUpdateTrigger(knex, TableName.CertificateSecret); + + await knex.schema.dropTableIfExists(TableName.CertificateCert); + await dropOnUpdateTrigger(knex, TableName.CertificateCert); + + await knex.schema.dropTableIfExists(TableName.Certificate); + await dropOnUpdateTrigger(knex, TableName.Certificate); + + // certificate authorities await knex.schema.dropTableIfExists(TableName.CertificateAuthoritySk); await dropOnUpdateTrigger(knex, TableName.CertificateAuthoritySk); diff --git a/backend/src/db/schemas/certificate-authorities.ts b/backend/src/db/schemas/certificate-authorities.ts index b49a151c1..da69dd5a5 100644 --- a/backend/src/db/schemas/certificate-authorities.ts +++ b/backend/src/db/schemas/certificate-authorities.ts @@ -14,7 +14,17 @@ export const CertificateAuthoritiesSchema = z.object({ parentCaId: z.string().uuid().nullable().optional(), projectId: z.string(), type: z.string(), - dn: z.string() + status: z.string(), + organization: z.string(), + ou: z.string(), + country: z.string(), + province: z.string(), + locality: z.string(), + commonName: z.string(), + dn: z.string(), + maxPathLength: z.number().nullable().optional(), + notBefore: z.date().nullable().optional(), + notAfter: z.date().nullable().optional() }); export type TCertificateAuthorities = z.infer; diff --git a/backend/src/db/schemas/certificate-certs.ts b/backend/src/db/schemas/certificate-certs.ts new file mode 100644 index 000000000..213cdbf61 --- /dev/null +++ b/backend/src/db/schemas/certificate-certs.ts @@ -0,0 +1,21 @@ +// 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 CertificateCertsSchema = z.object({ + id: z.string().uuid(), + createdAt: z.date(), + updatedAt: z.date(), + certId: z.string().uuid(), + certificate: z.string(), + certificateChain: z.string() +}); + +export type TCertificateCerts = z.infer; +export type TCertificateCertsInsert = Omit, TImmutableDBKeys>; +export type TCertificateCertsUpdate = Partial, TImmutableDBKeys>>; diff --git a/backend/src/db/schemas/certificate-secrets.ts b/backend/src/db/schemas/certificate-secrets.ts new file mode 100644 index 000000000..f8cad74f1 --- /dev/null +++ b/backend/src/db/schemas/certificate-secrets.ts @@ -0,0 +1,21 @@ +// 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 CertificateSecretsSchema = z.object({ + id: z.string().uuid(), + createdAt: z.date(), + updatedAt: z.date(), + certId: z.string().uuid(), + pk: z.string(), + sk: z.string() +}); + +export type TCertificateSecrets = z.infer; +export type TCertificateSecretsInsert = Omit, TImmutableDBKeys>; +export type TCertificateSecretsUpdate = Partial, TImmutableDBKeys>>; diff --git a/backend/src/db/schemas/certificates.ts b/backend/src/db/schemas/certificates.ts new file mode 100644 index 000000000..74486258d --- /dev/null +++ b/backend/src/db/schemas/certificates.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 { TImmutableDBKeys } from "./models"; + +export const CertificatesSchema = z.object({ + id: z.string().uuid(), + createdAt: z.date(), + updatedAt: z.date(), + caId: z.string().uuid(), + commonName: z.string(), + notBefore: z.date(), + notAfter: z.date() +}); + +export type TCertificates = z.infer; +export type TCertificatesInsert = Omit, TImmutableDBKeys>; +export type TCertificatesUpdate = Partial, TImmutableDBKeys>>; diff --git a/backend/src/db/schemas/index.ts b/backend/src/db/schemas/index.ts index b82f1a992..b18286438 100644 --- a/backend/src/db/schemas/index.ts +++ b/backend/src/db/schemas/index.ts @@ -11,6 +11,9 @@ export * from "./backup-private-key"; export * from "./certificate-authorities"; export * from "./certificate-authority-certs"; export * from "./certificate-authority-sk"; +export * from "./certificate-certs"; +export * from "./certificate-secrets"; +export * from "./certificates"; export * from "./dynamic-secret-leases"; export * from "./dynamic-secrets"; export * from "./git-app-install-sessions"; diff --git a/backend/src/db/schemas/models.ts b/backend/src/db/schemas/models.ts index 46c16eb1a..96bc4b25a 100644 --- a/backend/src/db/schemas/models.ts +++ b/backend/src/db/schemas/models.ts @@ -5,6 +5,9 @@ export enum TableName { CertificateAuthority = "certificate_authorities", CertificateAuthorityCert = "certificate_authority_certs", CertificateAuthoritySk = "certificate_authority_sk", + Certificate = "certificates", + CertificateCert = "certificate_certs", + CertificateSecret = "certificate_secrets", Groups = "groups", GroupProjectMembership = "group_project_memberships", GroupProjectMembershipRole = "group_project_membership_roles", diff --git a/backend/src/ee/services/permission/project-permission.ts b/backend/src/ee/services/permission/project-permission.ts index b24024bd4..4853faf61 100644 --- a/backend/src/ee/services/permission/project-permission.ts +++ b/backend/src/ee/services/permission/project-permission.ts @@ -26,7 +26,9 @@ export enum ProjectPermissionSub { SecretRollback = "secret-rollback", SecretApproval = "secret-approval", SecretRotation = "secret-rotation", - Identity = "identity" + Identity = "identity", + CertificateAuthorities = "certificate-authorities", + Certificates = "certificates" } type SubjectFields = { @@ -53,6 +55,8 @@ export type ProjectPermissionSet = | [ProjectPermissionActions, ProjectPermissionSub.SecretApproval] | [ProjectPermissionActions, ProjectPermissionSub.SecretRotation] | [ProjectPermissionActions, ProjectPermissionSub.Identity] + | [ProjectPermissionActions, ProjectPermissionSub.CertificateAuthorities] + | [ProjectPermissionActions, ProjectPermissionSub.Certificates] | [ProjectPermissionActions.Delete, ProjectPermissionSub.Project] | [ProjectPermissionActions.Edit, ProjectPermissionSub.Project] | [ProjectPermissionActions.Read, ProjectPermissionSub.SecretRollback] @@ -139,6 +143,17 @@ const buildAdminPermissionRules = () => { can(ProjectPermissionActions.Edit, ProjectPermissionSub.IpAllowList); can(ProjectPermissionActions.Delete, ProjectPermissionSub.IpAllowList); + // double check if all CRUD are needed for CA and Certificates + can(ProjectPermissionActions.Read, ProjectPermissionSub.CertificateAuthorities); + can(ProjectPermissionActions.Create, ProjectPermissionSub.CertificateAuthorities); + can(ProjectPermissionActions.Edit, ProjectPermissionSub.CertificateAuthorities); + can(ProjectPermissionActions.Delete, ProjectPermissionSub.CertificateAuthorities); + + can(ProjectPermissionActions.Read, ProjectPermissionSub.Certificates); + can(ProjectPermissionActions.Create, ProjectPermissionSub.Certificates); + can(ProjectPermissionActions.Edit, ProjectPermissionSub.Certificates); + can(ProjectPermissionActions.Delete, ProjectPermissionSub.Certificates); + can(ProjectPermissionActions.Edit, ProjectPermissionSub.Project); can(ProjectPermissionActions.Delete, ProjectPermissionSub.Project); @@ -205,6 +220,14 @@ const buildMemberPermissionRules = () => { can(ProjectPermissionActions.Read, ProjectPermissionSub.AuditLogs); can(ProjectPermissionActions.Read, ProjectPermissionSub.IpAllowList); + // double check if all CRUD are needed for CA and Certificates + can(ProjectPermissionActions.Read, ProjectPermissionSub.CertificateAuthorities); + + can(ProjectPermissionActions.Read, ProjectPermissionSub.Certificates); + can(ProjectPermissionActions.Create, ProjectPermissionSub.Certificates); + can(ProjectPermissionActions.Edit, ProjectPermissionSub.Certificates); + can(ProjectPermissionActions.Delete, ProjectPermissionSub.Certificates); + return rules; }; @@ -229,6 +252,8 @@ const buildViewerPermissionRules = () => { can(ProjectPermissionActions.Read, ProjectPermissionSub.Tags); can(ProjectPermissionActions.Read, ProjectPermissionSub.AuditLogs); can(ProjectPermissionActions.Read, ProjectPermissionSub.IpAllowList); + can(ProjectPermissionActions.Read, ProjectPermissionSub.CertificateAuthorities); + can(ProjectPermissionActions.Read, ProjectPermissionSub.Certificates); return rules; }; diff --git a/backend/src/server/routes/index.ts b/backend/src/server/routes/index.ts index 2f8ef02de..f0253ce67 100644 --- a/backend/src/server/routes/index.ts +++ b/backend/src/server/routes/index.ts @@ -71,8 +71,11 @@ import { authPaswordServiceFactory } from "@app/services/auth/auth-password-serv import { authSignupServiceFactory } from "@app/services/auth/auth-signup-service"; import { tokenDALFactory } from "@app/services/auth-token/auth-token-dal"; import { tokenServiceFactory } from "@app/services/auth-token/auth-token-service"; +import { certificateCertDALFactory } from "@app/services/certificate/certificate-cert-dal"; +import { certificateDALFactory } from "@app/services/certificate/certificate-dal"; +import { certificateSecretDALFactory } from "@app/services/certificate/certificate-secret-dal"; +import { certificateServiceFactory } from "@app/services/certificate/certificate-service"; import { certificateAuthorityCertDALFactory } from "@app/services/certificate-authority/certificate-authority-cert-dal"; -// ca / certs import { certificateAuthorityDALFactory } from "@app/services/certificate-authority/certificate-authority-dal"; import { certificateAuthorityServiceFactory } from "@app/services/certificate-authority/certificate-authority-service"; import { certificateAuthoritySkDALFactory } from "@app/services/certificate-authority/certificate-authority-sk-dal"; @@ -226,16 +229,6 @@ export const registerRoutes = async ( const trustedIpDAL = trustedIpDALFactory(db); const telemetryDAL = telemetryDALFactory(db); - const certificateAuthorityDAL = certificateAuthorityDALFactory(db); - const certificateAuthorityCertDAL = certificateAuthorityCertDALFactory(db); - const certificateAuthoritySkDAL = certificateAuthoritySkDALFactory(db); - const certificateAuthorityService = certificateAuthorityServiceFactory({ - certificateAuthorityDAL, - certificateAuthorityCertDAL, - certificateAuthoritySkDAL, - projectDAL - }); - // ee db layer ops const permissionDAL = permissionDALFactory(db); const samlConfigDAL = samlConfigDALFactory(db); @@ -503,6 +496,34 @@ export const registerRoutes = async ( projectUserMembershipRoleDAL }); + const certificateAuthorityDAL = certificateAuthorityDALFactory(db); + const certificateAuthorityCertDAL = certificateAuthorityCertDALFactory(db); + const certificateAuthoritySkDAL = certificateAuthoritySkDALFactory(db); + + const certificateDAL = certificateDALFactory(db); + const certificateCertDAL = certificateCertDALFactory(db); + const certificateSecretDAL = certificateSecretDALFactory(db); + + const certificateService = certificateServiceFactory({ + certificateDAL, + certificateCertDAL, + certificateSecretDAL, + certificateAuthorityDAL, + certificateAuthorityCertDAL, + permissionService + }); + + const certificateAuthorityService = certificateAuthorityServiceFactory({ + certificateAuthorityDAL, + certificateAuthorityCertDAL, + certificateAuthoritySkDAL, + certificateDAL, + certificateCertDAL, + certificateSecretDAL, + projectDAL, + permissionService + }); + const projectService = projectServiceFactory({ permissionService, projectDAL, @@ -520,6 +541,7 @@ export const registerRoutes = async ( folderDAL, licenseService, certificateAuthorityDAL, + certificateDAL, projectUserMembershipRoleDAL, identityProjectMembershipRoleDAL, keyStore @@ -842,6 +864,7 @@ export const registerRoutes = async ( ldap: ldapService, auditLog: auditLogService, auditLogStream: auditLogStreamService, + certificate: certificateService, certificateAuthority: certificateAuthorityService, secretScanning: secretScanningService, license: licenseService, diff --git a/backend/src/server/routes/v1/certificate-authority-router.ts b/backend/src/server/routes/v1/certificate-authority-router.ts index bfb670ac3..facd70a54 100644 --- a/backend/src/server/routes/v1/certificate-authority-router.ts +++ b/backend/src/server/routes/v1/certificate-authority-router.ts @@ -4,7 +4,8 @@ import { CertificateAuthoritiesSchema } from "@app/db/schemas"; 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 { CAType } from "@app/services/certificate-authority/certificate-authority-types"; +import { CaStatus, CaType } from "@app/services/certificate-authority/certificate-authority-types"; +import { validateCaDateField } from "@app/services/certificate-authority/certificate-authority-validators"; export const registerCaRouter = async (server: FastifyZodProvider) => { server.route({ @@ -16,18 +17,38 @@ export const registerCaRouter = async (server: FastifyZodProvider) => { onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), schema: { description: "Create CA", - body: z.object({ - projectSlug: z.string().trim(), - type: z.enum([CAType.ROOT, CAType.INTERMEDIATE]), - commonName: z.string().trim(), - organization: z.string().trim(), - ou: z.string().trim(), - country: z.string().trim(), - province: z.string().trim(), - locality: z.string().trim() - }), + body: z + .object({ + projectSlug: z.string().trim(), + type: z.enum([CaType.ROOT, CaType.INTERMEDIATE]), + commonName: z.string().trim(), + organization: z.string().trim(), + ou: z.string().trim(), + country: z.string().trim(), + province: z.string().trim(), + locality: z.string().trim(), + // format: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Date#date_time_string_format + notBefore: validateCaDateField.optional(), + notAfter: validateCaDateField.optional(), + maxPathLength: z.number().min(-1).optional() + }) + .refine( + (data) => { + // Check that at least one of the specified fields is non-empty + return [data.commonName, data.organization, data.ou, data.country, data.province, data.locality].some( + (field) => field !== "" + ); + }, + { + message: + "At least one of the fields commonName, organization, ou, country, province, or locality must be non-empty", + path: [] + } + ), response: { - 200: CertificateAuthoritiesSchema + 200: z.object({ + ca: CertificateAuthoritiesSchema + }) } }, handler: async (req) => { @@ -38,7 +59,109 @@ export const registerCaRouter = async (server: FastifyZodProvider) => { actorOrgId: req.permission.orgId, ...req.body }); - return ca; + return { + ca + }; + } + }); + + server.route({ + method: "GET", + url: "/:caId", + config: { + rateLimit: readLimit + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + schema: { + description: "Get CA", + params: z.object({ + caId: z.string().trim() + }), + response: { + 200: z.object({ + ca: CertificateAuthoritiesSchema + }) + } + }, + handler: async (req) => { + const ca = await server.services.certificateAuthority.getCaById({ + caId: req.params.caId, + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId + }); + return { + ca + }; + } + }); + + server.route({ + method: "PATCH", + url: "/:caId", + config: { + rateLimit: readLimit + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + schema: { + description: "Update CA", + params: z.object({ + caId: z.string().trim() + }), + body: z.object({ + status: z.enum([CaStatus.ACTIVE, CaStatus.DISABLED]).optional() + }), + response: { + 200: z.object({ + ca: CertificateAuthoritiesSchema + }) + } + }, + handler: async (req) => { + const ca = await server.services.certificateAuthority.updateCaById({ + caId: req.params.caId, + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + ...req.body + }); + return { + ca + }; + } + }); + + server.route({ + method: "DELETE", + url: "/:caId", + config: { + rateLimit: writeLimit + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + schema: { + description: "Delete CA", + params: z.object({ + caId: z.string().trim() + }), + response: { + 200: z.object({ + ca: CertificateAuthoritiesSchema + }) + } + }, + handler: async (req) => { + const ca = await server.services.certificateAuthority.deleteCaById({ + caId: req.params.caId, + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId + }); + return { + ca + }; } }); @@ -76,7 +199,7 @@ export const registerCaRouter = async (server: FastifyZodProvider) => { server.route({ method: "GET", - url: ":caId/certificate", + url: "/:caId/certificate", config: { rateLimit: readLimit }, @@ -89,12 +212,13 @@ export const registerCaRouter = async (server: FastifyZodProvider) => { response: { 200: z.object({ certificate: z.string(), - certificateChain: z.string() + certificateChain: z.string(), + serialNumber: z.string() }) } }, handler: async (req) => { - const { certificate, certificateChain } = await server.services.certificateAuthority.getCaCert({ + const { certificate, certificateChain, serialNumber } = await server.services.certificateAuthority.getCaCert({ caId: req.params.caId, actor: req.permission.type, actorId: req.permission.id, @@ -103,49 +227,155 @@ export const registerCaRouter = async (server: FastifyZodProvider) => { }); return { certificate, - certificateChain + certificateChain, + serialNumber }; } }); server.route({ method: "POST", - url: ":caId/issue-certificate", + url: "/:caId/sign-intermediate", config: { - rateLimit: readLimit + rateLimit: writeLimit }, onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), schema: { - description: "Issue (leaf) certificate from CA", + description: "Create intermediate CA certificate from parent CA", params: z.object({ caId: z.string().trim() }), body: z.object({ csr: z.string().trim(), - notBefore: z.string().trim(), - notAfter: z.string().trim() + notBefore: validateCaDateField.optional(), + notAfter: validateCaDateField, + maxPathLength: z.number().min(-1).default(-1) }), response: { 200: z.object({ - certificateId: z.string().trim() + certificate: z.string().trim(), + certificateChain: z.string().trim(), + issuingCaCertificate: z.string().trim(), + serialNumber: z.string().trim() }) } }, - handler: async () => { - // await server.services.certificateAuthority.issueCertFromCa({ - // caId: req.params.caId, - // actor: req.permission.type, - // actorId: req.permission.id, - // actorAuthMethod: req.permission.authMethod, - // actorOrgId: req.permission.orgId, - // ...req.body - // }); + handler: async (req) => { + const { certificate, certificateChain, issuingCaCertificate, serialNumber } = + await server.services.certificateAuthority.signIntermediate({ + caId: req.params.caId, + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + ...req.body + }); return { - certificateId: "111" + certificate, + certificateChain, + issuingCaCertificate, + serialNumber }; } }); - // TODO 2: logic for creating intermediary ca - // TODO 1: get certificate + certificate chain for the root and intermediary CA GET /ca/:caId/certificate + server.route({ + method: "POST", + url: "/:caId/import-certificate", + config: { + rateLimit: writeLimit + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + schema: { + description: "Import certificate and chain to CA", + params: z.object({ + caId: z.string().trim() + }), + body: z.object({ + certificate: z.string().trim(), + certificateChain: z.string().trim() + }), + response: { + 200: z.object({ + message: z.string().trim(), + caId: z.string().trim() + }) + } + }, + handler: async (req) => { + await server.services.certificateAuthority.importCertToCa({ + caId: req.params.caId, + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + ...req.body + }); + + return { + message: "Successfully imported certificate to CA", + caId: req.params.caId + }; + } + }); + + server.route({ + method: "POST", + url: "/:caId/issue-certificate", + config: { + rateLimit: writeLimit + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + schema: { + description: "Issue certificate from CA", + params: z.object({ + caId: z.string().trim() + }), + body: z + .object({ + commonName: z.string().trim().min(1), + ttl: z.number().int().min(0).optional(), + notBefore: validateCaDateField.optional(), + notAfter: validateCaDateField.optional() + }) + .refine( + (data) => { + const { ttl, notAfter } = data; + return (ttl !== undefined && notAfter === undefined) || (ttl === undefined && notAfter !== undefined); + }, + { + message: "Either ttl or notAfter must be present, but not both", + path: ["ttl", "notAfter"] + } + ), + response: { + 200: z.object({ + certificate: z.string().trim(), + issuingCaCertificate: z.string().trim(), + certificateChain: z.string().trim(), + privateKey: z.string().trim(), + serialNumber: z.string().trim() + }) + } + }, + handler: async (req) => { + const { certificate, certificateChain, issuingCaCertificate, privateKey, serialNumber } = + await server.services.certificateAuthority.issueCertFromCa({ + caId: req.params.caId, + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + ...req.body + }); + + return { + certificate, + certificateChain, + issuingCaCertificate, + privateKey, + serialNumber + }; + } + }); }; diff --git a/backend/src/server/routes/v1/certificate-router.ts b/backend/src/server/routes/v1/certificate-router.ts new file mode 100644 index 000000000..37714c067 --- /dev/null +++ b/backend/src/server/routes/v1/certificate-router.ts @@ -0,0 +1,113 @@ +import { z } from "zod"; + +import { CertificatesSchema } from "@app/db/schemas"; +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 registerCertRouter = async (server: FastifyZodProvider) => { + server.route({ + method: "GET", + url: "/:certId", + config: { + rateLimit: readLimit + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + schema: { + description: "Get certificate", + params: z.object({ + certId: z.string().trim() + }), + response: { + 200: z.object({ + certificate: CertificatesSchema + }) + } + }, + handler: async (req) => { + const certificate = await server.services.certificate.getCertById({ + certId: req.params.certId, + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId + }); + return { + certificate + }; + } + }); + + server.route({ + method: "DELETE", + url: "/:certId", + config: { + rateLimit: writeLimit + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + schema: { + description: "Delete certificate", + params: z.object({ + certId: z.string().trim() + }), + response: { + 200: z.object({ + certificate: CertificatesSchema + }) + } + }, + handler: async (req) => { + const certificate = await server.services.certificate.deleteCertById({ + certId: req.params.certId, + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId + }); + return { + certificate + }; + } + }); + + server.route({ + method: "GET", + url: "/:certId/certificate", + config: { + rateLimit: readLimit + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + schema: { + description: "Get certificate of certificate", + params: z.object({ + certId: z.string().trim() + }), + response: { + 200: z.object({ + certificate: z.string().trim(), + certificateChain: z.string().trim(), + issuingCaCertificate: z.string().trim(), + privateKey: z.string().trim(), + serialNumber: z.string().trim() + }) + } + }, + handler: async (req) => { + const { certificate, certificateChain, issuingCaCertificate, privateKey, serialNumber } = + await server.services.certificate.getCertCert({ + certId: req.params.certId, + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId + }); + return { + certificate, + certificateChain, + issuingCaCertificate, + privateKey, + serialNumber + }; + } + }); +}; diff --git a/backend/src/server/routes/v1/index.ts b/backend/src/server/routes/v1/index.ts index 72439cd10..61ef947ac 100644 --- a/backend/src/server/routes/v1/index.ts +++ b/backend/src/server/routes/v1/index.ts @@ -2,6 +2,7 @@ import { registerAdminRouter } from "./admin-router"; import { registerAuthRoutes } from "./auth-router"; import { registerProjectBotRouter } from "./bot-router"; import { registerCaRouter } from "./certificate-authority-router"; +import { registerCertRouter } from "./certificate-router"; import { registerIdentityAccessTokenRouter } from "./identity-access-token-router"; import { registerIdentityAwsAuthRouter } from "./identity-aws-iam-auth-router"; import { registerIdentityGcpAuthRouter } from "./identity-gcp-auth-router"; @@ -59,12 +60,8 @@ export const registerV1Routes = async (server: FastifyZodProvider) => { { prefix: "/workspace" } ); - await server.register( - async (certRouter) => { - await certRouter.register(registerCaRouter); - }, - { prefix: "/ca" } - ); + await server.register(registerCaRouter, { prefix: "/ca" }); + await server.register(registerCertRouter, { prefix: "/certificates" }); await server.register(registerProjectBotRouter, { prefix: "/bot" }); await server.register(registerIntegrationRouter, { prefix: "/integration" }); diff --git a/backend/src/server/routes/v2/project-router.ts b/backend/src/server/routes/v2/project-router.ts index 3653daae4..1b36a689e 100644 --- a/backend/src/server/routes/v2/project-router.ts +++ b/backend/src/server/routes/v2/project-router.ts @@ -1,13 +1,14 @@ import slugify from "@sindresorhus/slugify"; import { z } from "zod"; -import { CertificateAuthoritiesSchema, ProjectKeysSchema, ProjectsSchema } from "@app/db/schemas"; +import { CertificateAuthoritiesSchema, CertificatesSchema, ProjectKeysSchema, ProjectsSchema } from "@app/db/schemas"; import { EventType } from "@app/ee/services/audit-log/audit-log-types"; import { PROJECTS } from "@app/lib/api-docs"; import { creationLimit, readLimit, writeLimit } from "@app/server/config/rateLimiter"; import { getTelemetryDistinctId } from "@app/server/lib/telemetry"; import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; import { AuthMode } from "@app/services/auth/auth-type"; +import { CaStatus } from "@app/services/certificate-authority/certificate-authority-types"; import { ProjectFilterType } from "@app/services/project/project-types"; import { PostHogEventTypes } from "@app/services/telemetry/telemetry-types"; @@ -312,22 +313,58 @@ export const registerProjectRouter = async (server: FastifyZodProvider) => { method: "GET", url: "/:slug/cas", config: { - rateLimit: writeLimit + rateLimit: readLimit }, schema: { params: z.object({ slug: slugSchema.describe("The slug of the project to list CAs.") }), + querystring: z.object({ + status: z.enum([CaStatus.ACTIVE, CaStatus.PENDING_CERTIFICATE]).optional() + }), response: { 200: z.object({ cas: z.array(CertificateAuthoritiesSchema) }) } }, - onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), handler: async (req) => { const cas = await server.services.project.listProjectCas({ + filter: { + slug: req.params.slug, + orgId: req.permission.orgId, + type: ProjectFilterType.SLUG + }, + status: req.query.status, + actorId: req.permission.id, + actorOrgId: req.permission.orgId, + actorAuthMethod: req.permission.authMethod, + actor: req.permission.type + }); + return { cas }; + } + }); + + server.route({ + method: "GET", + url: "/:slug/certificates", + config: { + rateLimit: readLimit + }, + schema: { + params: z.object({ + slug: slugSchema.describe("The slug of the project to list certificates.") + }), + response: { + 200: z.object({ + certificates: z.array(CertificatesSchema) + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const certificates = await server.services.project.listProjectCertificates({ filter: { slug: req.params.slug, orgId: req.permission.orgId, @@ -338,7 +375,7 @@ export const registerProjectRouter = async (server: FastifyZodProvider) => { actorAuthMethod: req.permission.authMethod, actor: req.permission.type }); - return { cas }; + return { certificates }; } }); }; diff --git a/backend/src/services/certificate-authority/certificate-authority-dal.ts b/backend/src/services/certificate-authority/certificate-authority-dal.ts index 40d868560..3ad70bbdc 100644 --- a/backend/src/services/certificate-authority/certificate-authority-dal.ts +++ b/backend/src/services/certificate-authority/certificate-authority-dal.ts @@ -1,10 +1,47 @@ 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 TCertificateAuthorityDALFactory = ReturnType; export const certificateAuthorityDALFactory = (db: TDbClient) => { const caOrm = ormify(db, TableName.CertificateAuthority); - return caOrm; + + const buildCertificateChain = async (caId: string) => { + try { + const result: { + caId: string; + parentCaId?: string; + certificate: string; + }[] = await db + .withRecursive("cte", (cte) => { + void cte + .select("ca.id as caId", "ca.parentCaId", "cert.certificate") + .from({ ca: TableName.CertificateAuthority }) + .leftJoin({ cert: TableName.CertificateAuthorityCert }, "ca.id", "cert.caId") + .where("ca.id", caId) + .unionAll((builder) => { + void builder + .select("ca.id as caId", "ca.parentCaId", "cert.certificate") + .from({ ca: TableName.CertificateAuthority }) + .leftJoin({ cert: TableName.CertificateAuthorityCert }, "ca.id", "cert.caId") + .innerJoin("cte", "cte.parentCaId", "ca.id"); + }); + }) + .select("*") + .from("cte"); + + // Extract certificates and reverse the order to have the root CA at the end + const certChain: string[] = result.map((row) => row.certificate); + return certChain; + } catch (error) { + throw new DatabaseError({ error, name: "BuildCertificateChain" }); + } + }; + + return { + ...caOrm, + buildCertificateChain + }; }; diff --git a/backend/src/services/certificate-authority/certificate-authority-fns.ts b/backend/src/services/certificate-authority/certificate-authority-fns.ts index e69de29bb..f7157925b 100644 --- a/backend/src/services/certificate-authority/certificate-authority-fns.ts +++ b/backend/src/services/certificate-authority/certificate-authority-fns.ts @@ -0,0 +1,12 @@ +import { TDNParts } from "./certificate-authority-types"; + +export const createDistinguishedName = (parts: TDNParts) => { + const dnParts = []; + if (parts.country) dnParts.push(`C=${parts.country}`); + if (parts.organization) dnParts.push(`O=${parts.organization}`); + if (parts.ou) dnParts.push(`OU=${parts.ou}`); + if (parts.province) dnParts.push(`ST=${parts.province}`); + if (parts.commonName) dnParts.push(`CN=${parts.commonName}`); + if (parts.locality) dnParts.push(`L=${parts.locality}`); + return dnParts.join(", "); +}; diff --git a/backend/src/services/certificate-authority/certificate-authority-service.ts b/backend/src/services/certificate-authority/certificate-authority-service.ts index ffb4ee187..22c307710 100644 --- a/backend/src/services/certificate-authority/certificate-authority-service.ts +++ b/backend/src/services/certificate-authority/certificate-authority-service.ts @@ -1,18 +1,31 @@ +import { ForbiddenError } from "@casl/ability"; import * as x509 from "@peculiar/x509"; import crypto, { KeyObject } from "crypto"; +import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service"; +import { ProjectPermissionActions, ProjectPermissionSub } from "@app/ee/services/permission/project-permission"; import { BadRequestError } from "@app/lib/errors"; +import { TCertificateCertDALFactory } from "@app/services/certificate/certificate-cert-dal"; +import { TCertificateDALFactory } from "@app/services/certificate/certificate-dal"; +import { TCertificateSecretDALFactory } from "@app/services/certificate/certificate-secret-dal"; import { TProjectDALFactory } from "@app/services/project/project-dal"; import { TCertificateAuthorityCertDALFactory } from "./certificate-authority-cert-dal"; import { TCertificateAuthorityDALFactory } from "./certificate-authority-dal"; +import { createDistinguishedName } from "./certificate-authority-fns"; import { TCertificateAuthoritySkDALFactory } from "./certificate-authority-sk-dal"; import { - // TIssueCertFromCaDTO, - CAType, + CaStatus, + CaType, TCreateCaDTO, + TDeleteCaDTO, TGetCaCertDTO, - TGetCaCsrDTO + TGetCaCsrDTO, + TGetCaDTO, + TImportCertToCaDTO, + TIssueCertFromCaDTO, + TSignIntermediateDTO, + TUpdateCaDTO } from "./certificate-authority-types"; type TCertificateAuthorityServiceFactoryDep = { @@ -20,41 +33,27 @@ type TCertificateAuthorityServiceFactoryDep = { certificateAuthorityDAL: TCertificateAuthorityDALFactory; certificateAuthorityCertDAL: TCertificateAuthorityCertDALFactory; certificateAuthoritySkDAL: TCertificateAuthoritySkDALFactory; + certificateDAL: TCertificateDALFactory; + certificateCertDAL: TCertificateCertDALFactory; + certificateSecretDAL: TCertificateSecretDALFactory; projectDAL: TProjectDALFactory; + permissionService: TPermissionServiceFactory; }; export type TCertificateAuthorityServiceFactory = ReturnType; -type DNParts = { - commonName?: string; - organization?: string; - ou?: string; - country?: string; - province?: string; - locality?: string; -}; - -function createDistinguishedName(parts: DNParts) { - const dnParts = []; - if (parts.country) dnParts.push(`C=${parts.country}`); - if (parts.organization) dnParts.push(`O=${parts.organization}`); - if (parts.ou) dnParts.push(`OU=${parts.ou}`); - if (parts.province) dnParts.push(`ST=${parts.province}`); - if (parts.commonName) dnParts.push(`CN=${parts.commonName}`); - if (parts.locality) dnParts.push(`L=${parts.locality}`); - return dnParts.join(", "); -} - export const certificateAuthorityServiceFactory = ({ certificateAuthorityDAL, certificateAuthorityCertDAL, certificateAuthoritySkDAL, - projectDAL + certificateDAL, + certificateCertDAL, + certificateSecretDAL, + projectDAL, + permissionService }: TCertificateAuthorityServiceFactoryDep) => { /** * Generates a new root or intermediate CA - * @param param0 - * @returns */ const createCa = async ({ projectSlug, @@ -65,14 +64,30 @@ export const certificateAuthorityServiceFactory = ({ country, province, locality, - // actorId, - // actorAuthMethod, - // actor, + notBefore, + notAfter, + maxPathLength, + actorId, + actorAuthMethod, + actor, actorOrgId }: TCreateCaDTO) => { const project = await projectDAL.findProjectBySlug(projectSlug, actorOrgId); if (!project) throw new BadRequestError({ message: "Project not found" }); + const { permission } = await permissionService.getProjectPermission( + actor, + actorId, + project.id, + actorAuthMethod, + actorOrgId + ); + + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionActions.Create, + ProjectPermissionSub.CertificateAuthorities + ); + const dn = createDistinguishedName({ commonName, organization, @@ -97,25 +112,41 @@ export const certificateAuthorityServiceFactory = ({ const pk = pkObj.export({ format: "pem", type: "spki" }) as string; const newCa = await certificateAuthorityDAL.transaction(async (tx) => { + const notBeforeDate = notBefore ? new Date(notBefore) : new Date(); + + // if undefined, set [notAfterDate] to 10 years from now + const notAfterDate = notAfter + ? new Date(notAfter) + : new Date(new Date().setFullYear(new Date().getFullYear() + 10)); + const ca = await certificateAuthorityDAL.create( { projectId: project.id, type, - dn + organization, + ou, + country, + province, + locality, + commonName, + status: type === CaType.ROOT ? CaStatus.ACTIVE : CaStatus.PENDING_CERTIFICATE, + dn, + ...(type === CaType.ROOT && { maxPathLength, notBefore: notBeforeDate, notAfter: notAfterDate }) }, tx ); - if (type === CAType.ROOT) { + if (type === CaType.ROOT) { // note: self-signed cert only applicable for root CA + const cert = await x509.X509CertificateGenerator.createSelfSigned({ name: dn, - notBefore: new Date("2020/01/01"), - notAfter: new Date("2020/01/02"), + notBefore: notBeforeDate, + notAfter: notAfterDate, signingAlgorithm: alg, keys, extensions: [ - new x509.BasicConstraintsExtension(true, 2, true), + new x509.BasicConstraintsExtension(true, maxPathLength === -1 ? undefined : maxPathLength, true), new x509.ExtendedKeyUsageExtension(["1.2.3.4.5.6.7", "2.3.4.5.6.7.8"], true), // eslint-disable-next-line no-bitwise new x509.KeyUsagesExtension(x509.KeyUsageFlags.keyCertSign | x509.KeyUsageFlags.cRLSign, true), @@ -148,15 +179,94 @@ export const certificateAuthorityServiceFactory = ({ return newCa; }; + const getCaById = async ({ caId, actorId, actorAuthMethod, actor, actorOrgId }: TGetCaDTO) => { + const ca = await certificateAuthorityDAL.findById(caId); + if (!ca) throw new BadRequestError({ message: "CA not found" }); + + const { permission } = await permissionService.getProjectPermission( + actor, + actorId, + ca.projectId, + actorAuthMethod, + actorOrgId + ); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionActions.Read, + ProjectPermissionSub.CertificateAuthorities + ); + + return ca; + }; + + const updateCaById = async ({ caId, status, actorId, actorAuthMethod, actor, actorOrgId }: TUpdateCaDTO) => { + const ca = await certificateAuthorityDAL.findById(caId); + if (!ca) throw new BadRequestError({ message: "CA not found" }); + + const { permission } = await permissionService.getProjectPermission( + actor, + actorId, + ca.projectId, + actorAuthMethod, + actorOrgId + ); + + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionActions.Edit, + ProjectPermissionSub.CertificateAuthorities + ); + + const updatedCa = await certificateAuthorityDAL.updateById(caId, { status }); + + return updatedCa; + }; + + const deleteCaById = async ({ caId, actorId, actorAuthMethod, actor, actorOrgId }: TDeleteCaDTO) => { + const ca = await certificateAuthorityDAL.findById(caId); + if (!ca) throw new BadRequestError({ message: "CA not found" }); + + const { permission } = await permissionService.getProjectPermission( + actor, + actorId, + ca.projectId, + actorAuthMethod, + actorOrgId + ); + + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionActions.Delete, + ProjectPermissionSub.CertificateAuthorities + ); + + const deletedCa = await certificateAuthorityDAL.deleteById(caId); + + return deletedCa; + }; + /** * Generates a CSR for a CA */ - const getCaCsr = async ({ caId }: TGetCaCsrDTO) => { + const getCaCsr = async ({ caId, actorId, actorAuthMethod, actor, actorOrgId }: TGetCaCsrDTO) => { const ca = await certificateAuthorityDAL.findById(caId); if (!ca) throw new BadRequestError({ message: "CA not found" }); - // TODO: permissioning + + const { permission } = await permissionService.getProjectPermission( + actor, + actorId, + ca.projectId, + actorAuthMethod, + actorOrgId + ); + + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionActions.Create, + ProjectPermissionSub.CertificateAuthorities + ); + + if (ca.type === CaType.ROOT) throw new BadRequestError({ message: "Root CA cannot generate CSR" }); const caCert = await certificateAuthorityCertDAL.findOne({ caId: ca.id }); + if (caCert) throw new BadRequestError({ message: "CA already has a certificate installed" }); + const caKeys = await certificateAuthoritySkDAL.findOne({ caId: ca.id }); const alg = { @@ -176,10 +286,8 @@ export const certificateAuthorityServiceFactory = ({ "verify" ]); - const cert = new x509.X509Certificate(caCert.certificate); - - const csr = await x509.Pkcs10CertificateRequestGenerator.create({ - name: cert.subject || ca.dn, + const csrObj = await x509.Pkcs10CertificateRequestGenerator.create({ + name: ca.dn, keys: { privateKey: sk, publicKey: pk }, signingAlgorithm: alg, extensions: [ @@ -189,80 +297,389 @@ export const certificateAuthorityServiceFactory = ({ attributes: [new x509.ChallengePasswordAttribute("password")] }); - return csr.toString("base64"); + return csrObj.toString("pem"); }; /** * Return certificate and certificate chain for CA */ - const getCaCert = async ({ caId }: TGetCaCertDTO) => { + const getCaCert = async ({ caId, actorId, actorAuthMethod, actor, actorOrgId }: TGetCaCertDTO) => { const ca = await certificateAuthorityDAL.findById(caId); if (!ca) throw new BadRequestError({ message: "CA not found" }); - // TODO: permissioning + + const { permission } = await permissionService.getProjectPermission( + actor, + actorId, + ca.projectId, + actorAuthMethod, + actorOrgId + ); + + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionActions.Read, + ProjectPermissionSub.CertificateAuthorities + ); const caCert = await certificateAuthorityCertDAL.findOne({ caId: ca.id }); + const certObj = new x509.X509Certificate(caCert.certificate); return { certificate: caCert.certificate, - certificateChain: caCert.certificateChain + certificateChain: caCert.certificateChain, + serialNumber: certObj.serialNumber }; }; /** - * Issue new certificate + * Issue certificate to be imported back in for intermediate CA + * TODO: cannot chain to self */ - const issueCertFromCa = async () => { - // WIP: parse publicKey from CSR - // const csrArrayBuffer = Uint8Array.from(atob(csr), (c) => c.charCodeAt(0)); - // const csrR = new x509.Pkcs10CertificateRequest(csrArrayBuffer); + const signIntermediate = async ({ + caId, + actorId, + actorAuthMethod, + actor, + actorOrgId, + csr, + notBefore, + notAfter, + maxPathLength + }: TSignIntermediateDTO) => { + const ca = await certificateAuthorityDAL.findById(caId); + if (!ca) throw new BadRequestError({ message: "CA not found" }); - // const ca = await certificateAuthorityDAL.findById(caId); - // if (!ca) throw new BadRequestError({ message: "CA not found" }); - // // TODO: permissioning + const { permission } = await permissionService.getProjectPermission( + actor, + actorId, + ca.projectId, + actorAuthMethod, + actorOrgId + ); - // const alg = { - // name: "RSASSA-PKCS1-v1_5", - // hash: "SHA-256", - // publicExponent: new Uint8Array([1, 0, 1]), - // modulusLength: 2048 - // }; + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionActions.Create, + ProjectPermissionSub.CertificateAuthorities + ); - // const caCert = await certificateAuthorityCertDAL.findOne({ caId: ca.id }); - // const caKeys = await certificateAuthoritySkDAL.findOne({ caId: ca.id }); + if (ca.status === CaStatus.DISABLED) throw new BadRequestError({ message: "CA is disabled" }); - // const skObj = crypto.createPrivateKey({ key: caKeys.sk, format: "pem", type: "pkcs8" }); - // const sk = await crypto.subtle.importKey("pkcs8", skObj.export({ format: "der", type: "pkcs8" }), alg, true, [ - // "sign" - // ]); + const alg = { + name: "RSASSA-PKCS1-v1_5", + hash: "SHA-256", + publicExponent: new Uint8Array([1, 0, 1]), + modulusLength: 2048 + }; - // const cert = new x509.X509Certificate(caCert.certificate); + const caCert = await certificateAuthorityCertDAL.findOne({ caId: ca.id }); + const caKeys = await certificateAuthoritySkDAL.findOne({ caId: ca.id }); - // const leafCert = await x509.X509CertificateGenerator.create({ - // // serialNumber: "03", - // subject: "CN=Leaf", // TODO: make dynamic - // issuer: cert.subject, - // notBefore: new Date(notBefore), - // notAfter: new Date(notAfter), - // signingKey: sk, - // publicKey: leafKeys.publicKey, - // signingAlgorithm: alg, - // extensions: [ - // new x509.KeyUsagesExtension(x509.KeyUsageFlags.dataEncipherment, true), - // new x509.BasicConstraintsExtension(false), - // await x509.AuthorityKeyIdentifierExtension.create(cert, false), - // await x509.SubjectKeyIdentifierExtension.create(leafKeys.publicKey) - // ] - // }); + const skObj = crypto.createPrivateKey({ key: caKeys.sk, format: "pem", type: "pkcs8" }); + const sk = await crypto.subtle.importKey("pkcs8", skObj.export({ format: "der", type: "pkcs8" }), alg, true, [ + "sign" + ]); - // console.log("leafCert: ", leafCert); + const certObj = new x509.X509Certificate(caCert.certificate); + const csrObj = new x509.Pkcs10CertificateRequest(csr); - return ""; + // check path length constraint + const caPathLength = certObj.getExtension(x509.BasicConstraintsExtension)?.pathLength; + if (caPathLength !== undefined) { + if (caPathLength === 0) + throw new BadRequestError({ + message: "Failed to issue intermediate certificate due to CA path length constraint" + }); + if (maxPathLength >= caPathLength || (maxPathLength === -1 && caPathLength !== -1)) + throw new BadRequestError({ + message: "The requested path length constraint exceeds the CA's allowed path length" + }); + } + + const notBeforeDate = notBefore ? new Date(notBefore) : new Date(); + const notAfterDate = new Date(notAfter); + + const caCertNotBeforeDate = new Date(certObj.notBefore); + const caCertNotAfterDate = new Date(certObj.notAfter); + + // check not before constraint + if (notBeforeDate < caCertNotBeforeDate) { + throw new BadRequestError({ message: "notBefore date is before CA certificate's notBefore date" }); + } + + if (notBeforeDate > notAfterDate) throw new BadRequestError({ message: "notBefore date is after notAfter date" }); + + // check not after constraint + if (notAfterDate > caCertNotAfterDate) { + throw new BadRequestError({ message: "notAfter date is after CA certificate's notAfter date" }); + } + + const intermediateCert = await x509.X509CertificateGenerator.create({ + // serialNumber: "03", + subject: csrObj.subject, + issuer: certObj.subject, + notBefore: notBeforeDate, + notAfter: notAfterDate, + signingKey: sk, + publicKey: csrObj.publicKey, + signingAlgorithm: alg, + extensions: [ + new x509.KeyUsagesExtension(x509.KeyUsageFlags.dataEncipherment, true), + new x509.BasicConstraintsExtension(true, maxPathLength === -1 ? undefined : maxPathLength, true), + await x509.AuthorityKeyIdentifierExtension.create(certObj, false), + await x509.SubjectKeyIdentifierExtension.create(csrObj.publicKey) + ] + }); + + const chain = await certificateAuthorityDAL.buildCertificateChain(caId); + + return { + certificate: intermediateCert.toString("pem"), + issuingCaCertificate: caCert.certificate, + certificateChain: chain.join("\n"), + serialNumber: intermediateCert.serialNumber + }; + }; + + const importCertToCa = async ({ + caId, + actorId, + actorAuthMethod, + actor, + actorOrgId, + certificate, + certificateChain + }: TImportCertToCaDTO) => { + const ca = await certificateAuthorityDAL.findById(caId); + if (!ca) throw new BadRequestError({ message: "CA not found" }); + + const { permission } = await permissionService.getProjectPermission( + actor, + actorId, + ca.projectId, + actorAuthMethod, + actorOrgId + ); + + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionActions.Create, + ProjectPermissionSub.CertificateAuthorities + ); + + const caCert = await certificateAuthorityCertDAL.findOne({ caId: ca.id }); + if (caCert) throw new BadRequestError({ message: "CA has already imported a certificate" }); + + const certObj = new x509.X509Certificate(certificate); + const maxPathLength = certObj.getExtension(x509.BasicConstraintsExtension)?.pathLength; + + // validate imported certificate and certificate chain + const certificates = certificateChain + .match(/-----BEGIN CERTIFICATE-----[\s\S]+?-----END CERTIFICATE-----/g) + ?.map((cert) => new x509.X509Certificate(cert)); + + if (!certificates) throw new BadRequestError({ message: "Failed to parse certificate chain" }); + + const chain = new x509.X509ChainBuilder({ + certificates + }); + + const chainItems = await chain.build(certObj); + + // chain.build() implicitly verifies the chain + if (chainItems.length !== certificates.length + 1) + throw new BadRequestError({ message: "Invalid certificate chain" }); + + const parentCertObj = chainItems[1]; + const parentCertSubject = parentCertObj.subject; + + const parentCa = await certificateAuthorityDAL.findOne({ + projectId: ca.projectId, + dn: parentCertSubject + }); + + await certificateAuthorityCertDAL.transaction(async (tx) => { + await certificateAuthorityCertDAL.create( + { + caId: ca.id, + certificate, // TODO: encrypt + certificateChain // TODO: encrypt + }, + tx + ); + + await certificateAuthorityDAL.updateById( + ca.id, + { + status: CaStatus.ACTIVE, + maxPathLength: maxPathLength === undefined ? -1 : maxPathLength, + notBefore: new Date(certObj.notBefore), + notAfter: new Date(certObj.notAfter), + parentCaId: parentCa?.id + }, + tx + ); + }); + }; + + const issueCertFromCa = async ({ + caId, + commonName, + ttl, + notBefore, + notAfter, + actorId, + actorAuthMethod, + actor, + actorOrgId + }: TIssueCertFromCaDTO) => { + const ca = await certificateAuthorityDAL.findById(caId); + if (!ca) throw new BadRequestError({ message: "CA not found" }); + + const { permission } = await permissionService.getProjectPermission( + actor, + actorId, + ca.projectId, + actorAuthMethod, + actorOrgId + ); + + ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Create, ProjectPermissionSub.Certificates); + + const caCert = await certificateAuthorityCertDAL.findOne({ caId: ca.id }); + if (!caCert) throw new BadRequestError({ message: "CA does not have a certificate installed" }); + + if (ca.status === CaStatus.DISABLED) throw new BadRequestError({ message: "CA is disabled" }); + + const caCertObj = new x509.X509Certificate(caCert.certificate); + + const alg = { + name: "RSASSA-PKCS1-v1_5", + hash: "SHA-256", + publicExponent: new Uint8Array([1, 0, 1]), + modulusLength: 2048 + }; + + const caKeys = await certificateAuthoritySkDAL.findOne({ caId: ca.id }); + + const caSkObj = crypto.createPrivateKey({ key: caKeys.sk, format: "pem", type: "pkcs8" }); + const caSk = await crypto.subtle.importKey("pkcs8", caSkObj.export({ format: "der", type: "pkcs8" }), alg, true, [ + "sign" + ]); + + const leafKeys = await crypto.subtle.generateKey(alg, true, ["sign", "verify"]); + + const csrObj = await x509.Pkcs10CertificateRequestGenerator.create({ + name: `CN=${commonName}`, + keys: leafKeys, + signingAlgorithm: alg, + extensions: [ + // eslint-disable-next-line no-bitwise + new x509.KeyUsagesExtension(x509.KeyUsageFlags.digitalSignature | x509.KeyUsageFlags.keyEncipherment) + ], + attributes: [new x509.ChallengePasswordAttribute("password")] + }); + + const notBeforeDate = notBefore ? new Date(notBefore) : new Date(); + + let notAfterDate = new Date(new Date().setFullYear(new Date().getFullYear() + 1)); + if (notAfter) { + notAfterDate = new Date(notAfter); + } else if (ttl) { + // ttl in seconds + notAfterDate = new Date(new Date().getTime() + ttl * 1000); + } + + const caCertNotBeforeDate = new Date(caCertObj.notBefore); + const caCertNotAfterDate = new Date(caCertObj.notAfter); + + // check not before constraint + if (notBeforeDate < caCertNotBeforeDate) { + throw new BadRequestError({ message: "notBefore date is before CA certificate's notBefore date" }); + } + + if (notBeforeDate > notAfterDate) throw new BadRequestError({ message: "notBefore date is after notAfter date" }); + + // check not after constraint + if (notAfterDate > caCertNotAfterDate) { + throw new BadRequestError({ message: "notAfter date is after CA certificate's notAfter date" }); + } + + const leafCert = await x509.X509CertificateGenerator.create({ + // serialNumber: "03", + subject: csrObj.subject, + issuer: caCertObj.subject, + notBefore: notBeforeDate, + notAfter: notAfterDate, + signingKey: caSk, + publicKey: csrObj.publicKey, + signingAlgorithm: alg, + extensions: [ + new x509.KeyUsagesExtension(x509.KeyUsageFlags.dataEncipherment, true), + new x509.BasicConstraintsExtension(false), + await x509.AuthorityKeyIdentifierExtension.create(caCertObj, false), + await x509.SubjectKeyIdentifierExtension.create(csrObj.publicKey) + ] + }); + + const skLeafObj = KeyObject.from(leafKeys.privateKey); + const skLeaf = skLeafObj.export({ format: "pem", type: "pkcs8" }) as string; + + const chain = await certificateAuthorityDAL.buildCertificateChain(caId); + + // https://nodejs.org/api/crypto.html#static-method-keyobjectfromkey + const skObj = KeyObject.from(leafKeys.privateKey); + const sk = skObj.export({ format: "pem", type: "pkcs8" }) as string; + const pkObj = KeyObject.from(leafKeys.publicKey); + const pk = pkObj.export({ format: "pem", type: "spki" }) as string; + + await certificateDAL.transaction(async (tx) => { + const cert = await certificateDAL.create( + { + caId: ca.id, + commonName, + notBefore: notBeforeDate, + notAfter: notAfterDate + }, + tx + ); + + await certificateCertDAL.create( + { + certId: cert.id, + certificate: leafCert.toString("pem"), // TODO: encrypt + certificateChain: chain.join("\n") // TODO: encrypt + }, + tx + ); + + await certificateSecretDAL.create( + { + certId: cert.id, + pk, // TODO: encrypt + sk // TODO: encrypt + }, + tx + ); + + return cert; + }); + + return { + certificate: leafCert.toString("pem"), + certificateChain: chain.join("\n"), + issuingCaCertificate: caCert.certificate, + privateKey: skLeaf, + serialNumber: leafCert.serialNumber + }; }; return { createCa, + getCaById, + updateCaById, + deleteCaById, getCaCsr, getCaCert, + signIntermediate, + importCertToCa, issueCertFromCa }; }; diff --git a/backend/src/services/certificate-authority/certificate-authority-types.ts b/backend/src/services/certificate-authority/certificate-authority-types.ts index 0ad259829..5c3a22e35 100644 --- a/backend/src/services/certificate-authority/certificate-authority-types.ts +++ b/backend/src/services/certificate-authority/certificate-authority-types.ts @@ -1,21 +1,43 @@ import { TProjectPermission } from "@app/lib/types"; -export enum CAType { +export enum CaType { ROOT = "root", INTERMEDIATE = "intermediate" } +export enum CaStatus { + ACTIVE = "active", + DISABLED = "disabled", + PENDING_CERTIFICATE = "pending-certificate" +} + // TODO: attach permissions after draft impl export type TCreateCaDTO = { projectSlug: string; - type: CAType; + type: CaType; commonName: string; organization: string; ou: string; country: string; province: string; locality: string; + notBefore?: string; + notAfter?: string; + maxPathLength?: number; +} & Omit; + +export type TGetCaDTO = { + caId: string; +} & Omit; + +export type TUpdateCaDTO = { + caId: string; + status?: CaStatus; +} & Omit; + +export type TDeleteCaDTO = { + caId: string; } & Omit; export type TGetCaCsrDTO = { @@ -26,9 +48,33 @@ export type TGetCaCertDTO = { caId: string; } & Omit; -export type TIssueCertFromCaDTO = { +export type TSignIntermediateDTO = { caId: string; csr: string; - notBefore: string; + notBefore?: string; notAfter: string; + maxPathLength: number; } & Omit; + +export type TImportCertToCaDTO = { + caId: string; + certificate: string; + certificateChain: string; +} & Omit; + +export type TIssueCertFromCaDTO = { + caId: string; + commonName: string; + ttl?: number; + notBefore?: string; + notAfter?: string; +} & Omit; + +export type TDNParts = { + commonName?: string; + organization?: string; + ou?: string; + country?: string; + province?: string; + locality?: string; +}; diff --git a/backend/src/services/certificate-authority/certificate-authority-validators.ts b/backend/src/services/certificate-authority/certificate-authority-validators.ts new file mode 100644 index 000000000..77bf9ad2f --- /dev/null +++ b/backend/src/services/certificate-authority/certificate-authority-validators.ts @@ -0,0 +1,8 @@ +import { z } from "zod"; + +const isValidDate = (dateString: string) => { + const date = new Date(dateString); + return !Number.isNaN(date.getTime()); +}; + +export const validateCaDateField = z.string().trim().refine(isValidDate, { message: "Invalid date format" }); diff --git a/backend/src/services/certificate/certificate-cert-dal.ts b/backend/src/services/certificate/certificate-cert-dal.ts new file mode 100644 index 000000000..b2dd0001b --- /dev/null +++ b/backend/src/services/certificate/certificate-cert-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 TCertificateCertDALFactory = ReturnType; + +export const certificateCertDALFactory = (db: TDbClient) => { + const certificateCertOrm = ormify(db, TableName.CertificateCert); + return certificateCertOrm; +}; diff --git a/backend/src/services/certificate/certificate-dal.ts b/backend/src/services/certificate/certificate-dal.ts new file mode 100644 index 000000000..2f049fcc8 --- /dev/null +++ b/backend/src/services/certificate/certificate-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 TCertificateDALFactory = ReturnType; + +export const certificateDALFactory = (db: TDbClient) => { + const certificateOrm = ormify(db, TableName.Certificate); + return certificateOrm; +}; diff --git a/backend/src/services/certificate/certificate-secret-dal.ts b/backend/src/services/certificate/certificate-secret-dal.ts new file mode 100644 index 000000000..bd0394f78 --- /dev/null +++ b/backend/src/services/certificate/certificate-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 TCertificateSecretDALFactory = ReturnType; + +export const certificateSecretDALFactory = (db: TDbClient) => { + const certificateSecretOrm = ormify(db, TableName.CertificateSecret); + return certificateSecretOrm; +}; diff --git a/backend/src/services/certificate/certificate-service.ts b/backend/src/services/certificate/certificate-service.ts new file mode 100644 index 000000000..98cb46d24 --- /dev/null +++ b/backend/src/services/certificate/certificate-service.ts @@ -0,0 +1,102 @@ +import { ForbiddenError } from "@casl/ability"; +import * as x509 from "@peculiar/x509"; + +import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service"; +import { ProjectPermissionActions, ProjectPermissionSub } from "@app/ee/services/permission/project-permission"; +import { TCertificateCertDALFactory } from "@app/services/certificate/certificate-cert-dal"; +import { TCertificateDALFactory } from "@app/services/certificate/certificate-dal"; +import { TCertificateSecretDALFactory } from "@app/services/certificate/certificate-secret-dal"; +import { TCertificateAuthorityCertDALFactory } from "@app/services/certificate-authority/certificate-authority-cert-dal"; +import { TCertificateAuthorityDALFactory } from "@app/services/certificate-authority/certificate-authority-dal"; + +import { TDeleteCertDTO, TGetCertCertDTO, TGetCertDTO } from "./certificate-types"; + +type TCertificateServiceFactoryDep = { + // TODO: Pick + certificateDAL: TCertificateDALFactory; + certificateCertDAL: TCertificateCertDALFactory; + certificateSecretDAL: TCertificateSecretDALFactory; + certificateAuthorityDAL: TCertificateAuthorityDALFactory; + certificateAuthorityCertDAL: TCertificateAuthorityCertDALFactory; + permissionService: TPermissionServiceFactory; +}; + +export type TCertificateServiceFactory = ReturnType; + +export const certificateServiceFactory = ({ + certificateDAL, + certificateCertDAL, + certificateSecretDAL, + certificateAuthorityDAL, + certificateAuthorityCertDAL, + permissionService +}: TCertificateServiceFactoryDep) => { + const getCertById = async ({ certId, actorId, actorAuthMethod, actor, actorOrgId }: TGetCertDTO) => { + const cert = await certificateDAL.findById(certId); + const ca = await certificateAuthorityDAL.findById(cert.caId); + + const { permission } = await permissionService.getProjectPermission( + actor, + actorId, + ca.projectId, + actorAuthMethod, + actorOrgId + ); + + ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Read, ProjectPermissionSub.Certificates); + + return cert; + }; + + const deleteCertById = async ({ certId, actorId, actorAuthMethod, actor, actorOrgId }: TDeleteCertDTO) => { + const cert = await certificateDAL.findById(certId); + const ca = await certificateAuthorityDAL.findById(cert.caId); + + const { permission } = await permissionService.getProjectPermission( + actor, + actorId, + ca.projectId, + actorAuthMethod, + actorOrgId + ); + + ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Delete, ProjectPermissionSub.Certificates); + + const deletedCert = await certificateDAL.deleteById(cert.id); + return deletedCert; + }; + + const getCertCert = async ({ certId, actorId, actorAuthMethod, actor, actorOrgId }: TGetCertCertDTO) => { + const cert = await certificateDAL.findById(certId); + const ca = await certificateAuthorityDAL.findById(cert.caId); + + const { permission } = await permissionService.getProjectPermission( + actor, + actorId, + ca.projectId, + actorAuthMethod, + actorOrgId + ); + // TODO: re-evaluate this permission + ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Create, ProjectPermissionSub.Certificates); + + const caCert = await certificateAuthorityCertDAL.findOne({ caId: ca.id }); + const certCert = await certificateCertDAL.findOne({ certId }); + const certSecret = await certificateSecretDAL.findOne({ certId }); + const certObj = new x509.X509Certificate(certCert.certificate); + + return { + certificate: certCert.certificate, + certificateChain: certCert.certificateChain, + issuingCaCertificate: caCert.certificate, + privateKey: certSecret.sk, + serialNumber: certObj.serialNumber + }; + }; + + return { + getCertById, + deleteCertById, + getCertCert + }; +}; diff --git a/backend/src/services/certificate/certificate-types.ts b/backend/src/services/certificate/certificate-types.ts new file mode 100644 index 000000000..1389896c6 --- /dev/null +++ b/backend/src/services/certificate/certificate-types.ts @@ -0,0 +1,13 @@ +import { TProjectPermission } from "@app/lib/types"; + +export type TGetCertDTO = { + certId: string; +} & Omit; + +export type TDeleteCertDTO = { + certId: string; +} & Omit; + +export type TGetCertCertDTO = { + certId: string; +} & Omit; diff --git a/backend/src/services/project/project-service.ts b/backend/src/services/project/project-service.ts index 64f2bf7ac..961a57985 100644 --- a/backend/src/services/project/project-service.ts +++ b/backend/src/services/project/project-service.ts @@ -16,6 +16,7 @@ import { alphaNumericNanoId } from "@app/lib/nanoid"; import { TProjectPermission } from "@app/lib/types"; import { ActorType } from "../auth/auth-type"; +import { TCertificateDALFactory } from "../certificate/certificate-dal"; import { TCertificateAuthorityDALFactory } from "../certificate-authority/certificate-authority-dal"; import { TIdentityOrgDALFactory } from "../identity/identity-org-dal"; import { TIdentityProjectDALFactory } from "../identity-project/identity-project-dal"; @@ -51,6 +52,7 @@ export const DEFAULT_PROJECT_ENVS = [ ]; type TProjectServiceFactoryDep = { + // TODO: Pick projectDAL: TProjectDALFactory; projectQueue: TProjectQueueFactory; userDAL: TUserDALFactory; @@ -65,6 +67,7 @@ type TProjectServiceFactoryDep = { projectUserMembershipRoleDAL: Pick; secretBlindIndexDAL: Pick; certificateAuthorityDAL: Pick; + certificateDAL: TCertificateDALFactory; permissionService: TPermissionServiceFactory; orgService: Pick; licenseService: Pick; @@ -93,6 +96,7 @@ export const projectServiceFactory = ({ projectUserMembershipRoleDAL, identityProjectMembershipRoleDAL, certificateAuthorityDAL, + certificateDAL, keyStore }: TProjectServiceFactoryDep) => { /* @@ -500,24 +504,66 @@ export const projectServiceFactory = ({ * Return list of CAs for project */ const listProjectCas = async ({ - // actorId, - // actorOrgId, - // actorAuthMethod, - filter // actor + status, + actorId, + actorOrgId, + actorAuthMethod, + filter, + actor }: TListProjectCasDTO) => { const project = await projectDAL.findProjectByFilter(filter); - // const { permission } = await permissionService.getProjectPermission( - // actor, - // actorId, - // project.id, - // actorAuthMethod, - // actorOrgId - // ); - // TODO: add permissioning + const { permission } = await permissionService.getProjectPermission( + actor, + actorId, + project.id, + actorAuthMethod, + actorOrgId + ); + + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionActions.Read, + ProjectPermissionSub.CertificateAuthorities + ); + + const cas = await certificateAuthorityDAL.find({ + projectId: project.id, + ...(status && { status }) + }); + + return cas; + }; + + /** + * Return list of certificates for project + */ + const listProjectCertificates = async ({ + actorId, + actorOrgId, + actorAuthMethod, + filter, + actor + }: TListProjectCasDTO) => { + const project = await projectDAL.findProjectByFilter(filter); + + const { permission } = await permissionService.getProjectPermission( + actor, + actorId, + project.id, + actorAuthMethod, + actorOrgId + ); + + ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Read, ProjectPermissionSub.Certificates); const cas = await certificateAuthorityDAL.find({ projectId: project.id }); - return cas; + + const certificates = await certificateDAL.find({ + $in: { + caId: cas.map((ca) => ca.id) + } + }); + return certificates; }; return { @@ -530,6 +576,7 @@ export const projectServiceFactory = ({ toggleAutoCapitalization, updateName, upgradeProject, - listProjectCas + listProjectCas, + listProjectCertificates }; }; diff --git a/backend/src/services/project/project-types.ts b/backend/src/services/project/project-types.ts index f6268a93e..e04d3663f 100644 --- a/backend/src/services/project/project-types.ts +++ b/backend/src/services/project/project-types.ts @@ -2,6 +2,7 @@ import { ProjectMembershipRole, TProjectKeys } from "@app/db/schemas"; import { TProjectPermission } from "@app/lib/types"; import { ActorAuthMethod, ActorType } from "../auth/auth-type"; +import { CaStatus } from "../certificate-authority/certificate-authority-types"; export enum ProjectFilterType { ID = "id", @@ -77,5 +78,6 @@ export type AddUserToWsDTO = { }; export type TListProjectCasDTO = { + status?: CaStatus; filter: Filter; } & Omit; diff --git a/frontend/src/context/ProjectPermissionContext/types.ts b/frontend/src/context/ProjectPermissionContext/types.ts index 79c8f2d30..113aaff19 100644 --- a/frontend/src/context/ProjectPermissionContext/types.ts +++ b/frontend/src/context/ProjectPermissionContext/types.ts @@ -24,7 +24,9 @@ export enum ProjectPermissionSub { SecretRollback = "secret-rollback", SecretApproval = "secret-approval", SecretRotation = "secret-rotation", - Identity = "identity" + Identity = "identity", + CertificateAuthorities = "certificate-authorities", + Certificates = "certificates" } type SubjectFields = { @@ -51,6 +53,8 @@ export type ProjectPermissionSet = | [ProjectPermissionActions, ProjectPermissionSub.ServiceTokens] | [ProjectPermissionActions, ProjectPermissionSub.SecretApproval] | [ProjectPermissionActions, ProjectPermissionSub.SecretRotation] + | [ProjectPermissionActions, ProjectPermissionSub.CertificateAuthorities] + | [ProjectPermissionActions, ProjectPermissionSub.Certificates] | [ProjectPermissionActions.Delete, ProjectPermissionSub.Workspace] | [ProjectPermissionActions.Edit, ProjectPermissionSub.Workspace] | [ProjectPermissionActions.Read, ProjectPermissionSub.SecretRollback] diff --git a/frontend/src/hooks/api/ca/constants.tsx b/frontend/src/hooks/api/ca/constants.tsx index d368d3c04..dbef15ffd 100644 --- a/frontend/src/hooks/api/ca/constants.tsx +++ b/frontend/src/hooks/api/ca/constants.tsx @@ -1,6 +1,12 @@ -import { CertificateAuthorityType } from "./enums"; +import { CaStatus,CaType } from "./enums"; -export const caTypeToNameMap: { [K in CertificateAuthorityType]: string } = { - [CertificateAuthorityType.ROOT]: "Root", - [CertificateAuthorityType.INTERMEDIATE]: "Intermediate" +export const caTypeToNameMap: { [K in CaType]: string } = { + [CaType.ROOT]: "Root", + [CaType.INTERMEDIATE]: "Intermediate" +}; + +export const caStatusToNameMap: { [K in CaStatus]: string } = { + [CaStatus.ACTIVE]: "Active", + [CaStatus.DISABLED]: "Disabled", + [CaStatus.PENDING_CERTIFICATE]: "Pending Certificate" }; diff --git a/frontend/src/hooks/api/ca/enums.tsx b/frontend/src/hooks/api/ca/enums.tsx index 0db5fcfdb..bdd498a12 100644 --- a/frontend/src/hooks/api/ca/enums.tsx +++ b/frontend/src/hooks/api/ca/enums.tsx @@ -1,4 +1,10 @@ -export enum CertificateAuthorityType { +export enum CaType { ROOT = "root", INTERMEDIATE = "intermediate" } + +export enum CaStatus { + ACTIVE = "active", + DISABLED = "disabled", + PENDING_CERTIFICATE = "pending-certificate" +} diff --git a/frontend/src/hooks/api/ca/index.tsx b/frontend/src/hooks/api/ca/index.tsx index f03d26abc..b39680b53 100644 --- a/frontend/src/hooks/api/ca/index.tsx +++ b/frontend/src/hooks/api/ca/index.tsx @@ -1,2 +1,9 @@ -export { CertificateAuthorityType } from "./enums"; -export { useCreateCa } from "./mutations"; +export { CaStatus,CaType } from "./enums"; +export { + useCreateCa, + useCreateCertificate, + useDeleteCa, + useImportCaCertificate, + useSignIntermediate, + useUpdateCa} from "./mutations"; +export { useGetCaById, useGetCaCert, useGetCaCsr } from "./queries"; diff --git a/frontend/src/hooks/api/ca/mutations.tsx b/frontend/src/hooks/api/ca/mutations.tsx index 38cb1cf9f..49de70d4f 100644 --- a/frontend/src/hooks/api/ca/mutations.tsx +++ b/frontend/src/hooks/api/ca/mutations.tsx @@ -1,23 +1,107 @@ -import { - useMutation - // useQueryClient -} from "@tanstack/react-query"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; import { apiRequest } from "@app/config/request"; -import { TCertificateAuthority, TCreateCaDTO } from "./types"; +import { workspaceKeys } from "../workspace/queries"; +import { + TCertificateAuthority, + TCreateCaDTO, + TCreateCertificateDTO, + TCreateCertificateResponse, + TDeleteCaDTO, + TImportCaCertificateDTO, + TImportCaCertificateResponse, + TSignIntermediateDTO, + TSignIntermediateResponse, + TUpdateCaDTO} from "./types"; export const useCreateCa = () => { - // const queryClient = useQueryClient(); + const queryClient = useQueryClient(); return useMutation({ mutationFn: async (body) => { const { - data: { identity } - } = await apiRequest.post("/api/v1/ca/", body); - return identity; + data: { ca } + } = await apiRequest.post<{ ca: TCertificateAuthority }>("/api/v1/ca/", body); + return ca; }, - onSuccess: () => { - // queryClient.invalidateQueries(organizationKeys.getOrgIdentityMemberships(organizationId)); + onSuccess: (_, { projectSlug }) => { + queryClient.invalidateQueries(workspaceKeys.getWorkspaceCas({ projectSlug })); + } + }); +}; + +export const useUpdateCa = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async ({ caId, projectSlug, ...body }) => { + const { + data: { ca } + } = await apiRequest.patch<{ ca: TCertificateAuthority }>(`/api/v1/ca/${caId}`, body); + return ca; + }, + onSuccess: (_, { projectSlug }) => { + queryClient.invalidateQueries(workspaceKeys.getWorkspaceCas({ projectSlug })); + } + }); +}; + +export const useDeleteCa = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async ({ caId }) => { + const { + data: { ca } + } = await apiRequest.delete<{ ca: TCertificateAuthority }>(`/api/v1/ca/${caId}`); + return ca; + }, + onSuccess: (_, { projectSlug }) => { + queryClient.invalidateQueries(workspaceKeys.getWorkspaceCas({ projectSlug })); + } + }); +}; + +export const useSignIntermediate = () => { + // TODO: consider renaming + return useMutation({ + mutationFn: async (body) => { + const { data } = await apiRequest.post( + `/api/v1/ca/${body.caId}/sign-intermediate`, + body + ); + return data; + } + }); +}; + +export const useImportCaCertificate = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async ({ caId, ...body }) => { + const { data } = await apiRequest.post( + `/api/v1/ca/${caId}/import-certificate`, + body + ); + return data; + }, + onSuccess: (_, { projectSlug }) => { + queryClient.invalidateQueries(workspaceKeys.getWorkspaceCas({ projectSlug })); + } + }); +}; + +// consider rename to issue certificate +export const useCreateCertificate = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async ({ caId, ...body }) => { + const { data } = await apiRequest.post( + `/api/v1/ca/${caId}/issue-certificate`, + body + ); + return data; + }, + onSuccess: (_, { projectSlug }) => { + queryClient.invalidateQueries(workspaceKeys.getWorkspaceCertificates(projectSlug)); } }); }; diff --git a/frontend/src/hooks/api/ca/queries.tsx b/frontend/src/hooks/api/ca/queries.tsx new file mode 100644 index 000000000..838f50726 --- /dev/null +++ b/frontend/src/hooks/api/ca/queries.tsx @@ -0,0 +1,54 @@ +import { useQuery } from "@tanstack/react-query"; + +import { apiRequest } from "@app/config/request"; + +import { TCertificateAuthority } from "./types"; + +export const caKeys = { + getCaById: (caId: string) => [{ caId }, "ca"], + getCaCert: (caId: string) => [{ caId }, "ca-cert"], + getCaCsr: (caId: string) => [{ caId }, "ca-csr"] +}; + +export const useGetCaById = (caId: string) => { + return useQuery({ + queryKey: caKeys.getCaById(caId), + queryFn: async () => { + const { + data: { ca } + } = await apiRequest.get<{ ca: TCertificateAuthority }>(`/api/v1/ca/${caId}`); + return ca; + }, + enabled: Boolean(caId) + }); +}; + +export const useGetCaCert = (caId: string) => { + return useQuery({ + queryKey: caKeys.getCaCert(caId), + queryFn: async () => { + const { data } = await apiRequest.get<{ + certificate: string; + certificateChain: string; + serialNumber: string; + }>(`/api/v1/ca/${caId}/certificate`); + return data; + }, + enabled: Boolean(caId) + }); +}; + +export const useGetCaCsr = (caId: string) => { + return useQuery({ + queryKey: caKeys.getCaCsr(caId), + queryFn: async () => { + const { + data: { csr } + } = await apiRequest.get<{ + csr: string; + }>(`/api/v1/ca/${caId}/csr`); + return csr; + }, + enabled: Boolean(caId) + }); +}; diff --git a/frontend/src/hooks/api/ca/types.ts b/frontend/src/hooks/api/ca/types.ts index 34dcbb536..3c7eca11f 100644 --- a/frontend/src/hooks/api/ca/types.ts +++ b/frontend/src/hooks/api/ca/types.ts @@ -1,12 +1,21 @@ -import { CertificateAuthorityType } from "./enums"; +import { CaStatus,CaType } from "./enums"; export type TCertificateAuthority = { id: string; parentCaId?: string; projectId: string; - type: CertificateAuthorityType; - dn: string; + type: CaType; + status: CaStatus; + organization: string; + ou: string; + country: string; + province: string; + locality: string; commonName: string; + dn: string; + maxPathLength?: number; + notAfter?: string; + notBefore?: string; createdAt: string; updatedAt: string; }; @@ -20,4 +29,62 @@ export type TCreateCaDTO = { province: string; locality: string; commonName: string; + notAfter?: string; + maxPathLength: number; +}; + +export type TUpdateCaDTO = { + projectSlug: string; + caId: string; + status?: CaStatus; +}; + +export type TDeleteCaDTO = { + projectSlug: string; + caId: string; +}; + +export type TSignIntermediateDTO = { + caId: string; + csr: string; + maxPathLength: number; + notBefore?: string; + notAfter?: string; +}; + +export type TSignIntermediateResponse = { + certificate: string; + certificateChain: string; + issuingCaCertificate: string; + serialNumber: string; +}; + +export type TImportCaCertificateDTO = { + caId: string; + projectSlug: string; + certificate: string; + certificateChain: string; +}; + +export type TImportCaCertificateResponse = { + message: string; + caId: string; +}; + +// TODO: add TTL +export type TCreateCertificateDTO = { + projectSlug: string; + caId: string; + commonName: string; + ttl?: number; + notBefore?: string; + notAfter?: string; +}; + +export type TCreateCertificateResponse = { + certificate: string; + issuingCertificate: string; + certificateChain: string; + sk: string; + serialNumber: string; }; diff --git a/frontend/src/hooks/api/certificates/index.tsx b/frontend/src/hooks/api/certificates/index.tsx new file mode 100644 index 000000000..095d7d10a --- /dev/null +++ b/frontend/src/hooks/api/certificates/index.tsx @@ -0,0 +1,2 @@ +export { useDeleteCert } from "./mutations"; +export { useGetCertById, useGetCertCert } from "./queries"; diff --git a/frontend/src/hooks/api/certificates/mutations.tsx b/frontend/src/hooks/api/certificates/mutations.tsx new file mode 100644 index 000000000..0e128c37d --- /dev/null +++ b/frontend/src/hooks/api/certificates/mutations.tsx @@ -0,0 +1,26 @@ +import { useMutation, useQueryClient } from "@tanstack/react-query"; + +import { apiRequest } from "@app/config/request"; + +import { workspaceKeys } from "../workspace/queries"; +import { TCertificate } from "./types"; + +export type TDeleteCaDTO = { + projectSlug: string; + certId: string; +}; + +export const useDeleteCert = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async ({ certId }) => { + const { + data: { certificate } + } = await apiRequest.delete<{ certificate: TCertificate }>(`/api/v1/certificates/${certId}`); + return certificate; + }, + onSuccess: (_, { projectSlug }) => { + queryClient.invalidateQueries(workspaceKeys.getWorkspaceCertificates(projectSlug)); + } + }); +}; diff --git a/frontend/src/hooks/api/certificates/queries.tsx b/frontend/src/hooks/api/certificates/queries.tsx new file mode 100644 index 000000000..0001ba3ac --- /dev/null +++ b/frontend/src/hooks/api/certificates/queries.tsx @@ -0,0 +1,40 @@ +import { useQuery } from "@tanstack/react-query"; + +import { apiRequest } from "@app/config/request"; + +import { TCertificate } from "./types"; + +export const certKeys = { + getCertById: (certId: string) => [{ certId }, "cert"], + getCertCert: (certId: string) => [{ certId }, "certCert"] +}; + +export const useGetCertById = (certId: string) => { + return useQuery({ + queryKey: certKeys.getCertById(certId), + queryFn: async () => { + const { + data: { certificate } + } = await apiRequest.get<{ certificate: TCertificate }>(`/api/v1/certificates/${certId}`); + return certificate; + }, + enabled: Boolean(certId) + }); +}; + +export const useGetCertCert = (certId: string) => { + return useQuery({ + queryKey: certKeys.getCertCert(certId), + queryFn: async () => { + const { data } = await apiRequest.get<{ + certificate: string; + certificateChain: string; + issuingCaCertificate: string; + privateKey: string; + serialNumber: string; + }>(`/api/v1/certificates/${certId}/certificate`); + return data; + }, + enabled: Boolean(certId) + }); +}; diff --git a/frontend/src/hooks/api/certificates/types.ts b/frontend/src/hooks/api/certificates/types.ts new file mode 100644 index 000000000..71f2e5cd9 --- /dev/null +++ b/frontend/src/hooks/api/certificates/types.ts @@ -0,0 +1,7 @@ +export type TCertificate = { + id: string; + caId: string; + commonName: string; + notBefore: string; + notAfter: string; +}; diff --git a/frontend/src/hooks/api/index.tsx b/frontend/src/hooks/api/index.tsx index 64e306714..8dd0d1e72 100644 --- a/frontend/src/hooks/api/index.tsx +++ b/frontend/src/hooks/api/index.tsx @@ -6,6 +6,7 @@ export * from "./auditLogStreams"; export * from "./auth"; export * from "./bots"; export * from "./ca"; +export * from "./certificates"; export * from "./dynamicSecret"; export * from "./dynamicSecretLease"; export * from "./groups"; diff --git a/frontend/src/hooks/api/workspace/index.tsx b/frontend/src/hooks/api/workspace/index.tsx index 58c54f126..1daec0fd0 100644 --- a/frontend/src/hooks/api/workspace/index.tsx +++ b/frontend/src/hooks/api/workspace/index.tsx @@ -22,6 +22,7 @@ export { useGetWorkspaceSecrets, useGetWorkspaceUsers, useListWorkspaceCas, + useListWorkspaceCertificates, useListWorkspaceGroups, useNameWorkspaceSecrets, useRenameWorkspace, diff --git a/frontend/src/hooks/api/workspace/queries.tsx b/frontend/src/hooks/api/workspace/queries.tsx index 07bd2901a..6b92ae5a1 100644 --- a/frontend/src/hooks/api/workspace/queries.tsx +++ b/frontend/src/hooks/api/workspace/queries.tsx @@ -2,7 +2,9 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { apiRequest } from "@app/config/request"; +import { CaStatus } from "../ca/enums"; import { TCertificateAuthority } from "../ca/types"; +import { TCertificate } from "../certificates/types"; import { TGroupMembership } from "../groups/types"; import { IdentityMembership } from "../identities/types"; import { IntegrationAuth } from "../integrationAuth/types"; @@ -41,7 +43,12 @@ export const workspaceKeys = { [{ workspaceId }, "workspace-identity-memberships"] as const, getWorkspaceGroupMemberships: (workspaceId: string) => [{ workspaceId }, "workspace-groups"] as const, - getWorkspaceCas: (workspaceId: string) => [{ workspaceId }, "workspace-cas"] as const + getWorkspaceCas: ({ projectSlug }: { projectSlug: string }) => + [{ projectSlug }, "workspace-cas"] as const, + specificWorkspaceCas: ({ projectSlug, status }: { projectSlug: string; status?: CaStatus }) => + [...workspaceKeys.getWorkspaceCas({ projectSlug }), { status }] as const, + getWorkspaceCertificates: (projectSlug: string) => + [{ projectSlug }, "workspace-certificates"] as const }; const fetchWorkspaceById = async (workspaceId: string) => { @@ -472,17 +479,48 @@ export const useListWorkspaceGroups = (projectSlug: string) => { }); }; -export const useListWorkspaceCas = (projectSlug: string) => { +export const useListWorkspaceCas = ({ + projectSlug, + status +}: { + projectSlug: string; + status?: CaStatus; +}) => { return useQuery({ - queryKey: workspaceKeys.getWorkspaceCas(projectSlug), + queryKey: workspaceKeys.specificWorkspaceCas({ + projectSlug, + status + }), queryFn: async () => { + const params = new URLSearchParams({ + ...(status && { status }) + }); + const { data: { cas } } = await apiRequest.get<{ cas: TCertificateAuthority[] }>( - `/api/v2/workspace/${projectSlug}/cas` + `/api/v2/workspace/${projectSlug}/cas`, + { + params + } ); return cas; }, enabled: Boolean(projectSlug) }); }; + +export const useListWorkspaceCertificates = (projectSlug: string) => { + return useQuery({ + queryKey: workspaceKeys.getWorkspaceCertificates(projectSlug), + queryFn: async () => { + const { + data: { certificates } + } = await apiRequest.get<{ certificates: TCertificate[] }>( + `/api/v2/workspace/${projectSlug}/certificates` + ); + return certificates; + }, + enabled: Boolean(projectSlug) + }); +}; diff --git a/frontend/src/pages/project/[id]/certificates/index.tsx b/frontend/src/pages/project/[id]/certificates/index.tsx index f7d2cd8b6..ede2e75f0 100644 --- a/frontend/src/pages/project/[id]/certificates/index.tsx +++ b/frontend/src/pages/project/[id]/certificates/index.tsx @@ -9,11 +9,10 @@ const Certificates = () => { return (
- {t("common.head-title", { title: t("settings.project.title") })} + {t("common.head-title", { title: "Certificates" })} -
Test CA page
); diff --git a/frontend/src/views/Project/CertificatesPage/CertificatesPage.tsx b/frontend/src/views/Project/CertificatesPage/CertificatesPage.tsx index 655e83ad5..d4b0175a1 100644 --- a/frontend/src/views/Project/CertificatesPage/CertificatesPage.tsx +++ b/frontend/src/views/Project/CertificatesPage/CertificatesPage.tsx @@ -2,15 +2,7 @@ import { Tab, TabList, TabPanel, Tabs } from "@app/components/v2"; import { ProjectPermissionActions, ProjectPermissionSub } from "@app/context"; import { withProjectPermission } from "@app/hoc"; -import { CaTab } from "./components"; -// import { CaTable } from "./components"; - -// TODO: fix permission - -/** - * TODO 1: CA section - * TODO 2: Certificates section - */ +import { CaTab, CertificatesTab } from "./components"; enum TabSections { Ca = "certificate-authorities", @@ -23,18 +15,17 @@ export const CertificatesPage = withProjectPermission(

Certificates

- + - Certificate Authorities Certificates + Certificate Authorities + + + - -
Certs
- {/* */} -
diff --git a/frontend/src/views/Project/CertificatesPage/components/CaTab/components/CaCertModal.tsx b/frontend/src/views/Project/CertificatesPage/components/CaTab/components/CaCertModal.tsx new file mode 100644 index 000000000..320245cae --- /dev/null +++ b/frontend/src/views/Project/CertificatesPage/components/CaTab/components/CaCertModal.tsx @@ -0,0 +1,109 @@ +import { useEffect } from "react"; +import { faCheck, faCopy } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; + +import { IconButton,Modal, ModalContent } from "@app/components/v2"; +import { useToggle } from "@app/hooks"; +import { useGetCaCert } from "@app/hooks/api"; +import { UsePopUpState } from "@app/hooks/usePopUp"; + +type Props = { + popUp: UsePopUpState<["caCert"]>; + handlePopUpToggle: (popUpName: keyof UsePopUpState<["caCert"]>, state?: boolean) => void; +}; + +export const CaCertModal = ({ popUp, handlePopUpToggle }: Props) => { + const { data } = useGetCaCert((popUp?.caCert?.data as { caId: string })?.caId || ""); + const [isSerialNumberCopied, setIsSerialNumberCopied] = useToggle(false); + const [isCertificateCopied, setIsCertificateCopied] = useToggle(false); + const [isCertificateChainCopied, setIsCertificateChainCopied] = useToggle(false); + + useEffect(() => { + let timer: NodeJS.Timeout; + if (isSerialNumberCopied) { + timer = setTimeout(() => setIsSerialNumberCopied.off(), 2000); + } + + if (isCertificateCopied) { + timer = setTimeout(() => setIsCertificateCopied.off(), 2000); + } + + if (isCertificateChainCopied) { + timer = setTimeout(() => setIsCertificateChainCopied.off(), 2000); + } + + return () => clearTimeout(timer); + }, [isSerialNumberCopied, isCertificateCopied, isCertificateChainCopied]); + + return ( + { + handlePopUpToggle("caCert", isOpen); + }} + > + + {data ? ( +
+

Serial Number

+
+

{data.serialNumber}

+ { + navigator.clipboard.writeText(data.serialNumber); + setIsSerialNumberCopied.on(); + }} + > + + + Click to copy + + +
+

Certificate Body

+
+

{data.certificate}

+ { + navigator.clipboard.writeText(data.certificate); + setIsCertificateCopied.on(); + }} + > + + + Click to copy + + +
+

Certificate Chain

+
+

{data.certificateChain}

+ { + navigator.clipboard.writeText(data.certificateChain); + setIsCertificateChainCopied.on(); + }} + > + + + Click to copy + + +
+
+ ) : ( +
+ )} + + + ); +}; diff --git a/frontend/src/views/Project/CertificatesPage/components/CaTab/components/CaInstallCertModal/CaInstallCertModal.tsx b/frontend/src/views/Project/CertificatesPage/components/CaTab/components/CaInstallCertModal/CaInstallCertModal.tsx new file mode 100644 index 000000000..4d2bcb243 --- /dev/null +++ b/frontend/src/views/Project/CertificatesPage/components/CaTab/components/CaInstallCertModal/CaInstallCertModal.tsx @@ -0,0 +1,328 @@ +import { useEffect, useState } from "react"; +import { Controller, useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { format } from "date-fns"; +import { z } from "zod"; + +import { createNotification } from "@app/components/notifications"; +import { + // DatePicker, + Button, + FormControl, + Input, + Modal, + ModalContent, + Select, + SelectItem +} from "@app/components/v2"; +import { useWorkspace } from "@app/context"; +import { + CaStatus, + useGetCaById, + useGetCaCsr, + useImportCaCertificate, + useListWorkspaceCas, + useSignIntermediate +} from "@app/hooks/api"; +import { caTypeToNameMap } from "@app/hooks/api/ca/constants"; +import { UsePopUpState } from "@app/hooks/usePopUp"; + +const isValidDate = (dateString: string) => { + const date = new Date(dateString); + return !Number.isNaN(date.getTime()); +}; + +const getMiddleDate = (date1: Date, date2: Date) => { + const timestamp1 = date1.getTime(); + const timestamp2 = date2.getTime(); + + const middleTimestamp = (timestamp1 + timestamp2) / 2; + + return new Date(middleTimestamp); +}; + +const schema = z.object({ + parentCaId: z.string(), + notAfter: z.string().trim().refine(isValidDate, { message: "Invalid date format" }), + maxPathLength: z.string() +}); + +export type FormData = z.infer; + +type Props = { + popUp: UsePopUpState<["installCaCert"]>; + handlePopUpToggle: (popUpName: keyof UsePopUpState<["installCaCert"]>, state?: boolean) => void; +}; + +enum ParentCaType { + Internal = "internal", + External = "external" +} + +export const CaInstallCertModal = ({ popUp, handlePopUpToggle }: Props) => { + const [parentCaType] = useState(ParentCaType.Internal); + const { currentWorkspace } = useWorkspace(); + const caId = (popUp?.installCaCert?.data as { caId: string })?.caId || ""; + + // const [isStartDatePickerOpen, setIsStartDatePickerOpen] = useState(false); + const { data: cas } = useListWorkspaceCas({ + projectSlug: currentWorkspace?.slug ?? "", + status: CaStatus.ACTIVE + }); + const { data: ca } = useGetCaById(caId); + const { data: csr } = useGetCaCsr(caId); + + const { mutateAsync: signIntermediate } = useSignIntermediate(); + const { mutateAsync: importCaCertificate } = useImportCaCertificate(); + + const { + control, + handleSubmit, + reset, + formState: { isSubmitting }, + setValue, + watch + } = useForm({ + resolver: zodResolver(schema), + defaultValues: { + maxPathLength: "0" + } + }); + + useEffect(() => { + if (cas?.length) { + setValue("parentCaId", cas[0].id); + } + }, [cas, setValue]); + + const parentCaId = watch("parentCaId"); + const { data: parentCa } = useGetCaById(parentCaId); + + useEffect(() => { + if (parentCa?.maxPathLength) { + setValue( + "maxPathLength", + (parentCa.maxPathLength === -1 ? 3 : parentCa.maxPathLength - 1).toString() + ); + } + + if (parentCa?.notAfter) { + const parentCaNotAfter = new Date(parentCa.notAfter); + const middleDate = getMiddleDate(new Date(), parentCaNotAfter); + setValue("notAfter", format(middleDate, "yyyy-MM-dd")); + } + }, [parentCa]); + + const onFormSubmit = async ({ notAfter, maxPathLength }: FormData) => { + try { + if (!csr || !caId || !currentWorkspace?.slug) return; + + const { certificate, certificateChain } = await signIntermediate({ + caId: parentCaId, + csr, + maxPathLength: Number(maxPathLength), + notAfter, + notBefore: new Date().toISOString() + }); + + await importCaCertificate({ + caId, + projectSlug: currentWorkspace?.slug, + certificate, + certificateChain + }); + + reset(); + + createNotification({ + text: "Successfully installed certificate for CA", + type: "success" + }); + handlePopUpToggle("installCaCert", false); + } catch (err) { + createNotification({ + text: "Failed to install certificate for CA", + type: "error" + }); + } + }; + + function generatePathLengthOpts(parentCaMaxPathLength: number): number[] { + if (parentCaMaxPathLength === -1) { + return [-1, 0, 1, 2, 3]; + } + + return Array.from({ length: parentCaMaxPathLength }, (_, index) => index); + } + + const renderForm = (parentCaTypeInput: ParentCaType) => { + switch (parentCaTypeInput) { + case ParentCaType.Internal: + return ( +
+ ( + + + + )} + /> + {/* { + return ( + + { + onChange(date); + setIsStartDatePickerOpen(false); + }} + popUpProps={{ + open: isStartDatePickerOpen, + onOpenChange: setIsStartDatePickerOpen + }} + popUpContentProps={{}} + /> + + ); + }} + /> */} + ( + + + + )} + /> + ( + + + + )} + /> +
+ + +
+ + ); + default: + return
External TODO
; + } + }; + + return ( + { + handlePopUpToggle("installCaCert", isOpen); + reset(); + }} + > + + {/* + + */} + {renderForm(parentCaType)} + + + ); +}; diff --git a/frontend/src/views/Project/CertificatesPage/components/CaTab/components/CaInstallCertModal/index.tsx b/frontend/src/views/Project/CertificatesPage/components/CaTab/components/CaInstallCertModal/index.tsx new file mode 100644 index 000000000..9ad602c06 --- /dev/null +++ b/frontend/src/views/Project/CertificatesPage/components/CaTab/components/CaInstallCertModal/index.tsx @@ -0,0 +1 @@ +export { CaInstallCertModal } from "./CaInstallCertModal"; 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 b9461c6bb..52e37974e 100644 --- a/frontend/src/views/Project/CertificatesPage/components/CaTab/components/CaModal.tsx +++ b/frontend/src/views/Project/CertificatesPage/components/CaTab/components/CaModal.tsx @@ -1,8 +1,7 @@ -// import { useEffect } from "react"; +import { useEffect } from "react"; import { Controller, useForm } from "react-hook-form"; -// import { yupResolver } from "@hookform/resolvers/yup"; -// import * as yup from "yup"; import { zodResolver } from "@hookform/resolvers/zod"; +import { format } from "date-fns"; import { z } from "zod"; import { createNotification } from "@app/components/notifications"; @@ -14,21 +13,34 @@ import { ModalContent, Select, SelectItem + // DatePicker } from "@app/components/v2"; import { useWorkspace } from "@app/context"; -import { CertificateAuthorityType, useCreateCa } from "@app/hooks/api/ca"; -// import { useCreateIdentity, useGetOrgRoles, useUpdateIdentity } from "@app/hooks/api"; +import { CaType, useCreateCa, useGetCaById } from "@app/hooks/api/ca"; import { UsePopUpState } from "@app/hooks/usePopUp"; +const isValidDate = (dateString: string) => { + const date = new Date(dateString); + return !Number.isNaN(date.getTime()); +}; + +const getDateTenYearsFromToday = () => { + const date = new Date(); + date.setFullYear(date.getFullYear() + 10); + return format(date, "yyyy-MM-dd"); +}; + const schema = z .object({ - type: z.enum(["root", "intermediate"]), // move to ref enum of hooks/api + type: z.enum([CaType.ROOT, CaType.INTERMEDIATE]), organization: z.string(), ou: z.string(), country: z.string(), province: z.string(), locality: z.string(), - commonName: z.string() + commonName: z.string(), + notAfter: z.string().trim().refine(isValidDate, { message: "Invalid date format" }), + maxPathLength: z.string() }) .required(); @@ -36,75 +48,71 @@ export type FormData = z.infer; type Props = { popUp: UsePopUpState<["ca"]>; - // handlePopUpOpen: ( - // popUpName: keyof UsePopUpState<["identityAuthMethod"]>, - // data: { - // identityId: string; - // name: string; - // authMethod?: IdentityAuthMethod; - // } - // ) => void; handlePopUpToggle: (popUpName: keyof UsePopUpState<["ca"]>, state?: boolean) => void; }; const caTypes = [ - { label: "Root", value: CertificateAuthorityType.ROOT }, - { label: "intermediate", value: CertificateAuthorityType.INTERMEDIATE } + { label: "Root", value: CaType.ROOT }, + { label: "Intermediate", value: CaType.INTERMEDIATE } ]; export const CaModal = ({ popUp, handlePopUpToggle }: Props) => { const { currentWorkspace } = useWorkspace(); - console.log("CaModal currentWorkspace: ", currentWorkspace); + // const [isStartDatePickerOpen, setIsStartDatePickerOpen] = useState(false); + const { data: ca } = useGetCaById((popUp?.ca?.data as { caId: string })?.caId || ""); const { mutateAsync: createMutateAsync } = useCreateCa(); - // const { data: roles } = useGetOrgRoles(orgId); - // const { mutateAsync: createMutateAsync } = useCreateIdentity(); - // const { mutateAsync: updateMutateAsync } = useUpdateIdentity(); - // const { mutateAsync: addMutateAsync } = useAddIdentityUniversalAuth(); const { control, handleSubmit, reset, - formState: { isSubmitting } + formState: { isSubmitting }, + watch } = useForm({ resolver: zodResolver(schema), defaultValues: { - type: CertificateAuthorityType.ROOT, + type: CaType.ROOT, organization: "", ou: "", country: "", province: "", locality: "", - commonName: "" + commonName: "", + notAfter: getDateTenYearsFromToday(), + maxPathLength: "-1" } }); - // useEffect(() => { - // const identity = popUp?.identity?.data as { - // identityId: string; - // name: string; - // role: string; - // customRole: { - // name: string; - // slug: string; - // }; - // }; + const caType = watch("type"); - // if (!roles?.length) return; - - // if (identity) { - // reset({ - // name: identity.name, - // role: identity?.customRole?.slug ?? identity.role - // }); - // } else { - // reset({ - // name: "", - // role: roles[0].slug - // }); - // } - // }, [popUp?.identity?.data, roles]); + useEffect(() => { + if (ca) { + reset({ + type: ca.type, + organization: ca.organization, + ou: ca.ou, + country: ca.country, + province: ca.province, + locality: ca.locality, + commonName: ca.commonName, + notAfter: ca.notAfter ? format(new Date(ca.notAfter), "yyyy-MM-dd") : "", + maxPathLength: ca.maxPathLength ? String(ca.maxPathLength) : "" + }); + } else { + reset({ + type: CaType.ROOT, + organization: "", + ou: "", + country: "", + province: "", + locality: "", + commonName: "", + notAfter: getDateTenYearsFromToday(), + maxPathLength: "-1" + }); + } + }, [ca]); const onFormSubmit = async ({ type, @@ -113,18 +121,11 @@ export const CaModal = ({ popUp, handlePopUpToggle }: Props) => { ou, country, locality, - province + province, + notAfter, + maxPathLength }: FormData) => { try { - console.log("onFormSubmit args: ", { - commonName, - organization, - ou, - country, - locality, - province - }); - if (!currentWorkspace?.slug) return; await createMutateAsync({ @@ -135,90 +136,47 @@ export const CaModal = ({ popUp, handlePopUpToggle }: Props) => { ou, country, province, - locality - }); - - // const identity = popUp?.identity?.data as { - // identityId: string; - // name: string; - // role: string; - // }; - - // if (identity) { - // // update - - // await updateMutateAsync({ - // identityId: identity.identityId, - // name, - // role: role || undefined, - // organizationId: orgId - // }); - - // handlePopUpToggle("identity", false); - // } else { - // // create - - // const { - // id: createdId, - // name: createdName, - // authMethod - // } = await createMutateAsync({ - // name, - // role: role || undefined, - // organizationId: orgId - // }); - - // await addMutateAsync({ - // organizationId: orgId, - // identityId: createdId, - // clientSecretTrustedIps: [{ ipAddress: "0.0.0.0/0" }, { ipAddress: "::/0" }], - // accessTokenTrustedIps: [{ ipAddress: "0.0.0.0/0" }, { ipAddress: "::/0" }], - // accessTokenTTL: 2592000, - // accessTokenMaxTTL: 2592000, - // accessTokenNumUsesLimit: 0 - // }); - - // handlePopUpToggle("identity", false); - // handlePopUpOpen("identityAuthMethod", { - // identityId: createdId, - // name: createdName, - // authMethod - // }); - // } - - createNotification({ - text: `Successfully ${popUp?.ca?.data ? "updated" : "created"} CA`, - type: "success" + locality, + notAfter, + maxPathLength: Number(maxPathLength) }); reset(); - } catch (err) { - console.error(err); - const error = err as any; - const text = - error?.response?.data?.message ?? `Failed to ${popUp?.ca?.data ? "update" : "create"} CA`; + handlePopUpToggle("ca", false); createNotification({ - text, + text: "Successfully created CA", + type: "success" + }); + } catch (err) { + console.error(err); + createNotification({ + text: "Failed to create CA", type: "error" }); } }; + // const getDefaultNotAfterDate = () => { + // const date = new Date(); + // date.setFullYear(date.getFullYear() + 10); + // return date; + // }; + return ( { - handlePopUpToggle("ca", isOpen); reset(); + handlePopUpToggle("ca", isOpen); }} > - +
( + + )} + /> + ( + + + + )} + /> + + )} { isError={Boolean(error)} errorText={error?.message} > - + )} /> @@ -261,7 +293,7 @@ export const CaModal = ({ popUp, handlePopUpToggle }: Props) => { isError={Boolean(error)} errorText={error?.message} > - + )} /> @@ -275,7 +307,7 @@ export const CaModal = ({ popUp, handlePopUpToggle }: Props) => { isError={Boolean(error)} errorText={error?.message} > - + )} /> @@ -289,7 +321,7 @@ export const CaModal = ({ popUp, handlePopUpToggle }: Props) => { isError={Boolean(error)} errorText={error?.message} > - + )} /> @@ -303,7 +335,7 @@ export const CaModal = ({ popUp, handlePopUpToggle }: Props) => { isError={Boolean(error)} errorText={error?.message} > - + )} /> @@ -317,28 +349,30 @@ export const CaModal = ({ popUp, handlePopUpToggle }: Props) => { isError={Boolean(error)} errorText={error?.message} > - + )} /> -
- - -
+ {!ca && ( +
+ + +
+ )}
diff --git a/frontend/src/views/Project/CertificatesPage/components/CaTab/components/CaSection.tsx b/frontend/src/views/Project/CertificatesPage/components/CaTab/components/CaSection.tsx index b28c8be6c..63144db04 100644 --- a/frontend/src/views/Project/CertificatesPage/components/CaTab/components/CaSection.tsx +++ b/frontend/src/views/Project/CertificatesPage/components/CaTab/components/CaSection.tsx @@ -1,141 +1,126 @@ -// import { useState } from "react"; 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, - // EmailServiceSetupModal, - // UpgradePlanModal -} from "@app/components/v2"; -// import { -// OrgPermissionActions, -// OrgPermissionSubjects, -// useOrganization, -// useSubscription -// } from "@app/context"; -// import { useDeleteOrgMembership } from "@app/hooks/api"; +import { createNotification } from "@app/components/notifications"; +import { ProjectPermissionCan } from "@app/components/permissions"; +import { Button, DeleteActionModal } from "@app/components/v2"; +import { ProjectPermissionActions, ProjectPermissionSub, useWorkspace } from "@app/context"; +import { CaStatus,useDeleteCa, useUpdateCa } from "@app/hooks/api"; import { usePopUp } from "@app/hooks/usePopUp"; +import { CaCertModal } from "./CaCertModal"; +import { CaInstallCertModal } from "./CaInstallCertModal"; import { CaModal } from "./CaModal"; import { CaTable } from "./CaTable"; -// import { AddOrgMemberModal } from "./AddOrgMemberModal"; -// import { OrgMembersTable } from "./OrgMembersTable"; export const CaSection = () => { - // const { subscription } = useSubscription(); - // const { currentOrg } = useOrganization(); - // const orgId = currentOrg?.id ?? ""; + const { currentWorkspace } = useWorkspace(); + const { mutateAsync: deleteCa } = useDeleteCa(); + const { mutateAsync: updateCa } = useUpdateCa(); - // const [completeInviteLink, setCompleteInviteLink] = useState(""); + const { popUp, handlePopUpOpen, handlePopUpClose, handlePopUpToggle } = usePopUp([ + "ca", + "caCert", + "installCaCert", + "deleteCa", + "caStatus" // enable / disable + ] as const); - const { - popUp, - handlePopUpOpen, - // handlePopUpClose, - handlePopUpToggle - } = usePopUp(["ca"] as const); + const onRemoveCaSubmit = async (caId: string) => { + try { + if (!currentWorkspace?.slug) return; - // const { mutateAsync: deleteMutateAsync } = useDeleteOrgMembership(); + await deleteCa({ caId, projectSlug: currentWorkspace.slug }); - // const isMoreUsersNotAllowed = subscription?.memberLimit - // ? subscription.membersUsed >= subscription.memberLimit - // : false; + await createNotification({ + text: "Successfully deleted CA", + type: "success" + }); - // const handleAddMemberModal = () => { - // if (currentOrg?.authEnforced) { - // createNotification({ - // text: "You cannot manage users from Infisical when org-level auth is enforced for your organization", - // type: "error" - // }); - // return; - // } + handlePopUpClose("deleteCa"); + } catch (err) { + console.error(err); + createNotification({ + text: "Failed to delete CA", + type: "error" + }); + } + }; - // if (isMoreUsersNotAllowed) { - // handlePopUpOpen("upgradePlan", { - // description: "You can add more members if you upgrade your Infisical plan." - // }); - // } else { - // handlePopUpOpen("addMember"); - // } - // }; + const onUpdateCaStatus = async ({ caId, status }: { caId: string; status: CaStatus }) => { + try { + if (!currentWorkspace?.slug) return; - // const onRemoveMemberSubmit = async (orgMembershipId: string) => { - // try { - // await deleteMutateAsync({ - // orgId, - // membershipId: orgMembershipId - // }); + await updateCa({ caId, projectSlug: currentWorkspace.slug, status }); - // createNotification({ - // text: "Successfully removed user from org", - // type: "success" - // }); - // } catch (err) { - // console.error(err); - // createNotification({ - // text: "Failed to remove user from the organization", - // type: "error" - // }); - // } + await createNotification({ + text: `Successfully ${status === CaStatus.ACTIVE ? "enabled" : "disabled"} CA`, + type: "success" + }); - // handlePopUpClose("removeMember"); - // }; + handlePopUpClose("caStatus"); + } catch (err) { + console.error(err); + createNotification({ + text: `Failed to ${status === CaStatus.ACTIVE ? "enabled" : "disabled"} CA`, + type: "error" + }); + } + }; return (

Certificate Authorities

- {/* - {(isAllowed) => ( */} - - {/* )} */} - {/* */} + {(isAllowed) => ( + + )} +
- - {/* - + + + handlePopUpToggle("deleteCa", isOpen)} + deleteKey="confirm" + onDeleteApproved={() => onRemoveCaSubmit((popUp?.deleteCa?.data as { caId: string })?.caId)} /> handlePopUpToggle("removeMember", isOpen)} + isOpen={popUp.caStatus.isOpen} + title={`Are you sure want to ${ + (popUp?.caStatus?.data as { status: string })?.status === CaStatus.ACTIVE + ? "enable" + : "disable" + } the CA ${(popUp?.caStatus?.data as { dn: string })?.dn || ""} from the project?`} + subTitle={ + (popUp?.caStatus?.data as { status: string })?.status === CaStatus.ACTIVE + ? "This action will allow the CA to start issuing certificates again." + : "This action will prevent the CA from issuing new certificates." + } + onChange={(isOpen) => handlePopUpToggle("caStatus", isOpen)} deleteKey="confirm" onDeleteApproved={() => - onRemoveMemberSubmit( - (popUp?.removeMember?.data as { orgMembershipId: string })?.orgMembershipId - ) + onUpdateCaStatus(popUp?.caStatus?.data as { caId: string; status: CaStatus }) } /> - handlePopUpToggle("upgradePlan", isOpen)} - text={(popUp.upgradePlan?.data as { description: string })?.description} - /> - handlePopUpToggle("setUpEmail", isOpen)} - /> */}
); }; 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 992c3046e..2fdb35f4c 100644 --- a/frontend/src/views/Project/CertificatesPage/components/CaTab/components/CaTable.tsx +++ b/frontend/src/views/Project/CertificatesPage/components/CaTab/components/CaTable.tsx @@ -1,6 +1,20 @@ -import { faCertificate } from "@fortawesome/free-solid-svg-icons"; - import { + faBan, + faCertificate, + faEllipsis, + faEye, + faTrash +} from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { format } from "date-fns"; +import { twMerge } from "tailwind-merge"; + +import { ProjectPermissionCan } from "@app/components/permissions"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, EmptyState, Table, TableContainer, @@ -9,15 +23,29 @@ import { Td, Th, THead, - Tr -} from "@app/components/v2"; -import { useWorkspace } from "@app/context"; -import { useListWorkspaceCas } from "@app/hooks/api"; -import { caTypeToNameMap } from "@app/hooks/api/ca/constants"; + Tooltip, + Tr} from "@app/components/v2"; +import { ProjectPermissionActions, ProjectPermissionSub, useWorkspace } from "@app/context"; +import { CaStatus, useListWorkspaceCas } from "@app/hooks/api"; +import { caStatusToNameMap,caTypeToNameMap } from "@app/hooks/api/ca/constants"; +import { UsePopUpState } from "@app/hooks/usePopUp"; -export const CaTable = () => { +type Props = { + handlePopUpOpen: ( + popUpName: keyof UsePopUpState<["installCaCert", "caCert", "ca", "deleteCa", "caStatus"]>, + data?: { + caId?: string; + dn?: string; + status?: CaStatus; + } + ) => void; +}; + +export const CaTable = ({ handlePopUpOpen }: Props) => { const { currentWorkspace } = useWorkspace(); - const { data, isLoading } = useListWorkspaceCas(currentWorkspace?.slug ?? ""); + const { data, isLoading } = useListWorkspaceCas({ + projectSlug: currentWorkspace?.slug ?? "" + }); return (
@@ -27,6 +55,8 @@ export const CaTable = () => { Subject Status Type + Valid Until + @@ -38,15 +68,148 @@ export const CaTable = () => { return ( {ca.dn} - Pending + {caStatusToNameMap[ca.status]} {caTypeToNameMap[ca.type]} + {ca.notAfter ? format(new Date(ca.notAfter), "yyyy-MM-dd") : "-"} + + + +
+ + + +
+
+ + {ca.status === CaStatus.PENDING_CERTIFICATE && ( + + {(isAllowed) => ( + { + handlePopUpOpen("installCaCert", { + caId: ca.id + }); + }} + disabled={!isAllowed} + icon={} + > + Install Certificate + + )} + + )} + {ca.status !== CaStatus.PENDING_CERTIFICATE && ( + + {(isAllowed) => ( + { + handlePopUpOpen("caCert", { + caId: ca.id + }); + }} + disabled={!isAllowed} + icon={} + > + View Certificate + + )} + + )} + + {(isAllowed) => ( + + handlePopUpOpen("ca", { + caId: ca.id + }) + } + disabled={!isAllowed} + icon={} + > + View CA + + )} + + {(ca.status === CaStatus.ACTIVE || ca.status === CaStatus.DISABLED) && ( + + {(isAllowed) => ( + + handlePopUpOpen("caStatus", { + caId: ca.id, + status: + ca.status === CaStatus.ACTIVE + ? CaStatus.DISABLED + : CaStatus.ACTIVE + }) + } + disabled={!isAllowed} + icon={} + > + {`${ca.status === CaStatus.ACTIVE ? "Disable" : "Enable"} CA`} + + )} + + )} + + {(isAllowed) => ( + + handlePopUpOpen("deleteCa", { + caId: ca.id, + dn: ca.dn + }) + } + disabled={!isAllowed} + icon={} + > + Delete CA + + )} + + +
+ ); })} {!isLoading && data?.length === 0 && ( - + )}
diff --git a/frontend/src/views/Project/CertificatesPage/components/CertificatesTab/CertificatesTab.tsx b/frontend/src/views/Project/CertificatesPage/components/CertificatesTab/CertificatesTab.tsx new file mode 100644 index 000000000..f054e2546 --- /dev/null +++ b/frontend/src/views/Project/CertificatesPage/components/CertificatesTab/CertificatesTab.tsx @@ -0,0 +1,17 @@ +import { motion } from "framer-motion"; + +import { CertificatesSection } from "./components"; + +export const CertificatesTab = () => { + return ( + + + + ); +}; diff --git a/frontend/src/views/Project/CertificatesPage/components/CertificatesTab/components/CertificateCertModal.tsx b/frontend/src/views/Project/CertificatesPage/components/CertificatesTab/components/CertificateCertModal.tsx new file mode 100644 index 000000000..8a48d4f0d --- /dev/null +++ b/frontend/src/views/Project/CertificatesPage/components/CertificatesTab/components/CertificateCertModal.tsx @@ -0,0 +1,135 @@ +import { useEffect } from "react"; +import { faCheck, faCopy } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; + +import { IconButton,Modal, ModalContent } from "@app/components/v2"; +import { useToggle } from "@app/hooks"; +import { useGetCertCert } from "@app/hooks/api"; +import { UsePopUpState } from "@app/hooks/usePopUp"; + +type Props = { + popUp: UsePopUpState<["certificateCert"]>; + handlePopUpToggle: (popUpName: keyof UsePopUpState<["certificateCert"]>, state?: boolean) => void; +}; + +export const CertificateCertModal = ({ popUp, handlePopUpToggle }: Props) => { + const { data } = useGetCertCert( + (popUp?.certificateCert?.data as { certId: string })?.certId || "" + ); + + const [isSerialNumberCopied, setIsSerialNumberCopied] = useToggle(false); + const [isCertificateCopied, setIsCertificateCopied] = useToggle(false); + const [isCertificateChainCopied, setIsCertificateChainCopied] = useToggle(false); + const [isCertificateSkCopied, setIsCertificateSkCopied] = useToggle(false); + + useEffect(() => { + let timer: NodeJS.Timeout; + if (isSerialNumberCopied) { + timer = setTimeout(() => setIsSerialNumberCopied.off(), 2000); + } + + if (isCertificateCopied) { + timer = setTimeout(() => setIsCertificateCopied.off(), 2000); + } + + if (isCertificateChainCopied) { + timer = setTimeout(() => setIsCertificateChainCopied.off(), 2000); + } + + if (isCertificateSkCopied) { + timer = setTimeout(() => setIsCertificateSkCopied.off(), 2000); + } + + return () => clearTimeout(timer); + }, [isSerialNumberCopied, isCertificateCopied, isCertificateChainCopied, isCertificateSkCopied]); + + return ( + { + handlePopUpToggle("certificateCert", isOpen); + }} + > + + {data ? ( +
+

Serial Number

+
+

{data.serialNumber}

+ { + navigator.clipboard.writeText(data.serialNumber); + setIsSerialNumberCopied.on(); + }} + > + + + Click to copy + + +
+

Certificate Body

+
+

{data.certificate}

+ { + navigator.clipboard.writeText(data.certificate); + setIsCertificateCopied.on(); + }} + > + + + Click to copy + + +
+

Certificate Chain

+
+

{data.certificateChain}

+ { + navigator.clipboard.writeText(data.certificateChain); + setIsCertificateChainCopied.on(); + }} + > + + + Click to copy + + +
+

Certificate Private Key

+
+

{data.privateKey}

+ { + navigator.clipboard.writeText(data.certificateChain); + setIsCertificateSkCopied.on(); + }} + > + + + Click to copy + + +
+
+ ) : ( +
+ )} + + + ); +}; diff --git a/frontend/src/views/Project/CertificatesPage/components/CertificatesTab/components/CertificateModal.tsx b/frontend/src/views/Project/CertificatesPage/components/CertificatesTab/components/CertificateModal.tsx new file mode 100644 index 000000000..a740b9063 --- /dev/null +++ b/frontend/src/views/Project/CertificatesPage/components/CertificatesTab/components/CertificateModal.tsx @@ -0,0 +1,223 @@ +import { useEffect } from "react"; +import { Controller, useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { format } from "date-fns"; +import { z } from "zod"; + +import { createNotification } from "@app/components/notifications"; +import { + Button, + FormControl, + Input, + Modal, + ModalContent, + Select, + SelectItem +} from "@app/components/v2"; +import { useWorkspace } from "@app/context"; +import { + CaStatus, + useCreateCertificate, + useGetCertById, + useListWorkspaceCas +} from "@app/hooks/api"; +import { caTypeToNameMap } from "@app/hooks/api/ca/constants"; +import { UsePopUpState } from "@app/hooks/usePopUp"; + +const isValidDate = (dateString: string) => { + if (dateString === "") return true; + const date = new Date(dateString); + return !Number.isNaN(date.getTime()); +}; + +const schema = z.object({ + caId: z.string(), + commonName: z.string().trim().min(1), + ttl: z.string().trim().optional(), + notAfter: z + .string() + .trim() + .refine(isValidDate, { message: "Invalid date format" }) + .transform((val) => (val === "" ? undefined : val)) +}); + +export type FormData = z.infer; + +type Props = { + popUp: UsePopUpState<["certificate"]>; + handlePopUpToggle: (popUpName: keyof UsePopUpState<["certificate"]>, state?: boolean) => void; +}; + +export const CertificateModal = ({ popUp, handlePopUpToggle }: Props) => { + const { currentWorkspace } = useWorkspace(); + const { data: cert } = useGetCertById( + (popUp?.certificate?.data as { certId: string })?.certId || "" + ); + + const { data: cas } = useListWorkspaceCas({ + projectSlug: currentWorkspace?.slug ?? "", + status: CaStatus.ACTIVE + }); + + const { mutateAsync: createCertificate } = useCreateCertificate(); + + const { + control, + handleSubmit, + reset, + formState: { isSubmitting }, + setValue + } = useForm({ + resolver: zodResolver(schema) + }); + + useEffect(() => { + if (cert) { + reset({ + caId: cert.caId, + commonName: cert.commonName, + ttl: "", + notAfter: format(new Date(cert.notAfter), "yyyy-MM-dd") + }); + } else { + reset({ + caId: "", + commonName: "", + ttl: "", + notAfter: "" + }); + } + }, [cert]); + + const onFormSubmit = async ({ caId, commonName, ttl, notAfter }: FormData) => { + try { + if (!currentWorkspace?.slug) return; + + await createCertificate({ + projectSlug: currentWorkspace.slug, + caId, + commonName, + ttl: ttl ? Number(ttl) : undefined, + notBefore: new Date().toISOString(), + notAfter + }); + + reset(); + handlePopUpToggle("certificate", false); + + createNotification({ + text: "Successfully created certificate", + type: "success" + }); + } catch (err) { + console.error(err); + createNotification({ + text: "Failed to create certificate", + type: "error" + }); + } + }; + + useEffect(() => { + if (cas?.length) { + setValue("caId", cas[0].id); + } + }, [cas, setValue]); + + return ( + { + handlePopUpToggle("certificate", isOpen); + reset(); + }} + > + +
+ ( + + + + )} + /> + ( + + + + )} + /> + ( + + + + )} + /> + ( + + + + )} + /> + {!cert && ( +
+ + +
+ )} + +
+
+ ); +}; diff --git a/frontend/src/views/Project/CertificatesPage/components/CertificatesTab/components/CertificatesSection.tsx b/frontend/src/views/Project/CertificatesPage/components/CertificatesTab/components/CertificatesSection.tsx new file mode 100644 index 000000000..addcff7a8 --- /dev/null +++ b/frontend/src/views/Project/CertificatesPage/components/CertificatesTab/components/CertificatesSection.tsx @@ -0,0 +1,79 @@ +import { faPlus } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; + +import { createNotification } from "@app/components/notifications"; +import { Button, DeleteActionModal } from "@app/components/v2"; +import { useWorkspace } from "@app/context"; +import { useDeleteCert } from "@app/hooks/api"; +import { usePopUp } from "@app/hooks/usePopUp"; + +import { CertificateCertModal } from "./CertificateCertModal"; +import { CertificateModal } from "./CertificateModal"; +import { CertificatesTable } from "./CertificatesTable"; + +export const CertificatesSection = () => { + const { currentWorkspace } = useWorkspace(); + const { mutateAsync: deleteCert } = useDeleteCert(); + + const { popUp, handlePopUpOpen, handlePopUpClose, handlePopUpToggle } = usePopUp([ + "certificate", + "certificateCert", + "deleteCertificate" + ] as const); + + const onRemoveCertificateSubmit = async (certId: string) => { + try { + if (!currentWorkspace?.slug) return; + + await deleteCert({ certId, projectSlug: currentWorkspace.slug }); + + await createNotification({ + text: "Successfully deleted certificate", + type: "success" + }); + + handlePopUpClose("deleteCertificate"); + } catch (err) { + console.error(err); + createNotification({ + text: "Failed to delete certificate", + type: "error" + }); + } + }; + + return ( +
+
+

Certificates

+ {/* + {(isAllowed) => ( */} + + {/* )} */} + {/* */} +
+ + + + handlePopUpToggle("deleteCertificate", isOpen)} + deleteKey="confirm" + onDeleteApproved={() => + onRemoveCertificateSubmit((popUp?.deleteCertificate?.data as { certId: string })?.certId) + } + /> +
+ ); +}; diff --git a/frontend/src/views/Project/CertificatesPage/components/CertificatesTab/components/CertificatesTable.tsx b/frontend/src/views/Project/CertificatesPage/components/CertificatesTab/components/CertificatesTable.tsx new file mode 100644 index 000000000..663ac6f8b --- /dev/null +++ b/frontend/src/views/Project/CertificatesPage/components/CertificatesTab/components/CertificatesTable.tsx @@ -0,0 +1,160 @@ +import { + faCertificate, + faEllipsis, + faEye, + faFileExport, + faTrash +} from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { format } from "date-fns"; +import { twMerge } from "tailwind-merge"; + +import { ProjectPermissionCan } from "@app/components/permissions"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, + EmptyState, + Table, + TableContainer, + TableSkeleton, + TBody, + Td, + Th, + THead, + Tooltip, + Tr} from "@app/components/v2"; +import { ProjectPermissionActions, ProjectPermissionSub, useWorkspace } from "@app/context"; +import { useListWorkspaceCertificates } from "@app/hooks/api"; +import { UsePopUpState } from "@app/hooks/usePopUp"; + +type Props = { + handlePopUpOpen: ( + popUpName: keyof UsePopUpState<["certificate", "deleteCertificate", "certificateCert"]>, + data?: { + certId?: string; + commonName?: string; + } + ) => void; +}; + +export const CertificatesTable = ({ handlePopUpOpen }: Props) => { + const { currentWorkspace } = useWorkspace(); + const { data, isLoading } = useListWorkspaceCertificates(currentWorkspace?.slug ?? ""); + return ( +
+ + + + + + + + + + + {isLoading && } + {!isLoading && + data && + data.length > 0 && + data.map((certificate) => { + return ( + + + + + + + ); + })} + +
Certificate IDCommon NameValid Until +
{certificate.id}{certificate.commonName} + {certificate.notAfter + ? format(new Date(certificate.notAfter), "yyyy-MM-dd") + : "-"} + + + +
+ + + +
+
+ + + {(isAllowed) => ( + + handlePopUpOpen("certificateCert", { + certId: certificate.id + }) + } + disabled={!isAllowed} + icon={} + > + Export Certificate + + )} + + + {(isAllowed) => ( + + handlePopUpOpen("certificate", { + certId: certificate.id + }) + } + disabled={!isAllowed} + icon={} + > + View Details + + )} + + + {(isAllowed) => ( + + handlePopUpOpen("deleteCertificate", { + certId: certificate.id, + commonName: certificate.commonName + }) + } + disabled={!isAllowed} + icon={} + > + Delete Certificate + + )} + + +
+
+ {!isLoading && data?.length === 0 && ( + + )} +
+
+ ); +}; diff --git a/frontend/src/views/Project/CertificatesPage/components/CertificatesTab/components/index.tsx b/frontend/src/views/Project/CertificatesPage/components/CertificatesTab/components/index.tsx new file mode 100644 index 000000000..7854a6f8b --- /dev/null +++ b/frontend/src/views/Project/CertificatesPage/components/CertificatesTab/components/index.tsx @@ -0,0 +1 @@ +export { CertificatesSection } from "./CertificatesSection"; diff --git a/frontend/src/views/Project/CertificatesPage/components/CertificatesTab/index.tsx b/frontend/src/views/Project/CertificatesPage/components/CertificatesTab/index.tsx new file mode 100644 index 000000000..277134d56 --- /dev/null +++ b/frontend/src/views/Project/CertificatesPage/components/CertificatesTab/index.tsx @@ -0,0 +1 @@ +export { CertificatesTab } from "./CertificatesTab"; diff --git a/frontend/src/views/Project/CertificatesPage/components/index.tsx b/frontend/src/views/Project/CertificatesPage/components/index.tsx index 9e52be028..9dbd8694f 100644 --- a/frontend/src/views/Project/CertificatesPage/components/index.tsx +++ b/frontend/src/views/Project/CertificatesPage/components/index.tsx @@ -1 +1,2 @@ export { CaTab } from "./CaTab"; +export { CertificatesTab } from "./CertificatesTab";