From 79d8a9debb75a1a599ada18b2bce4ecb7879f7cc Mon Sep 17 00:00:00 2001 From: carlosmonastyrski Date: Wed, 12 Mar 2025 16:23:59 -0300 Subject: [PATCH] Add Humanitec secret sync integration --- backend/src/lib/api-docs/constants.ts | 5 + backend/src/lib/config/env.ts | 3 + .../app-connection-router.ts | 10 +- .../humanitec-connection-router.ts | 69 ++++++++ .../routes/v1/app-connection-routers/index.ts | 4 +- .../humanitec-sync-router.ts | 17 ++ .../routes/v1/secret-sync-routers/index.ts | 4 +- .../secret-sync-routers/secret-sync-router.ts | 7 +- .../app-connection/app-connection-enums.ts | 3 +- .../app-connection/app-connection-fns.ts | 12 +- .../app-connection/app-connection-maps.ts | 3 +- .../app-connection/app-connection-service.ts | 8 +- .../app-connection/app-connection-types.ts | 14 +- .../humanitec/humanitec-connection-enums.ts | 3 + .../humanitec/humanitec-connection-fns.ts | 101 +++++++++++ .../humanitec/humanitec-connection-schemas.ts | 58 +++++++ .../humanitec/humanitec-connection-service.ts | 27 +++ .../humanitec/humanitec-connection-types.ts | 40 +++++ .../app-connection/humanitec/index.ts | 4 + .../integration-auth/integration-list.ts | 1 + .../humanitec/humanitec-sync-constants.ts | 10 ++ .../humanitec/humanitec-sync-fns.ts | 160 ++++++++++++++++++ .../humanitec/humanitec-sync-schemas.ts | 45 +++++ .../humanitec/humanitec-sync-types.ts | 23 +++ .../services/secret-sync/humanitec/index.ts | 4 + .../services/secret-sync/secret-sync-enums.ts | 3 +- .../services/secret-sync/secret-sync-fns.ts | 12 +- .../services/secret-sync/secret-sync-maps.ts | 6 +- .../services/secret-sync/secret-sync-types.ts | 18 +- .../public/images/integrations/Humanitec.png | Bin 0 -> 19943 bytes .../HumanitecSyncFields.tsx | 125 ++++++++++++++ .../SecretSyncDestinationFields.tsx | 3 + .../SecretSyncOptionsFields.tsx | 1 + .../HumanitecSyncReviewFields.tsx | 20 +++ .../SecretSyncReviewFields.tsx | 4 + .../humanitec-sync-destination-schema.ts | 15 ++ .../forms/schemas/secret-sync-schema.ts | 4 +- frontend/src/helpers/appConnections.ts | 6 +- frontend/src/helpers/secretSyncs.ts | 7 +- .../src/hooks/api/appConnections/enums.ts | 3 +- .../api/appConnections/humanitec/index.ts | 2 + .../api/appConnections/humanitec/queries.tsx | 37 ++++ .../api/appConnections/humanitec/types.ts | 15 ++ .../api/appConnections/types/app-options.ts | 5 + .../types/humanitec-connection.ts | 13 ++ .../hooks/api/appConnections/types/index.ts | 6 +- frontend/src/hooks/api/secretSyncs/enums.ts | 3 +- .../api/secretSyncs/types/humanitec-sync.ts | 16 ++ .../src/hooks/api/secretSyncs/types/index.ts | 4 +- .../AppConnectionForm/AppConnectionForm.tsx | 5 + .../HumanitecConnectionForm.tsx | 132 +++++++++++++++ .../HumanitecSyncDestinationCol.tsx | 14 ++ .../SecretSyncDestinationCol.tsx | 3 + .../SecretSyncTable/helpers/index.ts | 4 + .../HumanitecSyncDestinationSection.tsx | 19 +++ .../SecretSyncDestinatonSection.tsx | 4 + .../SecretSyncOptionsSection.tsx | 1 + 57 files changed, 1117 insertions(+), 28 deletions(-) create mode 100644 backend/src/server/routes/v1/app-connection-routers/humanitec-connection-router.ts create mode 100644 backend/src/server/routes/v1/secret-sync-routers/humanitec-sync-router.ts create mode 100644 backend/src/services/app-connection/humanitec/humanitec-connection-enums.ts create mode 100644 backend/src/services/app-connection/humanitec/humanitec-connection-fns.ts create mode 100644 backend/src/services/app-connection/humanitec/humanitec-connection-schemas.ts create mode 100644 backend/src/services/app-connection/humanitec/humanitec-connection-service.ts create mode 100644 backend/src/services/app-connection/humanitec/humanitec-connection-types.ts create mode 100644 backend/src/services/app-connection/humanitec/index.ts create mode 100644 backend/src/services/secret-sync/humanitec/humanitec-sync-constants.ts create mode 100644 backend/src/services/secret-sync/humanitec/humanitec-sync-fns.ts create mode 100644 backend/src/services/secret-sync/humanitec/humanitec-sync-schemas.ts create mode 100644 backend/src/services/secret-sync/humanitec/humanitec-sync-types.ts create mode 100644 backend/src/services/secret-sync/humanitec/index.ts create mode 100644 frontend/public/images/integrations/Humanitec.png create mode 100644 frontend/src/components/secret-syncs/forms/SecretSyncDestinationFields/HumanitecSyncFields.tsx create mode 100644 frontend/src/components/secret-syncs/forms/SecretSyncReviewFields/HumanitecSyncReviewFields.tsx create mode 100644 frontend/src/components/secret-syncs/forms/schemas/humanitec-sync-destination-schema.ts create mode 100644 frontend/src/hooks/api/appConnections/humanitec/index.ts create mode 100644 frontend/src/hooks/api/appConnections/humanitec/queries.tsx create mode 100644 frontend/src/hooks/api/appConnections/humanitec/types.ts create mode 100644 frontend/src/hooks/api/appConnections/types/humanitec-connection.ts create mode 100644 frontend/src/hooks/api/secretSyncs/types/humanitec-sync.ts create mode 100644 frontend/src/pages/organization/AppConnections/AppConnectionsPage/components/AppConnectionForm/HumanitecConnectionForm.tsx create mode 100644 frontend/src/pages/secret-manager/IntegrationsListPage/components/SecretSyncsTab/SecretSyncTable/SecretSyncDestinationCol/HumanitecSyncDestinationCol.tsx create mode 100644 frontend/src/pages/secret-manager/SecretSyncDetailsByIDPage/components/SecretSyncDestinationSection/HumanitecSyncDestinationSection.tsx 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 0000000000000000000000000000000000000000..7f763d359a6b149b5a5c29f609ddc27c2ca3c24e GIT binary patch literal 19943 zcmY&=WmHt(_xGJ)gh5F`q#3%qd+6?vmTr(ndXN;QOF}_Hy1PfDTe_q}LQqgTp5gmj z{}<1TS+myMbN1b5pM7?I&b-xBSHONu`WOHJY$ZimZ2$lv9zIZX)Gr174PB@&h@F(0 z6adu3Ke@4Z1ORYANmfetHRx9mMzgM5Oe+7uOIvJ5uhyyhHcYb$->r~q@k?eztxu)Kz%u76@?%oQ44O5 z%jG^0gDa`caghA>`6S!}pIiox1q2L*v(I|C+jZZn9%!BnfbRBZ>baT!Dkz`5S{v0;5|WfFkL{>T@Ccm z(jgBQo4NP?NN!zOS;gZ`*>hao*a@St1pk$>G?=N@Jo!~l5$m$}d1 z*hyVQeS2TG`56Xb$iUpD)r>YavqbP-J(O0j^0c5D-S~kd+)K1JmBI)-TS3Jx zq7e~X3m6!~HiH=4LW2yo)1Yn#?5e5f6c2?zqG(NIa0)v-+TcWn%~6}Y(Ih~v-tBQ{ zZ1hiuaEx{6L?nW=PiH1+Bva-YuiWh1F^`#L*Q>ad6RP{nh9&XnOjn1L+T<{Hn zN_!SMW*SApMIc0*8=B!-ZjhVT&Usjn5vE4Ig80JR zXj=2KEvutUq>$tY*6JN1=7D=+u}eU@ZP~KA2n#d=9SQZB`-T~qYcvdHw!?u-*@j&h z!(#*RuUItub-5tR^5$Ysn=#|ojGG8*FQy*!T3n*i?9rb&Y4A<+@b|XEZ7WNt>?6?G zQ-8d5bs=z;Wp|N@AjTTn?>|0w?-Kt?;+_4IdZaRmH}M9EtGQJLJ><4t#Sj~N<_S=u z;hY>t(osqO{OLZKmQ9^Y5=x?ZWF!=-!N*D>@MC_nrw0uJziGgyRM1Ywdw(O2Tuy=# zf&&>D{!py9J!Kk2D=(h`#0JC&e@-QixIxIi9)MQTWY_T5qBo?9vEM&wrys)R!dSCJ z54|9x@qcN;FVy9_E#qVPG%3)@A2rbDZMBvvh>|zR*+FGVLgOuN3X8GXVtAnHORoF?2L7YV zmv4UBQj`BOuO~{SHizDvsgK5VSw%+4Kn<{wAea`@0S)QhLDLQ;#&0j)$CXC4R{b0c zk<}ET+Jzo+B{7;LU(KS4=@vsSTo4VRIrXfirjo43emi&rIj86E;nkE9ewG<~n{$S%cH`$^-`%y|Zja$Fo$N1qT=Z8C!3Dap|?z=w9s%vRkw+)h!|PgklzHdXATB zSNV9Cbe(_Fu2RQ#b~I!v z)=*l%7P|cP543SM;*C~ziGdx~4S^l68`v(7?3hhjj+)pNyWDz37Ft~t4CAjnI*n$A zv?rQzx>qKh?>h=}wLZ?D%66Fqp%xNph;b@ml zU#-Szy$06Z0}3Eu*5GBBCs810U6+)>ZyDzhiRc_PehWMS_J&Ye`#FC}hgJWV4k2s7 zHMf1&ZY)d$7D(?DRtNbfX&4We|2)vS_OaF%X-ixNT zxW@tq_AMLz&x^i_?6UiPEUKC%1k$m7RO2oW&lm}I^Ctb@5Q__cuy>DyTK~xHKm#h9hvh@T=%&|{)F;=*traaB!lTC*#S9%it zk~f&iL<37XY$q3A1c{foh0~#35YWEFOmE#nr&Aj8qWGX1(zM*+6wYA)$5u!e3F;7CV+84mhW zxBibg{8ZZ{?vS~0w+m~MO57q&HPehqPOdRHDzaPiv-G3A?zpA4sJ0(i247ckzp57w%L32}qlw{8qoOIBC#NWcA)L(Z!v z-*H!1ejOoxsQM-n{;{KU_)!r#OZwRXIavrE->vC{FY|^NzB;1rZzg~;uYW9oljGPQ z>G=CS8gj06?x#L}>lF(p)AM^LmxsqRb|D|ICGu|Xcz&0wd#t?uRcMQiV;Nsw9=2`Q zjTuO62uZ>c!~nS9cWBk?)~<&KRKkY!hm3w}EzC8xlw%*Qo|-{~n((utDyb&)j0D!n zBNGYZ@Yk$8#^olj=<;cg0r;IS5Kpc2LDtrHzud;-xJxqeh2+hm#7}vKxe(qIsyK~< zpoHbWMXr;DGRe_~FP)Dxg+iBN;%S-*3^9NWJMj0G0FLX&KlScp`|hJQvj+UAXc2FwyS;yBY4h zTa`r&!%f4p@?Wj%xK$uVd?kz_l=G$>5*_)v(y#KMgOK^5V{qcZFFauk;AHpr;2eIO zJha2jF;c5)Jn3_MAlLrn48&jg-Y_ zM$NXvrSiYd2Pex2vkZNq#F=jmx`?XHbyJJ0nYYrC&-IoHY6;Rjo(Jlp`LKgxZjR6x zk)Na!69$BUgb=i?pKVZz?oV}`U4j~}WOtz>ruJ55-Jj;n=I0t`Iz9Sxt+qxsg;W{- z@;o8B!&f)={>dH@n&<|=LdJG3C8V>s?Dqe*ux>9}yc*I~ZF5EifU)rBsyeq5Ysm#V z#+~Y)(=Mxz41_`_#u-TcQOt6}ZG#aL%rEeG}2-IO-RFN{fCd=_P@S)944f+G z6)apjt^C4}quHkX-yW`&v<~xc1OfC0;$h(q&?ZoS&Y1C} zOM6M~wM%v~EbnxHOW-6slh)y=pS<4miS4;1#T&dqMIpqUR1@QG9N_-$7|wu&=`XK& z|BtL`@L5wA3#X+X2Z0RC8JhiWW`MHomaoLL&e1^i$&Eia)CYjwgP4@YBR})0-C35H z{NClRb+#!MAql5;8bQavx1TJId*51?gK`=rVKrBzGjhe4V!LJNesq)us0AHkx2?)U z|MJ8gt_UCgdCRl)l4`!x#E;NAfKLeO#$Wq+j+{1(^Oz)ZaA00^6CxCf8Y&3cQ?P@FnKvm?KMqrLM>JB)Ck2XmA+`bH3Zc+MvZ ze%SJB@YXmU*fc7;?Wcn9W>-#A%T?G}--pgP8YDl4+2!hKq04X(dF))&KpM>`?K&j* zH}$XVG?U0c06cgU^R-zT4`Sz@$T6l(=}0iH)_TX{M};Y zho{A)05XVj;L~$rFBPVtP}8R6YaH9(Hw8VU9S9@jrPSak z5_P=qy@7wBxqz|ucx+bFaEoAEnjmAa&Pfsl88{M79-8b=$;7$h8#i74wdP}J>cKjM z66?)bDC%(E9|6fP7g!&GrjtY}O**91J3R_UvYTqR=tTukuX_q5_i>~3+x-5dP%F%c zz7n$I#>n>zn-RJ9Q}5lwDc`;Fu^cgPb&SInO zn=ezY^cX3@E>{CXu@3-`b-6R0&P$iC3F+dYJsV%$IaNYSGUb6x!jQgE4R)O>pl2TG z`^RaHS%@`Z;g+RnnC;E&xLP7vjYk;wQPX!OMFWy|-5G;@#elyyX0#-|HdR?MF!4eUjr%*OB@Kw^V z*fVd`X&k?MnM3V0u#83FyW+7b>VZq-#{DqO7q6M*$;fih=AR?kBiHgeJaL7`uY3hF z4lU;{u+528p@f&7(oJPGnr(?8J`v+b0+3oc#4+(If2Sviz=O0!Ew#85w6=QB4UclV zDxG9CjdS-prf-T%-tEIUlhG47J_3f{*iH?C9Sq*94qszHR zLfLrXw|8jNBctyyx-7S-qL+E-zeNHck-Xo=pkrOVt2~HJ@Zwj@n*wS{K7NomCLAnt zH_|Ru9J44Hg55js6-@&UJiov}R~>NQ5_y)_{lE-7*FEyFERXIRsz55g(A82k)CMCt zAs09&rdS`c@pr{mXU;k>q40Xh;~VMDqGNmOVubg<5j>{0QM}*KP{0TO2gF_^mw90B z^H}FB>NI)5HK-wxH^KwMSC;{Q^^JKV;gQhaAGg{_VAQ+f&vxOf(`Pdvq@%#?-@l_* zYy>+AUS#W8^!7e&)NA2SG) z^r*8s3Q7rLHL0?bw{&xMx8)$LsL4=Ri2C00 zaqIKg8&7tdv+Rer+=TheHgG~$7Z6b41e2fdE;fMQ$M;?aWYK1lCLj}PIkl>liL$f5bo_WH0iI8F5_^sCn4$+;+xE3pMZAHlzsI%ZoWd9>u5ftBEdzf7ex-gRf5Cwv%Mt5YV7KqT1DF#} z`9r^++7>x~Cr1UG%lC(j3lg^HD64>whFox*engQaql)n?Wc+XRXfKpPyoM!Sc8B9i zi{o;sSgBR-6ME(Wn5)oBu zmE|+rLQ#bY>WR|aPE{(;!ovo;nPr;aN6cpyE)lrhmA+OdrRZ*~SlViLH>%V3oK~?n z9jsevNz`l1;2W!cUojvjd@yxOb*|SN0}*mhp5*)SAZ;rHS>4Y#R7O>7T+6FbcIb}U zRz-QlvC!&2V8Gk`$V*RejsmFf!u=dmroTmUS+Ezu(?jGjP9PVsj0Z&r2hLrNu|D_M z>2N*xxi1L3=HmEs(YYkQMR0!=yHpUg8Zua$nw|8m2agMqhkz%`dt0AfI2HJ){aZzg z>umz&(h|HX&{5&nGOj(R>Wk&O(!{PaRi0ZcP!#?el|<(?C;?-`WIv{~QW&rgz>yYx zQSZYvnTQI_pRZ>A(sU+@MVTN7CaCjz3)q&%p-*q6spJEFwbBc3%swt^U^NoO+e>;T zsS-6s)u^;Wj-1Tng1m`An(>znmb<@Dsp|`H60iWJY>G+6jmsVDW3Pdk%F}Ly1qU8A zz2`rfGucuTdsfBqvQ~_S z(9hm)d26*OO#VID>dSDCg9>@vOdc=Fvv9%SuCE;ry+*$z7*l*yU#&5N|5lyJUABa9 zoK>ppS1d5OsQYWqzM3yp7t|R*apDEjY}4n@xZm-ftPWPAc$_+GNw3}jkB2Elj|`F` z0j4iWH*eQxWM1-~Fyb+w*wJJ_onXaEO*RwZ_a+C?LJy}mhyU#X#rJ^-#Xc)=vHP*7 z!J7y7Kn&i)hrLMPB^>_eJ~nDBr$7$W3q4v#wl}y_zi|=u8N~KgbECF>H;H1SI|dLA zTmGy&(6F`K-ph>Prn8dSO!)P7Ts@I~^>$19kf;JhF&rP{f!-ohBh=mE7Xp#jE8vA+ znb2z9{9bAuN2|qW4MJ7#P%I{3gZXPb=HFn>Io-hmArj~s!+l?p&PBA)+9f6DUmw+1)wcClH9#7Dbvhc>C!XuH=$>9`~ z6AE2}AwuR)E>&JA7d0LR=Br;M=Fxkxjp)?{Isqp8S`?QA@13GL9JX?tN~(7soOL8{ z4^L7_dE6pcwe92S0{~x$o_5L7a-p(?afX1Y58Pjpsqx?tAwzOuxe}A7yBpcl!wVo- z!ztc0b~@{u{!W)>C)VO^NUgXvN}ONi5!uqNT1;E!9`5Q%@n~*d>M_~%8h<8!VtQI1 zbyI1Z)aam=Zw}px1cW1&^K?CSHnPcqQt~KpwE7})Ef1#&_g*v0=u7;AECGgH;pW#FPL!>cZ@gF(sNT4NxJYUyiW2?L^w0lYy11Q6D zbOKxUkI@K2>w-AUT=RqZx8LnqLc=jbcQB??tGJErx0PG&e0{KE|3$Y@w}!h8GDMh^ z|BQm@CQ4!l``lpB-aC7&9Py*1T8=vIsvfb@)Qrhlbx9`VaCN=d^7^3@+u)B~2PK1s z5;jc_mX$g|9K1zZDE_8)XQ!MT$P8HoDqR<3c8&-(S8_O4IdnQc6}15dhp3TjB&d-d zangH4yFcIS4G)1}vrl;A7QT@k-!|P|pX0fz4BwiLLD?1TyG0`=3^~Cq zCjTcLVvq+;i^6B?-tQ!$fG#^uR1JGQN#H7Pj=j#{-v?cQ!g5mrjCxu`^2rD0L9j+o zo|y07vWVEPmCSB`JK^fo7d$v~ybElQ2d=Qnx zyEHWs4bDdLf_X^o`)4w<*G`QUXWMvb|CvRU0=&Scea@WnUZ~9k1JLN+qv5I4e*LHi zWiA3zQwpt6+7t=TfHH081-l<{=)5vxnggwU;(%VC+OnT)3$6~=3$Hq8_5bC1SSc-> zgNBirz(b%#w8DA=P*)=h!+oCWZ$gz&P50o&(7?*VB_>B>J)pne0XOL=_9dU8`;I?0 z8xxKH>CXkq+Pz&aP#xMns>cCb6k{zkMb+eEgLYXB&xeWBSTZc814>&AHj^A< z>o%pSv!JySf}z04q9=zwHkJ?UA_O2Q9y&%ioa3Mrwh7woKHR(!5M*qFX zIvi+uOJ1lt>G3)P2hfXB=Grn)>#L4wa7vH8nMq;%@(!iL2X?~|Q zPYUH5hCq!yx^v-xaEwWzYON<=&s+Kg4QIwWyt_1&;k9Zy8=hjqy^Wo3+KNI$!W>l# zG8;~Gu~4yxuQ{L>)hFDMvSBj=2!($*e*4%W)p6*3A&VC`{hL@g&m|)pH)8_4yHOnm zuhgE!>aoo6Fp91vhreJ%xUt0feqkeg%~h0F_nIaK&T^sww@`M_0UYZ+>hm3Y4itS_)%#o4F6f=8 z^W9JkUlvAiqJgyhA2}&Rw%n06-N()4&Mf5;M}Wdzq2b5T$)7b()rPAn5%2tiK-r$8 zF?3MUeSXtNmYcP#k`Dv}gv@PKY>)xNEe<(pb;hP+h7xl4P-(ZThTm&0i%Br9PF+EF zIQ-2Gr{r#Q`6v%#0>AYc91}w5@q*cP?igUmLSxdZJmf-yoKWA({@sB*Rh$=i4q3UA z{n0dRE)1xb|Lt8)Z83i}E=vqz9FPR%2!}$RHjWa7qDr6>0>UQ#7>IbtsKu7V0_pzj zZE_b={ZWo^oL~-DQ_Cg=OR*?5!NcKsFqVxs(=xkmg|B@lVAu?~Nz3t{A=7(f5n_ z>!-GrGXVXcB0UCqkO-?K7I*=U{=n$nPJo<228$8igyJ2h^T>aKV}Yc&8*$Gu&H+Y+ z+RO2towa5S-7Un$sdV{ao=OW?+M6k|n$Jh7bkwV`5guK6z(YYb#mqihp z5Gn%+W%d$fkU=4Xn8cTDTHtQ{q9IavyX4PcBN&H?g&N%GL^_#L$_*@km#O?-!ZgKw zDR?V*7TBjg{0!pg+=^f=$5fOtv!9l#Bj7rjGqCj9lXbNs(WlwE@yEo-l`eW{6ICqv z6i@V~OuR+NGn#Kz)s@>%#VpIm>CkawXufMW((LE@!1_SMRrJwvuX^wE`3qxK7M}`Z;XKU!>yX z#z<SEc)RJO^JsKpq_OFiv5aP z#-IQ4Zu9RnaFOJ(ZjHhvU_*EwXu85)xiiqhWk@SiArh>v(b9a^({6J8OD>AC`o81uRvG z?WH>vf}H_j!MHQ_AVvapp-`4o)|lE#Kfes&t4`0YLy2D}G2cp}0?tB|8PqtvSXw7~ z^4}f^;h49fTVK*-rpTLz<$ZN;IMd~|Z|Bn#&i+gQCAcI2tN9f;Pw3##_k2_!bpIhB z0zCWnsw@*t(Qr|~S${pps&!^jN3MaNUft(J5W=^e_@%b2B#>#N^_Qa~kU9z~V9VD> zQ^b7tkqr%`#t*33G+{Ld3RgrQSmMCtun{i-LeQqlSH;xgFX@T|Ed|{}V|$L^NnM|Y zlvtLF!_uElcBr$VMkb>tzFN?b@_eD)CW`RcvyzD-*I)ZAf4S&~`FBx$hdTB#mREDlOzW6~WZRWW1WzI=E6(cTNH7SCs*)HkJ zm-g?vw!B}*$^uf{P0G=8BTe^@JBnUh;uU*!GMnVfuL`jL<>M^|$-Sk?JdxMQ&`T2M z=1*~7_K!m|D0`Iae-_GZqaRo&8;|zW-$19te9EU_gi%t1N2Ar-dB5m!TVX}X$kCS% z;}}CiuIv;DQlFBprhi;YdJ_B{ssZ^KN7Vw@IwcOffwBm{yh02-iwRxZppi6fCDzSm zetk5iRJkj4`!e-&iP9E{1e(gkMzQbuwhe^1vHXA&cv4$rXu@h?$h+32?2{CKM?4>f zro8^8kCD!&>hjtVla)5jn96VqRYI!B8BirD);`ZXX3`S3jRD&2{ttkXuB1e0}B z$doEHi+`mJYyR->O|N7r`18F(RLLac(2)J`6BipjorMQGCILQ1HNSPsj0|;xOTNo3 z4Jk8vIniU6&!ru2hxsAfC+HT3N{O5YT(*Sdo|b{C-3{fEDT zjRZ4vd~*UU1H=J2U4os^FAmd&@`n0q%CvA-9lmFL!XfN^@rPfJ=*yJL4#qRUPUy;* zV$a#{{|;|6S6zCaB=O>&vpT2<#vvNT%t>qv`I%!8Jg&xaN4r9Rvc=(xuNrl?zeM4N z3%}Qk-VL{I4PVOOIx;by+5b{mOe^lGwxhIv>SCiyATZQikuXn3j(o}vf5unKkXw7Q z@on5o7}&p@8xBY-to-Op0*ak?V$0{Slj-};y4gs)%SaBl=+kcw%;(|Ut8G}2yI(lm zu2mY`;KCejGk49+7Zp??U!0|dL<`r<(XnN*t)hPL zNZm0U97ge;HIgq5mF^YNd2& zh7mT*)a?ByE`>&`ohp;3J-Pim{?jpsEpLVAyfX@(1w7NGTWy$-!5Im=A^ zZO;EXI`Ya}w<`XS6g2wW68SkzzQH>Bd-srK;KhpTwkvcm5WZ!unPFmk$Ck`s^AI`3 zv`8#~irv>^&r9fsc1Afw?SfkKT86~8O3efWkY7E%O58V@{uusB{4rM9)6i>na@mqL zJ#mUq2^GJIA}p(NrM~Cmh{MB39lc)@+_wfBH5nELKXlS}kHwA8O4^E)Z{HLUa?_}!NCm&d89|FJiKE%H6rNY>l3tI9L zFMs>W0fT;heLRGv>+Y(|$U9ylujQc~7DPBk9d+^Dm#&&N(kQ|v-e!VM92NFDTc84D z<>#V^X3So_u)ho~Aq}2ww=S}7!d&CmJ`MI%fm6rfGFvZ|F*s7us;bi8lGIGGu?4V& zp#+G;M{e=RX)oVte4G|R?CY^A^@ou$cdHe#+`m4w`WQp+;63A~FqFXhFStilTd$X+ zhQ@Z09%?iG3Tw~;HM4dpC$Iqrf$-K7So)l&%BFAAccaHc4N$EgHSZ96f_q!^&u|8F z3>==P38#rC|9!|f0AWPj83iop&(*JeNy>YnEX`re%z z9_MalTWT;{J`J~}+8?d$e@TaV>^l_W`_8+O7Bh!S2VkqhbW>39!U8gm-^e`_@SFRNd>sH`X#wYS~o@R^IAvmM;pi!c=Xe4x+4DZKs` zbaFTK9OH_XTk>QQeNj5=4GWtJn+`(;HS#D2<)uC@jIR7hodgw>=bI-n3iMSWD%;LUXUS7)ae zhXjk48G&n)J5R3HqDw-jL{q!zCIzVuIP2CXDle<@>;F~6m5ki2Ovj37&v7M4Vq1~$ zoM`B=3`-Oxl-U;2gcxb=NCr-WBiFC)S(j8_RRy9dH+<M{sY?zeJxWOWY+cHD0z$ zi_|+9Tv;N<50N*guR>}yo1e}cPzA9s1!l&7y7)AC0NV>$w5a*Rl6}do%JanYmfZWu zs&k!8mTjTVskgsrAo!n?qxOBs8h~D;&GZrNXB-AYq5nXYX9vesQ{c^|Wmgiebt?*b zkj&X>FJlOlQ9Z%ncY?VX@g=w#6jqLz&3uDeF=rXrzQ0lVs(pm%k-|g$Up*{?A6SXC z&8?Zw&Nxw`Qm?%8tK&QTqp{GyDwJ;;gkZKwivQtLY7#t#&s-(9zSa7&aR7z5M0L@h z-2ZT$|GSjdSm&ebG|p1vGD@l!{_eFx#7~{=+s%B*bk?KS>@)PD>FA)!hz`fem-RvU z{v`Kz<18w_y!ZhLUXWBQ+FDfO10L(VsMN5fmd#}5W7kp0%2N*Ypr2CfSCk3N6Lf!> zlKsC31zrAQu`uKwM477O+QC(Oh;X~A(!vv_Jy&nqn5&#cY9;ZdQNqxQCEuZixh^JW zUO{e%HZ`*LD^GW=Ws*2pxrLcMs>mBC7AXg?+A63s#M#Cq+-Ltp%}chBQ=1}=L1^C_ zhkM)AutKdN1`GxQ2J))43Wx?3k7S+zphaUrByua9ZZu3>dN&$zzcu3P$0{vai1#i5qhG+HC;y5%w(sBDPegpD~UYKYFV* zWOm?7NdDQqUQONYOV6a3wX0zBtHL)YF*oEIqzI*5Jm&mme4G^kITBME%&$YfI*Y=j&SIws@Ko} z2$>uhIq;bG?L=9Ckhy|G6KfUBjqc;9vF`x$i`b+pqPsQ4K%%$7kPmYsr|e7RZFZe4 zayT^4nUkg6&UQ0?rX-?8D-=``x!ocvLyh%0lU4s*eahhqlGadda^VM6C7w%&?{J@H z`ne`ifwM1S$qO`jcJ|?-h%~kRA%_lJ?dXu-x-`_}zJqmVYOvRa={0@Mr*@QNmv&f& zi)4f1Td;_Yb~JeE#uMVJ3IVHES2}_&ERXc51Tg+l{0*5EWvUi7Xxupb^{w58GD|27 zp?!2IFg8O%lA(r;z_&d#xKi_F+w=7JzSV z#-s5otjaT^->+dn{4xE1{fPIv_SMUO897YNHweLp=K;e(#{;pxYSj+3+2EO|*Fqg= zrwM)z$ss~np#QZnhCsQF+*Dk_8*C|;{XQs=9k}DC&mFXx08Jt4lkb#@Kxnq;?^aS) zMjxw{1?bCN3)k-jpAtL?hZx=C#ta(D$>CzNjJ$C_^2u7Xr*bfi*pb-H!jf1oA~SDC z&s}9-xwj&K(-=Qn&+BG@BadzlHC1_bIlJLmvA%xPdKunyIbq-X`ISlreLFT7szx#3Q$S2Bg$$=wmKOWP{nHUz!Mo%M=O@0LAvP4 z^URRFJI_KiB1OSJRH25aF9sI`+wGk$TC<;3JlP*^7pzQ;dFGL)mfnzz;|9F6%~G7@ z2N)3HwidkNOFhe-IV7Ifxht-W1eX^r1{Yep5)9{e0ksd%h?CbBkANCGN-j=kkng!? z=yG1lP0_aWoh+N(i%27+&Fee&bBl3~2pX>-r&ZtPk%ktRS~QOSHe836crl(W-My6( zSU>S$jS1cdu@22j(yPqChM)Z=ludTEvbip0K{jU>Qbv+@W;htXWz!gXUB~-D)6!V1 z_>MbC)mh{1H!EVo2aCHuRJ%7gLIK9!*4-Tf>m2|67#U2pu%L_0#aNG1@mIsqa|W1# ze;$X{;C|$^qx7wQF=&%KCcnH4#W9x=z@Uh-cQq=i7Fp&?oj*mM^dS=2p4bT7e3D`yOAh)B zUw7~U-WXj+)sR>&u{E@gA(#~M!@u@MYFE>b3U82e@P5RIVQ%>{GLSp|nJWP&KF~6> z%W2B|aZ%ueXF+vp1EV=@qK0bEkc-mRq3@8nr2^L5!o)6}V%z7q!YIzB64FnpL*x@)pa|p=Arqx?JYQy&8`WtU zo%p-N;yV2ay;q3!b8uz%~bj@~S4FkLM{6)|;G&SiD`$%=_nno20y zonmh_wS3RefaD`Z${ZSBCWd>Z%OuJ5kv4RsqULLj-W|Oor({%DTq!G=GKqe5fe_#5 zWM_>C+j`#c9q^`>Pk%d13TMq@lLgbE%iF%vkkU z%dXoqG|%{i5OYs4T3kPR0PmxCBPc(V(YQ(o-i2LyT=C!U+CC-n#ewR69rk{j8F7{4 z^C3Ih<=KcJ;iH8(2mTdl-tp)cc0*b7uKhWL1g6mU*K;9Ct5&*W7fQLEQub&VznwaZ zg*Sj=h$AT7BHz{A0G!KP6-2Gyt4_Y8{~Y|!@kRU$^e0wak0qH!%6O*KTS@2J>hAXL>Y>ePkz9A(HP3**wce%A)gp$yvug;%Qm{0b%p%kn#x%@k% zBDeElh38tMg{)WR$A4>v&@8bPjXvqltyG%d!_r1G%#ny*; z&gN(2$g{2F%R|w9pcsmhxn=THkL)FJ(q3JlgRtT9tcxg5P|uN09Pq>)g|gX#tInxJ z;XRp`%(ZYSiRfI$3ec}&d&Xitdyp~3;k;N&+js=pU3l5)*bx}U+^2L_gSfR}ol{zQ zxt6j?O8>7^uYx@5FgApNaM%WRU;g4&*)E<82Vp9Fk=p9l8h+b;CaIyGghEgp6g^IB z?1xt8&LIdCg~E+4Dv?2tm4eY053Gz2U!h-;we;t{9T~4axM#XQo}|qDWs0?p6OQIq zAG6nt276Qd2n)Gmc;rOLK|ZzFU8HIAvQ$faY7hkm5tBtEcc_sHYaW=~W72VeFD283 z+#Ci^T1JpZWl0!83&nfQANhGCZTmw#K7P-GTSSJfs%t z&Ki5;5e7Y5%LGfTGh@>{DEOfu{#u&`jwZ*Jrbh|uhBASsv`*j?A@u8G#yEc0xmU}P zW>WVt|Mn+}bM_vP8e_CbGpoA(m$VH|ceD7&OG5H@e++J}?~yh0%CcLw*a+STZTA`4 zDg!|N#|-Bt#~b?JbuuU<0BY(X&WNuBuqC;oc4F7qvJY%*Z!kDhkTv7QPkmQIWduVL zP6mb#kjT(#TBPsC<8}{fyChN5XDFza81XKK|AZbZd350KZSifsj=Fnc^Urb?D5`<| zmz5ZB%J-f{Wu>iG*s#ySosXuwC5ocw1>My*&z@s}_m_{g3=xT+{u2xb#K_9)qMrN3 zkl7y)BBtUR)apJIg^dDw`C}u{d$2Fdmio5HB%k!L-UkGm+vSYR1qZC)L0Q4sT4~2V zDD1c4?-!f{IhcMMzz4i7l5mn5@m@(6<0D#)`&+MG(o9E`^b^?sKlB@_!yK;cS zF+(tXq8P67Y{gBh>Hm;&Pr!lcm+B~_TTB87j+PuW9Ly6Wnk!S|T^nIUZ0egdGq?8$HhLaJS&?_;2>Ydc8 zWG6xtn9&@1fdEzwQ2<5i5SpIUPBHorhW!>a^E@sua)Bzhmu-BGnko-S-SzU&2eIZ- zfjY|q?JkedWzF7t=I!oZZ2%kkwM5lpp6@&rt4&cJek5#HxllQKd+`A^S5JfVO*v-y z4=Fj(M1;Re4v4L=5C^Nkd6e$v3k$`9uN|bc=Kk_QXx;*=TAgK}q^wO8qH?HH7}4CI zVR2oTK2ly0!Yt|Bd!yqX& z!0?L<-78hpd864GRu_N`n!{fc9rWdeOOLhjjfHlW{3=7sodL}81;}AU#3UXzoGq70 zN1@;62>Bu1?}1gh&SVe)&IQf+CCjIHG^Syr_qeY~=Ej%~TKi64+~0cpZAFapmD-As zfD_?srKfQzQTIN|6V5hOIlTN#6@#GZM=+B+)mxxOwy)-Pf8%S_fam6nhI@+ULSrP{ z9*=mUDPN5D5KBwOGJ|>;^zzpl5Il6~<9mG4fyrcHMxJmdq%ZkGvu14G?`mu+;?eg9 zF}BMfj>%U$5Q&m46>K1ym1iGUoq>y?mP+K7sE-y3i?ai^6O>!0O}raBqAAFU1kRaT;siAhen0n z?25jPB5*Hp1S=C0V(g#vxFPvCrR z*A>!`w*tZw76Og6{ic?tr^HaxuUvsKz7(F+CwgMkm(ke?L#DPje^5c}oN~UMQqTU0 zeez|95nxKkTXsU zb*KjbdDZ{~W8D#cW{wYI@qB*ERCIrST3hX=rm4o&fgRe8Z?C8MvhVizaTD#R!3<_PbGu_KUjm{^w~a^rl7>od%*Wz-P@G6Uxl`U-bh7E|HZ)r7g0>f z%io-`EjW#rf+j@@OAj~Zo^45TZtL|jqU{0EjM?y4&MWe32BFBuMHu8~7qTR8_Gn{b zV!%#O%&t)D7?3M8Zj+*{DX9AJ&w}bS23JLr!>v zjMQBdJ~=KRPd6}abqiNCNrLk31D03Hx6?vLu>jmZ_6P^0zQ=Vlo4nnvBMMPJ z=X)FvxjfhK6vs>N-T+F0Uw@!M;81^bWe&qXbtZ1Z2B^-M1G?`Ldn7^~(n>z7EB_e88w@k)P2dB88wykEhr7;186`)=hPq>f z{ofrU%y&w*3eorvLEA%wSnq1Y>8$T@^W7bGA8WzQri+8f05fW0WX0o2&#YarV)=WF zc&XC=X)N#WwI^$F;e* zOt-@1dzp2Ix}EUTL(mE5UlEI{$tNhw5$(?S8{{KnNRS)rOhkHvY&%)L$f!>*LJ@d@ z2apUQI->?r(IN5d&L@cL&2EV5hryi4$Q97Uz-!Nm>v?P!@l+r6oHp%;dO<7&jF=&g z7AbLyi!Grf(qMDoSRCPU*B{xiz$m;+vkusEWdoA`FB=Qw^bEg?5USomw5_DP{N6md zZTJT=7wZw57y|hs(6-gSa>t-!O$Go?fc?~7M>sn9d|3B6nt0Ufvtpx(7caLSLt)KlC+%u}K;76V-V09Y5Vldg z>0tU87f%8Chh~shI%5;pBIf{TIGjLrY+gp_!{aE(j-5sF?D!e-&nG8|If5wUc=k57 zXi`u9Jg~c5)VHHlu2doW2!i_rTLsPp`ELoX3At$=Ne4iv-~>2<+*1glnMIU#l_zW( z8u$Kux%c0b<B#xuBoicNo-1p2U^6H0Q+iGM{%6QnyRk=>IO$~XEo*2x|iZ#|8vep8@$-e0mSnRPM|k-`CWiLg(4{%H*c30uo@XBotrl2 zyGUXXR--$&+dno*+j&@(t7Z+henwWdybQ=n@?y{gN9oqTAF_zyjBzfjnerx8o>Jm>PanH1wdngb|N zqTmGf#i=(CI+eH-$-^z%cFNdyzLF7W9{T9B#jLctHZJvtT0*(V$#B>e_^v-p}MA@BnB+0$op(mC4&0_FhH6azSd>u~uV1p6CR z0;lgW0o~Yl=f*}GPnoflAJR+QVTW!a05X%V9h=5R>7Mn+wz6M1=i$#6!kviVW?=PT zz7d7>d2;~epNwt&H^3Bg&?pejxwJ>2HhnRdWCt!YUmPZiInZ zP0_s5M7kedVD0&(VJh#;!?@vkkU#tSo0NlqIe>BiDtQ@>pg+z%hQRrROtM6Py^1)- zfTNK4?SRu!tc_@sQ9U&2a2f`-<#RMH`|4Y-oh%E~@3*tB^5r0YPVHclAp+(AG6X95 zHaLMcIDQ_XL-Kjs&kcbUKW>oO7W@o+^pJESxwW_4SPJqKHhaud6U^teERBjhI zdnuj3*NazU;rpMZaOJw+wkxo|Vx4T=rmKcut!iZ{Xj}(jZ)vvw-cr!0PHcNn^9C{U zkJ#**xn+9xPqFLS{Ro3W{*j!_Eqlmzf5M1scRaE_f+IKv4~;GWl8>YQX9%TOSN$>-2e-J;{`!>lD0V}gP0+;+4oB(Il z;$(7XB%il^eF)Hb&>18g0yRt!K1ttR2z_~$LZer3j+43B3P&lx$>x-m_iWHA=UzC1 zfw+Z(PWCM4R=aEn7y{WNunCV`2jb@_eG>-|FbCiOh{;jF3ADs93uw>e^R{mY7y@}A zFcD94I)_Dh#bOWdhCrm&ecR1)mk#0uv=^ek3}Ky1$yl_8KvWRmJH&UXmyy5oS#C7| z=P@mg5w(GaI2ev#-v)|Nu^mGoV+7dNKM2IXD&r^Y#;g!92apw9x!nv$a5|o(Be0%A zxj|@8MGFD;@FDS!iA%u=;ZnH~vlpGe|iMW0i?&r)2naX78ZvJlKy9ERQn~r7!0_FfpW1{*D96>cGH~`^#gacJ8Ysy6h zfp1_aJ1;y3;@=t-qr+gQIe>uXA{+tp?i7TZ5V{A{3{yMU2uz0h_aeLw;x`%zYW*UXcDYw-T{mhATuH!6bMHaQfv52(KYb#lL+UtQEXGbX8+>0ItFtj>zE<8pDzk5alHR_$w6ZXeuc}z#M?0%m{)ia0Zp2=N}O03>etW)XEV+@Im;G zp4~J__zeF6>F>nW#1O~`0ef*Xg2`?w3jsKUy12&XqOS44X(>2ior&`ZaH2I%zc3{p z*sSxD^T}9@Q9-~QKvZ(tBRk;`IBj)DgoH!jb{~(D4_h)n!k`4%b2|fsZ__M?Eth?? z#~gr<;728CIE7}gl}$zM5%@uw+m;BnBq>0E6d^1^n2o>>x7>aSvR{I&i6P(`0_Ff* zgVrKWhX9;HbzE { + 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: