mirror of
https://github.com/awatertrevi/infisical.git
synced 2026-10-14 08:29:55 +00:00
Merge remote-tracking branch 'origin/main' into feat/add-cloudflare-app-connection-and-sync
This commit is contained in:
@@ -50,6 +50,8 @@ export const initDbConnection = ({
|
||||
}
|
||||
: false
|
||||
},
|
||||
// https://knexjs.org/guide/#pool
|
||||
pool: { min: 0, max: 10 },
|
||||
migrations: {
|
||||
tableName: "infisical_migrations"
|
||||
}
|
||||
@@ -70,7 +72,8 @@ export const initDbConnection = ({
|
||||
},
|
||||
migrations: {
|
||||
tableName: "infisical_migrations"
|
||||
}
|
||||
},
|
||||
pool: { min: 0, max: 10 }
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -128,11 +128,21 @@ export const AwsIamProvider = (): TDynamicProviderFns => {
|
||||
|
||||
const username = generateUsername(usernameTemplate, identity);
|
||||
const { policyArns, userGroups, policyDocument, awsPath, permissionBoundaryPolicyArn } = providerInputs;
|
||||
const awsTags = [{ Key: "createdBy", Value: "infisical-dynamic-secret" }];
|
||||
|
||||
if (providerInputs.tags && Array.isArray(providerInputs.tags)) {
|
||||
const additionalTags = providerInputs.tags.map((tag) => ({
|
||||
Key: tag.key,
|
||||
Value: tag.value
|
||||
}));
|
||||
awsTags.push(...additionalTags);
|
||||
}
|
||||
|
||||
const createUserRes = await client.send(
|
||||
new CreateUserCommand({
|
||||
Path: awsPath,
|
||||
PermissionsBoundary: permissionBoundaryPolicyArn || undefined,
|
||||
Tags: [{ Key: "createdBy", Value: "infisical-dynamic-secret" }],
|
||||
Tags: awsTags,
|
||||
UserName: username
|
||||
})
|
||||
);
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
import axios from "axios";
|
||||
import * as jwt from "jsonwebtoken";
|
||||
|
||||
import { BadRequestError, InternalServerError } from "@app/lib/errors";
|
||||
import { alphaNumericNanoId } from "@app/lib/nanoid";
|
||||
import { IntegrationUrls } from "@app/services/integration-auth/integration-list";
|
||||
|
||||
import { DynamicSecretGithubSchema, TDynamicProviderFns } from "./models";
|
||||
|
||||
interface GitHubInstallationTokenResponse {
|
||||
token: string;
|
||||
expires_at: string; // ISO 8601 timestamp e.g., "2024-01-15T12:00:00Z"
|
||||
permissions?: Record<string, string>;
|
||||
repository_selection?: string;
|
||||
}
|
||||
|
||||
interface TGithubProviderInputs {
|
||||
appId: number;
|
||||
installationId: number;
|
||||
privateKey: string;
|
||||
}
|
||||
|
||||
export const GithubProvider = (): TDynamicProviderFns => {
|
||||
const validateProviderInputs = async (inputs: unknown) => {
|
||||
const providerInputs = await DynamicSecretGithubSchema.parseAsync(inputs);
|
||||
return providerInputs;
|
||||
};
|
||||
|
||||
const $generateGitHubInstallationAccessToken = async (
|
||||
credentials: TGithubProviderInputs
|
||||
): Promise<GitHubInstallationTokenResponse> => {
|
||||
const { appId, installationId, privateKey } = credentials;
|
||||
|
||||
const nowInSeconds = Math.floor(Date.now() / 1000);
|
||||
const jwtPayload = {
|
||||
iat: nowInSeconds - 5,
|
||||
exp: nowInSeconds + 60,
|
||||
iss: String(appId)
|
||||
};
|
||||
|
||||
let appJwt: string;
|
||||
try {
|
||||
appJwt = jwt.sign(jwtPayload, privateKey, { algorithm: "RS256" });
|
||||
} catch (error) {
|
||||
let message = "Failed to sign JWT.";
|
||||
if (error instanceof jwt.JsonWebTokenError) {
|
||||
message += ` JsonWebTokenError: ${error.message}`;
|
||||
}
|
||||
throw new InternalServerError({
|
||||
message
|
||||
});
|
||||
}
|
||||
|
||||
const tokenUrl = `${IntegrationUrls.GITHUB_API_URL}/app/installations/${String(installationId)}/access_tokens`;
|
||||
|
||||
try {
|
||||
const response = await axios.post<GitHubInstallationTokenResponse>(tokenUrl, undefined, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${appJwt}`,
|
||||
Accept: "application/vnd.github.v3+json",
|
||||
"X-GitHub-Api-Version": "2022-11-28"
|
||||
}
|
||||
});
|
||||
|
||||
if (response.status === 201 && response.data.token) {
|
||||
return response.data; // Includes token, expires_at, permissions, repository_selection
|
||||
}
|
||||
|
||||
throw new InternalServerError({
|
||||
message: `GitHub API responded with unexpected status ${response.status}: ${JSON.stringify(response.data)}`
|
||||
});
|
||||
} catch (error) {
|
||||
let message = "Failed to fetch GitHub installation access token.";
|
||||
if (axios.isAxiosError(error) && error.response) {
|
||||
const githubErrorMsg =
|
||||
(error.response.data as { message?: string })?.message || JSON.stringify(error.response.data);
|
||||
message += ` GitHub API Error: ${error.response.status} - ${githubErrorMsg}`;
|
||||
|
||||
// Classify as BadRequestError for auth-related issues (401, 403, 404) which might be due to user input
|
||||
if ([401, 403, 404].includes(error.response.status)) {
|
||||
throw new BadRequestError({ message });
|
||||
}
|
||||
}
|
||||
|
||||
throw new InternalServerError({ message });
|
||||
}
|
||||
};
|
||||
|
||||
const validateConnection = async (inputs: unknown) => {
|
||||
const providerInputs = await validateProviderInputs(inputs);
|
||||
await $generateGitHubInstallationAccessToken(providerInputs);
|
||||
return true;
|
||||
};
|
||||
|
||||
const create = async (data: { inputs: unknown }) => {
|
||||
const { inputs } = data;
|
||||
const providerInputs = await validateProviderInputs(inputs);
|
||||
|
||||
const ghTokenData = await $generateGitHubInstallationAccessToken(providerInputs);
|
||||
const entityId = alphaNumericNanoId(32);
|
||||
|
||||
return {
|
||||
entityId,
|
||||
data: {
|
||||
TOKEN: ghTokenData.token,
|
||||
EXPIRES_AT: ghTokenData.expires_at,
|
||||
PERMISSIONS: ghTokenData.permissions,
|
||||
REPOSITORY_SELECTION: ghTokenData.repository_selection
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
const revoke = async () => {
|
||||
// GitHub installation tokens cannot be revoked.
|
||||
throw new BadRequestError({
|
||||
message:
|
||||
"Github dynamic secret does not support revocation because GitHub itself cannot revoke installation tokens"
|
||||
});
|
||||
};
|
||||
|
||||
const renew = async () => {
|
||||
// No renewal
|
||||
throw new BadRequestError({ message: "Github dynamic secret does not support renewal" });
|
||||
};
|
||||
|
||||
return {
|
||||
validateProviderInputs,
|
||||
validateConnection,
|
||||
create,
|
||||
revoke,
|
||||
renew
|
||||
};
|
||||
};
|
||||
@@ -7,6 +7,7 @@ import { AzureEntraIDProvider } from "./azure-entra-id";
|
||||
import { CassandraProvider } from "./cassandra";
|
||||
import { ElasticSearchProvider } from "./elastic-search";
|
||||
import { GcpIamProvider } from "./gcp-iam";
|
||||
import { GithubProvider } from "./github";
|
||||
import { KubernetesProvider } from "./kubernetes";
|
||||
import { LdapProvider } from "./ldap";
|
||||
import { DynamicSecretProviders, TDynamicProviderFns } from "./models";
|
||||
@@ -44,5 +45,6 @@ export const buildDynamicSecretProviders = ({
|
||||
[DynamicSecretProviders.SapAse]: SapAseProvider(),
|
||||
[DynamicSecretProviders.Kubernetes]: KubernetesProvider({ gatewayService }),
|
||||
[DynamicSecretProviders.Vertica]: VerticaProvider({ gatewayService }),
|
||||
[DynamicSecretProviders.GcpIam]: GcpIamProvider()
|
||||
[DynamicSecretProviders.GcpIam]: GcpIamProvider(),
|
||||
[DynamicSecretProviders.Github]: GithubProvider()
|
||||
});
|
||||
|
||||
@@ -2,6 +2,7 @@ import RE2 from "re2";
|
||||
import { z } from "zod";
|
||||
|
||||
import { CharacterType, characterValidator } from "@app/lib/validator/validate-string";
|
||||
import { ResourceMetadataSchema } from "@app/services/resource-metadata/resource-metadata-schema";
|
||||
|
||||
import { TDynamicSecretLeaseConfig } from "../../dynamic-secret-lease/dynamic-secret-lease-types";
|
||||
|
||||
@@ -207,7 +208,8 @@ export const DynamicSecretAwsIamSchema = z.preprocess(
|
||||
permissionBoundaryPolicyArn: z.string().trim().optional(),
|
||||
policyDocument: z.string().trim().optional(),
|
||||
userGroups: z.string().trim().optional(),
|
||||
policyArns: z.string().trim().optional()
|
||||
policyArns: z.string().trim().optional(),
|
||||
tags: ResourceMetadataSchema.optional()
|
||||
}),
|
||||
z.object({
|
||||
method: z.literal(AwsIamAuthType.AssumeRole),
|
||||
@@ -217,7 +219,8 @@ export const DynamicSecretAwsIamSchema = z.preprocess(
|
||||
permissionBoundaryPolicyArn: z.string().trim().optional(),
|
||||
policyDocument: z.string().trim().optional(),
|
||||
userGroups: z.string().trim().optional(),
|
||||
policyArns: z.string().trim().optional()
|
||||
policyArns: z.string().trim().optional(),
|
||||
tags: ResourceMetadataSchema.optional()
|
||||
})
|
||||
])
|
||||
);
|
||||
@@ -474,6 +477,23 @@ export const DynamicSecretGcpIamSchema = z.object({
|
||||
serviceAccountEmail: z.string().email().trim().min(1, "Service account email required").max(128)
|
||||
});
|
||||
|
||||
export const DynamicSecretGithubSchema = z.object({
|
||||
appId: z.number().min(1).describe("The ID of your GitHub App."),
|
||||
installationId: z.number().min(1).describe("The ID of the GitHub App installation."),
|
||||
privateKey: z
|
||||
.string()
|
||||
.trim()
|
||||
.min(1)
|
||||
.refine(
|
||||
(val) =>
|
||||
new RE2(
|
||||
/^-----BEGIN(?:(?: RSA| PGP| ENCRYPTED)? PRIVATE KEY)-----\s*[\s\S]*?-----END(?:(?: RSA| PGP| ENCRYPTED)? PRIVATE KEY)-----$/
|
||||
).test(val),
|
||||
"Invalid PEM format for private key"
|
||||
)
|
||||
.describe("The private key generated for your GitHub App.")
|
||||
});
|
||||
|
||||
export enum DynamicSecretProviders {
|
||||
SqlDatabase = "sql-database",
|
||||
Cassandra = "cassandra",
|
||||
@@ -492,7 +512,8 @@ export enum DynamicSecretProviders {
|
||||
SapAse = "sap-ase",
|
||||
Kubernetes = "kubernetes",
|
||||
Vertica = "vertica",
|
||||
GcpIam = "gcp-iam"
|
||||
GcpIam = "gcp-iam",
|
||||
Github = "github"
|
||||
}
|
||||
|
||||
export const DynamicSecretProviderSchema = z.discriminatedUnion("type", [
|
||||
@@ -513,7 +534,8 @@ export const DynamicSecretProviderSchema = z.discriminatedUnion("type", [
|
||||
z.object({ type: z.literal(DynamicSecretProviders.Totp), inputs: DynamicSecretTotpSchema }),
|
||||
z.object({ type: z.literal(DynamicSecretProviders.Kubernetes), inputs: DynamicSecretKubernetesSchema }),
|
||||
z.object({ type: z.literal(DynamicSecretProviders.Vertica), inputs: DynamicSecretVerticaSchema }),
|
||||
z.object({ type: z.literal(DynamicSecretProviders.GcpIam), inputs: DynamicSecretGcpIamSchema })
|
||||
z.object({ type: z.literal(DynamicSecretProviders.GcpIam), inputs: DynamicSecretGcpIamSchema }),
|
||||
z.object({ type: z.literal(DynamicSecretProviders.Github), inputs: DynamicSecretGithubSchema })
|
||||
]);
|
||||
|
||||
export type TDynamicProviderFns = {
|
||||
|
||||
@@ -2390,6 +2390,10 @@ export const SecretSyncs = {
|
||||
ONEPASS: {
|
||||
vaultId: "The ID of the 1Password vault to sync secrets to."
|
||||
},
|
||||
HEROKU: {
|
||||
app: "The ID of the Heroku app to sync secrets to.",
|
||||
appName: "The name of the Heroku app to sync secrets to."
|
||||
},
|
||||
RENDER: {
|
||||
serviceId: "The ID of the Render service to sync secrets to.",
|
||||
scope: "The Render scope that secrets should be synced to.",
|
||||
|
||||
@@ -50,6 +50,7 @@ import {
|
||||
HCVaultConnectionListItemSchema,
|
||||
SanitizedHCVaultConnectionSchema
|
||||
} from "@app/services/app-connection/hc-vault";
|
||||
import { HerokuConnectionListItemSchema, SanitizedHerokuConnectionSchema } from "@app/services/app-connection/heroku";
|
||||
import {
|
||||
HumanitecConnectionListItemSchema,
|
||||
SanitizedHumanitecConnectionSchema
|
||||
@@ -110,6 +111,7 @@ const SanitizedAppConnectionSchema = z.union([
|
||||
...SanitizedOCIConnectionSchema.options,
|
||||
...SanitizedOracleDBConnectionSchema.options,
|
||||
...SanitizedOnePassConnectionSchema.options,
|
||||
...SanitizedHerokuConnectionSchema.options,
|
||||
...SanitizedRenderConnectionSchema.options,
|
||||
...SanitizedFlyioConnectionSchema.options,
|
||||
...SanitizedCloudflareConnectionSchema.options
|
||||
@@ -140,6 +142,7 @@ const AppConnectionOptionsSchema = z.discriminatedUnion("app", [
|
||||
OCIConnectionListItemSchema,
|
||||
OracleDBConnectionListItemSchema,
|
||||
OnePassConnectionListItemSchema,
|
||||
HerokuConnectionListItemSchema,
|
||||
RenderConnectionListItemSchema,
|
||||
FlyioConnectionListItemSchema,
|
||||
CloudflareConnectionListItemSchema
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import z from "zod";
|
||||
|
||||
import { readLimit } from "@app/server/config/rateLimiter";
|
||||
import { verifyAuth } from "@app/server/plugins/auth/verify-auth";
|
||||
import { AppConnection } from "@app/services/app-connection/app-connection-enums";
|
||||
import {
|
||||
CreateHerokuConnectionSchema,
|
||||
SanitizedHerokuConnectionSchema,
|
||||
THerokuApp,
|
||||
UpdateHerokuConnectionSchema
|
||||
} from "@app/services/app-connection/heroku";
|
||||
import { AuthMode } from "@app/services/auth/auth-type";
|
||||
|
||||
import { registerAppConnectionEndpoints } from "./app-connection-endpoints";
|
||||
|
||||
export const registerHerokuConnectionRouter = async (server: FastifyZodProvider) => {
|
||||
registerAppConnectionEndpoints({
|
||||
app: AppConnection.Heroku,
|
||||
server,
|
||||
sanitizedResponseSchema: SanitizedHerokuConnectionSchema,
|
||||
createSchema: CreateHerokuConnectionSchema,
|
||||
updateSchema: UpdateHerokuConnectionSchema
|
||||
});
|
||||
|
||||
// The below endpoints are not exposed and for Infisical App use
|
||||
server.route({
|
||||
method: "GET",
|
||||
url: `/:connectionId/apps`,
|
||||
config: {
|
||||
rateLimit: readLimit
|
||||
},
|
||||
schema: {
|
||||
params: z.object({
|
||||
connectionId: z.string().uuid()
|
||||
}),
|
||||
response: {
|
||||
200: z
|
||||
.object({
|
||||
id: z.string(),
|
||||
name: z.string()
|
||||
})
|
||||
.array()
|
||||
}
|
||||
},
|
||||
onRequest: verifyAuth([AuthMode.JWT]),
|
||||
handler: async (req) => {
|
||||
const { connectionId } = req.params;
|
||||
|
||||
const apps: THerokuApp[] = await server.services.appConnection.heroku.listApps(connectionId, req.permission);
|
||||
|
||||
return apps;
|
||||
}
|
||||
});
|
||||
};
|
||||
@@ -16,6 +16,7 @@ import { registerGcpConnectionRouter } from "./gcp-connection-router";
|
||||
import { registerGitHubConnectionRouter } from "./github-connection-router";
|
||||
import { registerGitHubRadarConnectionRouter } from "./github-radar-connection-router";
|
||||
import { registerHCVaultConnectionRouter } from "./hc-vault-connection-router";
|
||||
import { registerHerokuConnectionRouter } from "./heroku-connection-router";
|
||||
import { registerHumanitecConnectionRouter } from "./humanitec-connection-router";
|
||||
import { registerLdapConnectionRouter } from "./ldap-connection-router";
|
||||
import { registerMsSqlConnectionRouter } from "./mssql-connection-router";
|
||||
@@ -56,6 +57,7 @@ export const APP_CONNECTION_REGISTER_ROUTER_MAP: Record<AppConnection, (server:
|
||||
[AppConnection.OCI]: registerOCIConnectionRouter,
|
||||
[AppConnection.OracleDB]: registerOracleDBConnectionRouter,
|
||||
[AppConnection.OnePass]: registerOnePassConnectionRouter,
|
||||
[AppConnection.Heroku]: registerHerokuConnectionRouter,
|
||||
[AppConnection.Render]: registerRenderConnectionRouter,
|
||||
[AppConnection.Flyio]: registerFlyioConnectionRouter,
|
||||
[AppConnection.Cloudflare]: registerCloudflareConnectionRouter
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import { CreateHerokuSyncSchema, HerokuSyncSchema, UpdateHerokuSyncSchema } from "@app/services/secret-sync/heroku";
|
||||
import { SecretSync } from "@app/services/secret-sync/secret-sync-enums";
|
||||
|
||||
import { registerSyncSecretsEndpoints } from "./secret-sync-endpoints";
|
||||
|
||||
export const registerHerokuSyncRouter = async (server: FastifyZodProvider) =>
|
||||
registerSyncSecretsEndpoints({
|
||||
destination: SecretSync.Heroku,
|
||||
server,
|
||||
responseSchema: HerokuSyncSchema,
|
||||
createSchema: CreateHerokuSyncSchema,
|
||||
updateSchema: UpdateHerokuSyncSchema
|
||||
});
|
||||
@@ -13,6 +13,7 @@ import { registerFlyioSyncRouter } from "./flyio-sync-router";
|
||||
import { registerGcpSyncRouter } from "./gcp-sync-router";
|
||||
import { registerGitHubSyncRouter } from "./github-sync-router";
|
||||
import { registerHCVaultSyncRouter } from "./hc-vault-sync-router";
|
||||
import { registerHerokuSyncRouter } from "./heroku-sync-router";
|
||||
import { registerHumanitecSyncRouter } from "./humanitec-sync-router";
|
||||
import { registerRenderSyncRouter } from "./render-sync-router";
|
||||
import { registerTeamCitySyncRouter } from "./teamcity-sync-router";
|
||||
@@ -41,6 +42,7 @@ export const SECRET_SYNC_REGISTER_ROUTER_MAP: Record<SecretSync, (server: Fastif
|
||||
[SecretSync.TeamCity]: registerTeamCitySyncRouter,
|
||||
[SecretSync.OCIVault]: registerOCIVaultSyncRouter,
|
||||
[SecretSync.OnePass]: registerOnePassSyncRouter,
|
||||
[SecretSync.Heroku]: registerHerokuSyncRouter,
|
||||
[SecretSync.Render]: registerRenderSyncRouter,
|
||||
[SecretSync.Flyio]: registerFlyioSyncRouter,
|
||||
[SecretSync.CloudflarePages]: registerCloudflarePagesSyncRouter
|
||||
|
||||
@@ -27,6 +27,7 @@ import { FlyioSyncListItemSchema, FlyioSyncSchema } from "@app/services/secret-s
|
||||
import { GcpSyncListItemSchema, GcpSyncSchema } from "@app/services/secret-sync/gcp";
|
||||
import { GitHubSyncListItemSchema, GitHubSyncSchema } from "@app/services/secret-sync/github";
|
||||
import { HCVaultSyncListItemSchema, HCVaultSyncSchema } from "@app/services/secret-sync/hc-vault";
|
||||
import { HerokuSyncListItemSchema, HerokuSyncSchema } from "@app/services/secret-sync/heroku";
|
||||
import { HumanitecSyncListItemSchema, HumanitecSyncSchema } from "@app/services/secret-sync/humanitec";
|
||||
import { RenderSyncListItemSchema, RenderSyncSchema } from "@app/services/secret-sync/render/render-sync-schemas";
|
||||
import { TeamCitySyncListItemSchema, TeamCitySyncSchema } from "@app/services/secret-sync/teamcity";
|
||||
@@ -56,6 +57,7 @@ const SecretSyncSchema = z.discriminatedUnion("destination", [
|
||||
TeamCitySyncSchema,
|
||||
OCIVaultSyncSchema,
|
||||
OnePassSyncSchema,
|
||||
HerokuSyncSchema,
|
||||
RenderSyncSchema,
|
||||
FlyioSyncSchema,
|
||||
CloudflarePagesSyncSchema
|
||||
@@ -79,6 +81,7 @@ const SecretSyncOptionsSchema = z.discriminatedUnion("destination", [
|
||||
TeamCitySyncListItemSchema,
|
||||
OCIVaultSyncListItemSchema,
|
||||
OnePassSyncListItemSchema,
|
||||
HerokuSyncListItemSchema,
|
||||
RenderSyncListItemSchema,
|
||||
FlyioSyncListItemSchema,
|
||||
CloudflarePagesSyncListItemSchema
|
||||
|
||||
@@ -23,6 +23,7 @@ export enum AppConnection {
|
||||
OCI = "oci",
|
||||
OracleDB = "oracledb",
|
||||
OnePass = "1password",
|
||||
Heroku = "heroku",
|
||||
Render = "render",
|
||||
Flyio = "flyio",
|
||||
Cloudflare = "cloudflare"
|
||||
|
||||
@@ -69,6 +69,7 @@ import {
|
||||
HCVaultConnectionMethod,
|
||||
validateHCVaultConnectionCredentials
|
||||
} from "./hc-vault";
|
||||
import { getHerokuConnectionListItem, HerokuConnectionMethod, validateHerokuConnectionCredentials } from "./heroku";
|
||||
import {
|
||||
getHumanitecConnectionListItem,
|
||||
HumanitecConnectionMethod,
|
||||
@@ -130,6 +131,7 @@ export const listAppConnectionOptions = () => {
|
||||
getOCIConnectionListItem(),
|
||||
getOracleDBConnectionListItem(),
|
||||
getOnePassConnectionListItem(),
|
||||
getHerokuConnectionListItem(),
|
||||
getRenderConnectionListItem(),
|
||||
getFlyioConnectionListItem(),
|
||||
getCloudflareConnectionListItem()
|
||||
@@ -208,6 +210,7 @@ export const validateAppConnectionCredentials = async (
|
||||
[AppConnection.OCI]: validateOCIConnectionCredentials as TAppConnectionCredentialsValidator,
|
||||
[AppConnection.OracleDB]: validateSqlConnectionCredentials as TAppConnectionCredentialsValidator,
|
||||
[AppConnection.OnePass]: validateOnePassConnectionCredentials as TAppConnectionCredentialsValidator,
|
||||
[AppConnection.Heroku]: validateHerokuConnectionCredentials as TAppConnectionCredentialsValidator,
|
||||
[AppConnection.Render]: validateRenderConnectionCredentials as TAppConnectionCredentialsValidator,
|
||||
[AppConnection.Flyio]: validateFlyioConnectionCredentials as TAppConnectionCredentialsValidator,
|
||||
[AppConnection.Cloudflare]: validateCloudflareConnectionCredentials as TAppConnectionCredentialsValidator
|
||||
@@ -226,7 +229,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";
|
||||
@@ -318,6 +324,7 @@ export const TRANSITION_CONNECTION_CREDENTIALS_TO_PLATFORM: Record<
|
||||
[AppConnection.OCI]: platformManagedCredentialsNotSupported,
|
||||
[AppConnection.OracleDB]: transferSqlConnectionCredentialsToPlatform as TAppConnectionTransitionCredentialsToPlatform,
|
||||
[AppConnection.OnePass]: platformManagedCredentialsNotSupported,
|
||||
[AppConnection.Heroku]: platformManagedCredentialsNotSupported,
|
||||
[AppConnection.Render]: platformManagedCredentialsNotSupported,
|
||||
[AppConnection.Flyio]: platformManagedCredentialsNotSupported,
|
||||
[AppConnection.Cloudflare]: platformManagedCredentialsNotSupported
|
||||
|
||||
@@ -25,6 +25,7 @@ export const APP_CONNECTION_NAME_MAP: Record<AppConnection, string> = {
|
||||
[AppConnection.OCI]: "OCI",
|
||||
[AppConnection.OracleDB]: "OracleDB",
|
||||
[AppConnection.OnePass]: "1Password",
|
||||
[AppConnection.Heroku]: "Heroku",
|
||||
[AppConnection.Render]: "Render",
|
||||
[AppConnection.Flyio]: "Fly.io",
|
||||
[AppConnection.Cloudflare]: "Cloudflare"
|
||||
@@ -55,6 +56,7 @@ export const APP_CONNECTION_PLAN_MAP: Record<AppConnection, AppConnectionPlanTyp
|
||||
[AppConnection.OracleDB]: AppConnectionPlanType.Enterprise,
|
||||
[AppConnection.OnePass]: AppConnectionPlanType.Regular,
|
||||
[AppConnection.MySql]: AppConnectionPlanType.Regular,
|
||||
[AppConnection.Heroku]: AppConnectionPlanType.Regular,
|
||||
[AppConnection.Render]: AppConnectionPlanType.Regular,
|
||||
[AppConnection.Flyio]: AppConnectionPlanType.Regular,
|
||||
[AppConnection.Cloudflare]: AppConnectionPlanType.Regular
|
||||
|
||||
@@ -60,6 +60,8 @@ import { githubConnectionService } from "./github/github-connection-service";
|
||||
import { ValidateGitHubRadarConnectionCredentialsSchema } from "./github-radar";
|
||||
import { ValidateHCVaultConnectionCredentialsSchema } from "./hc-vault";
|
||||
import { hcVaultConnectionService } from "./hc-vault/hc-vault-connection-service";
|
||||
import { ValidateHerokuConnectionCredentialsSchema } from "./heroku";
|
||||
import { herokuConnectionService } from "./heroku/heroku-connection-service";
|
||||
import { ValidateHumanitecConnectionCredentialsSchema } from "./humanitec";
|
||||
import { humanitecConnectionService } from "./humanitec/humanitec-connection-service";
|
||||
import { ValidateLdapConnectionCredentialsSchema } from "./ldap";
|
||||
@@ -111,6 +113,7 @@ const VALIDATE_APP_CONNECTION_CREDENTIALS_MAP: Record<AppConnection, TValidateAp
|
||||
[AppConnection.OCI]: ValidateOCIConnectionCredentialsSchema,
|
||||
[AppConnection.OracleDB]: ValidateOracleDBConnectionCredentialsSchema,
|
||||
[AppConnection.OnePass]: ValidateOnePassConnectionCredentialsSchema,
|
||||
[AppConnection.Heroku]: ValidateHerokuConnectionCredentialsSchema,
|
||||
[AppConnection.Render]: ValidateRenderConnectionCredentialsSchema,
|
||||
[AppConnection.Flyio]: ValidateFlyioConnectionCredentialsSchema,
|
||||
[AppConnection.Cloudflare]: ValidateCloudflareConnectionCredentialsSchema
|
||||
@@ -519,6 +522,7 @@ export const appConnectionServiceFactory = ({
|
||||
teamcity: teamcityConnectionService(connectAppConnectionById),
|
||||
oci: ociConnectionService(connectAppConnectionById, licenseService),
|
||||
onepass: onePassConnectionService(connectAppConnectionById),
|
||||
heroku: herokuConnectionService(connectAppConnectionById, appConnectionDAL, kmsService),
|
||||
render: renderConnectionService(connectAppConnectionById),
|
||||
cloudflare: cloudflareConnectionService(connectAppConnectionById),
|
||||
flyio: flyioConnectionService(connectAppConnectionById)
|
||||
|
||||
@@ -98,6 +98,12 @@ import {
|
||||
THCVaultConnectionInput,
|
||||
TValidateHCVaultConnectionCredentialsSchema
|
||||
} from "./hc-vault";
|
||||
import {
|
||||
THerokuConnection,
|
||||
THerokuConnectionConfig,
|
||||
THerokuConnectionInput,
|
||||
TValidateHerokuConnectionCredentialsSchema
|
||||
} from "./heroku";
|
||||
import {
|
||||
THumanitecConnection,
|
||||
THumanitecConnectionConfig,
|
||||
@@ -179,6 +185,7 @@ export type TAppConnection = { id: string } & (
|
||||
| TOCIConnection
|
||||
| TOracleDBConnection
|
||||
| TOnePassConnection
|
||||
| THerokuConnection
|
||||
| TRenderConnection
|
||||
| TFlyioConnection
|
||||
| TCloudflareConnection
|
||||
@@ -213,6 +220,7 @@ export type TAppConnectionInput = { id: string } & (
|
||||
| TOCIConnectionInput
|
||||
| TOracleDBConnectionInput
|
||||
| TOnePassConnectionInput
|
||||
| THerokuConnectionInput
|
||||
| TRenderConnectionInput
|
||||
| TFlyioConnectionInput
|
||||
| TCloudflareConnectionInput
|
||||
@@ -255,6 +263,7 @@ export type TAppConnectionConfig =
|
||||
| TTeamCityConnectionConfig
|
||||
| TOCIConnectionConfig
|
||||
| TOnePassConnectionConfig
|
||||
| THerokuConnectionConfig
|
||||
| TRenderConnectionConfig
|
||||
| TFlyioConnectionConfig
|
||||
| TCloudflareConnectionConfig;
|
||||
@@ -284,6 +293,7 @@ export type TValidateAppConnectionCredentialsSchema =
|
||||
| TValidateOCIConnectionCredentialsSchema
|
||||
| TValidateOracleDBConnectionCredentialsSchema
|
||||
| TValidateOnePassConnectionCredentialsSchema
|
||||
| TValidateHerokuConnectionCredentialsSchema
|
||||
| TValidateRenderConnectionCredentialsSchema
|
||||
| TValidateFlyioConnectionCredentialsSchema
|
||||
| TValidateCloudflareConnectionCredentialsSchema;
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
export enum HerokuConnectionMethod {
|
||||
AuthToken = "auth-token",
|
||||
OAuth = "oauth"
|
||||
}
|
||||
@@ -0,0 +1,208 @@
|
||||
import { AxiosError, AxiosResponse } from "axios";
|
||||
|
||||
import { getConfig } from "@app/lib/config/env";
|
||||
import { request } from "@app/lib/config/request";
|
||||
import { BadRequestError, InternalServerError } from "@app/lib/errors";
|
||||
import { AppConnection } from "@app/services/app-connection/app-connection-enums";
|
||||
import { encryptAppConnectionCredentials } from "@app/services/app-connection/app-connection-fns";
|
||||
import { IntegrationUrls } from "@app/services/integration-auth/integration-list";
|
||||
import { TKmsServiceFactory } from "@app/services/kms/kms-service";
|
||||
|
||||
import { TAppConnectionDALFactory } from "../app-connection-dal";
|
||||
import { HerokuConnectionMethod } from "./heroku-connection-enums";
|
||||
import { THerokuApp, THerokuConnection, THerokuConnectionConfig } from "./heroku-connection-types";
|
||||
|
||||
interface HerokuOAuthTokenResponse {
|
||||
access_token: string;
|
||||
expires_in: number;
|
||||
refresh_token: string;
|
||||
token_type: string;
|
||||
user_id: string;
|
||||
session_nonce: string;
|
||||
}
|
||||
|
||||
export const getHerokuConnectionListItem = () => {
|
||||
const { CLIENT_ID_HEROKU } = getConfig();
|
||||
|
||||
return {
|
||||
name: "Heroku" as const,
|
||||
app: AppConnection.Heroku as const,
|
||||
methods: Object.values(HerokuConnectionMethod) as [HerokuConnectionMethod.AuthToken, HerokuConnectionMethod.OAuth],
|
||||
oauthClientId: CLIENT_ID_HEROKU
|
||||
};
|
||||
};
|
||||
|
||||
export const refreshHerokuToken = async (
|
||||
refreshToken: string,
|
||||
appId: string,
|
||||
orgId: string,
|
||||
appConnectionDAL: Pick<TAppConnectionDALFactory, "updateById">,
|
||||
kmsService: Pick<TKmsServiceFactory, "createCipherPairWithDataKey">
|
||||
): Promise<string> => {
|
||||
const { CLIENT_SECRET_HEROKU } = getConfig();
|
||||
|
||||
const payload = {
|
||||
grant_type: "refresh_token",
|
||||
refresh_token: refreshToken,
|
||||
client_secret: CLIENT_SECRET_HEROKU
|
||||
};
|
||||
|
||||
const { data } = await request.post<{ access_token: string; expires_in: number }>(
|
||||
IntegrationUrls.HEROKU_TOKEN_URL,
|
||||
payload,
|
||||
{
|
||||
headers: {
|
||||
"Content-Type": "application/x-www-form-urlencoded"
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
const encryptedCredentials = await encryptAppConnectionCredentials({
|
||||
credentials: {
|
||||
refreshToken,
|
||||
authToken: data.access_token,
|
||||
expiresAt: new Date(Date.now() + data.expires_in * 1000 - 60000)
|
||||
},
|
||||
orgId,
|
||||
kmsService
|
||||
});
|
||||
|
||||
await appConnectionDAL.updateById(appId, { encryptedCredentials });
|
||||
|
||||
return data.access_token;
|
||||
};
|
||||
|
||||
export const exchangeHerokuOAuthCode = async (code: string): Promise<HerokuOAuthTokenResponse> => {
|
||||
const { CLIENT_SECRET_HEROKU } = getConfig();
|
||||
|
||||
try {
|
||||
const response = await request.post<HerokuOAuthTokenResponse>(
|
||||
IntegrationUrls.HEROKU_TOKEN_URL,
|
||||
{
|
||||
grant_type: "authorization_code",
|
||||
code,
|
||||
client_secret: CLIENT_SECRET_HEROKU
|
||||
},
|
||||
{
|
||||
headers: {
|
||||
"Content-Type": "application/x-www-form-urlencoded"
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.data) {
|
||||
throw new InternalServerError({
|
||||
message: "Failed to exchange OAuth code: Empty response"
|
||||
});
|
||||
}
|
||||
|
||||
return response.data;
|
||||
} catch (error: unknown) {
|
||||
if (error instanceof AxiosError) {
|
||||
throw new BadRequestError({
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
|
||||
message: `Failed to exchange OAuth code: ${error.response?.data?.message || error.message || "Unknown error"}`
|
||||
});
|
||||
}
|
||||
throw new BadRequestError({
|
||||
message: "Unable to exchange OAuth code"
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
export const validateHerokuConnectionCredentials = async (config: THerokuConnectionConfig) => {
|
||||
const { credentials: inputCredentials, method } = config;
|
||||
|
||||
let authToken: string;
|
||||
let oauthData: HerokuOAuthTokenResponse | null = null;
|
||||
|
||||
if (method === HerokuConnectionMethod.OAuth && "code" in inputCredentials) {
|
||||
oauthData = await exchangeHerokuOAuthCode(inputCredentials.code);
|
||||
authToken = oauthData.access_token;
|
||||
} else if (method === HerokuConnectionMethod.AuthToken && "authToken" in inputCredentials) {
|
||||
authToken = inputCredentials.authToken;
|
||||
} else {
|
||||
throw new BadRequestError({
|
||||
message: "Invalid credentials for the selected connection method"
|
||||
});
|
||||
}
|
||||
|
||||
let response: AxiosResponse<THerokuApp[]> | null = null;
|
||||
|
||||
try {
|
||||
response = await request.get<THerokuApp[]>(`${IntegrationUrls.HEROKU_API_URL}/apps`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${authToken}`,
|
||||
Accept: "application/vnd.heroku+json; version=3"
|
||||
}
|
||||
});
|
||||
} catch (error: unknown) {
|
||||
if (error instanceof AxiosError) {
|
||||
throw new BadRequestError({
|
||||
message: `Failed to validate credentials: ${error.message || "Unknown error"}`
|
||||
});
|
||||
}
|
||||
throw new BadRequestError({
|
||||
message: "Unable to validate connection: verify credentials"
|
||||
});
|
||||
}
|
||||
|
||||
if (!response?.data) {
|
||||
throw new InternalServerError({
|
||||
message: "Failed to get apps: Response was empty"
|
||||
});
|
||||
}
|
||||
|
||||
if (method === HerokuConnectionMethod.OAuth && oauthData) {
|
||||
return {
|
||||
authToken,
|
||||
refreshToken: oauthData.refresh_token,
|
||||
expiresIn: oauthData.expires_in,
|
||||
tokenType: oauthData.token_type,
|
||||
userId: oauthData.user_id,
|
||||
sessionNonce: oauthData.session_nonce
|
||||
};
|
||||
}
|
||||
|
||||
return inputCredentials;
|
||||
};
|
||||
|
||||
export const listHerokuApps = async ({
|
||||
appConnection,
|
||||
appConnectionDAL,
|
||||
kmsService
|
||||
}: {
|
||||
appConnection: THerokuConnection;
|
||||
appConnectionDAL: Pick<TAppConnectionDALFactory, "updateById">;
|
||||
kmsService: Pick<TKmsServiceFactory, "createCipherPairWithDataKey">;
|
||||
}): Promise<THerokuApp[]> => {
|
||||
let authCredential = appConnection.credentials.authToken;
|
||||
if (
|
||||
appConnection.method === HerokuConnectionMethod.OAuth &&
|
||||
appConnection.credentials.refreshToken &&
|
||||
appConnection.credentials.expiresAt < new Date()
|
||||
) {
|
||||
authCredential = await refreshHerokuToken(
|
||||
appConnection.credentials.refreshToken,
|
||||
appConnection.id,
|
||||
appConnection.orgId,
|
||||
appConnectionDAL,
|
||||
kmsService
|
||||
);
|
||||
}
|
||||
|
||||
const { data } = await request.get<THerokuApp[]>(`${IntegrationUrls.HEROKU_API_URL}/apps`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${authCredential}`,
|
||||
Accept: "application/vnd.heroku+json; version=3"
|
||||
}
|
||||
});
|
||||
|
||||
if (!data) {
|
||||
throw new InternalServerError({
|
||||
message: "Failed to get apps: Response was empty"
|
||||
});
|
||||
}
|
||||
|
||||
return data.map((res) => ({ name: res.name, id: res.id }));
|
||||
};
|
||||
@@ -0,0 +1,103 @@
|
||||
import z from "zod";
|
||||
|
||||
import { AppConnections } from "@app/lib/api-docs";
|
||||
import { AppConnection } from "@app/services/app-connection/app-connection-enums";
|
||||
import {
|
||||
BaseAppConnectionSchema,
|
||||
GenericCreateAppConnectionFieldsSchema,
|
||||
GenericUpdateAppConnectionFieldsSchema
|
||||
} from "@app/services/app-connection/app-connection-schemas";
|
||||
|
||||
import { HerokuConnectionMethod } from "./heroku-connection-enums";
|
||||
|
||||
export const HerokuConnectionAuthTokenCredentialsSchema = z.object({
|
||||
authToken: z.string().trim().min(1, "Auth Token required").startsWith("HRKU-", "Token must start with 'HRKU-")
|
||||
});
|
||||
|
||||
export const HerokuConnectionOAuthCredentialsSchema = z.object({
|
||||
code: z.string().trim().min(1, "OAuth code required")
|
||||
});
|
||||
|
||||
export const HerokuConnectionOAuthOutputCredentialsSchema = z.object({
|
||||
authToken: z.string().trim(),
|
||||
refreshToken: z.string().trim(),
|
||||
expiresAt: z.date()
|
||||
});
|
||||
|
||||
// Schema for refresh token input during initial setup
|
||||
export const HerokuConnectionRefreshTokenCredentialsSchema = z.object({
|
||||
refreshToken: z.string().trim().min(1, "Refresh token required")
|
||||
});
|
||||
|
||||
const BaseHerokuConnectionSchema = BaseAppConnectionSchema.extend({
|
||||
app: z.literal(AppConnection.Heroku)
|
||||
});
|
||||
|
||||
export const HerokuConnectionSchema = z.intersection(
|
||||
BaseHerokuConnectionSchema,
|
||||
z.discriminatedUnion("method", [
|
||||
z.object({
|
||||
method: z.literal(HerokuConnectionMethod.AuthToken),
|
||||
credentials: HerokuConnectionAuthTokenCredentialsSchema
|
||||
}),
|
||||
z.object({
|
||||
method: z.literal(HerokuConnectionMethod.OAuth),
|
||||
credentials: HerokuConnectionOAuthOutputCredentialsSchema
|
||||
})
|
||||
])
|
||||
);
|
||||
|
||||
export const SanitizedHerokuConnectionSchema = z.discriminatedUnion("method", [
|
||||
BaseHerokuConnectionSchema.extend({
|
||||
method: z.literal(HerokuConnectionMethod.AuthToken),
|
||||
credentials: HerokuConnectionAuthTokenCredentialsSchema.pick({})
|
||||
}),
|
||||
BaseHerokuConnectionSchema.extend({
|
||||
method: z.literal(HerokuConnectionMethod.OAuth),
|
||||
credentials: HerokuConnectionOAuthOutputCredentialsSchema.pick({})
|
||||
})
|
||||
]);
|
||||
|
||||
export const ValidateHerokuConnectionCredentialsSchema = z.discriminatedUnion("method", [
|
||||
z.object({
|
||||
method: z.literal(HerokuConnectionMethod.AuthToken).describe(AppConnections.CREATE(AppConnection.Heroku).method),
|
||||
credentials: HerokuConnectionAuthTokenCredentialsSchema.describe(
|
||||
AppConnections.CREATE(AppConnection.Heroku).credentials
|
||||
)
|
||||
}),
|
||||
z.object({
|
||||
method: z.literal(HerokuConnectionMethod.OAuth).describe(AppConnections.CREATE(AppConnection.Heroku).method),
|
||||
credentials: z
|
||||
.union([
|
||||
HerokuConnectionOAuthCredentialsSchema,
|
||||
HerokuConnectionRefreshTokenCredentialsSchema,
|
||||
HerokuConnectionOAuthOutputCredentialsSchema
|
||||
])
|
||||
.describe(AppConnections.CREATE(AppConnection.Heroku).credentials)
|
||||
})
|
||||
]);
|
||||
|
||||
export const CreateHerokuConnectionSchema = ValidateHerokuConnectionCredentialsSchema.and(
|
||||
GenericCreateAppConnectionFieldsSchema(AppConnection.Heroku)
|
||||
);
|
||||
|
||||
export const UpdateHerokuConnectionSchema = z
|
||||
.object({
|
||||
credentials: z
|
||||
.union([
|
||||
HerokuConnectionAuthTokenCredentialsSchema,
|
||||
HerokuConnectionOAuthOutputCredentialsSchema,
|
||||
HerokuConnectionRefreshTokenCredentialsSchema,
|
||||
HerokuConnectionOAuthCredentialsSchema
|
||||
])
|
||||
.optional()
|
||||
.describe(AppConnections.UPDATE(AppConnection.Heroku).credentials)
|
||||
})
|
||||
.and(GenericUpdateAppConnectionFieldsSchema(AppConnection.Heroku));
|
||||
|
||||
export const HerokuConnectionListItemSchema = z.object({
|
||||
name: z.literal("Heroku"),
|
||||
app: z.literal(AppConnection.Heroku),
|
||||
methods: z.nativeEnum(HerokuConnectionMethod).array(),
|
||||
oauthClientId: z.string().optional()
|
||||
});
|
||||
@@ -0,0 +1,35 @@
|
||||
import { logger } from "@app/lib/logger";
|
||||
import { OrgServiceActor } from "@app/lib/types";
|
||||
import { TKmsServiceFactory } from "@app/services/kms/kms-service";
|
||||
|
||||
import { TAppConnectionDALFactory } from "../app-connection-dal";
|
||||
import { AppConnection } from "../app-connection-enums";
|
||||
import { listHerokuApps as getHerokuApps } from "./heroku-connection-fns";
|
||||
import { THerokuConnection } from "./heroku-connection-types";
|
||||
|
||||
type TGetAppConnectionFunc = (
|
||||
app: AppConnection,
|
||||
connectionId: string,
|
||||
actor: OrgServiceActor
|
||||
) => Promise<THerokuConnection>;
|
||||
|
||||
export const herokuConnectionService = (
|
||||
getAppConnection: TGetAppConnectionFunc,
|
||||
appConnectionDAL: Pick<TAppConnectionDALFactory, "updateById">,
|
||||
kmsService: Pick<TKmsServiceFactory, "createCipherPairWithDataKey">
|
||||
) => {
|
||||
const listApps = async (connectionId: string, actor: OrgServiceActor) => {
|
||||
const appConnection = await getAppConnection(AppConnection.Heroku, connectionId, actor);
|
||||
try {
|
||||
const apps = await getHerokuApps({ appConnection, appConnectionDAL, kmsService });
|
||||
return apps;
|
||||
} catch (error) {
|
||||
logger.error(error, `Failed to establish connection with Heroku for app ${connectionId}`);
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
listApps
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,27 @@
|
||||
import z from "zod";
|
||||
|
||||
import { DiscriminativePick } from "@app/lib/types";
|
||||
|
||||
import { AppConnection } from "../app-connection-enums";
|
||||
import {
|
||||
CreateHerokuConnectionSchema,
|
||||
HerokuConnectionSchema,
|
||||
ValidateHerokuConnectionCredentialsSchema
|
||||
} from "./heroku-connection-schemas";
|
||||
|
||||
export type THerokuConnection = z.infer<typeof HerokuConnectionSchema>;
|
||||
|
||||
export type THerokuConnectionInput = z.infer<typeof CreateHerokuConnectionSchema> & {
|
||||
app: AppConnection.Heroku;
|
||||
};
|
||||
|
||||
export type TValidateHerokuConnectionCredentialsSchema = typeof ValidateHerokuConnectionCredentialsSchema;
|
||||
|
||||
export type THerokuConnectionConfig = DiscriminativePick<THerokuConnectionInput, "method" | "app" | "credentials"> & {
|
||||
orgId: string;
|
||||
};
|
||||
|
||||
export type THerokuApp = {
|
||||
name: string;
|
||||
id: string;
|
||||
};
|
||||
@@ -0,0 +1,4 @@
|
||||
export * from "./heroku-connection-enums";
|
||||
export * from "./heroku-connection-fns";
|
||||
export * from "./heroku-connection-schemas";
|
||||
export * from "./heroku-connection-types";
|
||||
@@ -0,0 +1,10 @@
|
||||
import { AppConnection } from "@app/services/app-connection/app-connection-enums";
|
||||
import { SecretSync } from "@app/services/secret-sync/secret-sync-enums";
|
||||
import { TSecretSyncListItem } from "@app/services/secret-sync/secret-sync-types";
|
||||
|
||||
export const HEROKU_SYNC_LIST_OPTION: TSecretSyncListItem = {
|
||||
name: "Heroku",
|
||||
destination: SecretSync.Heroku,
|
||||
connection: AppConnection.Heroku,
|
||||
canImportSecrets: true
|
||||
};
|
||||
@@ -0,0 +1,170 @@
|
||||
import { request } from "@app/lib/config/request";
|
||||
import { TAppConnectionDALFactory } from "@app/services/app-connection/app-connection-dal";
|
||||
import { HerokuConnectionMethod, refreshHerokuToken, THerokuConnection } from "@app/services/app-connection/heroku";
|
||||
import { IntegrationUrls } from "@app/services/integration-auth/integration-list";
|
||||
import { TKmsServiceFactory } from "@app/services/kms/kms-service";
|
||||
import {
|
||||
THerokuConfigVars,
|
||||
THerokuListVariables,
|
||||
THerokuSyncWithCredentials,
|
||||
THerokuUpdateVariables
|
||||
} from "@app/services/secret-sync/heroku/heroku-sync-types";
|
||||
import { SecretSyncError } from "@app/services/secret-sync/secret-sync-errors";
|
||||
import { matchesSchema } from "@app/services/secret-sync/secret-sync-fns";
|
||||
import { TSecretMap } from "@app/services/secret-sync/secret-sync-types";
|
||||
|
||||
type THerokuSyncFactoryDeps = {
|
||||
appConnectionDAL: Pick<TAppConnectionDALFactory, "updateById">;
|
||||
kmsService: Pick<TKmsServiceFactory, "createCipherPairWithDataKey">;
|
||||
};
|
||||
|
||||
const getValidAuthToken = async (
|
||||
connection: THerokuConnection,
|
||||
appConnectionDAL: Pick<TAppConnectionDALFactory, "updateById">,
|
||||
kmsService: Pick<TKmsServiceFactory, "createCipherPairWithDataKey">
|
||||
): Promise<string> => {
|
||||
if (
|
||||
connection.method === HerokuConnectionMethod.OAuth &&
|
||||
connection.credentials.refreshToken &&
|
||||
connection.credentials.expiresAt < new Date()
|
||||
) {
|
||||
const authToken = await refreshHerokuToken(
|
||||
connection.credentials.refreshToken,
|
||||
connection.id,
|
||||
connection.orgId,
|
||||
appConnectionDAL,
|
||||
kmsService
|
||||
);
|
||||
return authToken;
|
||||
}
|
||||
return connection.credentials.authToken;
|
||||
};
|
||||
|
||||
const getHerokuConfigVars = async ({ authToken, app }: THerokuListVariables): Promise<THerokuConfigVars> => {
|
||||
const { data } = await request.get<THerokuConfigVars>(
|
||||
`${IntegrationUrls.HEROKU_API_URL}/apps/${encodeURIComponent(app)}/config-vars`,
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${authToken}`,
|
||||
Accept: "application/vnd.heroku+json; version=3"
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
return data;
|
||||
};
|
||||
|
||||
const updateHerokuConfigVars = async ({ authToken, app, configVars }: THerokuUpdateVariables) => {
|
||||
return request.patch(`${IntegrationUrls.HEROKU_API_URL}/apps/${encodeURIComponent(app)}/config-vars`, configVars, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${authToken}`,
|
||||
Accept: "application/vnd.heroku+json; version=3",
|
||||
"Content-Type": "application/json"
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
export const HerokuSyncFns = {
|
||||
syncSecrets: async (
|
||||
secretSync: THerokuSyncWithCredentials,
|
||||
secretMap: TSecretMap,
|
||||
{ appConnectionDAL, kmsService }: THerokuSyncFactoryDeps
|
||||
) => {
|
||||
const {
|
||||
connection,
|
||||
environment,
|
||||
destinationConfig: { app }
|
||||
} = secretSync;
|
||||
|
||||
const authToken = await getValidAuthToken(connection, appConnectionDAL, kmsService);
|
||||
|
||||
try {
|
||||
const updatedConfigVars: THerokuConfigVars = {};
|
||||
|
||||
for (const [key, { value }] of Object.entries(secretMap)) {
|
||||
updatedConfigVars[key] = value;
|
||||
}
|
||||
|
||||
if (!secretSync.syncOptions.disableSecretDeletion) {
|
||||
const currentConfigVars = await getHerokuConfigVars({ authToken, app });
|
||||
|
||||
for (const key of Object.keys(currentConfigVars)) {
|
||||
if (matchesSchema(key, environment?.slug || "", secretSync.syncOptions.keySchema) && !(key in secretMap)) {
|
||||
updatedConfigVars[key] = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await updateHerokuConfigVars({
|
||||
authToken,
|
||||
app,
|
||||
configVars: updatedConfigVars
|
||||
});
|
||||
} catch (error) {
|
||||
throw new SecretSyncError({
|
||||
error,
|
||||
secretKey: "batch_update"
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
removeSecrets: async (
|
||||
secretSync: THerokuSyncWithCredentials,
|
||||
secretMap: TSecretMap,
|
||||
{ appConnectionDAL, kmsService }: THerokuSyncFactoryDeps
|
||||
) => {
|
||||
const {
|
||||
connection,
|
||||
destinationConfig: { app }
|
||||
} = secretSync;
|
||||
|
||||
const authToken = await getValidAuthToken(connection, appConnectionDAL, kmsService);
|
||||
|
||||
try {
|
||||
const currentConfigVars = await getHerokuConfigVars({ authToken, app });
|
||||
const configVarsToUpdate: Record<string, null> = {};
|
||||
|
||||
for (const key of Object.keys(secretMap)) {
|
||||
if (key in currentConfigVars) {
|
||||
configVarsToUpdate[key] = null;
|
||||
}
|
||||
}
|
||||
|
||||
if (Object.keys(configVarsToUpdate).length > 0) {
|
||||
await updateHerokuConfigVars({
|
||||
authToken,
|
||||
app,
|
||||
configVars: configVarsToUpdate
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
throw new SecretSyncError({
|
||||
error,
|
||||
secretKey: "batch_remove"
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
getSecrets: async (
|
||||
secretSync: THerokuSyncWithCredentials,
|
||||
{ appConnectionDAL, kmsService }: THerokuSyncFactoryDeps
|
||||
): Promise<TSecretMap> => {
|
||||
const {
|
||||
connection,
|
||||
destinationConfig: { app }
|
||||
} = secretSync;
|
||||
|
||||
const authToken = await getValidAuthToken(connection, appConnectionDAL, kmsService);
|
||||
|
||||
const data = await getHerokuConfigVars({ authToken, app });
|
||||
const transformed = Object.entries(data).reduce((acc, [key, value]) => {
|
||||
if (!value) {
|
||||
return acc;
|
||||
}
|
||||
acc[key] = { value };
|
||||
return acc;
|
||||
}, {} as TSecretMap);
|
||||
|
||||
return transformed;
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,44 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import { SecretSyncs } from "@app/lib/api-docs";
|
||||
import { AppConnection } from "@app/services/app-connection/app-connection-enums";
|
||||
import { SecretSync } from "@app/services/secret-sync/secret-sync-enums";
|
||||
import {
|
||||
BaseSecretSyncSchema,
|
||||
GenericCreateSecretSyncFieldsSchema,
|
||||
GenericUpdateSecretSyncFieldsSchema
|
||||
} from "@app/services/secret-sync/secret-sync-schemas";
|
||||
import { TSyncOptionsConfig } from "@app/services/secret-sync/secret-sync-types";
|
||||
|
||||
const HerokuSyncDestinationConfigSchema = z.object({
|
||||
app: z.string().trim().min(1, "App required").describe(SecretSyncs.DESTINATION_CONFIG.HEROKU.app),
|
||||
appName: z.string().trim().min(1, "App name required").describe(SecretSyncs.DESTINATION_CONFIG.HEROKU.appName)
|
||||
});
|
||||
|
||||
const HerokuSyncOptionsConfig: TSyncOptionsConfig = { canImportSecrets: true };
|
||||
|
||||
export const HerokuSyncSchema = BaseSecretSyncSchema(SecretSync.Heroku, HerokuSyncOptionsConfig).extend({
|
||||
destination: z.literal(SecretSync.Heroku),
|
||||
destinationConfig: HerokuSyncDestinationConfigSchema
|
||||
});
|
||||
|
||||
export const CreateHerokuSyncSchema = GenericCreateSecretSyncFieldsSchema(
|
||||
SecretSync.Heroku,
|
||||
HerokuSyncOptionsConfig
|
||||
).extend({
|
||||
destinationConfig: HerokuSyncDestinationConfigSchema
|
||||
});
|
||||
|
||||
export const UpdateHerokuSyncSchema = GenericUpdateSecretSyncFieldsSchema(
|
||||
SecretSync.Heroku,
|
||||
HerokuSyncOptionsConfig
|
||||
).extend({
|
||||
destinationConfig: HerokuSyncDestinationConfigSchema.optional()
|
||||
});
|
||||
|
||||
export const HerokuSyncListItemSchema = z.object({
|
||||
name: z.literal("Heroku"),
|
||||
connection: z.literal(AppConnection.Heroku),
|
||||
destination: z.literal(SecretSync.Heroku),
|
||||
canImportSecrets: z.literal(true)
|
||||
});
|
||||
@@ -0,0 +1,24 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import { THerokuConnection } from "@app/services/app-connection/heroku";
|
||||
|
||||
import { CreateHerokuSyncSchema, HerokuSyncListItemSchema, HerokuSyncSchema } from "./heroku-sync-schemas";
|
||||
|
||||
export type THerokuSync = z.infer<typeof HerokuSyncSchema>;
|
||||
export type THerokuSyncInput = z.infer<typeof CreateHerokuSyncSchema>;
|
||||
export type THerokuSyncListItem = z.infer<typeof HerokuSyncListItemSchema>;
|
||||
|
||||
export type THerokuSyncWithCredentials = THerokuSync & {
|
||||
connection: THerokuConnection;
|
||||
};
|
||||
|
||||
export type THerokuConfigVars = Record<string, string | null>;
|
||||
|
||||
export type THerokuListVariables = {
|
||||
authToken: string;
|
||||
app: string;
|
||||
};
|
||||
|
||||
export type THerokuUpdateVariables = THerokuListVariables & {
|
||||
configVars: THerokuConfigVars;
|
||||
};
|
||||
@@ -0,0 +1,4 @@
|
||||
export * from "./heroku-sync-constants";
|
||||
export * from "./heroku-sync-fns";
|
||||
export * from "./heroku-sync-schemas";
|
||||
export * from "./heroku-sync-types";
|
||||
@@ -16,6 +16,7 @@ export enum SecretSync {
|
||||
TeamCity = "teamcity",
|
||||
OCIVault = "oci-vault",
|
||||
OnePass = "1password",
|
||||
Heroku = "heroku",
|
||||
Render = "render",
|
||||
Flyio = "flyio",
|
||||
CloudflarePages = "cloudflare-pages"
|
||||
|
||||
@@ -35,6 +35,7 @@ import { FLYIO_SYNC_LIST_OPTION, FlyioSyncFns } from "./flyio";
|
||||
import { GCP_SYNC_LIST_OPTION } from "./gcp";
|
||||
import { GcpSyncFns } from "./gcp/gcp-sync-fns";
|
||||
import { HC_VAULT_SYNC_LIST_OPTION, HCVaultSyncFns } from "./hc-vault";
|
||||
import { HEROKU_SYNC_LIST_OPTION, HerokuSyncFns } from "./heroku";
|
||||
import { HUMANITEC_SYNC_LIST_OPTION } from "./humanitec";
|
||||
import { HumanitecSyncFns } from "./humanitec/humanitec-sync-fns";
|
||||
import { RENDER_SYNC_LIST_OPTION, RenderSyncFns } from "./render";
|
||||
@@ -62,6 +63,7 @@ const SECRET_SYNC_LIST_OPTIONS: Record<SecretSync, TSecretSyncListItem> = {
|
||||
[SecretSync.TeamCity]: TEAMCITY_SYNC_LIST_OPTION,
|
||||
[SecretSync.OCIVault]: OCI_VAULT_SYNC_LIST_OPTION,
|
||||
[SecretSync.OnePass]: ONEPASS_SYNC_LIST_OPTION,
|
||||
[SecretSync.Heroku]: HEROKU_SYNC_LIST_OPTION,
|
||||
[SecretSync.Render]: RENDER_SYNC_LIST_OPTION,
|
||||
[SecretSync.Flyio]: FLYIO_SYNC_LIST_OPTION,
|
||||
[SecretSync.CloudflarePages]: CLOUDFLARE_PAGES_SYNC_LIST_OPTION
|
||||
@@ -210,6 +212,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:
|
||||
@@ -305,6 +309,9 @@ export const SecretSyncFns = {
|
||||
case SecretSync.OnePass:
|
||||
secretMap = await OnePassSyncFns.getSecrets(secretSync);
|
||||
break;
|
||||
case SecretSync.Heroku:
|
||||
secretMap = await HerokuSyncFns.getSecrets(secretSync, { appConnectionDAL, kmsService });
|
||||
break;
|
||||
case SecretSync.Render:
|
||||
secretMap = await RenderSyncFns.getSecrets(secretSync);
|
||||
break;
|
||||
@@ -381,6 +388,8 @@ export const SecretSyncFns = {
|
||||
return OCIVaultSyncFns.removeSecrets(secretSync, schemaSecretMap);
|
||||
case SecretSync.OnePass:
|
||||
return OnePassSyncFns.removeSecrets(secretSync, schemaSecretMap);
|
||||
case SecretSync.Heroku:
|
||||
return HerokuSyncFns.removeSecrets(secretSync, schemaSecretMap, { appConnectionDAL, kmsService });
|
||||
case SecretSync.Render:
|
||||
return RenderSyncFns.removeSecrets(secretSync, schemaSecretMap);
|
||||
case SecretSync.Flyio:
|
||||
|
||||
@@ -19,6 +19,7 @@ export const SECRET_SYNC_NAME_MAP: Record<SecretSync, string> = {
|
||||
[SecretSync.TeamCity]: "TeamCity",
|
||||
[SecretSync.OCIVault]: "OCI Vault",
|
||||
[SecretSync.OnePass]: "1Password",
|
||||
[SecretSync.Heroku]: "Heroku",
|
||||
[SecretSync.Render]: "Render",
|
||||
[SecretSync.Flyio]: "Fly.io",
|
||||
[SecretSync.CloudflarePages]: "Cloudflare Pages"
|
||||
@@ -42,6 +43,7 @@ export const SECRET_SYNC_CONNECTION_MAP: Record<SecretSync, AppConnection> = {
|
||||
[SecretSync.TeamCity]: AppConnection.TeamCity,
|
||||
[SecretSync.OCIVault]: AppConnection.OCI,
|
||||
[SecretSync.OnePass]: AppConnection.OnePass,
|
||||
[SecretSync.Heroku]: AppConnection.Heroku,
|
||||
[SecretSync.Render]: AppConnection.Render,
|
||||
[SecretSync.Flyio]: AppConnection.Flyio,
|
||||
[SecretSync.CloudflarePages]: AppConnection.Cloudflare
|
||||
@@ -65,6 +67,7 @@ export const SECRET_SYNC_PLAN_MAP: Record<SecretSync, SecretSyncPlanType> = {
|
||||
[SecretSync.TeamCity]: SecretSyncPlanType.Regular,
|
||||
[SecretSync.OCIVault]: SecretSyncPlanType.Enterprise,
|
||||
[SecretSync.OnePass]: SecretSyncPlanType.Regular,
|
||||
[SecretSync.Heroku]: SecretSyncPlanType.Regular,
|
||||
[SecretSync.Render]: SecretSyncPlanType.Regular,
|
||||
[SecretSync.Flyio]: SecretSyncPlanType.Regular,
|
||||
[SecretSync.CloudflarePages]: SecretSyncPlanType.Regular
|
||||
|
||||
@@ -80,6 +80,7 @@ import {
|
||||
THCVaultSyncListItem,
|
||||
THCVaultSyncWithCredentials
|
||||
} from "./hc-vault/hc-vault-sync-types";
|
||||
import { THerokuSync, THerokuSyncInput, THerokuSyncListItem, THerokuSyncWithCredentials } from "./heroku";
|
||||
import {
|
||||
THumanitecSync,
|
||||
THumanitecSyncInput,
|
||||
@@ -130,6 +131,7 @@ export type TSecretSync =
|
||||
| TTeamCitySync
|
||||
| TOCIVaultSync
|
||||
| TOnePassSync
|
||||
| THerokuSync
|
||||
| TRenderSync
|
||||
| TFlyioSync
|
||||
| TCloudflarePagesSync;
|
||||
@@ -152,6 +154,7 @@ export type TSecretSyncWithCredentials =
|
||||
| TTeamCitySyncWithCredentials
|
||||
| TOCIVaultSyncWithCredentials
|
||||
| TOnePassSyncWithCredentials
|
||||
| THerokuSyncWithCredentials
|
||||
| TRenderSyncWithCredentials
|
||||
| TFlyioSyncWithCredentials
|
||||
| TCloudflarePagesSyncWithCredentials;
|
||||
@@ -174,6 +177,7 @@ export type TSecretSyncInput =
|
||||
| TTeamCitySyncInput
|
||||
| TOCIVaultSyncInput
|
||||
| TOnePassSyncInput
|
||||
| THerokuSyncInput
|
||||
| TRenderSyncInput
|
||||
| TFlyioSyncInput
|
||||
| TCloudflarePagesSyncInput;
|
||||
@@ -196,6 +200,7 @@ export type TSecretSyncListItem =
|
||||
| TTeamCitySyncListItem
|
||||
| TOCIVaultSyncListItem
|
||||
| TOnePassSyncListItem
|
||||
| THerokuSyncListItem
|
||||
| TRenderSyncListItem
|
||||
| TFlyioSyncListItem
|
||||
| TCloudflarePagesSyncListItem;
|
||||
|
||||
Reference in New Issue
Block a user