mirror of
https://github.com/awatertrevi/infisical.git
synced 2026-10-11 20:29:01 +00:00
checkpoint
This commit is contained in:
@@ -0,0 +1,16 @@
|
|||||||
|
import { Knex } from "knex";
|
||||||
|
|
||||||
|
import { TableName } from "../schemas";
|
||||||
|
|
||||||
|
export async function up(knex: Knex): Promise<void> {
|
||||||
|
if (await knex.schema.hasTable(TableName.Certificate)) {
|
||||||
|
await knex.schema.alterTable(TableName.Certificate, (t) => {
|
||||||
|
t.uuid("caId").nullable().alter();
|
||||||
|
t.uuid("caCertId").nullable().alter();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function down(): Promise<void> {
|
||||||
|
// Altering back to nullable will fail
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import { Knex } from "knex";
|
||||||
|
|
||||||
|
import { TableName } from "../schemas";
|
||||||
|
|
||||||
|
export async function up(knex: Knex): Promise<void> {
|
||||||
|
if (await knex.schema.hasTable(TableName.CertificateBody)) {
|
||||||
|
await knex.schema.alterTable(TableName.CertificateBody, (t) => {
|
||||||
|
t.binary("encryptedCertificateChain").nullable();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
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.binary("encryptedPrivateKey").notNullable();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function down(knex: Knex): Promise<void> {
|
||||||
|
if (await knex.schema.hasTable(TableName.CertificateSecret)) {
|
||||||
|
await knex.schema.dropTable(TableName.CertificateSecret);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (await knex.schema.hasTable(TableName.CertificateBody)) {
|
||||||
|
await knex.schema.alterTable(TableName.CertificateBody, (t) => {
|
||||||
|
t.dropColumn("encryptedCertificateChain");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import { Knex } from "knex";
|
||||||
|
|
||||||
|
import { TableName } from "../schemas";
|
||||||
|
|
||||||
|
export async function up(knex: Knex): Promise<void> {
|
||||||
|
if (await knex.schema.hasTable(TableName.Certificate)) {
|
||||||
|
await knex.schema.alterTable(TableName.Certificate, (t) => {
|
||||||
|
t.uuid("projectId").notNullable();
|
||||||
|
t.foreign("projectId").references("id").inTable(TableName.Certificate).onDelete("CASCADE");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function down(knex: Knex): Promise<void> {
|
||||||
|
// .. tbd
|
||||||
|
}
|
||||||
@@ -14,7 +14,8 @@ export const CertificateBodiesSchema = z.object({
|
|||||||
createdAt: z.date(),
|
createdAt: z.date(),
|
||||||
updatedAt: z.date(),
|
updatedAt: z.date(),
|
||||||
certId: z.string().uuid(),
|
certId: z.string().uuid(),
|
||||||
encryptedCertificate: zodBuffer
|
encryptedCertificate: zodBuffer,
|
||||||
|
encryptedCertificateChain: zodBuffer.nullable().optional()
|
||||||
});
|
});
|
||||||
|
|
||||||
export type TCertificateBodies = z.infer<typeof CertificateBodiesSchema>;
|
export type TCertificateBodies = z.infer<typeof CertificateBodiesSchema>;
|
||||||
|
|||||||
@@ -5,6 +5,8 @@
|
|||||||
|
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
|
import { zodBuffer } from "@app/lib/zod";
|
||||||
|
|
||||||
import { TImmutableDBKeys } from "./models";
|
import { TImmutableDBKeys } from "./models";
|
||||||
|
|
||||||
export const CertificateSecretsSchema = z.object({
|
export const CertificateSecretsSchema = z.object({
|
||||||
@@ -12,8 +14,7 @@ export const CertificateSecretsSchema = z.object({
|
|||||||
createdAt: z.date(),
|
createdAt: z.date(),
|
||||||
updatedAt: z.date(),
|
updatedAt: z.date(),
|
||||||
certId: z.string().uuid(),
|
certId: z.string().uuid(),
|
||||||
pk: z.string(),
|
encryptedPrivateKey: zodBuffer
|
||||||
sk: z.string()
|
|
||||||
});
|
});
|
||||||
|
|
||||||
export type TCertificateSecrets = z.infer<typeof CertificateSecretsSchema>;
|
export type TCertificateSecrets = z.infer<typeof CertificateSecretsSchema>;
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ export const CertificatesSchema = z.object({
|
|||||||
id: z.string().uuid(),
|
id: z.string().uuid(),
|
||||||
createdAt: z.date(),
|
createdAt: z.date(),
|
||||||
updatedAt: z.date(),
|
updatedAt: z.date(),
|
||||||
caId: z.string().uuid(),
|
caId: z.string().uuid().nullable().optional(),
|
||||||
status: z.string(),
|
status: z.string(),
|
||||||
serialNumber: z.string(),
|
serialNumber: z.string(),
|
||||||
friendlyName: z.string(),
|
friendlyName: z.string(),
|
||||||
@@ -21,7 +21,7 @@ export const CertificatesSchema = z.object({
|
|||||||
revokedAt: z.date().nullable().optional(),
|
revokedAt: z.date().nullable().optional(),
|
||||||
revocationReason: z.number().nullable().optional(),
|
revocationReason: z.number().nullable().optional(),
|
||||||
altNames: z.string().nullable().optional(),
|
altNames: z.string().nullable().optional(),
|
||||||
caCertId: z.string().uuid(),
|
caCertId: z.string().uuid().nullable().optional(),
|
||||||
certificateTemplateId: z.string().uuid().nullable().optional(),
|
certificateTemplateId: z.string().uuid().nullable().optional(),
|
||||||
keyUsages: z.string().array().nullable().optional(),
|
keyUsages: z.string().array().nullable().optional(),
|
||||||
extendedKeyUsages: z.string().array().nullable().optional()
|
extendedKeyUsages: z.string().array().nullable().optional()
|
||||||
|
|||||||
@@ -23,7 +23,6 @@ export const OrganizationsSchema = z.object({
|
|||||||
defaultMembershipRole: z.string().default("member"),
|
defaultMembershipRole: z.string().default("member"),
|
||||||
enforceMfa: z.boolean().default(false),
|
enforceMfa: z.boolean().default(false),
|
||||||
selectedMfaMethod: z.string().nullable().optional(),
|
selectedMfaMethod: z.string().nullable().optional(),
|
||||||
secretShareSendToAnyone: z.boolean().default(true).nullable().optional(),
|
|
||||||
allowSecretSharingOutsideOrganization: z.boolean().default(true).nullable().optional(),
|
allowSecretSharingOutsideOrganization: z.boolean().default(true).nullable().optional(),
|
||||||
shouldUseNewPrivilegeSystem: z.boolean().default(true),
|
shouldUseNewPrivilegeSystem: z.boolean().default(true),
|
||||||
privilegeUpgradeInitiatedByUsername: z.string().nullable().optional(),
|
privilegeUpgradeInitiatedByUsername: z.string().nullable().optional(),
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ export const ProjectsSchema = z.object({
|
|||||||
description: z.string().nullable().optional(),
|
description: z.string().nullable().optional(),
|
||||||
type: z.string(),
|
type: z.string(),
|
||||||
enforceCapitalization: z.boolean().default(false),
|
enforceCapitalization: z.boolean().default(false),
|
||||||
hasDeleteProtection: z.boolean().default(true).nullable().optional()
|
hasDeleteProtection: z.boolean().default(false).nullable().optional()
|
||||||
});
|
});
|
||||||
|
|
||||||
export type TProjects = z.infer<typeof ProjectsSchema>;
|
export type TProjects = z.infer<typeof ProjectsSchema>;
|
||||||
|
|||||||
@@ -1581,6 +1581,19 @@ export const CERTIFICATES = {
|
|||||||
certificate: "The certificate body of the certificate.",
|
certificate: "The certificate body of the certificate.",
|
||||||
certificateChain: "The certificate chain of the certificate.",
|
certificateChain: "The certificate chain of the certificate.",
|
||||||
serialNumberRes: "The serial number of the certificate."
|
serialNumberRes: "The serial number of the certificate."
|
||||||
|
},
|
||||||
|
IMPORT: {
|
||||||
|
projectSlug: "Slug of the project to import the certificate into.",
|
||||||
|
certificatePem: "The PEM-encoded leaf certificate.",
|
||||||
|
privateKeyPem: "The PEM-encoded private key corresponding to the certificate.",
|
||||||
|
chainPem: "The PEM-encoded chain of intermediate certificates.",
|
||||||
|
friendlyName: "A friendly name for the certificate.",
|
||||||
|
pkiCollectionId: "The ID of the PKI collection to add the certificate to.",
|
||||||
|
|
||||||
|
certificate: "The issued certificate.",
|
||||||
|
certificateChain: "The certificate chain of the issued certificate.",
|
||||||
|
privateKey: "The private key of the issued certificate.",
|
||||||
|
serialNumber: "The serial number of the issued certificate."
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -177,6 +177,79 @@ export const registerCertRouter = async (server: FastifyZodProvider) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
server.route({
|
||||||
|
method: "POST",
|
||||||
|
url: "/import-certificate",
|
||||||
|
config: {
|
||||||
|
rateLimit: writeLimit
|
||||||
|
},
|
||||||
|
onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]),
|
||||||
|
schema: {
|
||||||
|
hide: false,
|
||||||
|
tags: [ApiDocsTags.PkiCertificates],
|
||||||
|
description: "Import certificate",
|
||||||
|
body: z.object({
|
||||||
|
projectSlug: z.string().trim().min(1).describe(CERTIFICATES.IMPORT.projectSlug),
|
||||||
|
|
||||||
|
certificatePem: z.string().trim().min(1).describe(CERTIFICATES.IMPORT.certificatePem),
|
||||||
|
privateKeyPem: z.string().trim().optional().describe(CERTIFICATES.IMPORT.privateKeyPem),
|
||||||
|
chainPem: z.string().trim().optional().describe(CERTIFICATES.IMPORT.chainPem),
|
||||||
|
|
||||||
|
friendlyName: z.string().trim().optional().describe(CERTIFICATES.IMPORT.friendlyName),
|
||||||
|
pkiCollectionId: z.string().trim().optional().describe(CERTIFICATES.IMPORT.pkiCollectionId)
|
||||||
|
}),
|
||||||
|
response: {
|
||||||
|
200: z.object({
|
||||||
|
certificate: z.string().trim().describe(CERTIFICATES.IMPORT.certificate),
|
||||||
|
certificateChain: z.string().trim().optional().describe(CERTIFICATES.IMPORT.certificateChain),
|
||||||
|
privateKey: z.string().trim().optional().describe(CERTIFICATES.IMPORT.privateKey),
|
||||||
|
serialNumber: z.string().trim().describe(CERTIFICATES.IMPORT.serialNumber)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
},
|
||||||
|
handler: async (req) => {
|
||||||
|
const { certificate, certificateChain, privateKey, serialNumber } = await server.services.certificate.importCert({
|
||||||
|
actor: req.permission.type,
|
||||||
|
actorId: req.permission.id,
|
||||||
|
actorAuthMethod: req.permission.authMethod,
|
||||||
|
actorOrgId: req.permission.orgId,
|
||||||
|
...req.body
|
||||||
|
});
|
||||||
|
|
||||||
|
// TODO(andrey): Add logs
|
||||||
|
// await server.services.auditLog.createAuditLog({
|
||||||
|
// ...req.auditLogInfo,
|
||||||
|
// projectId: ca.projectId,
|
||||||
|
// event: {
|
||||||
|
// type: EventType.ISSUE_CERT,
|
||||||
|
// metadata: {
|
||||||
|
// caId: ca.id,
|
||||||
|
// dn: ca.dn,
|
||||||
|
// serialNumber
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
// });
|
||||||
|
|
||||||
|
// await server.services.telemetry.sendPostHogEvents({
|
||||||
|
// event: PostHogEventTypes.IssueCert,
|
||||||
|
// distinctId: getTelemetryDistinctId(req),
|
||||||
|
// properties: {
|
||||||
|
// caId: req.body.caId,
|
||||||
|
// certificateTemplateId: req.body.certificateTemplateId,
|
||||||
|
// commonName: req.body.commonName,
|
||||||
|
// ...req.auditLogInfo
|
||||||
|
// }
|
||||||
|
// });
|
||||||
|
|
||||||
|
return {
|
||||||
|
certificate,
|
||||||
|
certificateChain,
|
||||||
|
privateKey,
|
||||||
|
serialNumber
|
||||||
|
};
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
server.route({
|
server.route({
|
||||||
method: "POST",
|
method: "POST",
|
||||||
url: "/sign-certificate",
|
url: "/sign-certificate",
|
||||||
|
|||||||
@@ -1,31 +1,47 @@
|
|||||||
import { ForbiddenError } from "@casl/ability";
|
import { ForbiddenError } from "@casl/ability";
|
||||||
import * as x509 from "@peculiar/x509";
|
import * as x509 from "@peculiar/x509";
|
||||||
|
import { createPrivateKey, createPublicKey, sign, verify } from "crypto";
|
||||||
|
|
||||||
import { ActionProjectType } from "@app/db/schemas";
|
import { ActionProjectType, ProjectType } from "@app/db/schemas";
|
||||||
import { TCertificateAuthorityCrlDALFactory } from "@app/ee/services/certificate-authority-crl/certificate-authority-crl-dal";
|
import { TCertificateAuthorityCrlDALFactory } from "@app/ee/services/certificate-authority-crl/certificate-authority-crl-dal";
|
||||||
import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service";
|
import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service";
|
||||||
import { ProjectPermissionActions, ProjectPermissionSub } from "@app/ee/services/permission/project-permission";
|
import { ProjectPermissionActions, ProjectPermissionSub } from "@app/ee/services/permission/project-permission";
|
||||||
|
import { BadRequestError, NotFoundError } from "@app/lib/errors";
|
||||||
import { TCertificateBodyDALFactory } from "@app/services/certificate/certificate-body-dal";
|
import { TCertificateBodyDALFactory } from "@app/services/certificate/certificate-body-dal";
|
||||||
import { TCertificateDALFactory } from "@app/services/certificate/certificate-dal";
|
import { TCertificateDALFactory } from "@app/services/certificate/certificate-dal";
|
||||||
import { TCertificateAuthorityCertDALFactory } from "@app/services/certificate-authority/certificate-authority-cert-dal";
|
import { TCertificateAuthorityCertDALFactory } from "@app/services/certificate-authority/certificate-authority-cert-dal";
|
||||||
import { TCertificateAuthorityDALFactory } from "@app/services/certificate-authority/certificate-authority-dal";
|
import { TCertificateAuthorityDALFactory } from "@app/services/certificate-authority/certificate-authority-dal";
|
||||||
import { TCertificateAuthoritySecretDALFactory } from "@app/services/certificate-authority/certificate-authority-secret-dal";
|
import { TCertificateAuthoritySecretDALFactory } from "@app/services/certificate-authority/certificate-authority-secret-dal";
|
||||||
import { TKmsServiceFactory } from "@app/services/kms/kms-service";
|
import { TKmsServiceFactory } from "@app/services/kms/kms-service";
|
||||||
|
import { TPkiCollectionDALFactory } from "@app/services/pki-collection/pki-collection-dal";
|
||||||
|
import { TPkiCollectionItemDALFactory } from "@app/services/pki-collection/pki-collection-item-dal";
|
||||||
import { TProjectDALFactory } from "@app/services/project/project-dal";
|
import { TProjectDALFactory } from "@app/services/project/project-dal";
|
||||||
import { getProjectKmsCertificateKeyId } from "@app/services/project/project-fns";
|
import { getProjectKmsCertificateKeyId } from "@app/services/project/project-fns";
|
||||||
|
|
||||||
import { getCaCertChain, rebuildCaCrl } from "../certificate-authority/certificate-authority-fns";
|
import { getCaCertChain, rebuildCaCrl } from "../certificate-authority/certificate-authority-fns";
|
||||||
import { revocationReasonToCrlCode } from "./certificate-fns";
|
import { revocationReasonToCrlCode } from "./certificate-fns";
|
||||||
import { CertStatus, TDeleteCertDTO, TGetCertBodyDTO, TGetCertDTO, TRevokeCertDTO } from "./certificate-types";
|
import {
|
||||||
|
CertStatus,
|
||||||
|
TDeleteCertDTO,
|
||||||
|
TGetCertBodyDTO,
|
||||||
|
TGetCertDTO,
|
||||||
|
TImportCertDTO,
|
||||||
|
TRevokeCertDTO
|
||||||
|
} from "./certificate-types";
|
||||||
|
|
||||||
type TCertificateServiceFactoryDep = {
|
type TCertificateServiceFactoryDep = {
|
||||||
certificateDAL: Pick<TCertificateDALFactory, "findOne" | "deleteById" | "update" | "find">;
|
certificateDAL: Pick<TCertificateDALFactory, "findOne" | "deleteById" | "update" | "find" | "transaction" | "create">;
|
||||||
certificateBodyDAL: Pick<TCertificateBodyDALFactory, "findOne">;
|
certificateBodyDAL: Pick<TCertificateBodyDALFactory, "findOne" | "create">;
|
||||||
certificateAuthorityDAL: Pick<TCertificateAuthorityDALFactory, "findById">;
|
certificateAuthorityDAL: Pick<TCertificateAuthorityDALFactory, "findById">;
|
||||||
certificateAuthorityCertDAL: Pick<TCertificateAuthorityCertDALFactory, "findById">;
|
certificateAuthorityCertDAL: Pick<TCertificateAuthorityCertDALFactory, "findById">;
|
||||||
certificateAuthorityCrlDAL: Pick<TCertificateAuthorityCrlDALFactory, "update">;
|
certificateAuthorityCrlDAL: Pick<TCertificateAuthorityCrlDALFactory, "update">;
|
||||||
certificateAuthoritySecretDAL: Pick<TCertificateAuthoritySecretDALFactory, "findOne">;
|
certificateAuthoritySecretDAL: Pick<TCertificateAuthoritySecretDALFactory, "findOne">;
|
||||||
projectDAL: Pick<TProjectDALFactory, "findOne" | "updateById" | "findById" | "transaction">;
|
pkiCollectionDAL: Pick<TPkiCollectionDALFactory, "findById">;
|
||||||
|
pkiCollectionItemDAL: Pick<TPkiCollectionItemDALFactory, "create">;
|
||||||
|
projectDAL: Pick<
|
||||||
|
TProjectDALFactory,
|
||||||
|
"findProjectBySlug" | "findOne" | "updateById" | "findById" | "transaction" | "getProjectFromSplitId"
|
||||||
|
>;
|
||||||
kmsService: Pick<TKmsServiceFactory, "generateKmsKey" | "encryptWithKmsKey" | "decryptWithKmsKey">;
|
kmsService: Pick<TKmsServiceFactory, "generateKmsKey" | "encryptWithKmsKey" | "decryptWithKmsKey">;
|
||||||
permissionService: Pick<TPermissionServiceFactory, "getProjectPermission">;
|
permissionService: Pick<TPermissionServiceFactory, "getProjectPermission">;
|
||||||
};
|
};
|
||||||
@@ -39,6 +55,8 @@ export const certificateServiceFactory = ({
|
|||||||
certificateAuthorityCertDAL,
|
certificateAuthorityCertDAL,
|
||||||
certificateAuthorityCrlDAL,
|
certificateAuthorityCrlDAL,
|
||||||
certificateAuthoritySecretDAL,
|
certificateAuthoritySecretDAL,
|
||||||
|
pkiCollectionDAL,
|
||||||
|
pkiCollectionItemDAL,
|
||||||
projectDAL,
|
projectDAL,
|
||||||
kmsService,
|
kmsService,
|
||||||
permissionService
|
permissionService
|
||||||
@@ -48,7 +66,12 @@ export const certificateServiceFactory = ({
|
|||||||
*/
|
*/
|
||||||
const getCert = async ({ serialNumber, actorId, actorAuthMethod, actor, actorOrgId }: TGetCertDTO) => {
|
const getCert = async ({ serialNumber, actorId, actorAuthMethod, actor, actorOrgId }: TGetCertDTO) => {
|
||||||
const cert = await certificateDAL.findOne({ serialNumber });
|
const cert = await certificateDAL.findOne({ serialNumber });
|
||||||
const ca = await certificateAuthorityDAL.findById(cert.caId);
|
|
||||||
|
let ca;
|
||||||
|
|
||||||
|
if (cert.caId) {
|
||||||
|
ca = await certificateAuthorityDAL.findById(cert.caId);
|
||||||
|
}
|
||||||
|
|
||||||
const { permission } = await permissionService.getProjectPermission({
|
const { permission } = await permissionService.getProjectPermission({
|
||||||
actor,
|
actor,
|
||||||
@@ -201,10 +224,171 @@ export const certificateServiceFactory = ({
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Import certificate
|
||||||
|
*/
|
||||||
|
const importCert = async ({
|
||||||
|
projectSlug,
|
||||||
|
pkiCollectionId,
|
||||||
|
actorId,
|
||||||
|
actorAuthMethod,
|
||||||
|
actor,
|
||||||
|
actorOrgId,
|
||||||
|
friendlyName,
|
||||||
|
certificatePem,
|
||||||
|
chainPem,
|
||||||
|
privateKeyPem
|
||||||
|
}: TImportCertDTO) => {
|
||||||
|
const collectionId = pkiCollectionId;
|
||||||
|
|
||||||
|
const project = await projectDAL.findProjectBySlug(projectSlug, actorOrgId);
|
||||||
|
if (!project) throw new NotFoundError({ message: `Project with slug '${projectSlug}' not found` });
|
||||||
|
let projectId = project.id;
|
||||||
|
|
||||||
|
const certManagerProjectFromSplit = await projectDAL.getProjectFromSplitId(
|
||||||
|
projectId,
|
||||||
|
ProjectType.CertificateManager
|
||||||
|
);
|
||||||
|
if (certManagerProjectFromSplit) {
|
||||||
|
projectId = certManagerProjectFromSplit.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { permission } = await permissionService.getProjectPermission({
|
||||||
|
actor,
|
||||||
|
actorId,
|
||||||
|
projectId,
|
||||||
|
actorAuthMethod,
|
||||||
|
actorOrgId,
|
||||||
|
actionProjectType: ActionProjectType.CertificateManager
|
||||||
|
});
|
||||||
|
|
||||||
|
ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Create, ProjectPermissionSub.Certificates);
|
||||||
|
|
||||||
|
// Check PKI collection
|
||||||
|
if (collectionId) {
|
||||||
|
const pkiCollection = await pkiCollectionDAL.findById(collectionId);
|
||||||
|
if (!pkiCollection) throw new NotFoundError({ message: "PKI collection not found" });
|
||||||
|
if (pkiCollection.projectId !== projectId) throw new BadRequestError({ message: "Invalid PKI collection" });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse the certificate
|
||||||
|
const certObj = new x509.X509Certificate(certificatePem);
|
||||||
|
|
||||||
|
// Verify the certificate chain
|
||||||
|
if (chainPem) {
|
||||||
|
const chainCert = new x509.X509Certificate(chainPem);
|
||||||
|
if (!(await certObj.verify({ publicKey: chainCert.publicKey }))) {
|
||||||
|
throw new BadRequestError({ message: "Certificate chain verification failed" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If private key provided, verify it matches the certificate
|
||||||
|
if (privateKeyPem) {
|
||||||
|
try {
|
||||||
|
const message = Buffer.from("certificate-verification-test");
|
||||||
|
|
||||||
|
const privateKey = createPrivateKey(privateKeyPem);
|
||||||
|
const publicKey = createPublicKey(certificatePem);
|
||||||
|
|
||||||
|
const signature = sign(null, message, privateKey);
|
||||||
|
const isValid = verify(null, message, publicKey, signature);
|
||||||
|
|
||||||
|
if (!isValid) {
|
||||||
|
throw new BadRequestError({ message: "Private key does not match certificate" });
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
throw new BadRequestError({ message: "Invalid private key format" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get certificate attributes
|
||||||
|
const commonName = Array.from(certObj.subjectName.getField("CN")?.values() || [])[0] || "";
|
||||||
|
|
||||||
|
let altNames: undefined | string;
|
||||||
|
const sanExtension = certObj.extensions.find((ext) => ext.type === "2.5.29.17");
|
||||||
|
if (sanExtension) {
|
||||||
|
const sanNames = new x509.GeneralNames(sanExtension.value);
|
||||||
|
altNames = sanNames.items.map((name) => name.value).join(", ");
|
||||||
|
}
|
||||||
|
|
||||||
|
const { serialNumber, notBefore, notAfter } = certObj;
|
||||||
|
|
||||||
|
// Encrypt certificate for storage
|
||||||
|
const certificateManagerKeyId = await getProjectKmsCertificateKeyId({
|
||||||
|
projectId,
|
||||||
|
projectDAL,
|
||||||
|
kmsService
|
||||||
|
});
|
||||||
|
const kmsEncryptor = await kmsService.encryptWithKmsKey({
|
||||||
|
kmsId: certificateManagerKeyId
|
||||||
|
});
|
||||||
|
|
||||||
|
const { cipherTextBlob: encryptedCertificate } = await kmsEncryptor({
|
||||||
|
plainText: Buffer.from(certificatePem)
|
||||||
|
});
|
||||||
|
|
||||||
|
let encryptedCertificateChain: undefined | Buffer;
|
||||||
|
if (chainPem) {
|
||||||
|
const { cipherTextBlob } = await kmsEncryptor({
|
||||||
|
plainText: Buffer.from(chainPem)
|
||||||
|
});
|
||||||
|
encryptedCertificateChain = cipherTextBlob;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(friendlyName, commonName, altNames, serialNumber, notBefore, notAfter);
|
||||||
|
|
||||||
|
// Store in database
|
||||||
|
await certificateDAL.transaction(async (tx) => {
|
||||||
|
const cert = await certificateDAL.create(
|
||||||
|
{
|
||||||
|
status: CertStatus.ACTIVE,
|
||||||
|
friendlyName: friendlyName || commonName,
|
||||||
|
commonName,
|
||||||
|
altNames,
|
||||||
|
serialNumber,
|
||||||
|
notBefore,
|
||||||
|
notAfter
|
||||||
|
// keyUsages,
|
||||||
|
// extendedKeyUsages
|
||||||
|
},
|
||||||
|
tx
|
||||||
|
);
|
||||||
|
|
||||||
|
await certificateBodyDAL.create(
|
||||||
|
{
|
||||||
|
certId: cert.id,
|
||||||
|
encryptedCertificate,
|
||||||
|
encryptedCertificateChain
|
||||||
|
},
|
||||||
|
tx
|
||||||
|
);
|
||||||
|
|
||||||
|
if (collectionId) {
|
||||||
|
await pkiCollectionItemDAL.create(
|
||||||
|
{
|
||||||
|
pkiCollectionId: collectionId,
|
||||||
|
certId: cert.id
|
||||||
|
},
|
||||||
|
tx
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return cert;
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
certificate: certificatePem,
|
||||||
|
certificateChain: chainPem,
|
||||||
|
privateKey: privateKeyPem,
|
||||||
|
serialNumber
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
getCert,
|
getCert,
|
||||||
deleteCert,
|
deleteCert,
|
||||||
revokeCert,
|
revokeCert,
|
||||||
getCertBody
|
getCertBody,
|
||||||
|
importCert
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -73,3 +73,14 @@ export type TRevokeCertDTO = {
|
|||||||
export type TGetCertBodyDTO = {
|
export type TGetCertBodyDTO = {
|
||||||
serialNumber: string;
|
serialNumber: string;
|
||||||
} & Omit<TProjectPermission, "projectId">;
|
} & Omit<TProjectPermission, "projectId">;
|
||||||
|
|
||||||
|
export type TImportCertDTO = {
|
||||||
|
projectSlug: string;
|
||||||
|
|
||||||
|
friendlyName?: string;
|
||||||
|
pkiCollectionId?: string;
|
||||||
|
|
||||||
|
certificatePem: string;
|
||||||
|
privateKeyPem?: string;
|
||||||
|
chainPem?: string;
|
||||||
|
} & Omit<TProjectPermission, "projectId">;
|
||||||
|
|||||||
@@ -1,2 +1,2 @@
|
|||||||
export { useDeleteCert, useRevokeCert } from "./mutations";
|
export { useDeleteCert, useRevokeCert, useImportCertificate } from "./mutations";
|
||||||
export { useGetCert, useGetCertBody } from "./queries";
|
export { useGetCert, useGetCertBody } from "./queries";
|
||||||
|
|||||||
@@ -3,7 +3,13 @@ import { useMutation, useQueryClient } from "@tanstack/react-query";
|
|||||||
import { apiRequest } from "@app/config/request";
|
import { apiRequest } from "@app/config/request";
|
||||||
|
|
||||||
import { workspaceKeys } from "../workspace";
|
import { workspaceKeys } from "../workspace";
|
||||||
import { TCertificate, TDeleteCertDTO, TRevokeCertDTO } from "./types";
|
import {
|
||||||
|
TCertificate,
|
||||||
|
TDeleteCertDTO,
|
||||||
|
TImportCertificateDTO,
|
||||||
|
TImportCertificateResponse,
|
||||||
|
TRevokeCertDTO
|
||||||
|
} from "./types";
|
||||||
|
|
||||||
export const useDeleteCert = () => {
|
export const useDeleteCert = () => {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
@@ -45,3 +51,21 @@ export const useRevokeCert = () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const useImportCertificate = () => {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
return useMutation<TImportCertificateResponse, object, TImportCertificateDTO>({
|
||||||
|
mutationFn: async (body) => {
|
||||||
|
const { data } = await apiRequest.post<TImportCertificateResponse>(
|
||||||
|
"/api/v1/pki/certificates/import-certificate",
|
||||||
|
body
|
||||||
|
);
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
onSuccess: (_, { projectSlug }) => {
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: workspaceKeys.forWorkspaceCertificates(projectSlug)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|||||||
@@ -25,3 +25,23 @@ export type TRevokeCertDTO = {
|
|||||||
serialNumber: string;
|
serialNumber: string;
|
||||||
revocationReason: string;
|
revocationReason: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type TImportCertificateDTO = {
|
||||||
|
projectSlug: string;
|
||||||
|
|
||||||
|
certificatePem: string;
|
||||||
|
privateKeyPem?: string;
|
||||||
|
chainPem?: string;
|
||||||
|
|
||||||
|
pkiCollectionId?: string;
|
||||||
|
friendlyName?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
// TODO(andrey): Change this
|
||||||
|
export type TImportCertificateResponse = {
|
||||||
|
certificate: string;
|
||||||
|
issuingCertificate: string;
|
||||||
|
certificateChain: string;
|
||||||
|
privateKey: string;
|
||||||
|
serialNumber: string;
|
||||||
|
};
|
||||||
|
|||||||
+223
@@ -0,0 +1,223 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { Controller, useForm } from "react-hook-form";
|
||||||
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
import { createNotification } from "@app/components/notifications";
|
||||||
|
import {
|
||||||
|
Button,
|
||||||
|
FormControl,
|
||||||
|
Input,
|
||||||
|
Modal,
|
||||||
|
ModalContent,
|
||||||
|
Select,
|
||||||
|
SelectItem
|
||||||
|
} from "@app/components/v2";
|
||||||
|
import { useWorkspace } from "@app/context";
|
||||||
|
import { useImportCertificate, useGetCert, useListWorkspacePkiCollections } from "@app/hooks/api";
|
||||||
|
import { UsePopUpState } from "@app/hooks/usePopUp";
|
||||||
|
|
||||||
|
import { CertificateContent } from "./CertificateContent";
|
||||||
|
|
||||||
|
const schema = z.object({
|
||||||
|
certificatePem: z.string().trim().min(1, "Certificate PEM is required"),
|
||||||
|
privateKeyPem: z.string().trim().optional(),
|
||||||
|
chainPem: z.string().trim().optional(),
|
||||||
|
|
||||||
|
friendlyName: z.string(),
|
||||||
|
collectionId: z.string().optional()
|
||||||
|
|
||||||
|
// Can be added as override fields in the future | Also edit /frontend/src/hooks/api/ca/types.ts
|
||||||
|
// commonName: z.string().trim().min(1),
|
||||||
|
// altNames: z.string(),
|
||||||
|
|
||||||
|
// Can be added as override fields in the future | Also edit /frontend/src/hooks/api/ca/types.ts
|
||||||
|
// keyUsages: z.object({
|
||||||
|
// [CertKeyUsage.DIGITAL_SIGNATURE]: z.boolean().optional(),
|
||||||
|
// [CertKeyUsage.KEY_ENCIPHERMENT]: z.boolean().optional(),
|
||||||
|
// [CertKeyUsage.NON_REPUDIATION]: z.boolean().optional(),
|
||||||
|
// [CertKeyUsage.DATA_ENCIPHERMENT]: z.boolean().optional(),
|
||||||
|
// [CertKeyUsage.KEY_AGREEMENT]: z.boolean().optional(),
|
||||||
|
// [CertKeyUsage.KEY_CERT_SIGN]: z.boolean().optional(),
|
||||||
|
// [CertKeyUsage.CRL_SIGN]: z.boolean().optional(),
|
||||||
|
// [CertKeyUsage.ENCIPHER_ONLY]: z.boolean().optional(),
|
||||||
|
// [CertKeyUsage.DECIPHER_ONLY]: z.boolean().optional()
|
||||||
|
// }),
|
||||||
|
// extendedKeyUsages: z.object({
|
||||||
|
// [CertExtendedKeyUsage.CLIENT_AUTH]: z.boolean().optional(),
|
||||||
|
// [CertExtendedKeyUsage.CODE_SIGNING]: z.boolean().optional(),
|
||||||
|
// [CertExtendedKeyUsage.EMAIL_PROTECTION]: z.boolean().optional(),
|
||||||
|
// [CertExtendedKeyUsage.OCSP_SIGNING]: z.boolean().optional(),
|
||||||
|
// [CertExtendedKeyUsage.SERVER_AUTH]: z.boolean().optional(),
|
||||||
|
// [CertExtendedKeyUsage.TIMESTAMPING]: z.boolean().optional()
|
||||||
|
// })
|
||||||
|
});
|
||||||
|
|
||||||
|
export type FormData = z.infer<typeof schema>;
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
popUp: UsePopUpState<["certificateImport"]>;
|
||||||
|
handlePopUpToggle: (
|
||||||
|
popUpName: keyof UsePopUpState<["certificateImport"]>,
|
||||||
|
state?: boolean
|
||||||
|
) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
type TCertificateDetails = {
|
||||||
|
serialNumber: string;
|
||||||
|
certificate: string;
|
||||||
|
certificateChain: string;
|
||||||
|
privateKey: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const CertificateImportModal = ({ popUp, handlePopUpToggle }: Props) => {
|
||||||
|
const [certificateDetails, setCertificateDetails] = useState<TCertificateDetails | null>(null);
|
||||||
|
const { currentWorkspace } = useWorkspace();
|
||||||
|
const { data: cert } = useGetCert(
|
||||||
|
(popUp?.certificateImport?.data as { serialNumber: string })?.serialNumber || ""
|
||||||
|
);
|
||||||
|
|
||||||
|
const { data } = useListWorkspacePkiCollections({
|
||||||
|
workspaceId: currentWorkspace?.id || ""
|
||||||
|
});
|
||||||
|
|
||||||
|
const { mutateAsync: importCertificate } = useImportCertificate();
|
||||||
|
|
||||||
|
const {
|
||||||
|
control,
|
||||||
|
handleSubmit,
|
||||||
|
reset,
|
||||||
|
formState: { isSubmitting }
|
||||||
|
} = useForm<FormData>({
|
||||||
|
resolver: zodResolver(schema)
|
||||||
|
});
|
||||||
|
|
||||||
|
const onFormSubmit = async ({
|
||||||
|
certificatePem,
|
||||||
|
privateKeyPem,
|
||||||
|
chainPem,
|
||||||
|
friendlyName,
|
||||||
|
collectionId
|
||||||
|
}: FormData) => {
|
||||||
|
try {
|
||||||
|
if (!currentWorkspace?.slug) return;
|
||||||
|
|
||||||
|
const { serialNumber, certificate, certificateChain, privateKey } = await importCertificate({
|
||||||
|
projectSlug: currentWorkspace.slug,
|
||||||
|
projectSlug: currentWorkspace.slug,
|
||||||
|
|
||||||
|
certificatePem,
|
||||||
|
privateKeyPem,
|
||||||
|
chainPem,
|
||||||
|
|
||||||
|
friendlyName,
|
||||||
|
pkiCollectionId: collectionId
|
||||||
|
});
|
||||||
|
|
||||||
|
reset();
|
||||||
|
|
||||||
|
setCertificateDetails({
|
||||||
|
serialNumber,
|
||||||
|
certificate,
|
||||||
|
certificateChain,
|
||||||
|
privateKey
|
||||||
|
});
|
||||||
|
|
||||||
|
createNotification({
|
||||||
|
text: "Successfully imported certificate",
|
||||||
|
type: "success"
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
createNotification({
|
||||||
|
text: "Failed to import certificate",
|
||||||
|
type: "error"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
isOpen={popUp?.certificateImport?.isOpen}
|
||||||
|
onOpenChange={(isOpen) => {
|
||||||
|
handlePopUpToggle("certificateImport", isOpen);
|
||||||
|
reset();
|
||||||
|
setCertificateDetails(null);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ModalContent title={`${cert ? "View" : "Issue"} Certificate`}>
|
||||||
|
{!certificateDetails ? (
|
||||||
|
<form onSubmit={handleSubmit(onFormSubmit)}>
|
||||||
|
<Controller
|
||||||
|
control={control}
|
||||||
|
name="collectionId"
|
||||||
|
render={({ field: { onChange, ...field }, fieldState: { error } }) => (
|
||||||
|
<FormControl
|
||||||
|
label="Certificate Collection (Optional)"
|
||||||
|
errorText={error?.message}
|
||||||
|
isError={Boolean(error)}
|
||||||
|
className="mt-4"
|
||||||
|
>
|
||||||
|
<Select
|
||||||
|
defaultValue={field.value}
|
||||||
|
{...field}
|
||||||
|
onValueChange={(e) => onChange(e)}
|
||||||
|
className="w-full"
|
||||||
|
isDisabled={Boolean(cert)}
|
||||||
|
>
|
||||||
|
{(data?.collections || []).map(({ id, name }) => (
|
||||||
|
<SelectItem value={id} key={`pki-collection-${id}`}>
|
||||||
|
{name}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
</FormControl>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<Controller
|
||||||
|
control={control}
|
||||||
|
defaultValue=""
|
||||||
|
name="friendlyName"
|
||||||
|
render={({ field, fieldState: { error } }) => (
|
||||||
|
<FormControl
|
||||||
|
label="Friendly Name"
|
||||||
|
isError={Boolean(error)}
|
||||||
|
errorText={error?.message}
|
||||||
|
>
|
||||||
|
<Input {...field} placeholder="My Certificate" isDisabled={Boolean(cert)} />
|
||||||
|
</FormControl>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
{!cert && (
|
||||||
|
<div className="mt-4 flex items-center">
|
||||||
|
<Button
|
||||||
|
className="mr-4"
|
||||||
|
size="sm"
|
||||||
|
type="submit"
|
||||||
|
isLoading={isSubmitting}
|
||||||
|
isDisabled={isSubmitting}
|
||||||
|
>
|
||||||
|
Create
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
colorSchema="secondary"
|
||||||
|
variant="plain"
|
||||||
|
onClick={() => handlePopUpToggle("certificateImport", false)}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</form>
|
||||||
|
) : (
|
||||||
|
<CertificateContent
|
||||||
|
serialNumber={certificateDetails.serialNumber}
|
||||||
|
certificate={certificateDetails.certificate}
|
||||||
|
certificateChain={certificateDetails.certificateChain}
|
||||||
|
privateKey={certificateDetails.privateKey}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</ModalContent>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
};
|
||||||
+23
-10
@@ -1,4 +1,4 @@
|
|||||||
import { faPlus } from "@fortawesome/free-solid-svg-icons";
|
import { faArrowRight, faPlus } from "@fortawesome/free-solid-svg-icons";
|
||||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||||
|
|
||||||
import { createNotification } from "@app/components/notifications";
|
import { createNotification } from "@app/components/notifications";
|
||||||
@@ -12,6 +12,7 @@ import { CertificateCertModal } from "./CertificateCertModal";
|
|||||||
import { CertificateModal } from "./CertificateModal";
|
import { CertificateModal } from "./CertificateModal";
|
||||||
import { CertificateRevocationModal } from "./CertificateRevocationModal";
|
import { CertificateRevocationModal } from "./CertificateRevocationModal";
|
||||||
import { CertificatesTable } from "./CertificatesTable";
|
import { CertificatesTable } from "./CertificatesTable";
|
||||||
|
import { CertificateImportModal } from "./CertificateImportModal";
|
||||||
|
|
||||||
export const CertificatesSection = () => {
|
export const CertificatesSection = () => {
|
||||||
const { currentWorkspace } = useWorkspace();
|
const { currentWorkspace } = useWorkspace();
|
||||||
@@ -19,6 +20,7 @@ export const CertificatesSection = () => {
|
|||||||
|
|
||||||
const { popUp, handlePopUpOpen, handlePopUpClose, handlePopUpToggle } = usePopUp([
|
const { popUp, handlePopUpOpen, handlePopUpClose, handlePopUpToggle } = usePopUp([
|
||||||
"certificate",
|
"certificate",
|
||||||
|
"certificateImport",
|
||||||
"certificateCert",
|
"certificateCert",
|
||||||
"deleteCertificate",
|
"deleteCertificate",
|
||||||
"revokeCertificate"
|
"revokeCertificate"
|
||||||
@@ -54,20 +56,31 @@ export const CertificatesSection = () => {
|
|||||||
a={ProjectPermissionSub.Certificates}
|
a={ProjectPermissionSub.Certificates}
|
||||||
>
|
>
|
||||||
{(isAllowed) => (
|
{(isAllowed) => (
|
||||||
<Button
|
<div className="flex gap-2">
|
||||||
colorSchema="primary"
|
<Button
|
||||||
type="submit"
|
variant="outline_bg"
|
||||||
leftIcon={<FontAwesomeIcon icon={faPlus} />}
|
leftIcon={<FontAwesomeIcon icon={faArrowRight} />}
|
||||||
onClick={() => handlePopUpOpen("certificate")}
|
onClick={() => handlePopUpOpen("certificateImport")}
|
||||||
isDisabled={!isAllowed}
|
isDisabled={!isAllowed}
|
||||||
>
|
>
|
||||||
Issue
|
Import
|
||||||
</Button>
|
</Button>
|
||||||
|
<Button
|
||||||
|
colorSchema="primary"
|
||||||
|
type="submit"
|
||||||
|
leftIcon={<FontAwesomeIcon icon={faPlus} />}
|
||||||
|
onClick={() => handlePopUpOpen("certificate")}
|
||||||
|
isDisabled={!isAllowed}
|
||||||
|
>
|
||||||
|
Issue
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</ProjectPermissionCan>
|
</ProjectPermissionCan>
|
||||||
</div>
|
</div>
|
||||||
<CertificatesTable handlePopUpOpen={handlePopUpOpen} />
|
<CertificatesTable handlePopUpOpen={handlePopUpOpen} />
|
||||||
<CertificateModal popUp={popUp} handlePopUpToggle={handlePopUpToggle} />
|
<CertificateModal popUp={popUp} handlePopUpToggle={handlePopUpToggle} />
|
||||||
|
<CertificateImportModal popUp={popUp} handlePopUpToggle={handlePopUpToggle} />
|
||||||
<CertificateCertModal popUp={popUp} handlePopUpToggle={handlePopUpToggle} />
|
<CertificateCertModal popUp={popUp} handlePopUpToggle={handlePopUpToggle} />
|
||||||
<CertificateRevocationModal popUp={popUp} handlePopUpToggle={handlePopUpToggle} />
|
<CertificateRevocationModal popUp={popUp} handlePopUpToggle={handlePopUpToggle} />
|
||||||
<DeleteActionModal
|
<DeleteActionModal
|
||||||
|
|||||||
Reference in New Issue
Block a user