mirror of
https://github.com/awatertrevi/infisical.git
synced 2026-09-22 13:39:35 +00:00
Add Azure SQL Database Dynamic Secret
This commit is contained in:
@@ -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
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -5,6 +5,7 @@ import { TGatewayV2ServiceFactory } from "../../gateway-v2/gateway-v2-service";
|
|||||||
import { AwsElastiCacheDatabaseProvider } from "./aws-elasticache";
|
import { AwsElastiCacheDatabaseProvider } from "./aws-elasticache";
|
||||||
import { AwsIamProvider } from "./aws-iam";
|
import { AwsIamProvider } from "./aws-iam";
|
||||||
import { AzureEntraIDProvider } from "./azure-entra-id";
|
import { AzureEntraIDProvider } from "./azure-entra-id";
|
||||||
|
import { AzureSqlDatabaseProvider } from "./azure-sql-database";
|
||||||
import { CassandraProvider } from "./cassandra";
|
import { CassandraProvider } from "./cassandra";
|
||||||
import { CouchbaseProvider } from "./couchbase";
|
import { CouchbaseProvider } from "./couchbase";
|
||||||
import { ElasticSearchProvider } from "./elastic-search";
|
import { ElasticSearchProvider } from "./elastic-search";
|
||||||
@@ -42,6 +43,7 @@ export const buildDynamicSecretProviders = ({
|
|||||||
[DynamicSecretProviders.ElasticSearch]: ElasticSearchProvider(),
|
[DynamicSecretProviders.ElasticSearch]: ElasticSearchProvider(),
|
||||||
[DynamicSecretProviders.RabbitMq]: RabbitMqProvider(),
|
[DynamicSecretProviders.RabbitMq]: RabbitMqProvider(),
|
||||||
[DynamicSecretProviders.AzureEntraID]: AzureEntraIDProvider(),
|
[DynamicSecretProviders.AzureEntraID]: AzureEntraIDProvider(),
|
||||||
|
[DynamicSecretProviders.AzureSqlDatabase]: AzureSqlDatabaseProvider({ gatewayService, gatewayV2Service }),
|
||||||
[DynamicSecretProviders.Ldap]: LdapProvider(),
|
[DynamicSecretProviders.Ldap]: LdapProvider(),
|
||||||
[DynamicSecretProviders.SapHana]: SapHanaProvider(),
|
[DynamicSecretProviders.SapHana]: SapHanaProvider(),
|
||||||
[DynamicSecretProviders.Snowflake]: SnowflakeProvider(),
|
[DynamicSecretProviders.Snowflake]: SnowflakeProvider(),
|
||||||
|
|||||||
@@ -327,6 +327,44 @@ export const AzureEntraIDSchema = z.object({
|
|||||||
clientSecret: z.string().trim().min(1)
|
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([
|
export const LdapSchema = z.union([
|
||||||
z.object({
|
z.object({
|
||||||
url: z.string().trim().min(1),
|
url: z.string().trim().min(1),
|
||||||
@@ -610,6 +648,7 @@ export enum DynamicSecretProviders {
|
|||||||
MongoDB = "mongo-db",
|
MongoDB = "mongo-db",
|
||||||
RabbitMq = "rabbit-mq",
|
RabbitMq = "rabbit-mq",
|
||||||
AzureEntraID = "azure-entra-id",
|
AzureEntraID = "azure-entra-id",
|
||||||
|
AzureSqlDatabase = "azure-sql-database",
|
||||||
Ldap = "ldap",
|
Ldap = "ldap",
|
||||||
SapHana = "sap-hana",
|
SapHana = "sap-hana",
|
||||||
Snowflake = "snowflake",
|
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.MongoDB), inputs: DynamicSecretMongoDBSchema }),
|
||||||
z.object({ type: z.literal(DynamicSecretProviders.RabbitMq), inputs: DynamicSecretRabbitMqSchema }),
|
z.object({ type: z.literal(DynamicSecretProviders.RabbitMq), inputs: DynamicSecretRabbitMqSchema }),
|
||||||
z.object({ type: z.literal(DynamicSecretProviders.AzureEntraID), inputs: AzureEntraIDSchema }),
|
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.Ldap), inputs: LdapSchema }),
|
||||||
z.object({ type: z.literal(DynamicSecretProviders.Snowflake), inputs: DynamicSecretSnowflakeSchema }),
|
z.object({ type: z.literal(DynamicSecretProviders.Snowflake), inputs: DynamicSecretSnowflakeSchema }),
|
||||||
z.object({ type: z.literal(DynamicSecretProviders.Totp), inputs: DynamicSecretTotpSchema }),
|
z.object({ type: z.literal(DynamicSecretProviders.Totp), inputs: DynamicSecretTotpSchema }),
|
||||||
|
|||||||
@@ -453,6 +453,7 @@
|
|||||||
"documentation/platform/dynamic-secrets/aws-elasticache",
|
"documentation/platform/dynamic-secrets/aws-elasticache",
|
||||||
"documentation/platform/dynamic-secrets/aws-iam",
|
"documentation/platform/dynamic-secrets/aws-iam",
|
||||||
"documentation/platform/dynamic-secrets/azure-entra-id",
|
"documentation/platform/dynamic-secrets/azure-entra-id",
|
||||||
|
"documentation/platform/dynamic-secrets/azure-sql-database",
|
||||||
"documentation/platform/dynamic-secrets/cassandra",
|
"documentation/platform/dynamic-secrets/cassandra",
|
||||||
"documentation/platform/dynamic-secrets/couchbase",
|
"documentation/platform/dynamic-secrets/couchbase",
|
||||||
"documentation/platform/dynamic-secrets/elastic-search",
|
"documentation/platform/dynamic-secrets/elastic-search",
|
||||||
|
|||||||
@@ -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">
|
||||||
|

|
||||||
|
</Step>
|
||||||
|
<Step title="Select `Azure SQL Database`">
|
||||||
|

|
||||||
|
</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>
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
</Step>
|
||||||
|
<Step title="Configure SQL Statements">
|
||||||
|

|
||||||
|
|
||||||
|
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>
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
</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.
|
||||||
|
|
||||||
|

|
||||||
|

|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
<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.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
</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.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
## 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.
|
||||||
|

|
||||||
|
|
||||||
|
<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 |
@@ -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 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": "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 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": "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": "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"},
|
{"name": "Couchbase", "slug": "couchbase", "path": "/documentation/platform/dynamic-secrets/couchbase", "description": "Learn how to generate dynamic Couchbase database credentials on-demand.", "category": "Databases"},
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ export enum DynamicSecretProviders {
|
|||||||
MongoDB = "mongo-db",
|
MongoDB = "mongo-db",
|
||||||
RabbitMq = "rabbit-mq",
|
RabbitMq = "rabbit-mq",
|
||||||
AzureEntraId = "azure-entra-id",
|
AzureEntraId = "azure-entra-id",
|
||||||
|
AzureSqlDatabase = "azure-sql-database",
|
||||||
Ldap = "ldap",
|
Ldap = "ldap",
|
||||||
SapHana = "sap-hana",
|
SapHana = "sap-hana",
|
||||||
Snowflake = "snowflake",
|
Snowflake = "snowflake",
|
||||||
@@ -242,6 +243,34 @@ export type TDynamicSecretProvider =
|
|||||||
clientSecret: string;
|
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;
|
type: DynamicSecretProviders.Ldap;
|
||||||
inputs: {
|
inputs: {
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -29,6 +29,7 @@ import { ProjectEnv } from "@app/hooks/api/types";
|
|||||||
import { AwsElastiCacheInputForm } from "./AwsElastiCacheInputForm";
|
import { AwsElastiCacheInputForm } from "./AwsElastiCacheInputForm";
|
||||||
import { AwsIamInputForm } from "./AwsIamInputForm";
|
import { AwsIamInputForm } from "./AwsIamInputForm";
|
||||||
import { AzureEntraIdInputForm } from "./AzureEntraIdInputForm";
|
import { AzureEntraIdInputForm } from "./AzureEntraIdInputForm";
|
||||||
|
import { AzureSqlDatabaseInputForm } from "./AzureSqlDatabaseInputForm";
|
||||||
import { CassandraInputForm } from "./CassandraInputForm";
|
import { CassandraInputForm } from "./CassandraInputForm";
|
||||||
import { CouchbaseInputForm } from "./CouchbaseInputForm";
|
import { CouchbaseInputForm } from "./CouchbaseInputForm";
|
||||||
import { ElasticSearchInputForm } from "./ElasticSearchInputForm";
|
import { ElasticSearchInputForm } from "./ElasticSearchInputForm";
|
||||||
@@ -112,6 +113,11 @@ const DYNAMIC_SECRET_LIST = [
|
|||||||
provider: DynamicSecretProviders.AzureEntraId,
|
provider: DynamicSecretProviders.AzureEntraId,
|
||||||
title: "Azure Entra ID"
|
title: "Azure Entra ID"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
icon: <VscAzure size="1.5rem" />,
|
||||||
|
provider: DynamicSecretProviders.AzureSqlDatabase,
|
||||||
|
title: "Azure SQL Database"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
icon: <SiFiles size="1.5rem" />,
|
icon: <SiFiles size="1.5rem" />,
|
||||||
provider: DynamicSecretProviders.Ldap,
|
provider: DynamicSecretProviders.Ldap,
|
||||||
@@ -443,6 +449,25 @@ export const CreateDynamicSecretForm = ({
|
|||||||
/>
|
/>
|
||||||
</motion.div>
|
</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 &&
|
{wizardStep === WizardSteps.ProviderInputs &&
|
||||||
selectedProvider === DynamicSecretProviders.Ldap && (
|
selectedProvider === DynamicSecretProviders.Ldap && (
|
||||||
<motion.div
|
<motion.div
|
||||||
|
|||||||
@@ -138,7 +138,8 @@ const renderOutputForm = (
|
|||||||
provider === DynamicSecretProviders.MongoAtlas ||
|
provider === DynamicSecretProviders.MongoAtlas ||
|
||||||
provider === DynamicSecretProviders.MongoDB ||
|
provider === DynamicSecretProviders.MongoDB ||
|
||||||
provider === DynamicSecretProviders.Vertica ||
|
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 };
|
const { DB_PASSWORD, DB_USERNAME } = data as { DB_USERNAME: string; DB_PASSWORD: string };
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -7,6 +7,7 @@ import { DynamicSecretProviders } from "@app/hooks/api/dynamicSecret/types";
|
|||||||
import { EditDynamicSecretAwsElastiCacheProviderForm } from "./EditDynamicSecretAwsElastiCacheProviderForm";
|
import { EditDynamicSecretAwsElastiCacheProviderForm } from "./EditDynamicSecretAwsElastiCacheProviderForm";
|
||||||
import { EditDynamicSecretAwsIamForm } from "./EditDynamicSecretAwsIamForm";
|
import { EditDynamicSecretAwsIamForm } from "./EditDynamicSecretAwsIamForm";
|
||||||
import { EditDynamicSecretAzureEntraIdForm } from "./EditDynamicSecretAzureEntraIdForm";
|
import { EditDynamicSecretAzureEntraIdForm } from "./EditDynamicSecretAzureEntraIdForm";
|
||||||
|
import { EditDynamicSecretAzureSqlDatabaseForm } from "./EditDynamicSecretAzureSqlDatabaseForm";
|
||||||
import { EditDynamicSecretCassandraForm } from "./EditDynamicSecretCassandraForm";
|
import { EditDynamicSecretCassandraForm } from "./EditDynamicSecretCassandraForm";
|
||||||
import { EditDynamicSecretCouchbaseForm } from "./EditDynamicSecretCouchbaseForm";
|
import { EditDynamicSecretCouchbaseForm } from "./EditDynamicSecretCouchbaseForm";
|
||||||
import { EditDynamicSecretElasticSearchForm } from "./EditDynamicSecretElasticSearchForm";
|
import { EditDynamicSecretElasticSearchForm } from "./EditDynamicSecretElasticSearchForm";
|
||||||
@@ -232,6 +233,24 @@ export const EditDynamicSecretForm = ({
|
|||||||
</motion.div>
|
</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 && (
|
{dynamicSecretDetails?.type === DynamicSecretProviders.Ldap && (
|
||||||
<motion.div
|
<motion.div
|
||||||
key="ldap-edit"
|
key="ldap-edit"
|
||||||
|
|||||||
Reference in New Issue
Block a user