diff --git a/backend/src/db/instance.ts b/backend/src/db/instance.ts index cdb8c3028..3ce8148aa 100644 --- a/backend/src/db/instance.ts +++ b/backend/src/db/instance.ts @@ -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 } }); }); diff --git a/backend/src/ee/services/dynamic-secret/providers/aws-iam.ts b/backend/src/ee/services/dynamic-secret/providers/aws-iam.ts index f7383d4ac..ec75bb2e4 100644 --- a/backend/src/ee/services/dynamic-secret/providers/aws-iam.ts +++ b/backend/src/ee/services/dynamic-secret/providers/aws-iam.ts @@ -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 }) ); diff --git a/backend/src/ee/services/dynamic-secret/providers/github.ts b/backend/src/ee/services/dynamic-secret/providers/github.ts new file mode 100644 index 000000000..67d92b2a6 --- /dev/null +++ b/backend/src/ee/services/dynamic-secret/providers/github.ts @@ -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; + 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 => { + 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(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 + }; +}; diff --git a/backend/src/ee/services/dynamic-secret/providers/index.ts b/backend/src/ee/services/dynamic-secret/providers/index.ts index 7e14cf1ab..7fd65f98d 100644 --- a/backend/src/ee/services/dynamic-secret/providers/index.ts +++ b/backend/src/ee/services/dynamic-secret/providers/index.ts @@ -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() }); diff --git a/backend/src/ee/services/dynamic-secret/providers/models.ts b/backend/src/ee/services/dynamic-secret/providers/models.ts index 8f361e166..f6fa2a4a8 100644 --- a/backend/src/ee/services/dynamic-secret/providers/models.ts +++ b/backend/src/ee/services/dynamic-secret/providers/models.ts @@ -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 = { diff --git a/backend/src/lib/api-docs/constants.ts b/backend/src/lib/api-docs/constants.ts index c18c87c39..67b70079b 100644 --- a/backend/src/lib/api-docs/constants.ts +++ b/backend/src/lib/api-docs/constants.ts @@ -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.", diff --git a/backend/src/lib/config/env.ts b/backend/src/lib/config/env.ts index 547de9898..b0b35cc2f 100644 --- a/backend/src/lib/config/env.ts +++ b/backend/src/lib/config/env.ts @@ -101,9 +101,9 @@ const envSchema = z LOOPS_API_KEY: zpStr(z.string().optional()), // jwt options AUTH_SECRET: zpStr(z.string()).default(process.env.JWT_AUTH_SECRET), // for those still using old JWT_AUTH_SECRET - JWT_AUTH_LIFETIME: zpStr(z.string().default("1d")), + JWT_AUTH_LIFETIME: zpStr(z.string().default("10d")), JWT_SIGNUP_LIFETIME: zpStr(z.string().default("15m")), - JWT_REFRESH_LIFETIME: zpStr(z.string().default("14d")), + JWT_REFRESH_LIFETIME: zpStr(z.string().default("90d")), JWT_INVITE_LIFETIME: zpStr(z.string().default("1d")), JWT_MFA_LIFETIME: zpStr(z.string().default("5m")), JWT_PROVIDER_AUTH_LIFETIME: zpStr(z.string().default("15m")), diff --git a/backend/src/server/plugins/auth/inject-identity.ts b/backend/src/server/plugins/auth/inject-identity.ts index 211bcea0c..f065bfbed 100644 --- a/backend/src/server/plugins/auth/inject-identity.ts +++ b/backend/src/server/plugins/auth/inject-identity.ts @@ -107,7 +107,7 @@ export const injectIdentity = fp(async (server: FastifyZodProvider) => { server.addHook("onRequest", async (req) => { const appCfg = getConfig(); - if (req.url.includes(".well-known/est") || req.url.includes("/api/v3/auth/") || req.url === "/api/v1/auth/token") { + if (req.url.includes(".well-known/est") || req.url.includes("/api/v3/auth/")) { return; } 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 ab84c014d..e474859c3 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 @@ -50,6 +50,7 @@ import { HCVaultConnectionListItemSchema, SanitizedHCVaultConnectionSchema } from "@app/services/app-connection/hc-vault"; +import { HerokuConnectionListItemSchema, SanitizedHerokuConnectionSchema } from "@app/services/app-connection/heroku"; import { HumanitecConnectionListItemSchema, SanitizedHumanitecConnectionSchema @@ -106,6 +107,7 @@ const SanitizedAppConnectionSchema = z.union([ ...SanitizedOCIConnectionSchema.options, ...SanitizedOracleDBConnectionSchema.options, ...SanitizedOnePassConnectionSchema.options, + ...SanitizedHerokuConnectionSchema.options, ...SanitizedRenderConnectionSchema.options, ...SanitizedFlyioConnectionSchema.options ]); @@ -135,6 +137,7 @@ const AppConnectionOptionsSchema = z.discriminatedUnion("app", [ OCIConnectionListItemSchema, OracleDBConnectionListItemSchema, OnePassConnectionListItemSchema, + HerokuConnectionListItemSchema, RenderConnectionListItemSchema, FlyioConnectionListItemSchema ]); 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 45af934a7..da1857ac5 100644 --- a/backend/src/server/routes/v1/app-connection-routers/index.ts +++ b/backend/src/server/routes/v1/app-connection-routers/index.ts @@ -16,6 +16,7 @@ import { registerGcpConnectionRouter } from "./gcp-connection-router"; import { registerGitHubConnectionRouter } from "./github-connection-router"; import { registerGitHubRadarConnectionRouter } from "./github-radar-connection-router"; import { registerHCVaultConnectionRouter } from "./hc-vault-connection-router"; +import { registerHerokuConnectionRouter } from "./heroku-connection-router"; import { registerHumanitecConnectionRouter } from "./humanitec-connection-router"; import { registerLdapConnectionRouter } from "./ldap-connection-router"; import { registerMsSqlConnectionRouter } from "./mssql-connection-router"; @@ -55,6 +56,7 @@ export const APP_CONNECTION_REGISTER_ROUTER_MAP: Record + 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 675b74982..989d289ec 100644 --- a/backend/src/server/routes/v1/secret-sync-routers/index.ts +++ b/backend/src/server/routes/v1/secret-sync-routers/index.ts @@ -13,6 +13,7 @@ import { registerFlyioSyncRouter } from "./flyio-sync-router"; import { registerGcpSyncRouter } from "./gcp-sync-router"; import { registerGitHubSyncRouter } from "./github-sync-router"; import { registerHCVaultSyncRouter } from "./hc-vault-sync-router"; +import { registerHerokuSyncRouter } from "./heroku-sync-router"; import { registerHumanitecSyncRouter } from "./humanitec-sync-router"; import { registerRenderSyncRouter } from "./render-sync-router"; import { registerTeamCitySyncRouter } from "./teamcity-sync-router"; @@ -40,6 +41,7 @@ export const SECRET_SYNC_REGISTER_ROUTER_MAP: Record { 200: z.object({ token: z.string(), isMfaEnabled: z.boolean(), - mfaMethod: z.string().optional(), - refreshToken: z.string().optional() + mfaMethod: z.string().optional() }) } }, @@ -102,7 +101,7 @@ export const registerLoginRouter = async (server: FastifyZodProvider) => { maxAge: 0 }); - return { token: tokens.access, isMfaEnabled: false, refreshToken: tokens.refresh }; + return { token: tokens.access, isMfaEnabled: false }; } }); @@ -130,8 +129,7 @@ export const registerLoginRouter = async (server: FastifyZodProvider) => { encryptedPrivateKey: z.string(), iv: z.string(), tag: z.string(), - token: z.string(), - refreshToken: z.string().optional() + token: z.string() }) } }, @@ -174,8 +172,7 @@ export const registerLoginRouter = async (server: FastifyZodProvider) => { tag: data.user.tag, protectedKey: data.user.protectedKey || null, protectedKeyIV: data.user.protectedKeyIV || null, - protectedKeyTag: data.user.protectedKeyTag || null, - refreshToken: data.token.refresh + protectedKeyTag: data.user.protectedKeyTag || null } as const; } }); diff --git a/backend/src/services/app-connection/app-connection-enums.ts b/backend/src/services/app-connection/app-connection-enums.ts index 7d210897d..e0b7a61f1 100644 --- a/backend/src/services/app-connection/app-connection-enums.ts +++ b/backend/src/services/app-connection/app-connection-enums.ts @@ -23,6 +23,7 @@ export enum AppConnection { OCI = "oci", OracleDB = "oracledb", OnePass = "1password", + Heroku = "heroku", Render = "render", Flyio = "flyio" } diff --git a/backend/src/services/app-connection/app-connection-fns.ts b/backend/src/services/app-connection/app-connection-fns.ts index 4c97b0392..66027e24b 100644 --- a/backend/src/services/app-connection/app-connection-fns.ts +++ b/backend/src/services/app-connection/app-connection-fns.ts @@ -69,6 +69,7 @@ import { HCVaultConnectionMethod, validateHCVaultConnectionCredentials } from "./hc-vault"; +import { getHerokuConnectionListItem, HerokuConnectionMethod, validateHerokuConnectionCredentials } from "./heroku"; import { getHumanitecConnectionListItem, HumanitecConnectionMethod, @@ -125,6 +126,7 @@ export const listAppConnectionOptions = () => { getOCIConnectionListItem(), getOracleDBConnectionListItem(), getOnePassConnectionListItem(), + getHerokuConnectionListItem(), getRenderConnectionListItem(), getFlyioConnectionListItem() ].sort((a, b) => a.name.localeCompare(b.name)); @@ -202,6 +204,7 @@ export const validateAppConnectionCredentials = async ( [AppConnection.OCI]: validateOCIConnectionCredentials as TAppConnectionCredentialsValidator, [AppConnection.OracleDB]: validateSqlConnectionCredentials as TAppConnectionCredentialsValidator, [AppConnection.OnePass]: validateOnePassConnectionCredentials as TAppConnectionCredentialsValidator, + [AppConnection.Heroku]: validateHerokuConnectionCredentials as TAppConnectionCredentialsValidator, [AppConnection.Render]: validateRenderConnectionCredentials as TAppConnectionCredentialsValidator, [AppConnection.Flyio]: validateFlyioConnectionCredentials as TAppConnectionCredentialsValidator }; @@ -219,7 +222,10 @@ export const getAppConnectionMethodName = (method: TAppConnection["method"]) => case AzureClientSecretsConnectionMethod.OAuth: case GitHubConnectionMethod.OAuth: case AzureDevOpsConnectionMethod.OAuth: + case HerokuConnectionMethod.OAuth: return "OAuth"; + case HerokuConnectionMethod.AuthToken: + return "Auth Token"; case AwsConnectionMethod.AccessKey: case OCIConnectionMethod.AccessKey: return "Access Key"; @@ -310,6 +316,7 @@ export const TRANSITION_CONNECTION_CREDENTIALS_TO_PLATFORM: Record< [AppConnection.OCI]: platformManagedCredentialsNotSupported, [AppConnection.OracleDB]: transferSqlConnectionCredentialsToPlatform as TAppConnectionTransitionCredentialsToPlatform, [AppConnection.OnePass]: platformManagedCredentialsNotSupported, + [AppConnection.Heroku]: platformManagedCredentialsNotSupported, [AppConnection.Render]: platformManagedCredentialsNotSupported, [AppConnection.Flyio]: platformManagedCredentialsNotSupported }; diff --git a/backend/src/services/app-connection/app-connection-maps.ts b/backend/src/services/app-connection/app-connection-maps.ts index 24dc31f99..57725d1e1 100644 --- a/backend/src/services/app-connection/app-connection-maps.ts +++ b/backend/src/services/app-connection/app-connection-maps.ts @@ -25,6 +25,7 @@ export const APP_CONNECTION_NAME_MAP: Record = { [AppConnection.OCI]: "OCI", [AppConnection.OracleDB]: "OracleDB", [AppConnection.OnePass]: "1Password", + [AppConnection.Heroku]: "Heroku", [AppConnection.Render]: "Render", [AppConnection.Flyio]: "Fly.io" }; @@ -54,6 +55,7 @@ export const APP_CONNECTION_PLAN_MAP: Record { + 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 * 1000 - 60000) + }, + 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..99d637dd5 --- /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 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() +}); 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..4b91adc0d --- /dev/null +++ b/backend/src/services/app-connection/heroku/heroku-connection-service.ts @@ -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; + +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 }); + return apps; + } catch (error) { + logger.error(error, `Failed to establish connection with Heroku for app ${connectionId}`); + 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..d2f0817db --- /dev/null +++ b/backend/src/services/secret-sync/heroku/heroku-sync-fns.ts @@ -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; + 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/${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 = {}; + + 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 2ac235fe2..04aaed0ce 100644 --- a/backend/src/services/secret-sync/secret-sync-enums.ts +++ b/backend/src/services/secret-sync/secret-sync-enums.ts @@ -16,6 +16,7 @@ export enum SecretSync { TeamCity = "teamcity", OCIVault = "oci-vault", OnePass = "1password", + Heroku = "heroku", Render = "render", Flyio = "flyio" } diff --git a/backend/src/services/secret-sync/secret-sync-fns.ts b/backend/src/services/secret-sync/secret-sync-fns.ts index 71c413c11..63822a59e 100644 --- a/backend/src/services/secret-sync/secret-sync-fns.ts +++ b/backend/src/services/secret-sync/secret-sync-fns.ts @@ -33,6 +33,7 @@ import { FLYIO_SYNC_LIST_OPTION, FlyioSyncFns } from "./flyio"; import { GCP_SYNC_LIST_OPTION } from "./gcp"; import { GcpSyncFns } from "./gcp/gcp-sync-fns"; import { HC_VAULT_SYNC_LIST_OPTION, HCVaultSyncFns } from "./hc-vault"; +import { HEROKU_SYNC_LIST_OPTION, HerokuSyncFns } from "./heroku"; import { HUMANITEC_SYNC_LIST_OPTION } from "./humanitec"; import { HumanitecSyncFns } from "./humanitec/humanitec-sync-fns"; import { RENDER_SYNC_LIST_OPTION, RenderSyncFns } from "./render"; @@ -60,6 +61,7 @@ const SECRET_SYNC_LIST_OPTIONS: Record = { [SecretSync.TeamCity]: TEAMCITY_SYNC_LIST_OPTION, [SecretSync.OCIVault]: OCI_VAULT_SYNC_LIST_OPTION, [SecretSync.OnePass]: ONEPASS_SYNC_LIST_OPTION, + [SecretSync.Heroku]: HEROKU_SYNC_LIST_OPTION, [SecretSync.Render]: RENDER_SYNC_LIST_OPTION, [SecretSync.Flyio]: FLYIO_SYNC_LIST_OPTION }; @@ -207,6 +209,8 @@ export const SecretSyncFns = { appConnectionDAL, kmsService }).syncSecrets(secretSync, schemaSecretMap); + case SecretSync.Heroku: + return HerokuSyncFns.syncSecrets(secretSync, schemaSecretMap, { appConnectionDAL, kmsService }); case SecretSync.Vercel: return VercelSyncFns.syncSecrets(secretSync, schemaSecretMap); case SecretSync.Windmill: @@ -300,6 +304,9 @@ export const SecretSyncFns = { case SecretSync.OnePass: secretMap = await OnePassSyncFns.getSecrets(secretSync); break; + case SecretSync.Heroku: + secretMap = await HerokuSyncFns.getSecrets(secretSync, { appConnectionDAL, kmsService }); + break; case SecretSync.Render: secretMap = await RenderSyncFns.getSecrets(secretSync); break; @@ -373,6 +380,8 @@ export const SecretSyncFns = { return OCIVaultSyncFns.removeSecrets(secretSync, schemaSecretMap); case SecretSync.OnePass: return OnePassSyncFns.removeSecrets(secretSync, schemaSecretMap); + case SecretSync.Heroku: + return HerokuSyncFns.removeSecrets(secretSync, schemaSecretMap, { appConnectionDAL, kmsService }); case SecretSync.Render: return RenderSyncFns.removeSecrets(secretSync, schemaSecretMap); case SecretSync.Flyio: diff --git a/backend/src/services/secret-sync/secret-sync-maps.ts b/backend/src/services/secret-sync/secret-sync-maps.ts index 47cd7c164..f8f5d813c 100644 --- a/backend/src/services/secret-sync/secret-sync-maps.ts +++ b/backend/src/services/secret-sync/secret-sync-maps.ts @@ -19,6 +19,7 @@ export const SECRET_SYNC_NAME_MAP: Record = { [SecretSync.TeamCity]: "TeamCity", [SecretSync.OCIVault]: "OCI Vault", [SecretSync.OnePass]: "1Password", + [SecretSync.Heroku]: "Heroku", [SecretSync.Render]: "Render", [SecretSync.Flyio]: "Fly.io" }; @@ -41,6 +42,7 @@ export const SECRET_SYNC_CONNECTION_MAP: Record = { [SecretSync.TeamCity]: AppConnection.TeamCity, [SecretSync.OCIVault]: AppConnection.OCI, [SecretSync.OnePass]: AppConnection.OnePass, + [SecretSync.Heroku]: AppConnection.Heroku, [SecretSync.Render]: AppConnection.Render, [SecretSync.Flyio]: AppConnection.Flyio }; @@ -63,6 +65,7 @@ export const SECRET_SYNC_PLAN_MAP: Record = { [SecretSync.TeamCity]: SecretSyncPlanType.Regular, [SecretSync.OCIVault]: SecretSyncPlanType.Enterprise, [SecretSync.OnePass]: SecretSyncPlanType.Regular, + [SecretSync.Heroku]: SecretSyncPlanType.Regular, [SecretSync.Render]: SecretSyncPlanType.Regular, [SecretSync.Flyio]: 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 f41d8e27b..d3dddea82 100644 --- a/backend/src/services/secret-sync/secret-sync-types.ts +++ b/backend/src/services/secret-sync/secret-sync-types.ts @@ -80,6 +80,7 @@ import { THCVaultSyncListItem, THCVaultSyncWithCredentials } from "./hc-vault/hc-vault-sync-types"; +import { THerokuSync, THerokuSyncInput, THerokuSyncListItem, THerokuSyncWithCredentials } from "./heroku"; import { THumanitecSync, THumanitecSyncInput, @@ -124,6 +125,7 @@ export type TSecretSync = | TTeamCitySync | TOCIVaultSync | TOnePassSync + | THerokuSync | TRenderSync | TFlyioSync; @@ -145,6 +147,7 @@ export type TSecretSyncWithCredentials = | TTeamCitySyncWithCredentials | TOCIVaultSyncWithCredentials | TOnePassSyncWithCredentials + | THerokuSyncWithCredentials | TRenderSyncWithCredentials | TFlyioSyncWithCredentials; @@ -166,6 +169,7 @@ export type TSecretSyncInput = | TTeamCitySyncInput | TOCIVaultSyncInput | TOnePassSyncInput + | THerokuSyncInput | TRenderSyncInput | TFlyioSyncInput; @@ -187,6 +191,7 @@ export type TSecretSyncListItem = | TTeamCitySyncListItem | TOCIVaultSyncListItem | TOnePassSyncListItem + | THerokuSyncListItem | TRenderSyncListItem | TFlyioSyncListItem; diff --git a/cli/packages/api/model.go b/cli/packages/api/model.go index 59abe0253..a7a797a0b 100644 --- a/cli/packages/api/model.go +++ b/cli/packages/api/model.go @@ -21,7 +21,7 @@ type LoginTwoRequest struct { } type LoginTwoResponse struct { - JWTToken string `json:"token"` + JTWToken string `json:"token"` RefreshToken string `json:"refreshToken"` PublicKey string `json:"publicKey"` EncryptedPrivateKey string `json:"encryptedPrivateKey"` @@ -267,7 +267,7 @@ type GetLoginTwoV2Response struct { ProtectedKey string `json:"protectedKey"` ProtectedKeyIV string `json:"protectedKeyIV"` ProtectedKeyTag string `json:"protectedKeyTag"` - RefreshToken string `json:"refreshToken"` + RefreshToken string `json:"RefreshToken"` } type VerifyMfaTokenRequest struct { diff --git a/cli/packages/cmd/dynamic_secrets.go b/cli/packages/cmd/dynamic_secrets.go index f8a43bc06..0443e7714 100644 --- a/cli/packages/cmd/dynamic_secrets.go +++ b/cli/packages/cmd/dynamic_secrets.go @@ -87,7 +87,7 @@ func getDynamicSecretList(cmd *cobra.Command, args []string) { loggedInUserDetails = util.EstablishUserLoginSession() } - infisicalToken = loggedInUserDetails.UserCredentials.JWTToken + infisicalToken = loggedInUserDetails.UserCredentials.JTWToken } httpClient.SetAuthToken(infisicalToken) @@ -211,7 +211,7 @@ func createDynamicSecretLeaseByName(cmd *cobra.Command, args []string) { if loggedInUserDetails.LoginExpired { loggedInUserDetails = util.EstablishUserLoginSession() } - infisicalToken = loggedInUserDetails.UserCredentials.JWTToken + infisicalToken = loggedInUserDetails.UserCredentials.JTWToken } httpClient.SetAuthToken(infisicalToken) @@ -363,7 +363,7 @@ func renewDynamicSecretLeaseByName(cmd *cobra.Command, args []string) { loggedInUserDetails = util.EstablishUserLoginSession() } - infisicalToken = loggedInUserDetails.UserCredentials.JWTToken + infisicalToken = loggedInUserDetails.UserCredentials.JTWToken } httpClient.SetAuthToken(infisicalToken) @@ -478,7 +478,7 @@ func revokeDynamicSecretLeaseByName(cmd *cobra.Command, args []string) { loggedInUserDetails = util.EstablishUserLoginSession() } - infisicalToken = loggedInUserDetails.UserCredentials.JWTToken + infisicalToken = loggedInUserDetails.UserCredentials.JTWToken } httpClient.SetAuthToken(infisicalToken) @@ -592,7 +592,7 @@ func listDynamicSecretLeaseByName(cmd *cobra.Command, args []string) { if loggedInUserDetails.LoginExpired { loggedInUserDetails = util.EstablishUserLoginSession() } - infisicalToken = loggedInUserDetails.UserCredentials.JWTToken + infisicalToken = loggedInUserDetails.UserCredentials.JTWToken } httpClient.SetAuthToken(infisicalToken) diff --git a/cli/packages/cmd/export.go b/cli/packages/cmd/export.go index 9066b7145..b872b0e61 100644 --- a/cli/packages/cmd/export.go +++ b/cli/packages/cmd/export.go @@ -115,7 +115,7 @@ var exportCmd = &cobra.Command{ if err != nil { util.HandleError(err) } - accessToken = loggedInUserDetails.UserCredentials.JWTToken + accessToken = loggedInUserDetails.UserCredentials.JTWToken } processedTemplate, err := ProcessTemplate(1, templatePath, nil, accessToken, "", &newEtag, dynamicSecretLeases) diff --git a/cli/packages/cmd/init.go b/cli/packages/cmd/init.go index c85cac9f9..2ef555a82 100644 --- a/cli/packages/cmd/init.go +++ b/cli/packages/cmd/init.go @@ -53,7 +53,7 @@ var initCmd = &cobra.Command{ if err != nil { util.HandleError(err, "Unable to get resty client with custom headers") } - httpClient.SetAuthToken(userCreds.UserCredentials.JWTToken) + httpClient.SetAuthToken(userCreds.UserCredentials.JTWToken) organizationResponse, err := api.CallGetAllOrganizations(httpClient) if err != nil { @@ -124,7 +124,7 @@ var initCmd = &cobra.Command{ } // set the config jwt token to the new token - userCreds.UserCredentials.JWTToken = tokenResponse.Token + userCreds.UserCredentials.JTWToken = tokenResponse.Token err = util.StoreUserCredsInKeyRing(&userCreds.UserCredentials) httpClient.SetAuthToken(tokenResponse.Token) diff --git a/cli/packages/cmd/login.go b/cli/packages/cmd/login.go index c7168066b..fd3ce1569 100644 --- a/cli/packages/cmd/login.go +++ b/cli/packages/cmd/login.go @@ -111,7 +111,7 @@ var loginCmd = &cobra.Command{ infisicalClient := infisicalSdk.NewInfisicalClient(context.Background(), infisicalSdk.Config{ SiteUrl: config.INFISICAL_URL, UserAgent: api.USER_AGENT, - AutoTokenRefresh: true, + AutoTokenRefresh: false, CustomHeaders: customHeaders, }) @@ -437,8 +437,7 @@ func cliDefaultLogin(userCredentialsToBeStored *models.UserCredentials) { //updating usercredentials userCredentialsToBeStored.Email = email userCredentialsToBeStored.PrivateKey = string(decryptedPrivateKey) - userCredentialsToBeStored.JWTToken = newJwtToken - userCredentialsToBeStored.RefreshToken = loginTwoResponse.RefreshToken + userCredentialsToBeStored.JTWToken = newJwtToken } func init() { @@ -863,7 +862,7 @@ func askToPasteJwtToken(success chan models.UserCredentials, failure chan error) os.Exit(1) } - // verify JWT + // verify JTW httpClient, err := util.GetRestyClientWithCustomHeaders() if err != nil { failure <- err @@ -872,7 +871,7 @@ func askToPasteJwtToken(success chan models.UserCredentials, failure chan error) } httpClient. - SetAuthToken(userCredentials.JWTToken). + SetAuthToken(userCredentials.JTWToken). SetHeader("Accept", "application/json") isAuthenticated := api.CallIsAuthenticated(httpClient) diff --git a/cli/packages/cmd/secrets.go b/cli/packages/cmd/secrets.go index 05d1c2d5f..930a27a56 100644 --- a/cli/packages/cmd/secrets.go +++ b/cli/packages/cmd/secrets.go @@ -245,7 +245,7 @@ var secretsSetCmd = &cobra.Command{ secretOperations, err = util.SetRawSecrets(processedArgs, secretType, environmentName, secretsPath, projectId, &models.TokenDetails{ Type: "", - Token: loggedInUserDetails.UserCredentials.JWTToken, + Token: loggedInUserDetails.UserCredentials.JTWToken, }, file) } @@ -330,7 +330,7 @@ var secretsDeleteCmd = &cobra.Command{ loggedInUserDetails = util.EstablishUserLoginSession() } - httpClient.SetAuthToken(loggedInUserDetails.UserCredentials.JWTToken) + httpClient.SetAuthToken(loggedInUserDetails.UserCredentials.JTWToken) } for _, secretName := range args { diff --git a/cli/packages/cmd/ssh.go b/cli/packages/cmd/ssh.go index e0939995e..4315989bd 100644 --- a/cli/packages/cmd/ssh.go +++ b/cli/packages/cmd/ssh.go @@ -186,7 +186,7 @@ func issueCredentials(cmd *cobra.Command, args []string) { if loggedInUserDetails.LoginExpired { loggedInUserDetails = util.EstablishUserLoginSession() } - infisicalToken = loggedInUserDetails.UserCredentials.JWTToken + infisicalToken = loggedInUserDetails.UserCredentials.JTWToken } certificateTemplateId, err := cmd.Flags().GetString("certificateTemplateId") @@ -419,7 +419,7 @@ func signKey(cmd *cobra.Command, args []string) { if loggedInUserDetails.LoginExpired { loggedInUserDetails = util.EstablishUserLoginSession() } - infisicalToken = loggedInUserDetails.UserCredentials.JWTToken + infisicalToken = loggedInUserDetails.UserCredentials.JTWToken } certificateTemplateId, err := cmd.Flags().GetString("certificateTemplateId") @@ -628,7 +628,7 @@ func sshConnect(cmd *cobra.Command, args []string) { if loggedInUserDetails.LoginExpired { loggedInUserDetails = util.EstablishUserLoginSession() } - infisicalToken = loggedInUserDetails.UserCredentials.JWTToken + infisicalToken = loggedInUserDetails.UserCredentials.JTWToken } writeHostCaToFile, err := cmd.Flags().GetBool("write-host-ca-to-file") @@ -881,7 +881,7 @@ func sshAddHost(cmd *cobra.Command, args []string) { if loggedInUserDetails.LoginExpired { loggedInUserDetails = util.EstablishUserLoginSession() } - infisicalToken = loggedInUserDetails.UserCredentials.JWTToken + infisicalToken = loggedInUserDetails.UserCredentials.JTWToken } projectId, err := cmd.Flags().GetString("projectId") diff --git a/cli/packages/cmd/tokens.go b/cli/packages/cmd/tokens.go index f867169f8..a2e445239 100644 --- a/cli/packages/cmd/tokens.go +++ b/cli/packages/cmd/tokens.go @@ -115,7 +115,7 @@ var tokensCreateCmd = &cobra.Command{ } } - workspaceKey, err := util.GetPlainTextWorkspaceKey(loggedInUserDetails.UserCredentials.JWTToken, loggedInUserDetails.UserCredentials.PrivateKey, workspaceId) + workspaceKey, err := util.GetPlainTextWorkspaceKey(loggedInUserDetails.UserCredentials.JTWToken, loggedInUserDetails.UserCredentials.PrivateKey, workspaceId) if err != nil { util.HandleError(err, "Unable to get workspace key needed to create service token") } @@ -140,7 +140,7 @@ var tokensCreateCmd = &cobra.Command{ util.HandleError(err, "Unable to get resty client with custom headers") } - httpClient.SetAuthToken(loggedInUserDetails.UserCredentials.JWTToken). + httpClient.SetAuthToken(loggedInUserDetails.UserCredentials.JTWToken). SetHeader("Accept", "application/json") createServiceTokenResponse, err := api.CallCreateServiceToken(httpClient, api.CreateServiceTokenRequest{ diff --git a/cli/packages/cmd/user.go b/cli/packages/cmd/user.go index bde0b3075..6c7d54d46 100644 --- a/cli/packages/cmd/user.go +++ b/cli/packages/cmd/user.go @@ -118,7 +118,7 @@ var userGetTokenCmd = &cobra.Command{ util.HandleError(err, "[infisical user get token]: Unable to get logged in user token") } - tokenParts := strings.Split(loggedInUserDetails.UserCredentials.JWTToken, ".") + tokenParts := strings.Split(loggedInUserDetails.UserCredentials.JTWToken, ".") if len(tokenParts) != 3 { util.HandleError(errors.New("invalid token format"), "[infisical user get token]: Invalid token format") } @@ -136,7 +136,7 @@ var userGetTokenCmd = &cobra.Command{ } fmt.Println("Session ID:", tokenPayload.TokenVersionId) - fmt.Println("Token:", loggedInUserDetails.UserCredentials.JWTToken) + fmt.Println("Token:", loggedInUserDetails.UserCredentials.JTWToken) }, } diff --git a/cli/packages/models/cli.go b/cli/packages/models/cli.go index 9a3ff85b2..8b9fef6f6 100644 --- a/cli/packages/models/cli.go +++ b/cli/packages/models/cli.go @@ -5,8 +5,8 @@ import "time" type UserCredentials struct { Email string `json:"email"` PrivateKey string `json:"privateKey"` - JWTToken string `json:"JWTToken"` - RefreshToken string `json:"refreshToken"` + JTWToken string `json:"JTWToken"` + RefreshToken string `json:"RefreshToken"` } // The file struct for Infisical config file diff --git a/cli/packages/util/credentials.go b/cli/packages/util/credentials.go index 58de59dee..cd73e47ca 100644 --- a/cli/packages/util/credentials.go +++ b/cli/packages/util/credentials.go @@ -9,7 +9,6 @@ import ( "github.com/Infisical/infisical-merge/packages/api" "github.com/Infisical/infisical-merge/packages/config" "github.com/Infisical/infisical-merge/packages/models" - "github.com/rs/zerolog/log" "github.com/zalando/go-keyring" ) @@ -91,22 +90,23 @@ func GetCurrentLoggedInUserDetails(setConfigVariables bool) (LoggedInUserDetails } httpClient. - SetAuthToken(userCreds.JWTToken). + SetAuthToken(userCreds.JTWToken). SetHeader("Accept", "application/json") isAuthenticated := api.CallIsAuthenticated(httpClient) - if !isAuthenticated { - accessTokenResponse, refreshErr := api.CallGetNewAccessTokenWithRefreshToken(httpClient, userCreds.RefreshToken) - if refreshErr == nil && accessTokenResponse.Token != "" { - isAuthenticated = true - userCreds.JWTToken = accessTokenResponse.Token - } - } + // TODO: add refresh token + // if !isAuthenticated { + // accessTokenResponse, err := api.CallGetNewAccessTokenWithRefreshToken(httpClient, userCreds.RefreshToken) + // if err == nil && accessTokenResponse.Token != "" { + // isAuthenticated = true + // userCreds.JTWToken = accessTokenResponse.Token + // } + // } - err = StoreUserCredsInKeyRing(&userCreds) - if err != nil { - log.Debug().Msg("unable to store your user credentials with new access token") - } + // err = StoreUserCredsInKeyRing(&userCreds) + // if err != nil { + // log.Debug().Msg("unable to store your user credentials with new access token") + // } if !isAuthenticated { return LoggedInUserDetails{ diff --git a/cli/packages/util/folders.go b/cli/packages/util/folders.go index 412fadb54..fb4f2a322 100644 --- a/cli/packages/util/folders.go +++ b/cli/packages/util/folders.go @@ -35,7 +35,7 @@ func GetAllFolders(params models.GetAllFoldersParameters) ([]models.SingleFolder params.WorkspaceId = workspaceFile.WorkspaceId } - folders, err := GetFoldersViaJWT(loggedInUserDetails.UserCredentials.JWTToken, params.WorkspaceId, params.Environment, params.FoldersPath) + folders, err := GetFoldersViaJTW(loggedInUserDetails.UserCredentials.JTWToken, params.WorkspaceId, params.Environment, params.FoldersPath) folderErr = err foldersToReturn = folders } else if params.InfisicalToken != "" { @@ -60,14 +60,14 @@ func GetAllFolders(params models.GetAllFoldersParameters) ([]models.SingleFolder return foldersToReturn, folderErr } -func GetFoldersViaJWT(JWTToken string, workspaceId string, environmentName string, foldersPath string) ([]models.SingleFolder, error) { +func GetFoldersViaJTW(JTWToken string, workspaceId string, environmentName string, foldersPath string) ([]models.SingleFolder, error) { // set up resty client httpClient, err := GetRestyClientWithCustomHeaders() if err != nil { return nil, err } - httpClient.SetAuthToken(JWTToken). + httpClient.SetAuthToken(JTWToken). SetHeader("Accept", "application/json") getFoldersRequest := api.GetFoldersV1Request{ @@ -194,7 +194,7 @@ func CreateFolder(params models.CreateFolderParameters) (models.SingleFolder, er loggedInUserDetails = EstablishUserLoginSession() } - params.InfisicalToken = loggedInUserDetails.UserCredentials.JWTToken + params.InfisicalToken = loggedInUserDetails.UserCredentials.JTWToken } // set up resty client @@ -243,7 +243,7 @@ func DeleteFolder(params models.DeleteFolderParameters) ([]models.SingleFolder, loggedInUserDetails = EstablishUserLoginSession() } - params.InfisicalToken = loggedInUserDetails.UserCredentials.JWTToken + params.InfisicalToken = loggedInUserDetails.UserCredentials.JTWToken } // set up resty client diff --git a/cli/packages/util/secrets.go b/cli/packages/util/secrets.go index 4666291e7..814e7da23 100644 --- a/cli/packages/util/secrets.go +++ b/cli/packages/util/secrets.go @@ -302,9 +302,9 @@ func GetAllEnvironmentVariables(params models.GetAllSecretsParameters, projectCo params.WorkspaceId = infisicalDotJson.WorkspaceId } - res, err := GetPlainTextSecretsV3(loggedInUserDetails.UserCredentials.JWTToken, params.WorkspaceId, + res, err := GetPlainTextSecretsV3(loggedInUserDetails.UserCredentials.JTWToken, params.WorkspaceId, params.Environment, params.SecretsPath, params.IncludeImport, params.Recursive, params.TagSlugs, true) - log.Debug().Msgf("GetAllEnvironmentVariables: Trying to fetch secrets JWT token [err=%s]", err) + log.Debug().Msgf("GetAllEnvironmentVariables: Trying to fetch secrets JTW token [err=%s]", err) if err == nil { backupEncryptionKey, err := GetBackupEncryptionKey() 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/documentation/guides/nextjs-vercel.mdx b/docs/documentation/guides/nextjs-vercel.mdx index 5aeadc752..f4dd9ceca 100644 --- a/docs/documentation/guides/nextjs-vercel.mdx +++ b/docs/documentation/guides/nextjs-vercel.mdx @@ -127,8 +127,8 @@ Follow the instructions for your operating system to install the Infisical CLI. - Add Infisical repository - + Add Infisical repository + ```console $ curl -1sLf \ 'https://dl.cloudsmith.io/public/infisical/infisical-cli/setup.deb.sh' \ @@ -143,7 +143,7 @@ Follow the instructions for your operating system to install the Infisical CLI. Use the `yay` package manager to install from the [Arch User Repository](https://aur.archlinux.org/packages/infisical-bin) - + ```console $ yay -S infisical-bin ``` @@ -187,7 +187,7 @@ We'll now use the Infisical-Vercel integration send secrets from Infisical to Ve ### Infisical-Vercel integration -To begin we have to import the Next.js app into Vercel as a project. [Follow these instructions](https://nextjs.org/learn/basics/deploying-nextjs-app/deploy) to deploy the Next.js app to Vercel. +To begin we have to import the Next.js app into Vercel as a project. [Follow these instructions](https://vercel.com/docs/frameworks/nextjs) to deploy the Next.js app to Vercel. Next, navigate to your project's integrations tab in Infisical and press on the Vercel tile to grant Infisical access to your Vercel account. @@ -237,7 +237,7 @@ At this stage, you know how to use the Infisical-Vercel integration to sync prod Yes. Your secrets are still encrypted at rest. To note, most secret managers actually don't support end-to-end encryption. - + Check out the [security guide](/security/overview). diff --git a/docs/documentation/platform/dynamic-secrets/aws-iam.mdx b/docs/documentation/platform/dynamic-secrets/aws-iam.mdx index a02d80a5c..03bc5df84 100644 --- a/docs/documentation/platform/dynamic-secrets/aws-iam.mdx +++ b/docs/documentation/platform/dynamic-secrets/aws-iam.mdx @@ -161,6 +161,11 @@ Replace **\** with your AWS account id and **\** w {{replace identity.name 'user' 'replace'}} // testreplace ``` + + + Tags to be added to the created IAM User resource. + + Select *Assume Role* method. @@ -304,6 +309,10 @@ Replace **\** with your AWS account id and **\** w - `{{unixTimestamp}}`: Current Unix timestamp + + Tags to be added to the created IAM User resource. + + diff --git a/docs/documentation/platform/dynamic-secrets/github.mdx b/docs/documentation/platform/dynamic-secrets/github.mdx new file mode 100644 index 000000000..b1ceb9871 --- /dev/null +++ b/docs/documentation/platform/dynamic-secrets/github.mdx @@ -0,0 +1,112 @@ +--- +title: "GitHub" +description: "Learn how to dynamically generate GitHub App tokens." +--- + +The Infisical GitHub dynamic secret allows you to generate short-lived tokens for a GitHub App on demand based on service account permissions. + +## Setup GitHub App + + + + Navigate to [GitHub App settings](https://github.com/settings/apps) and click **New GitHub App**. + + ![integrations github app create](/images/integrations/github/app/self-hosted-github-app-create.png) + + Give the application a name and a homepage URL. These values do not need to be anything specific. + + Disable webhook by unchecking the Active checkbox. + ![integrations github app webhook](/images/integrations/github/app/self-hosted-github-app-webhook.png) + + Configure the app's permissions to grant the necessary access for the dynamic secret's short-lived tokens based on your use case. + + Create the GitHub Application. + ![integrations github app create confirm](/images/integrations/github/app/self-hosted-github-app-create-confirm.png) + + + If you have a GitHub organization, you can create an application under it + in your organization Settings > Developer settings > GitHub Apps > New GitHub App. + + + + Copy the **App ID** and generate a new **Private Key** for your GitHub Application. + ![integrations github app create private key](/images/integrations/github/app/self-hosted-github-app-private-key.png) + + Save these for later steps. + + + Install your application to whichever repositories and organizations that you want the dynamic secret to access. + ![Install App](/images/platform/dynamic-secrets/github/install-app.png) + + ![Install App](/images/platform/dynamic-secrets/github/install-app-modal.png) + + Once you've installed the app, **copy the installation ID** from the URL and save it for later steps. + ![Install App](/images/platform/dynamic-secrets/github/installation.png) + + + +## Set up Dynamic Secrets with GitHub + + + + Open the Secret Overview dashboard and select the environment in which you would like to add a dynamic secret. + + + ![Add Dynamic Secret Button](../../../images/platform/dynamic-secrets/add-dynamic-secret-button.png) + + + ![Dynamic Secret Modal](../../../images/platform/dynamic-secrets/github/modal.png) + + + + Name by which you want the secret to be referenced + + + The ID of the app created in earlier steps. + + + The Private Key of the app created in earlier steps. + + + The ID of the installation from earlier steps. + + + + After submitting the form, you will see a dynamic secret created in the dashboard. + + + + Once you've successfully configured the dynamic secret, you're ready to generate on-demand credentials. + To do this, simply click on the 'Generate' button which appears when hovering over the dynamic secret item. + Alternatively, you can initiate the creation of a new lease by selecting 'New Lease' from the dynamic secret lease list section. + + ![Dynamic Secret](/images/platform/dynamic-secrets/dynamic-secret-generate.png) + ![Dynamic Secret](/images/platform/dynamic-secrets/dynamic-secret-lease-empty.png) + + When generating these secrets, the TTL will be fixed to 1 hour. + + ![Provision Lease](/images/platform/dynamic-secrets/provision-lease.png) + + Once you click the `Submit` button, a new secret lease will be generated and the credentials from it will be shown to you. + + ![Dynamic Secret Lease](/images/platform/dynamic-secrets/github/lease.png) + + + +## Audit or Revoke Leases + +Once you have created one or more leases, you will be able to access them by clicking on the respective dynamic secret item on the dashboard. + +This will allow you to see the expiration time of the lease or delete a lease before its set time to live. + +![Lease Data](/images/platform/dynamic-secrets/lease-data.png) + + + GitHub App tokens cannot be revoked. As such, revoking a token on Infisical does not invalidate the GitHub token; it remains active until it expires. + + +## Renew Leases + + + GitHub App tokens cannot be renewed because they are fixed to a lifetime of 1 hour. + diff --git a/docs/documentation/platform/pki/pki-issuer.mdx b/docs/documentation/platform/pki/pki-issuer.mdx index c46c1f35e..13214bfb7 100644 --- a/docs/documentation/platform/pki/pki-issuer.mdx +++ b/docs/documentation/platform/pki/pki-issuer.mdx @@ -49,11 +49,21 @@ In the following steps, we explore how to install the Infisical PKI Issuer using ``` - Install the Infisical PKI Issuer controller into your Kubernetes cluster by running the following command: + Install the Infisical PKI Issuer controller into your Kubernetes cluster using one of the following methods: - ```bash - kubectl apply -f https://raw.githubusercontent.com/Infisical/infisical-issuer/main/build/install.yaml - ``` + + + ```bash + helm repo add infisical-helm-charts 'https://dl.cloudsmith.io/public/infisical/helm-charts/helm/charts/' + helm install infisical-pki-issuer infisical-helm-charts/infisical-pki-issuer + ``` + + + ```bash + kubectl apply -f https://raw.githubusercontent.com/Infisical/infisical-issuer/main/build/install.yaml + ``` + + Start by creating a Kubernetes `Secret` containing the **Client Secret** from step 1. As mentioned previously, this will be used by the Infisical PKI issuer to authenticate with Infisical. 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/platform/dynamic-secrets/github/install-app-modal.png b/docs/images/platform/dynamic-secrets/github/install-app-modal.png new file mode 100644 index 000000000..f3aa1c51d Binary files /dev/null and b/docs/images/platform/dynamic-secrets/github/install-app-modal.png differ diff --git a/docs/images/platform/dynamic-secrets/github/install-app.png b/docs/images/platform/dynamic-secrets/github/install-app.png new file mode 100644 index 000000000..7be3d6c1b Binary files /dev/null and b/docs/images/platform/dynamic-secrets/github/install-app.png differ diff --git a/docs/images/platform/dynamic-secrets/github/installation.png b/docs/images/platform/dynamic-secrets/github/installation.png new file mode 100644 index 000000000..61a06ec04 Binary files /dev/null and b/docs/images/platform/dynamic-secrets/github/installation.png differ diff --git a/docs/images/platform/dynamic-secrets/github/lease.png b/docs/images/platform/dynamic-secrets/github/lease.png new file mode 100644 index 000000000..4ce602898 Binary files /dev/null and b/docs/images/platform/dynamic-secrets/github/lease.png differ diff --git a/docs/images/platform/dynamic-secrets/github/modal.png b/docs/images/platform/dynamic-secrets/github/modal.png new file mode 100644 index 000000000..9ac7743a8 Binary files /dev/null and b/docs/images/platform/dynamic-secrets/github/modal.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..d67411627 --- /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/integrations/heroku/oauth2/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: + + - `CLIENT_ID_HEROKU`: The **Client ID** of your Heroku API client. + - `CLIENT_SECRET_HEROKU`: 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 on the **Applications** tab, 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..e62b5df9e --- /dev/null +++ b/docs/integrations/secret-syncs/heroku.mdx @@ -0,0 +1,144 @@ +--- +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", + "disableSecretDeletion": true + }, + "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 b543ee09f..c0c7b7c28 100644 --- a/docs/mint.json +++ b/docs/mint.json @@ -214,6 +214,7 @@ "documentation/platform/dynamic-secrets/cassandra", "documentation/platform/dynamic-secrets/elastic-search", "documentation/platform/dynamic-secrets/gcp-iam", + "documentation/platform/dynamic-secrets/github", "documentation/platform/dynamic-secrets/ldap", "documentation/platform/dynamic-secrets/mongo-atlas", "documentation/platform/dynamic-secrets/mongo-db", @@ -509,6 +510,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", @@ -544,6 +546,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/render", @@ -1327,6 +1330,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": [ @@ -1642,6 +1657,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/features/TtlFormLabel.tsx b/frontend/src/components/features/TtlFormLabel.tsx index 02f162200..fdb9aea5e 100644 --- a/frontend/src/components/features/TtlFormLabel.tsx +++ b/frontend/src/components/features/TtlFormLabel.tsx @@ -26,7 +26,7 @@ export const TtlFormLabel = ({ label }: { label: string }) => ( } 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..6ceb94b3f --- /dev/null +++ b/frontend/src/components/secret-syncs/forms/SecretSyncDestinationFields/HerokuSyncFields.tsx @@ -0,0 +1,76 @@ +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", ""); + setValue("destinationConfig.appName", ""); + }} + /> + + ( + +
+ 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 5d36ac71f..64345aae4 100644 --- a/frontend/src/components/secret-syncs/forms/SecretSyncDestinationFields/SecretSyncDestinationFields.tsx +++ b/frontend/src/components/secret-syncs/forms/SecretSyncDestinationFields/SecretSyncDestinationFields.tsx @@ -15,6 +15,7 @@ import { FlyioSyncFields } from "./FlyioSyncFields"; 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 { RenderSyncFields } from "./RenderSyncFields"; @@ -63,6 +64,8 @@ export const SecretSyncDestinationFields = () => { return ; case SecretSync.OnePass: return ; + case SecretSync.Heroku: + return ; case SecretSync.Render: return ; case SecretSync.Flyio: diff --git a/frontend/src/components/secret-syncs/forms/SecretSyncOptionsFields/SecretSyncOptionsFields.tsx b/frontend/src/components/secret-syncs/forms/SecretSyncOptionsFields/SecretSyncOptionsFields.tsx index 9e60e164e..55aeca3c6 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: case SecretSync.Render: case SecretSync.Flyio: AdditionalSyncOptionsFieldsComponent = null; 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 a82400f5b..2131518a0 100644 --- a/frontend/src/components/secret-syncs/forms/SecretSyncReviewFields/SecretSyncReviewFields.tsx +++ b/frontend/src/components/secret-syncs/forms/SecretSyncReviewFields/SecretSyncReviewFields.tsx @@ -24,6 +24,7 @@ import { FlyioSyncReviewFields } from "./FlyioSyncReviewFields"; 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"; @@ -106,6 +107,9 @@ export const SecretSyncReviewFields = () => { case SecretSync.OnePass: DestinationFieldsComponent = ; break; + case SecretSync.Heroku: + DestinationFieldsComponent = ; + break; case SecretSync.Render: DestinationFieldsComponent = ; break; diff --git a/frontend/src/components/secret-syncs/forms/schemas/heroku-sync-destination-schema.ts b/frontend/src/components/secret-syncs/forms/schemas/heroku-sync-destination-schema.ts new file mode 100644 index 000000000..94cad7b83 --- /dev/null +++ b/frontend/src/components/secret-syncs/forms/schemas/heroku-sync-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 70cb4767d..b296cb6d6 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 @@ -12,6 +12,7 @@ import { FlyioSyncDestinationSchema } from "./flyio-sync-destination-schema"; 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-sync-destination-schema"; import { HumanitecSyncDestinationSchema } from "./humanitec-sync-destination-schema"; import { OCIVaultSyncDestinationSchema } from "./oci-vault-sync-destination-schema"; import { RenderSyncDestinationSchema } from "./render-sync-destination-schema"; @@ -38,6 +39,7 @@ const SecretSyncUnionSchema = z.discriminatedUnion("destination", [ TeamCitySyncDestinationSchema, OCIVaultSyncDestinationSchema, OnePassSyncDestinationSchema, + HerokuSyncDestinationSchema, RenderSyncDestinationSchema, FlyioSyncDestinationSchema ]); diff --git a/frontend/src/components/utilities/attemptCliLogin.ts b/frontend/src/components/utilities/attemptCliLogin.ts index 914a4c266..d897ebeaa 100644 --- a/frontend/src/components/utilities/attemptCliLogin.ts +++ b/frontend/src/components/utilities/attemptCliLogin.ts @@ -16,7 +16,7 @@ export interface IsCliLoginSuccessful { loginResponse?: { email: string; privateKey: string; - JWTToken: string; + JTWToken: string; }; success: boolean; } @@ -131,7 +131,7 @@ const attemptLogin = async ({ loginResponse: { email, privateKey, - JWTToken: token + JTWToken: token }, success: true }); diff --git a/frontend/src/components/utilities/attemptCliLoginMfa.ts b/frontend/src/components/utilities/attemptCliLoginMfa.ts index 2a5de18dd..14a61d817 100644 --- a/frontend/src/components/utilities/attemptCliLoginMfa.ts +++ b/frontend/src/components/utilities/attemptCliLoginMfa.ts @@ -14,7 +14,7 @@ interface IsMfaLoginSuccessful { success: boolean; loginResponse: { privateKey: string; - JWTToken: string; + JTWToken: string; }; } @@ -95,7 +95,7 @@ const attemptLoginMfa = async ({ success: true, loginResponse: { privateKey, - JWTToken: token + JTWToken: token } }); } catch (err) { diff --git a/frontend/src/helpers/appConnections.ts b/frontend/src/helpers/appConnections.ts index c27e1e646..d0e5be036 100644 --- a/frontend/src/helpers/appConnections.ts +++ b/frontend/src/helpers/appConnections.ts @@ -37,6 +37,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"; import { RenderConnectionMethod } from "@app/hooks/api/appConnections/types/render-connection"; @@ -81,6 +82,7 @@ export const APP_CONNECTION_MAP: Record< [AppConnection.TeamCity]: { name: "TeamCity", image: "TeamCity.png" }, [AppConnection.OCI]: { name: "OCI", image: "Oracle.png", enterprise: true }, [AppConnection.OnePass]: { name: "1Password", image: "1Password.png" }, + [AppConnection.Heroku]: { name: "Heroku", image: "Heroku.png" }, [AppConnection.Render]: { name: "Render", image: "Render.png" }, [AppConnection.Flyio]: { name: "Fly.io", image: "Flyio.svg" } }; @@ -95,6 +97,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: @@ -129,6 +132,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 }; case RenderConnectionMethod.ApiKey: return { name: "API Key", icon: faKey }; default: diff --git a/frontend/src/helpers/secretSyncs.ts b/frontend/src/helpers/secretSyncs.ts index c33e45159..afcd32958 100644 --- a/frontend/src/helpers/secretSyncs.ts +++ b/frontend/src/helpers/secretSyncs.ts @@ -62,6 +62,10 @@ export const SECRET_SYNC_MAP: Record = { [SecretSync.TeamCity]: AppConnection.TeamCity, [SecretSync.OCIVault]: AppConnection.OCI, [SecretSync.OnePass]: AppConnection.OnePass, + [SecretSync.Heroku]: AppConnection.Heroku, [SecretSync.Render]: AppConnection.Render, [SecretSync.Flyio]: AppConnection.Flyio }; diff --git a/frontend/src/hooks/api/accessApproval/types.ts b/frontend/src/hooks/api/accessApproval/types.ts index 28330b3ad..32baa3c62 100644 --- a/frontend/src/hooks/api/accessApproval/types.ts +++ b/frontend/src/hooks/api/accessApproval/types.ts @@ -35,7 +35,7 @@ export type Approver = { id: string; type: ApproverType; sequence?: number; - approvals?: number; + approvalsRequired?: number; }; export type Bypasser = { diff --git a/frontend/src/hooks/api/appConnections/enums.ts b/frontend/src/hooks/api/appConnections/enums.ts index 1f5cb4a3a..d4ffdaba3 100644 --- a/frontend/src/hooks/api/appConnections/enums.ts +++ b/frontend/src/hooks/api/appConnections/enums.ts @@ -23,6 +23,7 @@ export enum AppConnection { TeamCity = "teamcity", OCI = "oci", OnePass = "1password", + Heroku = "heroku", Render = "render", Flyio = "flyio" } 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 e6e545daf..1332354ec 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; }; @@ -141,6 +146,7 @@ export type TAppConnectionOption = | TTeamCityConnectionOption | TOCIConnectionOption | TOnePassConnectionOption + | THerokuConnectionOption | TRenderConnectionOption | TFlyioConnectionOption; @@ -169,6 +175,7 @@ export type TAppConnectionOptionMap = { [AppConnection.TeamCity]: TTeamCityConnectionOption; [AppConnection.OCI]: TOCIConnectionOption; [AppConnection.OnePass]: TOnePassConnectionOption; + [AppConnection.Heroku]: THerokuConnectionOption; [AppConnection.Render]: TRenderConnectionOption; [AppConnection.Flyio]: TFlyioConnectionOption; }; 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 abaac4fad..413ad0782 100644 --- a/frontend/src/hooks/api/appConnections/types/index.ts +++ b/frontend/src/hooks/api/appConnections/types/index.ts @@ -14,6 +14,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"; @@ -41,6 +42,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"; @@ -79,6 +81,7 @@ export type TAppConnection = | TTeamCityConnection | TOCIConnection | TOnePassConnection + | THerokuConnection | TRenderConnection | TFlyioConnection; @@ -132,6 +135,7 @@ export type TAppConnectionMap = { [AppConnection.TeamCity]: TTeamCityConnection; [AppConnection.OCI]: TOCIConnection; [AppConnection.OnePass]: TOnePassConnection; + [AppConnection.Heroku]: THerokuConnection; [AppConnection.Render]: TRenderConnection; [AppConnection.Flyio]: TFlyioConnection; }; diff --git a/frontend/src/hooks/api/auth/queries.tsx b/frontend/src/hooks/api/auth/queries.tsx index 9acd2308f..796fd3152 100644 --- a/frontend/src/hooks/api/auth/queries.tsx +++ b/frontend/src/hooks/api/auth/queries.tsx @@ -73,7 +73,6 @@ export const selectOrganization = async (data: { }) => { const { data: res } = await apiRequest.post<{ token: string; - refreshToken: string; isMfaEnabled: boolean; mfaMethod?: MfaMethod; }>("/api/v3/auth/select-organization", data); diff --git a/frontend/src/hooks/api/dynamicSecret/types.ts b/frontend/src/hooks/api/dynamicSecret/types.ts index e8d80e632..4dc635fa2 100644 --- a/frontend/src/hooks/api/dynamicSecret/types.ts +++ b/frontend/src/hooks/api/dynamicSecret/types.ts @@ -12,9 +12,10 @@ export type TDynamicSecret = { defaultTTL: string; status?: DynamicSecretStatus; statusDetails?: string; - maxTTL: string; + maxTTL?: string; usernameTemplate?: string | null; metadata?: { key: string; value: string }[]; + tags?: { key: string; value: string }[]; }; export enum DynamicSecretProviders { @@ -35,7 +36,8 @@ export enum DynamicSecretProviders { SapAse = "sap-ase", Kubernetes = "kubernetes", Vertica = "vertica", - GcpIam = "gcp-iam" + GcpIam = "gcp-iam", + Github = "github" } export enum KubernetesDynamicSecretCredentialType { @@ -89,6 +91,7 @@ export type TDynamicSecretProvider = } | { type: DynamicSecretProviders.AwsIam; + tags?: { key: string; value: string }[]; inputs: | { method: DynamicSecretAwsIamAuth.AccessKey; @@ -333,6 +336,14 @@ export type TDynamicSecretProvider = inputs: { serviceAccountEmail: string; }; + } + | { + type: DynamicSecretProviders.Github; + inputs: { + appId: number; + installationId: number; + privateKey: string; + }; }; export type TCreateDynamicSecretDTO = { @@ -345,6 +356,7 @@ export type TCreateDynamicSecretDTO = { name: string; metadata?: { key: string; value: string }[]; usernameTemplate?: string; + tags?: { key: string; value: string }[]; }; export type TUpdateDynamicSecretDTO = { @@ -359,6 +371,7 @@ export type TUpdateDynamicSecretDTO = { maxTTL?: string | null; inputs?: unknown; usernameTemplate?: string | null; + tags?: { key: string; value: string }[]; }; }; diff --git a/frontend/src/hooks/api/secretSyncs/enums.ts b/frontend/src/hooks/api/secretSyncs/enums.ts index 323266caa..a59ba20c9 100644 --- a/frontend/src/hooks/api/secretSyncs/enums.ts +++ b/frontend/src/hooks/api/secretSyncs/enums.ts @@ -16,6 +16,7 @@ export enum SecretSync { TeamCity = "teamcity", OCIVault = "oci-vault", OnePass = "1password", + Heroku = "heroku", Render = "render", Flyio = "flyio" } 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 fc8a0da46..aa46b5988 100644 --- a/frontend/src/hooks/api/secretSyncs/types/index.ts +++ b/frontend/src/hooks/api/secretSyncs/types/index.ts @@ -14,6 +14,7 @@ import { TFlyioSync } from "./flyio-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"; @@ -46,6 +47,7 @@ export type TSecretSync = | TTeamCitySync | TOCIVaultSync | TOnePassSync + | THerokuSync | TRenderSync | TFlyioSync; diff --git a/frontend/src/pages/auth/LoginPage/components/PasswordStep/PasswordStep.tsx b/frontend/src/pages/auth/LoginPage/components/PasswordStep/PasswordStep.tsx index e175ef7f2..91432f9be 100644 --- a/frontend/src/pages/auth/LoginPage/components/PasswordStep/PasswordStep.tsx +++ b/frontend/src/pages/auth/LoginPage/components/PasswordStep/PasswordStep.tsx @@ -76,9 +76,7 @@ export const PasswordStep = ({ // case: organization ID is present from the provider auth token -- select the org and use the new jwt token in the CLI, then navigate to the org if (organizationId) { const finishWithOrgWorkflow = async () => { - const { token, isMfaEnabled, mfaMethod, refreshToken } = await selectOrganization({ - organizationId - }); + const { token, isMfaEnabled, mfaMethod } = await selectOrganization({ organizationId }); if (isMfaEnabled) { SecurityClient.setMfaToken(token); @@ -96,11 +94,10 @@ export const PasswordStep = ({ const payload = { privateKey, email, - JWTToken: token, - refreshToken + JTWToken: token }; await instance.post(cliUrl, payload).catch(() => { - // if error happens to communicate we set the token with an expiry in session storage + // if error happens to communicate we set the token with an expiry in sessino storage // the cli-redirect page has logic to show this to user and ask them to paste it in terminal sessionStorage.setItem( SessionStorageKeys.CLI_TERMINAL_TOKEN, @@ -190,7 +187,7 @@ export const PasswordStep = ({ // case: organization ID is present from the provider auth token -- select the org and use the new jwt token in the CLI, then navigate to the org if (organizationId) { const finishWithOrgWorkflow = async () => { - const { token, isMfaEnabled, mfaMethod, refreshToken } = await selectOrganization({ + const { token, isMfaEnabled, mfaMethod } = await selectOrganization({ organizationId }); @@ -209,11 +206,10 @@ export const PasswordStep = ({ const instance = axios.create(); const payload = { ...isCliLoginSuccessful.loginResponse, - JWTToken: token, - refreshToken + JTWToken: token }; await instance.post(cliUrl, payload).catch(() => { - // if error happens to communicate we set the token with an expiry in session storage + // if error happens to communicate we set the token with an expiry in sessino storage // the cli-redirect page has logic to show this to user and ask them to paste it in terminal sessionStorage.setItem( SessionStorageKeys.CLI_TERMINAL_TOKEN, diff --git a/frontend/src/pages/auth/SelectOrgPage/SelectOrgSection.tsx b/frontend/src/pages/auth/SelectOrgPage/SelectOrgSection.tsx index ead8b1401..ac194cd05 100644 --- a/frontend/src/pages/auth/SelectOrgPage/SelectOrgSection.tsx +++ b/frontend/src/pages/auth/SelectOrgPage/SelectOrgSection.tsx @@ -112,7 +112,7 @@ export const SelectOrganizationSection = () => { return; } - const { token, isMfaEnabled, mfaMethod, refreshToken } = await selectOrg + const { token, isMfaEnabled, mfaMethod } = await selectOrg .mutateAsync({ organizationId: organization.id, userAgent: callbackPort ? UserAgentType.CLI : undefined @@ -149,16 +149,15 @@ export const SelectOrganizationSection = () => { } const payload = { - JWTToken: token, + JTWToken: token, email: user?.email, - privateKey, - refreshToken + privateKey } as IsCliLoginSuccessful["loginResponse"]; // send request to server endpoint const instance = axios.create(); await instance.post(`http://127.0.0.1:${callbackPort}/`, payload).catch(() => { - // if error happens to communicate we set the token with an expiry in session storage + // if error happens to communicate we set the token with an expiry in sessino storage // the cli-redirect page has logic to show this to user and ask them to paste it in terminal sessionStorage.setItem( SessionStorageKeys.CLI_TERMINAL_TOKEN, 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 99c81a745..45d550dae 100644 --- a/frontend/src/pages/organization/AppConnections/AppConnectionsPage/components/AppConnectionForm/AppConnectionForm.tsx +++ b/frontend/src/pages/organization/AppConnections/AppConnectionsPage/components/AppConnectionForm/AppConnectionForm.tsx @@ -23,6 +23,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"; @@ -121,6 +122,8 @@ const CreateForm = ({ app, onComplete }: CreateFormProps) => { return ; case AppConnection.OnePass: return ; + case AppConnection.Heroku: + return ; case AppConnection.Render: return ; case AppConnection.Flyio: @@ -209,6 +212,8 @@ const UpdateForm = ({ appConnection, onComplete }: UpdateFormProps) => { return ; case AppConnection.OnePass: return ; + case AppConnection.Heroku: + return ; case AppConnection.Render: return ; case AppConnection.Flyio: 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..085ef49c5 --- /dev/null +++ b/frontend/src/pages/organization/AppConnections/AppConnectionsPage/components/AppConnectionForm/HerokuAppConnectionForm.tsx @@ -0,0 +1,253 @@ +/* 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, + setValue, + 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 OAuth + 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 ca7b37682..449ee4b58 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 @@ -12,6 +12,7 @@ import { FlyioSyncDestinationCol } from "./FlyioSyncDestinationCol"; 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 { RenderSyncDestinationCol } from "./RenderSyncDestinationCol"; @@ -60,6 +61,8 @@ export const SecretSyncDestinationCol = ({ secretSync }: Props) => { return ; case SecretSync.AzureDevOps: return ; + case SecretSync.Heroku: + return ; case SecretSync.Render: return ; case SecretSync.Flyio: 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 f8ab727a2..3e36cd3bc 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; case SecretSync.Render: primaryText = destinationConfig.serviceName ?? destinationConfig.serviceId; secondaryText = "Service"; diff --git a/frontend/src/pages/secret-manager/SecretApprovalsPage/components/AccessApprovalRequest/components/ReviewAccessModal.tsx b/frontend/src/pages/secret-manager/SecretApprovalsPage/components/AccessApprovalRequest/components/ReviewAccessModal.tsx index e6df8d814..4cea61cbe 100644 --- a/frontend/src/pages/secret-manager/SecretApprovalsPage/components/AccessApprovalRequest/components/ReviewAccessModal.tsx +++ b/frontend/src/pages/secret-manager/SecretApprovalsPage/components/AccessApprovalRequest/components/ReviewAccessModal.tsx @@ -185,7 +185,7 @@ export const ReviewAccessRequestModal = ({ return acc; } - const approvals = curr.approvals || policy.approvals; + const approvals = curr.approvalsRequired || policy.approvals; const sequence = curr.sequence || 1; acc.push( diff --git a/frontend/src/pages/secret-manager/SecretApprovalsPage/components/ApprovalPolicyList/components/AccessPolicyModal.tsx b/frontend/src/pages/secret-manager/SecretApprovalsPage/components/ApprovalPolicyList/components/AccessPolicyModal.tsx index a20d95db9..42c85f9d1 100644 --- a/frontend/src/pages/secret-manager/SecretApprovalsPage/components/ApprovalPolicyList/components/AccessPolicyModal.tsx +++ b/frontend/src/pages/secret-manager/SecretApprovalsPage/components/ApprovalPolicyList/components/AccessPolicyModal.tsx @@ -152,34 +152,34 @@ const Form = ({ .map(({ id, type }) => ({ id, type: type as BypasserType.Group })) || [], approvals: editValues?.approvals, allowedSelfApprovals: editValues?.allowedSelfApprovals, - sequenceApprovers: editValues.approvers - ?.sort((a, b) => (a?.sequence || 0) - (b?.sequence || 0)) - .reduce( - (acc, curr) => { - if (acc.length && acc[acc.length - 1].sequence === curr.sequence) { - acc[acc.length - 1][curr.type]?.push(curr); - return acc; - } - const approvals = curr.approvals || editValues.approvals; - acc.push( - curr.type === ApproverType.User - ? { - user: [curr], - group: [], - sequence: 1, - approvals - } - : { group: [curr], user: [], sequence: 1, approvals } - ); + sequenceApprovers: editValues.approvers?.reduce( + (acc, curr) => { + if (acc.length && acc[acc.length - 1].sequence === curr.sequence) { + acc[acc.length - 1][curr.type]?.push(curr); return acc; - }, - [] as { user: Approver[]; group: Approver[]; sequence?: number; approvals: number }[] - ) + } + const approvals = curr.approvalsRequired || editValues.approvals; + acc.push( + curr.type === ApproverType.User + ? { + user: [curr], + group: [], + sequence: 1, + approvals + } + : { group: [curr], user: [], sequence: 1, approvals } + ); + return acc; + }, + [] as { user: Approver[]; group: Approver[]; sequence?: number; approvals: number }[] + ) } as TFormSchema) : undefined, - defaultValues: { - sequenceApprovers: [{ approvals: 1 }] - } + defaultValues: !editValues + ? { + sequenceApprovers: [{ approvals: 1 }] + } + : undefined }); const sequenceApproversFieldArray = useFieldArray({ control, diff --git a/frontend/src/pages/secret-manager/SecretApprovalsPage/components/ApprovalPolicyList/components/ApprovalPolicyRow.tsx b/frontend/src/pages/secret-manager/SecretApprovalsPage/components/ApprovalPolicyList/components/ApprovalPolicyRow.tsx index 683820be6..907baf8c2 100644 --- a/frontend/src/pages/secret-manager/SecretApprovalsPage/components/ApprovalPolicyList/components/ApprovalPolicyRow.tsx +++ b/frontend/src/pages/secret-manager/SecretApprovalsPage/components/ApprovalPolicyList/components/ApprovalPolicyRow.tsx @@ -61,11 +61,11 @@ export const ApprovalPolicyRow = ({ const sortedSteps = policy.approvers?.sort((a, b) => (a?.sequence || 0) - (b?.sequence || 0)); const entityInSameSequence = sortedSteps?.reduce( (acc, curr) => { - if (acc.length && acc[acc.length - 1].sequence === curr.sequence) { + if (acc.length && acc[acc.length - 1].sequence === (curr.sequence || 1)) { acc[acc.length - 1][curr.type]?.push(curr); return acc; } - const approvals = curr.approvals || policy.approvals; + const approvals = curr.approvalsRequired || policy.approvals; acc.push( curr.type === ApproverType.User ? { user: [curr], group: [], sequence: 1, approvals } diff --git a/frontend/src/pages/secret-manager/SecretDashboardPage/components/ActionBar/CreateDynamicSecretForm/AwsIamInputForm.tsx b/frontend/src/pages/secret-manager/SecretDashboardPage/components/ActionBar/CreateDynamicSecretForm/AwsIamInputForm.tsx index 80c80ee61..48e942d0a 100644 --- a/frontend/src/pages/secret-manager/SecretDashboardPage/components/ActionBar/CreateDynamicSecretForm/AwsIamInputForm.tsx +++ b/frontend/src/pages/secret-manager/SecretDashboardPage/components/ActionBar/CreateDynamicSecretForm/AwsIamInputForm.tsx @@ -21,6 +21,8 @@ import { } from "@app/hooks/api/dynamicSecret/types"; import { WorkspaceEnv } from "@app/hooks/api/types"; +import { MetadataForm } from "../../DynamicSecretListView/MetadataForm"; + const formSchema = z.object({ provider: z.discriminatedUnion("method", [ z.object({ @@ -32,7 +34,12 @@ const formSchema = z.object({ 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: z + .array( + z.object({ key: z.string().trim().min(1).max(128), value: z.string().trim().min(1).max(256) }) + ) + .optional() }), z.object({ method: z.literal(DynamicSecretAwsIamAuth.AssumeRole), @@ -42,7 +49,12 @@ const formSchema = z.object({ 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: z + .array( + z.object({ key: z.string().trim().min(1).max(128), value: z.string().trim().min(1).max(256) }) + ) + .optional() }) ]), defaultTTL: z.string().superRefine((val, ctx) => { @@ -398,6 +410,7 @@ export const AwsIamInputForm = ({ )} /> + {!isSingleEnvironmentMode && ( , provider: DynamicSecretProviders.GcpIam, title: "GCP IAM" + }, + { + icon: , + provider: DynamicSecretProviders.Github, + title: "GitHub" } ]; @@ -548,6 +554,25 @@ export const CreateDynamicSecretForm = ({ /> )} + {wizardStep === WizardSteps.ProviderInputs && + selectedProvider === DynamicSecretProviders.Github && ( + + + + )} diff --git a/frontend/src/pages/secret-manager/SecretDashboardPage/components/ActionBar/CreateDynamicSecretForm/GithubInputForm.tsx b/frontend/src/pages/secret-manager/SecretDashboardPage/components/ActionBar/CreateDynamicSecretForm/GithubInputForm.tsx new file mode 100644 index 000000000..800bc677a --- /dev/null +++ b/frontend/src/pages/secret-manager/SecretDashboardPage/components/ActionBar/CreateDynamicSecretForm/GithubInputForm.tsx @@ -0,0 +1,234 @@ +import { Controller, useForm } from "react-hook-form"; +import { faQuestionCircle } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { z } from "zod"; + +import { createNotification } from "@app/components/notifications"; +import { + Button, + FilterableSelect, + FormControl, + FormLabel, + Input, + SecretInput, + Tooltip +} from "@app/components/v2"; +import { useCreateDynamicSecret } from "@app/hooks/api"; +import { DynamicSecretProviders } from "@app/hooks/api/dynamicSecret/types"; +import { WorkspaceEnv } from "@app/hooks/api/types"; + +const formSchema = z.object({ + provider: z.object({ + appId: z.coerce.number().min(1, "Required"), + installationId: z.coerce.number().min(1, "Required"), + privateKey: z + .string() + .trim() + .min(1, "Required") + .refine( + (val) => + /^-----BEGIN(?:(?: RSA| PGP| ENCRYPTED)? PRIVATE KEY)-----\s*[\s\S]*?-----END(?:(?: RSA| PGP| ENCRYPTED)? PRIVATE KEY)-----$/.test( + val + ), + "Invalid PEM format for private key" + ) + }), + name: z.string().refine((val) => val.toLowerCase() === val, "Must be lowercase"), + environment: z.object({ name: z.string(), slug: z.string() }) +}); +type TForm = z.infer; + +type Props = { + onCompleted: () => void; + onCancel: () => void; + secretPath: string; + projectSlug: string; + environments: WorkspaceEnv[]; + isSingleEnvironmentMode?: boolean; +}; + +export const GithubInputForm = ({ + onCompleted, + onCancel, + environments, + secretPath, + projectSlug, + isSingleEnvironmentMode +}: Props) => { + const { + control, + formState: { isSubmitting }, + handleSubmit + } = useForm({ + resolver: zodResolver(formSchema), + defaultValues: { + environment: isSingleEnvironmentMode && environments.length > 0 ? environments[0] : undefined + } + }); + + const createDynamicSecret = useCreateDynamicSecret(); + + const handleCreateDynamicSecret = async ({ name, provider, environment }: TForm) => { + if (createDynamicSecret.isPending) return; + try { + await createDynamicSecret.mutateAsync({ + provider: { + type: DynamicSecretProviders.Github, + inputs: { + ...provider + } + }, + defaultTTL: "1h", // Github is limited to 1 hour tokens + name, + path: secretPath, + projectSlug, + environmentSlug: environment.slug + }); + onCompleted(); + } catch { + createNotification({ + type: "error", + text: "Failed to create dynamic secret" + }); + } + }; + + return ( +
+
+
+
+
+ ( + + + + )} + /> +
+
+ + + + } + /> + } + > + + +
+
+
+
+ Configuration +
+ +
+ ( + + + + )} + /> + + ( + + + + )} + /> + + ( + + + + )} + /> +
+ + {!isSingleEnvironmentMode && ( + ( + + option.name} + getOptionValue={(option) => option.slug} + menuPlacement="top" + /> + + )} + /> + )} +
+
+ +
+ + +
+
+
+ ); +}; diff --git a/frontend/src/pages/secret-manager/SecretDashboardPage/components/DynamicSecretListView/CreateDynamicSecretLease.tsx b/frontend/src/pages/secret-manager/SecretDashboardPage/components/DynamicSecretListView/CreateDynamicSecretLease.tsx index 1bdf94665..133abe58e 100644 --- a/frontend/src/pages/secret-manager/SecretDashboardPage/components/DynamicSecretListView/CreateDynamicSecretLease.tsx +++ b/frontend/src/pages/secret-manager/SecretDashboardPage/components/DynamicSecretListView/CreateDynamicSecretLease.tsx @@ -368,6 +368,22 @@ const renderOutputForm = ( ); } + if (provider === DynamicSecretProviders.Github) { + const { TOKEN } = data as { + TOKEN: string; + }; + + return ( +
+ +
+ ); + } + return null; }; @@ -608,6 +624,9 @@ export const CreateDynamicSecretLease = ({ return ; } + // Github tokens are fixed to 1 hour + const fixedTtl = provider === DynamicSecretProviders.Github; + return (
@@ -629,8 +648,11 @@ export const CreateDynamicSecretLease = ({ label={} isError={Boolean(error?.message)} errorText={error?.message} + helperText={ + fixedTtl ? `This provider has a fixed TTL of ${field.value}` : undefined + } > - + )} /> diff --git a/frontend/src/pages/secret-manager/SecretDashboardPage/components/DynamicSecretListView/DynamicSecretLease.tsx b/frontend/src/pages/secret-manager/SecretDashboardPage/components/DynamicSecretListView/DynamicSecretLease.tsx index 49c38b2eb..085a06169 100644 --- a/frontend/src/pages/secret-manager/SecretDashboardPage/components/DynamicSecretListView/DynamicSecretLease.tsx +++ b/frontend/src/pages/secret-manager/SecretDashboardPage/components/DynamicSecretListView/DynamicSecretLease.tsx @@ -29,7 +29,7 @@ import { import { ProjectPermissionDynamicSecretActions, ProjectPermissionSub } from "@app/context"; import { usePopUp } from "@app/hooks"; import { useGetDynamicSecretLeases, useRevokeDynamicSecretLease } from "@app/hooks/api"; -import { TDynamicSecret } from "@app/hooks/api/dynamicSecret/types"; +import { DynamicSecretProviders, TDynamicSecret } from "@app/hooks/api/dynamicSecret/types"; import { DynamicSecretLeaseStatus } from "@app/hooks/api/dynamicSecretLease/types"; import { RenewDynamicSecretLease } from "./RenewDynamicSecretLease"; @@ -44,6 +44,8 @@ type Props = { onClose: () => void; }; +const DYNAMIC_SECRETS_WITHOUT_RENEWAL = [DynamicSecretProviders.Github]; + export const DynamicSecretLease = ({ projectSlug, dynamicSecretName, @@ -94,6 +96,8 @@ export const DynamicSecretLease = ({ } }; + const canRenew = !DYNAMIC_SECRETS_WITHOUT_RENEWAL.includes(dynamicSecret.type); + return (
@@ -141,29 +145,31 @@ export const DynamicSecretLease = ({
- - {(isAllowed) => ( - handlePopUpOpen("renewSecret", { leaseId: id })} - > - - - )} - + {canRenew && ( + + {(isAllowed) => ( + handlePopUpOpen("renewSecret", { leaseId: id })} + > + + + )} + + )} { @@ -89,7 +97,7 @@ export const EditDynamicSecretAwsIamForm = ({ usernameTemplate: dynamicSecret?.usernameTemplate || "{{randomUsername}}", inputs: { ...(dynamicSecret.inputs as TForm["inputs"]) - } + }, } }); const isAccessKeyMethod = watch("inputs.method") === DynamicSecretAwsIamAuth.AccessKey; @@ -117,7 +125,8 @@ export const EditDynamicSecretAwsIamForm = ({ defaultTTL, inputs, newName: newName === dynamicSecret.name ? undefined : newName, - usernameTemplate: !usernameTemplate || isDefaultUsernameTemplate ? null : usernameTemplate + usernameTemplate: + !usernameTemplate || isDefaultUsernameTemplate ? null : usernameTemplate } }); onClose(); @@ -380,6 +389,7 @@ export const EditDynamicSecretAwsIamForm = ({ )} /> +
diff --git a/frontend/src/pages/secret-manager/SecretDashboardPage/components/DynamicSecretListView/EditDynamicSecretForm/EditDynamicSecretForm.tsx b/frontend/src/pages/secret-manager/SecretDashboardPage/components/DynamicSecretListView/EditDynamicSecretForm/EditDynamicSecretForm.tsx index 11589d531..5c4d31dd0 100644 --- a/frontend/src/pages/secret-manager/SecretDashboardPage/components/DynamicSecretListView/EditDynamicSecretForm/EditDynamicSecretForm.tsx +++ b/frontend/src/pages/secret-manager/SecretDashboardPage/components/DynamicSecretListView/EditDynamicSecretForm/EditDynamicSecretForm.tsx @@ -10,6 +10,7 @@ import { EditDynamicSecretAzureEntraIdForm } from "./EditDynamicSecretAzureEntra import { EditDynamicSecretCassandraForm } from "./EditDynamicSecretCassandraForm"; import { EditDynamicSecretElasticSearchForm } from "./EditDynamicSecretElasticSearchForm"; import { EditDynamicSecretGcpIamForm } from "./EditDynamicSecretGcpIamForm"; +import { EditDynamicSecretGithubForm } from "./EditDynamicSecretGithubForm"; import { EditDynamicSecretKubernetesForm } from "./EditDynamicSecretKubernetesForm"; import { EditDynamicSecretLdapForm } from "./EditDynamicSecretLdapForm"; import { EditDynamicSecretMongoAtlasForm } from "./EditDynamicSecretMongoAtlasForm"; @@ -366,6 +367,23 @@ export const EditDynamicSecretForm = ({ /> )} + {dynamicSecretDetails?.type === DynamicSecretProviders.Github && ( + + + + )} ); }; diff --git a/frontend/src/pages/secret-manager/SecretDashboardPage/components/DynamicSecretListView/EditDynamicSecretForm/EditDynamicSecretGithubForm.tsx b/frontend/src/pages/secret-manager/SecretDashboardPage/components/DynamicSecretListView/EditDynamicSecretForm/EditDynamicSecretGithubForm.tsx new file mode 100644 index 000000000..d6219d36b --- /dev/null +++ b/frontend/src/pages/secret-manager/SecretDashboardPage/components/DynamicSecretListView/EditDynamicSecretForm/EditDynamicSecretGithubForm.tsx @@ -0,0 +1,199 @@ +import { Controller, useForm } from "react-hook-form"; +import { faQuestionCircle } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { z } from "zod"; + +import { createNotification } from "@app/components/notifications"; +import { Button, FormControl, FormLabel, Input, SecretInput, Tooltip } from "@app/components/v2"; +import { useUpdateDynamicSecret } from "@app/hooks/api"; +import { TDynamicSecret } from "@app/hooks/api/dynamicSecret/types"; + +const formSchema = z.object({ + inputs: z.object({ + appId: z.coerce.number().min(1, "Required"), + installationId: z.coerce.number().min(1, "Required"), + privateKey: z + .string() + .trim() + .min(1, "Required") + .refine( + (val) => + /^-----BEGIN(?:(?: RSA| PGP| ENCRYPTED)? PRIVATE KEY)-----\s*[\s\S]*?-----END(?:(?: RSA| PGP| ENCRYPTED)? PRIVATE KEY)-----$/.test( + val + ), + "Invalid PEM format for private key" + ) + }), + newName: z.string().refine((val) => val.toLowerCase() === val, "Must be lowercase") +}); +type TForm = z.infer; + +type Props = { + onClose: () => void; + dynamicSecret: TDynamicSecret & { inputs: unknown }; + secretPath: string; + environment: string; + projectSlug: string; +}; +export const EditDynamicSecretGithubForm = ({ + onClose, + dynamicSecret, + secretPath, + environment, + projectSlug +}: Props) => { + const { + control, + formState: { isSubmitting }, + handleSubmit + } = useForm({ + resolver: zodResolver(formSchema), + values: { + newName: dynamicSecret.name, + inputs: { + ...(dynamicSecret.inputs as TForm["inputs"]) + } + } + }); + + const updateDynamicSecret = useUpdateDynamicSecret(); + + const handleUpdateDynamicSecret = async ({ inputs, newName }: TForm) => { + if (updateDynamicSecret.isPending) return; + try { + await updateDynamicSecret.mutateAsync({ + name: dynamicSecret.name, + path: secretPath, + projectSlug, + environmentSlug: environment, + data: { + inputs, + newName: newName === dynamicSecret.name ? undefined : newName + } + }); + onClose(); + createNotification({ + type: "success", + text: "Successfully updated dynamic secret" + }); + } catch { + createNotification({ + type: "error", + text: "Failed to update dynamic secret" + }); + } + }; + + return ( +
+
+
+
+
+ ( + + + + )} + /> +
+
+ + + + } + /> + } + > + + +
+
+
+
+ Configuration +
+ +
+ ( + + + + )} + /> + + ( + + + + )} + /> + + ( + + + + )} + /> +
+
+
+
+ + +
+
+
+ ); +}; diff --git a/frontend/src/pages/secret-manager/SecretDashboardPage/components/DynamicSecretListView/MetadataForm.tsx b/frontend/src/pages/secret-manager/SecretDashboardPage/components/DynamicSecretListView/MetadataForm.tsx index 986ba9e46..510bc2a05 100644 --- a/frontend/src/pages/secret-manager/SecretDashboardPage/components/DynamicSecretListView/MetadataForm.tsx +++ b/frontend/src/pages/secret-manager/SecretDashboardPage/components/DynamicSecretListView/MetadataForm.tsx @@ -4,14 +4,24 @@ import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { FormControl, FormLabel, IconButton, Input } from "@app/components/v2"; -export const MetadataForm = ({ control }: { control: Control }) => { +export const MetadataForm = ({ + control, + name = "metadata", + title = "Metadata", + isValueRequired = false +}: { + control: Control; + name?: string; + title?: string; + isValueRequired?: boolean; +}) => { const metadataFormFields = useFieldArray({ control, - name: "metadata" + name }); return ( - +
{metadataFormFields.fields.map(({ id: metadataFieldId }, i) => (
@@ -19,7 +29,7 @@ export const MetadataForm = ({ control }: { control: Control }) => { {i === 0 && Key} ( }) => {
{i === 0 && ( - + )} ( { - const maxTtlMs = ms(dynamicSecret.maxTTL); + const maxTtlMs = dynamicSecret.maxTTL ? ms(dynamicSecret.maxTTL) : undefined; const formSchema = z.object({ ttl: z.string().superRefine((val, ctx) => { @@ -39,7 +39,7 @@ export const RenewDynamicSecretLease = ({ code: z.ZodIssueCode.custom, message: "TTL must be greater than 1 second" }); - if (valMs > maxTtlMs) + if (maxTtlMs && valMs > maxTtlMs) ctx.addIssue({ code: z.ZodIssueCode.custom, message: `TTL must be less than ${dynamicSecret.maxTTL}` 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..05831e8a7 --- /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 bd245cc19..95a2a9f8e 100644 --- a/frontend/src/pages/secret-manager/SecretSyncDetailsByIDPage/components/SecretSyncDestinationSection/SecretSyncDestinatonSection.tsx +++ b/frontend/src/pages/secret-manager/SecretSyncDetailsByIDPage/components/SecretSyncDestinationSection/SecretSyncDestinatonSection.tsx @@ -23,6 +23,7 @@ import { FlyioSyncDestinationSection } from "./FlyioSyncDestinationSection"; 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 { RenderSyncDestinationSection } from "./RenderSyncDestinationSection"; @@ -96,6 +97,9 @@ export const SecretSyncDestinationSection = ({ secretSync, onEditDestination }: case SecretSync.AzureDevOps: DestinationComponents = ; break; + case SecretSync.Heroku: + DestinationComponents = ; + break; case SecretSync.Render: DestinationComponents = ; break; 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 213a08bdc..a76a20e3e 100644 --- a/frontend/src/pages/secret-manager/SecretSyncDetailsByIDPage/components/SecretSyncOptionsSection/SecretSyncOptionsSection.tsx +++ b/frontend/src/pages/secret-manager/SecretSyncDetailsByIDPage/components/SecretSyncOptionsSection/SecretSyncOptionsSection.tsx @@ -55,6 +55,7 @@ export const SecretSyncOptionsSection = ({ secretSync, onEditOptions }: Props) = case SecretSync.TeamCity: case SecretSync.OCIVault: case SecretSync.OnePass: + case SecretSync.Heroku: case SecretSync.Render: case SecretSync.Flyio: AdditionalSyncOptionsComponent = null; 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...

+
+
+ ); };