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 7ae0b64fdf..02159fa9cb 100644 --- a/backend/src/@types/fastify.d.ts +++ b/backend/src/@types/fastify.d.ts @@ -36,6 +36,7 @@ import { TSshCertificateTemplateServiceFactory } from "@app/ee/services/ssh-cert 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"; @@ -208,6 +209,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 6f7e36c7db..05dff5a735 100644 --- a/backend/src/@types/knex.d.ts +++ b/backend/src/@types/knex.d.ts @@ -363,6 +363,7 @@ import { TWorkflowIntegrationsInsert, TWorkflowIntegrationsUpdate } from "@app/db/schemas"; +import { TAppConnections, TAppConnectionsInsert, TAppConnectionsUpdate } from "@app/db/schemas/app-connections"; import { TExternalGroupOrgRoleMappings, TExternalGroupOrgRoleMappingsInsert, @@ -886,5 +887,10 @@ declare module "knex/types/tables" { TProjectSplitBackfillIdsInsert, TProjectSplitBackfillIdsUpdate >; + [TableName.AppConnection]: KnexOriginal.CompositeTableType< + TAppConnections, + TAppConnectionsInsert, + TAppConnectionsUpdate + >; } } diff --git a/backend/src/db/migrations/20241218181018_app-connection.ts b/backend/src/db/migrations/20241218181018_app-connection.ts new file mode 100644 index 0000000000..d09907ae17 --- /dev/null +++ b/backend/src/db/migrations/20241218181018_app-connection.ts @@ -0,0 +1,28 @@ +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("description"); + 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..8c9dff2367 --- /dev/null +++ b/backend/src/db/schemas/app-connections.ts @@ -0,0 +1,27 @@ +// 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(), + description: z.string().nullable().optional(), + 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 3156c97420..620c526a53 100644 --- a/backend/src/db/schemas/models.ts +++ b/backend/src/db/schemas/models.ts @@ -129,7 +129,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 1338257e2e..a8d98977f3 100644 --- a/backend/src/ee/services/audit-log/audit-log-types.ts +++ b/backend/src/ee/services/audit-log/audit-log-types.ts @@ -6,6 +6,8 @@ import { SshCaStatus, SshCertType } from "@app/ee/services/ssh/ssh-certificate-a import { SshCertTemplateStatus } from "@app/ee/services/ssh-certificate-template/ssh-certificate-template-types"; import { SymmetricEncryption } from "@app/lib/crypto/cipher"; import { TProjectPermission } from "@app/lib/types"; +import { AppConnection } from "@app/services/app-connection/app-connection-enums"; +import { TCreateAppConnectionDTO, TUpdateAppConnectionDTO } from "@app/services/app-connection/app-connection-types"; import { ActorType } from "@app/services/auth/auth-type"; import { CertKeyAlgorithm } from "@app/services/certificate/certificate-types"; import { CaStatus } from "@app/services/certificate-authority/certificate-authority-types"; @@ -222,7 +224,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 { @@ -1867,6 +1874,39 @@ interface ApplyProjectTemplateEvent { }; } +interface GetAppConnectionsEvent { + type: EventType.GET_APP_CONNECTIONS; + metadata: { + app?: AppConnection; + count: number; + connectionIds: string[]; + }; +} + +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 @@ -2038,4 +2078,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 4e655420d8..293815d7a0 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/services/app-connection/app-connection-enums"; +import { APP_CONNECTION_NAME_MAP } from "@app/services/app-connection/app-connection-maps"; + export const GROUPS = { CREATE: { name: "The name of the group to create.", @@ -1605,3 +1608,34 @@ 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.`, + description: `An optional description for the ${appName} Connection.`, + 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.`, + description: `The updated description of the ${appName} Connection.`, + 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_NAME_MAP[app]} connection to be deleted.` + }) +}; 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/fn/string.ts b/backend/src/lib/fn/string.ts index 26e8f27df1..1dc2bbfed0 100644 --- a/backend/src/lib/fn/string.ts +++ b/backend/src/lib/fn/string.ts @@ -14,3 +14,5 @@ export const prefixWithSlash = (str: string) => { if (str.startsWith("/")) return str; return `/${str}`; }; + +export const startsWithVowel = (str: string) => /^[aeiou]/i.test(str); 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 2c39ef4b53..eafd3467fa 100644 --- a/backend/src/server/routes/index.ts +++ b/backend/src/server/routes/index.ts @@ -91,6 +91,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"; @@ -314,6 +316,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); @@ -1352,6 +1355,13 @@ export const registerRoutes = async ( externalGroupOrgRoleMappingDAL }); + const appConnectionService = appConnectionServiceFactory({ + appConnectionDAL, + permissionService, + kmsService, + licenseService + }); + await superAdminService.initServerCfg(); // setup the communication with license key server @@ -1448,7 +1458,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..d186397864 --- /dev/null +++ b/backend/src/server/routes/v1/app-connection-routers/app-connection-router.ts @@ -0,0 +1,74 @@ +import { z } from "zod"; + +import { EventType } from "@app/ee/services/audit-log/audit-log-types"; +import { readLimit } from "@app/server/config/rateLimiter"; +import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; +import { AwsConnectionListItemSchema, SanitizedAwsConnectionSchema } from "@app/services/app-connection/aws"; +import { GitHubConnectionListItemSchema, SanitizedGitHubConnectionSchema } from "@app/services/app-connection/github"; +import { AuthMode } from "@app/services/auth/auth-type"; + +// can't use discriminated due to multiple schemas for certain apps +const SanitizedAppConnectionSchema = z.union([ + ...SanitizedAwsConnectionSchema.options, + ...SanitizedGitHubConnectionSchema.options +]); + +const AppConnectionOptionsSchema = z.discriminatedUnion("app", [ + AwsConnectionListItemSchema, + GitHubConnectionListItemSchema +]); + +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: AppConnectionOptionsSchema.array() + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_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.IDENTITY_ACCESS_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, + metadata: { + count: appConnections.length, + connectionIds: appConnections.map((connection) => connection.id) + } + } + }); + + 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..ec3b633a19 --- /dev/null +++ b/backend/src/server/routes/v1/app-connection-routers/apps/app-connection-endpoints.ts @@ -0,0 +1,274 @@ +import { z } from "zod"; + +import { EventType } from "@app/ee/services/audit-log/audit-log-types"; +import { AppConnections } from "@app/lib/api-docs"; +import { startsWithVowel } from "@app/lib/fn"; +import { readLimit, writeLimit } from "@app/server/config/rateLimiter"; +import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; +import { AppConnection } from "@app/services/app-connection/app-connection-enums"; +import { APP_CONNECTION_NAME_MAP } from "@app/services/app-connection/app-connection-maps"; +import { TAppConnection, TAppConnectionInput } from "@app/services/app-connection/app-connection-types"; +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"]; + description?: string | null; + }>; + updateSchema: z.ZodType<{ name?: string; credentials?: I["credentials"]; description?: string | null }>; + 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.IDENTITY_ACCESS_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, + count: appConnections.length, + connectionIds: appConnections.map((connection) => connection.id) + } + } + }); + + 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.IDENTITY_ACCESS_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.IDENTITY_ACCESS_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 ${ + startsWithVowel(appName) ? "an" : "a" + } ${appName} Connection for the current organization.`, + body: createSchema, + response: { + 200: z.object({ appConnection: responseSchema }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const { name, method, credentials, description } = req.body; + + const appConnection = (await server.services.appConnection.createAppConnection( + { name, method, app, credentials, description }, + 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.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const { name, credentials, description } = req.body; + const { connectionId } = req.params; + + const appConnection = (await server.services.appConnection.updateAppConnection( + { name, credentials, connectionId, description }, + req.permission + )) as T; + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + orgId: req.permission.orgId, + event: { + type: EventType.UPDATE_APP_CONNECTION, + metadata: { + name, + description, + 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.IDENTITY_ACCESS_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..189ca4fbdf --- /dev/null +++ b/backend/src/server/routes/v1/app-connection-routers/apps/aws-connection-router.ts @@ -0,0 +1,17 @@ +import { AppConnection } from "@app/services/app-connection/app-connection-enums"; +import { + CreateAwsConnectionSchema, + SanitizedAwsConnectionSchema, + UpdateAwsConnectionSchema +} from "@app/services/app-connection/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..273d4b9e16 --- /dev/null +++ b/backend/src/server/routes/v1/app-connection-routers/apps/github-connection-router.ts @@ -0,0 +1,17 @@ +import { AppConnection } from "@app/services/app-connection/app-connection-enums"; +import { + CreateGitHubConnectionSchema, + SanitizedGitHubConnectionSchema, + UpdateGitHubConnectionSchema +} from "@app/services/app-connection/github"; + +import { registerAppConnectionEndpoints } from "./app-connection-endpoints"; + +export const registerGitHubConnectionRouter = async (server: FastifyZodProvider) => + registerAppConnectionEndpoints({ + app: AppConnection.GitHub, + server, + responseSchema: SanitizedGitHubConnectionSchema, + 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..b56a65f508 --- /dev/null +++ b/backend/src/server/routes/v1/app-connection-routers/apps/index.ts @@ -0,0 +1,8 @@ +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"; +import { AppConnection } from "@app/services/app-connection/app-connection-enums"; + +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..7fae1d1f99 100644 --- a/backend/src/server/routes/v1/index.ts +++ b/backend/src/server/routes/v1/index.ts @@ -1,3 +1,4 @@ +import { APP_CONNECTION_REGISTER_MAP, registerAppConnectionRouter } from "@app/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 +111,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..f74f7cf068 --- /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 appConnectionOrm = ormify(db, TableName.AppConnection); + + return { ...appConnectionOrm }; +}; diff --git a/backend/src/services/app-connection/app-connection-enums.ts b/backend/src/services/app-connection/app-connection-enums.ts new file mode 100644 index 0000000000..d69b7dec1a --- /dev/null +++ b/backend/src/services/app-connection/app-connection-enums.ts @@ -0,0 +1,4 @@ +export enum AppConnection { + GitHub = "github", + AWS = "aws" +} 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..787839cf7b --- /dev/null +++ b/backend/src/services/app-connection/app-connection-fns.ts @@ -0,0 +1,92 @@ +import { AppConnection } from "@app/services/app-connection/app-connection-enums"; +import { TAppConnectionServiceFactoryDep } from "@app/services/app-connection/app-connection-service"; +import { TAppConnection, TAppConnectionConfig } from "@app/services/app-connection/app-connection-types"; +import { + AwsConnectionMethod, + getAwsAppConnectionListItem, + validateAwsConnectionCredentials +} from "@app/services/app-connection/aws"; +import { + getGitHubConnectionListItem, + GitHubConnectionMethod, + validateGitHubConnectionCredentials +} from "@app/services/app-connection/github"; +import { KmsDataKey } from "@app/services/kms/kms-types"; + +export const listAppConnectionOptions = () => { + 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}`); + } +}; + +export const getAppConnectionMethodName = (method: TAppConnection["method"]) => { + switch (method) { + case GitHubConnectionMethod.App: + return "GitHub App"; + case GitHubConnectionMethod.OAuth: + return "OAuth"; + case AwsConnectionMethod.AccessKey: + return "Access Key"; + case AwsConnectionMethod.AssumeRole: + return "Assume Role"; + default: + // eslint-disable-next-line @typescript-eslint/restrict-template-expressions + throw new Error(`Unhandled App Connection Method: ${method}`); + } +}; diff --git a/backend/src/services/app-connection/app-connection-maps.ts b/backend/src/services/app-connection/app-connection-maps.ts new file mode 100644 index 0000000000..f473b1e38f --- /dev/null +++ b/backend/src/services/app-connection/app-connection-maps.ts @@ -0,0 +1,6 @@ +import { AppConnection } from "./app-connection-enums"; + +export const APP_CONNECTION_NAME_MAP: Record = { + [AppConnection.AWS]: "AWS", + [AppConnection.GitHub]: "GitHub" +}; 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..ce5e877fd8 --- /dev/null +++ b/backend/src/services/app-connection/app-connection-schemas.ts @@ -0,0 +1,35 @@ +import { z } from "zod"; + +import { AppConnectionsSchema } from "@app/db/schemas/app-connections"; +import { AppConnections } from "@app/lib/api-docs"; +import { slugSchema } from "@app/server/lib/schemas"; + +import { AppConnection } from "./app-connection-enums"; + +export const BaseAppConnectionSchema = AppConnectionsSchema.omit({ + encryptedCredentials: true, + app: true, + method: true +}); + +export const GenericCreateAppConnectionFieldsSchema = (app: AppConnection) => + z.object({ + name: slugSchema({ field: "name" }).describe(AppConnections.CREATE(app).name), + description: z + .string() + .trim() + .max(256, "Description cannot exceed 256 characters") + .nullish() + .describe(AppConnections.CREATE(app).description) + }); + +export const GenericUpdateAppConnectionFieldsSchema = (app: AppConnection) => + z.object({ + name: slugSchema({ field: "name" }).describe(AppConnections.UPDATE(app).name).optional(), + description: z + .string() + .trim() + .max(256, "Description cannot exceed 256 characters") + .nullish() + .describe(AppConnections.UPDATE(app).description) + }); 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..9b9f16626d --- /dev/null +++ b/backend/src/services/app-connection/app-connection-service.ts @@ -0,0 +1,360 @@ +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 { BadRequestError, NotFoundError } from "@app/lib/errors"; +import { DiscriminativePick, OrgServiceActor } from "@app/lib/types"; +import { AppConnection } from "@app/services/app-connection/app-connection-enums"; +import { + decryptAppConnectionCredentials, + encryptAppConnectionCredentials, + getAppConnectionMethodName, + listAppConnectionOptions, + validateAppConnectionCredentials +} from "@app/services/app-connection/app-connection-fns"; +import { APP_CONNECTION_NAME_MAP } from "@app/services/app-connection/app-connection-maps"; +import { + TAppConnection, + TAppConnectionConfig, + TCreateAppConnectionDTO, + TUpdateAppConnectionDTO, + TValidateAppConnectionCredentials +} from "@app/services/app-connection/app-connection-types"; +import { ValidateAwsConnectionCredentialsSchema } from "@app/services/app-connection/aws"; +import { ValidateGitHubConnectionCredentialsSchema } from "@app/services/app-connection/github"; +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; + +const VALIDATE_APP_CONNECTION_CREDENTIALS_MAP: Record = { + [AppConnection.AWS]: ValidateAwsConnectionCredentialsSchema, + [AppConnection.GitHub]: ValidateGitHubConnectionCredentialsSchema +}; + +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 appConnection = await appConnectionDAL.transaction(async (tx) => { + const isConflictingName = Boolean( + await appConnectionDAL.findOne( + { + name: params.name, + orgId: actor.orgId + }, + tx + ) + ); + + 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 connection = await appConnectionDAL.create( + { + orgId: actor.orgId, + encryptedCredentials, + method, + app, + ...params + }, + tx + ); + + return { + ...connection, + credentials: validatedCredentials + }; + }); + + return appConnection; + }; + + 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); + + const updatedAppConnection = await appConnectionDAL.transaction(async (tx) => { + if (params.name && appConnection.name !== params.name) { + const isConflictingName = Boolean( + await appConnectionDAL.findOne( + { + name: params.name, + orgId: appConnection.orgId + }, + tx + ) + ); + + if (isConflictingName) + throw new BadRequestError({ + message: `An App Connection with the name "${params.name}" already exists` + }); + } + + let encryptedCredentials: undefined | Buffer; + + if (credentials) { + const { app, method } = appConnection as DiscriminativePick; + + if ( + !VALIDATE_APP_CONNECTION_CREDENTIALS_MAP[app].safeParse({ + method, + credentials + }).success + ) + throw new BadRequestError({ + message: `Invalid credential format for ${ + APP_CONNECTION_NAME_MAP[app] + } Connection with method ${getAppConnectionMethodName(method)}` + }); + + const validatedCredentials = await validateAppConnectionCredentials({ + app, + orgId: actor.orgId, + credentials, + method + } as TAppConnectionConfig); + + if (!validatedCredentials) + throw new BadRequestError({ message: "Unable to validate connection - check credentials" }); + + encryptedCredentials = await encryptAppConnectionCredentials({ + credentials: validatedCredentials, + orgId: actor.orgId, + kmsService + }); + } + + const updatedConnection = await appConnectionDAL.updateById( + connectionId, + { + orgId: actor.orgId, + encryptedCredentials, + ...params + }, + tx + ); + + return updatedConnection; + }); + + 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/backend/src/services/app-connection/app-connection-types.ts b/backend/src/services/app-connection/app-connection-types.ts new file mode 100644 index 0000000000..e3983cf91e --- /dev/null +++ b/backend/src/services/app-connection/app-connection-types.ts @@ -0,0 +1,31 @@ +import { + TAwsConnection, + TAwsConnectionConfig, + TAwsConnectionInput, + TValidateAwsConnectionCredentials +} from "@app/services/app-connection/aws"; +import { + TGitHubConnection, + TGitHubConnectionConfig, + TGitHubConnectionInput, + TValidateGitHubConnectionCredentials +} from "@app/services/app-connection/github"; + +export type TAppConnection = { id: string } & (TAwsConnection | TGitHubConnection); + +export type TAppConnectionInput = { id: string } & (TAwsConnectionInput | TGitHubConnectionInput); + +export type TCreateAppConnectionDTO = Pick< + TAppConnectionInput, + "credentials" | "method" | "name" | "app" | "description" +>; + +export type TUpdateAppConnectionDTO = Partial> & { + connectionId: string; +}; + +export type TAppConnectionConfig = TAwsConnectionConfig | TGitHubConnectionConfig; + +export type TValidateAppConnectionCredentials = + | TValidateAwsConnectionCredentials + | TValidateGitHubConnectionCredentials; diff --git a/backend/src/services/app-connection/aws/aws-connection-enums.ts b/backend/src/services/app-connection/aws/aws-connection-enums.ts new file mode 100644 index 0000000000..0b571de0c8 --- /dev/null +++ b/backend/src/services/app-connection/aws/aws-connection-enums.ts @@ -0,0 +1,4 @@ +export enum AwsConnectionMethod { + AssumeRole = "assume-role", + AccessKey = "access-key" +} diff --git a/backend/src/services/app-connection/aws/aws-connection-fns.ts b/backend/src/services/app-connection/aws/aws-connection-fns.ts new file mode 100644 index 0000000000..36008bc58a --- /dev/null +++ b/backend/src/services/app-connection/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 { getConfig } from "@app/lib/config/env"; +import { BadRequestError, InternalServerError } from "@app/lib/errors"; +import { AppConnection } from "@app/services/app-connection/app-connection-enums"; + +import { AwsConnectionMethod } from "./aws-connection-enums"; +import { TAwsConnectionConfig } from "./aws-connection-types"; + +export const getAwsAppConnectionListItem = () => { + const { INF_APP_CONNECTION_AWS_ACCESS_KEY_ID } = getConfig(); + + return { + name: "AWS" as const, + app: AppConnection.AWS as const, + methods: Object.values(AwsConnectionMethod) as [AwsConnectionMethod.AssumeRole, AwsConnectionMethod.AccessKey], + 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/services/app-connection/aws/aws-connection-schemas.ts b/backend/src/services/app-connection/aws/aws-connection-schemas.ts new file mode 100644 index 0000000000..914e92671f --- /dev/null +++ b/backend/src/services/app-connection/aws/aws-connection-schemas.ts @@ -0,0 +1,82 @@ +import { z } from "zod"; + +import { AppConnections } from "@app/lib/api-docs"; +import { AppConnection } from "@app/services/app-connection/app-connection-enums"; +import { + BaseAppConnectionSchema, + GenericCreateAppConnectionFieldsSchema, + GenericUpdateAppConnectionFieldsSchema +} from "@app/services/app-connection/app-connection-schemas"; + +import { AwsConnectionMethod } from "./aws-connection-enums"; + +export const AwsConnectionAssumeRoleCredentialsSchema = z.object({ + roleArn: z.string().trim().min(1, "Role ARN required") +}); + +export const AwsConnectionAccessTokenCredentialsSchema = z.object({ + accessKeyId: z.string().trim().min(1, "Access Key ID required"), + secretAccessKey: z.string().trim().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 ValidateAwsConnectionCredentialsSchema = 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 + ) + }) +]); + +export const CreateAwsConnectionSchema = ValidateAwsConnectionCredentialsSchema.and( + GenericCreateAppConnectionFieldsSchema(AppConnection.AWS) +); + +export const UpdateAwsConnectionSchema = z + .object({ + credentials: z + .union([AwsConnectionAccessTokenCredentialsSchema, AwsConnectionAssumeRoleCredentialsSchema]) + .optional() + .describe(AppConnections.UPDATE(AppConnection.AWS).credentials) + }) + .and(GenericUpdateAppConnectionFieldsSchema(AppConnection.AWS)); + +export const AwsConnectionListItemSchema = z.object({ + name: z.literal("AWS"), + app: z.literal(AppConnection.AWS), + // the below is preferable but currently breaks mintlify + // methods: z.tuple([z.literal(AwsConnectionMethod.AssumeRole), z.literal(AwsConnectionMethod.AccessKey)]), + methods: z.nativeEnum(AwsConnectionMethod).array(), + accessKeyId: z.string().optional() +}); diff --git a/backend/src/services/app-connection/aws/aws-connection-types.ts b/backend/src/services/app-connection/aws/aws-connection-types.ts new file mode 100644 index 0000000000..a0b74c3d0b --- /dev/null +++ b/backend/src/services/app-connection/aws/aws-connection-types.ts @@ -0,0 +1,22 @@ +import { z } from "zod"; + +import { DiscriminativePick } from "@app/lib/types"; +import { AppConnection } from "@app/services/app-connection/app-connection-enums"; + +import { + AwsConnectionSchema, + CreateAwsConnectionSchema, + ValidateAwsConnectionCredentialsSchema +} from "./aws-connection-schemas"; + +export type TAwsConnection = z.infer; + +export type TAwsConnectionInput = z.infer & { + app: AppConnection.AWS; +}; + +export type TValidateAwsConnectionCredentials = typeof ValidateAwsConnectionCredentialsSchema; + +export type TAwsConnectionConfig = DiscriminativePick & { + orgId: string; +}; diff --git a/backend/src/services/app-connection/aws/index.ts b/backend/src/services/app-connection/aws/index.ts new file mode 100644 index 0000000000..4608a3483a --- /dev/null +++ b/backend/src/services/app-connection/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/services/app-connection/github/github-connection-enums.ts b/backend/src/services/app-connection/github/github-connection-enums.ts new file mode 100644 index 0000000000..77a4eebac5 --- /dev/null +++ b/backend/src/services/app-connection/github/github-connection-enums.ts @@ -0,0 +1,4 @@ +export enum GitHubConnectionMethod { + OAuth = "oauth", + App = "github-app" +} diff --git a/backend/src/services/app-connection/github/github-connection-fns.ts b/backend/src/services/app-connection/github/github-connection-fns.ts new file mode 100644 index 0000000000..01fa7846fb --- /dev/null +++ b/backend/src/services/app-connection/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 { getAppConnectionMethodName } from "@app/services/app-connection/app-connection-fns"; +import { IntegrationUrls } from "@app/services/integration-auth/integration-list"; + +import { AppConnection } from "../app-connection-enums"; +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" as const, + app: AppConnection.GitHub as const, + methods: Object.values(GitHubConnectionMethod) as [GitHubConnectionMethod.App, GitHubConnectionMethod.OAuth], + 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 ${getAppConnectionMethodName(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/services/app-connection/github/github-connection-schemas.ts b/backend/src/services/app-connection/github/github-connection-schemas.ts new file mode 100644 index 0000000000..5adb211bac --- /dev/null +++ b/backend/src/services/app-connection/github/github-connection-schemas.ts @@ -0,0 +1,93 @@ +import { z } from "zod"; + +import { AppConnections } from "@app/lib/api-docs"; +import { AppConnection } from "@app/services/app-connection/app-connection-enums"; +import { + BaseAppConnectionSchema, + GenericCreateAppConnectionFieldsSchema, + GenericUpdateAppConnectionFieldsSchema +} from "@app/services/app-connection/app-connection-schemas"; + +import { GitHubConnectionMethod } from "./github-connection-enums"; + +export const GitHubConnectionOAuthInputCredentialsSchema = z.object({ + code: z.string().trim().min(1, "OAuth code required") +}); + +export const GitHubConnectionAppInputCredentialsSchema = z.object({ + code: z.string().trim().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 ValidateGitHubConnectionCredentialsSchema = 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 + ) + }) +]); + +export const CreateGitHubConnectionSchema = ValidateGitHubConnectionCredentialsSchema.and( + GenericCreateAppConnectionFieldsSchema(AppConnection.GitHub) +); + +export const UpdateGitHubConnectionSchema = z + .object({ + credentials: z + .union([GitHubConnectionAppInputCredentialsSchema, GitHubConnectionOAuthInputCredentialsSchema]) + .optional() + .describe(AppConnections.UPDATE(AppConnection.GitHub).credentials) + }) + .and(GenericUpdateAppConnectionFieldsSchema(AppConnection.GitHub)); + +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 }) + }) +]); + +export const GitHubConnectionListItemSchema = z.object({ + name: z.literal("GitHub"), + app: z.literal(AppConnection.GitHub), + // the below is preferable but currently breaks mintlify + // methods: z.tuple([z.literal(GitHubConnectionMethod.GitHubApp), z.literal(GitHubConnectionMethod.OAuth)]), + methods: z.nativeEnum(GitHubConnectionMethod).array(), + oauthClientId: z.string().optional(), + appClientSlug: z.string().optional() +}); diff --git a/backend/src/services/app-connection/github/github-connection-types.ts b/backend/src/services/app-connection/github/github-connection-types.ts new file mode 100644 index 0000000000..5a9b13c00f --- /dev/null +++ b/backend/src/services/app-connection/github/github-connection-types.ts @@ -0,0 +1,20 @@ +import { z } from "zod"; + +import { DiscriminativePick } from "@app/lib/types"; + +import { AppConnection } from "../app-connection-enums"; +import { + CreateGitHubConnectionSchema, + GitHubAppConnectionSchema, + ValidateGitHubConnectionCredentialsSchema +} from "./github-connection-schemas"; + +export type TGitHubConnection = z.infer; + +export type TGitHubConnectionInput = z.infer & { + app: AppConnection.GitHub; +}; + +export type TValidateGitHubConnectionCredentials = typeof ValidateGitHubConnectionCredentialsSchema; + +export type TGitHubConnectionConfig = DiscriminativePick; diff --git a/backend/src/services/app-connection/github/index.ts b/backend/src/services/app-connection/github/index.ts new file mode 100644 index 0000000000..35915046be --- /dev/null +++ b/backend/src/services/app-connection/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/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/parameter-store-permissions.png b/docs/images/app-connections/aws/parameter-store-permissions.png new file mode 100644 index 0000000000..1fb2b8118c Binary files /dev/null and b/docs/images/app-connections/aws/parameter-store-permissions.png differ diff --git a/docs/images/app-connections/aws/secrets-manager-permissions.png b/docs/images/app-connections/aws/secrets-manager-permissions.png new file mode 100644 index 0000000000..57d2eb2e2c Binary files /dev/null and b/docs/images/app-connections/aws/secrets-manager-permissions.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..65af1bcdc2 --- /dev/null +++ b/docs/integrations/app-connections/aws.mdx @@ -0,0 +1,354 @@ +--- +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: + + + + + + Use the following custom policy to grant the minimum permissions required by Infisical to sync secrets to AWS Secrets Manager: + + ![IAM Role Secrets Manager Permissions](/images/app-connections/aws/secrets-manager-permissions.png) + + ```json + { + "Version": "2012-10-17", + "Statement": [ + { + "Sid": "AllowSecretsManagerAccess", + "Effect": "Allow", + "Action": [ + "secretsmanager:GetSecretValue", + "secretsmanager:CreateSecret", + "secretsmanager:UpdateSecret", + "secretsmanager:DescribeSecret", + "secretsmanager:TagResource", + "secretsmanager:UntagResource", + "kms:ListKeys", + "kms:ListAliases", + "kms:Encrypt", + "kms:Decrypt" + ], + "Resource": "*" + } + ] + } + ``` + + + Use the following custom policy to grant the minimum permissions required by Infisical to sync secrets to AWS Parameter Store: + + ![IAM Role Secrets Manager Permissions](/images/app-connections/aws/parameter-store-permissions.png) + + ```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: + + + + + + Use the following custom policy to grant the minimum permissions required by Infisical to sync secrets to AWS Secrets Manager: + + ![IAM Role Secrets Manager Permissions](/images/app-connections/aws/secrets-manager-permissions.png) + + ```json + { + "Version": "2012-10-17", + "Statement": [ + { + "Sid": "AllowSecretsManagerAccess", + "Effect": "Allow", + "Action": [ + "secretsmanager:GetSecretValue", + "secretsmanager:CreateSecret", + "secretsmanager:UpdateSecret", + "secretsmanager:DescribeSecret", + "secretsmanager:TagResource", + "secretsmanager:UntagResource", + "kms:ListKeys", + "kms:ListAliases", + "kms:Encrypt", + "kms:Decrypt" + ], + "Resource": "*" + } + ] + } + ``` + + + Use the following custom policy to grant the minimum permissions required by Infisical to sync secrets to AWS Parameter Store: + + ![IAM Role Secrets Manager Permissions](/images/app-connections/aws/parameter-store-permissions.png) + + ```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..18f702bb61 --- /dev/null +++ b/docs/integrations/app-connections/github.mdx @@ -0,0 +1,169 @@ +--- +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) + + + Using the GitHub integration on a self-hosted instance of Infisical requires configuring an OAuth application in GitHub + and registering your instance with it. + + + Navigate to your user Settings > Developer settings > OAuth Apps to create a new GitHub OAuth application. + + ![integrations github config](../../images/integrations/github/integrations-github-config-settings.png) + ![integrations github config](../../images/integrations/github/integrations-github-config-dev-settings.png) + ![integrations github config](../../images/integrations/github/integrations-github-config-new-app.png) + + Create the OAuth application. As part of the form, set the **Homepage URL** to your self-hosted domain `https://your-domain.com` + and the **Authorization callback URL** to `https://your-domain.com/app-connections/github/oauth/callback`. + + ![integrations github config](../../images/integrations/github/integrations-github-config-new-app-form.png) + + + If you have a GitHub organization, you can create an OAuth application under it + in your organization Settings > Developer settings > OAuth Apps > New Org OAuth App. + + + + Obtain the **Client ID** and generate a new **Client Secret** for your GitHub OAuth application. + + ![integrations github config](../../images/integrations/github/integrations-github-config-credentials.png) + + Back in your Infisical instance, add two new environment variables for the credentials of your GitHub OAuth application: + + - `INF_APP_CONNECTION_GITHUB_OAUTH_CLIENT_ID`: The **Client ID** of your GitHub OAuth application. + - `INF_APP_CONNECTION_GITHUB_OAUTH_CLIENT_SECRET`: The **Client Secret** of your GitHub OAuth application. + + Once added, restart your Infisical instance and use the GitHub integration. + + + + + ## 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..64f3616de3 --- /dev/null +++ b/docs/integrations/app-connections/overview.mdx @@ -0,0 +1,77 @@ +--- +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] + B[AWS Connection] + C[Project 1 Secret Sync] + D[Project 2 Secret Sync] + E[Project 3 Generate Dynamic Secret] + + B --> A + C --> B + D --> B + E --> B + + 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 + classDef connection fill:#F4FFE6,stroke:#96D600,stroke-width:2px,color:black,rx:15px + + class A aws + class B connection + class C,D,E 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 3c67bb7cfd..7eb28336b8 100644 --- a/docs/mint.json +++ b/docs/mint.json @@ -341,6 +341,14 @@ "cli/faq" ] }, + { + "group": "App Connections", + "pages": [ + "integrations/app-connections/overview", + "integrations/app-connections/aws", + "integrations/app-connections/github" + ] + }, { "group": "Infrastructure Integrations", "pages": [ @@ -757,6 +765,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/navigation/RegionSelect.tsx b/frontend/src/components/navigation/RegionSelect.tsx index 44f2336cd8..51a033244a 100644 --- a/frontend/src/components/navigation/RegionSelect.tsx +++ b/frontend/src/components/navigation/RegionSelect.tsx @@ -3,6 +3,7 @@ import { faCheck } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { Modal, ModalContent, ModalTrigger, Select, SelectItem } from "@app/components/v2"; +import { isInfisicalCloud } from "@app/helpers/platform"; enum Region { US = "us", @@ -79,10 +80,7 @@ export const RegionSelect = () => { }; const shouldDisplay = - window.location.origin.includes("https://app.infisical.com") || - window.location.origin.includes("https://us.infisical.com") || - window.location.origin.includes("https://eu.infisical.com") || - window.location.origin.includes("http://localhost:8080"); + isInfisicalCloud() || window.location.origin.includes("http://localhost:8080"); // only display region select for cloud if (!shouldDisplay) return null; 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..9d52fb14e1 --- /dev/null +++ b/frontend/src/helpers/appConnections.ts @@ -0,0 +1,29 @@ +import { faGithub } 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 { + AwsConnectionMethod, + GitHubConnectionMethod, + TAppConnection +} from "@app/hooks/api/appConnections/types"; + +export const APP_CONNECTION_MAP: Record = { + [AppConnection.AWS]: { name: "AWS", image: "Amazon Web Services.png" }, + [AppConnection.GitHub]: { name: "GitHub", image: "GitHub.png" } +}; + +export const getAppConnectionMethodDetails = (method: TAppConnection["method"]) => { + switch (method) { + case GitHubConnectionMethod.App: + return { name: "GitHub App", icon: faGithub }; + case GitHubConnectionMethod.OAuth: + return { name: "OAuth", icon: faPassport }; + case AwsConnectionMethod.AccessKey: + return { name: "Access Key", icon: faKey }; + case AwsConnectionMethod.AssumeRole: + return { name: "Assume Role", icon: faUser }; + default: + throw new Error(`Unhandled App Connection Method: ${method}`); + } +}; diff --git a/frontend/src/helpers/platform.ts b/frontend/src/helpers/platform.ts new file mode 100644 index 0000000000..821febcb8c --- /dev/null +++ b/frontend/src/helpers/platform.ts @@ -0,0 +1,4 @@ +export const isInfisicalCloud = () => + window.location.origin.includes("https://app.infisical.com") || + window.location.origin.includes("https://us.infisical.com") || + window.location.origin.includes("https://eu.infisical.com"); 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..fcec4a1df5 --- /dev/null +++ b/frontend/src/hooks/api/appConnections/types/index.ts @@ -0,0 +1,36 @@ +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" | "description" +>; + +export type TUpdateAppConnectionDTO = Partial< + Pick +> & { + 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..0dc4a616f6 --- /dev/null +++ b/frontend/src/hooks/api/appConnections/types/root-connection.ts @@ -0,0 +1,9 @@ +export type TRootAppConnection = { + id: string; + name: string; + description?: string | null; + 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 918ec415a4..693f6e3426 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..79827acaa4 --- /dev/null +++ b/frontend/src/pages/app-connections/github/oauth/callback.tsx @@ -0,0 +1,134 @@ +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, description, 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, + description, + ...(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 a5d4f10a82..8f90766b75 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..f0aa811609 --- /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..d1137a8872 --- /dev/null +++ b/frontend/src/views/Settings/OrgSettingsPage/components/AppConnectionsTab/components/AppConnectionForm/AwsConnectionForm.tsx @@ -0,0 +1,187 @@ +import { Controller, FormProvider, 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, getAppConnectionMethodDetails } from "@app/helpers/appConnections"; +import { AwsConnectionMethod, TAwsConnection } from "@app/hooks/api/appConnections"; +import { AppConnection } from "@app/hooks/api/appConnections/enums"; + +import { + genericAppConnectionFieldsSchema, + GenericAppConnectionsFields +} from "./GenericAppConnectionFields"; + +type Props = { + appConnection?: TAwsConnection; + onSubmit: (formData: FormData) => void; +}; + +const rootSchema = genericAppConnectionFieldsSchema.extend({ + app: z.literal(AppConnection.AWS) +}); + +const formSchema = z.discriminatedUnion("method", [ + rootSchema.extend({ + method: z.literal(AwsConnectionMethod.AssumeRole), + credentials: z.object({ + roleArn: z.string().trim().min(1, "Role ARN required") + }) + }), + rootSchema.extend({ + method: z.literal(AwsConnectionMethod.AccessKey), + credentials: z.object({ + accessKeyId: z.string().trim().min(1, "Access Key ID required"), + secretAccessKey: z.string().trim().min(1, "Secret Access Key required") + }) + }) +]); + +type FormData = z.infer; + +export const AwsConnectionForm = ({ appConnection, onSubmit }: Props) => { + const isUpdate = Boolean(appConnection); + + const form = useForm({ + resolver: zodResolver(formSchema), + defaultValues: appConnection ?? { + app: AppConnection.AWS, + method: AwsConnectionMethod.AssumeRole + } + }); + + const { + handleSubmit, + control, + watch, + formState: { isSubmitting, isDirty } + } = form; + + 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/GenericAppConnectionFields.tsx b/frontend/src/views/Settings/OrgSettingsPage/components/AppConnectionsTab/components/AppConnectionForm/GenericAppConnectionFields.tsx new file mode 100644 index 0000000000..d9e25a0a7f --- /dev/null +++ b/frontend/src/views/Settings/OrgSettingsPage/components/AppConnectionsTab/components/AppConnectionForm/GenericAppConnectionFields.tsx @@ -0,0 +1,42 @@ +import { useFormContext } from "react-hook-form"; +import { z } from "zod"; + +import { FormControl, Input, TextArea } from "@app/components/v2"; +import { slugSchema } from "@app/lib/schemas"; + +export const genericAppConnectionFieldsSchema = z.object({ + name: slugSchema({ min: 1, max: 32, field: "Name" }), + description: z.string().trim().max(256, "Description cannot exceed 256 characters").nullish() +}); + +export const GenericAppConnectionsFields = () => { + const { + register, + formState: { errors } + } = useFormContext<{ name: string; description?: string | null }>(); + + return ( + <> + + + + +