diff --git a/.env.example b/.env.example index 3f3664f2eb..82b4019283 100644 --- a/.env.example +++ b/.env.example @@ -104,4 +104,7 @@ INF_APP_CONNECTION_GITHUB_APP_CLIENT_ID= INF_APP_CONNECTION_GITHUB_APP_CLIENT_SECRET= INF_APP_CONNECTION_GITHUB_APP_PRIVATE_KEY= INF_APP_CONNECTION_GITHUB_APP_SLUG= -INF_APP_CONNECTION_GITHUB_APP_ID= \ No newline at end of file +INF_APP_CONNECTION_GITHUB_APP_ID= + +#gcp app +INF_APP_CONNECTION_GCP_SERVICE_ACCOUNT_CREDENTIAL= \ No newline at end of file diff --git a/backend/src/lib/config/env.ts b/backend/src/lib/config/env.ts index 762ca298de..627ccb0538 100644 --- a/backend/src/lib/config/env.ts +++ b/backend/src/lib/config/env.ts @@ -201,6 +201,9 @@ const envSchema = z INF_APP_CONNECTION_GITHUB_APP_SLUG: zpStr(z.string().optional()), INF_APP_CONNECTION_GITHUB_APP_ID: zpStr(z.string().optional()), + // gcp app + INF_APP_CONNECTION_GCP_SERVICE_ACCOUNT_CREDENTIAL: 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 d186397864..d9a5b0ee2e 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 @@ -4,18 +4,21 @@ import { EventType } from "@app/ee/services/audit-log/audit-log-types"; import { readLimit } from "@app/server/config/rateLimiter"; import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; import { AwsConnectionListItemSchema, SanitizedAwsConnectionSchema } from "@app/services/app-connection/aws"; +import { GcpConnectionListItemSchema, SanitizedGcpConnectionSchema } from "@app/services/app-connection/gcp"; import { GitHubConnectionListItemSchema, SanitizedGitHubConnectionSchema } from "@app/services/app-connection/github"; import { AuthMode } from "@app/services/auth/auth-type"; // can't use discriminated due to multiple schemas for certain apps const SanitizedAppConnectionSchema = z.union([ ...SanitizedAwsConnectionSchema.options, - ...SanitizedGitHubConnectionSchema.options + ...SanitizedGitHubConnectionSchema.options, + ...SanitizedGcpConnectionSchema.options ]); const AppConnectionOptionsSchema = z.discriminatedUnion("app", [ AwsConnectionListItemSchema, - GitHubConnectionListItemSchema + GitHubConnectionListItemSchema, + GcpConnectionListItemSchema ]); export const registerAppConnectionRouter = async (server: FastifyZodProvider) => { diff --git a/backend/src/server/routes/v1/app-connection-routers/gcp-connection-router.ts b/backend/src/server/routes/v1/app-connection-routers/gcp-connection-router.ts new file mode 100644 index 0000000000..5c76df1604 --- /dev/null +++ b/backend/src/server/routes/v1/app-connection-routers/gcp-connection-router.ts @@ -0,0 +1,17 @@ +import { AppConnection } from "@app/services/app-connection/app-connection-enums"; +import { + CreateGcpConnectionSchema, + SanitizedGcpConnectionSchema, + UpdateGcpConnectionSchema +} from "@app/services/app-connection/gcp"; + +import { registerAppConnectionEndpoints } from "./app-connection-endpoints"; + +export const registerGcpConnectionRouter = async (server: FastifyZodProvider) => + registerAppConnectionEndpoints({ + app: AppConnection.GCP, + server, + sanitizedResponseSchema: SanitizedGcpConnectionSchema, + createSchema: CreateGcpConnectionSchema, + updateSchema: UpdateGcpConnectionSchema + }); 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 2570cb3ada..4551a0fbb3 100644 --- a/backend/src/server/routes/v1/app-connection-routers/index.ts +++ b/backend/src/server/routes/v1/app-connection-routers/index.ts @@ -1,6 +1,7 @@ import { AppConnection } from "@app/services/app-connection/app-connection-enums"; import { registerAwsConnectionRouter } from "./aws-connection-router"; +import { registerGcpConnectionRouter } from "./gcp-connection-router"; import { registerGitHubConnectionRouter } from "./github-connection-router"; export * from "./app-connection-router"; @@ -8,5 +9,6 @@ export * from "./app-connection-router"; export const APP_CONNECTION_REGISTER_ROUTER_MAP: Record Promise> = { [AppConnection.AWS]: registerAwsConnectionRouter, - [AppConnection.GitHub]: registerGitHubConnectionRouter + [AppConnection.GitHub]: registerGitHubConnectionRouter, + [AppConnection.GCP]: registerGcpConnectionRouter }; diff --git a/backend/src/services/app-connection/app-connection-enums.ts b/backend/src/services/app-connection/app-connection-enums.ts index e96886e9f9..61787b47c6 100644 --- a/backend/src/services/app-connection/app-connection-enums.ts +++ b/backend/src/services/app-connection/app-connection-enums.ts @@ -1,6 +1,7 @@ export enum AppConnection { GitHub = "github", - AWS = "aws" + AWS = "aws", + GCP = "gcp" } 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 3f52b12854..7d74288153 100644 --- a/backend/src/services/app-connection/app-connection-fns.ts +++ b/backend/src/services/app-connection/app-connection-fns.ts @@ -7,6 +7,11 @@ import { getAwsAppConnectionListItem, validateAwsConnectionCredentials } from "@app/services/app-connection/aws"; +import { + GcpConnectionMethod, + getGcpAppConnectionListItem, + validateGcpConnectionCredentials +} from "@app/services/app-connection/gcp"; import { getGitHubConnectionListItem, GitHubConnectionMethod, @@ -15,7 +20,9 @@ import { import { KmsDataKey } from "@app/services/kms/kms-types"; export const listAppConnectionOptions = () => { - return [getAwsAppConnectionListItem(), getGitHubConnectionListItem()].sort((a, b) => a.name.localeCompare(b.name)); + return [getAwsAppConnectionListItem(), getGitHubConnectionListItem(), getGcpAppConnectionListItem()].sort((a, b) => + a.name.localeCompare(b.name) + ); }; export const encryptAppConnectionCredentials = async ({ @@ -69,6 +76,8 @@ export const validateAppConnectionCredentials = async ( return validateAwsConnectionCredentials(appConnection); case AppConnection.GitHub: return validateGitHubConnectionCredentials(appConnection); + case AppConnection.GCP: + return validateGcpConnectionCredentials(appConnection); default: // eslint-disable-next-line @typescript-eslint/restrict-template-expressions throw new Error(`Unhandled App Connection ${app}`); @@ -85,6 +94,8 @@ export const getAppConnectionMethodName = (method: TAppConnection["method"]) => return "Access Key"; case AwsConnectionMethod.AssumeRole: return "Assume Role"; + case GcpConnectionMethod.ServiceAccountImpersonation: + return "Service Account Impersonation"; 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-service.ts b/backend/src/services/app-connection/app-connection-service.ts index 91e7d9dc44..5befc01688 100644 --- a/backend/src/services/app-connection/app-connection-service.ts +++ b/backend/src/services/app-connection/app-connection-service.ts @@ -26,6 +26,7 @@ import { githubConnectionService } from "@app/services/app-connection/github/git import { TKmsServiceFactory } from "@app/services/kms/kms-service"; import { TAppConnectionDALFactory } from "./app-connection-dal"; +import { ValidateGcpConnectionCredentialsSchema } from "./gcp"; export type TAppConnectionServiceFactoryDep = { appConnectionDAL: TAppConnectionDALFactory; @@ -37,7 +38,8 @@ export type TAppConnectionServiceFactory = ReturnType = { [AppConnection.AWS]: ValidateAwsConnectionCredentialsSchema, - [AppConnection.GitHub]: ValidateGitHubConnectionCredentialsSchema + [AppConnection.GitHub]: ValidateGitHubConnectionCredentialsSchema, + [AppConnection.GCP]: ValidateGcpConnectionCredentialsSchema }; export const appConnectionServiceFactory = ({ diff --git a/backend/src/services/app-connection/app-connection-types.ts b/backend/src/services/app-connection/app-connection-types.ts index e3983cf91e..dfe2d1c642 100644 --- a/backend/src/services/app-connection/app-connection-types.ts +++ b/backend/src/services/app-connection/app-connection-types.ts @@ -11,9 +11,11 @@ import { TValidateGitHubConnectionCredentials } from "@app/services/app-connection/github"; -export type TAppConnection = { id: string } & (TAwsConnection | TGitHubConnection); +import { TGcpConnection, TGcpConnectionConfig, TGcpConnectionInput, TValidateGcpConnectionCredentials } from "./gcp"; -export type TAppConnectionInput = { id: string } & (TAwsConnectionInput | TGitHubConnectionInput); +export type TAppConnection = { id: string } & (TAwsConnection | TGitHubConnection | TGcpConnection); + +export type TAppConnectionInput = { id: string } & (TAwsConnectionInput | TGitHubConnectionInput | TGcpConnectionInput); export type TCreateAppConnectionDTO = Pick< TAppConnectionInput, @@ -24,8 +26,9 @@ export type TUpdateAppConnectionDTO = Partial { + return { + name: "GCP" as const, + app: AppConnection.GCP as const, + methods: Object.values(GcpConnectionMethod) as [GcpConnectionMethod.ServiceAccountImpersonation] + }; +}; + +export const validateGcpConnectionCredentials = async (appConnection: TGcpConnectionConfig) => { + const appCfg = getConfig(); + + if (!appCfg.INF_APP_CONNECTION_GCP_SERVICE_ACCOUNT_CREDENTIAL) { + throw new InternalServerError({ + message: `Environment variables have not been configured for GCP ${getAppConnectionMethodName( + GcpConnectionMethod.ServiceAccountImpersonation + )}` + }); + } + + const credJson = JSON.parse(appCfg.INF_APP_CONNECTION_GCP_SERVICE_ACCOUNT_CREDENTIAL) as { + client_email: string; + private_key: string; + }; + + const sourceClient = new JWT({ + email: credJson.client_email, + key: credJson.private_key, + scopes: ["https://www.googleapis.com/auth/cloud-platform"] + }); + + const impersonatedCredentials = new Impersonated({ + sourceClient, + targetPrincipal: appConnection.credentials.serviceAccountEmail, + lifetime: 3600, + delegates: [], + targetScopes: ["https://www.googleapis.com/auth/cloud-platform"] + }); + + let tokenResponse: GetAccessTokenResponse | undefined; + try { + tokenResponse = await impersonatedCredentials.getAccessToken(); + } catch (error) { + let message = "Unable to validate connection"; + if (error instanceof gaxios.GaxiosError) { + message = error.message; + } + + throw new BadRequestError({ + message + }); + } + + if (!tokenResponse || !tokenResponse.token) { + throw new BadRequestError({ + message: `Unable to validate connection` + }); + } + + return appConnection.credentials; +}; diff --git a/backend/src/services/app-connection/gcp/gcp-connection-schemas.ts b/backend/src/services/app-connection/gcp/gcp-connection-schemas.ts new file mode 100644 index 0000000000..5ad919adc0 --- /dev/null +++ b/backend/src/services/app-connection/gcp/gcp-connection-schemas.ts @@ -0,0 +1,65 @@ +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 { GcpConnectionMethod } from "./gcp-connection-enums"; + +export const GcpConnectionServiceAccountImpersonationCredentialsSchema = z.object({ + serviceAccountEmail: z.string().trim().min(1, "Service account email required") +}); + +const BaseGcpConnectionSchema = BaseAppConnectionSchema.extend({ app: z.literal(AppConnection.GCP) }); + +export const GcpConnectionSchema = z.intersection( + BaseGcpConnectionSchema, + z.discriminatedUnion("method", [ + z.object({ + method: z.literal(GcpConnectionMethod.ServiceAccountImpersonation), + credentials: GcpConnectionServiceAccountImpersonationCredentialsSchema + }) + ]) +); + +export const SanitizedGcpConnectionSchema = z.discriminatedUnion("method", [ + BaseGcpConnectionSchema.extend({ + method: z.literal(GcpConnectionMethod.ServiceAccountImpersonation), + credentials: GcpConnectionServiceAccountImpersonationCredentialsSchema.pick({}) + }) +]); + +export const ValidateGcpConnectionCredentialsSchema = z.discriminatedUnion("method", [ + z.object({ + method: z + .literal(GcpConnectionMethod.ServiceAccountImpersonation) + .describe(AppConnections?.CREATE(AppConnection.GCP).method), + credentials: GcpConnectionServiceAccountImpersonationCredentialsSchema.describe( + AppConnections.CREATE(AppConnection.GCP).credentials + ) + }) +]); + +export const CreateGcpConnectionSchema = ValidateGcpConnectionCredentialsSchema.and( + GenericCreateAppConnectionFieldsSchema(AppConnection.GCP) +); + +export const UpdateGcpConnectionSchema = z + .object({ + credentials: GcpConnectionServiceAccountImpersonationCredentialsSchema.optional().describe( + AppConnections.UPDATE(AppConnection.GCP).credentials + ) + }) + .and(GenericUpdateAppConnectionFieldsSchema(AppConnection.GCP)); + +export const GcpConnectionListItemSchema = z.object({ + name: z.literal("GCP"), + app: z.literal(AppConnection.GCP), + // the below is preferable but currently breaks with our zod to json schema parser + // methods: z.tuple([z.literal(GitHubConnectionMethod.App), z.literal(GitHubConnectionMethod.OAuth)]), + methods: z.nativeEnum(GcpConnectionMethod).array() +}); diff --git a/backend/src/services/app-connection/gcp/gcp-connection-types.ts b/backend/src/services/app-connection/gcp/gcp-connection-types.ts new file mode 100644 index 0000000000..30b1ff8f3b --- /dev/null +++ b/backend/src/services/app-connection/gcp/gcp-connection-types.ts @@ -0,0 +1,22 @@ +import z from "zod"; + +import { DiscriminativePick } from "@app/lib/types"; + +import { AppConnection } from "../app-connection-enums"; +import { + CreateGcpConnectionSchema, + GcpConnectionSchema, + ValidateGcpConnectionCredentialsSchema +} from "./gcp-connection-schemas"; + +export type TGcpConnection = z.infer; + +export type TGcpConnectionInput = z.infer & { + app: AppConnection.GCP; +}; + +export type TValidateGcpConnectionCredentials = typeof ValidateGcpConnectionCredentialsSchema; + +export type TGcpConnectionConfig = DiscriminativePick & { + orgId: string; +}; diff --git a/backend/src/services/app-connection/gcp/index.ts b/backend/src/services/app-connection/gcp/index.ts new file mode 100644 index 0000000000..60ebf13e71 --- /dev/null +++ b/backend/src/services/app-connection/gcp/index.ts @@ -0,0 +1,4 @@ +export * from "./gcp-connection-enums"; +export * from "./gcp-connection-fns"; +export * from "./gcp-connection-schemas"; +export * from "./gcp-connection-types"; diff --git a/frontend/src/helpers/appConnections.ts b/frontend/src/helpers/appConnections.ts index 25551e3784..1349395b8a 100644 --- a/frontend/src/helpers/appConnections.ts +++ b/frontend/src/helpers/appConnections.ts @@ -4,13 +4,18 @@ import { faKey, faPassport, faUser } from "@fortawesome/free-solid-svg-icons"; import { AppConnection } from "@app/hooks/api/appConnections/enums"; import { AwsConnectionMethod, + GcpConnectionMethod, GitHubConnectionMethod, TAppConnection } from "@app/hooks/api/appConnections/types"; export const APP_CONNECTION_MAP: Record = { [AppConnection.AWS]: { name: "AWS", image: "Amazon Web Services.png" }, - [AppConnection.GitHub]: { name: "GitHub", image: "GitHub.png" } + [AppConnection.GitHub]: { name: "GitHub", image: "GitHub.png" }, + [AppConnection.GCP]: { + name: "GCP", + image: "Google Cloud Platform.png" + } }; export const getAppConnectionMethodDetails = (method: TAppConnection["method"]) => { @@ -23,6 +28,8 @@ export const getAppConnectionMethodDetails = (method: TAppConnection["method"]) return { name: "Access Key", icon: faKey }; case AwsConnectionMethod.AssumeRole: return { name: "Assume Role", icon: faUser }; + case GcpConnectionMethod.ServiceAccountImpersonation: + return { name: "Service Account Impersonation", icon: faUser }; default: throw new Error(`Unhandled App Connection Method: ${method}`); } diff --git a/frontend/src/hooks/api/appConnections/enums.ts b/frontend/src/hooks/api/appConnections/enums.ts index 3c1a409a45..ba29a37811 100644 --- a/frontend/src/hooks/api/appConnections/enums.ts +++ b/frontend/src/hooks/api/appConnections/enums.ts @@ -1,4 +1,5 @@ export enum AppConnection { AWS = "aws", - GitHub = "github" + GitHub = "github", + GCP = "gcp" } diff --git a/frontend/src/hooks/api/appConnections/types/gcp-connection.ts b/frontend/src/hooks/api/appConnections/types/gcp-connection.ts new file mode 100644 index 0000000000..44a73d24a5 --- /dev/null +++ b/frontend/src/hooks/api/appConnections/types/gcp-connection.ts @@ -0,0 +1,13 @@ +import { AppConnection } from "../enums"; +import { TRootAppConnection } from "./root-connection"; + +export enum GcpConnectionMethod { + ServiceAccountImpersonation = "service-account-impersonation" +} + +export type TGcpConnection = TRootAppConnection & { app: AppConnection.GCP } & { + method: GcpConnectionMethod.ServiceAccountImpersonation; + credentials: { + serviceAccountEmail: string; + }; +}; diff --git a/frontend/src/hooks/api/appConnections/types/index.ts b/frontend/src/hooks/api/appConnections/types/index.ts index 64bd41015f..96c321b1e0 100644 --- a/frontend/src/hooks/api/appConnections/types/index.ts +++ b/frontend/src/hooks/api/appConnections/types/index.ts @@ -3,10 +3,13 @@ import { TAppConnectionOption } from "@app/hooks/api/appConnections/types/app-op import { TAwsConnection } from "@app/hooks/api/appConnections/types/aws-connection"; import { TGitHubConnection } from "@app/hooks/api/appConnections/types/github-connection"; +import { TGcpConnection } from "./gcp-connection"; + export * from "./aws-connection"; +export * from "./gcp-connection"; export * from "./github-connection"; -export type TAppConnection = TAwsConnection | TGitHubConnection; +export type TAppConnection = TAwsConnection | TGitHubConnection | TGcpConnection; export type TAvailableAppConnection = Pick; diff --git a/frontend/src/pages/organization/SettingsPage/components/AppConnectionsTab/components/AppConnectionForm/AppConnectionForm.tsx b/frontend/src/pages/organization/SettingsPage/components/AppConnectionsTab/components/AppConnectionForm/AppConnectionForm.tsx index 1032f99580..99a18a6fcd 100644 --- a/frontend/src/pages/organization/SettingsPage/components/AppConnectionsTab/components/AppConnectionForm/AppConnectionForm.tsx +++ b/frontend/src/pages/organization/SettingsPage/components/AppConnectionsTab/components/AppConnectionForm/AppConnectionForm.tsx @@ -10,6 +10,7 @@ import { DiscriminativePick } from "@app/types"; import { AppConnectionHeader } from "../AppConnectionHeader"; import { AwsConnectionForm } from "./AwsConnectionForm"; +import { GcpConnectionForm } from "./GcpConnectionForm"; import { GitHubConnectionForm } from "./GitHubConnectionForm"; type FormProps = { @@ -50,6 +51,8 @@ const CreateForm = ({ app, onComplete }: CreateFormProps) => { return ; case AppConnection.GitHub: return ; + case AppConnection.GCP: + return ; default: throw new Error(`Unhandled App ${app}`); } @@ -87,6 +90,8 @@ const UpdateForm = ({ appConnection, onComplete }: UpdateFormProps) => { return ; case AppConnection.GitHub: return ; + case AppConnection.GCP: + return ; default: throw new Error(`Unhandled App ${(appConnection as TAppConnection).app}`); } diff --git a/frontend/src/pages/organization/SettingsPage/components/AppConnectionsTab/components/AppConnectionForm/GcpConnectionForm.tsx b/frontend/src/pages/organization/SettingsPage/components/AppConnectionsTab/components/AppConnectionForm/GcpConnectionForm.tsx new file mode 100644 index 0000000000..d647c29a6d --- /dev/null +++ b/frontend/src/pages/organization/SettingsPage/components/AppConnectionsTab/components/AppConnectionForm/GcpConnectionForm.tsx @@ -0,0 +1,133 @@ +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 { GcpConnectionMethod, TGcpConnection } from "@app/hooks/api/appConnections"; +import { AppConnection } from "@app/hooks/api/appConnections/enums"; + +import { + genericAppConnectionFieldsSchema, + GenericAppConnectionsFields +} from "./GenericAppConnectionFields"; + +type Props = { + appConnection?: TGcpConnection; + onSubmit: (formData: FormData) => void; +}; + +const rootSchema = genericAppConnectionFieldsSchema.extend({ + app: z.literal(AppConnection.GCP) +}); + +const formSchema = z.discriminatedUnion("method", [ + rootSchema.extend({ + method: z.literal(GcpConnectionMethod.ServiceAccountImpersonation), + credentials: z.object({ + serviceAccountEmail: z.string().trim().min(1, "Service account email required") + }) + }) +]); + +type FormData = z.infer; + +export const GcpConnectionForm = ({ appConnection, onSubmit }: Props) => { + const isUpdate = Boolean(appConnection); + + const form = useForm({ + resolver: zodResolver(formSchema), + defaultValues: appConnection ?? { + app: AppConnection.GCP, + method: GcpConnectionMethod.ServiceAccountImpersonation + } + }); + + const { + handleSubmit, + control, + formState: { isSubmitting, isDirty } + } = form; + + return ( + +
+ {!isUpdate && } + ( + + + + )} + /> + ( + + onChange(e.target.value)} + /> + + )} + /> +
+ + + + +
+ +
+ ); +};