diff --git a/backend/src/lib/api-docs/constants.ts b/backend/src/lib/api-docs/constants.ts index bd6056359..a4ceb6079 100644 --- a/backend/src/lib/api-docs/constants.ts +++ b/backend/src/lib/api-docs/constants.ts @@ -633,7 +633,8 @@ export const FOLDERS = { path: "The path to list folders from.", directory: "The directory to list folders from. (Deprecated in favor of path)", recursive: "Whether or not to fetch all folders from the specified base path, and all of its subdirectories.", - lastSecretModified: "The timestamp used to filter folders with secrets modified after the specified date. The format for this timestamp is ISO 8601 (e.g. 2025-04-01T09:41:45-04:00)" + lastSecretModified: + "The timestamp used to filter folders with secrets modified after the specified date. The format for this timestamp is ISO 8601 (e.g. 2025-04-01T09:41:45-04:00)" }, GET_BY_ID: { folderId: "The ID of the folder to get details." @@ -1787,6 +1788,12 @@ export const SecretSyncs = { org: "The ID of the Humanitec org to sync secrets to.", env: "The ID of the Humanitec environment to sync secrets to.", scope: "The Humanitec scope that secrets should be synced to." + }, + TERRAFORM_CLOUD: { + org: "The ID of the Terraform Cloud org to sync secrets to.", + project: "The ID of the Terraform Cloud project to sync secrets to.", + workspace: "The ID of the Terraform Cloud workspace to sync secrets to.", + scope: "The Terraform Cloud scope that secrets should be synced to." } } }; diff --git a/backend/src/server/routes/v1/app-connection-routers/app-connection-router.ts b/backend/src/server/routes/v1/app-connection-routers/app-connection-router.ts index cf8181ec4..9449b9fc1 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 @@ -22,6 +22,10 @@ import { HumanitecConnectionListItemSchema, SanitizedHumanitecConnectionSchema } from "@app/services/app-connection/humanitec"; +import { + SanitizedTerraformCloudConnectionSchema, + TerraformCloudConnectionListItemSchema +} from "@app/services/app-connection/terraform-cloud"; import { AuthMode } from "@app/services/auth/auth-type"; // can't use discriminated due to multiple schemas for certain apps @@ -32,7 +36,8 @@ const SanitizedAppConnectionSchema = z.union([ ...SanitizedAzureKeyVaultConnectionSchema.options, ...SanitizedAzureAppConfigurationConnectionSchema.options, ...SanitizedDatabricksConnectionSchema.options, - ...SanitizedHumanitecConnectionSchema.options + ...SanitizedHumanitecConnectionSchema.options, + ...SanitizedTerraformCloudConnectionSchema.options ]); const AppConnectionOptionsSchema = z.discriminatedUnion("app", [ @@ -42,7 +47,8 @@ const AppConnectionOptionsSchema = z.discriminatedUnion("app", [ AzureKeyVaultConnectionListItemSchema, AzureAppConfigurationConnectionListItemSchema, DatabricksConnectionListItemSchema, - HumanitecConnectionListItemSchema + HumanitecConnectionListItemSchema, + TerraformCloudConnectionListItemSchema ]); export const registerAppConnectionRouter = async (server: FastifyZodProvider) => { 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 c2b688a43..98a98d2fa 100644 --- a/backend/src/server/routes/v1/app-connection-routers/index.ts +++ b/backend/src/server/routes/v1/app-connection-routers/index.ts @@ -7,6 +7,7 @@ import { registerDatabricksConnectionRouter } from "./databricks-connection-rout import { registerGcpConnectionRouter } from "./gcp-connection-router"; import { registerGitHubConnectionRouter } from "./github-connection-router"; import { registerHumanitecConnectionRouter } from "./humanitec-connection-router"; +import { registerTerraformCloudConnectionRouter } from "./terraform-cloud-router"; export * from "./app-connection-router"; @@ -18,5 +19,6 @@ export const APP_CONNECTION_REGISTER_ROUTER_MAP: Record { + registerAppConnectionEndpoints({ + app: AppConnection.TerraformCloud, + server, + sanitizedResponseSchema: SanitizedTerraformCloudConnectionSchema, + createSchema: CreateTerraformCloudConnectionSchema, + updateSchema: UpdateTerraformCloudConnectionSchema + }); + + // The below endpoints are not exposed and for Infisical App use + server.route({ + method: "GET", + url: `/:connectionId/organizations`, + config: { + rateLimit: readLimit + }, + schema: { + params: z.object({ + connectionId: z.string().uuid() + }), + response: { + 200: z + .object({ + id: z.string(), + name: z.string(), + projects: z + .object({ + id: z.string(), + name: z.string() + }) + .array(), + workspaces: z + .object({ + id: z.string(), + name: z.string() + }) + .array() + }) + .array() + } + }, + onRequest: verifyAuth([AuthMode.JWT]), + handler: async (req) => { + const { connectionId } = req.params; + + const organizations: TerraformCloudOrgWithApps[] = + await server.services.appConnection.terraformCloud.listOrganizations(connectionId, req.permission); + + return organizations; + } + }); +}; 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 c342f3b73..622aff324 100644 --- a/backend/src/server/routes/v1/secret-sync-routers/index.ts +++ b/backend/src/server/routes/v1/secret-sync-routers/index.ts @@ -8,6 +8,7 @@ import { registerDatabricksSyncRouter } from "./databricks-sync-router"; import { registerGcpSyncRouter } from "./gcp-sync-router"; import { registerGitHubSyncRouter } from "./github-sync-router"; import { registerHumanitecSyncRouter } from "./humanitec-sync-router"; +import { registerTerraformCloudSyncRouter } from "./terraform-cloud-sync-router"; export * from "./secret-sync-router"; @@ -19,5 +20,6 @@ export const SECRET_SYNC_REGISTER_ROUTER_MAP: Record { diff --git a/backend/src/server/routes/v1/secret-sync-routers/terraform-cloud-sync-router.ts b/backend/src/server/routes/v1/secret-sync-routers/terraform-cloud-sync-router.ts new file mode 100644 index 000000000..d52666593 --- /dev/null +++ b/backend/src/server/routes/v1/secret-sync-routers/terraform-cloud-sync-router.ts @@ -0,0 +1,17 @@ +import { SecretSync } from "@app/services/secret-sync/secret-sync-enums"; +import { + CreateTerraformCloudSyncSchema, + TerraformCloudSyncSchema, + UpdateTerraformCloudSyncSchema +} from "@app/services/secret-sync/terraform-cloud"; + +import { registerSyncSecretsEndpoints } from "./secret-sync-endpoints"; + +export const registerTerraformCloudSyncRouter = async (server: FastifyZodProvider) => + registerSyncSecretsEndpoints({ + destination: SecretSync.TerraformCloud, + server, + responseSchema: TerraformCloudSyncSchema, + createSchema: CreateTerraformCloudSyncSchema, + updateSchema: UpdateTerraformCloudSyncSchema + }); diff --git a/backend/src/services/app-connection/app-connection-enums.ts b/backend/src/services/app-connection/app-connection-enums.ts index 9da622541..e3e2d5b68 100644 --- a/backend/src/services/app-connection/app-connection-enums.ts +++ b/backend/src/services/app-connection/app-connection-enums.ts @@ -5,7 +5,8 @@ export enum AppConnection { GCP = "gcp", AzureKeyVault = "azure-key-vault", AzureAppConfiguration = "azure-app-configuration", - Humanitec = "humanitec" + Humanitec = "humanitec", + TerraformCloud = "terraform-cloud" } export enum AWSRegion { diff --git a/backend/src/services/app-connection/app-connection-fns.ts b/backend/src/services/app-connection/app-connection-fns.ts index fe5130ff1..2db19db32 100644 --- a/backend/src/services/app-connection/app-connection-fns.ts +++ b/backend/src/services/app-connection/app-connection-fns.ts @@ -40,6 +40,11 @@ import { HumanitecConnectionMethod, validateHumanitecConnectionCredentials } from "./humanitec"; +import { + getTerraformCloudConnectionListItem, + TerraformCloudConnectionMethod, + validateTerraformCloudConnectionCredentials +} from "./terraform-cloud"; export const listAppConnectionOptions = () => { return [ @@ -49,7 +54,8 @@ export const listAppConnectionOptions = () => { getAzureKeyVaultConnectionListItem(), getAzureAppConfigurationConnectionListItem(), getDatabricksConnectionListItem(), - getHumanitecConnectionListItem() + getHumanitecConnectionListItem(), + getTerraformCloudConnectionListItem() ].sort((a, b) => a.name.localeCompare(b.name)); }; @@ -114,6 +120,8 @@ export const validateAppConnectionCredentials = async ( return validateAzureAppConfigurationConnectionCredentials(appConnection); case AppConnection.Humanitec: return validateHumanitecConnectionCredentials(appConnection); + case AppConnection.TerraformCloud: + return validateTerraformCloudConnectionCredentials(appConnection); default: // eslint-disable-next-line @typescript-eslint/restrict-template-expressions throw new Error(`Unhandled App Connection ${app}`); @@ -138,6 +146,8 @@ export const getAppConnectionMethodName = (method: TAppConnection["method"]) => return "Service Principal"; case HumanitecConnectionMethod.API_TOKEN: return "API Token"; + case TerraformCloudConnectionMethod.API_TOKEN: + return "API Token"; default: // eslint-disable-next-line @typescript-eslint/restrict-template-expressions throw new Error(`Unhandled App Connection Method: ${method}`); diff --git a/backend/src/services/app-connection/app-connection-maps.ts b/backend/src/services/app-connection/app-connection-maps.ts index 8a6c65426..915c93401 100644 --- a/backend/src/services/app-connection/app-connection-maps.ts +++ b/backend/src/services/app-connection/app-connection-maps.ts @@ -7,5 +7,6 @@ export const APP_CONNECTION_NAME_MAP: Record = { [AppConnection.AzureKeyVault]: "Azure Key Vault", [AppConnection.AzureAppConfiguration]: "Azure App Configuration", [AppConnection.Databricks]: "Databricks", - [AppConnection.Humanitec]: "Humanitec" + [AppConnection.Humanitec]: "Humanitec", + [AppConnection.TerraformCloud]: "Terraform Cloud" }; diff --git a/backend/src/services/app-connection/app-connection-service.ts b/backend/src/services/app-connection/app-connection-service.ts index e2e55bba0..c6d2ac65a 100644 --- a/backend/src/services/app-connection/app-connection-service.ts +++ b/backend/src/services/app-connection/app-connection-service.ts @@ -37,6 +37,8 @@ import { ValidateGitHubConnectionCredentialsSchema } from "./github"; import { githubConnectionService } from "./github/github-connection-service"; import { ValidateHumanitecConnectionCredentialsSchema } from "./humanitec"; import { humanitecConnectionService } from "./humanitec/humanitec-connection-service"; +import { ValidateTerraformCloudConnectionCredentialsSchema } from "./terraform-cloud"; +import { terraformCloudConnectionService } from "./terraform-cloud/terraform-cloud-connection-service"; export type TAppConnectionServiceFactoryDep = { appConnectionDAL: TAppConnectionDALFactory; @@ -53,7 +55,8 @@ const VALIDATE_APP_CONNECTION_CREDENTIALS_MAP: Record { + return { + name: "Terraform Cloud" as const, + app: AppConnection.TerraformCloud as const, + methods: Object.values(TerraformCloudConnectionMethod) as [TerraformCloudConnectionMethod.API_TOKEN] + }; +}; + +export const validateTerraformCloudConnectionCredentials = async (config: TTerraformCloudConnectionConfig) => { + const { credentials: inputCredentials } = config; + + let response: AxiosResponse<{ data: TTerraformCloudOrganization[] }> | null = null; + + try { + response = await request.get<{ data: TTerraformCloudOrganization[] }>( + `${IntegrationUrls.TERRAFORM_CLOUD_API_URL}/api/v2/organizations`, + { + headers: { + Authorization: `Bearer ${inputCredentials.apiToken}`, + "Content-Type": "application/vnd.api+json" + } + } + ); + } 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 organizations: Response was empty" + }); + } + + return inputCredentials; +}; + +export const listOrganizations = async ( + appConnection: TTerraformCloudConnection +): Promise => { + const { + credentials: { apiToken } + } = appConnection; + + const orgsResponse = await request.get<{ data: { id: string; attributes: { name: string } }[] }>( + `${IntegrationUrls.TERRAFORM_CLOUD_API_URL}/api/v2/organizations`, + { + headers: { + Authorization: `Bearer ${apiToken}`, + "Content-Type": "application/vnd.api+json" + } + } + ); + + if (!orgsResponse.data?.data) { + throw new InternalServerError({ + message: "Failed to get organizations: Response was empty" + }); + } + + const orgEntities = orgsResponse.data.data; + const orgsWithProjectsAndWorkspaces: TTerraformCloudOrganization[] = []; + + const projectPromises = orgEntities.map((org) => + request + .get<{ data: { id: string; attributes: { name: string } }[] }>( + `${IntegrationUrls.TERRAFORM_CLOUD_API_URL}/api/v2/organizations/${org.id}/projects`, + { + headers: { + Authorization: `Bearer ${apiToken}`, + "Content-Type": "application/vnd.api+json" + } + } + ) + .catch(() => ({ data: { data: [] } })) + ); + + const workspacePromises = orgEntities.map((org) => + request + .get<{ data: { id: string; attributes: { name: string } }[] }>( + `${IntegrationUrls.TERRAFORM_CLOUD_API_URL}/api/v2/organizations/${org.id}/workspaces`, + { + headers: { + Authorization: `Bearer ${apiToken}`, + "Content-Type": "application/vnd.api+json" + } + } + ) + .catch(() => ({ data: { data: [] } })) + ); + + const [projectResponses, workspaceResponses] = await Promise.all([ + Promise.all(projectPromises), + Promise.all(workspacePromises) + ]); + + for (let i = 0; i < orgEntities.length; i += 1) { + const org = orgEntities[i]; + const projectsData = projectResponses[i].data?.data || []; + const workspacesData = workspaceResponses[i].data?.data || []; + + const projects: TTerraformCloudProject[] = projectsData.map((project) => ({ + id: project.id, + name: project.attributes.name + })); + + const workspaces: TTerraformCloudWorkspace[] = workspacesData.map((workspace) => ({ + id: workspace.id, + name: workspace.attributes.name + })); + + orgsWithProjectsAndWorkspaces.push({ + id: org.id, + name: org.attributes.name, + projects, + workspaces + }); + } + + return orgsWithProjectsAndWorkspaces; +}; diff --git a/backend/src/services/app-connection/terraform-cloud/terraform-cloud-connection-schemas.ts b/backend/src/services/app-connection/terraform-cloud/terraform-cloud-connection-schemas.ts new file mode 100644 index 000000000..c085350f5 --- /dev/null +++ b/backend/src/services/app-connection/terraform-cloud/terraform-cloud-connection-schemas.ts @@ -0,0 +1,60 @@ +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 { TerraformCloudConnectionMethod } from "./terraform-cloud-connection-enums"; + +export const TerraformCloudConnectionAccessTokenCredentialsSchema = z.object({ + apiToken: z.string().trim().min(1, "API Token required") +}); + +const BaseTerraformCloudConnectionSchema = BaseAppConnectionSchema.extend({ + app: z.literal(AppConnection.TerraformCloud) +}); + +export const TerraformCloudConnectionSchema = BaseTerraformCloudConnectionSchema.extend({ + method: z.literal(TerraformCloudConnectionMethod.API_TOKEN), + credentials: TerraformCloudConnectionAccessTokenCredentialsSchema +}); + +export const SanitizedTerraformCloudConnectionSchema = z.discriminatedUnion("method", [ + BaseTerraformCloudConnectionSchema.extend({ + method: z.literal(TerraformCloudConnectionMethod.API_TOKEN), + credentials: TerraformCloudConnectionAccessTokenCredentialsSchema.pick({}) + }) +]); + +export const ValidateTerraformCloudConnectionCredentialsSchema = z.discriminatedUnion("method", [ + z.object({ + method: z + .literal(TerraformCloudConnectionMethod.API_TOKEN) + .describe(AppConnections?.CREATE(AppConnection.TerraformCloud).method), + credentials: TerraformCloudConnectionAccessTokenCredentialsSchema.describe( + AppConnections.CREATE(AppConnection.TerraformCloud).credentials + ) + }) +]); + +export const CreateTerraformCloudConnectionSchema = ValidateTerraformCloudConnectionCredentialsSchema.and( + GenericCreateAppConnectionFieldsSchema(AppConnection.TerraformCloud) +); + +export const UpdateTerraformCloudConnectionSchema = z + .object({ + credentials: TerraformCloudConnectionAccessTokenCredentialsSchema.optional().describe( + AppConnections.UPDATE(AppConnection.TerraformCloud).credentials + ) + }) + .and(GenericUpdateAppConnectionFieldsSchema(AppConnection.TerraformCloud)); + +export const TerraformCloudConnectionListItemSchema = z.object({ + name: z.literal("Terraform Cloud"), + app: z.literal(AppConnection.TerraformCloud), + methods: z.nativeEnum(TerraformCloudConnectionMethod).array() +}); diff --git a/backend/src/services/app-connection/terraform-cloud/terraform-cloud-connection-service.ts b/backend/src/services/app-connection/terraform-cloud/terraform-cloud-connection-service.ts new file mode 100644 index 000000000..56d56492b --- /dev/null +++ b/backend/src/services/app-connection/terraform-cloud/terraform-cloud-connection-service.ts @@ -0,0 +1,29 @@ +import { logger } from "@app/lib/logger"; +import { OrgServiceActor } from "@app/lib/types"; + +import { AppConnection } from "../app-connection-enums"; +import { listOrganizations as getTerraformCloudOrganizations } from "./terraform-cloud-connection-fns"; +import { TTerraformCloudConnection } from "./terraform-cloud-connection-types"; + +type TGetAppConnectionFunc = ( + app: AppConnection, + connectionId: string, + actor: OrgServiceActor +) => Promise; + +export const terraformCloudConnectionService = (getAppConnection: TGetAppConnectionFunc) => { + const listOrganizations = async (connectionId: string, actor: OrgServiceActor) => { + const appConnection = await getAppConnection(AppConnection.TerraformCloud, connectionId, actor); + try { + const organizations = await getTerraformCloudOrganizations(appConnection); + return organizations; + } catch (error) { + logger.error(error, "Failed to establish connection with Terraform Cloud"); + return []; + } + }; + + return { + listOrganizations + }; +}; diff --git a/backend/src/services/app-connection/terraform-cloud/terraform-cloud-connection-types.ts b/backend/src/services/app-connection/terraform-cloud/terraform-cloud-connection-types.ts new file mode 100644 index 000000000..624bcc385 --- /dev/null +++ b/backend/src/services/app-connection/terraform-cloud/terraform-cloud-connection-types.ts @@ -0,0 +1,66 @@ +import z from "zod"; + +import { DiscriminativePick } from "@app/lib/types"; + +import { AppConnection } from "../app-connection-enums"; +import { + CreateTerraformCloudConnectionSchema, + TerraformCloudConnectionSchema, + ValidateTerraformCloudConnectionCredentialsSchema +} from "./terraform-cloud-connection-schemas"; + +export type TTerraformCloudConnection = z.infer; + +export type TTerraformCloudConnectionInput = z.infer & { + app: AppConnection.TerraformCloud; +}; + +export type TValidateTerraformCloudConnectionCredentials = typeof ValidateTerraformCloudConnectionCredentialsSchema; + +export type TTerraformCloudConnectionConfig = DiscriminativePick< + TTerraformCloudConnectionInput, + "method" | "app" | "credentials" +> & { + orgId: string; +}; + +export type TerraformCloudOrg = { + id: string; + name: string; +}; + +export type TerraformCloudApp = { + name: string; + id: string; + envs: { name: string; id: string }[]; +}; + +export type TerraformCloudOrgWithApps = TerraformCloudOrg & { + apps: TerraformCloudApp[]; +}; + +export type TTerraformCloudProject = { + id: string; + name: string; +}; + +export type TTerraformCloudWorkspace = { + id: string; + name: string; +}; + +export type TTerraformCloudOrganization = { + id: string; + name: string; + projects: TTerraformCloudProject[]; + workspaces: TTerraformCloudWorkspace[]; +}; + +export type TTerraformCloudConnectionOrganization = TTerraformCloudOrganization; +export type TTerraformCloudConnectionProject = TTerraformCloudProject; +export type TTerraformCloudConnectionWorkspace = TTerraformCloudWorkspace; + +export enum TerraformCloudSyncScope { + Project = "project", + Workspace = "workspace" +} diff --git a/backend/src/services/secret-sync/secret-sync-enums.ts b/backend/src/services/secret-sync/secret-sync-enums.ts index 006d033f5..bdfe84950 100644 --- a/backend/src/services/secret-sync/secret-sync-enums.ts +++ b/backend/src/services/secret-sync/secret-sync-enums.ts @@ -6,7 +6,8 @@ export enum SecretSync { AzureKeyVault = "azure-key-vault", AzureAppConfiguration = "azure-app-configuration", Databricks = "databricks", - Humanitec = "humanitec" + Humanitec = "humanitec", + TerraformCloud = "terraform-cloud" } export enum SecretSyncInitialSyncBehavior { diff --git a/backend/src/services/secret-sync/secret-sync-fns.ts b/backend/src/services/secret-sync/secret-sync-fns.ts index 6c8a6d4df..af40aa39a 100644 --- a/backend/src/services/secret-sync/secret-sync-fns.ts +++ b/backend/src/services/secret-sync/secret-sync-fns.ts @@ -26,6 +26,7 @@ import { GCP_SYNC_LIST_OPTION } from "./gcp"; import { GcpSyncFns } from "./gcp/gcp-sync-fns"; import { HUMANITEC_SYNC_LIST_OPTION } from "./humanitec"; import { HumanitecSyncFns } from "./humanitec/humanitec-sync-fns"; +import { TERRAFORM_CLOUD_SYNC_LIST_OPTION, TerraformCloudSyncFns } from "./terraform-cloud"; const SECRET_SYNC_LIST_OPTIONS: Record = { [SecretSync.AWSParameterStore]: AWS_PARAMETER_STORE_SYNC_LIST_OPTION, @@ -35,7 +36,8 @@ const SECRET_SYNC_LIST_OPTIONS: Record = { [SecretSync.AzureKeyVault]: AZURE_KEY_VAULT_SYNC_LIST_OPTION, [SecretSync.AzureAppConfiguration]: AZURE_APP_CONFIGURATION_SYNC_LIST_OPTION, [SecretSync.Databricks]: DATABRICKS_SYNC_LIST_OPTION, - [SecretSync.Humanitec]: HUMANITEC_SYNC_LIST_OPTION + [SecretSync.Humanitec]: HUMANITEC_SYNC_LIST_OPTION, + [SecretSync.TerraformCloud]: TERRAFORM_CLOUD_SYNC_LIST_OPTION }; export const listSecretSyncOptions = () => { @@ -121,6 +123,8 @@ export const SecretSyncFns = { }).syncSecrets(secretSync, secretMap); case SecretSync.Humanitec: return HumanitecSyncFns.syncSecrets(secretSync, secretMap); + case SecretSync.TerraformCloud: + return TerraformCloudSyncFns.syncSecrets(secretSync, secretMap); default: throw new Error( `Unhandled sync destination for sync secrets fns: ${(secretSync as TSecretSyncWithCredentials).destination}` @@ -165,6 +169,9 @@ export const SecretSyncFns = { case SecretSync.Humanitec: secretMap = await HumanitecSyncFns.getSecrets(secretSync); break; + case SecretSync.TerraformCloud: + secretMap = await TerraformCloudSyncFns.getSecrets(secretSync); + break; default: throw new Error( `Unhandled sync destination for get secrets fns: ${(secretSync as TSecretSyncWithCredentials).destination}` @@ -207,6 +214,8 @@ export const SecretSyncFns = { }).removeSecrets(secretSync, secretMap); case SecretSync.Humanitec: return HumanitecSyncFns.removeSecrets(secretSync, secretMap); + case SecretSync.TerraformCloud: + return TerraformCloudSyncFns.removeSecrets(secretSync, secretMap); default: throw new Error( `Unhandled sync destination for remove secrets fns: ${(secretSync as TSecretSyncWithCredentials).destination}` diff --git a/backend/src/services/secret-sync/secret-sync-maps.ts b/backend/src/services/secret-sync/secret-sync-maps.ts index cd4125e1b..84faa90a9 100644 --- a/backend/src/services/secret-sync/secret-sync-maps.ts +++ b/backend/src/services/secret-sync/secret-sync-maps.ts @@ -9,7 +9,8 @@ export const SECRET_SYNC_NAME_MAP: Record = { [SecretSync.AzureKeyVault]: "Azure Key Vault", [SecretSync.AzureAppConfiguration]: "Azure App Configuration", [SecretSync.Databricks]: "Databricks", - [SecretSync.Humanitec]: "Humanitec" + [SecretSync.Humanitec]: "Humanitec", + [SecretSync.TerraformCloud]: "Terraform Cloud" }; export const SECRET_SYNC_CONNECTION_MAP: Record = { @@ -20,5 +21,6 @@ export const SECRET_SYNC_CONNECTION_MAP: Record = { [SecretSync.AzureKeyVault]: AppConnection.AzureKeyVault, [SecretSync.AzureAppConfiguration]: AppConnection.AzureAppConfiguration, [SecretSync.Databricks]: AppConnection.Databricks, - [SecretSync.Humanitec]: AppConnection.Humanitec + [SecretSync.Humanitec]: AppConnection.Humanitec, + [SecretSync.TerraformCloud]: AppConnection.TerraformCloud }; diff --git a/backend/src/services/secret-sync/secret-sync-types.ts b/backend/src/services/secret-sync/secret-sync-types.ts index 2044c7c17..b1f0a1a2b 100644 --- a/backend/src/services/secret-sync/secret-sync-types.ts +++ b/backend/src/services/secret-sync/secret-sync-types.ts @@ -49,6 +49,12 @@ import { THumanitecSyncListItem, THumanitecSyncWithCredentials } from "./humanitec"; +import { + TTerraformCloudSync, + TTerraformCloudSyncInput, + TTerraformCloudSyncListItem, + TTerraformCloudSyncWithCredentials +} from "./terraform-cloud"; export type TSecretSync = | TAwsParameterStoreSync @@ -58,7 +64,8 @@ export type TSecretSync = | TAzureKeyVaultSync | TAzureAppConfigurationSync | TDatabricksSync - | THumanitecSync; + | THumanitecSync + | TTerraformCloudSync; export type TSecretSyncWithCredentials = | TAwsParameterStoreSyncWithCredentials @@ -68,7 +75,8 @@ export type TSecretSyncWithCredentials = | TAzureKeyVaultSyncWithCredentials | TAzureAppConfigurationSyncWithCredentials | TDatabricksSyncWithCredentials - | THumanitecSyncWithCredentials; + | THumanitecSyncWithCredentials + | TTerraformCloudSyncWithCredentials; export type TSecretSyncInput = | TAwsParameterStoreSyncInput @@ -78,7 +86,8 @@ export type TSecretSyncInput = | TAzureKeyVaultSyncInput | TAzureAppConfigurationSyncInput | TDatabricksSyncInput - | THumanitecSyncInput; + | THumanitecSyncInput + | TTerraformCloudSyncInput; export type TSecretSyncListItem = | TAwsParameterStoreSyncListItem @@ -88,7 +97,8 @@ export type TSecretSyncListItem = | TAzureKeyVaultSyncListItem | TAzureAppConfigurationSyncListItem | TDatabricksSyncListItem - | THumanitecSyncListItem; + | THumanitecSyncListItem + | TTerraformCloudSyncListItem; export type TSyncOptionsConfig = { canImportSecrets: boolean; diff --git a/backend/src/services/secret-sync/terraform-cloud/index.ts b/backend/src/services/secret-sync/terraform-cloud/index.ts new file mode 100644 index 000000000..2df19747d --- /dev/null +++ b/backend/src/services/secret-sync/terraform-cloud/index.ts @@ -0,0 +1,5 @@ +export * from "./terraform-cloud-sync-constants"; +export * from "./terraform-cloud-sync-enums"; +export * from "./terraform-cloud-sync-fns"; +export * from "./terraform-cloud-sync-schemas"; +export * from "./terraform-cloud-sync-types"; diff --git a/backend/src/services/secret-sync/terraform-cloud/terraform-cloud-sync-constants.ts b/backend/src/services/secret-sync/terraform-cloud/terraform-cloud-sync-constants.ts new file mode 100644 index 000000000..edca7d304 --- /dev/null +++ b/backend/src/services/secret-sync/terraform-cloud/terraform-cloud-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 TERRAFORM_CLOUD_SYNC_LIST_OPTION: TSecretSyncListItem = { + name: "Terraform Cloud", + destination: SecretSync.TerraformCloud, + connection: AppConnection.TerraformCloud, + canImportSecrets: false +}; diff --git a/backend/src/services/secret-sync/terraform-cloud/terraform-cloud-sync-enums.ts b/backend/src/services/secret-sync/terraform-cloud/terraform-cloud-sync-enums.ts new file mode 100644 index 000000000..e994047c0 --- /dev/null +++ b/backend/src/services/secret-sync/terraform-cloud/terraform-cloud-sync-enums.ts @@ -0,0 +1,4 @@ +export enum TerraformCloudSyncScope { + Project = "project", + Workspace = "workspace" +} diff --git a/backend/src/services/secret-sync/terraform-cloud/terraform-cloud-sync-fns.ts b/backend/src/services/secret-sync/terraform-cloud/terraform-cloud-sync-fns.ts new file mode 100644 index 000000000..21d4bb87f --- /dev/null +++ b/backend/src/services/secret-sync/terraform-cloud/terraform-cloud-sync-fns.ts @@ -0,0 +1,234 @@ +/* eslint-disable no-await-in-loop */ +import { request } from "@app/lib/config/request"; +import { logger } from "@app/lib/logger"; +import { IntegrationUrls } from "@app/services/integration-auth/integration-list"; +import { SecretSyncError } from "@app/services/secret-sync/secret-sync-errors"; +import { SECRET_SYNC_NAME_MAP } from "@app/services/secret-sync/secret-sync-maps"; +import { TSecretMap } from "@app/services/secret-sync/secret-sync-types"; + +import { TerraformCloudSyncScope } from "./terraform-cloud-sync-enums"; +import { + TerraformCloudApiResponse, + TerraformCloudApiVariable, + TerraformCloudVariable, + TTerraformCloudSyncWithCredentials +} from "./terraform-cloud-sync-types"; + +const getTerraformCloudVariables = async ( + secretSync: TTerraformCloudSyncWithCredentials +): Promise => { + const { + destinationConfig, + connection: { + credentials: { apiToken } + } + } = secretSync; + + let url; + if (destinationConfig.scope === TerraformCloudSyncScope.Project) { + url = `${IntegrationUrls.TERRAFORM_CLOUD_API_URL}/api/v2/organizations/${destinationConfig.org}/projects/${destinationConfig.project}/vars`; + } else { + url = `${IntegrationUrls.TERRAFORM_CLOUD_API_URL}/api/v2/organizations/${destinationConfig.org}/workspaces/${destinationConfig.workspace}/vars`; + } + + const response = await request.get>(url, { + headers: { + Authorization: `Bearer ${apiToken}`, + "Content-Type": "application/vnd.api+json" + } + }); + + if (!response.data || !response.data.data) { + return []; + } + + const variables: TerraformCloudVariable[] = response.data.data.map((variable: TerraformCloudApiVariable) => ({ + id: variable.id, + key: variable.attributes.key, + value: variable.attributes.value || "", + sensitive: variable.attributes.sensitive, + description: variable.attributes.description || "", + category: variable.attributes.category, + source: destinationConfig.scope === TerraformCloudSyncScope.Project ? "project" : "workspace" + })); + + return variables; +}; + +const deleteVariable = async ( + secretSync: TTerraformCloudSyncWithCredentials, + variable: TerraformCloudVariable +): Promise => { + const { + destinationConfig, + connection: { + credentials: { apiToken } + } + } = secretSync; + + try { + let url; + if (destinationConfig.scope === TerraformCloudSyncScope.Project) { + url = `${IntegrationUrls.TERRAFORM_CLOUD_API_URL}/api/v2/organizations/${destinationConfig.org}/projects/${destinationConfig.project}/vars/${variable.id}`; + } else { + url = `${IntegrationUrls.TERRAFORM_CLOUD_API_URL}/api/v2/organizations/${destinationConfig.org}/workspaces/${destinationConfig.workspace}/vars/${variable.id}`; + } + + await request.delete(url, { + headers: { + Authorization: `Bearer ${apiToken}`, + "Content-Type": "application/vnd.api+json" + } + }); + + logger.info(`Deleted variable ${variable.key} from Terraform Cloud ${destinationConfig.scope}`); + } catch (error) { + throw new SecretSyncError({ + error, + secretKey: variable.key + }); + } +}; + +const createVariable = async ( + secretSync: TTerraformCloudSyncWithCredentials, + secretMap: TSecretMap, + key: string +): Promise => { + try { + const { + destinationConfig, + connection: { + credentials: { apiToken } + } + } = secretSync; + + let url; + if (destinationConfig.scope === TerraformCloudSyncScope.Project) { + url = `${IntegrationUrls.TERRAFORM_CLOUD_API_URL}/api/v2/organizations/${destinationConfig.org}/projects/${destinationConfig.project}/vars`; + } else { + url = `${IntegrationUrls.TERRAFORM_CLOUD_API_URL}/api/v2/organizations/${destinationConfig.org}/workspaces/${destinationConfig.workspace}/vars`; + } + + await request.post( + url, + { + data: { + type: "vars", + attributes: { + key, + value: secretMap[key].value, + description: secretMap[key].comment || "", + category: "env", + sensitive: true, + hcl: false + } + } + }, + { + headers: { + Authorization: `Bearer ${apiToken}`, + "Content-Type": "application/vnd.api+json" + } + } + ); + + logger.info(`Created variable ${key} in Terraform Cloud ${destinationConfig.scope}`); + } catch (error) { + throw new SecretSyncError({ + error, + secretKey: key + }); + } +}; + +const updateVariable = async ( + secretSync: TTerraformCloudSyncWithCredentials, + secretMap: TSecretMap, + variable: TerraformCloudVariable +): Promise => { + try { + const { + destinationConfig, + connection: { + credentials: { apiToken } + } + } = secretSync; + + let url; + if (destinationConfig.scope === TerraformCloudSyncScope.Project) { + url = `${IntegrationUrls.TERRAFORM_CLOUD_API_URL}/api/v2/organizations/${destinationConfig.org}/projects/${destinationConfig.project}/vars/${variable.id}`; + } else { + url = `${IntegrationUrls.TERRAFORM_CLOUD_API_URL}/api/v2/organizations/${destinationConfig.org}/workspaces/${destinationConfig.workspace}/vars/${variable.id}`; + } + + await request.patch( + url, + { + data: { + type: "vars", + id: variable.id, + attributes: { + value: secretMap[variable.key].value, + description: secretMap[variable.key].comment || "" + } + } + }, + { + headers: { + Authorization: `Bearer ${apiToken}`, + "Content-Type": "application/vnd.api+json" + } + } + ); + + logger.info(`Updated variable ${variable.key} in Terraform Cloud ${destinationConfig.scope}`); + } catch (error) { + throw new SecretSyncError({ + error, + secretKey: variable.key + }); + } +}; + +export const TerraformCloudSyncFns = { + syncSecrets: async (secretSync: TTerraformCloudSyncWithCredentials, secretMap: TSecretMap): Promise => { + const terraformCloudVariables = await getTerraformCloudVariables(secretSync); + const terraformCloudVariablesMap = new Map( + terraformCloudVariables.map((v) => [v.key, v]) + ); + + const secretKeys = Object.keys(secretMap); + for (const key of secretKeys) { + const existingVariable = terraformCloudVariablesMap.get(key); + + if (!existingVariable) { + await createVariable(secretSync, secretMap, key); + } else { + await updateVariable(secretSync, secretMap, existingVariable); + } + } + + if (secretSync.syncOptions.disableSecretDeletion) return; + + for (const terraformCloudVariable of terraformCloudVariables) { + if (!Object.prototype.hasOwnProperty.call(secretMap, terraformCloudVariable.key)) { + await deleteVariable(secretSync, terraformCloudVariable); + } + } + }, + + getSecrets: async (secretSync: TTerraformCloudSyncWithCredentials): Promise => { + throw new Error(`${SECRET_SYNC_NAME_MAP[secretSync.destination]} does not support importing secrets.`); + }, + + removeSecrets: async (secretSync: TTerraformCloudSyncWithCredentials, secretMap: TSecretMap): Promise => { + const terraformCloudVariables = await getTerraformCloudVariables(secretSync); + + for (const variable of terraformCloudVariables) { + if (Object.prototype.hasOwnProperty.call(secretMap, variable.key)) { + await deleteVariable(secretSync, variable); + } + } + } +}; diff --git a/backend/src/services/secret-sync/terraform-cloud/terraform-cloud-sync-schemas.ts b/backend/src/services/secret-sync/terraform-cloud/terraform-cloud-sync-schemas.ts new file mode 100644 index 000000000..71713d2b0 --- /dev/null +++ b/backend/src/services/secret-sync/terraform-cloud/terraform-cloud-sync-schemas.ts @@ -0,0 +1,62 @@ +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"; +import { TerraformCloudSyncScope } from "@app/services/secret-sync/terraform-cloud/terraform-cloud-sync-enums"; + +const TerraformCloudSyncDestinationConfigSchema = z.discriminatedUnion("scope", [ + z.object({ + scope: z.literal(TerraformCloudSyncScope.Project).describe(SecretSyncs.DESTINATION_CONFIG.TERRAFORM_CLOUD.scope), + org: z.string().min(1, "Org ID is required").describe(SecretSyncs.DESTINATION_CONFIG.TERRAFORM_CLOUD.org), + project: z + .string() + .min(1, "Project ID is required") + .describe(SecretSyncs.DESTINATION_CONFIG.TERRAFORM_CLOUD.project) + }), + z.object({ + scope: z.literal(TerraformCloudSyncScope.Workspace).describe(SecretSyncs.DESTINATION_CONFIG.TERRAFORM_CLOUD.scope), + org: z.string().min(1, "Org ID is required").describe(SecretSyncs.DESTINATION_CONFIG.TERRAFORM_CLOUD.org), + workspace: z + .string() + .min(1, "Workspace ID is required") + .describe(SecretSyncs.DESTINATION_CONFIG.TERRAFORM_CLOUD.workspace) + }) +]); + +const TerraformCloudSyncOptionsConfig: TSyncOptionsConfig = { canImportSecrets: false }; + +export const TerraformCloudSyncSchema = BaseSecretSyncSchema( + SecretSync.TerraformCloud, + TerraformCloudSyncOptionsConfig +).extend({ + destination: z.literal(SecretSync.TerraformCloud), + destinationConfig: TerraformCloudSyncDestinationConfigSchema +}); + +export const CreateTerraformCloudSyncSchema = GenericCreateSecretSyncFieldsSchema( + SecretSync.TerraformCloud, + TerraformCloudSyncOptionsConfig +).extend({ + destinationConfig: TerraformCloudSyncDestinationConfigSchema +}); + +export const UpdateTerraformCloudSyncSchema = GenericUpdateSecretSyncFieldsSchema( + SecretSync.TerraformCloud, + TerraformCloudSyncOptionsConfig +).extend({ + destinationConfig: TerraformCloudSyncDestinationConfigSchema.optional() +}); + +export const TerraformCloudSyncListItemSchema = z.object({ + name: z.literal("Terraform Cloud"), + connection: z.literal(AppConnection.TerraformCloud), + destination: z.literal(SecretSync.TerraformCloud), + canImportSecrets: z.literal(false) +}); diff --git a/backend/src/services/secret-sync/terraform-cloud/terraform-cloud-sync-types.ts b/backend/src/services/secret-sync/terraform-cloud/terraform-cloud-sync-types.ts new file mode 100644 index 000000000..bb9cd2f43 --- /dev/null +++ b/backend/src/services/secret-sync/terraform-cloud/terraform-cloud-sync-types.ts @@ -0,0 +1,90 @@ +import z from "zod"; + +import { TTerraformCloudConnection } from "@app/services/app-connection/terraform-cloud"; + +import { + CreateTerraformCloudSyncSchema, + TerraformCloudSyncListItemSchema, + TerraformCloudSyncSchema +} from "./terraform-cloud-sync-schemas"; + +export type TTerraformCloudSyncListItem = z.infer; + +export type TTerraformCloudSync = z.infer; + +export type TTerraformCloudSyncInput = z.infer; + +export type TTerraformCloudSyncWithCredentials = TTerraformCloudSync & { + connection: TTerraformCloudConnection; +}; + +export type TerraformCloudSecret = { + description: string; + is_secret: boolean; + key: string; + source: "project" | "workspace"; + value: string; +}; + +export type TerraformCloudApiVariable = { + id: string; + type: string; + attributes: { + key: string; + value: string | null; + sensitive: boolean; + category: "terraform" | "env"; + hcl: boolean; + description: string | null; + }; + relationships: { + workspace?: { + data: { + id: string; + type: string; + }; + }; + project?: { + data: { + id: string; + type: string; + }; + }; + }; +}; + +export type TerraformCloudVariable = { + id: string; + key: string; + value: string; + sensitive: boolean; + description: string; + category: "terraform" | "env"; + source: "project" | "workspace"; +}; + +export type TerraformCloudApiResponse = { + data: T; + included?: unknown[]; + links?: { + self?: string; + first?: string; + prev?: string; + next?: string; + last?: string; + }; + meta?: { + pagination?: { + current_page: number; + prev_page: number | null; + next_page: number | null; + total_pages: number; + total_count: number; + }; + }; +}; + +export enum TerraformCloudSyncScope { + Project = "project", + Workspace = "workspace" +} diff --git a/frontend/src/components/secret-syncs/forms/SecretSyncDestinationFields/SecretSyncDestinationFields.tsx b/frontend/src/components/secret-syncs/forms/SecretSyncDestinationFields/SecretSyncDestinationFields.tsx index 973f8bf17..4b615473c 100644 --- a/frontend/src/components/secret-syncs/forms/SecretSyncDestinationFields/SecretSyncDestinationFields.tsx +++ b/frontend/src/components/secret-syncs/forms/SecretSyncDestinationFields/SecretSyncDestinationFields.tsx @@ -11,11 +11,13 @@ import { DatabricksSyncFields } from "./DatabricksSyncFields"; import { GcpSyncFields } from "./GcpSyncFields"; import { GitHubSyncFields } from "./GitHubSyncFields"; import { HumanitecSyncFields } from "./HumanitecSyncFields"; +import { TerraformCloudSyncFields } from "./TerraformCloudSyncFields"; export const SecretSyncDestinationFields = () => { const { watch } = useFormContext(); const destination = watch("destination"); + console.log(destination); switch (destination) { case SecretSync.AWSParameterStore: @@ -34,6 +36,8 @@ export const SecretSyncDestinationFields = () => { return ; case SecretSync.Humanitec: return ; + case SecretSync.TerraformCloud: + return ; default: throw new Error(`Unhandled Destination Config Field: ${destination}`); } diff --git a/frontend/src/components/secret-syncs/forms/SecretSyncDestinationFields/TerraformCloudSyncFields.tsx b/frontend/src/components/secret-syncs/forms/SecretSyncDestinationFields/TerraformCloudSyncFields.tsx new file mode 100644 index 000000000..7f73dbca3 --- /dev/null +++ b/frontend/src/components/secret-syncs/forms/SecretSyncDestinationFields/TerraformCloudSyncFields.tsx @@ -0,0 +1,208 @@ +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, Select, SelectItem, Tooltip } from "@app/components/v2"; +import { + TERRAFORM_CLOUD_SYNC_SCOPES, + TerraformCloudSyncScope, + TTerraformCloudConnectionOrganization, + TTerraformCloudConnectionProject, + TTerraformCloudConnectionWorkspace, + useTerraformCloudConnectionListOrganizations +} from "@app/hooks/api/appConnections/terraform-cloud"; +import { SecretSync } from "@app/hooks/api/secretSyncs"; + +import { TSecretSyncForm } from "../schemas"; + +export const TerraformCloudSyncFields = () => { + const { control, watch, setValue } = useFormContext< + TSecretSyncForm & { destination: SecretSync.TerraformCloud } + >(); + + const connectionId = useWatch({ name: "connection.id", control }); + const currentOrg = watch("destinationConfig.org"); + const currentScope = watch("destinationConfig.scope"); + + const { data: organizations = [], isPending: isOrganizationsPending } = + useTerraformCloudConnectionListOrganizations(connectionId, { + enabled: Boolean(connectionId) + }); + + const selectedOrg = organizations?.find((org) => org.id === currentOrg); + const projects = selectedOrg?.projects || []; + const workspaces = selectedOrg?.workspaces || []; + + return ( + <> + { + setValue("destinationConfig.org", ""); + setValue("destinationConfig.project", ""); + setValue("destinationConfig.workspace", ""); + }} + /> + ( + + org.id === value) ?? null) : null} + onChange={(option) => { + onChange( + (option as SingleValue)?.id ?? null + ); + setValue("destinationConfig.project", ""); + setValue("destinationConfig.workspace", ""); + }} + options={organizations} + placeholder="Select an organization..." + getOptionLabel={(option) => option.name} + getOptionValue={(option) => option.id.toString()} + /> + + )} + /> + ( + +

+ Specify how Infisical should manage secrets from Terraform Cloud. The following + options are available: +

+
    + {Object.values(TERRAFORM_CLOUD_SYNC_SCOPES).map(({ name, description }) => { + return ( +
  • +

    + {name}: {description} +

    +
  • + ); + })} +
+ + } + > + +
+ )} + /> + {currentScope === TerraformCloudSyncScope.Project && ( + ( + +
+ Don't see the project you're looking for?{" "} + +
+ + } + > + project.id === value) ?? null} + onChange={(option) => { + onChange((option as SingleValue)?.id ?? null); + }} + options={projects} + placeholder="Select a project..." + getOptionLabel={(option) => option.name} + getOptionValue={(option) => option.id.toString()} + /> +
+ )} + /> + )} + {currentScope === TerraformCloudSyncScope.Workspace && ( + ( + +
+ Don't see the workspace you're looking for?{" "} + +
+ + } + > + workspace.id === value) ?? null} + onChange={(option) => { + onChange((option as SingleValue)?.id ?? null); + }} + options={workspaces} + placeholder="Select a workspace..." + getOptionLabel={(option) => option.name} + getOptionValue={(option) => option.id.toString()} + /> +
+ )} + /> + )} + + ); +}; diff --git a/frontend/src/components/secret-syncs/forms/SecretSyncOptionsFields/SecretSyncOptionsFields.tsx b/frontend/src/components/secret-syncs/forms/SecretSyncOptionsFields/SecretSyncOptionsFields.tsx index b14bce809..14edc4e06 100644 --- a/frontend/src/components/secret-syncs/forms/SecretSyncOptionsFields/SecretSyncOptionsFields.tsx +++ b/frontend/src/components/secret-syncs/forms/SecretSyncOptionsFields/SecretSyncOptionsFields.tsx @@ -39,6 +39,7 @@ export const SecretSyncOptionsFields = ({ hideInitialSync }: Props) => { case SecretSync.AzureAppConfiguration: case SecretSync.Databricks: case SecretSync.Humanitec: + case SecretSync.TerraformCloud: AdditionalSyncOptionsFieldsComponent = null; break; default: diff --git a/frontend/src/components/secret-syncs/forms/SecretSyncReviewFields/SecretSyncReviewFields.tsx b/frontend/src/components/secret-syncs/forms/SecretSyncReviewFields/SecretSyncReviewFields.tsx index 040944298..b0472619c 100644 --- a/frontend/src/components/secret-syncs/forms/SecretSyncReviewFields/SecretSyncReviewFields.tsx +++ b/frontend/src/components/secret-syncs/forms/SecretSyncReviewFields/SecretSyncReviewFields.tsx @@ -21,6 +21,7 @@ import { DatabricksSyncReviewFields } from "./DatabricksSyncReviewFields"; import { GcpSyncReviewFields } from "./GcpSyncReviewFields"; import { GitHubSyncReviewFields } from "./GitHubSyncReviewFields"; import { HumanitecSyncReviewFields } from "./HumanitecSyncReviewFields"; +import { TerraformCloudSyncReviewFields } from "./TerraformCloudSyncReviewFields"; export const SecretSyncReviewFields = () => { const { watch } = useFormContext(); @@ -72,6 +73,9 @@ export const SecretSyncReviewFields = () => { case SecretSync.Humanitec: DestinationFieldsComponent = ; break; + case SecretSync.TerraformCloud: + DestinationFieldsComponent = ; + break; default: throw new Error(`Unhandled Destination Review Fields: ${destination}`); } diff --git a/frontend/src/components/secret-syncs/forms/SecretSyncReviewFields/TerraformCloudSyncReviewFields.tsx b/frontend/src/components/secret-syncs/forms/SecretSyncReviewFields/TerraformCloudSyncReviewFields.tsx new file mode 100644 index 000000000..6ffe03c47 --- /dev/null +++ b/frontend/src/components/secret-syncs/forms/SecretSyncReviewFields/TerraformCloudSyncReviewFields.tsx @@ -0,0 +1,26 @@ +import { useFormContext } from "react-hook-form"; + +import { SecretSyncLabel } from "@app/components/secret-syncs"; +import { TSecretSyncForm } from "@app/components/secret-syncs/forms/schemas"; +import { TerraformCloudSyncScope } from "@app/hooks/api/appConnections/terraform-cloud"; +import { SecretSync } from "@app/hooks/api/secretSyncs"; + +export const TerraformCloudSyncReviewFields = () => { + const { watch } = useFormContext(); + const orgId = watch("destinationConfig.org"); + const projectId = watch("destinationConfig.project"); + const workspaceId = watch("destinationConfig.workspace"); + const scope = watch("destinationConfig.scope"); + + return ( + <> + {orgId} + {scope === TerraformCloudSyncScope.Project && ( + {projectId} + )} + {scope === TerraformCloudSyncScope.Workspace && ( + {workspaceId} + )} + + ); +}; 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 be2322304..300e2472f 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 @@ -9,6 +9,7 @@ import { AzureAppConfigurationSyncDestinationSchema } from "./azure-app-configur import { AzureKeyVaultSyncDestinationSchema } from "./azure-key-vault-sync-destination-schema"; import { GcpSyncDestinationSchema } from "./gcp-sync-destination-schema"; import { HumanitecSyncDestinationSchema } from "./humanitec-sync-destination-schema"; +import { TerraformCloudSyncDestinationSchema } from "./terraform-cloud-destination-schema"; const SecretSyncUnionSchema = z.discriminatedUnion("destination", [ AwsParameterStoreSyncDestinationSchema, @@ -18,7 +19,8 @@ const SecretSyncUnionSchema = z.discriminatedUnion("destination", [ AzureKeyVaultSyncDestinationSchema, AzureAppConfigurationSyncDestinationSchema, DatabricksSyncDestinationSchema, - HumanitecSyncDestinationSchema + HumanitecSyncDestinationSchema, + TerraformCloudSyncDestinationSchema ]); export const SecretSyncFormSchema = SecretSyncUnionSchema; diff --git a/frontend/src/components/secret-syncs/forms/schemas/terraform-cloud-destination-schema.ts b/frontend/src/components/secret-syncs/forms/schemas/terraform-cloud-destination-schema.ts new file mode 100644 index 000000000..d75ea643a --- /dev/null +++ b/frontend/src/components/secret-syncs/forms/schemas/terraform-cloud-destination-schema.ts @@ -0,0 +1,23 @@ +import { z } from "zod"; + +import { BaseSecretSyncSchema } from "@app/components/secret-syncs/forms/schemas/base-secret-sync-schema"; +import { TerraformCloudSyncScope } from "@app/hooks/api/appConnections/terraform-cloud"; +import { SecretSync } from "@app/hooks/api/secretSyncs"; + +export const TerraformCloudSyncDestinationSchema = BaseSecretSyncSchema().merge( + z.object({ + destination: z.literal(SecretSync.TerraformCloud), + destinationConfig: z.discriminatedUnion("scope", [ + z.object({ + scope: z.literal(TerraformCloudSyncScope.Project), + org: z.string().trim().min(1, "Organization required"), + project: z.string().trim().min(1, "Project required") + }), + z.object({ + scope: z.literal(TerraformCloudSyncScope.Workspace), + org: z.string().trim().min(1, "Organization required"), + workspace: z.string().trim().min(1, "Workspace required") + }) + ]) + }) +); diff --git a/frontend/src/helpers/appConnections.ts b/frontend/src/helpers/appConnections.ts index cf643218b..8c75b55c2 100644 --- a/frontend/src/helpers/appConnections.ts +++ b/frontend/src/helpers/appConnections.ts @@ -26,7 +26,8 @@ export const APP_CONNECTION_MAP: Record { @@ -47,6 +48,8 @@ export const getAppConnectionMethodDetails = (method: TAppConnection["method"]) return { name: "Service Principal", icon: faUser }; case HumanitecConnectionMethod.API_TOKEN: return { name: "API Token", icon: faKey }; + case TerraformCloudConnectionMethod.API_TOKEN: + return { name: "API Token", icon: faKey }; default: throw new Error(`Unhandled App Connection Method: ${method}`); } diff --git a/frontend/src/helpers/secretSyncs.ts b/frontend/src/helpers/secretSyncs.ts index 1cc076cce..56b0b034f 100644 --- a/frontend/src/helpers/secretSyncs.ts +++ b/frontend/src/helpers/secretSyncs.ts @@ -23,6 +23,10 @@ export const SECRET_SYNC_MAP: Record = { [SecretSync.AzureKeyVault]: AppConnection.AzureKeyVault, [SecretSync.AzureAppConfiguration]: AppConnection.AzureAppConfiguration, [SecretSync.Databricks]: AppConnection.Databricks, - [SecretSync.Humanitec]: AppConnection.Humanitec + [SecretSync.Humanitec]: AppConnection.Humanitec, + [SecretSync.TerraformCloud]: AppConnection.TerraformCloud }; export const SECRET_SYNC_INITIAL_SYNC_BEHAVIOR_MAP: Record< diff --git a/frontend/src/hooks/api/appConnections/enums.ts b/frontend/src/hooks/api/appConnections/enums.ts index c07808af9..0593c772b 100644 --- a/frontend/src/hooks/api/appConnections/enums.ts +++ b/frontend/src/hooks/api/appConnections/enums.ts @@ -5,5 +5,6 @@ export enum AppConnection { AzureKeyVault = "azure-key-vault", AzureAppConfiguration = "azure-app-configuration", Databricks = "databricks", - Humanitec = "humanitec" + Humanitec = "humanitec", + TerraformCloud = "terraform-cloud" } diff --git a/frontend/src/hooks/api/appConnections/terraform-cloud/index.ts b/frontend/src/hooks/api/appConnections/terraform-cloud/index.ts new file mode 100644 index 000000000..2c1906d36 --- /dev/null +++ b/frontend/src/hooks/api/appConnections/terraform-cloud/index.ts @@ -0,0 +1,2 @@ +export * from "./queries"; +export * from "./types"; diff --git a/frontend/src/hooks/api/appConnections/terraform-cloud/queries.tsx b/frontend/src/hooks/api/appConnections/terraform-cloud/queries.tsx new file mode 100644 index 000000000..a5da22a0a --- /dev/null +++ b/frontend/src/hooks/api/appConnections/terraform-cloud/queries.tsx @@ -0,0 +1,37 @@ +import { useQuery, UseQueryOptions } from "@tanstack/react-query"; + +import { apiRequest } from "@app/config/request"; + +import { appConnectionKeys } from "../queries"; +import { TTerraformCloudOrganization } from "./types"; + +const terraformCloudConnectionKeys = { + all: [...appConnectionKeys.all, "terraform-cloud"] as const, + listOrganizations: (connectionId: string) => + [...terraformCloudConnectionKeys.all, "organizations", connectionId] as const +}; + +export const useTerraformCloudConnectionListOrganizations = ( + connectionId: string, + options?: Omit< + UseQueryOptions< + TTerraformCloudOrganization[], + unknown, + TTerraformCloudOrganization[], + ReturnType + >, + "queryKey" | "queryFn" + > +) => { + return useQuery({ + queryKey: terraformCloudConnectionKeys.listOrganizations(connectionId), + queryFn: async () => { + const { data } = await apiRequest.get( + `/api/v1/app-connections/terraform-cloud/${connectionId}/organizations` + ); + + return data; + }, + ...options + }); +}; diff --git a/frontend/src/hooks/api/appConnections/terraform-cloud/types.ts b/frontend/src/hooks/api/appConnections/terraform-cloud/types.ts new file mode 100644 index 000000000..9b80d9a05 --- /dev/null +++ b/frontend/src/hooks/api/appConnections/terraform-cloud/types.ts @@ -0,0 +1,49 @@ +export type TTerraformCloudOrganization = { + name: string; + id: string; + projects: TTerraformCloudProject[]; + workspaces: TTerraformCloudWorkspace[]; +}; + +export type TTerraformCloudProject = { + id: string; + name: string; +}; + +export type TTerraformCloudWorkspace = { + id: string; + name: string; +}; + +export type TTerraformCloudConnectionOrganization = { + id: string; + name: string; + projects: TTerraformCloudConnectionProject[]; + workspaces: TTerraformCloudConnectionWorkspace[]; +}; + +export type TTerraformCloudConnectionProject = { + id: string; + name: string; +}; + +export type TTerraformCloudConnectionWorkspace = { + id: string; + name: string; +}; + +export enum TerraformCloudSyncScope { + Project = "project", + Workspace = "workspace" +} + +export const TERRAFORM_CLOUD_SYNC_SCOPES = { + [TerraformCloudSyncScope.Project]: { + name: "Project", + description: "Sync secrets to a specific project in Terraform Cloud." + }, + [TerraformCloudSyncScope.Workspace]: { + name: "Workspace", + description: "Sync secrets to a specific workspace in Terraform Cloud." + } +}; diff --git a/frontend/src/hooks/api/appConnections/types/app-options.ts b/frontend/src/hooks/api/appConnections/types/app-options.ts index a323765f7..6860a9733 100644 --- a/frontend/src/hooks/api/appConnections/types/app-options.ts +++ b/frontend/src/hooks/api/appConnections/types/app-options.ts @@ -38,6 +38,10 @@ export type THumanitecConnectionOption = TAppConnectionOptionBase & { app: AppConnection.Humanitec; }; +export type TTerraformCloudConnectionOption = TAppConnectionOptionBase & { + app: AppConnection.TerraformCloud; +}; + export type TAppConnectionOption = | TAwsConnectionOption | TGitHubConnectionOption @@ -45,7 +49,8 @@ export type TAppConnectionOption = | TAzureAppConfigurationConnectionOption | TAzureKeyVaultConnectionOption | TDatabricksConnectionOption - | THumanitecConnectionOption; + | THumanitecConnectionOption + | TTerraformCloudConnectionOption; export type TAppConnectionOptionMap = { [AppConnection.AWS]: TAwsConnectionOption; @@ -55,4 +60,5 @@ export type TAppConnectionOptionMap = { [AppConnection.AzureAppConfiguration]: TAzureAppConfigurationConnectionOption; [AppConnection.Databricks]: TDatabricksConnectionOption; [AppConnection.Humanitec]: THumanitecConnectionOption; + [AppConnection.TerraformCloud]: TTerraformCloudConnectionOption; }; diff --git a/frontend/src/hooks/api/appConnections/types/index.ts b/frontend/src/hooks/api/appConnections/types/index.ts index db56839fc..5eabfa178 100644 --- a/frontend/src/hooks/api/appConnections/types/index.ts +++ b/frontend/src/hooks/api/appConnections/types/index.ts @@ -8,6 +8,7 @@ import { THumanitecConnection } from "@app/hooks/api/appConnections/types/humani import { TAzureAppConfigurationConnection } from "./azure-app-configuration-connection"; import { TAzureKeyVaultConnection } from "./azure-key-vault-connection"; import { TGcpConnection } from "./gcp-connection"; +import { TTerraformCloudConnection } from "./terraform-cloud-connection"; export * from "./aws-connection"; export * from "./azure-app-configuration-connection"; @@ -15,6 +16,7 @@ export * from "./azure-key-vault-connection"; export * from "./gcp-connection"; export * from "./github-connection"; export * from "./humanitec-connection"; +export * from "./terraform-cloud-connection"; export type TAppConnection = | TAwsConnection @@ -23,7 +25,8 @@ export type TAppConnection = | TAzureKeyVaultConnection | TAzureAppConfigurationConnection | TDatabricksConnection - | THumanitecConnection; + | THumanitecConnection + | TTerraformCloudConnection; export type TAvailableAppConnection = Pick; @@ -58,4 +61,5 @@ export type TAppConnectionMap = { [AppConnection.AzureAppConfiguration]: TAzureAppConfigurationConnection; [AppConnection.Databricks]: TDatabricksConnection; [AppConnection.Humanitec]: THumanitecConnection; + [AppConnection.TerraformCloud]: TTerraformCloudConnection; }; diff --git a/frontend/src/hooks/api/appConnections/types/terraform-cloud-connection.ts b/frontend/src/hooks/api/appConnections/types/terraform-cloud-connection.ts new file mode 100644 index 000000000..9f4c61fc6 --- /dev/null +++ b/frontend/src/hooks/api/appConnections/types/terraform-cloud-connection.ts @@ -0,0 +1,15 @@ +import { AppConnection } from "@app/hooks/api/appConnections/enums"; +import { TRootAppConnection } from "@app/hooks/api/appConnections/types/root-connection"; + +export enum TerraformCloudConnectionMethod { + API_TOKEN = "api-token" +} + +export type TTerraformCloudConnection = TRootAppConnection & { + app: AppConnection.TerraformCloud; +} & { + method: TerraformCloudConnectionMethod.API_TOKEN; + credentials: { + apiToken: string; + }; +}; diff --git a/frontend/src/hooks/api/secretSyncs/enums.ts b/frontend/src/hooks/api/secretSyncs/enums.ts index 08accba16..0d6a92bf0 100644 --- a/frontend/src/hooks/api/secretSyncs/enums.ts +++ b/frontend/src/hooks/api/secretSyncs/enums.ts @@ -6,7 +6,8 @@ export enum SecretSync { AzureKeyVault = "azure-key-vault", AzureAppConfiguration = "azure-app-configuration", Databricks = "databricks", - Humanitec = "humanitec" + Humanitec = "humanitec", + TerraformCloud = "terraform-cloud" } export enum SecretSyncStatus { diff --git a/frontend/src/hooks/api/secretSyncs/types/index.ts b/frontend/src/hooks/api/secretSyncs/types/index.ts index a90a8a3ef..81192f55d 100644 --- a/frontend/src/hooks/api/secretSyncs/types/index.ts +++ b/frontend/src/hooks/api/secretSyncs/types/index.ts @@ -9,6 +9,7 @@ import { TAzureAppConfigurationSync } from "./azure-app-configuration-sync"; import { TAzureKeyVaultSync } from "./azure-key-vault-sync"; import { TGcpSync } from "./gcp-sync"; import { THumanitecSync } from "./humanitec-sync"; +import { TTerraformCloudSync } from "./terraform-cloud-sync"; export type TSecretSyncOption = { name: string; @@ -24,7 +25,8 @@ export type TSecretSync = | TAzureKeyVaultSync | TAzureAppConfigurationSync | TDatabricksSync - | THumanitecSync; + | THumanitecSync + | TTerraformCloudSync; export type TListSecretSyncs = { secretSyncs: TSecretSync[] }; diff --git a/frontend/src/hooks/api/secretSyncs/types/terraform-cloud-sync.ts b/frontend/src/hooks/api/secretSyncs/types/terraform-cloud-sync.ts new file mode 100644 index 000000000..9da8a848a --- /dev/null +++ b/frontend/src/hooks/api/secretSyncs/types/terraform-cloud-sync.ts @@ -0,0 +1,28 @@ +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 TTerraformCloudSync = TRootSecretSync & { + destination: SecretSync.TerraformCloud; + destinationConfig: + | { + scope: TerraformCloudSyncScope.Project; + org: string; + project: string; + } + | { + scope: TerraformCloudSyncScope.Workspace; + org: string; + workspace: string; + }; + connection: { + app: AppConnection.TerraformCloud; + name: string; + id: string; + }; +}; + +export enum TerraformCloudSyncScope { + Project = "project", + Workspace = "workspace" +} 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 fc3257841..6cbfe9b16 100644 --- a/frontend/src/pages/organization/AppConnections/AppConnectionsPage/components/AppConnectionForm/AppConnectionForm.tsx +++ b/frontend/src/pages/organization/AppConnections/AppConnectionsPage/components/AppConnectionForm/AppConnectionForm.tsx @@ -16,6 +16,7 @@ import { DatabricksConnectionForm } from "./DatabricksConnectionForm"; import { GcpConnectionForm } from "./GcpConnectionForm"; import { GitHubConnectionForm } from "./GitHubConnectionForm"; import { HumanitecConnectionForm } from "./HumanitecConnectionForm"; +import { TerraformCloudConnectionForm } from "./TerraformCloudConnectionForm"; type FormProps = { onComplete: (appConnection: TAppConnection) => void; @@ -65,6 +66,8 @@ const CreateForm = ({ app, onComplete }: CreateFormProps) => { return ; case AppConnection.Humanitec: return ; + case AppConnection.TerraformCloud: + return ; default: throw new Error(`Unhandled App ${app}`); } @@ -112,6 +115,8 @@ const UpdateForm = ({ appConnection, onComplete }: UpdateFormProps) => { return ; case AppConnection.Humanitec: return ; + case AppConnection.TerraformCloud: + return ; default: throw new Error(`Unhandled App ${(appConnection as TAppConnection).app}`); } diff --git a/frontend/src/pages/organization/AppConnections/AppConnectionsPage/components/AppConnectionForm/TerraformCloudConnectionForm.tsx b/frontend/src/pages/organization/AppConnections/AppConnectionsPage/components/AppConnectionForm/TerraformCloudConnectionForm.tsx new file mode 100644 index 000000000..b97015304 --- /dev/null +++ b/frontend/src/pages/organization/AppConnections/AppConnectionsPage/components/AppConnectionForm/TerraformCloudConnectionForm.tsx @@ -0,0 +1,135 @@ +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 { + TerraformCloudConnectionMethod, + TTerraformCloudConnection +} from "@app/hooks/api/appConnections"; +import { AppConnection } from "@app/hooks/api/appConnections/enums"; + +import { + genericAppConnectionFieldsSchema, + GenericAppConnectionsFields +} from "./GenericAppConnectionFields"; + +type Props = { + appConnection?: TTerraformCloudConnection; + onSubmit: (formData: FormData) => void; +}; + +const rootSchema = genericAppConnectionFieldsSchema.extend({ + app: z.literal(AppConnection.TerraformCloud) +}); + +const formSchema = z.discriminatedUnion("method", [ + rootSchema.extend({ + method: z.literal(TerraformCloudConnectionMethod.API_TOKEN), + credentials: z.object({ + apiToken: z.string().trim().min(1, "Service API Token required") + }) + }) +]); + +type FormData = z.infer; + +export const TerraformCloudConnectionForm = ({ appConnection, onSubmit }: Props) => { + const isUpdate = Boolean(appConnection); + + const form = useForm({ + resolver: zodResolver(formSchema), + defaultValues: appConnection ?? { + app: AppConnection.TerraformCloud, + method: TerraformCloudConnectionMethod.API_TOKEN + } + }); + + const { + handleSubmit, + control, + formState: { isSubmitting, isDirty } + } = form; + + return ( + +
+ {!isUpdate && } + ( + + + + )} + /> + ( + + onChange(e.target.value)} + /> + + )} + /> +
+ + + + +
+ +
+ ); +}; 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 c4dc564b0..b1874c9ef 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 @@ -8,6 +8,7 @@ import { DatabricksSyncDestinationCol } from "./DatabricksSyncDestinationCol"; import { GcpSyncDestinationCol } from "./GcpSyncDestinationCol"; import { GitHubSyncDestinationCol } from "./GitHubSyncDestinationCol"; import { HumanitecSyncDestinationCol } from "./HumanitecSyncDestinationCol"; +import { TerraformCloudSyncDestinationCol } from "./TerraformCloudSyncDestinationCol"; type Props = { secretSync: TSecretSync; @@ -31,6 +32,8 @@ export const SecretSyncDestinationCol = ({ secretSync }: Props) => { return ; case SecretSync.Humanitec: return ; + case SecretSync.TerraformCloud: + return ; default: throw new Error( `Unhandled Secret Sync Destination Col: ${(secretSync as TSecretSync).destination}` diff --git a/frontend/src/pages/secret-manager/IntegrationsListPage/components/SecretSyncsTab/SecretSyncTable/SecretSyncDestinationCol/TerraformCloudSyncDestinationCol.tsx b/frontend/src/pages/secret-manager/IntegrationsListPage/components/SecretSyncsTab/SecretSyncTable/SecretSyncDestinationCol/TerraformCloudSyncDestinationCol.tsx new file mode 100644 index 000000000..71ddcc56a --- /dev/null +++ b/frontend/src/pages/secret-manager/IntegrationsListPage/components/SecretSyncsTab/SecretSyncTable/SecretSyncDestinationCol/TerraformCloudSyncDestinationCol.tsx @@ -0,0 +1,14 @@ +import { TTerraformCloudSync } from "@app/hooks/api/secretSyncs/types/terraform-cloud-sync"; + +import { getSecretSyncDestinationColValues } from "../helpers"; +import { SecretSyncTableCell } from "../SecretSyncTableCell"; + +type Props = { + secretSync: TTerraformCloudSync; +}; + +export const TerraformCloudSyncDestinationCol = ({ secretSync }: Props) => { + const { primaryText, secondaryText } = getSecretSyncDestinationColValues(secretSync); + + return ; +}; 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 6eb3a04e3..550474386 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 @@ -1,3 +1,4 @@ +import { TerraformCloudSyncScope } from "@app/hooks/api/appConnections/terraform-cloud"; import { SecretSync, TSecretSync } from "@app/hooks/api/secretSyncs"; import { GitHubSyncScope, @@ -73,6 +74,13 @@ export const getSecretSyncDestinationColValues = (secretSync: TSecretSync) => { } secondaryText = `Organization - ${destinationConfig.org}`; break; + case SecretSync.TerraformCloud: + primaryText = destinationConfig.org; + secondaryText = + destinationConfig.scope === TerraformCloudSyncScope.Project + ? destinationConfig.project + : destinationConfig.workspace; + break; default: throw new Error(`Unhandled Destination Col Values ${destination}`); } 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 c1dee10c2..1202686a1 100644 --- a/frontend/src/pages/secret-manager/SecretSyncDetailsByIDPage/components/SecretSyncDestinationSection/SecretSyncDestinatonSection.tsx +++ b/frontend/src/pages/secret-manager/SecretSyncDetailsByIDPage/components/SecretSyncDestinationSection/SecretSyncDestinatonSection.tsx @@ -9,6 +9,7 @@ import { ProjectPermissionSub } from "@app/context"; import { ProjectPermissionSecretSyncActions } from "@app/context/ProjectPermissionContext/types"; import { APP_CONNECTION_MAP } from "@app/helpers/appConnections"; import { SecretSync, TSecretSync } from "@app/hooks/api/secretSyncs"; +import { TerraformCloudSyncDestinationCol } from "@app/pages/secret-manager/IntegrationsListPage/components/SecretSyncsTab/SecretSyncTable/SecretSyncDestinationCol/TerraformCloudSyncDestinationCol"; import { AwsParameterStoreSyncDestinationSection } from "@app/pages/secret-manager/SecretSyncDetailsByIDPage/components/SecretSyncDestinationSection/AwsParameterStoreSyncDestinationSection"; import { AwsSecretsManagerSyncDestinationSection } from "@app/pages/secret-manager/SecretSyncDetailsByIDPage/components/SecretSyncDestinationSection/AwsSecretsManagerSyncDestinationSection"; import { DatabricksSyncDestinationSection } from "@app/pages/secret-manager/SecretSyncDetailsByIDPage/components/SecretSyncDestinationSection/DatabricksSyncDestinationSection"; @@ -57,6 +58,9 @@ export const SecretSyncDestinationSection = ({ secretSync, onEditDestination }: case SecretSync.Humanitec: DestinationComponents = ; break; + case SecretSync.TerraformCloud: + DestinationComponents = ; + break; default: throw new Error(`Unhandled Destination Section components: ${destination}`); } diff --git a/frontend/src/pages/secret-manager/SecretSyncDetailsByIDPage/components/SecretSyncDestinationSection/TerraformCloudSyncDestinationCol.tsx b/frontend/src/pages/secret-manager/SecretSyncDetailsByIDPage/components/SecretSyncDestinationSection/TerraformCloudSyncDestinationCol.tsx new file mode 100644 index 000000000..a6157c2b2 --- /dev/null +++ b/frontend/src/pages/secret-manager/SecretSyncDetailsByIDPage/components/SecretSyncDestinationSection/TerraformCloudSyncDestinationCol.tsx @@ -0,0 +1,48 @@ +import { ReactNode } from "react"; + +import { SecretSyncLabel } from "@app/components/secret-syncs"; +import { + TerraformCloudSyncScope, + TTerraformCloudSync +} from "@app/hooks/api/secretSyncs/types/terraform-cloud-sync"; + +type Props = { + secretSync: TTerraformCloudSync; +}; + +export const TerraformCloudSyncDestinationSection = ({ secretSync }: Props) => { + const { destinationConfig } = secretSync; + + let Components: ReactNode; + switch (destinationConfig.scope) { + case TerraformCloudSyncScope.Project: + Components = ( + <> + {destinationConfig.org} + {destinationConfig.project} + + ); + break; + case TerraformCloudSyncScope.Workspace: + Components = ( + <> + {destinationConfig.org} + {destinationConfig.workspace} + + ); + break; + default: + throw new Error( + `Unhandled Terraform Cloud Sync Destination Section Scope ${secretSync.destinationConfig.scope}` + ); + } + + return ( + <> + + {destinationConfig.scope.replace("-", " ")} + + {Components} + + ); +}; 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 351cb70e1..3f536a5af 100644 --- a/frontend/src/pages/secret-manager/SecretSyncDetailsByIDPage/components/SecretSyncOptionsSection/SecretSyncOptionsSection.tsx +++ b/frontend/src/pages/secret-manager/SecretSyncDetailsByIDPage/components/SecretSyncOptionsSection/SecretSyncOptionsSection.tsx @@ -48,6 +48,7 @@ export const SecretSyncOptionsSection = ({ secretSync, onEditOptions }: Props) = case SecretSync.AzureAppConfiguration: case SecretSync.Databricks: case SecretSync.Humanitec: + case SecretSync.TerraformCloud: AdditionalSyncOptionsComponent = null; break; default: