mirror of
https://github.com/awatertrevi/infisical.git
synced 2026-10-09 20:28:31 +00:00
feat: completed mongo atlas dynamic secret backend logic
This commit is contained in:
@@ -3,6 +3,7 @@ import { AwsIamProvider } from "./aws-iam";
|
|||||||
import { CassandraProvider } from "./cassandra";
|
import { CassandraProvider } from "./cassandra";
|
||||||
import { DynamicSecretProviders } from "./models";
|
import { DynamicSecretProviders } from "./models";
|
||||||
import { RedisDatabaseProvider } from "./redis";
|
import { RedisDatabaseProvider } from "./redis";
|
||||||
|
import { MongoAtlasProvider } from "./mongo-atlas";
|
||||||
import { SqlDatabaseProvider } from "./sql-database";
|
import { SqlDatabaseProvider } from "./sql-database";
|
||||||
|
|
||||||
export const buildDynamicSecretProviders = () => ({
|
export const buildDynamicSecretProviders = () => ({
|
||||||
@@ -10,5 +11,6 @@ export const buildDynamicSecretProviders = () => ({
|
|||||||
[DynamicSecretProviders.Cassandra]: CassandraProvider(),
|
[DynamicSecretProviders.Cassandra]: CassandraProvider(),
|
||||||
[DynamicSecretProviders.AwsIam]: AwsIamProvider(),
|
[DynamicSecretProviders.AwsIam]: AwsIamProvider(),
|
||||||
[DynamicSecretProviders.Redis]: RedisDatabaseProvider(),
|
[DynamicSecretProviders.Redis]: RedisDatabaseProvider(),
|
||||||
[DynamicSecretProviders.AwsElastiCache]: AwsElastiCacheDatabaseProvider()
|
[DynamicSecretProviders.AwsElastiCache]: AwsElastiCacheDatabaseProvider(),
|
||||||
|
[DynamicSecretProviders.MongoAtlas]: MongoAtlasProvider()
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -67,12 +67,50 @@ export const DynamicSecretAwsIamSchema = z.object({
|
|||||||
policyArns: z.string().trim().optional()
|
policyArns: z.string().trim().optional()
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const DynamicSecretMongoAtlasSchema = z.object({
|
||||||
|
adminPublicKey: z.string().trim().min(1).describe("Admin user public api key"),
|
||||||
|
adminPrivateKey: z.string().trim().min(1).describe("Admin user private api key"),
|
||||||
|
groupId: z
|
||||||
|
.string()
|
||||||
|
.trim()
|
||||||
|
.min(1)
|
||||||
|
.describe("Unique 24-hexadecimal digit string that identifies your project. This is same as project id"),
|
||||||
|
roles: z
|
||||||
|
.object({
|
||||||
|
collectionName: z.string().optional().describe("Collection on which this role applies."),
|
||||||
|
databaseName: z.string().min(1).describe("Database to which the user is granted access privileges."),
|
||||||
|
roleName: z
|
||||||
|
.string()
|
||||||
|
.min(1)
|
||||||
|
.describe(
|
||||||
|
' Enum: "atlasAdmin" "backup" "clusterMonitor" "dbAdmin" "dbAdminAnyDatabase" "enableSharding" "read" "readAnyDatabase" "readWrite" "readWriteAnyDatabase" "<a custom role name>".Human-readable label that identifies a group of privileges assigned to a database user. This value can either be a built-in role or a custom role.'
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.array()
|
||||||
|
.min(1),
|
||||||
|
scopes: z
|
||||||
|
.object({
|
||||||
|
name: z
|
||||||
|
.string()
|
||||||
|
.min(1)
|
||||||
|
.describe(
|
||||||
|
"Human-readable label that identifies the cluster or MongoDB Atlas Data Lake that this database user can access."
|
||||||
|
),
|
||||||
|
type: z
|
||||||
|
.string()
|
||||||
|
.min(1)
|
||||||
|
.describe("Category of resource that this database user can access. Enum: CLUSTER, DATA_LAKE, STREAM")
|
||||||
|
})
|
||||||
|
.array()
|
||||||
|
});
|
||||||
|
|
||||||
export enum DynamicSecretProviders {
|
export enum DynamicSecretProviders {
|
||||||
SqlDatabase = "sql-database",
|
SqlDatabase = "sql-database",
|
||||||
Cassandra = "cassandra",
|
Cassandra = "cassandra",
|
||||||
AwsIam = "aws-iam",
|
AwsIam = "aws-iam",
|
||||||
Redis = "redis",
|
Redis = "redis",
|
||||||
AwsElastiCache = "aws-elasticache"
|
AwsElastiCache = "aws-elasticache",
|
||||||
|
MongoAtlas = "mongo-db-atlas"
|
||||||
}
|
}
|
||||||
|
|
||||||
export const DynamicSecretProviderSchema = z.discriminatedUnion("type", [
|
export const DynamicSecretProviderSchema = z.discriminatedUnion("type", [
|
||||||
@@ -80,7 +118,8 @@ export const DynamicSecretProviderSchema = z.discriminatedUnion("type", [
|
|||||||
z.object({ type: z.literal(DynamicSecretProviders.Cassandra), inputs: DynamicSecretCassandraSchema }),
|
z.object({ type: z.literal(DynamicSecretProviders.Cassandra), inputs: DynamicSecretCassandraSchema }),
|
||||||
z.object({ type: z.literal(DynamicSecretProviders.AwsIam), inputs: DynamicSecretAwsIamSchema }),
|
z.object({ type: z.literal(DynamicSecretProviders.AwsIam), inputs: DynamicSecretAwsIamSchema }),
|
||||||
z.object({ type: z.literal(DynamicSecretProviders.Redis), inputs: DynamicSecretRedisDBSchema }),
|
z.object({ type: z.literal(DynamicSecretProviders.Redis), inputs: DynamicSecretRedisDBSchema }),
|
||||||
z.object({ type: z.literal(DynamicSecretProviders.AwsElastiCache), inputs: DynamicSecretAwsElastiCacheSchema })
|
z.object({ type: z.literal(DynamicSecretProviders.AwsElastiCache), inputs: DynamicSecretAwsElastiCacheSchema }),
|
||||||
|
z.object({ type: z.literal(DynamicSecretProviders.MongoAtlas), inputs: DynamicSecretMongoAtlasSchema })
|
||||||
]);
|
]);
|
||||||
|
|
||||||
export type TDynamicProviderFns = {
|
export type TDynamicProviderFns = {
|
||||||
|
|||||||
@@ -0,0 +1,146 @@
|
|||||||
|
import axios, { AxiosError } from "axios";
|
||||||
|
import { customAlphabet } from "nanoid";
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
import { createDigestAuthRequestInterceptor } from "@app/lib/axios/digest-auth";
|
||||||
|
import { alphaNumericNanoId } from "@app/lib/nanoid";
|
||||||
|
|
||||||
|
import { DynamicSecretMongoAtlasSchema, TDynamicProviderFns } from "./models";
|
||||||
|
|
||||||
|
const generatePassword = (size = 48) => {
|
||||||
|
const charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_.~!*$#";
|
||||||
|
return customAlphabet(charset, 48)(size);
|
||||||
|
};
|
||||||
|
|
||||||
|
const generateUsername = () => {
|
||||||
|
return alphaNumericNanoId(32);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const MongoAtlasProvider = (): TDynamicProviderFns => {
|
||||||
|
const validateProviderInputs = async (inputs: unknown) => {
|
||||||
|
const providerInputs = await DynamicSecretMongoAtlasSchema.parseAsync(inputs);
|
||||||
|
return providerInputs;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getClient = async (providerInputs: z.infer<typeof DynamicSecretMongoAtlasSchema>) => {
|
||||||
|
const client = axios.create({
|
||||||
|
baseURL: "https://cloud.mongodb.com/api/atlas",
|
||||||
|
headers: {
|
||||||
|
Accept: "application/vnd.atlas.2023-02-01+json",
|
||||||
|
"Content-Type": "application/json"
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const digestAuth = createDigestAuthRequestInterceptor(
|
||||||
|
client,
|
||||||
|
providerInputs.adminPublicKey,
|
||||||
|
providerInputs.adminPrivateKey
|
||||||
|
);
|
||||||
|
return digestAuth;
|
||||||
|
};
|
||||||
|
|
||||||
|
const validateConnection = async (inputs: unknown) => {
|
||||||
|
const providerInputs = await validateProviderInputs(inputs);
|
||||||
|
const client = await getClient(providerInputs);
|
||||||
|
|
||||||
|
const isConnected = await client({
|
||||||
|
method: "GET",
|
||||||
|
url: `v2/groups/${providerInputs.groupId}/databaseUsers`,
|
||||||
|
params: { itemsPerPage: 1 }
|
||||||
|
})
|
||||||
|
.then(() => true)
|
||||||
|
.catch((error) => {
|
||||||
|
if ((error as AxiosError).response) {
|
||||||
|
throw new Error(JSON.stringify((error as AxiosError).response?.data));
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
});
|
||||||
|
return isConnected;
|
||||||
|
};
|
||||||
|
|
||||||
|
const create = async (inputs: unknown, expireAt: number) => {
|
||||||
|
const providerInputs = await validateProviderInputs(inputs);
|
||||||
|
const client = await getClient(providerInputs);
|
||||||
|
|
||||||
|
const username = generateUsername();
|
||||||
|
const password = generatePassword();
|
||||||
|
const expiration = new Date(expireAt).toISOString();
|
||||||
|
await client({
|
||||||
|
method: "POST",
|
||||||
|
url: `/v2/groups/${providerInputs.groupId}/databaseUsers`,
|
||||||
|
data: {
|
||||||
|
roles: providerInputs.roles,
|
||||||
|
scopes: providerInputs.scopes,
|
||||||
|
deleteAfterDate: expiration,
|
||||||
|
username,
|
||||||
|
password,
|
||||||
|
databaseName: "admin",
|
||||||
|
groupId: providerInputs.groupId
|
||||||
|
}
|
||||||
|
}).catch((error) => {
|
||||||
|
if ((error as AxiosError).response) {
|
||||||
|
throw new Error(JSON.stringify((error as AxiosError).response?.data));
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
});
|
||||||
|
return { entityId: username, data: { DB_USERNAME: username, DB_PASSWORD: password } };
|
||||||
|
};
|
||||||
|
|
||||||
|
const revoke = async (inputs: unknown, entityId: string) => {
|
||||||
|
const providerInputs = await validateProviderInputs(inputs);
|
||||||
|
const client = await getClient(providerInputs);
|
||||||
|
|
||||||
|
const username = entityId;
|
||||||
|
const isExisting = await client({
|
||||||
|
method: "GET",
|
||||||
|
url: `/v2/groups/${providerInputs.groupId}/databaseUsers/admin/${username}`
|
||||||
|
}).catch((err) => {
|
||||||
|
if ((err as AxiosError).response?.status === 404) return false;
|
||||||
|
throw err;
|
||||||
|
});
|
||||||
|
if (isExisting) {
|
||||||
|
await client({
|
||||||
|
method: "DELETE",
|
||||||
|
url: `/v2/groups/${providerInputs.groupId}/databaseUsers/admin/${username}`
|
||||||
|
}).catch((error) => {
|
||||||
|
if ((error as AxiosError).response) {
|
||||||
|
throw new Error(JSON.stringify((error as AxiosError).response?.data));
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return { entityId: username };
|
||||||
|
};
|
||||||
|
|
||||||
|
const renew = async (inputs: unknown, entityId: string, expireAt: number) => {
|
||||||
|
const providerInputs = await validateProviderInputs(inputs);
|
||||||
|
const client = await getClient(providerInputs);
|
||||||
|
|
||||||
|
const username = entityId;
|
||||||
|
const expiration = new Date(expireAt).toISOString();
|
||||||
|
|
||||||
|
await client({
|
||||||
|
method: "PATCH",
|
||||||
|
url: `/v2/groups/${providerInputs.groupId}/databaseUsers/admin/${username}`,
|
||||||
|
data: {
|
||||||
|
deleteAfterDate: expiration,
|
||||||
|
databaseName: "admin",
|
||||||
|
groupId: providerInputs.groupId
|
||||||
|
}
|
||||||
|
}).catch((error) => {
|
||||||
|
if ((error as AxiosError).response) {
|
||||||
|
throw new Error(JSON.stringify((error as AxiosError).response?.data));
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
});
|
||||||
|
return { entityId: username };
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
validateProviderInputs,
|
||||||
|
validateConnection,
|
||||||
|
create,
|
||||||
|
revoke,
|
||||||
|
renew
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import crypto from "node:crypto";
|
||||||
|
|
||||||
|
import { AxiosError, AxiosInstance, AxiosRequestConfig } from "axios";
|
||||||
|
|
||||||
|
export const createDigestAuthRequestInterceptor = (
|
||||||
|
axiosInstance: AxiosInstance,
|
||||||
|
username: string,
|
||||||
|
password: string
|
||||||
|
) => {
|
||||||
|
let nc = 0;
|
||||||
|
|
||||||
|
return async (opts: AxiosRequestConfig) => {
|
||||||
|
try {
|
||||||
|
return await axiosInstance.request(opts);
|
||||||
|
} catch (err) {
|
||||||
|
const error = err as AxiosError;
|
||||||
|
const authHeader = (error?.response?.headers?.["www-authenticate"] as string) || "";
|
||||||
|
|
||||||
|
if (error?.response?.status !== 401 || !authHeader?.includes("nonce")) {
|
||||||
|
return Promise.reject(error.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!error.config) {
|
||||||
|
return Promise.reject(error);
|
||||||
|
}
|
||||||
|
|
||||||
|
const authDetails = authHeader.split(",").map((el) => el.split("="));
|
||||||
|
nc += 1;
|
||||||
|
const nonceCount = nc.toString(16).padStart(8, "0");
|
||||||
|
const cnonce = crypto.randomBytes(24).toString("hex");
|
||||||
|
const realm = authDetails.find((el) => el[0].toLowerCase().indexOf("realm") > -1)?.[1].replace(/"/g, "");
|
||||||
|
const nonce = authDetails.find((el) => el[0].toLowerCase().indexOf("nonce") > -1)?.[1].replace(/"/g, "");
|
||||||
|
const ha1 = crypto.createHash("md5").update(`${username}:${realm}:${password}`).digest("hex");
|
||||||
|
const path = opts.url;
|
||||||
|
|
||||||
|
const ha2 = crypto
|
||||||
|
.createHash("md5")
|
||||||
|
.update(`${opts.method ?? "GET"}:${path}`)
|
||||||
|
.digest("hex");
|
||||||
|
|
||||||
|
const response = crypto
|
||||||
|
.createHash("md5")
|
||||||
|
.update(`${ha1}:${nonce}:${nonceCount}:${cnonce}:auth:${ha2}`)
|
||||||
|
.digest("hex");
|
||||||
|
const authorization = `Digest username="${username}",realm="${realm}",nonce="${nonce}",uri="${path}",qop="auth",algorithm="MD5",response="${response}",nc="${nonceCount}",cnonce="${cnonce}"`;
|
||||||
|
|
||||||
|
if (opts.headers) {
|
||||||
|
// eslint-disable-next-line
|
||||||
|
opts.headers.authorization = authorization;
|
||||||
|
} else {
|
||||||
|
// eslint-disable-next-line
|
||||||
|
opts.headers = { authorization };
|
||||||
|
}
|
||||||
|
return axiosInstance.request(opts);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user