mirror of
https://github.com/awatertrevi/infisical.git
synced 2026-10-19 17:32:37 +00:00
Merge branch 'main' into feature/ldap-gateway-support
This commit is contained in:
@@ -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()
|
||||
})
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user