mirror of
https://github.com/awatertrevi/infisical.git
synced 2026-10-19 14:33:52 +00:00
Merge branch 'main' into feature/ldap-gateway-support
This commit is contained in:
+2
-1
@@ -1,4 +1,5 @@
|
||||
export enum AzureClientSecretsConnectionMethod {
|
||||
OAuth = "oauth",
|
||||
ClientSecret = "client-secret"
|
||||
ClientSecret = "client-secret",
|
||||
Certificate = "certificate"
|
||||
}
|
||||
|
||||
+179
-5
@@ -1,9 +1,14 @@
|
||||
/* eslint-disable no-case-declarations */
|
||||
import { AxiosError, AxiosResponse } from "axios";
|
||||
import type { KeyObject } from "crypto";
|
||||
import RE2 from "re2";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
|
||||
import { getConfig } from "@app/lib/config/env";
|
||||
import { request } from "@app/lib/config/request";
|
||||
import { crypto } from "@app/lib/crypto";
|
||||
import { BadRequestError, InternalServerError, NotFoundError } from "@app/lib/errors";
|
||||
import { logger } from "@app/lib/logger";
|
||||
import {
|
||||
decryptAppConnectionCredentials,
|
||||
encryptAppConnectionCredentials,
|
||||
@@ -17,11 +22,82 @@ import { AppConnection } from "../app-connection-enums";
|
||||
import { AzureClientSecretsConnectionMethod } from "./azure-client-secrets-connection-enums";
|
||||
import {
|
||||
ExchangeCodeAzureResponse,
|
||||
TAzureClientSecretsConnectionCertificateCredentials,
|
||||
TAzureClientSecretsConnectionClientSecretCredentials,
|
||||
TAzureClientSecretsConnectionConfig,
|
||||
TAzureClientSecretsConnectionCredentials
|
||||
} from "./azure-client-secrets-connection-types";
|
||||
|
||||
const generateClientAssertion = (
|
||||
clientId: string,
|
||||
tenantId: string,
|
||||
privateKey: string,
|
||||
certificate: string
|
||||
): string => {
|
||||
const tokenEndpoint = `https://login.microsoftonline.com/${tenantId}/oauth2/v2.0/token`;
|
||||
|
||||
const certBuffer = Buffer.from(
|
||||
certificate
|
||||
.replace(new RE2("-----BEGIN CERTIFICATE-----"), "")
|
||||
.replace(new RE2("-----END CERTIFICATE-----"), "")
|
||||
.replace(new RE2("\\s", "g"), ""),
|
||||
"base64"
|
||||
);
|
||||
|
||||
// thumbprint of the certificate is used for the jwt header
|
||||
const thumbprint = crypto.nativeCrypto.createHash("sha1").update(certBuffer).digest("hex");
|
||||
const x5t = Buffer.from(thumbprint, "hex").toString("base64url");
|
||||
|
||||
// JWT Header
|
||||
const header = {
|
||||
alg: "RS256",
|
||||
typ: "JWT",
|
||||
x5t
|
||||
};
|
||||
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const payload = {
|
||||
aud: tokenEndpoint,
|
||||
exp: now + 600, // expire the assertion in 10 minutes (not the access access token TTL, but rather the assertion TTL itself)
|
||||
iss: clientId,
|
||||
jti: uuidv4(), // random ID for the JWT
|
||||
nbf: now, // not before the jwt is valid
|
||||
sub: clientId
|
||||
};
|
||||
|
||||
// encode header and payload
|
||||
const encodedHeader = Buffer.from(JSON.stringify(header)).toString("base64url");
|
||||
const encodedPayload = Buffer.from(JSON.stringify(payload)).toString("base64url");
|
||||
const signatureInput = `${encodedHeader}.${encodedPayload}`;
|
||||
|
||||
let keyObject: KeyObject;
|
||||
|
||||
try {
|
||||
if (privateKey.includes("BEGIN PRIVATE KEY")) {
|
||||
keyObject = crypto.nativeCrypto.createPrivateKey(privateKey);
|
||||
} else {
|
||||
// if user forgot to wrap in begin/end private key, decode and use as der format
|
||||
keyObject = crypto.nativeCrypto.createPrivateKey({
|
||||
key: Buffer.from(privateKey, "base64"),
|
||||
format: "der",
|
||||
type: "pkcs8"
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
throw new BadRequestError({
|
||||
message: "Invalid private key format provided. Expected PEM format private key."
|
||||
});
|
||||
}
|
||||
|
||||
// sign with private key
|
||||
const signer = crypto.nativeCrypto.createSign("RSA-SHA256");
|
||||
signer.update(signatureInput);
|
||||
signer.end();
|
||||
const signature = signer.sign(keyObject, "base64url");
|
||||
|
||||
return `${signatureInput}.${signature}`;
|
||||
};
|
||||
|
||||
export const getAzureClientSecretsConnectionListItem = () => {
|
||||
const { INF_APP_CONNECTION_AZURE_CLIENT_SECRETS_CLIENT_ID } = getConfig();
|
||||
|
||||
@@ -30,7 +106,8 @@ export const getAzureClientSecretsConnectionListItem = () => {
|
||||
app: AppConnection.AzureClientSecrets as const,
|
||||
methods: Object.values(AzureClientSecretsConnectionMethod) as [
|
||||
AzureClientSecretsConnectionMethod.OAuth,
|
||||
AzureClientSecretsConnectionMethod.ClientSecret
|
||||
AzureClientSecretsConnectionMethod.ClientSecret,
|
||||
AzureClientSecretsConnectionMethod.Certificate
|
||||
],
|
||||
oauthClientId: INF_APP_CONNECTION_AZURE_CLIENT_SECRETS_CLIENT_ID
|
||||
};
|
||||
@@ -64,7 +141,7 @@ export const getAzureConnectionAccessToken = async (
|
||||
const { refreshToken } = credentials;
|
||||
const currentTime = Date.now();
|
||||
switch (appConnection.method) {
|
||||
case AzureClientSecretsConnectionMethod.OAuth:
|
||||
case AzureClientSecretsConnectionMethod.OAuth: {
|
||||
if (
|
||||
!appCfg.INF_APP_CONNECTION_AZURE_CLIENT_SECRETS_CLIENT_ID ||
|
||||
!appCfg.INF_APP_CONNECTION_AZURE_CLIENT_SECRETS_CLIENT_SECRET
|
||||
@@ -101,7 +178,8 @@ export const getAzureConnectionAccessToken = async (
|
||||
await appConnectionDAL.updateById(appConnection.id, { encryptedCredentials });
|
||||
|
||||
return data.access_token;
|
||||
case AzureClientSecretsConnectionMethod.ClientSecret:
|
||||
}
|
||||
case AzureClientSecretsConnectionMethod.ClientSecret: {
|
||||
const accessTokenCredentials = (await decryptAppConnectionCredentials({
|
||||
orgId: appConnection.orgId,
|
||||
projectId: appConnection.projectId,
|
||||
@@ -139,6 +217,50 @@ export const getAzureConnectionAccessToken = async (
|
||||
await appConnectionDAL.updateById(appConnection.id, { encryptedCredentials: encryptedClientCredentials });
|
||||
|
||||
return clientData.access_token;
|
||||
}
|
||||
|
||||
case AzureClientSecretsConnectionMethod.Certificate: {
|
||||
const accessTokenCredentials = (await decryptAppConnectionCredentials({
|
||||
orgId: appConnection.orgId,
|
||||
projectId: appConnection.projectId,
|
||||
kmsService,
|
||||
encryptedCredentials: appConnection.encryptedCredentials
|
||||
})) as TAzureClientSecretsConnectionCertificateCredentials;
|
||||
const { accessToken, expiresAt, clientId, tenantId, certificateBody, privateKey } = accessTokenCredentials;
|
||||
if (accessToken && expiresAt && expiresAt > currentTime + 300000) {
|
||||
return accessToken;
|
||||
}
|
||||
|
||||
const clientAssertion = generateClientAssertion(clientId, tenantId, privateKey, certificateBody);
|
||||
const { data: clientData } = await request.post<ExchangeCodeAzureResponse>(
|
||||
IntegrationUrls.AZURE_TOKEN_URL.replace("common", tenantId || "common"),
|
||||
new URLSearchParams({
|
||||
grant_type: "client_credentials",
|
||||
scope: `https://graph.microsoft.com/.default`,
|
||||
client_id: clientId,
|
||||
client_assertion_type: "urn:ietf:params:oauth:client-assertion-type:jwt-bearer",
|
||||
client_assertion: clientAssertion
|
||||
})
|
||||
);
|
||||
|
||||
const updatedClientCredentials = {
|
||||
...accessTokenCredentials,
|
||||
accessToken: clientData.access_token,
|
||||
expiresAt: currentTime + clientData.expires_in * 1000
|
||||
};
|
||||
|
||||
const encryptedClientCredentials = await encryptAppConnectionCredentials({
|
||||
credentials: updatedClientCredentials,
|
||||
orgId: appConnection.orgId,
|
||||
projectId: appConnection.projectId,
|
||||
kmsService
|
||||
});
|
||||
|
||||
await appConnectionDAL.updateById(appConnection.id, { encryptedCredentials: encryptedClientCredentials });
|
||||
|
||||
return clientData.access_token;
|
||||
}
|
||||
|
||||
default:
|
||||
throw new InternalServerError({
|
||||
message: `Unhandled Azure connection method: ${appConnection.method as AzureClientSecretsConnectionMethod}`
|
||||
@@ -156,7 +278,7 @@ export const validateAzureClientSecretsConnectionCredentials = async (config: TA
|
||||
} = getConfig();
|
||||
|
||||
switch (method) {
|
||||
case AzureClientSecretsConnectionMethod.OAuth:
|
||||
case AzureClientSecretsConnectionMethod.OAuth: {
|
||||
if (!SITE_URL) {
|
||||
throw new InternalServerError({ message: "SITE_URL env var is required to complete Azure OAuth flow" });
|
||||
}
|
||||
@@ -221,8 +343,9 @@ export const validateAzureClientSecretsConnectionCredentials = async (config: TA
|
||||
refreshToken: tokenResp.data.refresh_token,
|
||||
expiresAt: Date.now() + tokenResp.data.expires_in * 1000
|
||||
};
|
||||
}
|
||||
|
||||
case AzureClientSecretsConnectionMethod.ClientSecret:
|
||||
case AzureClientSecretsConnectionMethod.ClientSecret: {
|
||||
const { tenantId, clientId, clientSecret } = inputCredentials;
|
||||
try {
|
||||
const { data: clientData } = await request.post<ExchangeCodeAzureResponse>(
|
||||
@@ -255,6 +378,57 @@ export const validateAzureClientSecretsConnectionCredentials = async (config: TA
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
case AzureClientSecretsConnectionMethod.Certificate: {
|
||||
const { tenantId, certificateBody, privateKey, clientId } = inputCredentials;
|
||||
try {
|
||||
const clientAssertion = generateClientAssertion(clientId, tenantId, privateKey, certificateBody);
|
||||
|
||||
const tokenEndpoint = `https://login.microsoftonline.com/${tenantId}/oauth2/v2.0/token`;
|
||||
|
||||
const params = new URLSearchParams({
|
||||
client_id: clientId,
|
||||
client_assertion_type: "urn:ietf:params:oauth:client-assertion-type:jwt-bearer",
|
||||
client_assertion: clientAssertion,
|
||||
scope: "https://graph.microsoft.com/.default",
|
||||
grant_type: "client_credentials"
|
||||
});
|
||||
|
||||
const response = await request.post<ExchangeCodeAzureResponse>(tokenEndpoint, params.toString(), {
|
||||
headers: {
|
||||
"Content-Type": "application/x-www-form-urlencoded"
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
tenantId,
|
||||
clientId,
|
||||
certificateBody,
|
||||
privateKey,
|
||||
accessToken: response.data.access_token,
|
||||
expiresAt: Date.now() + response.data.expires_in * 1000
|
||||
};
|
||||
} catch (e: unknown) {
|
||||
if (e instanceof AxiosError) {
|
||||
throw new BadRequestError({
|
||||
message: `Failed to get access token: ${
|
||||
(e?.response?.data as { error_description?: string })?.error_description || "Unknown error"
|
||||
}`
|
||||
});
|
||||
} else if (e instanceof BadRequestError) {
|
||||
throw e;
|
||||
} else {
|
||||
logger.error(
|
||||
e,
|
||||
"validateAzureClientSecretsConnectionCredentials: Failed to get access token using certificate authentication"
|
||||
);
|
||||
throw new InternalServerError({
|
||||
message: "Failed to get access token"
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
default:
|
||||
throw new InternalServerError({
|
||||
message: `Unhandled Azure connection method: ${method as AzureClientSecretsConnectionMethod}`
|
||||
|
||||
+55
-1
@@ -48,6 +48,31 @@ export const AzureClientSecretsConnectionClientSecretInputCredentialsSchema = z.
|
||||
.describe(AppConnections.CREDENTIALS.AZURE_CLIENT_SECRETS.tenantId)
|
||||
});
|
||||
|
||||
export const AzureClientSecretsConnectionCertificateInputCredentialsSchema = z.object({
|
||||
tenantId: z
|
||||
.string()
|
||||
.uuid()
|
||||
.trim()
|
||||
.min(1, "Tenant ID required")
|
||||
.describe(AppConnections.CREDENTIALS.AZURE_CLIENT_SECRETS.tenantId),
|
||||
clientId: z
|
||||
.string()
|
||||
.uuid()
|
||||
.trim()
|
||||
.min(1, "Client ID required")
|
||||
.describe(AppConnections.CREDENTIALS.AZURE_CLIENT_SECRETS.clientId),
|
||||
certificateBody: z
|
||||
.string()
|
||||
.trim()
|
||||
.min(1, "Certificate body required")
|
||||
.describe(AppConnections.CREDENTIALS.AZURE_CLIENT_SECRETS.certificateBody),
|
||||
privateKey: z
|
||||
.string()
|
||||
.trim()
|
||||
.min(1, "Private Key required")
|
||||
.describe(AppConnections.CREDENTIALS.AZURE_CLIENT_SECRETS.privateKey)
|
||||
});
|
||||
|
||||
export const AzureClientSecretsConnectionClientSecretOutputCredentialsSchema = z.object({
|
||||
clientId: z.string(),
|
||||
clientSecret: z.string(),
|
||||
@@ -56,6 +81,15 @@ export const AzureClientSecretsConnectionClientSecretOutputCredentialsSchema = z
|
||||
expiresAt: z.number()
|
||||
});
|
||||
|
||||
export const AzureClientSecretsConnectionCertificateOutputCredentialsSchema = z.object({
|
||||
clientId: z.string(),
|
||||
tenantId: z.string(),
|
||||
certificateBody: z.string(),
|
||||
privateKey: z.string(),
|
||||
accessToken: z.string(),
|
||||
expiresAt: z.number()
|
||||
});
|
||||
|
||||
export const ValidateAzureClientSecretsConnectionCredentialsSchema = z.discriminatedUnion("method", [
|
||||
z.object({
|
||||
method: z
|
||||
@@ -72,6 +106,14 @@ export const ValidateAzureClientSecretsConnectionCredentialsSchema = z.discrimin
|
||||
credentials: AzureClientSecretsConnectionClientSecretInputCredentialsSchema.describe(
|
||||
AppConnections.CREATE(AppConnection.AzureClientSecrets).credentials
|
||||
)
|
||||
}),
|
||||
z.object({
|
||||
method: z
|
||||
.literal(AzureClientSecretsConnectionMethod.Certificate)
|
||||
.describe(AppConnections.CREATE(AppConnection.AzureClientSecrets).method),
|
||||
credentials: AzureClientSecretsConnectionCertificateInputCredentialsSchema.describe(
|
||||
AppConnections.CREATE(AppConnection.AzureClientSecrets).credentials
|
||||
)
|
||||
})
|
||||
]);
|
||||
|
||||
@@ -84,7 +126,8 @@ export const UpdateAzureClientSecretsConnectionSchema = z
|
||||
credentials: z
|
||||
.union([
|
||||
AzureClientSecretsConnectionOAuthInputCredentialsSchema,
|
||||
AzureClientSecretsConnectionClientSecretInputCredentialsSchema
|
||||
AzureClientSecretsConnectionClientSecretInputCredentialsSchema,
|
||||
AzureClientSecretsConnectionCertificateInputCredentialsSchema
|
||||
])
|
||||
.optional()
|
||||
.describe(AppConnections.UPDATE(AppConnection.AzureClientSecrets).credentials)
|
||||
@@ -105,6 +148,10 @@ export const AzureClientSecretsConnectionSchema = z.intersection(
|
||||
z.object({
|
||||
method: z.literal(AzureClientSecretsConnectionMethod.ClientSecret),
|
||||
credentials: AzureClientSecretsConnectionClientSecretOutputCredentialsSchema
|
||||
}),
|
||||
z.object({
|
||||
method: z.literal(AzureClientSecretsConnectionMethod.Certificate),
|
||||
credentials: AzureClientSecretsConnectionCertificateOutputCredentialsSchema
|
||||
})
|
||||
])
|
||||
);
|
||||
@@ -122,6 +169,13 @@ export const SanitizedAzureClientSecretsConnectionSchema = z.discriminatedUnion(
|
||||
clientId: true,
|
||||
tenantId: true
|
||||
})
|
||||
}),
|
||||
BaseAzureClientSecretsConnectionSchema.extend({
|
||||
method: z.literal(AzureClientSecretsConnectionMethod.Certificate),
|
||||
credentials: AzureClientSecretsConnectionCertificateOutputCredentialsSchema.pick({
|
||||
tenantId: true,
|
||||
clientId: true
|
||||
})
|
||||
})
|
||||
]);
|
||||
|
||||
|
||||
+5
@@ -4,6 +4,7 @@ import { DiscriminativePick } from "@app/lib/types";
|
||||
|
||||
import { AppConnection } from "../app-connection-enums";
|
||||
import {
|
||||
AzureClientSecretsConnectionCertificateOutputCredentialsSchema,
|
||||
AzureClientSecretsConnectionClientSecretOutputCredentialsSchema,
|
||||
AzureClientSecretsConnectionOAuthOutputCredentialsSchema,
|
||||
AzureClientSecretsConnectionSchema,
|
||||
@@ -35,6 +36,10 @@ export type TAzureClientSecretsConnectionClientSecretCredentials = z.infer<
|
||||
typeof AzureClientSecretsConnectionClientSecretOutputCredentialsSchema
|
||||
>;
|
||||
|
||||
export type TAzureClientSecretsConnectionCertificateCredentials = z.infer<
|
||||
typeof AzureClientSecretsConnectionCertificateOutputCredentialsSchema
|
||||
>;
|
||||
|
||||
export interface ExchangeCodeAzureResponse {
|
||||
token_type: string;
|
||||
scope: string;
|
||||
|
||||
@@ -41,6 +41,7 @@ export enum ActorType { // would extend to AWS, Azure, ...
|
||||
IDENTITY = "identity",
|
||||
Machine = "machine",
|
||||
SCIM_CLIENT = "scimClient",
|
||||
ACME_ACCOUNT = "acmeAccount",
|
||||
UNKNOWN_USER = "unknownUser"
|
||||
}
|
||||
|
||||
|
||||
@@ -65,6 +65,23 @@ export const certificateProfileDALFactory = (db: TDbClient) => {
|
||||
}
|
||||
};
|
||||
|
||||
const findByIdWithOwnerOrgId = async (
|
||||
id: string,
|
||||
tx?: Knex
|
||||
): Promise<(TCertificateProfile & { ownerOrgId: string }) | undefined> => {
|
||||
try {
|
||||
const certificateProfile = (await (tx || db)(TableName.PkiCertificateProfile)
|
||||
.join(TableName.Project, `${TableName.PkiCertificateProfile}.projectId`, `${TableName.Project}.id`)
|
||||
.select(selectAllTableCols(TableName.PkiCertificateProfile))
|
||||
.select(db.ref("orgId").withSchema(TableName.Project).as("ownerOrgId"))
|
||||
.where(`${TableName.PkiCertificateProfile}.id`, id)
|
||||
.first()) as (TCertificateProfile & { ownerOrgId: string }) | undefined;
|
||||
return certificateProfile;
|
||||
} catch (error) {
|
||||
throw new DatabaseError({ error, name: "Find certificate profile by id with owner org id" });
|
||||
}
|
||||
};
|
||||
|
||||
const findByIdWithConfigs = async (id: string, tx?: Knex): Promise<TCertificateProfileWithConfigs | undefined> => {
|
||||
try {
|
||||
const query = (tx || db)(TableName.PkiCertificateProfile)
|
||||
@@ -88,6 +105,11 @@ export const certificateProfileDALFactory = (db: TDbClient) => {
|
||||
`${TableName.PkiCertificateProfile}.apiConfigId`,
|
||||
`${TableName.PkiApiEnrollmentConfig}.id`
|
||||
)
|
||||
.leftJoin(
|
||||
TableName.PkiAcmeEnrollmentConfig,
|
||||
`${TableName.PkiCertificateProfile}.acmeConfigId`,
|
||||
`${TableName.PkiAcmeEnrollmentConfig}.id`
|
||||
)
|
||||
.select(selectAllTableCols(TableName.PkiCertificateProfile))
|
||||
.select(
|
||||
db.ref("id").withSchema(TableName.CertificateAuthority).as("caId"),
|
||||
@@ -107,7 +129,9 @@ export const certificateProfileDALFactory = (db: TDbClient) => {
|
||||
db.ref("encryptedCaChain").withSchema(TableName.PkiEstEnrollmentConfig).as("estConfigEncryptedCaChain"),
|
||||
db.ref("id").withSchema(TableName.PkiApiEnrollmentConfig).as("apiConfigId"),
|
||||
db.ref("autoRenew").withSchema(TableName.PkiApiEnrollmentConfig).as("apiConfigAutoRenew"),
|
||||
db.ref("renewBeforeDays").withSchema(TableName.PkiApiEnrollmentConfig).as("apiConfigRenewBeforeDays")
|
||||
db.ref("renewBeforeDays").withSchema(TableName.PkiApiEnrollmentConfig).as("apiConfigRenewBeforeDays"),
|
||||
db.ref("id").withSchema(TableName.PkiAcmeEnrollmentConfig).as("acmeConfigId"),
|
||||
db.ref("encryptedEabSecret").withSchema(TableName.PkiAcmeEnrollmentConfig).as("acmeConfigEncryptedEabSecret")
|
||||
)
|
||||
.where(`${TableName.PkiCertificateProfile}.id`, id)
|
||||
.first();
|
||||
@@ -134,6 +158,13 @@ export const certificateProfileDALFactory = (db: TDbClient) => {
|
||||
} as TCertificateProfileWithConfigs["apiConfig"])
|
||||
: undefined;
|
||||
|
||||
const acmeConfig = result.acmeConfigId
|
||||
? ({
|
||||
id: result.acmeConfigId,
|
||||
encryptedEabSecret: result.acmeConfigEncryptedEabSecret
|
||||
} as TCertificateProfileWithConfigs["acmeConfig"])
|
||||
: undefined;
|
||||
|
||||
const certificateAuthority =
|
||||
result.caId && result.caProjectId && result.caStatus && result.caName
|
||||
? ({
|
||||
@@ -164,10 +195,12 @@ export const certificateProfileDALFactory = (db: TDbClient) => {
|
||||
enrollmentType: result.enrollmentType as EnrollmentType,
|
||||
estConfigId: result.estConfigId,
|
||||
apiConfigId: result.apiConfigId,
|
||||
acmeConfigId: result.acmeConfigId,
|
||||
createdAt: result.createdAt,
|
||||
updatedAt: result.updatedAt,
|
||||
estConfig,
|
||||
apiConfig,
|
||||
acmeConfig,
|
||||
certificateAuthority,
|
||||
certificateTemplate
|
||||
};
|
||||
@@ -241,6 +274,11 @@ export const certificateProfileDALFactory = (db: TDbClient) => {
|
||||
`${TableName.PkiCertificateProfile}.apiConfigId`,
|
||||
`${TableName.PkiApiEnrollmentConfig}.id`
|
||||
)
|
||||
.leftJoin(
|
||||
TableName.PkiAcmeEnrollmentConfig,
|
||||
`${TableName.PkiCertificateProfile}.acmeConfigId`,
|
||||
`${TableName.PkiAcmeEnrollmentConfig}.id`
|
||||
)
|
||||
.select(selectAllTableCols(TableName.PkiCertificateProfile))
|
||||
.select(
|
||||
db.ref("id").withSchema(TableName.PkiEstEnrollmentConfig).as("estId"),
|
||||
@@ -252,7 +290,8 @@ export const certificateProfileDALFactory = (db: TDbClient) => {
|
||||
db.ref("encryptedCaChain").withSchema(TableName.PkiEstEnrollmentConfig).as("estEncryptedCaChain"),
|
||||
db.ref("id").withSchema(TableName.PkiApiEnrollmentConfig).as("apiId"),
|
||||
db.ref("autoRenew").withSchema(TableName.PkiApiEnrollmentConfig).as("apiAutoRenew"),
|
||||
db.ref("renewBeforeDays").withSchema(TableName.PkiApiEnrollmentConfig).as("apiRenewBeforeDays")
|
||||
db.ref("renewBeforeDays").withSchema(TableName.PkiApiEnrollmentConfig).as("apiRenewBeforeDays"),
|
||||
db.ref("id").withSchema(TableName.PkiAcmeEnrollmentConfig).as("acmeId")
|
||||
);
|
||||
|
||||
const results = (await query
|
||||
@@ -279,6 +318,12 @@ export const certificateProfileDALFactory = (db: TDbClient) => {
|
||||
}
|
||||
: undefined;
|
||||
|
||||
const acmeConfig = result.acmeId
|
||||
? {
|
||||
id: result.acmeId as string
|
||||
}
|
||||
: undefined;
|
||||
|
||||
const baseProfile = {
|
||||
id: result.id,
|
||||
projectId: result.projectId,
|
||||
@@ -292,7 +337,8 @@ export const certificateProfileDALFactory = (db: TDbClient) => {
|
||||
createdAt: result.createdAt,
|
||||
updatedAt: result.updatedAt,
|
||||
estConfig,
|
||||
apiConfig
|
||||
apiConfig,
|
||||
acmeConfig
|
||||
};
|
||||
|
||||
return baseProfile as TCertificateProfileWithConfigs;
|
||||
@@ -416,6 +462,24 @@ export const certificateProfileDALFactory = (db: TDbClient) => {
|
||||
}
|
||||
};
|
||||
|
||||
const getLatestActiveCertificateForProfile = async (profileId: string, tx?: Knex) => {
|
||||
try {
|
||||
const now = new Date();
|
||||
|
||||
const certificate = await (tx || db)(TableName.Certificate)
|
||||
.where("profileId", profileId)
|
||||
.where("status", "active")
|
||||
.where("notAfter", ">", now)
|
||||
.whereNull("revokedAt")
|
||||
.orderBy("createdAt", "desc")
|
||||
.first();
|
||||
|
||||
return certificate;
|
||||
} catch (error) {
|
||||
throw new DatabaseError({ error, name: "Get latest active certificate by profile" });
|
||||
}
|
||||
};
|
||||
|
||||
const isProfileInUse = async (profileId: string, tx?: Knex) => {
|
||||
try {
|
||||
const doc = await (tx || db)(TableName.Certificate).where("profileId", profileId).count("*").first();
|
||||
@@ -432,12 +496,14 @@ export const certificateProfileDALFactory = (db: TDbClient) => {
|
||||
updateById,
|
||||
deleteById,
|
||||
findById,
|
||||
findByIdWithOwnerOrgId,
|
||||
findByIdWithConfigs,
|
||||
findBySlugAndProjectId,
|
||||
findByProjectId,
|
||||
countByProjectId,
|
||||
findByNameAndProjectId,
|
||||
getCertificatesByProfile,
|
||||
getLatestActiveCertificateForProfile,
|
||||
isProfileInUse
|
||||
};
|
||||
};
|
||||
|
||||
@@ -27,7 +27,8 @@ export const createCertificateProfileSchema = z
|
||||
autoRenew: z.boolean().default(false),
|
||||
renewBeforeDays: z.number().min(1).max(30).optional()
|
||||
})
|
||||
.optional()
|
||||
.optional(),
|
||||
acmeConfig: z.object({}).optional()
|
||||
})
|
||||
.refine(
|
||||
(data) => {
|
||||
@@ -38,6 +39,9 @@ export const createCertificateProfileSchema = z
|
||||
if (data.apiConfig) {
|
||||
return false;
|
||||
}
|
||||
if (data.acmeConfig) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if (data.enrollmentType === EnrollmentType.API) {
|
||||
if (!data.apiConfig) {
|
||||
@@ -46,6 +50,20 @@ export const createCertificateProfileSchema = z
|
||||
if (data.estConfig) {
|
||||
return false;
|
||||
}
|
||||
if (data.acmeConfig) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if (data.enrollmentType === EnrollmentType.ACME) {
|
||||
if (!data.acmeConfig) {
|
||||
return false;
|
||||
}
|
||||
if (data.estConfig) {
|
||||
return false;
|
||||
}
|
||||
if (data.apiConfig) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
},
|
||||
|
||||
@@ -9,7 +9,12 @@ import type { TPermissionServiceFactory } from "@app/ee/services/permission/perm
|
||||
import { ForbiddenRequestError, NotFoundError } from "@app/lib/errors";
|
||||
|
||||
import { ActorType, AuthMethod } from "../auth/auth-type";
|
||||
import type { TCertificateBodyDALFactory } from "../certificate/certificate-body-dal";
|
||||
import type { TCertificateSecretDALFactory } from "../certificate/certificate-secret-dal";
|
||||
import type { TCertificateAuthorityCertDALFactory } from "../certificate-authority/certificate-authority-cert-dal";
|
||||
import type { TCertificateAuthorityDALFactory } from "../certificate-authority/certificate-authority-dal";
|
||||
import type { TCertificateTemplateV2DALFactory } from "../certificate-template-v2/certificate-template-v2-dal";
|
||||
import { TAcmeEnrollmentConfigDALFactory } from "../enrollment-config/acme-enrollment-config-dal";
|
||||
import type { TApiEnrollmentConfigDALFactory } from "../enrollment-config/api-enrollment-config-dal";
|
||||
import type { TEstEnrollmentConfigDALFactory } from "../enrollment-config/est-enrollment-config-dal";
|
||||
import type { TKmsServiceFactory } from "../kms/kms-service";
|
||||
@@ -142,6 +147,17 @@ describe("CertificateProfileService", () => {
|
||||
delete: vi.fn()
|
||||
} as unknown as TEstEnrollmentConfigDALFactory;
|
||||
|
||||
const mockAcmeEnrollmentConfigDAL = {
|
||||
create: vi.fn().mockResolvedValue({ id: "acme-config-123" }),
|
||||
findById: vi.fn(),
|
||||
updateById: vi.fn(),
|
||||
transaction: vi.fn(),
|
||||
find: vi.fn(),
|
||||
findOne: vi.fn(),
|
||||
update: vi.fn(),
|
||||
delete: vi.fn()
|
||||
} as unknown as TAcmeEnrollmentConfigDALFactory;
|
||||
|
||||
const mockPermissionService = {
|
||||
getProjectPermission: vi.fn().mockResolvedValue({
|
||||
permission: {
|
||||
@@ -166,6 +182,54 @@ describe("CertificateProfileService", () => {
|
||||
transaction: vi.fn()
|
||||
} as unknown as Pick<TProjectDALFactory, "findProjectBySlug" | "findOne" | "updateById" | "findById" | "transaction">;
|
||||
|
||||
const mockCertificateBodyDAL = {
|
||||
create: vi.fn(),
|
||||
findById: vi.fn(),
|
||||
updateById: vi.fn(),
|
||||
deleteById: vi.fn(),
|
||||
transaction: vi.fn(),
|
||||
find: vi.fn(),
|
||||
findOne: vi.fn(),
|
||||
update: vi.fn(),
|
||||
delete: vi.fn()
|
||||
} as unknown as TCertificateBodyDALFactory;
|
||||
|
||||
const mockCertificateSecretDAL = {
|
||||
create: vi.fn(),
|
||||
findById: vi.fn(),
|
||||
updateById: vi.fn(),
|
||||
deleteById: vi.fn(),
|
||||
transaction: vi.fn(),
|
||||
find: vi.fn(),
|
||||
findOne: vi.fn(),
|
||||
update: vi.fn(),
|
||||
delete: vi.fn()
|
||||
} as unknown as TCertificateSecretDALFactory;
|
||||
|
||||
const mockCertificateAuthorityDAL = {
|
||||
create: vi.fn(),
|
||||
findById: vi.fn(),
|
||||
updateById: vi.fn(),
|
||||
deleteById: vi.fn(),
|
||||
transaction: vi.fn(),
|
||||
find: vi.fn(),
|
||||
findOne: vi.fn(),
|
||||
update: vi.fn(),
|
||||
delete: vi.fn()
|
||||
} as unknown as TCertificateAuthorityDALFactory;
|
||||
|
||||
const mockCertificateAuthorityCertDAL = {
|
||||
create: vi.fn(),
|
||||
findById: vi.fn(),
|
||||
updateById: vi.fn(),
|
||||
deleteById: vi.fn(),
|
||||
transaction: vi.fn(),
|
||||
find: vi.fn(),
|
||||
findOne: vi.fn(),
|
||||
update: vi.fn(),
|
||||
delete: vi.fn()
|
||||
} as unknown as TCertificateAuthorityCertDALFactory;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.spyOn(ForbiddenError, "from").mockReturnValue({
|
||||
throwUnlessCan: vi.fn()
|
||||
@@ -182,6 +246,11 @@ describe("CertificateProfileService", () => {
|
||||
certificateTemplateV2DAL: mockCertificateTemplateV2DAL,
|
||||
apiEnrollmentConfigDAL: mockApiEnrollmentConfigDAL,
|
||||
estEnrollmentConfigDAL: mockEstEnrollmentConfigDAL,
|
||||
acmeEnrollmentConfigDAL: mockAcmeEnrollmentConfigDAL,
|
||||
certificateBodyDAL: mockCertificateBodyDAL,
|
||||
certificateSecretDAL: mockCertificateSecretDAL,
|
||||
certificateAuthorityDAL: mockCertificateAuthorityDAL,
|
||||
certificateAuthorityCertDAL: mockCertificateAuthorityCertDAL,
|
||||
permissionService: mockPermissionService,
|
||||
kmsService: mockKmsService,
|
||||
projectDAL: mockProjectDAL
|
||||
@@ -234,6 +303,7 @@ describe("CertificateProfileService", () => {
|
||||
certificateTemplateId: "template-123",
|
||||
apiConfigId: "api-config-123",
|
||||
estConfigId: null,
|
||||
acmeConfigId: null,
|
||||
projectId: "project-123"
|
||||
},
|
||||
undefined
|
||||
|
||||
@@ -4,19 +4,26 @@ import * as x509 from "@peculiar/x509";
|
||||
import { ActionProjectType } from "@app/db/schemas";
|
||||
import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service-types";
|
||||
import {
|
||||
ProjectPermissionCertificateActions,
|
||||
ProjectPermissionCertificateProfileActions,
|
||||
ProjectPermissionSub
|
||||
} from "@app/ee/services/permission/project-permission";
|
||||
import { buildUrl } from "@app/ee/services/pki-acme/pki-acme-fns";
|
||||
import { extractX509CertFromChain } from "@app/lib/certificates/extract-certificate";
|
||||
import { getConfig } from "@app/lib/config/env";
|
||||
import { crypto } from "@app/lib/crypto/cryptography";
|
||||
import { BadRequestError, ForbiddenRequestError, NotFoundError } from "@app/lib/errors";
|
||||
|
||||
import { ActorAuthMethod, ActorType } from "../auth/auth-type";
|
||||
import { isCertChainValid } from "../certificate/certificate-fns";
|
||||
import { TCertificateBodyDALFactory } from "../certificate/certificate-body-dal";
|
||||
import { getCertificateCredentials, isCertChainValid } from "../certificate/certificate-fns";
|
||||
import { TCertificateSecretDALFactory } from "../certificate/certificate-secret-dal";
|
||||
import { TCertificateAuthorityCertDALFactory } from "../certificate-authority/certificate-authority-cert-dal";
|
||||
import { TCertificateAuthorityDALFactory } from "../certificate-authority/certificate-authority-dal";
|
||||
import { TCertificateTemplateV2DALFactory } from "../certificate-template-v2/certificate-template-v2-dal";
|
||||
import { TAcmeEnrollmentConfigDALFactory } from "../enrollment-config/acme-enrollment-config-dal";
|
||||
import { TApiEnrollmentConfigDALFactory } from "../enrollment-config/api-enrollment-config-dal";
|
||||
import { TApiConfigData, TEstConfigData } from "../enrollment-config/enrollment-config-types";
|
||||
import { TAcmeConfigData, TApiConfigData, TEstConfigData } from "../enrollment-config/enrollment-config-types";
|
||||
import { TEstEnrollmentConfigDALFactory } from "../enrollment-config/est-enrollment-config-dal";
|
||||
import { TKmsServiceFactory } from "../kms/kms-service";
|
||||
import { TProjectDALFactory } from "../project/project-dal";
|
||||
@@ -31,6 +38,36 @@ import {
|
||||
TCertificateProfileWithConfigs
|
||||
} from "./certificate-profile-types";
|
||||
|
||||
const generateAndEncryptAcmeEabSecret = async (
|
||||
projectId: string,
|
||||
kmsService: Pick<TKmsServiceFactory, "generateKmsKey" | "encryptWithKmsKey">,
|
||||
projectDAL: Pick<TProjectDALFactory, "findOne" | "updateById" | "transaction">
|
||||
) => {
|
||||
try {
|
||||
const certificateManagerKmsId = await getProjectKmsCertificateKeyId({
|
||||
projectId,
|
||||
projectDAL,
|
||||
kmsService
|
||||
});
|
||||
|
||||
const kmsEncryptor = await kmsService.encryptWithKmsKey({
|
||||
kmsId: certificateManagerKmsId
|
||||
});
|
||||
|
||||
const appCfg = getConfig();
|
||||
const secret = crypto.randomBytes(32).toString("hex");
|
||||
const secretHash = await crypto.hashing().createHash(secret, appCfg.SALT_ROUNDS);
|
||||
|
||||
const { cipherTextBlob } = await kmsEncryptor({
|
||||
plainText: Buffer.from(secretHash)
|
||||
});
|
||||
|
||||
return { encryptedEabSecret: cipherTextBlob };
|
||||
} catch (error) {
|
||||
throw new BadRequestError({ message: `Failed to generate ACME EAB secret: ${(error as Error).message}` });
|
||||
}
|
||||
};
|
||||
|
||||
const validateAndEncryptPemCaChain = async (
|
||||
caChain: string,
|
||||
projectId: string,
|
||||
@@ -95,9 +132,13 @@ const decryptCaChain = async (
|
||||
}
|
||||
};
|
||||
|
||||
export type TCertificateProfileCreateData = Omit<TCertificateProfileInsert, "estConfigId" | "apiConfigId"> & {
|
||||
export type TCertificateProfileCreateData = Omit<
|
||||
TCertificateProfileInsert,
|
||||
"estConfigId" | "apiConfigId" | "acmeConfigId"
|
||||
> & {
|
||||
estConfig?: TEstConfigData;
|
||||
apiConfig?: TApiConfigData;
|
||||
acmeConfig?: TAcmeConfigData;
|
||||
};
|
||||
|
||||
type TCertificateProfileServiceFactoryDep = {
|
||||
@@ -105,6 +146,11 @@ type TCertificateProfileServiceFactoryDep = {
|
||||
certificateTemplateV2DAL: TCertificateTemplateV2DALFactory;
|
||||
apiEnrollmentConfigDAL: TApiEnrollmentConfigDALFactory;
|
||||
estEnrollmentConfigDAL: TEstEnrollmentConfigDALFactory;
|
||||
acmeEnrollmentConfigDAL: TAcmeEnrollmentConfigDALFactory;
|
||||
certificateBodyDAL: Pick<TCertificateBodyDALFactory, "findOne">;
|
||||
certificateSecretDAL: Pick<TCertificateSecretDALFactory, "findOne">;
|
||||
certificateAuthorityDAL: Pick<TCertificateAuthorityDALFactory, "findById">;
|
||||
certificateAuthorityCertDAL: Pick<TCertificateAuthorityCertDALFactory, "findById">;
|
||||
permissionService: Pick<TPermissionServiceFactory, "getProjectPermission">;
|
||||
kmsService: Pick<TKmsServiceFactory, "generateKmsKey" | "encryptWithKmsKey" | "decryptWithKmsKey">;
|
||||
projectDAL: Pick<TProjectDALFactory, "findProjectBySlug" | "findOne" | "updateById" | "findById" | "transaction">;
|
||||
@@ -124,6 +170,9 @@ export const certificateProfileServiceFactory = ({
|
||||
certificateTemplateV2DAL,
|
||||
apiEnrollmentConfigDAL,
|
||||
estEnrollmentConfigDAL,
|
||||
acmeEnrollmentConfigDAL,
|
||||
certificateBodyDAL,
|
||||
certificateSecretDAL,
|
||||
permissionService,
|
||||
kmsService,
|
||||
projectDAL
|
||||
@@ -188,11 +237,14 @@ export const certificateProfileServiceFactory = ({
|
||||
message: "API enrollment requires API configuration"
|
||||
});
|
||||
}
|
||||
// TODO: acme type currently doesn't require config obj, but add a check in the future if
|
||||
// we have options
|
||||
|
||||
// Create enrollment configs and profile
|
||||
const profile = await certificateProfileDAL.transaction(async (tx) => {
|
||||
let estConfigId: string | null = null;
|
||||
let apiConfigId: string | null = null;
|
||||
let acmeConfigId: string | null = null;
|
||||
|
||||
if (data.enrollmentType === EnrollmentType.EST && data.estConfig) {
|
||||
const appCfg = getConfig();
|
||||
@@ -228,16 +280,21 @@ export const certificateProfileServiceFactory = ({
|
||||
tx
|
||||
);
|
||||
apiConfigId = apiConfig.id;
|
||||
} else if (data.enrollmentType === EnrollmentType.ACME && data.acmeConfig) {
|
||||
const { encryptedEabSecret } = await generateAndEncryptAcmeEabSecret(projectId, kmsService, projectDAL);
|
||||
const acmeConfig = await acmeEnrollmentConfigDAL.create({ encryptedEabSecret }, tx);
|
||||
acmeConfigId = acmeConfig.id;
|
||||
}
|
||||
|
||||
// Create the profile with the created config IDs
|
||||
const { estConfig, apiConfig, ...profileData } = data;
|
||||
const { estConfig, apiConfig, acmeConfig, ...profileData } = data;
|
||||
const profileResult = await certificateProfileDAL.create(
|
||||
{
|
||||
...profileData,
|
||||
projectId,
|
||||
estConfigId,
|
||||
apiConfigId
|
||||
apiConfigId,
|
||||
acmeConfigId
|
||||
},
|
||||
tx
|
||||
);
|
||||
@@ -439,6 +496,12 @@ export const certificateProfileServiceFactory = ({
|
||||
profile.estConfig.caChain = "";
|
||||
}
|
||||
}
|
||||
if (profile.enrollmentType === EnrollmentType.ACME && profile.acmeConfig) {
|
||||
profile.acmeConfig.directoryUrl = buildUrl(profile.id, "/directory");
|
||||
if (profile.acmeConfig.encryptedEabSecret) {
|
||||
profile.acmeConfig.encryptedEabSecret = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
...profile,
|
||||
@@ -574,7 +637,10 @@ export const certificateProfileServiceFactory = ({
|
||||
const result: TCertificateProfileWithConfigs = {
|
||||
...converted,
|
||||
estConfig: decryptedEstConfig,
|
||||
apiConfig: profileWithConfigs.apiConfig
|
||||
apiConfig: profileWithConfigs.apiConfig,
|
||||
acmeConfig: profileWithConfigs.acmeConfig
|
||||
? { ...profileWithConfigs.acmeConfig, directoryUrl: buildUrl(profile.id, "/directory") }
|
||||
: undefined
|
||||
};
|
||||
|
||||
return result;
|
||||
@@ -674,6 +740,106 @@ export const certificateProfileServiceFactory = ({
|
||||
return certificates;
|
||||
};
|
||||
|
||||
const getLatestActiveCertificateBundle = async ({
|
||||
actor,
|
||||
actorId,
|
||||
actorAuthMethod,
|
||||
actorOrgId,
|
||||
profileId
|
||||
}: {
|
||||
actor: ActorType;
|
||||
actorId: string;
|
||||
actorAuthMethod: ActorAuthMethod;
|
||||
actorOrgId: string;
|
||||
profileId: string;
|
||||
}) => {
|
||||
const profile = await certificateProfileDAL.findById(profileId);
|
||||
if (!profile) {
|
||||
throw new NotFoundError({ message: "Certificate profile not found" });
|
||||
}
|
||||
|
||||
const { permission } = await permissionService.getProjectPermission({
|
||||
actor,
|
||||
actorId,
|
||||
projectId: profile.projectId,
|
||||
actorAuthMethod,
|
||||
actorOrgId,
|
||||
actionProjectType: ActionProjectType.CertificateManager
|
||||
});
|
||||
ForbiddenError.from(permission).throwUnlessCan(
|
||||
ProjectPermissionCertificateProfileActions.Read,
|
||||
ProjectPermissionSub.CertificateProfiles
|
||||
);
|
||||
|
||||
ForbiddenError.from(permission).throwUnlessCan(
|
||||
ProjectPermissionCertificateActions.Read,
|
||||
ProjectPermissionSub.Certificates
|
||||
);
|
||||
|
||||
ForbiddenError.from(permission).throwUnlessCan(
|
||||
ProjectPermissionCertificateActions.ReadPrivateKey,
|
||||
ProjectPermissionSub.Certificates
|
||||
);
|
||||
|
||||
const cert = await certificateProfileDAL.getLatestActiveCertificateForProfile(profileId);
|
||||
|
||||
if (!cert) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const certBody = await certificateBodyDAL.findOne({ certId: cert.id });
|
||||
|
||||
const certificateManagerKeyId = await getProjectKmsCertificateKeyId({
|
||||
projectId: cert.projectId,
|
||||
projectDAL,
|
||||
kmsService
|
||||
});
|
||||
|
||||
const kmsDecryptor = await kmsService.decryptWithKmsKey({
|
||||
kmsId: certificateManagerKeyId
|
||||
});
|
||||
const decryptedCert = await kmsDecryptor({
|
||||
cipherTextBlob: certBody.encryptedCertificate
|
||||
});
|
||||
|
||||
const certObj = new x509.X509Certificate(decryptedCert);
|
||||
const certificate = certObj.toString("pem");
|
||||
|
||||
const decryptedCertChain = await kmsDecryptor({
|
||||
cipherTextBlob: certBody.encryptedCertificateChain!
|
||||
});
|
||||
|
||||
const certificateChain = decryptedCertChain.toString();
|
||||
|
||||
let privateKey = null;
|
||||
try {
|
||||
const { certPrivateKey } = await getCertificateCredentials({
|
||||
certId: cert.id,
|
||||
projectId: cert.projectId,
|
||||
certificateSecretDAL,
|
||||
projectDAL,
|
||||
kmsService
|
||||
});
|
||||
privateKey = certPrivateKey;
|
||||
} catch (error) {
|
||||
// Private key might not exist for ACME certificates or other external workflows
|
||||
// where the key is generated client-side
|
||||
if (error instanceof NotFoundError) {
|
||||
privateKey = null;
|
||||
} else {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
certificate,
|
||||
certificateChain,
|
||||
privateKey,
|
||||
profile,
|
||||
certObj: cert
|
||||
};
|
||||
};
|
||||
|
||||
const getEstConfigurationByProfile = async (
|
||||
params:
|
||||
| {
|
||||
@@ -737,6 +903,59 @@ export const certificateProfileServiceFactory = ({
|
||||
};
|
||||
};
|
||||
|
||||
const revealAcmeEabSecret = async ({
|
||||
actor,
|
||||
actorId,
|
||||
actorAuthMethod,
|
||||
actorOrgId,
|
||||
profileId
|
||||
}: {
|
||||
actor: ActorType;
|
||||
actorId: string;
|
||||
actorAuthMethod: ActorAuthMethod;
|
||||
actorOrgId: string;
|
||||
profileId: string;
|
||||
}) => {
|
||||
const profile = await certificateProfileDAL.findByIdWithConfigs(profileId);
|
||||
if (!profile) {
|
||||
throw new NotFoundError({ message: "Certificate profile not found" });
|
||||
}
|
||||
|
||||
const { permission } = await permissionService.getProjectPermission({
|
||||
actor,
|
||||
actorId,
|
||||
projectId: profile.projectId,
|
||||
actorAuthMethod,
|
||||
actorOrgId,
|
||||
actionProjectType: ActionProjectType.CertificateManager
|
||||
});
|
||||
ForbiddenError.from(permission).throwUnlessCan(
|
||||
ProjectPermissionCertificateProfileActions.RevealAcmeEabSecret,
|
||||
ProjectPermissionSub.CertificateProfiles
|
||||
);
|
||||
|
||||
if (profile.enrollmentType !== EnrollmentType.ACME) {
|
||||
throw new ForbiddenRequestError({
|
||||
message: "Profile is not configured for ACME enrollment"
|
||||
});
|
||||
}
|
||||
if (!profile.acmeConfig) {
|
||||
throw new NotFoundError({ message: "ACME configuration not found for this profile" });
|
||||
}
|
||||
|
||||
const certificateManagerKmsId = await getProjectKmsCertificateKeyId({
|
||||
projectId: profile.projectId,
|
||||
projectDAL,
|
||||
kmsService
|
||||
});
|
||||
|
||||
const kmsDecryptor = await kmsService.decryptWithKmsKey({
|
||||
kmsId: certificateManagerKmsId
|
||||
});
|
||||
const eabSecret = await kmsDecryptor({ cipherTextBlob: profile.acmeConfig.encryptedEabSecret! });
|
||||
return { eabKid: profile.id, eabSecret: eabSecret.toString("base64url") };
|
||||
};
|
||||
|
||||
return {
|
||||
createProfile,
|
||||
updateProfile,
|
||||
@@ -746,6 +965,8 @@ export const certificateProfileServiceFactory = ({
|
||||
listProfiles,
|
||||
deleteProfile,
|
||||
getProfileCertificates,
|
||||
getEstConfigurationByProfile
|
||||
getLatestActiveCertificateBundle,
|
||||
getEstConfigurationByProfile,
|
||||
revealAcmeEabSecret
|
||||
};
|
||||
};
|
||||
|
||||
@@ -6,7 +6,8 @@ import {
|
||||
|
||||
export enum EnrollmentType {
|
||||
API = "api",
|
||||
EST = "est"
|
||||
EST = "est",
|
||||
ACME = "acme"
|
||||
}
|
||||
|
||||
export type TCertificateProfile = Omit<TPkiCertificateProfiles, "enrollmentType"> & {
|
||||
@@ -28,6 +29,7 @@ export type TCertificateProfileUpdate = Omit<TPkiCertificateProfilesUpdate, "enr
|
||||
autoRenew?: boolean;
|
||||
renewBeforeDays?: number;
|
||||
};
|
||||
acmeConfig?: unknown;
|
||||
};
|
||||
|
||||
export type TCertificateProfileWithConfigs = TCertificateProfile & {
|
||||
@@ -54,6 +56,11 @@ export type TCertificateProfileWithConfigs = TCertificateProfile & {
|
||||
autoRenew: boolean;
|
||||
renewBeforeDays?: number;
|
||||
};
|
||||
acmeConfig?: {
|
||||
id: string;
|
||||
directoryUrl: string;
|
||||
encryptedEabSecret?: Buffer;
|
||||
};
|
||||
};
|
||||
|
||||
export interface TCertificateProfileCertificate {
|
||||
|
||||
@@ -613,39 +613,6 @@ describe("CertificateTemplateV2Service", () => {
|
||||
expect(result.isValid).toBe(true);
|
||||
});
|
||||
|
||||
it("should handle camelCase key usage mapping correctly", async () => {
|
||||
const templateWithOptionalUsages = {
|
||||
...sampleTemplate,
|
||||
keyUsages: {
|
||||
requiredUsages: {
|
||||
all: [CertKeyUsageType.DIGITAL_SIGNATURE, CertKeyUsageType.NON_REPUDIATION, CertKeyUsageType.KEY_AGREEMENT]
|
||||
},
|
||||
optionalUsages: { all: [CertKeyUsageType.CRL_SIGN, CertKeyUsageType.DECIPHER_ONLY] }
|
||||
},
|
||||
extendedKeyUsages: {
|
||||
requiredUsages: { all: [CertExtendedKeyUsageType.CLIENT_AUTH, CertExtendedKeyUsageType.CODE_SIGNING] },
|
||||
optionalUsages: { all: [CertExtendedKeyUsageType.SERVER_AUTH, CertExtendedKeyUsageType.OCSP_SIGNING] }
|
||||
}
|
||||
};
|
||||
mockCertificateTemplateV2DAL.findById.mockResolvedValue(templateWithOptionalUsages);
|
||||
|
||||
const requestWithCamelCaseUsages = {
|
||||
...validRequest,
|
||||
keyUsages: [
|
||||
CertKeyUsageType.DIGITAL_SIGNATURE,
|
||||
CertKeyUsageType.NON_REPUDIATION,
|
||||
CertKeyUsageType.KEY_AGREEMENT,
|
||||
CertKeyUsageType.CRL_SIGN,
|
||||
CertKeyUsageType.DECIPHER_ONLY
|
||||
],
|
||||
extendedKeyUsages: [CertExtendedKeyUsageType.CLIENT_AUTH, CertExtendedKeyUsageType.CODE_SIGNING]
|
||||
};
|
||||
|
||||
const result = await service.validateCertificateRequest("template-123", requestWithCamelCaseUsages);
|
||||
expect(result.isValid).toBe(true);
|
||||
expect(result.errors).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("should validate wildcard patterns in allow attributes", async () => {
|
||||
const wildcardTemplate = {
|
||||
...sampleTemplate,
|
||||
|
||||
@@ -482,7 +482,7 @@ export const certificateTemplateV2ServiceFactory = ({
|
||||
}
|
||||
|
||||
// Check ALLOWED key usages - if present, all usages must be in allowed list
|
||||
if (request.keyUsages && keyUsagePolicy && keyUsagePolicy.allowed && keyUsagePolicy.allowed.length > 0) {
|
||||
if (request.keyUsages && keyUsagePolicy) {
|
||||
const allAllowedUsages = [...(keyUsagePolicy.required || []), ...(keyUsagePolicy.allowed || [])];
|
||||
const invalidUsages = request.keyUsages.filter((usage) => !allAllowedUsages.includes(usage));
|
||||
if (invalidUsages.length > 0) {
|
||||
@@ -517,12 +517,7 @@ export const certificateTemplateV2ServiceFactory = ({
|
||||
}
|
||||
|
||||
// Check ALLOWED extended key usages - if present, all usages must be in allowed list
|
||||
if (
|
||||
request.extendedKeyUsages &&
|
||||
extendedKeyUsagePolicy &&
|
||||
extendedKeyUsagePolicy.allowed &&
|
||||
extendedKeyUsagePolicy.allowed.length > 0
|
||||
) {
|
||||
if (request.extendedKeyUsages && extendedKeyUsagePolicy) {
|
||||
const allAllowedExtendedUsages = [
|
||||
...(extendedKeyUsagePolicy.required || []),
|
||||
...(extendedKeyUsagePolicy.allowed || [])
|
||||
|
||||
@@ -7,6 +7,7 @@ import { ForbiddenError } from "@casl/ability";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service-types";
|
||||
import { TPkiAcmeAccountDALFactory } from "@app/ee/services/pki-acme/pki-acme-account-dal";
|
||||
import { BadRequestError, ForbiddenRequestError, NotFoundError } from "@app/lib/errors";
|
||||
import { TCertificateDALFactory } from "@app/services/certificate/certificate-dal";
|
||||
import { TCertificateSecretDALFactory } from "@app/services/certificate/certificate-secret-dal";
|
||||
@@ -69,6 +70,10 @@ describe("CertificateV3Service", () => {
|
||||
getTemplateV2ById: vi.fn()
|
||||
};
|
||||
|
||||
const mockAcmeAccountDAL: Pick<TPkiAcmeAccountDALFactory, "findById"> = {
|
||||
findById: vi.fn()
|
||||
};
|
||||
|
||||
const mockInternalCaService: Pick<TInternalCertificateAuthorityServiceFactory, "signCertFromCa" | "issueCertFromCa"> =
|
||||
{
|
||||
signCertFromCa: vi.fn(),
|
||||
@@ -132,6 +137,7 @@ describe("CertificateV3Service", () => {
|
||||
certificateAuthorityDAL: mockCertificateAuthorityDAL,
|
||||
certificateProfileDAL: mockCertificateProfileDAL,
|
||||
certificateTemplateV2Service: mockCertificateTemplateV2Service,
|
||||
acmeAccountDAL: mockAcmeAccountDAL,
|
||||
internalCaService: mockInternalCaService,
|
||||
permissionService: mockPermissionService,
|
||||
certificateSyncDAL: {
|
||||
@@ -697,6 +703,7 @@ describe("CertificateV3Service", () => {
|
||||
profileId,
|
||||
csr: mockCSR,
|
||||
validity: mockValidity,
|
||||
enrollmentType: EnrollmentType.API,
|
||||
...mockActor
|
||||
});
|
||||
|
||||
@@ -731,6 +738,7 @@ describe("CertificateV3Service", () => {
|
||||
profileId,
|
||||
csr: mockCSR,
|
||||
validity: mockValidity,
|
||||
enrollmentType: EnrollmentType.API,
|
||||
...mockActor
|
||||
})
|
||||
).rejects.toThrow(ForbiddenRequestError);
|
||||
@@ -740,6 +748,7 @@ describe("CertificateV3Service", () => {
|
||||
profileId,
|
||||
csr: mockCSR,
|
||||
validity: mockValidity,
|
||||
enrollmentType: EnrollmentType.API,
|
||||
...mockActor
|
||||
})
|
||||
).rejects.toThrow("Profile is not configured for api enrollment");
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
ProjectPermissionCertificateProfileActions,
|
||||
ProjectPermissionSub
|
||||
} from "@app/ee/services/permission/project-permission";
|
||||
import { TPkiAcmeAccountDALFactory } from "@app/ee/services/pki-acme/pki-acme-account-dal";
|
||||
import { BadRequestError, ForbiddenRequestError, NotFoundError } from "@app/lib/errors";
|
||||
import { ActorAuthMethod, ActorType } from "@app/services/auth/auth-type";
|
||||
import { TCertificateDALFactory } from "@app/services/certificate/certificate-dal";
|
||||
@@ -70,6 +71,7 @@ type TCertificateV3ServiceFactoryDep = {
|
||||
certificateSecretDAL: Pick<TCertificateSecretDALFactory, "findOne">;
|
||||
certificateAuthorityDAL: Pick<TCertificateAuthorityDALFactory, "findByIdWithAssociatedCa">;
|
||||
certificateProfileDAL: Pick<TCertificateProfileDALFactory, "findByIdWithConfigs">;
|
||||
acmeAccountDAL: Pick<TPkiAcmeAccountDALFactory, "findById">;
|
||||
certificateTemplateV2Service: Pick<
|
||||
TCertificateTemplateV2ServiceFactory,
|
||||
"validateCertificateRequest" | "getTemplateV2ById"
|
||||
@@ -93,6 +95,7 @@ const validateProfileAndPermissions = async (
|
||||
actorAuthMethod: ActorAuthMethod,
|
||||
actorOrgId: string,
|
||||
certificateProfileDAL: Pick<TCertificateProfileDALFactory, "findByIdWithConfigs">,
|
||||
acmeAccountDAL: Pick<TPkiAcmeAccountDALFactory, "findById">,
|
||||
permissionService: Pick<TPermissionServiceFactory, "getProjectPermission">,
|
||||
requiredEnrollmentType: EnrollmentType
|
||||
) => {
|
||||
@@ -107,6 +110,19 @@ const validateProfileAndPermissions = async (
|
||||
});
|
||||
}
|
||||
|
||||
if (actor === ActorType.ACME_ACCOUNT && requiredEnrollmentType === EnrollmentType.ACME) {
|
||||
const account = await acmeAccountDAL.findById(actorId);
|
||||
if (!account) {
|
||||
throw new NotFoundError({ message: "ACME account not found" });
|
||||
}
|
||||
if (account.profileId !== profile.id) {
|
||||
throw new ForbiddenRequestError({
|
||||
message: "ACME account is not associated with this profile"
|
||||
});
|
||||
}
|
||||
return profile;
|
||||
}
|
||||
|
||||
const { permission } = await permissionService.getProjectPermission({
|
||||
actor,
|
||||
actorId,
|
||||
@@ -336,6 +352,7 @@ export const certificateV3ServiceFactory = ({
|
||||
certificateSecretDAL,
|
||||
certificateAuthorityDAL,
|
||||
certificateProfileDAL,
|
||||
acmeAccountDAL,
|
||||
certificateTemplateV2Service,
|
||||
internalCaService,
|
||||
permissionService,
|
||||
@@ -358,6 +375,7 @@ export const certificateV3ServiceFactory = ({
|
||||
actorAuthMethod,
|
||||
actorOrgId,
|
||||
certificateProfileDAL,
|
||||
acmeAccountDAL,
|
||||
permissionService,
|
||||
EnrollmentType.API
|
||||
);
|
||||
@@ -484,7 +502,8 @@ export const certificateV3ServiceFactory = ({
|
||||
actor,
|
||||
actorId,
|
||||
actorAuthMethod,
|
||||
actorOrgId
|
||||
actorOrgId,
|
||||
enrollmentType
|
||||
}: TSignCertificateFromProfileDTO): Promise<Omit<TCertificateFromProfileResponse, "privateKey">> => {
|
||||
const profile = await validateProfileAndPermissions(
|
||||
profileId,
|
||||
@@ -493,8 +512,9 @@ export const certificateV3ServiceFactory = ({
|
||||
actorAuthMethod,
|
||||
actorOrgId,
|
||||
certificateProfileDAL,
|
||||
acmeAccountDAL,
|
||||
permissionService,
|
||||
EnrollmentType.API
|
||||
enrollmentType
|
||||
);
|
||||
|
||||
const ca = await certificateAuthorityDAL.findByIdWithAssociatedCa(profile.caId);
|
||||
@@ -523,6 +543,10 @@ export const certificateV3ServiceFactory = ({
|
||||
const { keyAlgorithm: extractedKeyAlgorithm, signatureAlgorithm: extractedSignatureAlgorithm } =
|
||||
extractAlgorithmsFromCSR(csr);
|
||||
|
||||
mappedCertificateRequest.keyAlgorithm = extractedKeyAlgorithm;
|
||||
mappedCertificateRequest.signatureAlgorithm = extractedSignatureAlgorithm;
|
||||
mappedCertificateRequest.validity = validity;
|
||||
|
||||
const validationResult = await certificateTemplateV2Service.validateCertificateRequest(
|
||||
profile.certificateTemplateId,
|
||||
mappedCertificateRequest
|
||||
@@ -595,6 +619,7 @@ export const certificateV3ServiceFactory = ({
|
||||
actorAuthMethod,
|
||||
actorOrgId,
|
||||
certificateProfileDAL,
|
||||
acmeAccountDAL,
|
||||
permissionService,
|
||||
EnrollmentType.API
|
||||
);
|
||||
@@ -654,7 +679,7 @@ export const certificateV3ServiceFactory = ({
|
||||
status: CertificateOrderStatus.VALID
|
||||
})),
|
||||
authorizations: [],
|
||||
finalize: `/api/v3/certificates/orders/${orderId}/completed`,
|
||||
finalize: `/api/v3/pki/certificates/orders/${orderId}/completed`,
|
||||
certificate: certificateResult.certificate,
|
||||
projectId: certificateResult.projectId,
|
||||
profileName: certificateResult.profileName
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
CertKeyUsageType,
|
||||
CertSubjectAlternativeNameType
|
||||
} from "../certificate-common/certificate-constants";
|
||||
import { EnrollmentType } from "../certificate-profile/certificate-profile-types";
|
||||
|
||||
export type TIssueCertificateFromProfileDTO = {
|
||||
profileId: string;
|
||||
@@ -35,6 +36,7 @@ export type TSignCertificateFromProfileDTO = {
|
||||
};
|
||||
notBefore?: Date;
|
||||
notAfter?: Date;
|
||||
enrollmentType: EnrollmentType;
|
||||
} & Omit<TProjectPermission, "projectId">;
|
||||
|
||||
export type TOrderCertificateFromProfileDTO = {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import * as x509 from "@peculiar/x509";
|
||||
import forge from "node-forge";
|
||||
import RE2 from "re2";
|
||||
|
||||
import { crypto } from "@app/lib/crypto/cryptography";
|
||||
@@ -104,3 +105,53 @@ export const getCertificateCredentials = async ({
|
||||
throw new BadRequestError({ message: `Failed to process private key for certificate with ID '${certId}'` });
|
||||
}
|
||||
};
|
||||
|
||||
export const generatePkcs12FromCertificate = async ({
|
||||
certificate,
|
||||
certificateChain,
|
||||
privateKey,
|
||||
password,
|
||||
alias
|
||||
}: {
|
||||
certificate: string;
|
||||
certificateChain: string;
|
||||
privateKey: string;
|
||||
password: string;
|
||||
alias: string;
|
||||
}): Promise<Buffer> => {
|
||||
try {
|
||||
if (!password || password.trim() === "") {
|
||||
throw new BadRequestError({ message: "Password is required for PKCS12 keystore generation" });
|
||||
}
|
||||
|
||||
const cert = forge.pki.certificateFromPem(certificate);
|
||||
const key = forge.pki.privateKeyFromPem(privateKey);
|
||||
|
||||
const chainCerts = [];
|
||||
if (certificateChain) {
|
||||
const chainPems = splitPemChain(certificateChain);
|
||||
for (const chainPem of chainPems) {
|
||||
try {
|
||||
const chainCert = forge.pki.certificateFromPem(chainPem);
|
||||
chainCerts.push(chainCert);
|
||||
} catch (error) {
|
||||
// Skip invalid certificates in chain
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Generate PKCS12 file
|
||||
const p12Asn1 = forge.pkcs12.toPkcs12Asn1(key, [cert, ...chainCerts], password, {
|
||||
algorithm: "aes256", // Modern AES-256 encryption
|
||||
friendlyName: alias
|
||||
});
|
||||
|
||||
const p12Der = forge.asn1.toDer(p12Asn1).getBytes();
|
||||
|
||||
return Buffer.from(p12Der, "binary");
|
||||
} catch (error) {
|
||||
throw new BadRequestError({
|
||||
message: `Failed to generate PKCS12 keystore: ${error instanceof Error ? error.message : "Unknown error"}`
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
@@ -29,7 +29,12 @@ import { TProjectDALFactory } from "@app/services/project/project-dal";
|
||||
import { getProjectKmsCertificateKeyId } from "@app/services/project/project-fns";
|
||||
|
||||
import { expandInternalCa, getCaCertChain, rebuildCaCrl } from "../certificate-authority/certificate-authority-fns";
|
||||
import { getCertificateCredentials, revocationReasonToCrlCode, splitPemChain } from "./certificate-fns";
|
||||
import {
|
||||
generatePkcs12FromCertificate,
|
||||
getCertificateCredentials,
|
||||
revocationReasonToCrlCode,
|
||||
splitPemChain
|
||||
} from "./certificate-fns";
|
||||
import { TCertificateSecretDALFactory } from "./certificate-secret-dal";
|
||||
import {
|
||||
CertExtendedKeyUsage,
|
||||
@@ -40,6 +45,7 @@ import {
|
||||
TGetCertBodyDTO,
|
||||
TGetCertBundleDTO,
|
||||
TGetCertDTO,
|
||||
TGetCertPkcs12DTO,
|
||||
TGetCertPrivateKeyDTO,
|
||||
TImportCertDTO,
|
||||
TRevokeCertDTO
|
||||
@@ -656,6 +662,71 @@ export const certificateServiceFactory = ({
|
||||
};
|
||||
};
|
||||
|
||||
const getCertPkcs12 = async ({
|
||||
serialNumber,
|
||||
password,
|
||||
alias,
|
||||
actorId,
|
||||
actorAuthMethod,
|
||||
actor,
|
||||
actorOrgId
|
||||
}: TGetCertPkcs12DTO) => {
|
||||
if (!password || password.trim() === "") {
|
||||
throw new BadRequestError({ message: "Password is required for PKCS12 keystore generation" });
|
||||
}
|
||||
|
||||
if (password.length < 6) {
|
||||
throw new BadRequestError({
|
||||
message: "Password must be at least 6 characters long for PKCS12 keystore security"
|
||||
});
|
||||
}
|
||||
|
||||
if (!alias || alias.trim() === "") {
|
||||
throw new BadRequestError({ message: "Alias is required for PKCS12 keystore generation" });
|
||||
}
|
||||
const cert = await certificateDAL.findOne({ serialNumber });
|
||||
|
||||
const { permission } = await permissionService.getProjectPermission({
|
||||
actor,
|
||||
actorId,
|
||||
projectId: cert.projectId,
|
||||
actorAuthMethod,
|
||||
actorOrgId,
|
||||
actionProjectType: ActionProjectType.CertificateManager
|
||||
});
|
||||
|
||||
ForbiddenError.from(permission).throwUnlessCan(
|
||||
ProjectPermissionCertificateActions.ReadPrivateKey,
|
||||
ProjectPermissionSub.Certificates
|
||||
);
|
||||
|
||||
// Get certificate bundle (certificate, chain, private key)
|
||||
const { certificate, certificateChain, privateKey } = await getCertBundle({
|
||||
serialNumber,
|
||||
actor,
|
||||
actorId,
|
||||
actorAuthMethod,
|
||||
actorOrgId
|
||||
});
|
||||
|
||||
if (!privateKey) {
|
||||
throw new BadRequestError({ message: "Certificate private key is required for PKCS12 export" });
|
||||
}
|
||||
|
||||
const pkcs12Data = await generatePkcs12FromCertificate({
|
||||
certificate,
|
||||
certificateChain: certificateChain || "",
|
||||
privateKey,
|
||||
password,
|
||||
alias
|
||||
});
|
||||
|
||||
return {
|
||||
pkcs12Data,
|
||||
cert
|
||||
};
|
||||
};
|
||||
|
||||
return {
|
||||
getCert,
|
||||
getCertPrivateKey,
|
||||
@@ -663,6 +734,7 @@ export const certificateServiceFactory = ({
|
||||
revokeCert,
|
||||
getCertBody,
|
||||
importCert,
|
||||
getCertBundle
|
||||
getCertBundle,
|
||||
getCertPkcs12
|
||||
};
|
||||
};
|
||||
|
||||
@@ -119,6 +119,12 @@ export type TGetCertBundleDTO = {
|
||||
serialNumber: string;
|
||||
} & Omit<TProjectPermission, "projectId">;
|
||||
|
||||
export type TGetCertPkcs12DTO = {
|
||||
serialNumber: string;
|
||||
password: string;
|
||||
alias: string;
|
||||
} & Omit<TProjectPermission, "projectId">;
|
||||
|
||||
export type TGetCertificateCredentialsDTO = {
|
||||
certId: string;
|
||||
projectId: string;
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
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";
|
||||
|
||||
import { TAcmeEnrollmentConfigInsert, TAcmeEnrollmentConfigUpdate } from "./enrollment-config-types";
|
||||
|
||||
export type TAcmeEnrollmentConfigDALFactory = ReturnType<typeof acmeEnrollmentConfigDALFactory>;
|
||||
|
||||
export const acmeEnrollmentConfigDALFactory = (db: TDbClient) => {
|
||||
const acmeEnrollmentConfigOrm = ormify(db, TableName.PkiAcmeEnrollmentConfig);
|
||||
|
||||
const create = async (data: TAcmeEnrollmentConfigInsert, tx?: Knex) => {
|
||||
try {
|
||||
const result = await (tx || db)(TableName.PkiAcmeEnrollmentConfig).insert(data).returning("*");
|
||||
const [acmeConfig] = result;
|
||||
|
||||
if (!acmeConfig) {
|
||||
throw new Error("Failed to create ACME enrollment config");
|
||||
}
|
||||
|
||||
return acmeConfig;
|
||||
} catch (error) {
|
||||
throw new DatabaseError({ error, name: "Create ACME enrollment config" });
|
||||
}
|
||||
};
|
||||
|
||||
const updateById = async (id: string, data: TAcmeEnrollmentConfigUpdate, tx?: Knex) => {
|
||||
try {
|
||||
const result = await (tx || db)(TableName.PkiAcmeEnrollmentConfig).where({ id }).update(data).returning("*");
|
||||
const [acmeConfig] = result;
|
||||
|
||||
if (!acmeConfig) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return acmeConfig;
|
||||
} catch (error) {
|
||||
throw new DatabaseError({ error, name: "Update ACME enrollment config" });
|
||||
}
|
||||
};
|
||||
|
||||
const findById = async (id: string, tx?: Knex) => {
|
||||
try {
|
||||
const acmeConfig = await (tx || db)(TableName.PkiAcmeEnrollmentConfig).where({ id }).first();
|
||||
|
||||
return acmeConfig || null;
|
||||
} catch (error) {
|
||||
throw new DatabaseError({ error, name: "Find ACME enrollment config by id" });
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
...acmeEnrollmentConfigOrm,
|
||||
create,
|
||||
updateById,
|
||||
findById
|
||||
};
|
||||
};
|
||||
@@ -1,3 +1,8 @@
|
||||
import {
|
||||
TPkiAcmeEnrollmentConfigs,
|
||||
TPkiAcmeEnrollmentConfigsInsert,
|
||||
TPkiAcmeEnrollmentConfigsUpdate
|
||||
} from "@app/db/schemas/pki-acme-enrollment-configs";
|
||||
import {
|
||||
TPkiApiEnrollmentConfigs,
|
||||
TPkiApiEnrollmentConfigsInsert,
|
||||
@@ -17,6 +22,10 @@ export type TApiEnrollmentConfig = TPkiApiEnrollmentConfigs;
|
||||
export type TApiEnrollmentConfigInsert = TPkiApiEnrollmentConfigsInsert;
|
||||
export type TApiEnrollmentConfigUpdate = TPkiApiEnrollmentConfigsUpdate;
|
||||
|
||||
export type TAcmeEnrollmentConfig = TPkiAcmeEnrollmentConfigs;
|
||||
export type TAcmeEnrollmentConfigInsert = TPkiAcmeEnrollmentConfigsInsert;
|
||||
export type TAcmeEnrollmentConfigUpdate = TPkiAcmeEnrollmentConfigsUpdate;
|
||||
|
||||
export interface TEstConfigData {
|
||||
disableBootstrapCaValidation: boolean;
|
||||
passphrase: string;
|
||||
@@ -27,3 +36,5 @@ export interface TApiConfigData {
|
||||
autoRenew: boolean;
|
||||
renewBeforeDays?: number;
|
||||
}
|
||||
|
||||
export interface TAcmeConfigData {}
|
||||
|
||||
@@ -186,8 +186,8 @@ export const identityAccessTokenServiceFactory = ({
|
||||
|
||||
const fnValidateIdentityAccessToken = async (
|
||||
token: TIdentityAccessTokenJwtPayload,
|
||||
subOrganizationSelector?: string,
|
||||
ipAddress?: string
|
||||
ipAddress?: string,
|
||||
subOrganizationSelector?: string
|
||||
) => {
|
||||
const identityAccessToken = await identityAccessTokenDAL.findOne({
|
||||
[`${TableName.IdentityAccessToken}.id` as "id"]: token.identityAccessTokenId,
|
||||
|
||||
@@ -112,7 +112,20 @@ export const identityOidcAuthServiceFactory = ({
|
||||
});
|
||||
|
||||
const { kid } = decodedToken.header as { kid: string };
|
||||
const oidcSigningKey = await client.getSigningKey(kid);
|
||||
|
||||
let oidcSigningKey;
|
||||
try {
|
||||
oidcSigningKey = await client.getSigningKey(kid);
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.name === "SigningKeyNotFoundError") {
|
||||
throw new UnauthorizedError({
|
||||
message: `Access denied: Unable to verify JWT signature. The signing key '${kid}' was not found in the OIDC provider's JWKS endpoint. This may indicate an invalid token or misconfigured OIDC provider.`
|
||||
});
|
||||
}
|
||||
throw new UnauthorizedError({
|
||||
message: `Access denied: Failed to retrieve signing key from OIDC provider: ${error instanceof Error ? error.message : String(error)}`
|
||||
});
|
||||
}
|
||||
|
||||
let tokenData: Record<string, string>;
|
||||
try {
|
||||
|
||||
@@ -1,16 +1,20 @@
|
||||
import { Knex } from "knex";
|
||||
|
||||
import { TDbClient } from "@app/db";
|
||||
import { TableName } from "@app/db/schemas";
|
||||
import { TableName, TMicrosoftTeamsIntegrations } from "@app/db/schemas";
|
||||
import { TProjectMicrosoftTeamsConfigs } from "@app/db/schemas/project-microsoft-teams-configs";
|
||||
import { ormify, selectAllTableCols } from "@app/lib/knex";
|
||||
|
||||
export type TProjectMicrosoftTeamsConfigDALFactory = ReturnType<typeof projectMicrosoftTeamsConfigDALFactory>;
|
||||
export type TProjectMicrosoftTeamsConfigWithIntegrations = TProjectMicrosoftTeamsConfigs & TMicrosoftTeamsIntegrations;
|
||||
|
||||
export const projectMicrosoftTeamsConfigDALFactory = (db: TDbClient) => {
|
||||
const projectMicrosoftTeamsConfigOrm = ormify(db, TableName.ProjectMicrosoftTeamsConfigs);
|
||||
|
||||
const getIntegrationDetailsByProject = (projectId: string, tx?: Knex) => {
|
||||
return (tx || db.replicaNode())(TableName.ProjectMicrosoftTeamsConfigs)
|
||||
return (tx || db.replicaNode())<TProjectMicrosoftTeamsConfigWithIntegrations>(
|
||||
TableName.ProjectMicrosoftTeamsConfigs
|
||||
)
|
||||
.join(
|
||||
TableName.MicrosoftTeamsIntegrations,
|
||||
`${TableName.ProjectMicrosoftTeamsConfigs}.microsoftTeamsIntegrationId`,
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import { Knex } from "knex";
|
||||
|
||||
import { TDbClient } from "@app/db";
|
||||
import { TableName, TPkiAlertChannels, TPkiAlertChannelsInsert } from "@app/db/schemas";
|
||||
import { DatabaseError } from "@app/lib/errors";
|
||||
import { ormify, selectAllTableCols } from "@app/lib/knex";
|
||||
|
||||
export type TPkiAlertChannelDALFactory = ReturnType<typeof pkiAlertChannelDALFactory>;
|
||||
|
||||
export const pkiAlertChannelDALFactory = (db: TDbClient) => {
|
||||
const pkiAlertChannelOrm = ormify(db, TableName.PkiAlertChannels);
|
||||
|
||||
const insertMany = async (data: TPkiAlertChannelsInsert[], tx?: Knex): Promise<TPkiAlertChannels[]> => {
|
||||
try {
|
||||
if (!data.length) return [];
|
||||
|
||||
const serializedData = data.map((item) => ({
|
||||
...item,
|
||||
config: item.config ? JSON.stringify(item.config) : null
|
||||
}));
|
||||
|
||||
const res = await (tx || db)(TableName.PkiAlertChannels).insert(serializedData).returning("*");
|
||||
|
||||
return res as TPkiAlertChannels[];
|
||||
} catch (error) {
|
||||
throw new DatabaseError({ error, name: "InsertMany" });
|
||||
}
|
||||
};
|
||||
|
||||
const findByAlertId = async (alertId: string, tx?: Knex): Promise<TPkiAlertChannels[]> => {
|
||||
try {
|
||||
const channels = await (tx || db.replicaNode())(TableName.PkiAlertChannels)
|
||||
.where(`${TableName.PkiAlertChannels}.alertId`, alertId)
|
||||
.select(selectAllTableCols(TableName.PkiAlertChannels))
|
||||
.orderBy(`${TableName.PkiAlertChannels}.createdAt`, "asc");
|
||||
|
||||
return channels as TPkiAlertChannels[];
|
||||
} catch (error) {
|
||||
throw new DatabaseError({ error, name: "FindByAlertId" });
|
||||
}
|
||||
};
|
||||
|
||||
const deleteByAlertId = async (alertId: string, tx?: Knex): Promise<number> => {
|
||||
try {
|
||||
const deletedCount = await (tx || db)(TableName.PkiAlertChannels)
|
||||
.where(`${TableName.PkiAlertChannels}.alertId`, alertId)
|
||||
.del();
|
||||
|
||||
return deletedCount;
|
||||
} catch (error) {
|
||||
throw new DatabaseError({ error, name: "DeleteByAlertId" });
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
...pkiAlertChannelOrm,
|
||||
insertMany,
|
||||
findByAlertId,
|
||||
deleteByAlertId
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,110 @@
|
||||
import { TDbClient } from "@app/db";
|
||||
import { TableName, TPkiAlertHistory } from "@app/db/schemas";
|
||||
import { DatabaseError } from "@app/lib/errors";
|
||||
import { ormify, selectAllTableCols } from "@app/lib/knex";
|
||||
|
||||
export type TPkiAlertHistoryDALFactory = ReturnType<typeof pkiAlertHistoryDALFactory>;
|
||||
|
||||
export const pkiAlertHistoryDALFactory = (db: TDbClient) => {
|
||||
const pkiAlertHistoryOrm = ormify(db, TableName.PkiAlertHistory);
|
||||
|
||||
const createWithCertificates = async (
|
||||
alertId: string,
|
||||
certificateIds: string[],
|
||||
options?: {
|
||||
hasNotificationSent?: boolean;
|
||||
notificationError?: string;
|
||||
}
|
||||
): Promise<TPkiAlertHistory> => {
|
||||
try {
|
||||
return await db.transaction(async (tx) => {
|
||||
const historyRecords = await tx(TableName.PkiAlertHistory)
|
||||
.insert({
|
||||
alertId,
|
||||
hasNotificationSent: options?.hasNotificationSent || false,
|
||||
notificationError: options?.notificationError
|
||||
})
|
||||
.returning("*");
|
||||
|
||||
const historyRecord = historyRecords[0];
|
||||
|
||||
if (certificateIds.length > 0) {
|
||||
const certificateAssociations = certificateIds.map((certificateId) => ({
|
||||
alertHistoryId: historyRecord.id,
|
||||
certificateId
|
||||
}));
|
||||
|
||||
await tx(TableName.PkiAlertHistoryCertificate).insert(certificateAssociations);
|
||||
}
|
||||
|
||||
return historyRecord;
|
||||
});
|
||||
} catch (error) {
|
||||
throw new DatabaseError({ error, name: "CreateWithCertificates" });
|
||||
}
|
||||
};
|
||||
|
||||
const findByAlertId = async (
|
||||
alertId: string,
|
||||
options?: {
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
}
|
||||
): Promise<TPkiAlertHistory[]> => {
|
||||
try {
|
||||
let query = db
|
||||
.replicaNode()
|
||||
.select(selectAllTableCols(TableName.PkiAlertHistory))
|
||||
.from(TableName.PkiAlertHistory)
|
||||
.where(`${TableName.PkiAlertHistory}.alertId`, alertId)
|
||||
.orderBy(`${TableName.PkiAlertHistory}.triggeredAt`, "desc");
|
||||
|
||||
if (options?.limit) {
|
||||
query = query.limit(options.limit);
|
||||
}
|
||||
|
||||
if (options?.offset) {
|
||||
query = query.offset(options.offset);
|
||||
}
|
||||
|
||||
const results = await query;
|
||||
return results as TPkiAlertHistory[];
|
||||
} catch (error) {
|
||||
throw new DatabaseError({ error, name: "FindByAlertId" });
|
||||
}
|
||||
};
|
||||
|
||||
const findRecentlyAlertedCertificates = async (
|
||||
alertId: string,
|
||||
certificateIds: string[],
|
||||
withinHours = 24
|
||||
): Promise<string[]> => {
|
||||
try {
|
||||
if (certificateIds.length === 0) return [];
|
||||
|
||||
const cutoffDate = new Date();
|
||||
cutoffDate.setHours(cutoffDate.getHours() - withinHours);
|
||||
|
||||
const results = (await db
|
||||
.replicaNode()
|
||||
.select("cert.certificateId")
|
||||
.from(`${TableName.PkiAlertHistory} as hist`)
|
||||
.join(`${TableName.PkiAlertHistoryCertificate} as cert`, "hist.id", "cert.alertHistoryId")
|
||||
.where("hist.alertId", alertId)
|
||||
.where("hist.hasNotificationSent", true)
|
||||
.where("hist.triggeredAt", ">=", cutoffDate)
|
||||
.whereIn("cert.certificateId", certificateIds)) as Array<{ certificateId: string }>;
|
||||
|
||||
return results.map((row) => row.certificateId);
|
||||
} catch (error) {
|
||||
throw new DatabaseError({ error, name: "FindRecentlyAlertedCertificates" });
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
...pkiAlertHistoryOrm,
|
||||
createWithCertificates,
|
||||
findByAlertId,
|
||||
findRecentlyAlertedCertificates
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,556 @@
|
||||
import { Knex } from "knex";
|
||||
|
||||
import { TDbClient } from "@app/db";
|
||||
import { TableName, TPkiAlertsV2, TPkiAlertsV2Insert, TPkiAlertsV2Update } from "@app/db/schemas";
|
||||
import { DatabaseError } from "@app/lib/errors";
|
||||
import { ormify, selectAllTableCols } from "@app/lib/knex";
|
||||
|
||||
import {
|
||||
applyCaFilters,
|
||||
applyCertificateFilters,
|
||||
requiresProfileJoin,
|
||||
sanitizeLikeInput,
|
||||
shouldIncludeCAs
|
||||
} from "./pki-alert-v2-filter-utils";
|
||||
import { CertificateOrigin, TCertificatePreview, TPkiFilterRule } from "./pki-alert-v2-types";
|
||||
|
||||
export type TPkiAlertV2DALFactory = ReturnType<typeof pkiAlertV2DALFactory>;
|
||||
|
||||
export const pkiAlertV2DALFactory = (db: TDbClient) => {
|
||||
const pkiAlertV2Orm = ormify(db, TableName.PkiAlertsV2);
|
||||
|
||||
const create = async (data: TPkiAlertsV2Insert, tx?: Knex): Promise<TPkiAlertsV2> => {
|
||||
try {
|
||||
const serializedData = {
|
||||
...data,
|
||||
filters: data.filters ? JSON.stringify(data.filters) : null
|
||||
};
|
||||
const [res] = await (tx || db)(TableName.PkiAlertsV2).insert(serializedData).returning("*");
|
||||
|
||||
return res;
|
||||
} catch (error) {
|
||||
throw new DatabaseError({ error, name: "Create" });
|
||||
}
|
||||
};
|
||||
|
||||
const updateById = async (id: string, data: TPkiAlertsV2Update, tx?: Knex): Promise<TPkiAlertsV2> => {
|
||||
try {
|
||||
const serializedData: Record<string, unknown> = {
|
||||
...data,
|
||||
filters: data.filters !== undefined ? JSON.stringify(data.filters) : undefined
|
||||
};
|
||||
Object.keys(serializedData).forEach((key) => {
|
||||
if (serializedData[key] === undefined) {
|
||||
delete serializedData[key];
|
||||
}
|
||||
});
|
||||
|
||||
const [res] = await (tx || db)(TableName.PkiAlertsV2).where({ id }).update(serializedData).returning("*");
|
||||
|
||||
return res;
|
||||
} catch (error) {
|
||||
throw new DatabaseError({ error, name: "UpdateById" });
|
||||
}
|
||||
};
|
||||
|
||||
const findById = async (id: string, tx?: Knex): Promise<TPkiAlertsV2 | null> => {
|
||||
try {
|
||||
const [res] = await (tx || db.replicaNode())(TableName.PkiAlertsV2).where({ id }).select("*");
|
||||
|
||||
if (!res) return null;
|
||||
|
||||
return res;
|
||||
} catch (error) {
|
||||
throw new DatabaseError({ error, name: "FindById" });
|
||||
}
|
||||
};
|
||||
|
||||
type TChannelResult = {
|
||||
id: string;
|
||||
alertId: string;
|
||||
channelType: string;
|
||||
config: unknown;
|
||||
enabled: boolean;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
};
|
||||
|
||||
type TAlertWithChannels = TPkiAlertsV2 & {
|
||||
channels: TChannelResult[];
|
||||
};
|
||||
|
||||
const findByIdWithChannels = async (alertId: string, tx?: Knex): Promise<TAlertWithChannels | null> => {
|
||||
try {
|
||||
const [alert] = (await (tx || db.replicaNode())
|
||||
.select(selectAllTableCols(TableName.PkiAlertsV2))
|
||||
.from(TableName.PkiAlertsV2)
|
||||
.where(`${TableName.PkiAlertsV2}.id`, alertId)) as TPkiAlertsV2[];
|
||||
|
||||
if (!alert) return null;
|
||||
|
||||
const channels = (await (tx || db.replicaNode())
|
||||
.select(selectAllTableCols(TableName.PkiAlertChannels))
|
||||
.from(TableName.PkiAlertChannels)
|
||||
.where(`${TableName.PkiAlertChannels}.alertId`, alertId)) as TChannelResult[];
|
||||
|
||||
return {
|
||||
...alert,
|
||||
channels: channels || []
|
||||
} as TAlertWithChannels;
|
||||
} catch (error) {
|
||||
throw new DatabaseError({ error, name: "FindByIdWithChannels" });
|
||||
}
|
||||
};
|
||||
|
||||
const findByProjectIdWithCount = async (
|
||||
projectId: string,
|
||||
filters?: {
|
||||
search?: string;
|
||||
eventType?: string;
|
||||
enabled?: boolean;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
},
|
||||
tx?: Knex
|
||||
): Promise<{ alerts: TAlertWithChannels[]; total: number }> => {
|
||||
try {
|
||||
let countQuery = (tx || db.replicaNode())
|
||||
.count("* as count")
|
||||
.from(TableName.PkiAlertsV2)
|
||||
.where(`${TableName.PkiAlertsV2}.projectId`, projectId);
|
||||
|
||||
if (filters?.search) {
|
||||
countQuery = countQuery.whereILike(`${TableName.PkiAlertsV2}.name`, `%${sanitizeLikeInput(filters.search)}%`);
|
||||
}
|
||||
|
||||
if (filters?.eventType) {
|
||||
countQuery = countQuery.where(`${TableName.PkiAlertsV2}.eventType`, filters.eventType);
|
||||
}
|
||||
|
||||
if (filters?.enabled !== undefined) {
|
||||
countQuery = countQuery.where(`${TableName.PkiAlertsV2}.enabled`, filters.enabled);
|
||||
}
|
||||
|
||||
let alertQuery = (tx || db.replicaNode())
|
||||
.select(selectAllTableCols(TableName.PkiAlertsV2))
|
||||
.from(TableName.PkiAlertsV2)
|
||||
.where(`${TableName.PkiAlertsV2}.projectId`, projectId);
|
||||
|
||||
if (filters?.search) {
|
||||
alertQuery = alertQuery.whereILike(`${TableName.PkiAlertsV2}.name`, `%${sanitizeLikeInput(filters.search)}%`);
|
||||
}
|
||||
|
||||
if (filters?.eventType) {
|
||||
alertQuery = alertQuery.where(`${TableName.PkiAlertsV2}.eventType`, filters.eventType);
|
||||
}
|
||||
|
||||
if (filters?.enabled !== undefined) {
|
||||
alertQuery = alertQuery.where(`${TableName.PkiAlertsV2}.enabled`, filters.enabled);
|
||||
}
|
||||
|
||||
alertQuery = alertQuery.orderBy(`${TableName.PkiAlertsV2}.createdAt`, "desc");
|
||||
|
||||
if (filters?.limit) {
|
||||
alertQuery = alertQuery.limit(filters.limit);
|
||||
}
|
||||
|
||||
if (filters?.offset) {
|
||||
alertQuery = alertQuery.offset(filters.offset);
|
||||
}
|
||||
|
||||
const [countResult, alerts] = await Promise.all([countQuery, alertQuery]);
|
||||
|
||||
const total = parseInt((countResult[0] as { count: string }).count, 10);
|
||||
|
||||
const alertIds = (alerts as TPkiAlertsV2[]).map((alert) => alert.id);
|
||||
const channels = (await (tx || db.replicaNode())
|
||||
.select(selectAllTableCols(TableName.PkiAlertChannels))
|
||||
.from(TableName.PkiAlertChannels)
|
||||
.whereIn(`${TableName.PkiAlertChannels}.alertId`, alertIds)) as TChannelResult[];
|
||||
|
||||
const channelsByAlertId = channels.reduce(
|
||||
(acc, channel) => {
|
||||
if (!acc[channel.alertId]) {
|
||||
acc[channel.alertId] = [];
|
||||
}
|
||||
acc[channel.alertId].push(channel);
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, TChannelResult[]>
|
||||
);
|
||||
|
||||
const alertsWithChannels: TAlertWithChannels[] = (alerts as TPkiAlertsV2[]).map((alert) => ({
|
||||
...alert,
|
||||
channels: channelsByAlertId[alert.id] || []
|
||||
}));
|
||||
|
||||
return { alerts: alertsWithChannels, total };
|
||||
} catch (error) {
|
||||
throw new DatabaseError({ error, name: "FindByProjectIdWithCount" });
|
||||
}
|
||||
};
|
||||
|
||||
const findByProjectId = async (
|
||||
projectId: string,
|
||||
filters?: {
|
||||
search?: string;
|
||||
eventType?: string;
|
||||
enabled?: boolean;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
},
|
||||
tx?: Knex
|
||||
): Promise<TAlertWithChannels[]> => {
|
||||
const result = await findByProjectIdWithCount(projectId, filters, tx);
|
||||
return result.alerts;
|
||||
};
|
||||
|
||||
const countByProjectId = async (
|
||||
projectId: string,
|
||||
filters?: {
|
||||
search?: string;
|
||||
eventType?: string;
|
||||
enabled?: boolean;
|
||||
},
|
||||
tx?: Knex
|
||||
): Promise<number> => {
|
||||
try {
|
||||
let query = (tx || db.replicaNode())
|
||||
.count("* as count")
|
||||
.from(TableName.PkiAlertsV2)
|
||||
.where(`${TableName.PkiAlertsV2}.projectId`, projectId);
|
||||
|
||||
if (filters?.search) {
|
||||
query = query.whereILike(`${TableName.PkiAlertsV2}.name`, `%${sanitizeLikeInput(filters.search)}%`);
|
||||
}
|
||||
|
||||
if (filters?.eventType) {
|
||||
query = query.where(`${TableName.PkiAlertsV2}.eventType`, filters.eventType);
|
||||
}
|
||||
|
||||
if (filters?.enabled !== undefined) {
|
||||
query = query.where(`${TableName.PkiAlertsV2}.enabled`, filters.enabled);
|
||||
}
|
||||
|
||||
const result = await query;
|
||||
return parseInt((result[0] as { count: string }).count, 10);
|
||||
} catch (error) {
|
||||
throw new DatabaseError({ error, name: "CountByProjectId" });
|
||||
}
|
||||
};
|
||||
|
||||
const getDistinctProjectIds = async (
|
||||
filters?: {
|
||||
enabled?: boolean;
|
||||
},
|
||||
tx?: Knex
|
||||
): Promise<string[]> => {
|
||||
try {
|
||||
let query = (tx || db.replicaNode()).distinct(`${TableName.PkiAlertsV2}.projectId`).from(TableName.PkiAlertsV2);
|
||||
|
||||
if (filters?.enabled !== undefined) {
|
||||
query = query.where(`${TableName.PkiAlertsV2}.enabled`, filters.enabled);
|
||||
}
|
||||
|
||||
const result = await query;
|
||||
return result.map((row: { projectId: string }) => row.projectId);
|
||||
} catch (error) {
|
||||
throw new DatabaseError({ error, name: "GetDistinctProjectIds" });
|
||||
}
|
||||
};
|
||||
|
||||
const findMatchingCertificates = async (
|
||||
projectId: string,
|
||||
filters: TPkiFilterRule[] = [],
|
||||
options?: {
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
alertBefore?: string;
|
||||
showFutureMatches?: boolean;
|
||||
showCurrentMatches?: boolean;
|
||||
showPreview?: boolean;
|
||||
excludeAlerted?: boolean;
|
||||
alertId?: string;
|
||||
},
|
||||
tx?: Knex
|
||||
): Promise<{ certificates: TCertificatePreview[]; total: number }> => {
|
||||
try {
|
||||
const includeCAs = shouldIncludeCAs(filters);
|
||||
const needsProfileJoin = requiresProfileJoin(filters);
|
||||
const limit = options?.limit || 10;
|
||||
const offset = options?.offset || 0;
|
||||
|
||||
let caTotalCount = 0;
|
||||
let certTotalCount = 0;
|
||||
|
||||
if (includeCAs) {
|
||||
let caCountQuery = (tx || db.replicaNode())
|
||||
.count("* as count")
|
||||
.from(TableName.CertificateAuthority)
|
||||
.innerJoin(
|
||||
`${TableName.InternalCertificateAuthority} as ica`,
|
||||
`${TableName.CertificateAuthority}.id`,
|
||||
`ica.caId`
|
||||
);
|
||||
|
||||
caCountQuery = applyCaFilters(caCountQuery, filters, projectId) as typeof caCountQuery;
|
||||
|
||||
if (options?.alertBefore) {
|
||||
if (options.showFutureMatches) {
|
||||
caCountQuery = caCountQuery
|
||||
.whereRaw(`ica."notAfter" > NOW() + ?::interval`, [options.alertBefore])
|
||||
.whereRaw(`ica."notAfter" > NOW()`);
|
||||
} else if (options.showCurrentMatches) {
|
||||
caCountQuery = caCountQuery
|
||||
.whereRaw(`ica."notAfter" > NOW()`)
|
||||
.whereRaw(`ica."notAfter" <= NOW() + ?::interval`, [options.alertBefore]);
|
||||
} else {
|
||||
caCountQuery = caCountQuery
|
||||
.whereRaw(`ica."notAfter" > NOW()`)
|
||||
.whereRaw(`ica."notAfter" <= NOW() + ?::interval`, [options.alertBefore]);
|
||||
}
|
||||
}
|
||||
|
||||
const caCountResult = await caCountQuery;
|
||||
caTotalCount = parseInt((caCountResult[0] as { count: string }).count, 10);
|
||||
}
|
||||
|
||||
let certCountQuery = (tx || db.replicaNode()).count("* as count").from(TableName.Certificate);
|
||||
certCountQuery = applyCertificateFilters(certCountQuery, filters, projectId) as typeof certCountQuery;
|
||||
|
||||
if (options?.showPreview) {
|
||||
certCountQuery = certCountQuery
|
||||
.whereRaw(`"${TableName.Certificate}"."notAfter" > NOW()`)
|
||||
.whereNot(`${TableName.Certificate}.status`, "revoked");
|
||||
} else if (options?.alertBefore) {
|
||||
if (options.showFutureMatches) {
|
||||
certCountQuery = certCountQuery
|
||||
.whereRaw(`"${TableName.Certificate}"."notAfter" > NOW() + ?::interval`, [options.alertBefore])
|
||||
.whereRaw(`"${TableName.Certificate}"."notAfter" > NOW()`)
|
||||
.whereNot(`${TableName.Certificate}.status`, "revoked");
|
||||
} else if (options.showCurrentMatches) {
|
||||
certCountQuery = certCountQuery
|
||||
.whereRaw(`"${TableName.Certificate}"."notAfter" > NOW()`)
|
||||
.whereRaw(`"${TableName.Certificate}"."notAfter" <= NOW() + ?::interval`, [options.alertBefore])
|
||||
.whereNot(`${TableName.Certificate}.status`, "revoked");
|
||||
} else {
|
||||
certCountQuery = certCountQuery
|
||||
.whereRaw(`"${TableName.Certificate}"."notAfter" > NOW()`)
|
||||
.whereRaw(`"${TableName.Certificate}"."notAfter" <= NOW() + ?::interval`, [options.alertBefore])
|
||||
.whereNot(`${TableName.Certificate}.status`, "revoked");
|
||||
}
|
||||
}
|
||||
|
||||
if (options?.excludeAlerted && options?.alertId) {
|
||||
certCountQuery = certCountQuery.whereNotExists(
|
||||
(tx || db.replicaNode())
|
||||
.select("*")
|
||||
.from(TableName.PkiAlertHistory)
|
||||
.join(
|
||||
TableName.PkiAlertHistoryCertificate,
|
||||
`${TableName.PkiAlertHistory}.id`,
|
||||
`${TableName.PkiAlertHistoryCertificate}.alertHistoryId`
|
||||
)
|
||||
.where(`${TableName.PkiAlertHistory}.alertId`, options.alertId)
|
||||
.whereRaw(`"${TableName.PkiAlertHistoryCertificate}"."certificateId" = "${TableName.Certificate}".id`)
|
||||
);
|
||||
}
|
||||
|
||||
const certCountResult = await certCountQuery;
|
||||
certTotalCount = parseInt((certCountResult[0] as { count: string }).count, 10);
|
||||
|
||||
const totalCount = caTotalCount + certTotalCount;
|
||||
let results: TCertificatePreview[] = [];
|
||||
|
||||
const fetchCertificates = async (certLimit: number, certOffset: number) => {
|
||||
const selectColumns = [
|
||||
`${TableName.Certificate}.id`,
|
||||
`${TableName.Certificate}.serialNumber`,
|
||||
`${TableName.Certificate}.commonName`,
|
||||
`${TableName.Certificate}.altNames as san`,
|
||||
`${TableName.Certificate}.notBefore`,
|
||||
`${TableName.Certificate}.notAfter`,
|
||||
`${TableName.Certificate}.status`,
|
||||
`${TableName.Certificate}.profileId`,
|
||||
`${TableName.Certificate}.pkiSubscriberId`
|
||||
];
|
||||
|
||||
if (needsProfileJoin) {
|
||||
selectColumns.push("profile.name as profileName");
|
||||
}
|
||||
|
||||
let certificateQuery = (tx || db.replicaNode()).select(selectColumns).from(TableName.Certificate);
|
||||
|
||||
certificateQuery = applyCertificateFilters(certificateQuery, filters, projectId) as typeof certificateQuery;
|
||||
|
||||
if (options?.showPreview) {
|
||||
certificateQuery = certificateQuery
|
||||
.whereRaw(`"${TableName.Certificate}"."notAfter" > NOW()`)
|
||||
.whereNot(`${TableName.Certificate}.status`, "revoked");
|
||||
} else if (options?.alertBefore) {
|
||||
if (options.showFutureMatches) {
|
||||
certificateQuery = certificateQuery
|
||||
.whereRaw(`"${TableName.Certificate}"."notAfter" > NOW() + ?::interval`, [options.alertBefore])
|
||||
.whereRaw(`"${TableName.Certificate}"."notAfter" > NOW()`)
|
||||
.whereNot(`${TableName.Certificate}.status`, "revoked");
|
||||
} else if (options.showCurrentMatches) {
|
||||
certificateQuery = certificateQuery
|
||||
.whereRaw(`"${TableName.Certificate}"."notAfter" > NOW()`)
|
||||
.whereRaw(`"${TableName.Certificate}"."notAfter" <= NOW() + ?::interval`, [options.alertBefore])
|
||||
.whereNot(`${TableName.Certificate}.status`, "revoked");
|
||||
} else {
|
||||
certificateQuery = certificateQuery
|
||||
.whereRaw(`"${TableName.Certificate}"."notAfter" > NOW()`)
|
||||
.whereRaw(`"${TableName.Certificate}"."notAfter" <= NOW() + ?::interval`, [options.alertBefore])
|
||||
.whereNot(`${TableName.Certificate}.status`, "revoked");
|
||||
}
|
||||
}
|
||||
|
||||
if (options?.excludeAlerted && options?.alertId) {
|
||||
certificateQuery = certificateQuery.whereNotExists(
|
||||
(tx || db.replicaNode())
|
||||
.select("*")
|
||||
.from(TableName.PkiAlertHistory)
|
||||
.join(
|
||||
TableName.PkiAlertHistoryCertificate,
|
||||
`${TableName.PkiAlertHistory}.id`,
|
||||
`${TableName.PkiAlertHistoryCertificate}.alertHistoryId`
|
||||
)
|
||||
.where(`${TableName.PkiAlertHistory}.alertId`, options.alertId)
|
||||
.whereRaw(`"${TableName.PkiAlertHistoryCertificate}"."certificateId" = "${TableName.Certificate}".id`)
|
||||
);
|
||||
}
|
||||
|
||||
certificateQuery = certificateQuery
|
||||
.orderBy(`${TableName.Certificate}.notAfter`, "asc")
|
||||
.limit(certLimit)
|
||||
.offset(certOffset);
|
||||
|
||||
const certificates = await certificateQuery;
|
||||
const formattedCertificates: TCertificatePreview[] = (
|
||||
certificates as Array<{
|
||||
id: string;
|
||||
serialNumber: string;
|
||||
commonName: string;
|
||||
san: string[] | null;
|
||||
profileId: string | null;
|
||||
pkiSubscriberId: string | null;
|
||||
profileName?: string | null;
|
||||
notBefore: Date;
|
||||
notAfter: Date;
|
||||
status: string;
|
||||
}>
|
||||
).map((cert) => {
|
||||
let enrollmentType = CertificateOrigin.UNKNOWN;
|
||||
if (cert.profileId) {
|
||||
enrollmentType = CertificateOrigin.PROFILE;
|
||||
} else if (cert.pkiSubscriberId) {
|
||||
enrollmentType = CertificateOrigin.IMPORT;
|
||||
}
|
||||
|
||||
return {
|
||||
id: cert.id,
|
||||
serialNumber: cert.serialNumber,
|
||||
commonName: cert.commonName,
|
||||
san: Array.isArray(cert.san) ? cert.san : [],
|
||||
profileName: cert.profileName || null,
|
||||
enrollmentType,
|
||||
notBefore: cert.notBefore,
|
||||
notAfter: cert.notAfter,
|
||||
status: cert.status
|
||||
};
|
||||
});
|
||||
|
||||
results = [...results, ...formattedCertificates];
|
||||
};
|
||||
|
||||
if (offset < caTotalCount) {
|
||||
const caLimit = Math.min(limit, caTotalCount - offset);
|
||||
const caOffset = offset;
|
||||
|
||||
let caQuery = (tx || db.replicaNode())
|
||||
.select(
|
||||
`${TableName.CertificateAuthority}.id`,
|
||||
`ica.serialNumber`,
|
||||
`ica.commonName`,
|
||||
`ica.notBefore`,
|
||||
`ica.notAfter`
|
||||
)
|
||||
.from(TableName.CertificateAuthority)
|
||||
.innerJoin(
|
||||
`${TableName.InternalCertificateAuthority} as ica`,
|
||||
`${TableName.CertificateAuthority}.id`,
|
||||
`ica.caId`
|
||||
);
|
||||
|
||||
caQuery = applyCaFilters(caQuery, filters, projectId) as typeof caQuery;
|
||||
|
||||
if (options?.alertBefore) {
|
||||
if (options.showFutureMatches) {
|
||||
caQuery = caQuery
|
||||
.whereRaw(`ica."notAfter" > NOW() + ?::interval`, [options.alertBefore])
|
||||
.whereRaw(`ica."notAfter" > NOW()`);
|
||||
} else {
|
||||
caQuery = caQuery
|
||||
.whereRaw(`ica."notAfter" > NOW()`)
|
||||
.whereRaw(`ica."notAfter" <= NOW() + ?::interval`, [options.alertBefore]);
|
||||
}
|
||||
}
|
||||
|
||||
caQuery = caQuery.orderBy(`ica.notAfter`, "asc").limit(caLimit).offset(caOffset);
|
||||
|
||||
const cas = await caQuery;
|
||||
const formattedCAs: TCertificatePreview[] = (
|
||||
cas as Array<{
|
||||
id: string;
|
||||
serialNumber: string;
|
||||
commonName: string;
|
||||
notBefore: Date;
|
||||
notAfter: Date;
|
||||
}>
|
||||
).map((ca) => ({
|
||||
id: ca.id,
|
||||
serialNumber: ca.serialNumber,
|
||||
commonName: ca.commonName,
|
||||
san: [],
|
||||
profileName: null,
|
||||
enrollmentType: CertificateOrigin.CA,
|
||||
notBefore: ca.notBefore,
|
||||
notAfter: ca.notAfter,
|
||||
status: "active"
|
||||
}));
|
||||
|
||||
results = [...results, ...formattedCAs];
|
||||
|
||||
const remainingLimit = limit - caLimit;
|
||||
if (remainingLimit > 0 && certTotalCount > 0) {
|
||||
const certOffset = 0;
|
||||
await fetchCertificates(remainingLimit, certOffset);
|
||||
}
|
||||
} else {
|
||||
const certOffset = offset - caTotalCount;
|
||||
await fetchCertificates(limit, certOffset);
|
||||
}
|
||||
|
||||
return {
|
||||
certificates: results,
|
||||
total: totalCount
|
||||
};
|
||||
} catch (error) {
|
||||
throw new DatabaseError({ error, name: "FindMatchingCertificates" });
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
...pkiAlertV2Orm,
|
||||
create,
|
||||
updateById,
|
||||
findById,
|
||||
findByIdWithChannels,
|
||||
findByProjectId,
|
||||
findByProjectIdWithCount,
|
||||
countByProjectId,
|
||||
getDistinctProjectIds,
|
||||
findMatchingCertificates
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,358 @@
|
||||
import { Knex } from "knex";
|
||||
import RE2 from "re2";
|
||||
|
||||
import { TableName } from "@app/db/schemas";
|
||||
import { logger } from "@app/lib/logger";
|
||||
|
||||
import { PkiFilterField, PkiFilterOperator, TPkiFilterRule } from "./pki-alert-v2-types";
|
||||
|
||||
export const sanitizeLikeInput = (input: string): string => {
|
||||
const allowedCharsRegex = new RE2("^[a-zA-Z0-9\\s\\-_\\.@\\*]+$");
|
||||
if (!allowedCharsRegex.test(input)) {
|
||||
throw new Error(
|
||||
"Invalid characters in input. Only alphanumeric characters, spaces, hyphens, underscores, dots, @ and * are allowed."
|
||||
);
|
||||
}
|
||||
|
||||
const backslashRegex = new RE2("\\\\", "g");
|
||||
const percentRegex = new RE2("%", "g");
|
||||
const underscoreRegex = new RE2("_", "g");
|
||||
const quoteRegex = new RE2("'", "g");
|
||||
|
||||
return input
|
||||
.replace(backslashRegex, "\\\\\\\\")
|
||||
.replace(percentRegex, "\\%")
|
||||
.replace(underscoreRegex, "\\_")
|
||||
.replace(quoteRegex, "''");
|
||||
};
|
||||
|
||||
export const parseTimeToPostgresInterval = (duration: string): string => {
|
||||
if (duration.length > 32) {
|
||||
throw new Error(`Invalid duration format: ${duration}. Use format like '30d', '1w', '3m', '1y'`);
|
||||
}
|
||||
|
||||
const durationRegex = new RE2("^(\\d+)([dwmy])$");
|
||||
const match = durationRegex.exec(duration);
|
||||
|
||||
if (!match) {
|
||||
throw new Error(`Invalid duration format: ${duration}. Use format like '30d', '1w', '3m', '1y'`);
|
||||
}
|
||||
|
||||
const [, value, unit] = match;
|
||||
const amount = parseInt(value, 10);
|
||||
|
||||
if (amount <= 0 || amount > 9999) {
|
||||
throw new Error(`Duration value out of range: ${duration}. Must be between 1 and 9999.`);
|
||||
}
|
||||
|
||||
const unitMap = {
|
||||
d: "days",
|
||||
w: "weeks",
|
||||
m: "months",
|
||||
y: "years"
|
||||
};
|
||||
|
||||
return `${amount} ${unitMap[unit as keyof typeof unitMap]}`;
|
||||
};
|
||||
|
||||
export const parseTimeToDays = (timeStr: string): number => {
|
||||
const alertBeforeRegex = new RE2("^(\\d+)([dwmy])$");
|
||||
const match = alertBeforeRegex.exec(timeStr);
|
||||
if (!match) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const [, value, unit] = match;
|
||||
const amount = parseInt(value, 10);
|
||||
|
||||
if (amount <= 0 || amount > 9999) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
switch (unit) {
|
||||
case "d":
|
||||
return amount;
|
||||
case "w":
|
||||
return amount * 7;
|
||||
case "m":
|
||||
return amount * 30;
|
||||
case "y":
|
||||
return amount * 365;
|
||||
default:
|
||||
return 0;
|
||||
}
|
||||
};
|
||||
|
||||
const applyProfileNameFilter = (query: Knex.QueryBuilder, filter: TPkiFilterRule): Knex.QueryBuilder => {
|
||||
const { value } = filter;
|
||||
|
||||
switch (filter.operator) {
|
||||
case PkiFilterOperator.EQUALS:
|
||||
return query.where("profile.slug", value as string);
|
||||
|
||||
case PkiFilterOperator.MATCHES:
|
||||
if (Array.isArray(value)) {
|
||||
return query.whereIn("profile.slug", value);
|
||||
}
|
||||
return query.whereILike("profile.slug", `%${sanitizeLikeInput(String(value))}%`);
|
||||
|
||||
case PkiFilterOperator.CONTAINS:
|
||||
if (Array.isArray(value)) {
|
||||
return query.where((builder) => {
|
||||
value.forEach((v, index) => {
|
||||
const sanitizedValue = sanitizeLikeInput(String(v));
|
||||
if (index === 0) {
|
||||
void builder.whereILike("profile.slug", `%${sanitizedValue}%`);
|
||||
} else {
|
||||
void builder.orWhereILike("profile.slug", `%${sanitizedValue}%`);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
return query.whereILike("profile.slug", `%${sanitizeLikeInput(String(value))}%`);
|
||||
|
||||
case PkiFilterOperator.STARTS_WITH:
|
||||
return query.whereILike("profile.slug", `${sanitizeLikeInput(String(value))}%`);
|
||||
|
||||
case PkiFilterOperator.ENDS_WITH:
|
||||
return query.whereILike("profile.slug", `%${sanitizeLikeInput(String(value))}`);
|
||||
|
||||
default:
|
||||
logger.warn(`Unsupported operator for profile_name: ${String(filter.operator)}`);
|
||||
return query;
|
||||
}
|
||||
};
|
||||
|
||||
const applyCommonNameFilter = (query: Knex.QueryBuilder, filter: TPkiFilterRule): Knex.QueryBuilder => {
|
||||
const { value } = filter;
|
||||
const columnName = `${TableName.Certificate}.commonName`;
|
||||
|
||||
switch (filter.operator) {
|
||||
case PkiFilterOperator.EQUALS:
|
||||
return query.where(columnName, value as string);
|
||||
|
||||
case PkiFilterOperator.MATCHES:
|
||||
if (Array.isArray(value)) {
|
||||
return query.whereIn(columnName, value);
|
||||
}
|
||||
return query.whereILike(columnName, `%${sanitizeLikeInput(String(value))}%`);
|
||||
|
||||
case PkiFilterOperator.CONTAINS:
|
||||
if (Array.isArray(value)) {
|
||||
return query.where((builder) => {
|
||||
value.forEach((v, index) => {
|
||||
const sanitizedValue = sanitizeLikeInput(String(v));
|
||||
if (index === 0) {
|
||||
void builder.whereILike(columnName, `%${sanitizedValue}%`);
|
||||
} else {
|
||||
void builder.orWhereILike(columnName, `%${sanitizedValue}%`);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
return query.whereILike(columnName, `%${sanitizeLikeInput(String(value))}%`);
|
||||
|
||||
case PkiFilterOperator.STARTS_WITH:
|
||||
return query.whereILike(columnName, `${sanitizeLikeInput(String(value))}%`);
|
||||
|
||||
case PkiFilterOperator.ENDS_WITH:
|
||||
return query.whereILike(columnName, `%${sanitizeLikeInput(String(value))}`);
|
||||
|
||||
default:
|
||||
logger.warn(`Unsupported operator for common_name: ${String(filter.operator)}`);
|
||||
return query;
|
||||
}
|
||||
};
|
||||
|
||||
const applySanFilter = (query: Knex.QueryBuilder, filter: TPkiFilterRule): Knex.QueryBuilder => {
|
||||
const { value } = filter;
|
||||
const columnName = `${TableName.Certificate}.altNames`;
|
||||
|
||||
switch (filter.operator) {
|
||||
case PkiFilterOperator.EQUALS:
|
||||
return query.whereJsonSupersetOf(columnName, [value as string]);
|
||||
|
||||
case PkiFilterOperator.MATCHES:
|
||||
if (Array.isArray(value)) {
|
||||
return query.where((builder) => {
|
||||
value.forEach((v, index) => {
|
||||
const sanitizedValue = `%"${String(v)}"%`;
|
||||
if (index === 0) {
|
||||
void builder.whereRaw(`??."altNames"::text ILIKE ?`, [TableName.Certificate, sanitizedValue]);
|
||||
} else {
|
||||
void builder.orWhereRaw(`??."altNames"::text ILIKE ?`, [TableName.Certificate, sanitizedValue]);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
{
|
||||
const sanitizedValue = `%"${String(value)}"%`;
|
||||
return query.whereRaw(`??."altNames"::text ILIKE ?`, [TableName.Certificate, sanitizedValue]);
|
||||
}
|
||||
|
||||
case PkiFilterOperator.CONTAINS:
|
||||
return applySanFilter(query, { ...filter, operator: PkiFilterOperator.MATCHES });
|
||||
|
||||
case PkiFilterOperator.STARTS_WITH: {
|
||||
const startsWithValue = `%"${String(value)}%`;
|
||||
return query.whereRaw(`??."altNames"::text ILIKE ?`, [TableName.Certificate, startsWithValue]);
|
||||
}
|
||||
|
||||
case PkiFilterOperator.ENDS_WITH: {
|
||||
const endsWithValue = `%${String(value)}"%`;
|
||||
return query.whereRaw(`??."altNames"::text ILIKE ?`, [TableName.Certificate, endsWithValue]);
|
||||
}
|
||||
|
||||
default:
|
||||
logger.warn(`Unsupported operator for SAN: ${String(filter.operator)}`);
|
||||
return query;
|
||||
}
|
||||
};
|
||||
|
||||
export const shouldIncludeCAs = (filters: TPkiFilterRule[]): boolean => {
|
||||
return filters.some((filter) => filter.field === PkiFilterField.INCLUDE_CAS && filter.value === true);
|
||||
};
|
||||
|
||||
const applyCaCommonNameFilter = (query: Knex.QueryBuilder, filter: TPkiFilterRule): Knex.QueryBuilder => {
|
||||
const { value } = filter;
|
||||
const columnName = "ica.commonName";
|
||||
|
||||
switch (filter.operator) {
|
||||
case PkiFilterOperator.EQUALS:
|
||||
return query.where(columnName, value as string);
|
||||
|
||||
case PkiFilterOperator.MATCHES:
|
||||
if (Array.isArray(value)) {
|
||||
return query.whereIn(columnName, value);
|
||||
}
|
||||
return query.whereILike(columnName, `%${sanitizeLikeInput(String(value))}%`);
|
||||
|
||||
case PkiFilterOperator.CONTAINS:
|
||||
if (Array.isArray(value)) {
|
||||
return query.where((builder) => {
|
||||
value.forEach((v, index) => {
|
||||
if (index === 0) {
|
||||
void builder.whereILike(columnName, `%${sanitizeLikeInput(String(v))}%`);
|
||||
} else {
|
||||
void builder.orWhereILike(columnName, `%${sanitizeLikeInput(String(v))}%`);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
return query.whereILike(columnName, `%${sanitizeLikeInput(String(value))}%`);
|
||||
|
||||
case PkiFilterOperator.STARTS_WITH:
|
||||
return query.whereILike(columnName, `${sanitizeLikeInput(String(value))}%`);
|
||||
|
||||
case PkiFilterOperator.ENDS_WITH:
|
||||
return query.whereILike(columnName, `%${sanitizeLikeInput(String(value))}`);
|
||||
|
||||
default:
|
||||
logger.warn(`Unsupported operator for CA common_name: ${String(filter.operator)}`);
|
||||
return query;
|
||||
}
|
||||
};
|
||||
|
||||
export const applyCaFilters = (
|
||||
query: Knex.QueryBuilder,
|
||||
filters: TPkiFilterRule[],
|
||||
projectId: string
|
||||
): Knex.QueryBuilder => {
|
||||
let filteredQuery = query.where(`${TableName.CertificateAuthority}.projectId`, projectId).whereNotNull("ica.caId"); // Only include CAs that have internal CA data
|
||||
|
||||
filters.forEach((filter) => {
|
||||
switch (filter.field) {
|
||||
case PkiFilterField.COMMON_NAME:
|
||||
filteredQuery = applyCaCommonNameFilter(filteredQuery, filter);
|
||||
break;
|
||||
|
||||
default:
|
||||
break;
|
||||
}
|
||||
});
|
||||
|
||||
return filteredQuery;
|
||||
};
|
||||
|
||||
export const validateFilterRules = (filters: TPkiFilterRule[]): void => {
|
||||
for (const filter of filters) {
|
||||
if (!Object.values(PkiFilterField).includes(filter.field)) {
|
||||
throw new Error(`Invalid filter field: ${filter.field}`);
|
||||
}
|
||||
|
||||
if (!Object.values(PkiFilterOperator).includes(filter.operator)) {
|
||||
throw new Error(`Invalid filter operator: ${filter.operator}`);
|
||||
}
|
||||
|
||||
switch (filter.field) {
|
||||
case PkiFilterField.INCLUDE_CAS:
|
||||
if (typeof filter.value !== "boolean") {
|
||||
throw new Error("include_cas filter value must be boolean");
|
||||
}
|
||||
break;
|
||||
|
||||
case PkiFilterField.PROFILE_NAME:
|
||||
case PkiFilterField.COMMON_NAME:
|
||||
case PkiFilterField.SAN:
|
||||
if (filter.operator === PkiFilterOperator.CONTAINS || filter.operator === PkiFilterOperator.MATCHES) {
|
||||
if (!Array.isArray(filter.value) && typeof filter.value !== "string") {
|
||||
throw new Error(
|
||||
`${filter.field} filter value must be string or array of strings for ${filter.operator} operator`
|
||||
);
|
||||
}
|
||||
} else if (typeof filter.value !== "string") {
|
||||
throw new Error(`${filter.field} filter value must be string for ${filter.operator} operator`);
|
||||
}
|
||||
break;
|
||||
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export const requiresProfileJoin = (filters: TPkiFilterRule[]): boolean => {
|
||||
return filters.some((filter) => filter.field === PkiFilterField.PROFILE_NAME);
|
||||
};
|
||||
|
||||
export const applyCertificateFilters = (
|
||||
query: Knex.QueryBuilder,
|
||||
filters: TPkiFilterRule[],
|
||||
projectId: string
|
||||
): Knex.QueryBuilder => {
|
||||
let filteredQuery = query.where(`${TableName.Certificate}.projectId`, projectId);
|
||||
|
||||
const needsProfileJoin = requiresProfileJoin(filters);
|
||||
if (needsProfileJoin) {
|
||||
filteredQuery = filteredQuery.leftJoin(
|
||||
`${TableName.PkiCertificateProfile} as profile`,
|
||||
`${TableName.Certificate}.profileId`,
|
||||
"profile.id"
|
||||
);
|
||||
}
|
||||
|
||||
filters.forEach((filter) => {
|
||||
switch (filter.field) {
|
||||
case PkiFilterField.PROFILE_NAME:
|
||||
filteredQuery = applyProfileNameFilter(filteredQuery, filter);
|
||||
break;
|
||||
|
||||
case PkiFilterField.COMMON_NAME:
|
||||
filteredQuery = applyCommonNameFilter(filteredQuery, filter);
|
||||
break;
|
||||
|
||||
case PkiFilterField.SAN:
|
||||
filteredQuery = applySanFilter(filteredQuery, filter);
|
||||
break;
|
||||
|
||||
case PkiFilterField.INCLUDE_CAS:
|
||||
break;
|
||||
|
||||
default:
|
||||
logger.warn(`Unknown filter field: ${String(filter.field)}`);
|
||||
break;
|
||||
}
|
||||
});
|
||||
|
||||
return filteredQuery;
|
||||
};
|
||||
@@ -0,0 +1,220 @@
|
||||
/* eslint-disable no-await-in-loop */
|
||||
|
||||
import { getConfig } from "@app/lib/config/env";
|
||||
import { logger } from "@app/lib/logger";
|
||||
import { QueueJobs, QueueName, TQueueServiceFactory } from "@app/queue";
|
||||
|
||||
import { TPkiAlertHistoryDALFactory } from "./pki-alert-history-dal";
|
||||
import { TPkiAlertV2DALFactory } from "./pki-alert-v2-dal";
|
||||
import { parseTimeToDays, parseTimeToPostgresInterval } from "./pki-alert-v2-filter-utils";
|
||||
import { TPkiAlertV2ServiceFactory } from "./pki-alert-v2-service";
|
||||
import { CertificateOrigin, PkiAlertEventType, TPkiFilterRule } from "./pki-alert-v2-types";
|
||||
|
||||
type TPkiAlertV2QueueServiceFactoryDep = {
|
||||
queueService: TQueueServiceFactory;
|
||||
pkiAlertV2Service: Pick<TPkiAlertV2ServiceFactory, "sendAlertNotifications">;
|
||||
pkiAlertV2DAL: Pick<TPkiAlertV2DALFactory, "findByProjectId" | "findMatchingCertificates" | "getDistinctProjectIds">;
|
||||
pkiAlertHistoryDAL: Pick<TPkiAlertHistoryDALFactory, "findRecentlyAlertedCertificates">;
|
||||
};
|
||||
|
||||
export type TPkiAlertV2QueueServiceFactory = ReturnType<typeof pkiAlertV2QueueServiceFactory>;
|
||||
|
||||
export const pkiAlertV2QueueServiceFactory = ({
|
||||
queueService,
|
||||
pkiAlertV2Service,
|
||||
pkiAlertV2DAL,
|
||||
pkiAlertHistoryDAL
|
||||
}: TPkiAlertV2QueueServiceFactoryDep) => {
|
||||
const appCfg = getConfig();
|
||||
const calculateDeduplicationWindow = (alertBefore: string): number => {
|
||||
const alertDays = parseTimeToDays(alertBefore);
|
||||
|
||||
if (alertDays === 0) {
|
||||
return 24;
|
||||
}
|
||||
|
||||
if (alertDays <= 1) {
|
||||
return 8;
|
||||
}
|
||||
if (alertDays <= 7) {
|
||||
return 24;
|
||||
}
|
||||
if (alertDays <= 30) {
|
||||
return 48;
|
||||
}
|
||||
if (alertDays <= 90) {
|
||||
return 168;
|
||||
}
|
||||
return 720;
|
||||
};
|
||||
|
||||
const getAllProjectsWithAlerts = async (): Promise<string[]> => {
|
||||
try {
|
||||
const projectIds = await pkiAlertV2DAL.getDistinctProjectIds({ enabled: true });
|
||||
|
||||
logger.info(`Found ${projectIds.length} projects with PKI alerts`);
|
||||
return projectIds;
|
||||
} catch (error) {
|
||||
logger.error(error, "Failed to get projects with alerts");
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
const evaluateAlert = async (
|
||||
alert: {
|
||||
id: string;
|
||||
name: string;
|
||||
eventType: string;
|
||||
alertBefore: string;
|
||||
filters: TPkiFilterRule[];
|
||||
},
|
||||
projectId: string
|
||||
): Promise<{ shouldNotify: boolean; certificateIds: string[] }> => {
|
||||
if (alert.eventType !== PkiAlertEventType.EXPIRATION) {
|
||||
return { shouldNotify: false, certificateIds: [] };
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await pkiAlertV2DAL.findMatchingCertificates(projectId, alert.filters, {
|
||||
limit: 1000,
|
||||
alertBefore: parseTimeToPostgresInterval(alert.alertBefore),
|
||||
showCurrentMatches: true
|
||||
});
|
||||
|
||||
if (result.certificates.length === 0) {
|
||||
return { shouldNotify: false, certificateIds: [] };
|
||||
}
|
||||
|
||||
const allCertificateIds = result.certificates
|
||||
.filter((cert) => cert.enrollmentType !== CertificateOrigin.CA)
|
||||
.map((cert) => cert.id);
|
||||
|
||||
const deduplicationHours = calculateDeduplicationWindow(alert.alertBefore);
|
||||
const recentlyAlertedIds = await pkiAlertHistoryDAL.findRecentlyAlertedCertificates(
|
||||
alert.id,
|
||||
allCertificateIds,
|
||||
deduplicationHours
|
||||
);
|
||||
|
||||
const certificateIds = allCertificateIds.filter((certId) => !recentlyAlertedIds.includes(certId));
|
||||
|
||||
if (certificateIds.length === 0) {
|
||||
logger.debug(
|
||||
`All ${allCertificateIds.length} matching certificates for alert ${alert.id} were already alerted within the last ${deduplicationHours} hours`
|
||||
);
|
||||
return { shouldNotify: false, certificateIds: [] };
|
||||
}
|
||||
|
||||
logger.debug(
|
||||
`Alert ${alert.id}: Found ${allCertificateIds.length} expiring certificates, ${recentlyAlertedIds.length} already alerted recently, ${certificateIds.length} new to alert`
|
||||
);
|
||||
|
||||
return {
|
||||
shouldNotify: true,
|
||||
certificateIds
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error(error, `Failed to evaluate alert ${alert.id}`);
|
||||
return { shouldNotify: false, certificateIds: [] };
|
||||
}
|
||||
};
|
||||
|
||||
const processProjectAlerts = async (
|
||||
projectId: string
|
||||
): Promise<{ alertsProcessed: number; notificationsSent: number }> => {
|
||||
logger.info(`Processing alerts for project: ${projectId}`);
|
||||
|
||||
const alerts = await pkiAlertV2DAL.findByProjectId(projectId, {
|
||||
enabled: true,
|
||||
limit: 1000
|
||||
});
|
||||
|
||||
let alertsProcessed = 0;
|
||||
let notificationsSent = 0;
|
||||
|
||||
for (const alert of alerts) {
|
||||
const typedAlert = alert as {
|
||||
id: string;
|
||||
name: string;
|
||||
eventType: string;
|
||||
alertBefore: string;
|
||||
filters: TPkiFilterRule[];
|
||||
};
|
||||
try {
|
||||
const { shouldNotify, certificateIds } = await evaluateAlert(typedAlert, projectId);
|
||||
|
||||
if (shouldNotify && certificateIds.length > 0) {
|
||||
await pkiAlertV2Service.sendAlertNotifications(typedAlert.id, certificateIds);
|
||||
notificationsSent += 1;
|
||||
logger.info(
|
||||
`Sent notification for alert ${typedAlert.id} (${typedAlert.name}) with ${certificateIds.length} certificates`
|
||||
);
|
||||
}
|
||||
|
||||
alertsProcessed += 1;
|
||||
} catch (error) {
|
||||
logger.error(error, `Failed to process alert ${typedAlert.id} (${typedAlert.name})`);
|
||||
}
|
||||
}
|
||||
|
||||
logger.info(
|
||||
`Completed processing ${alertsProcessed} alerts for project ${projectId}, sent ${notificationsSent} notifications`
|
||||
);
|
||||
|
||||
return { alertsProcessed, notificationsSent };
|
||||
};
|
||||
|
||||
const processDailyAlerts = async () => {
|
||||
logger.info("Starting daily PKI alert processing...");
|
||||
|
||||
const allProjects = await getAllProjectsWithAlerts();
|
||||
|
||||
let totalAlertsProcessed = 0;
|
||||
let totalNotificationsSent = 0;
|
||||
|
||||
for (const projectId of allProjects) {
|
||||
try {
|
||||
const { alertsProcessed, notificationsSent } = await processProjectAlerts(projectId);
|
||||
totalAlertsProcessed += alertsProcessed;
|
||||
totalNotificationsSent += notificationsSent;
|
||||
} catch (error) {
|
||||
logger.error(error, `Failed to process alerts for project ${projectId}`);
|
||||
}
|
||||
}
|
||||
|
||||
logger.info(
|
||||
`Daily PKI alert processing completed. Processed ${totalAlertsProcessed} alerts, sent ${totalNotificationsSent} notifications.`
|
||||
);
|
||||
};
|
||||
|
||||
const init = async () => {
|
||||
if (appCfg.isSecondaryInstance) {
|
||||
return;
|
||||
}
|
||||
|
||||
await queueService.startPg<QueueName.DailyPkiAlertV2Processing>(
|
||||
QueueJobs.DailyPkiAlertV2Processing,
|
||||
async () => {
|
||||
try {
|
||||
logger.info(`${QueueJobs.DailyPkiAlertV2Processing}: queue task started`);
|
||||
await processDailyAlerts();
|
||||
logger.info(`${QueueJobs.DailyPkiAlertV2Processing}: queue task completed successfully`);
|
||||
} catch (error) {
|
||||
logger.error(error, `${QueueJobs.DailyPkiAlertV2Processing}: queue task failed`);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
{
|
||||
batchSize: 1,
|
||||
workerCount: 1,
|
||||
pollingIntervalSeconds: 60
|
||||
}
|
||||
);
|
||||
|
||||
await queueService.schedulePg(QueueJobs.DailyPkiAlertV2Processing, "0 0 * * *", undefined, { tz: "UTC" });
|
||||
};
|
||||
|
||||
return {
|
||||
init
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,507 @@
|
||||
import { ForbiddenError } from "@casl/ability";
|
||||
|
||||
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 { logger } from "@app/lib/logger";
|
||||
import { SmtpTemplates, TSmtpService } from "@app/services/smtp/smtp-service";
|
||||
|
||||
import { TPkiAlertChannelDALFactory } from "./pki-alert-channel-dal";
|
||||
import { TPkiAlertHistoryDALFactory } from "./pki-alert-history-dal";
|
||||
import { TPkiAlertV2DALFactory } from "./pki-alert-v2-dal";
|
||||
import { parseTimeToDays, parseTimeToPostgresInterval } from "./pki-alert-v2-filter-utils";
|
||||
import {
|
||||
CertificateOrigin,
|
||||
PkiAlertChannelType,
|
||||
PkiAlertEventType,
|
||||
TAlertV2Response,
|
||||
TChannelConfig,
|
||||
TCreateAlertV2DTO,
|
||||
TDeleteAlertV2DTO,
|
||||
TEmailChannelConfig,
|
||||
TGetAlertV2DTO,
|
||||
TListAlertsV2DTO,
|
||||
TListAlertsV2Response,
|
||||
TListCurrentMatchingCertificatesDTO,
|
||||
TListMatchingCertificatesDTO,
|
||||
TListMatchingCertificatesResponse,
|
||||
TPkiFilterRule,
|
||||
TUpdateAlertV2DTO
|
||||
} from "./pki-alert-v2-types";
|
||||
|
||||
type TPkiAlertV2ServiceFactoryDep = {
|
||||
pkiAlertV2DAL: Pick<
|
||||
TPkiAlertV2DALFactory,
|
||||
| "create"
|
||||
| "findById"
|
||||
| "findByIdWithChannels"
|
||||
| "updateById"
|
||||
| "deleteById"
|
||||
| "findByProjectId"
|
||||
| "findByProjectIdWithCount"
|
||||
| "countByProjectId"
|
||||
| "findMatchingCertificates"
|
||||
| "transaction"
|
||||
>;
|
||||
pkiAlertChannelDAL: Pick<TPkiAlertChannelDALFactory, "create" | "findByAlertId" | "deleteByAlertId" | "insertMany">;
|
||||
pkiAlertHistoryDAL: Pick<TPkiAlertHistoryDALFactory, "createWithCertificates" | "findByAlertId">;
|
||||
permissionService: Pick<TPermissionServiceFactory, "getProjectPermission">;
|
||||
smtpService: Pick<TSmtpService, "sendMail">;
|
||||
};
|
||||
|
||||
export type TPkiAlertV2ServiceFactory = ReturnType<typeof pkiAlertV2ServiceFactory>;
|
||||
|
||||
export const pkiAlertV2ServiceFactory = ({
|
||||
pkiAlertV2DAL,
|
||||
pkiAlertChannelDAL,
|
||||
pkiAlertHistoryDAL,
|
||||
permissionService,
|
||||
smtpService
|
||||
}: TPkiAlertV2ServiceFactoryDep) => {
|
||||
type TAlertWithChannels = {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
eventType: string;
|
||||
alertBefore: string;
|
||||
filters: TPkiFilterRule[];
|
||||
enabled: boolean;
|
||||
projectId: string;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
channels?: Array<{
|
||||
id: string;
|
||||
channelType: string;
|
||||
config: unknown;
|
||||
enabled: boolean;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}>;
|
||||
};
|
||||
|
||||
const formatAlertResponse = (alert: TAlertWithChannels): TAlertV2Response => {
|
||||
return {
|
||||
id: alert.id,
|
||||
name: alert.name,
|
||||
description: alert.description,
|
||||
eventType: alert.eventType as PkiAlertEventType,
|
||||
alertBefore: alert.alertBefore,
|
||||
filters: alert.filters,
|
||||
enabled: alert.enabled,
|
||||
projectId: alert.projectId,
|
||||
channels: (alert.channels || []).map((channel) => ({
|
||||
id: channel.id,
|
||||
channelType: channel.channelType as PkiAlertChannelType,
|
||||
config: channel.config as TChannelConfig,
|
||||
enabled: channel.enabled,
|
||||
createdAt: channel.createdAt,
|
||||
updatedAt: channel.updatedAt
|
||||
})),
|
||||
createdAt: alert.createdAt,
|
||||
updatedAt: alert.updatedAt
|
||||
};
|
||||
};
|
||||
|
||||
const createAlert = async ({
|
||||
projectId,
|
||||
name,
|
||||
description,
|
||||
eventType,
|
||||
alertBefore,
|
||||
filters,
|
||||
enabled = true,
|
||||
channels,
|
||||
actorId,
|
||||
actorAuthMethod,
|
||||
actor,
|
||||
actorOrgId
|
||||
}: TCreateAlertV2DTO): Promise<TAlertV2Response> => {
|
||||
const { permission } = await permissionService.getProjectPermission({
|
||||
actor,
|
||||
actorId,
|
||||
projectId,
|
||||
actorAuthMethod,
|
||||
actorOrgId,
|
||||
actionProjectType: ActionProjectType.CertificateManager
|
||||
});
|
||||
|
||||
ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Create, ProjectPermissionSub.PkiAlerts);
|
||||
|
||||
try {
|
||||
parseTimeToPostgresInterval(alertBefore);
|
||||
} catch (error) {
|
||||
throw new BadRequestError({ message: "Invalid alertBefore format. Use format like '30d', '1w', '3m', '1y'" });
|
||||
}
|
||||
|
||||
return pkiAlertV2DAL.transaction(async (tx) => {
|
||||
const alert = await pkiAlertV2DAL.create(
|
||||
{
|
||||
projectId,
|
||||
name,
|
||||
description,
|
||||
eventType,
|
||||
alertBefore,
|
||||
filters,
|
||||
enabled
|
||||
},
|
||||
tx
|
||||
);
|
||||
|
||||
const channelInserts = channels.map((channel) => ({
|
||||
alertId: alert.id,
|
||||
channelType: channel.channelType,
|
||||
config: channel.config,
|
||||
enabled: channel.enabled
|
||||
}));
|
||||
|
||||
await pkiAlertChannelDAL.insertMany(channelInserts, tx);
|
||||
|
||||
const completeAlert = await pkiAlertV2DAL.findByIdWithChannels(alert.id, tx);
|
||||
if (!completeAlert) {
|
||||
throw new NotFoundError({ message: "Failed to retrieve created alert" });
|
||||
}
|
||||
|
||||
return formatAlertResponse(completeAlert as TAlertWithChannels);
|
||||
});
|
||||
};
|
||||
|
||||
const getAlertById = async ({
|
||||
alertId,
|
||||
actorId,
|
||||
actorAuthMethod,
|
||||
actor,
|
||||
actorOrgId
|
||||
}: TGetAlertV2DTO): Promise<TAlertV2Response> => {
|
||||
const alert = await pkiAlertV2DAL.findByIdWithChannels(alertId);
|
||||
if (!alert) throw new NotFoundError({ message: `Alert with ID '${alertId}' not found` });
|
||||
|
||||
const { permission } = await permissionService.getProjectPermission({
|
||||
actor,
|
||||
actorId,
|
||||
projectId: (alert as { projectId: string }).projectId,
|
||||
actorAuthMethod,
|
||||
actorOrgId,
|
||||
actionProjectType: ActionProjectType.CertificateManager
|
||||
});
|
||||
|
||||
ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Read, ProjectPermissionSub.PkiAlerts);
|
||||
|
||||
return formatAlertResponse(alert as TAlertWithChannels);
|
||||
};
|
||||
|
||||
const listAlerts = async ({
|
||||
projectId,
|
||||
search,
|
||||
eventType,
|
||||
enabled,
|
||||
limit = 20,
|
||||
offset = 0,
|
||||
actorId,
|
||||
actorAuthMethod,
|
||||
actor,
|
||||
actorOrgId
|
||||
}: TListAlertsV2DTO): Promise<TListAlertsV2Response> => {
|
||||
const { permission } = await permissionService.getProjectPermission({
|
||||
actor,
|
||||
actorId,
|
||||
projectId,
|
||||
actorAuthMethod,
|
||||
actorOrgId,
|
||||
actionProjectType: ActionProjectType.CertificateManager
|
||||
});
|
||||
|
||||
ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Read, ProjectPermissionSub.PkiAlerts);
|
||||
|
||||
const filters = { search, eventType, enabled, limit, offset };
|
||||
|
||||
const { alerts, total } = await pkiAlertV2DAL.findByProjectIdWithCount(projectId, filters);
|
||||
|
||||
return {
|
||||
alerts: alerts.map((alert) => formatAlertResponse(alert as TAlertWithChannels)),
|
||||
total
|
||||
};
|
||||
};
|
||||
|
||||
const updateAlert = async ({
|
||||
alertId,
|
||||
name,
|
||||
description,
|
||||
eventType,
|
||||
alertBefore,
|
||||
filters,
|
||||
enabled,
|
||||
channels,
|
||||
actorId,
|
||||
actorAuthMethod,
|
||||
actor,
|
||||
actorOrgId
|
||||
}: TUpdateAlertV2DTO): Promise<TAlertV2Response> => {
|
||||
let alert = await pkiAlertV2DAL.findById(alertId);
|
||||
if (!alert) throw new NotFoundError({ message: `Alert with ID '${alertId}' not found` });
|
||||
|
||||
const { permission } = await permissionService.getProjectPermission({
|
||||
actor,
|
||||
actorId,
|
||||
projectId: (alert as { projectId: string }).projectId,
|
||||
actorAuthMethod,
|
||||
actorOrgId,
|
||||
actionProjectType: ActionProjectType.CertificateManager
|
||||
});
|
||||
|
||||
ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Edit, ProjectPermissionSub.PkiAlerts);
|
||||
|
||||
if (alertBefore) {
|
||||
try {
|
||||
parseTimeToPostgresInterval(alertBefore);
|
||||
} catch (error) {
|
||||
throw new BadRequestError({ message: "Invalid alertBefore format. Use format like '30d', '1w', '3m', '1y'" });
|
||||
}
|
||||
}
|
||||
|
||||
const updateData: {
|
||||
name?: string;
|
||||
description?: string;
|
||||
eventType?: PkiAlertEventType;
|
||||
alertBefore?: string;
|
||||
filters?: TPkiFilterRule[];
|
||||
enabled?: boolean;
|
||||
} = {};
|
||||
if (name !== undefined) updateData.name = name;
|
||||
if (description !== undefined) updateData.description = description;
|
||||
if (eventType !== undefined) updateData.eventType = eventType;
|
||||
if (alertBefore !== undefined) updateData.alertBefore = alertBefore;
|
||||
if (filters !== undefined) updateData.filters = filters;
|
||||
if (enabled !== undefined) updateData.enabled = enabled;
|
||||
|
||||
return pkiAlertV2DAL.transaction(async (tx) => {
|
||||
alert = await pkiAlertV2DAL.updateById(alertId, updateData, tx);
|
||||
|
||||
if (channels) {
|
||||
await pkiAlertChannelDAL.deleteByAlertId(alertId, tx);
|
||||
|
||||
const channelInserts = channels.map((channel) => ({
|
||||
alertId,
|
||||
channelType: channel.channelType,
|
||||
config: channel.config,
|
||||
enabled: channel.enabled
|
||||
}));
|
||||
|
||||
await pkiAlertChannelDAL.insertMany(channelInserts, tx);
|
||||
}
|
||||
|
||||
const completeAlert = await pkiAlertV2DAL.findByIdWithChannels(alertId, tx);
|
||||
if (!completeAlert) {
|
||||
throw new NotFoundError({ message: "Failed to retrieve updated alert" });
|
||||
}
|
||||
|
||||
return formatAlertResponse(completeAlert as TAlertWithChannels);
|
||||
});
|
||||
};
|
||||
|
||||
const deleteAlert = async ({
|
||||
alertId,
|
||||
actorId,
|
||||
actorAuthMethod,
|
||||
actor,
|
||||
actorOrgId
|
||||
}: TDeleteAlertV2DTO): Promise<TAlertV2Response> => {
|
||||
const alert = await pkiAlertV2DAL.findByIdWithChannels(alertId);
|
||||
if (!alert) throw new NotFoundError({ message: `Alert with ID '${alertId}' not found` });
|
||||
|
||||
const { permission } = await permissionService.getProjectPermission({
|
||||
actor,
|
||||
actorId,
|
||||
projectId: (alert as { projectId: string }).projectId,
|
||||
actorAuthMethod,
|
||||
actorOrgId,
|
||||
actionProjectType: ActionProjectType.CertificateManager
|
||||
});
|
||||
|
||||
ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Delete, ProjectPermissionSub.PkiAlerts);
|
||||
|
||||
const formattedAlert = formatAlertResponse(alert as TAlertWithChannels);
|
||||
await pkiAlertV2DAL.deleteById(alertId);
|
||||
|
||||
return formattedAlert;
|
||||
};
|
||||
|
||||
const listMatchingCertificates = async ({
|
||||
alertId,
|
||||
limit = 20,
|
||||
offset = 0,
|
||||
actorId,
|
||||
actorAuthMethod,
|
||||
actor,
|
||||
actorOrgId
|
||||
}: TListMatchingCertificatesDTO): Promise<TListMatchingCertificatesResponse> => {
|
||||
const alert = await pkiAlertV2DAL.findById(alertId);
|
||||
if (!alert) throw new NotFoundError({ message: `Alert with ID '${alertId}' not found` });
|
||||
|
||||
const { permission } = await permissionService.getProjectPermission({
|
||||
actor,
|
||||
actorId,
|
||||
projectId: (alert as { projectId: string }).projectId,
|
||||
actorAuthMethod,
|
||||
actorOrgId,
|
||||
actionProjectType: ActionProjectType.CertificateManager
|
||||
});
|
||||
|
||||
ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Read, ProjectPermissionSub.PkiAlerts);
|
||||
|
||||
const options: {
|
||||
limit: number;
|
||||
offset: number;
|
||||
showPreview?: boolean;
|
||||
excludeAlerted?: boolean;
|
||||
alertId?: string;
|
||||
} = {
|
||||
limit,
|
||||
offset,
|
||||
showPreview: true,
|
||||
excludeAlerted: (alert as { eventType: string }).eventType === PkiAlertEventType.EXPIRATION,
|
||||
alertId
|
||||
};
|
||||
|
||||
const result = await pkiAlertV2DAL.findMatchingCertificates(
|
||||
(alert as { projectId: string }).projectId,
|
||||
(alert as { filters: TPkiFilterRule[] }).filters,
|
||||
options
|
||||
);
|
||||
|
||||
return {
|
||||
certificates: result.certificates,
|
||||
total: result.total
|
||||
};
|
||||
};
|
||||
|
||||
const listCurrentMatchingCertificates = async ({
|
||||
projectId,
|
||||
filters,
|
||||
alertBefore,
|
||||
limit = 20,
|
||||
offset = 0,
|
||||
actorId,
|
||||
actorAuthMethod,
|
||||
actor,
|
||||
actorOrgId
|
||||
}: TListCurrentMatchingCertificatesDTO): Promise<TListMatchingCertificatesResponse> => {
|
||||
const { permission } = await permissionService.getProjectPermission({
|
||||
actor,
|
||||
actorId,
|
||||
projectId,
|
||||
actorAuthMethod,
|
||||
actorOrgId,
|
||||
actionProjectType: ActionProjectType.CertificateManager
|
||||
});
|
||||
|
||||
ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Read, ProjectPermissionSub.PkiAlerts);
|
||||
|
||||
try {
|
||||
parseTimeToPostgresInterval(alertBefore);
|
||||
} catch (error) {
|
||||
throw new BadRequestError({ message: "Invalid alertBefore format. Use format like '30d', '1w', '3m', '1y'" });
|
||||
}
|
||||
|
||||
const options: {
|
||||
limit: number;
|
||||
offset: number;
|
||||
showPreview?: boolean;
|
||||
alertBefore?: string;
|
||||
} = {
|
||||
limit,
|
||||
offset,
|
||||
showPreview: true,
|
||||
alertBefore: parseTimeToPostgresInterval(alertBefore)
|
||||
};
|
||||
|
||||
const result = await pkiAlertV2DAL.findMatchingCertificates(projectId, filters, options);
|
||||
|
||||
return {
|
||||
certificates: result.certificates,
|
||||
total: result.total
|
||||
};
|
||||
};
|
||||
|
||||
const sendAlertNotifications = async (alertId: string, certificateIds: string[]) => {
|
||||
const alert = await pkiAlertV2DAL.findByIdWithChannels(alertId);
|
||||
if (!alert || !(alert as { enabled: boolean }).enabled) return;
|
||||
|
||||
const channels =
|
||||
(alert as { channels?: Array<{ enabled: boolean; channelType: string; config: unknown }> }).channels?.filter(
|
||||
(channel: { enabled: boolean; channelType: string; config: unknown }) => channel.enabled
|
||||
) || [];
|
||||
if (channels.length === 0) return;
|
||||
|
||||
const { certificates } = await pkiAlertV2DAL.findMatchingCertificates(
|
||||
(alert as { projectId: string }).projectId,
|
||||
(alert as { filters: TPkiFilterRule[] }).filters,
|
||||
{
|
||||
alertBefore: parseTimeToPostgresInterval((alert as { alertBefore: string }).alertBefore)
|
||||
}
|
||||
);
|
||||
|
||||
const matchingCertificates = certificates.filter(
|
||||
(cert) => certificateIds.includes(cert.id) && cert.enrollmentType !== CertificateOrigin.CA
|
||||
);
|
||||
|
||||
if (matchingCertificates.length === 0) return;
|
||||
|
||||
let hasNotificationSent = false;
|
||||
let notificationError: string | undefined;
|
||||
|
||||
try {
|
||||
const emailChannels = channels.filter(
|
||||
(channel: { enabled: boolean; channelType: string; config: unknown }) =>
|
||||
channel.channelType === PkiAlertChannelType.EMAIL
|
||||
);
|
||||
|
||||
const alertBeforeDays = parseTimeToDays((alert as { alertBefore: string }).alertBefore);
|
||||
const alertName = (alert as { name: string }).name;
|
||||
|
||||
const emailPromises = emailChannels.map((channel) => {
|
||||
const config = channel.config as TEmailChannelConfig;
|
||||
|
||||
return smtpService.sendMail({
|
||||
recipients: config.recipients,
|
||||
subjectLine: `Infisical Certificate Alert - ${alertName}`,
|
||||
substitutions: {
|
||||
alertName,
|
||||
alertBeforeDays,
|
||||
projectId: (alert as { projectId: string }).projectId,
|
||||
items: matchingCertificates.map((cert) => ({
|
||||
type: "Certificate",
|
||||
friendlyName: cert.commonName,
|
||||
serialNumber: cert.serialNumber,
|
||||
expiryDate: cert.notAfter.toLocaleDateString()
|
||||
}))
|
||||
},
|
||||
template: SmtpTemplates.PkiExpirationAlert
|
||||
});
|
||||
});
|
||||
|
||||
await Promise.all(emailPromises);
|
||||
|
||||
hasNotificationSent = true;
|
||||
} catch (error) {
|
||||
notificationError = error instanceof Error ? error.message : "Unknown error occurred";
|
||||
logger.error(error, `Failed to send notifications for alert ${alertId}`);
|
||||
}
|
||||
|
||||
await pkiAlertHistoryDAL.createWithCertificates(alertId, certificateIds, {
|
||||
hasNotificationSent,
|
||||
notificationError
|
||||
});
|
||||
};
|
||||
|
||||
return {
|
||||
createAlert,
|
||||
getAlertById,
|
||||
listAlerts,
|
||||
updateAlert,
|
||||
deleteAlert,
|
||||
listMatchingCertificates,
|
||||
listCurrentMatchingCertificates,
|
||||
sendAlertNotifications
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,205 @@
|
||||
import RE2 from "re2";
|
||||
import { z } from "zod";
|
||||
|
||||
import { TGenericPermission } from "@app/lib/types";
|
||||
|
||||
const createSecureNameValidator = () => {
|
||||
// Validates name format: lowercase alphanumeric characters with optional hyphens
|
||||
// Pattern: starts and ends with alphanumeric, allows hyphens between segments
|
||||
// Examples: "my-alert", "alert1", "test-alert-2"
|
||||
const nameRegex = new RE2("^[a-z0-9]+(?:-[a-z0-9]+)*$");
|
||||
return (value: string) => nameRegex.test(value);
|
||||
};
|
||||
|
||||
export const createSecureAlertBeforeValidator = () => {
|
||||
// Validates alertBefore duration format: number followed by time unit
|
||||
// Pattern: one or more digits followed by d(days), w(weeks), m(months), or y(years)
|
||||
// Examples: "30d", "2w", "6m", "1y"
|
||||
const alertBeforeRegex = new RE2("^\\d+[dwmy]$");
|
||||
return (value: string) => {
|
||||
if (value.length > 32) return false;
|
||||
return alertBeforeRegex.test(value);
|
||||
};
|
||||
};
|
||||
|
||||
export enum PkiAlertEventType {
|
||||
EXPIRATION = "expiration",
|
||||
RENEWAL = "renewal",
|
||||
ISSUANCE = "issuance",
|
||||
REVOCATION = "revocation"
|
||||
}
|
||||
|
||||
export enum PkiAlertChannelType {
|
||||
EMAIL = "email",
|
||||
WEBHOOK = "webhook",
|
||||
SLACK = "slack"
|
||||
}
|
||||
|
||||
export enum PkiFilterOperator {
|
||||
EQUALS = "equals",
|
||||
MATCHES = "matches",
|
||||
CONTAINS = "contains",
|
||||
STARTS_WITH = "starts_with",
|
||||
ENDS_WITH = "ends_with"
|
||||
}
|
||||
|
||||
export enum PkiFilterField {
|
||||
PROFILE_NAME = "profile_name",
|
||||
COMMON_NAME = "common_name",
|
||||
SAN = "san",
|
||||
INCLUDE_CAS = "include_cas"
|
||||
}
|
||||
|
||||
export enum CertificateOrigin {
|
||||
UNKNOWN = "unknown",
|
||||
PROFILE = "profile",
|
||||
IMPORT = "import",
|
||||
CA = "ca"
|
||||
}
|
||||
|
||||
export const PkiFilterRuleSchema = z.object({
|
||||
field: z.nativeEnum(PkiFilterField),
|
||||
operator: z.nativeEnum(PkiFilterOperator),
|
||||
value: z.union([z.string(), z.array(z.string()), z.boolean()])
|
||||
});
|
||||
|
||||
export type TPkiFilterRule = z.infer<typeof PkiFilterRuleSchema>;
|
||||
|
||||
export const PkiFiltersSchema = z.array(PkiFilterRuleSchema);
|
||||
export type TPkiFilters = z.infer<typeof PkiFiltersSchema>;
|
||||
|
||||
export const EmailChannelConfigSchema = z.object({
|
||||
recipients: z.array(z.string().email()).min(1).max(10)
|
||||
});
|
||||
|
||||
export const WebhookChannelConfigSchema = z.object({
|
||||
url: z.string().url(),
|
||||
method: z.enum(["POST", "PUT"]).default("POST"),
|
||||
headers: z.record(z.string()).optional()
|
||||
});
|
||||
|
||||
export const SlackChannelConfigSchema = z.object({
|
||||
webhookUrl: z.string().url(),
|
||||
channel: z.string().optional(),
|
||||
mentionUsers: z.array(z.string()).optional()
|
||||
});
|
||||
|
||||
export const ChannelConfigSchema = z.union([
|
||||
EmailChannelConfigSchema,
|
||||
WebhookChannelConfigSchema,
|
||||
SlackChannelConfigSchema
|
||||
]);
|
||||
|
||||
export type TEmailChannelConfig = z.infer<typeof EmailChannelConfigSchema>;
|
||||
export type TWebhookChannelConfig = z.infer<typeof WebhookChannelConfigSchema>;
|
||||
export type TSlackChannelConfig = z.infer<typeof SlackChannelConfigSchema>;
|
||||
export type TChannelConfig = z.infer<typeof ChannelConfigSchema>;
|
||||
|
||||
export const CreateChannelSchema = z.object({
|
||||
channelType: z.nativeEnum(PkiAlertChannelType),
|
||||
config: ChannelConfigSchema,
|
||||
enabled: z.boolean().default(true)
|
||||
});
|
||||
|
||||
export type TCreateChannel = z.infer<typeof CreateChannelSchema>;
|
||||
|
||||
export const CreatePkiAlertV2Schema = z.object({
|
||||
name: z
|
||||
.string()
|
||||
.min(1)
|
||||
.max(255)
|
||||
.refine(createSecureNameValidator(), "Must be a valid name (lowercase, numbers, hyphens only)"),
|
||||
description: z.string().max(1000).optional(),
|
||||
eventType: z.nativeEnum(PkiAlertEventType),
|
||||
alertBefore: z.string().refine(createSecureAlertBeforeValidator(), "Must be in format like '30d', '1w', '3m', '1y'"),
|
||||
filters: PkiFiltersSchema,
|
||||
enabled: z.boolean().default(true),
|
||||
channels: z.array(CreateChannelSchema).min(1, "At least one channel is required")
|
||||
});
|
||||
|
||||
export type TCreatePkiAlertV2 = z.infer<typeof CreatePkiAlertV2Schema>;
|
||||
|
||||
export const UpdatePkiAlertV2Schema = CreatePkiAlertV2Schema.partial();
|
||||
export type TUpdatePkiAlertV2 = z.infer<typeof UpdatePkiAlertV2Schema>;
|
||||
|
||||
export type TCreateAlertV2DTO = TGenericPermission & {
|
||||
projectId: string;
|
||||
} & TCreatePkiAlertV2;
|
||||
|
||||
export type TUpdateAlertV2DTO = TGenericPermission & {
|
||||
alertId: string;
|
||||
} & TUpdatePkiAlertV2;
|
||||
|
||||
export type TGetAlertV2DTO = TGenericPermission & {
|
||||
alertId: string;
|
||||
};
|
||||
|
||||
export type TDeleteAlertV2DTO = TGenericPermission & {
|
||||
alertId: string;
|
||||
};
|
||||
|
||||
export type TListAlertsV2DTO = TGenericPermission & {
|
||||
projectId: string;
|
||||
search?: string;
|
||||
eventType?: PkiAlertEventType;
|
||||
enabled?: boolean;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
};
|
||||
|
||||
export type TListMatchingCertificatesDTO = TGenericPermission & {
|
||||
alertId: string;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
};
|
||||
|
||||
export type TListCurrentMatchingCertificatesDTO = TGenericPermission & {
|
||||
projectId: string;
|
||||
filters: TPkiFilters;
|
||||
alertBefore: string;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
};
|
||||
|
||||
export type TCertificatePreview = {
|
||||
id: string;
|
||||
serialNumber: string;
|
||||
commonName: string;
|
||||
san: string[];
|
||||
profileName: string | null;
|
||||
enrollmentType: CertificateOrigin | null;
|
||||
notBefore: Date;
|
||||
notAfter: Date;
|
||||
status: string;
|
||||
};
|
||||
|
||||
export type TAlertV2Response = {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
eventType: PkiAlertEventType;
|
||||
alertBefore: string;
|
||||
filters: TPkiFilters;
|
||||
enabled: boolean;
|
||||
projectId: string;
|
||||
channels: Array<{
|
||||
id: string;
|
||||
channelType: PkiAlertChannelType;
|
||||
config: TChannelConfig;
|
||||
enabled: boolean;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}>;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
};
|
||||
|
||||
export type TListAlertsV2Response = {
|
||||
alerts: TAlertV2Response[];
|
||||
total: number;
|
||||
};
|
||||
|
||||
export type TListMatchingCertificatesResponse = {
|
||||
certificates: TCertificatePreview[];
|
||||
total: number;
|
||||
};
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
/* eslint-disable no-await-in-loop */
|
||||
import * as AWS from "aws-sdk";
|
||||
import AWS from "aws-sdk";
|
||||
import RE2 from "re2";
|
||||
import { z } from "zod";
|
||||
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
import * as AWS from "aws-sdk";
|
||||
import AWS from "aws-sdk";
|
||||
import { z } from "zod";
|
||||
|
||||
import { TAwsConnection } from "@app/services/app-connection/aws/aws-connection-types";
|
||||
|
||||
@@ -252,7 +252,8 @@ export const projectServiceFactory = ({
|
||||
tx: trx,
|
||||
createDefaultEnvs = true,
|
||||
template = InfisicalProjectTemplate.Default,
|
||||
type = ProjectType.SecretManager
|
||||
type = ProjectType.SecretManager,
|
||||
hasDeleteProtection
|
||||
}: TCreateProjectDTO) => {
|
||||
const organization = await orgDAL.findOne({ id: actorOrgId });
|
||||
const { permission } = await permissionService.getOrgPermission({
|
||||
@@ -325,7 +326,8 @@ export const projectServiceFactory = ({
|
||||
slug,
|
||||
kmsSecretManagerKeyId: kmsKeyId,
|
||||
version: ProjectVersion.V3,
|
||||
pitVersionLimit: 10
|
||||
pitVersionLimit: 10,
|
||||
hasDeleteProtection
|
||||
},
|
||||
tx
|
||||
);
|
||||
@@ -587,6 +589,7 @@ export const projectServiceFactory = ({
|
||||
actorOrgId,
|
||||
actionProjectType: ActionProjectType.Any
|
||||
});
|
||||
|
||||
return project;
|
||||
};
|
||||
|
||||
@@ -1605,8 +1608,14 @@ export const projectServiceFactory = ({
|
||||
isAccessRequestNotificationEnabled,
|
||||
accessRequestChannels,
|
||||
isSecretRequestNotificationEnabled,
|
||||
secretRequestChannels
|
||||
}: TUpdateProjectWorkflowIntegration) => {
|
||||
secretRequestChannels,
|
||||
secretSyncErrorChannels,
|
||||
isSecretSyncErrorNotificationEnabled
|
||||
}: TUpdateProjectWorkflowIntegration & {
|
||||
// workaround intersection type while we don't have the microsoft teams integration for failed secret syncs
|
||||
isSecretSyncErrorNotificationEnabled?: boolean;
|
||||
secretSyncErrorChannels?: string;
|
||||
}) => {
|
||||
const project = await projectDAL.findById(projectId);
|
||||
if (!project) {
|
||||
throw new NotFoundError({
|
||||
@@ -1628,6 +1637,7 @@ export const projectServiceFactory = ({
|
||||
|
||||
const sanitizedAccessRequestChannels = validateSlackChannelsField.parse(accessRequestChannels);
|
||||
const sanitizedSecretRequestChannels = validateSlackChannelsField.parse(secretRequestChannels);
|
||||
const sanitizedSecretSyncErrorChannels = validateSlackChannelsField.parse(secretSyncErrorChannels);
|
||||
|
||||
const slackIntegration = await slackIntegrationDAL.findByIdWithWorkflowIntegrationDetails(integrationId);
|
||||
|
||||
@@ -1665,7 +1675,9 @@ export const projectServiceFactory = ({
|
||||
isAccessRequestNotificationEnabled,
|
||||
accessRequestChannels: sanitizedAccessRequestChannels,
|
||||
isSecretRequestNotificationEnabled,
|
||||
secretRequestChannels: sanitizedSecretRequestChannels
|
||||
secretRequestChannels: sanitizedSecretRequestChannels,
|
||||
isSecretSyncErrorNotificationEnabled,
|
||||
secretSyncErrorChannels: sanitizedSecretSyncErrorChannels
|
||||
},
|
||||
tx
|
||||
);
|
||||
@@ -1678,7 +1690,9 @@ export const projectServiceFactory = ({
|
||||
isAccessRequestNotificationEnabled,
|
||||
accessRequestChannels: sanitizedAccessRequestChannels,
|
||||
isSecretRequestNotificationEnabled,
|
||||
secretRequestChannels: sanitizedSecretRequestChannels
|
||||
secretRequestChannels: sanitizedSecretRequestChannels,
|
||||
isSecretSyncErrorNotificationEnabled,
|
||||
secretSyncErrorChannels: sanitizedSecretSyncErrorChannels
|
||||
},
|
||||
tx
|
||||
);
|
||||
@@ -1688,6 +1702,7 @@ export const projectServiceFactory = ({
|
||||
...updatedWorkflowIntegration,
|
||||
accessRequestChannels: sanitizedAccessRequestChannels,
|
||||
secretRequestChannels: sanitizedSecretRequestChannels,
|
||||
secretSyncErrorChannels: sanitizedSecretSyncErrorChannels,
|
||||
integrationId: slackIntegration.id,
|
||||
integration: WorkflowIntegration.SLACK
|
||||
} as const;
|
||||
|
||||
@@ -51,6 +51,7 @@ export type TCreateProjectDTO = {
|
||||
pitVersionLimit?: number;
|
||||
tx?: Knex;
|
||||
type?: ProjectType;
|
||||
hasDeleteProtection?: boolean;
|
||||
};
|
||||
|
||||
export type TDeleteProjectBySlugDTO = {
|
||||
@@ -185,8 +186,10 @@ export type TUpdateProjectWorkflowIntegration = (
|
||||
integration: WorkflowIntegration.SLACK;
|
||||
isAccessRequestNotificationEnabled: boolean;
|
||||
isSecretRequestNotificationEnabled: boolean;
|
||||
isSecretSyncErrorNotificationEnabled: boolean;
|
||||
accessRequestChannels?: string;
|
||||
secretRequestChannels?: string;
|
||||
secretSyncErrorChannels?: string;
|
||||
}
|
||||
| {
|
||||
integrationId: string;
|
||||
|
||||
@@ -10,6 +10,8 @@ import { TLicenseServiceFactory } from "@app/ee/services/license/license-service
|
||||
import { KeyStorePrefixes, TKeyStoreFactory } from "@app/keystore/keystore";
|
||||
import { getConfig } from "@app/lib/config/env";
|
||||
import { logger } from "@app/lib/logger";
|
||||
import { triggerWorkflowIntegrationNotification } from "@app/lib/workflow-integrations/trigger-notification";
|
||||
import { TriggerFeature } from "@app/lib/workflow-integrations/types";
|
||||
import { QueueJobs, QueueName, TQueueServiceFactory } from "@app/queue";
|
||||
import { SecretNameSchema } from "@app/server/lib/schemas";
|
||||
import { decryptAppConnectionCredentials } from "@app/services/app-connection/app-connection-fns";
|
||||
@@ -62,8 +64,11 @@ import { SmtpTemplates, TSmtpService } from "@app/services/smtp/smtp-service";
|
||||
|
||||
import { TAppConnectionDALFactory } from "../app-connection/app-connection-dal";
|
||||
import { TFolderCommitServiceFactory } from "../folder-commit/folder-commit-service";
|
||||
import { TMicrosoftTeamsServiceFactory } from "../microsoft-teams/microsoft-teams-service";
|
||||
import { TProjectMicrosoftTeamsConfigDALFactory } from "../microsoft-teams/project-microsoft-teams-config-dal";
|
||||
import { TNotificationServiceFactory } from "../notification/notification-service";
|
||||
import { NotificationType } from "../notification/notification-types";
|
||||
import { TProjectSlackConfigDALFactory } from "../slack/project-slack-config-dal";
|
||||
|
||||
export type TSecretSyncQueueFactory = ReturnType<typeof secretSyncQueueFactory>;
|
||||
|
||||
@@ -104,6 +109,9 @@ type TSecretSyncQueueFactoryDep = {
|
||||
gatewayService: Pick<TGatewayServiceFactory, "fnGetGatewayClientTlsByGatewayId">;
|
||||
gatewayV2Service: Pick<TGatewayV2ServiceFactory, "getPlatformConnectionDetailsByGatewayId">;
|
||||
notificationService: Pick<TNotificationServiceFactory, "createUserNotifications">;
|
||||
projectSlackConfigDAL: Pick<TProjectSlackConfigDALFactory, "getIntegrationDetailsByProject">;
|
||||
projectMicrosoftTeamsConfigDAL: Pick<TProjectMicrosoftTeamsConfigDALFactory, "getIntegrationDetailsByProject">;
|
||||
microsoftTeamsService: Pick<TMicrosoftTeamsServiceFactory, "sendNotification">;
|
||||
};
|
||||
|
||||
type SecretSyncActionJob = Job<
|
||||
@@ -147,7 +155,10 @@ export const secretSyncQueueFactory = ({
|
||||
licenseService,
|
||||
gatewayService,
|
||||
gatewayV2Service,
|
||||
notificationService
|
||||
notificationService,
|
||||
projectSlackConfigDAL,
|
||||
projectMicrosoftTeamsConfigDAL,
|
||||
microsoftTeamsService
|
||||
}: TSecretSyncQueueFactoryDep) => {
|
||||
const appCfg = getConfig();
|
||||
|
||||
@@ -921,34 +932,65 @@ export const secretSyncQueueFactory = ({
|
||||
break;
|
||||
}
|
||||
|
||||
const syncPath = `/projects/secret-management/${projectId}/integrations/secret-syncs/${destination}/${secretSync.id}`;
|
||||
const baseProjectPath = `/projects/secret-management/${projectId}`;
|
||||
const overviewPath = `${baseProjectPath}/overview`;
|
||||
const syncPath = `${baseProjectPath}/integrations/secret-syncs/${destination}/${secretSync.id}`;
|
||||
|
||||
await notificationService.createUserNotifications(
|
||||
projectAdmins.map((admin) => ({
|
||||
userId: admin.userId,
|
||||
orgId: project.orgId,
|
||||
type: NotificationType.SECRET_SYNC_FAILED,
|
||||
title: `Secret Sync Failed to ${actionLabel} Secrets`,
|
||||
body: `Your **${syncDestination}** sync **${name}** failed to complete${failureMessage ? `: \`${failureMessage}\`` : ""}`,
|
||||
link: syncPath
|
||||
}))
|
||||
);
|
||||
const notifications = [
|
||||
triggerWorkflowIntegrationNotification({
|
||||
input: {
|
||||
notification: {
|
||||
type: TriggerFeature.SECRET_SYNC_ERROR,
|
||||
payload: {
|
||||
syncName: name,
|
||||
syncDestination,
|
||||
failureMessage: failureMessage || "An unknown error occurred",
|
||||
syncUrl: `${appCfg.SITE_URL}${syncPath}`,
|
||||
syncActionLabel: actionLabel,
|
||||
environment: environment?.name || "-",
|
||||
secretPath: folder?.path || "-",
|
||||
projectName: project.name,
|
||||
projectPath: overviewPath
|
||||
}
|
||||
},
|
||||
projectId
|
||||
},
|
||||
dependencies: {
|
||||
projectDAL,
|
||||
projectSlackConfigDAL,
|
||||
kmsService,
|
||||
microsoftTeamsService,
|
||||
projectMicrosoftTeamsConfigDAL
|
||||
}
|
||||
}),
|
||||
notificationService.createUserNotifications(
|
||||
projectAdmins.map((admin) => ({
|
||||
userId: admin.userId,
|
||||
orgId: project.orgId,
|
||||
type: NotificationType.SECRET_SYNC_FAILED,
|
||||
title: `Secret Sync Failed to ${actionLabel} Secrets`,
|
||||
body: `Your **${syncDestination}** sync **${name}** failed to complete${failureMessage ? `: \`${failureMessage}\`` : ""}`,
|
||||
link: syncPath
|
||||
}))
|
||||
),
|
||||
smtpService.sendMail({
|
||||
recipients: projectAdmins.map((member) => member.user.email!).filter(Boolean),
|
||||
template: SmtpTemplates.SecretSyncFailed,
|
||||
subjectLine: `Secret Sync Failed to ${actionLabel} Secrets`,
|
||||
substitutions: {
|
||||
syncName: name,
|
||||
syncDestination,
|
||||
content: `Your ${syncDestination} Sync named "${name}" failed while attempting to ${action.toLowerCase()} secrets.`,
|
||||
failureMessage,
|
||||
secretPath: folder?.path,
|
||||
environment: environment?.name,
|
||||
projectName: project.name,
|
||||
syncUrl: `${appCfg.SITE_URL}${syncPath}`
|
||||
}
|
||||
})
|
||||
];
|
||||
|
||||
await smtpService.sendMail({
|
||||
recipients: projectAdmins.map((member) => member.user.email!).filter(Boolean),
|
||||
template: SmtpTemplates.SecretSyncFailed,
|
||||
subjectLine: `Secret Sync Failed to ${actionLabel} Secrets`,
|
||||
substitutions: {
|
||||
syncName: name,
|
||||
syncDestination,
|
||||
content: `Your ${syncDestination} Sync named "${name}" failed while attempting to ${action.toLowerCase()} secrets.`,
|
||||
failureMessage,
|
||||
secretPath: folder?.path,
|
||||
environment: environment?.name,
|
||||
projectName: project.name,
|
||||
syncUrl: `${appCfg.SITE_URL}${syncPath}`
|
||||
}
|
||||
});
|
||||
await Promise.allSettled(notifications);
|
||||
};
|
||||
|
||||
const queueSecretSyncsSyncSecretsByPath = async ({
|
||||
|
||||
@@ -793,6 +793,7 @@ export const reshapeBridgeSecret = (
|
||||
userActorId?: string | null;
|
||||
identityActorId?: string | null;
|
||||
membershipId?: string | null;
|
||||
groupId?: string | null;
|
||||
actorType?: string | null;
|
||||
tags?: {
|
||||
id: string;
|
||||
@@ -823,7 +824,8 @@ export const reshapeBridgeSecret = (
|
||||
actorType: secret.actorType,
|
||||
actorId: secret.userActorId || secret.identityActorId,
|
||||
name: secret.identityActorName || secret.userActorName,
|
||||
membershipId: secret.membershipId
|
||||
membershipId: secret.membershipId,
|
||||
groupId: secret.groupId
|
||||
}
|
||||
: undefined,
|
||||
tags: secret.tags,
|
||||
|
||||
@@ -182,7 +182,6 @@ export const secretVersionV2BridgeDALFactory = (db: TDbClient) => {
|
||||
|
||||
const findVersionsBySecretIdWithActors = async ({
|
||||
secretId,
|
||||
projectId,
|
||||
secretVersions,
|
||||
findOpt = {},
|
||||
tx
|
||||
@@ -196,13 +195,22 @@ export const secretVersionV2BridgeDALFactory = (db: TDbClient) => {
|
||||
try {
|
||||
const { offset, limit, sort = [["createdAt", "desc"]] } = findOpt;
|
||||
const query = (tx || db.replicaNode())(TableName.SecretVersionV2)
|
||||
.leftJoin(TableName.SecretFolder, `${TableName.SecretFolder}.id`, `${TableName.SecretVersionV2}.folderId`)
|
||||
.leftJoin(TableName.Environment, `${TableName.Environment}.id`, `${TableName.SecretFolder}.envId`)
|
||||
.leftJoin(TableName.Users, `${TableName.Users}.id`, `${TableName.SecretVersionV2}.userActorId`)
|
||||
.leftJoin(TableName.Identity, `${TableName.Identity}.id`, `${TableName.SecretVersionV2}.identityActorId`)
|
||||
.leftJoin(TableName.UserGroupMembership, `${TableName.UserGroupMembership}.userId`, `${TableName.Users}.id`)
|
||||
.leftJoin(TableName.Membership, (qb) => {
|
||||
void qb
|
||||
.on(`${TableName.Membership}.actorUserId`, `${TableName.SecretVersionV2}.userActorId`)
|
||||
.andOn(`${TableName.Membership}.scope`, db.raw("?", [AccessScope.Project]));
|
||||
.on(`${TableName.Membership}.scope`, db.raw("?", [AccessScope.Project]))
|
||||
.andOn(`${TableName.Membership}.scopeProjectId`, `${TableName.Environment}.projectId`)
|
||||
.andOn((sqb) => {
|
||||
void sqb
|
||||
.on(`${TableName.Membership}.actorUserId`, `${TableName.SecretVersionV2}.userActorId`)
|
||||
.orOn(`${TableName.Membership}.actorIdentityId`, `${TableName.SecretVersionV2}.identityActorId`)
|
||||
.orOn(`${TableName.Membership}.actorGroupId`, `${TableName.UserGroupMembership}.groupId`);
|
||||
});
|
||||
})
|
||||
.leftJoin(TableName.Identity, `${TableName.Identity}.id`, `${TableName.SecretVersionV2}.identityActorId`)
|
||||
.leftJoin(TableName.SecretV2, `${TableName.SecretVersionV2}.secretId`, `${TableName.SecretV2}.id`)
|
||||
.leftJoin(
|
||||
TableName.SecretVersionV2Tag,
|
||||
@@ -216,12 +224,6 @@ export const secretVersionV2BridgeDALFactory = (db: TDbClient) => {
|
||||
)
|
||||
.where((qb) => {
|
||||
void qb.where(`${TableName.SecretVersionV2}.secretId`, secretId);
|
||||
void qb.where(`${TableName.Membership}.scopeProjectId`, projectId);
|
||||
if (secretVersions?.length) void qb.whereIn(`${TableName.SecretVersionV2}.version`, secretVersions);
|
||||
})
|
||||
.orWhere((qb) => {
|
||||
void qb.where(`${TableName.SecretVersionV2}.secretId`, secretId);
|
||||
void qb.whereNull(`${TableName.Membership}.scopeProjectId`);
|
||||
if (secretVersions?.length) void qb.whereIn(`${TableName.SecretVersionV2}.version`, secretVersions);
|
||||
})
|
||||
.select(
|
||||
@@ -229,6 +231,7 @@ export const secretVersionV2BridgeDALFactory = (db: TDbClient) => {
|
||||
db.ref("username").withSchema(TableName.Users).as("userActorName"),
|
||||
db.ref("name").withSchema(TableName.Identity).as("identityActorName"),
|
||||
db.ref("id").withSchema(TableName.Membership).as("membershipId"),
|
||||
db.ref("actorGroupId").withSchema(TableName.Membership).as("groupId"),
|
||||
db.ref("id").withSchema(TableName.SecretTag).as("tagId"),
|
||||
db.ref("color").withSchema(TableName.SecretTag).as("tagColor"),
|
||||
db.ref("slug").withSchema(TableName.SecretTag).as("tagSlug")
|
||||
@@ -256,7 +259,8 @@ export const secretVersionV2BridgeDALFactory = (db: TDbClient) => {
|
||||
...SecretVersionsV2Schema.parse(el),
|
||||
userActorName: el.userActorName,
|
||||
identityActorName: el.identityActorName,
|
||||
membershipId: el.membershipId
|
||||
membershipId: el.membershipId,
|
||||
groupId: el.groupId
|
||||
}),
|
||||
childrenMapper: [
|
||||
{
|
||||
|
||||
@@ -1,16 +1,17 @@
|
||||
import { Knex } from "knex";
|
||||
|
||||
import { TDbClient } from "@app/db";
|
||||
import { TableName } from "@app/db/schemas";
|
||||
import { TableName, TProjectSlackConfigs, TSlackIntegrations } from "@app/db/schemas";
|
||||
import { ormify, selectAllTableCols } from "@app/lib/knex";
|
||||
|
||||
export type TProjectSlackConfigDALFactory = ReturnType<typeof projectSlackConfigDALFactory>;
|
||||
export type TProjectSlackConfigWithIntegrations = TProjectSlackConfigs & TSlackIntegrations;
|
||||
|
||||
export const projectSlackConfigDALFactory = (db: TDbClient) => {
|
||||
const projectSlackConfigOrm = ormify(db, TableName.ProjectSlackConfigs);
|
||||
|
||||
const getIntegrationDetailsByProject = (projectId: string, tx?: Knex) => {
|
||||
return (tx || db.replicaNode())(TableName.ProjectSlackConfigs)
|
||||
return (tx || db.replicaNode())<TProjectSlackConfigWithIntegrations>(TableName.ProjectSlackConfigs)
|
||||
.join(
|
||||
TableName.SlackIntegrations,
|
||||
`${TableName.ProjectSlackConfigs}.slackIntegrationId`,
|
||||
|
||||
@@ -8,6 +8,9 @@ import { TNotification, TriggerFeature } from "@app/lib/workflow-integrations/ty
|
||||
import { KmsDataKey } from "../kms/kms-types";
|
||||
import { TSendSlackNotificationDTO } from "./slack-types";
|
||||
|
||||
const COMPANY_BRAND_COLOR = "#e0ed34";
|
||||
const ERROR_COLOR = "#e74c3c";
|
||||
|
||||
export const fetchSlackChannels = async (botKey: string) => {
|
||||
const slackChannels: {
|
||||
name: string;
|
||||
@@ -48,13 +51,9 @@ const buildSlackPayload = (notification: TNotification) => {
|
||||
const messageBody = `A secret approval request has been opened by ${payload.userEmail}.
|
||||
*Environment*: ${payload.environment}
|
||||
*Secret path*: ${payload.secretPath || "/"}
|
||||
*Secret Key${payload.secretKeys.length > 1 ? "s" : ""}*: ${payload.secretKeys.join(", ")}
|
||||
*Secret Key${payload.secretKeys.length > 1 ? "s" : ""}*: ${payload.secretKeys.join(", ")}`;
|
||||
|
||||
View the complete details <${appCfg.SITE_URL}/projects/secret-management/${payload.projectId}/approval?requestId=${
|
||||
payload.requestId
|
||||
}|here>.`;
|
||||
|
||||
const payloadBlocks = [
|
||||
const headerBlocks = [
|
||||
{
|
||||
type: "header",
|
||||
text: {
|
||||
@@ -62,37 +61,52 @@ View the complete details <${appCfg.SITE_URL}/projects/secret-management/${paylo
|
||||
text: "Secret approval request",
|
||||
emoji: true
|
||||
}
|
||||
},
|
||||
}
|
||||
];
|
||||
|
||||
const payloadBlocks = [
|
||||
{
|
||||
type: "section",
|
||||
text: {
|
||||
type: "mrkdwn",
|
||||
text: messageBody
|
||||
}
|
||||
},
|
||||
{
|
||||
type: "actions",
|
||||
elements: [
|
||||
{
|
||||
type: "button",
|
||||
text: {
|
||||
type: "plain_text",
|
||||
text: "View request",
|
||||
emoji: true
|
||||
},
|
||||
style: "primary",
|
||||
url: payload.approvalUrl
|
||||
}
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
return {
|
||||
headerBlocks,
|
||||
payloadMessage: messageBody,
|
||||
payloadBlocks
|
||||
payloadBlocks,
|
||||
color: COMPANY_BRAND_COLOR
|
||||
};
|
||||
}
|
||||
case TriggerFeature.ACCESS_REQUEST: {
|
||||
const { payload } = notification;
|
||||
const messageBody = `${payload.requesterFullName} (${payload.requesterEmail}) has requested ${
|
||||
payload.isTemporary ? "temporary" : "permanent"
|
||||
} access to ${payload.secretPath} in the ${payload.environment} environment of ${payload.projectName}.
|
||||
|
||||
The following permissions are requested: ${payload.permissions.join(", ")}
|
||||
const projectUrl = `${appCfg.SITE_URL}${payload.projectPath}/overview`;
|
||||
const accessType = payload.isTemporary ? "temporary" : "permanent";
|
||||
const permissionsFormatted = payload.permissions.map((p) => `*${p}*`).join(", ");
|
||||
|
||||
View the request and approve or deny it <${payload.approvalUrl}|here>.${
|
||||
payload.note
|
||||
? `
|
||||
User Note: ${payload.note}`
|
||||
: ""
|
||||
const messageBody = `${payload.requesterFullName} (${payload.requesterEmail}) has requested ${accessType} access to ${payload.secretPath} in the ${payload.environment} environment of ${payload.projectName}.\n\nThe following permissions are requested: ${payload.permissions.join(", ")}${
|
||||
payload.note ? `\n\nUser note: ${payload.note}` : ""
|
||||
}`;
|
||||
|
||||
const payloadBlocks = [
|
||||
const headerBlocks = [
|
||||
{
|
||||
type: "header",
|
||||
text: {
|
||||
@@ -100,37 +114,54 @@ User Note: ${payload.note}`
|
||||
text: "New access approval request pending for review",
|
||||
emoji: true
|
||||
}
|
||||
},
|
||||
}
|
||||
];
|
||||
|
||||
const payloadBlocks = [
|
||||
{
|
||||
type: "section",
|
||||
text: {
|
||||
type: "mrkdwn",
|
||||
text: messageBody
|
||||
text: `*${payload.requesterFullName}* (${payload.requesterEmail}) has requested *${accessType}* access to *${payload.secretPath}* in the *${payload.environment}* environment of *<${projectUrl}|${payload.projectName}>*.\n\nThe following permissions are requested: ${permissionsFormatted}${
|
||||
payload.note ? `\n\n*User note:* ${payload.note}` : ""
|
||||
}`
|
||||
}
|
||||
},
|
||||
{
|
||||
type: "actions",
|
||||
elements: [
|
||||
{
|
||||
type: "button",
|
||||
text: {
|
||||
type: "plain_text",
|
||||
text: "View request",
|
||||
emoji: true
|
||||
},
|
||||
style: "primary",
|
||||
url: payload.approvalUrl
|
||||
}
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
return {
|
||||
headerBlocks,
|
||||
payloadMessage: messageBody,
|
||||
payloadBlocks
|
||||
payloadBlocks,
|
||||
color: COMPANY_BRAND_COLOR
|
||||
};
|
||||
}
|
||||
case TriggerFeature.ACCESS_REQUEST_UPDATED: {
|
||||
const { payload } = notification;
|
||||
const messageBody = `${payload.editorFullName} (${payload.editorEmail}) has updated the ${
|
||||
payload.isTemporary ? "temporary" : "permanent"
|
||||
} access request from ${payload.requesterFullName} (${payload.requesterEmail}) to ${payload.secretPath} in the ${payload.environment} environment of ${payload.projectName}.
|
||||
|
||||
The following permissions are requested: ${payload.permissions.join(", ")}
|
||||
const projectUrl = `${appCfg.SITE_URL}${payload.projectPath}/overview`;
|
||||
const accessType = payload.isTemporary ? "temporary" : "permanent";
|
||||
const permissionsFormatted = payload.permissions.map((p) => `*${p}*`).join(", ");
|
||||
|
||||
View the request and approve or deny it <${payload.approvalUrl}|here>.${
|
||||
payload.editNote
|
||||
? `
|
||||
Editor Note: ${payload.editNote}`
|
||||
: ""
|
||||
const messageBody = `${payload.editorFullName} (${payload.editorEmail}) has updated the ${accessType} access request from ${payload.requesterFullName} (${payload.requesterEmail}) to ${payload.secretPath} in the ${payload.environment} environment of ${payload.projectName}.\n\nThe following permissions are requested: ${payload.permissions.join(", ")}${
|
||||
payload.editNote ? `\n\nEditor Note: ${payload.editNote}` : ""
|
||||
}`;
|
||||
|
||||
const payloadBlocks = [
|
||||
const headerBlocks = [
|
||||
{
|
||||
type: "header",
|
||||
text: {
|
||||
@@ -138,19 +169,89 @@ Editor Note: ${payload.editNote}`
|
||||
text: "Updated access approval request pending for review",
|
||||
emoji: true
|
||||
}
|
||||
},
|
||||
}
|
||||
];
|
||||
|
||||
const payloadBlocks = [
|
||||
{
|
||||
type: "section",
|
||||
text: {
|
||||
type: "mrkdwn",
|
||||
text: messageBody
|
||||
text: `*${payload.editorFullName}* (${payload.editorEmail}) has updated the *${accessType}* access request from *${payload.requesterFullName}* (${payload.requesterEmail}) to *${payload.secretPath}* in the *${payload.environment}* environment of *<${projectUrl}|${payload.projectName}>*.\n\nThe following permissions are requested: ${permissionsFormatted}${
|
||||
payload.editNote ? `\n\n*Editor Note:* ${payload.editNote}` : ""
|
||||
}`
|
||||
}
|
||||
},
|
||||
{
|
||||
type: "actions",
|
||||
elements: [
|
||||
{
|
||||
type: "button",
|
||||
text: {
|
||||
type: "plain_text",
|
||||
text: "View request",
|
||||
emoji: true
|
||||
},
|
||||
style: "primary",
|
||||
url: payload.approvalUrl
|
||||
}
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
return {
|
||||
headerBlocks,
|
||||
payloadMessage: messageBody,
|
||||
payloadBlocks,
|
||||
color: COMPANY_BRAND_COLOR
|
||||
};
|
||||
}
|
||||
case TriggerFeature.SECRET_SYNC_ERROR: {
|
||||
const { payload } = notification;
|
||||
const projectUrl = `${appCfg.SITE_URL}${payload.projectPath}`;
|
||||
const messageBody = `Secret sync ${payload.syncName} for ${payload.syncDestination} failed on ${payload.syncActionLabel}\n\n\nEnvironment: ${payload.environment}\n\n\nSecret Path: ${payload.secretPath}\n\n\nProject: ${payload.projectName} (${projectUrl})\n\n\nReason:\n${payload.failureMessage}`;
|
||||
|
||||
const headerBlocks = [
|
||||
{
|
||||
type: "header",
|
||||
text: {
|
||||
type: "plain_text",
|
||||
text: `Secret sync ${payload.syncName} for ${payload.syncDestination} failed on ${payload.syncActionLabel}`,
|
||||
emoji: true
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
const payloadBlocks = [
|
||||
{
|
||||
type: "section",
|
||||
text: {
|
||||
type: "mrkdwn",
|
||||
text: `*Environment:* ${payload.environment}\n\n*Secret Path:* ${payload.secretPath}\n\n*Project:* <${projectUrl}|${payload.projectName}>\n\n*Reason:* ${payload.failureMessage}`
|
||||
}
|
||||
},
|
||||
{
|
||||
type: "actions",
|
||||
elements: [
|
||||
{
|
||||
type: "button",
|
||||
text: {
|
||||
type: "plain_text",
|
||||
text: "Open secret sync",
|
||||
emoji: true
|
||||
},
|
||||
style: "primary",
|
||||
url: payload.syncUrl
|
||||
}
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
return {
|
||||
payloadMessage: messageBody,
|
||||
payloadBlocks
|
||||
headerBlocks,
|
||||
payloadBlocks,
|
||||
color: ERROR_COLOR
|
||||
};
|
||||
}
|
||||
default: {
|
||||
@@ -177,15 +278,22 @@ export const sendSlackNotification = async ({
|
||||
}).toString("utf8");
|
||||
const slackWebClient = new WebClient(botKey);
|
||||
|
||||
const { payloadMessage, payloadBlocks } = buildSlackPayload(notification);
|
||||
const { payloadMessage, payloadBlocks, color, headerBlocks } = buildSlackPayload(notification);
|
||||
|
||||
for await (const conversationId of targetChannelIds) {
|
||||
// we send both text and blocks for compatibility with barebone clients
|
||||
|
||||
await slackWebClient.chat
|
||||
.postMessage({
|
||||
channel: conversationId,
|
||||
text: payloadMessage,
|
||||
blocks: payloadBlocks
|
||||
blocks: headerBlocks,
|
||||
attachments: [
|
||||
{
|
||||
color,
|
||||
blocks: payloadBlocks
|
||||
}
|
||||
]
|
||||
})
|
||||
.catch((err) => logger.error(err));
|
||||
}
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import { Heading, Hr, Section, Text } from "@react-email/components";
|
||||
import React, { Fragment } from "react";
|
||||
import { Heading, Section, Text } from "@react-email/components";
|
||||
|
||||
import { BaseButton } from "./BaseButton";
|
||||
import { BaseEmailWrapper, BaseEmailWrapperProps } from "./BaseEmailWrapper";
|
||||
|
||||
interface PkiExpirationAlertTemplateProps extends Omit<BaseEmailWrapperProps, "title" | "preview" | "children"> {
|
||||
alertName: string;
|
||||
alertBeforeDays: number;
|
||||
projectId: string;
|
||||
items: { type: string; friendlyName: string; serialNumber: string; expiryDate: string }[];
|
||||
}
|
||||
|
||||
@@ -13,44 +14,56 @@ export const PkiExpirationAlertTemplate = ({
|
||||
alertName,
|
||||
siteUrl,
|
||||
alertBeforeDays,
|
||||
projectId,
|
||||
items
|
||||
}: PkiExpirationAlertTemplateProps) => {
|
||||
const formatDate = (dateStr: string) => {
|
||||
try {
|
||||
return new Date(dateStr).toLocaleDateString("en-US", {
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "numeric"
|
||||
});
|
||||
} catch {
|
||||
return dateStr;
|
||||
}
|
||||
};
|
||||
|
||||
const certificateText = items.length === 1 ? "certificate" : "certificates";
|
||||
const daysText = alertBeforeDays === 1 ? "1 day" : `${alertBeforeDays} days`;
|
||||
|
||||
const message = `Alert ${alertName}: You have ${items.length === 1 ? "one" : items.length} ${certificateText} that will expire in ${daysText}.`;
|
||||
|
||||
return (
|
||||
<BaseEmailWrapper
|
||||
title="Infisical CA/Certificate Expiration Notice"
|
||||
preview="One or more of your Infisical certificates is about to expire."
|
||||
siteUrl={siteUrl}
|
||||
>
|
||||
<BaseEmailWrapper title="Certificate Expiration Notice" preview={message} siteUrl={siteUrl}>
|
||||
<Heading className="text-black text-[18px] leading-[28px] text-center font-normal p-0 mx-0">
|
||||
<strong>CA/Certificate Expiration Notice</strong>
|
||||
<strong>Certificate Expiration Notice</strong>
|
||||
</Heading>
|
||||
<Section className="px-[24px] mt-[36px] pt-[12px] pb-[8px] border border-solid border-gray-200 rounded-md bg-gray-50">
|
||||
<Text>Hello,</Text>
|
||||
<Text className="text-black text-[14px] leading-[24px]">
|
||||
This is an automated alert for <strong>{alertName}</strong> triggered for CAs/Certificates expiring in{" "}
|
||||
<strong>{alertBeforeDays}</strong> days.
|
||||
</Text>
|
||||
<Text className="text-[14px] leading-[24px] mb-[4px]">
|
||||
<strong>Expiring Items:</strong>
|
||||
|
||||
<Section className="px-[24px] mb-[28px] mt-[36px] pt-[12px] pb-[8px] border border-solid border-gray-200 rounded-md bg-gray-50">
|
||||
<Text className="text-[14px]">
|
||||
Alert <strong className="font-semibold">{alertName}</strong>: You have{" "}
|
||||
{items.length === 1 ? "one" : items.length} {certificateText} that will expire in {daysText}.
|
||||
</Text>
|
||||
</Section>
|
||||
<Section className="mb-[28px]">
|
||||
<Text className="text-[14px] font-semibold mb-[12px]">Expiring certificates:</Text>
|
||||
{items.map((item) => (
|
||||
<Fragment key={item.serialNumber}>
|
||||
<Hr className="mb-[16px]" />
|
||||
<strong className="text-[14px]">{item.type}:</strong>
|
||||
<Text className="text-[14px] my-[2px] leading-[24px]">{item.friendlyName}</Text>
|
||||
<strong className="text-[14px]">Serial Number:</strong>
|
||||
<Text className="text-[14px] my-[2px] leading-[24px]">{item.serialNumber}</Text>
|
||||
<strong className="text-[14px]">Expires On:</strong>
|
||||
<Text className="text-[14px] mt-[2px] mb-[16px] leading-[24px]">{item.expiryDate}</Text>
|
||||
</Fragment>
|
||||
<Section
|
||||
key={item.serialNumber}
|
||||
className="mb-[16px] p-[16px] border border-solid border-gray-200 rounded-md bg-gray-50"
|
||||
>
|
||||
<Text className="text-[14px] font-semibold m-0 mb-[4px]">{item.friendlyName}</Text>
|
||||
<Text className="text-[12px] text-gray-600 m-0 mb-[4px]">Serial: {item.serialNumber}</Text>
|
||||
<Text className="text-[12px] text-gray-600 m-0">Expires: {formatDate(item.expiryDate)}</Text>
|
||||
</Section>
|
||||
))}
|
||||
<Hr />
|
||||
<Text className="text-[14px] leading-[24px]">
|
||||
Please take the necessary actions to renew these items before they expire.
|
||||
</Text>
|
||||
<Text className="text-[14px] leading-[24px]">
|
||||
For more details, please log in to your Infisical account and check your PKI management section.
|
||||
</Text>
|
||||
</Section>
|
||||
|
||||
<Section className="text-center mt-[32px] mb-[16px]">
|
||||
<BaseButton href={`${siteUrl}/projects/cert-management/${projectId}/policies`}>
|
||||
View Certificate Alerts
|
||||
</BaseButton>
|
||||
</Section>
|
||||
</BaseEmailWrapper>
|
||||
);
|
||||
@@ -59,11 +72,22 @@ export const PkiExpirationAlertTemplate = ({
|
||||
export default PkiExpirationAlertTemplate;
|
||||
|
||||
PkiExpirationAlertTemplate.PreviewProps = {
|
||||
alertBeforeDays: 5,
|
||||
alertBeforeDays: 7,
|
||||
items: [
|
||||
{ type: "CA", friendlyName: "Example CA", serialNumber: "1234567890", expiryDate: "2032-01-01" },
|
||||
{ type: "Certificate", friendlyName: "Example Certificate", serialNumber: "2345678901", expiryDate: "2032-01-01" }
|
||||
{
|
||||
type: "Certificate",
|
||||
friendlyName: "api.production.company.com",
|
||||
serialNumber: "4B:3E:2F:A1:D6:7C:89:45:B2:E8:7F:1A:3D:9C:5E:8B",
|
||||
expiryDate: "2025-11-12"
|
||||
},
|
||||
{
|
||||
type: "Certificate",
|
||||
friendlyName: "web.company.com",
|
||||
serialNumber: "8A:7F:1C:E4:92:B5:D3:68:F1:A2:7E:9B:4C:6D:5A:3F",
|
||||
expiryDate: "2025-11-10"
|
||||
}
|
||||
],
|
||||
alertName: "My PKI Alert",
|
||||
alertName: "Production SSL Certificate Expiration Alert",
|
||||
projectId: "c3b0ef29-915b-4cb1-8684-65b91b7fe02d",
|
||||
siteUrl: "https://infisical.com"
|
||||
} as PkiExpirationAlertTemplateProps;
|
||||
|
||||
Reference in New Issue
Block a user