diff --git a/backend/src/ee/services/dynamic-secret/providers/azure-sql-database.ts b/backend/src/ee/services/dynamic-secret/providers/azure-sql-database.ts new file mode 100644 index 000000000..965964883 --- /dev/null +++ b/backend/src/ee/services/dynamic-secret/providers/azure-sql-database.ts @@ -0,0 +1,541 @@ +import handlebars from "handlebars"; +import knex from "knex"; +import RE2 from "re2"; +import { z } from "zod"; + +import { crypto } from "@app/lib/crypto/cryptography"; +import { BadRequestError } from "@app/lib/errors"; +import { sanitizeString } from "@app/lib/fn"; +import { GatewayProxyProtocol, withGatewayProxy } from "@app/lib/gateway"; +import { withGatewayV2Proxy } from "@app/lib/gateway-v2/gateway-v2"; +import { alphaNumericNanoId } from "@app/lib/nanoid"; +import { validateHandlebarTemplate } from "@app/lib/template/validate-handlebars"; + +import { TGatewayServiceFactory } from "../../gateway/gateway-service"; +import { TGatewayV2ServiceFactory } from "../../gateway-v2/gateway-v2-service"; +import { verifyHostInputValidity } from "../dynamic-secret-fns"; +import { DynamicSecretAzureSqlDBSchema, PasswordRequirements, SqlProviders, TDynamicProviderFns } from "./models"; +import { compileUsernameTemplate } from "./templateUtils"; + +const EXTERNAL_REQUEST_TIMEOUT = 10 * 1000; + +const DEFAULT_PASSWORD_REQUIREMENTS = { + length: 48, + required: { + lowercase: 1, + uppercase: 1, + digits: 1, + symbols: 0 + }, + allowedSymbols: "-_.~!*" +}; + +const generatePassword = (requirements?: PasswordRequirements) => { + const finalReqs = requirements || DEFAULT_PASSWORD_REQUIREMENTS; + + try { + const { length, required, allowedSymbols } = finalReqs; + + const chars = { + lowercase: "abcdefghijklmnopqrstuvwxyz", + uppercase: "ABCDEFGHIJKLMNOPQRSTUVWXYZ", + digits: "0123456789", + symbols: allowedSymbols || "-_.~!*" + }; + + const parts: string[] = []; + + if (required.lowercase > 0) { + parts.push( + ...Array(required.lowercase) + .fill(0) + .map(() => chars.lowercase[crypto.randomInt(chars.lowercase.length)]) + ); + } + + if (required.uppercase > 0) { + parts.push( + ...Array(required.uppercase) + .fill(0) + .map(() => chars.uppercase[crypto.randomInt(chars.uppercase.length)]) + ); + } + + if (required.digits > 0) { + parts.push( + ...Array(required.digits) + .fill(0) + .map(() => chars.digits[crypto.randomInt(chars.digits.length)]) + ); + } + + if (required.symbols > 0) { + parts.push( + ...Array(required.symbols) + .fill(0) + .map(() => chars.symbols[crypto.randomInt(chars.symbols.length)]) + ); + } + + const requiredTotal = Object.values(required).reduce((a, b) => a + b, 0); + const remainingLength = Math.max(length - requiredTotal, 0); + + const allowedChars = Object.entries(chars) + .filter(([key]) => required[key as keyof typeof required] > 0) + .map(([, value]) => value) + .join(""); + + parts.push( + ...Array(remainingLength) + .fill(0) + .map(() => allowedChars[crypto.randomInt(allowedChars.length)]) + ); + + // shuffle the array to mix up the characters + for (let i = parts.length - 1; i > 0; i -= 1) { + const j = crypto.randomInt(i + 1); + [parts[i], parts[j]] = [parts[j], parts[i]]; + } + + return parts.join(""); + } catch (error: unknown) { + const message = error instanceof Error ? error.message : "Unknown error"; + throw new Error(`Failed to generate password: ${message}`); + } +}; + +const generateUsername = (usernameTemplate?: string | null, identity?: { name: string }) => { + const randomUsername = alphaNumericNanoId(32); + if (!usernameTemplate) return randomUsername; + return compileUsernameTemplate({ + usernameTemplate, + randomUsername, + identity + }); +}; + +type TAzureSqlDatabaseProviderDTO = { + gatewayService: Pick; + gatewayV2Service: Pick; +}; + +export const AzureSqlDatabaseProvider = ({ + gatewayService, + gatewayV2Service +}: TAzureSqlDatabaseProviderDTO): TDynamicProviderFns => { + const validateProviderInputs = async (inputs: unknown) => { + const providerInputs = await DynamicSecretAzureSqlDBSchema.parseAsync(inputs); + + const [hostIp] = await verifyHostInputValidity(providerInputs.host, Boolean(providerInputs.gatewayId)); + validateHandlebarTemplate("Azure SQL master creation", providerInputs.masterCreationStatement, { + allowedExpressions: (val) => ["username", "password", "expiration", "database"].includes(val) + }); + validateHandlebarTemplate("Azure SQL creation", providerInputs.creationStatement, { + allowedExpressions: (val) => ["username", "password", "expiration", "database"].includes(val) + }); + if (providerInputs.renewStatement) { + validateHandlebarTemplate("Azure SQL renew", providerInputs.renewStatement, { + allowedExpressions: (val) => ["username", "expiration", "database"].includes(val) + }); + } + validateHandlebarTemplate("Azure SQL revoke", providerInputs.revocationStatement, { + allowedExpressions: (val) => ["username", "database"].includes(val) + }); + + return { ...providerInputs, hostIp }; + }; + + const $getClient = async ( + providerInputs: z.infer & { hostIp: string; originalHost: string }, + targetDatabase?: string + ) => { + const ssl = providerInputs.ca + ? { rejectUnauthorized: false, ca: providerInputs.ca, servername: providerInputs.host } + : undefined; + + /* + We route through the gateway by setting connection.host = "localhost". + Azure SQL identifies the logical server from the TDS login name when the host + isn't the Azure FQDN. Therefore, when using the gateway, ensure username is + "user@" so Azure opens the correct logical server. + Direct connections to the Azure FQDN usually don't require this suffix. + */ + const isAzureSql = new RE2(/\.database\.windows\.net$/i).test(providerInputs.originalHost); + const azureServerLabel = + isAzureSql && providerInputs.gatewayId ? providerInputs.originalHost?.split(".")[0] : undefined; + const effectiveUser = + isAzureSql && !providerInputs.username.includes("@") && azureServerLabel + ? `${providerInputs.username}@${azureServerLabel}` + : providerInputs.username; + + const db = knex({ + client: SqlProviders.MsSQL, + connection: { + database: targetDatabase || providerInputs.database, + port: providerInputs.port, + host: providerInputs.host, + user: effectiveUser, + password: providerInputs.password, + ssl, + // @ts-expect-error this is because of knexjs type signature issue. This is directly passed to driver + // https://github.com/knex/knex/blob/b6507a7129d2b9fafebf5f831494431e64c6a8a0/lib/dialects/mssql/index.js#L66 + // https://github.com/tediousjs/tedious/blob/ebb023ed90969a7ec0e4b036533ad52739d921f7/test/config.ci.ts#L19 + options: { + ...(providerInputs.sslEnabled !== undefined ? { encrypt: providerInputs.sslEnabled } : {}), + trustServerCertificate: !providerInputs.ca, + cryptoCredentialsDetails: providerInputs.ca ? { ca: providerInputs.ca } : {} + } + }, + acquireConnectionTimeout: EXTERNAL_REQUEST_TIMEOUT, + pool: { min: 0, max: 7 } + }); + return db; + }; + + const gatewayProxyWrapper = async ( + providerInputs: z.infer, + gatewayCallback: (host: string, port: number) => Promise + ) => { + const gatewayV2ConnectionDetails = await gatewayV2Service.getPlatformConnectionDetailsByGatewayId({ + gatewayId: providerInputs.gatewayId as string, + targetHost: providerInputs.host, + targetPort: providerInputs.port + }); + + if (gatewayV2ConnectionDetails) { + return withGatewayV2Proxy( + async (port) => { + await gatewayCallback("localhost", port); + }, + { + relayHost: gatewayV2ConnectionDetails.relayHost, + gateway: gatewayV2ConnectionDetails.gateway, + relay: gatewayV2ConnectionDetails.relay, + protocol: GatewayProxyProtocol.Tcp + } + ); + } + + const relayDetails = await gatewayService.fnGetGatewayClientTlsByGatewayId(providerInputs.gatewayId as string); + const [relayHost, relayPort] = relayDetails.relayAddress.split(":"); + await withGatewayProxy( + async (port) => { + await gatewayCallback("localhost", port); + }, + { + protocol: GatewayProxyProtocol.Tcp, + targetHost: providerInputs.host, + targetPort: providerInputs.port, + relayHost, + relayPort: Number(relayPort), + identityId: relayDetails.identityId, + orgId: relayDetails.orgId, + tlsOptions: { + ca: relayDetails.certChain, + cert: relayDetails.certificate, + key: relayDetails.privateKey.toString() + } + } + ); + }; + + const validateConnection = async (inputs: unknown) => { + const providerInputs = await validateProviderInputs(inputs); + let isConnected = false; + const gatewayCallback = async (host = providerInputs.host, port = providerInputs.port) => { + const db = await $getClient({ + ...providerInputs, + port, + host, + hostIp: providerInputs.hostIp, + originalHost: providerInputs.host + }); + + try { + isConnected = await db.raw("SELECT 1").then(() => true); + } catch (err) { + const sanitizedErrorMessage = sanitizeString({ + unsanitizedString: (err as Error)?.message, + tokens: [providerInputs.username] + }); + throw new BadRequestError({ + message: `Failed to connect with provider: ${sanitizedErrorMessage}` + }); + } finally { + await db.destroy(); + } + }; + + if (providerInputs.gatewayId) { + await gatewayProxyWrapper(providerInputs, gatewayCallback); + } else { + await gatewayCallback(); + } + return isConnected; + }; + + const create = async (data: { + inputs: unknown; + expireAt: number; + usernameTemplate?: string | null; + identity?: { name: string }; + }) => { + const { inputs, expireAt, usernameTemplate, identity } = data; + + const providerInputs = await validateProviderInputs(inputs); + const { database, masterDatabase } = providerInputs; + const username = generateUsername(usernameTemplate, identity); + const password = generatePassword(providerInputs.passwordRequirements); + + const gatewayCallback = async (host = providerInputs.host, port = providerInputs.port) => { + const expiration = new Date(expireAt).toISOString(); + + const masterDb = await $getClient( + { + ...providerInputs, + port, + host, + originalHost: providerInputs.host + }, + masterDatabase + ); + + try { + const masterCreationStatement = handlebars.compile(providerInputs.masterCreationStatement, { noEscape: true })({ + username, + password, + expiration, + database + }); + + const masterQueries = masterCreationStatement.toString().split(";").filter(Boolean); + await masterDb.transaction(async (tx) => { + for (const query of masterQueries) { + // eslint-disable-next-line + await tx.raw(query); + } + }); + } catch (err) { + const sanitizedErrorMessage = sanitizeString({ + unsanitizedString: (err as Error)?.message, + tokens: [username, password, database] + }); + throw new BadRequestError({ + message: `Failed to create login in master database: ${sanitizedErrorMessage}` + }); + } finally { + await masterDb.destroy(); + } + + const targetDb = await $getClient({ + ...providerInputs, + port, + host, + originalHost: providerInputs.host + }); + + try { + const creationStatement = handlebars.compile(providerInputs.creationStatement, { noEscape: true })({ + username, + password, + expiration, + database + }); + + const queries = creationStatement.toString().split(";").filter(Boolean); + await targetDb.transaction(async (tx) => { + for (const query of queries) { + // eslint-disable-next-line + await tx.raw(query); + } + }); + } catch (err) { + const sanitizedErrorMessage = sanitizeString({ + unsanitizedString: (err as Error)?.message, + tokens: [username, password, database] + }); + throw new BadRequestError({ + message: `Failed to create user in target database: ${sanitizedErrorMessage}` + }); + } finally { + await targetDb.destroy(); + } + }; + + if (providerInputs.gatewayId) { + await gatewayProxyWrapper(providerInputs, gatewayCallback); + } else { + await gatewayCallback(); + } + return { entityId: username, data: { DB_USERNAME: username, DB_PASSWORD: password } }; + }; + + const revoke = async (inputs: unknown, entityId: string) => { + const providerInputs = await validateProviderInputs(inputs); + const username = entityId; + const { database, masterDatabase } = providerInputs; + + const gatewayCallback = async (host = providerInputs.host, port = providerInputs.port) => { + const revokeStatement = handlebars.compile(providerInputs.revocationStatement)({ username, database }); + const queries = revokeStatement.toString().split(";").filter(Boolean); + + const userDropQueries = queries.filter((query) => query.toLowerCase().includes("drop user")); + const loginDropQueries = queries.filter((query) => query.toLowerCase().includes("drop login")); + + if (userDropQueries.length > 0) { + const targetDb = await $getClient({ + ...providerInputs, + port, + host, + originalHost: providerInputs.host + }); + + try { + await targetDb.transaction(async (tx) => { + for (const query of userDropQueries) { + // eslint-disable-next-line + await tx.raw(query.trim()); + } + }); + } catch (err) { + const sanitizedErrorMessage = sanitizeString({ + unsanitizedString: (err as Error)?.message, + tokens: [username, database] + }); + throw new BadRequestError({ + message: `Failed to drop user from target database: ${sanitizedErrorMessage}` + }); + } finally { + await targetDb.destroy(); + } + } + + if (loginDropQueries.length > 0) { + const masterDb = await $getClient( + { + ...providerInputs, + port, + host, + originalHost: providerInputs.host + }, + masterDatabase + ); + + try { + await masterDb.transaction(async (tx) => { + for (const query of loginDropQueries) { + // eslint-disable-next-line + await tx.raw(query.trim()); + } + }); + } catch (err) { + const sanitizedErrorMessage = sanitizeString({ + unsanitizedString: (err as Error)?.message, + tokens: [username, database] + }); + throw new BadRequestError({ + message: `Failed to drop login from master database: ${sanitizedErrorMessage}` + }); + } finally { + await masterDb.destroy(); + } + } + + const otherQueries = queries.filter( + (query) => !query.toLowerCase().includes("drop user") && !query.toLowerCase().includes("drop login") + ); + + if (otherQueries.length > 0) { + const targetDb = await $getClient({ + ...providerInputs, + port, + host, + originalHost: providerInputs.host + }); + + try { + await targetDb.transaction(async (tx) => { + for (const query of otherQueries) { + // eslint-disable-next-line + await tx.raw(query.trim()); + } + }); + } catch (err) { + const sanitizedErrorMessage = sanitizeString({ + unsanitizedString: (err as Error)?.message, + tokens: [username, database] + }); + throw new BadRequestError({ + message: `Failed to execute revocation statement: ${sanitizedErrorMessage}` + }); + } finally { + await targetDb.destroy(); + } + } + }; + + if (providerInputs.gatewayId) { + await gatewayProxyWrapper(providerInputs, gatewayCallback); + } else { + await gatewayCallback(); + } + return { entityId: username }; + }; + + const renew = async (inputs: unknown, entityId: string, expireAt: number) => { + const providerInputs = await validateProviderInputs(inputs); + if (!providerInputs.renewStatement) return { entityId }; + + const gatewayCallback = async (host = providerInputs.host, port = providerInputs.port) => { + const db = await $getClient({ + ...providerInputs, + port, + host, + originalHost: providerInputs.host + }); + const expiration = new Date(expireAt).toISOString(); + const { database } = providerInputs; + + const renewStatement = handlebars.compile(providerInputs.renewStatement)({ + username: entityId, + expiration, + database + }); + try { + if (renewStatement) { + const queries = renewStatement.toString().split(";").filter(Boolean); + await db.transaction(async (tx) => { + for (const query of queries) { + // eslint-disable-next-line + await tx.raw(query); + } + }); + } + } catch (err) { + const sanitizedErrorMessage = sanitizeString({ + unsanitizedString: (err as Error)?.message, + tokens: [database] + }); + throw new BadRequestError({ + message: `Failed to renew lease from provider: ${sanitizedErrorMessage}` + }); + } finally { + await db.destroy(); + } + }; + if (providerInputs.gatewayId) { + await gatewayProxyWrapper(providerInputs, gatewayCallback); + } else { + await gatewayCallback(); + } + return { entityId }; + }; + + return { + validateProviderInputs, + validateConnection, + create, + revoke, + renew + }; +}; diff --git a/backend/src/ee/services/dynamic-secret/providers/index.ts b/backend/src/ee/services/dynamic-secret/providers/index.ts index 3ec0f795e..0259f2d2d 100644 --- a/backend/src/ee/services/dynamic-secret/providers/index.ts +++ b/backend/src/ee/services/dynamic-secret/providers/index.ts @@ -5,6 +5,7 @@ import { TGatewayV2ServiceFactory } from "../../gateway-v2/gateway-v2-service"; import { AwsElastiCacheDatabaseProvider } from "./aws-elasticache"; import { AwsIamProvider } from "./aws-iam"; import { AzureEntraIDProvider } from "./azure-entra-id"; +import { AzureSqlDatabaseProvider } from "./azure-sql-database"; import { CassandraProvider } from "./cassandra"; import { CouchbaseProvider } from "./couchbase"; import { ElasticSearchProvider } from "./elastic-search"; @@ -42,6 +43,7 @@ export const buildDynamicSecretProviders = ({ [DynamicSecretProviders.ElasticSearch]: ElasticSearchProvider(), [DynamicSecretProviders.RabbitMq]: RabbitMqProvider(), [DynamicSecretProviders.AzureEntraID]: AzureEntraIDProvider(), + [DynamicSecretProviders.AzureSqlDatabase]: AzureSqlDatabaseProvider({ gatewayService, gatewayV2Service }), [DynamicSecretProviders.Ldap]: LdapProvider(), [DynamicSecretProviders.SapHana]: SapHanaProvider(), [DynamicSecretProviders.Snowflake]: SnowflakeProvider(), diff --git a/backend/src/ee/services/dynamic-secret/providers/models.ts b/backend/src/ee/services/dynamic-secret/providers/models.ts index 3586fa0d9..8baf178a9 100644 --- a/backend/src/ee/services/dynamic-secret/providers/models.ts +++ b/backend/src/ee/services/dynamic-secret/providers/models.ts @@ -327,6 +327,44 @@ export const AzureEntraIDSchema = z.object({ clientSecret: z.string().trim().min(1) }); +export const DynamicSecretAzureSqlDBSchema = z.object({ + host: z.string().trim().toLowerCase(), + port: z.number(), + database: z.string().trim(), + masterDatabase: z.string().trim().optional().default("master"), + username: z.string().trim(), + password: z.string().trim(), + passwordRequirements: z + .object({ + length: z.number().min(1).max(250), + required: z + .object({ + lowercase: z.number().min(0), + uppercase: z.number().min(0), + digits: z.number().min(0), + symbols: z.number().min(0) + }) + .refine((data) => { + const total = Object.values(data).reduce((sum, count) => sum + count, 0); + return total <= 250; + }, "Sum of required characters cannot exceed 250"), + allowedSymbols: z.string().optional() + }) + .refine((data) => { + const total = Object.values(data.required).reduce((sum, count) => sum + count, 0); + return total <= data.length; + }, "Sum of required characters cannot exceed the total length") + .optional() + .describe("Password generation requirements"), + masterCreationStatement: z.string().trim(), + creationStatement: z.string().trim(), + revocationStatement: z.string().trim(), + renewStatement: z.string().trim().optional(), + ca: z.string().optional(), + sslEnabled: z.boolean().optional(), + gatewayId: z.string().nullable().optional() +}); + export const LdapSchema = z.union([ z.object({ url: z.string().trim().min(1), @@ -610,6 +648,7 @@ export enum DynamicSecretProviders { MongoDB = "mongo-db", RabbitMq = "rabbit-mq", AzureEntraID = "azure-entra-id", + AzureSqlDatabase = "azure-sql-database", Ldap = "ldap", SapHana = "sap-hana", Snowflake = "snowflake", @@ -635,6 +674,7 @@ export const DynamicSecretProviderSchema = z.discriminatedUnion("type", [ z.object({ type: z.literal(DynamicSecretProviders.MongoDB), inputs: DynamicSecretMongoDBSchema }), z.object({ type: z.literal(DynamicSecretProviders.RabbitMq), inputs: DynamicSecretRabbitMqSchema }), z.object({ type: z.literal(DynamicSecretProviders.AzureEntraID), inputs: AzureEntraIDSchema }), + z.object({ type: z.literal(DynamicSecretProviders.AzureSqlDatabase), inputs: DynamicSecretAzureSqlDBSchema }), z.object({ type: z.literal(DynamicSecretProviders.Ldap), inputs: LdapSchema }), z.object({ type: z.literal(DynamicSecretProviders.Snowflake), inputs: DynamicSecretSnowflakeSchema }), z.object({ type: z.literal(DynamicSecretProviders.Totp), inputs: DynamicSecretTotpSchema }), diff --git a/docs/docs.json b/docs/docs.json index b2ce93499..210c5145c 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -455,6 +455,7 @@ "documentation/platform/dynamic-secrets/aws-elasticache", "documentation/platform/dynamic-secrets/aws-iam", "documentation/platform/dynamic-secrets/azure-entra-id", + "documentation/platform/dynamic-secrets/azure-sql-database", "documentation/platform/dynamic-secrets/cassandra", "documentation/platform/dynamic-secrets/couchbase", "documentation/platform/dynamic-secrets/elastic-search", diff --git a/docs/documentation/platform/dynamic-secrets/azure-sql-database.mdx b/docs/documentation/platform/dynamic-secrets/azure-sql-database.mdx new file mode 100644 index 000000000..f92ea7232 --- /dev/null +++ b/docs/documentation/platform/dynamic-secrets/azure-sql-database.mdx @@ -0,0 +1,184 @@ +--- +title: "Azure SQL Database" +description: "Learn how to dynamically generate Azure SQL Database user credentials." +--- + +The Infisical Azure SQL Database dynamic secret allows you to generate Azure SQL Database user credentials on demand based on configured roles. + +## How Azure SQL Database Authentication Works + +Azure SQL Database uses a two-tier authentication system that differs from traditional SQL Server: + +1. **Master Database**: Contains server-level logins that can authenticate to the Azure SQL Database server +2. **User Databases**: Individual databases that contain database users mapped to server logins + +When creating dynamic credentials for Azure SQL Database, Infisical performs a two-step process: +1. **Create Login in Master Database**: Creates a server-level login with the specified password +2. **Create User in Target Database**: Creates a database user mapped to the login and grants the necessary permissions + +This architecture ensures proper security isolation and follows Azure SQL Database best practices. + +## Prerequisite + +Create a user with the required permissions in your Azure SQL Database instance. This user will be used to create new accounts on-demand. + +The user needs: +- `loginmanager` role in the master database (to create logins) +- `db_owner` role in the target database (to create users and grant permissions) + +## Set up Dynamic Secrets with Azure SQL Database + + + + Open the Secret Overview dashboard and select the environment in which you would like to add a dynamic secret. + + + ![Add Dynamic Secret Button](../../../images/platform/dynamic-secrets/add-dynamic-secret-button.png) + + + ![Dynamic Secret Modal](../../../images/platform/dynamic-secrets/azure-sql-database/add-dynamic-secret-button.png) + + + + Name by which you want the secret to be referenced + + + + Default time-to-live for a generated secret (it is possible to modify this value after a secret is generated) + + + + Maximum time-to-live for a generated secret + + + + List of key/value metadata pairs + + + + Azure SQL Database server hostname (e.g., myserver.database.windows.net) + + + + Database port (typically 1433 for Azure SQL Database) + + + + Username that will be used to create dynamic secrets (must have loginmanager role in master and db_owner in target database) + + + + Password that will be used to create dynamic secrets + + + + Name of the target database where users will be created and granted permissions + + + + Enable SSL encryption for the database connection (recommended for Azure SQL Database) + + + + SSL certificate authority certificate. For Azure SQL Database, this is typically not required as Azure manages the certificates. + + + ![Dynamic Secret Setup Modal](../../../images/platform/dynamic-secrets/azure-sql-database/create-dynamic-secret-form.png) + + + + ![Modify SQL Statements Modal](../../../images/platform/dynamic-secrets/azure-sql-database/modify-sql-statements-azure-sql.png) + + Azure SQL Database dynamic secrets use predefined SQL statements that follow Azure's security best practices: + + + SQL statement executed in the master database to create a server-level login. This login allows authentication to the Azure SQL Database server. + + + + SQL statement executed in the target database to create a database user and grant permissions. The user is mapped to the login created in the master database. + + + + SQL statements executed when a lease expires or is manually revoked. The system intelligently routes DROP USER commands to the target database and DROP LOGIN commands to the master database for proper cleanup. + + + + Specifies a template for generating usernames. This field allows customization of how usernames are automatically created. + + Allowed template variables are: + - `{{randomUsername}}`: Random username string + - `{{unixTimestamp}}`: Current Unix timestamp + - `{{identity.name}}`: Name of the identity that is generating the secret + - `{{random N}}`: Random string of N characters + + Allowed template functions are: + - `truncate`: Truncates a string to a specified length + - `replace`: Replaces a substring with another value + + Examples: + ``` + {{randomUsername}} // 3POnzeFyK9gW2nioK0q2gMjr6CZqsRiX + {{unixTimestamp}} // 17490641580 + {{identity.name}} // testuser + {{random-5}} // x9k2m + {{truncate identity.name 4}} // test + {{replace identity.name 'user' 'replace'}} // testreplace + ``` + + + + + After submitting the form, you will see a dynamic secret created in the dashboard. + + + If this step fails, ensure your user has the proper permissions in both the master database (`loginmanager` role) and target database (`db_owner` role). + + + ![Dynamic Secret](../../../images/platform/dynamic-secrets/dynamic-secret.png) + + + + Once you've successfully configured the dynamic secret, you're ready to generate on-demand credentials. + To do this, simply click on the 'Generate' button which appears when hovering over the dynamic secret item. + Alternatively, you can initiate the creation of a new lease by selecting 'New Lease' from the dynamic secret lease list section. + + ![Dynamic Secret](/images/platform/dynamic-secrets/dynamic-secret-generate.png) + ![Dynamic Secret](/images/platform/dynamic-secrets/dynamic-secret-lease-empty.png) + + When generating these secrets, it's important to specify a Time-to-Live (TTL) duration. This will dictate how long the credentials are valid for. + + ![Provision Lease](/images/platform/dynamic-secrets/provision-lease.png) + + + Ensure that the TTL for the lease falls within the maximum TTL defined when configuring the dynamic secret. + + + Once you click the `Submit` button, a new secret lease will be generated and the credentials for it will be shown to you. + + ![Provision Lease](/images/platform/dynamic-secrets/lease-values.png) + + + + +## Audit or Revoke Leases + +Once you have created one or more leases, you will be able to access them by clicking on the respective dynamic secret item on the dashboard. +This will allow you to see the expiration time of the lease or delete the lease before its set time to live. + +When a lease is revoked or expires, Infisical automatically: +1. **Drops the user** from the target database +2. **Drops the login** from the master database + +This ensures complete cleanup and prevents orphaned credentials. + +![Provision Lease](/images/platform/dynamic-secrets/lease-data.png) + +## Renew Leases + +To extend the life of the generated dynamic secret leases past its initial time to live, simply click on the **Renew** button as illustrated below. +![Provision Lease](/images/platform/dynamic-secrets/dynamic-secret-lease-renew.png) + + + Lease renewals cannot exceed the maximum TTL set when configuring the dynamic secret + diff --git a/docs/images/platform/dynamic-secrets/azure-sql-database/add-dynamic-secret-button.png b/docs/images/platform/dynamic-secrets/azure-sql-database/add-dynamic-secret-button.png new file mode 100644 index 000000000..3adbc8a9e Binary files /dev/null and b/docs/images/platform/dynamic-secrets/azure-sql-database/add-dynamic-secret-button.png differ diff --git a/docs/images/platform/dynamic-secrets/azure-sql-database/create-dynamic-secret-form.png b/docs/images/platform/dynamic-secrets/azure-sql-database/create-dynamic-secret-form.png new file mode 100644 index 000000000..3092ab7b3 Binary files /dev/null and b/docs/images/platform/dynamic-secrets/azure-sql-database/create-dynamic-secret-form.png differ diff --git a/docs/images/platform/dynamic-secrets/azure-sql-database/modify-sql-statements-azure-sql.png b/docs/images/platform/dynamic-secrets/azure-sql-database/modify-sql-statements-azure-sql.png new file mode 100644 index 000000000..e04aae8c4 Binary files /dev/null and b/docs/images/platform/dynamic-secrets/azure-sql-database/modify-sql-statements-azure-sql.png differ diff --git a/docs/snippets/DynamicSecretsBrowser.jsx b/docs/snippets/DynamicSecretsBrowser.jsx index 6438deea0..650e47c3c 100644 --- a/docs/snippets/DynamicSecretsBrowser.jsx +++ b/docs/snippets/DynamicSecretsBrowser.jsx @@ -10,6 +10,7 @@ export const DynamicSecretsBrowser = () => { {"name": "AWS IAM", "slug": "aws-iam", "path": "/documentation/platform/dynamic-secrets/aws-iam", "description": "Learn how to generate dynamic AWS IAM credentials on-demand.", "category": "Cloud Providers"}, {"name": "AWS ElastiCache", "slug": "aws-elasticache", "path": "/documentation/platform/dynamic-secrets/aws-elasticache", "description": "Learn how to generate dynamic AWS ElastiCache credentials on-demand.", "category": "Caches"}, {"name": "Azure Entra ID", "slug": "azure-entra-id", "path": "/documentation/platform/dynamic-secrets/azure-entra-id", "description": "Learn how to generate dynamic Azure Entra ID credentials on-demand.", "category": "Cloud Providers"}, + {"name": "Azure SQL Database", "slug": "azure-sql-database", "path": "/documentation/platform/dynamic-secrets/azure-sql-database", "description": "Learn how to generate dynamic Azure SQL Database credentials on-demand.", "category": "Databases"}, {"name": "GCP IAM", "slug": "gcp-iam", "path": "/documentation/platform/dynamic-secrets/gcp-iam", "description": "Learn how to generate dynamic GCP IAM credentials on-demand.", "category": "Cloud Providers"}, {"name": "Cassandra", "slug": "cassandra", "path": "/documentation/platform/dynamic-secrets/cassandra", "description": "Learn how to generate dynamic Cassandra database credentials on-demand.", "category": "Databases"}, {"name": "Couchbase", "slug": "couchbase", "path": "/documentation/platform/dynamic-secrets/couchbase", "description": "Learn how to generate dynamic Couchbase database credentials on-demand.", "category": "Databases"}, diff --git a/frontend/src/hooks/api/dynamicSecret/types.ts b/frontend/src/hooks/api/dynamicSecret/types.ts index 03e018dae..8584d6947 100644 --- a/frontend/src/hooks/api/dynamicSecret/types.ts +++ b/frontend/src/hooks/api/dynamicSecret/types.ts @@ -29,6 +29,7 @@ export enum DynamicSecretProviders { MongoDB = "mongo-db", RabbitMq = "rabbit-mq", AzureEntraId = "azure-entra-id", + AzureSqlDatabase = "azure-sql-database", Ldap = "ldap", SapHana = "sap-hana", Snowflake = "snowflake", @@ -242,6 +243,34 @@ export type TDynamicSecretProvider = clientSecret: string; }; } + | { + type: DynamicSecretProviders.AzureSqlDatabase; + inputs: { + host: string; + port: number; + database: string; + masterDatabase?: string; + username: string; + password: string; + passwordRequirements?: { + length: number; + required: { + lowercase: number; + uppercase: number; + digits: number; + symbols: number; + }; + allowedSymbols?: string; + }; + masterCreationStatement: string; + creationStatement: string; + revocationStatement: string; + renewStatement?: string; + ca?: string; + sslEnabled?: boolean; + gatewayId?: string; + }; + } | { type: DynamicSecretProviders.Ldap; inputs: { diff --git a/frontend/src/pages/secret-manager/SecretDashboardPage/components/ActionBar/CreateDynamicSecretForm/AzureSqlDatabaseInputForm.tsx b/frontend/src/pages/secret-manager/SecretDashboardPage/components/ActionBar/CreateDynamicSecretForm/AzureSqlDatabaseInputForm.tsx new file mode 100644 index 000000000..e45e98e20 --- /dev/null +++ b/frontend/src/pages/secret-manager/SecretDashboardPage/components/ActionBar/CreateDynamicSecretForm/AzureSqlDatabaseInputForm.tsx @@ -0,0 +1,729 @@ +import { Controller, useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { useQuery } from "@tanstack/react-query"; +import ms from "ms"; +import { z } from "zod"; + +import { TtlFormLabel } from "@app/components/features"; +import { createNotification } from "@app/components/notifications"; +import { OrgPermissionCan } from "@app/components/permissions"; +import { + Accordion, + AccordionContent, + AccordionItem, + AccordionTrigger, + Button, + FilterableSelect, + FormControl, + Input, + SecretInput, + Select, + SelectItem, + Switch, + TextArea, + Tooltip +} from "@app/components/v2"; +import { + OrgGatewayPermissionActions, + OrgPermissionSubjects +} from "@app/context/OrgPermissionContext/types"; +import { gatewaysQueryKeys, useCreateDynamicSecret } from "@app/hooks/api"; +import { DynamicSecretProviders } from "@app/hooks/api/dynamicSecret/types"; +import { ProjectEnv } from "@app/hooks/api/types"; +import { slugSchema } from "@app/lib/schemas"; + +import { MetadataForm } from "../../DynamicSecretListView/MetadataForm"; + +const passwordRequirementsSchema = z + .object({ + length: z.number().min(1).max(250), + required: z + .object({ + lowercase: z.number().min(0), + uppercase: z.number().min(0), + digits: z.number().min(0), + symbols: z.number().min(0) + }) + .refine((data) => { + const total = Object.values(data).reduce((sum, count) => sum + count, 0); + return total <= 250; + }, "Sum of required characters cannot exceed 250"), + allowedSymbols: z.string().optional() + }) + .refine((data) => { + const total = Object.values(data.required).reduce((sum, count) => sum + count, 0); + return total <= data.length; + }, "Sum of required characters cannot exceed the total length"); + +const formSchema = z.object({ + provider: z.object({ + host: z.string().toLowerCase().min(1), + port: z.coerce.number(), + database: z.string().min(1), + username: z.string().min(1), + password: z.string().min(1), + passwordRequirements: passwordRequirementsSchema.optional(), + masterCreationStatement: z.string().min(1), + creationStatement: z.string().min(1), + revocationStatement: z.string().min(1), + renewStatement: z.string().optional(), + sslEnabled: z.boolean().optional(), + ca: z.string().optional(), + gatewayId: z.string().optional() + }), + defaultTTL: z.string().superRefine((val, ctx) => { + const valMs = ms(val); + if (valMs < 60 * 1000) + ctx.addIssue({ code: z.ZodIssueCode.custom, message: "TTL must be a greater than 1min" }); + if (valMs > 24 * 60 * 60 * 1000) + ctx.addIssue({ code: z.ZodIssueCode.custom, message: "TTL must be less than a day" }); + }), + maxTTL: z + .string() + .optional() + .superRefine((val, ctx) => { + if (!val) return; + const valMs = ms(val); + if (valMs < 60 * 1000) + ctx.addIssue({ code: z.ZodIssueCode.custom, message: "TTL must be a greater than 1min" }); + if (valMs > 24 * 60 * 60 * 1000) + ctx.addIssue({ code: z.ZodIssueCode.custom, message: "TTL must be less than a day" }); + }), + name: slugSchema(), + environment: z.object({ name: z.string(), slug: z.string() }), + metadata: z + .object({ + key: z.string().trim().min(1), + value: z.string().trim().default("") + }) + .array() + .optional(), + usernameTemplate: z.string().nullable().optional() +}); + +type TForm = z.infer; + +type Props = { + onCompleted: () => void; + onCancel: () => void; + secretPath: string; + projectSlug: string; + environments: ProjectEnv[]; + isSingleEnvironmentMode?: boolean; +}; + +const getDefaultAzureSqlStatements = () => ({ + masterCreationStatement: "CREATE LOGIN [{{username}}] WITH PASSWORD = '{{password}}';", + creationStatement: + "CREATE USER [{{username}}] FOR LOGIN [{{username}}];\nGRANT SELECT, INSERT, UPDATE, DELETE ON SCHEMA::dbo TO [{{username}}];", + renewStatement: "", + revocationStatement: "DROP USER [{{username}}];\nDROP LOGIN [{{username}}];" +}); + +export const AzureSqlDatabaseInputForm = ({ + onCompleted, + onCancel, + environments, + secretPath, + projectSlug, + isSingleEnvironmentMode +}: Props) => { + const { + control, + formState: { isSubmitting }, + handleSubmit, + watch + } = useForm({ + resolver: zodResolver(formSchema), + defaultValues: { + provider: { + port: 1433, + ...getDefaultAzureSqlStatements(), + passwordRequirements: { + length: 48, + required: { + lowercase: 1, + uppercase: 1, + digits: 1, + symbols: 0 + }, + allowedSymbols: "-_.~!*" + } + }, + environment: isSingleEnvironmentMode ? environments[0] : undefined, + usernameTemplate: "{{randomUsername}}" + } + }); + + const createDynamicSecret = useCreateDynamicSecret(); + const { data: gateways, isPending: isGatewaysLoading } = useQuery(gatewaysQueryKeys.list()); + const sslEnabled = watch("provider.sslEnabled"); + + const handleCreateDynamicSecret = async ({ + name, + maxTTL, + provider, + defaultTTL, + environment, + metadata, + usernameTemplate + }: TForm) => { + if (createDynamicSecret.isPending) return; + + const isDefaultUsernameTemplate = usernameTemplate === "{{randomUsername}}"; + try { + await createDynamicSecret.mutateAsync({ + provider: { + type: DynamicSecretProviders.AzureSqlDatabase, + inputs: { ...provider, masterDatabase: "master" } + }, + maxTTL, + name, + path: secretPath, + defaultTTL, + projectSlug, + environmentSlug: environment.slug, + metadata, + usernameTemplate: + !usernameTemplate || isDefaultUsernameTemplate ? undefined : usernameTemplate + }); + onCompleted(); + } catch { + createNotification({ + type: "error", + text: "Failed to create dynamic secret" + }); + } + }; + + return ( +
+
+
+
+
+ ( + + + + )} + /> +
+ +
+ ( + } + isError={Boolean(error?.message)} + errorText={error?.message} + > + + + )} + /> +
+
+ ( + } + isError={Boolean(error?.message)} + errorText={error?.message} + > + + + )} + /> +
+
+ +
+
+ Configuration +
+
+ + {(isAllowed) => ( + ( + + +
+ +
+
+
+ )} + /> + )} +
+
+
+
+ ( + + + + )} + /> + ( + + + + )} + /> +
+
+
+ ( + + + + )} + /> +
+
+ ( + + + + )} + /> +
+
+ ( + + + + )} + /> +
+
+
+
+ ( + + + Encrypt Connection (SSL) + + + )} + /> +
+ {sslEnabled && ( + ( + + + + )} + /> + )} + + + + Creation, Revocation & Renew Statements (optional) + + + ( + + + + )} + /> +
+ Customize SQL statements for managing Azure SQL Database user lifecycle +
+ ( + +