diff --git a/backend/src/lib/api-docs/constants.ts b/backend/src/lib/api-docs/constants.ts index fe893d5ab..b9f66adc8 100644 --- a/backend/src/lib/api-docs/constants.ts +++ b/backend/src/lib/api-docs/constants.ts @@ -1771,6 +1771,11 @@ export const SecretSyncs = { }, DATABRICKS: { scope: "The Databricks secret scope that secrets should be synced to." + }, + HUMANITEC: { + app: "The ID of the Humanitec app to sync secrets to.", + org: "The ID of the Humanitec org to sync secrets to.", + env: "The ID of the Humanitec environment to sync secrets to." } } }; diff --git a/backend/src/lib/config/env.ts b/backend/src/lib/config/env.ts index 0d13939a2..15ffd2d51 100644 --- a/backend/src/lib/config/env.ts +++ b/backend/src/lib/config/env.ts @@ -224,6 +224,9 @@ const envSchema = z DATADOG_SERVICE: zpStr(z.string().optional().default("infisical-core")), DATADOG_HOSTNAME: zpStr(z.string().optional()), + // humanitec + INF_APP_CONNECTION_HUMANITEC_ACCESS_KEY: zpStr(z.string().optional()), + /* CORS ----------------------------------------------------------------------------- */ CORS_ALLOWED_ORIGINS: zpStr( 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 964a57a13..cf8181ec4 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 @@ -18,6 +18,10 @@ import { } from "@app/services/app-connection/databricks"; import { GcpConnectionListItemSchema, SanitizedGcpConnectionSchema } from "@app/services/app-connection/gcp"; import { GitHubConnectionListItemSchema, SanitizedGitHubConnectionSchema } from "@app/services/app-connection/github"; +import { + HumanitecConnectionListItemSchema, + SanitizedHumanitecConnectionSchema +} from "@app/services/app-connection/humanitec"; import { AuthMode } from "@app/services/auth/auth-type"; // can't use discriminated due to multiple schemas for certain apps @@ -27,7 +31,8 @@ const SanitizedAppConnectionSchema = z.union([ ...SanitizedGcpConnectionSchema.options, ...SanitizedAzureKeyVaultConnectionSchema.options, ...SanitizedAzureAppConfigurationConnectionSchema.options, - ...SanitizedDatabricksConnectionSchema.options + ...SanitizedDatabricksConnectionSchema.options, + ...SanitizedHumanitecConnectionSchema.options ]); const AppConnectionOptionsSchema = z.discriminatedUnion("app", [ @@ -36,7 +41,8 @@ const AppConnectionOptionsSchema = z.discriminatedUnion("app", [ GcpConnectionListItemSchema, AzureKeyVaultConnectionListItemSchema, AzureAppConfigurationConnectionListItemSchema, - DatabricksConnectionListItemSchema + DatabricksConnectionListItemSchema, + HumanitecConnectionListItemSchema ]); export const registerAppConnectionRouter = async (server: FastifyZodProvider) => { diff --git a/backend/src/server/routes/v1/app-connection-routers/humanitec-connection-router.ts b/backend/src/server/routes/v1/app-connection-routers/humanitec-connection-router.ts new file mode 100644 index 000000000..2d462c4ef --- /dev/null +++ b/backend/src/server/routes/v1/app-connection-routers/humanitec-connection-router.ts @@ -0,0 +1,69 @@ +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 { + CreateHumanitecConnectionSchema, + HumanitecOrgWithApps, + SanitizedHumanitecConnectionSchema, + UpdateHumanitecConnectionSchema +} from "@app/services/app-connection/humanitec"; +import { AuthMode } from "@app/services/auth/auth-type"; + +import { registerAppConnectionEndpoints } from "./app-connection-endpoints"; + +export const registerHumanitecConnectionRouter = async (server: FastifyZodProvider) => { + registerAppConnectionEndpoints({ + app: AppConnection.Humanitec, + server, + sanitizedResponseSchema: SanitizedHumanitecConnectionSchema, + createSchema: CreateHumanitecConnectionSchema, + updateSchema: UpdateHumanitecConnectionSchema + }); + + // 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(), + apps: z + .object({ + id: z.string(), + name: z.string(), + envs: z + .object({ + id: z.string(), + name: z.string() + }) + .array() + }) + .array() + }) + .array() + } + }, + onRequest: verifyAuth([AuthMode.JWT]), + handler: async (req) => { + const { connectionId } = req.params; + + const organizations: HumanitecOrgWithApps[] = await server.services.appConnection.humanitec.listOrganizations( + connectionId, + req.permission + ); + + return organizations; + } + }); +}; 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 e86c37753..c2b688a43 100644 --- a/backend/src/server/routes/v1/app-connection-routers/index.ts +++ b/backend/src/server/routes/v1/app-connection-routers/index.ts @@ -6,6 +6,7 @@ import { registerAzureKeyVaultConnectionRouter } from "./azure-key-vault-connect import { registerDatabricksConnectionRouter } from "./databricks-connection-router"; import { registerGcpConnectionRouter } from "./gcp-connection-router"; import { registerGitHubConnectionRouter } from "./github-connection-router"; +import { registerHumanitecConnectionRouter } from "./humanitec-connection-router"; export * from "./app-connection-router"; @@ -16,5 +17,6 @@ export const APP_CONNECTION_REGISTER_ROUTER_MAP: Record + registerSyncSecretsEndpoints({ + destination: SecretSync.Humanitec, + server, + responseSchema: HumanitecSyncSchema, + createSchema: CreateHumanitecSyncSchema, + updateSchema: UpdateHumanitecSyncSchema + }); 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 137335186..c342f3b73 100644 --- a/backend/src/server/routes/v1/secret-sync-routers/index.ts +++ b/backend/src/server/routes/v1/secret-sync-routers/index.ts @@ -7,6 +7,7 @@ import { registerAzureKeyVaultSyncRouter } from "./azure-key-vault-sync-router"; import { registerDatabricksSyncRouter } from "./databricks-sync-router"; import { registerGcpSyncRouter } from "./gcp-sync-router"; import { registerGitHubSyncRouter } from "./github-sync-router"; +import { registerHumanitecSyncRouter } from "./humanitec-sync-router"; export * from "./secret-sync-router"; @@ -17,5 +18,6 @@ export const SECRET_SYNC_REGISTER_ROUTER_MAP: Record { diff --git a/backend/src/services/app-connection/app-connection-enums.ts b/backend/src/services/app-connection/app-connection-enums.ts index ce116a80a..9da622541 100644 --- a/backend/src/services/app-connection/app-connection-enums.ts +++ b/backend/src/services/app-connection/app-connection-enums.ts @@ -4,7 +4,8 @@ export enum AppConnection { Databricks = "databricks", GCP = "gcp", AzureKeyVault = "azure-key-vault", - AzureAppConfiguration = "azure-app-configuration" + AzureAppConfiguration = "azure-app-configuration", + Humanitec = "humanitec" } 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 c66fe52c4..f1d899a4d 100644 --- a/backend/src/services/app-connection/app-connection-fns.ts +++ b/backend/src/services/app-connection/app-connection-fns.ts @@ -35,6 +35,11 @@ import { getAzureKeyVaultConnectionListItem, validateAzureKeyVaultConnectionCredentials } from "./azure-key-vault"; +import { + getHumanitecConnectionListItem, + HumanitecConnectionMethod, + validateHumanitecConnectionCredentials +} from "./humanitec"; export const listAppConnectionOptions = () => { return [ @@ -43,7 +48,8 @@ export const listAppConnectionOptions = () => { getGcpConnectionListItem(), getAzureKeyVaultConnectionListItem(), getAzureAppConfigurationConnectionListItem(), - getDatabricksConnectionListItem() + getDatabricksConnectionListItem(), + getHumanitecConnectionListItem() ].sort((a, b) => a.name.localeCompare(b.name)); }; @@ -106,6 +112,8 @@ export const validateAppConnectionCredentials = async ( return validateAzureKeyVaultConnectionCredentials(appConnection); case AppConnection.AzureAppConfiguration: return validateAzureAppConfigurationConnectionCredentials(appConnection); + case AppConnection.Humanitec: + return validateHumanitecConnectionCredentials(appConnection); default: // eslint-disable-next-line @typescript-eslint/restrict-template-expressions throw new Error(`Unhandled App Connection ${app}`); @@ -128,6 +136,8 @@ export const getAppConnectionMethodName = (method: TAppConnection["method"]) => return "Service Account Impersonation"; case DatabricksConnectionMethod.ServicePrincipal: return "Service Principal"; + case HumanitecConnectionMethod.AccessKey: + return "Access Key"; 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 8a045efe4..8a6c65426 100644 --- a/backend/src/services/app-connection/app-connection-maps.ts +++ b/backend/src/services/app-connection/app-connection-maps.ts @@ -6,5 +6,6 @@ export const APP_CONNECTION_NAME_MAP: Record = { [AppConnection.GCP]: "GCP", [AppConnection.AzureKeyVault]: "Azure Key Vault", [AppConnection.AzureAppConfiguration]: "Azure App Configuration", - [AppConnection.Databricks]: "Databricks" + [AppConnection.Databricks]: "Databricks", + [AppConnection.Humanitec]: "Humanitec" }; diff --git a/backend/src/services/app-connection/app-connection-service.ts b/backend/src/services/app-connection/app-connection-service.ts index 8c4dd6a7c..e2e55bba0 100644 --- a/backend/src/services/app-connection/app-connection-service.ts +++ b/backend/src/services/app-connection/app-connection-service.ts @@ -35,6 +35,8 @@ import { ValidateGcpConnectionCredentialsSchema } from "./gcp"; import { gcpConnectionService } from "./gcp/gcp-connection-service"; import { ValidateGitHubConnectionCredentialsSchema } from "./github"; import { githubConnectionService } from "./github/github-connection-service"; +import { ValidateHumanitecConnectionCredentialsSchema } from "./humanitec"; +import { humanitecConnectionService } from "./humanitec/humanitec-connection-service"; export type TAppConnectionServiceFactoryDep = { appConnectionDAL: TAppConnectionDALFactory; @@ -50,7 +52,8 @@ const VALIDATE_APP_CONNECTION_CREDENTIALS_MAP: Record { + return { + name: "Humanitec" as const, + app: AppConnection.Humanitec as const, + methods: Object.values(HumanitecConnectionMethod) as [HumanitecConnectionMethod.AccessKey] + }; +}; + +export const validateHumanitecConnectionCredentials = async (config: THumanitecConnectionConfig) => { + const { credentials: inputCredentials } = config; + + let response: AxiosResponse | null = null; + + try { + response = await request.get(`${IntegrationUrls.HUMANITEC_API_URL}/orgs`, { + headers: { + Authorization: `Bearer ${inputCredentials.accessKeyId}` + } + }); + } catch (error: unknown) { + if (error instanceof AxiosError) { + throw new BadRequestError({ + message: `Failed to validate credentials: ${error.response?.data || "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: THumanitecConnection): Promise => { + const { + credentials: { accessKeyId } + } = appConnection; + const response = await request.get(`${IntegrationUrls.HUMANITEC_API_URL}/orgs`, { + headers: { + Authorization: `Bearer ${accessKeyId}` + } + }); + + if (!response.data) { + throw new InternalServerError({ + message: "Failed to get organizations: Response was empty" + }); + } + + const orgs = response.data; + const appPromises = orgs.map(async (org) => { + return request.get(`${IntegrationUrls.HUMANITEC_API_URL}/orgs/${org.id}/apps`, { + headers: { + Authorization: `Bearer ${accessKeyId}` + } + }); + }); + + const appsResponses = await Promise.all(appPromises); + + const orgsWithApps: HumanitecOrgWithApps[] = orgs.map((org, index) => { + if (!appsResponses[index].data) { + throw new InternalServerError({ + message: "Failed to get apps for organization: Response was empty" + }); + } + + const apps = appsResponses[index].data; + return { + ...org, + apps: apps.map((app) => ({ + name: app.name, + id: app.id, + envs: app.envs + })) + }; + }); + return orgsWithApps; +}; diff --git a/backend/src/services/app-connection/humanitec/humanitec-connection-schemas.ts b/backend/src/services/app-connection/humanitec/humanitec-connection-schemas.ts new file mode 100644 index 000000000..e0370207f --- /dev/null +++ b/backend/src/services/app-connection/humanitec/humanitec-connection-schemas.ts @@ -0,0 +1,58 @@ +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 { HumanitecConnectionMethod } from "./humanitec-connection-enums"; + +export const HumanitecConnectionAccessTokenCredentialsSchema = z.object({ + accessKeyId: z.string().trim().min(1, "Access Key ID required") +}); + +const BaseHumanitecConnectionSchema = BaseAppConnectionSchema.extend({ app: z.literal(AppConnection.Humanitec) }); + +export const HumanitecConnectionSchema = BaseHumanitecConnectionSchema.extend({ + method: z.literal(HumanitecConnectionMethod.AccessKey), + credentials: HumanitecConnectionAccessTokenCredentialsSchema +}); + +export const SanitizedHumanitecConnectionSchema = z.discriminatedUnion("method", [ + BaseHumanitecConnectionSchema.extend({ + method: z.literal(HumanitecConnectionMethod.AccessKey), + credentials: HumanitecConnectionAccessTokenCredentialsSchema.pick({}) + }) +]); + +export const ValidateHumanitecConnectionCredentialsSchema = z.discriminatedUnion("method", [ + z.object({ + method: z + .literal(HumanitecConnectionMethod.AccessKey) + .describe(AppConnections?.CREATE(AppConnection.Humanitec).method), + credentials: HumanitecConnectionAccessTokenCredentialsSchema.describe( + AppConnections.CREATE(AppConnection.Humanitec).credentials + ) + }) +]); + +export const CreateHumanitecConnectionSchema = ValidateHumanitecConnectionCredentialsSchema.and( + GenericCreateAppConnectionFieldsSchema(AppConnection.Humanitec) +); + +export const UpdateHumanitecConnectionSchema = z + .object({ + credentials: HumanitecConnectionAccessTokenCredentialsSchema.optional().describe( + AppConnections.UPDATE(AppConnection.Humanitec).credentials + ) + }) + .and(GenericUpdateAppConnectionFieldsSchema(AppConnection.Humanitec)); + +export const HumanitecConnectionListItemSchema = z.object({ + name: z.literal("Humanitec"), + app: z.literal(AppConnection.Humanitec), + methods: z.nativeEnum(HumanitecConnectionMethod).array() +}); diff --git a/backend/src/services/app-connection/humanitec/humanitec-connection-service.ts b/backend/src/services/app-connection/humanitec/humanitec-connection-service.ts new file mode 100644 index 000000000..0036d5c5b --- /dev/null +++ b/backend/src/services/app-connection/humanitec/humanitec-connection-service.ts @@ -0,0 +1,27 @@ +import { OrgServiceActor } from "@app/lib/types"; + +import { AppConnection } from "../app-connection-enums"; +import { listOrganizations as getHumanitecOrganizations } from "./humanitec-connection-fns"; +import { THumanitecConnection } from "./humanitec-connection-types"; + +type TGetAppConnectionFunc = ( + app: AppConnection, + connectionId: string, + actor: OrgServiceActor +) => Promise; + +export const humanitecConnectionService = (getAppConnection: TGetAppConnectionFunc) => { + const listOrganizations = async (connectionId: string, actor: OrgServiceActor) => { + const appConnection = await getAppConnection(AppConnection.Humanitec, connectionId, actor); + try { + const organizations = await getHumanitecOrganizations(appConnection); + return organizations; + } catch (error) { + return []; + } + }; + + return { + listOrganizations + }; +}; diff --git a/backend/src/services/app-connection/humanitec/humanitec-connection-types.ts b/backend/src/services/app-connection/humanitec/humanitec-connection-types.ts new file mode 100644 index 000000000..94613bfba --- /dev/null +++ b/backend/src/services/app-connection/humanitec/humanitec-connection-types.ts @@ -0,0 +1,40 @@ +import z from "zod"; + +import { DiscriminativePick } from "@app/lib/types"; + +import { AppConnection } from "../app-connection-enums"; +import { + CreateHumanitecConnectionSchema, + HumanitecConnectionSchema, + ValidateHumanitecConnectionCredentialsSchema +} from "./humanitec-connection-schemas"; + +export type THumanitecConnection = z.infer; + +export type THumanitecConnectionInput = z.infer & { + app: AppConnection.Humanitec; +}; + +export type TValidateHumanitecConnectionCredentials = typeof ValidateHumanitecConnectionCredentialsSchema; + +export type THumanitecConnectionConfig = DiscriminativePick< + THumanitecConnectionInput, + "method" | "app" | "credentials" +> & { + orgId: string; +}; + +export type HumanitecOrg = { + id: string; + name: string; +}; + +export type HumanitecApp = { + name: string; + id: string; + envs: { name: string; id: string }[]; +}; + +export type HumanitecOrgWithApps = HumanitecOrg & { + apps: HumanitecApp[]; +}; diff --git a/backend/src/services/app-connection/humanitec/index.ts b/backend/src/services/app-connection/humanitec/index.ts new file mode 100644 index 000000000..52fb6b3c2 --- /dev/null +++ b/backend/src/services/app-connection/humanitec/index.ts @@ -0,0 +1,4 @@ +export * from "./humanitec-connection-enums"; +export * from "./humanitec-connection-fns"; +export * from "./humanitec-connection-schemas"; +export * from "./humanitec-connection-types"; diff --git a/backend/src/services/integration-auth/integration-list.ts b/backend/src/services/integration-auth/integration-list.ts index 16a717bd6..d6c450751 100644 --- a/backend/src/services/integration-auth/integration-list.ts +++ b/backend/src/services/integration-auth/integration-list.ts @@ -93,6 +93,7 @@ export enum IntegrationUrls { NORTHFLANK_API_URL = "https://api.northflank.com", HASURA_CLOUD_API_URL = "https://data.pro.hasura.io/v1/graphql", AZURE_DEVOPS_API_URL = "https://dev.azure.com", + HUMANITEC_API_URL = "https://api.humanitec.io", GCP_SECRET_MANAGER_SERVICE_NAME = "secretmanager.googleapis.com", GCP_SECRET_MANAGER_URL = `https://${GCP_SECRET_MANAGER_SERVICE_NAME}`, diff --git a/backend/src/services/secret-sync/humanitec/humanitec-sync-constants.ts b/backend/src/services/secret-sync/humanitec/humanitec-sync-constants.ts new file mode 100644 index 000000000..d81cfd041 --- /dev/null +++ b/backend/src/services/secret-sync/humanitec/humanitec-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 HUMANITEC_SYNC_LIST_OPTION: TSecretSyncListItem = { + name: "Humanitec", + destination: SecretSync.Humanitec, + connection: AppConnection.Humanitec, + canImportSecrets: false +}; diff --git a/backend/src/services/secret-sync/humanitec/humanitec-sync-fns.ts b/backend/src/services/secret-sync/humanitec/humanitec-sync-fns.ts new file mode 100644 index 000000000..61655ceea --- /dev/null +++ b/backend/src/services/secret-sync/humanitec/humanitec-sync-fns.ts @@ -0,0 +1,160 @@ +import { request } from "@app/lib/config/request"; +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 { HumanitecSecret, THumanitecSyncWithCredentials } from "./humanitec-sync-types"; + +const getHumanitecSecrets = async (secretSync: THumanitecSyncWithCredentials) => { + const { + destinationConfig, + connection: { + credentials: { accessKeyId } + } + } = secretSync; + + const { data } = await request.get( + `${IntegrationUrls.HUMANITEC_API_URL}/orgs/${destinationConfig.org}/apps/${destinationConfig.app}/envs/${destinationConfig.env}/values`, + { + headers: { + Authorization: `Bearer ${accessKeyId}`, + "Accept-Encoding": "application/json" + } + } + ); + + return data; +}; + +const deleteSecret = async (secretSync: THumanitecSyncWithCredentials, encryptedSecret: HumanitecSecret) => { + const { + destinationConfig, + connection: { + credentials: { accessKeyId } + } + } = secretSync; + + try { + await request.delete( + `${IntegrationUrls.HUMANITEC_API_URL}/orgs/${destinationConfig.org}/apps/${destinationConfig.app}/envs/${destinationConfig.env}/values/${encryptedSecret.key}`, + { + headers: { + Authorization: `Bearer ${accessKeyId}`, + "Accept-Encoding": "application/json" + } + } + ); + } catch (error) { + throw new SecretSyncError({ + error, + secretKey: encryptedSecret.key + }); + } +}; + +const createSecret = async (secretSync: THumanitecSyncWithCredentials, secretMap: TSecretMap, key: string) => { + try { + const { + destinationConfig, + connection: { + credentials: { accessKeyId } + } + } = secretSync; + + await request.post( + `${IntegrationUrls.HUMANITEC_API_URL}/orgs/${destinationConfig.org}/apps/${destinationConfig.app}/values`, + { + key, + value: "", + description: secretMap[key].comment || "" + }, + { + headers: { + Authorization: `Bearer ${accessKeyId}`, + "Accept-Encoding": "application/json" + } + } + ); + await request.patch( + `${IntegrationUrls.HUMANITEC_API_URL}/orgs/${destinationConfig.org}/apps/${destinationConfig.app}/envs/${destinationConfig.env}/values/${key}`, + { + value: secretMap[key].value, + description: secretMap[key].comment || "" + }, + { + headers: { + Authorization: `Bearer ${accessKeyId}`, + "Accept-Encoding": "application/json" + } + } + ); + } catch (error) { + throw new SecretSyncError({ + error, + secretKey: key + }); + } +}; + +const updateSecret = async (secretSync: THumanitecSyncWithCredentials, secretMap: TSecretMap, key: string) => { + try { + const { + destinationConfig, + connection: { + credentials: { accessKeyId } + } + } = secretSync; + await request.patch( + `${IntegrationUrls.HUMANITEC_API_URL}/orgs/${destinationConfig.org}/apps/${destinationConfig.app}/envs/${destinationConfig.env}/values/${key}`, + { + value: secretMap[key].value, + description: secretMap[key].comment + }, + { + headers: { + Authorization: `Bearer ${accessKeyId}`, + "Accept-Encoding": "application/json" + } + } + ); + } catch (error) { + throw new SecretSyncError({ + error, + secretKey: key + }); + } +}; + +export const HumanitecSyncFns = { + syncSecrets: async (secretSync: THumanitecSyncWithCredentials, secretMap: TSecretMap) => { + const humanitecSecrets = await getHumanitecSecrets(secretSync); + const humanitecSecretsKeys = new Set(humanitecSecrets.map((s) => s.key)); + for await (const key of Object.keys(secretMap)) { + if (!humanitecSecretsKeys.has(key)) { + await createSecret(secretSync, secretMap, key); + } else { + await updateSecret(secretSync, secretMap, key); + } + } + + for await (const humanitecSecret of humanitecSecrets) { + if (!secretMap[humanitecSecret.key]) { + await deleteSecret(secretSync, humanitecSecret); + } + } + }, + getSecrets: async (secretSync: THumanitecSyncWithCredentials): Promise => { + throw new Error(`${SECRET_SYNC_NAME_MAP[secretSync.destination]} does not support importing secrets.`); + }, + + removeSecrets: async (secretSync: THumanitecSyncWithCredentials, secretMap: TSecretMap) => { + const encryptedSecrets = await getHumanitecSecrets(secretSync); + + for await (const encryptedSecret of encryptedSecrets) { + if (encryptedSecret.key in secretMap) { + await deleteSecret(secretSync, encryptedSecret); + } + } + } +}; diff --git a/backend/src/services/secret-sync/humanitec/humanitec-sync-schemas.ts b/backend/src/services/secret-sync/humanitec/humanitec-sync-schemas.ts new file mode 100644 index 000000000..fab0512cd --- /dev/null +++ b/backend/src/services/secret-sync/humanitec/humanitec-sync-schemas.ts @@ -0,0 +1,45 @@ +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 HumanitecSyncDestinationConfigSchema = z.object({ + app: z.string().min(1, "App ID is required").describe(SecretSyncs.DESTINATION_CONFIG.HUMANITEC.app), + org: z.string().min(1, "Org ID is required").describe(SecretSyncs.DESTINATION_CONFIG.HUMANITEC.org), + env: z.string().min(1, "Env ID is required").describe(SecretSyncs.DESTINATION_CONFIG.HUMANITEC.env) +}); + +const HumanitecSyncOptionsConfig: TSyncOptionsConfig = { canImportSecrets: false }; + +export const HumanitecSyncSchema = BaseSecretSyncSchema(SecretSync.Humanitec, HumanitecSyncOptionsConfig).extend({ + destination: z.literal(SecretSync.Humanitec), + destinationConfig: HumanitecSyncDestinationConfigSchema +}); + +export const CreateHumanitecSyncSchema = GenericCreateSecretSyncFieldsSchema( + SecretSync.Humanitec, + HumanitecSyncOptionsConfig +).extend({ + destinationConfig: HumanitecSyncDestinationConfigSchema +}); + +export const UpdateHumanitecSyncSchema = GenericUpdateSecretSyncFieldsSchema( + SecretSync.Humanitec, + HumanitecSyncOptionsConfig +).extend({ + destinationConfig: HumanitecSyncDestinationConfigSchema.optional() +}); + +export const HumanitecSyncListItemSchema = z.object({ + name: z.literal("Humanitec"), + connection: z.literal(AppConnection.Humanitec), + destination: z.literal(SecretSync.Humanitec), + canImportSecrets: z.literal(false) +}); diff --git a/backend/src/services/secret-sync/humanitec/humanitec-sync-types.ts b/backend/src/services/secret-sync/humanitec/humanitec-sync-types.ts new file mode 100644 index 000000000..d49e4401e --- /dev/null +++ b/backend/src/services/secret-sync/humanitec/humanitec-sync-types.ts @@ -0,0 +1,23 @@ +import z from "zod"; + +import { THumanitecConnection } from "@app/services/app-connection/humanitec"; + +import { CreateHumanitecSyncSchema, HumanitecSyncListItemSchema, HumanitecSyncSchema } from "./humanitec-sync-schemas"; + +export type THumanitecSyncListItem = z.infer; + +export type THumanitecSync = z.infer; + +export type THumanitecSyncInput = z.infer; + +export type THumanitecSyncWithCredentials = THumanitecSync & { + connection: THumanitecConnection; +}; + +export type HumanitecSecret = { + description: string; + is_secret: boolean; + key: string; + source: "app" | "env"; + value: string; +}; diff --git a/backend/src/services/secret-sync/humanitec/index.ts b/backend/src/services/secret-sync/humanitec/index.ts new file mode 100644 index 000000000..24bad622e --- /dev/null +++ b/backend/src/services/secret-sync/humanitec/index.ts @@ -0,0 +1,4 @@ +export * from "./humanitec-sync-constants"; +export * from "./humanitec-sync-fns"; +export * from "./humanitec-sync-schemas"; +export * from "./humanitec-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 743b900bc..006d033f5 100644 --- a/backend/src/services/secret-sync/secret-sync-enums.ts +++ b/backend/src/services/secret-sync/secret-sync-enums.ts @@ -5,7 +5,8 @@ export enum SecretSync { GCPSecretManager = "gcp-secret-manager", AzureKeyVault = "azure-key-vault", AzureAppConfiguration = "azure-app-configuration", - Databricks = "databricks" + Databricks = "databricks", + Humanitec = "humanitec" } 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 597c7fc01..6c8a6d4df 100644 --- a/backend/src/services/secret-sync/secret-sync-fns.ts +++ b/backend/src/services/secret-sync/secret-sync-fns.ts @@ -24,6 +24,8 @@ import { AZURE_APP_CONFIGURATION_SYNC_LIST_OPTION, azureAppConfigurationSyncFact import { AZURE_KEY_VAULT_SYNC_LIST_OPTION, azureKeyVaultSyncFactory } from "./azure-key-vault"; 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"; const SECRET_SYNC_LIST_OPTIONS: Record = { [SecretSync.AWSParameterStore]: AWS_PARAMETER_STORE_SYNC_LIST_OPTION, @@ -32,7 +34,8 @@ const SECRET_SYNC_LIST_OPTIONS: Record = { [SecretSync.GCPSecretManager]: GCP_SYNC_LIST_OPTION, [SecretSync.AzureKeyVault]: AZURE_KEY_VAULT_SYNC_LIST_OPTION, [SecretSync.AzureAppConfiguration]: AZURE_APP_CONFIGURATION_SYNC_LIST_OPTION, - [SecretSync.Databricks]: DATABRICKS_SYNC_LIST_OPTION + [SecretSync.Databricks]: DATABRICKS_SYNC_LIST_OPTION, + [SecretSync.Humanitec]: HUMANITEC_SYNC_LIST_OPTION }; export const listSecretSyncOptions = () => { @@ -116,6 +119,8 @@ export const SecretSyncFns = { appConnectionDAL, kmsService }).syncSecrets(secretSync, secretMap); + case SecretSync.Humanitec: + return HumanitecSyncFns.syncSecrets(secretSync, secretMap); default: throw new Error( `Unhandled sync destination for sync secrets fns: ${(secretSync as TSecretSyncWithCredentials).destination}` @@ -157,6 +162,9 @@ export const SecretSyncFns = { appConnectionDAL, kmsService }).getSecrets(secretSync); + case SecretSync.Humanitec: + secretMap = await HumanitecSyncFns.getSecrets(secretSync); + break; default: throw new Error( `Unhandled sync destination for get secrets fns: ${(secretSync as TSecretSyncWithCredentials).destination}` @@ -197,6 +205,8 @@ export const SecretSyncFns = { appConnectionDAL, kmsService }).removeSecrets(secretSync, secretMap); + case SecretSync.Humanitec: + return HumanitecSyncFns.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 92546706d..cd4125e1b 100644 --- a/backend/src/services/secret-sync/secret-sync-maps.ts +++ b/backend/src/services/secret-sync/secret-sync-maps.ts @@ -8,7 +8,8 @@ export const SECRET_SYNC_NAME_MAP: Record = { [SecretSync.GCPSecretManager]: "GCP Secret Manager", [SecretSync.AzureKeyVault]: "Azure Key Vault", [SecretSync.AzureAppConfiguration]: "Azure App Configuration", - [SecretSync.Databricks]: "Databricks" + [SecretSync.Databricks]: "Databricks", + [SecretSync.Humanitec]: "Humanitec" }; export const SECRET_SYNC_CONNECTION_MAP: Record = { @@ -18,5 +19,6 @@ export const SECRET_SYNC_CONNECTION_MAP: Record = { [SecretSync.GCPSecretManager]: AppConnection.GCP, [SecretSync.AzureKeyVault]: AppConnection.AzureKeyVault, [SecretSync.AzureAppConfiguration]: AppConnection.AzureAppConfiguration, - [SecretSync.Databricks]: AppConnection.Databricks + [SecretSync.Databricks]: AppConnection.Databricks, + [SecretSync.Humanitec]: AppConnection.Humanitec }; diff --git a/backend/src/services/secret-sync/secret-sync-types.ts b/backend/src/services/secret-sync/secret-sync-types.ts index 60742e797..2044c7c17 100644 --- a/backend/src/services/secret-sync/secret-sync-types.ts +++ b/backend/src/services/secret-sync/secret-sync-types.ts @@ -43,6 +43,12 @@ import { TAzureKeyVaultSyncWithCredentials } from "./azure-key-vault"; import { TGcpSync, TGcpSyncInput, TGcpSyncListItem, TGcpSyncWithCredentials } from "./gcp"; +import { + THumanitecSync, + THumanitecSyncInput, + THumanitecSyncListItem, + THumanitecSyncWithCredentials +} from "./humanitec"; export type TSecretSync = | TAwsParameterStoreSync @@ -51,7 +57,8 @@ export type TSecretSync = | TGcpSync | TAzureKeyVaultSync | TAzureAppConfigurationSync - | TDatabricksSync; + | TDatabricksSync + | THumanitecSync; export type TSecretSyncWithCredentials = | TAwsParameterStoreSyncWithCredentials @@ -60,7 +67,8 @@ export type TSecretSyncWithCredentials = | TGcpSyncWithCredentials | TAzureKeyVaultSyncWithCredentials | TAzureAppConfigurationSyncWithCredentials - | TDatabricksSyncWithCredentials; + | TDatabricksSyncWithCredentials + | THumanitecSyncWithCredentials; export type TSecretSyncInput = | TAwsParameterStoreSyncInput @@ -69,7 +77,8 @@ export type TSecretSyncInput = | TGcpSyncInput | TAzureKeyVaultSyncInput | TAzureAppConfigurationSyncInput - | TDatabricksSyncInput; + | TDatabricksSyncInput + | THumanitecSyncInput; export type TSecretSyncListItem = | TAwsParameterStoreSyncListItem @@ -78,7 +87,8 @@ export type TSecretSyncListItem = | TGcpSyncListItem | TAzureKeyVaultSyncListItem | TAzureAppConfigurationSyncListItem - | TDatabricksSyncListItem; + | TDatabricksSyncListItem + | THumanitecSyncListItem; export type TSyncOptionsConfig = { canImportSecrets: boolean; diff --git a/frontend/public/images/integrations/Humanitec.png b/frontend/public/images/integrations/Humanitec.png new file mode 100644 index 000000000..7f763d359 Binary files /dev/null and b/frontend/public/images/integrations/Humanitec.png differ diff --git a/frontend/src/components/secret-syncs/forms/SecretSyncDestinationFields/HumanitecSyncFields.tsx b/frontend/src/components/secret-syncs/forms/SecretSyncDestinationFields/HumanitecSyncFields.tsx new file mode 100644 index 000000000..e1b05c606 --- /dev/null +++ b/frontend/src/components/secret-syncs/forms/SecretSyncDestinationFields/HumanitecSyncFields.tsx @@ -0,0 +1,125 @@ +import { Controller, useFormContext, useWatch } from "react-hook-form"; +import { SingleValue } from "react-select"; + +import { SecretSyncConnectionField } from "@app/components/secret-syncs/forms/SecretSyncConnectionField"; +import { FilterableSelect, FormControl } from "@app/components/v2"; +import { + THumanitecConnectionApp, + useHumanitecConnectionListOrganizations +} from "@app/hooks/api/appConnections/humanitec"; +import { SecretSync } from "@app/hooks/api/secretSyncs"; + +import { TSecretSyncForm } from "../schemas"; + +export const HumanitecSyncFields = () => { + const { control, watch, setValue } = useFormContext< + TSecretSyncForm & { destination: SecretSync.Humanitec } + >(); + + const connectionId = useWatch({ name: "connection.id", control }); + const currentOrg = watch("destinationConfig.org"); + const currentApp = watch("destinationConfig.app"); + + const { data: organizations = [], isPending: isOrganizationsPending } = + useHumanitecConnectionListOrganizations(connectionId, { + enabled: Boolean(connectionId) + }); + return ( + <> + { + setValue("destinationConfig.org", ""); + setValue("destinationConfig.app", ""); + }} + /> + ( + + org.id === value) ?? []) : []} + onChange={(option) => + onChange((option as SingleValue)?.id ?? null) + } + options={organizations} + placeholder="Select an organization..." + getOptionLabel={(option) => option.name} + getOptionValue={(option) => option.id.toString()} + /> + + )} + /> + ( + + org.id === currentOrg) + ?.apps?.find((app) => app.id === value) ?? null + } + onChange={(option) => + onChange((option as SingleValue)?.id ?? null) + } + options={ + currentOrg ? (organizations.find((org) => org.id === currentOrg)?.apps ?? []) : [] + } + placeholder="Select an app..." + getOptionLabel={(option) => option.name} + getOptionValue={(option) => option.id.toString()} + /> + + )} + /> + ( + + org.id === currentOrg) + ?.apps?.find((app) => app.id === currentApp) + ?.envs?.find((env) => env.id === value) ?? null + } + onChange={(option) => + onChange((option as SingleValue)?.id ?? null) + } + options={ + currentApp + ? ((organizations.find((org) => org.id === currentOrg)?.apps ?? [])?.find( + (app) => app.id === currentApp + )?.envs ?? []) + : [] + } + placeholder="Select an env..." + getOptionLabel={(option) => option.name} + getOptionValue={(option) => option.id.toString()} + /> + + )} + /> + + ); +}; diff --git a/frontend/src/components/secret-syncs/forms/SecretSyncDestinationFields/SecretSyncDestinationFields.tsx b/frontend/src/components/secret-syncs/forms/SecretSyncDestinationFields/SecretSyncDestinationFields.tsx index fa1aaa84c..973f8bf17 100644 --- a/frontend/src/components/secret-syncs/forms/SecretSyncDestinationFields/SecretSyncDestinationFields.tsx +++ b/frontend/src/components/secret-syncs/forms/SecretSyncDestinationFields/SecretSyncDestinationFields.tsx @@ -10,6 +10,7 @@ import { AzureKeyVaultSyncFields } from "./AzureKeyVaultSyncFields"; import { DatabricksSyncFields } from "./DatabricksSyncFields"; import { GcpSyncFields } from "./GcpSyncFields"; import { GitHubSyncFields } from "./GitHubSyncFields"; +import { HumanitecSyncFields } from "./HumanitecSyncFields"; export const SecretSyncDestinationFields = () => { const { watch } = useFormContext(); @@ -31,6 +32,8 @@ export const SecretSyncDestinationFields = () => { return ; case SecretSync.Databricks: return ; + case SecretSync.Humanitec: + return ; default: throw new Error(`Unhandled Destination Config Field: ${destination}`); } diff --git a/frontend/src/components/secret-syncs/forms/SecretSyncOptionsFields/SecretSyncOptionsFields.tsx b/frontend/src/components/secret-syncs/forms/SecretSyncOptionsFields/SecretSyncOptionsFields.tsx index ad9a26408..d3213caf7 100644 --- a/frontend/src/components/secret-syncs/forms/SecretSyncOptionsFields/SecretSyncOptionsFields.tsx +++ b/frontend/src/components/secret-syncs/forms/SecretSyncOptionsFields/SecretSyncOptionsFields.tsx @@ -38,6 +38,7 @@ export const SecretSyncOptionsFields = ({ hideInitialSync }: Props) => { case SecretSync.AzureKeyVault: case SecretSync.AzureAppConfiguration: case SecretSync.Databricks: + case SecretSync.Humanitec: AdditionalSyncOptionsFieldsComponent = null; break; default: diff --git a/frontend/src/components/secret-syncs/forms/SecretSyncReviewFields/HumanitecSyncReviewFields.tsx b/frontend/src/components/secret-syncs/forms/SecretSyncReviewFields/HumanitecSyncReviewFields.tsx new file mode 100644 index 000000000..5ae98e232 --- /dev/null +++ b/frontend/src/components/secret-syncs/forms/SecretSyncReviewFields/HumanitecSyncReviewFields.tsx @@ -0,0 +1,20 @@ +import { useFormContext } from "react-hook-form"; + +import { SecretSyncLabel } from "@app/components/secret-syncs"; +import { TSecretSyncForm } from "@app/components/secret-syncs/forms/schemas"; +import { SecretSync } from "@app/hooks/api/secretSyncs"; + +export const HumanitecSyncReviewFields = () => { + const { watch } = useFormContext(); + const orgId = watch("destinationConfig.org"); + const appId = watch("destinationConfig.app"); + const envId = watch("destinationConfig.env"); + + return ( + <> + {orgId} + {appId} + {envId} + + ); +}; diff --git a/frontend/src/components/secret-syncs/forms/SecretSyncReviewFields/SecretSyncReviewFields.tsx b/frontend/src/components/secret-syncs/forms/SecretSyncReviewFields/SecretSyncReviewFields.tsx index 2846433ec..4004de105 100644 --- a/frontend/src/components/secret-syncs/forms/SecretSyncReviewFields/SecretSyncReviewFields.tsx +++ b/frontend/src/components/secret-syncs/forms/SecretSyncReviewFields/SecretSyncReviewFields.tsx @@ -20,6 +20,7 @@ import { AzureKeyVaultSyncReviewFields } from "./AzureKeyVaultSyncReviewFields"; import { DatabricksSyncReviewFields } from "./DatabricksSyncReviewFields"; import { GcpSyncReviewFields } from "./GcpSyncReviewFields"; import { GitHubSyncReviewFields } from "./GitHubSyncReviewFields"; +import { HumanitecSyncReviewFields } from "./HumanitecSyncReviewFields"; export const SecretSyncReviewFields = () => { const { watch } = useFormContext(); @@ -67,6 +68,9 @@ export const SecretSyncReviewFields = () => { case SecretSync.Databricks: DestinationFieldsComponent = ; break; + case SecretSync.Humanitec: + DestinationFieldsComponent = ; + break; default: throw new Error(`Unhandled Destination Review Fields: ${destination}`); } diff --git a/frontend/src/components/secret-syncs/forms/schemas/humanitec-sync-destination-schema.ts b/frontend/src/components/secret-syncs/forms/schemas/humanitec-sync-destination-schema.ts new file mode 100644 index 000000000..d70d3318d --- /dev/null +++ b/frontend/src/components/secret-syncs/forms/schemas/humanitec-sync-destination-schema.ts @@ -0,0 +1,15 @@ +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 HumanitecSyncDestinationSchema = BaseSecretSyncSchema().merge( + z.object({ + destination: z.literal(SecretSync.Humanitec), + destinationConfig: z.object({ + org: z.string().trim().min(1, "Organization required"), + app: z.string().trim().min(1, "App required"), + env: z.string().trim().min(1, "Environment 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 55ee6cb3d..be2322304 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 @@ -8,6 +8,7 @@ import { AwsParameterStoreSyncDestinationSchema } from "./aws-parameter-store-sy import { AzureAppConfigurationSyncDestinationSchema } from "./azure-app-configuration-sync-destination-schema"; import { AzureKeyVaultSyncDestinationSchema } from "./azure-key-vault-sync-destination-schema"; import { GcpSyncDestinationSchema } from "./gcp-sync-destination-schema"; +import { HumanitecSyncDestinationSchema } from "./humanitec-sync-destination-schema"; const SecretSyncUnionSchema = z.discriminatedUnion("destination", [ AwsParameterStoreSyncDestinationSchema, @@ -16,7 +17,8 @@ const SecretSyncUnionSchema = z.discriminatedUnion("destination", [ GcpSyncDestinationSchema, AzureKeyVaultSyncDestinationSchema, AzureAppConfigurationSyncDestinationSchema, - DatabricksSyncDestinationSchema + DatabricksSyncDestinationSchema, + HumanitecSyncDestinationSchema ]); export const SecretSyncFormSchema = SecretSyncUnionSchema; diff --git a/frontend/src/helpers/appConnections.ts b/frontend/src/helpers/appConnections.ts index 016a86264..683626e26 100644 --- a/frontend/src/helpers/appConnections.ts +++ b/frontend/src/helpers/appConnections.ts @@ -11,6 +11,7 @@ import { TAppConnection } from "@app/hooks/api/appConnections/types"; import { DatabricksConnectionMethod } from "@app/hooks/api/appConnections/types/databricks-connection"; +import { HumanitecConnectionMethod } from "@app/hooks/api/appConnections/types/humanitec-connection"; export const APP_CONNECTION_MAP: Record = { [AppConnection.AWS]: { name: "AWS", image: "Amazon Web Services.png" }, @@ -24,7 +25,8 @@ export const APP_CONNECTION_MAP: Record { @@ -43,6 +45,8 @@ export const getAppConnectionMethodDetails = (method: TAppConnection["method"]) return { name: "Service Account Impersonation", icon: faUser }; case DatabricksConnectionMethod.ServicePrincipal: return { name: "Service Principal", icon: faUser }; + case HumanitecConnectionMethod.AccessKey: + return { name: "Access Key", 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 b119983a0..9f94e3c9d 100644 --- a/frontend/src/helpers/secretSyncs.ts +++ b/frontend/src/helpers/secretSyncs.ts @@ -18,6 +18,10 @@ export const SECRET_SYNC_MAP: Record = { [SecretSync.GCPSecretManager]: AppConnection.GCP, [SecretSync.AzureKeyVault]: AppConnection.AzureKeyVault, [SecretSync.AzureAppConfiguration]: AppConnection.AzureAppConfiguration, - [SecretSync.Databricks]: AppConnection.Databricks + [SecretSync.Databricks]: AppConnection.Databricks, + [SecretSync.Humanitec]: AppConnection.Humanitec }; 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 97fcf9be6..c07808af9 100644 --- a/frontend/src/hooks/api/appConnections/enums.ts +++ b/frontend/src/hooks/api/appConnections/enums.ts @@ -4,5 +4,6 @@ export enum AppConnection { GCP = "gcp", AzureKeyVault = "azure-key-vault", AzureAppConfiguration = "azure-app-configuration", - Databricks = "databricks" + Databricks = "databricks", + Humanitec = "humanitec" } diff --git a/frontend/src/hooks/api/appConnections/humanitec/index.ts b/frontend/src/hooks/api/appConnections/humanitec/index.ts new file mode 100644 index 000000000..2c1906d36 --- /dev/null +++ b/frontend/src/hooks/api/appConnections/humanitec/index.ts @@ -0,0 +1,2 @@ +export * from "./queries"; +export * from "./types"; diff --git a/frontend/src/hooks/api/appConnections/humanitec/queries.tsx b/frontend/src/hooks/api/appConnections/humanitec/queries.tsx new file mode 100644 index 000000000..d53913e37 --- /dev/null +++ b/frontend/src/hooks/api/appConnections/humanitec/queries.tsx @@ -0,0 +1,37 @@ +import { useQuery, UseQueryOptions } from "@tanstack/react-query"; + +import { apiRequest } from "@app/config/request"; + +import { appConnectionKeys } from "../queries"; +import { THumanitecOrganization } from "./types"; + +const humanitecConnectionKeys = { + all: [...appConnectionKeys.all, "humanitec"] as const, + listOrganizations: (connectionId: string) => + [...humanitecConnectionKeys.all, "organizations", connectionId] as const +}; + +export const useHumanitecConnectionListOrganizations = ( + connectionId: string, + options?: Omit< + UseQueryOptions< + THumanitecOrganization[], + unknown, + THumanitecOrganization[], + ReturnType + >, + "queryKey" | "queryFn" + > +) => { + return useQuery({ + queryKey: humanitecConnectionKeys.listOrganizations(connectionId), + queryFn: async () => { + const { data } = await apiRequest.get( + `/api/v1/app-connections/humanitec/${connectionId}/organizations` + ); + + return data; + }, + ...options + }); +}; diff --git a/frontend/src/hooks/api/appConnections/humanitec/types.ts b/frontend/src/hooks/api/appConnections/humanitec/types.ts new file mode 100644 index 000000000..22b85f77d --- /dev/null +++ b/frontend/src/hooks/api/appConnections/humanitec/types.ts @@ -0,0 +1,15 @@ +export type THumanitecOrganization = { + name: string; + id: string; + apps: THumanitecApp[]; +}; + +export type THumanitecApp = { + id: string; + name: string; + envs: { id: string; name: string }[]; +}; + +export type THumanitecConnectionApp = { + id: string; +}; diff --git a/frontend/src/hooks/api/appConnections/types/app-options.ts b/frontend/src/hooks/api/appConnections/types/app-options.ts index e42593c94..063dcbc80 100644 --- a/frontend/src/hooks/api/appConnections/types/app-options.ts +++ b/frontend/src/hooks/api/appConnections/types/app-options.ts @@ -34,6 +34,10 @@ export type TDatabricksConnectionOption = TAppConnectionOptionBase & { app: AppConnection.Databricks; }; +export type THumanitecConnectionOption = TAppConnectionOptionBase & { + app: AppConnection.Humanitec; +}; + export type TAppConnectionOption = | TAwsConnectionOption | TGitHubConnectionOption @@ -49,4 +53,5 @@ export type TAppConnectionOptionMap = { [AppConnection.AzureKeyVault]: TAzureKeyVaultConnectionOption; [AppConnection.AzureAppConfiguration]: TAzureAppConfigurationConnectionOption; [AppConnection.Databricks]: TDatabricksConnectionOption; + [AppConnection.Humanitec]: THumanitecConnectionOption; }; diff --git a/frontend/src/hooks/api/appConnections/types/humanitec-connection.ts b/frontend/src/hooks/api/appConnections/types/humanitec-connection.ts new file mode 100644 index 000000000..b604b8694 --- /dev/null +++ b/frontend/src/hooks/api/appConnections/types/humanitec-connection.ts @@ -0,0 +1,13 @@ +import { AppConnection } from "@app/hooks/api/appConnections/enums"; +import { TRootAppConnection } from "@app/hooks/api/appConnections/types/root-connection"; + +export enum HumanitecConnectionMethod { + AccessKey = "access-key" +} + +export type THumanitecConnection = TRootAppConnection & { app: AppConnection.Humanitec } & { + method: HumanitecConnectionMethod.AccessKey; + credentials: { + accessKeyId: string; + }; +}; diff --git a/frontend/src/hooks/api/appConnections/types/index.ts b/frontend/src/hooks/api/appConnections/types/index.ts index a2a1b6792..db56839fc 100644 --- a/frontend/src/hooks/api/appConnections/types/index.ts +++ b/frontend/src/hooks/api/appConnections/types/index.ts @@ -3,6 +3,7 @@ import { TAppConnectionOption } from "@app/hooks/api/appConnections/types/app-op import { TAwsConnection } from "@app/hooks/api/appConnections/types/aws-connection"; import { TDatabricksConnection } from "@app/hooks/api/appConnections/types/databricks-connection"; import { TGitHubConnection } from "@app/hooks/api/appConnections/types/github-connection"; +import { THumanitecConnection } from "@app/hooks/api/appConnections/types/humanitec-connection"; import { TAzureAppConfigurationConnection } from "./azure-app-configuration-connection"; import { TAzureKeyVaultConnection } from "./azure-key-vault-connection"; @@ -13,6 +14,7 @@ export * from "./azure-app-configuration-connection"; export * from "./azure-key-vault-connection"; export * from "./gcp-connection"; export * from "./github-connection"; +export * from "./humanitec-connection"; export type TAppConnection = | TAwsConnection @@ -20,7 +22,8 @@ export type TAppConnection = | TGcpConnection | TAzureKeyVaultConnection | TAzureAppConfigurationConnection - | TDatabricksConnection; + | TDatabricksConnection + | THumanitecConnection; export type TAvailableAppConnection = Pick; @@ -54,4 +57,5 @@ export type TAppConnectionMap = { [AppConnection.AzureKeyVault]: TAzureKeyVaultConnection; [AppConnection.AzureAppConfiguration]: TAzureAppConfigurationConnection; [AppConnection.Databricks]: TDatabricksConnection; + [AppConnection.Humanitec]: THumanitecConnection; }; diff --git a/frontend/src/hooks/api/secretSyncs/enums.ts b/frontend/src/hooks/api/secretSyncs/enums.ts index c942c388d..08accba16 100644 --- a/frontend/src/hooks/api/secretSyncs/enums.ts +++ b/frontend/src/hooks/api/secretSyncs/enums.ts @@ -5,7 +5,8 @@ export enum SecretSync { GCPSecretManager = "gcp-secret-manager", AzureKeyVault = "azure-key-vault", AzureAppConfiguration = "azure-app-configuration", - Databricks = "databricks" + Databricks = "databricks", + Humanitec = "humanitec" } export enum SecretSyncStatus { diff --git a/frontend/src/hooks/api/secretSyncs/types/humanitec-sync.ts b/frontend/src/hooks/api/secretSyncs/types/humanitec-sync.ts new file mode 100644 index 000000000..dc22cf3e0 --- /dev/null +++ b/frontend/src/hooks/api/secretSyncs/types/humanitec-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 THumanitecSync = TRootSecretSync & { + destination: SecretSync.Humanitec; + destinationConfig: { + org: string; + app: string; + }; + connection: { + app: AppConnection.Humanitec; + 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 e797c6458..a90a8a3ef 100644 --- a/frontend/src/hooks/api/secretSyncs/types/index.ts +++ b/frontend/src/hooks/api/secretSyncs/types/index.ts @@ -8,6 +8,7 @@ import { TAwsSecretsManagerSync } from "./aws-secrets-manager-sync"; import { TAzureAppConfigurationSync } from "./azure-app-configuration-sync"; import { TAzureKeyVaultSync } from "./azure-key-vault-sync"; import { TGcpSync } from "./gcp-sync"; +import { THumanitecSync } from "./humanitec-sync"; export type TSecretSyncOption = { name: string; @@ -22,7 +23,8 @@ export type TSecretSync = | TGcpSync | TAzureKeyVaultSync | TAzureAppConfigurationSync - | TDatabricksSync; + | TDatabricksSync + | THumanitecSync; export type TListSecretSyncs = { secretSyncs: TSecretSync[] }; diff --git a/frontend/src/pages/organization/AppConnections/AppConnectionsPage/components/AppConnectionForm/AppConnectionForm.tsx b/frontend/src/pages/organization/AppConnections/AppConnectionsPage/components/AppConnectionForm/AppConnectionForm.tsx index c84b20885..fc3257841 100644 --- a/frontend/src/pages/organization/AppConnections/AppConnectionsPage/components/AppConnectionForm/AppConnectionForm.tsx +++ b/frontend/src/pages/organization/AppConnections/AppConnectionsPage/components/AppConnectionForm/AppConnectionForm.tsx @@ -15,6 +15,7 @@ import { AzureKeyVaultConnectionForm } from "./AzureKeyVaultConnectionForm"; import { DatabricksConnectionForm } from "./DatabricksConnectionForm"; import { GcpConnectionForm } from "./GcpConnectionForm"; import { GitHubConnectionForm } from "./GitHubConnectionForm"; +import { HumanitecConnectionForm } from "./HumanitecConnectionForm"; type FormProps = { onComplete: (appConnection: TAppConnection) => void; @@ -62,6 +63,8 @@ const CreateForm = ({ app, onComplete }: CreateFormProps) => { return ; case AppConnection.Databricks: return ; + case AppConnection.Humanitec: + return ; default: throw new Error(`Unhandled App ${app}`); } @@ -107,6 +110,8 @@ const UpdateForm = ({ appConnection, onComplete }: UpdateFormProps) => { return ; case AppConnection.Databricks: return ; + case AppConnection.Humanitec: + return ; default: throw new Error(`Unhandled App ${(appConnection as TAppConnection).app}`); } diff --git a/frontend/src/pages/organization/AppConnections/AppConnectionsPage/components/AppConnectionForm/HumanitecConnectionForm.tsx b/frontend/src/pages/organization/AppConnections/AppConnectionsPage/components/AppConnectionForm/HumanitecConnectionForm.tsx new file mode 100644 index 000000000..a3c9eb4a7 --- /dev/null +++ b/frontend/src/pages/organization/AppConnections/AppConnectionsPage/components/AppConnectionForm/HumanitecConnectionForm.tsx @@ -0,0 +1,132 @@ +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 { HumanitecConnectionMethod, THumanitecConnection } from "@app/hooks/api/appConnections"; +import { AppConnection } from "@app/hooks/api/appConnections/enums"; + +import { + genericAppConnectionFieldsSchema, + GenericAppConnectionsFields +} from "./GenericAppConnectionFields"; + +type Props = { + appConnection?: THumanitecConnection; + onSubmit: (formData: FormData) => void; +}; + +const rootSchema = genericAppConnectionFieldsSchema.extend({ + app: z.literal(AppConnection.Humanitec) +}); + +const formSchema = z.discriminatedUnion("method", [ + rootSchema.extend({ + method: z.literal(HumanitecConnectionMethod.AccessKey), + credentials: z.object({ + accessKeyId: z.string().trim().min(1, "Service API Token required") + }) + }) +]); + +type FormData = z.infer; + +export const HumanitecConnectionForm = ({ appConnection, onSubmit }: Props) => { + const isUpdate = Boolean(appConnection); + + const form = useForm({ + resolver: zodResolver(formSchema), + defaultValues: appConnection ?? { + app: AppConnection.Humanitec, + method: HumanitecConnectionMethod.AccessKey + } + }); + + 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/HumanitecSyncDestinationCol.tsx b/frontend/src/pages/secret-manager/IntegrationsListPage/components/SecretSyncsTab/SecretSyncTable/SecretSyncDestinationCol/HumanitecSyncDestinationCol.tsx new file mode 100644 index 000000000..b0fffebfe --- /dev/null +++ b/frontend/src/pages/secret-manager/IntegrationsListPage/components/SecretSyncsTab/SecretSyncTable/SecretSyncDestinationCol/HumanitecSyncDestinationCol.tsx @@ -0,0 +1,14 @@ +import { THumanitecSync } from "@app/hooks/api/secretSyncs/types/humanitec-sync"; + +import { getSecretSyncDestinationColValues } from "../helpers"; +import { SecretSyncTableCell } from "../SecretSyncTableCell"; + +type Props = { + secretSync: THumanitecSync; +}; + +export const HumanitecSyncDestinationCol = ({ 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 2c375a8b5..c4dc564b0 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 @@ -7,6 +7,7 @@ import { AzureKeyVaultDestinationSyncCol } from "./AzureKeyVaultDestinationSyncC import { DatabricksSyncDestinationCol } from "./DatabricksSyncDestinationCol"; import { GcpSyncDestinationCol } from "./GcpSyncDestinationCol"; import { GitHubSyncDestinationCol } from "./GitHubSyncDestinationCol"; +import { HumanitecSyncDestinationCol } from "./HumanitecSyncDestinationCol"; type Props = { secretSync: TSecretSync; @@ -28,6 +29,8 @@ export const SecretSyncDestinationCol = ({ secretSync }: Props) => { return ; case SecretSync.Databricks: return ; + case SecretSync.Humanitec: + return ; default: throw new Error( `Unhandled Secret Sync Destination Col: ${(secretSync as TSecretSync).destination}` diff --git a/frontend/src/pages/secret-manager/IntegrationsListPage/components/SecretSyncsTab/SecretSyncTable/helpers/index.ts b/frontend/src/pages/secret-manager/IntegrationsListPage/components/SecretSyncsTab/SecretSyncTable/helpers/index.ts index f1087c03c..f295b964c 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 @@ -59,6 +59,10 @@ export const getSecretSyncDestinationColValues = (secretSync: TSecretSync) => { case SecretSync.Databricks: primaryText = destinationConfig.scope; break; + case SecretSync.Humanitec: + primaryText = destinationConfig.app; + secondaryText = `Org - ${destinationConfig.org}`; + break; default: throw new Error(`Unhandled Destination Col Values ${destination}`); } diff --git a/frontend/src/pages/secret-manager/SecretSyncDetailsByIDPage/components/SecretSyncDestinationSection/HumanitecSyncDestinationSection.tsx b/frontend/src/pages/secret-manager/SecretSyncDetailsByIDPage/components/SecretSyncDestinationSection/HumanitecSyncDestinationSection.tsx new file mode 100644 index 000000000..ce80b14ec --- /dev/null +++ b/frontend/src/pages/secret-manager/SecretSyncDetailsByIDPage/components/SecretSyncDestinationSection/HumanitecSyncDestinationSection.tsx @@ -0,0 +1,19 @@ +import { SecretSyncLabel } from "@app/components/secret-syncs"; +import { THumanitecSync } from "@app/hooks/api/secretSyncs/types/humanitec-sync"; + +type Props = { + secretSync: THumanitecSync; +}; + +export const HumanitecSyncDestinationSection = ({ secretSync }: Props) => { + const { + destinationConfig: { app, org } + } = secretSync; + + return ( + <> + {app} + {org} + + ); +}; 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 b7d3964c7..c1dee10c2 100644 --- a/frontend/src/pages/secret-manager/SecretSyncDetailsByIDPage/components/SecretSyncDestinationSection/SecretSyncDestinatonSection.tsx +++ b/frontend/src/pages/secret-manager/SecretSyncDetailsByIDPage/components/SecretSyncDestinationSection/SecretSyncDestinatonSection.tsx @@ -17,6 +17,7 @@ import { GitHubSyncDestinationSection } from "@app/pages/secret-manager/SecretSy import { AzureAppConfigurationSyncDestinationSection } from "./AzureAppConfigurationSyncDestinationSection"; import { AzureKeyVaultSyncDestinationSection } from "./AzureKeyVaultSyncDestinationSection"; import { GcpSyncDestinationSection } from "./GcpSyncDestinationSection"; +import { HumanitecSyncDestinationSection } from "./HumanitecSyncDestinationSection"; type Props = { secretSync: TSecretSync; @@ -53,6 +54,9 @@ export const SecretSyncDestinationSection = ({ secretSync, onEditDestination }: case SecretSync.Databricks: DestinationComponents = ; break; + case SecretSync.Humanitec: + DestinationComponents = ; + break; default: throw new Error(`Unhandled Destination Section components: ${destination}`); } diff --git a/frontend/src/pages/secret-manager/SecretSyncDetailsByIDPage/components/SecretSyncOptionsSection/SecretSyncOptionsSection.tsx b/frontend/src/pages/secret-manager/SecretSyncDetailsByIDPage/components/SecretSyncOptionsSection/SecretSyncOptionsSection.tsx index df6927b48..be48428ea 100644 --- a/frontend/src/pages/secret-manager/SecretSyncDetailsByIDPage/components/SecretSyncOptionsSection/SecretSyncOptionsSection.tsx +++ b/frontend/src/pages/secret-manager/SecretSyncDetailsByIDPage/components/SecretSyncOptionsSection/SecretSyncOptionsSection.tsx @@ -46,6 +46,7 @@ export const SecretSyncOptionsSection = ({ secretSync, onEditOptions }: Props) = case SecretSync.AzureKeyVault: case SecretSync.AzureAppConfiguration: case SecretSync.Databricks: + case SecretSync.Humanitec: AdditionalSyncOptionsComponent = null; break; default: