From 93d7c812e7fdb97f01d516341af6eb3c210ffdca Mon Sep 17 00:00:00 2001 From: = Date: Wed, 28 May 2025 16:08:26 +0530 Subject: [PATCH] feat: backend changes for dynamic secret --- ...140639_dynamic-secret-username-template.ts | 21 +++++ backend/src/db/schemas/dynamic-secrets.ts | 3 +- .../src/ee/routes/v1/dynamic-secret-router.ts | 82 +++++++++++-------- .../dynamic-secret-lease-service.ts | 6 +- .../dynamic-secret/dynamic-secret-service.ts | 12 ++- .../dynamic-secret/dynamic-secret-types.ts | 2 + .../providers/aws-elasticache.ts | 15 +++- .../dynamic-secret/providers/aws-iam.ts | 17 +++- .../providers/azure-entra-id.ts | 4 +- .../dynamic-secret/providers/cassandra.ts | 15 +++- .../providers/elastic-search.ts | 16 +++- .../services/dynamic-secret/providers/ldap.ts | 15 +++- .../dynamic-secret/providers/models.ts | 6 +- .../dynamic-secret/providers/mongo-atlas.ts | 16 +++- .../dynamic-secret/providers/mongo-db.ts | 16 +++- .../dynamic-secret/providers/rabbit-mq.ts | 16 +++- .../dynamic-secret/providers/redis.ts | 15 +++- .../dynamic-secret/providers/sap-ase.ts | 17 ++-- .../dynamic-secret/providers/sap-hana.ts | 15 +++- .../dynamic-secret/providers/snowflake.ts | 15 +++- .../dynamic-secret/providers/sql-database.ts | 25 ++++-- .../src/lib/template/validate-handlebars.ts | 12 +++ backend/src/server/routes/sanitizedSchemas.ts | 8 +- 23 files changed, 265 insertions(+), 104 deletions(-) create mode 100644 backend/src/db/migrations/20250527140639_dynamic-secret-username-template.ts diff --git a/backend/src/db/migrations/20250527140639_dynamic-secret-username-template.ts b/backend/src/db/migrations/20250527140639_dynamic-secret-username-template.ts new file mode 100644 index 000000000..2ff493c6f --- /dev/null +++ b/backend/src/db/migrations/20250527140639_dynamic-secret-username-template.ts @@ -0,0 +1,21 @@ +import { Knex } from "knex"; + +import { TableName } from "../schemas"; + +export async function up(knex: Knex): Promise { + const hasColumn = await knex.schema.hasColumn(TableName.DynamicSecret, "usernameTemplate"); + if (!hasColumn) { + await knex.schema.alterTable(TableName.DynamicSecret, (t) => { + t.string("usernameTemplate").nullable(); + }); + } +} + +export async function down(knex: Knex): Promise { + const hasColumn = await knex.schema.hasColumn(TableName.DynamicSecret, "usernameTemplate"); + if (hasColumn) { + await knex.schema.alterTable(TableName.DynamicSecret, (t) => { + t.dropColumn("usernameTemplate"); + }); + } +} diff --git a/backend/src/db/schemas/dynamic-secrets.ts b/backend/src/db/schemas/dynamic-secrets.ts index 350a32b7a..637d0c632 100644 --- a/backend/src/db/schemas/dynamic-secrets.ts +++ b/backend/src/db/schemas/dynamic-secrets.ts @@ -28,7 +28,8 @@ export const DynamicSecretsSchema = z.object({ updatedAt: z.date(), encryptedInput: zodBuffer, projectGatewayId: z.string().uuid().nullable().optional(), - gatewayId: z.string().uuid().nullable().optional() + gatewayId: z.string().uuid().nullable().optional(), + usernameTemplate: z.string().nullable().optional() }); export type TDynamicSecrets = z.infer; diff --git a/backend/src/ee/routes/v1/dynamic-secret-router.ts b/backend/src/ee/routes/v1/dynamic-secret-router.ts index 6e70effe4..486f92980 100644 --- a/backend/src/ee/routes/v1/dynamic-secret-router.ts +++ b/backend/src/ee/routes/v1/dynamic-secret-router.ts @@ -6,6 +6,7 @@ import { ApiDocsTags, DYNAMIC_SECRETS } from "@app/lib/api-docs"; import { daysToMillisecond } from "@app/lib/dates"; import { removeTrailingSlash } from "@app/lib/fn"; import { ms } from "@app/lib/ms"; +import { isValidHandleBarTemplate } from "@app/lib/template/validate-handlebars"; import { readLimit, writeLimit } from "@app/server/config/rateLimiter"; import { slugSchema } from "@app/server/lib/schemas"; import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; @@ -13,6 +14,15 @@ import { SanitizedDynamicSecretSchema } from "@app/server/routes/sanitizedSchema import { AuthMode } from "@app/services/auth/auth-type"; import { ResourceMetadataSchema } from "@app/services/resource-metadata/resource-metadata-schema"; +const userTemplateSchema = z + .string() + .trim() + .refine((el) => + isValidHandleBarTemplate(el, { + allowedExpressions: (val) => ["randomUsername", "unixTimestamp"].includes(val) + }) + ); + export const registerDynamicSecretRouter = async (server: FastifyZodProvider) => { server.route({ method: "POST", @@ -52,7 +62,8 @@ export const registerDynamicSecretRouter = async (server: FastifyZodProvider) => path: z.string().describe(DYNAMIC_SECRETS.CREATE.path).trim().default("/").transform(removeTrailingSlash), environmentSlug: z.string().describe(DYNAMIC_SECRETS.CREATE.environmentSlug).min(1), name: slugSchema({ min: 1, max: 64, field: "Name" }).describe(DYNAMIC_SECRETS.CREATE.name), - metadata: ResourceMetadataSchema.optional() + metadata: ResourceMetadataSchema.optional(), + usernameTemplate: userTemplateSchema.optional() }), response: { 200: z.object({ @@ -73,39 +84,6 @@ export const registerDynamicSecretRouter = async (server: FastifyZodProvider) => } }); - server.route({ - method: "POST", - url: "/entra-id/users", - config: { - rateLimit: readLimit - }, - schema: { - body: z.object({ - tenantId: z.string().min(1).describe("The tenant ID of the Azure Entra ID"), - applicationId: z.string().min(1).describe("The application ID of the Azure Entra ID App Registration"), - clientSecret: z.string().min(1).describe("The client secret of the Azure Entra ID App Registration") - }), - response: { - 200: z - .object({ - name: z.string().min(1).describe("The name of the user"), - id: z.string().min(1).describe("The ID of the user"), - email: z.string().min(1).describe("The email of the user") - }) - .array() - } - }, - onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), - handler: async (req) => { - const data = await server.services.dynamicSecret.fetchAzureEntraIdUsers({ - tenantId: req.body.tenantId, - applicationId: req.body.applicationId, - clientSecret: req.body.clientSecret - }); - return data; - } - }); - server.route({ method: "PATCH", url: "/:name", @@ -150,7 +128,8 @@ export const registerDynamicSecretRouter = async (server: FastifyZodProvider) => }) .nullable(), newName: z.string().describe(DYNAMIC_SECRETS.UPDATE.newName).optional(), - metadata: ResourceMetadataSchema.optional() + metadata: ResourceMetadataSchema.optional(), + usernameTemplate: userTemplateSchema.nullable().optional() }) }), response: { @@ -328,4 +307,37 @@ export const registerDynamicSecretRouter = async (server: FastifyZodProvider) => return { leases }; } }); + + server.route({ + method: "POST", + url: "/entra-id/users", + config: { + rateLimit: readLimit + }, + schema: { + body: z.object({ + tenantId: z.string().min(1).describe("The tenant ID of the Azure Entra ID"), + applicationId: z.string().min(1).describe("The application ID of the Azure Entra ID App Registration"), + clientSecret: z.string().min(1).describe("The client secret of the Azure Entra ID App Registration") + }), + response: { + 200: z + .object({ + name: z.string().min(1).describe("The name of the user"), + id: z.string().min(1).describe("The ID of the user"), + email: z.string().min(1).describe("The email of the user") + }) + .array() + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const data = await server.services.dynamicSecret.fetchAzureEntraIdUsers({ + tenantId: req.body.tenantId, + applicationId: req.body.applicationId, + clientSecret: req.body.clientSecret + }); + return data; + } + }); }; diff --git a/backend/src/ee/services/dynamic-secret-lease/dynamic-secret-lease-service.ts b/backend/src/ee/services/dynamic-secret-lease/dynamic-secret-lease-service.ts index 4adf8b7e2..f3f3f3acd 100644 --- a/backend/src/ee/services/dynamic-secret-lease/dynamic-secret-lease-service.ts +++ b/backend/src/ee/services/dynamic-secret-lease/dynamic-secret-lease-service.ts @@ -132,7 +132,11 @@ export const dynamicSecretLeaseServiceFactory = ({ let result; try { - result = await selectedProvider.create(decryptedStoredInput, expireAt.getTime()); + result = await selectedProvider.create({ + inputs: decryptedStoredInput, + expireAt: expireAt.getTime(), + usernameTemplate: dynamicSecretCfg.usernameTemplate + }); } catch (error: unknown) { if (error && typeof error === "object" && error !== null && "sqlMessage" in error) { throw new BadRequestError({ message: error.sqlMessage as string }); diff --git a/backend/src/ee/services/dynamic-secret/dynamic-secret-service.ts b/backend/src/ee/services/dynamic-secret/dynamic-secret-service.ts index c39f07b5c..16ac10716 100644 --- a/backend/src/ee/services/dynamic-secret/dynamic-secret-service.ts +++ b/backend/src/ee/services/dynamic-secret/dynamic-secret-service.ts @@ -78,7 +78,8 @@ export const dynamicSecretServiceFactory = ({ actorOrgId, defaultTTL, actorAuthMethod, - metadata + metadata, + usernameTemplate }: TCreateDynamicSecretDTO) => { const project = await projectDAL.findProjectBySlug(projectSlug, actorOrgId); if (!project) throw new NotFoundError({ message: `Project with slug '${projectSlug}' not found` }); @@ -163,7 +164,8 @@ export const dynamicSecretServiceFactory = ({ defaultTTL, folderId: folder.id, name, - gatewayId: selectedGatewayId + gatewayId: selectedGatewayId, + usernameTemplate }, tx ); @@ -199,7 +201,8 @@ export const dynamicSecretServiceFactory = ({ newName, actorOrgId, actorAuthMethod, - metadata + metadata, + usernameTemplate }: TUpdateDynamicSecretDTO) => { const project = await projectDAL.findProjectBySlug(projectSlug, actorOrgId); if (!project) throw new NotFoundError({ message: `Project with slug '${projectSlug}' not found` }); @@ -311,7 +314,8 @@ export const dynamicSecretServiceFactory = ({ defaultTTL, name: newName ?? name, status: null, - gatewayId: selectedGatewayId + gatewayId: selectedGatewayId, + usernameTemplate }, tx ); diff --git a/backend/src/ee/services/dynamic-secret/dynamic-secret-types.ts b/backend/src/ee/services/dynamic-secret/dynamic-secret-types.ts index 58fdc2143..6720cf2c8 100644 --- a/backend/src/ee/services/dynamic-secret/dynamic-secret-types.ts +++ b/backend/src/ee/services/dynamic-secret/dynamic-secret-types.ts @@ -22,6 +22,7 @@ export type TCreateDynamicSecretDTO = { name: string; projectSlug: string; metadata?: ResourceMetadataDTO; + usernameTemplate?: string | null; } & Omit; export type TUpdateDynamicSecretDTO = { @@ -34,6 +35,7 @@ export type TUpdateDynamicSecretDTO = { inputs?: TProvider["inputs"]; projectSlug: string; metadata?: ResourceMetadataDTO; + usernameTemplate?: string | null; } & Omit; export type TDeleteDynamicSecretDTO = { diff --git a/backend/src/ee/services/dynamic-secret/providers/aws-elasticache.ts b/backend/src/ee/services/dynamic-secret/providers/aws-elasticache.ts index f2907f7dc..5e6279a13 100644 --- a/backend/src/ee/services/dynamic-secret/providers/aws-elasticache.ts +++ b/backend/src/ee/services/dynamic-secret/providers/aws-elasticache.ts @@ -132,9 +132,15 @@ const generatePassword = () => { return customAlphabet(charset, 64)(); }; -const generateUsername = () => { +const generateUsername = (usernameTemplate?: string | null) => { const charset = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-"; - return `inf-${customAlphabet(charset, 32)()}`; // Username must start with an ascii letter, so we prepend the username with "inf-" + const randomUsername = `inf-${customAlphabet(charset, 32)()}`; // Username must start with an ascii letter, so we prepend the username with "inf-" + if (!usernameTemplate) return randomUsername; + + return handlebars.compile(usernameTemplate)({ + randomUsername, + unixTimestamp: Math.floor(Date.now() / 100) + }); }; export const AwsElastiCacheDatabaseProvider = (): TDynamicProviderFns => { @@ -168,13 +174,14 @@ export const AwsElastiCacheDatabaseProvider = (): TDynamicProviderFns => { return true; }; - const create = async (inputs: unknown, expireAt: number) => { + const create = async (data: { inputs: unknown; expireAt: number; usernameTemplate?: string | null }) => { + const { inputs, expireAt, usernameTemplate } = data; const providerInputs = await validateProviderInputs(inputs); if (!(await validateConnection(providerInputs))) { throw new BadRequestError({ message: "Failed to establish connection" }); } - const leaseUsername = generateUsername(); + const leaseUsername = generateUsername(usernameTemplate); const leasePassword = generatePassword(); const leaseExpiration = new Date(expireAt).toISOString(); diff --git a/backend/src/ee/services/dynamic-secret/providers/aws-iam.ts b/backend/src/ee/services/dynamic-secret/providers/aws-iam.ts index 64ea6a02e..9d8e10f60 100644 --- a/backend/src/ee/services/dynamic-secret/providers/aws-iam.ts +++ b/backend/src/ee/services/dynamic-secret/providers/aws-iam.ts @@ -16,6 +16,7 @@ import { PutUserPolicyCommand, RemoveUserFromGroupCommand } from "@aws-sdk/client-iam"; +import handlebars from "handlebars"; import { z } from "zod"; import { BadRequestError } from "@app/lib/errors"; @@ -23,8 +24,14 @@ import { alphaNumericNanoId } from "@app/lib/nanoid"; import { DynamicSecretAwsIamSchema, TDynamicProviderFns } from "./models"; -const generateUsername = () => { - return alphaNumericNanoId(32); +const generateUsername = (usernameTemplate?: string | null) => { + const randomUsername = alphaNumericNanoId(32); + if (!usernameTemplate) return randomUsername; + + return handlebars.compile(usernameTemplate)({ + randomUsername, + unixTimestamp: Math.floor(Date.now() / 100) + }); }; export const AwsIamProvider = (): TDynamicProviderFns => { @@ -53,11 +60,13 @@ export const AwsIamProvider = (): TDynamicProviderFns => { return isConnected; }; - const create = async (inputs: unknown) => { + const create = async (data: { inputs: unknown; expireAt: number; usernameTemplate?: string | null }) => { + const { inputs, usernameTemplate } = data; + const providerInputs = await validateProviderInputs(inputs); const client = await $getClient(providerInputs); - const username = generateUsername(); + const username = generateUsername(usernameTemplate); const { policyArns, userGroups, policyDocument, awsPath, permissionBoundaryPolicyArn } = providerInputs; const createUserRes = await client.send( new CreateUserCommand({ diff --git a/backend/src/ee/services/dynamic-secret/providers/azure-entra-id.ts b/backend/src/ee/services/dynamic-secret/providers/azure-entra-id.ts index 17f644601..4b2232bc8 100644 --- a/backend/src/ee/services/dynamic-secret/providers/azure-entra-id.ts +++ b/backend/src/ee/services/dynamic-secret/providers/azure-entra-id.ts @@ -55,7 +55,7 @@ export const AzureEntraIDProvider = (): TDynamicProviderFns & { return data.success; }; - const create = async (inputs: unknown) => { + const create = async ({ inputs }: { inputs: unknown }) => { const providerInputs = await validateProviderInputs(inputs); const data = await $getToken(providerInputs.tenantId, providerInputs.applicationId, providerInputs.clientSecret); if (!data.success) { @@ -88,7 +88,7 @@ export const AzureEntraIDProvider = (): TDynamicProviderFns & { const revoke = async (inputs: unknown, entityId: string) => { // Creates a new password - await create(inputs); + await create({ inputs }); return { entityId }; }; diff --git a/backend/src/ee/services/dynamic-secret/providers/cassandra.ts b/backend/src/ee/services/dynamic-secret/providers/cassandra.ts index 0b6d50146..fce23b56f 100644 --- a/backend/src/ee/services/dynamic-secret/providers/cassandra.ts +++ b/backend/src/ee/services/dynamic-secret/providers/cassandra.ts @@ -14,8 +14,14 @@ const generatePassword = (size = 48) => { return customAlphabet(charset, 48)(size); }; -const generateUsername = () => { - return alphaNumericNanoId(32); +const generateUsername = (usernameTemplate?: string | null) => { + const randomUsername = alphaNumericNanoId(32); // Username must start with an ascii letter, so we prepend the username with "inf-" + if (!usernameTemplate) return randomUsername; + + return handlebars.compile(usernameTemplate)({ + randomUsername, + unixTimestamp: Math.floor(Date.now() / 100) + }); }; export const CassandraProvider = (): TDynamicProviderFns => { @@ -69,11 +75,12 @@ export const CassandraProvider = (): TDynamicProviderFns => { return isConnected; }; - const create = async (inputs: unknown, expireAt: number) => { + const create = async (data: { inputs: unknown; expireAt: number; usernameTemplate?: string | null }) => { + const { inputs, expireAt, usernameTemplate } = data; const providerInputs = await validateProviderInputs(inputs); const client = await $getClient(providerInputs); - const username = generateUsername(); + const username = generateUsername(usernameTemplate); const password = generatePassword(); const { keyspace } = providerInputs; const expiration = new Date(expireAt).toISOString(); diff --git a/backend/src/ee/services/dynamic-secret/providers/elastic-search.ts b/backend/src/ee/services/dynamic-secret/providers/elastic-search.ts index 6c1affa39..066822827 100644 --- a/backend/src/ee/services/dynamic-secret/providers/elastic-search.ts +++ b/backend/src/ee/services/dynamic-secret/providers/elastic-search.ts @@ -1,4 +1,5 @@ import { Client as ElasticSearchClient } from "@elastic/elasticsearch"; +import handlebars from "handlebars"; import { customAlphabet } from "nanoid"; import { z } from "zod"; @@ -12,8 +13,14 @@ const generatePassword = () => { return customAlphabet(charset, 64)(); }; -const generateUsername = () => { - return alphaNumericNanoId(32); +const generateUsername = (usernameTemplate?: string | null) => { + const randomUsername = alphaNumericNanoId(32); // Username must start with an ascii letter, so we prepend the username with "inf-" + if (!usernameTemplate) return randomUsername; + + return handlebars.compile(usernameTemplate)({ + randomUsername, + unixTimestamp: Math.floor(Date.now() / 100) + }); }; export const ElasticSearchProvider = (): TDynamicProviderFns => { @@ -64,11 +71,12 @@ export const ElasticSearchProvider = (): TDynamicProviderFns => { return infoResponse; }; - const create = async (inputs: unknown) => { + const create = async (data: { inputs: unknown; usernameTemplate?: string | null }) => { + const { inputs, usernameTemplate } = data; const providerInputs = await validateProviderInputs(inputs); const connection = await $getClient(providerInputs); - const username = generateUsername(); + const username = generateUsername(usernameTemplate); const password = generatePassword(); await connection.security.putUser({ diff --git a/backend/src/ee/services/dynamic-secret/providers/ldap.ts b/backend/src/ee/services/dynamic-secret/providers/ldap.ts index cc68304e0..d0e3fbe66 100644 --- a/backend/src/ee/services/dynamic-secret/providers/ldap.ts +++ b/backend/src/ee/services/dynamic-secret/providers/ldap.ts @@ -22,8 +22,14 @@ const encodePassword = (password?: string) => { return base64Password; }; -const generateUsername = () => { - return alphaNumericNanoId(20); +const generateUsername = (usernameTemplate?: string | null) => { + const randomUsername = alphaNumericNanoId(32); // Username must start with an ascii letter, so we prepend the username with "inf-" + if (!usernameTemplate) return randomUsername; + + return handlebars.compile(usernameTemplate)({ + randomUsername, + unixTimestamp: Math.floor(Date.now() / 100) + }); }; const generateLDIF = ({ @@ -190,7 +196,8 @@ export const LdapProvider = (): TDynamicProviderFns => { return dnArray; }; - const create = async (inputs: unknown) => { + const create = async (data: { inputs: unknown; usernameTemplate?: string | null }) => { + const { inputs, usernameTemplate } = data; const providerInputs = await validateProviderInputs(inputs); const client = await $getClient(providerInputs); @@ -217,7 +224,7 @@ export const LdapProvider = (): TDynamicProviderFns => { }); } } else { - const username = generateUsername(); + const username = generateUsername(usernameTemplate); const password = generatePassword(); const generatedLdif = generateLDIF({ username, password, ldifTemplate: providerInputs.creationLdif }); diff --git a/backend/src/ee/services/dynamic-secret/providers/models.ts b/backend/src/ee/services/dynamic-secret/providers/models.ts index 0c6eaf151..ad688dfe7 100644 --- a/backend/src/ee/services/dynamic-secret/providers/models.ts +++ b/backend/src/ee/services/dynamic-secret/providers/models.ts @@ -342,7 +342,11 @@ export const DynamicSecretProviderSchema = z.discriminatedUnion("type", [ ]); export type TDynamicProviderFns = { - create: (inputs: unknown, expireAt: number) => Promise<{ entityId: string; data: unknown }>; + create: (arg: { + inputs: unknown; + expireAt: number; + usernameTemplate?: string | null; + }) => Promise<{ entityId: string; data: unknown }>; validateConnection: (inputs: unknown) => Promise; validateProviderInputs: (inputs: object) => Promise; revoke: (inputs: unknown, entityId: string) => Promise<{ entityId: string }>; diff --git a/backend/src/ee/services/dynamic-secret/providers/mongo-atlas.ts b/backend/src/ee/services/dynamic-secret/providers/mongo-atlas.ts index 6cb414d10..eee6558ba 100644 --- a/backend/src/ee/services/dynamic-secret/providers/mongo-atlas.ts +++ b/backend/src/ee/services/dynamic-secret/providers/mongo-atlas.ts @@ -1,4 +1,5 @@ import axios, { AxiosError } from "axios"; +import handlebars from "handlebars"; import { customAlphabet } from "nanoid"; import { z } from "zod"; @@ -12,8 +13,14 @@ const generatePassword = (size = 48) => { return customAlphabet(charset, 48)(size); }; -const generateUsername = () => { - return alphaNumericNanoId(32); +const generateUsername = (usernameTemplate?: string | null) => { + const randomUsername = alphaNumericNanoId(32); // Username must start with an ascii letter, so we prepend the username with "inf-" + if (!usernameTemplate) return randomUsername; + + return handlebars.compile(usernameTemplate)({ + randomUsername, + unixTimestamp: Math.floor(Date.now() / 100) + }); }; export const MongoAtlasProvider = (): TDynamicProviderFns => { @@ -57,11 +64,12 @@ export const MongoAtlasProvider = (): TDynamicProviderFns => { return isConnected; }; - const create = async (inputs: unknown, expireAt: number) => { + const create = async (data: { inputs: unknown; expireAt: number; usernameTemplate?: string | null }) => { + const { inputs, expireAt, usernameTemplate } = data; const providerInputs = await validateProviderInputs(inputs); const client = await $getClient(providerInputs); - const username = generateUsername(); + const username = generateUsername(usernameTemplate); const password = generatePassword(); const expiration = new Date(expireAt).toISOString(); await client({ diff --git a/backend/src/ee/services/dynamic-secret/providers/mongo-db.ts b/backend/src/ee/services/dynamic-secret/providers/mongo-db.ts index bee29bfc4..2af7dcf4e 100644 --- a/backend/src/ee/services/dynamic-secret/providers/mongo-db.ts +++ b/backend/src/ee/services/dynamic-secret/providers/mongo-db.ts @@ -1,3 +1,4 @@ +import handlebars from "handlebars"; import { MongoClient } from "mongodb"; import { customAlphabet } from "nanoid"; import { z } from "zod"; @@ -12,8 +13,14 @@ const generatePassword = (size = 48) => { return customAlphabet(charset, 48)(size); }; -const generateUsername = () => { - return alphaNumericNanoId(32); +const generateUsername = (usernameTemplate?: string | null) => { + const randomUsername = alphaNumericNanoId(32); // Username must start with an ascii letter, so we prepend the username with "inf-" + if (!usernameTemplate) return randomUsername; + + return handlebars.compile(usernameTemplate)({ + randomUsername, + unixTimestamp: Math.floor(Date.now() / 100) + }); }; export const MongoDBProvider = (): TDynamicProviderFns => { @@ -53,11 +60,12 @@ export const MongoDBProvider = (): TDynamicProviderFns => { return isConnected; }; - const create = async (inputs: unknown) => { + const create = async (data: { inputs: unknown; usernameTemplate?: string | null }) => { + const { inputs, usernameTemplate } = data; const providerInputs = await validateProviderInputs(inputs); const client = await $getClient(providerInputs); - const username = generateUsername(); + const username = generateUsername(usernameTemplate); const password = generatePassword(); const db = client.db(providerInputs.database); diff --git a/backend/src/ee/services/dynamic-secret/providers/rabbit-mq.ts b/backend/src/ee/services/dynamic-secret/providers/rabbit-mq.ts index f6c73ba54..e7d90d272 100644 --- a/backend/src/ee/services/dynamic-secret/providers/rabbit-mq.ts +++ b/backend/src/ee/services/dynamic-secret/providers/rabbit-mq.ts @@ -1,4 +1,5 @@ import axios, { Axios } from "axios"; +import handlebars from "handlebars"; import https from "https"; import { customAlphabet } from "nanoid"; import { z } from "zod"; @@ -14,8 +15,14 @@ const generatePassword = () => { return customAlphabet(charset, 64)(); }; -const generateUsername = () => { - return alphaNumericNanoId(32); +const generateUsername = (usernameTemplate?: string | null) => { + const randomUsername = alphaNumericNanoId(32); // Username must start with an ascii letter, so we prepend the username with "inf-" + if (!usernameTemplate) return randomUsername; + + return handlebars.compile(usernameTemplate)({ + randomUsername, + unixTimestamp: Math.floor(Date.now() / 100) + }); }; type TCreateRabbitMQUser = { @@ -110,11 +117,12 @@ export const RabbitMqProvider = (): TDynamicProviderFns => { return infoResponse; }; - const create = async (inputs: unknown) => { + const create = async (data: { inputs: unknown; usernameTemplate?: string | null }) => { + const { inputs, usernameTemplate } = data; const providerInputs = await validateProviderInputs(inputs); const connection = await $getClient(providerInputs); - const username = generateUsername(); + const username = generateUsername(usernameTemplate); const password = generatePassword(); await createRabbitMqUser({ diff --git a/backend/src/ee/services/dynamic-secret/providers/redis.ts b/backend/src/ee/services/dynamic-secret/providers/redis.ts index f180dd607..855af2e29 100644 --- a/backend/src/ee/services/dynamic-secret/providers/redis.ts +++ b/backend/src/ee/services/dynamic-secret/providers/redis.ts @@ -15,8 +15,14 @@ const generatePassword = () => { return customAlphabet(charset, 64)(); }; -const generateUsername = () => { - return alphaNumericNanoId(32); +const generateUsername = (usernameTemplate?: string | null) => { + const randomUsername = alphaNumericNanoId(32); // Username must start with an ascii letter, so we prepend the username with "inf-" + if (!usernameTemplate) return randomUsername; + + return handlebars.compile(usernameTemplate)({ + randomUsername, + unixTimestamp: Math.floor(Date.now() / 100) + }); }; const executeTransactions = async (connection: Redis, commands: string[]): Promise<(string | null)[] | null> => { @@ -115,11 +121,12 @@ export const RedisDatabaseProvider = (): TDynamicProviderFns => { return pingResponse; }; - const create = async (inputs: unknown, expireAt: number) => { + const create = async (data: { inputs: unknown; expireAt: number; usernameTemplate?: string | null }) => { + const { inputs, expireAt, usernameTemplate } = data; const providerInputs = await validateProviderInputs(inputs); const connection = await $getClient(providerInputs); - const username = generateUsername(); + const username = generateUsername(usernameTemplate); const password = generatePassword(); const expiration = new Date(expireAt).toISOString(); diff --git a/backend/src/ee/services/dynamic-secret/providers/sap-ase.ts b/backend/src/ee/services/dynamic-secret/providers/sap-ase.ts index c832e9867..af2431058 100644 --- a/backend/src/ee/services/dynamic-secret/providers/sap-ase.ts +++ b/backend/src/ee/services/dynamic-secret/providers/sap-ase.ts @@ -15,8 +15,14 @@ const generatePassword = (size = 48) => { return customAlphabet(charset, 48)(size); }; -const generateUsername = () => { - return alphaNumericNanoId(25); +const generateUsername = (usernameTemplate?: string | null) => { + const randomUsername = `inf_${alphaNumericNanoId(25)}`; // Username must start with an ascii letter, so we prepend the username with "inf-" + if (!usernameTemplate) return randomUsername; + + return handlebars.compile(usernameTemplate)({ + randomUsername, + unixTimestamp: Math.floor(Date.now() / 100) + }); }; enum SapCommands { @@ -81,11 +87,12 @@ export const SapAseProvider = (): TDynamicProviderFns => { return true; }; - const create = async (inputs: unknown) => { + const create = async (data: { inputs: unknown; usernameTemplate?: string | null }) => { + const { inputs, usernameTemplate } = data; const providerInputs = await validateProviderInputs(inputs); - const username = `inf_${generateUsername()}`; - const password = `${generatePassword()}`; + const username = generateUsername(usernameTemplate); + const password = generatePassword(); const client = await $getClient(providerInputs); const masterClient = await $getClient(providerInputs, true); diff --git a/backend/src/ee/services/dynamic-secret/providers/sap-hana.ts b/backend/src/ee/services/dynamic-secret/providers/sap-hana.ts index 1ad24473c..654e2d144 100644 --- a/backend/src/ee/services/dynamic-secret/providers/sap-hana.ts +++ b/backend/src/ee/services/dynamic-secret/providers/sap-hana.ts @@ -21,8 +21,14 @@ const generatePassword = (size = 48) => { return customAlphabet(charset, 48)(size); }; -const generateUsername = () => { - return alphaNumericNanoId(32); +const generateUsername = (usernameTemplate?: string | null) => { + const randomUsername = alphaNumericNanoId(32); // Username must start with an ascii letter, so we prepend the username with "inf-" + if (!usernameTemplate) return randomUsername; + + return handlebars.compile(usernameTemplate)({ + randomUsername, + unixTimestamp: Math.floor(Date.now() / 100) + }); }; export const SapHanaProvider = (): TDynamicProviderFns => { @@ -91,10 +97,11 @@ export const SapHanaProvider = (): TDynamicProviderFns => { return testResult; }; - const create = async (inputs: unknown, expireAt: number) => { + const create = async (data: { inputs: unknown; expireAt: number; usernameTemplate?: string | null }) => { + const { inputs, expireAt, usernameTemplate } = data; const providerInputs = await validateProviderInputs(inputs); - const username = generateUsername(); + const username = generateUsername(usernameTemplate); const password = generatePassword(); const expiration = new Date(expireAt).toISOString(); diff --git a/backend/src/ee/services/dynamic-secret/providers/snowflake.ts b/backend/src/ee/services/dynamic-secret/providers/snowflake.ts index bea7eca89..571d488c9 100644 --- a/backend/src/ee/services/dynamic-secret/providers/snowflake.ts +++ b/backend/src/ee/services/dynamic-secret/providers/snowflake.ts @@ -17,8 +17,14 @@ const generatePassword = (size = 48) => { return customAlphabet(charset, 48)(size); }; -const generateUsername = () => { - return `infisical_${alphaNumericNanoId(32)}`; // username must start with alpha character, hence prefix +const generateUsername = (usernameTemplate?: string | null) => { + const randomUsername = `infisical_${alphaNumericNanoId(32)}`; // Username must start with an ascii letter, so we prepend the username with "inf-" + if (!usernameTemplate) return randomUsername; + + return handlebars.compile(usernameTemplate)({ + randomUsername, + unixTimestamp: Math.floor(Date.now() / 100) + }); }; const getDaysToExpiry = (expiryDate: Date) => { @@ -82,12 +88,13 @@ export const SnowflakeProvider = (): TDynamicProviderFns => { return isValidConnection; }; - const create = async (inputs: unknown, expireAt: number) => { + const create = async (data: { inputs: unknown; expireAt: number; usernameTemplate?: string | null }) => { + const { inputs, expireAt, usernameTemplate } = data; const providerInputs = await validateProviderInputs(inputs); const client = await $getClient(providerInputs); - const username = generateUsername(); + const username = generateUsername(usernameTemplate); const password = generatePassword(); try { diff --git a/backend/src/ee/services/dynamic-secret/providers/sql-database.ts b/backend/src/ee/services/dynamic-secret/providers/sql-database.ts index 3ae85ed7b..ce16a1237 100644 --- a/backend/src/ee/services/dynamic-secret/providers/sql-database.ts +++ b/backend/src/ee/services/dynamic-secret/providers/sql-database.ts @@ -104,11 +104,21 @@ const generatePassword = (provider: SqlProviders, requirements?: PasswordRequire } }; -const generateUsername = (provider: SqlProviders) => { - // For oracle, the client assumes everything is upper case when not using quotes around the password - if (provider === SqlProviders.Oracle) return alphaNumericNanoId(32).toUpperCase(); +const generateUsername = (provider: SqlProviders, usernameTemplate?: string | null) => { + let randomUsername = ""; - return alphaNumericNanoId(32); + // For oracle, the client assumes everything is upper case when not using quotes around the password + if (provider === SqlProviders.Oracle) { + randomUsername = alphaNumericNanoId(32).toUpperCase(); + } else { + randomUsername = alphaNumericNanoId(32); + } + if (!usernameTemplate) return randomUsername; + + return handlebars.compile(usernameTemplate)({ + randomUsername, + unixTimestamp: Math.floor(Date.now() / 100) + }); }; type TSqlDatabaseProviderDTO = { @@ -210,9 +220,12 @@ export const SqlDatabaseProvider = ({ gatewayService }: TSqlDatabaseProviderDTO) return isConnected; }; - const create = async (inputs: unknown, expireAt: number) => { + const create = async (data: { inputs: unknown; expireAt: number; usernameTemplate?: string | null }) => { + const { inputs, expireAt, usernameTemplate } = data; + const providerInputs = await validateProviderInputs(inputs); - const username = generateUsername(providerInputs.client); + const username = generateUsername(providerInputs.client, usernameTemplate); + const password = generatePassword(providerInputs.client, providerInputs.passwordRequirements); const gatewayCallback = async (host = providerInputs.host, port = providerInputs.port) => { const db = await $getClient({ ...providerInputs, port, host }); diff --git a/backend/src/lib/template/validate-handlebars.ts b/backend/src/lib/template/validate-handlebars.ts index a83c9efc2..08343e962 100644 --- a/backend/src/lib/template/validate-handlebars.ts +++ b/backend/src/lib/template/validate-handlebars.ts @@ -19,3 +19,15 @@ export const validateHandlebarTemplate = (templateName: string, template: string throw new BadRequestError({ message: `Template sanitization failed: ${templateName}` }); }); }; + +export const isValidHandleBarTemplate = (template: string, dto: SanitizationArg) => { + const parsedAst = handlebars.parse(template); + return parsedAst.body.every((el) => { + if (el.type === "ContentStatement") return true; + if (el.type === "MustacheStatement" && "path" in el) { + const { path } = el as { type: "MustacheStatement"; path: { type: "PathExpression"; original: string } }; + if (path.type === "PathExpression" && dto?.allowedExpressions?.(path.original)) return true; + } + return false; + }); +}; diff --git a/backend/src/server/routes/sanitizedSchemas.ts b/backend/src/server/routes/sanitizedSchemas.ts index 209044434..a26293ac8 100644 --- a/backend/src/server/routes/sanitizedSchemas.ts +++ b/backend/src/server/routes/sanitizedSchemas.ts @@ -235,11 +235,9 @@ export const SanitizedDynamicSecretSchema = DynamicSecretsSchema.omit({ inputIV: true, inputTag: true, algorithm: true -}).merge( - z.object({ - metadata: ResourceMetadataSchema.optional() - }) -); +}).extend({ + metadata: ResourceMetadataSchema.optional() +}); export const SanitizedAuditLogStreamSchema = z.object({ id: z.string(),