feature: app connections

This commit is contained in:
Scott Wilson
2024-12-16 22:46:08 -08:00
parent b669b0a9f8
commit 52ce90846a
108 changed files with 4031 additions and 19 deletions
+32
View File
@@ -1,3 +1,6 @@
import { AppConnection } from "@app/lib/app-connections";
import { APP_CONNECTION_NAME_MAP } from "@app/lib/app-connections/maps";
export const GROUPS = {
CREATE: {
name: "The name of the group to create.",
@@ -1515,3 +1518,32 @@ export const ProjectTemplates = {
templateId: "The ID of the project template to be deleted."
}
};
export const AppConnections = {
GET_BY_ID: (app: AppConnection) => ({
connectionId: `The ID of the ${APP_CONNECTION_NAME_MAP[app]} Connection to retrieve.`
}),
GET_BY_NAME: (app: AppConnection) => ({
connectionName: `The name of the ${APP_CONNECTION_NAME_MAP[app]} Connection to retrieve.`
}),
CREATE: (app: AppConnection) => {
const appName = APP_CONNECTION_NAME_MAP[app];
return {
name: `The name of the ${appName} Connection to create. Must be slug-friendly.`,
credentials: `The credentials used to connect with ${appName}.`,
method: `The method used to authenticate with ${appName}.`
};
},
UPDATE: (app: AppConnection) => {
const appName = APP_CONNECTION_NAME_MAP[app];
return {
connectionId: `The ID of the ${appName} Connection to be updated.`,
name: `The updated name of the ${appName} Connection. Must be slug-friendly.`,
credentials: `The credentials used to connect with ${appName}.`,
method: `The method used to authenticate with ${appName}.`
};
},
DELETE: (app: AppConnection) => ({
connectionId: `The ID of the ${app} connection to be deleted.`
})
};
@@ -0,0 +1,4 @@
export enum AppConnection {
GitHub = "github",
AWS = "aws"
}
@@ -0,0 +1,26 @@
import { TAwsConnection } from "@app/lib/app-connections/aws/aws-connection-types";
import { TGitHubConnection, TGitHubConnectionInput } from "@app/lib/app-connections/github";
import { DiscriminativePick } from "@app/lib/types";
import { AppConnection } from "./app-connection-enums";
export type AppConnectionListItem = {
app: AppConnection;
name: string;
methods: string[];
};
export type TAppConnection = { id: string } & (TAwsConnection | TGitHubConnection);
export type TAppConnectionInput = { id: string } & (TAwsConnection | TGitHubConnectionInput);
export type TCreateAppConnectionDTO = Pick<TAppConnectionInput, "credentials" | "method" | "name" | "app">;
export type TUpdateAppConnectionDTO = Partial<Omit<TCreateAppConnectionDTO, "method" | "app">> & {
connectionId: string;
};
export type TAppConnectionConfig = { orgId: string } & DiscriminativePick<
TAppConnectionInput,
"app" | "method" | "credentials"
>;
@@ -0,0 +1,4 @@
export enum AwsConnectionMethod {
AssumeRole = "assume-role",
AccessKey = "access-key"
}
@@ -0,0 +1,105 @@
import { AssumeRoleCommand, STSClient } from "@aws-sdk/client-sts";
import AWS from "aws-sdk";
import { randomUUID } from "crypto";
import { AppConnection } from "@app/lib/app-connections/app-connection-enums";
import { TAwsConnectionConfig } from "@app/lib/app-connections/aws/aws-connection-types";
import { getConfig } from "@app/lib/config/env";
import { BadRequestError, InternalServerError } from "@app/lib/errors";
import { AwsConnectionMethod } from "./aws-connection-enums";
export const getAwsAppConnectionListItem = () => {
const { INF_APP_CONNECTION_AWS_ACCESS_KEY_ID } = getConfig();
return {
name: "AWS",
app: AppConnection.AWS,
methods: Object.values(AwsConnectionMethod),
accessKeyId: INF_APP_CONNECTION_AWS_ACCESS_KEY_ID
};
};
export const getAwsConnectionConfig = async (appConnection: TAwsConnectionConfig, region = "us-east-1") => {
const appCfg = getConfig();
let accessKeyId: string;
let secretAccessKey: string;
let sessionToken: undefined | string;
const { method, credentials, orgId } = appConnection;
switch (method) {
case AwsConnectionMethod.AssumeRole: {
const client = new STSClient({
region,
credentials:
appCfg.INF_APP_CONNECTION_AWS_ACCESS_KEY_ID && appCfg.INF_APP_CONNECTION_AWS_SECRET_ACCESS_KEY
? {
accessKeyId: appCfg.INF_APP_CONNECTION_AWS_ACCESS_KEY_ID,
secretAccessKey: appCfg.INF_APP_CONNECTION_AWS_SECRET_ACCESS_KEY
}
: undefined // if hosting on AWS
});
const command = new AssumeRoleCommand({
RoleArn: credentials.roleArn,
RoleSessionName: `infisical-app-connection-${randomUUID()}`,
DurationSeconds: 900, // 15 mins
ExternalId: orgId
});
const assumeRes = await client.send(command);
if (!assumeRes.Credentials?.AccessKeyId || !assumeRes.Credentials?.SecretAccessKey) {
throw new BadRequestError({ message: "Failed to assume role - verify credentials and role configuration" });
}
accessKeyId = assumeRes.Credentials.AccessKeyId;
secretAccessKey = assumeRes.Credentials.SecretAccessKey;
sessionToken = assumeRes.Credentials?.SessionToken;
break;
}
case AwsConnectionMethod.AccessKey: {
accessKeyId = credentials.accessKeyId;
secretAccessKey = credentials.secretAccessKey;
break;
}
default:
// eslint-disable-next-line @typescript-eslint/restrict-template-expressions
throw new InternalServerError({ message: `Unsupported AWS connection method: ${method}` });
}
return new AWS.Config({
region,
credentials: {
accessKeyId,
secretAccessKey,
sessionToken
}
});
};
export const validateAwsConnectionCredentials = async (appConnection: TAwsConnectionConfig) => {
const awsConfig = await getAwsConnectionConfig(appConnection);
const sts = new AWS.STS(awsConfig);
let resp: Awaited<ReturnType<ReturnType<typeof sts.getCallerIdentity>["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;
};
@@ -0,0 +1,69 @@
import { z } from "zod";
import { AppConnections } from "@app/lib/api-docs";
import { slugSchema } from "@app/server/lib/schemas";
import { BaseAppConnectionSchema } from "@app/services/app-connection/app-connection-schemas";
import { AppConnection } from "../app-connection-enums";
import { AwsConnectionMethod } from "./aws-connection-enums";
export const AwsConnectionAssumeRoleCredentialsSchema = z.object({
roleArn: z.string().min(1, "Role ARN required")
});
export const AwsConnectionAccessTokenCredentialsSchema = z.object({
accessKeyId: z.string().min(1, "Access Key ID required"),
secretAccessKey: z.string().min(1, "Secret Access Key required")
});
const BaseAwsConnectionSchema = BaseAppConnectionSchema.extend({ app: z.literal(AppConnection.AWS) });
export const AwsConnectionSchema = z.intersection(
BaseAwsConnectionSchema,
z.discriminatedUnion("method", [
z.object({
method: z.literal(AwsConnectionMethod.AssumeRole),
credentials: AwsConnectionAssumeRoleCredentialsSchema
}),
z.object({
method: z.literal(AwsConnectionMethod.AccessKey),
credentials: AwsConnectionAccessTokenCredentialsSchema
})
])
);
export const SanitizedAwsConnectionSchema = z.discriminatedUnion("method", [
BaseAwsConnectionSchema.extend({
method: z.literal(AwsConnectionMethod.AssumeRole),
credentials: AwsConnectionAssumeRoleCredentialsSchema.omit({ roleArn: true })
}),
BaseAwsConnectionSchema.extend({
method: z.literal(AwsConnectionMethod.AccessKey),
credentials: AwsConnectionAccessTokenCredentialsSchema.omit({ secretAccessKey: true })
})
]);
export const CreateAwsConnectionSchema = z
.discriminatedUnion("method", [
z.object({
method: z.literal(AwsConnectionMethod.AssumeRole).describe(AppConnections.CREATE(AppConnection.AWS).method),
credentials: AwsConnectionAssumeRoleCredentialsSchema.describe(
AppConnections.CREATE(AppConnection.AWS).credentials
)
}),
z.object({
method: z.literal(AwsConnectionMethod.AccessKey).describe(AppConnections.CREATE(AppConnection.AWS).method),
credentials: AwsConnectionAccessTokenCredentialsSchema.describe(
AppConnections.CREATE(AppConnection.AWS).credentials
)
})
])
.and(z.object({ name: slugSchema({ field: "name" }).describe(AppConnections.CREATE(AppConnection.AWS).name) }));
export const UpdateAwsConnectionSchema = z.object({
name: slugSchema({ field: "name" }).optional().describe(AppConnections.UPDATE(AppConnection.AWS).name),
credentials: z
.union([AwsConnectionAccessTokenCredentialsSchema, AwsConnectionAssumeRoleCredentialsSchema])
.optional()
.describe(AppConnections.UPDATE(AppConnection.AWS).credentials)
});
@@ -0,0 +1,9 @@
import { z } from "zod";
import { DiscriminativePick } from "@app/lib/types";
import { AwsConnectionSchema } from "./aws-connection-schemas";
export type TAwsConnection = z.infer<typeof AwsConnectionSchema>;
export type TAwsConnectionConfig = DiscriminativePick<TAwsConnection, "orgId" | "method" | "app" | "credentials">;
@@ -0,0 +1,4 @@
export * from "./aws-connection-enums";
export * from "./aws-connection-fns";
export * from "./aws-connection-schemas";
export * from "./aws-connection-types";
@@ -0,0 +1,4 @@
export enum GitHubConnectionMethod {
OAuth = "oauth",
App = "github-app"
}
@@ -0,0 +1,129 @@
import { AxiosResponse } from "axios";
import { getConfig } from "@app/lib/config/env";
import { request } from "@app/lib/config/request";
import { BadRequestError, ForbiddenRequestError, InternalServerError } from "@app/lib/errors";
import { IntegrationUrls } from "@app/services/integration-auth/integration-list";
import { AppConnection } from "../app-connection-enums";
import { APP_CONNECTION_METHOD_NAME_MAP } from "../maps";
import { GitHubConnectionMethod } from "./github-connection-enums";
import { TGitHubConnectionConfig } from "./github-connection-types";
export const getGitHubConnectionListItem = () => {
const { INF_APP_CONNECTION_GITHUB_OAUTH_CLIENT_ID, INF_APP_CONNECTION_GITHUB_APP_SLUG } = getConfig();
return {
name: "GitHub",
app: AppConnection.GitHub,
methods: Object.values(GitHubConnectionMethod),
oauthClientId: INF_APP_CONNECTION_GITHUB_OAUTH_CLIENT_ID,
appClientSlug: INF_APP_CONNECTION_GITHUB_APP_SLUG
};
};
type TokenRespData = {
access_token: string;
scope: string;
token_type: string;
};
export const validateGitHubConnectionCredentials = async (config: TGitHubConnectionConfig) => {
const { credentials, method } = config;
const {
INF_APP_CONNECTION_GITHUB_OAUTH_CLIENT_ID,
INF_APP_CONNECTION_GITHUB_OAUTH_CLIENT_SECRET,
INF_APP_CONNECTION_GITHUB_APP_CLIENT_ID,
INF_APP_CONNECTION_GITHUB_APP_CLIENT_SECRET,
SITE_URL
} = getConfig();
const { clientId, clientSecret } =
method === GitHubConnectionMethod.App
? {
clientId: INF_APP_CONNECTION_GITHUB_APP_CLIENT_ID,
clientSecret: INF_APP_CONNECTION_GITHUB_APP_CLIENT_SECRET
}
: // oauth
{
clientId: INF_APP_CONNECTION_GITHUB_OAUTH_CLIENT_ID,
clientSecret: INF_APP_CONNECTION_GITHUB_OAUTH_CLIENT_SECRET
};
if (!clientId || !clientSecret) {
throw new InternalServerError({
message: `GitHub ${APP_CONNECTION_METHOD_NAME_MAP[method]} environment variables have not been configured`
});
}
let tokenResp: AxiosResponse<TokenRespData>;
try {
tokenResp = await request.get<TokenRespData>("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}`
});
}
};
@@ -0,0 +1,77 @@
import { z } from "zod";
import { AppConnections } from "@app/lib/api-docs";
import { AppConnection } from "@app/lib/app-connections";
import { slugSchema } from "@app/server/lib/schemas";
import { BaseAppConnectionSchema } from "@app/services/app-connection/app-connection-schemas";
import { GitHubConnectionMethod } from "./github-connection-enums";
export const GitHubConnectionOAuthInputCredentialsSchema = z.object({
code: z.string().min(1, "OAuth code required")
});
export const GitHubConnectionAppInputCredentialsSchema = z.object({
code: z.string().min(1, "GitHub App code required"),
installationId: z.string().min(1, "GitHub App Installation ID required")
});
export const GitHubConnectionOAuthOutputCredentialsSchema = z.object({
accessToken: z.string()
});
export const GitHubConnectionAppOutputCredentialsSchema = z.object({
installationId: z.string()
});
export const CreateGitHubConnectionSchema = z
.discriminatedUnion("method", [
z.object({
method: z.literal(GitHubConnectionMethod.App).describe(AppConnections.CREATE(AppConnection.GitHub).method),
credentials: GitHubConnectionAppInputCredentialsSchema.describe(
AppConnections.CREATE(AppConnection.GitHub).credentials
)
}),
z.object({
method: z.literal(GitHubConnectionMethod.OAuth).describe(AppConnections.CREATE(AppConnection.GitHub).method),
credentials: GitHubConnectionOAuthInputCredentialsSchema.describe(
AppConnections.CREATE(AppConnection.GitHub).credentials
)
})
])
.and(z.object({ name: slugSchema({ field: "name" }).describe(AppConnections.CREATE(AppConnection.GitHub).name) }));
export const UpdateGitHubConnectionSchema = z.object({
name: slugSchema({ field: "name" }).optional().describe(AppConnections.UPDATE(AppConnection.GitHub).name),
credentials: z
.union([GitHubConnectionAppInputCredentialsSchema, GitHubConnectionOAuthInputCredentialsSchema])
.optional()
.describe(AppConnections.UPDATE(AppConnection.GitHub).credentials)
});
const BaseGitHubConnectionSchema = BaseAppConnectionSchema.extend({ app: z.literal(AppConnection.GitHub) });
export const GitHubAppConnectionSchema = z.intersection(
BaseGitHubConnectionSchema,
z.discriminatedUnion("method", [
z.object({
method: z.literal(GitHubConnectionMethod.App),
credentials: GitHubConnectionAppOutputCredentialsSchema
}),
z.object({
method: z.literal(GitHubConnectionMethod.OAuth),
credentials: GitHubConnectionOAuthOutputCredentialsSchema
})
])
);
export const SanitizedGitHubConnectionSchema = z.discriminatedUnion("method", [
BaseGitHubConnectionSchema.extend({
method: z.literal(GitHubConnectionMethod.App),
credentials: GitHubConnectionAppOutputCredentialsSchema.omit({ installationId: true })
}),
BaseGitHubConnectionSchema.extend({
method: z.literal(GitHubConnectionMethod.OAuth),
credentials: GitHubConnectionOAuthOutputCredentialsSchema.omit({ accessToken: true })
})
]);
@@ -0,0 +1,14 @@
import { z } from "zod";
import { DiscriminativePick } from "@app/lib/types";
import { AppConnection } from "../app-connection-enums";
import { CreateGitHubConnectionSchema, GitHubAppConnectionSchema } from "./github-connection-schemas";
export type TGitHubConnectionConfig = DiscriminativePick<TGitHubConnectionInput, "method" | "app" | "credentials">;
export type TGitHubConnection = z.infer<typeof GitHubAppConnectionSchema>;
export type TGitHubConnectionInput = z.infer<typeof CreateGitHubConnectionSchema> & {
app: AppConnection.GitHub;
};
@@ -0,0 +1,4 @@
export * from "./github-connection-enums";
export * from "./github-connection-fns";
export * from "./github-connection-schemas";
export * from "./github-connection-types";
+2
View File
@@ -0,0 +1,2 @@
export * from "./app-connection-enums";
export * from "./app-connection-types";
+17
View File
@@ -0,0 +1,17 @@
import { TAppConnection } from "@app/lib/app-connections/app-connection-types";
import { AppConnection } from "./app-connection-enums";
import { AwsConnectionMethod } from "./aws/aws-connection-enums";
import { GitHubConnectionMethod } from "./github/github-connection-enums";
export const APP_CONNECTION_NAME_MAP: Record<AppConnection, string> = {
[AppConnection.AWS]: "AWS",
[AppConnection.GitHub]: "GitHub"
};
export const APP_CONNECTION_METHOD_NAME_MAP: Record<TAppConnection["method"], string> = {
[AwsConnectionMethod.AssumeRole]: "Assume Role",
[AwsConnectionMethod.AccessKey]: "Access Key",
[GitHubConnectionMethod.App]: "Github App",
[GitHubConnectionMethod.OAuth]: "OAuth"
};
+18 -1
View File
@@ -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(
+2
View File
@@ -43,6 +43,8 @@ export type RequiredKeys<T> = {
export type PickRequired<T> = Pick<T, RequiredKeys<T>>;
export type DiscriminativePick<T, K extends keyof T> = T extends unknown ? Pick<T, K> : never;
export enum EnforcementLevel {
Hard = "hard",
Soft = "soft"