diff --git a/backend/src/ee/routes/v1/pam-session-router.ts b/backend/src/ee/routes/v1/pam-session-router.ts index ea0ce04bbd9..98de9caafb9 100644 --- a/backend/src/ee/routes/v1/pam-session-router.ts +++ b/backend/src/ee/routes/v1/pam-session-router.ts @@ -5,6 +5,7 @@ import { EventType } from "@app/ee/services/audit-log/audit-log-types"; import { PolicyRulesResponseSchema } from "@app/ee/services/pam-account-policy"; import { KubernetesSessionCredentialsSchema } from "@app/ee/services/pam-resource/kubernetes/kubernetes-resource-schemas"; import { MongoDBSessionCredentialsSchema } from "@app/ee/services/pam-resource/mongodb/mongodb-resource-schemas"; +import { MsSQLSessionCredentialsSchema } from "@app/ee/services/pam-resource/mssql/mssql-resource-schemas"; import { MySQLSessionCredentialsSchema } from "@app/ee/services/pam-resource/mysql/mysql-resource-schemas"; import { OracleSessionCredentialsSchema } from "@app/ee/services/pam-resource/oracle/oracle-resource-schemas"; import { PostgresSessionCredentialsSchema } from "@app/ee/services/pam-resource/postgres/postgres-resource-schemas"; @@ -26,15 +27,17 @@ import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; import { AuthMode } from "@app/services/auth/auth-type"; import { PostHogEventTypes } from "@app/services/telemetry/telemetry-types"; +// Schemas with distinguishing fields must precede simpler ones — Zod strips unrecognized keys on first match. const SessionCredentialsSchema = z.union([ + MsSQLSessionCredentialsSchema, SSHSessionCredentialsSchema, + WindowsSessionCredentialsSchema, + KubernetesSessionCredentialsSchema, + MongoDBSessionCredentialsSchema, PostgresSessionCredentialsSchema, MySQLSessionCredentialsSchema, OracleSessionCredentialsSchema, - MongoDBSessionCredentialsSchema, - KubernetesSessionCredentialsSchema, - RedisSessionCredentialsSchema, - WindowsSessionCredentialsSchema + RedisSessionCredentialsSchema ]); export const registerPamSessionRouter = async (server: FastifyZodProvider) => { diff --git a/backend/src/ee/services/pam-account/pam-account-service.ts b/backend/src/ee/services/pam-account/pam-account-service.ts index a231bcd32bb..422bfcecc8e 100644 --- a/backend/src/ee/services/pam-account/pam-account-service.ts +++ b/backend/src/ee/services/pam-account/pam-account-service.ts @@ -69,6 +69,7 @@ import { PamDomainType } from "../pam-domain/pam-domain-enums"; import { PAM_DOMAIN_FACTORY_MAP } from "../pam-domain/pam-domain-factory"; import { TPamProjectRecordingConfigDALFactory } from "../pam-project-recording-config/pam-project-recording-config-dal"; import { TPamProjectRecordingConfigServiceFactory } from "../pam-project-recording-config/pam-project-recording-config-service"; +import { MsSqlAuthMethod } from "../pam-resource/mssql/mssql-resource-enums"; import { TPamResourceDALFactory } from "../pam-resource/pam-resource-dal"; import { PamResource } from "../pam-resource/pam-resource-enums"; import { TPamResourceRotationRulesDALFactory } from "../pam-resource/pam-resource-rotation-rules-dal"; @@ -1499,11 +1500,18 @@ export const pamAccountServiceFactory = ({ }; } + const credentials: Record = { + ...decryptedResource.connectionDetails, + ...decryptedAccount.credentials + }; + + // Old MSSQL accounts pre-date the authMethod field — default to sql-login + if (decryptedResource.resourceType === PamResource.MsSQL && !("authMethod" in credentials)) { + credentials.authMethod = MsSqlAuthMethod.SqlLogin; + } + return { - credentials: { - ...decryptedResource.connectionDetails, - ...decryptedAccount.credentials - }, + credentials, policyRules, projectId: project.id, account, diff --git a/backend/src/ee/services/pam-resource/mssql/mssql-resource-enums.ts b/backend/src/ee/services/pam-resource/mssql/mssql-resource-enums.ts new file mode 100644 index 00000000000..d3f287d10c0 --- /dev/null +++ b/backend/src/ee/services/pam-resource/mssql/mssql-resource-enums.ts @@ -0,0 +1,4 @@ +export enum MsSqlAuthMethod { + SqlLogin = "sql-login", + Ntlm = "ntlm" +} diff --git a/backend/src/ee/services/pam-resource/mssql/mssql-resource-schemas.ts b/backend/src/ee/services/pam-resource/mssql/mssql-resource-schemas.ts index b7f15197be0..c35f3a5e016 100644 --- a/backend/src/ee/services/pam-resource/mssql/mssql-resource-schemas.ts +++ b/backend/src/ee/services/pam-resource/mssql/mssql-resource-schemas.ts @@ -10,14 +10,29 @@ import { BaseUpdateGatewayPamResourceSchema, BaseUpdatePamAccountSchema } from "../pam-resource-schemas"; -import { - BaseSqlAccountCredentialsSchema, - BaseSqlResourceConnectionDetailsSchema -} from "../shared/sql/sql-resource-schemas"; +import { BaseSqlResourceConnectionDetailsSchema } from "../shared/sql/sql-resource-schemas"; +import { MsSqlAuthMethod } from "./mssql-resource-enums"; + +export { MsSqlAuthMethod }; // Resources export const MsSQLResourceConnectionDetailsSchema = BaseSqlResourceConnectionDetailsSchema; -export const MsSQLAccountCredentialsSchema = BaseSqlAccountCredentialsSchema; + +const MsSQLSqlLoginCredentialsSchema = z.object({ + authMethod: z.literal(MsSqlAuthMethod.SqlLogin).default(MsSqlAuthMethod.SqlLogin), + username: z.string().trim().min(1).max(63), + password: z.string().trim().min(1).max(256) +}); + +const MsSQLNtlmCredentialsSchema = z.object({ + authMethod: z.literal(MsSqlAuthMethod.Ntlm), + username: z.string().trim().min(1).max(63), + password: z.string().trim().min(1).max(256), + domain: z.string().trim().min(1, "Domain is required for NTLM authentication").max(255) +}); + +// z.union so old accounts without authMethod fall through to the sql-login .default() +export const MsSQLAccountCredentialsSchema = z.union([MsSQLNtlmCredentialsSchema, MsSQLSqlLoginCredentialsSchema]); const BaseMsSQLResourceSchema = BasePamResourceSchema.extend({ resourceType: z.literal(PamResource.MsSQL) }); @@ -26,13 +41,14 @@ export const MsSQLResourceSchema = BaseMsSQLResourceSchema.extend({ rotationAccountCredentials: MsSQLAccountCredentialsSchema.nullable().optional() }); +const SanitizedMsSQLCredentialsSchema = z.union([ + z.object({ authMethod: z.literal(MsSqlAuthMethod.Ntlm), username: z.string(), domain: z.string() }), + z.object({ authMethod: z.literal(MsSqlAuthMethod.SqlLogin).default(MsSqlAuthMethod.SqlLogin), username: z.string() }) +]); + export const SanitizedMsSQLResourceSchema = BaseMsSQLResourceSchema.extend({ connectionDetails: MsSQLResourceConnectionDetailsSchema, - rotationAccountCredentials: MsSQLAccountCredentialsSchema.pick({ - username: true - }) - .nullable() - .optional() + rotationAccountCredentials: SanitizedMsSQLCredentialsSchema.nullable().optional() }); export const MsSQLResourceListItemSchema = z.object({ @@ -65,10 +81,17 @@ export const UpdateMsSQLAccountSchema = BaseUpdatePamAccountSchema.extend({ export const SanitizedMsSQLAccountWithResourceSchema = BasePamAccountSchemaWithResource.extend({ parentType: z.literal(PamResource.MsSQL), - credentials: MsSQLAccountCredentialsSchema.pick({ - username: true - }) + credentials: SanitizedMsSQLCredentialsSchema +}); + +// Strict variant (no .default()) — prevents cross-resource false matches in SessionCredentialsSchema +const MsSQLStrictSqlLoginCredentialsSchema = z.object({ + authMethod: z.literal(MsSqlAuthMethod.SqlLogin), + username: z.string().trim().min(1).max(63), + password: z.string().trim().min(1).max(256) }); -// Sessions -export const MsSQLSessionCredentialsSchema = MsSQLResourceConnectionDetailsSchema.and(MsSQLAccountCredentialsSchema); +export const MsSQLSessionCredentialsSchema = z.union([ + MsSQLResourceConnectionDetailsSchema.and(MsSQLNtlmCredentialsSchema), + MsSQLResourceConnectionDetailsSchema.and(MsSQLStrictSqlLoginCredentialsSchema) +]); diff --git a/backend/src/ee/services/pam-resource/shared/sql/sql-resource-factory.ts b/backend/src/ee/services/pam-resource/shared/sql/sql-resource-factory.ts index f3ff03e703f..1830ab62d01 100644 --- a/backend/src/ee/services/pam-resource/shared/sql/sql-resource-factory.ts +++ b/backend/src/ee/services/pam-resource/shared/sql/sql-resource-factory.ts @@ -61,6 +61,8 @@ const makeSqlConnection = ( resourceType: PamResource; username?: string; password?: string; + authMethod?: string; // MSSQL-only: "ntlm" triggers Windows auth via Tedious + domain?: string; // MSSQL-only: AD domain for NTLM authentication } ): SqlResourceConnection => { const { connectionDetails, resourceType, username, password } = config; @@ -191,22 +193,35 @@ const makeSqlConnection = ( encrypt: true, trustServerCertificate: !sslRejectUnauthorized, cryptoCredentialsDetails: sslCertificate ? { ca: sslCertificate } : {}, - // serverName tells tedious to use this hostname for TLS SNI and certificate validation - // instead of the server/host value used for the TCP connection serverName: host } : { encrypt: false }; + const isNtlm = config.authMethod === "ntlm"; + + if (isNtlm && !config.domain) { + throw new BadRequestError({ message: "Domain is required for NTLM authentication" }); + } + const client = knex({ client: "mssql", connection: { server: "localhost", port: proxyPort, - user: actualUsername, - password: actualPassword, database: connectionDetails.database, requestTimeout: EXTERNAL_REQUEST_TIMEOUT, - // mssqlOptions is passed to tedious driver + // Knex MSSQL dialect maps these flat fields into Tedious's authentication object + ...(isNtlm + ? { + type: "ntlm", + userName: actualUsername, + password: actualPassword, + domain: config.domain + } + : { + user: actualUsername, + password: actualPassword + }), // ref: https://github.com/knex/knex/blob/b6507a7129d2b9fafebf5f831494431e64c6a8a0/lib/dialects/mssql/index.js#L66 options: mssqlOptions } @@ -253,6 +268,8 @@ export const executeWithGateway = async ( gatewayId: string; username?: string; password?: string; + authMethod?: string; // MSSQL-only: "ntlm" triggers Windows auth via Tedious + domain?: string; // MSSQL-only: AD domain for NTLM authentication }, gatewayV2Service: Pick, operation: (connection: SqlResourceConnection) => Promise @@ -333,7 +350,9 @@ export const sqlResourceFactory: TPamResourceFactory< gatewayId, resourceType, username: credentials.username, - password: credentials.password + password: credentials.password, + authMethod: "authMethod" in credentials ? credentials.authMethod : undefined, + domain: "domain" in credentials ? credentials.domain : undefined }, gatewayV2Service, async (client) => { diff --git a/docs/documentation/platform/pam/getting-started/resources/mssql.mdx b/docs/documentation/platform/pam/getting-started/resources/mssql.mdx index 0714fe87102..1d10f0c3d4d 100644 --- a/docs/documentation/platform/pam/getting-started/resources/mssql.mdx +++ b/docs/documentation/platform/pam/getting-started/resources/mssql.mdx @@ -38,7 +38,7 @@ sequenceDiagram 1. **Gateway**: An Infisical Gateway deployed in your network that can reach the MsSQL server. The Gateway handles secure communication between users and your MsSQL instance. -2. **Authentication**: Credentials (username/password) are stored securely in Infisical and used by the Gateway to authenticate with MsSQL on behalf of the user. +2. **Authentication**: Credentials are stored securely in Infisical and used by the Gateway to authenticate with MsSQL on behalf of the user. Both SQL Server Authentication (username/password) and Windows Authentication (NTLM) are supported. 3. **Local Proxy**: The Infisical CLI starts a local proxy on your machine that intercepts MsSQL connections and routes them securely through the Gateway to your MsSQL instance. @@ -63,7 +63,7 @@ Infisical tracks: Before configuring MsSQL access in Infisical PAM, you need: 1. **Infisical Gateway** - A Gateway deployed in your network with access to the MsSQL server -2. **MsSQL Credentials** - Username and password for the MsSQL instance +2. **MsSQL Credentials** - SQL Server credentials (username/password) or Windows domain credentials (domain/username/password) for the MsSQL instance 3. **Infisical CLI** - The Infisical CLI installed on user machines @@ -126,12 +126,22 @@ A PAM Account represents a specific set of credentials that users can request ac An optional description for this account. + + Choose how the account authenticates with SQL Server: + - **SQL Server Authentication** — standard username and password + - **Windows Authentication (NTLM)** — authenticates using Active Directory domain credentials + + + + The Active Directory domain name (e.g., `CORP`). Only required when using Windows Authentication (NTLM). + + - The MsSQL username. + The MsSQL or domain username. - The MsSQL password. + The MsSQL or domain password. diff --git a/frontend/src/hooks/api/pam/types/mssql-resource.ts b/frontend/src/hooks/api/pam/types/mssql-resource.ts index 9a5b2e0c2de..bae9104a634 100644 --- a/frontend/src/hooks/api/pam/types/mssql-resource.ts +++ b/frontend/src/hooks/api/pam/types/mssql-resource.ts @@ -1,8 +1,28 @@ import { PamResourceType } from "../enums"; -import { TBaseSqlConnectionDetails, TBaseSqlCredentials } from "./shared/sql-resource"; +import { TBaseSqlConnectionDetails } from "./shared/sql-resource"; import { TBasePamAccount } from "./base-account"; import { TBasePamResource } from "./base-resource"; +export enum MsSqlAuthMethod { + SqlLogin = "sql-login", + Ntlm = "ntlm" +} + +export type TMsSQLSqlLoginCredentials = { + authMethod: MsSqlAuthMethod.SqlLogin; + username: string; + password: string; +}; + +export type TMsSQLNtlmCredentials = { + authMethod: MsSqlAuthMethod.Ntlm; + username: string; + password: string; + domain: string; +}; + +export type TMsSQLCredentials = TMsSQLSqlLoginCredentials | TMsSQLNtlmCredentials; + // Resources export type TMsSQLResource = TBasePamResource & { resourceType: PamResourceType.MsSQL } & { connectionDetails: TBaseSqlConnectionDetails; @@ -10,5 +30,5 @@ export type TMsSQLResource = TBasePamResource & { resourceType: PamResourceType. // Accounts export type TMsSQLAccount = TBasePamAccount & { - credentials: TBaseSqlCredentials; + credentials: TMsSQLCredentials; }; diff --git a/frontend/src/pages/pam/PamAccountsPage/components/PamAccountForm/MsSQLAccountForm.tsx b/frontend/src/pages/pam/PamAccountsPage/components/PamAccountForm/MsSQLAccountForm.tsx index 00b6a972c42..a0c482c368a 100644 --- a/frontend/src/pages/pam/PamAccountsPage/components/PamAccountForm/MsSQLAccountForm.tsx +++ b/frontend/src/pages/pam/PamAccountsPage/components/PamAccountForm/MsSQLAccountForm.tsx @@ -1,13 +1,14 @@ -import { FormProvider, useForm } from "react-hook-form"; +import { useEffect, useState } from "react"; +import { Controller, FormProvider, useForm, useFormContext, useWatch } from "react-hook-form"; import { zodResolver } from "@hookform/resolvers/zod"; import { z } from "zod"; +import { FormControl, Input, Select, SelectItem } from "@app/components/v2"; import { Button, SheetFooter } from "@app/components/v3"; import { PamResourceType, TMsSQLAccount } from "@app/hooks/api/pam"; import { UNCHANGED_PASSWORD_SENTINEL } from "@app/hooks/api/pam/constants"; +import { MsSqlAuthMethod } from "@app/hooks/api/pam/types/mssql-resource"; -import { BaseSqlAccountSchema } from "./shared/sql-account-schemas"; -import { SqlAccountFields } from "./shared/SqlAccountFields"; import { AccountPolicyField, GenericAccountFields, @@ -24,13 +25,137 @@ type Props = { closeSheet: () => void; }; +const MsSQLSqlLoginCredentialsSchema = z.object({ + authMethod: z.literal(MsSqlAuthMethod.SqlLogin), + username: z.string().trim().min(1, "Username is required").max(63), + password: z.string().trim().min(1, "Password is required").max(256) +}); + +const MsSQLNtlmCredentialsSchema = z.object({ + authMethod: z.literal(MsSqlAuthMethod.Ntlm), + username: z.string().trim().min(1, "Username is required").max(63), + password: z.string().trim().min(1, "Password is required").max(256), + domain: z.string().trim().min(1, "Domain is required for NTLM authentication").max(255) +}); + +const MsSQLAccountCredentialsSchema = z.discriminatedUnion("authMethod", [ + MsSQLSqlLoginCredentialsSchema, + MsSQLNtlmCredentialsSchema +]); + const formSchema = genericAccountFieldsSchema.extend({ - credentials: BaseSqlAccountSchema, + credentials: MsSQLAccountCredentialsSchema, requireMfa: z.boolean().nullable().optional() }); type FormData = z.infer; +const MsSQLAccountFields = ({ isUpdate }: { isUpdate: boolean }) => { + const { control, setValue } = useFormContext(); + const [showPassword, setShowPassword] = useState(false); + const password = useWatch({ control, name: "credentials.password" }); + + const authMethod = + useWatch({ control, name: "credentials.authMethod" }) || MsSqlAuthMethod.SqlLogin; + + useEffect(() => { + if (password === UNCHANGED_PASSWORD_SENTINEL) { + setShowPassword(false); + } + }, [password]); + + return ( +
+ ( + + + + )} + /> + + {authMethod === MsSqlAuthMethod.Ntlm && ( + ( + + + + )} + /> + )} + +
+ ( + + + + )} + /> + + ( + + { + if (isUpdate && field.value === UNCHANGED_PASSWORD_SENTINEL) { + field.onChange(""); + } + setShowPassword(true); + }} + onBlur={() => { + if (isUpdate && field.value === "") { + field.onChange(UNCHANGED_PASSWORD_SENTINEL); + } + setShowPassword(false); + }} + /> + + )} + /> +
+
+ ); +}; + export const MsSQLAccountForm = ({ account, onSubmit, closeSheet }: Props) => { const isUpdate = Boolean(account); @@ -41,10 +166,20 @@ export const MsSQLAccountForm = ({ account, onSubmit, closeSheet }: Props) => { ...account, credentials: { ...account.credentials, + authMethod: account.credentials.authMethod || MsSqlAuthMethod.SqlLogin, password: UNCHANGED_PASSWORD_SENTINEL } } - : undefined + : { + name: "", + description: "", + requireMfa: false, + credentials: { + authMethod: MsSqlAuthMethod.SqlLogin, + username: "", + password: "" + } + } }); const { @@ -57,7 +192,7 @@ export const MsSQLAccountForm = ({ account, onSubmit, closeSheet }: Props) => {
- +