feat(secret-syncs): azure app config & key vault support

This commit is contained in:
Daniel Hougaard
2025-02-08 04:27:17 +04:00
parent 4e41e84491
commit 5cd6a66989
70 changed files with 2065 additions and 204 deletions
@@ -1,7 +1,8 @@
export enum AppConnection {
GitHub = "github",
AWS = "aws",
GCP = "gcp"
GCP = "gcp",
Azure = "azure"
}
export enum AWSRegion {
@@ -20,10 +20,15 @@ import {
} from "@app/services/app-connection/github";
import { KmsDataKey } from "@app/services/kms/kms-types";
import { AzureConnectionMethod, getAzureConnectionListItem, validateAzureConnectionCredentials } from "./azure";
export const listAppConnectionOptions = () => {
return [getAwsAppConnectionListItem(), getGitHubConnectionListItem(), getGcpAppConnectionListItem()].sort((a, b) =>
a.name.localeCompare(b.name)
);
return [
getAwsAppConnectionListItem(),
getGitHubConnectionListItem(),
getGcpAppConnectionListItem(),
getAzureConnectionListItem()
].sort((a, b) => a.name.localeCompare(b.name));
};
export const encryptAppConnectionCredentials = async ({
@@ -79,6 +84,8 @@ export const validateAppConnectionCredentials = async (
return validateGitHubConnectionCredentials(appConnection);
case AppConnection.GCP:
return validateGcpConnectionCredentials(appConnection);
case AppConnection.Azure:
return validateAzureConnectionCredentials(appConnection);
default:
// eslint-disable-next-line @typescript-eslint/restrict-template-expressions
throw new Error(`Unhandled App Connection ${app}`);
@@ -89,6 +96,7 @@ export const getAppConnectionMethodName = (method: TAppConnection["method"]) =>
switch (method) {
case GitHubConnectionMethod.App:
return "GitHub App";
case AzureConnectionMethod.OAuth:
case GitHubConnectionMethod.OAuth:
return "OAuth";
case AwsConnectionMethod.AccessKey:
@@ -3,5 +3,6 @@ import { AppConnection } from "./app-connection-enums";
export const APP_CONNECTION_NAME_MAP: Record<AppConnection, string> = {
[AppConnection.AWS]: "AWS",
[AppConnection.GitHub]: "GitHub",
[AppConnection.GCP]: "GCP"
[AppConnection.GCP]: "GCP",
[AppConnection.Azure]: "Azure"
};
@@ -27,7 +27,9 @@ import { ValidateGitHubConnectionCredentialsSchema } from "@app/services/app-con
import { githubConnectionService } from "@app/services/app-connection/github/github-connection-service";
import { TKmsServiceFactory } from "@app/services/kms/kms-service";
import { KmsDataKey } from "../kms/kms-types";
import { TAppConnectionDALFactory } from "./app-connection-dal";
import { AzureResources } from "./azure";
import { ValidateGcpConnectionCredentialsSchema } from "./gcp";
import { gcpConnectionService } from "./gcp/gcp-connection-service";
@@ -42,7 +44,8 @@ export type TAppConnectionServiceFactory = ReturnType<typeof appConnectionServic
const VALIDATE_APP_CONNECTION_CREDENTIALS_MAP: Record<AppConnection, TValidateAppConnectionCredentials> = {
[AppConnection.AWS]: ValidateAwsConnectionCredentialsSchema,
[AppConnection.GitHub]: ValidateGitHubConnectionCredentialsSchema,
[AppConnection.GCP]: ValidateGcpConnectionCredentialsSchema
[AppConnection.GCP]: ValidateGcpConnectionCredentialsSchema,
[AppConnection.Azure]: ValidateGcpConnectionCredentialsSchema
};
export const appConnectionServiceFactory = ({
@@ -347,7 +350,27 @@ export const appConnectionServiceFactory = ({
)
);
return availableConnections as Omit<TAppConnection, "credentials">[];
const { decryptor } = await kmsService.createCipherPairWithDataKey({
type: KmsDataKey.Organization,
orgId: actor.orgId
});
const decryptedConnections = availableConnections.map((connection) => {
const decryptedPlainTextBlob = decryptor({
cipherTextBlob: connection.encryptedCredentials
});
const credentials = JSON.parse(decryptedPlainTextBlob.toString()) as TAppConnection["credentials"];
return {
...connection,
...(app === AppConnection.Azure && {
azureResource: (credentials as { resource: AzureResources }).resource
})
};
});
return decryptedConnections as Omit<TAppConnection, "credentials">[];
};
return {
@@ -11,11 +11,22 @@ import {
TValidateGitHubConnectionCredentials
} from "@app/services/app-connection/github";
import {
TAzureConnection,
TAzureConnectionConfig,
TAzureConnectionInput,
TValidateAzureConnectionCredentials
} from "./azure";
import { TGcpConnection, TGcpConnectionConfig, TGcpConnectionInput, TValidateGcpConnectionCredentials } from "./gcp";
export type TAppConnection = { id: string } & (TAwsConnection | TGitHubConnection | TGcpConnection);
export type TAppConnection = { id: string } & (TAwsConnection | TGitHubConnection | TGcpConnection | TAzureConnection);
export type TAppConnectionInput = { id: string } & (TAwsConnectionInput | TGitHubConnectionInput | TGcpConnectionInput);
export type TAppConnectionInput = { id: string } & (
| TAwsConnectionInput
| TGitHubConnectionInput
| TGcpConnectionInput
| TAzureConnectionInput
);
export type TCreateAppConnectionDTO = Pick<
TAppConnectionInput,
@@ -26,9 +37,14 @@ export type TUpdateAppConnectionDTO = Partial<Omit<TCreateAppConnectionDTO, "met
connectionId: string;
};
export type TAppConnectionConfig = TAwsConnectionConfig | TGitHubConnectionConfig | TGcpConnectionConfig;
export type TAppConnectionConfig =
| TAwsConnectionConfig
| TGitHubConnectionConfig
| TGcpConnectionConfig
| TAzureConnectionConfig;
export type TValidateAppConnectionCredentials =
| TValidateAwsConnectionCredentials
| TValidateGitHubConnectionCredentials
| TValidateGcpConnectionCredentials;
| TValidateGcpConnectionCredentials
| TValidateAzureConnectionCredentials;
@@ -0,0 +1,8 @@
export enum AzureConnectionMethod {
OAuth = "oauth"
}
export enum AzureResources {
KeyVault = "key-vault",
AppConfiguration = "app-configuration"
}
@@ -0,0 +1,182 @@
import { AxiosError, AxiosResponse } from "axios";
import { getConfig } from "@app/lib/config/env";
import { request } from "@app/lib/config/request";
import { BadRequestError, InternalServerError, NotFoundError } from "@app/lib/errors";
import {
decryptAppConnectionCredentials,
encryptAppConnectionCredentials,
getAppConnectionMethodName
} from "@app/services/app-connection/app-connection-fns";
import { IntegrationUrls } from "@app/services/integration-auth/integration-list";
import { TKmsServiceFactory } from "@app/services/kms/kms-service";
import { TAppConnectionDALFactory } from "../app-connection-dal";
import { AppConnection } from "../app-connection-enums";
import { AzureConnectionMethod, AzureResources } from "./azure-connection-enums";
import { TAzureConnectionConfig, TAzureConnectionCredentials } from "./azure-connection-types";
const resourceScopes: Record<AzureResources, string> = {
[AzureResources.AppConfiguration]: "https://azconfig.io/.default",
[AzureResources.KeyVault]: "https://vault.azure.net/.default"
};
export const getAzureConnectionAccessToken = async (
connectionId: string,
appConnectionDAL: Pick<TAppConnectionDALFactory, "findById" | "update">,
kmsService: Pick<TKmsServiceFactory, "createCipherPairWithDataKey">
) => {
const appCfg = getConfig();
const appConnection = await appConnectionDAL.findById(connectionId);
if (!appConnection) {
throw new NotFoundError({ message: `Connection with ID '${connectionId}' not found` });
}
if (appConnection.app !== AppConnection.Azure) {
throw new BadRequestError({ message: `Connection with ID '${connectionId}' is not an Azure connection` });
}
const credentials = (await decryptAppConnectionCredentials({
orgId: appConnection.orgId,
kmsService,
encryptedCredentials: appConnection.encryptedCredentials
})) as TAzureConnectionCredentials;
const { data } = await request.post<ExchangeCodeAzureResponse>(
IntegrationUrls.AZURE_TOKEN_URL.replace("common", credentials.tenantId || "common"),
new URLSearchParams({
grant_type: "refresh_token",
scope: `openid offline_access`,
client_id: appCfg.CLIENT_ID_AZURE!,
client_secret: appCfg.CLIENT_SECRET_AZURE!,
refresh_token: credentials.refreshToken
})
);
const accessExpiresAt = new Date();
accessExpiresAt.setSeconds(accessExpiresAt.getSeconds() + data.expires_in);
const updatedCredentials = {
...credentials,
accessToken: data.access_token,
expiresAt: accessExpiresAt.getTime(),
refreshToken: data.refresh_token
};
const encryptedCredentials = await encryptAppConnectionCredentials({
credentials: updatedCredentials,
orgId: appConnection.orgId,
kmsService
});
await appConnectionDAL.update(
{ id: connectionId },
{
encryptedCredentials
}
);
return {
accessToken: data.access_token
};
};
export const getAzureConnectionListItem = () => {
const { CLIENT_ID_AZURE } = getConfig();
return {
name: "Azure" as const,
app: AppConnection.Azure as const,
methods: Object.values(AzureConnectionMethod) as [AzureConnectionMethod.OAuth],
oauthClientId: CLIENT_ID_AZURE
};
};
type ExchangeCodeAzureResponse = {
token_type: string;
scope: string;
expires_in: number;
ext_expires_in: number;
access_token: string;
refresh_token: string;
id_token: string;
};
export const validateAzureConnectionCredentials = async (config: TAzureConnectionConfig) => {
const { credentials: inputCredentials, method } = config;
const { CLIENT_ID_AZURE, CLIENT_SECRET_AZURE } = getConfig();
if (!CLIENT_ID_AZURE || !CLIENT_SECRET_AZURE) {
throw new InternalServerError({
message: `Azure ${getAppConnectionMethodName(method)} environment variables have not been configured`
});
}
let tokenResp: AxiosResponse<ExchangeCodeAzureResponse> | null = null;
let tokenError: AxiosError | null = null;
try {
const appCfg = getConfig();
if (!appCfg.CLIENT_ID_AZURE || !appCfg.CLIENT_SECRET_AZURE) {
throw new BadRequestError({ message: "Missing client id and client secret" });
}
tokenResp = await request.post<ExchangeCodeAzureResponse>(
IntegrationUrls.AZURE_TOKEN_URL.replace("common", inputCredentials.tenantId || "common"),
new URLSearchParams({
grant_type: "authorization_code",
code: inputCredentials.code,
scope: `openid offline_access ${resourceScopes[inputCredentials.resource]}`,
client_id: appCfg.CLIENT_ID_AZURE,
client_secret: appCfg.CLIENT_SECRET_AZURE,
redirect_uri: `${appCfg.SITE_URL}/organization/app-connections/azure/oauth/callback`
})
);
// TODO(daniel): handle token refreshing
} catch (e: unknown) {
if (e instanceof AxiosError) {
tokenError = e;
} else {
throw new BadRequestError({
message: `Unable to validate connection - verify credentials`
});
}
}
if (tokenError) {
if (tokenError instanceof AxiosError) {
throw new BadRequestError({
message: `Failed to get access token: ${
(tokenError?.response?.data as { error_description?: string })?.error_description || "Unknown error"
}`
});
} else {
throw new InternalServerError({
message: "Failed to get access token"
});
}
}
if (!tokenResp) {
throw new InternalServerError({
message: `Failed to get access token: Token was empty with no error`
});
}
switch (method) {
case AzureConnectionMethod.OAuth:
return {
accessToken: tokenResp.data.access_token,
refreshToken: tokenResp.data.refresh_token,
expiresAt: Date.now() + tokenResp.data.expires_in * 1000,
resource: inputCredentials.resource
};
default:
throw new InternalServerError({
message: `Unhandled Azure connection method: ${method as AzureConnectionMethod}`
});
}
};
@@ -0,0 +1,74 @@
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 { AzureConnectionMethod, AzureResources } from "./azure-connection-enums";
export const AzureConnectionOAuthInputCredentialsSchema = z.object({
code: z.string().trim().min(1, "OAuth code required"),
tenantId: z.string().trim().optional(),
resource: z.nativeEnum(AzureResources)
});
export const AzureConnectionOAuthOutputCredentialsSchema = z.object({
tenantId: z.string().optional(),
accessToken: z.string(),
refreshToken: z.string(),
expiresAt: z.number(), // unix timestamp,
resource: z.nativeEnum(AzureResources)
});
export const ValidateAzureConnectionCredentialsSchema = z.discriminatedUnion("method", [
z.object({
method: z.literal(AzureConnectionMethod.OAuth).describe(AppConnections.CREATE(AppConnection.Azure).method),
credentials: AzureConnectionOAuthInputCredentialsSchema.describe(
AppConnections.CREATE(AppConnection.Azure).credentials
)
})
]);
export const CreateAzureConnectionSchema = ValidateAzureConnectionCredentialsSchema.and(
GenericCreateAppConnectionFieldsSchema(AppConnection.Azure)
);
export const UpdateAzureConnectionSchema = z
.object({
credentials: AzureConnectionOAuthInputCredentialsSchema.optional().describe(
AppConnections.UPDATE(AppConnection.Azure).credentials
)
})
.and(GenericUpdateAppConnectionFieldsSchema(AppConnection.Azure));
const BaseAzureConnectionSchema = BaseAppConnectionSchema.extend({ app: z.literal(AppConnection.Azure) });
export const AzureConnectionSchema = z.intersection(
BaseAzureConnectionSchema,
z.discriminatedUnion("method", [
z.object({
method: z.literal(AzureConnectionMethod.OAuth),
credentials: AzureConnectionOAuthOutputCredentialsSchema
})
])
);
export const SanitizedAzureConnectionSchema = z.discriminatedUnion("method", [
BaseAzureConnectionSchema.extend({
method: z.literal(AzureConnectionMethod.OAuth),
credentials: AzureConnectionOAuthOutputCredentialsSchema.pick({
resource: true
})
})
]);
export const AzureConnectionListItemSchema = z.object({
name: z.literal("Azure"),
app: z.literal(AppConnection.Azure),
methods: z.nativeEnum(AzureConnectionMethod).array(),
oauthClientId: z.string().optional()
});
@@ -0,0 +1,35 @@
import z from "zod";
import { DiscriminativePick } from "@app/lib/types";
import { AppConnection } from "../app-connection-enums";
import {
AzureConnectionOAuthOutputCredentialsSchema,
AzureConnectionSchema,
CreateAzureConnectionSchema,
ValidateAzureConnectionCredentialsSchema
} from "./azure-connection-schemas";
export type TAzureConnection = z.infer<typeof AzureConnectionSchema>;
export type TAzureConnectionInput = z.infer<typeof CreateAzureConnectionSchema> & {
app: AppConnection.Azure;
};
export type TValidateAzureConnectionCredentials = typeof ValidateAzureConnectionCredentialsSchema;
export type TAzureConnectionConfig = DiscriminativePick<TAzureConnectionInput, "method" | "app" | "credentials"> & {
orgId: string;
};
export type ExchangeCodeAzureResponse = {
token_type: string;
scope: string;
expires_in: number;
ext_expires_in: number;
access_token: string;
refresh_token: string;
id_token: string;
};
export type TAzureConnectionCredentials = z.infer<typeof AzureConnectionOAuthOutputCredentialsSchema>;
@@ -0,0 +1,4 @@
export * from "./azure-connection-enums";
export * from "./azure-connection-fns";
export * from "./azure-connection-schemas";
export * from "./azure-connection-types";