Continue cert mgmt mvp

This commit is contained in:
Tuan Dang
2024-05-28 17:00:48 -07:00
parent 26ea949a4e
commit b8516da90f
57 changed files with 3339 additions and 467 deletions

View File

@@ -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;

View File

@@ -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<TCertificates, TCertificatesInsert, TCertificatesUpdate>;
[TableName.CertificateCert]: Knex.CompositeTableType<
TCertificateCerts,
TCertificateCertsInsert,
TCertificateCertsUpdate
>;
[TableName.CertificateSecret]: Knex.CompositeTableType<
TCertificateSecrets,
TCertificateSecretsInsert,
TCertificateSecretsUpdate
>;
[TableName.UserGroupMembership]: Knex.CompositeTableType<
TUserGroupMembership,
TUserGroupMembershipInsert,

View File

@@ -14,11 +14,23 @@ export async function up(knex: Knex): Promise<void> {
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<void> {
});
}
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<void> {
// 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);

View File

@@ -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<typeof CertificateAuthoritiesSchema>;

View File

@@ -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<typeof CertificateCertsSchema>;
export type TCertificateCertsInsert = Omit<z.input<typeof CertificateCertsSchema>, TImmutableDBKeys>;
export type TCertificateCertsUpdate = Partial<Omit<z.input<typeof CertificateCertsSchema>, TImmutableDBKeys>>;

View File

@@ -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<typeof CertificateSecretsSchema>;
export type TCertificateSecretsInsert = Omit<z.input<typeof CertificateSecretsSchema>, TImmutableDBKeys>;
export type TCertificateSecretsUpdate = Partial<Omit<z.input<typeof CertificateSecretsSchema>, TImmutableDBKeys>>;

View File

@@ -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<typeof CertificatesSchema>;
export type TCertificatesInsert = Omit<z.input<typeof CertificatesSchema>, TImmutableDBKeys>;
export type TCertificatesUpdate = Partial<Omit<z.input<typeof CertificatesSchema>, TImmutableDBKeys>>;

View File

@@ -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";

View File

@@ -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",

View File

@@ -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;
};

View File

@@ -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,

View File

@@ -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
};
}
});
};

View File

@@ -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
};
}
});
};

View File

@@ -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" });

View File

@@ -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 };
}
});
};

View File

@@ -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<typeof certificateAuthorityDALFactory>;
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
};
};

View File

@@ -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(", ");
};

View File

@@ -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<typeof certificateAuthorityServiceFactory>;
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
};
};

View File

@@ -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<TProjectPermission, "projectId">;
export type TGetCaDTO = {
caId: string;
} & Omit<TProjectPermission, "projectId">;
export type TUpdateCaDTO = {
caId: string;
status?: CaStatus;
} & Omit<TProjectPermission, "projectId">;
export type TDeleteCaDTO = {
caId: string;
} & Omit<TProjectPermission, "projectId">;
export type TGetCaCsrDTO = {
@@ -26,9 +48,33 @@ export type TGetCaCertDTO = {
caId: string;
} & Omit<TProjectPermission, "projectId">;
export type TIssueCertFromCaDTO = {
export type TSignIntermediateDTO = {
caId: string;
csr: string;
notBefore: string;
notBefore?: string;
notAfter: string;
maxPathLength: number;
} & Omit<TProjectPermission, "projectId">;
export type TImportCertToCaDTO = {
caId: string;
certificate: string;
certificateChain: string;
} & Omit<TProjectPermission, "projectId">;
export type TIssueCertFromCaDTO = {
caId: string;
commonName: string;
ttl?: number;
notBefore?: string;
notAfter?: string;
} & Omit<TProjectPermission, "projectId">;
export type TDNParts = {
commonName?: string;
organization?: string;
ou?: string;
country?: string;
province?: string;
locality?: string;
};

View File

@@ -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" });

View File

@@ -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<typeof certificateCertDALFactory>;
export const certificateCertDALFactory = (db: TDbClient) => {
const certificateCertOrm = ormify(db, TableName.CertificateCert);
return certificateCertOrm;
};

View File

@@ -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<typeof certificateDALFactory>;
export const certificateDALFactory = (db: TDbClient) => {
const certificateOrm = ormify(db, TableName.Certificate);
return certificateOrm;
};

View File

@@ -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<typeof certificateSecretDALFactory>;
export const certificateSecretDALFactory = (db: TDbClient) => {
const certificateSecretOrm = ormify(db, TableName.CertificateSecret);
return certificateSecretOrm;
};

View File

@@ -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<typeof certificateServiceFactory>;
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
};
};

View File

@@ -0,0 +1,13 @@
import { TProjectPermission } from "@app/lib/types";
export type TGetCertDTO = {
certId: string;
} & Omit<TProjectPermission, "projectId">;
export type TDeleteCertDTO = {
certId: string;
} & Omit<TProjectPermission, "projectId">;
export type TGetCertCertDTO = {
certId: string;
} & Omit<TProjectPermission, "projectId">;

View File

@@ -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<TProjectUserMembershipRoleDALFactory, "create">;
secretBlindIndexDAL: Pick<TSecretBlindIndexDALFactory, "create">;
certificateAuthorityDAL: Pick<TCertificateAuthorityDALFactory, "find">;
certificateDAL: TCertificateDALFactory;
permissionService: TPermissionServiceFactory;
orgService: Pick<TOrgServiceFactory, "addGhostUser">;
licenseService: Pick<TLicenseServiceFactory, "getPlan">;
@@ -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
};
};

View File

@@ -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<TProjectPermission, "projectId">;

View File

@@ -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]

View File

@@ -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"
};

View File

@@ -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"
}

View File

@@ -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";

View File

@@ -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<TCertificateAuthority, {}, TCreateCaDTO>({
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<TCertificateAuthority, {}, TUpdateCaDTO>({
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<TCertificateAuthority, {}, TDeleteCaDTO>({
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<TSignIntermediateResponse, {}, TSignIntermediateDTO>({
mutationFn: async (body) => {
const { data } = await apiRequest.post<TSignIntermediateResponse>(
`/api/v1/ca/${body.caId}/sign-intermediate`,
body
);
return data;
}
});
};
export const useImportCaCertificate = () => {
const queryClient = useQueryClient();
return useMutation<TImportCaCertificateResponse, {}, TImportCaCertificateDTO>({
mutationFn: async ({ caId, ...body }) => {
const { data } = await apiRequest.post<TImportCaCertificateResponse>(
`/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<TCreateCertificateResponse, {}, TCreateCertificateDTO>({
mutationFn: async ({ caId, ...body }) => {
const { data } = await apiRequest.post<TCreateCertificateResponse>(
`/api/v1/ca/${caId}/issue-certificate`,
body
);
return data;
},
onSuccess: (_, { projectSlug }) => {
queryClient.invalidateQueries(workspaceKeys.getWorkspaceCertificates(projectSlug));
}
});
};

View File

@@ -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)
});
};

View File

@@ -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;
};

View File

@@ -0,0 +1,2 @@
export { useDeleteCert } from "./mutations";
export { useGetCertById, useGetCertCert } from "./queries";

View File

@@ -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<TCertificate, {}, TDeleteCaDTO>({
mutationFn: async ({ certId }) => {
const {
data: { certificate }
} = await apiRequest.delete<{ certificate: TCertificate }>(`/api/v1/certificates/${certId}`);
return certificate;
},
onSuccess: (_, { projectSlug }) => {
queryClient.invalidateQueries(workspaceKeys.getWorkspaceCertificates(projectSlug));
}
});
};

View File

@@ -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)
});
};

View File

@@ -0,0 +1,7 @@
export type TCertificate = {
id: string;
caId: string;
commonName: string;
notBefore: string;
notAfter: string;
};

View File

@@ -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";

View File

@@ -22,6 +22,7 @@ export {
useGetWorkspaceSecrets,
useGetWorkspaceUsers,
useListWorkspaceCas,
useListWorkspaceCertificates,
useListWorkspaceGroups,
useNameWorkspaceSecrets,
useRenameWorkspace,

View File

@@ -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)
});
};

View File

@@ -9,11 +9,10 @@ const Certificates = () => {
return (
<div className="h-full bg-bunker-800">
<Head>
<title>{t("common.head-title", { title: t("settings.project.title") })}</title>
<title>{t("common.head-title", { title: "Certificates" })}</title>
<link rel="icon" href="/infisical.ico" />
<meta property="og:image" content="/images/message.png" />
</Head>
<div>Test CA page</div>
<CertificatesPage />
</div>
);

View File

@@ -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(
<div className="container mx-auto flex flex-col justify-between bg-bunker-800 text-white">
<div className="mx-auto mb-6 w-full max-w-7xl py-6 px-6">
<p className="mr-4 mb-4 text-3xl font-semibold text-white">Certificates</p>
<Tabs defaultValue={TabSections.Ca}>
<Tabs defaultValue={TabSections.Certificates}>
<TabList>
<Tab value={TabSections.Ca}>Certificate Authorities</Tab>
<Tab value={TabSections.Certificates}>Certificates</Tab>
<Tab value={TabSections.Ca}>Certificate Authorities</Tab>
</TabList>
<TabPanel value={TabSections.Certificates}>
<CertificatesTab />
</TabPanel>
<TabPanel value={TabSections.Ca}>
<CaTab />
</TabPanel>
<TabPanel value={TabSections.Certificates}>
<div>Certs</div>
{/* <OrgGroupsTab /> */}
</TabPanel>
</Tabs>
</div>
</div>

View File

@@ -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 (
<Modal
isOpen={popUp?.caCert?.isOpen}
onOpenChange={(isOpen) => {
handlePopUpToggle("caCert", isOpen);
}}
>
<ModalContent title="CA Certificate">
{data ? (
<div>
<h2 className="mb-4">Serial Number</h2>
<div className="mb-8 flex items-center justify-between rounded-md bg-white/[0.07] p-2 text-base text-gray-400">
<p className="mr-4 break-all">{data.serialNumber}</p>
<IconButton
ariaLabel="copy icon"
colorSchema="secondary"
className="group relative"
onClick={() => {
navigator.clipboard.writeText(data.serialNumber);
setIsSerialNumberCopied.on();
}}
>
<FontAwesomeIcon icon={isSerialNumberCopied ? faCheck : faCopy} />
<span className="absolute -left-8 -top-20 hidden w-28 translate-y-full rounded-md bg-bunker-800 py-2 pl-3 text-center text-sm text-gray-400 group-hover:flex group-hover:animate-fadeIn">
Click to copy
</span>
</IconButton>
</div>
<h2 className="mb-4">Certificate Body</h2>
<div className="mb-8 flex items-center justify-between rounded-md bg-white/[0.07] p-2 text-base text-gray-400">
<p className="mr-4 whitespace-pre-wrap break-all">{data.certificate}</p>
<IconButton
ariaLabel="copy icon"
colorSchema="secondary"
className="group relative"
onClick={() => {
navigator.clipboard.writeText(data.certificate);
setIsCertificateCopied.on();
}}
>
<FontAwesomeIcon icon={isCertificateCopied ? faCheck : faCopy} />
<span className="absolute -left-8 -top-20 hidden w-28 translate-y-full rounded-md bg-bunker-800 py-2 pl-3 text-center text-sm text-gray-400 group-hover:flex group-hover:animate-fadeIn">
Click to copy
</span>
</IconButton>
</div>
<h2 className="mb-4">Certificate Chain</h2>
<div className="mb-8 flex items-center justify-between rounded-md bg-white/[0.07] p-2 text-base text-gray-400">
<p className="mr-4 whitespace-pre-wrap break-all">{data.certificateChain}</p>
<IconButton
ariaLabel="copy icon"
colorSchema="secondary"
className="group relative"
onClick={() => {
navigator.clipboard.writeText(data.certificateChain);
setIsCertificateChainCopied.on();
}}
>
<FontAwesomeIcon icon={isCertificateChainCopied ? faCheck : faCopy} />
<span className="absolute -left-8 -top-20 hidden w-28 translate-y-full rounded-md bg-bunker-800 py-2 pl-3 text-center text-sm text-gray-400 group-hover:flex group-hover:animate-fadeIn">
Click to copy
</span>
</IconButton>
</div>
</div>
) : (
<div />
)}
</ModalContent>
</Modal>
);
};

View File

@@ -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<typeof schema>;
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>(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<FormData>({
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 (
<form onSubmit={handleSubmit(onFormSubmit)}>
<Controller
control={control}
name="parentCaId"
defaultValue=""
render={({ field: { onChange, ...field }, fieldState: { error } }) => (
<FormControl
label="Parent CA"
errorText={error?.message}
isError={Boolean(error)}
className="mt-4"
isRequired
>
<Select
defaultValue={field.value}
{...field}
onValueChange={(e) => onChange(e)}
className="w-full"
>
{(cas || [])
.filter((c) => {
const isParentCaNotSelf = c.id !== ca?.id;
const isParentCaActive = c.status === CaStatus.ACTIVE;
const isParentCaAllowedChildrenCas =
c.maxPathLength && c.maxPathLength !== 0;
return (
isParentCaNotSelf && isParentCaActive && isParentCaAllowedChildrenCas
);
})
.map(({ id, type, dn }) => (
<SelectItem value={id} key={`parent-ca-${id}`}>
{`${caTypeToNameMap[type]}: ${dn}`}
</SelectItem>
))}
</Select>
</FormControl>
)}
/>
{/* <Controller
name="notAfter"
control={control}
defaultValue={getDefaultNotAfterDate()}
render={({ field: { onChange, ...field }, fieldState: { error } }) => {
return (
<FormControl
label="Validity"
errorText={error?.message}
isError={Boolean(error)}
className="mr-4"
>
<DatePicker
value={field.value || undefined}
onChange={(date) => {
onChange(date);
setIsStartDatePickerOpen(false);
}}
popUpProps={{
open: isStartDatePickerOpen,
onOpenChange: setIsStartDatePickerOpen
}}
popUpContentProps={{}}
/>
</FormControl>
);
}}
/> */}
<Controller
control={control}
name="notAfter"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Valid Until"
isError={Boolean(error)}
errorText={error?.message}
isRequired
>
<Input {...field} placeholder="YYYY-MM-DD" />
</FormControl>
)}
/>
<Controller
control={control}
name="maxPathLength"
// defaultValue="0"
render={({ field: { onChange, ...field }, fieldState: { error } }) => (
<FormControl
label="Path Length"
errorText={error?.message}
isError={Boolean(error)}
className="mt-4"
>
<Select
defaultValue={field.value}
{...field}
onValueChange={(e) => onChange(e)}
className="w-full"
>
{generatePathLengthOpts(parentCa?.maxPathLength || 0).map((value) => (
<SelectItem value={String(value)} key={`ca-path-length-${value}`}>
{`${value}`}
</SelectItem>
))}
</Select>
</FormControl>
)}
/>
<div className="flex items-center">
<Button
className="mr-4"
size="sm"
type="submit"
isLoading={isSubmitting}
isDisabled={isSubmitting}
>
Install
</Button>
<Button
colorSchema="secondary"
variant="plain"
onClick={() => handlePopUpToggle("installCaCert", false)}
>
Cancel
</Button>
</div>
</form>
);
default:
return <div>External TODO</div>;
}
};
return (
<Modal
isOpen={popUp?.installCaCert?.isOpen}
onOpenChange={(isOpen) => {
handlePopUpToggle("installCaCert", isOpen);
reset();
}}
>
<ModalContent title="Install Intermediate CA certificate">
{/* <FormControl label="Parent CA Type" className="mt-4">
<Select
defaultValue={ParentCaType.Internal}
value={parentCaType}
onValueChange={(e) => setParentCaType(e as ParentCaType)}
className="w-full"
>
<SelectItem
value={ParentCaType.Internal}
key={`parent-ca-type-${ParentCaType.Internal}`}
>
Infisical Private CA
</SelectItem>
<SelectItem
value={ParentCaType.External}
key={`parent-ca-type-${ParentCaType.External}`}
>
External Private CA
</SelectItem>
</Select>
</FormControl> */}
{renderForm(parentCaType)}
</ModalContent>
</Modal>
);
};

View File

@@ -0,0 +1 @@
export { CaInstallCertModal } from "./CaInstallCertModal";

View File

@@ -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<typeof schema>;
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<FormData>({
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 (
<Modal
isOpen={popUp?.ca?.isOpen}
onOpenChange={(isOpen) => {
handlePopUpToggle("ca", isOpen);
reset();
handlePopUpToggle("ca", isOpen);
}}
>
<ModalContent title={`${popUp?.ca?.data ? "Update" : "Create"} CA`}>
<ModalContent title={`${ca ? "View" : "Create"} Private CA`}>
<form onSubmit={handleSubmit(onFormSubmit)}>
<Controller
control={control}
name="type"
defaultValue={CertificateAuthorityType.ROOT}
defaultValue={CaType.ROOT}
render={({ field: { onChange, ...field }, fieldState: { error } }) => (
<FormControl label="CA Type" errorText={error?.message} isError={Boolean(error)}>
<Select
@@ -226,7 +184,7 @@ export const CaModal = ({ popUp, handlePopUpToggle }: Props) => {
{...field}
onValueChange={(e) => onChange(e)}
className="w-full"
// isDisabled={!!identityAuthMethodData?.authMethod}
isDisabled={Boolean(ca)}
>
{caTypes.map(({ label, value }) => (
<SelectItem value={String(value || "")} key={label}>
@@ -237,6 +195,80 @@ export const CaModal = ({ popUp, handlePopUpToggle }: Props) => {
</FormControl>
)}
/>
{caType === CaType.ROOT && (
<>
{/* <Controller
name="notAfter"
control={control}
defaultValue={getDefaultNotAfterDate()}
render={({ field: { onChange, ...field }, fieldState: { error } }) => {
return (
<FormControl
label="Validity"
errorText={error?.message}
isError={Boolean(error)}
className="mr-4"
>
<DatePicker
value={field.value || undefined}
onChange={(date) => {
onChange(date);
setIsStartDatePickerOpen(false);
}}
popUpProps={{
open: isStartDatePickerOpen,
onOpenChange: setIsStartDatePickerOpen
}}
popUpContentProps={{}}
/>
</FormControl>
);
}}
/> */}
<Controller
control={control}
defaultValue=""
name="notAfter"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Valid Until"
isError={Boolean(error)}
errorText={error?.message}
isRequired
>
<Input {...field} placeholder="YYYY-MM-DD" isDisabled={Boolean(ca)} />
</FormControl>
)}
/>
<Controller
control={control}
name="maxPathLength"
defaultValue="-1"
render={({ field: { onChange, ...field }, fieldState: { error } }) => (
<FormControl
label="Path Length"
errorText={error?.message}
isError={Boolean(error)}
className="mt-4"
>
<Select
defaultValue={field.value}
{...field}
onValueChange={(e) => onChange(e)}
className="w-full"
isDisabled={Boolean(ca)}
>
{[-1, 0, 1, 2, 3, 4].map((value) => (
<SelectItem value={String(value)} key={`ca-path-length-${value}`}>
{`${value}`}
</SelectItem>
))}
</Select>
</FormControl>
)}
/>
</>
)}
<Controller
control={control}
defaultValue=""
@@ -247,7 +279,7 @@ export const CaModal = ({ popUp, handlePopUpToggle }: Props) => {
isError={Boolean(error)}
errorText={error?.message}
>
<Input {...field} placeholder="Acme Corp" />
<Input {...field} placeholder="Acme Corp" isDisabled={Boolean(ca)} />
</FormControl>
)}
/>
@@ -261,7 +293,7 @@ export const CaModal = ({ popUp, handlePopUpToggle }: Props) => {
isError={Boolean(error)}
errorText={error?.message}
>
<Input {...field} placeholder="Engineering" />
<Input {...field} placeholder="Engineering" isDisabled={Boolean(ca)} />
</FormControl>
)}
/>
@@ -275,7 +307,7 @@ export const CaModal = ({ popUp, handlePopUpToggle }: Props) => {
isError={Boolean(error)}
errorText={error?.message}
>
<Input {...field} placeholder="United States (US)" />
<Input {...field} placeholder="United States (US)" isDisabled={Boolean(ca)} />
</FormControl>
)}
/>
@@ -289,7 +321,7 @@ export const CaModal = ({ popUp, handlePopUpToggle }: Props) => {
isError={Boolean(error)}
errorText={error?.message}
>
<Input {...field} placeholder="California" />
<Input {...field} placeholder="California" isDisabled={Boolean(ca)} />
</FormControl>
)}
/>
@@ -303,7 +335,7 @@ export const CaModal = ({ popUp, handlePopUpToggle }: Props) => {
isError={Boolean(error)}
errorText={error?.message}
>
<Input {...field} placeholder="San Francisco" />
<Input {...field} placeholder="San Francisco" isDisabled={Boolean(ca)} />
</FormControl>
)}
/>
@@ -317,28 +349,30 @@ export const CaModal = ({ popUp, handlePopUpToggle }: Props) => {
isError={Boolean(error)}
errorText={error?.message}
>
<Input {...field} placeholder="Example CA" />
<Input {...field} placeholder="Example CA" isDisabled={Boolean(ca)} />
</FormControl>
)}
/>
<div className="flex items-center">
<Button
className="mr-4"
size="sm"
type="submit"
isLoading={isSubmitting}
isDisabled={isSubmitting}
>
{popUp?.ca?.data ? "Update" : "Create"}
</Button>
<Button
colorSchema="secondary"
variant="plain"
onClick={() => handlePopUpToggle("ca", false)}
>
Cancel
</Button>
</div>
{!ca && (
<div className="flex items-center">
<Button
className="mr-4"
size="sm"
type="submit"
isLoading={isSubmitting}
isDisabled={isSubmitting}
>
{popUp?.ca?.data ? "Update" : "Create"}
</Button>
<Button
colorSchema="secondary"
variant="plain"
onClick={() => handlePopUpToggle("ca", false)}
>
Cancel
</Button>
</div>
)}
</form>
</ModalContent>
</Modal>

View File

@@ -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<string>("");
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 (
<div className="mb-6 rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
<div className="mb-4 flex justify-between">
<p className="text-xl font-semibold text-mineshaft-100">Certificate Authorities</p>
{/* <OrgPermissionCan I={OrgPermissionActions.Create} a={OrgPermissionSubjects.Member}>
{(isAllowed) => ( */}
<Button
colorSchema="primary"
type="submit"
leftIcon={<FontAwesomeIcon icon={faPlus} />}
onClick={() => handlePopUpOpen("ca")}
// isDisabled={!isAllowed}
<ProjectPermissionCan
I={ProjectPermissionActions.Create}
a={ProjectPermissionSub.CertificateAuthorities}
>
Create CA
</Button>
{/* )} */}
{/* </OrgPermissionCan> */}
{(isAllowed) => (
<Button
colorSchema="primary"
type="submit"
leftIcon={<FontAwesomeIcon icon={faPlus} />}
onClick={() => handlePopUpOpen("ca")}
isDisabled={!isAllowed}
>
Create CA
</Button>
)}
</ProjectPermissionCan>
</div>
<CaModal popUp={popUp} handlePopUpToggle={handlePopUpToggle} />
<CaTable />
{/* <OrgMembersTable
handlePopUpOpen={handlePopUpOpen}
setCompleteInviteLink={setCompleteInviteLink}
/>
<AddOrgMemberModal
popUp={popUp}
handlePopUpToggle={handlePopUpToggle}
completeInviteLink={completeInviteLink}
setCompleteInviteLink={setCompleteInviteLink}
<CaInstallCertModal popUp={popUp} handlePopUpToggle={handlePopUpToggle} />
<CaCertModal popUp={popUp} handlePopUpToggle={handlePopUpToggle} />
<CaTable handlePopUpOpen={handlePopUpOpen} />
<DeleteActionModal
isOpen={popUp.deleteCa.isOpen}
title={`Are you sure want to remove the CA ${
(popUp?.deleteCa?.data as { dn: string })?.dn || ""
} from the project?`}
subTitle="This action will delete other CAs and certificates below it in your CA hierarchy."
onChange={(isOpen) => handlePopUpToggle("deleteCa", isOpen)}
deleteKey="confirm"
onDeleteApproved={() => onRemoveCaSubmit((popUp?.deleteCa?.data as { caId: string })?.caId)}
/>
<DeleteActionModal
isOpen={popUp.removeMember.isOpen}
title={`Are you sure want to remove member with username ${
(popUp?.removeMember?.data as { username: string })?.username || ""
}?`}
onChange={(isOpen) => 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 })
}
/>
<UpgradePlanModal
isOpen={popUp.upgradePlan.isOpen}
onOpenChange={(isOpen) => handlePopUpToggle("upgradePlan", isOpen)}
text={(popUp.upgradePlan?.data as { description: string })?.description}
/>
<EmailServiceSetupModal
isOpen={popUp.setUpEmail?.isOpen}
onOpenChange={(isOpen) => handlePopUpToggle("setUpEmail", isOpen)}
/> */}
</div>
);
};

View File

@@ -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 (
<div>
<TableContainer>
@@ -27,6 +55,8 @@ export const CaTable = () => {
<Th>Subject</Th>
<Th>Status</Th>
<Th>Type</Th>
<Th>Valid Until</Th>
<Th />
</Tr>
</THead>
<TBody>
@@ -38,15 +68,148 @@ export const CaTable = () => {
return (
<Tr className="h-10" key={`ca-${ca.id}`}>
<Td>{ca.dn}</Td>
<Td>Pending</Td>
<Td>{caStatusToNameMap[ca.status]}</Td>
<Td>{caTypeToNameMap[ca.type]}</Td>
<Td>{ca.notAfter ? format(new Date(ca.notAfter), "yyyy-MM-dd") : "-"}</Td>
<Td className="flex justify-end">
<DropdownMenu>
<DropdownMenuTrigger asChild className="rounded-lg">
<div className="hover:text-primary-400 data-[state=open]:text-primary-400">
<Tooltip content="More options">
<FontAwesomeIcon size="lg" icon={faEllipsis} />
</Tooltip>
</div>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="p-1">
{ca.status === CaStatus.PENDING_CERTIFICATE && (
<ProjectPermissionCan
I={ProjectPermissionActions.Create}
a={ProjectPermissionSub.CertificateAuthorities}
>
{(isAllowed) => (
<DropdownMenuItem
className={twMerge(
!isAllowed &&
"pointer-events-none cursor-not-allowed opacity-50"
)}
onClick={async () => {
handlePopUpOpen("installCaCert", {
caId: ca.id
});
}}
disabled={!isAllowed}
icon={<FontAwesomeIcon icon={faCertificate} />}
>
Install Certificate
</DropdownMenuItem>
)}
</ProjectPermissionCan>
)}
{ca.status !== CaStatus.PENDING_CERTIFICATE && (
<ProjectPermissionCan
I={ProjectPermissionActions.Read}
a={ProjectPermissionSub.CertificateAuthorities}
>
{(isAllowed) => (
<DropdownMenuItem
className={twMerge(
!isAllowed &&
"pointer-events-none cursor-not-allowed opacity-50"
)}
onClick={async () => {
handlePopUpOpen("caCert", {
caId: ca.id
});
}}
disabled={!isAllowed}
icon={<FontAwesomeIcon icon={faCertificate} />}
>
View Certificate
</DropdownMenuItem>
)}
</ProjectPermissionCan>
)}
<ProjectPermissionCan
I={ProjectPermissionActions.Read}
a={ProjectPermissionSub.CertificateAuthorities}
>
{(isAllowed) => (
<DropdownMenuItem
className={twMerge(
!isAllowed && "pointer-events-none cursor-not-allowed opacity-50"
)}
onClick={async () =>
handlePopUpOpen("ca", {
caId: ca.id
})
}
disabled={!isAllowed}
icon={<FontAwesomeIcon icon={faEye} />}
>
View CA
</DropdownMenuItem>
)}
</ProjectPermissionCan>
{(ca.status === CaStatus.ACTIVE || ca.status === CaStatus.DISABLED) && (
<ProjectPermissionCan
I={ProjectPermissionActions.Edit}
a={ProjectPermissionSub.CertificateAuthorities}
>
{(isAllowed) => (
<DropdownMenuItem
className={twMerge(
!isAllowed &&
"pointer-events-none cursor-not-allowed opacity-50"
)}
onClick={async () =>
handlePopUpOpen("caStatus", {
caId: ca.id,
status:
ca.status === CaStatus.ACTIVE
? CaStatus.DISABLED
: CaStatus.ACTIVE
})
}
disabled={!isAllowed}
icon={<FontAwesomeIcon icon={faBan} />}
>
{`${ca.status === CaStatus.ACTIVE ? "Disable" : "Enable"} CA`}
</DropdownMenuItem>
)}
</ProjectPermissionCan>
)}
<ProjectPermissionCan
I={ProjectPermissionActions.Delete}
a={ProjectPermissionSub.CertificateAuthorities}
>
{(isAllowed) => (
<DropdownMenuItem
className={twMerge(
!isAllowed && "pointer-events-none cursor-not-allowed opacity-50"
)}
onClick={async () =>
handlePopUpOpen("deleteCa", {
caId: ca.id,
dn: ca.dn
})
}
disabled={!isAllowed}
icon={<FontAwesomeIcon icon={faTrash} />}
>
Delete CA
</DropdownMenuItem>
)}
</ProjectPermissionCan>
</DropdownMenuContent>
</DropdownMenu>
</Td>
</Tr>
);
})}
</TBody>
</Table>
{!isLoading && data?.length === 0 && (
<EmptyState title="No groups have been added to this project" icon={faCertificate} />
<EmptyState title="No certificate authorities have been created" icon={faCertificate} />
)}
</TableContainer>
</div>

View File

@@ -0,0 +1,17 @@
import { motion } from "framer-motion";
import { CertificatesSection } from "./components";
export const CertificatesTab = () => {
return (
<motion.div
key="panel-certificates"
transition={{ duration: 0.15 }}
initial={{ opacity: 0, translateX: 30 }}
animate={{ opacity: 1, translateX: 0 }}
exit={{ opacity: 0, translateX: 30 }}
>
<CertificatesSection />
</motion.div>
);
};

View File

@@ -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 (
<Modal
isOpen={popUp?.certificateCert?.isOpen}
onOpenChange={(isOpen) => {
handlePopUpToggle("certificateCert", isOpen);
}}
>
<ModalContent title="Export Certificate">
{data ? (
<div>
<h2 className="mb-4">Serial Number</h2>
<div className="mb-8 flex items-center justify-between rounded-md bg-white/[0.07] p-2 text-base text-gray-400">
<p className="mr-4 break-all">{data.serialNumber}</p>
<IconButton
ariaLabel="copy icon"
colorSchema="secondary"
className="group relative"
onClick={() => {
navigator.clipboard.writeText(data.serialNumber);
setIsSerialNumberCopied.on();
}}
>
<FontAwesomeIcon icon={isSerialNumberCopied ? faCheck : faCopy} />
<span className="absolute -left-8 -top-20 hidden w-28 translate-y-full rounded-md bg-bunker-800 py-2 pl-3 text-center text-sm text-gray-400 group-hover:flex group-hover:animate-fadeIn">
Click to copy
</span>
</IconButton>
</div>
<h2 className="mb-4">Certificate Body</h2>
<div className="mb-8 flex items-center justify-between rounded-md bg-white/[0.07] p-2 text-base text-gray-400">
<p className="mr-4 whitespace-pre-wrap break-all">{data.certificate}</p>
<IconButton
ariaLabel="copy icon"
colorSchema="secondary"
className="group relative"
onClick={() => {
navigator.clipboard.writeText(data.certificate);
setIsCertificateCopied.on();
}}
>
<FontAwesomeIcon icon={isCertificateCopied ? faCheck : faCopy} />
<span className="absolute -left-8 -top-20 hidden w-28 translate-y-full rounded-md bg-bunker-800 py-2 pl-3 text-center text-sm text-gray-400 group-hover:flex group-hover:animate-fadeIn">
Click to copy
</span>
</IconButton>
</div>
<h2 className="mb-4">Certificate Chain</h2>
<div className="mb-8 flex items-center justify-between rounded-md bg-white/[0.07] p-2 text-base text-gray-400">
<p className="mr-4 whitespace-pre-wrap break-all">{data.certificateChain}</p>
<IconButton
ariaLabel="copy icon"
colorSchema="secondary"
className="group relative"
onClick={() => {
navigator.clipboard.writeText(data.certificateChain);
setIsCertificateChainCopied.on();
}}
>
<FontAwesomeIcon icon={isCertificateChainCopied ? faCheck : faCopy} />
<span className="absolute -left-8 -top-20 hidden w-28 translate-y-full rounded-md bg-bunker-800 py-2 pl-3 text-center text-sm text-gray-400 group-hover:flex group-hover:animate-fadeIn">
Click to copy
</span>
</IconButton>
</div>
<h2 className="mb-4">Certificate Private Key</h2>
<div className="mb-8 flex items-center justify-between rounded-md bg-white/[0.07] p-2 text-base text-gray-400">
<p className="mr-4 whitespace-pre-wrap break-all">{data.privateKey}</p>
<IconButton
ariaLabel="copy icon"
colorSchema="secondary"
className="group relative"
onClick={() => {
navigator.clipboard.writeText(data.certificateChain);
setIsCertificateSkCopied.on();
}}
>
<FontAwesomeIcon icon={isCertificateSkCopied ? faCheck : faCopy} />
<span className="absolute -left-8 -top-20 hidden w-28 translate-y-full rounded-md bg-bunker-800 py-2 pl-3 text-center text-sm text-gray-400 group-hover:flex group-hover:animate-fadeIn">
Click to copy
</span>
</IconButton>
</div>
</div>
) : (
<div />
)}
</ModalContent>
</Modal>
);
};

View File

@@ -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<typeof schema>;
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<FormData>({
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 (
<Modal
isOpen={popUp?.certificate?.isOpen}
onOpenChange={(isOpen) => {
handlePopUpToggle("certificate", isOpen);
reset();
}}
>
<ModalContent title={`${cert ? "View" : "Issue"} Certificate`}>
<form onSubmit={handleSubmit(onFormSubmit)}>
<Controller
control={control}
name="caId"
defaultValue=""
render={({ field: { onChange, ...field }, fieldState: { error } }) => (
<FormControl
label="Issuing CA"
errorText={error?.message}
isError={Boolean(error)}
className="mt-4"
isRequired
>
<Select
defaultValue={field.value}
{...field}
onValueChange={(e) => onChange(e)}
className="w-full"
isDisabled={Boolean(cert)}
>
{(cas || []).map(({ id, type, dn }) => (
<SelectItem value={id} key={`ca-${id}`}>
{`${caTypeToNameMap[type]}: ${dn}`}
</SelectItem>
))}
</Select>
</FormControl>
)}
/>
<Controller
control={control}
defaultValue=""
name="commonName"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Common Name (CN)"
isError={Boolean(error)}
errorText={error?.message}
isRequired
>
<Input {...field} placeholder="Acme Corp" isDisabled={Boolean(cert)} />
</FormControl>
)}
/>
<Controller
control={control}
name="ttl"
render={({ field, fieldState: { error } }) => (
<FormControl
label="TTL (seconds)"
isError={Boolean(error)}
errorText={error?.message}
>
<Input {...field} placeholder="86400" isDisabled={Boolean(cert)} />
</FormControl>
)}
/>
<Controller
control={control}
defaultValue=""
name="notAfter"
render={({ field, fieldState: { error } }) => (
<FormControl label="Valid Until" isError={Boolean(error)} errorText={error?.message}>
<Input {...field} placeholder="YYYY-MM-DD" isDisabled={Boolean(cert)} />
</FormControl>
)}
/>
{!cert && (
<div className="flex items-center">
<Button
className="mr-4"
size="sm"
type="submit"
isLoading={isSubmitting}
isDisabled={isSubmitting}
>
Create
</Button>
<Button colorSchema="secondary" variant="plain">
Cancel
</Button>
</div>
)}
</form>
</ModalContent>
</Modal>
);
};

View File

@@ -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 (
<div className="mb-6 rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
<div className="mb-4 flex justify-between">
<p className="text-xl font-semibold text-mineshaft-100">Certificates</p>
{/* <OrgPermissionCan I={OrgPermissionActions.Create} a={OrgPermissionSubjects.Member}>
{(isAllowed) => ( */}
<Button
colorSchema="primary"
type="submit"
leftIcon={<FontAwesomeIcon icon={faPlus} />}
onClick={() => handlePopUpOpen("certificate")}
// isDisabled={!isAllowed}
>
Issue Certificate
</Button>
{/* )} */}
{/* </OrgPermissionCan> */}
</div>
<CertificatesTable handlePopUpOpen={handlePopUpOpen} />
<CertificateModal popUp={popUp} handlePopUpToggle={handlePopUpToggle} />
<CertificateCertModal popUp={popUp} handlePopUpToggle={handlePopUpToggle} />
<DeleteActionModal
isOpen={popUp.deleteCertificate.isOpen}
title={`Are you sure want to remove the certificate ${
(popUp?.deleteCertificate?.data as { commonName: string })?.commonName || ""
} from the project?`}
onChange={(isOpen) => handlePopUpToggle("deleteCertificate", isOpen)}
deleteKey="confirm"
onDeleteApproved={() =>
onRemoveCertificateSubmit((popUp?.deleteCertificate?.data as { certId: string })?.certId)
}
/>
</div>
);
};

View File

@@ -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 (
<div>
<TableContainer>
<Table>
<THead>
<Tr>
<Th>Certificate ID</Th>
<Th>Common Name</Th>
<Th>Valid Until</Th>
<Th />
</Tr>
</THead>
<TBody>
{isLoading && <TableSkeleton columns={3} innerKey="project-cas" />}
{!isLoading &&
data &&
data.length > 0 &&
data.map((certificate) => {
return (
<Tr className="h-10" key={`certificate-${certificate.id}`}>
<Td>{certificate.id}</Td>
<Td>{certificate.commonName}</Td>
<Td>
{certificate.notAfter
? format(new Date(certificate.notAfter), "yyyy-MM-dd")
: "-"}
</Td>
<Td className="flex justify-end">
<DropdownMenu>
<DropdownMenuTrigger asChild className="rounded-lg">
<div className="hover:text-primary-400 data-[state=open]:text-primary-400">
<Tooltip content="More options">
<FontAwesomeIcon size="lg" icon={faEllipsis} />
</Tooltip>
</div>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="p-1">
<ProjectPermissionCan
I={ProjectPermissionActions.Read}
a={ProjectPermissionSub.Certificates}
>
{(isAllowed) => (
<DropdownMenuItem
className={twMerge(
!isAllowed && "pointer-events-none cursor-not-allowed opacity-50"
)}
onClick={async () =>
handlePopUpOpen("certificateCert", {
certId: certificate.id
})
}
disabled={!isAllowed}
icon={<FontAwesomeIcon icon={faFileExport} />}
>
Export Certificate
</DropdownMenuItem>
)}
</ProjectPermissionCan>
<ProjectPermissionCan
I={ProjectPermissionActions.Read}
a={ProjectPermissionSub.Certificates}
>
{(isAllowed) => (
<DropdownMenuItem
className={twMerge(
!isAllowed && "pointer-events-none cursor-not-allowed opacity-50"
)}
onClick={async () =>
handlePopUpOpen("certificate", {
certId: certificate.id
})
}
disabled={!isAllowed}
icon={<FontAwesomeIcon icon={faEye} />}
>
View Details
</DropdownMenuItem>
)}
</ProjectPermissionCan>
<ProjectPermissionCan
I={ProjectPermissionActions.Delete}
a={ProjectPermissionSub.Certificates}
>
{(isAllowed) => (
<DropdownMenuItem
className={twMerge(
!isAllowed && "pointer-events-none cursor-not-allowed opacity-50"
)}
onClick={async () =>
handlePopUpOpen("deleteCertificate", {
certId: certificate.id,
commonName: certificate.commonName
})
}
disabled={!isAllowed}
icon={<FontAwesomeIcon icon={faTrash} />}
>
Delete Certificate
</DropdownMenuItem>
)}
</ProjectPermissionCan>
</DropdownMenuContent>
</DropdownMenu>
</Td>
</Tr>
);
})}
</TBody>
</Table>
{!isLoading && data?.length === 0 && (
<EmptyState title="No certificates have been created" icon={faCertificate} />
)}
</TableContainer>
</div>
);
};

View File

@@ -0,0 +1 @@
export { CertificatesSection } from "./CertificatesSection";

View File

@@ -0,0 +1 @@
export { CertificatesTab } from "./CertificatesTab";

View File

@@ -1 +1,2 @@
export { CaTab } from "./CaTab";
export { CertificatesTab } from "./CertificatesTab";