Merge branch 'main' into feature/ldap-gateway-support

This commit is contained in:
Victor Santos
2025-11-12 10:07:30 -03:00
313 changed files with 14727 additions and 1448 deletions
+6
View File
@@ -1,4 +1,5 @@
import { registerProjectTemplateRouter } from "@app/ee/routes/v1/project-template-router";
import { getConfig } from "@app/lib/config/env";
import { registerAccessApprovalPolicyRouter } from "./access-approval-policy-router";
import { registerAccessApprovalRequestRouter } from "./access-approval-request-router";
@@ -30,6 +31,7 @@ import { PAM_RESOURCE_REGISTER_ROUTER_MAP } from "./pam-resource-routers";
import { registerPamResourceRouter } from "./pam-resource-routers/pam-resource-router";
import { registerPamSessionRouter } from "./pam-session-router";
import { registerPITRouter } from "./pit-router";
import { registerPkiAcmeRouter } from "./pki-acme-router";
import { registerProjectRoleRouter } from "./project-role-router";
import { registerProjectRouter } from "./project-router";
import { registerRateLimitRouter } from "./rate-limit-router";
@@ -107,6 +109,10 @@ export const registerV1EERoutes = async (server: FastifyZodProvider) => {
await server.register(
async (pkiRouter) => {
await pkiRouter.register(registerCaCrlRouter, { prefix: "/crl" });
// Notice: current this feature is still in development and is not yet ready for production.
if (getConfig().isAcmeFeatureEnabled === true) {
await pkiRouter.register(registerPkiAcmeRouter, { prefix: "/acme" });
}
},
{ prefix: "/pki" }
);
@@ -92,7 +92,8 @@ export const registerPamAccountRouter = async (server: FastifyZodProvider) => {
gatewayClientCertificate: z.string(),
gatewayClientPrivateKey: z.string(),
gatewayServerCertificateChain: z.string(),
relayHost: z.string()
relayHost: z.string(),
metadata: z.record(z.string(), z.string()).optional()
})
}
},
+461
View File
@@ -0,0 +1,461 @@
/* eslint-disable @typescript-eslint/no-floating-promises */
import { FastifyReply, FastifyRequest } from "fastify";
import { z } from "zod";
import { AcmeMalformedError } from "@app/ee/services/pki-acme/pki-acme-errors";
import {
AcmeOrderResourceSchema,
CreateAcmeAccountResponseSchema,
CreateAcmeOrderBodySchema,
DeactivateAcmeAccountBodySchema,
DeactivateAcmeAccountResponseSchema,
FinalizeAcmeOrderBodySchema,
GetAcmeAuthorizationResponseSchema,
GetAcmeDirectoryResponseSchema,
ListAcmeOrdersPayloadSchema,
ListAcmeOrdersResponseSchema,
RawJwsPayloadSchema,
RespondToAcmeChallengeBodySchema,
RespondToAcmeChallengeResponseSchema
} from "@app/ee/services/pki-acme/pki-acme-schemas";
import type { TAcmeResponse, TAuthenciatedJwsPayload, TRawJwsPayload } from "@app/ee/services/pki-acme/pki-acme-types";
import { ApiDocsTags } from "@app/lib/api-docs";
import { readLimit, writeLimit } from "@app/server/config/rateLimiter";
const SharedParamsSchema = z.object({
profileId: z.string().uuid()
});
export interface MyRequestInterface {
Params: { profileId: string; accountId?: string };
Body: TRawJwsPayload;
}
export const registerPkiAcmeRouter = async (server: FastifyZodProvider) => {
const validateExistingAccount = async <
// eslint-disable-next-line @typescript-eslint/no-explicit-any
R extends FastifyRequest<any>,
TSchema extends z.ZodSchema<unknown> | undefined = undefined,
T = TSchema extends z.ZodSchema<infer U> ? U : string
>({
req,
schema
}: {
req: R;
schema?: TSchema;
}): Promise<TAuthenciatedJwsPayload<T>> => {
return server.services.pkiAcme.validateExistingAccountJwsPayload({
url: new URL(req.url, `${req.protocol}://${req.hostname}`),
profileId: (req.params as { profileId: string }).profileId,
rawJwsPayload: req.body as TRawJwsPayload,
schema,
expectedAccountId: (req.params as { accountId?: string }).accountId
});
};
const sendAcmeResponse = async <T>(res: FastifyReply, profileId: string, response: TAcmeResponse<T>): Promise<T> => {
res.code(response.status);
for (const [key, value] of Object.entries(response.headers)) {
res.header(key, value);
}
const nonce = await server.services.pkiAcme.getAcmeNewNonce(profileId);
res.header("Replay-Nonce", nonce);
res.header("Cache-Control", "no-store");
return response.body;
};
server.addContentTypeParser("application/jose+json", { parseAs: "string" }, (_, body, done) => {
try {
const strBody = body instanceof Buffer ? body.toString() : body;
if (!strBody) {
done(null, undefined);
}
const json: unknown = JSON.parse(strBody);
done(null, json);
} catch (err) {
const error = err as Error;
done(error, undefined);
}
});
// GET /api/v1/pki/acme/profiles/<profile_id>/directory
// Directory (RFC 8555 Section 7.1.1)
server.route({
method: "GET",
url: "/profiles/:profileId/directory",
config: {
rateLimit: readLimit
},
schema: {
hide: false,
tags: [ApiDocsTags.PkiAcme],
description: "ACME Directory - provides URLs for the client to make API calls to",
params: z.object({
profileId: z.string().uuid()
}),
response: {
200: GetAcmeDirectoryResponseSchema
}
},
handler: async (req) => server.services.pkiAcme.getAcmeDirectory(req.params.profileId)
});
// HEAD /api/v1/pki/acme/profiles/<profile_id>/new-nonce
// New Nonce (RFC 8555 Section 7.2)
server.route({
method: "HEAD",
url: "/profiles/:profileId/new-nonce",
config: {
// TODO: probably a different rate limit for nonce creation?
rateLimit: readLimit
},
schema: {
hide: false,
tags: [ApiDocsTags.PkiAcme],
description: "ACME New Nonce - generate a new nonce and return in Replay-Nonce header",
params: z.object({
profileId: z.string().uuid()
}),
response: {
200: z.string().length(0)
}
},
handler: async (req, res) => {
const nonce = await server.services.pkiAcme.getAcmeNewNonce(req.params.profileId);
res.header("Replay-Nonce", nonce);
return "";
}
});
// POST /api/v1/pki/acme/profiles/<profile_id>/new-account
// New Account (RFC 8555 Section 7.3)
server.route({
method: "POST",
url: "/profiles/:profileId/new-account",
config: {
rateLimit: writeLimit
},
schema: {
hide: false,
tags: [ApiDocsTags.PkiAcme],
description: "ACME New Account - register a new account or find existing one",
params: SharedParamsSchema,
body: RawJwsPayloadSchema,
response: {
201: CreateAcmeAccountResponseSchema
}
},
handler: async (req, res) => {
const { payload, protectedHeader } = await server.services.pkiAcme.validateNewAccountJwsPayload({
url: new URL(req.url, `${req.protocol}://${req.hostname}`),
rawJwsPayload: req.body
});
const { alg, jwk } = protectedHeader;
return sendAcmeResponse(
res,
req.params.profileId,
await server.services.pkiAcme.createAcmeAccount({
profileId: req.params.profileId,
alg,
jwk: jwk!,
payload
})
);
}
});
// POST /api/v1/pki/acme/profiles/<profile_id>/accounts/<account_id>
// Account Deactivation (RFC 8555 Section 7.3.6)
server.route({
method: "POST",
url: "/profiles/:profileId/accounts/:accountId",
config: {
rateLimit: writeLimit
},
schema: {
hide: false,
tags: [ApiDocsTags.PkiAcme],
description: "ACME Account Deactivation",
params: SharedParamsSchema.extend({
accountId: z.string()
}),
body: RawJwsPayloadSchema,
response: {
200: DeactivateAcmeAccountResponseSchema
}
},
handler: async (req, res) => {
const { payload, profileId, accountId } = await validateExistingAccount({
req,
schema: DeactivateAcmeAccountBodySchema
});
return sendAcmeResponse(
res,
profileId,
await server.services.pkiAcme.deactivateAcmeAccount({
profileId,
accountId,
payload
})
);
}
});
// POST /api/v1/pki/acme/profiles/<profile_id>/new-order
// New Certificate Order (RFC 8555 Section 7.4)
server.route({
method: "POST",
url: "/profiles/:profileId/new-order",
config: {
rateLimit: writeLimit
},
schema: {
hide: false,
tags: [ApiDocsTags.PkiAcme],
description: "ACME New Order - apply for a new certificate",
params: SharedParamsSchema,
body: RawJwsPayloadSchema,
response: {
201: AcmeOrderResourceSchema
}
},
handler: async (req, res) => {
const { profileId, accountId, payload } = await validateExistingAccount({
req,
schema: CreateAcmeOrderBodySchema
});
return sendAcmeResponse(
res,
profileId,
await server.services.pkiAcme.createAcmeOrder({
profileId,
accountId,
payload
})
);
}
});
// POST /api/v1/pki/acme/profiles/<profile_id>/orders/<order_id>
// Get Order (RFC 8555 Section 7.1.3)
server.route({
method: "POST",
url: "/profiles/:profileId/orders/:orderId",
config: {
rateLimit: readLimit
},
schema: {
hide: false,
tags: [ApiDocsTags.PkiAcme],
description: "ACME Get Order - return status and details of the order",
params: SharedParamsSchema.extend({
orderId: z.string().uuid()
}),
body: RawJwsPayloadSchema,
response: {
200: AcmeOrderResourceSchema
}
},
handler: async (req, res) => {
const { profileId, accountId, payload } = await validateExistingAccount({
req
});
if (payload !== "") {
throw new AcmeMalformedError({ detail: "Payload should be empty" });
}
return sendAcmeResponse(
res,
profileId,
await server.services.pkiAcme.getAcmeOrder({
profileId,
accountId,
orderId: req.params.orderId
})
);
}
});
// POST /api/v1/pki/acme/profiles/<profile_id>/orders/<order_id>/finalize
// Applying for Certificate Issuance (RFC 8555 Section 7.4)
server.route({
method: "POST",
url: "/profiles/:profileId/orders/:orderId/finalize",
config: {
rateLimit: writeLimit
},
schema: {
hide: false,
tags: [ApiDocsTags.PkiAcme],
description: "ACME Finalize Order - finalize cert order by providing CSR",
params: SharedParamsSchema.extend({
orderId: z.string().uuid()
}),
body: RawJwsPayloadSchema,
response: {
200: AcmeOrderResourceSchema
}
},
handler: async (req, res) => {
const { profileId, accountId, payload } = await validateExistingAccount({
req,
schema: FinalizeAcmeOrderBodySchema
});
return sendAcmeResponse(
res,
profileId,
await server.services.pkiAcme.finalizeAcmeOrder({
profileId,
accountId,
orderId: req.params.orderId,
payload
})
);
}
});
// POST /api/v1/pki/acme/profiles/<profile_id>/accounts/<account_id>/orders
// List Orders (RFC 8555 Section 7.1.2.1)
server.route({
method: "POST",
url: "/profiles/:profileId/accounts/:accountId/orders",
config: {
rateLimit: readLimit
},
schema: {
hide: false,
tags: [ApiDocsTags.PkiAcme],
description: "ACME List Orders - get existing orders from current account",
params: SharedParamsSchema.extend({
accountId: z.string()
}),
body: RawJwsPayloadSchema,
response: {
200: ListAcmeOrdersResponseSchema
}
},
handler: async (req, res) => {
const { profileId, accountId } = await validateExistingAccount({
req,
schema: ListAcmeOrdersPayloadSchema
});
return sendAcmeResponse(
res,
profileId,
await server.services.pkiAcme.listAcmeOrders({
profileId,
accountId
})
);
}
});
// POST /api/v1/pki/acme/profiles/<profile_id>/orders/<order_id>/certificate
// Download Certificate (RFC 8555 Section 7.4.2)
server.route({
method: "POST",
url: "/profiles/:profileId/orders/:orderId/certificate",
config: {
rateLimit: readLimit
},
schema: {
hide: false,
tags: [ApiDocsTags.PkiAcme],
description: "ACME Download Certificate - download certificate when ready",
params: SharedParamsSchema.extend({
orderId: z.string().uuid()
}),
body: RawJwsPayloadSchema,
response: {
200: z.string()
}
},
handler: async (req, res) => {
const { profileId, accountId, payload } = await validateExistingAccount({
req
});
if (payload !== "") {
throw new AcmeMalformedError({ detail: "Payload should be empty" });
}
res.type("application/pem-certificate-chain");
return sendAcmeResponse(
res,
profileId,
await server.services.pkiAcme.downloadAcmeCertificate({ profileId, accountId, orderId: req.params.orderId })
);
}
});
// POST /api/v1/pki/acme/profiles/<profile_id>/authorizations/<authz_id>
// Identifier Authorization (RFC 8555 Section 7.5)
server.route({
method: "POST",
url: "/profiles/:profileId/authorizations/:authzId",
config: {
rateLimit: readLimit
},
schema: {
hide: false,
tags: [ApiDocsTags.PkiAcme],
description: "ACME Identifier Authorization - get authorization info (challenges)",
params: SharedParamsSchema.extend({
authzId: z.string().uuid()
}),
body: RawJwsPayloadSchema,
response: {
200: GetAcmeAuthorizationResponseSchema
}
},
handler: async (req, res) => {
const { profileId, accountId, payload } = await validateExistingAccount({ req });
if (payload !== "") {
throw new AcmeMalformedError({ detail: "Payload should be empty" });
}
return sendAcmeResponse(
res,
profileId,
await server.services.pkiAcme.getAcmeAuthorization({
profileId,
accountId,
authzId: req.params.authzId
})
);
}
});
// POST /api/v1/pki/acme/profiles/<profile_id>/authorizations/<authz_id>/challenges/<challenge_id>
// Respond to Challenge (RFC 8555 Section 7.5.1)
server.route({
method: "POST",
url: "/profiles/:profileId/authorizations/:authzId/challenges/:challengeId",
config: {
rateLimit: writeLimit
},
schema: {
hide: false,
tags: [ApiDocsTags.PkiAcme],
description: "ACME Respond to Challenge - let ACME server know challenge is ready",
params: SharedParamsSchema.extend({
authzId: z.string().uuid(),
challengeId: z.string().uuid()
}),
response: {
200: RespondToAcmeChallengeResponseSchema
}
},
handler: async (req, res) => {
const { profileId, accountId } = await validateExistingAccount({
req,
schema: RespondToAcmeChallengeBodySchema
});
return sendAcmeResponse(
res,
profileId,
await server.services.pkiAcme.respondToAcmeChallenge({
profileId,
accountId,
authzId: req.params.authzId,
challengeId: req.params.challengeId
})
);
}
});
};
@@ -315,6 +315,7 @@ export const registerSecretApprovalRequestRouter = async (server: FastifyZodProv
.extend({
status: z.string(),
comment: z.string().optional(),
createdAt: z.date(),
isOrgMembershipActive: z.boolean().nullable().optional()
})
.array(),
@@ -1,14 +1,14 @@
import { ForbiddenError } from "@casl/ability";
import { AccessScope, ActionProjectType } from "@app/db/schemas";
import { ActionProjectType } from "@app/db/schemas";
import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service-types";
import { ProjectPermissionActions, ProjectPermissionSub } from "@app/ee/services/permission/project-permission";
import { BadRequestError, NotFoundError } from "@app/lib/errors";
import { groupBy } from "@app/lib/fn";
import { TAdditionalPrivilegeDALFactory } from "@app/services/additional-privilege/additional-privilege-dal";
import { TMembershipUserDALFactory } from "@app/services/membership-user/membership-user-dal";
import { TProjectDALFactory } from "@app/services/project/project-dal";
import { TProjectEnvDALFactory } from "@app/services/project-env/project-env-dal";
import { TProjectMembershipDALFactory } from "@app/services/project-membership/project-membership-dal";
import { TUserDALFactory } from "@app/services/user/user-dal";
import { TAccessApprovalRequestDALFactory } from "../access-approval-request/access-approval-request-dal";
@@ -38,13 +38,13 @@ type TAccessApprovalPolicyServiceFactoryDep = {
projectEnvDAL: Pick<TProjectEnvDALFactory, "find" | "findOne">;
accessApprovalPolicyApproverDAL: TAccessApprovalPolicyApproverDALFactory;
accessApprovalPolicyBypasserDAL: TAccessApprovalPolicyBypasserDALFactory;
projectMembershipDAL: Pick<TProjectMembershipDALFactory, "findProjectMembershipsByUserIds">;
groupDAL: TGroupDALFactory;
userDAL: Pick<TUserDALFactory, "find">;
accessApprovalRequestDAL: Pick<TAccessApprovalRequestDALFactory, "update" | "find" | "resetReviewByPolicyId">;
additionalPrivilegeDAL: Pick<TAdditionalPrivilegeDALFactory, "delete">;
accessApprovalRequestReviewerDAL: Pick<TAccessApprovalRequestReviewerDALFactory, "update" | "delete">;
accessApprovalPolicyEnvironmentDAL: TAccessApprovalPolicyEnvironmentDALFactory;
membershipUserDAL: TMembershipUserDALFactory;
};
export const accessApprovalPolicyServiceFactory = ({
@@ -60,7 +60,7 @@ export const accessApprovalPolicyServiceFactory = ({
accessApprovalRequestDAL,
additionalPrivilegeDAL,
accessApprovalRequestReviewerDAL,
membershipUserDAL
projectMembershipDAL
}: TAccessApprovalPolicyServiceFactoryDep): TAccessApprovalPolicyServiceFactory => {
const $policyExists = async ({
envId,
@@ -83,6 +83,21 @@ export const accessApprovalPolicyServiceFactory = ({
return policyId ? policy && policy.id !== policyId : Boolean(policy);
};
const verifyProjectUserMembership = async (userIds: string[], orgId: string, projectId: string) => {
if (userIds.length === 0) return;
const projectMemberships = (await projectMembershipDAL.findProjectMembershipsByUserIds(orgId, userIds)).filter(
(v) => v.projectId === projectId
);
if (projectMemberships.length !== userIds.length) {
const projectMemberUserIds = new Set(projectMemberships.map((member) => member.userId));
const userIdsNotInProject = userIds.filter((id) => !projectMemberUserIds.has(id));
throw new BadRequestError({
message: `Some users are not members of the project: ${userIdsNotInProject.join(", ")}`
});
}
};
const createAccessApprovalPolicy: TAccessApprovalPolicyServiceFactory["createAccessApprovalPolicy"] = async ({
name,
actor,
@@ -160,7 +175,7 @@ export const accessApprovalPolicyServiceFactory = ({
if (invalidUsernames.length) {
throw new BadRequestError({
message: `Invalid approver user: ${invalidUsernames.join(", ")}`
message: `Invalid approver user: ${invalidUsernames.map((i) => i.username).join(", ")}`
});
}
@@ -171,6 +186,14 @@ export const accessApprovalPolicyServiceFactory = ({
}))
);
}
if (approverUserIds.length > 0) {
await verifyProjectUserMembership(
approverUserIds.map((au) => au.id),
project.orgId,
project.id
);
}
let groupBypassers: string[] = [];
let bypasserUserIds: string[] = [];
@@ -257,6 +280,8 @@ export const accessApprovalPolicyServiceFactory = ({
}
if (bypasserUserIds.length) {
await verifyProjectUserMembership(bypasserUserIds, project.orgId, project.id);
await accessApprovalPolicyBypasserDAL.insertMany(
bypasserUserIds.map((userId) => ({
bypasserUserId: userId,
@@ -420,23 +445,6 @@ export const accessApprovalPolicyServiceFactory = ({
bypasserUserIds = [...new Set(bypasserUserIds.concat(bypasserUsers.map((user) => user.id)))];
}
// Validate user bypassers
if (bypasserUserIds.length > 0) {
const orgMemberships = await membershipUserDAL.find({
$in: { actorUserId: bypasserUserIds },
scopeOrgId: actorOrgId,
scope: AccessScope.Organization
});
if (orgMemberships.length !== bypasserUserIds.length) {
const foundUserIdsInOrg = new Set(orgMemberships.map((mem) => mem.actorUserId as string));
const missingUserIds = bypasserUserIds.filter((id) => !foundUserIdsInOrg.has(id));
throw new BadRequestError({
message: `One or more specified bypasser users are not part of the organization or do not exist. Invalid or non-member user IDs: ${missingUserIds.join(", ")}`
});
}
}
// Validate group bypassers
if (groupBypassers.length > 0) {
const orgGroups = await groupDAL.find({
@@ -487,7 +495,7 @@ export const accessApprovalPolicyServiceFactory = ({
if (invalidUsernames.length) {
throw new BadRequestError({
message: `Invalid approver user: ${invalidUsernames.join(", ")}`
message: `Invalid approver user: ${invalidUsernames.map((i) => i.username).join(", ")}`
});
}
@@ -498,6 +506,14 @@ export const accessApprovalPolicyServiceFactory = ({
}))
);
}
if (approverUserIds.length > 0) {
await verifyProjectUserMembership(
approverUserIds.map((au) => au.id),
actorOrgId,
accessApprovalPolicy.projectId
);
}
await accessApprovalPolicyApproverDAL.insertMany(
approverUserIds.map((el) => ({
approverUserId: el.id,
@@ -536,6 +552,8 @@ export const accessApprovalPolicyServiceFactory = ({
await accessApprovalPolicyBypasserDAL.delete({ policyId: doc.id }, tx);
if (bypasserUserIds.length) {
await verifyProjectUserMembership(bypasserUserIds, actorOrgId, accessApprovalPolicy.projectId);
await accessApprovalPolicyBypasserDAL.insertMany(
bypasserUserIds.map((userId) => ({
bypasserUserId: userId,
@@ -243,7 +243,8 @@ export const accessApprovalRequestServiceFactory = ({
);
const requesterFullName = `${requestedByUser.firstName} ${requestedByUser.lastName}`;
const approvalPath = `/projects/secret-management/${project.id}/approval`;
const projectPath = `/projects/secret-management/${project.id}`;
const approvalPath = `${projectPath}/approval`;
const approvalUrl = `${cfg.SITE_URL}${approvalPath}`;
await triggerWorkflowIntegrationNotification({
@@ -252,6 +253,7 @@ export const accessApprovalRequestServiceFactory = ({
type: TriggerFeature.ACCESS_REQUEST,
payload: {
projectName: project.name,
projectPath,
requesterFullName,
isTemporary,
requesterEmail: requestedByUser.email as string,
@@ -397,7 +399,8 @@ export const accessApprovalRequestServiceFactory = ({
const requesterFullName = `${requestedByUser.firstName} ${requestedByUser.lastName}`;
const editorFullName = `${editedByUser.firstName} ${editedByUser.lastName}`;
const approvalPath = `/projects/secret-management/${project.id}/approval`;
const projectPath = `/projects/secret-management/${project.id}`;
const approvalPath = `${projectPath}/approval`;
const approvalUrl = `${cfg.SITE_URL}${approvalPath}`;
await triggerWorkflowIntegrationNotification({
@@ -415,7 +418,8 @@ export const accessApprovalRequestServiceFactory = ({
approvalUrl,
editNote,
editorEmail: editedByUser.email as string,
editorFullName
editorFullName,
projectPath
}
},
projectId: project.id
@@ -36,6 +36,7 @@ import { CertExtendedKeyUsage, CertKeyAlgorithm, CertKeyUsage } from "@app/servi
import { CaStatus } from "@app/services/certificate-authority/certificate-authority-enums";
import { TIdentityTrustedIp } from "@app/services/identity/identity-types";
import { TAllowedFields } from "@app/services/identity-ldap-auth/identity-ldap-auth-types";
import { PkiAlertEventType } from "@app/services/pki-alert-v2/pki-alert-v2-types";
import { PkiItemType } from "@app/services/pki-collection/pki-collection-types";
import { SecretSync, SecretSyncImportBehavior } from "@app/services/secret-sync/secret-sync-enums";
import {
@@ -322,6 +323,7 @@ export enum EventType {
GET_CERT_BODY = "get-cert-body",
GET_CERT_PRIVATE_KEY = "get-cert-private-key",
GET_CERT_BUNDLE = "get-cert-bundle",
EXPORT_CERT_PKCS12 = "export-cert-pkcs12",
CREATE_PKI_ALERT = "create-pki-alert",
GET_PKI_ALERT = "get-pki-alert",
UPDATE_PKI_ALERT = "update-pki-alert",
@@ -370,6 +372,7 @@ export enum EventType {
SIGN_CERTIFICATE_FROM_PROFILE = "sign-certificate-from-profile",
ORDER_CERTIFICATE_FROM_PROFILE = "order-certificate-from-profile",
RENEW_CERTIFICATE = "renew-certificate",
GET_CERTIFICATE_PROFILE_LATEST_ACTIVE_BUNDLE = "get-certificate-profile-latest-active-bundle",
UPDATE_CERTIFICATE_RENEWAL_CONFIG = "update-certificate-renewal-config",
DISABLE_CERTIFICATE_RENEWAL_CONFIG = "disable-certificate-renewal-config",
ATTEMPT_CREATE_SLACK_INTEGRATION = "attempt-create-slack-integration",
@@ -2313,15 +2316,24 @@ interface GetCertBundle {
serialNumber: string;
};
}
interface GetCertPkcs12 {
type: EventType.EXPORT_CERT_PKCS12;
metadata: {
certId: string;
cn: string;
serialNumber: string;
};
}
interface CreatePkiAlert {
type: EventType.CREATE_PKI_ALERT;
metadata: {
pkiAlertId: string;
pkiCollectionId: string;
pkiCollectionId?: string;
name: string;
alertBeforeDays: number;
recipientEmails: string;
alertBefore: string;
eventType: PkiAlertEventType;
recipientEmails?: string;
};
}
interface GetPkiAlert {
@@ -2337,7 +2349,8 @@ interface UpdatePkiAlert {
pkiAlertId: string;
pkiCollectionId?: string;
name?: string;
alertBeforeDays?: number;
alertBefore?: string;
eventType?: PkiAlertEventType;
recipientEmails?: string;
};
}
@@ -2749,6 +2762,17 @@ interface OrderCertificateFromProfile {
};
}
interface GetCertificateProfileLatestActiveBundle {
type: EventType.GET_CERTIFICATE_PROFILE_LATEST_ACTIVE_BUNDLE;
metadata: {
certificateProfileId: string;
certificateId: string;
commonName: string;
profileName: string;
serialNumber: string;
};
}
interface RenewCertificate {
type: EventType.RENEW_CERTIFICATE;
metadata: {
@@ -4237,6 +4261,7 @@ export type Event =
| GetCertBody
| GetCertPrivateKey
| GetCertBundle
| GetCertPkcs12
| CreatePkiAlert
| GetPkiAlert
| UpdatePkiAlert
@@ -4279,6 +4304,7 @@ export type Event =
| DeleteCertificateProfile
| GetCertificateProfile
| ListCertificateProfiles
| GetCertificateProfileLatestActiveBundle
| IssueCertificateFromProfile
| SignCertificateFromProfile
| OrderCertificateFromProfile
@@ -480,6 +480,36 @@ export const pamAccountServiceFactory = ({
throw new NotFoundError({ message: `Gateway connection details for gateway '${gatewayId}' not found.` });
}
let metadata;
switch (resourceType) {
case PamResource.Postgres:
case PamResource.MySQL:
{
const connectionCredentials = await decryptResourceConnectionDetails({
encryptedConnectionDetails: resource.encryptedConnectionDetails,
kmsService,
projectId: account.projectId
});
const credentials = await decryptAccountCredentials({
encryptedCredentials: account.encryptedCredentials,
kmsService,
projectId: account.projectId
});
metadata = {
username: credentials.username,
database: connectionCredentials.database,
accountName: account.name,
accountPath
};
}
break;
default:
break;
}
return {
sessionId: session.id,
resourceType,
@@ -491,7 +521,8 @@ export const pamAccountServiceFactory = ({
gatewayServerCertificateChain: gatewayConnectionDetails.gateway.serverCertificateChain,
relayHost: gatewayConnectionDetails.relayHost,
projectId: account.projectId,
account
account,
metadata
};
};
@@ -104,8 +104,7 @@ const makeSqlConnection = (
// (like being able to do an auth handshake regardless pass or not)
if (
connectOnly &&
(error.message === `password authentication failed for user "${TEST_CONNECTION_USERNAME}"` ||
error.message.includes("no pg_hba.conf entry for host"))
error.message === `password authentication failed for user "${TEST_CONNECTION_USERNAME}"`
) {
return;
}
@@ -106,7 +106,9 @@ const buildAdminPermissionRules = () => {
ProjectPermissionCertificateProfileActions.Edit,
ProjectPermissionCertificateProfileActions.Create,
ProjectPermissionCertificateProfileActions.Delete,
ProjectPermissionCertificateProfileActions.IssueCert
ProjectPermissionCertificateProfileActions.IssueCert,
ProjectPermissionCertificateProfileActions.RevealAcmeEabSecret,
ProjectPermissionCertificateProfileActions.RotateAcmeEabSecret
],
ProjectPermissionSub.CertificateProfiles
);
@@ -116,7 +116,9 @@ export enum ProjectPermissionCertificateProfileActions {
Create = "create",
Edit = "edit",
Delete = "delete",
IssueCert = "issue-cert"
IssueCert = "issue-cert",
RevealAcmeEabSecret = "reveal-acme-eab-secret",
RotateAcmeEabSecret = "rotate-acme-eab-secret"
}
export enum ProjectPermissionSecretSyncActions {
@@ -0,0 +1,42 @@
import { Knex } from "knex";
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 TPkiAcmeAccountDALFactory = ReturnType<typeof pkiAcmeAccountDALFactory>;
export const pkiAcmeAccountDALFactory = (db: TDbClient) => {
const pkiAcmeAccountOrm = ormify(db, TableName.PkiAcmeAccount);
const findByProjectIdAndAccountId = async (profileId: string, id: string, tx?: Knex) => {
try {
const account = await (tx || db)(TableName.PkiAcmeAccount).where({ profileId, id }).first();
return account || null;
} catch (error) {
throw new DatabaseError({ error, name: "Find PKI ACME account by id" });
}
};
const findByProfileIdAndPublicKeyThumbprintAndAlg = async (
profileId: string,
alg: string,
publicKeyThumbprint: string,
tx?: Knex
) => {
try {
const account = await (tx || db)(TableName.PkiAcmeAccount).where({ profileId, alg, publicKeyThumbprint }).first();
return account || null;
} catch (error) {
throw new DatabaseError({ error, name: "Find PKI ACME account by profile id, public key thumbprint and alg" });
}
};
return {
...pkiAcmeAccountOrm,
findByProjectIdAndAccountId,
findByProfileIdAndPublicKeyThumbprintAndAlg
};
};
@@ -0,0 +1,54 @@
import { Knex } from "knex";
import { TDbClient } from "@app/db";
import { TableName } from "@app/db/schemas";
import { DatabaseError } from "@app/lib/errors";
import { ormify, selectAllTableCols, sqlNestRelationships } from "@app/lib/knex";
export type TPkiAcmeAuthDALFactory = ReturnType<typeof pkiAcmeAuthDALFactory>;
export const pkiAcmeAuthDALFactory = (db: TDbClient) => {
const pkiAcmeAuthOrm = ormify(db, TableName.PkiAcmeAuth);
const findByAccountIdAndAuthIdWithChallenges = async (accountId: string, authId: string, tx?: Knex) => {
try {
const rows = await (tx || db)(TableName.PkiAcmeAuth)
.leftJoin(TableName.PkiAcmeChallenge, `${TableName.PkiAcmeChallenge}.authId`, `${TableName.PkiAcmeAuth}.id`)
.select(
selectAllTableCols(TableName.PkiAcmeAuth),
db.ref("id").withSchema(TableName.PkiAcmeChallenge).as("challengeId"),
db.ref("type").withSchema(TableName.PkiAcmeChallenge).as("challengeType"),
db.ref("status").withSchema(TableName.PkiAcmeChallenge).as("challengeStatus")
)
.where(`${TableName.PkiAcmeAuth}.accountId`, accountId)
.where(`${TableName.PkiAcmeAuth}.id`, authId);
if (rows.length === 0) {
return null;
}
return sqlNestRelationships({
data: rows,
key: "id",
parentMapper: (row) => row,
childrenMapper: [
{
key: "challengeId",
label: "challenges" as const,
mapper: ({ challengeId, challengeType, challengeStatus }) => ({
id: challengeId,
type: challengeType,
status: challengeStatus
})
}
]
})?.[0];
} catch (error) {
throw new DatabaseError({ error, name: "Find PKI ACME auth by account id and auth id with challenges" });
}
};
return {
...pkiAcmeAuthOrm,
findByAccountIdAndAuthIdWithChallenges
};
};
@@ -0,0 +1,177 @@
import { Knex } from "knex";
import { TDbClient } from "@app/db";
import { TableName, TPkiAcmeChallenges } from "@app/db/schemas";
import { DatabaseError } from "@app/lib/errors";
import { ormify, selectAllTableCols } from "@app/lib/knex";
import { AcmeAuthStatus, AcmeChallengeStatus, AcmeOrderStatus } from "./pki-acme-schemas";
export type TPkiAcmeChallengeDALFactory = ReturnType<typeof pkiAcmeChallengeDALFactory>;
export const pkiAcmeChallengeDALFactory = (db: TDbClient) => {
const pkiAcmeChallengeOrm = ormify(db, TableName.PkiAcmeChallenge);
const markAsValidCascadeById = async (id: string, tx?: Knex): Promise<TPkiAcmeChallenges> => {
try {
const [challenge] = (await (tx || db)(TableName.PkiAcmeChallenge)
.where({ id })
.update({ status: AcmeChallengeStatus.Valid, validatedAt: new Date() })
.returning("*")) as [TPkiAcmeChallenges];
// Update pending auth to valid as well
const updatedAuths = await (tx || db)(TableName.PkiAcmeAuth)
.where({ id: challenge.authId, status: AcmeAuthStatus.Pending })
.update({ status: AcmeAuthStatus.Valid })
.returning("id");
if (updatedAuths.length > 0) {
// Find all the orders that are involved in the challenge validation
const involvedOrderIds = (tx || db)({ o: TableName.PkiAcmeOrder })
.distinct("o.id")
.join({ oa: TableName.PkiAcmeOrderAuth }, "o.id", "oa.orderId")
.join({ a: TableName.PkiAcmeAuth }, "oa.authId", `a.id`)
.whereIn(
"a.id",
updatedAuths.map((auth) => auth.id)
);
// Update status for pending orders that have all auths valid
await (tx || db)(TableName.PkiAcmeOrder)
.whereIn("id", (qb) => {
void qb
.select("o2.id")
.from({ o2: TableName.PkiAcmeOrder })
.join({ oa2: TableName.PkiAcmeOrderAuth }, "o2.id", "oa2.orderId")
.join({ a2: TableName.PkiAcmeAuth }, "oa2.authId", "a2.id")
.groupBy("o2.id")
// All auths should be valid for the order to be ready
.havingRaw("SUM(CASE WHEN a2.status = ? THEN 1 ELSE 0 END) = COUNT(DISTINCT a2.id)", [
AcmeAuthStatus.Valid
])
// Only update orders that are pending
.where("o2.status", AcmeOrderStatus.Pending)
.whereIn("o2.id", involvedOrderIds);
})
.update({ status: AcmeOrderStatus.Ready });
}
return challenge;
} catch (error) {
throw new DatabaseError({ error, name: "Update certificate profile" });
}
};
const markAsInvalidCascadeById = async (id: string, tx?: Knex): Promise<TPkiAcmeChallenges> => {
try {
const [challenge] = (await (tx || db)(TableName.PkiAcmeChallenge)
.where({ id })
.update({ status: AcmeChallengeStatus.Invalid })
.returning("*")) as [TPkiAcmeChallenges];
// Update pending auth to valid as well
const updatedAuths = await (tx || db)(TableName.PkiAcmeAuth)
.where({ id: challenge.authId, status: AcmeAuthStatus.Pending })
.update({ status: AcmeAuthStatus.Invalid })
.returning("id");
if (updatedAuths.length > 0) {
// Update status for pending orders that have all auths valid
await (tx || db)(TableName.PkiAcmeOrder)
.whereIn("id", (qb) => {
void qb
.select("o.id")
.from({ o: TableName.PkiAcmeOrder })
.join(TableName.PkiAcmeOrderAuth, "o.id", `${TableName.PkiAcmeOrderAuth}.orderId`)
.join(TableName.PkiAcmeAuth, `${TableName.PkiAcmeOrderAuth}.authId`, `${TableName.PkiAcmeAuth}.id`)
// We only update orders that are pending
.where("o.status", AcmeOrderStatus.Pending)
.whereIn(
`${TableName.PkiAcmeAuth}.id`,
updatedAuths.map((auth) => auth.id)
);
})
.update({ status: AcmeOrderStatus.Invalid });
}
// TODO: update order status to invalid as well
return challenge;
} catch (error) {
throw new DatabaseError({ error, name: "Update certificate profile" });
}
};
const findByAccountAuthAndChallengeId = async (accountId: string, authId: string, challengeId: string, tx?: Knex) => {
try {
const challenge = await (tx || db)(TableName.PkiAcmeChallenge)
.join(TableName.PkiAcmeAuth, `${TableName.PkiAcmeChallenge}.authId`, `${TableName.PkiAcmeAuth}.id`)
.select(selectAllTableCols(TableName.PkiAcmeChallenge))
.where(`${TableName.PkiAcmeChallenge}.id`, challengeId)
.where(`${TableName.PkiAcmeChallenge}.authId`, authId)
.where(`${TableName.PkiAcmeAuth}.accountId`, accountId)
.first();
if (!challenge) {
return null;
}
return challenge;
} catch (error) {
throw new DatabaseError({ error, name: "Find PKI ACME challenge by account id, auth id and challenge id" });
}
};
const findByIdForChallengeValidation = async (id: string, tx?: Knex) => {
const result = await (tx || db)(TableName.PkiAcmeChallenge)
.join(TableName.PkiAcmeAuth, `${TableName.PkiAcmeChallenge}.authId`, `${TableName.PkiAcmeAuth}.id`)
.join(TableName.PkiAcmeAccount, `${TableName.PkiAcmeAuth}.accountId`, `${TableName.PkiAcmeAccount}.id`)
.select(
selectAllTableCols(TableName.PkiAcmeChallenge),
db.ref("id").withSchema(TableName.PkiAcmeAuth).as("authId"),
db.ref("token").withSchema(TableName.PkiAcmeAuth).as("authToken"),
db.ref("status").withSchema(TableName.PkiAcmeAuth).as("authStatus"),
db.ref("identifierType").withSchema(TableName.PkiAcmeAuth).as("authIdentifierType"),
db.ref("identifierValue").withSchema(TableName.PkiAcmeAuth).as("authIdentifierValue"),
db.ref("expiresAt").withSchema(TableName.PkiAcmeAuth).as("authExpiresAt"),
db.ref("id").withSchema(TableName.PkiAcmeAccount).as("accountId"),
db.ref("publicKeyThumbprint").withSchema(TableName.PkiAcmeAccount).as("accountPublicKeyThumbprint")
)
// For all challenges, acquire update lock on the auth to avoid race conditions
.forUpdate(TableName.PkiAcmeAuth)
.where(`${TableName.PkiAcmeChallenge}.id`, id)
.first();
if (!result) {
return null;
}
const {
authId,
authToken,
authStatus,
authIdentifierType,
authIdentifierValue,
authExpiresAt,
accountId,
accountPublicKeyThumbprint,
...challenge
} = result;
return {
...challenge,
auth: {
token: authToken,
status: authStatus,
identifierType: authIdentifierType,
identifierValue: authIdentifierValue,
expiresAt: authExpiresAt,
account: {
id: accountId,
publicKeyThumbprint: accountPublicKeyThumbprint
}
}
};
};
return {
...pkiAcmeChallengeOrm,
markAsValidCascadeById,
markAsInvalidCascadeById,
findByAccountAuthAndChallengeId,
findByIdForChallengeValidation
};
};
@@ -0,0 +1,134 @@
import { getConfig } from "@app/lib/config/env";
import { BadRequestError, NotFoundError } from "@app/lib/errors";
import { isPrivateIp } from "@app/lib/ip/ipRange";
import { logger } from "@app/lib/logger";
import { TPkiAcmeChallengeDALFactory } from "./pki-acme-challenge-dal";
import {
AcmeConnectionError,
AcmeDnsFailureError,
AcmeIncorrectResponseError,
AcmeServerInternalError
} from "./pki-acme-errors";
import { AcmeAuthStatus, AcmeChallengeStatus, AcmeChallengeType } from "./pki-acme-schemas";
import { TPkiAcmeChallengeServiceFactory } from "./pki-acme-types";
type FetchError = Error & {
code?: string;
};
type TPkiAcmeChallengeServiceFactoryDep = {
acmeChallengeDAL: Pick<
TPkiAcmeChallengeDALFactory,
"transaction" | "findByIdForChallengeValidation" | "markAsValidCascadeById" | "markAsInvalidCascadeById"
>;
};
export const pkiAcmeChallengeServiceFactory = ({
acmeChallengeDAL
}: TPkiAcmeChallengeServiceFactoryDep): TPkiAcmeChallengeServiceFactory => {
const appCfg = getConfig();
const validateChallengeResponse = async (challengeId: string): Promise<void> => {
const error: Error | undefined = await acmeChallengeDAL.transaction(async (tx) => {
logger.info({ challengeId }, "Validating ACME challenge response");
const challenge = await acmeChallengeDAL.findByIdForChallengeValidation(challengeId, tx);
if (!challenge) {
throw new NotFoundError({ message: "ACME challenge not found" });
}
if (challenge.status !== AcmeChallengeStatus.Pending) {
throw new BadRequestError({
message: `ACME challenge is ${challenge.status} instead of ${AcmeChallengeStatus.Pending}`
});
}
if (challenge.auth.expiresAt < new Date()) {
throw new BadRequestError({ message: "ACME auth has expired" });
}
if (challenge.auth.status !== AcmeAuthStatus.Pending) {
throw new BadRequestError({
message: `ACME auth status is ${challenge.auth.status} instead of ${AcmeAuthStatus.Pending}`
});
}
// TODO: support other challenge types here. Currently only HTTP-01 is supported
if (challenge.type !== AcmeChallengeType.HTTP_01) {
throw new BadRequestError({ message: "Only HTTP-01 challenges are supported for now" });
}
let host = challenge.auth.identifierValue;
// check if host is a private ip address
if (isPrivateIp(host)) {
throw new BadRequestError({ message: "Private IP addresses are not allowed" });
}
if (appCfg.isAcmeDevelopmentMode && appCfg.ACME_DEVELOPMENT_HTTP01_CHALLENGE_HOST_OVERRIDES[host]) {
host = appCfg.ACME_DEVELOPMENT_HTTP01_CHALLENGE_HOST_OVERRIDES[host];
logger.warn(
{ srcHost: challenge.auth.identifierValue, dstHost: host },
"Using ACME development HTTP-01 challenge host override"
);
}
const challengeUrl = new URL(`/.well-known/acme-challenge/${challenge.auth.token}`, `http://${host}`);
logger.info({ challengeUrl }, "Performing ACME HTTP-01 challenge validation");
try {
// TODO: read config from the profile to get the timeout instead
const timeoutMs = 10 * 1000; // 10 seconds
// Notice: well, we are in a transaction, ideally we should not hold transaction and perform
// a long running operation for long time. But assuming we are not performing a tons of
// challenge validation at the same time, it should be fine.
const challengeResponse = await fetch(challengeUrl, { signal: AbortSignal.timeout(timeoutMs) });
if (challengeResponse.status !== 200) {
throw new BadRequestError({ message: "ACME challenge response is not 200" });
}
const challengeResponseBody = await challengeResponse.text();
const thumbprint = challenge.auth.account.publicKeyThumbprint;
const expectedChallengeResponseBody = `${challenge.auth.token}.${thumbprint}`;
if (challengeResponseBody.trimEnd() !== expectedChallengeResponseBody) {
throw new AcmeIncorrectResponseError({ message: "ACME challenge response is not correct" });
}
await acmeChallengeDAL.markAsValidCascadeById(challengeId, tx);
} catch (exp) {
// TODO: we should retry the challenge validation a few times, but let's keep it simple for now
await acmeChallengeDAL.markAsInvalidCascadeById(challengeId, tx);
// Properly type and inspect the error
if (exp instanceof TypeError && exp.message.includes("fetch failed")) {
const { cause } = exp;
let errors: Error[] = [];
if (cause instanceof AggregateError) {
errors = cause.errors as Error[];
} else if (cause instanceof Error) {
errors = [cause];
}
// eslint-disable-next-line no-unreachable-loop
for (const err of errors) {
// TODO: handle multiple errors, return a compound error instead of just the first error
const fetchError = err as FetchError;
if (fetchError.code === "ECONNREFUSED" || fetchError.message.includes("ECONNREFUSED")) {
return new AcmeConnectionError({ message: "Connection refused" });
}
if (fetchError.code === "ENOTFOUND" || fetchError.message.includes("ENOTFOUND")) {
return new AcmeDnsFailureError({ message: "Hostname could not be resolved (DNS failure)" });
}
return new AcmeServerInternalError({ message: "Unknown error validating ACME challenge response" });
}
} else if (exp instanceof DOMException) {
if (exp.name === "TimeoutError") {
logger.error(exp, "Connection timed out while validating ACME challenge response");
return new AcmeConnectionError({ message: "Connection timed out" });
}
logger.error(exp, "Unknown error validating ACME challenge response");
return new AcmeServerInternalError({ message: "Unknown error validating ACME challenge response" });
} else if (exp instanceof Error) {
logger.error(exp, "Error validating ACME challenge response");
} else {
logger.error(exp, "Unknown error validating ACME challenge response");
return new AcmeServerInternalError({ message: "Unknown error validating ACME challenge response" });
}
return exp;
}
});
if (error) {
throw error;
}
};
return { validateChallengeResponse };
};
@@ -0,0 +1,574 @@
/**
* ACME Error Classes based on RFC 8555 Section 6.7
* https://datatracker.ietf.org/doc/html/rfc8555#section-6.7
*/
/* eslint-disable max-classes-per-file */
// RFC 8555 Section 6.7 - Error Types
export enum AcmeErrorType {
AccountDoesNotExist = "accountDoesNotExist",
AlreadyRevoked = "alreadyRevoked",
BadCsr = "badCSR",
BadNonce = "badNonce",
BadPublicKey = "badPublicKey",
BadRevocationReason = "badRevocationReason",
BadSignatureAlgorithm = "badSignatureAlgorithm",
CAA = "caa",
Compound = "compound",
Connection = "connection",
DNS = "dns",
ExternalAccountRequired = "externalAccountRequired",
IncorrectResponse = "incorrectResponse",
InvalidContact = "invalidContact",
Malformed = "malformed",
OrderNotReady = "orderNotReady",
RateLimited = "rateLimited",
RejectedIdentifier = "rejectedIdentifier",
ServerInternal = "serverInternal",
TLS = "tls",
Unauthorized = "unauthorized",
UnsupportedContact = "unsupportedContact",
UnsupportedIdentifier = "unsupportedIdentifier",
UserActionRequired = "userActionRequired"
}
export interface IAcmeError {
type: AcmeErrorType;
detail: string;
status: number;
subproblems?: Array<{ type: string; detail: string; identifier?: { type: string; value: string } }>;
}
export class AcmeError extends Error implements IAcmeError {
type: AcmeErrorType;
detail: string;
status: number;
subproblems?: Array<{ type: string; detail: string; identifier?: { type: string; value: string } }>;
error?: unknown;
constructor({
type,
detail,
status,
subproblems,
error,
message
}: {
type: AcmeErrorType;
detail: string;
status: number;
subproblems?: Array<{ type: string; detail: string; identifier?: { type: string; value: string } }>;
error?: unknown;
message?: string;
}) {
super(message || detail);
this.type = type;
this.detail = detail;
this.status = status;
this.subproblems = subproblems;
this.error = error;
this.name = "AcmeError";
}
toAcmeResponse(): IAcmeError {
return {
type: this.type,
detail: this.detail,
status: this.status,
subproblems: this.subproblems
};
}
}
/**
* malformed - The request message was malformed (RFC 8555 Section 6.7.1)
*/
export class AcmeMalformedError extends AcmeError {
constructor({
detail = "The request message was malformed",
error,
message
}: {
detail?: string;
error?: unknown;
message?: string;
} = {}) {
super({
type: AcmeErrorType.Malformed,
detail,
status: 400,
error,
message
});
this.name = "AcmeMalformedError";
}
}
/**
* unauthorized - The client lacks sufficient authorization (RFC 8555 Section 6.7.2)
*/
export class AcmeUnauthorizedError extends AcmeError {
constructor({
detail = "The client lacks sufficient authorization",
error,
message
}: {
detail?: string;
error?: unknown;
message?: string;
} = {}) {
super({
type: AcmeErrorType.Unauthorized,
detail,
status: 403,
error,
message
});
this.name = "AcmeUnauthorizedError";
}
}
/**
* accountDoesNotExist - The request specified an account that does not exist
* (RFC 8555 Section 6.7.3)
*/
export class AcmeAccountDoesNotExistError extends AcmeError {
constructor({
detail = "The request specified an account that does not exist",
error,
message
}: {
detail?: string;
error?: unknown;
message?: string;
} = {}) {
super({
type: AcmeErrorType.AccountDoesNotExist,
detail,
status: 400,
error,
message
});
this.name = "AcmeAccountDoesNotExistError";
}
}
/**
* badNonce - The client sent an unacceptable anti-replay nonce (RFC 8555 Section 6.7.4)
*/
export class AcmeBadNonceError extends AcmeError {
constructor({
detail = "The client sent an unacceptable anti-replay nonce",
error,
message
}: {
detail?: string;
error?: unknown;
message?: string;
} = {}) {
super({
type: AcmeErrorType.BadNonce,
detail,
status: 400,
error,
message
});
this.name = "AcmeBadNonceError";
}
}
/**
* badSignatureAlgorithm - The signature algorithm is invalid (RFC 8555 Section 6.7.5)
*/
export class AcmeBadSignatureAlgorithmError extends AcmeError {
constructor({
detail = "The signature algorithm is invalid",
error,
message
}: {
detail?: string;
error?: unknown;
message?: string;
} = {}) {
super({
type: AcmeErrorType.BadSignatureAlgorithm,
detail,
status: 401,
error,
message
});
this.name = "AcmeBadSignatureAlgorithmError";
}
}
/**
* badPublicKey - The public key is not acceptable (RFC 8555 Section 6.7.6)
*/
export class AcmeBadPublicKeyError extends AcmeError {
constructor({
detail = "The public key is not acceptable",
error,
message
}: {
detail?: string;
error?: unknown;
message?: string;
} = {}) {
super({
type: AcmeErrorType.BadPublicKey,
detail,
status: 400,
error,
message
});
this.name = "AcmeBadPublicKeyError";
}
}
/**
* badCSR - The CSR is unacceptable (RFC 8555 Section 6.7.7)
*/
export class AcmeBadCsrError extends AcmeError {
constructor({
detail = "The CSR is unacceptable",
error,
message
}: {
detail?: string;
error?: unknown;
message?: string;
} = {}) {
super({
type: AcmeErrorType.BadCsr,
detail,
status: 400,
error,
message
});
this.name = "AcmeBadCsrError";
}
}
/**
* badRevocationReason - The revocation reason provided is not allowed
* (RFC 8555 Section 6.7.8)
*/
export class AcmeBadRevocationReasonError extends AcmeError {
constructor({
detail = "The revocation reason provided is not allowed",
error,
message
}: {
detail?: string;
error?: unknown;
message?: string;
} = {}) {
super({
type: AcmeErrorType.BadRevocationReason,
detail,
status: 400,
error,
message
});
this.name = "AcmeBadRevocationReasonError";
}
}
/**
* rateLimited - The client has exceeded a rate limit (RFC 8555 Section 6.7.9)
*/
export class AcmeRateLimitedError extends AcmeError {
constructor({
detail = "The client has exceeded a rate limit",
error,
message
}: {
detail?: string;
error?: unknown;
message?: string;
} = {}) {
super({
type: AcmeErrorType.RateLimited,
detail,
status: 429,
error,
message
});
this.name = "AcmeRateLimitedError";
}
}
/**
* rejectedIdentifier - The server will not issue certificates for the identifier
* (RFC 8555 Section 6.7.10)
*/
export class AcmeRejectedIdentifierError extends AcmeError {
constructor({
detail = "The server will not issue certificates for the identifier",
subproblems,
error,
message
}: {
detail?: string;
subproblems?: Array<{ type: string; detail: string; identifier?: { type: string; value: string } }>;
error?: unknown;
message?: string;
} = {}) {
super({
type: AcmeErrorType.RejectedIdentifier,
detail,
status: 400,
subproblems,
error,
message
});
this.name = "AcmeRejectedIdentifierError";
}
}
/**
* serverInternal - An internal error occurred (RFC 8555 Section 6.7.11)
*/
export class AcmeServerInternalError extends AcmeError {
constructor({
detail = "An internal error occurred",
error,
message
}: {
detail?: string;
error?: unknown;
message?: string;
} = {}) {
super({
type: AcmeErrorType.ServerInternal,
detail,
status: 500,
error,
message
});
this.name = "AcmeServerInternalError";
}
}
/**
* unsupportedContact - A contact URL is of an unsupported type (RFC 8555 Section 6.7.13)
*/
export class AcmeUnsupportedContactError extends AcmeError {
constructor({
detail = "A contact URL is of an unsupported type",
error,
message
}: {
detail?: string;
error?: unknown;
message?: string;
} = {}) {
super({
type: AcmeErrorType.UnsupportedContact,
detail,
status: 400,
error,
message
});
this.name = "AcmeUnsupportedContactError";
}
}
/**
* unsupportedIdentifier - An identifier is of an unsupported type
* (RFC 8555 Section 6.7.14)
*/
export class AcmeUnsupportedIdentifierError extends AcmeError {
constructor({
detail = "An identifier is of an unsupported type",
error,
message
}: {
detail?: string;
error?: unknown;
message?: string;
} = {}) {
super({
type: AcmeErrorType.UnsupportedIdentifier,
detail,
status: 400,
error,
message
});
this.name = "AcmeUnsupportedIdentifierError";
}
}
/**
* userActionRequired - Visit the "instance" URL and take actions specified there
* (RFC 8555 Section 6.7.15)
*/
export class AcmeUserActionRequiredError extends AcmeError {
instance?: string;
constructor({
detail = "Visit the instance URL and take actions specified there",
instance,
error,
message
}: {
detail?: string;
instance?: string;
error?: unknown;
message?: string;
} = {}) {
super({
type: AcmeErrorType.UserActionRequired,
detail,
status: 403,
error,
message
});
this.instance = instance;
this.name = "AcmeUserActionRequiredError";
}
toAcmeResponse(): IAcmeError & { instance?: string } {
return {
...super.toAcmeResponse(),
instance: this.instance
};
}
}
/**
* incorrectResponse - The response is incorrect (RFC 8555 Section 6.7.16)
*/
export class AcmeIncorrectResponseError extends AcmeError {
constructor({
detail = "The response is incorrect",
error,
message
}: {
detail?: string;
error?: unknown;
message?: string;
} = {}) {
super({
type: AcmeErrorType.IncorrectResponse,
detail,
status: 400,
error,
message
});
this.name = "AcmeIncorrectResponseError";
}
}
/**
* connectionError - A connection error occurred (RFC 8555 Section 6.7.17)
*/
export class AcmeConnectionError extends AcmeError {
constructor({
detail = "A connection error occurred",
error,
message
}: {
detail?: string;
error?: unknown;
message?: string;
} = {}) {
super({
type: AcmeErrorType.Connection,
detail,
status: 400,
error,
message
});
this.name = "AcmeConnectionError";
}
}
export class AcmeDnsFailureError extends AcmeError {
constructor({
detail = "Hostname could not be resolved (DNS failure)",
error,
message
}: {
detail?: string;
error?: unknown;
message?: string;
} = {}) {
super({
type: AcmeErrorType.DNS,
detail,
status: 400,
error,
message
});
this.name = "AcmeDnsFailureError";
}
}
export class AcmeOrderNotReadyError extends AcmeError {
constructor({
detail = "The order is not ready",
error,
message
}: {
detail?: string;
error?: unknown;
message?: string;
} = {}) {
super({
type: AcmeErrorType.OrderNotReady,
detail,
status: 403,
error,
message
});
this.name = "AcmeOrderNotReadyError";
}
}
export class AcmeBadCSRError extends AcmeError {
constructor({
detail = "The CSR is unacceptable",
error,
message
}: {
detail?: string;
error?: unknown;
message?: string;
} = {}) {
super({
type: AcmeErrorType.BadCsr,
detail,
status: 400,
error,
message
});
this.name = "AcmeBadCSRError";
}
}
export class AcmeExternalAccountRequiredError extends AcmeError {
constructor({
detail = "External account binding is required",
error,
message
}: {
detail?: string;
error?: unknown;
message?: string;
} = {}) {
super({
type: AcmeErrorType.ExternalAccountRequired,
detail,
status: 400,
error,
message
});
this.name = "AcmeExternalAccountRequiredError";
}
}
@@ -0,0 +1,19 @@
import { z } from "zod";
import { getConfig } from "@app/lib/config/env";
import { AcmeMalformedError } from "./pki-acme-errors";
export const buildUrl = (profileId: string, path: string): string => {
const appCfg = getConfig();
const baseUrl = appCfg.SITE_URL ?? "";
return `${baseUrl}/api/v1/pki/acme/profiles/${profileId}${path}`;
};
export const extractAccountIdFromKid = (kid: string, profileId: string): string => {
const kidPrefix = buildUrl(profileId, "/accounts/");
if (!kid.startsWith(kidPrefix)) {
throw new AcmeMalformedError({ detail: "KID must start with the profile account URL" });
}
return z.string().uuid().parse(kid.slice(kidPrefix.length));
};
@@ -0,0 +1,13 @@
import { TDbClient } from "@app/db";
import { TableName } from "@app/db/schemas";
import { ormify } from "@app/lib/knex";
export type TPkiAcmeOrderAuthDALFactory = ReturnType<typeof pkiAcmeOrderAuthDALFactory>;
export const pkiAcmeOrderAuthDALFactory = (db: TDbClient) => {
const pkiAcmeOrderAuthOrm = ormify(db, TableName.PkiAcmeOrderAuth);
return {
...pkiAcmeOrderAuthOrm
};
};
@@ -0,0 +1,78 @@
import { Knex } from "knex";
import { TDbClient } from "@app/db";
import { TableName } from "@app/db/schemas";
import { DatabaseError } from "@app/lib/errors";
import { ormify, selectAllTableCols, sqlNestRelationships } from "@app/lib/knex";
export type TPkiAcmeOrderDALFactory = ReturnType<typeof pkiAcmeOrderDALFactory>;
export const pkiAcmeOrderDALFactory = (db: TDbClient) => {
const pkiAcmeOrderOrm = ormify(db, TableName.PkiAcmeOrder);
const findByIdForFinalization = async (id: string, tx?: Knex) => {
try {
const order = await (tx || db)(TableName.PkiAcmeOrder).forUpdate().where({ id }).first();
return order || null;
} catch (error) {
throw new DatabaseError({ error, name: "Find PKI ACME order by id for finalization" });
}
};
const findByAccountAndOrderIdWithAuthorizations = async (accountId: string, orderId: string, tx?: Knex) => {
try {
const rows = await (tx || db)(TableName.PkiAcmeOrder)
.join(TableName.PkiAcmeOrderAuth, `${TableName.PkiAcmeOrderAuth}.orderId`, `${TableName.PkiAcmeOrder}.id`)
.join(TableName.PkiAcmeAuth, `${TableName.PkiAcmeOrderAuth}.authId`, `${TableName.PkiAcmeAuth}.id`)
.select(
selectAllTableCols(TableName.PkiAcmeOrder),
db.ref("id").withSchema(TableName.PkiAcmeAuth).as("authId"),
db.ref("identifierType").withSchema(TableName.PkiAcmeAuth).as("identifierType"),
db.ref("identifierValue").withSchema(TableName.PkiAcmeAuth).as("identifierValue"),
db.ref("expiresAt").withSchema(TableName.PkiAcmeAuth).as("authExpiresAt")
)
.where(`${TableName.PkiAcmeOrder}.id`, orderId)
.where(`${TableName.PkiAcmeOrder}.accountId`, accountId)
.orderBy(`${TableName.PkiAcmeAuth}.identifierValue`, "asc");
if (rows.length === 0) {
return null;
}
return sqlNestRelationships({
data: rows,
key: "id",
parentMapper: (row) => row,
childrenMapper: [
{
key: "authId",
label: "authorizations" as const,
mapper: ({ authId, identifierType, identifierValue, authExpiresAt }) => ({
id: authId,
identifierType,
identifierValue,
expiresAt: authExpiresAt
})
}
]
})?.[0];
} catch (error) {
throw new DatabaseError({ error, name: "Find PKI ACME order by id" });
}
};
const listByAccountId = async (accountId: string, tx?: Knex) => {
try {
const orders = await (tx || db)(TableName.PkiAcmeOrder).where({ accountId }).orderBy("createdAt", "desc");
return orders;
} catch (error) {
throw new DatabaseError({ error, name: "List PKI ACME orders by account id" });
}
};
return {
...pkiAcmeOrderOrm,
findByIdForFinalization,
findByAccountAndOrderIdWithAuthorizations,
listByAccountId
};
};
@@ -0,0 +1,174 @@
import RE2 from "re2";
import { z } from "zod";
export enum AcmeIdentifierType {
DNS = "dns"
}
export enum AcmeOrderStatus {
Pending = "pending",
Processing = "processing",
Ready = "ready",
Valid = "valid",
Invalid = "invalid"
}
export enum AcmeAuthStatus {
Pending = "pending",
Valid = "valid",
Invalid = "invalid",
Deactivated = "deactivated",
Expired = "expired",
Revoked = "revoked"
}
export enum AcmeChallengeStatus {
Pending = "pending",
Processing = "processing",
Valid = "valid",
Invalid = "invalid"
}
export enum AcmeChallengeType {
HTTP_01 = "http-01",
DNS_01 = "dns-01",
TLS_ALPN_01 = "tls-alpn-01"
}
export const ProtectedHeaderSchema = z
.object({
alg: z.string(),
nonce: z.string(),
url: z.string(),
kid: z.string().optional(),
jwk: z.record(z.string(), z.string()).optional()
})
.refine((data) => data.kid || data.jwk, {
message: "Either kid or jwk must be provided",
path: ["kid", "jwk"]
});
// Raw JWS payload schema before parsing and verification
export const RawJwsPayloadSchema = z.object({
protected: z.string(),
payload: z.string(),
signature: z.string()
});
export const GetAcmeDirectoryResponseSchema = z.object({
newNonce: z.string(),
newAccount: z.string(),
newOrder: z.string(),
revokeCert: z.string().optional()
});
// New Account payload schema
export const CreateAcmeAccountBodySchema = z.object({
contact: z.array(z.string()).optional(),
termsOfServiceAgreed: z.boolean().optional(),
onlyReturnExisting: z.boolean().optional(),
externalAccountBinding: RawJwsPayloadSchema.optional()
});
// New Account endpoint
export const CreateAcmeAccountSchema = z.object({
params: z.object({
profileId: z.string().uuid()
}),
body: CreateAcmeAccountBodySchema
});
export const CreateAcmeAccountResponseSchema = z.object({
status: z.string(),
contact: z.array(z.string()).optional(),
orders: z.string().optional()
});
// New Order payload schema
export const CreateAcmeOrderBodySchema = z.object({
identifiers: z.array(
z.object({
type: z.enum(Object.values(AcmeIdentifierType) as [string, ...string[]]),
value: z.string().refine((val) => {
// DNS label pattern: 1-63 chars, alphanumeric or hyphen, but not starting or ending with hyphen
const labelPattern = new RE2(/^[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?$/);
const labels = val.split(".");
return labels.every((label) => label.length >= 1 && label.length <= 63 && labelPattern.test(label));
}, "Invalid DNS identifier")
})
),
notBefore: z.string().optional(),
notAfter: z.string().optional()
});
export const AcmeOrderResourceSchema = z.object({
status: z.enum(Object.values(AcmeOrderStatus) as [string, ...string[]]),
expires: z.string().optional(),
notBefore: z.string().optional(),
notAfter: z.string().optional(),
identifiers: z.array(
z.object({
type: z.string(),
value: z.string()
})
),
authorizations: z.array(z.string()),
finalize: z.string(),
certificate: z.string().optional()
});
// Account Deactivation payload schema
export const DeactivateAcmeAccountBodySchema = z.object({
status: z.literal("deactivated")
});
export const DeactivateAcmeAccountResponseSchema = z.object({
status: z.string()
});
// List Orders endpoint
export const ListAcmeOrdersPayloadSchema = z.object({}).strict();
export const ListAcmeOrdersResponseSchema = z.object({
orders: z.array(z.string())
});
// Finalize Order payload schema
export const FinalizeAcmeOrderBodySchema = z.object({
csr: z.string()
});
export const GetAcmeAuthorizationResponseSchema = z.object({
status: z.enum(Object.values(AcmeAuthStatus) as [string, ...string[]]),
expires: z.string().optional(),
identifier: z.object({
type: z.string(),
value: z.string()
}),
challenges: z.array(
z.object({
type: z.enum(Object.values(AcmeChallengeType) as [string, ...string[]]),
url: z.string(),
status: z.string(),
token: z.string(),
validated: z.string().optional()
})
)
});
export const RespondToAcmeChallengeBodySchema = z.object({}).strict();
export const RespondToAcmeChallengeResponseSchema = z.object({
type: z.enum(Object.values(AcmeChallengeType) as [string, ...string[]]),
url: z.string(),
status: z.string(),
token: z.string(),
validated: z.string().optional(),
error: z
.object({
type: z.string(),
detail: z.string(),
status: z.number()
})
.optional()
});
@@ -0,0 +1,841 @@
import * as x509 from "@peculiar/x509";
import {
calculateJwkThumbprint,
errors,
flattenedVerify,
FlattenedVerifyResult,
importJWK,
JWSHeaderParameters
} from "jose";
import { z, ZodError } from "zod";
import { TPkiAcmeAccounts } from "@app/db/schemas/pki-acme-accounts";
import { TPkiAcmeAuths } from "@app/db/schemas/pki-acme-auths";
import { KeyStorePrefixes, TKeyStoreFactory } from "@app/keystore/keystore";
import { crypto } from "@app/lib/crypto/cryptography";
import { BadRequestError, NotFoundError, UnauthorizedError } from "@app/lib/errors";
import { isPrivateIp } from "@app/lib/ip/ipRange";
import { logger } from "@app/lib/logger";
import { ActorType } from "@app/services/auth/auth-type";
import { TCertificateBodyDALFactory } from "@app/services/certificate/certificate-body-dal";
import { TCertificateProfileDALFactory } from "@app/services/certificate-profile/certificate-profile-dal";
import {
EnrollmentType,
TCertificateProfileWithConfigs
} from "@app/services/certificate-profile/certificate-profile-types";
import { TCertificateV3ServiceFactory } from "@app/services/certificate-v3/certificate-v3-service";
import { TKmsServiceFactory } from "@app/services/kms/kms-service";
import { TProjectDALFactory } from "@app/services/project/project-dal";
import { getProjectKmsCertificateKeyId } from "@app/services/project/project-fns";
import { TPkiAcmeAccountDALFactory } from "./pki-acme-account-dal";
import { TPkiAcmeAuthDALFactory } from "./pki-acme-auth-dal";
import { TPkiAcmeChallengeDALFactory } from "./pki-acme-challenge-dal";
import {
AcmeAccountDoesNotExistError,
AcmeBadCSRError,
AcmeBadNonceError,
AcmeBadPublicKeyError,
AcmeError,
AcmeExternalAccountRequiredError,
AcmeMalformedError,
AcmeOrderNotReadyError,
AcmeServerInternalError,
AcmeUnauthorizedError,
AcmeUnsupportedIdentifierError
} from "./pki-acme-errors";
import { buildUrl, extractAccountIdFromKid } from "./pki-acme-fns";
import { TPkiAcmeOrderAuthDALFactory } from "./pki-acme-order-auth-dal";
import { TPkiAcmeOrderDALFactory } from "./pki-acme-order-dal";
import {
AcmeAuthStatus,
AcmeChallengeStatus,
AcmeChallengeType,
AcmeIdentifierType,
AcmeOrderStatus,
CreateAcmeAccountBodySchema,
ProtectedHeaderSchema
} from "./pki-acme-schemas";
import {
TAcmeOrderResource,
TAcmeResponse,
TAuthenciatedJwsPayload,
TCreateAcmeAccountPayload,
TCreateAcmeAccountResponse,
TCreateAcmeOrderPayload,
TDeactivateAcmeAccountPayload,
TDeactivateAcmeAccountResponse,
TFinalizeAcmeOrderPayload,
TGetAcmeAuthorizationResponse,
TGetAcmeDirectoryResponse,
TJwsPayload,
TListAcmeOrdersResponse,
TPkiAcmeChallengeServiceFactory,
TPkiAcmeServiceFactory,
TRawJwsPayload,
TRespondToAcmeChallengeResponse
} from "./pki-acme-types";
type TPkiAcmeServiceFactoryDep = {
projectDAL: Pick<TProjectDALFactory, "findOne" | "updateById" | "transaction">;
certificateProfileDAL: Pick<TCertificateProfileDALFactory, "findByIdWithOwnerOrgId" | "findByIdWithConfigs">;
certificateBodyDAL: Pick<TCertificateBodyDALFactory, "findOne">;
acmeAccountDAL: Pick<
TPkiAcmeAccountDALFactory,
"findByProjectIdAndAccountId" | "findByProfileIdAndPublicKeyThumbprintAndAlg" | "create"
>;
acmeOrderDAL: Pick<
TPkiAcmeOrderDALFactory,
| "create"
| "transaction"
| "updateById"
| "findByAccountAndOrderIdWithAuthorizations"
| "findByIdForFinalization"
| "listByAccountId"
>;
acmeAuthDAL: Pick<TPkiAcmeAuthDALFactory, "create" | "findByAccountIdAndAuthIdWithChallenges">;
acmeOrderAuthDAL: Pick<TPkiAcmeOrderAuthDALFactory, "insertMany">;
acmeChallengeDAL: Pick<
TPkiAcmeChallengeDALFactory,
"create" | "transaction" | "updateById" | "findByAccountAuthAndChallengeId" | "findByIdForChallengeValidation"
>;
keyStore: Pick<TKeyStoreFactory, "getItem" | "setItemWithExpiry" | "deleteItem">;
kmsService: Pick<TKmsServiceFactory, "decryptWithKmsKey" | "generateKmsKey">;
certificateV3Service: Pick<TCertificateV3ServiceFactory, "signCertificateFromProfile">;
acmeChallengeService: TPkiAcmeChallengeServiceFactory;
};
export const pkiAcmeServiceFactory = ({
projectDAL,
certificateProfileDAL,
certificateBodyDAL,
acmeAccountDAL,
acmeOrderDAL,
acmeAuthDAL,
acmeOrderAuthDAL,
acmeChallengeDAL,
keyStore,
kmsService,
certificateV3Service,
acmeChallengeService
}: TPkiAcmeServiceFactoryDep): TPkiAcmeServiceFactory => {
const validateAcmeProfile = async (profileId: string): Promise<TCertificateProfileWithConfigs> => {
const profile = await certificateProfileDAL.findByIdWithConfigs(profileId);
if (!profile) {
throw new NotFoundError({ message: "Certificate profile not found" });
}
if (profile.enrollmentType !== EnrollmentType.ACME) {
throw new NotFoundError({ message: "Certificate profile is not configured for ACME enrollment" });
}
return profile;
};
const validateJwsPayload = async <
TSchema extends z.ZodSchema<unknown> | undefined = undefined,
T = TSchema extends z.ZodSchema<infer R> ? R : string
>({
url,
rawJwsPayload,
getJWK,
schema
}: {
url: URL;
rawJwsPayload: TRawJwsPayload;
getJWK: (protectedHeader: JWSHeaderParameters) => Promise<JsonWebKey>;
schema?: TSchema;
}): Promise<TJwsPayload<T>> => {
let result: FlattenedVerifyResult;
try {
result = await flattenedVerify(rawJwsPayload, async (protectedHeader: JWSHeaderParameters | undefined) => {
if (protectedHeader === undefined) {
throw new AcmeMalformedError({ detail: "Protected header is required" });
}
const jwk = await getJWK(protectedHeader);
const key = await importJWK(jwk, protectedHeader.alg);
return key;
});
} catch (error) {
if (error instanceof AcmeError) {
throw error;
}
if (error instanceof ZodError) {
throw new AcmeMalformedError({ detail: `Invalid JWS payload: ${error.message}` });
}
if (error instanceof errors.JWSSignatureVerificationFailed) {
throw new AcmeBadPublicKeyError({ detail: "Invalid JWS payload" });
}
logger.error(error, "Unexpected error while verifying JWS payload");
throw new AcmeServerInternalError({ detail: "Failed to verify JWS payload" });
}
const { protectedHeader: rawProtectedHeader, payload: rawPayload } = result;
try {
const protectedHeader = ProtectedHeaderSchema.parse(rawProtectedHeader);
// Validate the URL
if (new URL(protectedHeader.url).href !== url.href) {
throw new AcmeUnauthorizedError({ detail: "URL mismatch in the protected header" });
}
// Consume the nonce
if (!protectedHeader.nonce) {
throw new AcmeMalformedError({ detail: "Nonce is required in the protected header" });
}
const deleted = await keyStore.deleteItem(KeyStorePrefixes.PkiAcmeNonce(protectedHeader.nonce));
if (deleted !== 1) {
throw new AcmeBadNonceError({ detail: "Invalid nonce" });
}
// Parse the payload
const decoder = new TextDecoder();
const textPayload = decoder.decode(rawPayload);
const payload = schema ? schema.parse(JSON.parse(textPayload)) : textPayload;
return {
protectedHeader,
payload: payload as T
};
} catch (error) {
if (error instanceof AcmeError) {
throw error;
}
if (error instanceof ZodError) {
throw new AcmeMalformedError({ detail: `Invalid JWS payload: ${error.message}` });
}
logger.error(error, "Unexpected error while parsing JWS payload");
throw new AcmeServerInternalError({ detail: "Failed to verify JWS payload" });
}
};
const validateNewAccountJwsPayload = ({
url,
rawJwsPayload
}: {
url: URL;
rawJwsPayload: TRawJwsPayload;
}): Promise<TJwsPayload<TCreateAcmeAccountPayload>> => {
return validateJwsPayload({
url,
rawJwsPayload,
getJWK: async (protectedHeader) => {
if (!protectedHeader.jwk) {
throw new AcmeMalformedError({ detail: "JWK is required in the protected header" });
}
return protectedHeader.jwk as unknown as JsonWebKey;
},
schema: CreateAcmeAccountBodySchema
});
};
const validateExistingAccountJwsPayload = async <
// eslint-disable-next-line @typescript-eslint/no-explicit-any
TSchema extends z.ZodSchema<any> | undefined = undefined,
T = TSchema extends z.ZodSchema<infer R> ? R : string
>({
url,
profileId,
rawJwsPayload,
schema,
expectedAccountId
}: {
url: URL;
profileId: string;
rawJwsPayload: TRawJwsPayload;
schema?: TSchema;
expectedAccountId?: string;
}): Promise<TAuthenciatedJwsPayload<T>> => {
const profile = await validateAcmeProfile(profileId);
const result = await validateJwsPayload({
url,
rawJwsPayload,
getJWK: async (protectedHeader) => {
if (!protectedHeader.kid) {
throw new AcmeMalformedError({ detail: "KID is required in the protected header" });
}
const accountId = extractAccountIdFromKid(protectedHeader.kid, profileId);
if (expectedAccountId && accountId !== expectedAccountId) {
throw new NotFoundError({ message: "ACME resource not found" });
}
const account = await acmeAccountDAL.findByProjectIdAndAccountId(profile.id, accountId);
if (!account) {
throw new AcmeAccountDoesNotExistError({ message: "ACME account not found" });
}
if (account.alg !== protectedHeader.alg) {
throw new AcmeMalformedError({ detail: "ACME account algorithm mismatch" });
}
return account.publicKey as JsonWebKey;
},
schema
});
return {
...result,
accountId: extractAccountIdFromKid(result.protectedHeader.kid!, profileId),
profileId
};
};
const buildAcmeOrderResource = ({
profileId,
order
}: {
order: {
id: string;
status: string;
expiresAt: Date;
notBefore?: Date | null;
notAfter?: Date | null;
authorizations: {
id: string;
identifierType: string;
identifierValue: string;
expiresAt: Date;
}[];
};
profileId: string;
}): TAcmeOrderResource => {
return {
status: order.status,
expires: order.expiresAt.toISOString(),
notBefore: order.notBefore?.toISOString(),
notAfter: order.notAfter?.toISOString(),
identifiers: order.authorizations.map((auth) => ({
type: auth.identifierType,
value: auth.identifierValue
})),
authorizations: order.authorizations.map((auth) => buildUrl(profileId, `/authorizations/${auth.id}`)),
finalize: buildUrl(profileId, `/orders/${order.id}/finalize`),
certificate:
order.status === AcmeOrderStatus.Valid ? buildUrl(profileId, `/orders/${order.id}/certificate`) : undefined
};
};
const getAcmeDirectory = async (profileId: string): Promise<TGetAcmeDirectoryResponse> => {
const profile = await validateAcmeProfile(profileId);
return {
newNonce: buildUrl(profile.id, "/new-nonce"),
newAccount: buildUrl(profile.id, "/new-account"),
newOrder: buildUrl(profile.id, "/new-order")
};
};
const getAcmeNewNonce = async (profileId: string): Promise<string> => {
await validateAcmeProfile(profileId);
const nonce = crypto.randomBytes(32).toString("base64url");
const nonceKey = KeyStorePrefixes.PkiAcmeNonce(nonce);
await keyStore.setItemWithExpiry(
nonceKey,
// Expire in 5 minutes.
// TODO: read config from the profile to get the expiration time instead
60 * 5,
nonce
);
return nonce;
};
/** --------------------------------------------------------------
* ACME Account
* -------------------------------------------------------------- */
const createAcmeAccount = async ({
profileId,
alg,
jwk,
payload: { onlyReturnExisting, contact, externalAccountBinding }
}: {
profileId: string;
alg: string;
jwk: JsonWebKey;
payload: TCreateAcmeAccountPayload;
}): Promise<TAcmeResponse<TCreateAcmeAccountResponse>> => {
const profile = await validateAcmeProfile(profileId);
if (!externalAccountBinding) {
throw new AcmeExternalAccountRequiredError({ detail: "External account binding is required" });
}
const publicKeyThumbprint = await calculateJwkThumbprint(jwk, "sha256");
const certificateManagerKmsId = await getProjectKmsCertificateKeyId({
projectId: profile.projectId,
projectDAL,
kmsService
});
const kmsDecryptor = await kmsService.decryptWithKmsKey({
kmsId: certificateManagerKmsId
});
const eabSecret = await kmsDecryptor({ cipherTextBlob: profile.acmeConfig!.encryptedEabSecret! });
const { eabPayload, eabProtectedHeader } = await (async () => {
try {
const result = await flattenedVerify(externalAccountBinding, eabSecret);
return { eabPayload: result.payload, eabProtectedHeader: result.protectedHeader };
} catch (error) {
if (error instanceof errors.JWSSignatureVerificationFailed) {
throw new AcmeMalformedError({ detail: "Invalid external account binding JWS signature" });
}
logger.error(error, "Unexpected error while verifying EAB JWS signature");
throw new AcmeServerInternalError({ detail: "Failed to verify EAB JWS signature" });
}
})();
const { alg: eabAlg, kid: eabKid } = eabProtectedHeader!;
if (!["HS256", "HS384", "HS512"].includes(eabAlg!)) {
throw new AcmeMalformedError({ detail: "Invalid algorithm for external account binding JWS payload" });
}
// Make sure the KID in the EAB payload matches the profile ID
if (eabKid !== profile.id) {
throw new UnauthorizedError({ message: "External account binding KID mismatch" });
}
// Make sure the URL matches the expected URL
const url = eabProtectedHeader!.url!;
if (url !== buildUrl(profile.id, "/new-account")) {
throw new UnauthorizedError({ message: "External account binding URL mismatch" });
}
// Make sure the JWK in the EAB payload matches the one provided in the outer JWS payload
const decoder = new TextDecoder();
const decodedEabPayload = decoder.decode(eabPayload);
const eabJWK = JSON.parse(decodedEabPayload) as JsonWebKey;
const eabPayloadJwkThumbprint = await calculateJwkThumbprint(eabJWK, "sha256");
if (eabPayloadJwkThumbprint !== publicKeyThumbprint) {
throw new AcmeBadPublicKeyError({
message: "External account binding public key thumbprint or algorithm mismatch"
});
}
const existingAccount: TPkiAcmeAccounts | null = await acmeAccountDAL.findByProfileIdAndPublicKeyThumbprintAndAlg(
profileId,
alg,
publicKeyThumbprint
);
if (onlyReturnExisting && !existingAccount) {
throw new AcmeAccountDoesNotExistError({ message: "ACME account not found" });
}
if (existingAccount) {
// With the same public key, we found an existing account, just return it
return {
status: 200,
body: {
status: "valid",
contact: existingAccount.emails,
orders: buildUrl(profile.id, `/accounts/${existingAccount.id}/orders`)
},
headers: {
Location: buildUrl(profile.id, `/accounts/${existingAccount.id}`),
Link: `<${buildUrl(profile.id, "/directory")}>;rel="index"`
}
};
}
const newAccount = await acmeAccountDAL.create({
profileId: profile.id,
alg,
publicKey: jwk,
publicKeyThumbprint,
emails: contact ?? []
});
// TODO: create audit log here
return {
status: 201,
body: {
status: "valid",
contact: newAccount.emails,
orders: buildUrl(profile.id, `/accounts/${newAccount.id}/orders`)
},
headers: {
Location: buildUrl(profile.id, `/accounts/${newAccount.id}`),
Link: `<${buildUrl(profile.id, "/directory")}>;rel="index"`
}
};
};
const deactivateAcmeAccount = async ({
profileId,
accountId
}: {
profileId: string;
accountId: string;
payload?: TDeactivateAcmeAccountPayload;
}): Promise<TAcmeResponse<TDeactivateAcmeAccountResponse>> => {
await validateAcmeProfile(profileId);
// FIXME: Implement ACME account deactivation
return {
status: 200,
body: {
status: "deactivated"
},
headers: {
Location: buildUrl(profileId, `/accounts/${accountId}`),
Link: `<${buildUrl(profileId, "/directory")}>;rel="index"`
}
};
};
/** --------------------------------------------------------------
* ACME Order
* -------------------------------------------------------------- */
const createAcmeOrder = async ({
profileId,
accountId,
payload
}: {
profileId: string;
accountId: string;
payload: TCreateAcmeOrderPayload;
}): Promise<TAcmeResponse<TAcmeOrderResource>> => {
// TODO: check and see if we have existing orders for this account that meet the criteria
// if we do, return the existing order
// TODO: check the identifiers and see if are they even allowed for this profile.
// if not, we may be able to reject it early with an unsupportedIdentifier error.
const order = await acmeOrderDAL.transaction(async (tx) => {
const account = (await acmeAccountDAL.findByProjectIdAndAccountId(profileId, accountId))!;
const createdOrder = await acmeOrderDAL.create(
{
accountId: account.id,
status: AcmeOrderStatus.Pending,
notBefore: payload.notBefore ? new Date(payload.notBefore) : undefined,
notAfter: payload.notAfter ? new Date(payload.notAfter) : undefined,
// TODO: read config from the profile to get the expiration time instead
expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000)
},
tx
);
const authorizations: TPkiAcmeAuths[] = await Promise.all(
payload.identifiers.map(async (identifier) => {
if (identifier.type !== AcmeIdentifierType.DNS) {
throw new AcmeUnsupportedIdentifierError({ detail: "Only DNS identifiers are supported" });
}
if (isPrivateIp(identifier.value)) {
throw new AcmeUnsupportedIdentifierError({ detail: "Private IP addresses are not allowed" });
}
const auth = await acmeAuthDAL.create(
{
accountId: account.id,
status: AcmeAuthStatus.Pending,
identifierType: identifier.type,
identifierValue: identifier.value,
// RFC 8555 suggests a token with at least 128 bits of entropy
// We are using 256 bits of entropy here, should be enough for now
// ref: https://datatracker.ietf.org/doc/html/rfc8555#section-11.3
token: crypto.randomBytes(32).toString("base64url"),
// TODO: read config from the profile to get the expiration time instead
expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000)
},
tx
);
// TODO: support other challenge types here. Currently only HTTP-01 is supported.
await acmeChallengeDAL.create(
{
authId: auth.id,
status: AcmeChallengeStatus.Pending,
type: AcmeChallengeType.HTTP_01
},
tx
);
return auth;
})
);
await acmeOrderAuthDAL.insertMany(
authorizations.map((auth) => ({
orderId: createdOrder.id,
authId: auth.id
})),
tx
);
// TODO: create audit log here
return { ...createdOrder, authorizations, account };
});
return {
status: 201,
body: buildAcmeOrderResource({
profileId,
order
}),
headers: {
Location: buildUrl(profileId, `/orders/${order.id}`),
Link: `<${buildUrl(profileId, "/directory")}>;rel="index"`
}
};
};
const getAcmeOrder = async ({
profileId,
accountId,
orderId
}: {
profileId: string;
accountId: string;
orderId: string;
}): Promise<TAcmeResponse<TAcmeOrderResource>> => {
const order = await acmeOrderDAL.findByAccountAndOrderIdWithAuthorizations(accountId, orderId);
if (!order) {
throw new NotFoundError({ message: "ACME order not found" });
}
return {
status: 200,
body: buildAcmeOrderResource({ profileId, order }),
headers: {
Location: buildUrl(profileId, `/orders/${orderId}`),
Link: `<${buildUrl(profileId, "/directory")}>;rel="index"`
}
};
};
const finalizeAcmeOrder = async ({
profileId,
accountId,
orderId,
payload
}: {
profileId: string;
accountId: string;
orderId: string;
payload: TFinalizeAcmeOrderPayload;
}): Promise<TAcmeResponse<TAcmeOrderResource>> => {
let order = await acmeOrderDAL.findByAccountAndOrderIdWithAuthorizations(accountId, orderId);
if (!order) {
throw new NotFoundError({ message: "ACME order not found" });
}
if (order.status === AcmeOrderStatus.Ready) {
const { order: updatedOrder, error } = await acmeOrderDAL.transaction(async (tx) => {
const finalizingOrder = (await acmeOrderDAL.findByIdForFinalization(orderId, tx))!;
// TODO: ideally, this should be doen with onRequest: verifyAuth([AuthMode.ACME_JWS_SIGNATURE]), instead?
const { ownerOrgId: actorOrgId } = (await certificateProfileDAL.findByIdWithOwnerOrgId(profileId, tx))!;
if (finalizingOrder.status !== AcmeOrderStatus.Ready) {
throw new AcmeOrderNotReadyError({ message: "ACME order is not ready" });
}
if (finalizingOrder.expiresAt < new Date()) {
throw new AcmeOrderNotReadyError({ message: "ACME order has expired" });
}
const { csr } = payload;
let errorToReturn: Error | undefined;
try {
const { certificateId } = await certificateV3Service.signCertificateFromProfile({
actor: ActorType.ACME_ACCOUNT,
actorId: accountId,
actorAuthMethod: null,
actorOrgId,
profileId,
csr,
notBefore: finalizingOrder.notBefore ? new Date(finalizingOrder.notBefore) : undefined,
notAfter: finalizingOrder.notAfter ? new Date(finalizingOrder.notAfter) : undefined,
validity: !finalizingOrder.notAfter
? {
// TODO: read config from the profile to get the expiration time instead
ttl: (24 * 60 * 60 * 1000).toString()
}
: // ttl is not used if notAfter is provided
({ ttl: "0" } as const),
enrollmentType: EnrollmentType.ACME
});
// TODO: associate the certificate with the order
await acmeOrderDAL.updateById(
orderId,
{
status: AcmeOrderStatus.Valid,
csr,
certificateId
},
tx
);
} catch (exp) {
await acmeOrderDAL.updateById(
orderId,
{
csr,
status: AcmeOrderStatus.Invalid,
error: exp instanceof Error ? exp.message : "Unknown error"
},
tx
);
logger.error(exp, "Failed to sign certificate");
// TODO: audit log the error
if (exp instanceof BadRequestError) {
errorToReturn = new AcmeBadCSRError({ detail: `Invalid CSR: ${exp.message}` });
} else {
errorToReturn = new AcmeServerInternalError({ detail: "Failed to sign certificate with internal error" });
}
}
return {
order: (await acmeOrderDAL.findByAccountAndOrderIdWithAuthorizations(accountId, orderId, tx))!,
error: errorToReturn
};
});
if (error) {
throw error;
}
order = updatedOrder;
} else if (order.status !== AcmeOrderStatus.Valid) {
throw new AcmeOrderNotReadyError({ message: "ACME order is not ready" });
}
return {
status: 200,
body: buildAcmeOrderResource({ profileId, order }),
headers: {
Location: buildUrl(profileId, `/orders/${orderId}`),
Link: `<${buildUrl(profileId, "/directory")}>;rel="index"`
}
};
};
const downloadAcmeCertificate = async ({
profileId,
accountId,
orderId
}: {
profileId: string;
accountId: string;
orderId: string;
}): Promise<TAcmeResponse<string>> => {
const profile = await validateAcmeProfile(profileId);
const order = await acmeOrderDAL.findByAccountAndOrderIdWithAuthorizations(accountId, orderId);
if (!order) {
throw new NotFoundError({ message: "ACME order not found" });
}
if (order.status !== AcmeOrderStatus.Valid) {
throw new AcmeOrderNotReadyError({ message: "ACME order is not valid" });
}
if (!order.certificateId) {
throw new NotFoundError({ message: "The certificate for this ACME order no longer exists" });
}
const certBody = await certificateBodyDAL.findOne({ certId: order.certificateId });
const certificateManagerKeyId = await getProjectKmsCertificateKeyId({
projectId: profile.projectId,
projectDAL,
kmsService
});
const kmsDecryptor = await kmsService.decryptWithKmsKey({
kmsId: certificateManagerKeyId
});
const decryptedCert = await kmsDecryptor({
cipherTextBlob: certBody.encryptedCertificate
});
const certObj = new x509.X509Certificate(decryptedCert);
const decryptedCertChain = await kmsDecryptor({
cipherTextBlob: certBody.encryptedCertificateChain!
});
const certificateChain = decryptedCertChain.toString();
const certLeaf = certObj.toString("pem").trim().replace("\n", "\r\n");
const certChain = certificateChain.trim().replace("\n", "\r\n");
return {
status: 200,
body:
// The final line is needed, otherwise some clients will not parse the certificate chain correctly
// ref: https://github.com/certbot/certbot/blob/4d5d5f7ae8164884c841969e46caed8db1ad34af/certbot/src/certbot/crypto_util.py#L506-L514
`${certLeaf}\r\n${certChain}\r\n`,
headers: {
Location: buildUrl(profileId, `/orders/${orderId}/certificate`),
Link: `<${buildUrl(profileId, "/directory")}>;rel="index"`
}
};
};
const listAcmeOrders = async ({
profileId,
accountId
}: {
profileId: string;
accountId: string;
}): Promise<TAcmeResponse<TListAcmeOrdersResponse>> => {
const orders = await acmeOrderDAL.listByAccountId(accountId);
return {
status: 200,
body: { orders: orders.map((order) => buildUrl(profileId, `/orders/${order.id}`)) },
headers: {
Link: `<${buildUrl(profileId, "/directory")}>;rel="index"`
}
};
};
/** --------------------------------------------------------------
* ACME Authorization
* -------------------------------------------------------------- */
const getAcmeAuthorization = async ({
profileId,
accountId,
authzId
}: {
profileId: string;
accountId: string;
authzId: string;
}): Promise<TAcmeResponse<TGetAcmeAuthorizationResponse>> => {
const auth = await acmeAuthDAL.findByAccountIdAndAuthIdWithChallenges(accountId, authzId);
if (!auth) {
throw new NotFoundError({ message: "ACME authorization not found" });
}
return {
status: 200,
body: {
status: auth.status,
expires: auth.expiresAt.toISOString(),
identifier: {
type: auth.identifierType,
value: auth.identifierValue
},
challenges: auth.challenges.map((challenge) => {
return {
type: challenge.type,
url: buildUrl(profileId, `/authorizations/${authzId}/challenges/${challenge.id}`),
status: challenge.status,
token: auth.token!
};
})
},
headers: {
Location: buildUrl(profileId, `/authorizations/${authzId}`),
Link: `<${buildUrl(profileId, "/directory")}>;rel="index"`
}
};
};
const respondToAcmeChallenge = async ({
profileId,
accountId,
authzId,
challengeId
}: {
profileId: string;
accountId: string;
authzId: string;
challengeId: string;
}): Promise<TAcmeResponse<TRespondToAcmeChallengeResponse>> => {
const result = await acmeChallengeDAL.findByAccountAuthAndChallengeId(accountId, authzId, challengeId);
if (!result) {
throw new NotFoundError({ message: "ACME challenge not found" });
}
await acmeChallengeService.validateChallengeResponse(challengeId);
const challenge = (await acmeChallengeDAL.findByIdForChallengeValidation(challengeId))!;
return {
status: 200,
body: {
type: challenge.type,
url: buildUrl(profileId, `/authorizations/${authzId}/challenges/${challengeId}`),
status: challenge.status,
token: challenge.auth.token!
},
headers: {
Location: buildUrl(profileId, `/authorizations/${authzId}/challenges/${challengeId}`),
Link: [
`<${buildUrl(profileId, `/authorizations/${authzId}`)}>;rel="up"`,
`<${buildUrl(profileId, "/directory")}>;rel="index"`
]
}
};
};
return {
validateJwsPayload,
validateNewAccountJwsPayload,
validateExistingAccountJwsPayload,
getAcmeDirectory,
getAcmeNewNonce,
createAcmeAccount,
createAcmeOrder,
deactivateAcmeAccount,
listAcmeOrders,
getAcmeOrder,
finalizeAcmeOrder,
downloadAcmeCertificate,
getAcmeAuthorization,
respondToAcmeChallenge
};
};
@@ -0,0 +1,180 @@
import { JWSHeaderParameters } from "jose";
import { z } from "zod";
import {
AcmeOrderResourceSchema,
CreateAcmeAccountBodySchema,
CreateAcmeAccountResponseSchema,
CreateAcmeOrderBodySchema,
DeactivateAcmeAccountBodySchema,
DeactivateAcmeAccountResponseSchema,
FinalizeAcmeOrderBodySchema,
GetAcmeAuthorizationResponseSchema,
GetAcmeDirectoryResponseSchema,
ListAcmeOrdersResponseSchema,
ProtectedHeaderSchema,
RawJwsPayloadSchema,
RespondToAcmeChallengeResponseSchema
} from "./pki-acme-schemas";
export type TGetAcmeDirectoryResponse = z.infer<typeof GetAcmeDirectoryResponseSchema>;
export type TCreateAcmeAccountResponse = z.infer<typeof CreateAcmeAccountResponseSchema>;
export type TAcmeOrderResource = z.infer<typeof AcmeOrderResourceSchema>;
export type TDeactivateAcmeAccountResponse = z.infer<typeof DeactivateAcmeAccountResponseSchema>;
export type TListAcmeOrdersResponse = z.infer<typeof ListAcmeOrdersResponseSchema>;
export type TDownloadAcmeCertificateDTO = string;
export type TGetAcmeAuthorizationResponse = z.infer<typeof GetAcmeAuthorizationResponseSchema>;
export type TRespondToAcmeChallengeResponse = z.infer<typeof RespondToAcmeChallengeResponseSchema>;
// Payload types
export type TRawJwsPayload = z.infer<typeof RawJwsPayloadSchema>;
export type TProtectedHeader = z.infer<typeof ProtectedHeaderSchema>;
export type TCreateAcmeAccountPayload = z.infer<typeof CreateAcmeAccountBodySchema>;
export type TCreateAcmeOrderPayload = z.infer<typeof CreateAcmeOrderBodySchema>;
export type TDeactivateAcmeAccountPayload = z.infer<typeof DeactivateAcmeAccountBodySchema>;
export type TFinalizeAcmeOrderPayload = z.infer<typeof FinalizeAcmeOrderBodySchema>;
export type TJwsPayload<T> = {
protectedHeader: TProtectedHeader;
payload: T;
};
export type TAuthenciatedJwsPayload<T> = TJwsPayload<T> & {
profileId: string;
accountId: string;
};
export type TAcmeResponse<TPayload> = {
status: number;
headers: Record<string, string | string[]>;
body: TPayload;
};
export type TPkiAcmeServiceFactory = {
validateJwsPayload: <
TSchema extends z.ZodSchema<unknown> | undefined = undefined,
T = TSchema extends z.ZodSchema<infer R> ? R : string
>({
url,
rawJwsPayload,
getJWK,
schema
}: {
url: URL;
rawJwsPayload: TRawJwsPayload;
getJWK: (protectedHeader: JWSHeaderParameters) => Promise<JsonWebKey>;
schema?: TSchema;
}) => Promise<TJwsPayload<T>>;
validateNewAccountJwsPayload: ({
url,
rawJwsPayload
}: {
url: URL;
rawJwsPayload: TRawJwsPayload;
}) => Promise<TJwsPayload<TCreateAcmeAccountPayload>>;
validateExistingAccountJwsPayload: <
TSchema extends z.ZodSchema<unknown> | undefined = undefined,
T = TSchema extends z.ZodSchema<infer R> ? R : string
>({
url,
profileId,
rawJwsPayload,
schema,
expectedAccountId
}: {
url: URL;
profileId: string;
rawJwsPayload: TRawJwsPayload;
schema?: TSchema;
expectedAccountId?: string;
}) => Promise<TAuthenciatedJwsPayload<T>>;
getAcmeDirectory: (profileId: string) => Promise<TGetAcmeDirectoryResponse>;
getAcmeNewNonce: (profileId: string) => Promise<string>;
createAcmeAccount: ({
profileId,
alg,
jwk,
payload
}: {
profileId: string;
alg: string;
jwk: JsonWebKey;
payload: TCreateAcmeAccountPayload;
}) => Promise<TAcmeResponse<TCreateAcmeAccountResponse>>;
deactivateAcmeAccount: ({
profileId,
accountId,
payload
}: {
profileId: string;
accountId: string;
payload?: TDeactivateAcmeAccountPayload;
}) => Promise<TAcmeResponse<TDeactivateAcmeAccountResponse>>;
createAcmeOrder: ({
profileId,
accountId,
payload
}: {
profileId: string;
accountId: string;
payload: TCreateAcmeOrderPayload;
}) => Promise<TAcmeResponse<TAcmeOrderResource>>;
getAcmeOrder: ({
profileId,
accountId,
orderId
}: {
profileId: string;
accountId: string;
orderId: string;
}) => Promise<TAcmeResponse<TAcmeOrderResource>>;
finalizeAcmeOrder: ({
profileId,
accountId,
orderId,
payload
}: {
profileId: string;
accountId: string;
orderId: string;
payload: TFinalizeAcmeOrderPayload;
}) => Promise<TAcmeResponse<TAcmeOrderResource>>;
downloadAcmeCertificate: ({
profileId,
accountId,
orderId
}: {
profileId: string;
accountId: string;
orderId: string;
}) => Promise<TAcmeResponse<string>>;
listAcmeOrders: ({
profileId,
accountId
}: {
profileId: string;
accountId: string;
}) => Promise<TAcmeResponse<TListAcmeOrdersResponse>>;
getAcmeAuthorization: ({
profileId,
accountId,
authzId
}: {
profileId: string;
accountId: string;
authzId: string;
}) => Promise<TAcmeResponse<TGetAcmeAuthorizationResponse>>;
respondToAcmeChallenge: ({
profileId,
accountId,
authzId,
challengeId
}: {
profileId: string;
accountId: string;
authzId: string;
challengeId: string;
}) => Promise<TAcmeResponse<TRespondToAcmeChallengeResponse>>;
};
export type TPkiAcmeChallengeServiceFactory = {
validateChallengeResponse: (challengeId: string) => Promise<void>;
};
@@ -8,6 +8,7 @@ import { BadRequestError, NotFoundError } from "@app/lib/errors";
import { removeTrailingSlash } from "@app/lib/fn";
import { containsGlobPatterns } from "@app/lib/picomatch";
import { TProjectEnvDALFactory } from "@app/services/project-env/project-env-dal";
import { TProjectMembershipDALFactory } from "@app/services/project-membership/project-membership-dal";
import { TUserDALFactory } from "@app/services/user/user-dal";
import { ApproverType, BypasserType } from "../access-approval-policy/access-approval-policy-types";
@@ -39,6 +40,7 @@ type TSecretApprovalPolicyServiceFactoryDep = {
secretApprovalPolicyDAL: TSecretApprovalPolicyDALFactory;
projectEnvDAL: Pick<TProjectEnvDALFactory, "findOne" | "find">;
userDAL: Pick<TUserDALFactory, "find">;
projectMembershipDAL: Pick<TProjectMembershipDALFactory, "findProjectMembershipsByUserIds">;
secretApprovalPolicyApproverDAL: TSecretApprovalPolicyApproverDALFactory;
secretApprovalPolicyBypasserDAL: TSecretApprovalPolicyBypasserDALFactory;
licenseService: Pick<TLicenseServiceFactory, "getPlan">;
@@ -56,9 +58,25 @@ export const secretApprovalPolicyServiceFactory = ({
secretApprovalPolicyEnvironmentDAL,
projectEnvDAL,
userDAL,
projectMembershipDAL,
licenseService,
secretApprovalRequestDAL
}: TSecretApprovalPolicyServiceFactoryDep) => {
const verifyProjectUserMembership = async (userIds: string[], orgId: string, projectId: string) => {
if (userIds.length === 0) return;
const projectMemberships = (await projectMembershipDAL.findProjectMembershipsByUserIds(orgId, userIds)).filter(
(v) => v.projectId === projectId
);
if (projectMemberships.length !== userIds.length) {
const projectMemberUserIds = new Set(projectMemberships.map((member) => member.userId));
const userIdsNotInProject = userIds.filter((id) => !projectMemberUserIds.has(id));
throw new BadRequestError({
message: `Some users are not members of the project: ${userIdsNotInProject.join(", ")}`
});
}
};
const $policyExists = async ({
envIds,
envId,
@@ -202,6 +220,7 @@ export const secretApprovalPolicyServiceFactory = ({
},
tx
);
await secretApprovalPolicyEnvironmentDAL.insertMany(
envs.map((env) => ({
envId: env.id,
@@ -233,6 +252,8 @@ export const secretApprovalPolicyServiceFactory = ({
userApproverIds = userApproverIds.concat(approverUsers.map((user) => user.id));
}
await verifyProjectUserMembership(userApproverIds, actorOrgId, projectId);
await secretApprovalPolicyApproverDAL.insertMany(
userApproverIds.map((approverUserId) => ({
approverUserId,
@@ -250,6 +271,8 @@ export const secretApprovalPolicyServiceFactory = ({
);
if (bypasserUserIds.length) {
await verifyProjectUserMembership(bypasserUserIds, actorOrgId, projectId);
await secretApprovalPolicyBypasserDAL.insertMany(
bypasserUserIds.map((userId) => ({
bypasserUserId: userId,
@@ -425,6 +448,8 @@ export const secretApprovalPolicyServiceFactory = ({
userApproverIds = userApproverIds.concat(approverUsers.map((user) => user.id));
}
await verifyProjectUserMembership(userApproverIds, actorOrgId, secretApprovalPolicy.projectId);
await secretApprovalPolicyApproverDAL.insertMany(
userApproverIds.map((approverUserId) => ({
approverUserId,
@@ -458,6 +483,8 @@ export const secretApprovalPolicyServiceFactory = ({
await secretApprovalPolicyBypasserDAL.delete({ policyId: doc.id }, tx);
if (bypasserUserIds.length) {
await verifyProjectUserMembership(bypasserUserIds, actorOrgId, secretApprovalPolicy.projectId);
await secretApprovalPolicyBypasserDAL.insertMany(
bypasserUserIds.map((userId) => ({
bypasserUserId: userId,
@@ -166,6 +166,7 @@ export const secretApprovalRequestDALFactory = (db: TDbClient) => {
tx.ref("reviewerUserId").withSchema(TableName.SecretApprovalRequestReviewer),
tx.ref("status").withSchema(TableName.SecretApprovalRequestReviewer).as("reviewerStatus"),
tx.ref("comment").withSchema(TableName.SecretApprovalRequestReviewer).as("reviewerComment"),
tx.ref("createdAt").withSchema(TableName.SecretApprovalRequestReviewer).as("reviewerCreatedAt"),
tx.ref("email").withSchema("secretApprovalReviewerUser").as("reviewerEmail"),
tx.ref("username").withSchema("secretApprovalReviewerUser").as("reviewerUsername"),
tx.ref("firstName").withSchema("secretApprovalReviewerUser").as("reviewerFirstName"),
@@ -240,6 +241,7 @@ export const secretApprovalRequestDALFactory = (db: TDbClient) => {
reviewerUsername: username,
reviewerFirstName: firstName,
reviewerComment: comment,
reviewerCreatedAt: createdAt,
reviewerIsOrgMembershipActive: isOrgMembershipActive
}) =>
userId
@@ -251,6 +253,7 @@ export const secretApprovalRequestDALFactory = (db: TDbClient) => {
lastName,
username,
comment: comment ?? "",
createdAt,
isOrgMembershipActive
}
: undefined
@@ -37,7 +37,7 @@ export const sendApprovalEmailsFn = async ({
type: NotificationType.SECRET_CHANGE_REQUEST,
title: "Secret Change Request",
body: `You have a new secret change request pending your review for the project **${project.name}** in the organization **${project.organization.name}**.`,
link: `/projects/secret-management/${project.id}/approval?requestId=${secretApprovalRequest.id}`
link: `/projects/secret-management/${project.id}/approval`
}))
);
@@ -51,7 +51,7 @@ export const sendApprovalEmailsFn = async ({
firstName: reviewerUser.firstName,
projectName: project.name,
organizationName: project.organization.name,
approvalUrl: `${cfg.SITE_URL}/projects/secret-management/${project.id}/approval?requestId=${secretApprovalRequest.id}`
approvalUrl: `${cfg.SITE_URL}/projects/secret-management/${project.id}/approval}`
},
template: SmtpTemplates.SecretApprovalRequestNeedsReview
});
@@ -1416,6 +1416,11 @@ export const secretApprovalRequestServiceFactory = ({
const env = await projectEnvDAL.findOne({ id: policy.envId });
const user = await userDAL.findById(actorId);
const projectPath = `/projects/secret-management/${projectId}`;
const approvalPath = `${projectPath}/approval`;
const cfg = getConfig();
const approvalUrl = `${cfg.SITE_URL}${approvalPath}`;
await triggerWorkflowIntegrationNotification({
input: {
projectId,
@@ -1427,7 +1432,8 @@ export const secretApprovalRequestServiceFactory = ({
secretPath,
projectId,
requestId: secretApprovalRequest.id,
secretKeys: [...new Set(Object.values(data).flatMap((arr) => arr?.map((item) => item.secretName) ?? []))]
secretKeys: [...new Set(Object.values(data).flatMap((arr) => arr?.map((item) => item.secretName) ?? []))],
approvalUrl
}
}
},
@@ -1786,6 +1792,11 @@ export const secretApprovalRequestServiceFactory = ({
const user = await userDAL.findById(actorId);
const env = await projectEnvDAL.findOne({ id: policy.envId });
const projectPath = `/projects/secret-management/${project.id}`;
const approvalPath = `${projectPath}/approval`;
const cfg = getConfig();
const approvalUrl = `${cfg.SITE_URL}${approvalPath}`;
await triggerWorkflowIntegrationNotification({
input: {
projectId,
@@ -1797,7 +1808,8 @@ export const secretApprovalRequestServiceFactory = ({
secretPath,
projectId,
requestId: secretApprovalRequest.id,
secretKeys: [...new Set(Object.values(data).flatMap((arr) => arr?.map((item) => item.secretKey) ?? []))]
secretKeys: [...new Set(Object.values(data).flatMap((arr) => arr?.map((item) => item.secretKey) ?? []))],
approvalUrl
}
}
},