feat: Add dynamic secrets for Azure Entra ID

This commit is contained in:
Meet
2024-09-17 10:29:19 +05:30
parent a6f4a95821
commit b3f0d36ddc
14 changed files with 664 additions and 10 deletions
@@ -3,7 +3,10 @@ import ms from "ms";
import { z } from "zod";
import { DynamicSecretLeasesSchema } from "@app/db/schemas";
import { DynamicSecretProviderSchema } from "@app/ee/services/dynamic-secret/providers/models";
import {
DynamicSecretDataFetchTypes,
DynamicSecretProviderSchema
} from "@app/ee/services/dynamic-secret/providers/models";
import { DYNAMIC_SECRETS } from "@app/lib/api-docs";
import { daysToMillisecond } from "@app/lib/dates";
import { removeTrailingSlash } from "@app/lib/fn";
@@ -77,6 +80,32 @@ export const registerDynamicSecretRouter = async (server: FastifyZodProvider) =>
}
});
server.route({
method: "POST",
url: "/fetch-provider-data",
config: {
rateLimit: readLimit
},
schema: {
body: z.object({
dataFetchType: z.string().min(1).describe("Type of data to fetch"),
provider: DynamicSecretProviderSchema.describe(DYNAMIC_SECRETS.CREATE.provider)
}),
response: {
200: z.object({
data: z.unknown()
})
}
},
onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]),
handler: async (req) => {
const dataFetchType =
DynamicSecretDataFetchTypes[req.body.dataFetchType as keyof typeof DynamicSecretDataFetchTypes];
const data = await server.services.dynamicSecret.fetchData({ provider: req.body.provider, dataFetchType });
return data;
}
});
server.route({
method: "PATCH",
url: "/:name",
@@ -17,6 +17,7 @@ import {
TCreateDynamicSecretDTO,
TDeleteDynamicSecretDTO,
TDetailsDynamicSecretDTO,
TDynamicSecretsFetchDataDTO,
TListDynamicSecretsDTO,
TUpdateDynamicSecretDTO
} from "./dynamic-secret-types";
@@ -332,11 +333,20 @@ export const dynamicSecretServiceFactory = ({
return dynamicSecretCfg;
};
const fetchData = async ({ provider, dataFetchType }: TDynamicSecretsFetchDataDTO) => {
const selectedProvider = dynamicSecretProviders[provider.type];
if (selectedProvider.fetchData) {
const data = selectedProvider.fetchData(provider.inputs, dataFetchType);
return data;
}
};
return {
create,
updateByName,
deleteByName,
getDetails,
list
list,
fetchData
};
};
@@ -2,7 +2,7 @@ import { z } from "zod";
import { TProjectPermission } from "@app/lib/types";
import { DynamicSecretProviderSchema } from "./providers/models";
import { DynamicSecretDataFetchTypes, DynamicSecretProviderSchema } from "./providers/models";
// various status for dynamic secret that happens in background
export enum DynamicSecretStatus {
@@ -52,3 +52,8 @@ export type TListDynamicSecretsDTO = {
environmentSlug: string;
projectSlug: string;
} & Omit<TProjectPermission, "projectId">;
export type TDynamicSecretsFetchDataDTO = {
provider: TProvider;
dataFetchType: DynamicSecretDataFetchTypes;
};
@@ -0,0 +1,140 @@
import axios from "axios";
import { customAlphabet } from "nanoid";
import { getConfig } from "@app/lib/config/env";
import { BadRequestError } from "@app/lib/errors";
import { logger } from "@app/lib/logger";
import { AzureEntraIDSchema, DynamicSecretDataFetchTypes, TDynamicProviderFns } from "./models";
const MSFT_GRAPH_API_URL = "https://graph.microsoft.com/v1.0/";
const MSFT_LOGIN_URL = "https://login.microsoftonline.com";
const generatePassword = () => {
const charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_.~!*$#";
return customAlphabet(charset, 64)();
};
export const AzureEntraIDProvider = (): TDynamicProviderFns => {
const validateProviderInputs = async (inputs: unknown) => {
const providerInputs = await AzureEntraIDSchema.parseAsync(inputs);
return providerInputs;
};
const getToken = async (tenantId: string): Promise<{ token?: string; success: boolean }> => {
const appCfg = getConfig();
const response = await axios.post<{ access_token: string }>(
`${MSFT_LOGIN_URL}/${tenantId}/oauth2/v2.0/token`,
{
grant_type: "client_credentials",
client_id: appCfg.MSFT_ENTRA_ID_APPLICATION_ID,
client_secret: appCfg.MSFT_ENTRA_ID_CLIENT_SECRET,
scope: "https://graph.microsoft.com/.default"
},
{
headers: {
"Content-Type": "application/x-www-form-urlencoded"
}
}
);
if (response.status === 200) {
return { token: response.data.access_token, success: true };
}
return { success: false };
};
const validateConnection = async (inputs: unknown) => {
const providerInputs = await validateProviderInputs(inputs);
const data = await getToken(providerInputs.tenantId);
return data.success;
};
const renew = async (inputs: unknown, entityId: string) => {
// Do nothing
return { entityId };
};
const create = async (inputs: unknown) => {
const providerInputs = await validateProviderInputs(inputs);
const data = await getToken(providerInputs.tenantId);
if (!data.success) {
throw new BadRequestError({ message: "Failed to authorize to Microsoft Entra ID" });
}
const password = generatePassword();
try {
const response = await axios.patch(
`${MSFT_GRAPH_API_URL}/users/${providerInputs.userId}`,
{
passwordProfile: {
forceChangePasswordNextSignIn: false,
password
}
},
{
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${data.token}`
}
}
);
if (response.status !== 204) {
throw new BadRequestError({ message: "Failed to update password" });
}
} catch (error) {
logger.info(JSON.stringify(error));
}
return { entityId: providerInputs.userId, data: { userId: providerInputs.userId, password } };
};
const revoke = async (inputs: unknown, entityId: string) => {
await create(inputs);
return { entityId };
};
const fetchData = async (inputs: unknown, toFetch: DynamicSecretDataFetchTypes) => {
const providerInputs = await validateProviderInputs(inputs);
const data = await getToken(providerInputs.tenantId);
if (!data.success) {
throw new BadRequestError({ message: "Failed to authorize to Microsoft Entra ID" });
}
switch (toFetch) {
case DynamicSecretDataFetchTypes.Users: {
const response = await axios.get<{ value: [{ displayName: string; id: string }] }>(
`${MSFT_GRAPH_API_URL}/users`,
{
headers: {
"Content-Type": "application/x-www-form-urlencoded",
Authorization: `Bearer ${data.token}`
}
}
);
const users = response.data.value.map((user: { displayName: string; id: string }) => {
return {
name: user.displayName,
id: user.id
};
});
return {
data: {
users
}
};
}
default:
throw new BadRequestError({ message: "Unknown data to fetch" });
}
};
return {
validateProviderInputs,
validateConnection,
create,
revoke,
renew,
fetchData
};
};
@@ -1,5 +1,6 @@
import { AwsElastiCacheDatabaseProvider } from "./aws-elasticache";
import { AwsIamProvider } from "./aws-iam";
import { AzureEntraIDProvider } from "./azure-entra-id";
import { CassandraProvider } from "./cassandra";
import { ElasticSearchProvider } from "./elastic-search";
import { DynamicSecretProviders } from "./models";
@@ -18,5 +19,6 @@ export const buildDynamicSecretProviders = () => ({
[DynamicSecretProviders.MongoAtlas]: MongoAtlasProvider(),
[DynamicSecretProviders.MongoDB]: MongoDBProvider(),
[DynamicSecretProviders.ElasticSearch]: ElasticSearchProvider(),
[DynamicSecretProviders.RabbitMq]: RabbitMqProvider()
[DynamicSecretProviders.RabbitMq]: RabbitMqProvider(),
[DynamicSecretProviders.AzureEntraID]: AzureEntraIDProvider()
});
@@ -166,6 +166,11 @@ export const DynamicSecretMongoDBSchema = z.object({
)
});
export const AzureEntraIDSchema = z.object({
tenantId: z.string().trim().min(1),
userId: z.string().trim().min(1)
});
export enum DynamicSecretProviders {
SqlDatabase = "sql-database",
Cassandra = "cassandra",
@@ -175,7 +180,12 @@ export enum DynamicSecretProviders {
MongoAtlas = "mongo-db-atlas",
ElasticSearch = "elastic-search",
MongoDB = "mongo-db",
RabbitMq = "rabbit-mq"
RabbitMq = "rabbit-mq",
AzureEntraID = "azure-entra-id"
}
export enum DynamicSecretDataFetchTypes {
Users = "users"
}
export const DynamicSecretProviderSchema = z.discriminatedUnion("type", [
@@ -187,7 +197,8 @@ export const DynamicSecretProviderSchema = z.discriminatedUnion("type", [
z.object({ type: z.literal(DynamicSecretProviders.MongoAtlas), inputs: DynamicSecretMongoAtlasSchema }),
z.object({ type: z.literal(DynamicSecretProviders.ElasticSearch), inputs: DynamicSecretElasticSearchSchema }),
z.object({ type: z.literal(DynamicSecretProviders.MongoDB), inputs: DynamicSecretMongoDBSchema }),
z.object({ type: z.literal(DynamicSecretProviders.RabbitMq), inputs: DynamicSecretRabbitMqSchema })
z.object({ type: z.literal(DynamicSecretProviders.RabbitMq), inputs: DynamicSecretRabbitMqSchema }),
z.object({ type: z.literal(DynamicSecretProviders.AzureEntraID), inputs: AzureEntraIDSchema })
]);
export type TDynamicProviderFns = {
@@ -196,4 +207,5 @@ export type TDynamicProviderFns = {
validateProviderInputs: (inputs: object) => Promise<unknown>;
revoke: (inputs: unknown, entityId: string) => Promise<{ entityId: string }>;
renew: (inputs: unknown, entityId: string, expireAt: number) => Promise<{ entityId: string }>;
fetchData?: (inputs: unknown, toFetch: DynamicSecretDataFetchTypes) => Promise<{ data: unknown }>;
};
+3
View File
@@ -134,6 +134,9 @@ const envSchema = z
LICENSE_SERVER_KEY: zpStr(z.string().optional()),
LICENSE_KEY: zpStr(z.string().optional()),
LICENSE_KEY_OFFLINE: zpStr(z.string().optional()),
// MICROSOFT ENTRA ID APP
MSFT_ENTRA_ID_APPLICATION_ID: zpStr(z.string().optional()),
MSFT_ENTRA_ID_CLIENT_SECRET: zpStr(z.string().optional()),
// GENERIC
STANDALONE_MODE: z