mirror of
https://github.com/awatertrevi/infisical.git
synced 2026-10-19 11:42:52 +00:00
Merge branch 'main' into fix/docs-connections-tab-title
This commit is contained in:
@@ -435,14 +435,7 @@ export const registerPITRouter = async (server: FastifyZodProvider) => {
|
||||
projectId: z.string().trim(),
|
||||
environment: z.string().trim(),
|
||||
secretPath: z.string().trim().default("/").transform(removeTrailingSlash),
|
||||
message: z
|
||||
.string()
|
||||
.trim()
|
||||
.min(1)
|
||||
.max(255)
|
||||
.refine((message) => message.trim() !== "", {
|
||||
message: "Commit message cannot be empty"
|
||||
}),
|
||||
message: z.string().trim().max(255).optional(),
|
||||
changes: z.object({
|
||||
secrets: z.object({
|
||||
create: z
|
||||
@@ -546,7 +539,7 @@ export const registerPITRouter = async (server: FastifyZodProvider) => {
|
||||
projectId: req.body.projectId,
|
||||
environment: req.body.environment,
|
||||
secretPath: req.body.secretPath,
|
||||
message: req.body.message,
|
||||
message: req.body.message || "",
|
||||
changes: {
|
||||
secrets: req.body.changes.secrets,
|
||||
folders: req.body.changes.folders
|
||||
@@ -564,7 +557,7 @@ export const registerPITRouter = async (server: FastifyZodProvider) => {
|
||||
projectId: req.body.projectId,
|
||||
environment: req.body.environment,
|
||||
secretPath: req.body.secretPath,
|
||||
message: req.body.message
|
||||
message: req.body.message || ""
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
import { FastifyReply, FastifyRequest } from "fastify";
|
||||
import { z } from "zod";
|
||||
|
||||
import { AcmeMalformedError } from "@app/ee/services/pki-acme/pki-acme-errors";
|
||||
import {
|
||||
AcmeOrderResourceSchema,
|
||||
CreateAcmeAccountResponseSchema,
|
||||
@@ -257,12 +256,9 @@ export const registerPkiAcmeRouter = async (server: FastifyZodProvider) => {
|
||||
}
|
||||
},
|
||||
handler: async (req, res) => {
|
||||
const { profileId, accountId, payload } = await validateExistingAccount({
|
||||
const { profileId, accountId } = await validateExistingAccount({
|
||||
req
|
||||
});
|
||||
if (payload !== "") {
|
||||
throw new AcmeMalformedError({ message: "Payload should be empty" });
|
||||
}
|
||||
return sendAcmeResponse(
|
||||
res,
|
||||
profileId,
|
||||
@@ -369,12 +365,9 @@ export const registerPkiAcmeRouter = async (server: FastifyZodProvider) => {
|
||||
}
|
||||
},
|
||||
handler: async (req, res) => {
|
||||
const { profileId, accountId, payload } = await validateExistingAccount({
|
||||
const { profileId, accountId } = await validateExistingAccount({
|
||||
req
|
||||
});
|
||||
if (payload !== "") {
|
||||
throw new AcmeMalformedError({ message: "Payload should be empty" });
|
||||
}
|
||||
res.type("application/pem-certificate-chain");
|
||||
return sendAcmeResponse(
|
||||
res,
|
||||
@@ -405,10 +398,7 @@ export const registerPkiAcmeRouter = async (server: FastifyZodProvider) => {
|
||||
}
|
||||
},
|
||||
handler: async (req, res) => {
|
||||
const { profileId, accountId, payload } = await validateExistingAccount({ req });
|
||||
if (payload !== "") {
|
||||
throw new AcmeMalformedError({ message: "Payload should be empty" });
|
||||
}
|
||||
const { profileId, accountId } = await validateExistingAccount({ req });
|
||||
return sendAcmeResponse(
|
||||
res,
|
||||
profileId,
|
||||
|
||||
@@ -76,7 +76,9 @@ export const pkiAcmeChallengeServiceFactory = ({
|
||||
// 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" });
|
||||
throw new AcmeIncorrectResponseError({
|
||||
message: `ACME challenge response is not 200: ${challengeResponse.status}`
|
||||
});
|
||||
}
|
||||
const challengeResponseBody = await challengeResponse.text();
|
||||
const thumbprint = challenge.auth.account.publicKeyThumbprint;
|
||||
@@ -107,6 +109,7 @@ export const pkiAcmeChallengeServiceFactory = ({
|
||||
if (fetchError.code === "ENOTFOUND" || fetchError.message.includes("ENOTFOUND")) {
|
||||
return new AcmeDnsFailureError({ message: "Hostname could not be resolved (DNS failure)" });
|
||||
}
|
||||
logger.error(exp, "Unknown error validating ACME challenge response");
|
||||
return new AcmeServerInternalError({ message: "Unknown error validating ACME challenge response" });
|
||||
}
|
||||
} else if (exp instanceof DOMException) {
|
||||
|
||||
@@ -468,7 +468,7 @@ export class AcmeOrderNotReadyError extends AcmeError {
|
||||
super({
|
||||
type: AcmeErrorType.OrderNotReady,
|
||||
message,
|
||||
status: 403,
|
||||
status: 400,
|
||||
error
|
||||
});
|
||||
this.name = "AcmeOrderNotReadyError";
|
||||
|
||||
@@ -17,8 +17,21 @@ import { crypto } from "@app/lib/crypto/cryptography";
|
||||
import { BadRequestError, NotFoundError } from "@app/lib/errors";
|
||||
import { isPrivateIp } from "@app/lib/ip/ipRange";
|
||||
import { logger } from "@app/lib/logger";
|
||||
import { TAppConnectionDALFactory } from "@app/services/app-connection/app-connection-dal";
|
||||
import { ActorType } from "@app/services/auth/auth-type";
|
||||
import { TCertificateBodyDALFactory } from "@app/services/certificate/certificate-body-dal";
|
||||
import { TCertificateDALFactory } from "@app/services/certificate/certificate-dal";
|
||||
import { TCertificateSecretDALFactory } from "@app/services/certificate/certificate-secret-dal";
|
||||
import {
|
||||
CertExtendedKeyUsage,
|
||||
CertKeyUsage,
|
||||
CertSubjectAlternativeNameType
|
||||
} from "@app/services/certificate/certificate-types";
|
||||
import { orderCertificate } from "@app/services/certificate-authority/acme/acme-certificate-authority-fns";
|
||||
import { TCertificateAuthorityDALFactory } from "@app/services/certificate-authority/certificate-authority-dal";
|
||||
import { CaType } from "@app/services/certificate-authority/certificate-authority-enums";
|
||||
import { TExternalCertificateAuthorityDALFactory } from "@app/services/certificate-authority/external-certificate-authority-dal";
|
||||
import { extractCertificateRequestFromCSR } from "@app/services/certificate-common/certificate-csr-utils";
|
||||
import { TCertificateProfileDALFactory } from "@app/services/certificate-profile/certificate-profile-dal";
|
||||
import {
|
||||
EnrollmentType,
|
||||
@@ -79,9 +92,14 @@ import {
|
||||
} from "./pki-acme-types";
|
||||
|
||||
type TPkiAcmeServiceFactoryDep = {
|
||||
projectDAL: Pick<TProjectDALFactory, "findOne" | "updateById" | "transaction">;
|
||||
projectDAL: Pick<TProjectDALFactory, "findOne" | "updateById" | "transaction" | "findById">;
|
||||
appConnectionDAL: Pick<TAppConnectionDALFactory, "findById">;
|
||||
certificateDAL: Pick<TCertificateDALFactory, "create" | "transaction">;
|
||||
certificateAuthorityDAL: Pick<TCertificateAuthorityDALFactory, "findByIdWithAssociatedCa">;
|
||||
externalCertificateAuthorityDAL: Pick<TExternalCertificateAuthorityDALFactory, "update">;
|
||||
certificateProfileDAL: Pick<TCertificateProfileDALFactory, "findByIdWithOwnerOrgId" | "findByIdWithConfigs">;
|
||||
certificateBodyDAL: Pick<TCertificateBodyDALFactory, "findOne">;
|
||||
certificateBodyDAL: Pick<TCertificateBodyDALFactory, "findOne" | "create">;
|
||||
certificateSecretDAL: Pick<TCertificateSecretDALFactory, "findOne" | "create">;
|
||||
acmeAccountDAL: Pick<
|
||||
TPkiAcmeAccountDALFactory,
|
||||
"findByProjectIdAndAccountId" | "findByProfileIdAndPublicKeyThumbprintAndAlg" | "create"
|
||||
@@ -102,7 +120,10 @@ type TPkiAcmeServiceFactoryDep = {
|
||||
"create" | "transaction" | "updateById" | "findByAccountAuthAndChallengeId" | "findByIdForChallengeValidation"
|
||||
>;
|
||||
keyStore: Pick<TKeyStoreFactory, "getItem" | "setItemWithExpiry" | "deleteItem">;
|
||||
kmsService: Pick<TKmsServiceFactory, "decryptWithKmsKey" | "generateKmsKey">;
|
||||
kmsService: Pick<
|
||||
TKmsServiceFactory,
|
||||
"decryptWithKmsKey" | "generateKmsKey" | "encryptWithKmsKey" | "createCipherPairWithDataKey"
|
||||
>;
|
||||
licenseService: Pick<TLicenseServiceFactory, "getPlan">;
|
||||
certificateV3Service: Pick<TCertificateV3ServiceFactory, "signCertificateFromProfile">;
|
||||
acmeChallengeService: TPkiAcmeChallengeServiceFactory;
|
||||
@@ -110,8 +131,13 @@ type TPkiAcmeServiceFactoryDep = {
|
||||
|
||||
export const pkiAcmeServiceFactory = ({
|
||||
projectDAL,
|
||||
appConnectionDAL,
|
||||
certificateDAL,
|
||||
certificateAuthorityDAL,
|
||||
externalCertificateAuthorityDAL,
|
||||
certificateProfileDAL,
|
||||
certificateBodyDAL,
|
||||
certificateSecretDAL,
|
||||
acmeAccountDAL,
|
||||
acmeOrderDAL,
|
||||
acmeAuthDAL,
|
||||
@@ -622,6 +648,7 @@ export const pkiAcmeServiceFactory = ({
|
||||
orderId: string;
|
||||
payload: TFinalizeAcmeOrderPayload;
|
||||
}): Promise<TAcmeResponse<TAcmeOrderResource>> => {
|
||||
const profile = (await certificateProfileDAL.findByIdWithConfigs(profileId))!;
|
||||
let order = await acmeOrderDAL.findByAccountAndOrderIdWithAuthorizations(accountId, orderId);
|
||||
if (!order) {
|
||||
throw new NotFoundError({ message: "ACME order not found" });
|
||||
@@ -637,29 +664,100 @@ export const pkiAcmeServiceFactory = ({
|
||||
if (finalizingOrder.expiresAt < new Date()) {
|
||||
throw new AcmeOrderNotReadyError({ message: "ACME order has expired" });
|
||||
}
|
||||
|
||||
const { csr } = payload;
|
||||
|
||||
// Check and validate the CSR
|
||||
const certificateRequest = extractCertificateRequestFromCSR(csr);
|
||||
if (!certificateRequest.commonName) {
|
||||
throw new AcmeBadCSRError({ message: "Invalid CSR: Common name is required" });
|
||||
}
|
||||
if (
|
||||
certificateRequest.subjectAlternativeNames?.some(
|
||||
(san) => san.type !== CertSubjectAlternativeNameType.DNS_NAME
|
||||
)
|
||||
) {
|
||||
throw new AcmeBadCSRError({ message: "Invalid CSR: Only DNS subject alternative names are supported" });
|
||||
}
|
||||
const orderWithAuthorizations = (await acmeOrderDAL.findByAccountAndOrderIdWithAuthorizations(
|
||||
accountId,
|
||||
orderId,
|
||||
tx
|
||||
))!;
|
||||
const csrIdentifierValues = new Set(
|
||||
(certificateRequest.subjectAlternativeNames ?? [])
|
||||
.map((san) => san.value.toLowerCase())
|
||||
.concat([certificateRequest.commonName.toLowerCase()])
|
||||
);
|
||||
if (
|
||||
csrIdentifierValues.size !== orderWithAuthorizations.authorizations.length ||
|
||||
!orderWithAuthorizations.authorizations.every((auth) =>
|
||||
csrIdentifierValues.has(auth.identifierValue.toLowerCase())
|
||||
)
|
||||
) {
|
||||
throw new AcmeBadCSRError({ message: "Invalid CSR: Common name + SANs mismatch with order identifiers" });
|
||||
}
|
||||
|
||||
const ca = await certificateAuthorityDAL.findByIdWithAssociatedCa(profile.caId);
|
||||
if (!ca) {
|
||||
throw new NotFoundError({ message: "Certificate Authority not found" });
|
||||
}
|
||||
const caType = (ca.externalCa?.type as CaType) ?? CaType.INTERNAL;
|
||||
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
|
||||
? {
|
||||
// 47 days, the default TTL comes with Let's Encrypt
|
||||
// TODO: read config from the profile to get the expiration time instead
|
||||
ttl: `${47}d`
|
||||
}
|
||||
: // ttl is not used if notAfter is provided
|
||||
({ ttl: "0d" } as const),
|
||||
enrollmentType: EnrollmentType.ACME
|
||||
});
|
||||
// TODO: associate the certificate with the order
|
||||
const { certificateId } = await (async () => {
|
||||
if (caType === CaType.INTERNAL) {
|
||||
const result = 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
|
||||
? {
|
||||
// 47 days, the default TTL comes with Let's Encrypt
|
||||
// TODO: read config from the profile to get the expiration time instead
|
||||
ttl: `${47}d`
|
||||
}
|
||||
: // ttl is not used if notAfter is provided
|
||||
({ ttl: "0d" } as const),
|
||||
enrollmentType: EnrollmentType.ACME
|
||||
});
|
||||
return { certificateId: result.certificateId };
|
||||
}
|
||||
const { certificateAuthority } = (await certificateProfileDAL.findByIdWithConfigs(profileId, tx))!;
|
||||
const csrObj = new x509.Pkcs10CertificateRequest(csr);
|
||||
const csrPem = csrObj.toString("pem");
|
||||
// TODO: for internal CA, we rely on the internal certificate authority service to check CSR against the template
|
||||
// we should check the CSR against the template here
|
||||
// TODO: this is pretty slow, and we are holding the transaction open for a long time,
|
||||
// we should queue the certificate issuance to a background job instead
|
||||
const cert = await orderCertificate(
|
||||
{
|
||||
caId: certificateAuthority!.id,
|
||||
commonName: certificateRequest.commonName!,
|
||||
altNames: certificateRequest.subjectAlternativeNames?.map((san) => san.value),
|
||||
csr: Buffer.from(csrPem),
|
||||
// TODO: not 100% sure what are these columns for, but let's put the values for common website SSL certs for now
|
||||
keyUsages: [CertKeyUsage.DIGITAL_SIGNATURE, CertKeyUsage.KEY_ENCIPHERMENT, CertKeyUsage.KEY_AGREEMENT],
|
||||
extendedKeyUsages: [CertExtendedKeyUsage.SERVER_AUTH]
|
||||
},
|
||||
{
|
||||
appConnectionDAL,
|
||||
certificateAuthorityDAL,
|
||||
externalCertificateAuthorityDAL,
|
||||
certificateDAL,
|
||||
certificateBodyDAL,
|
||||
certificateSecretDAL,
|
||||
kmsService,
|
||||
projectDAL
|
||||
}
|
||||
);
|
||||
return { certificateId: cert.id };
|
||||
})();
|
||||
await acmeOrderDAL.updateById(
|
||||
orderId,
|
||||
{
|
||||
|
||||
@@ -106,7 +106,9 @@ const envSchema = z
|
||||
HTTPS_ENABLED: zodStrBool,
|
||||
ROTATION_DEVELOPMENT_MODE: zodStrBool.default("false").optional(),
|
||||
DAILY_RESOURCE_CLEAN_UP_DEVELOPMENT_MODE: zodStrBool.default("false").optional(),
|
||||
BDD_NOCK_API_ENABLED: zodStrBool.default("false").optional(),
|
||||
ACME_DEVELOPMENT_MODE: zodStrBool.default("false").optional(),
|
||||
ACME_SKIP_UPSTREAM_VALIDATION: zodStrBool.default("false").optional(),
|
||||
ACME_DEVELOPMENT_HTTP01_CHALLENGE_HOST_OVERRIDES: zpStr(
|
||||
z
|
||||
.string()
|
||||
@@ -398,6 +400,7 @@ const envSchema = z
|
||||
isAcmeDevelopmentMode: data.NODE_ENV === "development" && data.ACME_DEVELOPMENT_MODE,
|
||||
isProductionMode: data.NODE_ENV === "production" || IS_PACKAGED,
|
||||
isRedisSentinelMode: Boolean(data.REDIS_SENTINEL_HOSTS),
|
||||
isBddNockApiEnabled: data.NODE_ENV === "development" && data.BDD_NOCK_API_ENABLED,
|
||||
REDIS_SENTINEL_HOSTS: data.REDIS_SENTINEL_HOSTS?.trim()
|
||||
?.split(",")
|
||||
.map((el) => {
|
||||
|
||||
@@ -643,7 +643,8 @@ export const registerRoutes = async (
|
||||
projectDAL,
|
||||
identityDAL,
|
||||
userDAL,
|
||||
externalGroupOrgRoleMappingDAL
|
||||
externalGroupOrgRoleMappingDAL,
|
||||
membershipRoleDAL
|
||||
});
|
||||
const additionalPrivilegeService = additionalPrivilegeServiceFactory({
|
||||
additionalPrivilegeDAL,
|
||||
@@ -2244,8 +2245,13 @@ export const registerRoutes = async (
|
||||
});
|
||||
const pkiAcmeService = pkiAcmeServiceFactory({
|
||||
projectDAL,
|
||||
appConnectionDAL,
|
||||
certificateDAL,
|
||||
certificateAuthorityDAL,
|
||||
externalCertificateAuthorityDAL,
|
||||
certificateProfileDAL,
|
||||
certificateBodyDAL,
|
||||
certificateSecretDAL,
|
||||
acmeAccountDAL,
|
||||
acmeOrderDAL,
|
||||
acmeAuthDAL,
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import { getConfig } from "@app/lib/config/env";
|
||||
import { ForbiddenRequestError } from "@app/lib/errors";
|
||||
import { verifyAuth } from "@app/server/plugins/auth/verify-auth";
|
||||
import { AuthMode } from "@app/services/auth/auth-type";
|
||||
import { logger } from "@app/lib/logger";
|
||||
import nock, { Definition } from "nock";
|
||||
|
||||
export const registerBddNockRouter = async (server: FastifyZodProvider) => {
|
||||
const checkIfBddNockApiEnabled = () => {
|
||||
const appCfg = getConfig();
|
||||
// Note: Please note that this API is only available in development mode and only for BDD tests.
|
||||
// This endpoint should NEVER BE ENABLED IN PRODUCTION!
|
||||
if (appCfg.NODE_ENV !== "development" || !appCfg.isBddNockApiEnabled) {
|
||||
throw new ForbiddenRequestError({ message: "BDD Nock API is not enabled" });
|
||||
}
|
||||
};
|
||||
|
||||
server.route({
|
||||
method: "POST",
|
||||
url: "/define",
|
||||
schema: {
|
||||
body: z.object({ definitions: z.unknown().array() }),
|
||||
response: {
|
||||
200: z.object({ status: z.string() })
|
||||
}
|
||||
},
|
||||
onRequest: verifyAuth([AuthMode.JWT]),
|
||||
handler: async (req) => {
|
||||
checkIfBddNockApiEnabled();
|
||||
const { body } = req;
|
||||
const { definitions } = body;
|
||||
logger.info(definitions, "Defining nock");
|
||||
const processedDefinitions = definitions.map((definition: unknown) => {
|
||||
const { path, ...rest } = definition as Definition;
|
||||
return {
|
||||
...rest,
|
||||
path:
|
||||
path !== undefined && typeof path === "string"
|
||||
? path
|
||||
: new RegExp((path as unknown as { regex: string }).regex ?? "")
|
||||
} as Definition;
|
||||
});
|
||||
|
||||
nock.define(processedDefinitions);
|
||||
// Ensure we are activating the nocks, because we could have called `nock.restore()` before this call.
|
||||
if (!nock.isActive()) {
|
||||
nock.activate();
|
||||
}
|
||||
return { status: "ok" };
|
||||
}
|
||||
});
|
||||
|
||||
server.route({
|
||||
method: "POST",
|
||||
url: "/clean-all",
|
||||
schema: {
|
||||
response: {
|
||||
200: z.object({ status: z.string() })
|
||||
}
|
||||
},
|
||||
onRequest: verifyAuth([AuthMode.JWT]),
|
||||
handler: async () => {
|
||||
checkIfBddNockApiEnabled();
|
||||
logger.info("Cleaning all nocks");
|
||||
nock.cleanAll();
|
||||
return { status: "ok" };
|
||||
}
|
||||
});
|
||||
|
||||
server.route({
|
||||
method: "POST",
|
||||
url: "/restore",
|
||||
schema: {
|
||||
response: {
|
||||
200: z.object({ status: z.string() })
|
||||
}
|
||||
},
|
||||
onRequest: verifyAuth([AuthMode.JWT]),
|
||||
handler: async () => {
|
||||
checkIfBddNockApiEnabled();
|
||||
logger.info("Restore network requests from nock");
|
||||
nock.restore();
|
||||
return { status: "ok" };
|
||||
}
|
||||
});
|
||||
};
|
||||
@@ -6,8 +6,10 @@ import { registerCmekRouter } from "@app/server/routes/v1/cmek-router";
|
||||
import { registerDashboardRouter } from "@app/server/routes/v1/dashboard-router";
|
||||
import { registerSecretSyncRouter, SECRET_SYNC_REGISTER_ROUTER_MAP } from "@app/server/routes/v1/secret-sync-routers";
|
||||
|
||||
import { getConfig } from "@app/lib/config/env";
|
||||
import { registerAdminRouter } from "./admin-router";
|
||||
import { registerAuthRoutes } from "./auth-router";
|
||||
import { registerBddNockRouter } from "./bdd-nock-router";
|
||||
import { registerProjectBotRouter } from "./bot-router";
|
||||
import { registerCaRouter } from "./certificate-authority-router";
|
||||
import { CERTIFICATE_AUTHORITY_REGISTER_ROUTER_MAP } from "./certificate-authority-routers";
|
||||
@@ -237,4 +239,10 @@ export const registerV1Routes = async (server: FastifyZodProvider) => {
|
||||
|
||||
await server.register(registerEventRouter, { prefix: "/events" });
|
||||
await server.register(registerUpgradePathRouter, { prefix: "/upgrade-path" });
|
||||
|
||||
// Note: This is a special route for BDD tests. It's only available in development mode and only for BDD tests.
|
||||
// This route should NEVER BE ENABLED IN PRODUCTION!
|
||||
if (getConfig().isBddNockApiEnabled) {
|
||||
await server.register(registerBddNockRouter, { prefix: "/bdd-nock" });
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import { AccessScope, ProjectMembershipRole, ProjectMembershipsSchema } from "@app/db/schemas";
|
||||
import { AccessScope, OrgMembershipRole, ProjectMembershipRole, ProjectMembershipsSchema } from "@app/db/schemas";
|
||||
import { EventType } from "@app/ee/services/audit-log/audit-log-types";
|
||||
import { ApiDocsTags, PROJECT_USERS } from "@app/lib/api-docs";
|
||||
import { writeLimit } from "@app/server/config/rateLimiter";
|
||||
@@ -51,6 +51,19 @@ export const registerDeprecatedProjectMembershipRouter = async (server: FastifyZ
|
||||
onRequest: verifyAuth([AuthMode.JWT, AuthMode.API_KEY, AuthMode.IDENTITY_ACCESS_TOKEN]),
|
||||
handler: async (req) => {
|
||||
const usernamesAndEmails = [...req.body.emails, ...req.body.usernames];
|
||||
|
||||
await server.services.membershipUser.createMembership({
|
||||
permission: req.permission,
|
||||
scopeData: {
|
||||
scope: AccessScope.Organization,
|
||||
orgId: req.permission.orgId
|
||||
},
|
||||
data: {
|
||||
roles: [{ isTemporary: false, role: OrgMembershipRole.NoAccess }],
|
||||
usernames: usernamesAndEmails
|
||||
}
|
||||
});
|
||||
|
||||
const { memberships } = await server.services.membershipUser.createMembership({
|
||||
permission: req.permission,
|
||||
scopeData: {
|
||||
|
||||
+276
-195
@@ -1,5 +1,5 @@
|
||||
import * as x509 from "@peculiar/x509";
|
||||
import acme from "acme-client";
|
||||
import acme, { CsrBuffer } from "acme-client";
|
||||
|
||||
import { TableName } from "@app/db/schemas";
|
||||
import { crypto } from "@app/lib/crypto/cryptography";
|
||||
@@ -29,6 +29,8 @@ import { triggerAutoSyncForSubscriber } from "@app/services/pki-sync/pki-sync-ut
|
||||
import { TProjectDALFactory } from "@app/services/project/project-dal";
|
||||
import { getProjectKmsCertificateKeyId } from "@app/services/project/project-fns";
|
||||
|
||||
import { getConfig } from "@app/lib/config/env";
|
||||
import { Knex } from "knex";
|
||||
import { TCertificateAuthorityDALFactory } from "../certificate-authority-dal";
|
||||
import { CaStatus, CaType } from "../certificate-authority-enums";
|
||||
import { keyAlgorithmToAlgCfg } from "../certificate-authority-fns";
|
||||
@@ -64,6 +66,20 @@ type TAcmeCertificateAuthorityFnsDeps = {
|
||||
projectDAL: Pick<TProjectDALFactory, "findById" | "findOne" | "updateById" | "transaction">;
|
||||
};
|
||||
|
||||
type TOrderCertificateDeps = {
|
||||
appConnectionDAL: Pick<TAppConnectionDALFactory, "findById">;
|
||||
certificateAuthorityDAL: Pick<TCertificateAuthorityDALFactory, "findByIdWithAssociatedCa">;
|
||||
externalCertificateAuthorityDAL: Pick<TExternalCertificateAuthorityDALFactory, "update">;
|
||||
certificateDAL: Pick<TCertificateDALFactory, "create" | "transaction">;
|
||||
certificateBodyDAL: Pick<TCertificateBodyDALFactory, "create">;
|
||||
certificateSecretDAL: Pick<TCertificateSecretDALFactory, "create">;
|
||||
kmsService: Pick<
|
||||
TKmsServiceFactory,
|
||||
"encryptWithKmsKey" | "generateKmsKey" | "createCipherPairWithDataKey" | "decryptWithKmsKey"
|
||||
>;
|
||||
projectDAL: Pick<TProjectDALFactory, "findById" | "findOne" | "updateById" | "transaction">;
|
||||
};
|
||||
|
||||
type DBConfigurationColumn = {
|
||||
dnsProvider: string;
|
||||
directoryUrl: string;
|
||||
@@ -104,6 +120,245 @@ export const castDbEntryToAcmeCertificateAuthority = (
|
||||
};
|
||||
};
|
||||
|
||||
export const orderCertificate = async (
|
||||
{
|
||||
caId,
|
||||
subscriberId,
|
||||
commonName,
|
||||
altNames,
|
||||
csr,
|
||||
csrPrivateKey,
|
||||
keyUsages,
|
||||
extendedKeyUsages
|
||||
}: {
|
||||
caId: string;
|
||||
subscriberId?: string;
|
||||
commonName: string;
|
||||
altNames?: string[];
|
||||
csr: CsrBuffer;
|
||||
csrPrivateKey?: string;
|
||||
keyUsages?: CertKeyUsage[];
|
||||
extendedKeyUsages?: CertExtendedKeyUsage[];
|
||||
},
|
||||
deps: TOrderCertificateDeps,
|
||||
tx?: Knex
|
||||
) => {
|
||||
const {
|
||||
appConnectionDAL,
|
||||
certificateAuthorityDAL,
|
||||
externalCertificateAuthorityDAL,
|
||||
certificateDAL,
|
||||
certificateBodyDAL,
|
||||
certificateSecretDAL,
|
||||
kmsService,
|
||||
projectDAL
|
||||
} = deps;
|
||||
|
||||
const ca = await certificateAuthorityDAL.findByIdWithAssociatedCa(caId, tx);
|
||||
if (!ca.externalCa || ca.externalCa.type !== CaType.ACME) {
|
||||
throw new BadRequestError({ message: "CA is not an ACME CA" });
|
||||
}
|
||||
|
||||
const acmeCa = castDbEntryToAcmeCertificateAuthority(ca);
|
||||
if (acmeCa.status !== CaStatus.ACTIVE) {
|
||||
throw new BadRequestError({ message: "CA is disabled" });
|
||||
}
|
||||
|
||||
const certificateManagerKmsId = await getProjectKmsCertificateKeyId({
|
||||
projectId: ca.projectId,
|
||||
projectDAL,
|
||||
kmsService
|
||||
});
|
||||
|
||||
const kmsEncryptor = await kmsService.encryptWithKmsKey({
|
||||
kmsId: certificateManagerKmsId
|
||||
});
|
||||
|
||||
const kmsDecryptor = await kmsService.decryptWithKmsKey({
|
||||
kmsId: certificateManagerKmsId
|
||||
});
|
||||
|
||||
let accountKey: Buffer | undefined;
|
||||
if (acmeCa.credentials) {
|
||||
const decryptedCredentials = await kmsDecryptor({
|
||||
cipherTextBlob: acmeCa.credentials as Buffer
|
||||
});
|
||||
|
||||
const parsedCredentials = await AcmeCertificateAuthorityCredentialsSchema.parseAsync(
|
||||
JSON.parse(decryptedCredentials.toString("utf8"))
|
||||
);
|
||||
|
||||
accountKey = Buffer.from(parsedCredentials.accountKey, "base64");
|
||||
}
|
||||
if (!accountKey) {
|
||||
accountKey = await acme.crypto.createPrivateRsaKey();
|
||||
const newCredentials = {
|
||||
accountKey: accountKey.toString("base64")
|
||||
};
|
||||
const { cipherTextBlob: encryptedNewCredentials } = await kmsEncryptor({
|
||||
plainText: Buffer.from(JSON.stringify(newCredentials))
|
||||
});
|
||||
await externalCertificateAuthorityDAL.update(
|
||||
{
|
||||
caId: acmeCa.id
|
||||
},
|
||||
{
|
||||
credentials: encryptedNewCredentials
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
await blockLocalAndPrivateIpAddresses(acmeCa.configuration.directoryUrl);
|
||||
|
||||
const acmeClientOptions: acme.ClientOptions = {
|
||||
directoryUrl: acmeCa.configuration.directoryUrl,
|
||||
accountKey
|
||||
};
|
||||
|
||||
if (acmeCa.configuration.eabKid && acmeCa.configuration.eabHmacKey) {
|
||||
acmeClientOptions.externalAccountBinding = {
|
||||
kid: acmeCa.configuration.eabKid,
|
||||
hmacKey: acmeCa.configuration.eabHmacKey
|
||||
};
|
||||
}
|
||||
|
||||
const acmeClient = new acme.Client(acmeClientOptions);
|
||||
|
||||
const appConnection = await appConnectionDAL.findById(acmeCa.configuration.dnsAppConnectionId);
|
||||
const connection = await decryptAppConnection(appConnection, kmsService);
|
||||
|
||||
const pem = await acmeClient.auto({
|
||||
csr,
|
||||
email: acmeCa.configuration.accountEmail,
|
||||
challengePriority: ["dns-01"],
|
||||
// For ACME development mode, we mock the DNS challenge API calls. So, no real DNS records are created.
|
||||
// We need to disable the challenge verification to avoid errors.
|
||||
skipChallengeVerification: getConfig().isAcmeDevelopmentMode && getConfig().ACME_SKIP_UPSTREAM_VALIDATION,
|
||||
termsOfServiceAgreed: true,
|
||||
|
||||
challengeCreateFn: async (authz, challenge, keyAuthorization) => {
|
||||
if (challenge.type !== "dns-01") {
|
||||
throw new Error("Unsupported challenge type");
|
||||
}
|
||||
|
||||
const recordName = `_acme-challenge.${authz.identifier.value}`; // e.g., "_acme-challenge.example.com"
|
||||
const recordValue = `"${keyAuthorization}"`; // must be double quoted
|
||||
|
||||
switch (acmeCa.configuration.dnsProviderConfig.provider) {
|
||||
case AcmeDnsProvider.Route53: {
|
||||
await route53InsertTxtRecord(
|
||||
connection as TAwsConnection,
|
||||
acmeCa.configuration.dnsProviderConfig.hostedZoneId,
|
||||
recordName,
|
||||
recordValue
|
||||
);
|
||||
break;
|
||||
}
|
||||
case AcmeDnsProvider.Cloudflare: {
|
||||
await cloudflareInsertTxtRecord(
|
||||
connection as TCloudflareConnection,
|
||||
acmeCa.configuration.dnsProviderConfig.hostedZoneId,
|
||||
recordName,
|
||||
recordValue
|
||||
);
|
||||
break;
|
||||
}
|
||||
default: {
|
||||
throw new Error(`Unsupported DNS provider: ${acmeCa.configuration.dnsProviderConfig.provider as string}`);
|
||||
}
|
||||
}
|
||||
},
|
||||
challengeRemoveFn: async (authz, challenge, keyAuthorization) => {
|
||||
const recordName = `_acme-challenge.${authz.identifier.value}`; // e.g., "_acme-challenge.example.com"
|
||||
const recordValue = `"${keyAuthorization}"`; // must be double quoted
|
||||
|
||||
switch (acmeCa.configuration.dnsProviderConfig.provider) {
|
||||
case AcmeDnsProvider.Route53: {
|
||||
await route53DeleteTxtRecord(
|
||||
connection as TAwsConnection,
|
||||
acmeCa.configuration.dnsProviderConfig.hostedZoneId,
|
||||
recordName,
|
||||
recordValue
|
||||
);
|
||||
break;
|
||||
}
|
||||
case AcmeDnsProvider.Cloudflare: {
|
||||
await cloudflareDeleteTxtRecord(
|
||||
connection as TCloudflareConnection,
|
||||
acmeCa.configuration.dnsProviderConfig.hostedZoneId,
|
||||
recordName,
|
||||
recordValue
|
||||
);
|
||||
break;
|
||||
}
|
||||
default: {
|
||||
throw new Error(`Unsupported DNS provider: ${acmeCa.configuration.dnsProviderConfig.provider as string}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const [leafCert, parentCert] = acme.crypto.splitPemChain(pem);
|
||||
const certObj = new x509.X509Certificate(leafCert);
|
||||
|
||||
const { cipherTextBlob: encryptedCertificate } = await kmsEncryptor({
|
||||
plainText: Buffer.from(new Uint8Array(certObj.rawData))
|
||||
});
|
||||
|
||||
const certificateChainPem = parentCert.trim();
|
||||
|
||||
const { cipherTextBlob: encryptedCertificateChain } = await kmsEncryptor({
|
||||
plainText: Buffer.from(certificateChainPem)
|
||||
});
|
||||
|
||||
const { cipherTextBlob: encryptedPrivateKey } = csrPrivateKey
|
||||
? await kmsEncryptor({
|
||||
plainText: Buffer.from(csrPrivateKey)
|
||||
})
|
||||
: { cipherTextBlob: undefined };
|
||||
|
||||
return (tx || certificateDAL).transaction(async (innerTx: Knex) => {
|
||||
const cert = await certificateDAL.create(
|
||||
{
|
||||
caId: ca.id,
|
||||
pkiSubscriberId: subscriberId,
|
||||
status: CertStatus.ACTIVE,
|
||||
friendlyName: commonName,
|
||||
commonName,
|
||||
altNames: altNames?.join(","),
|
||||
serialNumber: certObj.serialNumber,
|
||||
notBefore: certObj.notBefore,
|
||||
notAfter: certObj.notAfter,
|
||||
keyUsages,
|
||||
extendedKeyUsages,
|
||||
projectId: ca.projectId
|
||||
},
|
||||
innerTx
|
||||
);
|
||||
|
||||
await certificateBodyDAL.create(
|
||||
{
|
||||
certId: cert.id,
|
||||
encryptedCertificate,
|
||||
encryptedCertificateChain
|
||||
},
|
||||
innerTx
|
||||
);
|
||||
|
||||
if (encryptedPrivateKey !== undefined) {
|
||||
await certificateSecretDAL.create(
|
||||
{
|
||||
certId: cert.id,
|
||||
encryptedPrivateKey
|
||||
},
|
||||
innerTx
|
||||
);
|
||||
}
|
||||
|
||||
return cert;
|
||||
});
|
||||
};
|
||||
|
||||
export const AcmeCertificateAuthorityFns = ({
|
||||
appConnectionDAL,
|
||||
appConnectionService,
|
||||
@@ -322,77 +577,6 @@ export const AcmeCertificateAuthorityFns = ({
|
||||
if (!subscriber.caId) {
|
||||
throw new BadRequestError({ message: "Subscriber does not have a CA" });
|
||||
}
|
||||
|
||||
const ca = await certificateAuthorityDAL.findByIdWithAssociatedCa(subscriber.caId);
|
||||
if (!ca.externalCa || ca.externalCa.type !== CaType.ACME) {
|
||||
throw new BadRequestError({ message: "CA is not an ACME CA" });
|
||||
}
|
||||
|
||||
const acmeCa = castDbEntryToAcmeCertificateAuthority(ca);
|
||||
if (acmeCa.status !== CaStatus.ACTIVE) {
|
||||
throw new BadRequestError({ message: "CA is disabled" });
|
||||
}
|
||||
|
||||
const certificateManagerKmsId = await getProjectKmsCertificateKeyId({
|
||||
projectId: ca.projectId,
|
||||
projectDAL,
|
||||
kmsService
|
||||
});
|
||||
|
||||
const kmsEncryptor = await kmsService.encryptWithKmsKey({
|
||||
kmsId: certificateManagerKmsId
|
||||
});
|
||||
|
||||
const kmsDecryptor = await kmsService.decryptWithKmsKey({
|
||||
kmsId: certificateManagerKmsId
|
||||
});
|
||||
|
||||
let accountKey: Buffer | undefined;
|
||||
if (acmeCa.credentials) {
|
||||
const decryptedCredentials = await kmsDecryptor({
|
||||
cipherTextBlob: acmeCa.credentials as Buffer
|
||||
});
|
||||
|
||||
const parsedCredentials = await AcmeCertificateAuthorityCredentialsSchema.parseAsync(
|
||||
JSON.parse(decryptedCredentials.toString("utf8"))
|
||||
);
|
||||
|
||||
accountKey = Buffer.from(parsedCredentials.accountKey, "base64");
|
||||
}
|
||||
if (!accountKey) {
|
||||
accountKey = await acme.crypto.createPrivateRsaKey();
|
||||
const newCredentials = {
|
||||
accountKey: accountKey.toString("base64")
|
||||
};
|
||||
const { cipherTextBlob: encryptedNewCredentials } = await kmsEncryptor({
|
||||
plainText: Buffer.from(JSON.stringify(newCredentials))
|
||||
});
|
||||
await externalCertificateAuthorityDAL.update(
|
||||
{
|
||||
caId: acmeCa.id
|
||||
},
|
||||
{
|
||||
credentials: encryptedNewCredentials
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
await blockLocalAndPrivateIpAddresses(acmeCa.configuration.directoryUrl);
|
||||
|
||||
const acmeClientOptions: acme.ClientOptions = {
|
||||
directoryUrl: acmeCa.configuration.directoryUrl,
|
||||
accountKey
|
||||
};
|
||||
|
||||
if (acmeCa.configuration.eabKid && acmeCa.configuration.eabHmacKey) {
|
||||
acmeClientOptions.externalAccountBinding = {
|
||||
kid: acmeCa.configuration.eabKid,
|
||||
hmacKey: acmeCa.configuration.eabHmacKey
|
||||
};
|
||||
}
|
||||
|
||||
const acmeClient = new acme.Client(acmeClientOptions);
|
||||
|
||||
const alg = keyAlgorithmToAlgCfg(CertKeyAlgorithm.RSA_2048);
|
||||
|
||||
const leafKeys = await crypto.nativeCrypto.subtle.generateKey(alg, true, ["sign", "verify"]);
|
||||
@@ -407,131 +591,28 @@ export const AcmeCertificateAuthorityFns = ({
|
||||
skLeaf
|
||||
);
|
||||
|
||||
const appConnection = await appConnectionDAL.findById(acmeCa.configuration.dnsAppConnectionId);
|
||||
const connection = await decryptAppConnection(appConnection, kmsService);
|
||||
|
||||
const pem = await acmeClient.auto({
|
||||
csr: certificateCsr,
|
||||
email: acmeCa.configuration.accountEmail,
|
||||
challengePriority: ["dns-01"],
|
||||
termsOfServiceAgreed: true,
|
||||
|
||||
challengeCreateFn: async (authz, challenge, keyAuthorization) => {
|
||||
if (challenge.type !== "dns-01") {
|
||||
throw new Error("Unsupported challenge type");
|
||||
}
|
||||
|
||||
const recordName = `_acme-challenge.${authz.identifier.value}`; // e.g., "_acme-challenge.example.com"
|
||||
const recordValue = `"${keyAuthorization}"`; // must be double quoted
|
||||
|
||||
switch (acmeCa.configuration.dnsProviderConfig.provider) {
|
||||
case AcmeDnsProvider.Route53: {
|
||||
await route53InsertTxtRecord(
|
||||
connection as TAwsConnection,
|
||||
acmeCa.configuration.dnsProviderConfig.hostedZoneId,
|
||||
recordName,
|
||||
recordValue
|
||||
);
|
||||
break;
|
||||
}
|
||||
case AcmeDnsProvider.Cloudflare: {
|
||||
await cloudflareInsertTxtRecord(
|
||||
connection as TCloudflareConnection,
|
||||
acmeCa.configuration.dnsProviderConfig.hostedZoneId,
|
||||
recordName,
|
||||
recordValue
|
||||
);
|
||||
break;
|
||||
}
|
||||
default: {
|
||||
throw new Error(`Unsupported DNS provider: ${acmeCa.configuration.dnsProviderConfig.provider as string}`);
|
||||
}
|
||||
}
|
||||
await orderCertificate(
|
||||
{
|
||||
caId: subscriber.caId,
|
||||
subscriberId: subscriber.id,
|
||||
commonName: subscriber.commonName,
|
||||
altNames: subscriber.subjectAlternativeNames,
|
||||
csr: certificateCsr,
|
||||
csrPrivateKey: skLeaf,
|
||||
keyUsages: subscriber.keyUsages as CertKeyUsage[],
|
||||
extendedKeyUsages: subscriber.extendedKeyUsages as CertExtendedKeyUsage[]
|
||||
},
|
||||
challengeRemoveFn: async (authz, challenge, keyAuthorization) => {
|
||||
const recordName = `_acme-challenge.${authz.identifier.value}`; // e.g., "_acme-challenge.example.com"
|
||||
const recordValue = `"${keyAuthorization}"`; // must be double quoted
|
||||
|
||||
switch (acmeCa.configuration.dnsProviderConfig.provider) {
|
||||
case AcmeDnsProvider.Route53: {
|
||||
await route53DeleteTxtRecord(
|
||||
connection as TAwsConnection,
|
||||
acmeCa.configuration.dnsProviderConfig.hostedZoneId,
|
||||
recordName,
|
||||
recordValue
|
||||
);
|
||||
break;
|
||||
}
|
||||
case AcmeDnsProvider.Cloudflare: {
|
||||
await cloudflareDeleteTxtRecord(
|
||||
connection as TCloudflareConnection,
|
||||
acmeCa.configuration.dnsProviderConfig.hostedZoneId,
|
||||
recordName,
|
||||
recordValue
|
||||
);
|
||||
break;
|
||||
}
|
||||
default: {
|
||||
throw new Error(`Unsupported DNS provider: ${acmeCa.configuration.dnsProviderConfig.provider as string}`);
|
||||
}
|
||||
}
|
||||
{
|
||||
appConnectionDAL,
|
||||
certificateAuthorityDAL,
|
||||
externalCertificateAuthorityDAL,
|
||||
certificateDAL,
|
||||
certificateBodyDAL,
|
||||
certificateSecretDAL,
|
||||
kmsService,
|
||||
projectDAL
|
||||
}
|
||||
});
|
||||
|
||||
const [leafCert, parentCert] = acme.crypto.splitPemChain(pem);
|
||||
const certObj = new x509.X509Certificate(leafCert);
|
||||
|
||||
const { cipherTextBlob: encryptedCertificate } = await kmsEncryptor({
|
||||
plainText: Buffer.from(new Uint8Array(certObj.rawData))
|
||||
});
|
||||
|
||||
const certificateChainPem = parentCert.trim();
|
||||
|
||||
const { cipherTextBlob: encryptedCertificateChain } = await kmsEncryptor({
|
||||
plainText: Buffer.from(certificateChainPem)
|
||||
});
|
||||
|
||||
const { cipherTextBlob: encryptedPrivateKey } = await kmsEncryptor({
|
||||
plainText: Buffer.from(skLeaf)
|
||||
});
|
||||
|
||||
await certificateDAL.transaction(async (tx) => {
|
||||
const cert = await certificateDAL.create(
|
||||
{
|
||||
caId: ca.id,
|
||||
pkiSubscriberId: subscriber.id,
|
||||
status: CertStatus.ACTIVE,
|
||||
friendlyName: subscriber.commonName,
|
||||
commonName: subscriber.commonName,
|
||||
altNames: subscriber.subjectAlternativeNames.join(","),
|
||||
serialNumber: certObj.serialNumber,
|
||||
notBefore: certObj.notBefore,
|
||||
notAfter: certObj.notAfter,
|
||||
keyUsages: subscriber.keyUsages as CertKeyUsage[],
|
||||
extendedKeyUsages: subscriber.extendedKeyUsages as CertExtendedKeyUsage[],
|
||||
projectId: ca.projectId
|
||||
},
|
||||
tx
|
||||
);
|
||||
|
||||
await certificateBodyDAL.create(
|
||||
{
|
||||
certId: cert.id,
|
||||
encryptedCertificate,
|
||||
encryptedCertificateChain
|
||||
},
|
||||
tx
|
||||
);
|
||||
|
||||
await certificateSecretDAL.create(
|
||||
{
|
||||
certId: cert.id,
|
||||
encryptedPrivateKey
|
||||
},
|
||||
tx
|
||||
);
|
||||
});
|
||||
|
||||
);
|
||||
await triggerAutoSyncForSubscriber(subscriber.id, { pkiSyncDAL, pkiSyncQueue });
|
||||
};
|
||||
|
||||
|
||||
@@ -168,15 +168,12 @@ export const certificateProfileDALFactory = (db: TDbClient) => {
|
||||
} as TCertificateProfileWithConfigs["acmeConfig"])
|
||||
: undefined;
|
||||
|
||||
const certificateAuthority =
|
||||
result.caId && result.caProjectId && result.caStatus && result.caName
|
||||
? ({
|
||||
id: result.caId,
|
||||
projectId: result.caProjectId,
|
||||
status: result.caStatus,
|
||||
name: result.caName
|
||||
} as TCertificateProfileWithConfigs["certificateAuthority"])
|
||||
: undefined;
|
||||
const certificateAuthority = {
|
||||
id: result.caId,
|
||||
projectId: result.caProjectId,
|
||||
status: result.caStatus,
|
||||
name: result.caName
|
||||
} as TCertificateProfileWithConfigs["certificateAuthority"];
|
||||
|
||||
const certificateTemplate =
|
||||
result.templateId && result.templateProjectId && result.templateName
|
||||
|
||||
@@ -6,6 +6,7 @@ import { TPermissionServiceFactory } from "@app/ee/services/permission/permissio
|
||||
import { BadRequestError, NotFoundError } from "@app/lib/errors";
|
||||
import { validateHandlebarTemplate } from "@app/lib/template/validate-handlebars";
|
||||
import { UnpackedPermissionSchema, unpackPermissions } from "@app/server/routes/sanitizedSchema/permission";
|
||||
import { TMembershipRoleDALFactory } from "@app/services/membership/membership-role-dal";
|
||||
|
||||
import { ActorType } from "../auth/auth-type";
|
||||
import { TExternalGroupOrgRoleMappingDALFactory } from "../external-group-org-role-mapping/external-group-org-role-mapping-dal";
|
||||
@@ -33,6 +34,7 @@ type TRoleServiceFactoryDep = {
|
||||
permissionService: Pick<TPermissionServiceFactory, "getProjectPermission" | "getOrgPermission">;
|
||||
projectDAL: Pick<TProjectDALFactory, "findById">;
|
||||
externalGroupOrgRoleMappingDAL: Pick<TExternalGroupOrgRoleMappingDALFactory, "findOne">;
|
||||
membershipRoleDAL: Pick<TMembershipRoleDALFactory, "find">;
|
||||
};
|
||||
|
||||
export type TRoleServiceFactory = ReturnType<typeof roleServiceFactory>;
|
||||
@@ -43,7 +45,8 @@ export const roleServiceFactory = ({
|
||||
projectDAL,
|
||||
identityDAL,
|
||||
userDAL,
|
||||
externalGroupOrgRoleMappingDAL
|
||||
externalGroupOrgRoleMappingDAL,
|
||||
membershipRoleDAL
|
||||
}: TRoleServiceFactoryDep) => {
|
||||
const orgRoleFactory = newOrgRoleFactory({
|
||||
permissionService,
|
||||
@@ -137,6 +140,23 @@ export const roleServiceFactory = ({
|
||||
});
|
||||
if (!existingRole) throw new NotFoundError({ message: `Role with ${dto.selector.id} not found` });
|
||||
|
||||
const [roleUsageData] = await membershipRoleDAL.find(
|
||||
{
|
||||
customRoleId: dto.selector.id
|
||||
},
|
||||
{ count: true }
|
||||
);
|
||||
|
||||
if (roleUsageData) {
|
||||
const count = Number.parseInt(roleUsageData.count, 10);
|
||||
if (count > 0) {
|
||||
const plural = count > 1 ? "s" : "";
|
||||
throw new BadRequestError({
|
||||
message: `Role is assigned to ${count} identity membership${plural}. Re-assign membership role${plural} to delete this role.`
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const [role] = await roleDAL.delete({
|
||||
id: existingRole.id,
|
||||
[scope.key]: scope.value
|
||||
|
||||
Reference in New Issue
Block a user