diff --git a/backend/src/lib/api-docs/constants.ts b/backend/src/lib/api-docs/constants.ts index 1f5f5a4ea..6c3421222 100644 --- a/backend/src/lib/api-docs/constants.ts +++ b/backend/src/lib/api-docs/constants.ts @@ -2384,6 +2384,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." } } }; diff --git a/backend/src/server/routes/v1/app-connection-routers/app-connection-router.ts b/backend/src/server/routes/v1/app-connection-routers/app-connection-router.ts index 53c503e4a..5122c4159 100644 --- a/backend/src/server/routes/v1/app-connection-routers/app-connection-router.ts +++ b/backend/src/server/routes/v1/app-connection-routers/app-connection-router.ts @@ -49,6 +49,7 @@ import { HCVaultConnectionListItemSchema, SanitizedHCVaultConnectionSchema } from "@app/services/app-connection/hc-vault"; +import { HerokuConnectionListItemSchema, SanitizedHerokuConnectionSchema } from "@app/services/app-connection/heroku"; import { HumanitecConnectionListItemSchema, SanitizedHumanitecConnectionSchema @@ -100,7 +101,8 @@ const SanitizedAppConnectionSchema = z.union([ ...SanitizedTeamCityConnectionSchema.options, ...SanitizedOCIConnectionSchema.options, ...SanitizedOracleDBConnectionSchema.options, - ...SanitizedOnePassConnectionSchema.options + ...SanitizedOnePassConnectionSchema.options, + ...SanitizedHerokuConnectionSchema.options ]); const AppConnectionOptionsSchema = z.discriminatedUnion("app", [ @@ -127,7 +129,8 @@ const AppConnectionOptionsSchema = z.discriminatedUnion("app", [ TeamCityConnectionListItemSchema, OCIConnectionListItemSchema, OracleDBConnectionListItemSchema, - OnePassConnectionListItemSchema + OnePassConnectionListItemSchema, + HerokuConnectionListItemSchema ]); export const registerAppConnectionRouter = async (server: FastifyZodProvider) => { diff --git a/backend/src/server/routes/v1/app-connection-routers/heroku-connection-router.ts b/backend/src/server/routes/v1/app-connection-routers/heroku-connection-router.ts new file mode 100644 index 000000000..18dda257a --- /dev/null +++ b/backend/src/server/routes/v1/app-connection-routers/heroku-connection-router.ts @@ -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; + } + }); +}; diff --git a/backend/src/server/routes/v1/app-connection-routers/index.ts b/backend/src/server/routes/v1/app-connection-routers/index.ts index 5f07f1be7..beabaf1f2 100644 --- a/backend/src/server/routes/v1/app-connection-routers/index.ts +++ b/backend/src/server/routes/v1/app-connection-routers/index.ts @@ -15,6 +15,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"; @@ -52,5 +53,6 @@ export const APP_CONNECTION_REGISTER_ROUTER_MAP: Record + registerSyncSecretsEndpoints({ + destination: SecretSync.Heroku, + server, + responseSchema: HerokuSyncSchema, + createSchema: CreateHerokuSyncSchema, + updateSchema: UpdateHerokuSyncSchema + }); diff --git a/backend/src/server/routes/v1/secret-sync-routers/index.ts b/backend/src/server/routes/v1/secret-sync-routers/index.ts index 3f4276726..81fa9f703 100644 --- a/backend/src/server/routes/v1/secret-sync-routers/index.ts +++ b/backend/src/server/routes/v1/secret-sync-routers/index.ts @@ -12,6 +12,7 @@ import { registerDatabricksSyncRouter } from "./databricks-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 { registerTeamCitySyncRouter } from "./teamcity-sync-router"; import { registerTerraformCloudSyncRouter } from "./terraform-cloud-sync-router"; @@ -37,5 +38,6 @@ export const SECRET_SYNC_REGISTER_ROUTER_MAP: Record { diff --git a/backend/src/services/app-connection/app-connection-enums.ts b/backend/src/services/app-connection/app-connection-enums.ts index 19aabc55e..fdb8888d9 100644 --- a/backend/src/services/app-connection/app-connection-enums.ts +++ b/backend/src/services/app-connection/app-connection-enums.ts @@ -22,7 +22,8 @@ export enum AppConnection { TeamCity = "teamcity", OCI = "oci", OracleDB = "oracledb", - OnePass = "1password" + OnePass = "1password", + Heroku = "heroku" } export enum AWSRegion { diff --git a/backend/src/services/app-connection/app-connection-fns.ts b/backend/src/services/app-connection/app-connection-fns.ts index 4a272bb5c..f20674b07 100644 --- a/backend/src/services/app-connection/app-connection-fns.ts +++ b/backend/src/services/app-connection/app-connection-fns.ts @@ -68,6 +68,7 @@ import { HCVaultConnectionMethod, validateHCVaultConnectionCredentials } from "./hc-vault"; +import { getHerokuConnectionListItem, HerokuConnectionMethod, validateHerokuConnectionCredentials } from "./heroku"; import { getHumanitecConnectionListItem, HumanitecConnectionMethod, @@ -121,7 +122,8 @@ export const listAppConnectionOptions = () => { getTeamCityConnectionListItem(), getOCIConnectionListItem(), getOracleDBConnectionListItem(), - getOnePassConnectionListItem() + getOnePassConnectionListItem(), + getHerokuConnectionListItem() ].sort((a, b) => a.name.localeCompare(b.name)); }; @@ -196,7 +198,8 @@ export const validateAppConnectionCredentials = async ( [AppConnection.TeamCity]: validateTeamCityConnectionCredentials as TAppConnectionCredentialsValidator, [AppConnection.OCI]: validateOCIConnectionCredentials as TAppConnectionCredentialsValidator, [AppConnection.OracleDB]: validateSqlConnectionCredentials as TAppConnectionCredentialsValidator, - [AppConnection.OnePass]: validateOnePassConnectionCredentials as TAppConnectionCredentialsValidator + [AppConnection.OnePass]: validateOnePassConnectionCredentials as TAppConnectionCredentialsValidator, + [AppConnection.Heroku]: validateHerokuConnectionCredentials as TAppConnectionCredentialsValidator }; return VALIDATE_APP_CONNECTION_CREDENTIALS_MAP[appConnection.app](appConnection); @@ -212,7 +215,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"; @@ -299,7 +305,8 @@ export const TRANSITION_CONNECTION_CREDENTIALS_TO_PLATFORM: Record< [AppConnection.TeamCity]: platformManagedCredentialsNotSupported, [AppConnection.OCI]: platformManagedCredentialsNotSupported, [AppConnection.OracleDB]: transferSqlConnectionCredentialsToPlatform as TAppConnectionTransitionCredentialsToPlatform, - [AppConnection.OnePass]: platformManagedCredentialsNotSupported + [AppConnection.OnePass]: platformManagedCredentialsNotSupported, + [AppConnection.Heroku]: platformManagedCredentialsNotSupported }; export const enterpriseAppCheck = async ( diff --git a/backend/src/services/app-connection/app-connection-maps.ts b/backend/src/services/app-connection/app-connection-maps.ts index 19967aa3b..4edaf9edb 100644 --- a/backend/src/services/app-connection/app-connection-maps.ts +++ b/backend/src/services/app-connection/app-connection-maps.ts @@ -24,7 +24,8 @@ export const APP_CONNECTION_NAME_MAP: Record = { [AppConnection.TeamCity]: "TeamCity", [AppConnection.OCI]: "OCI", [AppConnection.OracleDB]: "OracleDB", - [AppConnection.OnePass]: "1Password" + [AppConnection.OnePass]: "1Password", + [AppConnection.Heroku]: "Heroku" }; export const APP_CONNECTION_PLAN_MAP: Record = { @@ -51,5 +52,6 @@ export const APP_CONNECTION_PLAN_MAP: Record>>; @@ -192,6 +199,7 @@ export type TAppConnectionInput = { id: string } & ( | TOCIConnectionInput | TOracleDBConnectionInput | TOnePassConnectionInput + | THerokuConnectionInput ); export type TSqlConnectionInput = @@ -230,7 +238,8 @@ export type TAppConnectionConfig = | TLdapConnectionConfig | TTeamCityConnectionConfig | TOCIConnectionConfig - | TOnePassConnectionConfig; + | TOnePassConnectionConfig + | THerokuConnectionConfig; export type TValidateAppConnectionCredentialsSchema = | TValidateAwsConnectionCredentialsSchema @@ -256,7 +265,8 @@ export type TValidateAppConnectionCredentialsSchema = | TValidateTeamCityConnectionCredentialsSchema | TValidateOCIConnectionCredentialsSchema | TValidateOracleDBConnectionCredentialsSchema - | TValidateOnePassConnectionCredentialsSchema; + | TValidateOnePassConnectionCredentialsSchema + | TValidateHerokuConnectionCredentialsSchema; export type TListAwsConnectionKmsKeys = { connectionId: string; diff --git a/backend/src/services/app-connection/heroku/heroku-connection-enums.ts b/backend/src/services/app-connection/heroku/heroku-connection-enums.ts new file mode 100644 index 000000000..02d3992c8 --- /dev/null +++ b/backend/src/services/app-connection/heroku/heroku-connection-enums.ts @@ -0,0 +1,4 @@ +export enum HerokuConnectionMethod { + AuthToken = "auth-token", + OAuth = "oauth" +} diff --git a/backend/src/services/app-connection/heroku/heroku-connection-fns.ts b/backend/src/services/app-connection/heroku/heroku-connection-fns.ts new file mode 100644 index 000000000..1f234fab7 --- /dev/null +++ b/backend/src/services/app-connection/heroku/heroku-connection-fns.ts @@ -0,0 +1,233 @@ +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 { IntegrationUrls } from "@app/services/integration-auth/integration-list"; +import { TKmsServiceFactory } from "@app/services/kms/kms-service"; +import { KmsDataKey } from "@app/services/kms/kms-types"; + +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; +} + +const encryptAppConnectionCredentials = async ({ + orgId, + credentials, + kmsService +}: { + orgId: string; + credentials: { + refreshToken: string; + authToken: string; + expiresAt: Date; + }; + kmsService: Pick; +}) => { + const { encryptor } = await kmsService.createCipherPairWithDataKey({ + type: KmsDataKey.Organization, + orgId + }); + + const { cipherTextBlob: encryptedCredentialsBlob } = encryptor({ + plainText: Buffer.from(JSON.stringify(credentials)) + }); + + return encryptedCredentialsBlob; +}; + +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, + kmsService: Pick +): Promise => { + 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) + }, + orgId, + kmsService + }); + + await appConnectionDAL.updateById(appId, { encryptedCredentials }); + + return data.access_token; +}; + +export const exchangeHerokuOAuthCode = async (code: string): Promise => { + const { CLIENT_SECRET_HEROKU } = getConfig(); + + try { + const response = await request.post( + 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 | null = null; + + try { + response = await request.get(`${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; + kmsService: Pick; +}): Promise => { + 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(`${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 })); +}; diff --git a/backend/src/services/app-connection/heroku/heroku-connection-schemas.ts b/backend/src/services/app-connection/heroku/heroku-connection-schemas.ts new file mode 100644 index 000000000..13d7d5989 --- /dev/null +++ b/backend/src/services/app-connection/heroku/heroku-connection-schemas.ts @@ -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 HerokuConnectionAccessTokenCredentialsSchema = z.object({ + authToken: z.string().trim().min(1, "Auth Token required") +}); + +export const HerokuConnectionOAuthCredentialsSchema = z.object({ + code: z.string().trim().min(1, "OAuth code required") +}); + +export const HerokuConnectionOAuthOutputCredentialsSchema = z.object({ + authToken: z.string(), + refreshToken: z.string(), + 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: HerokuConnectionAccessTokenCredentialsSchema + }), + z.object({ + method: z.literal(HerokuConnectionMethod.OAuth), + credentials: HerokuConnectionOAuthOutputCredentialsSchema + }) + ]) +); + +export const SanitizedHerokuConnectionSchema = z.discriminatedUnion("method", [ + BaseHerokuConnectionSchema.extend({ + method: z.literal(HerokuConnectionMethod.AuthToken), + credentials: HerokuConnectionAccessTokenCredentialsSchema.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: HerokuConnectionAccessTokenCredentialsSchema.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([ + HerokuConnectionAccessTokenCredentialsSchema, + 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() +}); diff --git a/backend/src/services/app-connection/heroku/heroku-connection-service.ts b/backend/src/services/app-connection/heroku/heroku-connection-service.ts new file mode 100644 index 000000000..3b1b01d81 --- /dev/null +++ b/backend/src/services/app-connection/heroku/heroku-connection-service.ts @@ -0,0 +1,36 @@ +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; + +export const herokuConnectionService = ( + getAppConnection: TGetAppConnectionFunc, + appConnectionDAL: Pick, + kmsService: Pick +) => { + const listApps = async (connectionId: string, actor: OrgServiceActor) => { + const appConnection = await getAppConnection(AppConnection.Heroku, connectionId, actor); + try { + const apps = await getHerokuApps({ appConnection, appConnectionDAL, kmsService }); + // eslint-disable-next-line @typescript-eslint/no-unsafe-return + return apps; + } catch (error) { + logger.error(error, "Failed to establish connection with Heroku"); + return []; + } + }; + + return { + listApps + }; +}; diff --git a/backend/src/services/app-connection/heroku/heroku-connection-types.ts b/backend/src/services/app-connection/heroku/heroku-connection-types.ts new file mode 100644 index 000000000..c487af819 --- /dev/null +++ b/backend/src/services/app-connection/heroku/heroku-connection-types.ts @@ -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; + +export type THerokuConnectionInput = z.infer & { + app: AppConnection.Heroku; +}; + +export type TValidateHerokuConnectionCredentialsSchema = typeof ValidateHerokuConnectionCredentialsSchema; + +export type THerokuConnectionConfig = DiscriminativePick & { + orgId: string; +}; + +export type THerokuApp = { + name: string; + id: string; +}; diff --git a/backend/src/services/app-connection/heroku/index.ts b/backend/src/services/app-connection/heroku/index.ts new file mode 100644 index 000000000..c23bc60c6 --- /dev/null +++ b/backend/src/services/app-connection/heroku/index.ts @@ -0,0 +1,4 @@ +export * from "./heroku-connection-enums"; +export * from "./heroku-connection-fns"; +export * from "./heroku-connection-schemas"; +export * from "./heroku-connection-types"; diff --git a/backend/src/services/secret-sync/heroku/heroku-sync-constants.ts b/backend/src/services/secret-sync/heroku/heroku-sync-constants.ts new file mode 100644 index 000000000..7742800e2 --- /dev/null +++ b/backend/src/services/secret-sync/heroku/heroku-sync-constants.ts @@ -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 +}; diff --git a/backend/src/services/secret-sync/heroku/heroku-sync-fns.ts b/backend/src/services/secret-sync/heroku/heroku-sync-fns.ts new file mode 100644 index 000000000..1de000e17 --- /dev/null +++ b/backend/src/services/secret-sync/heroku/heroku-sync-fns.ts @@ -0,0 +1,167 @@ +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; + kmsService: Pick; +}; + +const getValidAuthToken = async ( + connection: THerokuConnection, + appConnectionDAL: Pick, + kmsService: Pick +): Promise => { + 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 => { + const { data } = await request.get(`${IntegrationUrls.HEROKU_API_URL}/apps/${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/${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 currentConfigVars = await getHerokuConfigVars({ authToken, app }); + + const updatedConfigVars: THerokuConfigVars = {}; + + for (const [key, { value }] of Object.entries(secretMap)) { + updatedConfigVars[key] = value; + } + + if (!secretSync.syncOptions.disableSecretDeletion) { + 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 = {}; + + 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 => { + 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; + } +}; diff --git a/backend/src/services/secret-sync/heroku/heroku-sync-schemas.ts b/backend/src/services/secret-sync/heroku/heroku-sync-schemas.ts new file mode 100644 index 000000000..5c9ba570e --- /dev/null +++ b/backend/src/services/secret-sync/heroku/heroku-sync-schemas.ts @@ -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) +}); diff --git a/backend/src/services/secret-sync/heroku/heroku-sync-types.ts b/backend/src/services/secret-sync/heroku/heroku-sync-types.ts new file mode 100644 index 000000000..48a7c4267 --- /dev/null +++ b/backend/src/services/secret-sync/heroku/heroku-sync-types.ts @@ -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; +export type THerokuSyncInput = z.infer; +export type THerokuSyncListItem = z.infer; + +export type THerokuSyncWithCredentials = THerokuSync & { + connection: THerokuConnection; +}; + +export type THerokuConfigVars = Record; + +export type THerokuListVariables = { + authToken: string; + app: string; +}; + +export type THerokuUpdateVariables = THerokuListVariables & { + configVars: THerokuConfigVars; +}; diff --git a/backend/src/services/secret-sync/heroku/index.ts b/backend/src/services/secret-sync/heroku/index.ts new file mode 100644 index 000000000..8916f58a9 --- /dev/null +++ b/backend/src/services/secret-sync/heroku/index.ts @@ -0,0 +1,4 @@ +export * from "./heroku-sync-constants"; +export * from "./heroku-sync-fns"; +export * from "./heroku-sync-schemas"; +export * from "./heroku-sync-types"; diff --git a/backend/src/services/secret-sync/secret-sync-enums.ts b/backend/src/services/secret-sync/secret-sync-enums.ts index 3cf940459..8cc8a07a4 100644 --- a/backend/src/services/secret-sync/secret-sync-enums.ts +++ b/backend/src/services/secret-sync/secret-sync-enums.ts @@ -15,7 +15,8 @@ export enum SecretSync { HCVault = "hashicorp-vault", TeamCity = "teamcity", OCIVault = "oci-vault", - OnePass = "1password" + OnePass = "1password", + Heroku = "heroku" } export enum SecretSyncInitialSyncBehavior { diff --git a/backend/src/services/secret-sync/secret-sync-fns.ts b/backend/src/services/secret-sync/secret-sync-fns.ts index bcda32967..1e573a5df 100644 --- a/backend/src/services/secret-sync/secret-sync-fns.ts +++ b/backend/src/services/secret-sync/secret-sync-fns.ts @@ -32,6 +32,7 @@ import { CAMUNDA_SYNC_LIST_OPTION, camundaSyncFactory } from "./camunda"; 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 { SECRET_SYNC_PLAN_MAP } from "./secret-sync-maps"; @@ -57,7 +58,8 @@ const SECRET_SYNC_LIST_OPTIONS: Record = { [SecretSync.HCVault]: HC_VAULT_SYNC_LIST_OPTION, [SecretSync.TeamCity]: TEAMCITY_SYNC_LIST_OPTION, [SecretSync.OCIVault]: OCI_VAULT_SYNC_LIST_OPTION, - [SecretSync.OnePass]: ONEPASS_SYNC_LIST_OPTION + [SecretSync.OnePass]: ONEPASS_SYNC_LIST_OPTION, + [SecretSync.Heroku]: HEROKU_SYNC_LIST_OPTION }; export const listSecretSyncOptions = () => { @@ -203,6 +205,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: @@ -292,6 +296,9 @@ export const SecretSyncFns = { case SecretSync.OnePass: secretMap = await OnePassSyncFns.getSecrets(secretSync); break; + case SecretSync.Heroku: + secretMap = await HerokuSyncFns.getSecrets(secretSync, { appConnectionDAL, kmsService }); + break; default: throw new Error( `Unhandled sync destination for get secrets fns: ${(secretSync as TSecretSyncWithCredentials).destination}` @@ -359,6 +366,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 }); default: throw new Error( `Unhandled sync destination for remove secrets fns: ${(secretSync as TSecretSyncWithCredentials).destination}` diff --git a/backend/src/services/secret-sync/secret-sync-maps.ts b/backend/src/services/secret-sync/secret-sync-maps.ts index dd329734a..d0fbd5f6b 100644 --- a/backend/src/services/secret-sync/secret-sync-maps.ts +++ b/backend/src/services/secret-sync/secret-sync-maps.ts @@ -18,7 +18,8 @@ export const SECRET_SYNC_NAME_MAP: Record = { [SecretSync.HCVault]: "Hashicorp Vault", [SecretSync.TeamCity]: "TeamCity", [SecretSync.OCIVault]: "OCI Vault", - [SecretSync.OnePass]: "1Password" + [SecretSync.OnePass]: "1Password", + [SecretSync.Heroku]: "Heroku" }; export const SECRET_SYNC_CONNECTION_MAP: Record = { @@ -38,7 +39,8 @@ export const SECRET_SYNC_CONNECTION_MAP: Record = { [SecretSync.HCVault]: AppConnection.HCVault, [SecretSync.TeamCity]: AppConnection.TeamCity, [SecretSync.OCIVault]: AppConnection.OCI, - [SecretSync.OnePass]: AppConnection.OnePass + [SecretSync.OnePass]: AppConnection.OnePass, + [SecretSync.Heroku]: AppConnection.Heroku }; export const SECRET_SYNC_PLAN_MAP: Record = { @@ -58,5 +60,6 @@ export const SECRET_SYNC_PLAN_MAP: Record = { [SecretSync.HCVault]: SecretSyncPlanType.Regular, [SecretSync.TeamCity]: SecretSyncPlanType.Regular, [SecretSync.OCIVault]: SecretSyncPlanType.Enterprise, - [SecretSync.OnePass]: SecretSyncPlanType.Regular + [SecretSync.OnePass]: SecretSyncPlanType.Regular, + [SecretSync.Heroku]: SecretSyncPlanType.Regular }; diff --git a/backend/src/services/secret-sync/secret-sync-types.ts b/backend/src/services/secret-sync/secret-sync-types.ts index ff355e5e1..59c95f964 100644 --- a/backend/src/services/secret-sync/secret-sync-types.ts +++ b/backend/src/services/secret-sync/secret-sync-types.ts @@ -79,6 +79,7 @@ import { THCVaultSyncListItem, THCVaultSyncWithCredentials } from "./hc-vault/hc-vault-sync-types"; +import { THerokuSync, THerokuSyncInput, THerokuSyncListItem, THerokuSyncWithCredentials } from "./heroku"; import { THumanitecSync, THumanitecSyncInput, @@ -116,7 +117,8 @@ export type TSecretSync = | THCVaultSync | TTeamCitySync | TOCIVaultSync - | TOnePassSync; + | TOnePassSync + | THerokuSync; export type TSecretSyncWithCredentials = | TAwsParameterStoreSyncWithCredentials @@ -135,7 +137,8 @@ export type TSecretSyncWithCredentials = | THCVaultSyncWithCredentials | TTeamCitySyncWithCredentials | TOCIVaultSyncWithCredentials - | TOnePassSyncWithCredentials; + | TOnePassSyncWithCredentials + | THerokuSyncWithCredentials; export type TSecretSyncInput = | TAwsParameterStoreSyncInput @@ -154,7 +157,8 @@ export type TSecretSyncInput = | THCVaultSyncInput | TTeamCitySyncInput | TOCIVaultSyncInput - | TOnePassSyncInput; + | TOnePassSyncInput + | THerokuSyncInput; export type TSecretSyncListItem = | TAwsParameterStoreSyncListItem @@ -173,7 +177,8 @@ export type TSecretSyncListItem = | THCVaultSyncListItem | TTeamCitySyncListItem | TOCIVaultSyncListItem - | TOnePassSyncListItem; + | TOnePassSyncListItem + | THerokuSyncListItem; export type TSyncOptionsConfig = { canImportSecrets: boolean; diff --git a/docs/api-reference/endpoints/app-connections/heroku/available.mdx b/docs/api-reference/endpoints/app-connections/heroku/available.mdx new file mode 100644 index 000000000..4f1ac5e38 --- /dev/null +++ b/docs/api-reference/endpoints/app-connections/heroku/available.mdx @@ -0,0 +1,4 @@ +--- +title: "Available" +openapi: "GET /api/v1/app-connections/heroku/available" +--- diff --git a/docs/api-reference/endpoints/app-connections/heroku/create.mdx b/docs/api-reference/endpoints/app-connections/heroku/create.mdx new file mode 100644 index 000000000..15130b416 --- /dev/null +++ b/docs/api-reference/endpoints/app-connections/heroku/create.mdx @@ -0,0 +1,10 @@ +--- +title: "Create" +openapi: "POST /api/v1/app-connections/heroku" +--- + + + Heroku OAuth Connections must be created through the Infisical UI. + Check out the configuration docs for [Heroku OAuth Connections](/integrations/app-connections/heroku) for a step-by-step + guide. + \ No newline at end of file diff --git a/docs/api-reference/endpoints/app-connections/heroku/delete.mdx b/docs/api-reference/endpoints/app-connections/heroku/delete.mdx new file mode 100644 index 000000000..77c219845 --- /dev/null +++ b/docs/api-reference/endpoints/app-connections/heroku/delete.mdx @@ -0,0 +1,4 @@ +--- +title: "Delete" +openapi: "DELETE /api/v1/app-connections/heroku/{connectionId}" +--- diff --git a/docs/api-reference/endpoints/app-connections/heroku/get-by-id.mdx b/docs/api-reference/endpoints/app-connections/heroku/get-by-id.mdx new file mode 100644 index 000000000..d12b3b9a8 --- /dev/null +++ b/docs/api-reference/endpoints/app-connections/heroku/get-by-id.mdx @@ -0,0 +1,4 @@ +--- +title: "Get by ID" +openapi: "GET /api/v1/app-connections/heroku/{connectionId}" +--- diff --git a/docs/api-reference/endpoints/app-connections/heroku/get-by-name.mdx b/docs/api-reference/endpoints/app-connections/heroku/get-by-name.mdx new file mode 100644 index 000000000..fa7425ab0 --- /dev/null +++ b/docs/api-reference/endpoints/app-connections/heroku/get-by-name.mdx @@ -0,0 +1,4 @@ +--- +title: "Get by Name" +openapi: "GET /api/v1/app-connections/heroku/connection-name/{connectionName}" +--- diff --git a/docs/api-reference/endpoints/app-connections/heroku/list.mdx b/docs/api-reference/endpoints/app-connections/heroku/list.mdx new file mode 100644 index 000000000..45065955a --- /dev/null +++ b/docs/api-reference/endpoints/app-connections/heroku/list.mdx @@ -0,0 +1,4 @@ +--- +title: "List" +openapi: "GET /api/v1/app-connections/heroku" +--- diff --git a/docs/api-reference/endpoints/app-connections/heroku/update.mdx b/docs/api-reference/endpoints/app-connections/heroku/update.mdx new file mode 100644 index 000000000..2a2e3007c --- /dev/null +++ b/docs/api-reference/endpoints/app-connections/heroku/update.mdx @@ -0,0 +1,10 @@ +--- +title: "Update" +openapi: "PATCH /api/v1/app-connections/heroku/{connectionId}" +--- + + + Heroku OAuth Connections must be updated through the Infisical UI. + Check out the configuration docs for [Heroku OAuth Connections](/integrations/app-connections/heroku) for a step-by-step + guide. + diff --git a/docs/api-reference/endpoints/secret-syncs/heroku/create.mdx b/docs/api-reference/endpoints/secret-syncs/heroku/create.mdx new file mode 100644 index 000000000..ef69574bb --- /dev/null +++ b/docs/api-reference/endpoints/secret-syncs/heroku/create.mdx @@ -0,0 +1,4 @@ +--- +title: "Create" +openapi: "POST /api/v1/secret-syncs/heroku" +--- diff --git a/docs/api-reference/endpoints/secret-syncs/heroku/delete.mdx b/docs/api-reference/endpoints/secret-syncs/heroku/delete.mdx new file mode 100644 index 000000000..5624e5af6 --- /dev/null +++ b/docs/api-reference/endpoints/secret-syncs/heroku/delete.mdx @@ -0,0 +1,4 @@ +--- +title: "Delete" +openapi: "DELETE /api/v1/secret-syncs/heroku/{syncId}" +--- diff --git a/docs/api-reference/endpoints/secret-syncs/heroku/get-by-id.mdx b/docs/api-reference/endpoints/secret-syncs/heroku/get-by-id.mdx new file mode 100644 index 000000000..dcd0cfb8d --- /dev/null +++ b/docs/api-reference/endpoints/secret-syncs/heroku/get-by-id.mdx @@ -0,0 +1,4 @@ +--- +title: "Get by ID" +openapi: "GET /api/v1/secret-syncs/heroku/{syncId}" +--- diff --git a/docs/api-reference/endpoints/secret-syncs/heroku/get-by-name.mdx b/docs/api-reference/endpoints/secret-syncs/heroku/get-by-name.mdx new file mode 100644 index 000000000..9d6842673 --- /dev/null +++ b/docs/api-reference/endpoints/secret-syncs/heroku/get-by-name.mdx @@ -0,0 +1,4 @@ +--- +title: "Get by Name" +openapi: "GET /api/v1/secret-syncs/heroku/sync-name/{syncName}" +--- diff --git a/docs/api-reference/endpoints/secret-syncs/heroku/import-secrets.mdx b/docs/api-reference/endpoints/secret-syncs/heroku/import-secrets.mdx new file mode 100644 index 000000000..15b1d228a --- /dev/null +++ b/docs/api-reference/endpoints/secret-syncs/heroku/import-secrets.mdx @@ -0,0 +1,4 @@ +--- +title: "Import Secrets" +openapi: "POST /api/v1/secret-syncs/heroku/{syncId}/import-secrets" +--- diff --git a/docs/api-reference/endpoints/secret-syncs/heroku/list.mdx b/docs/api-reference/endpoints/secret-syncs/heroku/list.mdx new file mode 100644 index 000000000..e869b8165 --- /dev/null +++ b/docs/api-reference/endpoints/secret-syncs/heroku/list.mdx @@ -0,0 +1,4 @@ +--- +title: "List" +openapi: "GET /api/v1/secret-syncs/heroku" +--- diff --git a/docs/api-reference/endpoints/secret-syncs/heroku/remove-secrets.mdx b/docs/api-reference/endpoints/secret-syncs/heroku/remove-secrets.mdx new file mode 100644 index 000000000..b6ffdce4b --- /dev/null +++ b/docs/api-reference/endpoints/secret-syncs/heroku/remove-secrets.mdx @@ -0,0 +1,4 @@ +--- +title: "Remove Secrets" +openapi: "POST /api/v1/secret-syncs/heroku/{syncId}/remove-secrets" +--- diff --git a/docs/api-reference/endpoints/secret-syncs/heroku/sync-secrets.mdx b/docs/api-reference/endpoints/secret-syncs/heroku/sync-secrets.mdx new file mode 100644 index 000000000..1ca46bf0f --- /dev/null +++ b/docs/api-reference/endpoints/secret-syncs/heroku/sync-secrets.mdx @@ -0,0 +1,4 @@ +--- +title: "Sync Secrets" +openapi: "POST /api/v1/secret-syncs/heroku/{syncId}/sync-secrets" +--- diff --git a/docs/api-reference/endpoints/secret-syncs/heroku/update.mdx b/docs/api-reference/endpoints/secret-syncs/heroku/update.mdx new file mode 100644 index 000000000..82307b82a --- /dev/null +++ b/docs/api-reference/endpoints/secret-syncs/heroku/update.mdx @@ -0,0 +1,4 @@ +--- +title: "Update" +openapi: "PATCH /api/v1/secret-syncs/heroku/{syncId}" +--- diff --git a/docs/images/app-connections/heroku/heroku-api-token.png b/docs/images/app-connections/heroku/heroku-api-token.png new file mode 100644 index 000000000..88b632d63 Binary files /dev/null and b/docs/images/app-connections/heroku/heroku-api-token.png differ diff --git a/docs/images/app-connections/heroku/heroku-connection.png b/docs/images/app-connections/heroku/heroku-connection.png new file mode 100644 index 000000000..ce761c4ae Binary files /dev/null and b/docs/images/app-connections/heroku/heroku-connection.png differ diff --git a/docs/images/app-connections/heroku/heroku-create-oauth-method.png b/docs/images/app-connections/heroku/heroku-create-oauth-method.png new file mode 100644 index 000000000..a77fa6b84 Binary files /dev/null and b/docs/images/app-connections/heroku/heroku-create-oauth-method.png differ diff --git a/docs/images/app-connections/heroku/heroku-create-token-method.png b/docs/images/app-connections/heroku/heroku-create-token-method.png new file mode 100644 index 000000000..efab60d80 Binary files /dev/null and b/docs/images/app-connections/heroku/heroku-create-token-method.png differ diff --git a/docs/images/app-connections/heroku/heroku-select-connection.png b/docs/images/app-connections/heroku/heroku-select-connection.png new file mode 100644 index 000000000..f19ff56f6 Binary files /dev/null and b/docs/images/app-connections/heroku/heroku-select-connection.png differ diff --git a/docs/images/secret-syncs/heroku/heroku-created.png b/docs/images/secret-syncs/heroku/heroku-created.png new file mode 100644 index 000000000..7c2960b3d Binary files /dev/null and b/docs/images/secret-syncs/heroku/heroku-created.png differ diff --git a/docs/images/secret-syncs/heroku/heroku-destination.png b/docs/images/secret-syncs/heroku/heroku-destination.png new file mode 100644 index 000000000..7717b60c2 Binary files /dev/null and b/docs/images/secret-syncs/heroku/heroku-destination.png differ diff --git a/docs/images/secret-syncs/heroku/heroku-details.png b/docs/images/secret-syncs/heroku/heroku-details.png new file mode 100644 index 000000000..89277228b Binary files /dev/null and b/docs/images/secret-syncs/heroku/heroku-details.png differ diff --git a/docs/images/secret-syncs/heroku/heroku-options.png b/docs/images/secret-syncs/heroku/heroku-options.png new file mode 100644 index 000000000..765c9d666 Binary files /dev/null and b/docs/images/secret-syncs/heroku/heroku-options.png differ diff --git a/docs/images/secret-syncs/heroku/heroku-review.png b/docs/images/secret-syncs/heroku/heroku-review.png new file mode 100644 index 000000000..a3228ff61 Binary files /dev/null and b/docs/images/secret-syncs/heroku/heroku-review.png differ diff --git a/docs/images/secret-syncs/heroku/heroku-source.png b/docs/images/secret-syncs/heroku/heroku-source.png new file mode 100644 index 000000000..8f700e62a Binary files /dev/null and b/docs/images/secret-syncs/heroku/heroku-source.png differ diff --git a/docs/images/secret-syncs/heroku/select-heroku-option.png b/docs/images/secret-syncs/heroku/select-heroku-option.png new file mode 100644 index 000000000..b5f802dec Binary files /dev/null and b/docs/images/secret-syncs/heroku/select-heroku-option.png differ diff --git a/docs/integrations/app-connections/heroku.mdx b/docs/integrations/app-connections/heroku.mdx new file mode 100644 index 000000000..0c27a8dc5 --- /dev/null +++ b/docs/integrations/app-connections/heroku.mdx @@ -0,0 +1,121 @@ +--- +title: "Heroku App Connection" +description: "Learn how to configure a Heroku App Connection for Infisical using OAuth or Auth Token methods." +--- + +Infisical supports two methods for connecting to Heroku: **OAuth** and **Auth Token**. Choose the method that best fits your setup and security requirements. + + + + The OAuth method provides secure authentication through Heroku's OAuth flow. + + + Using the Heroku App Connection with OAuth on a self-hosted instance of Infisical requires configuring an API client in Heroku and registering your instance with it. + + **Prerequisites:** + - A Heroku account with existing applications + - Self-hosted Infisical instance + + + + Navigate to your user Account settings > Applications to create a new API client. + + ![Heroku config settings](/images/integrations/heroku/integrations-heroku-config-settings.png) + ![Heroku config applications](/images/integrations/heroku/integrations-heroku-config-applications.png) + ![Heroku config new app](/images/integrations/heroku/integrations-heroku-config-new-app.png) + + Create the API client. As part of the form, set the **OAuth callback URL** to `https://your-domain.com/organization/app-connections/heroku/oauth/callback`. + + + The domain you defined in the OAuth callback URL should be equivalent to the `SITE_URL` configured in your Infisical instance. + + + ![Heroku config new app form](/images/integrations/heroku/integrations-heroku-config-new-app-form.png) + + + Obtain the **Client ID** and **Client Secret** for your Heroku API client. + + ![Heroku config credentials](/images/integrations/heroku/integrations-heroku-config-credentials.png) + + Back in your Infisical instance, add two new environment variables for the credentials of your Heroku API client: + + - `INF_APP_CONNECTION_HEROKU_CLIENT_ID`: The **Client ID** of your Heroku API client. + - `INF_APP_CONNECTION_HEROKU_CLIENT_SECRET`: The **Client Secret** of your Heroku API client. + + Once added, restart your Infisical instance and use the Heroku App Connection. + + + + + ## Setup Heroku OAuth Connection in Infisical + + + + Navigate to the **App Connections** tab on the **Organization Settings** page. + ![App Connections Tab](/images/app-connections/general/add-connection.png) + + + Select the **Heroku App Connection** option from the connection options modal. + ![Select Heroku Connection](/images/app-connections/heroku/heroku-select-connection.png) + + + Select the **OAuth** method and click **Connect to Heroku**. + + ![Connect via Heroku OAuth](/images/app-connections/heroku/heroku-create-oauth-method.png) + + + You will be redirected to Heroku to grant Infisical access to your Heroku account. Once granted, you will be redirected back to Infisical's App Connections page. + ![Heroku Authorization](/images/integrations/heroku/integrations-heroku-auth.png) + + + Your **Heroku App Connection** is now available for use. + ![Heroku OAuth Connection](/images/app-connections/heroku/heroku-connection.png) + + + + + + + The Auth Token method uses a Heroku API token for authentication, providing a straightforward setup process. + + ## Setup Heroku Auth Token Connection in Infisical + + + + Log in to your Heroku account and navigate to Account Settings. + + Under the **Authorizations** section, reveal and copy your Authorization token. If you don't have one, click **Create Authorization** to create a new token. + + + Keep your Authorization token secure and do not share it. Anyone with access to this token can manage your Heroku applications. + + + ![Heroku API Token](/images/app-connections/heroku/heroku-api-token.png) + + + Navigate to the **App Connections** tab on the **Organization Settings** page. + ![App Connections Tab](/images/app-connections/general/add-connection.png) + + + Select the **Heroku App Connection** option from the connection options modal. + ![Select Heroku Connection](/images/app-connections/heroku/heroku-select-connection.png) + + + Select the **Auth Token** method and paste your Heroku Authorization token in the provided field. + + ![Configure Auth Token](/images/app-connections/heroku/heroku-create-token-method.png) + + Click **Connect** to establish the connection. + + + Your **Heroku App Connection** is now available for use. + ![Heroku Auth Token Connection](/images/app-connections/heroku/heroku-connection.png) + + + + + Auth Token connections require manual token rotation when your Heroku Authorization expires or is regenerated. Monitor your connection status and update the token as needed. + + + + diff --git a/docs/integrations/secret-syncs/heroku.mdx b/docs/integrations/secret-syncs/heroku.mdx new file mode 100644 index 000000000..d2d52b6b0 --- /dev/null +++ b/docs/integrations/secret-syncs/heroku.mdx @@ -0,0 +1,143 @@ +--- +title: "Heroku Sync" +description: "Learn how to configure a Heroku Sync for Infisical." +--- + +**Prerequisites:** + +- Set up and add secrets to [Infisical Cloud](https://app.infisical.com) +- Create a [Heroku App Connection](/integrations/app-connections/heroku) + + + + 1. Navigate to **Project** > **Integrations** and select the **Secret Syncs** tab. Click on the **Add Sync** button. + ![Secret Syncs Tab](/images/secret-syncs/general/secret-sync-tab.png) + + 2. Select the **Heroku** option. + ![Select Heroku](/images/secret-syncs/heroku/select-heroku-option.png) + + 3. Configure the **Source** from where secrets should be retrieved, then click **Next**. + ![Configure Source](/images/secret-syncs/heroku/heroku-source.png) + + - **Environment**: The project environment to retrieve secrets from. + - **Secret Path**: The folder path to retrieve secrets from. + + + If you need to sync secrets from multiple folder locations, check out [secret imports](/documentation/platform/secret-reference#secret-imports). + + + 4. Configure the **Destination** to where secrets should be deployed, then click **Next**. + ![Configure Destination](/images/secret-syncs/heroku/heroku-destination.png) + + - **Heroku App Connection**: The Heroku App Connection to authenticate with. + - **Heroku App**: The Heroku application to sync secrets to. + + 5. Configure the **Sync Options** to specify how secrets should be synced, then click **Next**. + ![Configure Options](/images/secret-syncs/heroku/heroku-options.png) + + - **Initial Sync Behavior**: Determines how Infisical should resolve the initial sync. + - **Import - Prefer values from Infisical**: Import secrets from Heroku to Infisical; if a secret with the same name already exists in Infisical, do nothing. Afterwards, sync secrets to Heroku. + - **Import - Prefer values from Heroku**: Import secrets from Heroku to Infisical; if a secret with the same name already exists in Infisical, replace its value with the one from Heroku. Afterwards, sync secrets to Heroku. + - **Overwrite Destination Secrets**: Removes any secrets at the destination endpoint not present in Infisical. + - **Key Schema**: Template that determines how secret names are transformed when syncing, using `{{secretKey}}` as a placeholder for the original secret name and `{{environment}}` for the environment. + + We highly recommend using a Key Schema to ensure that Infisical only manages the specific keys you intend, keeping everything else untouched. + + - **Auto-Sync Enabled**: If enabled, secrets will automatically be synced from the source location when changes occur. Disable to enforce manual syncing only. + - **Disable Secret Deletion**: If enabled, Infisical will not remove secrets from the sync destination. Enable this option if you intend to manage some secrets manually outside of Infisical. + + 6. Configure the **Details** of your Heroku Sync, then click **Next**. + ![Configure Details](/images/secret-syncs/heroku/heroku-details.png) + + - **Name**: The name of your sync. Must be slug-friendly. + - **Description**: An optional description for your sync. + + 7. Review your Heroku Sync configuration, then click **Create Sync**. + ![Confirm Configuration](/images/secret-syncs/heroku/heroku-review.png) + + 8. If enabled, your Heroku Sync will begin syncing your secrets to the destination endpoint. + ![Sync Secrets](/images/secret-syncs/heroku/heroku-created.png) + + + + To create a **Heroku Sync**, make an API request to the [Create Heroku Sync](/api-reference/endpoints/secret-syncs/heroku/create) API endpoint. + + ### Sample request + + ```bash Request + curl --request POST \ + --url https://app.infisical.com/api/v1/secret-syncs/heroku \ + --header 'Content-Type: application/json' \ + --data '{ + "name": "my-heroku-sync", + "projectId": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "description": "an example sync", + "connectionId": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "environment": "dev", + "secretPath": "/my-secrets", + "isEnabled": true, + "syncOptions": { + "initialSyncBehavior": "overwrite-destination", + "disableSecretDeletion": true + }, + "destinationConfig": { + "app": "8dd25736052a4b50", + "appName": "my-app", + } + }' + ``` + + ### Sample response + + ```bash Response + { + "secretSync": { + "id": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "name": "my-heroku-sync", + "description": "an example sync", + "isEnabled": true, + "version": 1, + "folderId": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "connectionId": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "createdAt": "2023-11-07T05:31:56Z", + "updatedAt": "2023-11-07T05:31:56Z", + "syncStatus": "succeeded", + "lastSyncJobId": "123", + "lastSyncMessage": null, + "lastSyncedAt": "2023-11-07T05:31:56Z", + "importStatus": null, + "lastImportJobId": null, + "lastImportMessage": null, + "lastImportedAt": null, + "removeStatus": null, + "lastRemoveJobId": null, + "lastRemoveMessage": null, + "lastRemovedAt": null, + "syncOptions": { + "initialSyncBehavior": "overwrite-destination" + }, + "projectId": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "connection": { + "app": "heroku", + "name": "my-heroku-connection", + "id": "3c90c3cc-0d44-4b50-8888-8dd25736052a" + }, + "environment": { + "slug": "dev", + "name": "Development", + "id": "3c90c3cc-0d44-4b50-8888-8dd25736052a" + }, + "folder": { + "id": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "path": "/my-secrets" + }, + "destination": "heroku", + "destinationConfig": { + "app": "8dd25736052a4b50", + "appName": "my-app" + } + } + } + ``` + + diff --git a/docs/mint.json b/docs/mint.json index 1af808fea..99ecea86a 100644 --- a/docs/mint.json +++ b/docs/mint.json @@ -1,6 +1,6 @@ { "name": "Infisical", - "openapi": "https://app.infisical.com/api/docs/json", + "openapi": "http://localhost:8080/api/docs/json", "logo": { "dark": "/logo/dark.svg", "light": "/logo/light.svg", @@ -507,6 +507,7 @@ "integrations/app-connections/github", "integrations/app-connections/github-radar", "integrations/app-connections/hashicorp-vault", + "integrations/app-connections/heroku", "integrations/app-connections/humanitec", "integrations/app-connections/ldap", "integrations/app-connections/mssql", @@ -540,6 +541,7 @@ "integrations/secret-syncs/gcp-secret-manager", "integrations/secret-syncs/github", "integrations/secret-syncs/hashicorp-vault", + "integrations/secret-syncs/heroku", "integrations/secret-syncs/humanitec", "integrations/secret-syncs/oci-vault", "integrations/secret-syncs/teamcity", @@ -1310,6 +1312,18 @@ "api-reference/endpoints/app-connections/hashicorp-vault/delete" ] }, + { + "group": "Heroku", + "pages": [ + "api-reference/endpoints/app-connections/heroku/list", + "api-reference/endpoints/app-connections/heroku/available", + "api-reference/endpoints/app-connections/heroku/get-by-id", + "api-reference/endpoints/app-connections/heroku/get-by-name", + "api-reference/endpoints/app-connections/heroku/create", + "api-reference/endpoints/app-connections/heroku/update", + "api-reference/endpoints/app-connections/heroku/delete" + ] + }, { "group": "Humanitec", "pages": [ @@ -1600,6 +1614,19 @@ "api-reference/endpoints/secret-syncs/hashicorp-vault/remove-secrets" ] }, + { + "group": "Heroku", + "pages": [ + "api-reference/endpoints/secret-syncs/heroku/list", + "api-reference/endpoints/secret-syncs/heroku/get-by-id", + "api-reference/endpoints/secret-syncs/heroku/get-by-name", + "api-reference/endpoints/secret-syncs/heroku/create", + "api-reference/endpoints/secret-syncs/heroku/update", + "api-reference/endpoints/secret-syncs/heroku/delete", + "api-reference/endpoints/secret-syncs/heroku/sync-secrets", + "api-reference/endpoints/secret-syncs/heroku/remove-secrets" + ] + }, { "group": "Humanitec", "pages": [ diff --git a/frontend/src/components/secret-syncs/forms/SecretSyncDestinationFields/HerokuSyncFields.tsx b/frontend/src/components/secret-syncs/forms/SecretSyncDestinationFields/HerokuSyncFields.tsx new file mode 100644 index 000000000..008e19d35 --- /dev/null +++ b/frontend/src/components/secret-syncs/forms/SecretSyncDestinationFields/HerokuSyncFields.tsx @@ -0,0 +1,75 @@ +import { Controller, useFormContext, useWatch } from "react-hook-form"; +import { SingleValue } from "react-select"; +import { faCircleInfo } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; + +import { SecretSyncConnectionField } from "@app/components/secret-syncs/forms/SecretSyncConnectionField"; +import { FilterableSelect, FormControl, Tooltip } from "@app/components/v2"; +import { THerokuApp } from "@app/hooks/api/appConnections/heroku"; +import { useHerokuConnectionListApps } from "@app/hooks/api/appConnections/heroku/queries"; +import { SecretSync } from "@app/hooks/api/secretSyncs"; + +import { TSecretSyncForm } from "../schemas"; + +export const HerokuSyncFields = () => { + const { control, setValue } = useFormContext< + TSecretSyncForm & { destination: SecretSync.Heroku } + >(); + + const connectionId = useWatch({ name: "connection.id", control }); + + const { data: apps, isLoading: isAppsLoading } = useHerokuConnectionListApps(connectionId, { + enabled: Boolean(connectionId) + }); + + return ( + <> + { + setValue("destinationConfig.app", ""); + }} + /> + + ( + +
+ Don't see the app you're looking for?{" "} + +
+ + } + > + app.id === value) ?? null} + onChange={(option) => { + onChange((option as SingleValue)?.id ?? ""); + setValue( + "destinationConfig.appName", + (option as SingleValue)?.name ?? "" + ); + }} + options={apps} + placeholder="Select an app..." + getOptionLabel={(option) => option.name} + getOptionValue={(option) => option.id} + /> +
+ )} + /> + + ); +}; diff --git a/frontend/src/components/secret-syncs/forms/SecretSyncDestinationFields/SecretSyncDestinationFields.tsx b/frontend/src/components/secret-syncs/forms/SecretSyncDestinationFields/SecretSyncDestinationFields.tsx index 71405c448..976c33efa 100644 --- a/frontend/src/components/secret-syncs/forms/SecretSyncDestinationFields/SecretSyncDestinationFields.tsx +++ b/frontend/src/components/secret-syncs/forms/SecretSyncDestinationFields/SecretSyncDestinationFields.tsx @@ -14,6 +14,7 @@ import { DatabricksSyncFields } from "./DatabricksSyncFields"; import { GcpSyncFields } from "./GcpSyncFields"; import { GitHubSyncFields } from "./GitHubSyncFields"; import { HCVaultSyncFields } from "./HCVaultSyncFields"; +import { HerokuSyncFields } from "./HerokuSyncFields"; import { HumanitecSyncFields } from "./HumanitecSyncFields"; import { OCIVaultSyncFields } from "./OCIVaultSyncFields"; import { TeamCitySyncFields } from "./TeamCitySyncFields"; @@ -61,6 +62,8 @@ export const SecretSyncDestinationFields = () => { return ; case SecretSync.OnePass: return ; + case SecretSync.Heroku: + return ; default: throw new Error(`Unhandled Destination Config Field: ${destination}`); } diff --git a/frontend/src/components/secret-syncs/forms/SecretSyncOptionsFields/SecretSyncOptionsFields.tsx b/frontend/src/components/secret-syncs/forms/SecretSyncOptionsFields/SecretSyncOptionsFields.tsx index 0f6382edc..d1cc0d74f 100644 --- a/frontend/src/components/secret-syncs/forms/SecretSyncOptionsFields/SecretSyncOptionsFields.tsx +++ b/frontend/src/components/secret-syncs/forms/SecretSyncOptionsFields/SecretSyncOptionsFields.tsx @@ -52,6 +52,7 @@ export const SecretSyncOptionsFields = ({ hideInitialSync }: Props) => { case SecretSync.TeamCity: case SecretSync.OnePass: case SecretSync.OCIVault: + case SecretSync.Heroku: AdditionalSyncOptionsFieldsComponent = null; break; default: diff --git a/frontend/src/components/secret-syncs/forms/SecretSyncReviewFields/HerokuSyncReviewFields.tsx b/frontend/src/components/secret-syncs/forms/SecretSyncReviewFields/HerokuSyncReviewFields.tsx new file mode 100644 index 000000000..2fa55932a --- /dev/null +++ b/frontend/src/components/secret-syncs/forms/SecretSyncReviewFields/HerokuSyncReviewFields.tsx @@ -0,0 +1,18 @@ +import { useFormContext } from "react-hook-form"; + +import { GenericFieldLabel } from "@app/components/secret-syncs"; +import { TSecretSyncForm } from "@app/components/secret-syncs/forms/schemas"; +import { SecretSync } from "@app/hooks/api/secretSyncs"; + +export const HerokuSyncReviewFields = () => { + const { watch } = useFormContext(); + const appName = watch("destinationConfig.appName"); + const appId = watch("destinationConfig.app"); + + return ( + <> + {appName} + {appId} + + ); +}; diff --git a/frontend/src/components/secret-syncs/forms/SecretSyncReviewFields/SecretSyncReviewFields.tsx b/frontend/src/components/secret-syncs/forms/SecretSyncReviewFields/SecretSyncReviewFields.tsx index 854555ffb..437d07d36 100644 --- a/frontend/src/components/secret-syncs/forms/SecretSyncReviewFields/SecretSyncReviewFields.tsx +++ b/frontend/src/components/secret-syncs/forms/SecretSyncReviewFields/SecretSyncReviewFields.tsx @@ -23,6 +23,7 @@ import { DatabricksSyncReviewFields } from "./DatabricksSyncReviewFields"; import { GcpSyncReviewFields } from "./GcpSyncReviewFields"; import { GitHubSyncReviewFields } from "./GitHubSyncReviewFields"; import { HCVaultSyncReviewFields } from "./HCVaultSyncReviewFields"; +import { HerokuSyncReviewFields } from "./HerokuSyncReviewFields"; import { HumanitecSyncReviewFields } from "./HumanitecSyncReviewFields"; import { OCIVaultSyncReviewFields } from "./OCIVaultSyncReviewFields"; import { OnePassSyncReviewFields } from "./OnePassSyncReviewFields"; @@ -104,6 +105,9 @@ export const SecretSyncReviewFields = () => { case SecretSync.OnePass: DestinationFieldsComponent = ; break; + case SecretSync.Heroku: + DestinationFieldsComponent = ; + break; default: throw new Error(`Unhandled Destination Review Fields: ${destination}`); } diff --git a/frontend/src/components/secret-syncs/forms/schemas/heroku-destination-schema.ts b/frontend/src/components/secret-syncs/forms/schemas/heroku-destination-schema.ts new file mode 100644 index 000000000..94cad7b83 --- /dev/null +++ b/frontend/src/components/secret-syncs/forms/schemas/heroku-destination-schema.ts @@ -0,0 +1,14 @@ +import { z } from "zod"; + +import { BaseSecretSyncSchema } from "@app/components/secret-syncs/forms/schemas/base-secret-sync-schema"; +import { SecretSync } from "@app/hooks/api/secretSyncs"; + +export const HerokuSyncDestinationSchema = BaseSecretSyncSchema().merge( + z.object({ + destination: z.literal(SecretSync.Heroku), + destinationConfig: z.object({ + app: z.string().trim().min(1, "App ID required"), + appName: z.string().trim().min(1, "App name required") + }) + }) +); diff --git a/frontend/src/components/secret-syncs/forms/schemas/secret-sync-schema.ts b/frontend/src/components/secret-syncs/forms/schemas/secret-sync-schema.ts index 5dfb643b4..6002e4d2e 100644 --- a/frontend/src/components/secret-syncs/forms/schemas/secret-sync-schema.ts +++ b/frontend/src/components/secret-syncs/forms/schemas/secret-sync-schema.ts @@ -11,6 +11,7 @@ import { DatabricksSyncDestinationSchema } from "./databricks-sync-destination-s import { GcpSyncDestinationSchema } from "./gcp-sync-destination-schema"; import { GitHubSyncDestinationSchema } from "./github-sync-destination-schema"; import { HCVaultSyncDestinationSchema } from "./hc-vault-sync-destination-schema"; +import { HerokuSyncDestinationSchema } from "./heroku-destination-schema"; import { HumanitecSyncDestinationSchema } from "./humanitec-sync-destination-schema"; import { OCIVaultSyncDestinationSchema } from "./oci-vault-sync-destination-schema"; import { TeamCitySyncDestinationSchema } from "./teamcity-sync-destination-schema"; @@ -35,7 +36,8 @@ const SecretSyncUnionSchema = z.discriminatedUnion("destination", [ HCVaultSyncDestinationSchema, TeamCitySyncDestinationSchema, OCIVaultSyncDestinationSchema, - OnePassSyncDestinationSchema + OnePassSyncDestinationSchema, + HerokuSyncDestinationSchema ]); export const SecretSyncFormSchema = SecretSyncUnionSchema; diff --git a/frontend/src/helpers/appConnections.ts b/frontend/src/helpers/appConnections.ts index 7a87de58c..4c16c429d 100644 --- a/frontend/src/helpers/appConnections.ts +++ b/frontend/src/helpers/appConnections.ts @@ -36,6 +36,7 @@ import { VercelConnectionMethod, WindmillConnectionMethod } from "@app/hooks/api/appConnections/types"; +import { HerokuConnectionMethod } from "@app/hooks/api/appConnections/types/heroku-connection"; import { OCIConnectionMethod } from "@app/hooks/api/appConnections/types/oci-connection"; export const APP_CONNECTION_MAP: Record< @@ -78,7 +79,8 @@ export const APP_CONNECTION_MAP: Record< [AppConnection.LDAP]: { name: "LDAP", image: "LDAP.png", size: 65 }, [AppConnection.TeamCity]: { name: "TeamCity", image: "TeamCity.png" }, [AppConnection.OCI]: { name: "OCI", image: "Oracle.png", enterprise: true }, - [AppConnection.OnePass]: { name: "1Password", image: "1Password.png" } + [AppConnection.OnePass]: { name: "1Password", image: "1Password.png" }, + [AppConnection.Heroku]: { name: "Heroku", image: "Heroku.png" } }; export const getAppConnectionMethodDetails = (method: TAppConnection["method"]) => { @@ -91,6 +93,7 @@ export const getAppConnectionMethodDetails = (method: TAppConnection["method"]) case AzureClientSecretsConnectionMethod.OAuth: case AzureDevOpsConnectionMethod.OAuth: case GitHubConnectionMethod.OAuth: + case HerokuConnectionMethod.OAuth: return { name: "OAuth", icon: faPassport }; case AwsConnectionMethod.AccessKey: case OCIConnectionMethod.AccessKey: @@ -124,6 +127,8 @@ export const getAppConnectionMethodDetails = (method: TAppConnection["method"]) return { name: "App Role", icon: faUser }; case LdapConnectionMethod.SimpleBind: return { name: "Simple Bind", icon: faLink }; + case HerokuConnectionMethod.AuthToken: + return { name: "Auth Token", icon: faKey }; default: throw new Error(`Unhandled App Connection Method: ${method}`); } diff --git a/frontend/src/helpers/secretSyncs.ts b/frontend/src/helpers/secretSyncs.ts index 98a519e33..477f92425 100644 --- a/frontend/src/helpers/secretSyncs.ts +++ b/frontend/src/helpers/secretSyncs.ts @@ -60,6 +60,10 @@ export const SECRET_SYNC_MAP: Record = { [SecretSync.HCVault]: AppConnection.HCVault, [SecretSync.TeamCity]: AppConnection.TeamCity, [SecretSync.OCIVault]: AppConnection.OCI, - [SecretSync.OnePass]: AppConnection.OnePass + [SecretSync.OnePass]: AppConnection.OnePass, + [SecretSync.Heroku]: AppConnection.Heroku }; export const SECRET_SYNC_INITIAL_SYNC_BEHAVIOR_MAP: Record< diff --git a/frontend/src/hooks/api/appConnections/enums.ts b/frontend/src/hooks/api/appConnections/enums.ts index 1a3afe355..144cef776 100644 --- a/frontend/src/hooks/api/appConnections/enums.ts +++ b/frontend/src/hooks/api/appConnections/enums.ts @@ -22,5 +22,6 @@ export enum AppConnection { LDAP = "ldap", TeamCity = "teamcity", OCI = "oci", - OnePass = "1password" + OnePass = "1password", + Heroku = "heroku" } diff --git a/frontend/src/hooks/api/appConnections/heroku/index.ts b/frontend/src/hooks/api/appConnections/heroku/index.ts new file mode 100644 index 000000000..2c1906d36 --- /dev/null +++ b/frontend/src/hooks/api/appConnections/heroku/index.ts @@ -0,0 +1,2 @@ +export * from "./queries"; +export * from "./types"; diff --git a/frontend/src/hooks/api/appConnections/heroku/queries.tsx b/frontend/src/hooks/api/appConnections/heroku/queries.tsx new file mode 100644 index 000000000..51b5aeb73 --- /dev/null +++ b/frontend/src/hooks/api/appConnections/heroku/queries.tsx @@ -0,0 +1,36 @@ +import { useQuery, UseQueryOptions } from "@tanstack/react-query"; + +import { apiRequest } from "@app/config/request"; + +import { appConnectionKeys } from "../queries"; +import { THerokuApp } from "./types"; + +const herokuConnectionKeys = { + all: [...appConnectionKeys.all, "heroku"] as const, + listApps: (connectionId: string) => [...herokuConnectionKeys.all, "apps", connectionId] as const +}; + +export const useHerokuConnectionListApps = ( + connectionId: string, + options?: Omit< + UseQueryOptions< + THerokuApp[], + unknown, + THerokuApp[], + ReturnType + >, + "queryKey" | "queryFn" + > +) => { + return useQuery({ + queryKey: herokuConnectionKeys.listApps(connectionId), + queryFn: async () => { + const { data } = await apiRequest.get( + `/api/v1/app-connections/heroku/${connectionId}/apps` + ); + + return data; + }, + ...options + }); +}; diff --git a/frontend/src/hooks/api/appConnections/heroku/types.ts b/frontend/src/hooks/api/appConnections/heroku/types.ts new file mode 100644 index 000000000..24b1b76f2 --- /dev/null +++ b/frontend/src/hooks/api/appConnections/heroku/types.ts @@ -0,0 +1,4 @@ +export type THerokuApp = { + id: string; + name: string; +}; diff --git a/frontend/src/hooks/api/appConnections/types/app-options.ts b/frontend/src/hooks/api/appConnections/types/app-options.ts index 0dcb11fa6..cd5323c3c 100644 --- a/frontend/src/hooks/api/appConnections/types/app-options.ts +++ b/frontend/src/hooks/api/appConnections/types/app-options.ts @@ -106,6 +106,11 @@ export type TOCIConnectionOption = TAppConnectionOptionBase & { app: AppConnection.OCI; }; +export type THerokuConnectionOption = TAppConnectionOptionBase & { + app: AppConnection.Heroku; + oauthClientId?: string; +}; + export type TOnePassConnectionOption = TAppConnectionOptionBase & { app: AppConnection.OnePass; }; @@ -132,7 +137,8 @@ export type TAppConnectionOption = | THCVaultConnectionOption | TTeamCityConnectionOption | TOCIConnectionOption - | TOnePassConnectionOption; + | TOnePassConnectionOption + | THerokuConnectionOption; export type TAppConnectionOptionMap = { [AppConnection.AWS]: TAwsConnectionOption; @@ -159,4 +165,5 @@ export type TAppConnectionOptionMap = { [AppConnection.TeamCity]: TTeamCityConnectionOption; [AppConnection.OCI]: TOCIConnectionOption; [AppConnection.OnePass]: TOnePassConnectionOption; + [AppConnection.Heroku]: THerokuConnectionOption; }; diff --git a/frontend/src/hooks/api/appConnections/types/heroku-connection.ts b/frontend/src/hooks/api/appConnections/types/heroku-connection.ts new file mode 100644 index 000000000..11d0b80e2 --- /dev/null +++ b/frontend/src/hooks/api/appConnections/types/heroku-connection.ts @@ -0,0 +1,22 @@ +import { AppConnection } from "@app/hooks/api/appConnections/enums"; +import { TRootAppConnection } from "@app/hooks/api/appConnections/types/root-connection"; + +export enum HerokuConnectionMethod { + AuthToken = "auth-token", + OAuth = "oauth" +} + +export type THerokuConnection = TRootAppConnection & { app: AppConnection.Heroku } & ( + | { + method: HerokuConnectionMethod.AuthToken; + credentials: { + authToken: string; + }; + } + | { + method: HerokuConnectionMethod.OAuth; + credentials: { + code: string; + }; + } + ); diff --git a/frontend/src/hooks/api/appConnections/types/index.ts b/frontend/src/hooks/api/appConnections/types/index.ts index 890562b6b..e5b5569c9 100644 --- a/frontend/src/hooks/api/appConnections/types/index.ts +++ b/frontend/src/hooks/api/appConnections/types/index.ts @@ -13,6 +13,7 @@ import { TGcpConnection } from "./gcp-connection"; import { TGitHubConnection } from "./github-connection"; import { TGitHubRadarConnection } from "./github-radar-connection"; import { THCVaultConnection } from "./hc-vault-connection"; +import { THerokuConnection } from "./heroku-connection"; import { THumanitecConnection } from "./humanitec-connection"; import { TLdapConnection } from "./ldap-connection"; import { TMsSqlConnection } from "./mssql-connection"; @@ -38,6 +39,7 @@ export * from "./gcp-connection"; export * from "./github-connection"; export * from "./github-radar-connection"; export * from "./hc-vault-connection"; +export * from "./heroku-connection"; export * from "./humanitec-connection"; export * from "./ldap-connection"; export * from "./mssql-connection"; @@ -74,7 +76,8 @@ export type TAppConnection = | TLdapConnection | TTeamCityConnection | TOCIConnection - | TOnePassConnection; + | TOnePassConnection + | THerokuConnection; export type TAvailableAppConnection = Pick; @@ -126,4 +129,5 @@ export type TAppConnectionMap = { [AppConnection.TeamCity]: TTeamCityConnection; [AppConnection.OCI]: TOCIConnection; [AppConnection.OnePass]: TOnePassConnection; + [AppConnection.Heroku]: THerokuConnection; }; diff --git a/frontend/src/hooks/api/secretSyncs/enums.ts b/frontend/src/hooks/api/secretSyncs/enums.ts index f381b3fca..a0f2b6135 100644 --- a/frontend/src/hooks/api/secretSyncs/enums.ts +++ b/frontend/src/hooks/api/secretSyncs/enums.ts @@ -15,7 +15,8 @@ export enum SecretSync { HCVault = "hashicorp-vault", TeamCity = "teamcity", OCIVault = "oci-vault", - OnePass = "1password" + OnePass = "1password", + Heroku = "heroku" } export enum SecretSyncStatus { diff --git a/frontend/src/hooks/api/secretSyncs/types/heroku-sync.ts b/frontend/src/hooks/api/secretSyncs/types/heroku-sync.ts new file mode 100644 index 000000000..b2a829d41 --- /dev/null +++ b/frontend/src/hooks/api/secretSyncs/types/heroku-sync.ts @@ -0,0 +1,16 @@ +import { AppConnection } from "@app/hooks/api/appConnections/enums"; +import { SecretSync } from "@app/hooks/api/secretSyncs"; +import { TRootSecretSync } from "@app/hooks/api/secretSyncs/types/root-sync"; + +export type THerokuSync = TRootSecretSync & { + destination: SecretSync.Heroku; + destinationConfig: { + app: string; + appName: string; + }; + connection: { + app: AppConnection.Heroku; + name: string; + id: string; + }; +}; diff --git a/frontend/src/hooks/api/secretSyncs/types/index.ts b/frontend/src/hooks/api/secretSyncs/types/index.ts index 4f447f9df..bbc054ffd 100644 --- a/frontend/src/hooks/api/secretSyncs/types/index.ts +++ b/frontend/src/hooks/api/secretSyncs/types/index.ts @@ -12,6 +12,7 @@ import { TDatabricksSync } from "./databricks-sync"; import { TGcpSync } from "./gcp-sync"; import { TGitHubSync } from "./github-sync"; import { THCVaultSync } from "./hc-vault-sync"; +import { THerokuSync } from "./heroku-sync"; import { THumanitecSync } from "./humanitec-sync"; import { TOCIVaultSync } from "./oci-vault-sync"; import { TTeamCitySync } from "./teamcity-sync"; @@ -43,7 +44,8 @@ export type TSecretSync = | THCVaultSync | TTeamCitySync | TOCIVaultSync - | TOnePassSync; + | TOnePassSync + | THerokuSync; export type TListSecretSyncs = { secretSyncs: TSecretSync[] }; diff --git a/frontend/src/pages/organization/AppConnections/AppConnectionsPage/components/AppConnectionForm/AppConnectionForm.tsx b/frontend/src/pages/organization/AppConnections/AppConnectionsPage/components/AppConnectionForm/AppConnectionForm.tsx index 052ff18a7..5811f1502 100644 --- a/frontend/src/pages/organization/AppConnections/AppConnectionsPage/components/AppConnectionForm/AppConnectionForm.tsx +++ b/frontend/src/pages/organization/AppConnections/AppConnectionsPage/components/AppConnectionForm/AppConnectionForm.tsx @@ -22,6 +22,7 @@ import { GcpConnectionForm } from "./GcpConnectionForm"; import { GitHubConnectionForm } from "./GitHubConnectionForm"; import { GitHubRadarConnectionForm } from "./GitHubRadarConnectionForm"; import { HCVaultConnectionForm } from "./HCVaultConnectionForm"; +import { HerokuConnectionForm } from "./HerokuAppConnectionForm"; import { HumanitecConnectionForm } from "./HumanitecConnectionForm"; import { LdapConnectionForm } from "./LdapConnectionForm"; import { MsSqlConnectionForm } from "./MsSqlConnectionForm"; @@ -119,6 +120,8 @@ const CreateForm = ({ app, onComplete }: CreateFormProps) => { return ; case AppConnection.OnePass: return ; + case AppConnection.Heroku: + return ; default: throw new Error(`Unhandled App ${app}`); } @@ -203,6 +206,8 @@ const UpdateForm = ({ appConnection, onComplete }: UpdateFormProps) => { return ; case AppConnection.OnePass: return ; + case AppConnection.Heroku: + return ; default: throw new Error(`Unhandled App ${(appConnection as TAppConnection).app}`); } diff --git a/frontend/src/pages/organization/AppConnections/AppConnectionsPage/components/AppConnectionForm/HerokuAppConnectionForm.tsx b/frontend/src/pages/organization/AppConnections/AppConnectionsPage/components/AppConnectionForm/HerokuAppConnectionForm.tsx new file mode 100644 index 000000000..c464e866e --- /dev/null +++ b/frontend/src/pages/organization/AppConnections/AppConnectionsPage/components/AppConnectionForm/HerokuAppConnectionForm.tsx @@ -0,0 +1,246 @@ +/* eslint-disable no-case-declarations */ +/* eslint-disable no-nested-ternary */ +import crypto from "crypto"; + +import { useState } from "react"; +import { Controller, FormProvider, useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { z } from "zod"; + +import { + Button, + FormControl, + ModalClose, + SecretInput, + Select, + SelectItem +} from "@app/components/v2"; +import { APP_CONNECTION_MAP, getAppConnectionMethodDetails } from "@app/helpers/appConnections"; +import { isInfisicalCloud } from "@app/helpers/platform"; +import { useGetAppConnectionOption } from "@app/hooks/api/appConnections"; +import { AppConnection } from "@app/hooks/api/appConnections/enums"; +import { + HerokuConnectionMethod, + THerokuConnection +} from "@app/hooks/api/appConnections/types/heroku-connection"; + +import { + genericAppConnectionFieldsSchema, + GenericAppConnectionsFields +} from "./GenericAppConnectionFields"; + +type Props = { + appConnection?: THerokuConnection; + onSubmit: (formData: FormData) => Promise; +}; + +const formSchema = z.discriminatedUnion("method", [ + genericAppConnectionFieldsSchema.extend({ + app: z.literal(AppConnection.Heroku), + method: z.literal(HerokuConnectionMethod.AuthToken), + credentials: z.object({ + authToken: z.string().min(1, "Auth token is required") + }) + }), + genericAppConnectionFieldsSchema.extend({ + app: z.literal(AppConnection.Heroku), + method: z.literal(HerokuConnectionMethod.OAuth), + credentials: z.object({ + code: z.string().min(1, "Code is required") + }) + }) +]); + +type FormData = z.infer; + +export const HerokuConnectionForm = ({ appConnection, onSubmit: formSubmit }: Props) => { + const isUpdate = Boolean(appConnection); + const [isRedirecting, setIsRedirecting] = useState(false); + + const { + option: { oauthClientId }, + isLoading + } = useGetAppConnectionOption(AppConnection.Heroku); + + const form = useForm({ + resolver: zodResolver(formSchema), + defaultValues: + appConnection?.method === HerokuConnectionMethod.OAuth + ? { ...appConnection, credentials: { code: "custom" } } + : (appConnection ?? + ({ + app: AppConnection.Heroku, + method: HerokuConnectionMethod.AuthToken, + credentials: { + authToken: "" + } + } as FormData)) + }); + + const { + handleSubmit, + control, + watch, + formState: { isSubmitting, isDirty } + } = form; + + const selectedMethod = watch("method"); + + const onSubmit = async (formData: FormData) => { + try { + switch (formData.method) { + case HerokuConnectionMethod.AuthToken: + await formSubmit(formData); + break; + + case HerokuConnectionMethod.OAuth: + if (!oauthClientId) { + return; + } + setIsRedirecting(true); + + // Generate CSRF token + const state = crypto.randomBytes(16).toString("hex"); + + // Store state and form data for callback + localStorage.setItem("latestCSRFToken", state); + localStorage.setItem( + "herokuConnectionFormData", + JSON.stringify({ + ...formData, + connectionId: appConnection?.id, + isUpdate + }) + ); + + // Redirect to Heroku OAuh + const oauthUrl = new URL("https://id.heroku.com/oauth/authorize"); + oauthUrl.searchParams.set("client_id", oauthClientId); + oauthUrl.searchParams.set("response_type", "code"); + oauthUrl.searchParams.set("scope", "write-protected"); + oauthUrl.searchParams.set("state", state); + + window.location.assign(oauthUrl.toString()); + break; + + default: + throw new Error("Unhandled Heroku Connection method"); + } + } catch (error) { + console.error("Error handling form submission:", error); + setIsRedirecting(false); + } + }; + + let isMissingConfig: boolean; + + switch (selectedMethod) { + case HerokuConnectionMethod.OAuth: + isMissingConfig = !oauthClientId; + break; + case HerokuConnectionMethod.AuthToken: + isMissingConfig = false; + break; + default: + throw new Error(`Unhandled Heroku Connection method: ${selectedMethod}`); + } + + const methodDetails = getAppConnectionMethodDetails(selectedMethod); + + return ( + +
+ {!isUpdate && } + + ( + + + + )} + /> + + {selectedMethod === HerokuConnectionMethod.AuthToken && ( + ( + + onChange(e.target.value)} + /> + + )} + /> + )} + +
+ + + + +
+ +
+ ); +}; diff --git a/frontend/src/pages/secret-manager/IntegrationsListPage/components/SecretSyncsTab/SecretSyncTable/SecretSyncDestinationCol/HerokuSyncDestinationCol.tsx b/frontend/src/pages/secret-manager/IntegrationsListPage/components/SecretSyncsTab/SecretSyncTable/SecretSyncDestinationCol/HerokuSyncDestinationCol.tsx new file mode 100644 index 000000000..cd29c5b63 --- /dev/null +++ b/frontend/src/pages/secret-manager/IntegrationsListPage/components/SecretSyncsTab/SecretSyncTable/SecretSyncDestinationCol/HerokuSyncDestinationCol.tsx @@ -0,0 +1,14 @@ +import { THerokuSync } from "@app/hooks/api/secretSyncs/types/heroku-sync"; + +import { getSecretSyncDestinationColValues } from "../helpers"; +import { SecretSyncTableCell } from "../SecretSyncTableCell"; + +type Props = { + secretSync: THerokuSync; +}; + +export const HerokuSyncDestinationCol = ({ secretSync }: Props) => { + const { primaryText, secondaryText } = getSecretSyncDestinationColValues(secretSync); + + return ; +}; diff --git a/frontend/src/pages/secret-manager/IntegrationsListPage/components/SecretSyncsTab/SecretSyncTable/SecretSyncDestinationCol/SecretSyncDestinationCol.tsx b/frontend/src/pages/secret-manager/IntegrationsListPage/components/SecretSyncsTab/SecretSyncTable/SecretSyncDestinationCol/SecretSyncDestinationCol.tsx index f342c361c..7ebaef73d 100644 --- a/frontend/src/pages/secret-manager/IntegrationsListPage/components/SecretSyncsTab/SecretSyncTable/SecretSyncDestinationCol/SecretSyncDestinationCol.tsx +++ b/frontend/src/pages/secret-manager/IntegrationsListPage/components/SecretSyncsTab/SecretSyncTable/SecretSyncDestinationCol/SecretSyncDestinationCol.tsx @@ -11,6 +11,7 @@ import { DatabricksSyncDestinationCol } from "./DatabricksSyncDestinationCol"; import { GcpSyncDestinationCol } from "./GcpSyncDestinationCol"; import { GitHubSyncDestinationCol } from "./GitHubSyncDestinationCol"; import { HCVaultSyncDestinationCol } from "./HCVaultSyncDestinationCol"; +import { HerokuSyncDestinationCol } from "./HerokuSyncDestinationCol"; import { HumanitecSyncDestinationCol } from "./HumanitecSyncDestinationCol"; import { OCIVaultSyncDestinationCol } from "./OCIVaultSyncDestinationCol"; import { TeamCitySyncDestinationCol } from "./TeamCitySyncDestinationCol"; @@ -58,6 +59,8 @@ export const SecretSyncDestinationCol = ({ secretSync }: Props) => { return ; case SecretSync.AzureDevOps: return ; + case SecretSync.Heroku: + return ; default: throw new Error( `Unhandled Secret Sync Destination Col: ${(secretSync as TSecretSync).destination}` diff --git a/frontend/src/pages/secret-manager/IntegrationsListPage/components/SecretSyncsTab/SecretSyncTable/helpers/index.ts b/frontend/src/pages/secret-manager/IntegrationsListPage/components/SecretSyncsTab/SecretSyncTable/helpers/index.ts index 3c1056382..8cc5f956b 100644 --- a/frontend/src/pages/secret-manager/IntegrationsListPage/components/SecretSyncsTab/SecretSyncTable/helpers/index.ts +++ b/frontend/src/pages/secret-manager/IntegrationsListPage/components/SecretSyncsTab/SecretSyncTable/helpers/index.ts @@ -116,6 +116,10 @@ export const getSecretSyncDestinationColValues = (secretSync: TSecretSync) => { primaryText = destinationConfig.devopsProjectName; secondaryText = destinationConfig.devopsProjectId; break; + case SecretSync.Heroku: + primaryText = destinationConfig.appName; + secondaryText = destinationConfig.app; + break; default: throw new Error(`Unhandled Destination Col Values ${destination}`); } diff --git a/frontend/src/pages/secret-manager/SecretSyncDetailsByIDPage/components/SecretSyncDestinationSection/HerokuSyncDestinationSection.tsx b/frontend/src/pages/secret-manager/SecretSyncDetailsByIDPage/components/SecretSyncDestinationSection/HerokuSyncDestinationSection.tsx new file mode 100644 index 000000000..a850d13e1 --- /dev/null +++ b/frontend/src/pages/secret-manager/SecretSyncDetailsByIDPage/components/SecretSyncDestinationSection/HerokuSyncDestinationSection.tsx @@ -0,0 +1,19 @@ +import { GenericFieldLabel } from "@app/components/secret-syncs"; +import { THerokuSync } from "@app/hooks/api/secretSyncs/types/heroku-sync"; + +type Props = { + secretSync: THerokuSync; +}; + +export const HerokuSyncDestinationSection = ({ secretSync }: Props) => { + const { + destinationConfig: { app, appName } + } = secretSync; + + return ( + <> + {appName} + {app} + + ); +}; diff --git a/frontend/src/pages/secret-manager/SecretSyncDetailsByIDPage/components/SecretSyncDestinationSection/SecretSyncDestinatonSection.tsx b/frontend/src/pages/secret-manager/SecretSyncDetailsByIDPage/components/SecretSyncDestinationSection/SecretSyncDestinatonSection.tsx index 24cc3908a..6d904be3b 100644 --- a/frontend/src/pages/secret-manager/SecretSyncDetailsByIDPage/components/SecretSyncDestinationSection/SecretSyncDestinatonSection.tsx +++ b/frontend/src/pages/secret-manager/SecretSyncDetailsByIDPage/components/SecretSyncDestinationSection/SecretSyncDestinatonSection.tsx @@ -21,6 +21,7 @@ import { DatabricksSyncDestinationSection } from "./DatabricksSyncDestinationSec import { GcpSyncDestinationSection } from "./GcpSyncDestinationSection"; import { GitHubSyncDestinationSection } from "./GitHubSyncDestinationSection"; import { HCVaultSyncDestinationSection } from "./HCVaultSyncDestinationSection"; +import { HerokuSyncDestinationSection } from "./HerokuSyncDestinationSection"; import { HumanitecSyncDestinationSection } from "./HumanitecSyncDestinationSection"; import { OCIVaultSyncDestinationSection } from "./OCIVaultSyncDestinationSection"; import { TeamCitySyncDestinationSection } from "./TeamCitySyncDestinationSection"; @@ -93,6 +94,9 @@ export const SecretSyncDestinationSection = ({ secretSync, onEditDestination }: case SecretSync.AzureDevOps: DestinationComponents = ; break; + case SecretSync.Heroku: + DestinationComponents = ; + break; default: throw new Error(`Unhandled Destination Section components: ${destination}`); } diff --git a/frontend/src/pages/secret-manager/SecretSyncDetailsByIDPage/components/SecretSyncOptionsSection/SecretSyncOptionsSection.tsx b/frontend/src/pages/secret-manager/SecretSyncDetailsByIDPage/components/SecretSyncOptionsSection/SecretSyncOptionsSection.tsx index e02d2ff84..65712983a 100644 --- a/frontend/src/pages/secret-manager/SecretSyncDetailsByIDPage/components/SecretSyncOptionsSection/SecretSyncOptionsSection.tsx +++ b/frontend/src/pages/secret-manager/SecretSyncDetailsByIDPage/components/SecretSyncOptionsSection/SecretSyncOptionsSection.tsx @@ -52,6 +52,7 @@ export const SecretSyncOptionsSection = ({ secretSync, onEditOptions }: Props) = case SecretSync.TeamCity: case SecretSync.OCIVault: case SecretSync.OnePass: + case SecretSync.Heroku: AdditionalSyncOptionsComponent = null; break; default: diff --git a/frontend/src/pages/secret-manager/integrations/HerokuOauthCallbackPage/HerokuOauthCallbackPage.tsx b/frontend/src/pages/secret-manager/integrations/HerokuOauthCallbackPage/HerokuOauthCallbackPage.tsx index 33e7b79b0..226f84cc7 100644 --- a/frontend/src/pages/secret-manager/integrations/HerokuOauthCallbackPage/HerokuOauthCallbackPage.tsx +++ b/frontend/src/pages/secret-manager/integrations/HerokuOauthCallbackPage/HerokuOauthCallbackPage.tsx @@ -3,43 +3,99 @@ import { useNavigate, useSearch } from "@tanstack/react-router"; import { ROUTE_PATHS } from "@app/const/routes"; import { useWorkspace } from "@app/context"; -import { useAuthorizeIntegration } from "@app/hooks/api"; +import { useCreateAppConnection, useUpdateAppConnection } from "@app/hooks/api/appConnections"; +import { HerokuConnectionMethod } from "@app/hooks/api/appConnections/types/heroku-connection"; export const HerokuOAuthCallbackPage = () => { const navigate = useNavigate(); - const { mutateAsync } = useAuthorizeIntegration(); + const { mutateAsync: createAppConnection } = useCreateAppConnection(); + const { mutateAsync: updateAppConnection } = useUpdateAppConnection(); const { code, state } = useSearch({ from: ROUTE_PATHS.SecretManager.Integratons.HerokuOauthCallbackPage.id }); + const { currentWorkspace } = useWorkspace(); useEffect(() => { (async () => { try { - // validate state - if (state !== localStorage.getItem("latestCSRFToken")) return; - localStorage.removeItem("latestCSRFToken"); - const integrationAuth = await mutateAsync({ - workspaceId: currentWorkspace.id, - code: code as string, - integration: "heroku" - }); + // Validate CSRF state token + const storedState = localStorage.getItem("latestCSRFToken"); + if (state !== storedState) { + console.error("CSRF token mismatch"); + navigate({ + to: "/organization/app-connections", + search: { error: "invalid_state" } + }); + return; + } + // Clean up CSRF token + localStorage.removeItem("latestCSRFToken"); + + // Retrieve stored form data + const storedFormData = localStorage.getItem("herokuConnectionFormData"); + if (!storedFormData) { + console.error("No stored form data found"); + navigate({ + to: "/organization/app-connections", + search: { error: "missing_form_data" } + }); + return; + } + + const formData = JSON.parse(storedFormData); + localStorage.removeItem("herokuConnectionFormData"); + + // Prepare app connection data with OAuth credentials + const connectionData = { + ...formData, + method: HerokuConnectionMethod.OAuth, + credentials: { + code: code as string + } + }; + + let appConnection; + + // Create or update app connection + if (formData.isUpdate && formData.connectionId) { + appConnection = await updateAppConnection({ + connectionId: formData.connectionId, + ...connectionData + }); + } else { + appConnection = await createAppConnection({ + workspaceId: currentWorkspace.id, + ...connectionData + }); + } + + // Navigate to success page or app connections list navigate({ - to: "/secret-manager/$projectId/integrations/heroku/create", - params: { - projectId: currentWorkspace.id - }, + to: "/organization/app-connections", search: { - integrationAuthId: integrationAuth.id + success: formData.isUpdate ? "connection_updated" : "connection_created", + connectionId: appConnection.id } }); } catch (err) { - console.error(err); + console.error("Error handling Heroku OAuth callback:", err); + navigate({ + to: "/organization/app-connections", + search: { error: "connection_failed" } + }); } })(); - }, []); + }, [code, state, navigate, createAppConnection, updateAppConnection, currentWorkspace.id]); - return
; + return ( +
+
+
+

Connecting to Heroku...

+
+
+ ); };