Merge pull request #4578 from Infisical/ENG-3789

Add Azure SQL Database Dynamic Secret
This commit is contained in:
carlosmonastyrski
2025-09-23 22:46:37 -03:00
committed by GitHub
15 changed files with 2259 additions and 1 deletions

View File

@@ -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<number>((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<TGatewayServiceFactory, "fnGetGatewayClientTlsByGatewayId">;
gatewayV2Service: Pick<TGatewayV2ServiceFactory, "getPlatformConnectionDetailsByGatewayId">;
};
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<typeof DynamicSecretAzureSqlDBSchema> & { 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@<azure-server-name>" 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<typeof DynamicSecretAzureSqlDBSchema>,
gatewayCallback: (host: string, port: number) => Promise<void>
) => {
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
};
};

View File

@@ -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(),

View File

@@ -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 }),

View File

@@ -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",

View File

@@ -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
<Steps>
<Step title="Open Secret Overview Dashboard">
Open the Secret Overview dashboard and select the environment in which you would like to add a dynamic secret.
</Step>
<Step title="Click on the 'Add Dynamic Secret' button">
![Add Dynamic Secret Button](../../../images/platform/dynamic-secrets/add-dynamic-secret-button.png)
</Step>
<Step title="Select `Azure SQL Database`">
![Dynamic Secret Modal](../../../images/platform/dynamic-secrets/azure-sql-database/add-dynamic-secret-button.png)
</Step>
<Step title="Provide the inputs for dynamic secret parameters">
<ParamField path="Secret Name" type="string" required>
Name by which you want the secret to be referenced
</ParamField>
<ParamField path="Default TTL" type="string" required>
Default time-to-live for a generated secret (it is possible to modify this value after a secret is generated)
</ParamField>
<ParamField path="Max TTL" type="string" required>
Maximum time-to-live for a generated secret
</ParamField>
<ParamField path="Metadata" type="list" required>
List of key/value metadata pairs
</ParamField>
<ParamField path="Host" type="string" required>
Azure SQL Database server hostname (e.g., myserver.database.windows.net)
</ParamField>
<ParamField path="Port" type="number" required>
Database port (typically 1433 for Azure SQL Database)
</ParamField>
<ParamField path="User" type="string" required>
Username that will be used to create dynamic secrets (must have loginmanager role in master and db_owner in target database)
</ParamField>
<ParamField path="Password" type="string" required>
Password that will be used to create dynamic secrets
</ParamField>
<ParamField path="Database" type="string" required>
Name of the target database where users will be created and granted permissions
</ParamField>
<ParamField path="Encrypt Connection (SSL)" type="boolean">
Enable SSL encryption for the database connection (recommended for Azure SQL Database)
</ParamField>
<ParamField path="CA(SSL)" type="string">
SSL certificate authority certificate. For Azure SQL Database, this is typically not required as Azure manages the certificates.
</ParamField>
![Dynamic Secret Setup Modal](../../../images/platform/dynamic-secrets/azure-sql-database/create-dynamic-secret-form.png)
</Step>
<Step title="Configure SQL Statements">
![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:
<ParamField path="Master Creation Statement" type="string" default="CREATE LOGIN [{{username}}] WITH PASSWORD = '{{password}}';'">
SQL statement executed in the master database to create a server-level login. This login allows authentication to the Azure SQL Database server.
</ParamField>
<ParamField path="Creation Statement" type="string" default="CREATE USER [{{username}}] FOR LOGIN [{{username}}];\nGRANT SELECT, INSERT, UPDATE, DELETE ON SCHEMA::dbo TO [{{username}}];">
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.
</ParamField>
<ParamField path="Revocation Statement" type="string">
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.
</ParamField>
<ParamField path="Username Template" type="string" default="{{randomUsername}}">
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
```
</ParamField>
</Step>
<Step title="Click 'Submit'">
After submitting the form, you will see a dynamic secret created in the dashboard.
<Note>
If this step fails, ensure your user has the proper permissions in both the master database (`loginmanager` role) and target database (`db_owner` role).
</Note>
![Dynamic Secret](../../../images/platform/dynamic-secrets/dynamic-secret.png)
</Step>
<Step title="Generate dynamic secrets">
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)
<Tip>
Ensure that the TTL for the lease falls within the maximum TTL defined when configuring the dynamic secret.
</Tip>
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)
</Step>
</Steps>
## 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)
<Warning>
Lease renewals cannot exceed the maximum TTL set when configuring the dynamic secret
</Warning>

Binary file not shown.

After

Width:  |  Height:  |  Size: 520 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 537 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 573 KiB

View File

@@ -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"},

View File

@@ -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: {

View File

@@ -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<typeof formSchema>;
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<TForm>({
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 (
<div>
<form onSubmit={handleSubmit(handleCreateDynamicSecret)} autoComplete="off">
<div>
<div className="flex items-center space-x-2">
<div className="flex-grow">
<Controller
control={control}
defaultValue=""
name="name"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Secret Name"
isError={Boolean(error)}
errorText={error?.message}
>
<Input {...field} placeholder="dynamic-secret" />
</FormControl>
)}
/>
</div>
<div className="w-32">
<Controller
control={control}
name="defaultTTL"
defaultValue="1h"
render={({ field, fieldState: { error } }) => (
<FormControl
label={<TtlFormLabel label="Default TTL" />}
isError={Boolean(error?.message)}
errorText={error?.message}
>
<Input {...field} />
</FormControl>
)}
/>
</div>
<div className="w-32">
<Controller
control={control}
name="maxTTL"
defaultValue="24h"
render={({ field, fieldState: { error } }) => (
<FormControl
label={<TtlFormLabel label="Max TTL" />}
isError={Boolean(error?.message)}
errorText={error?.message}
>
<Input {...field} />
</FormControl>
)}
/>
</div>
</div>
<MetadataForm control={control} />
<div>
<div className="mb-4 mt-4 border-b border-mineshaft-500 pb-2 pl-1 font-medium text-mineshaft-200">
Configuration
</div>
<div>
<OrgPermissionCan
I={OrgGatewayPermissionActions.AttachGateways}
a={OrgPermissionSubjects.Gateway}
>
{(isAllowed) => (
<Controller
control={control}
name="provider.gatewayId"
defaultValue=""
render={({ field: { value, onChange }, fieldState: { error } }) => (
<FormControl
isError={Boolean(error?.message)}
errorText={error?.message}
label="Gateway"
>
<Tooltip
isDisabled={isAllowed}
content="Restricted access. You don't have permission to attach gateways to resources."
>
<div>
<Select
isDisabled={!isAllowed}
value={value}
onValueChange={onChange}
className="w-full border border-mineshaft-500"
dropdownContainerClassName="max-w-none"
isLoading={isGatewaysLoading}
placeholder="Default: Internet Gateway"
position="popper"
>
<SelectItem
value={null as unknown as string}
onClick={() => onChange(undefined)}
>
Internet Gateway
</SelectItem>
{gateways?.map((el) => (
<SelectItem value={el.id} key={el.id}>
{el.name}
</SelectItem>
))}
</Select>
</div>
</Tooltip>
</FormControl>
)}
/>
)}
</OrgPermissionCan>
</div>
<div className="flex flex-col">
<div className="flex items-center space-x-2">
<Controller
control={control}
name="provider.host"
defaultValue=""
render={({ field, fieldState: { error } }) => (
<FormControl
label="Host"
className="flex-grow"
isError={Boolean(error?.message)}
errorText={error?.message}
>
<Input {...field} placeholder="server.database.windows.net" />
</FormControl>
)}
/>
<Controller
control={control}
name="provider.port"
defaultValue={1433}
render={({ field, fieldState: { error } }) => (
<FormControl
label="Port"
isError={Boolean(error?.message)}
errorText={error?.message}
>
<Input {...field} type="number" />
</FormControl>
)}
/>
</div>
<div className="flex items-center space-x-2">
<div className="flex-grow">
<Controller
control={control}
name="provider.username"
defaultValue=""
render={({ field, fieldState: { error } }) => (
<FormControl
label="User"
isError={Boolean(error?.message)}
errorText={error?.message}
>
<Input {...field} autoComplete="off" />
</FormControl>
)}
/>
</div>
<div className="flex-grow">
<Controller
control={control}
name="provider.password"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Password"
isError={Boolean(error?.message)}
errorText={error?.message}
>
<Input {...field} type="password" autoComplete="new-password" />
</FormControl>
)}
/>
</div>
<div className="flex-grow">
<Controller
control={control}
name="provider.database"
defaultValue=""
render={({ field, fieldState: { error } }) => (
<FormControl
label="Database"
isError={Boolean(error?.message)}
errorText={error?.message}
>
<Input {...field} placeholder="mydatabase" />
</FormControl>
)}
/>
</div>
</div>
<div>
<div className="mb-2 mt-2">
<Controller
control={control}
name="provider.sslEnabled"
render={({ field: { value, onChange }, fieldState: { error } }) => (
<FormControl isError={Boolean(error?.message)} errorText={error?.message}>
<Switch
className="bg-mineshaft-400/50 shadow-inner data-[state=checked]:bg-green/80"
id="azure-sql-ds-ssl-enabled"
thumbClassName="bg-mineshaft-800"
isChecked={value}
onCheckedChange={onChange}
>
Encrypt Connection (SSL)
</Switch>
</FormControl>
)}
/>
</div>
{sslEnabled && (
<Controller
control={control}
name="provider.ca"
render={({ field, fieldState: { error } }) => (
<FormControl
isOptional
label="CA (SSL)"
isError={Boolean(error?.message)}
errorText={error?.message}
>
<SecretInput
{...field}
containerClassName="text-bunker-300 hover:border-primary-400/50 border border-mineshaft-600 bg-mineshaft-900 px-2 py-1.5"
/>
</FormControl>
)}
/>
)}
<Accordion type="multiple" className="mb-2 w-full bg-mineshaft-700">
<AccordionItem value="advanced">
<AccordionTrigger>
Creation, Revocation & Renew Statements (optional)
</AccordionTrigger>
<AccordionContent>
<Controller
control={control}
name="usernameTemplate"
defaultValue=""
render={({ field, fieldState: { error } }) => (
<FormControl
label="Username Template"
isError={Boolean(error?.message)}
errorText={error?.message}
>
<Input
{...field}
value={field.value || undefined}
className="border-mineshaft-600 bg-mineshaft-900 text-sm"
placeholder="{{randomUsername}}"
/>
</FormControl>
)}
/>
<div className="mb-4 text-sm text-mineshaft-300">
Customize SQL statements for managing Azure SQL Database user lifecycle
</div>
<Controller
control={control}
name="provider.masterCreationStatement"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Master Creation Statement"
isError={Boolean(error?.message)}
errorText={error?.message}
helperText="Statement to create login in master database"
>
<TextArea
{...field}
reSize="none"
rows={3}
className="border-mineshaft-600 bg-mineshaft-900 text-sm"
/>
</FormControl>
)}
/>
<Controller
control={control}
name="provider.creationStatement"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Creation Statement"
isError={Boolean(error?.message)}
errorText={error?.message}
helperText="Statement to create user in target database and grant permissions"
>
<TextArea
{...field}
reSize="none"
rows={3}
className="border-mineshaft-600 bg-mineshaft-900 text-sm"
/>
</FormControl>
)}
/>
<Controller
control={control}
name="provider.revocationStatement"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Revocation Statement"
isError={Boolean(error?.message)}
errorText={error?.message}
helperText="Statement to drop user and login"
>
<TextArea
{...field}
reSize="none"
rows={3}
className="border-mineshaft-600 bg-mineshaft-900 text-sm"
/>
</FormControl>
)}
/>
<Controller
control={control}
name="provider.renewStatement"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Renew Statement"
helperText="username and expiration are dynamically provisioned"
isError={Boolean(error?.message)}
errorText={error?.message}
>
<TextArea
{...field}
reSize="none"
rows={3}
className="border-mineshaft-600 bg-mineshaft-900 text-sm"
/>
</FormControl>
)}
/>
</AccordionContent>
</AccordionItem>
</Accordion>
<Accordion type="multiple" className="mb-2 mt-4 w-full bg-mineshaft-700">
<AccordionItem value="password-config">
<AccordionTrigger>Password Configuration (optional)</AccordionTrigger>
<AccordionContent>
<div className="mb-4 text-sm text-mineshaft-300">
Set constraints on the generated database password
</div>
<div className="space-y-4">
<div>
<Controller
control={control}
name="provider.passwordRequirements.length"
defaultValue={48}
render={({ field, fieldState: { error } }) => (
<FormControl
label="Password Length"
isError={Boolean(error)}
errorText={error?.message}
>
<Input
type="number"
min={1}
max={250}
{...field}
onChange={(e) => field.onChange(Number(e.target.value))}
/>
</FormControl>
)}
/>
</div>
<div className="space-y-2">
<h4 className="text-sm font-medium">Minimum Required Character Counts</h4>
<div className="text-sm text-gray-500">
{(() => {
const total = Object.values(
watch("provider.passwordRequirements.required") || {}
).reduce((sum, count) => sum + Number(count || 0), 0);
const length = watch("provider.passwordRequirements.length") || 0;
const isError = total > length;
return (
<span className={isError ? "text-red-500" : ""}>
Total required characters: {total}{" "}
{isError ? `(exceeds length of ${length})` : ""}
</span>
);
})()}
</div>
<div className="grid grid-cols-2 gap-4">
<Controller
control={control}
name="provider.passwordRequirements.required.lowercase"
defaultValue={1}
render={({ field, fieldState: { error } }) => (
<FormControl
label="Lowercase Count"
isError={Boolean(error)}
errorText={error?.message}
helperText="Minimum number of lowercase letters"
>
<Input
type="number"
min={0}
{...field}
onChange={(e) => field.onChange(Number(e.target.value))}
/>
</FormControl>
)}
/>
<Controller
control={control}
name="provider.passwordRequirements.required.uppercase"
defaultValue={1}
render={({ field, fieldState: { error } }) => (
<FormControl
label="Uppercase Count"
isError={Boolean(error)}
errorText={error?.message}
helperText="Minimum number of uppercase letters"
>
<Input
type="number"
min={0}
{...field}
onChange={(e) => field.onChange(Number(e.target.value))}
/>
</FormControl>
)}
/>
<Controller
control={control}
name="provider.passwordRequirements.required.digits"
defaultValue={1}
render={({ field, fieldState: { error } }) => (
<FormControl
label="Digit Count"
isError={Boolean(error)}
errorText={error?.message}
helperText="Minimum number of digits"
>
<Input
type="number"
min={0}
{...field}
onChange={(e) => field.onChange(Number(e.target.value))}
/>
</FormControl>
)}
/>
<Controller
control={control}
name="provider.passwordRequirements.required.symbols"
defaultValue={0}
render={({ field, fieldState: { error } }) => (
<FormControl
label="Symbol Count"
isError={Boolean(error)}
errorText={error?.message}
helperText="Minimum number of symbols"
>
<Input
type="number"
min={0}
{...field}
onChange={(e) => field.onChange(Number(e.target.value))}
/>
</FormControl>
)}
/>
</div>
</div>
<div className="space-y-2">
<h4 className="text-sm font-medium">Allowed Symbols</h4>
<Controller
control={control}
name="provider.passwordRequirements.allowedSymbols"
defaultValue="-_.~!*"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Symbols to use in password"
isError={Boolean(error)}
errorText={error?.message}
helperText="Default: -_.~!*"
>
<Input {...field} placeholder="-_.~!*" />
</FormControl>
)}
/>
</div>
</div>
</AccordionContent>
</AccordionItem>
</Accordion>
{!isSingleEnvironmentMode && (
<Controller
control={control}
name="environment"
render={({ field: { value, onChange }, fieldState: { error } }) => (
<FormControl
label="Environment"
isError={Boolean(error)}
errorText={error?.message}
>
<FilterableSelect
options={environments}
value={value}
onChange={onChange}
placeholder="Select the environment to create secret in..."
getOptionLabel={(option) => option.name}
getOptionValue={(option) => option.slug}
menuPlacement="top"
/>
</FormControl>
)}
/>
)}
</div>
</div>
</div>
</div>
<div className="mt-4 flex items-center space-x-4">
<Button type="submit" isLoading={isSubmitting}>
Submit
</Button>
<Button variant="outline_bg" onClick={onCancel}>
Cancel
</Button>
</div>
</form>
</div>
);
};

View File

@@ -29,6 +29,7 @@ import { ProjectEnv } from "@app/hooks/api/types";
import { AwsElastiCacheInputForm } from "./AwsElastiCacheInputForm";
import { AwsIamInputForm } from "./AwsIamInputForm";
import { AzureEntraIdInputForm } from "./AzureEntraIdInputForm";
import { AzureSqlDatabaseInputForm } from "./AzureSqlDatabaseInputForm";
import { CassandraInputForm } from "./CassandraInputForm";
import { CouchbaseInputForm } from "./CouchbaseInputForm";
import { ElasticSearchInputForm } from "./ElasticSearchInputForm";
@@ -112,6 +113,11 @@ const DYNAMIC_SECRET_LIST = [
provider: DynamicSecretProviders.AzureEntraId,
title: "Azure Entra ID"
},
{
icon: <VscAzure size="1.5rem" />,
provider: DynamicSecretProviders.AzureSqlDatabase,
title: "Azure SQL Database"
},
{
icon: <SiFiles size="1.5rem" />,
provider: DynamicSecretProviders.Ldap,
@@ -443,6 +449,25 @@ export const CreateDynamicSecretForm = ({
/>
</motion.div>
)}
{wizardStep === WizardSteps.ProviderInputs &&
selectedProvider === DynamicSecretProviders.AzureSqlDatabase && (
<motion.div
key="dynamic-azure-sql-database-step"
transition={{ duration: 0.1 }}
initial={{ opacity: 0, translateX: 30 }}
animate={{ opacity: 1, translateX: 0 }}
exit={{ opacity: 0, translateX: -30 }}
>
<AzureSqlDatabaseInputForm
onCompleted={handleFormReset}
onCancel={handleFormReset}
projectSlug={projectSlug}
secretPath={secretPath}
environments={environments}
isSingleEnvironmentMode={isSingleEnvironmentMode}
/>
</motion.div>
)}
{wizardStep === WizardSteps.ProviderInputs &&
selectedProvider === DynamicSecretProviders.Ldap && (
<motion.div

View File

@@ -138,7 +138,8 @@ const renderOutputForm = (
provider === DynamicSecretProviders.MongoAtlas ||
provider === DynamicSecretProviders.MongoDB ||
provider === DynamicSecretProviders.Vertica ||
provider === DynamicSecretProviders.SapAse
provider === DynamicSecretProviders.SapAse ||
provider === DynamicSecretProviders.AzureSqlDatabase
) {
const { DB_PASSWORD, DB_USERNAME } = data as { DB_USERNAME: string; DB_PASSWORD: string };
return (

View File

@@ -0,0 +1,686 @@
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,
FormControl,
Input,
SecretInput,
Select,
SelectItem,
Switch,
TextArea,
Tooltip
} from "@app/components/v2";
import { OrgPermissionSubjects } from "@app/context";
import { OrgGatewayPermissionActions } from "@app/context/OrgPermissionContext/types";
import { gatewaysQueryKeys, useUpdateDynamicSecret } from "@app/hooks/api";
import { TDynamicSecret } from "@app/hooks/api/dynamicSecret/types";
import { slugSchema } from "@app/lib/schemas";
import { MetadataForm } from "../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({
inputs: z
.object({
host: z.string().toLowerCase().min(1),
port: z.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(),
ca: z.string().optional(),
sslEnabled: z.boolean().optional(),
gatewayId: z.string().optional()
})
.partial(),
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" });
}),
newName: slugSchema().optional(),
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<typeof formSchema>;
type Props = {
onClose: () => void;
dynamicSecret: TDynamicSecret & { inputs: unknown };
secretPath: string;
projectSlug: string;
environment: string;
};
export const EditDynamicSecretAzureSqlDatabaseForm = ({
onClose,
dynamicSecret,
environment,
secretPath,
projectSlug
}: Props) => {
const getDefaultPasswordRequirements = () => ({
length: 48,
required: {
lowercase: 1,
uppercase: 1,
digits: 1,
symbols: 0
},
allowedSymbols: "-_.~!*"
});
const {
control,
formState: { isSubmitting },
handleSubmit,
watch
} = useForm<TForm>({
resolver: zodResolver(formSchema),
values: {
defaultTTL: dynamicSecret.defaultTTL,
maxTTL: dynamicSecret.maxTTL || "",
newName: dynamicSecret.name,
metadata: dynamicSecret.metadata?.map((item) => ({ key: item.key, value: item.value })) || [],
usernameTemplate: dynamicSecret?.usernameTemplate || "{{randomUsername}}",
inputs: {
...(dynamicSecret.inputs as TForm["inputs"]),
passwordRequirements:
(dynamicSecret.inputs as TForm["inputs"])?.passwordRequirements ||
getDefaultPasswordRequirements()
}
}
});
const updateDynamicSecret = useUpdateDynamicSecret();
const { data: gateways, isPending: isGatewaysLoading } = useQuery(gatewaysQueryKeys.list());
const sslEnabled = watch("inputs.sslEnabled");
const handleUpdateDynamicSecret = async ({
inputs,
maxTTL,
defaultTTL,
newName,
metadata,
usernameTemplate
}: TForm) => {
if (updateDynamicSecret.isPending) return;
try {
const isDefaultUsernameTemplate = usernameTemplate === "{{randomUsername}}";
await updateDynamicSecret.mutateAsync({
projectSlug,
environmentSlug: environment,
path: secretPath,
name: dynamicSecret.name,
data: {
maxTTL: maxTTL || undefined,
defaultTTL,
inputs: inputs ? { ...inputs, masterDatabase: "master" } : undefined,
newName: newName === dynamicSecret.name ? undefined : newName,
metadata,
usernameTemplate: !usernameTemplate || isDefaultUsernameTemplate ? null : usernameTemplate
}
});
onClose();
createNotification({
type: "success",
text: "Successfully updated dynamic secret"
});
} catch {
createNotification({
type: "error",
text: "Failed to update dynamic secret"
});
}
};
return (
<div>
<form onSubmit={handleSubmit(handleUpdateDynamicSecret)} autoComplete="off">
<div>
<div className="flex items-center space-x-2">
<div className="flex-grow">
<Controller
control={control}
name="newName"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Secret Name"
isError={Boolean(error)}
errorText={error?.message}
>
<Input {...field} placeholder="dynamic-secret" />
</FormControl>
)}
/>
</div>
<div className="w-32">
<Controller
control={control}
name="defaultTTL"
render={({ field, fieldState: { error } }) => (
<FormControl
label={<TtlFormLabel label="Default TTL" />}
isError={Boolean(error?.message)}
errorText={error?.message}
>
<Input {...field} />
</FormControl>
)}
/>
</div>
<div className="w-32">
<Controller
control={control}
name="maxTTL"
render={({ field, fieldState: { error } }) => (
<FormControl
label={<TtlFormLabel label="Max TTL" />}
isError={Boolean(error?.message)}
errorText={error?.message}
>
<Input {...field} />
</FormControl>
)}
/>
</div>
</div>
<MetadataForm control={control} />
<div>
<div className="mb-4 mt-4 border-b border-mineshaft-500 pb-2 pl-1 font-medium text-mineshaft-200">
Configuration
</div>
<div>
<OrgPermissionCan
I={OrgGatewayPermissionActions.AttachGateways}
a={OrgPermissionSubjects.Gateway}
>
{(isAllowed) => (
<Controller
control={control}
name="inputs.gatewayId"
render={({ field: { value, onChange }, fieldState: { error } }) => (
<FormControl
isError={Boolean(error?.message)}
errorText={error?.message}
label="Gateway"
>
<Tooltip
isDisabled={isAllowed}
content="Restricted access. You don't have permission to attach gateways to resources."
>
<div>
<Select
isDisabled={!isAllowed}
value={value}
onValueChange={onChange}
className="w-full border border-mineshaft-500"
dropdownContainerClassName="max-w-none"
isLoading={isGatewaysLoading}
placeholder="Default: Internet Gateway"
position="popper"
>
<SelectItem
value={null as unknown as string}
onClick={() => onChange(undefined)}
>
Internet Gateway
</SelectItem>
{gateways?.map((el) => (
<SelectItem value={el.id} key={el.id}>
{el.name}
</SelectItem>
))}
</Select>
</div>
</Tooltip>
</FormControl>
)}
/>
)}
</OrgPermissionCan>
</div>
<div className="flex flex-col">
<div className="flex items-center space-x-2">
<Controller
control={control}
name="inputs.host"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Host"
className="flex-grow"
isError={Boolean(error?.message)}
errorText={error?.message}
>
<Input {...field} placeholder="server.database.windows.net" />
</FormControl>
)}
/>
<Controller
control={control}
name="inputs.port"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Port"
isError={Boolean(error?.message)}
errorText={error?.message}
>
<Input {...field} type="number" />
</FormControl>
)}
/>
</div>
<div className="flex items-center space-x-2">
<div className="flex-grow">
<Controller
control={control}
name="inputs.username"
render={({ field, fieldState: { error } }) => (
<FormControl
label="User"
isError={Boolean(error?.message)}
errorText={error?.message}
>
<Input {...field} autoComplete="off" />
</FormControl>
)}
/>
</div>
<div className="flex-grow">
<Controller
control={control}
name="inputs.password"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Password"
isError={Boolean(error?.message)}
errorText={error?.message}
>
<Input {...field} type="password" autoComplete="new-password" />
</FormControl>
)}
/>
</div>
<div className="flex-grow">
<Controller
control={control}
name="inputs.database"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Database"
isError={Boolean(error?.message)}
errorText={error?.message}
>
<Input {...field} placeholder="mydatabase" />
</FormControl>
)}
/>
</div>
</div>
<div>
<div className="mb-2 mt-2">
<Controller
control={control}
name="inputs.sslEnabled"
render={({ field: { value, onChange }, fieldState: { error } }) => (
<FormControl isError={Boolean(error?.message)} errorText={error?.message}>
<Switch
className="bg-mineshaft-400/50 shadow-inner data-[state=checked]:bg-green/80"
id="azure-sql-edit-ssl-enabled"
thumbClassName="bg-mineshaft-800"
isChecked={value}
onCheckedChange={onChange}
>
Encrypt Connection (SSL)
</Switch>
</FormControl>
)}
/>
</div>
{sslEnabled && (
<Controller
control={control}
name="inputs.ca"
render={({ field, fieldState: { error } }) => (
<FormControl
isOptional
label="CA (SSL)"
isError={Boolean(error?.message)}
errorText={error?.message}
>
<SecretInput
{...field}
containerClassName="text-bunker-300 hover:border-primary-400/50 border border-mineshaft-600 bg-mineshaft-900 px-2 py-1.5"
/>
</FormControl>
)}
/>
)}
<Accordion type="multiple" className="mb-2 w-full bg-mineshaft-700">
<AccordionItem value="advanced">
<AccordionTrigger>
Creation, Revocation & Renew Statements (optional)
</AccordionTrigger>
<AccordionContent>
<Controller
control={control}
name="usernameTemplate"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Username Template"
isError={Boolean(error?.message)}
errorText={error?.message}
>
<Input
{...field}
value={field.value || undefined}
className="border-mineshaft-600 bg-mineshaft-900 text-sm"
placeholder="{{randomUsername}}"
/>
</FormControl>
)}
/>
<div className="mb-4 text-sm text-mineshaft-300">
Customize SQL statements for managing Azure SQL Database user lifecycle
</div>
<Controller
control={control}
name="inputs.masterCreationStatement"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Master Creation Statement"
isError={Boolean(error?.message)}
errorText={error?.message}
helperText="Statement to create login in master database"
>
<TextArea
{...field}
reSize="none"
rows={3}
className="border-mineshaft-600 bg-mineshaft-900 text-sm"
/>
</FormControl>
)}
/>
<Controller
control={control}
name="inputs.creationStatement"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Creation Statement"
isError={Boolean(error?.message)}
errorText={error?.message}
helperText="Statement to create user in target database and grant permissions"
>
<TextArea
{...field}
reSize="none"
rows={3}
className="border-mineshaft-600 bg-mineshaft-900 text-sm"
/>
</FormControl>
)}
/>
<Controller
control={control}
name="inputs.revocationStatement"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Revocation Statement"
isError={Boolean(error?.message)}
errorText={error?.message}
helperText="Statement to drop user and login"
>
<TextArea
{...field}
reSize="none"
rows={3}
className="border-mineshaft-600 bg-mineshaft-900 text-sm"
/>
</FormControl>
)}
/>
<Controller
control={control}
name="inputs.renewStatement"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Renew Statement"
helperText="username and expiration are dynamically provisioned"
isError={Boolean(error?.message)}
errorText={error?.message}
>
<TextArea
{...field}
reSize="none"
rows={3}
className="border-mineshaft-600 bg-mineshaft-900 text-sm"
/>
</FormControl>
)}
/>
</AccordionContent>
</AccordionItem>
</Accordion>
<Accordion type="multiple" className="mb-2 mt-4 w-full bg-mineshaft-700">
<AccordionItem value="password-config">
<AccordionTrigger>Password Configuration (optional)</AccordionTrigger>
<AccordionContent>
<div className="mb-4 text-sm text-mineshaft-300">
Set constraints on the generated database password
</div>
<div className="space-y-4">
<div>
<Controller
control={control}
name="inputs.passwordRequirements.length"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Password Length"
isError={Boolean(error)}
errorText={error?.message}
>
<Input
type="number"
min={1}
max={250}
{...field}
onChange={(e) => field.onChange(Number(e.target.value))}
/>
</FormControl>
)}
/>
</div>
<div className="space-y-2">
<h4 className="text-sm font-medium">Minimum Required Character Counts</h4>
<div className="text-sm text-gray-500">
{(() => {
const total = Object.values(
watch("inputs.passwordRequirements.required") || {}
).reduce((sum, count) => sum + Number(count || 0), 0);
const length = watch("inputs.passwordRequirements.length") || 0;
const isError = total > length;
return (
<span className={isError ? "text-red-500" : ""}>
Total required characters: {total}{" "}
{isError ? `(exceeds length of ${length})` : ""}
</span>
);
})()}
</div>
<div className="grid grid-cols-2 gap-4">
<Controller
control={control}
name="inputs.passwordRequirements.required.lowercase"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Lowercase Count"
isError={Boolean(error)}
errorText={error?.message}
helperText="Minimum number of lowercase letters"
>
<Input
type="number"
min={0}
{...field}
onChange={(e) => field.onChange(Number(e.target.value))}
/>
</FormControl>
)}
/>
<Controller
control={control}
name="inputs.passwordRequirements.required.uppercase"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Uppercase Count"
isError={Boolean(error)}
errorText={error?.message}
helperText="Minimum number of uppercase letters"
>
<Input
type="number"
min={0}
{...field}
onChange={(e) => field.onChange(Number(e.target.value))}
/>
</FormControl>
)}
/>
<Controller
control={control}
name="inputs.passwordRequirements.required.digits"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Digit Count"
isError={Boolean(error)}
errorText={error?.message}
helperText="Minimum number of digits"
>
<Input
type="number"
min={0}
{...field}
onChange={(e) => field.onChange(Number(e.target.value))}
/>
</FormControl>
)}
/>
<Controller
control={control}
name="inputs.passwordRequirements.required.symbols"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Symbol Count"
isError={Boolean(error)}
errorText={error?.message}
helperText="Minimum number of symbols"
>
<Input
type="number"
min={0}
{...field}
onChange={(e) => field.onChange(Number(e.target.value))}
/>
</FormControl>
)}
/>
</div>
</div>
<div className="space-y-2">
<h4 className="text-sm font-medium">Allowed Symbols</h4>
<Controller
control={control}
name="inputs.passwordRequirements.allowedSymbols"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Symbols to use in password"
isError={Boolean(error)}
errorText={error?.message}
helperText="Default: -_.~!*"
>
<Input {...field} placeholder="-_.~!*" />
</FormControl>
)}
/>
</div>
</div>
</AccordionContent>
</AccordionItem>
</Accordion>
</div>
</div>
</div>
</div>
<div className="mt-4 flex items-center space-x-4">
<Button type="submit" isLoading={isSubmitting}>
Save Changes
</Button>
<Button variant="outline_bg" onClick={onClose}>
Cancel
</Button>
</div>
</form>
</div>
);
};

View File

@@ -7,6 +7,7 @@ import { DynamicSecretProviders } from "@app/hooks/api/dynamicSecret/types";
import { EditDynamicSecretAwsElastiCacheProviderForm } from "./EditDynamicSecretAwsElastiCacheProviderForm";
import { EditDynamicSecretAwsIamForm } from "./EditDynamicSecretAwsIamForm";
import { EditDynamicSecretAzureEntraIdForm } from "./EditDynamicSecretAzureEntraIdForm";
import { EditDynamicSecretAzureSqlDatabaseForm } from "./EditDynamicSecretAzureSqlDatabaseForm";
import { EditDynamicSecretCassandraForm } from "./EditDynamicSecretCassandraForm";
import { EditDynamicSecretCouchbaseForm } from "./EditDynamicSecretCouchbaseForm";
import { EditDynamicSecretElasticSearchForm } from "./EditDynamicSecretElasticSearchForm";
@@ -232,6 +233,24 @@ export const EditDynamicSecretForm = ({
</motion.div>
)}
{dynamicSecretDetails?.type === DynamicSecretProviders.AzureSqlDatabase && (
<motion.div
key="azure-sql-database-edit"
transition={{ duration: 0.1 }}
initial={{ opacity: 0, translateX: 30 }}
animate={{ opacity: 1, translateX: 0 }}
exit={{ opacity: 0, translateX: -30 }}
>
<EditDynamicSecretAzureSqlDatabaseForm
onClose={onClose}
projectSlug={projectSlug}
secretPath={secretPath}
dynamicSecret={dynamicSecretDetails}
environment={environment}
/>
</motion.div>
)}
{dynamicSecretDetails?.type === DynamicSecretProviders.Ldap && (
<motion.div
key="ldap-edit"