Merge branch 'main' into feature/slack-secret-sync-error-notification

This commit is contained in:
Victor Santos
2025-11-07 10:03:56 -03:00
419 changed files with 7889 additions and 10146 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";
@@ -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"
});
}
@@ -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[]> = {
@@ -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: [
{