diff --git a/.env.example b/.env.example index b54f09921f..3f3664f2eb 100644 --- a/.env.example +++ b/.env.example @@ -88,3 +88,20 @@ PLAIN_WISH_LABEL_IDS= SSL_CLIENT_CERTIFICATE_HEADER_KEY= ENABLE_MSSQL_SECRET_ROTATION_ENCRYPT=true + +# App Connections + +# aws assume-role +INF_APP_CONNECTION_AWS_ACCESS_KEY_ID= +INF_APP_CONNECTION_AWS_SECRET_ACCESS_KEY= + +# github oauth +INF_APP_CONNECTION_GITHUB_OAUTH_CLIENT_ID= +INF_APP_CONNECTION_GITHUB_OAUTH_CLIENT_SECRET= + +#github app +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 diff --git a/backend/src/@types/fastify.d.ts b/backend/src/@types/fastify.d.ts index 8ff12069a4..c6efcaf897 100644 --- a/backend/src/@types/fastify.d.ts +++ b/backend/src/@types/fastify.d.ts @@ -34,6 +34,7 @@ import { TSecretSnapshotServiceFactory } from "@app/ee/services/secret-snapshot/ import { TTrustedIpServiceFactory } from "@app/ee/services/trusted-ip/trusted-ip-service"; import { TAuthMode } from "@app/server/plugins/auth/inject-identity"; import { TApiKeyServiceFactory } from "@app/services/api-key/api-key-service"; +import { TAppConnectionServiceFactory } from "@app/services/app-connection/app-connection-service"; import { TAuthLoginFactory } from "@app/services/auth/auth-login-service"; import { TAuthPasswordFactory } from "@app/services/auth/auth-password-service"; import { TAuthSignupFactory } from "@app/services/auth/auth-signup-service"; @@ -204,6 +205,7 @@ declare module "fastify" { externalGroupOrgRoleMapping: TExternalGroupOrgRoleMappingServiceFactory; projectTemplate: TProjectTemplateServiceFactory; totp: TTotpServiceFactory; + appConnection: TAppConnectionServiceFactory; }; // this is exclusive use for middlewares in which we need to inject data // everywhere else access using service layer diff --git a/backend/src/@types/knex.d.ts b/backend/src/@types/knex.d.ts index 5c77879204..bc941f4305 100644 --- a/backend/src/@types/knex.d.ts +++ b/backend/src/@types/knex.d.ts @@ -348,6 +348,7 @@ import { TWorkflowIntegrationsInsert, TWorkflowIntegrationsUpdate } from "@app/db/schemas"; +import { TAppConnections, TAppConnectionsInsert, TAppConnectionsUpdate } from "@app/db/schemas/app-connections"; import { TExternalGroupOrgRoleMappings, TExternalGroupOrgRoleMappingsInsert, @@ -846,5 +847,10 @@ declare module "knex/types/tables" { TProjectSplitBackfillIdsInsert, TProjectSplitBackfillIdsUpdate >; + [TableName.AppConnection]: KnexOriginal.CompositeTableType< + TAppConnections, + TAppConnectionsInsert, + TAppConnectionsUpdate + >; } } diff --git a/backend/src/db/migrations/20241209233334_app-connection.ts b/backend/src/db/migrations/20241209233334_app-connection.ts new file mode 100644 index 0000000000..c726e3881e --- /dev/null +++ b/backend/src/db/migrations/20241209233334_app-connection.ts @@ -0,0 +1,27 @@ +import { Knex } from "knex"; + +import { TableName } from "@app/db/schemas"; +import { createOnUpdateTrigger, dropOnUpdateTrigger } from "@app/db/utils"; + +export async function up(knex: Knex): Promise { + if (!(await knex.schema.hasTable(TableName.AppConnection))) { + await knex.schema.createTable(TableName.AppConnection, (t) => { + t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid()); + t.string("name", 32).notNullable(); + t.string("app").notNullable(); + t.string("method").notNullable(); + t.binary("encryptedCredentials").notNullable(); + t.integer("version").defaultTo(1).notNullable(); + t.uuid("orgId").notNullable(); + t.foreign("orgId").references("id").inTable(TableName.Organization).onDelete("CASCADE"); + t.timestamps(true, true, true); + }); + } + + await createOnUpdateTrigger(knex, TableName.AppConnection); +} + +export async function down(knex: Knex): Promise { + await knex.schema.dropTableIfExists(TableName.AppConnection); + await dropOnUpdateTrigger(knex, TableName.AppConnection); +} diff --git a/backend/src/db/schemas/app-connections.ts b/backend/src/db/schemas/app-connections.ts new file mode 100644 index 0000000000..aea7a2c152 --- /dev/null +++ b/backend/src/db/schemas/app-connections.ts @@ -0,0 +1,26 @@ +// Code generated by automation script, DO NOT EDIT. +// Automated by pulling database and generating zod schema +// To update. Just run npm run generate:schema +// Written by akhilmhdh. + +import { z } from "zod"; + +import { zodBuffer } from "@app/lib/zod"; + +import { TImmutableDBKeys } from "./models"; + +export const AppConnectionsSchema = z.object({ + id: z.string().uuid(), + name: z.string(), + app: z.string(), + method: z.string(), + encryptedCredentials: zodBuffer, + version: z.number().default(1), + orgId: z.string().uuid(), + createdAt: z.date(), + updatedAt: z.date() +}); + +export type TAppConnections = z.infer; +export type TAppConnectionsInsert = Omit, TImmutableDBKeys>; +export type TAppConnectionsUpdate = Partial, TImmutableDBKeys>>; diff --git a/backend/src/db/schemas/models.ts b/backend/src/db/schemas/models.ts index f670ad6e90..1dd075af19 100644 --- a/backend/src/db/schemas/models.ts +++ b/backend/src/db/schemas/models.ts @@ -124,7 +124,8 @@ export enum TableName { KmsKeyVersion = "kms_key_versions", WorkflowIntegrations = "workflow_integrations", SlackIntegrations = "slack_integrations", - ProjectSlackConfigs = "project_slack_configs" + ProjectSlackConfigs = "project_slack_configs", + AppConnection = "app_connections" } export type TImmutableDBKeys = "id" | "createdAt" | "updatedAt"; diff --git a/backend/src/ee/services/audit-log/audit-log-types.ts b/backend/src/ee/services/audit-log/audit-log-types.ts index d3b8656652..368b1cd6c9 100644 --- a/backend/src/ee/services/audit-log/audit-log-types.ts +++ b/backend/src/ee/services/audit-log/audit-log-types.ts @@ -2,6 +2,7 @@ import { TCreateProjectTemplateDTO, TUpdateProjectTemplateDTO } from "@app/ee/services/project-template/project-template-types"; +import { AppConnection, TCreateAppConnectionDTO, TUpdateAppConnectionDTO } from "@app/lib/app-connections"; import { SymmetricEncryption } from "@app/lib/crypto/cipher"; import { TProjectPermission } from "@app/lib/types"; import { ActorType } from "@app/services/auth/auth-type"; @@ -208,7 +209,12 @@ export enum EventType { CREATE_PROJECT_TEMPLATE = "create-project-template", UPDATE_PROJECT_TEMPLATE = "update-project-template", DELETE_PROJECT_TEMPLATE = "delete-project-template", - APPLY_PROJECT_TEMPLATE = "apply-project-template" + APPLY_PROJECT_TEMPLATE = "apply-project-template", + GET_APP_CONNECTIONS = "get-app-connections", + GET_APP_CONNECTION = "get-app-connection", + CREATE_APP_CONNECTION = "create-app-connection", + UPDATE_APP_CONNECTION = "update-app-connection", + DELETE_APP_CONNECTION = "delete-app-connection" } interface UserActorMetadata { @@ -1742,6 +1748,37 @@ interface ApplyProjectTemplateEvent { }; } +interface GetAppConnectionsEvent { + type: EventType.GET_APP_CONNECTIONS; + metadata?: { + app: AppConnection; + }; +} + +interface GetAppConnectionEvent { + type: EventType.GET_APP_CONNECTION; + metadata: { + connectionId: string; + }; +} + +interface CreateAppConnectionEvent { + type: EventType.CREATE_APP_CONNECTION; + metadata: Omit & { connectionId: string }; +} + +interface UpdateAppConnectionEvent { + type: EventType.UPDATE_APP_CONNECTION; + metadata: Omit & { connectionId: string; credentialsUpdated: boolean }; +} + +interface DeleteAppConnectionEvent { + type: EventType.DELETE_APP_CONNECTION; + metadata: { + connectionId: string; + }; +} + export type Event = | GetSecretsEvent | GetSecretEvent @@ -1902,4 +1939,9 @@ export type Event = | CreateProjectTemplateEvent | UpdateProjectTemplateEvent | DeleteProjectTemplateEvent - | ApplyProjectTemplateEvent; + | ApplyProjectTemplateEvent + | GetAppConnectionsEvent + | GetAppConnectionEvent + | CreateAppConnectionEvent + | UpdateAppConnectionEvent + | DeleteAppConnectionEvent; diff --git a/backend/src/ee/services/license/license-fns.ts b/backend/src/ee/services/license/license-fns.ts index 70c2995641..69daa85146 100644 --- a/backend/src/ee/services/license/license-fns.ts +++ b/backend/src/ee/services/license/license-fns.ts @@ -49,7 +49,8 @@ export const getDefaultOnPremFeatures = (): TFeatureSet => ({ }, pkiEst: false, enforceMfa: false, - projectTemplates: false + projectTemplates: false, + appConnections: false }); export const setupLicenseRequestWithStore = (baseURL: string, refreshUrl: string, licenseKey: string) => { diff --git a/backend/src/ee/services/license/license-types.ts b/backend/src/ee/services/license/license-types.ts index 622b0e06b0..381044f825 100644 --- a/backend/src/ee/services/license/license-types.ts +++ b/backend/src/ee/services/license/license-types.ts @@ -67,6 +67,7 @@ export type TFeatureSet = { pkiEst: boolean; enforceMfa: boolean; projectTemplates: false; + appConnections: false; // TODO: remove once live }; export type TOrgPlansTableDTO = { diff --git a/backend/src/ee/services/permission/org-permission.ts b/backend/src/ee/services/permission/org-permission.ts index aac45b2d5c..487d2155c8 100644 --- a/backend/src/ee/services/permission/org-permission.ts +++ b/backend/src/ee/services/permission/org-permission.ts @@ -27,7 +27,8 @@ export enum OrgPermissionSubjects { Kms = "kms", AdminConsole = "organization-admin-console", AuditLogs = "audit-logs", - ProjectTemplates = "project-templates" + ProjectTemplates = "project-templates", + AppConnections = "app-connections" } export type OrgPermissionSet = @@ -46,6 +47,7 @@ export type OrgPermissionSet = | [OrgPermissionActions, OrgPermissionSubjects.Kms] | [OrgPermissionActions, OrgPermissionSubjects.AuditLogs] | [OrgPermissionActions, OrgPermissionSubjects.ProjectTemplates] + | [OrgPermissionActions, OrgPermissionSubjects.AppConnections] | [OrgPermissionAdminConsoleAction, OrgPermissionSubjects.AdminConsole]; const buildAdminPermission = () => { @@ -123,6 +125,11 @@ const buildAdminPermission = () => { can(OrgPermissionActions.Edit, OrgPermissionSubjects.ProjectTemplates); can(OrgPermissionActions.Delete, OrgPermissionSubjects.ProjectTemplates); + can(OrgPermissionActions.Read, OrgPermissionSubjects.AppConnections); + can(OrgPermissionActions.Create, OrgPermissionSubjects.AppConnections); + can(OrgPermissionActions.Edit, OrgPermissionSubjects.AppConnections); + can(OrgPermissionActions.Delete, OrgPermissionSubjects.AppConnections); + can(OrgPermissionAdminConsoleAction.AccessAllProjects, OrgPermissionSubjects.AdminConsole); return rules; @@ -153,6 +160,8 @@ const buildMemberPermission = () => { can(OrgPermissionActions.Read, OrgPermissionSubjects.AuditLogs); + can(OrgPermissionActions.Read, OrgPermissionSubjects.AppConnections); + return rules; }; diff --git a/backend/src/lib/api-docs/constants.ts b/backend/src/lib/api-docs/constants.ts index fabcae4088..79af7a80e6 100644 --- a/backend/src/lib/api-docs/constants.ts +++ b/backend/src/lib/api-docs/constants.ts @@ -1,3 +1,6 @@ +import { AppConnection } from "@app/lib/app-connections"; +import { APP_CONNECTION_NAME_MAP } from "@app/lib/app-connections/maps"; + export const GROUPS = { CREATE: { name: "The name of the group to create.", @@ -1515,3 +1518,32 @@ export const ProjectTemplates = { templateId: "The ID of the project template to be deleted." } }; + +export const AppConnections = { + GET_BY_ID: (app: AppConnection) => ({ + connectionId: `The ID of the ${APP_CONNECTION_NAME_MAP[app]} Connection to retrieve.` + }), + GET_BY_NAME: (app: AppConnection) => ({ + connectionName: `The name of the ${APP_CONNECTION_NAME_MAP[app]} Connection to retrieve.` + }), + CREATE: (app: AppConnection) => { + const appName = APP_CONNECTION_NAME_MAP[app]; + return { + name: `The name of the ${appName} Connection to create. Must be slug-friendly.`, + credentials: `The credentials used to connect with ${appName}.`, + method: `The method used to authenticate with ${appName}.` + }; + }, + UPDATE: (app: AppConnection) => { + const appName = APP_CONNECTION_NAME_MAP[app]; + return { + connectionId: `The ID of the ${appName} Connection to be updated.`, + name: `The updated name of the ${appName} Connection. Must be slug-friendly.`, + credentials: `The credentials used to connect with ${appName}.`, + method: `The method used to authenticate with ${appName}.` + }; + }, + DELETE: (app: AppConnection) => ({ + connectionId: `The ID of the ${app} connection to be deleted.` + }) +}; diff --git a/backend/src/lib/app-connections/app-connection-enums.ts b/backend/src/lib/app-connections/app-connection-enums.ts new file mode 100644 index 0000000000..d69b7dec1a --- /dev/null +++ b/backend/src/lib/app-connections/app-connection-enums.ts @@ -0,0 +1,4 @@ +export enum AppConnection { + GitHub = "github", + AWS = "aws" +} diff --git a/backend/src/lib/app-connections/app-connection-types.ts b/backend/src/lib/app-connections/app-connection-types.ts new file mode 100644 index 0000000000..1093934715 --- /dev/null +++ b/backend/src/lib/app-connections/app-connection-types.ts @@ -0,0 +1,26 @@ +import { TAwsConnection } from "@app/lib/app-connections/aws/aws-connection-types"; +import { TGitHubConnection, TGitHubConnectionInput } from "@app/lib/app-connections/github"; +import { DiscriminativePick } from "@app/lib/types"; + +import { AppConnection } from "./app-connection-enums"; + +export type AppConnectionListItem = { + app: AppConnection; + name: string; + methods: string[]; +}; + +export type TAppConnection = { id: string } & (TAwsConnection | TGitHubConnection); + +export type TAppConnectionInput = { id: string } & (TAwsConnection | TGitHubConnectionInput); + +export type TCreateAppConnectionDTO = Pick; + +export type TUpdateAppConnectionDTO = Partial> & { + connectionId: string; +}; + +export type TAppConnectionConfig = { orgId: string } & DiscriminativePick< + TAppConnectionInput, + "app" | "method" | "credentials" +>; diff --git a/backend/src/lib/app-connections/aws/aws-connection-enums.ts b/backend/src/lib/app-connections/aws/aws-connection-enums.ts new file mode 100644 index 0000000000..0b571de0c8 --- /dev/null +++ b/backend/src/lib/app-connections/aws/aws-connection-enums.ts @@ -0,0 +1,4 @@ +export enum AwsConnectionMethod { + AssumeRole = "assume-role", + AccessKey = "access-key" +} diff --git a/backend/src/lib/app-connections/aws/aws-connection-fns.ts b/backend/src/lib/app-connections/aws/aws-connection-fns.ts new file mode 100644 index 0000000000..316939f7fc --- /dev/null +++ b/backend/src/lib/app-connections/aws/aws-connection-fns.ts @@ -0,0 +1,105 @@ +import { AssumeRoleCommand, STSClient } from "@aws-sdk/client-sts"; +import AWS from "aws-sdk"; +import { randomUUID } from "crypto"; + +import { AppConnection } from "@app/lib/app-connections/app-connection-enums"; +import { TAwsConnectionConfig } from "@app/lib/app-connections/aws/aws-connection-types"; +import { getConfig } from "@app/lib/config/env"; +import { BadRequestError, InternalServerError } from "@app/lib/errors"; + +import { AwsConnectionMethod } from "./aws-connection-enums"; + +export const getAwsAppConnectionListItem = () => { + const { INF_APP_CONNECTION_AWS_ACCESS_KEY_ID } = getConfig(); + + return { + name: "AWS", + app: AppConnection.AWS, + methods: Object.values(AwsConnectionMethod), + accessKeyId: INF_APP_CONNECTION_AWS_ACCESS_KEY_ID + }; +}; + +export const getAwsConnectionConfig = async (appConnection: TAwsConnectionConfig, region = "us-east-1") => { + const appCfg = getConfig(); + + let accessKeyId: string; + let secretAccessKey: string; + let sessionToken: undefined | string; + + const { method, credentials, orgId } = appConnection; + + switch (method) { + case AwsConnectionMethod.AssumeRole: { + const client = new STSClient({ + region, + credentials: + appCfg.INF_APP_CONNECTION_AWS_ACCESS_KEY_ID && appCfg.INF_APP_CONNECTION_AWS_SECRET_ACCESS_KEY + ? { + accessKeyId: appCfg.INF_APP_CONNECTION_AWS_ACCESS_KEY_ID, + secretAccessKey: appCfg.INF_APP_CONNECTION_AWS_SECRET_ACCESS_KEY + } + : undefined // if hosting on AWS + }); + + const command = new AssumeRoleCommand({ + RoleArn: credentials.roleArn, + RoleSessionName: `infisical-app-connection-${randomUUID()}`, + DurationSeconds: 900, // 15 mins + ExternalId: orgId + }); + + const assumeRes = await client.send(command); + + if (!assumeRes.Credentials?.AccessKeyId || !assumeRes.Credentials?.SecretAccessKey) { + throw new BadRequestError({ message: "Failed to assume role - verify credentials and role configuration" }); + } + + accessKeyId = assumeRes.Credentials.AccessKeyId; + secretAccessKey = assumeRes.Credentials.SecretAccessKey; + sessionToken = assumeRes.Credentials?.SessionToken; + break; + } + case AwsConnectionMethod.AccessKey: { + accessKeyId = credentials.accessKeyId; + secretAccessKey = credentials.secretAccessKey; + break; + } + default: + // eslint-disable-next-line @typescript-eslint/restrict-template-expressions + throw new InternalServerError({ message: `Unsupported AWS connection method: ${method}` }); + } + + return new AWS.Config({ + region, + credentials: { + accessKeyId, + secretAccessKey, + sessionToken + } + }); +}; + +export const validateAwsConnectionCredentials = async (appConnection: TAwsConnectionConfig) => { + const awsConfig = await getAwsConnectionConfig(appConnection); + const sts = new AWS.STS(awsConfig); + let resp: Awaited["promise"]>>; + + try { + resp = await sts.getCallerIdentity().promise(); + } catch (e: unknown) { + throw new BadRequestError({ + message: `Unable to validate connection - verify credentials` + }); + } + + if (resp.$response.httpResponse.statusCode !== 200) + throw new InternalServerError({ + message: `Unable to validate credentials: ${ + resp.$response.error?.message ?? + `AWS responded with a status code of ${resp.$response.httpResponse.statusCode}. Verify credentials and try again.` + }` + }); + + return appConnection.credentials; +}; diff --git a/backend/src/lib/app-connections/aws/aws-connection-schemas.ts b/backend/src/lib/app-connections/aws/aws-connection-schemas.ts new file mode 100644 index 0000000000..c8c7a7c34b --- /dev/null +++ b/backend/src/lib/app-connections/aws/aws-connection-schemas.ts @@ -0,0 +1,69 @@ +import { z } from "zod"; + +import { AppConnections } from "@app/lib/api-docs"; +import { slugSchema } from "@app/server/lib/schemas"; +import { BaseAppConnectionSchema } from "@app/services/app-connection/app-connection-schemas"; + +import { AppConnection } from "../app-connection-enums"; +import { AwsConnectionMethod } from "./aws-connection-enums"; + +export const AwsConnectionAssumeRoleCredentialsSchema = z.object({ + roleArn: z.string().min(1, "Role ARN required") +}); + +export const AwsConnectionAccessTokenCredentialsSchema = z.object({ + accessKeyId: z.string().min(1, "Access Key ID required"), + secretAccessKey: z.string().min(1, "Secret Access Key required") +}); + +const BaseAwsConnectionSchema = BaseAppConnectionSchema.extend({ app: z.literal(AppConnection.AWS) }); + +export const AwsConnectionSchema = z.intersection( + BaseAwsConnectionSchema, + z.discriminatedUnion("method", [ + z.object({ + method: z.literal(AwsConnectionMethod.AssumeRole), + credentials: AwsConnectionAssumeRoleCredentialsSchema + }), + z.object({ + method: z.literal(AwsConnectionMethod.AccessKey), + credentials: AwsConnectionAccessTokenCredentialsSchema + }) + ]) +); + +export const SanitizedAwsConnectionSchema = z.discriminatedUnion("method", [ + BaseAwsConnectionSchema.extend({ + method: z.literal(AwsConnectionMethod.AssumeRole), + credentials: AwsConnectionAssumeRoleCredentialsSchema.omit({ roleArn: true }) + }), + BaseAwsConnectionSchema.extend({ + method: z.literal(AwsConnectionMethod.AccessKey), + credentials: AwsConnectionAccessTokenCredentialsSchema.omit({ secretAccessKey: true }) + }) +]); + +export const CreateAwsConnectionSchema = z + .discriminatedUnion("method", [ + z.object({ + method: z.literal(AwsConnectionMethod.AssumeRole).describe(AppConnections.CREATE(AppConnection.AWS).method), + credentials: AwsConnectionAssumeRoleCredentialsSchema.describe( + AppConnections.CREATE(AppConnection.AWS).credentials + ) + }), + z.object({ + method: z.literal(AwsConnectionMethod.AccessKey).describe(AppConnections.CREATE(AppConnection.AWS).method), + credentials: AwsConnectionAccessTokenCredentialsSchema.describe( + AppConnections.CREATE(AppConnection.AWS).credentials + ) + }) + ]) + .and(z.object({ name: slugSchema({ field: "name" }).describe(AppConnections.CREATE(AppConnection.AWS).name) })); + +export const UpdateAwsConnectionSchema = z.object({ + name: slugSchema({ field: "name" }).optional().describe(AppConnections.UPDATE(AppConnection.AWS).name), + credentials: z + .union([AwsConnectionAccessTokenCredentialsSchema, AwsConnectionAssumeRoleCredentialsSchema]) + .optional() + .describe(AppConnections.UPDATE(AppConnection.AWS).credentials) +}); diff --git a/backend/src/lib/app-connections/aws/aws-connection-types.ts b/backend/src/lib/app-connections/aws/aws-connection-types.ts new file mode 100644 index 0000000000..86515eff5b --- /dev/null +++ b/backend/src/lib/app-connections/aws/aws-connection-types.ts @@ -0,0 +1,9 @@ +import { z } from "zod"; + +import { DiscriminativePick } from "@app/lib/types"; + +import { AwsConnectionSchema } from "./aws-connection-schemas"; + +export type TAwsConnection = z.infer; + +export type TAwsConnectionConfig = DiscriminativePick; diff --git a/backend/src/lib/app-connections/aws/index.ts b/backend/src/lib/app-connections/aws/index.ts new file mode 100644 index 0000000000..4608a3483a --- /dev/null +++ b/backend/src/lib/app-connections/aws/index.ts @@ -0,0 +1,4 @@ +export * from "./aws-connection-enums"; +export * from "./aws-connection-fns"; +export * from "./aws-connection-schemas"; +export * from "./aws-connection-types"; diff --git a/backend/src/lib/app-connections/github/github-connection-enums.ts b/backend/src/lib/app-connections/github/github-connection-enums.ts new file mode 100644 index 0000000000..77a4eebac5 --- /dev/null +++ b/backend/src/lib/app-connections/github/github-connection-enums.ts @@ -0,0 +1,4 @@ +export enum GitHubConnectionMethod { + OAuth = "oauth", + App = "github-app" +} diff --git a/backend/src/lib/app-connections/github/github-connection-fns.ts b/backend/src/lib/app-connections/github/github-connection-fns.ts new file mode 100644 index 0000000000..b952289cd6 --- /dev/null +++ b/backend/src/lib/app-connections/github/github-connection-fns.ts @@ -0,0 +1,129 @@ +import { AxiosResponse } from "axios"; + +import { getConfig } from "@app/lib/config/env"; +import { request } from "@app/lib/config/request"; +import { BadRequestError, ForbiddenRequestError, InternalServerError } from "@app/lib/errors"; +import { IntegrationUrls } from "@app/services/integration-auth/integration-list"; + +import { AppConnection } from "../app-connection-enums"; +import { APP_CONNECTION_METHOD_NAME_MAP } from "../maps"; +import { GitHubConnectionMethod } from "./github-connection-enums"; +import { TGitHubConnectionConfig } from "./github-connection-types"; + +export const getGitHubConnectionListItem = () => { + const { INF_APP_CONNECTION_GITHUB_OAUTH_CLIENT_ID, INF_APP_CONNECTION_GITHUB_APP_SLUG } = getConfig(); + + return { + name: "GitHub", + app: AppConnection.GitHub, + methods: Object.values(GitHubConnectionMethod), + oauthClientId: INF_APP_CONNECTION_GITHUB_OAUTH_CLIENT_ID, + appClientSlug: INF_APP_CONNECTION_GITHUB_APP_SLUG + }; +}; + +type TokenRespData = { + access_token: string; + scope: string; + token_type: string; +}; + +export const validateGitHubConnectionCredentials = async (config: TGitHubConnectionConfig) => { + const { credentials, method } = config; + + const { + INF_APP_CONNECTION_GITHUB_OAUTH_CLIENT_ID, + INF_APP_CONNECTION_GITHUB_OAUTH_CLIENT_SECRET, + INF_APP_CONNECTION_GITHUB_APP_CLIENT_ID, + INF_APP_CONNECTION_GITHUB_APP_CLIENT_SECRET, + SITE_URL + } = getConfig(); + + const { clientId, clientSecret } = + method === GitHubConnectionMethod.App + ? { + clientId: INF_APP_CONNECTION_GITHUB_APP_CLIENT_ID, + clientSecret: INF_APP_CONNECTION_GITHUB_APP_CLIENT_SECRET + } + : // oauth + { + clientId: INF_APP_CONNECTION_GITHUB_OAUTH_CLIENT_ID, + clientSecret: INF_APP_CONNECTION_GITHUB_OAUTH_CLIENT_SECRET + }; + + if (!clientId || !clientSecret) { + throw new InternalServerError({ + message: `GitHub ${APP_CONNECTION_METHOD_NAME_MAP[method]} environment variables have not been configured` + }); + } + + let tokenResp: AxiosResponse; + + try { + tokenResp = await request.get("https://github.com/login/oauth/access_token", { + params: { + client_id: clientId, + client_secret: clientSecret, + code: credentials.code, + redirect_uri: `${SITE_URL}/app-connections/github/oauth/callback` + }, + headers: { + Accept: "application/json", + "Accept-Encoding": "application/json" + } + }); + } catch (e: unknown) { + throw new BadRequestError({ + message: `Unable to validate connection - verify credentials` + }); + } + + if (tokenResp.status !== 200) { + throw new BadRequestError({ + message: `Unable to validate credentials: GitHub responded with a status code of ${tokenResp.status} (${tokenResp.statusText}). Verify credentials and try again.` + }); + } + + if (method === GitHubConnectionMethod.App) { + const installationsResp = await request.get<{ + installations: { + id: number; + account: { + login: string; + }; + }[]; + }>(IntegrationUrls.GITHUB_USER_INSTALLATIONS, { + headers: { + Accept: "application/json", + Authorization: `Bearer ${tokenResp.data.access_token}`, + "Accept-Encoding": "application/json" + } + }); + + const matchingInstallation = installationsResp.data.installations.find( + (installation) => installation.id === +credentials.installationId + ); + + if (!matchingInstallation) { + throw new ForbiddenRequestError({ + message: "User does not have access to the provided installation" + }); + } + } + + switch (method) { + case GitHubConnectionMethod.App: + return { + // access token not needed for GitHub App + installationId: credentials.installationId + }; + case GitHubConnectionMethod.OAuth: + return { + accessToken: tokenResp.data.access_token + }; + default: + throw new InternalServerError({ + message: `Unhandled GitHub connection method: ${method as GitHubConnectionMethod}` + }); + } +}; diff --git a/backend/src/lib/app-connections/github/github-connection-schemas.ts b/backend/src/lib/app-connections/github/github-connection-schemas.ts new file mode 100644 index 0000000000..84beb17f13 --- /dev/null +++ b/backend/src/lib/app-connections/github/github-connection-schemas.ts @@ -0,0 +1,77 @@ +import { z } from "zod"; + +import { AppConnections } from "@app/lib/api-docs"; +import { AppConnection } from "@app/lib/app-connections"; +import { slugSchema } from "@app/server/lib/schemas"; +import { BaseAppConnectionSchema } from "@app/services/app-connection/app-connection-schemas"; + +import { GitHubConnectionMethod } from "./github-connection-enums"; + +export const GitHubConnectionOAuthInputCredentialsSchema = z.object({ + code: z.string().min(1, "OAuth code required") +}); + +export const GitHubConnectionAppInputCredentialsSchema = z.object({ + code: z.string().min(1, "GitHub App code required"), + installationId: z.string().min(1, "GitHub App Installation ID required") +}); + +export const GitHubConnectionOAuthOutputCredentialsSchema = z.object({ + accessToken: z.string() +}); + +export const GitHubConnectionAppOutputCredentialsSchema = z.object({ + installationId: z.string() +}); + +export const CreateGitHubConnectionSchema = z + .discriminatedUnion("method", [ + z.object({ + method: z.literal(GitHubConnectionMethod.App).describe(AppConnections.CREATE(AppConnection.GitHub).method), + credentials: GitHubConnectionAppInputCredentialsSchema.describe( + AppConnections.CREATE(AppConnection.GitHub).credentials + ) + }), + z.object({ + method: z.literal(GitHubConnectionMethod.OAuth).describe(AppConnections.CREATE(AppConnection.GitHub).method), + credentials: GitHubConnectionOAuthInputCredentialsSchema.describe( + AppConnections.CREATE(AppConnection.GitHub).credentials + ) + }) + ]) + .and(z.object({ name: slugSchema({ field: "name" }).describe(AppConnections.CREATE(AppConnection.GitHub).name) })); + +export const UpdateGitHubConnectionSchema = z.object({ + name: slugSchema({ field: "name" }).optional().describe(AppConnections.UPDATE(AppConnection.GitHub).name), + credentials: z + .union([GitHubConnectionAppInputCredentialsSchema, GitHubConnectionOAuthInputCredentialsSchema]) + .optional() + .describe(AppConnections.UPDATE(AppConnection.GitHub).credentials) +}); + +const BaseGitHubConnectionSchema = BaseAppConnectionSchema.extend({ app: z.literal(AppConnection.GitHub) }); + +export const GitHubAppConnectionSchema = z.intersection( + BaseGitHubConnectionSchema, + z.discriminatedUnion("method", [ + z.object({ + method: z.literal(GitHubConnectionMethod.App), + credentials: GitHubConnectionAppOutputCredentialsSchema + }), + z.object({ + method: z.literal(GitHubConnectionMethod.OAuth), + credentials: GitHubConnectionOAuthOutputCredentialsSchema + }) + ]) +); + +export const SanitizedGitHubConnectionSchema = z.discriminatedUnion("method", [ + BaseGitHubConnectionSchema.extend({ + method: z.literal(GitHubConnectionMethod.App), + credentials: GitHubConnectionAppOutputCredentialsSchema.omit({ installationId: true }) + }), + BaseGitHubConnectionSchema.extend({ + method: z.literal(GitHubConnectionMethod.OAuth), + credentials: GitHubConnectionOAuthOutputCredentialsSchema.omit({ accessToken: true }) + }) +]); diff --git a/backend/src/lib/app-connections/github/github-connection-types.ts b/backend/src/lib/app-connections/github/github-connection-types.ts new file mode 100644 index 0000000000..7a96fab812 --- /dev/null +++ b/backend/src/lib/app-connections/github/github-connection-types.ts @@ -0,0 +1,14 @@ +import { z } from "zod"; + +import { DiscriminativePick } from "@app/lib/types"; + +import { AppConnection } from "../app-connection-enums"; +import { CreateGitHubConnectionSchema, GitHubAppConnectionSchema } from "./github-connection-schemas"; + +export type TGitHubConnectionConfig = DiscriminativePick; + +export type TGitHubConnection = z.infer; + +export type TGitHubConnectionInput = z.infer & { + app: AppConnection.GitHub; +}; diff --git a/backend/src/lib/app-connections/github/index.ts b/backend/src/lib/app-connections/github/index.ts new file mode 100644 index 0000000000..35915046be --- /dev/null +++ b/backend/src/lib/app-connections/github/index.ts @@ -0,0 +1,4 @@ +export * from "./github-connection-enums"; +export * from "./github-connection-fns"; +export * from "./github-connection-schemas"; +export * from "./github-connection-types"; diff --git a/backend/src/lib/app-connections/index.ts b/backend/src/lib/app-connections/index.ts new file mode 100644 index 0000000000..6c0b398c90 --- /dev/null +++ b/backend/src/lib/app-connections/index.ts @@ -0,0 +1,2 @@ +export * from "./app-connection-enums"; +export * from "./app-connection-types"; diff --git a/backend/src/lib/app-connections/maps.ts b/backend/src/lib/app-connections/maps.ts new file mode 100644 index 0000000000..3914020135 --- /dev/null +++ b/backend/src/lib/app-connections/maps.ts @@ -0,0 +1,17 @@ +import { TAppConnection } from "@app/lib/app-connections/app-connection-types"; + +import { AppConnection } from "./app-connection-enums"; +import { AwsConnectionMethod } from "./aws/aws-connection-enums"; +import { GitHubConnectionMethod } from "./github/github-connection-enums"; + +export const APP_CONNECTION_NAME_MAP: Record = { + [AppConnection.AWS]: "AWS", + [AppConnection.GitHub]: "GitHub" +}; + +export const APP_CONNECTION_METHOD_NAME_MAP: Record = { + [AwsConnectionMethod.AssumeRole]: "Assume Role", + [AwsConnectionMethod.AccessKey]: "Access Key", + [GitHubConnectionMethod.App]: "Github App", + [GitHubConnectionMethod.OAuth]: "OAuth" +}; diff --git a/backend/src/lib/config/env.ts b/backend/src/lib/config/env.ts index 7bb95468a6..7b6f356fd7 100644 --- a/backend/src/lib/config/env.ts +++ b/backend/src/lib/config/env.ts @@ -180,7 +180,24 @@ const envSchema = z HSM_SLOT: z.coerce.number().optional().default(0), USE_PG_QUEUE: zodStrBool.default("false"), - SHOULD_INIT_PG_QUEUE: zodStrBool.default("false") + SHOULD_INIT_PG_QUEUE: zodStrBool.default("false"), + + /* App Connections ----------------------------------------------------------------------------- */ + + // aws + INF_APP_CONNECTION_AWS_ACCESS_KEY_ID: zpStr(z.string().optional()), + INF_APP_CONNECTION_AWS_SECRET_ACCESS_KEY: zpStr(z.string().optional()), + + // github oauth + INF_APP_CONNECTION_GITHUB_OAUTH_CLIENT_ID: zpStr(z.string().optional()), + INF_APP_CONNECTION_GITHUB_OAUTH_CLIENT_SECRET: zpStr(z.string().optional()), + + // github app + INF_APP_CONNECTION_GITHUB_APP_CLIENT_ID: zpStr(z.string().optional()), + INF_APP_CONNECTION_GITHUB_APP_CLIENT_SECRET: zpStr(z.string().optional()), + INF_APP_CONNECTION_GITHUB_APP_PRIVATE_KEY: zpStr(z.string().optional()), + INF_APP_CONNECTION_GITHUB_APP_SLUG: zpStr(z.string().optional()), + INF_APP_CONNECTION_GITHUB_APP_ID: zpStr(z.string().optional()) }) // To ensure that basic encryption is always possible. .refine( diff --git a/backend/src/lib/types/index.ts b/backend/src/lib/types/index.ts index 6ebf91f36f..b8b272017d 100644 --- a/backend/src/lib/types/index.ts +++ b/backend/src/lib/types/index.ts @@ -43,6 +43,8 @@ export type RequiredKeys = { export type PickRequired = Pick>; +export type DiscriminativePick = T extends unknown ? Pick : never; + export enum EnforcementLevel { Hard = "hard", Soft = "soft" diff --git a/backend/src/server/plugins/error-handler.ts b/backend/src/server/plugins/error-handler.ts index ac4803c985..a6142cabca 100644 --- a/backend/src/server/plugins/error-handler.ts +++ b/backend/src/server/plugins/error-handler.ts @@ -52,13 +52,20 @@ export const fastifyErrHandler = fastifyPlugin(async (server: FastifyZodProvider message: error.message, error: error.name }); - } else if (error instanceof DatabaseError || error instanceof InternalServerError) { + } else if (error instanceof DatabaseError) { void res.status(HttpStatusCodes.InternalServerError).send({ reqId: req.id, statusCode: HttpStatusCodes.InternalServerError, message: "Something went wrong", error: error.name }); + } else if (error instanceof InternalServerError) { + void res.status(HttpStatusCodes.InternalServerError).send({ + reqId: req.id, + statusCode: HttpStatusCodes.InternalServerError, + message: error.message ?? "Something went wrong", + error: error.name + }); } else if (error instanceof GatewayTimeoutError) { void res.status(HttpStatusCodes.GatewayTimeout).send({ reqId: req.id, diff --git a/backend/src/server/routes/index.ts b/backend/src/server/routes/index.ts index 8890b0f4dc..f2a7ab0f58 100644 --- a/backend/src/server/routes/index.ts +++ b/backend/src/server/routes/index.ts @@ -84,6 +84,8 @@ import { readLimit } from "@app/server/config/rateLimiter"; import { accessTokenQueueServiceFactory } from "@app/services/access-token-queue/access-token-queue"; import { apiKeyDALFactory } from "@app/services/api-key/api-key-dal"; import { apiKeyServiceFactory } from "@app/services/api-key/api-key-service"; +import { appConnectionDALFactory } from "@app/services/app-connection/app-connection-dal"; +import { appConnectionServiceFactory } from "@app/services/app-connection/app-connection-service"; import { authDALFactory } from "@app/services/auth/auth-dal"; import { authLoginServiceFactory } from "@app/services/auth/auth-login-service"; import { authPaswordServiceFactory } from "@app/services/auth/auth-password-service"; @@ -307,6 +309,7 @@ export const registerRoutes = async ( const auditLogStreamDAL = auditLogStreamDALFactory(db); const trustedIpDAL = trustedIpDALFactory(db); const telemetryDAL = telemetryDALFactory(db); + const appConnectionDAL = appConnectionDALFactory(db); // ee db layer ops const permissionDAL = permissionDALFactory(db); @@ -1308,6 +1311,13 @@ export const registerRoutes = async ( externalGroupOrgRoleMappingDAL }); + const appConnectionService = appConnectionServiceFactory({ + appConnectionDAL, + permissionService, + kmsService, + licenseService + }); + await superAdminService.initServerCfg(); // setup the communication with license key server @@ -1402,7 +1412,8 @@ export const registerRoutes = async ( migration: migrationService, externalGroupOrgRoleMapping: externalGroupOrgRoleMappingService, projectTemplate: projectTemplateService, - totp: totpService + totp: totpService, + appConnection: appConnectionService }); const cronJobs: CronJob[] = []; 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 new file mode 100644 index 0000000000..8cee4f7a53 --- /dev/null +++ b/backend/src/server/routes/v1/app-connection-routers/app-connection-router.ts @@ -0,0 +1,73 @@ +import { z } from "zod"; + +import { EventType } from "@app/ee/services/audit-log/audit-log-types"; +import { AppConnection } from "@app/lib/app-connections"; +import { SanitizedAwsConnectionSchema } from "@app/lib/app-connections/aws"; +import { SanitizedGitHubConnectionSchema } from "@app/lib/app-connections/github"; +import { readLimit } from "@app/server/config/rateLimiter"; +import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; +import { AuthMode } from "@app/services/auth/auth-type"; + +// can't use discriminated due to multiple schemas for certain apps +export const SanitizedAppConnectionSchema = z.union([ + ...SanitizedAwsConnectionSchema.options, + ...SanitizedGitHubConnectionSchema.options +]); + +export const registerAppConnectionRouter = async (server: FastifyZodProvider) => { + server.route({ + method: "GET", + url: "/options", + config: { + rateLimit: readLimit + }, + schema: { + description: "List the available App Connection Options.", + response: { + 200: z.object({ + appConnectionOptions: z + .object({ + name: z.string(), + app: z.nativeEnum(AppConnection), + methods: z.string().array() + }) + .passthrough() + .array() + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.SERVICE_TOKEN]), + handler: () => { + const appConnectionOptions = server.services.appConnection.listAppConnectionOptions(); + return { appConnectionOptions }; + } + }); + + server.route({ + method: "GET", + url: "/", + config: { + rateLimit: readLimit + }, + schema: { + description: "List all the App Connections for the current organization.", + response: { + 200: z.object({ appConnections: SanitizedAppConnectionSchema.array() }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.SERVICE_TOKEN]), + handler: async (req) => { + const appConnections = await server.services.appConnection.listAppConnectionsByOrg(req.permission); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + orgId: req.permission.orgId, + event: { + type: EventType.GET_APP_CONNECTIONS + } + }); + + return { appConnections }; + } + }); +}; diff --git a/backend/src/server/routes/v1/app-connection-routers/apps/app-connection-endpoints.ts b/backend/src/server/routes/v1/app-connection-routers/apps/app-connection-endpoints.ts new file mode 100644 index 0000000000..fa72e564dc --- /dev/null +++ b/backend/src/server/routes/v1/app-connection-routers/apps/app-connection-endpoints.ts @@ -0,0 +1,262 @@ +import { z } from "zod"; + +import { EventType } from "@app/ee/services/audit-log/audit-log-types"; +import { AppConnections } from "@app/lib/api-docs"; +import { AppConnection, TAppConnection, TAppConnectionInput } from "@app/lib/app-connections"; +import { APP_CONNECTION_NAME_MAP } from "@app/lib/app-connections/maps"; +import { readLimit, writeLimit } from "@app/server/config/rateLimiter"; +import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; +import { AuthMode } from "@app/services/auth/auth-type"; + +export const registerAppConnectionEndpoints = ({ + server, + app, + createSchema, + updateSchema, + responseSchema +}: { + app: AppConnection; + server: FastifyZodProvider; + createSchema: z.ZodType<{ name: string; method: I["method"]; credentials: I["credentials"] }>; + updateSchema: z.ZodType<{ name?: string; credentials?: I["credentials"] }>; + responseSchema: z.ZodTypeAny; +}) => { + const appName = APP_CONNECTION_NAME_MAP[app]; + + server.route({ + method: "GET", + url: `/`, + config: { + rateLimit: readLimit + }, + schema: { + description: `List the ${appName} Connections for the current organization.`, + response: { + 200: z.object({ appConnections: responseSchema.array() }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.SERVICE_TOKEN]), + handler: async (req) => { + const appConnections = (await server.services.appConnection.listAppConnectionsByOrg(req.permission, app)) as T[]; + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + orgId: req.permission.orgId, + event: { + type: EventType.GET_APP_CONNECTIONS, + metadata: { + app + } + } + }); + + return { appConnections }; + } + }); + + server.route({ + method: "GET", + url: "/:connectionId", + config: { + rateLimit: readLimit + }, + schema: { + description: `Get the specified ${appName} Connection by ID.`, + params: z.object({ + connectionId: z.string().uuid().describe(AppConnections.GET_BY_ID(app).connectionId) + }), + response: { + 200: z.object({ appConnection: responseSchema }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.SERVICE_TOKEN]), + handler: async (req) => { + const { connectionId } = req.params; + + const appConnection = (await server.services.appConnection.findAppConnectionById( + app, + connectionId, + req.permission + )) as T; + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + orgId: req.permission.orgId, + event: { + type: EventType.GET_APP_CONNECTION, + metadata: { + connectionId + } + } + }); + + return { appConnection }; + } + }); + + server.route({ + method: "GET", + url: `/name/:connectionName`, + config: { + rateLimit: readLimit + }, + schema: { + description: `Get the specified ${appName} Connection by name.`, + params: z.object({ + connectionName: z + .string() + .min(0, "Connection name required") + .describe(AppConnections.GET_BY_NAME(app).connectionName) + }), + response: { + 200: z.object({ appConnection: responseSchema }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.SERVICE_TOKEN]), + handler: async (req) => { + const { connectionName } = req.params; + + const appConnection = (await server.services.appConnection.findAppConnectionByName( + app, + connectionName, + req.permission + )) as T; + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + orgId: req.permission.orgId, + event: { + type: EventType.GET_APP_CONNECTION, + metadata: { + connectionId: appConnection.id + } + } + }); + + return { appConnection }; + } + }); + + server.route({ + method: "POST", + url: "/", + config: { + rateLimit: writeLimit + }, + schema: { + description: `Create an ${appName} Connection for the current organization.`, + body: createSchema, + response: { + 200: z.object({ appConnection: responseSchema }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.SERVICE_TOKEN]), + handler: async (req) => { + const { name, method, credentials } = req.body; + + const appConnection = (await server.services.appConnection.createAppConnection( + { name, method, app, credentials }, + req.permission + )) as TAppConnection; + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + orgId: req.permission.orgId, + event: { + type: EventType.CREATE_APP_CONNECTION, + metadata: { + name, + method, + app, + connectionId: appConnection.id + } + } + }); + + return { appConnection }; + } + }); + + server.route({ + method: "PATCH", + url: "/:connectionId", + config: { + rateLimit: writeLimit + }, + schema: { + description: `Update the specified ${appName} Connection.`, + params: z.object({ + connectionId: z.string().uuid().describe(AppConnections.UPDATE(app).connectionId) + }), + body: updateSchema, + response: { + 200: z.object({ appConnection: responseSchema }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.SERVICE_TOKEN]), + handler: async (req) => { + const { name, credentials } = req.body; + const { connectionId } = req.params; + + const appConnection = (await server.services.appConnection.updateAppConnection( + { name, credentials, connectionId }, + req.permission + )) as T; + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + orgId: req.permission.orgId, + event: { + type: EventType.UPDATE_APP_CONNECTION, + metadata: { + name, + credentialsUpdated: Boolean(credentials), + connectionId + } + } + }); + + return { appConnection }; + } + }); + + server.route({ + method: "DELETE", + url: `/:connectionId`, + config: { + rateLimit: writeLimit + }, + schema: { + description: `Delete the specified ${appName} Connection.`, + params: z.object({ + connectionId: z.string().uuid().describe(AppConnections.DELETE(app).connectionId) + }), + response: { + 200: z.object({ appConnection: responseSchema }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.SERVICE_TOKEN]), + handler: async (req) => { + const { connectionId } = req.params; + + const appConnection = (await server.services.appConnection.deleteAppConnection( + app, + connectionId, + req.permission + )) as T; + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + orgId: req.permission.orgId, + event: { + type: EventType.DELETE_APP_CONNECTION, + metadata: { + connectionId + } + } + }); + + return { appConnection }; + } + }); +}; diff --git a/backend/src/server/routes/v1/app-connection-routers/apps/aws-connection-router.ts b/backend/src/server/routes/v1/app-connection-routers/apps/aws-connection-router.ts new file mode 100644 index 0000000000..b4cb468fd7 --- /dev/null +++ b/backend/src/server/routes/v1/app-connection-routers/apps/aws-connection-router.ts @@ -0,0 +1,17 @@ +import { AppConnection } from "@app/lib/app-connections"; +import { + CreateAwsConnectionSchema, + SanitizedAwsConnectionSchema, + UpdateAwsConnectionSchema +} from "@app/lib/app-connections/aws"; + +import { registerAppConnectionEndpoints } from "./app-connection-endpoints"; + +export const registerAwsConnectionRouter = async (server: FastifyZodProvider) => + registerAppConnectionEndpoints({ + app: AppConnection.AWS, + server, + responseSchema: SanitizedAwsConnectionSchema, + createSchema: CreateAwsConnectionSchema, + updateSchema: UpdateAwsConnectionSchema + }); diff --git a/backend/src/server/routes/v1/app-connection-routers/apps/github-connection-router.ts b/backend/src/server/routes/v1/app-connection-routers/apps/github-connection-router.ts new file mode 100644 index 0000000000..553c233547 --- /dev/null +++ b/backend/src/server/routes/v1/app-connection-routers/apps/github-connection-router.ts @@ -0,0 +1,17 @@ +import { AppConnection } from "@app/lib/app-connections"; +import { + CreateGitHubConnectionSchema, + GitHubAppConnectionSchema, + UpdateGitHubConnectionSchema +} from "@app/lib/app-connections/github"; + +import { registerAppConnectionEndpoints } from "./app-connection-endpoints"; + +export const registerGitHubConnectionRouter = async (server: FastifyZodProvider) => + registerAppConnectionEndpoints({ + app: AppConnection.GitHub, + server, + responseSchema: GitHubAppConnectionSchema, + createSchema: CreateGitHubConnectionSchema, + updateSchema: UpdateGitHubConnectionSchema + }); diff --git a/backend/src/server/routes/v1/app-connection-routers/apps/index.ts b/backend/src/server/routes/v1/app-connection-routers/apps/index.ts new file mode 100644 index 0000000000..b6fe7fc711 --- /dev/null +++ b/backend/src/server/routes/v1/app-connection-routers/apps/index.ts @@ -0,0 +1,8 @@ +import { AppConnection } from "@app/lib/app-connections"; +import { registerAwsConnectionRouter } from "@app/server/routes/v1/app-connection-routers/apps/aws-connection-router"; +import { registerGitHubConnectionRouter } from "@app/server/routes/v1/app-connection-routers/apps/github-connection-router"; + +export const APP_CONNECTION_REGISTER_MAP: Record Promise> = { + [AppConnection.AWS]: registerAwsConnectionRouter, + [AppConnection.GitHub]: registerGitHubConnectionRouter +}; diff --git a/backend/src/server/routes/v1/app-connection-routers/index.ts b/backend/src/server/routes/v1/app-connection-routers/index.ts new file mode 100644 index 0000000000..7200574495 --- /dev/null +++ b/backend/src/server/routes/v1/app-connection-routers/index.ts @@ -0,0 +1,2 @@ +export * from "./app-connection-router"; +export * from "./apps"; diff --git a/backend/src/server/routes/v1/index.ts b/backend/src/server/routes/v1/index.ts index a04f77b7ad..873e895b10 100644 --- a/backend/src/server/routes/v1/index.ts +++ b/backend/src/server/routes/v1/index.ts @@ -1,3 +1,5 @@ +import { APP_CONNECTION_REGISTER_MAP, registerAppConnectionRouter } from "src/server/routes/v1/app-connection-routers"; + import { registerCmekRouter } from "@app/server/routes/v1/cmek-router"; import { registerDashboardRouter } from "@app/server/routes/v1/dashboard-router"; @@ -110,4 +112,14 @@ export const registerV1Routes = async (server: FastifyZodProvider) => { await server.register(registerDashboardRouter, { prefix: "/dashboard" }); await server.register(registerCmekRouter, { prefix: "/kms" }); await server.register(registerExternalGroupOrgRoleMappingRouter, { prefix: "/external-group-mappings" }); + + await server.register( + async (appConnectionsRouter) => { + await appConnectionsRouter.register(registerAppConnectionRouter); + for await (const [app, router] of Object.entries(APP_CONNECTION_REGISTER_MAP)) { + await appConnectionsRouter.register(router, { prefix: `/${app}` }); + } + }, + { prefix: "/app-connections" } + ); }; diff --git a/backend/src/services/app-connection/app-connection-dal.ts b/backend/src/services/app-connection/app-connection-dal.ts new file mode 100644 index 0000000000..47d76f27d0 --- /dev/null +++ b/backend/src/services/app-connection/app-connection-dal.ts @@ -0,0 +1,11 @@ +import { TDbClient } from "@app/db"; +import { TableName } from "@app/db/schemas"; +import { ormify } from "@app/lib/knex"; + +export type TAppConnectionDALFactory = ReturnType; + +export const appConnectionDALFactory = (db: TDbClient) => { + const appConnection = ormify(db, TableName.AppConnection); + + return { ...appConnection }; +}; diff --git a/backend/src/services/app-connection/app-connection-fns.ts b/backend/src/services/app-connection/app-connection-fns.ts new file mode 100644 index 0000000000..ddae43deff --- /dev/null +++ b/backend/src/services/app-connection/app-connection-fns.ts @@ -0,0 +1,67 @@ +import { AppConnection, AppConnectionListItem, TAppConnection, TAppConnectionConfig } from "@app/lib/app-connections"; +import { getAwsAppConnectionListItem, validateAwsConnectionCredentials } from "@app/lib/app-connections/aws"; +import { getGitHubConnectionListItem, validateGitHubConnectionCredentials } from "@app/lib/app-connections/github"; +import { TAppConnectionServiceFactoryDep } from "@app/services/app-connection/app-connection-service"; +import { KmsDataKey } from "@app/services/kms/kms-types"; + +export const listAppConnectionOptions = (): (AppConnectionListItem & Record)[] => { + return [getAwsAppConnectionListItem(), getGitHubConnectionListItem()].sort((a, b) => a.name.localeCompare(b.name)); +}; + +export const encryptAppConnectionCredentials = async ({ + orgId, + credentials, + kmsService +}: { + orgId: string; + credentials: TAppConnection["credentials"]; + kmsService: TAppConnectionServiceFactoryDep["kmsService"]; +}) => { + const { encryptor } = await kmsService.createCipherPairWithDataKey({ + type: KmsDataKey.Organization, + orgId + }); + + const { cipherTextBlob: encryptedCredentialsBlob } = encryptor({ + plainText: Buffer.from(JSON.stringify(credentials)) + }); + + return encryptedCredentialsBlob; +}; + +export const decryptAppConnectionCredentials = async ({ + orgId, + encryptedCredentials, + kmsService +}: { + orgId: string; + encryptedCredentials: Buffer; + kmsService: TAppConnectionServiceFactoryDep["kmsService"]; +}) => { + const { decryptor } = await kmsService.createCipherPairWithDataKey({ + type: KmsDataKey.Organization, + orgId + }); + + const decryptedPlainTextBlob = decryptor({ + cipherTextBlob: encryptedCredentials + }); + + return JSON.parse(decryptedPlainTextBlob.toString()) as TAppConnection["credentials"]; +}; + +export const validateAppConnectionCredentials = async ( + appConnection: TAppConnectionConfig +): Promise => { + const { app } = appConnection; + switch (app) { + case AppConnection.AWS: { + return validateAwsConnectionCredentials(appConnection); + } + case AppConnection.GitHub: + return validateGitHubConnectionCredentials(appConnection); + default: + // eslint-disable-next-line @typescript-eslint/restrict-template-expressions + throw new Error(`Unhandled App Connection ${app}`); + } +}; diff --git a/backend/src/services/app-connection/app-connection-schemas.ts b/backend/src/services/app-connection/app-connection-schemas.ts new file mode 100644 index 0000000000..4d8d31cd1b --- /dev/null +++ b/backend/src/services/app-connection/app-connection-schemas.ts @@ -0,0 +1,7 @@ +import { AppConnectionsSchema } from "@app/db/schemas/app-connections"; + +export const BaseAppConnectionSchema = AppConnectionsSchema.omit({ + encryptedCredentials: true, + app: true, + method: true +}); diff --git a/backend/src/services/app-connection/app-connection-service.ts b/backend/src/services/app-connection/app-connection-service.ts new file mode 100644 index 0000000000..82dcb6f1a4 --- /dev/null +++ b/backend/src/services/app-connection/app-connection-service.ts @@ -0,0 +1,312 @@ +import { ForbiddenError } from "@casl/ability"; + +import { TLicenseServiceFactory } from "@app/ee/services/license/license-service"; +import { OrgPermissionActions, OrgPermissionSubjects } from "@app/ee/services/permission/org-permission"; +import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service"; +import { + AppConnection, + TAppConnection, + TAppConnectionConfig, + TCreateAppConnectionDTO, + TUpdateAppConnectionDTO +} from "@app/lib/app-connections"; +import { BadRequestError, NotFoundError } from "@app/lib/errors"; +import { OrgServiceActor } from "@app/lib/types"; +import { + decryptAppConnectionCredentials, + encryptAppConnectionCredentials, + listAppConnectionOptions, + validateAppConnectionCredentials +} from "@app/services/app-connection/app-connection-fns"; +import { TKmsServiceFactory } from "@app/services/kms/kms-service"; + +import { TAppConnectionDALFactory } from "./app-connection-dal"; + +export type TAppConnectionServiceFactoryDep = { + appConnectionDAL: TAppConnectionDALFactory; + permissionService: Pick; + kmsService: Pick; + licenseService: Pick; // TODO: remove once launched +}; + +export type TAppConnectionServiceFactory = ReturnType; + +export const appConnectionServiceFactory = ({ + appConnectionDAL, + permissionService, + kmsService, + licenseService +}: TAppConnectionServiceFactoryDep) => { + // app connections are disabled for public until launch + const checkAppServicesAvailability = async (orgId: string) => { + const subscription = await licenseService.getPlan(orgId); + + if (!subscription.appConnections) throw new BadRequestError({ message: "App Connections are not available yet." }); + }; + + const listAppConnectionsByOrg = async (actor: OrgServiceActor, app?: AppConnection) => { + await checkAppServicesAvailability(actor.orgId); + + const { permission } = await permissionService.getOrgPermission( + actor.type, + actor.id, + actor.orgId, + actor.authMethod, + actor.orgId + ); + + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Read, OrgPermissionSubjects.AppConnections); + + const appConnections = await appConnectionDAL.find( + app + ? { orgId: actor.orgId, app } + : { + orgId: actor.orgId + } + ); + + return Promise.all( + appConnections + .sort((a, b) => a.name.toLowerCase().localeCompare(b.name.toLowerCase())) + .map(async ({ encryptedCredentials, ...connection }) => { + const credentials = await decryptAppConnectionCredentials({ + encryptedCredentials, + kmsService, + orgId: connection.orgId + }); + + return { + ...connection, + credentials + } as TAppConnection; + }) + ); + }; + + const findAppConnectionById = async (app: AppConnection, connectionId: string, actor: OrgServiceActor) => { + await checkAppServicesAvailability(actor.orgId); + + const appConnection = await appConnectionDAL.findById(connectionId); + + if (!appConnection) throw new NotFoundError({ message: `Could not find App Connection with ID ${connectionId}` }); + + const { permission } = await permissionService.getOrgPermission( + actor.type, + actor.id, + actor.orgId, + actor.authMethod, + appConnection.orgId + ); + + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Read, OrgPermissionSubjects.AppConnections); + + if (appConnection.app !== app) + throw new BadRequestError({ message: `App Connection with ID ${connectionId} is not for App "${app}"` }); + + return { + ...appConnection, + credentials: await decryptAppConnectionCredentials({ + encryptedCredentials: appConnection.encryptedCredentials, + orgId: appConnection.orgId, + kmsService + }) + } as TAppConnection; + }; + + const findAppConnectionByName = async (app: AppConnection, connectionName: string, actor: OrgServiceActor) => { + await checkAppServicesAvailability(actor.orgId); + + const appConnection = await appConnectionDAL.findOne({ name: connectionName, orgId: actor.orgId }); + + if (!appConnection) + throw new NotFoundError({ message: `Could not find App Connection with name ${connectionName}` }); + + const { permission } = await permissionService.getOrgPermission( + actor.type, + actor.id, + actor.orgId, + actor.authMethod, + appConnection.orgId + ); + + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Read, OrgPermissionSubjects.AppConnections); + + if (appConnection.app !== app) + throw new BadRequestError({ message: `App Connection with name ${connectionName} is not for App "${app}"` }); + + return { + ...appConnection, + credentials: await decryptAppConnectionCredentials({ + encryptedCredentials: appConnection.encryptedCredentials, + orgId: appConnection.orgId, + kmsService + }) + } as TAppConnection; + }; + + const createAppConnection = async ( + { method, app, credentials, ...params }: TCreateAppConnectionDTO, + actor: OrgServiceActor + ) => { + await checkAppServicesAvailability(actor.orgId); + + const { permission } = await permissionService.getOrgPermission( + actor.type, + actor.id, + actor.orgId, + actor.authMethod, + actor.orgId + ); + + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Create, OrgPermissionSubjects.AppConnections); + + const isConflictingName = Boolean( + await appConnectionDAL.findOne({ + name: params.name, + orgId: actor.orgId + }) + ); + + if (isConflictingName) + throw new BadRequestError({ + message: `An App Connection with the name "${params.name}" already exists` + }); + + const validatedCredentials = await validateAppConnectionCredentials({ + app, + credentials, + method, + orgId: actor.orgId + } as TAppConnectionConfig); + + const encryptedCredentials = await encryptAppConnectionCredentials({ + credentials: validatedCredentials, + orgId: actor.orgId, + kmsService + }); + + const appConnection = await appConnectionDAL.create({ + orgId: actor.orgId, + encryptedCredentials, + method, + app, + ...params + }); + + return { ...appConnection, credentials: validatedCredentials }; + }; + + const updateAppConnection = async ( + { connectionId, credentials, ...params }: TUpdateAppConnectionDTO, + actor: OrgServiceActor + ) => { + await checkAppServicesAvailability(actor.orgId); + + const appConnection = await appConnectionDAL.findById(connectionId); + + if (!appConnection) throw new NotFoundError({ message: `Could not find App Connection with ID ${connectionId}` }); + + const { permission } = await permissionService.getOrgPermission( + actor.type, + actor.id, + actor.orgId, + actor.authMethod, + appConnection.orgId + ); + + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Edit, OrgPermissionSubjects.AppConnections); + + if (params.name && appConnection.name !== params.name) { + const isConflictingName = Boolean( + await appConnectionDAL.findOne({ + name: params.name, + orgId: appConnection.orgId + }) + ); + + if (isConflictingName) + throw new BadRequestError({ + message: `An App Connection with the name "${params.name}" already exists` + }); + } + + let encryptedCredentials: undefined | Buffer; + + if (credentials) { + const validatedCredentials = await validateAppConnectionCredentials({ + app: appConnection.app, + credentials, + method: appConnection.method, + orgId: actor.orgId + } as TAppConnectionConfig); + + if (!validatedCredentials) + throw new BadRequestError({ message: "Unable to validate connection - check credentials" }); + + encryptedCredentials = await encryptAppConnectionCredentials({ + credentials: validatedCredentials, + orgId: actor.orgId, + kmsService + }); + } + + const updatedAppConnection = await appConnectionDAL.updateById(connectionId, { + orgId: actor.orgId, + encryptedCredentials, + ...params + }); + + return { + ...updatedAppConnection, + credentials: await decryptAppConnectionCredentials({ + encryptedCredentials: updatedAppConnection.encryptedCredentials, + orgId: updatedAppConnection.orgId, + kmsService + }) + } as TAppConnection; + }; + + const deleteAppConnection = async (app: AppConnection, connectionId: string, actor: OrgServiceActor) => { + await checkAppServicesAvailability(actor.orgId); + + const appConnection = await appConnectionDAL.findById(connectionId); + + if (!appConnection) throw new NotFoundError({ message: `Could not find App Connection with ID ${connectionId}` }); + + const { permission } = await permissionService.getOrgPermission( + actor.type, + actor.id, + actor.orgId, + actor.authMethod, + appConnection.orgId + ); + + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Delete, OrgPermissionSubjects.AppConnections); + + if (appConnection.app !== app) + throw new BadRequestError({ message: `App Connection with ID ${connectionId} is not for App "${app}"` }); + + // TODO: specify delete error message if due to existing dependencies + + const deletedAppConnection = await appConnectionDAL.deleteById(connectionId); + + return { + ...deletedAppConnection, + credentials: await decryptAppConnectionCredentials({ + encryptedCredentials: deletedAppConnection.encryptedCredentials, + orgId: deletedAppConnection.orgId, + kmsService + }) + } as TAppConnection; + }; + + return { + listAppConnectionOptions, + listAppConnectionsByOrg, + findAppConnectionById, + findAppConnectionByName, + createAppConnection, + updateAppConnection, + deleteAppConnection + }; +}; diff --git a/docs/api-reference/endpoints/app-connections/aws/create.mdx b/docs/api-reference/endpoints/app-connections/aws/create.mdx new file mode 100644 index 0000000000..2fd1602ed0 --- /dev/null +++ b/docs/api-reference/endpoints/app-connections/aws/create.mdx @@ -0,0 +1,9 @@ +--- +title: "Create" +openapi: "POST /api/v1/app-connections/aws" +--- + + + Check out the configuration docs for [AWS Connections](/integrations/app-connections/aws) to learn how to obtain + the required credentials. + \ No newline at end of file diff --git a/docs/api-reference/endpoints/app-connections/aws/delete.mdx b/docs/api-reference/endpoints/app-connections/aws/delete.mdx new file mode 100644 index 0000000000..e6030257f6 --- /dev/null +++ b/docs/api-reference/endpoints/app-connections/aws/delete.mdx @@ -0,0 +1,4 @@ +--- +title: "Delete" +openapi: "DELETE /api/v1/app-connections/aws/{connectionId}" +--- diff --git a/docs/api-reference/endpoints/app-connections/aws/get-by-id.mdx b/docs/api-reference/endpoints/app-connections/aws/get-by-id.mdx new file mode 100644 index 0000000000..0a057cc1b4 --- /dev/null +++ b/docs/api-reference/endpoints/app-connections/aws/get-by-id.mdx @@ -0,0 +1,4 @@ +--- +title: "Get by ID" +openapi: "GET /api/v1/app-connections/aws/{connectionId}" +--- diff --git a/docs/api-reference/endpoints/app-connections/aws/get-by-name.mdx b/docs/api-reference/endpoints/app-connections/aws/get-by-name.mdx new file mode 100644 index 0000000000..d18994f7c7 --- /dev/null +++ b/docs/api-reference/endpoints/app-connections/aws/get-by-name.mdx @@ -0,0 +1,4 @@ +--- +title: "Get by Name" +openapi: "GET /api/v1/app-connections/aws/name/{connectionName}" +--- diff --git a/docs/api-reference/endpoints/app-connections/aws/list.mdx b/docs/api-reference/endpoints/app-connections/aws/list.mdx new file mode 100644 index 0000000000..5ea0c50a07 --- /dev/null +++ b/docs/api-reference/endpoints/app-connections/aws/list.mdx @@ -0,0 +1,4 @@ +--- +title: "List" +openapi: "GET /api/v1/app-connections/aws" +--- diff --git a/docs/api-reference/endpoints/app-connections/aws/update.mdx b/docs/api-reference/endpoints/app-connections/aws/update.mdx new file mode 100644 index 0000000000..4fd3a4a00c --- /dev/null +++ b/docs/api-reference/endpoints/app-connections/aws/update.mdx @@ -0,0 +1,9 @@ +--- +title: "Update" +openapi: "PATCH /api/v1/app-connections/aws/{connectionId}" +--- + + + Check out the configuration docs for [AWS Connections](/integrations/app-connections/aws) to learn how to obtain + the required credentials. + \ No newline at end of file diff --git a/docs/api-reference/endpoints/app-connections/github/create.mdx b/docs/api-reference/endpoints/app-connections/github/create.mdx new file mode 100644 index 0000000000..1e06fd64f4 --- /dev/null +++ b/docs/api-reference/endpoints/app-connections/github/create.mdx @@ -0,0 +1,10 @@ +--- +title: "Create" +openapi: "POST /api/v1/app-connections/github" +--- + + + GitHub Connections must be created through the Infisical UI. + Check out the configuration docs for [GitHub Connections](/integrations/app-connections/github) for a step-by-step + guide. + \ No newline at end of file diff --git a/docs/api-reference/endpoints/app-connections/github/delete.mdx b/docs/api-reference/endpoints/app-connections/github/delete.mdx new file mode 100644 index 0000000000..6b4f2e6760 --- /dev/null +++ b/docs/api-reference/endpoints/app-connections/github/delete.mdx @@ -0,0 +1,4 @@ +--- +title: "Delete" +openapi: "DELETE /api/v1/app-connections/github/{connectionId}" +--- diff --git a/docs/api-reference/endpoints/app-connections/github/get-by-id.mdx b/docs/api-reference/endpoints/app-connections/github/get-by-id.mdx new file mode 100644 index 0000000000..c85d41d373 --- /dev/null +++ b/docs/api-reference/endpoints/app-connections/github/get-by-id.mdx @@ -0,0 +1,4 @@ +--- +title: "Get by ID" +openapi: "GET /api/v1/app-connections/github/{connectionId}" +--- diff --git a/docs/api-reference/endpoints/app-connections/github/get-by-name.mdx b/docs/api-reference/endpoints/app-connections/github/get-by-name.mdx new file mode 100644 index 0000000000..95ddbd6e95 --- /dev/null +++ b/docs/api-reference/endpoints/app-connections/github/get-by-name.mdx @@ -0,0 +1,4 @@ +--- +title: "Get by Name" +openapi: "GET /api/v1/app-connections/github/name/{connectionName}" +--- diff --git a/docs/api-reference/endpoints/app-connections/github/list.mdx b/docs/api-reference/endpoints/app-connections/github/list.mdx new file mode 100644 index 0000000000..c4b13b8eb2 --- /dev/null +++ b/docs/api-reference/endpoints/app-connections/github/list.mdx @@ -0,0 +1,4 @@ +--- +title: "List" +openapi: "GET /api/v1/app-connections/github" +--- diff --git a/docs/api-reference/endpoints/app-connections/github/update.mdx b/docs/api-reference/endpoints/app-connections/github/update.mdx new file mode 100644 index 0000000000..7e2326c60f --- /dev/null +++ b/docs/api-reference/endpoints/app-connections/github/update.mdx @@ -0,0 +1,10 @@ +--- +title: "Update" +openapi: "PATCH /api/v1/app-connections/github/{connectionId}" +--- + + + GitHub Connections must be updated through the Infisical UI. + Check out the configuration docs for [GitHub Connections](/integrations/app-connections/github) for a step-by-step + guide. + diff --git a/docs/api-reference/endpoints/app-connections/list.mdx b/docs/api-reference/endpoints/app-connections/list.mdx new file mode 100644 index 0000000000..e7ee6b0096 --- /dev/null +++ b/docs/api-reference/endpoints/app-connections/list.mdx @@ -0,0 +1,4 @@ +--- +title: "List" +openapi: "GET /api/v1/app-connections" +--- diff --git a/docs/api-reference/endpoints/app-connections/options.mdx b/docs/api-reference/endpoints/app-connections/options.mdx new file mode 100644 index 0000000000..7cc03aca3d --- /dev/null +++ b/docs/api-reference/endpoints/app-connections/options.mdx @@ -0,0 +1,4 @@ +--- +title: "Options" +openapi: "GET /api/v1/app-connections/options" +--- diff --git a/docs/images/app-connections/aws/access-key-connection.png b/docs/images/app-connections/aws/access-key-connection.png new file mode 100644 index 0000000000..9c70da6232 Binary files /dev/null and b/docs/images/app-connections/aws/access-key-connection.png differ diff --git a/docs/images/app-connections/aws/assume-role-connection.png b/docs/images/app-connections/aws/assume-role-connection.png new file mode 100644 index 0000000000..c01f2e0162 Binary files /dev/null and b/docs/images/app-connections/aws/assume-role-connection.png differ diff --git a/docs/images/app-connections/aws/create-access-key-method.png b/docs/images/app-connections/aws/create-access-key-method.png new file mode 100644 index 0000000000..a82cca038c Binary files /dev/null and b/docs/images/app-connections/aws/create-access-key-method.png differ diff --git a/docs/images/app-connections/aws/create-assume-role-method.png b/docs/images/app-connections/aws/create-assume-role-method.png new file mode 100644 index 0000000000..4b422222d3 Binary files /dev/null and b/docs/images/app-connections/aws/create-assume-role-method.png differ diff --git a/docs/images/app-connections/aws/select-aws-connection.png b/docs/images/app-connections/aws/select-aws-connection.png new file mode 100644 index 0000000000..0cd51bb7f6 Binary files /dev/null and b/docs/images/app-connections/aws/select-aws-connection.png differ diff --git a/docs/images/app-connections/general/add-connection.png b/docs/images/app-connections/general/add-connection.png new file mode 100644 index 0000000000..97718065a6 Binary files /dev/null and b/docs/images/app-connections/general/add-connection.png differ diff --git a/docs/images/app-connections/github/create-github-app-method.png b/docs/images/app-connections/github/create-github-app-method.png new file mode 100644 index 0000000000..640fb0213a Binary files /dev/null and b/docs/images/app-connections/github/create-github-app-method.png differ diff --git a/docs/images/app-connections/github/create-oauth-method.png b/docs/images/app-connections/github/create-oauth-method.png new file mode 100644 index 0000000000..4898a0de05 Binary files /dev/null and b/docs/images/app-connections/github/create-oauth-method.png differ diff --git a/docs/images/app-connections/github/github-app-connection.png b/docs/images/app-connections/github/github-app-connection.png new file mode 100644 index 0000000000..3d81bc182c Binary files /dev/null and b/docs/images/app-connections/github/github-app-connection.png differ diff --git a/docs/images/app-connections/github/install-github-app.png b/docs/images/app-connections/github/install-github-app.png new file mode 100644 index 0000000000..3b09ed485b Binary files /dev/null and b/docs/images/app-connections/github/install-github-app.png differ diff --git a/docs/images/app-connections/github/oauth-connection.png b/docs/images/app-connections/github/oauth-connection.png new file mode 100644 index 0000000000..bf907256c2 Binary files /dev/null and b/docs/images/app-connections/github/oauth-connection.png differ diff --git a/docs/images/app-connections/github/select-github-connection.png b/docs/images/app-connections/github/select-github-connection.png new file mode 100644 index 0000000000..2856d0a39e Binary files /dev/null and b/docs/images/app-connections/github/select-github-connection.png differ diff --git a/docs/integrations/app-connections/aws.mdx b/docs/integrations/app-connections/aws.mdx new file mode 100644 index 0000000000..889e2c89d2 --- /dev/null +++ b/docs/integrations/app-connections/aws.mdx @@ -0,0 +1,289 @@ +--- +title: "AWS Connection" +description: "Learn how to configure an AWS Connection for Infisical." +--- + +Infisical supports two methods for connecting to AWS. + + + + Infisical will assume the provided role in your AWS account securely, without the need to share any credentials. + + **Prerequisites:** + + - Set up and add envars to [Infisical Cloud](https://app.infisical.com) + + + To connect your self-hosted Infisical instance with AWS, you need to set up an AWS IAM User account that can assume the configured AWS IAM Role. + + If your instance is deployed on AWS, the aws-sdk will automatically retrieve the credentials. Ensure that you assign the provided permission policy to your deployed instance, such as ECS or EC2. + + The following steps are for instances not deployed on AWS: + + + Navigate to [Create IAM User](https://console.aws.amazon.com/iamv2/home#/users/create) in your AWS Console. + + + Attach the following inline permission policy to the IAM User to allow it to assume any IAM Roles: + ```json + { + "Version": "2012-10-17", + "Statement": [ + { + "Sid": "AllowAssumeAnyRole", + "Effect": "Allow", + "Action": "sts:AssumeRole", + "Resource": "arn:aws:iam::*:role/*" + } + ] + } + ``` + + + Obtain the AWS access key ID and secret access key for your IAM User by navigating to **IAM > Users > [Your User] > Security credentials > Access keys**. + + ![Access Key Step 1](/images/integrations/aws/integrations-aws-access-key-1.png) + ![Access Key Step 2](/images/integrations/aws/integrations-aws-access-key-2.png) + ![Access Key Step 3](/images/integrations/aws/integrations-aws-access-key-3.png) + + + 1. Set the access key as **INF_APP_CONNECTION_AWS_CLIENT_ID**. + 2. Set the secret key as **INF_APP_CONNECTION_AWS_CLIENT_SECRET**. + + + + + + + 1. Navigate to the [Create IAM Role](https://console.aws.amazon.com/iamv2/home#/roles/create?step=selectEntities) page in your AWS Console. + ![IAM Role Creation](/images/integrations/aws/integration-aws-iam-assume-role.png) + + 2. Select **AWS Account** as the **Trusted Entity Type**. + 3. Choose **Another AWS Account** and enter **381492033652** (Infisical AWS Account ID). This restricts the role to be assumed only by Infisical. If self-hosting, provide your AWS account number instead. + 4. Optionally, enable **Require external ID** and enter your **Organization ID** to further enhance security. + + + + Depending on your use case, add one or more of the following policies to your IAM Role: + + + + Add the **SecretsManagerReadWrite** policy to your IAM Role. + + ![IAM Role Permissions](/images/integrations/aws/integration-aws-iam-assume-permission.png) + + Alternatively, use the following custom policy to grant the minimum permissions required by Infisical to sync secrets to AWS Parameter Store: + + ```json + { + "Version": "2012-10-17", + "Statement": [ + { + "Sid": "AllowSSMAccess", + "Effect": "Allow", + "Action": [ + "ssm:PutParameter", + "ssm:DeleteParameter", + "ssm:GetParameters", + "ssm:GetParametersByPath", + "ssm:DescribeParameters", + "ssm:DeleteParameters", + "ssm:AddTagsToResource", // if you need to add tags to secrets + "kms:ListKeys", // if you need to specify the KMS key + "kms:ListAliases", // if you need to specify the KMS key + "kms:Encrypt", // if you need to specify the KMS key + "kms:Decrypt" // if you need to specify the KMS key + ], + "Resource": "*" + } + ] + } + ``` + + + + + + ![Copy IAM Role ARN](/images/integrations/aws/integration-aws-iam-assume-arn.png) + + + + + + 1. Navigate to the App Connections tab on the Organization Settings page. + ![App Connections Tab](/images/app-connections/general/add-connection.png) + + 2. Select the **AWS Connection** option. + ![Select AWS Connection](/images/app-connections/aws/select-aws-connection.png) + + 3. Select the **Assume Role** method option and provide the **AWS IAM Role ARN** obtained from the previous step and press **Connect to AWS**. + ![Create AWS Connection](/images/app-connections/aws/create-assume-role-method.png) + + 4. Your **AWS Connection** is now available for use. + ![Assume Role AWS Connection](/images/app-connections/aws/assume-role-connection.png) + + + To create an AWS Connection, make an API request to the [Create AWS + Connection](/api-reference/endpoints/app-connections/aws/create) API endpoint. + + ### Sample request + + ```bash Request + curl --request POST \ + --url https://app.infisical.com/api/v1/app-connections/aws \ + --header 'Content-Type: application/json' \ + --data '{ + "name": "my-aws-connection", + "method": "assume-role", + "credentials": { + "roleArn": "...", + } + }' + ``` + + ### Sample response + + ```bash Response + { + "appConnection": { + "id": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "name": "my-aws-connection", + "version": 123, + "orgId": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "createdAt": "2023-11-07T05:31:56Z", + "updatedAt": "2023-11-07T05:31:56Z", + "app": "aws", + "method": "assume-role", + "credentials": {} + } + } + ``` + + + + + + + + Infisical will use the provided **Access Key ID** and **Secret Key** to connect to your AWS instance. + + **Prerequisites:** + + - Set up and add envars to [Infisical Cloud](https://app.infisical.com) + + + + 1. Navigate to the [Create IAM Role](https://console.aws.amazon.com/iamv2/home#/roles/create?step=selectEntities) page in your AWS Console. + ![IAM Role Creation](/images/integrations/aws/integration-aws-iam-assume-role.png) + + 2. Select **AWS Account** as the **Trusted Entity Type**. + 3. Choose **Another AWS Account** and enter **381492033652** (Infisical AWS Account ID). This restricts the role to be assumed only by Infisical. If self-hosting, provide your AWS account number instead. + 4. Optionally, enable **Require external ID** and enter your **Organization ID** to further enhance security. + + + + Depending on your use case, add one or more of the following policies to your IAM Role: + + + + Add the **SecretsManagerReadWrite** policy to your IAM Role. + + ![IAM Role Permissions](/images/integrations/aws/integration-aws-iam-assume-permission.png) + Alternatively, use the following custom policy to grant the minimum permissions required by Infisical to sync secrets to AWS Parameter Store: + + ```json + { + "Version": "2012-10-17", + "Statement": [ + { + "Sid": "AllowSSMAccess", + "Effect": "Allow", + "Action": [ + "ssm:PutParameter", + "ssm:DeleteParameter", + "ssm:GetParameters", + "ssm:GetParametersByPath", + "ssm:DescribeParameters", + "ssm:DeleteParameters", + "ssm:AddTagsToResource", // if you need to add tags to secrets + "kms:ListKeys", // if you need to specify the KMS key + "kms:ListAliases", // if you need to specify the KMS key + "kms:Encrypt", // if you need to specify the KMS key + "kms:Decrypt" // if you need to specify the KMS key + ], + "Resource": "*" + } + ] + } + ``` + + + + + Retrieve an AWS **Access Key ID** and a **Secret Key** for your IAM user in **IAM > Users > User > Security credentials > Access keys**. + + ![access key 1](/images/integrations/aws/integrations-aws-access-key-1.png) + ![access key 2](/images/integrations/aws/integrations-aws-access-key-2.png) + ![access key 3](/images/integrations/aws/integrations-aws-access-key-3.png) + + + + + 1. Navigate to the App Connections tab on the Organization Settings page. + ![App Connections Tab](/images/app-connections/general/add-connection.png) + + 2. Select the **AWS Connection** option. + ![Select AWS Connection](/images/app-connections/aws/select-aws-connection.png) + + 3. Select the **Access Key** method option and provide the **Access Key ID** and **Secret Key** obtained from the previous step and press **Connect to AWS**. + ![Create AWS Connection](/images/app-connections/aws/create-access-key-method.png) + + 4. Your **AWS Connection** is now available for use. + ![Assume Role AWS Connection](/images/app-connections/aws/access-key-connection.png) + + + To create an AWS Connection, make an API request to the [Create AWS + Connection](/api-reference/endpoints/app-connections/aws/create) API endpoint. + + ### Sample request + + ```bash Request + curl --request POST \ + --url https://app.infisical.com/api/v1/app-connections/aws \ + --header 'Content-Type: application/json' \ + --data '{ + "name": "my-aws-connection", + "method": "access-key", + "credentials": { + "accessKeyId": "...", + "secretKey": "..." + } + }' + ``` + + ### Sample response + + ```bash Response + { + "appConnection": { + "id": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "name": "my-aws-connection", + "version": 123, + "orgId": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "createdAt": "2023-11-07T05:31:56Z", + "updatedAt": "2023-11-07T05:31:56Z", + "app": "aws", + "method": "access-key", + "credentials": { + "accessKeyId": "..." + } + } + } + ``` + + + + + + + diff --git a/docs/integrations/app-connections/github.mdx b/docs/integrations/app-connections/github.mdx new file mode 100644 index 0000000000..26c26f6afd --- /dev/null +++ b/docs/integrations/app-connections/github.mdx @@ -0,0 +1,133 @@ +--- +title: "GitHub Connection" +description: "Learn how to configure a GitHub Connection for Infisical." +--- + +Infisical supports two methods for connecting to GitHub. + + + + Infisical will use a GitHub App with finely grained permissions to connect to GitHub. + + **Prerequisites:** + + - Set up and add envars to [Infisical Cloud](https://app.infisical.com) + + + Using the GitHub integration with app authentication on a self-hosted instance of Infisical requires configuring an application on GitHub + and registering your instance with it. + + + + Navigate to the GitHub app settings [here](https://github.com/settings/apps). Click **New GitHub App**. + + ![integrations github app create](/images/integrations/github/app/self-hosted-github-app-create.png) + + Give the application a name, a homepage URL (your self-hosted domain i.e. `https://your-domain.com`), and a callback URL (i.e. `https://your-domain.com/app-connections/github/oauth/callback`). + + ![integrations github app basic details](/images/integrations/github/app/self-hosted-github-app-basic-details.png) + + Enable request user authorization during app installation. + ![integrations github app enable auth](/images/integrations/github/app/self-hosted-github-app-enable-oauth.png) + + Disable webhook by unchecking the Active checkbox. + ![integrations github app webhook](/images/integrations/github/app/self-hosted-github-app-webhook.png) + + Set the repository permissions as follows: Metadata: Read-only, Secrets: Read and write, Environments: Read and write, Actions: Read. + ![integrations github app repository](/images/integrations/github/app/self-hosted-github-app-repository.png) + + Similarly, set the organization permissions as follows: Secrets: Read and write. + ![integrations github app organization](/images/integrations/github/app/self-hosted-github-app-organization.png) + + Create the Github application. + ![integrations github app create confirm](/images/integrations/github/app/self-hosted-github-app-create-confirm.png) + + + If you have a GitHub organization, you can create an application under it + in your organization Settings > Developer settings > GitHub Apps > New GitHub App. + + + + Generate a new **Client Secret** for your GitHub application. + ![integrations github app create secret](/images/integrations/github/app/self-hosted-github-app-secret.png) + + Generate a new **Private Key** for your Github application. + ![integrations github app create private key](/images/integrations/github/app/self-hosted-github-app-private-key.png) + + Obtain the necessary Github application credentials. This would be the application slug, client ID, app ID, client secret, and private key. + ![integrations github app credentials](/images/integrations/github/app/self-hosted-github-app-credentials.png) + + Back in your Infisical instance, add the five new environment variables for the credentials of your GitHub application: + + - `INF_APP_CONNECTION_GITHUB_APP_CLIENT_ID`: The **Client ID** of your GitHub application. + - `INF_APP_CONNECTION_GITHUB_APP_CLIENT_SECRET`: The **Client Secret** of your GitHub application. + - `INF_APP_CONNECTION_GITHUB_APP_CLIENT_SLUG`: The **Slug** of your GitHub application. This is the one found in the URL. + - `INF_APP_CONNECTION_GITHUB_APP_CLIENT_APP_ID`: The **App ID** of your GitHub application. + - `INF_APP_CONNECTION_GITHUB_APP_CLIENT_PRIVATE_KEY`: The **Private Key** of your GitHub application. + + Once added, restart your Infisical instance and use the GitHub integration via app authentication. + + + + + ## Setup GitHub Connection in Infisical + + + + Navigate to the **App Connections** tab on the **Organization Settings** page. + ![App Connections Tab](/images/app-connections/general/add-connection.png) + + + Select the **GitHub Connection** option from the connection options modal. + ![Select GitHub Connection](/images/app-connections/github/select-github-connection.png) + + + Select the **GitHub App** method and click **Connect to GitHub**. + ![Connect via GitHub App](/images/app-connections/github/create-github-app-method.png) + + + You will then be redirected to the GitHub app installation page. + + Install and authorize the GitHub application. This will redirect you back to Infisical's App Connections page. + ![Install GitHub App](/images/app-connections/github/install-github-app.png) + + + Your **GitHub Connection** is now available for use. + ![Assume Role AWS Connection](/images/app-connections/github/github-app-connection.png) + + + + + Infisical will use an OAuth App to connect to GitHub. + + **Prerequisites:** + + - Set up and add envars to [Infisical Cloud](https://app.infisical.com) + + ## Setup GitHub Connection in Infisical + + + + Navigate to the **App Connections** tab on the **Organization Settings** page. + ![App Connections Tab](/images/app-connections/general/add-connection.png) + + + Select the **GitHub Connection** option from the connection options modal. + ![Select GitHub Connection](/images/app-connections/github/select-github-connection.png) + + + Select the **OAuth** method and click **Connect to GitHub**. + ![Connect via GitHub App](/images/app-connections/github/create-oauth-method.png) + + + You will then be redirected to the GitHub to grant Infisical access to your GitHub account (organization and repo privileges). + Once granted, you will redirect you back to Infisical's App Connections page. + ![GitHub Authorization](/images/integrations/github/integrations-github-auth.png) + + + Your **GitHub Connection** is now available for use. + ![Assume Role AWS Connection](/images/app-connections/github/oauth-connection.png) + + + + diff --git a/docs/integrations/app-connections/overview.mdx b/docs/integrations/app-connections/overview.mdx new file mode 100644 index 0000000000..2d93e2cc5e --- /dev/null +++ b/docs/integrations/app-connections/overview.mdx @@ -0,0 +1,69 @@ +--- +sidebarTitle: "Overview" +description: "Learn how to manage and configure third-party app connections with Infisical." +--- + +App Connections enable your organization to integrate Infisical with third-party services in a secure and versatile way. + +## Concept + +App Connections are an organization-level resource used to establish connections with third-party applications +that can be used across Infisical projects. Example use cases include syncing secrets, generating dynamic secrets, and more. + +
+ +
+ + ```mermaid + %%{init: {'flowchart': {'curve': 'linear'} } }%% + graph TD + A[AWS Connection] + A --> B[Project 1 Secret Sync] + A --> C[Project 2 Secret Sync] + A --> D[Project 3 Generate Dynamic Secret] + + classDef default fill:#ffffff,stroke:#666,stroke-width:2px,rx:10px,color:black + classDef aws fill:#FFF2B2,stroke:#E6C34A,stroke-width:2px,color:black,rx:15px + classDef project fill:#E6F4FF,stroke:#0096D6,stroke-width:2px,color:black,rx:15px + + class A aws + class B,C,D project + ``` + +
+ +## Workflow + +App Connections require initial setup in both your third-party application and Infisical. Follow these steps to establish a secure connection: + + + For step-by-step guides specific to each application, refer to the App Connections section in the Navigation Bar. + + +1. Create Access Entity: If necessary, create an entity such as a service account or role within the third-party application you want to connect to. Be sure +to limit the access of this entity to the minimal permission set required to perform the operations you need. For example: + - For secret syncing: Read/write permissions to specific secret stores + - For dynamic secrets: Permissions to create temporary credentials + + + Whenever possible, Infisical encourages creating a designated service account for your App Connection to limit the scope of permissions based on your use-case. + + +2. Generate Authentication Credentials: Obtain the required credentials from your third-party application. These can vary between applications and might be: + - an API key or access token + - A client ID and secret pair + - other credentials, etc. + +3. Create App Connection: Configure the connection in Infisical using your generated credentials through either the UI or API. + + + Some App Connections can only be created via the UI such as connections using OAuth. + + +4. Utilize the Connection: Use your App Connection for various features across Infisical such as our Secrets Sync by selecting it via the dropdown menu +in the UI or by passing the associated `connectionId` when generating resources via the API. + + + Infisical is continuously expanding its third-party application support. If your desired application isn't listed, + you can still use previous methods of connecting to it such as our Native Integrations. + \ No newline at end of file diff --git a/docs/mint.json b/docs/mint.json index 030a0b4dc1..fc31c610e0 100644 --- a/docs/mint.json +++ b/docs/mint.json @@ -1,6 +1,6 @@ { "name": "Infisical", - "openapi": "https://app.infisical.com/api/docs/json", + "openapi": "http://localhost:8080/api/docs/json", "logo": { "dark": "/logo/dark.svg", "light": "/logo/light.svg", @@ -340,6 +340,14 @@ "cli/faq" ] }, + { + "group": "App Connections", + "pages": [ + "integrations/app-connections/overview", + "integrations/app-connections/aws", + "integrations/app-connections/github" + ] + }, { "group": "Infrastructure Integrations", "pages": [ @@ -756,6 +764,33 @@ "api-reference/endpoints/identity-specific-privilege/list" ] }, + { + "group": "App Connections", + "pages": [ + "api-reference/endpoints/app-connections/list", + "api-reference/endpoints/app-connections/options", + { "group": "AWS", + "pages": [ + "api-reference/endpoints/app-connections/aws/list", + "api-reference/endpoints/app-connections/aws/get-by-id", + "api-reference/endpoints/app-connections/aws/get-by-name", + "api-reference/endpoints/app-connections/aws/create", + "api-reference/endpoints/app-connections/aws/update", + "api-reference/endpoints/app-connections/aws/delete" + ] + }, + { "group": "GitHub", + "pages": [ + "api-reference/endpoints/app-connections/github/list", + "api-reference/endpoints/app-connections/github/get-by-id", + "api-reference/endpoints/app-connections/github/get-by-name", + "api-reference/endpoints/app-connections/github/create", + "api-reference/endpoints/app-connections/github/update", + "api-reference/endpoints/app-connections/github/delete" + ] + } + ] + }, { "group": "Integrations", "pages": [ diff --git a/docs/self-hosting/configuration/envars.mdx b/docs/self-hosting/configuration/envars.mdx index 2e21410a8b..8f902c5065 100644 --- a/docs/self-hosting/configuration/envars.mdx +++ b/docs/self-hosting/configuration/envars.mdx @@ -418,7 +418,53 @@ When set, all visits to the Infisical login page will automatically redirect use information. -## Native secret integrations +## App Connections + +You can configure third-party app connections for re-use across Infisical Projects. + + + + The AWS IAM User access key ID for assuming roles + + + + The AWS IAM User secret key for assuming roles + + + + + + The ID of the GitHub App + + + + The slug of the GitHub App + + + + The client ID for the GitHub App + + + + The client secret for the GitHub App + + + + The private key for the GitHub App + + + + + + The OAuth2 client ID for GitHub OAuth Connection + + + + The OAuth2 client secret for GitHub OAuth Connection + + + +## Native Secret Integrations To help you sync secrets from Infisical to services such as Github and Gitlab, Infisical provides native integrations out of the box. @@ -492,7 +538,7 @@ To help you sync secrets from Infisical to services such as Github and Gitlab, I - + The AWS IAM User access key for assuming roles. diff --git a/frontend/public/images/integrations/Amazon Web Services.png b/frontend/public/images/integrations/Amazon Web Services.png index 65b4a6ee84..d4025224e8 100644 Binary files a/frontend/public/images/integrations/Amazon Web Services.png and b/frontend/public/images/integrations/Amazon Web Services.png differ diff --git a/frontend/src/components/v2/FormControl/FormControl.tsx b/frontend/src/components/v2/FormControl/FormControl.tsx index 8651422a18..4d54465193 100644 --- a/frontend/src/components/v2/FormControl/FormControl.tsx +++ b/frontend/src/components/v2/FormControl/FormControl.tsx @@ -44,7 +44,7 @@ export const FormLabel = ({ )} {tooltipText && ( - + )} diff --git a/frontend/src/context/OrgPermissionContext/types.ts b/frontend/src/context/OrgPermissionContext/types.ts index 41a2e7e3c1..4480bbad82 100644 --- a/frontend/src/context/OrgPermissionContext/types.ts +++ b/frontend/src/context/OrgPermissionContext/types.ts @@ -23,7 +23,8 @@ export enum OrgPermissionSubjects { Kms = "kms", AdminConsole = "organization-admin-console", AuditLogs = "audit-logs", - ProjectTemplates = "project-templates" + ProjectTemplates = "project-templates", + AppConnections = "app-connections" } export enum OrgPermissionAdminConsoleAction { @@ -47,6 +48,7 @@ export type OrgPermissionSet = | [OrgPermissionActions, OrgPermissionSubjects.Kms] | [OrgPermissionAdminConsoleAction, OrgPermissionSubjects.AdminConsole] | [OrgPermissionActions, OrgPermissionSubjects.AuditLogs] - | [OrgPermissionActions, OrgPermissionSubjects.ProjectTemplates]; + | [OrgPermissionActions, OrgPermissionSubjects.ProjectTemplates] + | [OrgPermissionActions, OrgPermissionSubjects.AppConnections]; export type TOrgPermission = MongoAbility; diff --git a/frontend/src/helpers/appConnections.ts b/frontend/src/helpers/appConnections.ts new file mode 100644 index 0000000000..5d72c837e5 --- /dev/null +++ b/frontend/src/helpers/appConnections.ts @@ -0,0 +1,22 @@ +import { faGithub, IconDefinition } from "@fortawesome/free-brands-svg-icons"; +import { faKey, faPassport, faUser } from "@fortawesome/free-solid-svg-icons"; + +import { AppConnection } from "@app/hooks/api/appConnections/enums"; +import { TAppConnection } from "@app/hooks/api/appConnections/types"; +import { AwsConnectionMethod } from "@app/hooks/api/appConnections/types/aws-connection"; +import { GitHubConnectionMethod } from "@app/hooks/api/appConnections/types/github-connection"; + +export const APP_CONNECTION_MAP: Record = { + [AppConnection.AWS]: { name: "AWS", image: "Amazon Web Services.png" }, + [AppConnection.GitHub]: { name: "GitHub", image: "GitHub.png" } +}; + +export const APP_CONNECTION_METHOD_MAP: Record< + TAppConnection["method"], + { name: string; icon: IconDefinition } +> = { + [AwsConnectionMethod.AssumeRole]: { name: "Assume Role", icon: faUser }, + [AwsConnectionMethod.AccessKey]: { name: "Access Key", icon: faKey }, + [GitHubConnectionMethod.App]: { name: "GitHub App", icon: faGithub }, + [GitHubConnectionMethod.OAuth]: { name: "OAuth", icon: faPassport } +}; diff --git a/frontend/src/hooks/api/appConnections/enums.ts b/frontend/src/hooks/api/appConnections/enums.ts new file mode 100644 index 0000000000..3c1a409a45 --- /dev/null +++ b/frontend/src/hooks/api/appConnections/enums.ts @@ -0,0 +1,4 @@ +export enum AppConnection { + AWS = "aws", + GitHub = "github" +} diff --git a/frontend/src/hooks/api/appConnections/index.ts b/frontend/src/hooks/api/appConnections/index.ts new file mode 100644 index 0000000000..177955438b --- /dev/null +++ b/frontend/src/hooks/api/appConnections/index.ts @@ -0,0 +1,3 @@ +export * from "./mutations"; +export * from "./queries"; +export * from "./types"; diff --git a/frontend/src/hooks/api/appConnections/mutations.tsx b/frontend/src/hooks/api/appConnections/mutations.tsx new file mode 100644 index 0000000000..d9e2912d72 --- /dev/null +++ b/frontend/src/hooks/api/appConnections/mutations.tsx @@ -0,0 +1,58 @@ +import { useMutation, useQueryClient } from "@tanstack/react-query"; + +import { apiRequest } from "@app/config/request"; +import { appConnectionKeys } from "@app/hooks/api/appConnections/queries"; +import { + TAppConnectionResponse, + TCreateAppConnectionDTO, + TDeleteAppConnectionDTO, + TUpdateAppConnectionDTO +} from "@app/hooks/api/appConnections/types"; + +export const useCreateAppConnection = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async ({ app, ...params }: TCreateAppConnectionDTO) => { + const { data } = await apiRequest.post( + `/api/v1/app-connections/${app}`, + params + ); + + return data.appConnection; + }, + onSuccess: () => queryClient.invalidateQueries(appConnectionKeys.list()) + }); +}; + +export const useUpdateAppConnection = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async ({ connectionId, app, ...params }: TUpdateAppConnectionDTO) => { + const { data } = await apiRequest.patch( + `/api/v1/app-connections/${app}/${connectionId}`, + params + ); + + return data.appConnection; + }, + onSuccess: (_, { connectionId, app }) => { + queryClient.invalidateQueries(appConnectionKeys.list()); + queryClient.invalidateQueries(appConnectionKeys.byId(app, connectionId)); + } + }); +}; + +export const useDeleteAppConnection = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async ({ connectionId, app }: TDeleteAppConnectionDTO) => { + const { data } = await apiRequest.delete(`/api/v1/app-connections/${app}/${connectionId}`); + + return data; + }, + onSuccess: (_, { connectionId, app }) => { + queryClient.invalidateQueries(appConnectionKeys.list()); + queryClient.invalidateQueries(appConnectionKeys.byId(app, connectionId)); + } + }); +}; diff --git a/frontend/src/hooks/api/appConnections/queries.tsx b/frontend/src/hooks/api/appConnections/queries.tsx new file mode 100644 index 0000000000..10e2601ed6 --- /dev/null +++ b/frontend/src/hooks/api/appConnections/queries.tsx @@ -0,0 +1,136 @@ +import { useMemo } from "react"; +import { useQuery, UseQueryOptions } from "@tanstack/react-query"; + +import { apiRequest } from "@app/config/request"; +import { AppConnection } from "@app/hooks/api/appConnections/enums"; +import { + TAppConnection, + TAppConnectionMap, + TAppConnectionOptions, + TGetAppConnection, + TListAppConnections +} from "@app/hooks/api/appConnections/types"; +import { + TAppConnectionOption, + TAppConnectionOptionMap +} from "@app/hooks/api/appConnections/types/app-options"; + +export const appConnectionKeys = { + all: ["app-connection"] as const, + options: () => [...appConnectionKeys.all, "options"] as const, + list: () => [...appConnectionKeys.all, "list"] as const, + listByApp: (app: AppConnection) => [...appConnectionKeys.list(), app], + byId: (app: AppConnection, templateId: string) => + [...appConnectionKeys.all, app, "by-id", templateId] as const +}; + +export const useAppConnectionOptions = ( + options?: Omit< + UseQueryOptions< + TAppConnectionOption[], + unknown, + TAppConnectionOption[], + ReturnType + >, + "queryKey" | "queryFn" + > +) => { + return useQuery({ + queryKey: appConnectionKeys.options(), + queryFn: async () => { + const { data } = await apiRequest.get( + "/api/v1/app-connections/options" + ); + + return data.appConnectionOptions; + }, + ...options + }); +}; + +export const useGetAppConnectionOption = (app: T) => { + const { data: options = [], isLoading } = useAppConnectionOptions(); + + return useMemo( + () => ({ + option: (options.find((opt) => opt.app === app) as TAppConnectionOptionMap[T]) ?? {}, + isLoading + }), + [options, app] + ); +}; + +export const useListAppConnections = ( + options?: Omit< + UseQueryOptions< + TAppConnection[], + unknown, + TAppConnection[], + ReturnType + >, + "queryKey" | "queryFn" + > +) => { + return useQuery({ + queryKey: appConnectionKeys.list(), + queryFn: async () => { + const { data } = await apiRequest.get>( + "/api/v1/app-connections" + ); + + return data.appConnections; + }, + ...options + }); +}; + +export const useListAppConnectionsByApp = ( + app: T, + options?: Omit< + UseQueryOptions< + TAppConnectionMap[T][], + unknown, + TAppConnectionMap[T][], + ReturnType + >, + "queryKey" | "queryFn" + > +) => { + return useQuery({ + queryKey: appConnectionKeys.listByApp(app), + queryFn: async () => { + const { data } = await apiRequest.get>( + `/api/v1/app-connections/${app}` + ); + + return data.appConnections; + }, + ...options + }); +}; + +export const useGetAppConnectionById = ( + app: T, + connectionId: string, + options?: Omit< + UseQueryOptions< + TAppConnectionMap[T], + unknown, + TAppConnectionMap[T], + ReturnType + >, + "queryKey" | "queryFn" + > +) => { + return useQuery({ + queryKey: appConnectionKeys.byId(app, connectionId), + queryFn: async () => { + const { data } = await apiRequest.get>( + `/api/v1/app-connections/${app}/${connectionId}` + ); + + return data.appConnection; + }, + ...options + }); +}; diff --git a/frontend/src/hooks/api/appConnections/types/app-options.ts b/frontend/src/hooks/api/appConnections/types/app-options.ts new file mode 100644 index 0000000000..bfc9e5903c --- /dev/null +++ b/frontend/src/hooks/api/appConnections/types/app-options.ts @@ -0,0 +1,24 @@ +import { AppConnection } from "@app/hooks/api/appConnections/enums"; + +export type TAppConnectionOptionBase = { + name: string; + methods: string[]; +}; + +export type TAwsConnectionOption = TAppConnectionOptionBase & { + app: AppConnection.AWS; + accessKeyId?: string; +}; + +export type TGitHubConnectionOption = TAppConnectionOptionBase & { + app: AppConnection.GitHub; + oauthClientId?: string; + appClientSlug?: string; +}; + +export type TAppConnectionOption = TAwsConnectionOption | TGitHubConnectionOption; + +export type TAppConnectionOptionMap = { + [AppConnection.AWS]: TAwsConnectionOption; + [AppConnection.GitHub]: TGitHubConnectionOption; +}; diff --git a/frontend/src/hooks/api/appConnections/types/aws-connection.ts b/frontend/src/hooks/api/appConnections/types/aws-connection.ts new file mode 100644 index 0000000000..86074ca35f --- /dev/null +++ b/frontend/src/hooks/api/appConnections/types/aws-connection.ts @@ -0,0 +1,23 @@ +import { AppConnection } from "@app/hooks/api/appConnections/enums"; +import { TRootAppConnection } from "@app/hooks/api/appConnections/types/root-connection"; + +export enum AwsConnectionMethod { + AssumeRole = "assume-role", + AccessKey = "access-key" +} + +export type TAwsConnection = TRootAppConnection & { app: AppConnection.AWS } & ( + | { + method: AwsConnectionMethod.AccessKey; + credentials: { + accessKeyId: string; + secretAccessKey: string; + }; + } + | { + method: AwsConnectionMethod.AssumeRole; + credentials: { + roleArn: string; + }; + } + ); diff --git a/frontend/src/hooks/api/appConnections/types/github-connection.ts b/frontend/src/hooks/api/appConnections/types/github-connection.ts new file mode 100644 index 0000000000..d00936cda2 --- /dev/null +++ b/frontend/src/hooks/api/appConnections/types/github-connection.ts @@ -0,0 +1,23 @@ +import { AppConnection } from "@app/hooks/api/appConnections/enums"; +import { TRootAppConnection } from "@app/hooks/api/appConnections/types/root-connection"; + +export enum GitHubConnectionMethod { + App = "github-app", + OAuth = "oauth" +} + +export type TGitHubConnection = TRootAppConnection & { app: AppConnection.GitHub } & ( + | { + method: GitHubConnectionMethod.OAuth; + credentials: { + code: string; + }; + } + | { + method: GitHubConnectionMethod.App; + credentials: { + code: string; + installationId: string; + }; + } + ); diff --git a/frontend/src/hooks/api/appConnections/types/index.ts b/frontend/src/hooks/api/appConnections/types/index.ts new file mode 100644 index 0000000000..2896fc1b55 --- /dev/null +++ b/frontend/src/hooks/api/appConnections/types/index.ts @@ -0,0 +1,34 @@ +import { AppConnection } from "@app/hooks/api/appConnections/enums"; +import { TAppConnectionOption } from "@app/hooks/api/appConnections/types/app-options"; +import { TAwsConnection } from "@app/hooks/api/appConnections/types/aws-connection"; +import { TGitHubConnection } from "@app/hooks/api/appConnections/types/github-connection"; + +export * from "./aws-connection"; +export * from "./github-connection"; + +export type TAppConnection = TAwsConnection | TGitHubConnection; + +export type TListAppConnections = { appConnections: T[] }; +export type TGetAppConnection = { appConnection: T }; +export type TAppConnectionOptions = { appConnectionOptions: TAppConnectionOption[] }; +export type TAppConnectionResponse = { appConnection: TAppConnection }; + +export type TCreateAppConnectionDTO = Pick< + TAppConnection, + "name" | "credentials" | "method" | "app" +>; + +export type TUpdateAppConnectionDTO = Partial> & { + connectionId: string; + app: AppConnection; +}; + +export type TDeleteAppConnectionDTO = { + app: AppConnection; + connectionId: string; +}; + +export type TAppConnectionMap = { + [AppConnection.AWS]: TAwsConnection; + [AppConnection.GitHub]: TGitHubConnection; +}; diff --git a/frontend/src/hooks/api/appConnections/types/root-connection.ts b/frontend/src/hooks/api/appConnections/types/root-connection.ts new file mode 100644 index 0000000000..907efb71d3 --- /dev/null +++ b/frontend/src/hooks/api/appConnections/types/root-connection.ts @@ -0,0 +1,8 @@ +export type TRootAppConnection = { + id: string; + name: string; + version: number; + orgId: string; + createdAt: string; + updatedAt: string; +}; diff --git a/frontend/src/hooks/api/subscriptions/types.ts b/frontend/src/hooks/api/subscriptions/types.ts index b1c4e224d2..44b0a34fbb 100644 --- a/frontend/src/hooks/api/subscriptions/types.ts +++ b/frontend/src/hooks/api/subscriptions/types.ts @@ -45,4 +45,5 @@ export type SubscriptionPlan = { pkiEst: boolean; enforceMfa: boolean; projectTemplates: boolean; + appConnections: boolean; // TODO: remove once released }; diff --git a/frontend/src/layouts/AppLayout/AppLayout.tsx b/frontend/src/layouts/AppLayout/AppLayout.tsx index c084683cb8..04a1b30384 100644 --- a/frontend/src/layouts/AppLayout/AppLayout.tsx +++ b/frontend/src/layouts/AppLayout/AppLayout.tsx @@ -327,6 +327,7 @@ export const AppLayout = ({ children }: LayoutProps) => { )} {!router.asPath.includes("org") && + !router.asPath.includes("app-connections") && (!router.asPath.includes("personal") && currentWorkspace ? ( ) : ( @@ -339,7 +340,8 @@ export const AppLayout = ({ children }: LayoutProps) => { ))}
- {router.pathname.startsWith("/org") && ( + {(router.pathname.startsWith("/org") || + router.pathname.startsWith("/app-connections")) && ( { if ( !currentWorkspace || router.asPath.startsWith("personal") || - router.asPath.startsWith("integrations") + router.asPath.startsWith("integrations") || + router.asPath.startsWith("/app-connections") ) { return
; } diff --git a/frontend/src/lib/types/index.ts b/frontend/src/lib/types/index.ts new file mode 100644 index 0000000000..ce14b6acdd --- /dev/null +++ b/frontend/src/lib/types/index.ts @@ -0,0 +1 @@ +export type DiscriminativePick = T extends unknown ? Pick : never; diff --git a/frontend/src/pages/app-connections/github/oauth/callback.tsx b/frontend/src/pages/app-connections/github/oauth/callback.tsx new file mode 100644 index 0000000000..1c673dae8e --- /dev/null +++ b/frontend/src/pages/app-connections/github/oauth/callback.tsx @@ -0,0 +1,133 @@ +import { useEffect } from "react"; +import { useRouter } from "next/router"; +import queryString from "query-string"; + +import { createNotification } from "@app/components/notifications"; +import { ContentLoader } from "@app/components/v2"; +import { + GitHubConnectionMethod, + TAppConnection, + TGitHubConnection, + useCreateAppConnection, + useUpdateAppConnection +} from "@app/hooks/api/appConnections"; +import { AppConnection } from "@app/hooks/api/appConnections/enums"; + +type FormData = Pick & { + returnUrl?: string; + connectionId?: string; +}; + +export default function GitHubOAuthCallbackPage() { + const router = useRouter(); + const updateAppConnection = useUpdateAppConnection(); + const createAppConnection = useCreateAppConnection(); + + // eslint-disable-next-line @typescript-eslint/naming-convention + const { + code, + state, + installation_id: installationId + } = queryString.parse(router.asPath.split("?")[1]); + + useEffect(() => { + (async () => { + let formData: FormData; + + try { + formData = JSON.parse(localStorage.getItem("githubConnectionFormData") ?? "{}") as FormData; + } catch (e) { + createNotification({ + type: "error", + text: "Invalid form state, redirecting..." + }); + router.push(window.location.origin); + return; + } + + // validate state + if (state !== localStorage.getItem("latestCSRFToken")) { + createNotification({ + type: "error", + text: "Invalid state, redirecting..." + }); + router.push(window.location.origin); + return; + } + + localStorage.removeItem("githubConnectionFormData"); + localStorage.removeItem("latestCSRFToken"); + + const { connectionId, name, returnUrl } = formData; + + let appConnection: TAppConnection; + + try { + if (connectionId) { + appConnection = await updateAppConnection.mutateAsync({ + app: AppConnection.GitHub, + ...(installationId + ? { + connectionId, + credentials: { + code: code as string, + installationId: installationId as string + } + } + : { + connectionId, + credentials: { + code: code as string + } + }) + }); + } else { + appConnection = await createAppConnection.mutateAsync({ + app: AppConnection.GitHub, + name, + ...(installationId + ? { + method: GitHubConnectionMethod.App, + credentials: { + code: code as string, + installationId: installationId as string + } + } + : { + method: GitHubConnectionMethod.OAuth, + credentials: { + code: code as string + } + }) + }); + } + } catch (e: any) { + createNotification({ + title: `Failed to ${connectionId ? "update" : "add"} GitHub Connection`, + text: e.message, + type: "error" + }); + router.push( + returnUrl ?? + `/org/${localStorage.getItem("orgData.id")}/settings?selectedTab=app-connections` + ); + return; + } + + createNotification({ + text: `Successfully ${connectionId ? "updated" : "added"} GitHub Connection`, + type: "success" + }); + + router.push(returnUrl ?? `/org/${appConnection.orgId}/settings?selectedTab=app-connections`); + })(); + }, []); + + return ( +
+ +
+ ); +} + +GitHubOAuthCallbackPage.requireAuth = true; diff --git a/frontend/src/views/Org/RolePage/components/OrgRoleModifySection.utils.ts b/frontend/src/views/Org/RolePage/components/OrgRoleModifySection.utils.ts index aa8c4d7ec0..56f922b52e 100644 --- a/frontend/src/views/Org/RolePage/components/OrgRoleModifySection.utils.ts +++ b/frontend/src/views/Org/RolePage/components/OrgRoleModifySection.utils.ts @@ -49,7 +49,8 @@ export const formSchema = z.object({ identity: generalPermissionSchema, "organization-admin-console": adminConsolePermissionSchmea, [OrgPermissionSubjects.Kms]: generalPermissionSchema, - [OrgPermissionSubjects.ProjectTemplates]: generalPermissionSchema + [OrgPermissionSubjects.ProjectTemplates]: generalPermissionSchema, + [OrgPermissionSubjects.AppConnections]: generalPermissionSchema }) .optional() }); diff --git a/frontend/src/views/Org/RolePage/components/RolePermissionsSection/RolePermissionsSection.tsx b/frontend/src/views/Org/RolePage/components/RolePermissionsSection/RolePermissionsSection.tsx index 976b356363..31990d46e5 100644 --- a/frontend/src/views/Org/RolePage/components/RolePermissionsSection/RolePermissionsSection.tsx +++ b/frontend/src/views/Org/RolePage/components/RolePermissionsSection/RolePermissionsSection.tsx @@ -69,7 +69,8 @@ const SIMPLE_PERMISSION_OPTIONS = [ title: "External KMS", formName: OrgPermissionSubjects.Kms }, - { title: "Project Templates", formName: OrgPermissionSubjects.ProjectTemplates } + { title: "Project Templates", formName: OrgPermissionSubjects.ProjectTemplates }, + { title: "App Connections", formName: OrgPermissionSubjects.AppConnections } ] as const; type Props = { diff --git a/frontend/src/views/Settings/OrgSettingsPage/OrgSettingsPage.tsx b/frontend/src/views/Settings/OrgSettingsPage/OrgSettingsPage.tsx index b5ed6d26a0..cfabf2ebde 100644 --- a/frontend/src/views/Settings/OrgSettingsPage/OrgSettingsPage.tsx +++ b/frontend/src/views/Settings/OrgSettingsPage/OrgSettingsPage.tsx @@ -7,7 +7,7 @@ export const OrgSettingsPage = () => { return (
-
+

{t("settings.org.title")}

diff --git a/frontend/src/views/Settings/OrgSettingsPage/components/AppConnectionsTab/AppConnectionsTab.tsx b/frontend/src/views/Settings/OrgSettingsPage/components/AppConnectionsTab/AppConnectionsTab.tsx new file mode 100644 index 0000000000..2eeb22e659 --- /dev/null +++ b/frontend/src/views/Settings/OrgSettingsPage/components/AppConnectionsTab/AppConnectionsTab.tsx @@ -0,0 +1,99 @@ +import Link from "next/link"; +import { + faArrowUpRightFromSquare, + faBookOpen, + faPlus, + faWrench +} from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; + +import { OrgPermissionCan } from "@app/components/permissions"; +import { Button } from "@app/components/v2"; +import { OrgPermissionActions, OrgPermissionSubjects, useSubscription } from "@app/context"; +import { withPermission } from "@app/hoc"; +import { usePopUp } from "@app/hooks"; + +import { AddAppConnectionModal, AppConnectionsTable } from "./components"; + +export const AppConnectionsTab = withPermission( + () => { + const { subscription } = useSubscription(); + + const { popUp, handlePopUpOpen, handlePopUpToggle } = usePopUp(["addConnection"] as const); + + // TODO: remove once live + if (!subscription?.appConnections) + return ( +
+ +
+
+ App Connections are currently unavailable. +
+ Check back soon. +
+
+ ); + + return ( +
+
+
+
+
+

App Connections

+ + +
+ + Docs + +
+
+ +
+

+ Create and configure connections with third-party apps for re-use across Infisical + projects +

+
+ + {(isAllowed) => ( + + )} + +
+ + handlePopUpToggle("addConnection", isOpen)} + /> +
+
+ ); + }, + { + action: OrgPermissionActions.Read, + subject: OrgPermissionSubjects.AppConnections + } +); diff --git a/frontend/src/views/Settings/OrgSettingsPage/components/AppConnectionsTab/components/AddAppConnectionModal.tsx b/frontend/src/views/Settings/OrgSettingsPage/components/AppConnectionsTab/components/AddAppConnectionModal.tsx new file mode 100644 index 0000000000..c74985a3f8 --- /dev/null +++ b/frontend/src/views/Settings/OrgSettingsPage/components/AppConnectionsTab/components/AddAppConnectionModal.tsx @@ -0,0 +1,47 @@ +import { useState } from "react"; + +import { Modal, ModalContent } from "@app/components/v2"; +import { TAppConnection } from "@app/hooks/api/appConnections"; +import { AppConnection } from "@app/hooks/api/appConnections/enums"; + +import { AppConnectionForm } from "./AppConnectionForm"; +import { AppConnectionsSelect } from "./AppConnectionList"; + +type Props = { + isOpen: boolean; + onOpenChange: (isOpen: boolean) => void; +}; + +type ContentProps = { + onComplete: (appConnection: TAppConnection) => void; +}; + +const Content = ({ onComplete }: ContentProps) => { + const [selectedApp, setSelectedApp] = useState(null); + + if (selectedApp) { + return ( + setSelectedApp(null)} + app={selectedApp} + /> + ); + } + + return ; +}; + +export const AddAppConnectionModal = ({ isOpen, onOpenChange }: Props) => { + return ( + + + onOpenChange(false)} /> + + + ); +}; diff --git a/frontend/src/views/Settings/OrgSettingsPage/components/AppConnectionsTab/components/AppConnectionForm/AppConnectionForm.tsx b/frontend/src/views/Settings/OrgSettingsPage/components/AppConnectionsTab/components/AppConnectionForm/AppConnectionForm.tsx new file mode 100644 index 0000000000..ba0f343f97 --- /dev/null +++ b/frontend/src/views/Settings/OrgSettingsPage/components/AppConnectionsTab/components/AppConnectionForm/AppConnectionForm.tsx @@ -0,0 +1,117 @@ +import { createNotification } from "@app/components/notifications"; +import { APP_CONNECTION_MAP } from "@app/helpers/appConnections"; +import { + TAppConnection, + useCreateAppConnection, + useUpdateAppConnection +} from "@app/hooks/api/appConnections"; +import { AppConnection } from "@app/hooks/api/appConnections/enums"; +import { DiscriminativePick } from "@app/lib/types"; + +import { AppConnectionHeader } from "../AppConnectionHeader"; +import { AwsConnectionForm } from "./AwsConnectionForm"; +import { GitHubConnectionForm } from "./GitHubConnectionForm"; + +type FormProps = { + onComplete: (appConnection: TAppConnection) => void; +} & ({ appConnection: TAppConnection } | { app: AppConnection }); + +type CreateFormProps = FormProps & { app: AppConnection }; +type UpdateFormProps = FormProps & { + appConnection: TAppConnection; +}; + +const CreateForm = ({ app, onComplete }: CreateFormProps) => { + const createAppConnection = useCreateAppConnection(); + const { name: appName } = APP_CONNECTION_MAP[app]; + + const onSubmit = async ( + formData: DiscriminativePick + ) => { + try { + const connection = await createAppConnection.mutateAsync(formData); + createNotification({ + text: `Successfully added ${appName} Connection`, + type: "success" + }); + onComplete(connection); + } catch (err: any) { + console.error(err); + createNotification({ + title: `Failed to add ${appName} Connection`, + text: err.message, + type: "error" + }); + } + }; + + switch (app) { + case AppConnection.AWS: + return ; + case AppConnection.GitHub: + return ; + default: + throw new Error(`Unhandled App ${app}`); + } +}; + +const UpdateForm = ({ appConnection, onComplete }: UpdateFormProps) => { + const updateAppConnection = useUpdateAppConnection(); + const { name: appName } = APP_CONNECTION_MAP[appConnection.app]; + + const onSubmit = async ( + formData: DiscriminativePick + ) => { + try { + const connection = await updateAppConnection.mutateAsync({ + connectionId: appConnection.id, + ...formData + }); + createNotification({ + text: `Successfully updated ${appName} Connection`, + type: "success" + }); + onComplete(connection); + } catch (err: any) { + console.error(err); + createNotification({ + title: `Failed to update ${appName} Connection`, + text: err.message, + type: "error" + }); + } + }; + + switch (appConnection.app) { + case AppConnection.AWS: + return ; + case AppConnection.GitHub: + return ; + default: + throw new Error(`Unhandled App ${(appConnection as TAppConnection).app}`); + } +}; + +type Props = { onBack?: () => void } & Pick & + ( + | { app: AppConnection; appConnection?: undefined } + | { app?: undefined; appConnection: TAppConnection } + ); +export const AppConnectionForm = ({ onBack, ...props }: Props) => { + const { app, appConnection } = props; + + return ( +
+ + {appConnection ? ( + + ) : ( + + )} +
+ ); +}; diff --git a/frontend/src/views/Settings/OrgSettingsPage/components/AppConnectionsTab/components/AppConnectionForm/AwsConnectionForm.tsx b/frontend/src/views/Settings/OrgSettingsPage/components/AppConnectionsTab/components/AppConnectionForm/AwsConnectionForm.tsx new file mode 100644 index 0000000000..269d18b6f5 --- /dev/null +++ b/frontend/src/views/Settings/OrgSettingsPage/components/AppConnectionsTab/components/AppConnectionForm/AwsConnectionForm.tsx @@ -0,0 +1,194 @@ +import { Controller, useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { z } from "zod"; + +import { + Button, + FormControl, + Input, + ModalClose, + SecretInput, + Select, + SelectItem +} from "@app/components/v2"; +import { APP_CONNECTION_MAP, APP_CONNECTION_METHOD_MAP } from "@app/helpers/appConnections"; +import { AwsConnectionMethod, TAwsConnection } from "@app/hooks/api/appConnections"; +import { AppConnection } from "@app/hooks/api/appConnections/enums"; +import { slugSchema } from "@app/lib/schemas"; + +type Props = { + appConnection?: TAwsConnection; + onSubmit: (formData: FormData) => void; +}; + +const rootSchema = z.object({ + name: slugSchema({ min: 1, max: 32, field: "Name" }), + app: z.literal(AppConnection.AWS) +}); + +const formSchema = z.discriminatedUnion("method", [ + rootSchema.extend({ + method: z.literal(AwsConnectionMethod.AssumeRole), + credentials: z.object({ + roleArn: z.string().min(1, "Role ARN required") + }) + }), + rootSchema.extend({ + method: z.literal(AwsConnectionMethod.AccessKey), + credentials: z.object({ + accessKeyId: z.string().min(1, "Access Key ID required"), + secretAccessKey: z.string().min(1, "Secret Access Key required") + }) + }) +]); + +type FormData = z.infer; + +export const AwsConnectionForm = ({ appConnection, onSubmit }: Props) => { + const isUpdate = Boolean(appConnection); + + const { + handleSubmit, + register, + control, + watch, + formState: { isSubmitting, errors, isDirty } + } = useForm({ + resolver: zodResolver(formSchema), + defaultValues: appConnection ?? { + app: AppConnection.AWS, + method: AwsConnectionMethod.AssumeRole + } + }); + + const selectedMethod = watch("method"); + + return ( +
+ {!isUpdate && ( + + + + )} + ( + + + + )} + /> + {selectedMethod === AwsConnectionMethod.AssumeRole ? ( + ( + + onChange(e.target.value)} + /> + + )} + /> + ) : ( + <> + ( + + onChange(e.target.value)} + /> + + )} + /> + ( + + onChange(e.target.value)} + /> + + )} + /> + + )} +
+ + + + +
+ + ); +}; diff --git a/frontend/src/views/Settings/OrgSettingsPage/components/AppConnectionsTab/components/AppConnectionForm/GitHubConnectionForm.tsx b/frontend/src/views/Settings/OrgSettingsPage/components/AppConnectionsTab/components/AppConnectionForm/GitHubConnectionForm.tsx new file mode 100644 index 0000000000..76a1602ee7 --- /dev/null +++ b/frontend/src/views/Settings/OrgSettingsPage/components/AppConnectionsTab/components/AppConnectionForm/GitHubConnectionForm.tsx @@ -0,0 +1,172 @@ +import crypto from "crypto"; + +import { useState } from "react"; +import { Controller, useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { z } from "zod"; + +import { Button, FormControl, Input, ModalClose, Select, SelectItem } from "@app/components/v2"; +import { APP_CONNECTION_MAP, APP_CONNECTION_METHOD_MAP } from "@app/helpers/appConnections"; +import { + GitHubConnectionMethod, + TGitHubConnection, + useGetAppConnectionOption +} from "@app/hooks/api/appConnections"; +import { AppConnection } from "@app/hooks/api/appConnections/enums"; +import { slugSchema } from "@app/lib/schemas"; + +type Props = { + appConnection?: TGitHubConnection; +}; + +const rootSchema = z.object({ + name: slugSchema({ min: 1, max: 32, field: "Name" }), + app: z.literal(AppConnection.GitHub) +}); + +const formSchema = z.discriminatedUnion("method", [ + rootSchema.extend({ + method: z.literal(GitHubConnectionMethod.App) + }), + rootSchema.extend({ + method: z.literal(GitHubConnectionMethod.OAuth) + }) +]); + +type FormData = z.infer; + +export const GitHubConnectionForm = ({ appConnection }: Props) => { + const isUpdate = Boolean(appConnection); + const [isRedirecting, setIsRedirecting] = useState(false); + + const { + option: { oauthClientId, appClientSlug }, + isLoading + } = useGetAppConnectionOption(AppConnection.GitHub); + + const { + handleSubmit, + register, + control, + watch, + formState: { isSubmitting, errors, isDirty } + } = useForm({ + resolver: zodResolver(formSchema), + defaultValues: appConnection ?? { + app: AppConnection.GitHub, + method: GitHubConnectionMethod.App + } + }); + + const selectedMethod = watch("method"); + + const onSubmit = (formData: FormData) => { + setIsRedirecting(true); + const state = crypto.randomBytes(16).toString("hex"); + localStorage.setItem("latestCSRFToken", state); + localStorage.setItem( + "githubConnectionFormData", + JSON.stringify({ ...formData, connectionId: appConnection?.id }) + ); + + switch (formData.method) { + case GitHubConnectionMethod.App: + window.location.assign( + `https://github.com/apps/${appClientSlug}/installations/new?state=${state}` + ); + break; + case GitHubConnectionMethod.OAuth: + window.location.assign( + `https://github.com/login/oauth/authorize?client_id=${oauthClientId}&response_type=code&scope=repo,admin:org&redirect_uri=${window.location.origin}/app-connections/github/oauth/callback&state=${state}` + ); + break; + default: + throw new Error(`Unhandled GitHub Connection method: ${(formData as FormData).method}`); + } + }; + + let isMissingConfig: boolean; + + switch (selectedMethod) { + case GitHubConnectionMethod.OAuth: + isMissingConfig = !oauthClientId; + break; + case GitHubConnectionMethod.App: + isMissingConfig = !appClientSlug; + break; + default: + throw new Error(`Unhandled GitHub Connection method: ${selectedMethod}`); + } + + return ( +
+ {!isUpdate && ( + + + + )} + ( + + + + )} + /> +
+ + + + +
+ + ); +}; diff --git a/frontend/src/views/Settings/OrgSettingsPage/components/AppConnectionsTab/components/AppConnectionForm/index.ts b/frontend/src/views/Settings/OrgSettingsPage/components/AppConnectionsTab/components/AppConnectionForm/index.ts new file mode 100644 index 0000000000..2bc49b9075 --- /dev/null +++ b/frontend/src/views/Settings/OrgSettingsPage/components/AppConnectionsTab/components/AppConnectionForm/index.ts @@ -0,0 +1 @@ +export * from "./AppConnectionForm"; diff --git a/frontend/src/views/Settings/OrgSettingsPage/components/AppConnectionsTab/components/AppConnectionHeader.tsx b/frontend/src/views/Settings/OrgSettingsPage/components/AppConnectionsTab/components/AppConnectionHeader.tsx new file mode 100644 index 0000000000..1b4635be06 --- /dev/null +++ b/frontend/src/views/Settings/OrgSettingsPage/components/AppConnectionsTab/components/AppConnectionHeader.tsx @@ -0,0 +1,58 @@ +import Link from "next/link"; +import { faArrowUpRightFromSquare, faBookOpen } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; + +import { APP_CONNECTION_MAP } from "@app/helpers/appConnections"; +import { AppConnection } from "@app/hooks/api/appConnections/enums"; + +type Props = { + app: AppConnection; + isConnected: boolean; + onBack?: () => void; +}; + +export const AppConnectionHeader = ({ app, isConnected, onBack }: Props) => { + const appDetails = APP_CONNECTION_MAP[app]; + + return ( +
+ {`${appDetails.name} +
+
+ {appDetails.name} + + +
+ + Docs + +
+
+ +
+

+ {isConnected ? `${appDetails.name} Connection` : `Connect to ${appDetails.name}`} +

+
+ {onBack && ( + + )} +
+ ); +}; diff --git a/frontend/src/views/Settings/OrgSettingsPage/components/AppConnectionsTab/components/AppConnectionList.tsx b/frontend/src/views/Settings/OrgSettingsPage/components/AppConnectionsTab/components/AppConnectionList.tsx new file mode 100644 index 0000000000..08f1124139 --- /dev/null +++ b/frontend/src/views/Settings/OrgSettingsPage/components/AppConnectionsTab/components/AppConnectionList.tsx @@ -0,0 +1,60 @@ +import { faWrench } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; + +import { Spinner, Tooltip } from "@app/components/v2"; +import { APP_CONNECTION_MAP } from "@app/helpers/appConnections"; +import { useAppConnectionOptions } from "@app/hooks/api/appConnections"; +import { AppConnection } from "@app/hooks/api/appConnections/enums"; + +type Props = { + onSelect: (app: AppConnection) => void; +}; + +export const AppConnectionsSelect = ({ onSelect }: Props) => { + const { isLoading, data: appConnectionOptions } = useAppConnectionOptions(); + + if (isLoading) { + return ( +
+ +

Loading options...

+
+ ); + } + + return ( +
+ {appConnectionOptions?.map((option) => ( + + ))} + +
+ +
+ Coming Soon +
+
+
+
+ ); +}; diff --git a/frontend/src/views/Settings/OrgSettingsPage/components/AppConnectionsTab/components/AppConnectionRow.tsx b/frontend/src/views/Settings/OrgSettingsPage/components/AppConnectionsTab/components/AppConnectionRow.tsx new file mode 100644 index 0000000000..40c3b621bb --- /dev/null +++ b/frontend/src/views/Settings/OrgSettingsPage/components/AppConnectionsTab/components/AppConnectionRow.tsx @@ -0,0 +1,159 @@ +import { useCallback } from "react"; +import { + faAsterisk, + faCheck, + faCopy, + faEdit, + faEllipsisV, + faTrash +} from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { twMerge } from "tailwind-merge"; + +import { createNotification } from "@app/components/notifications"; +import { OrgPermissionCan } from "@app/components/permissions"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, + IconButton, + Td, + Tooltip, + Tr +} from "@app/components/v2"; +import { OrgPermissionActions, OrgPermissionSubjects } from "@app/context"; +import { APP_CONNECTION_MAP, APP_CONNECTION_METHOD_MAP } from "@app/helpers/appConnections"; +import { useToggle } from "@app/hooks"; +import { TAppConnection } from "@app/hooks/api/appConnections"; + +type Props = { + appConnection: TAppConnection; + onDelete: (appConnection: TAppConnection) => void; + onEditCredentials: (appConnection: TAppConnection) => void; + onEditName: (appConnection: TAppConnection) => void; +}; + +export const AppConnectionRow = ({ + appConnection, + onDelete, + onEditCredentials, + onEditName +}: Props) => { + const { id, name, method, app } = appConnection; + + const [isIdCopied, setIsIdCopied] = useToggle(false); + + const handleCopyId = useCallback(() => { + setIsIdCopied.on(); + navigator.clipboard.writeText(id); + + createNotification({ + text: "Connection ID copied to clipboard", + type: "info" + }); + + const timer = setTimeout(() => setIsIdCopied.off(), 2000); + + // eslint-disable-next-line consistent-return + return () => clearTimeout(timer); + }, [isIdCopied]); + + return ( + + +
+ {`${APP_CONNECTION_MAP[app].name} + {APP_CONNECTION_MAP[app].name} +
+ + +

{name}

+ + +

+ + {APP_CONNECTION_METHOD_MAP[method].name} +

+ + + + + + + + + + + + } + onClick={() => handleCopyId()} + > + Copy Connection ID + + + {(isAllowed: boolean) => ( + } + onClick={() => onEditName(appConnection)} + > + Edit Name + + )} + + + {(isAllowed: boolean) => ( + } + onClick={() => onEditCredentials(appConnection)} + > + Edit Credentials + + )} + + + {(isAllowed: boolean) => ( + } + onClick={() => onDelete(appConnection)} + > + Delete Connection + + )} + + + + + + + ); +}; diff --git a/frontend/src/views/Settings/OrgSettingsPage/components/AppConnectionsTab/components/AppConnectionsTable.tsx b/frontend/src/views/Settings/OrgSettingsPage/components/AppConnectionsTab/components/AppConnectionsTable.tsx new file mode 100644 index 0000000000..80908a45fb --- /dev/null +++ b/frontend/src/views/Settings/OrgSettingsPage/components/AppConnectionsTab/components/AppConnectionsTable.tsx @@ -0,0 +1,319 @@ +import { useMemo, useState } from "react"; +import { + faArrowDown, + faArrowUp, + faCheckCircle, + faFilter, + faMagnifyingGlass, + faPlug, + faSearch +} from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { twMerge } from "tailwind-merge"; + +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuTrigger, + EmptyState, + IconButton, + Input, + Pagination, + Table, + TableContainer, + TableSkeleton, + TBody, + Th, + THead, + Tr +} from "@app/components/v2"; +import { useSubscription } from "@app/context"; +import { APP_CONNECTION_MAP, APP_CONNECTION_METHOD_MAP } from "@app/helpers/appConnections"; +import { usePagination, usePopUp, useResetPageHelper } from "@app/hooks"; +import { TAppConnection, useListAppConnections } from "@app/hooks/api/appConnections"; +import { AppConnection } from "@app/hooks/api/appConnections/enums"; +import { OrderByDirection } from "@app/hooks/api/generic/types"; + +import { AppConnectionRow } from "./AppConnectionRow"; +import { DeleteAppConnectionModal } from "./DeleteAppConnectionModal"; +import { EditAppConnectionCredentialsModal } from "./EditAppConnectionCredentialsModal"; +import { EditAppConnectionNameModal } from "./EditAppConnectionNameModal"; + +enum AppConnectionsOrderBy { + App = "app", + Name = "name", + Method = "method" +} + +type AppConnectionFilters = { + apps: AppConnection[]; +}; + +export const AppConnectionsTable = () => { + const { subscription } = useSubscription(); + + const { isLoading, data: appConnections = [] } = useListAppConnections({ + enabled: subscription?.appConnections + }); + + const { popUp, handlePopUpOpen, handlePopUpToggle } = usePopUp([ + "deleteConnection", + "editCredentials", + "editName" + ] as const); + + const [filters, setFilters] = useState({ + apps: [] + }); + + const { + search, + setSearch, + setPage, + page, + perPage, + setPerPage, + offset, + orderDirection, + toggleOrderDirection, + orderBy, + setOrderDirection, + setOrderBy + } = usePagination(AppConnectionsOrderBy.App, { initPerPage: 20 }); + + const filteredAppConnections = useMemo( + () => + appConnections + .filter((appConnection) => { + const { app, method, name } = appConnection; + + if (filters.apps.length && !filters.apps.includes(app)) return false; + + const searchValue = search.trim().toLowerCase(); + + return ( + APP_CONNECTION_MAP[app].name.toLowerCase().includes(searchValue) || + APP_CONNECTION_METHOD_MAP[method].name.toLowerCase().includes(searchValue) || + name.toLowerCase().includes(searchValue) + ); + }) + .sort((a, b) => { + const [connectionOne, connectionTwo] = + orderDirection === OrderByDirection.ASC ? [a, b] : [b, a]; + + switch (orderBy) { + case AppConnectionsOrderBy.Name: + return connectionOne.name + .toLowerCase() + .localeCompare(connectionTwo.name.toLowerCase()); + case AppConnectionsOrderBy.Method: + return APP_CONNECTION_METHOD_MAP[connectionOne.method].name + .toLowerCase() + .localeCompare(APP_CONNECTION_METHOD_MAP[connectionTwo.method].name.toLowerCase()); + case AppConnectionsOrderBy.App: + default: + return APP_CONNECTION_MAP[connectionOne.app].name + .toLowerCase() + .localeCompare(APP_CONNECTION_MAP[connectionTwo.app].name.toLowerCase()); + } + }), + [appConnections, orderDirection, search, orderBy, filters] + ); + + useResetPageHelper({ + totalCount: filteredAppConnections.length, + offset, + setPage + }); + + const handleSort = (column: AppConnectionsOrderBy) => { + if (column === orderBy) { + toggleOrderDirection(); + return; + } + + setOrderBy(column); + setOrderDirection(OrderByDirection.ASC); + }; + + const getClassName = (col: AppConnectionsOrderBy) => + twMerge("ml-2", orderBy === col ? "" : "opacity-30"); + + const getColSortIcon = (col: AppConnectionsOrderBy) => + orderDirection === OrderByDirection.DESC && orderBy === col ? faArrowUp : faArrowDown; + + const isTableFiltered = Boolean(filters.apps.length); + + const handleDelete = (appConnection: TAppConnection) => + handlePopUpOpen("deleteConnection", appConnection); + + const handleEditCredentials = (appConnection: TAppConnection) => + handlePopUpOpen("editCredentials", appConnection); + + const handleEditName = (appConnection: TAppConnection) => + handlePopUpOpen("editName", appConnection); + + return ( +
+
+ setSearch(e.target.value)} + leftIcon={} + placeholder="Search integrations..." + className="flex-1" + /> + + + + + + + + Filter by Apps + {appConnections.length ? ( + [...new Set(appConnections.map(({ app }) => app))].map((app) => ( + { + e.preventDefault(); + setFilters((prev) => ({ + ...prev, + apps: prev.apps.includes(app) + ? prev.apps.filter((a) => a !== app) + : [...prev.apps, app] + })); + }} + key={app} + icon={ + filters.apps.includes(app) && ( + + ) + } + iconPos="right" + > +
+ {`${APP_CONNECTION_MAP[app].name} + {APP_CONNECTION_MAP[app].name} +
+
+ )) + ) : ( + No Connections Configured + )} +
+
+
+ + + + + + + + + + + + {isLoading && ( + + )} + {filteredAppConnections.slice(offset, perPage * page).map((connection) => ( + + ))} + +
+
+ App + handleSort(AppConnectionsOrderBy.App)} + > + + +
+
+
+ Name + handleSort(AppConnectionsOrderBy.Name)} + > + + +
+
+
+ Method + handleSort(AppConnectionsOrderBy.Method)} + > + + +
+
+
+ {Boolean(filteredAppConnections.length) && ( + + )} + {!isLoading && !filteredAppConnections?.length && ( + + )} +
+ handlePopUpToggle("deleteConnection", isOpen)} + appConnection={popUp.deleteConnection.data} + /> + handlePopUpToggle("editCredentials", isOpen)} + appConnection={popUp.editCredentials.data} + /> + handlePopUpToggle("editName", isOpen)} + appConnection={popUp.editName.data} + /> +
+ ); +}; diff --git a/frontend/src/views/Settings/OrgSettingsPage/components/AppConnectionsTab/components/DeleteAppConnectionModal.tsx b/frontend/src/views/Settings/OrgSettingsPage/components/AppConnectionsTab/components/DeleteAppConnectionModal.tsx new file mode 100644 index 0000000000..fc42f6a4ca --- /dev/null +++ b/frontend/src/views/Settings/OrgSettingsPage/components/AppConnectionsTab/components/DeleteAppConnectionModal.tsx @@ -0,0 +1,51 @@ +import { createNotification } from "@app/components/notifications"; +import { DeleteActionModal } from "@app/components/v2"; +import { APP_CONNECTION_MAP } from "@app/helpers/appConnections"; +import { TAppConnection, useDeleteAppConnection } from "@app/hooks/api/appConnections"; + +type Props = { + appConnection?: TAppConnection; + isOpen: boolean; + onOpenChange: (isOpen: boolean) => void; +}; + +export const DeleteAppConnectionModal = ({ isOpen, onOpenChange, appConnection }: Props) => { + const deleteAppConnection = useDeleteAppConnection(); + + if (!appConnection) return null; + + const { id: connectionId, name, app } = appConnection; + + const handleDeleteAppConnection = async () => { + try { + await deleteAppConnection.mutateAsync({ + connectionId, + app + }); + + createNotification({ + text: `Successfully removed ${APP_CONNECTION_MAP[app].name} connection`, + type: "success" + }); + + onOpenChange(false); + } catch (err) { + console.error(err); + + createNotification({ + text: `Failed remove ${APP_CONNECTION_MAP[app].name} connection`, + type: "error" + }); + } + }; + + return ( + + ); +}; diff --git a/frontend/src/views/Settings/OrgSettingsPage/components/AppConnectionsTab/components/EditAppConnectionCredentialsModal.tsx b/frontend/src/views/Settings/OrgSettingsPage/components/AppConnectionsTab/components/EditAppConnectionCredentialsModal.tsx new file mode 100644 index 0000000000..70fd323e7b --- /dev/null +++ b/frontend/src/views/Settings/OrgSettingsPage/components/AppConnectionsTab/components/EditAppConnectionCredentialsModal.tsx @@ -0,0 +1,33 @@ +import { Modal, ModalContent } from "@app/components/v2"; +import { APP_CONNECTION_MAP } from "@app/helpers/appConnections"; +import { TAppConnection } from "@app/hooks/api/appConnections"; + +import { AppConnectionForm } from "./AppConnectionForm"; + +type Props = { + isOpen: boolean; + onOpenChange: (isOpen: boolean) => void; + appConnection?: TAppConnection; +}; + +export const EditAppConnectionCredentialsModal = ({ + isOpen, + onOpenChange, + appConnection +}: Props) => { + if (!appConnection) return null; + + return ( + + + onOpenChange(false)} appConnection={appConnection} /> + + + ); +}; diff --git a/frontend/src/views/Settings/OrgSettingsPage/components/AppConnectionsTab/components/EditAppConnectionNameModal.tsx b/frontend/src/views/Settings/OrgSettingsPage/components/AppConnectionsTab/components/EditAppConnectionNameModal.tsx new file mode 100644 index 0000000000..26fb75c5a8 --- /dev/null +++ b/frontend/src/views/Settings/OrgSettingsPage/components/AppConnectionsTab/components/EditAppConnectionNameModal.tsx @@ -0,0 +1,110 @@ +import { useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { z } from "zod"; + +import { createNotification } from "@app/components/notifications"; +import { Button, FormControl, Input, Modal, ModalClose, ModalContent } from "@app/components/v2"; +import { APP_CONNECTION_MAP } from "@app/helpers/appConnections"; +import { TAppConnection, useUpdateAppConnection } from "@app/hooks/api/appConnections"; +import { AppConnection } from "@app/hooks/api/appConnections/enums"; +import { slugSchema } from "@app/lib/schemas"; +import { DiscriminativePick } from "@app/lib/types"; + +type Props = { + isOpen: boolean; + onOpenChange: (isOpen: boolean) => void; + appConnection?: TAppConnection; +}; + +const formSchema = z.object({ + name: slugSchema({ min: 1, max: 32, field: "Name" }), + app: z.nativeEnum(AppConnection) +}); + +type FormData = z.infer; + +type ContentProps = { appConnection: TAppConnection; onComplete: () => void }; + +const Content = ({ appConnection, onComplete }: ContentProps) => { + const updateAppConnection = useUpdateAppConnection(); + const { name: appName } = APP_CONNECTION_MAP[appConnection.app]; + + const { + handleSubmit, + register, + formState: { isSubmitting, errors, isDirty } + } = useForm({ + resolver: zodResolver(formSchema), + defaultValues: { name: appConnection.name, app: appConnection.app } + }); + + const onSubmit = async (formData: DiscriminativePick) => { + try { + await updateAppConnection.mutateAsync({ + connectionId: appConnection.id, + ...formData + }); + createNotification({ + text: `Successfully updated ${appName} Connection`, + type: "success" + }); + onComplete(); + } catch (err: any) { + console.error(err); + createNotification({ + title: `Failed to update ${appName} Connection`, + text: err.message, + type: "error" + }); + } + }; + + return ( +
+ + + + +
+ + + + +
+
+ ); +}; + +export const EditAppConnectionNameModal = ({ isOpen, onOpenChange, appConnection }: Props) => { + if (!appConnection) return null; + + return ( + + + onOpenChange(false)} /> + + + ); +}; diff --git a/frontend/src/views/Settings/OrgSettingsPage/components/AppConnectionsTab/components/index.tsx b/frontend/src/views/Settings/OrgSettingsPage/components/AppConnectionsTab/components/index.tsx new file mode 100644 index 0000000000..37ebe74fd7 --- /dev/null +++ b/frontend/src/views/Settings/OrgSettingsPage/components/AppConnectionsTab/components/index.tsx @@ -0,0 +1,2 @@ +export * from "./AddAppConnectionModal"; +export * from "./AppConnectionsTable"; diff --git a/frontend/src/views/Settings/OrgSettingsPage/components/AppConnectionsTab/index.tsx b/frontend/src/views/Settings/OrgSettingsPage/components/AppConnectionsTab/index.tsx new file mode 100644 index 0000000000..79649edaff --- /dev/null +++ b/frontend/src/views/Settings/OrgSettingsPage/components/AppConnectionsTab/index.tsx @@ -0,0 +1 @@ +export { AppConnectionsTab } from "./AppConnectionsTab"; diff --git a/frontend/src/views/Settings/OrgSettingsPage/components/OrgTabGroup/OrgTabGroup.tsx b/frontend/src/views/Settings/OrgSettingsPage/components/OrgTabGroup/OrgTabGroup.tsx index e8c106cde2..b15db359f0 100644 --- a/frontend/src/views/Settings/OrgSettingsPage/components/OrgTabGroup/OrgTabGroup.tsx +++ b/frontend/src/views/Settings/OrgSettingsPage/components/OrgTabGroup/OrgTabGroup.tsx @@ -4,6 +4,7 @@ import { Tab } from "@headlessui/react"; import { OrgPermissionCan } from "@app/components/permissions"; import { OrgPermissionActions, OrgPermissionSubjects } from "@app/context"; +import { AppConnectionsTab } from "@app/views/Settings/OrgSettingsPage/components/AppConnectionsTab"; import { ProjectTemplatesTab } from "@app/views/Settings/OrgSettingsPage/components/ProjectTemplatesTab"; import { AuditLogStreamsTab } from "../AuditLogStreamTab"; @@ -18,6 +19,7 @@ const tabs = [ { name: "Security", key: "tab-org-security" }, { name: "Encryption", key: "tab-org-encryption" }, { name: "Workflow Integrations", key: "workflow-integrations" }, + { name: "App Connections", key: "app-connections" }, { name: "Audit Log Streams", key: "tag-audit-log-streams" }, { name: "Import", key: "tab-import" }, { name: "Project Templates", key: "project-templates" } @@ -67,6 +69,9 @@ export const OrgTabGroup = () => { + + +