Merge remote-tracking branch 'origin/main' into feat/pki-alerting

This commit is contained in:
Carlos Monastyrski
2025-11-07 16:43:14 -03:00
508 changed files with 13840 additions and 10621 deletions
@@ -1,5 +1,10 @@
import { ProjectType } from "@app/db/schemas";
import { TAppConnections } from "@app/db/schemas/app-connections";
import {
ChefConnectionMethod,
getChefConnectionListItem,
validateChefConnectionCredentials
} from "@app/ee/services/app-connections/chef";
import {
getOCIConnectionListItem,
OCIConnectionMethod,
@@ -68,7 +73,6 @@ import {
} from "./bitbucket";
import { CamundaConnectionMethod, getCamundaConnectionListItem, validateCamundaConnectionCredentials } from "./camunda";
import { ChecklyConnectionMethod, getChecklyConnectionListItem, validateChecklyConnectionCredentials } from "./checkly";
import { ChefConnectionMethod, getChefConnectionListItem, validateChefConnectionCredentials } from "./chef";
import { CloudflareConnectionMethod } from "./cloudflare/cloudflare-connection-enum";
import {
getCloudflareConnectionListItem,
@@ -86,6 +86,6 @@ export const APP_CONNECTION_PLAN_MAP: Record<AppConnection, AppConnectionPlanTyp
[AppConnection.Netlify]: AppConnectionPlanType.Regular,
[AppConnection.Okta]: AppConnectionPlanType.Regular,
[AppConnection.Redis]: AppConnectionPlanType.Regular,
[AppConnection.Chef]: AppConnectionPlanType.Regular,
[AppConnection.Chef]: AppConnectionPlanType.Enterprise,
[AppConnection.Northflank]: AppConnectionPlanType.Regular
};
@@ -1,6 +1,8 @@
import { ForbiddenError, subject } from "@casl/ability";
import { ActionProjectType, OrganizationActionScope, TAppConnections } from "@app/db/schemas";
import { ValidateChefConnectionCredentialsSchema } from "@app/ee/services/app-connections/chef";
import { chefConnectionService } from "@app/ee/services/app-connections/chef/chef-connection-service";
import { ValidateOCIConnectionCredentialsSchema } from "@app/ee/services/app-connections/oci";
import { ociConnectionService } from "@app/ee/services/app-connections/oci/oci-connection-service";
import { ValidateOracleDBConnectionCredentialsSchema } from "@app/ee/services/app-connections/oracledb";
@@ -67,8 +69,6 @@ import { ValidateCamundaConnectionCredentialsSchema } from "./camunda";
import { camundaConnectionService } from "./camunda/camunda-connection-service";
import { ValidateChecklyConnectionCredentialsSchema } from "./checkly";
import { checklyConnectionService } from "./checkly/checkly-connection-service";
import { ValidateChefConnectionCredentialsSchema } from "./chef";
import { chefConnectionService } from "./chef/chef-connection-service";
import { ValidateCloudflareConnectionCredentialsSchema } from "./cloudflare/cloudflare-connection-schema";
import { cloudflareConnectionService } from "./cloudflare/cloudflare-connection-service";
import { ValidateDatabricksConnectionCredentialsSchema } from "./databricks";
@@ -885,6 +885,6 @@ export const appConnectionServiceFactory = ({
northflank: northflankConnectionService(connectAppConnectionById),
okta: oktaConnectionService(connectAppConnectionById),
laravelForge: laravelForgeConnectionService(connectAppConnectionById),
chef: chefConnectionService(connectAppConnectionById)
chef: chefConnectionService(connectAppConnectionById, licenseService)
};
};
@@ -1,3 +1,9 @@
import {
TChefConnection,
TChefConnectionConfig,
TChefConnectionInput,
TValidateChefConnectionCredentialsSchema
} from "@app/ee/services/app-connections/chef";
import {
TOCIConnection,
TOCIConnectionConfig,
@@ -82,12 +88,6 @@ import {
TChecklyConnectionInput,
TValidateChecklyConnectionCredentialsSchema
} from "./checkly";
import {
TChefConnection,
TChefConnectionConfig,
TChefConnectionInput,
TValidateChefConnectionCredentialsSchema
} from "./chef";
import {
TCloudflareConnection,
TCloudflareConnectionConfig,
@@ -1,3 +0,0 @@
export enum ChefConnectionMethod {
UserKey = "user-key"
}
@@ -1,288 +0,0 @@
import { AxiosError } from "axios";
import crypto from "crypto";
import { request } from "@app/lib/config/request";
import { BadRequestError } from "@app/lib/errors";
import { removeTrailingSlash } from "@app/lib/fn";
import { blockLocalAndPrivateIpAddresses } from "@app/lib/validator";
import { IntegrationUrls } from "@app/services/integration-auth/integration-list";
import { TChefDataBagItemContent } from "../../secret-sync/chef/chef-sync-types";
import { AppConnection } from "../app-connection-enums";
import { ChefConnectionMethod } from "./chef-connection-enums";
import {
TChefConnection,
TChefConnectionConfig,
TChefDataBag,
TChefDataBagItem,
TGetChefDataBagItem,
TUpdateChefDataBagItem
} from "./chef-connection-types";
export const getChefServerUrl = async (serverUrl?: string) => {
const chefServerUrl = serverUrl ? removeTrailingSlash(serverUrl) : IntegrationUrls.CHEF_API_URL;
await blockLocalAndPrivateIpAddresses(chefServerUrl);
return chefServerUrl;
};
// Helper to ensure private key is in proper PEM format
const formatPrivateKey = (key: string): string => {
let formattedKey = key.trim();
// Ensure proper line breaks in PEM format (handle escaped newlines)
formattedKey = formattedKey.replace(/\\n/g, "\n");
// Remove any extra whitespace between lines
formattedKey = formattedKey.replace(/\n\s+/g, "\n");
// If key doesn't have headers, add PKCS#1 RSA headers
if (!formattedKey.includes("BEGIN")) {
formattedKey = `-----BEGIN RSA PRIVATE KEY-----\n${formattedKey}\n-----END RSA PRIVATE KEY-----`;
}
// Ensure the key has proper line breaks after headers and before footers
formattedKey = formattedKey.replace(/(-----BEGIN[^-]+-----)\s*/g, "$1\n").replace(/\s*(-----END[^-]+-----)/g, "\n$1");
// Remove any duplicate newlines
formattedKey = formattedKey.replace(/\n{3,}/g, "\n\n");
return formattedKey;
};
const getChefAuthHeaders = (
method: string,
path: string,
body: string,
userId: string,
privateKey: string,
apiVersion: "1.0" | "1.3" = "1.3"
) => {
const timestamp = new Date().toISOString().replace(/\.\d{3}Z$/, "Z"); // Remove milliseconds from timestamp
// Calculate content hash based on version
let contentHash: string;
if (apiVersion === "1.3") {
contentHash = crypto.createHash("sha256").update(body).digest("base64");
} else {
contentHash = crypto.createHash("sha1").update(body).digest("base64");
}
// Build canonical request based on version
let canonicalRequest: string;
if (apiVersion === "1.3") {
canonicalRequest = [
`Method:${method}`,
`Path:${path}`,
`X-Ops-Content-Hash:${contentHash}`,
"X-Ops-Sign:version=1.3",
`X-Ops-Timestamp:${timestamp}`,
`X-Ops-UserId:${userId}`,
"X-Ops-Server-API-Version:1"
].join("\n");
} else {
const hashedPath = crypto.createHash("sha1").update(path).digest("base64");
canonicalRequest = [
`Method:${method}`,
`Hashed Path:${hashedPath}`,
`X-Ops-Content-Hash:${contentHash}`,
`X-Ops-Timestamp:${timestamp}`,
`X-Ops-UserId:${userId}`
].join("\n");
}
// Format the private key properly
const formattedKey = formatPrivateKey(privateKey);
// Sign the canonical request
const sign = crypto.createSign(apiVersion === "1.3" ? "RSA-SHA256" : "RSA-SHA1");
sign.update(canonicalRequest);
const signature = sign.sign(formattedKey, "base64");
// Split signature into 60-character chunks
const authHeaders: Record<string, string> = {};
const signatureLines = signature.match(/.{1,60}/g) || [];
signatureLines.forEach((line, index) => {
authHeaders[`X-Ops-Authorization-${index + 1}`] = line;
});
return {
Accept: "application/json",
"Content-Type": "application/json",
"X-Chef-Version": "14.0.0",
"X-Ops-Timestamp": timestamp,
"X-Ops-UserId": userId,
"X-Ops-Sign": apiVersion === "1.3" ? "version=1.3" : "algorithm=sha1;version=1.0",
"X-Ops-Content-Hash": contentHash,
...(apiVersion === "1.3" && { "X-Ops-Server-API-Version": "1" }),
...authHeaders
};
};
export const getChefConnectionListItem = () => {
return {
name: "Chef" as const,
app: AppConnection.Chef as const,
methods: Object.values(ChefConnectionMethod) as [ChefConnectionMethod.UserKey]
};
};
export const validateChefConnectionCredentials = async (config: TChefConnectionConfig) => {
const { credentials: inputCredentials } = config;
try {
const path = `/organizations/${inputCredentials.orgName}/users/${inputCredentials.userName}`;
const hostServerUrl = await getChefServerUrl(inputCredentials.serverUrl);
const headers = getChefAuthHeaders("GET", path, "", inputCredentials.userName, inputCredentials.privateKey);
await request.get(`${hostServerUrl}${path}`, {
headers
});
} catch (error: unknown) {
if (error instanceof AxiosError) {
throw new BadRequestError({
message: `Failed to validate Chef credentials: ${error.message || "Unknown error"}`
});
}
throw new BadRequestError({
message: "Unable to validate Chef connection: verify credentials"
});
}
return inputCredentials;
};
export const listChefDataBags = async (appConnection: TChefConnection): Promise<TChefDataBag[]> => {
const {
credentials: { serverUrl, userName, privateKey, orgName }
} = appConnection;
try {
const path = `/organizations/${orgName}/data`;
const body = "";
const hostServerUrl = await getChefServerUrl(serverUrl);
const headers = getChefAuthHeaders("GET", path, body, userName, privateKey);
const res = await request.get<Record<string, string>>(`${hostServerUrl}${path}`, {
headers
});
return Object.keys(res.data).map((name) => ({
name
}));
} catch (error) {
if (error instanceof AxiosError) {
throw new BadRequestError({
message: `Failed to list Chef data bags: ${error.message || "Unknown error"}`
});
}
throw new BadRequestError({
message: "Unable to list Chef data bags"
});
}
};
export const listChefDataBagItems = async (
appConnection: TChefConnection,
dataBagName: string
): Promise<TChefDataBagItem[]> => {
const {
credentials: { serverUrl, userName, privateKey, orgName }
} = appConnection;
try {
const path = `/organizations/${orgName}/data/${dataBagName}`;
const body = "";
const hostServerUrl = await getChefServerUrl(serverUrl);
const headers = getChefAuthHeaders("GET", path, body, userName, privateKey);
const res = await request.get<Record<string, string>>(`${hostServerUrl}${path}`, {
headers
});
return Object.keys(res.data).map((name) => ({
name
}));
} catch (error) {
if (error instanceof AxiosError) {
throw new BadRequestError({
message: `Failed to list Chef data bag items: ${error.message || "Unknown error"}`
});
}
throw new BadRequestError({
message: "Unable to list Chef data bag items"
});
}
};
export const getChefDataBagItem = async ({
serverUrl,
userName,
privateKey,
orgName,
dataBagName,
dataBagItemName
}: TGetChefDataBagItem): Promise<TChefDataBagItemContent> => {
try {
const path = `/organizations/${orgName}/data/${dataBagName}/${dataBagItemName}`;
const body = "";
const hostServerUrl = await getChefServerUrl(serverUrl);
const headers = getChefAuthHeaders("GET", path, body, userName, privateKey);
const res = await request.get<TChefDataBagItemContent>(`${hostServerUrl}${path}`, {
headers
});
return res.data;
} catch (error) {
if (error instanceof AxiosError) {
throw new BadRequestError({
message: `Failed to get Chef data bag item: ${error.message || "Unknown error"}`
});
}
throw new BadRequestError({
message: "Unable to get Chef data bag item"
});
}
};
export const updateChefDataBagItem = async ({
serverUrl,
userName,
privateKey,
orgName,
dataBagName,
dataBagItemName,
data
}: TUpdateChefDataBagItem): Promise<void> => {
try {
const path = `/organizations/${orgName}/data/${dataBagName}/${dataBagItemName}`;
const body = JSON.stringify(data);
const hostServerUrl = await getChefServerUrl(serverUrl);
const headers = getChefAuthHeaders("PUT", path, body, userName, privateKey);
await request.put(`${hostServerUrl}${path}`, data, {
headers
});
} catch (error) {
if (error instanceof AxiosError) {
throw new BadRequestError({
message: `Failed to update Chef data bag item: ${error.message || "Unknown error"}`
});
}
throw new BadRequestError({
message: "Unable to update Chef data bag item"
});
}
};
@@ -1,77 +0,0 @@
import z from "zod";
import { AppConnections } from "@app/lib/api-docs";
import { AppConnection } from "@app/services/app-connection/app-connection-enums";
import {
BaseAppConnectionSchema,
GenericCreateAppConnectionFieldsSchema,
GenericUpdateAppConnectionFieldsSchema
} from "@app/services/app-connection/app-connection-schemas";
import { ChefConnectionMethod } from "./chef-connection-enums";
export const ChefConnectionUserKeyCredentialsSchema = z.object({
serverUrl: z
.string()
.trim()
.url("Valid Chef Server URL required")
.optional()
.describe(AppConnections.CREDENTIALS.CHEF.serverUrl),
orgName: z
.string()
.trim()
.min(1, "Organization name required")
.max(256, "Organization name cannot exceed 256 characters")
.describe(AppConnections.CREDENTIALS.CHEF.orgName),
userName: z
.string()
.trim()
.min(1, "User name required")
.max(256, "User name cannot exceed 256 characters")
.describe(AppConnections.CREDENTIALS.CHEF.userName),
privateKey: z
.string()
.trim()
.min(1, "Private key required")
.max(16384, "Private key cannot exceed 16384 characters")
.describe(AppConnections.CREDENTIALS.CHEF.privateKey)
});
const BaseChefConnectionSchema = BaseAppConnectionSchema.extend({ app: z.literal(AppConnection.Chef) });
export const ChefConnectionSchema = BaseChefConnectionSchema.extend({
method: z.literal(ChefConnectionMethod.UserKey),
credentials: ChefConnectionUserKeyCredentialsSchema
});
export const SanitizedChefConnectionSchema = z.discriminatedUnion("method", [
BaseChefConnectionSchema.extend({
method: z.literal(ChefConnectionMethod.UserKey),
credentials: ChefConnectionUserKeyCredentialsSchema.pick({ serverUrl: true, orgName: true, userName: true })
})
]);
export const ValidateChefConnectionCredentialsSchema = z.discriminatedUnion("method", [
z.object({
method: z.literal(ChefConnectionMethod.UserKey).describe(AppConnections.CREATE(AppConnection.Chef).method),
credentials: ChefConnectionUserKeyCredentialsSchema.describe(AppConnections.CREATE(AppConnection.Chef).credentials)
})
]);
export const CreateChefConnectionSchema = ValidateChefConnectionCredentialsSchema.and(
GenericCreateAppConnectionFieldsSchema(AppConnection.Chef)
);
export const UpdateChefConnectionSchema = z
.object({
credentials: ChefConnectionUserKeyCredentialsSchema.optional().describe(
AppConnections.UPDATE(AppConnection.Chef).credentials
)
})
.and(GenericUpdateAppConnectionFieldsSchema(AppConnection.Chef));
export const ChefConnectionListItemSchema = z.object({
name: z.literal("Chef"),
app: z.literal(AppConnection.Chef),
methods: z.nativeEnum(ChefConnectionMethod).array()
});
@@ -1,39 +0,0 @@
import { ForbiddenRequestError } from "@app/lib/errors";
import { OrgServiceActor } from "@app/lib/types";
import { AppConnection } from "../app-connection-enums";
import { listChefDataBagItems, listChefDataBags } from "./chef-connection-fns";
import { TChefConnection } from "./chef-connection-types";
type TGetAppConnectionFunc = (
app: AppConnection,
connectionId: string,
actor: OrgServiceActor
) => Promise<TChefConnection>;
export const chefConnectionService = (getAppConnection: TGetAppConnectionFunc) => {
const listDataBags = async (appConnectionId: string, actor: OrgServiceActor) => {
const appConnection = await getAppConnection(AppConnection.Chef, appConnectionId, actor);
if (!appConnection) {
throw new ForbiddenRequestError({ message: "App connection not found" });
}
return listChefDataBags(appConnection);
};
const listDataBagItems = async (appConnectionId: string, dataBagName: string, actor: OrgServiceActor) => {
const appConnection = await getAppConnection(AppConnection.Chef, appConnectionId, actor);
if (!appConnection) {
throw new ForbiddenRequestError({ message: "App connection not found" });
}
return listChefDataBagItems(appConnection, dataBagName);
};
return {
listDataBags,
listDataBagItems
};
};
@@ -1,50 +0,0 @@
import z from "zod";
import { DiscriminativePick } from "@app/lib/types";
import { TChefDataBagItemContent } from "@app/services/secret-sync/chef";
import { AppConnection } from "../app-connection-enums";
import {
ChefConnectionSchema,
CreateChefConnectionSchema,
ValidateChefConnectionCredentialsSchema
} from "./chef-connection-schemas";
export type TChefConnection = z.infer<typeof ChefConnectionSchema>;
export type TChefConnectionInput = z.infer<typeof CreateChefConnectionSchema> & {
app: AppConnection.Chef;
};
export type TValidateChefConnectionCredentialsSchema = typeof ValidateChefConnectionCredentialsSchema;
export type TChefConnectionConfig = DiscriminativePick<TChefConnectionInput, "method" | "app" | "credentials"> & {
orgName: string;
};
export type TChefDataBag = {
name: string;
};
export type TChefDataBagItem = {
name: string;
};
export type TGetChefDataBagItem = {
serverUrl?: string;
userName: string;
privateKey: string;
orgName: string;
dataBagName: string;
dataBagItemName: string;
};
export type TUpdateChefDataBagItem = {
serverUrl?: string;
userName: string;
privateKey: string;
orgName: string;
dataBagName: string;
dataBagItemName: string;
data: TChefDataBagItemContent;
};
@@ -1,4 +0,0 @@
export * from "./chef-connection-enums";
export * from "./chef-connection-fns";
export * from "./chef-connection-schemas";
export * from "./chef-connection-types";
+1
View File
@@ -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;
@@ -432,6 +478,7 @@ export const certificateProfileDALFactory = (db: TDbClient) => {
updateById,
deleteById,
findById,
findByIdWithOwnerOrgId,
findByIdWithConfigs,
findBySlugAndProjectId,
findByProjectId,
@@ -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;
},
@@ -10,6 +10,7 @@ import { ForbiddenRequestError, NotFoundError } from "@app/lib/errors";
import { ActorType, AuthMethod } from "../auth/auth-type";
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 +143,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: {
@@ -182,6 +194,7 @@ describe("CertificateProfileService", () => {
certificateTemplateV2DAL: mockCertificateTemplateV2DAL,
apiEnrollmentConfigDAL: mockApiEnrollmentConfigDAL,
estEnrollmentConfigDAL: mockEstEnrollmentConfigDAL,
acmeEnrollmentConfigDAL: mockAcmeEnrollmentConfigDAL,
permissionService: mockPermissionService,
kmsService: mockKmsService,
projectDAL: mockProjectDAL
@@ -234,6 +247,7 @@ describe("CertificateProfileService", () => {
certificateTemplateId: "template-123",
apiConfigId: "api-config-123",
estConfigId: null,
acmeConfigId: null,
projectId: "project-123"
},
undefined
@@ -13,10 +13,10 @@ 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 { TCertificateTemplateV2DALFactory } from "../certificate-template-v2/certificate-template-v2-dal";
import { isCertChainValid } from "../certificate/certificate-fns";
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";
@@ -30,6 +30,38 @@ import {
TCertificateProfileUpdate,
TCertificateProfileWithConfigs
} from "./certificate-profile-types";
import { TAcmeEnrollmentConfigDALFactory } from "../enrollment-config/acme-enrollment-config-dal";
import { buildUrl } from "@app/ee/services/pki-acme/pki-acme-fns";
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,
@@ -95,9 +127,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 +141,7 @@ type TCertificateProfileServiceFactoryDep = {
certificateTemplateV2DAL: TCertificateTemplateV2DALFactory;
apiEnrollmentConfigDAL: TApiEnrollmentConfigDALFactory;
estEnrollmentConfigDAL: TEstEnrollmentConfigDALFactory;
acmeEnrollmentConfigDAL: TAcmeEnrollmentConfigDALFactory;
permissionService: Pick<TPermissionServiceFactory, "getProjectPermission">;
kmsService: Pick<TKmsServiceFactory, "generateKmsKey" | "encryptWithKmsKey" | "decryptWithKmsKey">;
projectDAL: Pick<TProjectDALFactory, "findProjectBySlug" | "findOne" | "updateById" | "findById" | "transaction">;
@@ -124,6 +161,7 @@ export const certificateProfileServiceFactory = ({
certificateTemplateV2DAL,
apiEnrollmentConfigDAL,
estEnrollmentConfigDAL,
acmeEnrollmentConfigDAL,
permissionService,
kmsService,
projectDAL
@@ -188,11 +226,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 +269,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 +485,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 +626,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;
@@ -737,6 +792,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 +854,7 @@ export const certificateProfileServiceFactory = ({
listProfiles,
deleteProfile,
getProfileCertificates,
getEstConfigurationByProfile
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 {
@@ -30,6 +30,7 @@ import {
extractCertificateRequestFromCSR
} from "../certificate-common/certificate-csr-utils";
import { certificateV3ServiceFactory, TCertificateV3ServiceFactory } from "./certificate-v3-service";
import { TPkiAcmeAccountDALFactory } from "@app/ee/services/pki-acme/pki-acme-account-dal";
vi.mock("../certificate-common/certificate-csr-utils", () => ({
extractCertificateRequestFromCSR: vi.fn(),
@@ -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");
@@ -64,12 +64,14 @@ import {
TSignCertificateFromProfileDTO,
TUpdateRenewalConfigDTO
} from "./certificate-v3-types";
import { TPkiAcmeAccountDALFactory } from "@app/ee/services/pki-acme/pki-acme-account-dal";
type TCertificateV3ServiceFactoryDep = {
certificateDAL: Pick<TCertificateDALFactory, "findOne" | "findById" | "updateById" | "transaction">;
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);
@@ -595,6 +615,7 @@ export const certificateV3ServiceFactory = ({
actorAuthMethod,
actorOrgId,
certificateProfileDAL,
acmeAccountDAL,
permissionService,
EnrollmentType.API
);
@@ -1,11 +1,12 @@
import { TProjectPermission } from "@app/lib/types";
import { ACMESANType, CertificateOrderStatus } from "../certificate/certificate-types";
import {
CertExtendedKeyUsageType,
CertKeyUsageType,
CertSubjectAlternativeNameType
} from "../certificate-common/certificate-constants";
import { EnrollmentType } from "../certificate-profile/certificate-profile-types";
import { ACMESANType, CertificateOrderStatus } from "../certificate/certificate-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 = {
@@ -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
};
};
@@ -8,6 +8,11 @@ import {
TPkiEstEnrollmentConfigsInsert,
TPkiEstEnrollmentConfigsUpdate
} from "@app/db/schemas/pki-est-enrollment-configs";
import {
TPkiAcmeEnrollmentConfigs,
TPkiAcmeEnrollmentConfigsInsert,
TPkiAcmeEnrollmentConfigsUpdate
} from "@app/db/schemas/pki-acme-enrollment-configs";
export type TEstEnrollmentConfig = TPkiEstEnrollmentConfigs;
export type TEstEnrollmentConfigInsert = TPkiEstEnrollmentConfigsInsert;
@@ -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 {}
@@ -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 {
@@ -244,8 +244,8 @@ export const identityTokenAuthServiceFactory = ({
}
if (!identityMembershipOrg.identity.authMethods.includes(IdentityAuthMethod.TOKEN_AUTH)) {
throw new BadRequestError({
message: "The identity does not have Token Auth attached"
throw new NotFoundError({
message: "Token Auth configuration not found for identity"
});
}
@@ -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`,
@@ -1605,8 +1605,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 +1634,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 +1672,9 @@ export const projectServiceFactory = ({
isAccessRequestNotificationEnabled,
accessRequestChannels: sanitizedAccessRequestChannels,
isSecretRequestNotificationEnabled,
secretRequestChannels: sanitizedSecretRequestChannels
secretRequestChannels: sanitizedSecretRequestChannels,
isSecretSyncErrorNotificationEnabled,
secretSyncErrorChannels: sanitizedSecretSyncErrorChannels
},
tx
);
@@ -1678,7 +1687,9 @@ export const projectServiceFactory = ({
isAccessRequestNotificationEnabled,
accessRequestChannels: sanitizedAccessRequestChannels,
isSecretRequestNotificationEnabled,
secretRequestChannels: sanitizedSecretRequestChannels
secretRequestChannels: sanitizedSecretRequestChannels,
isSecretSyncErrorNotificationEnabled,
secretSyncErrorChannels: sanitizedSecretSyncErrorChannels
},
tx
);
@@ -1688,6 +1699,7 @@ export const projectServiceFactory = ({
...updatedWorkflowIntegration,
accessRequestChannels: sanitizedAccessRequestChannels,
secretRequestChannels: sanitizedSecretRequestChannels,
secretSyncErrorChannels: sanitizedSecretSyncErrorChannels,
integrationId: slackIntegration.id,
integration: WorkflowIntegration.SLACK
} as const;
@@ -185,8 +185,10 @@ export type TUpdateProjectWorkflowIntegration = (
integration: WorkflowIntegration.SLACK;
isAccessRequestNotificationEnabled: boolean;
isSecretRequestNotificationEnabled: boolean;
isSecretSyncErrorNotificationEnabled: boolean;
accessRequestChannels?: string;
secretRequestChannels?: string;
secretSyncErrorChannels?: string;
}
| {
integrationId: string;
@@ -419,13 +419,14 @@ export const secretFolderDALFactory = (db: TDbClient) => {
.select(
selectAllTableCols(TableName.SecretFolder),
db.raw(
`DENSE_RANK() OVER (ORDER BY ${TableName.SecretFolder}."name" ${
orderDirection ?? OrderByDirection.ASC
}) as rank`
`DENSE_RANK() OVER (ORDER BY ${TableName.SecretFolder}."name" COLLATE "en-x-icu" ${orderDirection === OrderByDirection.ASC ? "ASC" : "DESC"}) as rank`
),
db.ref("slug").withSchema(TableName.Environment).as("environment")
)
.orderBy(`${TableName.SecretFolder}.${orderBy}`, orderDirection);
.orderByRaw(
`${TableName.SecretFolder}.?? COLLATE "en-x-icu" ${orderDirection === OrderByDirection.ASC ? "ASC" : "DESC"}`,
[orderBy]
);
if (limit) {
const rankOffset = offset + 1; // ranks start from 1
@@ -434,7 +435,10 @@ export const secretFolderDALFactory = (db: TDbClient) => {
.select("*")
.from<Awaited<typeof query>[number]>("w")
.where("w.rank", ">=", rankOffset)
.andWhere("w.rank", "<", rankOffset + limit);
.andWhere("w.rank", "<", rankOffset + limit)
.orderByRaw(`"w".?? COLLATE "en-x-icu" ${orderDirection === OrderByDirection.ASC ? "ASC" : "DESC"}`, [
orderBy
]);
}
const folders = await query;
@@ -445,7 +449,10 @@ export const secretFolderDALFactory = (db: TDbClient) => {
}
};
const findByEnvsDeep = async ({ parentIds }: TFindFoldersDeepByParentIdsDTO, tx?: Knex) => {
const findByEnvsDeep = async (
{ parentIds, orderBy = SecretsOrderBy.Name, orderDirection = OrderByDirection.ASC }: TFindFoldersDeepByParentIdsDTO,
tx?: Knex
) => {
try {
const folders = await (tx || db.replicaNode())
.withRecursive("parents", (qb) =>
@@ -480,7 +487,9 @@ export const secretFolderDALFactory = (db: TDbClient) => {
.select<(TSecretFolders & { path: string; depth: number; environment: string })[]>("*")
.from("parents")
.orderBy("depth")
.orderBy(`name`);
.orderByRaw(`"parents".?? COLLATE "en-x-icu" ${orderDirection === OrderByDirection.ASC ? "ASC" : "DESC"}`, [
orderBy
]);
return folders;
} catch (error) {
@@ -14,6 +14,7 @@ import { PgSqlLock } from "@app/keystore/keystore";
import { BadRequestError, NotFoundError } from "@app/lib/errors";
import { OrderByDirection, OrgServiceActor } from "@app/lib/types";
import { ActorType } from "@app/services/auth/auth-type";
import { SecretsOrderBy } from "@app/services/secret/secret-types";
import { buildFolderPath } from "@app/services/secret-folder/secret-folder-fns";
import {
@@ -781,7 +782,11 @@ export const secretFolderServiceFactory = ({
if (!parentFolder) return [];
if (recursive) {
const recursiveFolders = await folderDAL.findByEnvsDeep({ parentIds: [parentFolder.id] });
const recursiveFolders = await folderDAL.findByEnvsDeep({
parentIds: [parentFolder.id],
orderBy: orderBy || SecretsOrderBy.Name,
orderDirection: orderDirection || OrderByDirection.ASC
});
// remove the parent folder
return recursiveFolders
.filter((folder) => {
@@ -800,19 +805,15 @@ export const secretFolderServiceFactory = ({
}));
}
const folders = await folderDAL.find(
{
envId: env.id,
parentId: parentFolder.id,
isReserved: false,
$search: search ? { name: `%${search}%` } : undefined
},
{
sort: orderBy ? [[orderBy, orderDirection ?? OrderByDirection.ASC]] : undefined,
limit,
offset
}
);
const folders = await folderDAL.findByMultiEnv({
environmentIds: [env.id],
parentIds: [parentFolder.id],
search,
orderBy: orderBy || SecretsOrderBy.Name,
orderDirection: orderDirection || OrderByDirection.ASC,
limit,
offset
});
if (lastSecretModified) {
return folders.filter((el) =>
el.lastSecretModified ? el.lastSecretModified >= new Date(lastSecretModified) : false
@@ -64,6 +64,8 @@ export type TGetFoldersDeepByEnvsDTO = {
export type TFindFoldersDeepByParentIdsDTO = {
parentIds: string[];
orderBy?: SecretsOrderBy;
orderDirection?: OrderByDirection;
};
export type TCreateManyFoldersDTO = {
@@ -1,10 +0,0 @@
import { AppConnection } from "@app/services/app-connection/app-connection-enums";
import { SecretSync } from "@app/services/secret-sync/secret-sync-enums";
import { TSecretSyncListItem } from "@app/services/secret-sync/secret-sync-types";
export const CHEF_SYNC_LIST_OPTION: TSecretSyncListItem = {
name: "Chef",
destination: SecretSync.Chef,
connection: AppConnection.Chef,
canImportSecrets: true
};
@@ -1,151 +0,0 @@
import { getChefDataBagItem, updateChefDataBagItem } from "@app/services/app-connection/chef";
import { matchesSchema } from "@app/services/secret-sync/secret-sync-fns";
import { TSecretMap } from "@app/services/secret-sync/secret-sync-types";
import {
ChefSecret,
TChefDataBagItemContent,
TChefSecret,
TChefSecrets,
TChefSyncWithCredentials,
TGetChefSecrets
} from "./chef-sync-types";
const getChefSecretsRaw = async ({
serverUrl,
userName,
privateKey,
orgName,
dataBagName,
dataBagItemName
}: TGetChefSecrets): Promise<TChefDataBagItemContent> => {
const dataBagItem = await getChefDataBagItem({
serverUrl,
userName,
privateKey,
orgName,
dataBagName,
dataBagItemName
});
// Ensure the data bag item has an id field
if (!dataBagItem.id) {
dataBagItem.id = dataBagItemName;
}
return dataBagItem;
};
const getChefSecrets = async (secretSync: TChefSyncWithCredentials): Promise<TChefSecrets> => {
const {
connection,
destinationConfig: { dataBagName, dataBagItemName }
} = secretSync;
const { serverUrl, userName, privateKey, orgName } = connection.credentials;
const dataBagItem = await getChefSecretsRaw({
serverUrl,
orgName,
userName,
privateKey,
dataBagName,
dataBagItemName
});
const { id, ...existingSecrets } = dataBagItem;
// Convert data bag item to key-value pairs
const secrets: ChefSecret[] = [];
Object.entries(existingSecrets).forEach(([key, value]) => {
if (key !== "id" && value !== null && value !== undefined) {
secrets.push({ key, value: String(value) });
}
});
return { id, secrets };
};
const updateChefSecrets = async (
secretSync: TChefSyncWithCredentials,
id: string,
secrets: Record<string, TChefSecret>
) => {
const {
connection,
destinationConfig: { dataBagName, dataBagItemName }
} = secretSync;
const { serverUrl, userName, privateKey, orgName } = connection.credentials;
// Chef data bag items must have an 'id' field
const dataBagItemContent: TChefDataBagItemContent = {
id,
...secrets
};
await updateChefDataBagItem({
serverUrl,
orgName,
userName,
privateKey,
dataBagName,
dataBagItemName,
data: dataBagItemContent
});
};
export const ChefSyncFns = {
async syncSecrets(secretSync: TChefSyncWithCredentials, secretMap: TSecretMap) {
const {
environment,
syncOptions: { disableSecretDeletion, keySchema }
} = secretSync;
const { id, secrets } = await getChefSecrets(secretSync);
// Create a map of the existing secrets
const updatedSecretsMap = new Map(secrets.map((secret) => [secret.key, secret.value]));
// Add/update new secrets
for (const [key, { value }] of Object.entries(secretMap)) {
updatedSecretsMap.set(key, value);
}
// Delete secrets if not disabled
if (!disableSecretDeletion) {
secrets.forEach((secret) => {
if (!matchesSchema(secret.key, environment?.slug || "", keySchema)) return;
if (!secretMap[secret.key]) {
updatedSecretsMap.delete(secret.key);
}
});
}
// Convert map to object for Chef API
const updatedSecrets = Object.fromEntries(updatedSecretsMap.entries());
await updateChefSecrets(secretSync, id, updatedSecrets);
},
async getSecrets(secretSync: TChefSyncWithCredentials): Promise<TSecretMap> {
const { secrets } = await getChefSecrets(secretSync);
return Object.fromEntries(secrets.map((secret) => [secret.key, { value: secret.value }]));
},
async removeSecrets(secretSync: TChefSyncWithCredentials, secretMap: TSecretMap) {
const { id, secrets: existingSecrets } = await getChefSecrets(secretSync);
const newSecrets = existingSecrets.filter((secret) => !Object.hasOwn(secretMap, secret.key));
if (newSecrets.length === existingSecrets.length) {
return;
}
const updatedSecrets = Object.fromEntries(newSecrets.map((secret) => [secret.key, secret.value]));
await updateChefSecrets(secretSync, id, updatedSecrets);
}
};
@@ -1,46 +0,0 @@
import { z } from "zod";
import { SecretSyncs } from "@app/lib/api-docs";
import { AppConnection } from "@app/services/app-connection/app-connection-enums";
import { SecretSync } from "@app/services/secret-sync/secret-sync-enums";
import {
BaseSecretSyncSchema,
GenericCreateSecretSyncFieldsSchema,
GenericUpdateSecretSyncFieldsSchema
} from "@app/services/secret-sync/secret-sync-schemas";
import { TSyncOptionsConfig } from "@app/services/secret-sync/secret-sync-types";
const ChefSyncDestinationConfigSchema = z.object({
dataBagName: z
.string()
.min(1, "Data Bag Name is required")
.max(256, "Data Bag Name cannot exceed 256 characters")
.describe(SecretSyncs.DESTINATION_CONFIG.CHEF.dataBagName),
dataBagItemName: z
.string()
.min(1, "Data Bag Item Name is required")
.max(256, "Data Bag Item Name cannot exceed 256 characters")
.describe(SecretSyncs.DESTINATION_CONFIG.CHEF.dataBagItemName)
});
const ChefSyncOptionsConfig: TSyncOptionsConfig = { canImportSecrets: true };
export const ChefSyncSchema = BaseSecretSyncSchema(SecretSync.Chef, ChefSyncOptionsConfig).extend({
destination: z.literal(SecretSync.Chef),
destinationConfig: ChefSyncDestinationConfigSchema
});
export const CreateChefSyncSchema = GenericCreateSecretSyncFieldsSchema(SecretSync.Chef, ChefSyncOptionsConfig).extend({
destinationConfig: ChefSyncDestinationConfigSchema
});
export const UpdateChefSyncSchema = GenericUpdateSecretSyncFieldsSchema(SecretSync.Chef, ChefSyncOptionsConfig).extend({
destinationConfig: ChefSyncDestinationConfigSchema.optional()
});
export const ChefSyncListItemSchema = z.object({
name: z.literal("Chef"),
connection: z.literal(AppConnection.Chef),
destination: z.literal(SecretSync.Chef),
canImportSecrets: z.literal(true)
});
@@ -1,41 +0,0 @@
import z from "zod";
import { TChefConnection } from "@app/services/app-connection/chef";
import { ChefSyncListItemSchema, ChefSyncSchema, CreateChefSyncSchema } from "./chef-sync-schemas";
export type TChefSyncListItem = z.infer<typeof ChefSyncListItemSchema>;
export type TChefSync = z.infer<typeof ChefSyncSchema>;
export type TChefSyncInput = z.infer<typeof CreateChefSyncSchema>;
export type TChefSyncWithCredentials = TChefSync & {
connection: TChefConnection;
};
export type TGetChefSecrets = {
serverUrl?: string;
userName: string;
privateKey: string;
orgName: string;
dataBagName: string;
dataBagItemName: string;
};
export type TChefSecret = string | number | boolean | null;
export type TChefDataBagItemContent = {
id: string;
[key: string]: TChefSecret;
};
export type TChefSecrets = {
id: string;
secrets: ChefSecret[];
};
export type ChefSecret = {
key: string;
value: string;
};
@@ -1,4 +0,0 @@
export * from "./chef-sync-constants";
export * from "./chef-sync-fns";
export * from "./chef-sync-schemas";
export * from "./chef-sync-types";
@@ -4,6 +4,7 @@ import handlebars from "handlebars";
import { TGatewayServiceFactory } from "@app/ee/services/gateway/gateway-service";
import { TGatewayV2ServiceFactory } from "@app/ee/services/gateway-v2/gateway-v2-service";
import { TLicenseServiceFactory } from "@app/ee/services/license/license-service";
import { CHEF_SYNC_LIST_OPTION, ChefSyncFns } from "@app/ee/services/secret-sync/chef";
import { OCI_VAULT_SYNC_LIST_OPTION, OCIVaultSyncFns } from "@app/ee/services/secret-sync/oci-vault";
import { BadRequestError } from "@app/lib/errors";
import {
@@ -34,7 +35,6 @@ import { BITBUCKET_SYNC_LIST_OPTION, BitbucketSyncFns } from "./bitbucket";
import { CAMUNDA_SYNC_LIST_OPTION, camundaSyncFactory } from "./camunda";
import { CHECKLY_SYNC_LIST_OPTION } from "./checkly/checkly-sync-constants";
import { ChecklySyncFns } from "./checkly/checkly-sync-fns";
import { CHEF_SYNC_LIST_OPTION, ChefSyncFns } from "./chef";
import { CLOUDFLARE_PAGES_SYNC_LIST_OPTION } from "./cloudflare-pages/cloudflare-pages-constants";
import { CloudflarePagesSyncFns } from "./cloudflare-pages/cloudflare-pages-fns";
import { CLOUDFLARE_WORKERS_SYNC_LIST_OPTION, CloudflareWorkersSyncFns } from "./cloudflare-workers";
@@ -107,7 +107,7 @@ export const SECRET_SYNC_PLAN_MAP: Record<SecretSync, SecretSyncPlanType> = {
[SecretSync.Northflank]: SecretSyncPlanType.Regular,
[SecretSync.Bitbucket]: SecretSyncPlanType.Regular,
[SecretSync.LaravelForge]: SecretSyncPlanType.Regular,
[SecretSync.Chef]: SecretSyncPlanType.Regular
[SecretSync.Chef]: SecretSyncPlanType.Enterprise
};
export const SECRET_SYNC_SKIP_FIELDS_MAP: Record<SecretSync, 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 ({
@@ -1,6 +1,12 @@
import { Job } from "bullmq";
import { AuditLogInfo } from "@app/ee/services/audit-log/audit-log-types";
import {
TChefSync,
TChefSyncInput,
TChefSyncListItem,
TChefSyncWithCredentials
} from "@app/ee/services/secret-sync/chef";
import {
TOCIVaultSync,
TOCIVaultSyncInput,
@@ -21,7 +27,6 @@ import {
TCamundaSyncListItem,
TCamundaSyncWithCredentials
} from "@app/services/secret-sync/camunda";
import { TChefSync, TChefSyncInput, TChefSyncListItem, TChefSyncWithCredentials } from "@app/services/secret-sync/chef";
import {
TDatabricksSync,
TDatabricksSyncInput,
@@ -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`,
+146 -38
View File
@@ -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));
}