diff --git a/backend/src/db/migrations/20241209233334_app-connection.ts b/backend/src/db/migrations/20241218181018_app-connection.ts similarity index 91% rename from backend/src/db/migrations/20241209233334_app-connection.ts rename to backend/src/db/migrations/20241218181018_app-connection.ts index c726e3881..d09907ae1 100644 --- a/backend/src/db/migrations/20241209233334_app-connection.ts +++ b/backend/src/db/migrations/20241218181018_app-connection.ts @@ -8,6 +8,7 @@ export async function up(knex: Knex): Promise { 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(); @@ -16,9 +17,9 @@ export async function up(knex: Knex): Promise { t.foreign("orgId").references("id").inTable(TableName.Organization).onDelete("CASCADE"); t.timestamps(true, true, true); }); - } - await createOnUpdateTrigger(knex, TableName.AppConnection); + await createOnUpdateTrigger(knex, TableName.AppConnection); + } } export async function down(knex: Knex): Promise { diff --git a/backend/src/db/schemas/app-connections.ts b/backend/src/db/schemas/app-connections.ts index aea7a2c15..8c9dff236 100644 --- a/backend/src/db/schemas/app-connections.ts +++ b/backend/src/db/schemas/app-connections.ts @@ -12,6 +12,7 @@ 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, 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 6b4b5d20e..a8d98977f 100644 --- a/backend/src/ee/services/audit-log/audit-log-types.ts +++ b/backend/src/ee/services/audit-log/audit-log-types.ts @@ -4,9 +4,10 @@ import { } from "@app/ee/services/project-template/project-template-types"; import { SshCaStatus, SshCertType } from "@app/ee/services/ssh/ssh-certificate-authority-types"; import { SshCertTemplateStatus } from "@app/ee/services/ssh-certificate-template/ssh-certificate-template-types"; -import { AppConnection, TCreateAppConnectionDTO, TUpdateAppConnectionDTO } from "@app/lib/app-connections"; import { SymmetricEncryption } from "@app/lib/crypto/cipher"; import { TProjectPermission } from "@app/lib/types"; +import { 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"; @@ -1875,8 +1876,10 @@ interface ApplyProjectTemplateEvent { interface GetAppConnectionsEvent { type: EventType.GET_APP_CONNECTIONS; - metadata?: { - app: AppConnection; + metadata: { + app?: AppConnection; + count: number; + connectionIds: string[]; }; } diff --git a/backend/src/ee/services/license/license-fns.ts b/backend/src/ee/services/license/license-fns.ts index 69daa8514..88e1e154d 100644 --- a/backend/src/ee/services/license/license-fns.ts +++ b/backend/src/ee/services/license/license-fns.ts @@ -50,7 +50,7 @@ export const getDefaultOnPremFeatures = (): TFeatureSet => ({ pkiEst: false, enforceMfa: false, projectTemplates: false, - appConnections: false + appConnections: true }); export const setupLicenseRequestWithStore = (baseURL: string, refreshUrl: string, licenseKey: string) => { diff --git a/backend/src/lib/api-docs/constants.ts b/backend/src/lib/api-docs/constants.ts index 5beb7fd46..293815d7a 100644 --- a/backend/src/lib/api-docs/constants.ts +++ b/backend/src/lib/api-docs/constants.ts @@ -1,5 +1,5 @@ -import { AppConnection } from "@app/lib/app-connections"; -import { APP_CONNECTION_NAME_MAP } from "@app/lib/app-connections/maps"; +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: { @@ -1620,6 +1620,7 @@ export const AppConnections = { 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}.` }; @@ -1629,11 +1630,12 @@ export const AppConnections = { 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 to be deleted.` + connectionId: `The ID of the ${APP_CONNECTION_NAME_MAP[app]} connection to be deleted.` }) }; diff --git a/backend/src/lib/app-connections/app-connection-types.ts b/backend/src/lib/app-connections/app-connection-types.ts deleted file mode 100644 index 109393471..000000000 --- a/backend/src/lib/app-connections/app-connection-types.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { TAwsConnection } from "@app/lib/app-connections/aws/aws-connection-types"; -import { TGitHubConnection, TGitHubConnectionInput } from "@app/lib/app-connections/github"; -import { DiscriminativePick } from "@app/lib/types"; - -import { AppConnection } from "./app-connection-enums"; - -export type AppConnectionListItem = { - app: AppConnection; - name: string; - methods: string[]; -}; - -export type TAppConnection = { id: string } & (TAwsConnection | TGitHubConnection); - -export type TAppConnectionInput = { id: string } & (TAwsConnection | TGitHubConnectionInput); - -export type TCreateAppConnectionDTO = Pick; - -export type TUpdateAppConnectionDTO = Partial> & { - connectionId: string; -}; - -export type TAppConnectionConfig = { orgId: string } & DiscriminativePick< - TAppConnectionInput, - "app" | "method" | "credentials" ->; diff --git a/backend/src/lib/app-connections/aws/aws-connection-schemas.ts b/backend/src/lib/app-connections/aws/aws-connection-schemas.ts deleted file mode 100644 index c8c7a7c34..000000000 --- a/backend/src/lib/app-connections/aws/aws-connection-schemas.ts +++ /dev/null @@ -1,69 +0,0 @@ -import { z } from "zod"; - -import { AppConnections } from "@app/lib/api-docs"; -import { slugSchema } from "@app/server/lib/schemas"; -import { BaseAppConnectionSchema } from "@app/services/app-connection/app-connection-schemas"; - -import { AppConnection } from "../app-connection-enums"; -import { AwsConnectionMethod } from "./aws-connection-enums"; - -export const AwsConnectionAssumeRoleCredentialsSchema = z.object({ - roleArn: z.string().min(1, "Role ARN required") -}); - -export const AwsConnectionAccessTokenCredentialsSchema = z.object({ - accessKeyId: z.string().min(1, "Access Key ID required"), - secretAccessKey: z.string().min(1, "Secret Access Key required") -}); - -const BaseAwsConnectionSchema = BaseAppConnectionSchema.extend({ app: z.literal(AppConnection.AWS) }); - -export const AwsConnectionSchema = z.intersection( - BaseAwsConnectionSchema, - z.discriminatedUnion("method", [ - z.object({ - method: z.literal(AwsConnectionMethod.AssumeRole), - credentials: AwsConnectionAssumeRoleCredentialsSchema - }), - z.object({ - method: z.literal(AwsConnectionMethod.AccessKey), - credentials: AwsConnectionAccessTokenCredentialsSchema - }) - ]) -); - -export const SanitizedAwsConnectionSchema = z.discriminatedUnion("method", [ - BaseAwsConnectionSchema.extend({ - method: z.literal(AwsConnectionMethod.AssumeRole), - credentials: AwsConnectionAssumeRoleCredentialsSchema.omit({ roleArn: true }) - }), - BaseAwsConnectionSchema.extend({ - method: z.literal(AwsConnectionMethod.AccessKey), - credentials: AwsConnectionAccessTokenCredentialsSchema.omit({ secretAccessKey: true }) - }) -]); - -export const CreateAwsConnectionSchema = z - .discriminatedUnion("method", [ - z.object({ - method: z.literal(AwsConnectionMethod.AssumeRole).describe(AppConnections.CREATE(AppConnection.AWS).method), - credentials: AwsConnectionAssumeRoleCredentialsSchema.describe( - AppConnections.CREATE(AppConnection.AWS).credentials - ) - }), - z.object({ - method: z.literal(AwsConnectionMethod.AccessKey).describe(AppConnections.CREATE(AppConnection.AWS).method), - credentials: AwsConnectionAccessTokenCredentialsSchema.describe( - AppConnections.CREATE(AppConnection.AWS).credentials - ) - }) - ]) - .and(z.object({ name: slugSchema({ field: "name" }).describe(AppConnections.CREATE(AppConnection.AWS).name) })); - -export const UpdateAwsConnectionSchema = z.object({ - name: slugSchema({ field: "name" }).optional().describe(AppConnections.UPDATE(AppConnection.AWS).name), - credentials: z - .union([AwsConnectionAccessTokenCredentialsSchema, AwsConnectionAssumeRoleCredentialsSchema]) - .optional() - .describe(AppConnections.UPDATE(AppConnection.AWS).credentials) -}); diff --git a/backend/src/lib/app-connections/aws/aws-connection-types.ts b/backend/src/lib/app-connections/aws/aws-connection-types.ts deleted file mode 100644 index 86515eff5..000000000 --- a/backend/src/lib/app-connections/aws/aws-connection-types.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { z } from "zod"; - -import { DiscriminativePick } from "@app/lib/types"; - -import { AwsConnectionSchema } from "./aws-connection-schemas"; - -export type TAwsConnection = z.infer; - -export type TAwsConnectionConfig = DiscriminativePick; diff --git a/backend/src/lib/app-connections/github/github-connection-schemas.ts b/backend/src/lib/app-connections/github/github-connection-schemas.ts deleted file mode 100644 index 84beb17f1..000000000 --- a/backend/src/lib/app-connections/github/github-connection-schemas.ts +++ /dev/null @@ -1,77 +0,0 @@ -import { z } from "zod"; - -import { AppConnections } from "@app/lib/api-docs"; -import { AppConnection } from "@app/lib/app-connections"; -import { slugSchema } from "@app/server/lib/schemas"; -import { BaseAppConnectionSchema } from "@app/services/app-connection/app-connection-schemas"; - -import { GitHubConnectionMethod } from "./github-connection-enums"; - -export const GitHubConnectionOAuthInputCredentialsSchema = z.object({ - code: z.string().min(1, "OAuth code required") -}); - -export const GitHubConnectionAppInputCredentialsSchema = z.object({ - code: z.string().min(1, "GitHub App code required"), - installationId: z.string().min(1, "GitHub App Installation ID required") -}); - -export const GitHubConnectionOAuthOutputCredentialsSchema = z.object({ - accessToken: z.string() -}); - -export const GitHubConnectionAppOutputCredentialsSchema = z.object({ - installationId: z.string() -}); - -export const CreateGitHubConnectionSchema = z - .discriminatedUnion("method", [ - z.object({ - method: z.literal(GitHubConnectionMethod.App).describe(AppConnections.CREATE(AppConnection.GitHub).method), - credentials: GitHubConnectionAppInputCredentialsSchema.describe( - AppConnections.CREATE(AppConnection.GitHub).credentials - ) - }), - z.object({ - method: z.literal(GitHubConnectionMethod.OAuth).describe(AppConnections.CREATE(AppConnection.GitHub).method), - credentials: GitHubConnectionOAuthInputCredentialsSchema.describe( - AppConnections.CREATE(AppConnection.GitHub).credentials - ) - }) - ]) - .and(z.object({ name: slugSchema({ field: "name" }).describe(AppConnections.CREATE(AppConnection.GitHub).name) })); - -export const UpdateGitHubConnectionSchema = z.object({ - name: slugSchema({ field: "name" }).optional().describe(AppConnections.UPDATE(AppConnection.GitHub).name), - credentials: z - .union([GitHubConnectionAppInputCredentialsSchema, GitHubConnectionOAuthInputCredentialsSchema]) - .optional() - .describe(AppConnections.UPDATE(AppConnection.GitHub).credentials) -}); - -const BaseGitHubConnectionSchema = BaseAppConnectionSchema.extend({ app: z.literal(AppConnection.GitHub) }); - -export const GitHubAppConnectionSchema = z.intersection( - BaseGitHubConnectionSchema, - z.discriminatedUnion("method", [ - z.object({ - method: z.literal(GitHubConnectionMethod.App), - credentials: GitHubConnectionAppOutputCredentialsSchema - }), - z.object({ - method: z.literal(GitHubConnectionMethod.OAuth), - credentials: GitHubConnectionOAuthOutputCredentialsSchema - }) - ]) -); - -export const SanitizedGitHubConnectionSchema = z.discriminatedUnion("method", [ - BaseGitHubConnectionSchema.extend({ - method: z.literal(GitHubConnectionMethod.App), - credentials: GitHubConnectionAppOutputCredentialsSchema.omit({ installationId: true }) - }), - BaseGitHubConnectionSchema.extend({ - method: z.literal(GitHubConnectionMethod.OAuth), - credentials: GitHubConnectionOAuthOutputCredentialsSchema.omit({ accessToken: true }) - }) -]); diff --git a/backend/src/lib/app-connections/index.ts b/backend/src/lib/app-connections/index.ts deleted file mode 100644 index 6c0b398c9..000000000 --- a/backend/src/lib/app-connections/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export * from "./app-connection-enums"; -export * from "./app-connection-types"; diff --git a/backend/src/lib/app-connections/maps.ts b/backend/src/lib/app-connections/maps.ts deleted file mode 100644 index 391402013..000000000 --- a/backend/src/lib/app-connections/maps.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { TAppConnection } from "@app/lib/app-connections/app-connection-types"; - -import { AppConnection } from "./app-connection-enums"; -import { AwsConnectionMethod } from "./aws/aws-connection-enums"; -import { GitHubConnectionMethod } from "./github/github-connection-enums"; - -export const APP_CONNECTION_NAME_MAP: Record = { - [AppConnection.AWS]: "AWS", - [AppConnection.GitHub]: "GitHub" -}; - -export const APP_CONNECTION_METHOD_NAME_MAP: Record = { - [AwsConnectionMethod.AssumeRole]: "Assume Role", - [AwsConnectionMethod.AccessKey]: "Access Key", - [GitHubConnectionMethod.App]: "Github App", - [GitHubConnectionMethod.OAuth]: "OAuth" -}; diff --git a/backend/src/lib/fn/string.ts b/backend/src/lib/fn/string.ts index 26e8f27df..1dc2bbfed 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/server/routes/v1/app-connection-routers/app-connection-router.ts b/backend/src/server/routes/v1/app-connection-routers/app-connection-router.ts index 8cee4f7a5..63cfaafcd 100644 --- a/backend/src/server/routes/v1/app-connection-routers/app-connection-router.ts +++ b/backend/src/server/routes/v1/app-connection-routers/app-connection-router.ts @@ -1,19 +1,23 @@ +import { AwsConnectionListItemSchema, SanitizedAwsConnectionSchema } from "src/services/app-connection/aws"; +import { GitHubConnectionListItemSchema, SanitizedGitHubConnectionSchema } from "src/services/app-connection/github"; import { z } from "zod"; import { EventType } from "@app/ee/services/audit-log/audit-log-types"; -import { AppConnection } from "@app/lib/app-connections"; -import { SanitizedAwsConnectionSchema } from "@app/lib/app-connections/aws"; -import { SanitizedGitHubConnectionSchema } from "@app/lib/app-connections/github"; import { readLimit } from "@app/server/config/rateLimiter"; import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; import { AuthMode } from "@app/services/auth/auth-type"; // can't use discriminated due to multiple schemas for certain apps -export const SanitizedAppConnectionSchema = z.union([ +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", @@ -25,18 +29,11 @@ export const registerAppConnectionRouter = async (server: FastifyZodProvider) => description: "List the available App Connection Options.", response: { 200: z.object({ - appConnectionOptions: z - .object({ - name: z.string(), - app: z.nativeEnum(AppConnection), - methods: z.string().array() - }) - .passthrough() - .array() + appConnectionOptions: AppConnectionOptionsSchema.array() }) } }, - onRequest: verifyAuth([AuthMode.JWT, AuthMode.SERVICE_TOKEN]), + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), handler: () => { const appConnectionOptions = server.services.appConnection.listAppConnectionOptions(); return { appConnectionOptions }; @@ -55,7 +52,7 @@ export const registerAppConnectionRouter = async (server: FastifyZodProvider) => 200: z.object({ appConnections: SanitizedAppConnectionSchema.array() }) } }, - onRequest: verifyAuth([AuthMode.JWT, AuthMode.SERVICE_TOKEN]), + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), handler: async (req) => { const appConnections = await server.services.appConnection.listAppConnectionsByOrg(req.permission); @@ -63,7 +60,11 @@ export const registerAppConnectionRouter = async (server: FastifyZodProvider) => ...req.auditLogInfo, orgId: req.permission.orgId, event: { - type: EventType.GET_APP_CONNECTIONS + type: EventType.GET_APP_CONNECTIONS, + metadata: { + count: appConnections.length, + connectionIds: appConnections.map((connection) => connection.id) + } } }); 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 index fa72e564d..ec3b633a1 100644 --- 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 @@ -2,10 +2,12 @@ import { z } from "zod"; import { EventType } from "@app/ee/services/audit-log/audit-log-types"; import { AppConnections } from "@app/lib/api-docs"; -import { AppConnection, TAppConnection, TAppConnectionInput } from "@app/lib/app-connections"; -import { APP_CONNECTION_NAME_MAP } from "@app/lib/app-connections/maps"; +import { 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 = ({ @@ -17,8 +19,13 @@ export const registerAppConnectionEndpoints = ; - updateSchema: z.ZodType<{ name?: string; credentials?: I["credentials"] }>; + 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]; @@ -35,7 +42,7 @@ export const registerAppConnectionEndpoints = { const appConnections = (await server.services.appConnection.listAppConnectionsByOrg(req.permission, app)) as T[]; @@ -45,7 +52,9 @@ export const registerAppConnectionEndpoints = connection.id) } } }); @@ -69,7 +78,7 @@ export const registerAppConnectionEndpoints = { const { connectionId } = req.params; @@ -112,7 +121,7 @@ export const registerAppConnectionEndpoints = { const { connectionName } = req.params; @@ -144,18 +153,20 @@ export const registerAppConnectionEndpoints = { - const { name, method, credentials } = req.body; + const { name, method, credentials, description } = req.body; const appConnection = (await server.services.appConnection.createAppConnection( - { name, method, app, credentials }, + { name, method, app, credentials, description }, req.permission )) as TAppConnection; @@ -193,13 +204,13 @@ export const registerAppConnectionEndpoints = { - const { name, credentials } = req.body; + const { name, credentials, description } = req.body; const { connectionId } = req.params; const appConnection = (await server.services.appConnection.updateAppConnection( - { name, credentials, connectionId }, + { name, credentials, connectionId, description }, req.permission )) as T; @@ -210,6 +221,7 @@ export const registerAppConnectionEndpoints = { const { connectionId } = req.params; 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 index b4cb468fd..4bd232afb 100644 --- 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 @@ -1,9 +1,10 @@ -import { AppConnection } from "@app/lib/app-connections"; import { CreateAwsConnectionSchema, SanitizedAwsConnectionSchema, UpdateAwsConnectionSchema -} from "@app/lib/app-connections/aws"; +} from "src/services/app-connection/aws"; + +import { AppConnection } from "@app/services/app-connection/app-connection-enums"; import { registerAppConnectionEndpoints } from "./app-connection-endpoints"; 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 index 553c23354..71de42427 100644 --- 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 @@ -1,9 +1,10 @@ -import { AppConnection } from "@app/lib/app-connections"; import { CreateGitHubConnectionSchema, - GitHubAppConnectionSchema, + SanitizedGitHubConnectionSchema, UpdateGitHubConnectionSchema -} from "@app/lib/app-connections/github"; +} from "src/services/app-connection/github"; + +import { AppConnection } from "@app/services/app-connection/app-connection-enums"; import { registerAppConnectionEndpoints } from "./app-connection-endpoints"; @@ -11,7 +12,7 @@ export const registerGitHubConnectionRouter = async (server: FastifyZodProvider) registerAppConnectionEndpoints({ app: AppConnection.GitHub, server, - responseSchema: GitHubAppConnectionSchema, + 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 index b6fe7fc71..b56a65f50 100644 --- a/backend/src/server/routes/v1/app-connection-routers/apps/index.ts +++ b/backend/src/server/routes/v1/app-connection-routers/apps/index.ts @@ -1,6 +1,6 @@ -import { AppConnection } from "@app/lib/app-connections"; import { registerAwsConnectionRouter } from "@app/server/routes/v1/app-connection-routers/apps/aws-connection-router"; import { registerGitHubConnectionRouter } from "@app/server/routes/v1/app-connection-routers/apps/github-connection-router"; +import { AppConnection } from "@app/services/app-connection/app-connection-enums"; export const APP_CONNECTION_REGISTER_MAP: Record Promise> = { [AppConnection.AWS]: registerAwsConnectionRouter, diff --git a/backend/src/services/app-connection/app-connection-dal.ts b/backend/src/services/app-connection/app-connection-dal.ts index 47d76f27d..f74f7cf06 100644 --- a/backend/src/services/app-connection/app-connection-dal.ts +++ b/backend/src/services/app-connection/app-connection-dal.ts @@ -5,7 +5,7 @@ import { ormify } from "@app/lib/knex"; export type TAppConnectionDALFactory = ReturnType; export const appConnectionDALFactory = (db: TDbClient) => { - const appConnection = ormify(db, TableName.AppConnection); + const appConnectionOrm = ormify(db, TableName.AppConnection); - return { ...appConnection }; + return { ...appConnectionOrm }; }; diff --git a/backend/src/lib/app-connections/app-connection-enums.ts b/backend/src/services/app-connection/app-connection-enums.ts similarity index 100% rename from backend/src/lib/app-connections/app-connection-enums.ts rename to backend/src/services/app-connection/app-connection-enums.ts diff --git a/backend/src/services/app-connection/app-connection-fns.ts b/backend/src/services/app-connection/app-connection-fns.ts index ddae43def..4e0a7e3fa 100644 --- a/backend/src/services/app-connection/app-connection-fns.ts +++ b/backend/src/services/app-connection/app-connection-fns.ts @@ -1,10 +1,20 @@ -import { AppConnection, AppConnectionListItem, TAppConnection, TAppConnectionConfig } from "@app/lib/app-connections"; -import { getAwsAppConnectionListItem, validateAwsConnectionCredentials } from "@app/lib/app-connections/aws"; -import { getGitHubConnectionListItem, validateGitHubConnectionCredentials } from "@app/lib/app-connections/github"; +import { + AwsConnectionMethod, + getAwsAppConnectionListItem, + validateAwsConnectionCredentials +} from "src/services/app-connection/aws"; +import { + getGitHubConnectionListItem, + GitHubConnectionMethod, + validateGitHubConnectionCredentials +} from "src/services/app-connection/github"; + +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 { KmsDataKey } from "@app/services/kms/kms-types"; -export const listAppConnectionOptions = (): (AppConnectionListItem & Record)[] => { +export const listAppConnectionOptions = () => { return [getAwsAppConnectionListItem(), getGitHubConnectionListItem()].sort((a, b) => a.name.localeCompare(b.name)); }; @@ -65,3 +75,19 @@ export const validateAppConnectionCredentials = async ( 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 000000000..f473b1e38 --- /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 index 4d8d31cd1..ce5e877fd 100644 --- a/backend/src/services/app-connection/app-connection-schemas.ts +++ b/backend/src/services/app-connection/app-connection-schemas.ts @@ -1,7 +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 index 82dcb6f1a..9b9f16626 100644 --- a/backend/src/services/app-connection/app-connection-service.ts +++ b/backend/src/services/app-connection/app-connection-service.ts @@ -3,21 +3,26 @@ import { ForbiddenError } from "@casl/ability"; import { TLicenseServiceFactory } from "@app/ee/services/license/license-service"; import { OrgPermissionActions, OrgPermissionSubjects } from "@app/ee/services/permission/org-permission"; import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service"; -import { - AppConnection, - TAppConnection, - TAppConnectionConfig, - TCreateAppConnectionDTO, - TUpdateAppConnectionDTO -} from "@app/lib/app-connections"; import { BadRequestError, NotFoundError } from "@app/lib/errors"; -import { OrgServiceActor } from "@app/lib/types"; +import { 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"; @@ -31,6 +36,11 @@ export type TAppConnectionServiceFactoryDep = { export type TAppConnectionServiceFactory = ReturnType; +const VALIDATE_APP_CONNECTION_CREDENTIALS_MAP: Record = { + [AppConnection.AWS]: ValidateAwsConnectionCredentialsSchema, + [AppConnection.GitHub]: ValidateGitHubConnectionCredentialsSchema +}; + export const appConnectionServiceFactory = ({ appConnectionDAL, permissionService, @@ -160,40 +170,53 @@ export const appConnectionServiceFactory = ({ ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Create, OrgPermissionSubjects.AppConnections); - const isConflictingName = Boolean( - await appConnectionDAL.findOne({ - name: params.name, - orgId: actor.orgId - }) - ); + 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` + 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 validatedCredentials = await validateAppConnectionCredentials({ - app, - credentials, - method, - orgId: actor.orgId - } as TAppConnectionConfig); + const connection = await appConnectionDAL.create( + { + orgId: actor.orgId, + encryptedCredentials, + method, + app, + ...params + }, + tx + ); - const encryptedCredentials = await encryptAppConnectionCredentials({ - credentials: validatedCredentials, - orgId: actor.orgId, - kmsService + return { + ...connection, + credentials: validatedCredentials + }; }); - const appConnection = await appConnectionDAL.create({ - orgId: actor.orgId, - encryptedCredentials, - method, - app, - ...params - }); - - return { ...appConnection, credentials: validatedCredentials }; + return appConnection; }; const updateAppConnection = async ( @@ -216,44 +239,69 @@ export const appConnectionServiceFactory = ({ ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Edit, OrgPermissionSubjects.AppConnections); - if (params.name && appConnection.name !== params.name) { - const isConflictingName = Boolean( - await appConnectionDAL.findOne({ - name: params.name, - orgId: appConnection.orgId - }) + 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 ); - if (isConflictingName) - throw new BadRequestError({ - message: `An App Connection with the name "${params.name}" already exists` - }); - } - - let encryptedCredentials: undefined | Buffer; - - if (credentials) { - const validatedCredentials = await validateAppConnectionCredentials({ - app: appConnection.app, - credentials, - method: appConnection.method, - orgId: actor.orgId - } as TAppConnectionConfig); - - if (!validatedCredentials) - throw new BadRequestError({ message: "Unable to validate connection - check credentials" }); - - encryptedCredentials = await encryptAppConnectionCredentials({ - credentials: validatedCredentials, - orgId: actor.orgId, - kmsService - }); - } - - const updatedAppConnection = await appConnectionDAL.updateById(connectionId, { - orgId: actor.orgId, - encryptedCredentials, - ...params + return updatedConnection; }); return { 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 000000000..e3983cf91 --- /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/lib/app-connections/aws/aws-connection-enums.ts b/backend/src/services/app-connection/aws/aws-connection-enums.ts similarity index 100% rename from backend/src/lib/app-connections/aws/aws-connection-enums.ts rename to backend/src/services/app-connection/aws/aws-connection-enums.ts diff --git a/backend/src/lib/app-connections/aws/aws-connection-fns.ts b/backend/src/services/app-connection/aws/aws-connection-fns.ts similarity index 91% rename from backend/src/lib/app-connections/aws/aws-connection-fns.ts rename to backend/src/services/app-connection/aws/aws-connection-fns.ts index 316939f7f..36008bc58 100644 --- a/backend/src/lib/app-connections/aws/aws-connection-fns.ts +++ b/backend/src/services/app-connection/aws/aws-connection-fns.ts @@ -2,20 +2,20 @@ import { AssumeRoleCommand, STSClient } from "@aws-sdk/client-sts"; import AWS from "aws-sdk"; import { randomUUID } from "crypto"; -import { AppConnection } from "@app/lib/app-connections/app-connection-enums"; -import { TAwsConnectionConfig } from "@app/lib/app-connections/aws/aws-connection-types"; import { getConfig } from "@app/lib/config/env"; import { BadRequestError, InternalServerError } from "@app/lib/errors"; +import { 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", - app: AppConnection.AWS, - methods: Object.values(AwsConnectionMethod), + 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 }; }; 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 000000000..914e92671 --- /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 000000000..a0b74c3d0 --- /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/lib/app-connections/aws/index.ts b/backend/src/services/app-connection/aws/index.ts similarity index 100% rename from backend/src/lib/app-connections/aws/index.ts rename to backend/src/services/app-connection/aws/index.ts diff --git a/backend/src/lib/app-connections/github/github-connection-enums.ts b/backend/src/services/app-connection/github/github-connection-enums.ts similarity index 100% rename from backend/src/lib/app-connections/github/github-connection-enums.ts rename to backend/src/services/app-connection/github/github-connection-enums.ts diff --git a/backend/src/lib/app-connections/github/github-connection-fns.ts b/backend/src/services/app-connection/github/github-connection-fns.ts similarity index 90% rename from backend/src/lib/app-connections/github/github-connection-fns.ts rename to backend/src/services/app-connection/github/github-connection-fns.ts index b952289cd..01fa7846f 100644 --- a/backend/src/lib/app-connections/github/github-connection-fns.ts +++ b/backend/src/services/app-connection/github/github-connection-fns.ts @@ -3,10 +3,10 @@ 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 { APP_CONNECTION_METHOD_NAME_MAP } from "../maps"; import { GitHubConnectionMethod } from "./github-connection-enums"; import { TGitHubConnectionConfig } from "./github-connection-types"; @@ -14,9 +14,9 @@ export const getGitHubConnectionListItem = () => { const { INF_APP_CONNECTION_GITHUB_OAUTH_CLIENT_ID, INF_APP_CONNECTION_GITHUB_APP_SLUG } = getConfig(); return { - name: "GitHub", - app: AppConnection.GitHub, - methods: Object.values(GitHubConnectionMethod), + 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 }; @@ -53,7 +53,7 @@ export const validateGitHubConnectionCredentials = async (config: TGitHubConnect if (!clientId || !clientSecret) { throw new InternalServerError({ - message: `GitHub ${APP_CONNECTION_METHOD_NAME_MAP[method]} environment variables have not been configured` + message: `GitHub ${getAppConnectionMethodName(method)} environment variables have not been configured` }); } 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 000000000..5adb211ba --- /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/lib/app-connections/github/github-connection-types.ts b/backend/src/services/app-connection/github/github-connection-types.ts similarity index 63% rename from backend/src/lib/app-connections/github/github-connection-types.ts rename to backend/src/services/app-connection/github/github-connection-types.ts index 7a96fab81..5a9b13c00 100644 --- a/backend/src/lib/app-connections/github/github-connection-types.ts +++ b/backend/src/services/app-connection/github/github-connection-types.ts @@ -3,12 +3,18 @@ import { z } from "zod"; import { DiscriminativePick } from "@app/lib/types"; import { AppConnection } from "../app-connection-enums"; -import { CreateGitHubConnectionSchema, GitHubAppConnectionSchema } from "./github-connection-schemas"; - -export type TGitHubConnectionConfig = DiscriminativePick; +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/lib/app-connections/github/index.ts b/backend/src/services/app-connection/github/index.ts similarity index 100% rename from backend/src/lib/app-connections/github/index.ts rename to backend/src/services/app-connection/github/index.ts 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 000000000..1fb2b8118 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 000000000..57d2eb2e2 Binary files /dev/null and b/docs/images/app-connections/aws/secrets-manager-permissions.png differ diff --git a/docs/integrations/app-connections/aws.mdx b/docs/integrations/app-connections/aws.mdx index 889e2c89d..caebff7c1 100644 --- a/docs/integrations/app-connections/aws.mdx +++ b/docs/integrations/app-connections/aws.mdx @@ -68,37 +68,69 @@ Infisical supports two methods for connecting to AWS. - Add the **SecretsManagerReadWrite** policy 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 Permissions](/images/integrations/aws/integration-aws-iam-assume-permission.png) + ![IAM Role Secrets Manager Permissions](/images/app-connections/aws/secrets-manager-permissions.png) - Alternatively, use the following custom policy to grant the minimum permissions required by Infisical to sync secrets to AWS Parameter Store: + ```json + { + "Version": "2012-10-17", + "Statement": [ + { + "Sid": "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: - ```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": "*" - } - ] - } - ``` + ![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": "*" + } + ] + } + ``` + + @@ -186,36 +218,69 @@ Infisical supports two methods for connecting to AWS. - Add the **SecretsManagerReadWrite** policy 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 Permissions](/images/integrations/aws/integration-aws-iam-assume-permission.png) - Alternatively, use the following custom policy to grant the minimum permissions required by Infisical to sync secrets to AWS Parameter Store: + ![IAM Role Secrets Manager Permissions](/images/app-connections/aws/secrets-manager-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": "*" - } - ] - } - ``` + ```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": "*" + } + ] + } + ``` + + diff --git a/docs/integrations/app-connections/github.mdx b/docs/integrations/app-connections/github.mdx index 26c26f6af..18f702bb6 100644 --- a/docs/integrations/app-connections/github.mdx +++ b/docs/integrations/app-connections/github.mdx @@ -104,6 +104,42 @@ Infisical supports two methods for connecting to GitHub. - 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 diff --git a/docs/integrations/app-connections/overview.mdx b/docs/integrations/app-connections/overview.mdx index 2d93e2cc5..64f3616de 100644 --- a/docs/integrations/app-connections/overview.mdx +++ b/docs/integrations/app-connections/overview.mdx @@ -17,17 +17,25 @@ that can be used across Infisical projects. Example use cases include syncing se ```mermaid %%{init: {'flowchart': {'curve': 'linear'} } }%% graph TD - A[AWS Connection] - A --> B[Project 1 Secret Sync] - A --> C[Project 2 Secret Sync] - A --> D[Project 3 Generate Dynamic Secret] + 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,C,D project + class B connection + class C,D,E project ``` diff --git a/frontend/src/components/navigation/RegionSelect.tsx b/frontend/src/components/navigation/RegionSelect.tsx index 44f2336cd..51a033244 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/helpers/appConnections.ts b/frontend/src/helpers/appConnections.ts index 5d72c837e..9d52fb14e 100644 --- a/frontend/src/helpers/appConnections.ts +++ b/frontend/src/helpers/appConnections.ts @@ -1,22 +1,29 @@ -import { faGithub, IconDefinition } from "@fortawesome/free-brands-svg-icons"; +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 { TAppConnection } from "@app/hooks/api/appConnections/types"; -import { AwsConnectionMethod } from "@app/hooks/api/appConnections/types/aws-connection"; -import { GitHubConnectionMethod } from "@app/hooks/api/appConnections/types/github-connection"; +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 APP_CONNECTION_METHOD_MAP: Record< - TAppConnection["method"], - { name: string; icon: IconDefinition } -> = { - [AwsConnectionMethod.AssumeRole]: { name: "Assume Role", icon: faUser }, - [AwsConnectionMethod.AccessKey]: { name: "Access Key", icon: faKey }, - [GitHubConnectionMethod.App]: { name: "GitHub App", icon: faGithub }, - [GitHubConnectionMethod.OAuth]: { name: "OAuth", icon: faPassport } +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 000000000..821febcb8 --- /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/types/index.ts b/frontend/src/hooks/api/appConnections/types/index.ts index 2896fc1b5..fcec4a1df 100644 --- a/frontend/src/hooks/api/appConnections/types/index.ts +++ b/frontend/src/hooks/api/appConnections/types/index.ts @@ -15,10 +15,12 @@ export type TAppConnectionResponse = { appConnection: TAppConnection }; export type TCreateAppConnectionDTO = Pick< TAppConnection, - "name" | "credentials" | "method" | "app" + "name" | "credentials" | "method" | "app" | "description" >; -export type TUpdateAppConnectionDTO = Partial> & { +export type TUpdateAppConnectionDTO = Partial< + Pick +> & { connectionId: string; app: AppConnection; }; diff --git a/frontend/src/hooks/api/appConnections/types/root-connection.ts b/frontend/src/hooks/api/appConnections/types/root-connection.ts index 907efb71d..0dc4a616f 100644 --- a/frontend/src/hooks/api/appConnections/types/root-connection.ts +++ b/frontend/src/hooks/api/appConnections/types/root-connection.ts @@ -1,6 +1,7 @@ export type TRootAppConnection = { id: string; name: string; + description?: string | null; version: number; orgId: string; createdAt: string; diff --git a/frontend/src/pages/app-connections/github/oauth/callback.tsx b/frontend/src/pages/app-connections/github/oauth/callback.tsx index 1c673dae8..79827acaa 100644 --- a/frontend/src/pages/app-connections/github/oauth/callback.tsx +++ b/frontend/src/pages/app-connections/github/oauth/callback.tsx @@ -13,7 +13,7 @@ import { } from "@app/hooks/api/appConnections"; import { AppConnection } from "@app/hooks/api/appConnections/enums"; -type FormData = Pick & { +type FormData = Pick & { returnUrl?: string; connectionId?: string; }; @@ -58,7 +58,7 @@ export default function GitHubOAuthCallbackPage() { localStorage.removeItem("githubConnectionFormData"); localStorage.removeItem("latestCSRFToken"); - const { connectionId, name, returnUrl } = formData; + const { connectionId, name, description, returnUrl } = formData; let appConnection: TAppConnection; @@ -85,6 +85,7 @@ export default function GitHubOAuthCallbackPage() { appConnection = await createAppConnection.mutateAsync({ app: AppConnection.GitHub, name, + description, ...(installationId ? { method: GitHubConnectionMethod.App, diff --git a/frontend/src/views/Settings/OrgSettingsPage/components/AppConnectionsTab/AppConnectionsTab.tsx b/frontend/src/views/Settings/OrgSettingsPage/components/AppConnectionsTab/AppConnectionsTab.tsx index 2eeb22e65..f0aa81160 100644 --- a/frontend/src/views/Settings/OrgSettingsPage/components/AppConnectionsTab/AppConnectionsTab.tsx +++ b/frontend/src/views/Settings/OrgSettingsPage/components/AppConnectionsTab/AppConnectionsTab.tsx @@ -65,7 +65,7 @@ export const AppConnectionsTab = withPermission( {(isAllowed) => ( - - - - - + + + + + + ); }; 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 000000000..d9e25a0a7 --- /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 ( + <> + + + + +