Merge pull request #3806 from Infisical/feat/addHerokuSecretSync

feat(secret-sync): Add Heroku Secret Sync
This commit is contained in:
carlosmonastyrski
2025-06-19 15:28:56 -03:00
committed by GitHub
86 changed files with 1724 additions and 18 deletions
+4
View File
@@ -2390,6 +2390,10 @@ export const SecretSyncs = {
ONEPASS: {
vaultId: "The ID of the 1Password vault to sync secrets to."
},
HEROKU: {
app: "The ID of the Heroku app to sync secrets to.",
appName: "The name of the Heroku app to sync secrets to."
},
RENDER: {
serviceId: "The ID of the Render service to sync secrets to.",
scope: "The Render scope that secrets should be synced to.",
@@ -50,6 +50,7 @@ import {
HCVaultConnectionListItemSchema,
SanitizedHCVaultConnectionSchema
} from "@app/services/app-connection/hc-vault";
import { HerokuConnectionListItemSchema, SanitizedHerokuConnectionSchema } from "@app/services/app-connection/heroku";
import {
HumanitecConnectionListItemSchema,
SanitizedHumanitecConnectionSchema
@@ -106,6 +107,7 @@ const SanitizedAppConnectionSchema = z.union([
...SanitizedOCIConnectionSchema.options,
...SanitizedOracleDBConnectionSchema.options,
...SanitizedOnePassConnectionSchema.options,
...SanitizedHerokuConnectionSchema.options,
...SanitizedRenderConnectionSchema.options,
...SanitizedFlyioConnectionSchema.options
]);
@@ -135,6 +137,7 @@ const AppConnectionOptionsSchema = z.discriminatedUnion("app", [
OCIConnectionListItemSchema,
OracleDBConnectionListItemSchema,
OnePassConnectionListItemSchema,
HerokuConnectionListItemSchema,
RenderConnectionListItemSchema,
FlyioConnectionListItemSchema
]);
@@ -0,0 +1,54 @@
import z from "zod";
import { readLimit } from "@app/server/config/rateLimiter";
import { verifyAuth } from "@app/server/plugins/auth/verify-auth";
import { AppConnection } from "@app/services/app-connection/app-connection-enums";
import {
CreateHerokuConnectionSchema,
SanitizedHerokuConnectionSchema,
THerokuApp,
UpdateHerokuConnectionSchema
} from "@app/services/app-connection/heroku";
import { AuthMode } from "@app/services/auth/auth-type";
import { registerAppConnectionEndpoints } from "./app-connection-endpoints";
export const registerHerokuConnectionRouter = async (server: FastifyZodProvider) => {
registerAppConnectionEndpoints({
app: AppConnection.Heroku,
server,
sanitizedResponseSchema: SanitizedHerokuConnectionSchema,
createSchema: CreateHerokuConnectionSchema,
updateSchema: UpdateHerokuConnectionSchema
});
// The below endpoints are not exposed and for Infisical App use
server.route({
method: "GET",
url: `/:connectionId/apps`,
config: {
rateLimit: readLimit
},
schema: {
params: z.object({
connectionId: z.string().uuid()
}),
response: {
200: z
.object({
id: z.string(),
name: z.string()
})
.array()
}
},
onRequest: verifyAuth([AuthMode.JWT]),
handler: async (req) => {
const { connectionId } = req.params;
const apps: THerokuApp[] = await server.services.appConnection.heroku.listApps(connectionId, req.permission);
return apps;
}
});
};
@@ -16,6 +16,7 @@ import { registerGcpConnectionRouter } from "./gcp-connection-router";
import { registerGitHubConnectionRouter } from "./github-connection-router";
import { registerGitHubRadarConnectionRouter } from "./github-radar-connection-router";
import { registerHCVaultConnectionRouter } from "./hc-vault-connection-router";
import { registerHerokuConnectionRouter } from "./heroku-connection-router";
import { registerHumanitecConnectionRouter } from "./humanitec-connection-router";
import { registerLdapConnectionRouter } from "./ldap-connection-router";
import { registerMsSqlConnectionRouter } from "./mssql-connection-router";
@@ -55,6 +56,7 @@ export const APP_CONNECTION_REGISTER_ROUTER_MAP: Record<AppConnection, (server:
[AppConnection.OCI]: registerOCIConnectionRouter,
[AppConnection.OracleDB]: registerOracleDBConnectionRouter,
[AppConnection.OnePass]: registerOnePassConnectionRouter,
[AppConnection.Heroku]: registerHerokuConnectionRouter,
[AppConnection.Render]: registerRenderConnectionRouter,
[AppConnection.Flyio]: registerFlyioConnectionRouter
};
@@ -0,0 +1,13 @@
import { CreateHerokuSyncSchema, HerokuSyncSchema, UpdateHerokuSyncSchema } from "@app/services/secret-sync/heroku";
import { SecretSync } from "@app/services/secret-sync/secret-sync-enums";
import { registerSyncSecretsEndpoints } from "./secret-sync-endpoints";
export const registerHerokuSyncRouter = async (server: FastifyZodProvider) =>
registerSyncSecretsEndpoints({
destination: SecretSync.Heroku,
server,
responseSchema: HerokuSyncSchema,
createSchema: CreateHerokuSyncSchema,
updateSchema: UpdateHerokuSyncSchema
});
@@ -13,6 +13,7 @@ import { registerFlyioSyncRouter } from "./flyio-sync-router";
import { registerGcpSyncRouter } from "./gcp-sync-router";
import { registerGitHubSyncRouter } from "./github-sync-router";
import { registerHCVaultSyncRouter } from "./hc-vault-sync-router";
import { registerHerokuSyncRouter } from "./heroku-sync-router";
import { registerHumanitecSyncRouter } from "./humanitec-sync-router";
import { registerRenderSyncRouter } from "./render-sync-router";
import { registerTeamCitySyncRouter } from "./teamcity-sync-router";
@@ -40,6 +41,7 @@ export const SECRET_SYNC_REGISTER_ROUTER_MAP: Record<SecretSync, (server: Fastif
[SecretSync.TeamCity]: registerTeamCitySyncRouter,
[SecretSync.OCIVault]: registerOCIVaultSyncRouter,
[SecretSync.OnePass]: registerOnePassSyncRouter,
[SecretSync.Heroku]: registerHerokuSyncRouter,
[SecretSync.Render]: registerRenderSyncRouter,
[SecretSync.Flyio]: registerFlyioSyncRouter
};
@@ -27,6 +27,7 @@ import { FlyioSyncListItemSchema, FlyioSyncSchema } from "@app/services/secret-s
import { GcpSyncListItemSchema, GcpSyncSchema } from "@app/services/secret-sync/gcp";
import { GitHubSyncListItemSchema, GitHubSyncSchema } from "@app/services/secret-sync/github";
import { HCVaultSyncListItemSchema, HCVaultSyncSchema } from "@app/services/secret-sync/hc-vault";
import { HerokuSyncListItemSchema, HerokuSyncSchema } from "@app/services/secret-sync/heroku";
import { HumanitecSyncListItemSchema, HumanitecSyncSchema } from "@app/services/secret-sync/humanitec";
import { RenderSyncListItemSchema, RenderSyncSchema } from "@app/services/secret-sync/render/render-sync-schemas";
import { TeamCitySyncListItemSchema, TeamCitySyncSchema } from "@app/services/secret-sync/teamcity";
@@ -52,6 +53,7 @@ const SecretSyncSchema = z.discriminatedUnion("destination", [
TeamCitySyncSchema,
OCIVaultSyncSchema,
OnePassSyncSchema,
HerokuSyncSchema,
RenderSyncSchema,
FlyioSyncSchema
]);
@@ -74,6 +76,7 @@ const SecretSyncOptionsSchema = z.discriminatedUnion("destination", [
TeamCitySyncListItemSchema,
OCIVaultSyncListItemSchema,
OnePassSyncListItemSchema,
HerokuSyncListItemSchema,
RenderSyncListItemSchema,
FlyioSyncListItemSchema
]);
@@ -23,6 +23,7 @@ export enum AppConnection {
OCI = "oci",
OracleDB = "oracledb",
OnePass = "1password",
Heroku = "heroku",
Render = "render",
Flyio = "flyio"
}
@@ -69,6 +69,7 @@ import {
HCVaultConnectionMethod,
validateHCVaultConnectionCredentials
} from "./hc-vault";
import { getHerokuConnectionListItem, HerokuConnectionMethod, validateHerokuConnectionCredentials } from "./heroku";
import {
getHumanitecConnectionListItem,
HumanitecConnectionMethod,
@@ -125,6 +126,7 @@ export const listAppConnectionOptions = () => {
getOCIConnectionListItem(),
getOracleDBConnectionListItem(),
getOnePassConnectionListItem(),
getHerokuConnectionListItem(),
getRenderConnectionListItem(),
getFlyioConnectionListItem()
].sort((a, b) => a.name.localeCompare(b.name));
@@ -202,6 +204,7 @@ export const validateAppConnectionCredentials = async (
[AppConnection.OCI]: validateOCIConnectionCredentials as TAppConnectionCredentialsValidator,
[AppConnection.OracleDB]: validateSqlConnectionCredentials as TAppConnectionCredentialsValidator,
[AppConnection.OnePass]: validateOnePassConnectionCredentials as TAppConnectionCredentialsValidator,
[AppConnection.Heroku]: validateHerokuConnectionCredentials as TAppConnectionCredentialsValidator,
[AppConnection.Render]: validateRenderConnectionCredentials as TAppConnectionCredentialsValidator,
[AppConnection.Flyio]: validateFlyioConnectionCredentials as TAppConnectionCredentialsValidator
};
@@ -219,7 +222,10 @@ export const getAppConnectionMethodName = (method: TAppConnection["method"]) =>
case AzureClientSecretsConnectionMethod.OAuth:
case GitHubConnectionMethod.OAuth:
case AzureDevOpsConnectionMethod.OAuth:
case HerokuConnectionMethod.OAuth:
return "OAuth";
case HerokuConnectionMethod.AuthToken:
return "Auth Token";
case AwsConnectionMethod.AccessKey:
case OCIConnectionMethod.AccessKey:
return "Access Key";
@@ -310,6 +316,7 @@ export const TRANSITION_CONNECTION_CREDENTIALS_TO_PLATFORM: Record<
[AppConnection.OCI]: platformManagedCredentialsNotSupported,
[AppConnection.OracleDB]: transferSqlConnectionCredentialsToPlatform as TAppConnectionTransitionCredentialsToPlatform,
[AppConnection.OnePass]: platformManagedCredentialsNotSupported,
[AppConnection.Heroku]: platformManagedCredentialsNotSupported,
[AppConnection.Render]: platformManagedCredentialsNotSupported,
[AppConnection.Flyio]: platformManagedCredentialsNotSupported
};
@@ -25,6 +25,7 @@ export const APP_CONNECTION_NAME_MAP: Record<AppConnection, string> = {
[AppConnection.OCI]: "OCI",
[AppConnection.OracleDB]: "OracleDB",
[AppConnection.OnePass]: "1Password",
[AppConnection.Heroku]: "Heroku",
[AppConnection.Render]: "Render",
[AppConnection.Flyio]: "Fly.io"
};
@@ -54,6 +55,7 @@ export const APP_CONNECTION_PLAN_MAP: Record<AppConnection, AppConnectionPlanTyp
[AppConnection.OracleDB]: AppConnectionPlanType.Enterprise,
[AppConnection.OnePass]: AppConnectionPlanType.Regular,
[AppConnection.MySql]: AppConnectionPlanType.Regular,
[AppConnection.Heroku]: AppConnectionPlanType.Regular,
[AppConnection.Render]: AppConnectionPlanType.Regular,
[AppConnection.Flyio]: AppConnectionPlanType.Regular
};
@@ -58,6 +58,8 @@ import { githubConnectionService } from "./github/github-connection-service";
import { ValidateGitHubRadarConnectionCredentialsSchema } from "./github-radar";
import { ValidateHCVaultConnectionCredentialsSchema } from "./hc-vault";
import { hcVaultConnectionService } from "./hc-vault/hc-vault-connection-service";
import { ValidateHerokuConnectionCredentialsSchema } from "./heroku";
import { herokuConnectionService } from "./heroku/heroku-connection-service";
import { ValidateHumanitecConnectionCredentialsSchema } from "./humanitec";
import { humanitecConnectionService } from "./humanitec/humanitec-connection-service";
import { ValidateLdapConnectionCredentialsSchema } from "./ldap";
@@ -109,6 +111,7 @@ const VALIDATE_APP_CONNECTION_CREDENTIALS_MAP: Record<AppConnection, TValidateAp
[AppConnection.OCI]: ValidateOCIConnectionCredentialsSchema,
[AppConnection.OracleDB]: ValidateOracleDBConnectionCredentialsSchema,
[AppConnection.OnePass]: ValidateOnePassConnectionCredentialsSchema,
[AppConnection.Heroku]: ValidateHerokuConnectionCredentialsSchema,
[AppConnection.Render]: ValidateRenderConnectionCredentialsSchema,
[AppConnection.Flyio]: ValidateFlyioConnectionCredentialsSchema
};
@@ -516,6 +519,7 @@ export const appConnectionServiceFactory = ({
teamcity: teamcityConnectionService(connectAppConnectionById),
oci: ociConnectionService(connectAppConnectionById, licenseService),
onepass: onePassConnectionService(connectAppConnectionById),
heroku: herokuConnectionService(connectAppConnectionById, appConnectionDAL, kmsService),
render: renderConnectionService(connectAppConnectionById),
flyio: flyioConnectionService(connectAppConnectionById)
};
@@ -98,6 +98,12 @@ import {
THCVaultConnectionInput,
TValidateHCVaultConnectionCredentialsSchema
} from "./hc-vault";
import {
THerokuConnection,
THerokuConnectionConfig,
THerokuConnectionInput,
TValidateHerokuConnectionCredentialsSchema
} from "./heroku";
import {
THumanitecConnection,
THumanitecConnectionConfig,
@@ -173,6 +179,7 @@ export type TAppConnection = { id: string } & (
| TOCIConnection
| TOracleDBConnection
| TOnePassConnection
| THerokuConnection
| TRenderConnection
| TFlyioConnection
);
@@ -206,6 +213,7 @@ export type TAppConnectionInput = { id: string } & (
| TOCIConnectionInput
| TOracleDBConnectionInput
| TOnePassConnectionInput
| THerokuConnectionInput
| TRenderConnectionInput
| TFlyioConnectionInput
);
@@ -247,6 +255,7 @@ export type TAppConnectionConfig =
| TTeamCityConnectionConfig
| TOCIConnectionConfig
| TOnePassConnectionConfig
| THerokuConnectionConfig
| TRenderConnectionConfig
| TFlyioConnectionConfig;
@@ -275,6 +284,7 @@ export type TValidateAppConnectionCredentialsSchema =
| TValidateOCIConnectionCredentialsSchema
| TValidateOracleDBConnectionCredentialsSchema
| TValidateOnePassConnectionCredentialsSchema
| TValidateHerokuConnectionCredentialsSchema
| TValidateRenderConnectionCredentialsSchema
| TValidateFlyioConnectionCredentialsSchema;
@@ -0,0 +1,4 @@
export enum HerokuConnectionMethod {
AuthToken = "auth-token",
OAuth = "oauth"
}
@@ -0,0 +1,208 @@
import { AxiosError, AxiosResponse } from "axios";
import { getConfig } from "@app/lib/config/env";
import { request } from "@app/lib/config/request";
import { BadRequestError, InternalServerError } from "@app/lib/errors";
import { AppConnection } from "@app/services/app-connection/app-connection-enums";
import { encryptAppConnectionCredentials } from "@app/services/app-connection/app-connection-fns";
import { IntegrationUrls } from "@app/services/integration-auth/integration-list";
import { TKmsServiceFactory } from "@app/services/kms/kms-service";
import { TAppConnectionDALFactory } from "../app-connection-dal";
import { HerokuConnectionMethod } from "./heroku-connection-enums";
import { THerokuApp, THerokuConnection, THerokuConnectionConfig } from "./heroku-connection-types";
interface HerokuOAuthTokenResponse {
access_token: string;
expires_in: number;
refresh_token: string;
token_type: string;
user_id: string;
session_nonce: string;
}
export const getHerokuConnectionListItem = () => {
const { CLIENT_ID_HEROKU } = getConfig();
return {
name: "Heroku" as const,
app: AppConnection.Heroku as const,
methods: Object.values(HerokuConnectionMethod) as [HerokuConnectionMethod.AuthToken, HerokuConnectionMethod.OAuth],
oauthClientId: CLIENT_ID_HEROKU
};
};
export const refreshHerokuToken = async (
refreshToken: string,
appId: string,
orgId: string,
appConnectionDAL: Pick<TAppConnectionDALFactory, "updateById">,
kmsService: Pick<TKmsServiceFactory, "createCipherPairWithDataKey">
): Promise<string> => {
const { CLIENT_SECRET_HEROKU } = getConfig();
const payload = {
grant_type: "refresh_token",
refresh_token: refreshToken,
client_secret: CLIENT_SECRET_HEROKU
};
const { data } = await request.post<{ access_token: string; expires_in: number }>(
IntegrationUrls.HEROKU_TOKEN_URL,
payload,
{
headers: {
"Content-Type": "application/x-www-form-urlencoded"
}
}
);
const encryptedCredentials = await encryptAppConnectionCredentials({
credentials: {
refreshToken,
authToken: data.access_token,
expiresAt: new Date(Date.now() + data.expires_in * 1000 - 60000)
},
orgId,
kmsService
});
await appConnectionDAL.updateById(appId, { encryptedCredentials });
return data.access_token;
};
export const exchangeHerokuOAuthCode = async (code: string): Promise<HerokuOAuthTokenResponse> => {
const { CLIENT_SECRET_HEROKU } = getConfig();
try {
const response = await request.post<HerokuOAuthTokenResponse>(
IntegrationUrls.HEROKU_TOKEN_URL,
{
grant_type: "authorization_code",
code,
client_secret: CLIENT_SECRET_HEROKU
},
{
headers: {
"Content-Type": "application/x-www-form-urlencoded"
}
}
);
if (!response.data) {
throw new InternalServerError({
message: "Failed to exchange OAuth code: Empty response"
});
}
return response.data;
} catch (error: unknown) {
if (error instanceof AxiosError) {
throw new BadRequestError({
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
message: `Failed to exchange OAuth code: ${error.response?.data?.message || error.message || "Unknown error"}`
});
}
throw new BadRequestError({
message: "Unable to exchange OAuth code"
});
}
};
export const validateHerokuConnectionCredentials = async (config: THerokuConnectionConfig) => {
const { credentials: inputCredentials, method } = config;
let authToken: string;
let oauthData: HerokuOAuthTokenResponse | null = null;
if (method === HerokuConnectionMethod.OAuth && "code" in inputCredentials) {
oauthData = await exchangeHerokuOAuthCode(inputCredentials.code);
authToken = oauthData.access_token;
} else if (method === HerokuConnectionMethod.AuthToken && "authToken" in inputCredentials) {
authToken = inputCredentials.authToken;
} else {
throw new BadRequestError({
message: "Invalid credentials for the selected connection method"
});
}
let response: AxiosResponse<THerokuApp[]> | null = null;
try {
response = await request.get<THerokuApp[]>(`${IntegrationUrls.HEROKU_API_URL}/apps`, {
headers: {
Authorization: `Bearer ${authToken}`,
Accept: "application/vnd.heroku+json; version=3"
}
});
} catch (error: unknown) {
if (error instanceof AxiosError) {
throw new BadRequestError({
message: `Failed to validate credentials: ${error.message || "Unknown error"}`
});
}
throw new BadRequestError({
message: "Unable to validate connection: verify credentials"
});
}
if (!response?.data) {
throw new InternalServerError({
message: "Failed to get apps: Response was empty"
});
}
if (method === HerokuConnectionMethod.OAuth && oauthData) {
return {
authToken,
refreshToken: oauthData.refresh_token,
expiresIn: oauthData.expires_in,
tokenType: oauthData.token_type,
userId: oauthData.user_id,
sessionNonce: oauthData.session_nonce
};
}
return inputCredentials;
};
export const listHerokuApps = async ({
appConnection,
appConnectionDAL,
kmsService
}: {
appConnection: THerokuConnection;
appConnectionDAL: Pick<TAppConnectionDALFactory, "updateById">;
kmsService: Pick<TKmsServiceFactory, "createCipherPairWithDataKey">;
}): Promise<THerokuApp[]> => {
let authCredential = appConnection.credentials.authToken;
if (
appConnection.method === HerokuConnectionMethod.OAuth &&
appConnection.credentials.refreshToken &&
appConnection.credentials.expiresAt < new Date()
) {
authCredential = await refreshHerokuToken(
appConnection.credentials.refreshToken,
appConnection.id,
appConnection.orgId,
appConnectionDAL,
kmsService
);
}
const { data } = await request.get<THerokuApp[]>(`${IntegrationUrls.HEROKU_API_URL}/apps`, {
headers: {
Authorization: `Bearer ${authCredential}`,
Accept: "application/vnd.heroku+json; version=3"
}
});
if (!data) {
throw new InternalServerError({
message: "Failed to get apps: Response was empty"
});
}
return data.map((res) => ({ name: res.name, id: res.id }));
};
@@ -0,0 +1,103 @@
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 { HerokuConnectionMethod } from "./heroku-connection-enums";
export const HerokuConnectionAuthTokenCredentialsSchema = z.object({
authToken: z.string().trim().min(1, "Auth Token required").startsWith("HRKU-", "Token must start with 'HRKU-")
});
export const HerokuConnectionOAuthCredentialsSchema = z.object({
code: z.string().trim().min(1, "OAuth code required")
});
export const HerokuConnectionOAuthOutputCredentialsSchema = z.object({
authToken: z.string().trim(),
refreshToken: z.string().trim(),
expiresAt: z.date()
});
// Schema for refresh token input during initial setup
export const HerokuConnectionRefreshTokenCredentialsSchema = z.object({
refreshToken: z.string().trim().min(1, "Refresh token required")
});
const BaseHerokuConnectionSchema = BaseAppConnectionSchema.extend({
app: z.literal(AppConnection.Heroku)
});
export const HerokuConnectionSchema = z.intersection(
BaseHerokuConnectionSchema,
z.discriminatedUnion("method", [
z.object({
method: z.literal(HerokuConnectionMethod.AuthToken),
credentials: HerokuConnectionAuthTokenCredentialsSchema
}),
z.object({
method: z.literal(HerokuConnectionMethod.OAuth),
credentials: HerokuConnectionOAuthOutputCredentialsSchema
})
])
);
export const SanitizedHerokuConnectionSchema = z.discriminatedUnion("method", [
BaseHerokuConnectionSchema.extend({
method: z.literal(HerokuConnectionMethod.AuthToken),
credentials: HerokuConnectionAuthTokenCredentialsSchema.pick({})
}),
BaseHerokuConnectionSchema.extend({
method: z.literal(HerokuConnectionMethod.OAuth),
credentials: HerokuConnectionOAuthOutputCredentialsSchema.pick({})
})
]);
export const ValidateHerokuConnectionCredentialsSchema = z.discriminatedUnion("method", [
z.object({
method: z.literal(HerokuConnectionMethod.AuthToken).describe(AppConnections.CREATE(AppConnection.Heroku).method),
credentials: HerokuConnectionAuthTokenCredentialsSchema.describe(
AppConnections.CREATE(AppConnection.Heroku).credentials
)
}),
z.object({
method: z.literal(HerokuConnectionMethod.OAuth).describe(AppConnections.CREATE(AppConnection.Heroku).method),
credentials: z
.union([
HerokuConnectionOAuthCredentialsSchema,
HerokuConnectionRefreshTokenCredentialsSchema,
HerokuConnectionOAuthOutputCredentialsSchema
])
.describe(AppConnections.CREATE(AppConnection.Heroku).credentials)
})
]);
export const CreateHerokuConnectionSchema = ValidateHerokuConnectionCredentialsSchema.and(
GenericCreateAppConnectionFieldsSchema(AppConnection.Heroku)
);
export const UpdateHerokuConnectionSchema = z
.object({
credentials: z
.union([
HerokuConnectionAuthTokenCredentialsSchema,
HerokuConnectionOAuthOutputCredentialsSchema,
HerokuConnectionRefreshTokenCredentialsSchema,
HerokuConnectionOAuthCredentialsSchema
])
.optional()
.describe(AppConnections.UPDATE(AppConnection.Heroku).credentials)
})
.and(GenericUpdateAppConnectionFieldsSchema(AppConnection.Heroku));
export const HerokuConnectionListItemSchema = z.object({
name: z.literal("Heroku"),
app: z.literal(AppConnection.Heroku),
methods: z.nativeEnum(HerokuConnectionMethod).array(),
oauthClientId: z.string().optional()
});
@@ -0,0 +1,35 @@
import { logger } from "@app/lib/logger";
import { OrgServiceActor } from "@app/lib/types";
import { TKmsServiceFactory } from "@app/services/kms/kms-service";
import { TAppConnectionDALFactory } from "../app-connection-dal";
import { AppConnection } from "../app-connection-enums";
import { listHerokuApps as getHerokuApps } from "./heroku-connection-fns";
import { THerokuConnection } from "./heroku-connection-types";
type TGetAppConnectionFunc = (
app: AppConnection,
connectionId: string,
actor: OrgServiceActor
) => Promise<THerokuConnection>;
export const herokuConnectionService = (
getAppConnection: TGetAppConnectionFunc,
appConnectionDAL: Pick<TAppConnectionDALFactory, "updateById">,
kmsService: Pick<TKmsServiceFactory, "createCipherPairWithDataKey">
) => {
const listApps = async (connectionId: string, actor: OrgServiceActor) => {
const appConnection = await getAppConnection(AppConnection.Heroku, connectionId, actor);
try {
const apps = await getHerokuApps({ appConnection, appConnectionDAL, kmsService });
return apps;
} catch (error) {
logger.error(error, `Failed to establish connection with Heroku for app ${connectionId}`);
return [];
}
};
return {
listApps
};
};
@@ -0,0 +1,27 @@
import z from "zod";
import { DiscriminativePick } from "@app/lib/types";
import { AppConnection } from "../app-connection-enums";
import {
CreateHerokuConnectionSchema,
HerokuConnectionSchema,
ValidateHerokuConnectionCredentialsSchema
} from "./heroku-connection-schemas";
export type THerokuConnection = z.infer<typeof HerokuConnectionSchema>;
export type THerokuConnectionInput = z.infer<typeof CreateHerokuConnectionSchema> & {
app: AppConnection.Heroku;
};
export type TValidateHerokuConnectionCredentialsSchema = typeof ValidateHerokuConnectionCredentialsSchema;
export type THerokuConnectionConfig = DiscriminativePick<THerokuConnectionInput, "method" | "app" | "credentials"> & {
orgId: string;
};
export type THerokuApp = {
name: string;
id: string;
};
@@ -0,0 +1,4 @@
export * from "./heroku-connection-enums";
export * from "./heroku-connection-fns";
export * from "./heroku-connection-schemas";
export * from "./heroku-connection-types";
@@ -0,0 +1,10 @@
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 HEROKU_SYNC_LIST_OPTION: TSecretSyncListItem = {
name: "Heroku",
destination: SecretSync.Heroku,
connection: AppConnection.Heroku,
canImportSecrets: true
};
@@ -0,0 +1,170 @@
import { request } from "@app/lib/config/request";
import { TAppConnectionDALFactory } from "@app/services/app-connection/app-connection-dal";
import { HerokuConnectionMethod, refreshHerokuToken, THerokuConnection } from "@app/services/app-connection/heroku";
import { IntegrationUrls } from "@app/services/integration-auth/integration-list";
import { TKmsServiceFactory } from "@app/services/kms/kms-service";
import {
THerokuConfigVars,
THerokuListVariables,
THerokuSyncWithCredentials,
THerokuUpdateVariables
} from "@app/services/secret-sync/heroku/heroku-sync-types";
import { SecretSyncError } from "@app/services/secret-sync/secret-sync-errors";
import { matchesSchema } from "@app/services/secret-sync/secret-sync-fns";
import { TSecretMap } from "@app/services/secret-sync/secret-sync-types";
type THerokuSyncFactoryDeps = {
appConnectionDAL: Pick<TAppConnectionDALFactory, "updateById">;
kmsService: Pick<TKmsServiceFactory, "createCipherPairWithDataKey">;
};
const getValidAuthToken = async (
connection: THerokuConnection,
appConnectionDAL: Pick<TAppConnectionDALFactory, "updateById">,
kmsService: Pick<TKmsServiceFactory, "createCipherPairWithDataKey">
): Promise<string> => {
if (
connection.method === HerokuConnectionMethod.OAuth &&
connection.credentials.refreshToken &&
connection.credentials.expiresAt < new Date()
) {
const authToken = await refreshHerokuToken(
connection.credentials.refreshToken,
connection.id,
connection.orgId,
appConnectionDAL,
kmsService
);
return authToken;
}
return connection.credentials.authToken;
};
const getHerokuConfigVars = async ({ authToken, app }: THerokuListVariables): Promise<THerokuConfigVars> => {
const { data } = await request.get<THerokuConfigVars>(
`${IntegrationUrls.HEROKU_API_URL}/apps/${encodeURIComponent(app)}/config-vars`,
{
headers: {
Authorization: `Bearer ${authToken}`,
Accept: "application/vnd.heroku+json; version=3"
}
}
);
return data;
};
const updateHerokuConfigVars = async ({ authToken, app, configVars }: THerokuUpdateVariables) => {
return request.patch(`${IntegrationUrls.HEROKU_API_URL}/apps/${encodeURIComponent(app)}/config-vars`, configVars, {
headers: {
Authorization: `Bearer ${authToken}`,
Accept: "application/vnd.heroku+json; version=3",
"Content-Type": "application/json"
}
});
};
export const HerokuSyncFns = {
syncSecrets: async (
secretSync: THerokuSyncWithCredentials,
secretMap: TSecretMap,
{ appConnectionDAL, kmsService }: THerokuSyncFactoryDeps
) => {
const {
connection,
environment,
destinationConfig: { app }
} = secretSync;
const authToken = await getValidAuthToken(connection, appConnectionDAL, kmsService);
try {
const updatedConfigVars: THerokuConfigVars = {};
for (const [key, { value }] of Object.entries(secretMap)) {
updatedConfigVars[key] = value;
}
if (!secretSync.syncOptions.disableSecretDeletion) {
const currentConfigVars = await getHerokuConfigVars({ authToken, app });
for (const key of Object.keys(currentConfigVars)) {
if (matchesSchema(key, environment?.slug || "", secretSync.syncOptions.keySchema) && !(key in secretMap)) {
updatedConfigVars[key] = null;
}
}
}
await updateHerokuConfigVars({
authToken,
app,
configVars: updatedConfigVars
});
} catch (error) {
throw new SecretSyncError({
error,
secretKey: "batch_update"
});
}
},
removeSecrets: async (
secretSync: THerokuSyncWithCredentials,
secretMap: TSecretMap,
{ appConnectionDAL, kmsService }: THerokuSyncFactoryDeps
) => {
const {
connection,
destinationConfig: { app }
} = secretSync;
const authToken = await getValidAuthToken(connection, appConnectionDAL, kmsService);
try {
const currentConfigVars = await getHerokuConfigVars({ authToken, app });
const configVarsToUpdate: Record<string, null> = {};
for (const key of Object.keys(secretMap)) {
if (key in currentConfigVars) {
configVarsToUpdate[key] = null;
}
}
if (Object.keys(configVarsToUpdate).length > 0) {
await updateHerokuConfigVars({
authToken,
app,
configVars: configVarsToUpdate
});
}
} catch (error) {
throw new SecretSyncError({
error,
secretKey: "batch_remove"
});
}
},
getSecrets: async (
secretSync: THerokuSyncWithCredentials,
{ appConnectionDAL, kmsService }: THerokuSyncFactoryDeps
): Promise<TSecretMap> => {
const {
connection,
destinationConfig: { app }
} = secretSync;
const authToken = await getValidAuthToken(connection, appConnectionDAL, kmsService);
const data = await getHerokuConfigVars({ authToken, app });
const transformed = Object.entries(data).reduce((acc, [key, value]) => {
if (!value) {
return acc;
}
acc[key] = { value };
return acc;
}, {} as TSecretMap);
return transformed;
}
};
@@ -0,0 +1,44 @@
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 HerokuSyncDestinationConfigSchema = z.object({
app: z.string().trim().min(1, "App required").describe(SecretSyncs.DESTINATION_CONFIG.HEROKU.app),
appName: z.string().trim().min(1, "App name required").describe(SecretSyncs.DESTINATION_CONFIG.HEROKU.appName)
});
const HerokuSyncOptionsConfig: TSyncOptionsConfig = { canImportSecrets: true };
export const HerokuSyncSchema = BaseSecretSyncSchema(SecretSync.Heroku, HerokuSyncOptionsConfig).extend({
destination: z.literal(SecretSync.Heroku),
destinationConfig: HerokuSyncDestinationConfigSchema
});
export const CreateHerokuSyncSchema = GenericCreateSecretSyncFieldsSchema(
SecretSync.Heroku,
HerokuSyncOptionsConfig
).extend({
destinationConfig: HerokuSyncDestinationConfigSchema
});
export const UpdateHerokuSyncSchema = GenericUpdateSecretSyncFieldsSchema(
SecretSync.Heroku,
HerokuSyncOptionsConfig
).extend({
destinationConfig: HerokuSyncDestinationConfigSchema.optional()
});
export const HerokuSyncListItemSchema = z.object({
name: z.literal("Heroku"),
connection: z.literal(AppConnection.Heroku),
destination: z.literal(SecretSync.Heroku),
canImportSecrets: z.literal(true)
});
@@ -0,0 +1,24 @@
import { z } from "zod";
import { THerokuConnection } from "@app/services/app-connection/heroku";
import { CreateHerokuSyncSchema, HerokuSyncListItemSchema, HerokuSyncSchema } from "./heroku-sync-schemas";
export type THerokuSync = z.infer<typeof HerokuSyncSchema>;
export type THerokuSyncInput = z.infer<typeof CreateHerokuSyncSchema>;
export type THerokuSyncListItem = z.infer<typeof HerokuSyncListItemSchema>;
export type THerokuSyncWithCredentials = THerokuSync & {
connection: THerokuConnection;
};
export type THerokuConfigVars = Record<string, string | null>;
export type THerokuListVariables = {
authToken: string;
app: string;
};
export type THerokuUpdateVariables = THerokuListVariables & {
configVars: THerokuConfigVars;
};
@@ -0,0 +1,4 @@
export * from "./heroku-sync-constants";
export * from "./heroku-sync-fns";
export * from "./heroku-sync-schemas";
export * from "./heroku-sync-types";
@@ -16,6 +16,7 @@ export enum SecretSync {
TeamCity = "teamcity",
OCIVault = "oci-vault",
OnePass = "1password",
Heroku = "heroku",
Render = "render",
Flyio = "flyio"
}
@@ -33,6 +33,7 @@ import { FLYIO_SYNC_LIST_OPTION, FlyioSyncFns } from "./flyio";
import { GCP_SYNC_LIST_OPTION } from "./gcp";
import { GcpSyncFns } from "./gcp/gcp-sync-fns";
import { HC_VAULT_SYNC_LIST_OPTION, HCVaultSyncFns } from "./hc-vault";
import { HEROKU_SYNC_LIST_OPTION, HerokuSyncFns } from "./heroku";
import { HUMANITEC_SYNC_LIST_OPTION } from "./humanitec";
import { HumanitecSyncFns } from "./humanitec/humanitec-sync-fns";
import { RENDER_SYNC_LIST_OPTION, RenderSyncFns } from "./render";
@@ -60,6 +61,7 @@ const SECRET_SYNC_LIST_OPTIONS: Record<SecretSync, TSecretSyncListItem> = {
[SecretSync.TeamCity]: TEAMCITY_SYNC_LIST_OPTION,
[SecretSync.OCIVault]: OCI_VAULT_SYNC_LIST_OPTION,
[SecretSync.OnePass]: ONEPASS_SYNC_LIST_OPTION,
[SecretSync.Heroku]: HEROKU_SYNC_LIST_OPTION,
[SecretSync.Render]: RENDER_SYNC_LIST_OPTION,
[SecretSync.Flyio]: FLYIO_SYNC_LIST_OPTION
};
@@ -207,6 +209,8 @@ export const SecretSyncFns = {
appConnectionDAL,
kmsService
}).syncSecrets(secretSync, schemaSecretMap);
case SecretSync.Heroku:
return HerokuSyncFns.syncSecrets(secretSync, schemaSecretMap, { appConnectionDAL, kmsService });
case SecretSync.Vercel:
return VercelSyncFns.syncSecrets(secretSync, schemaSecretMap);
case SecretSync.Windmill:
@@ -300,6 +304,9 @@ export const SecretSyncFns = {
case SecretSync.OnePass:
secretMap = await OnePassSyncFns.getSecrets(secretSync);
break;
case SecretSync.Heroku:
secretMap = await HerokuSyncFns.getSecrets(secretSync, { appConnectionDAL, kmsService });
break;
case SecretSync.Render:
secretMap = await RenderSyncFns.getSecrets(secretSync);
break;
@@ -373,6 +380,8 @@ export const SecretSyncFns = {
return OCIVaultSyncFns.removeSecrets(secretSync, schemaSecretMap);
case SecretSync.OnePass:
return OnePassSyncFns.removeSecrets(secretSync, schemaSecretMap);
case SecretSync.Heroku:
return HerokuSyncFns.removeSecrets(secretSync, schemaSecretMap, { appConnectionDAL, kmsService });
case SecretSync.Render:
return RenderSyncFns.removeSecrets(secretSync, schemaSecretMap);
case SecretSync.Flyio:
@@ -19,6 +19,7 @@ export const SECRET_SYNC_NAME_MAP: Record<SecretSync, string> = {
[SecretSync.TeamCity]: "TeamCity",
[SecretSync.OCIVault]: "OCI Vault",
[SecretSync.OnePass]: "1Password",
[SecretSync.Heroku]: "Heroku",
[SecretSync.Render]: "Render",
[SecretSync.Flyio]: "Fly.io"
};
@@ -41,6 +42,7 @@ export const SECRET_SYNC_CONNECTION_MAP: Record<SecretSync, AppConnection> = {
[SecretSync.TeamCity]: AppConnection.TeamCity,
[SecretSync.OCIVault]: AppConnection.OCI,
[SecretSync.OnePass]: AppConnection.OnePass,
[SecretSync.Heroku]: AppConnection.Heroku,
[SecretSync.Render]: AppConnection.Render,
[SecretSync.Flyio]: AppConnection.Flyio
};
@@ -63,6 +65,7 @@ export const SECRET_SYNC_PLAN_MAP: Record<SecretSync, SecretSyncPlanType> = {
[SecretSync.TeamCity]: SecretSyncPlanType.Regular,
[SecretSync.OCIVault]: SecretSyncPlanType.Enterprise,
[SecretSync.OnePass]: SecretSyncPlanType.Regular,
[SecretSync.Heroku]: SecretSyncPlanType.Regular,
[SecretSync.Render]: SecretSyncPlanType.Regular,
[SecretSync.Flyio]: SecretSyncPlanType.Regular
};
@@ -80,6 +80,7 @@ import {
THCVaultSyncListItem,
THCVaultSyncWithCredentials
} from "./hc-vault/hc-vault-sync-types";
import { THerokuSync, THerokuSyncInput, THerokuSyncListItem, THerokuSyncWithCredentials } from "./heroku";
import {
THumanitecSync,
THumanitecSyncInput,
@@ -124,6 +125,7 @@ export type TSecretSync =
| TTeamCitySync
| TOCIVaultSync
| TOnePassSync
| THerokuSync
| TRenderSync
| TFlyioSync;
@@ -145,6 +147,7 @@ export type TSecretSyncWithCredentials =
| TTeamCitySyncWithCredentials
| TOCIVaultSyncWithCredentials
| TOnePassSyncWithCredentials
| THerokuSyncWithCredentials
| TRenderSyncWithCredentials
| TFlyioSyncWithCredentials;
@@ -166,6 +169,7 @@ export type TSecretSyncInput =
| TTeamCitySyncInput
| TOCIVaultSyncInput
| TOnePassSyncInput
| THerokuSyncInput
| TRenderSyncInput
| TFlyioSyncInput;
@@ -187,6 +191,7 @@ export type TSecretSyncListItem =
| TTeamCitySyncListItem
| TOCIVaultSyncListItem
| TOnePassSyncListItem
| THerokuSyncListItem
| TRenderSyncListItem
| TFlyioSyncListItem;