diff --git a/doc/connection-pooling-impersonation.md b/doc/connection-pooling-impersonation.md new file mode 100644 index 0000000000..c87f5757b9 --- /dev/null +++ b/doc/connection-pooling-impersonation.md @@ -0,0 +1,111 @@ +# Connection pooling and Windows impersonation + +On Windows, integrated-security connections are pooled by Windows identity and +logon session, in addition to the connection-string pool group. This applies to +both the wait-handle pool and the channel pool, with native or managed networking. + +Network-only logons (`LOGON32_LOGON_NEW_CREDENTIALS`, also used by `runas /netonly`) +can retain the process user's local SID while supplying different credentials for +outbound authentication. The token's `AuthenticationId` identifies its logon +session and prevents these connections from sharing a pool with the process +account or another network-only logon session. + +Duplicated tokens in the same logon session can reuse the same pool. Separate +logon sessions, even for the same account, use separate pools. Applications that +create a fresh network-only logon for every operation can therefore create more +pools and should consider reusing a suitably scoped token. + +Background replenishment creates connections only when its effective identity +matches the pool's identity, including the logon-session ID. Returning an expired +connection outside impersonation can cause replenishment to skip that pool. +`Min Pool Size` is therefore not guaranteed across impersonation boundaries; +subsequent opens under the owning identity can create connections on demand. + +Open connections inside the intended impersonation scope, including awaiting +asynchronous opens within that scope (see the +[complete sample](samples/ConnectionPool_Impersonation.cs)): + +```csharp +// token is a SafeAccessTokenHandle obtained from LogonUser. +await WindowsIdentity.RunImpersonated(token, async () => +{ + using var connection = new SqlConnection(connectionString); + await connection.OpenAsync(); + using var command = new SqlCommand("SELECT ORIGINAL_LOGIN()", connection); + Console.WriteLine(await command.ExecuteScalarAsync()); +}); +``` + +## Regression tests + +`DbConnectionPoolIdentityTest` uses synthetic network-only tokens and stub +connections; it requires Windows but no SQL Server or valid outbound credentials. + +`PoolIdentityImpersonationTest` exercises real SQL Server connections using both +pools and synchronous/asynchronous APIs. + +The `InvalidNetOnlyCredentials_*` tests need only an integrated-security +`TCPConnectionString` (local or remote; on a local instance a named-pipe or +shared-memory data source also works). They use a network-only token with +placeholder credentials, which cannot open a new physical connection, so any +successful open under that token proves it received a process-account connection. +If the environment accepts the placeholder credentials (an unpooled probe open +succeeds, as can happen with some loopback configurations), these tests skip. +They also verify that the failed open does not put the process account's pool into +its error blocking period. + +The remaining tests require an alternate account, supplied through the +`SQLCLIENT_NETONLY_USER`, `SQLCLIENT_NETONLY_DOMAIN`, and +`SQLCLIENT_NETONLY_PASSWORD` environment variables. Do not store credentials in +source or test configuration. The login-reversion test requires both the process +account and the alternate account to authenticate successfully as different SQL +logins; the replenishment test requires only the alternate account to succeed. Use +a TCP connection and verify the configured account is actually authenticated: +some loopback configurations may fall back to the process identity. Local +named-pipe and shared-memory transports do not exercise outbound credentials. +Managed networking is selected through the +existing `Switch.Microsoft.Data.SqlClient.UseManagedNetworkingOnWindows` setting. + +Windows local-server Set 3 CI jobs for the default SQL instance provision a unique +non-administrator local account and a SQL Windows login with access to `master`. +The generated password is passed only as a secret test-process environment +variable. An always-run cleanup step removes the SQL login and local account. +These jobs set `SQLCLIENT_NETONLY_REQUIRED=true` and +`SQLCLIENT_NETONLY_CONNECTION_STRING` to an explicit TCP endpoint using `master`; +missing prerequisites fail the successful-login tests rather than skipping them. +Both successful-login tests first verify that an unpooled connection authenticates +as the configured alternate account. Required CI coverage also verifies that the +process account authenticates as a different login, preventing loopback fallback +from producing a false pass. + +## CI-only outbound-name experiment + +The same designated jobs also provision a second disposable non-admin account +and SQL Windows login, using separate secret variables and always-run cleanup. +`OutboundCredentialNameTest` compares a fresh, targetless Negotiate SSPI +credential name acquired inside impersonation with `ORIGINAL_LOGIN()`, +`SUSER_SNAME()`, `SUSER_SID()` and connection authentication/transport properties. +All SQL opens are unpooled. It covers independent same-account sessions, +different accounts, restoration, and invalid credentials with the same name, +using sync/async APIs and the pipeline-selected native/managed networking. +For these configured local accounts, SSPI can return `user@machine` while SQL +returns `machine\user`; the experiment recognizes both exact account forms, +logs the raw names, and independently verifies SQL's SAM-form login and SID. +This is not general domain-alias normalization or a production pool-key rule. +Matched no-prequery and SSPI-prequery controls reuse the passing regression +preflight's unpooled open path and record logon-session IDs. A separate +cacheable/recompiled SQL comparison distinguishes context-sensitive scalar +results from the original authenticated login; normal identity probes recompile +the statement and retain strict configured-login and SID assertions. +The additional credentials are mapped through `SQLCLIENT_NETONLY_PEER_USER`, +`SQLCLIENT_NETONLY_PEER_DOMAIN`, and `SQLCLIENT_NETONLY_PEER_PASSWORD`. +Nothing is provisioned on the developer machine. + +A separate probe writes a SQL-SPN override using `CRED_PERSIST_SESSION` only +inside a fresh disposable network-only session, then deletes that exact target. +It records whether SQL selects the override and whether the targetless name, +`AuthenticationId`, or `ModifiedId` changes. An unavailable credential store or +an override not selected by SQL is explicitly reported as uncovered, not proof +that name-based caching is safe. Local accounts exercise NTLM, not Kerberos or +domain aliases. These probes do not implement outbound-name pooling or change +the driver's identity/replenishment strategy. diff --git a/doc/samples/ConnectionPool_Impersonation.cs b/doc/samples/ConnectionPool_Impersonation.cs new file mode 100644 index 0000000000..df968e2fe5 --- /dev/null +++ b/doc/samples/ConnectionPool_Impersonation.cs @@ -0,0 +1,32 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +// +using System.Security.Principal; +using System.Threading.Tasks; +using Microsoft.Data.SqlClient; +using Microsoft.Win32.SafeHandles; + +public static class ConnectionPoolImpersonationSample +{ + /// + /// Opens a pooled integrated-security connection under the supplied Windows token and + /// returns the authenticated SQL login. Requires Windows and an integrated-security + /// connection string. The caller retains ownership of the token. + /// + /// The Windows token supplying the intended outbound credentials. + /// A connection string with integrated security enabled. + /// The login that authenticated the SQL connection. + public static Task GetLoginAsync(SafeAccessTokenHandle token, string connectionString) + { + return WindowsIdentity.RunImpersonated(token, async () => + { + using var connection = new SqlConnection(connectionString); + await connection.OpenAsync(); + using var command = new SqlCommand("SELECT ORIGINAL_LOGIN()", connection); + return (string)await command.ExecuteScalarAsync(); + }); + } +} +// diff --git a/eng/pipelines/common/templates/jobs/ci-run-tests-job.yml b/eng/pipelines/common/templates/jobs/ci-run-tests-job.yml index 86ee3806fd..d6e3537df9 100644 --- a/eng/pipelines/common/templates/jobs/ci-run-tests-job.yml +++ b/eng/pipelines/common/templates/jobs/ci-run-tests-job.yml @@ -341,6 +341,15 @@ jobs: - pwsh: New-Item -ItemType Directory -Path TestResults -Force | Out-Null displayName: 'Create TestResults Directory' + - ${{ if and(eq(parameters.operatingSystem, 'Windows'), eq(parameters.configSqlFor, 'local'), eq(parameters.testSet, '3'), eq(coalesce(parameters.configProperties.instanceName, 'MSSQLSERVER'), 'MSSQLSERVER')) }}: + - template: /eng/pipelines/common/templates/steps/configure-netonly-account-step.yml@self + parameters: + mode: setup + - template: /eng/pipelines/common/templates/steps/configure-netonly-account-step.yml@self + parameters: + mode: setup + variablePrefix: netOnlyPeer + # Gate: record that all setup steps (SDK install, build, SQL config, x86 SDK # install, etc.) completed successfully. Test steps condition on this variable # so they are skipped when setup fails, yet remain independent of each @@ -359,6 +368,7 @@ jobs: operatingSystem: ${{ parameters.operatingSystem }} packageVersion: ${{ parameters.packageVersion }} sqlServerPackageVersion: ${{ parameters.sqlServerPackageVersion }} + enableNetOnlyTests: ${{ and(eq(parameters.operatingSystem, 'Windows'), eq(parameters.configSqlFor, 'local'), eq(parameters.testSet, '3'), eq(coalesce(parameters.configProperties.instanceName, 'MSSQLSERVER'), 'MSSQLSERVER')) }} - ${{ if and(eq(parameters.enableX86Test, true), eq(parameters.operatingSystem, 'Windows')) }}: - template: /eng/pipelines/common/templates/steps/run-all-tests-step.yml@self @@ -373,6 +383,16 @@ jobs: operatingSystem: ${{ parameters.operatingSystem }} packageVersion: ${{ parameters.packageVersion }} sqlServerPackageVersion: ${{ parameters.sqlServerPackageVersion }} + enableNetOnlyTests: ${{ and(eq(parameters.operatingSystem, 'Windows'), eq(parameters.configSqlFor, 'local'), eq(parameters.testSet, '3'), eq(coalesce(parameters.configProperties.instanceName, 'MSSQLSERVER'), 'MSSQLSERVER')) }} + + - ${{ if and(eq(parameters.operatingSystem, 'Windows'), eq(parameters.configSqlFor, 'local'), eq(parameters.testSet, '3'), eq(coalesce(parameters.configProperties.instanceName, 'MSSQLSERVER'), 'MSSQLSERVER')) }}: + - template: /eng/pipelines/common/templates/steps/configure-netonly-account-step.yml@self + parameters: + mode: cleanup + - template: /eng/pipelines/common/templates/steps/configure-netonly-account-step.yml@self + parameters: + mode: cleanup + variablePrefix: netOnlyPeer - template: /eng/pipelines/common/templates/steps/publish-test-results-step.yml@self parameters: diff --git a/eng/pipelines/common/templates/steps/configure-netonly-account-step.yml b/eng/pipelines/common/templates/steps/configure-netonly-account-step.yml new file mode 100644 index 0000000000..a413da4bc6 --- /dev/null +++ b/eng/pipelines/common/templates/steps/configure-netonly-account-step.yml @@ -0,0 +1,56 @@ +################################################################################# +# Licensed to the .NET Foundation under one or more agreements. # +# The .NET Foundation licenses this file to you under the MIT license. # +# See the LICENSE file in the project root for more information. # +################################################################################# + +# Invoke setup and cleanup in the same Windows job, with cleanup after the tests. +# The agent must be a local administrator with permission to manage SQL logins. +# Setup publishes netOnlyUser, netOnlyDomain, netOnlySid, netOnlyConnectionString, +# and secret netOnlyPassword. +# No server roles or database users are granted; tests must use master. +parameters: + - name: mode + type: string + default: setup + values: + - setup + - cleanup + + - name: instanceName + type: string + default: MSSQLSERVER + + # Must match the SQL listener, not a client alias. + - name: port + type: string + default: '1433' + + - name: variablePrefix + type: string + default: netOnly + values: + - netOnly + - netOnlyPeer + +steps: + - task: PowerShell@2 + displayName: '${{ parameters.mode }} temporary ${{ parameters.variablePrefix }} account [Win]' + ${{ if eq(parameters.mode, 'cleanup') }}: + condition: always() + inputs: + targetType: inline + pwsh: false + script: | + & "$(Build.SourcesDirectory)\eng\scripts\Configure-NetOnlyAccount.ps1" ` + -Mode $env:NETONLY_MODE -InstanceName $env:NETONLY_INSTANCE -Port $env:NETONLY_PORT ` + -AccountName $env:NETONLY_ACCOUNT -AccountSid $env:NETONLY_SID ` + -VariablePrefix $env:NETONLY_PREFIX + env: + NETONLY_MODE: ${{ parameters.mode }} + NETONLY_INSTANCE: ${{ parameters.instanceName }} + NETONLY_PORT: ${{ parameters.port }} + NETONLY_PREFIX: ${{ parameters.variablePrefix }} + # Undefined ADO macros are treated as absent by cleanup. + NETONLY_ACCOUNT: $(${{ parameters.variablePrefix }}User) + NETONLY_SID: $(${{ parameters.variablePrefix }}Sid) diff --git a/eng/pipelines/common/templates/steps/run-all-tests-step.yml b/eng/pipelines/common/templates/steps/run-all-tests-step.yml index 928c2e3abc..3bf9a9cd7c 100644 --- a/eng/pipelines/common/templates/steps/run-all-tests-step.yml +++ b/eng/pipelines/common/templates/steps/run-all-tests-step.yml @@ -68,6 +68,10 @@ parameters: type: number default: 2 + - name: enableNetOnlyTests + type: boolean + default: false + steps: - ${{ if parameters.debug }}: - powershell: 'dotnet sdk check' @@ -192,6 +196,16 @@ steps: - task: DotNetCoreCLI@2 displayName: 'Run Manual Tests ${{parameters.msbuildArchitecture }}' condition: and(eq(variables['setupSucceeded'], 'true'), succeededOrFailed()) + ${{ if parameters.enableNetOnlyTests }}: + env: + SQLCLIENT_NETONLY_REQUIRED: 'true' + SQLCLIENT_NETONLY_USER: $(netOnlyUser) + SQLCLIENT_NETONLY_DOMAIN: $(netOnlyDomain) + SQLCLIENT_NETONLY_PASSWORD: $(netOnlyPassword) + SQLCLIENT_NETONLY_CONNECTION_STRING: $(netOnlyConnectionString) + SQLCLIENT_NETONLY_PEER_USER: $(netOnlyPeerUser) + SQLCLIENT_NETONLY_PEER_DOMAIN: $(netOnlyPeerDomain) + SQLCLIENT_NETONLY_PEER_PASSWORD: $(netOnlyPeerPassword) inputs: command: build projects: build.proj diff --git a/eng/pipelines/pr/jobs/test-sqlclientmanual-job.yml b/eng/pipelines/pr/jobs/test-sqlclientmanual-job.yml index c81fd8304d..6ae22e7824 100644 --- a/eng/pipelines/pr/jobs/test-sqlclientmanual-job.yml +++ b/eng/pipelines/pr/jobs/test-sqlclientmanual-job.yml @@ -208,9 +208,28 @@ jobs: Write-Host "##vso[task.setvariable variable=TEST_MDS_CONFIG]$configPath" displayName: 'Generate manual test config' + - ${{ if and(eq(parameters.platformOperatingSystem, 'Windows'), parameters.isLocalServer, eq(parameters.configDisplayName, 'localhost'), contains(parameters.testSet, '3')) }}: + - template: /eng/pipelines/common/templates/steps/configure-netonly-account-step.yml@self + parameters: + mode: setup + - template: /eng/pipelines/common/templates/steps/configure-netonly-account-step.yml@self + parameters: + mode: setup + variablePrefix: netOnlyPeer + # Execute TestSqlClientManual target from build.proj - task: DotNetCoreCLI@2 displayName: 'Test: SqlClientManual ${{ parameters.testSet }}' + ${{ if and(eq(parameters.platformOperatingSystem, 'Windows'), parameters.isLocalServer, eq(parameters.configDisplayName, 'localhost'), contains(parameters.testSet, '3')) }}: + env: + SQLCLIENT_NETONLY_REQUIRED: 'true' + SQLCLIENT_NETONLY_USER: $(netOnlyUser) + SQLCLIENT_NETONLY_DOMAIN: $(netOnlyDomain) + SQLCLIENT_NETONLY_PASSWORD: $(netOnlyPassword) + SQLCLIENT_NETONLY_CONNECTION_STRING: $(netOnlyConnectionString) + SQLCLIENT_NETONLY_PEER_USER: $(netOnlyPeerUser) + SQLCLIENT_NETONLY_PEER_DOMAIN: $(netOnlyPeerDomain) + SQLCLIENT_NETONLY_PEER_PASSWORD: $(netOnlyPeerPassword) inputs: command: build projects: build.proj @@ -224,6 +243,15 @@ jobs: -p:TestSet=${{ parameters.testSet }} -p:TestResultsFolderPath="${{ variables.testResultsPath }}" + - ${{ if and(eq(parameters.platformOperatingSystem, 'Windows'), parameters.isLocalServer, eq(parameters.configDisplayName, 'localhost'), contains(parameters.testSet, '3')) }}: + - template: /eng/pipelines/common/templates/steps/configure-netonly-account-step.yml@self + parameters: + mode: cleanup + - template: /eng/pipelines/common/templates/steps/configure-netonly-account-step.yml@self + parameters: + mode: cleanup + variablePrefix: netOnlyPeer + # Publish test results - template: /eng/pipelines/pr/steps/publish-test-results-step.yml@self parameters: diff --git a/eng/scripts/Configure-NetOnlyAccount.ps1 b/eng/scripts/Configure-NetOnlyAccount.ps1 new file mode 100644 index 0000000000..679354ae38 --- /dev/null +++ b/eng/scripts/Configure-NetOnlyAccount.ps1 @@ -0,0 +1,223 @@ +# Licensed to the .NET Foundation under one or more agreements. +# The .NET Foundation licenses this file to you under the MIT license. +# See the LICENSE file in the project root for more information. + +<# +.SYNOPSIS +Creates or removes a temporary local Windows account and its local SQL Windows login. +.DESCRIPTION +Run setup and cleanup on the same Windows agent using Windows PowerShell 5.1, +as a local administrator with permission to create and drop SQL logins. +Setup publishes the account identity before provisioning and registers the +generated password only as a secret Azure DevOps variable. It publishes a +dedicated TCP/master integrated connection string as netOnlyConnectionString; +the caller maps it to SQLCLIENT_NETONLY_CONNECTION_STRING for manual tests. +Port must match the configured local SQL instance TCP listener. Cleanup accepts +netOnlyUser and netOnlySid from that job; blank/unexpanded values are safe. +No policy, group membership, server role, or database user is changed. +VariablePrefix selects independent netOnly or netOnlyPeer lifecycle variables. +#> +[CmdletBinding()] +param( + [Parameter(Mandatory = $true)] + [ValidateSet('setup', 'cleanup')] + [string] $Mode, + + [ValidatePattern('^[A-Za-z0-9_][A-Za-z0-9_$-]{0,127}$')] + [string] $InstanceName = 'MSSQLSERVER', + + [ValidateRange(1, 65535)] + [int] $Port = 1433, + + [ValidateSet('netOnly', 'netOnlyPeer')] + [string] $VariablePrefix = 'netOnly', + + [AllowEmptyString()] + [string] $AccountName = '', + + [AllowEmptyString()] + [string] $AccountSid = '' +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +function Set-PipelineVariable { + param([string] $Name, [string] $Value, [switch] $Secret) + $Name = $VariablePrefix + $Name.Substring('netOnly'.Length) + $escaped = $Value.Replace('%', '%AZP25').Replace("`r", '%0D').Replace("`n", '%0A') + $secretFlag = if ($Secret) { 'true' } else { 'false' } + Write-Host "##vso[task.setvariable variable=$Name;issecret=$secretFlag]$escaped" +} + +function Invoke-NetOnlySql { + param([string] $Query, [string] $LoginName, [string] $SidHex) + $builder = New-Object System.Data.SqlClient.SqlConnectionStringBuilder + # Shared memory forces a local connection without SQL Browser/TCP configuration. + $builder['Data Source'] = 'lpc:.' + if ($InstanceName -ne 'MSSQLSERVER') { + $builder['Data Source'] = "lpc:.\$InstanceName" + } + $builder['Initial Catalog'] = 'master' + $builder['Integrated Security'] = $true + $builder['Connect Timeout'] = 15 + $connection = New-Object System.Data.SqlClient.SqlConnection $builder.ConnectionString + try { + $connection.Open() + $command = $connection.CreateCommand() + try { + $command.CommandTimeout = 30 + $command.CommandText = $Query + [void] $command.Parameters.Add('@login', [System.Data.SqlDbType]::NVarChar, 128) + $command.Parameters['@login'].Value = $LoginName + [void] $command.Parameters.Add('@sid', [System.Data.SqlDbType]::VarChar, 184) + $command.Parameters['@sid'].Value = $SidHex + [void] $command.ExecuteNonQuery() + } + finally { + $command.Dispose() + } + } + finally { + $connection.Dispose() + } +} + +function Get-AccountSidHex { + param([System.Security.Principal.SecurityIdentifier] $Sid) + $bytes = New-Object byte[] $Sid.BinaryLength + $Sid.GetBinaryForm($bytes, 0) + return '0x' + ([System.BitConverter]::ToString($bytes)).Replace('-', '') +} + +if ($Mode -eq 'cleanup' -and + ([string]::IsNullOrWhiteSpace($AccountName) -or $AccountName -eq ('$(' + $VariablePrefix + 'User)'))) { + Write-Host 'Net-only setup did not publish an account; cleanup has nothing to remove.' + return +} + +$operation = 'validate prerequisites' +try { + if ($env:OS -ne 'Windows_NT' -or -not [Environment]::Is64BitProcess) { + throw 'A 64-bit Windows PowerShell process on Windows is required.' + } + Import-Module Microsoft.PowerShell.LocalAccounts -ErrorAction Stop + Add-Type -AssemblyName System.Data + + if ($Mode -eq 'setup') { + $operation = 'select a unique local account name' + $AccountName = 'netonly_' + [Guid]::NewGuid().ToString('N').Substring(0, 12) + # Check before publishing; an existing account must never become cleanup's target. + $existing = Get-LocalUser | Where-Object { $_.Name -eq $AccountName } + if ($existing) { + throw "Local account '$AccountName' already exists; refusing to reuse it." + } + Set-PipelineVariable -Name netOnlyUser -Value $AccountName + Set-PipelineVariable -Name netOnlyDomain -Value $env:COMPUTERNAME + Set-PipelineVariable -Name netOnlySid -Value '' + + $operation = "create local account '$AccountName'" + $random = [System.Security.Cryptography.RandomNumberGenerator]::Create() + try { + $bytes = New-Object byte[] 32 + $random.GetBytes($bytes) + # Fixed character classes plus 256 random bits satisfy ordinary complexity rules. + $password = 'Aa1!' + [Convert]::ToBase64String($bytes) + } + finally { + $random.Dispose() + } + Set-PipelineVariable -Name netOnlyPassword -Value $password -Secret + $securePassword = ConvertTo-SecureString -String $password -AsPlainText -Force + $password = $null + try { + $account = New-LocalUser -Name $AccountName -Password $securePassword ` + -Description "SqlClient net-only: $AccountName" -ErrorAction Stop + } + finally { + $securePassword.Dispose() + } + $AccountSid = $account.SID.Value + Set-PipelineVariable -Name netOnlySid -Value $AccountSid + $sidHex = Get-AccountSidHex -Sid $account.SID + $loginName = "$env:COMPUTERNAME\$AccountName" + $quotedLogin = '[' + $loginName.Replace(']', ']]') + ']' + + $operation = "create SQL Windows login '$loginName' on '$InstanceName'" + Invoke-NetOnlySql -LoginName $loginName -SidHex $sidHex -Query @" +IF EXISTS (SELECT 1 FROM sys.server_principals WHERE name = @login) + THROW 51000, 'Net-only SQL login already exists; refusing to replace it.', 1; +CREATE LOGIN $quotedLogin FROM WINDOWS WITH DEFAULT_DATABASE = [master]; +"@ + Set-PipelineVariable -Name netOnlyConnectionString ` + -Value "Data Source=tcp:localhost,$Port;Initial Catalog=master;Integrated Security=true;Encrypt=false" + Write-Host "Created non-admin local account '$loginName' and Windows SQL login (default database master)." + } + else { + $operation = 'validate cleanup ownership' + if ($AccountName -notmatch '^netonly_[0-9a-f]{12}$') { + throw "Refusing cleanup of unexpected account name '$AccountName'." + } + $account = Get-LocalUser | Where-Object { $_.Name -eq $AccountName } + if ($AccountSid -eq ('$(' + $VariablePrefix + 'Sid)')) { + $AccountSid = '' + } + if ($account) { + if ($account.Description -ne "SqlClient net-only: $AccountName") { + throw "Refusing to delete local account '$AccountName': ownership description does not match." + } + if ($AccountSid -and $account.SID.Value -ne $AccountSid) { + throw "Refusing to delete local account '$AccountName': SID does not match setup." + } + $AccountSid = $account.SID.Value + } + $sidHex = '' + if ($AccountSid) { + $sid = New-Object System.Security.Principal.SecurityIdentifier $AccountSid + $sidHex = Get-AccountSidHex -Sid $sid + } + $loginName = "$env:COMPUTERNAME\$AccountName" + $quotedLogin = '[' + $loginName.Replace(']', ']]') + ']' + $operation = "clean up account '$loginName' on '$InstanceName'" + $failures = @() + try { + Invoke-NetOnlySql -LoginName $loginName -SidHex $sidHex -Query @" +IF EXISTS (SELECT 1 FROM sys.server_principals WHERE name = @login) +BEGIN + IF NOT EXISTS (SELECT 1 FROM sys.server_principals + WHERE name = @login AND type = 'U' + AND sid = CONVERT(varbinary(85), NULLIF(@sid, ''), 1)) + THROW 51001, 'Refusing to drop net-only SQL login: Windows SID does not match setup.', 1; + DROP LOGIN $quotedLogin; +END; +"@ + Write-Host "SQL login '$loginName' removed (or already absent)." + } + catch { + $failures += "SQL login cleanup on '$InstanceName' failed: $($_.Exception.Message)" + Write-Host "##[error]$($failures[-1])" + } + try { + if ($account) { + Remove-LocalUser -SID $account.SID -ErrorAction Stop + } + Write-Host "Local account '$AccountName' removed (or already absent)." + } + catch { + $failures += "Local account cleanup failed: $($_.Exception.Message)" + Write-Host "##[error]$($failures[-1])" + } + if ($failures.Count -gt 0) { + throw ($failures -join [Environment]::NewLine) + } + Set-PipelineVariable -Name netOnlyUser -Value '' + Set-PipelineVariable -Name netOnlySid -Value '' + Set-PipelineVariable -Name netOnlyDomain -Value '' + Set-PipelineVariable -Name netOnlyConnectionString -Value '' + Set-PipelineVariable -Name netOnlyPassword -Value '' -Secret + } +} +catch { + Write-Host "##[error]Net-only $Mode failed during '$operation': $($_.Exception.Message)" + throw +} diff --git a/src/Microsoft.Data.SqlClient/src/Interop/Windows/Advapi32/Advapi32.cs b/src/Microsoft.Data.SqlClient/src/Interop/Windows/Advapi32/Advapi32.cs new file mode 100644 index 0000000000..090dbccc74 --- /dev/null +++ b/src/Microsoft.Data.SqlClient/src/Interop/Windows/Advapi32/Advapi32.cs @@ -0,0 +1,79 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System.ComponentModel; +using System.Runtime.InteropServices; +using Microsoft.Win32.SafeHandles; + +namespace Interop.Windows.Advapi32 +{ + internal static class Advapi32 + { + internal static long GetAuthenticationId(SafeAccessTokenHandle token) + { + if (!GetTokenInformation(token, TokenInformationClass.TokenStatistics, out TokenStatistics statistics, + Marshal.SizeOf(), out _)) + { + throw new Win32Exception(Marshal.GetLastWin32Error()); + } + + return ((long)statistics.AuthenticationId.HighPart << 32) | statistics.AuthenticationId.LowPart; + } + + internal static bool GetIsTokenRestricted(SafeAccessTokenHandle token) + { + // FALSE means either unrestricted or failure. Clear the native last error first, + // including on .NET Framework, whose P/Invoke marshaller does not clear it. + Kernel32.Kernel32.SetLastError(0); + bool restricted = IsTokenRestricted(token); + int error = Marshal.GetLastWin32Error(); + if (!restricted && error != 0) + { + throw new Win32Exception(error); + } + + return restricted; + } + + [DllImport("advapi32.dll", ExactSpelling = true, SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool GetTokenInformation( + SafeAccessTokenHandle token, + TokenInformationClass informationClass, + out TokenStatistics statistics, + int bufferLength, + out int returnLength); + + [DllImport("advapi32.dll", ExactSpelling = true, SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool IsTokenRestricted(SafeAccessTokenHandle token); + + private enum TokenInformationClass + { + TokenStatistics = 10 + } + + [StructLayout(LayoutKind.Sequential)] + private struct Luid + { + internal uint LowPart; + internal int HighPart; + } + + [StructLayout(LayoutKind.Sequential)] + private struct TokenStatistics + { + internal Luid TokenId; + internal Luid AuthenticationId; + internal long ExpirationTime; + internal int TokenType; + internal int ImpersonationLevel; + internal uint DynamicCharged; + internal uint DynamicAvailable; + internal uint GroupCount; + internal uint PrivilegeCount; + internal Luid ModifiedId; + } + } +} diff --git a/src/Microsoft.Data.SqlClient/src/Interop/Windows/Kernel32/Kernel32.cs b/src/Microsoft.Data.SqlClient/src/Interop/Windows/Kernel32/Kernel32.cs index 5ff162d69e..053e99b74a 100644 --- a/src/Microsoft.Data.SqlClient/src/Interop/Windows/Kernel32/Kernel32.cs +++ b/src/Microsoft.Data.SqlClient/src/Interop/Windows/Kernel32/Kernel32.cs @@ -79,5 +79,8 @@ internal static extern int GetFullPathName( [DllImport(DllName, SetLastError = true, ExactSpelling = true)] internal static extern bool SetThreadErrorMode(uint dwNewMode, out uint lpOldMode); + + [DllImport(DllName, ExactSpelling = true, SetLastError = true)] + internal static extern void SetLastError(uint error); } } diff --git a/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/ConnectionPool/ChannelDbConnectionPool.cs b/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/ConnectionPool/ChannelDbConnectionPool.cs index 6e60f6cdbb..25d8c02029 100644 --- a/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/ConnectionPool/ChannelDbConnectionPool.cs +++ b/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/ConnectionPool/ChannelDbConnectionPool.cs @@ -1972,11 +1972,25 @@ private async Task RunWarmupLoopAsync() // WaitHandle pool, which skips replenishment while blocking. (Warmup still // participates in the error state on its own creations - entering it on failure and // clearing it on success - via OpenNewInternalConnection.) + DbConnectionPoolIdentity? poolIdentity = Identity; + DbConnectionPoolIdentity? currentIdentity = poolIdentity != null && poolIdentity != DbConnectionPoolIdentity.NoIdentity + ? DbConnectionPoolIdentity.GetCurrent() + : null; + while (State == Running && !token.IsCancellationRequested && !ErrorOccurred && _connectionSlots.ReservationCount < MinPoolSize) { + // Match the legacy pool: background creation must use the pool's logon session. + // A mismatched context skips warmup; user opens can still create on demand. + if (poolIdentity != null && currentIdentity != null && !poolIdentity.Equals(currentIdentity)) + { + SqlClientEventSource.Log.TryPoolerTraceEvent( + "ChannelDbConnectionPool.RunWarmupLoopAsync | INFO | {0}, Skipping warmup for a different identity.", Id); + break; + } + // Fresh per-attempt timeout budget based on the pool's CreationTimeout, since // warmup has no owning Open() call to inherit a budget from. Matches the // replenishment behavior of the legacy WaitHandle pool. diff --git a/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/ConnectionPool/DbConnectionPoolIdentity.cs b/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/ConnectionPool/DbConnectionPoolIdentity.cs index 940a10271f..6b7ff19cec 100644 --- a/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/ConnectionPool/DbConnectionPoolIdentity.cs +++ b/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/ConnectionPool/DbConnectionPoolIdentity.cs @@ -4,6 +4,7 @@ using System; using System.Security.Principal; +using Interop.Windows.Advapi32; namespace Microsoft.Data.SqlClient.ConnectionPool { @@ -17,14 +18,17 @@ internal sealed partial class DbConnectionPoolIdentity private readonly string _sidString; private readonly bool _isRestricted; private readonly bool _isNetwork; + private readonly long _authenticationId; private readonly int _hashCode; - private DbConnectionPoolIdentity(string sidString, bool isRestricted, bool isNetwork) + private DbConnectionPoolIdentity(string sidString, bool isRestricted, bool isNetwork, long authenticationId = 0) { _sidString = sidString; _isRestricted = isRestricted; _isNetwork = isNetwork; - _hashCode = sidString == null ? 0 : sidString.GetHashCode(); + _authenticationId = authenticationId; + _hashCode = unchecked(((((sidString == null ? 0 : sidString.GetHashCode()) * 397) ^ authenticationId.GetHashCode()) * 397 + ^ (isRestricted ? 1 : 0)) * 397 ^ (isNetwork ? 1 : 0)); } // @TODO: Make auto-property @@ -41,7 +45,8 @@ public override bool Equals(object value) DbConnectionPoolIdentity that = (DbConnectionPoolIdentity)value; result = _sidString == that._sidString && _isRestricted == that._isRestricted && - _isNetwork == that._isNetwork; + _isNetwork == that._isNetwork && + _authenticationId == that._authenticationId; } return result; @@ -54,9 +59,7 @@ public override int GetHashCode() internal static DbConnectionPoolIdentity GetCurrent() { - return LocalAppContextSwitches.UseManagedNetworking - ? GetCurrentManaged() - : GetCurrentNative(); + return OsConstants.IsWindows ? GetCurrentWindows() : GetCurrentManaged(); } #if NETFRAMEWORK @@ -84,30 +87,32 @@ private static DbConnectionPoolIdentity GetCurrentManaged() return current; } - private static DbConnectionPoolIdentity GetCurrentNative() + private static DbConnectionPoolIdentity GetCurrentWindows() { DbConnectionPoolIdentity current; using (WindowsIdentity identity = WindowsIdentity.GetCurrent()) { - IntPtr token = identity.AccessToken.DangerousGetHandle(); SecurityIdentifier user = identity.User; bool isNetwork = user.IsWellKnown(WellKnownSidType.NetworkSid); string sidString = user.Value; - // Win32NativeMethods.IsTokenRestricted will raise exception if the native call fails - SniNativeWrapper.SniIsTokenRestricted(token, out bool isRestricted); + // NEW_CREDENTIALS logons retain the local SID but use different outbound credentials. + // Key by the logon session, not TokenId, so duplicated tokens still share a pool. + long authenticationId = Advapi32.GetAuthenticationId(identity.AccessToken); + bool isRestricted = Advapi32.GetIsTokenRestricted(identity.AccessToken); var lastIdentity = s_lastIdentity; if (lastIdentity != null && lastIdentity._sidString == sidString && lastIdentity._isRestricted == isRestricted && - lastIdentity._isNetwork == isNetwork) + lastIdentity._isNetwork == isNetwork && + lastIdentity._authenticationId == authenticationId) { current = lastIdentity; } else { - current = new DbConnectionPoolIdentity(sidString, isRestricted, isNetwork); + current = new DbConnectionPoolIdentity(sidString, isRestricted, isNetwork, authenticationId); } } s_lastIdentity = current; @@ -115,4 +120,3 @@ private static DbConnectionPoolIdentity GetCurrentNative() } } } - diff --git a/src/Microsoft.Data.SqlClient/tests/Common/WindowsImpersonationHelper.cs b/src/Microsoft.Data.SqlClient/tests/Common/WindowsImpersonationHelper.cs new file mode 100644 index 0000000000..42d74bffde --- /dev/null +++ b/src/Microsoft.Data.SqlClient/tests/Common/WindowsImpersonationHelper.cs @@ -0,0 +1,48 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System.ComponentModel; +using System.Runtime.InteropServices; +using Microsoft.Win32.SafeHandles; + +namespace Microsoft.Data.SqlClient.Tests.Common; + +/// +/// Creates Windows network-only logon tokens without storing credentials in test configuration. +/// +public static class WindowsImpersonationHelper +{ + private const int Logon32LogonNewCredentials = 9; + private const int Logon32ProviderWinnt50 = 3; + + /// + /// Creates a new logon session with alternate outbound credentials. Windows does not validate + /// these credentials until network authentication, so unit tests can use placeholders. + /// + /// The outbound account name. + /// The outbound account's domain. + /// The outbound password. + /// A token owned by the caller, which must be disposed. + public static SafeAccessTokenHandle LogonNetOnly(string userName, string domain, string password) + { + if (!LogonUser(userName, domain, password, Logon32LogonNewCredentials, Logon32ProviderWinnt50, out SafeAccessTokenHandle token)) + { + int error = Marshal.GetLastWin32Error(); + token?.Dispose(); + throw new Win32Exception(error); + } + + return token; + } + + [DllImport("advapi32.dll", EntryPoint = "LogonUserW", CharSet = CharSet.Unicode, ExactSpelling = true, SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool LogonUser( + string userName, + string domain, + string password, + int logonType, + int logonProvider, + out SafeAccessTokenHandle token); +} diff --git a/src/Microsoft.Data.SqlClient/tests/ManualTests/SQL/IntegratedAuthenticationTest/OutboundCredentialNameTest.cs b/src/Microsoft.Data.SqlClient/tests/ManualTests/SQL/IntegratedAuthenticationTest/OutboundCredentialNameTest.cs new file mode 100644 index 0000000000..cd38153125 --- /dev/null +++ b/src/Microsoft.Data.SqlClient/tests/ManualTests/SQL/IntegratedAuthenticationTest/OutboundCredentialNameTest.cs @@ -0,0 +1,453 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using System.ComponentModel; +using System.Runtime.InteropServices; +using System.Security.Principal; +using System.Text; +using System.Threading.Tasks; +using Microsoft.Data.SqlClient.Tests.Common; +using Microsoft.Win32.SafeHandles; +using Xunit; +using Xunit.Abstractions; + +namespace Microsoft.Data.SqlClient.ManualTesting.Tests +{ + /// + /// Compares targetless outbound SSPI names with actual unpooled SQL authentication. + /// This experiment does not implement or change connection pooling. + /// + [Collection("PoolIdentityImpersonation")] + [Trait("Set", "3")] + public class OutboundCredentialNameTest + { + private readonly ITestOutputHelper _output; + + /// Captures non-secret observations in published test results. + public OutboundCredentialNameTest(ITestOutputHelper output) => _output = output; + + public static bool RunExperiment => PoolIdentityImpersonationTest.RequiresNetOnlyTests + || (PoolIdentityImpersonationTest.IsConfigured + && !string.IsNullOrWhiteSpace(Environment.GetEnvironmentVariable("SQLCLIENT_NETONLY_PEER_USER"))); + + /// + /// Tests two real accounts, independent same-account logons, invalid same-name credentials, + /// and restoration with both Open APIs and the CI-selected networking implementation. + /// + [ConditionalTheory(nameof(RunExperiment))] + [InlineData(false)] + [InlineData(true)] + public async Task OutboundName_UnpooledSqlAuthentication(bool async) + { + AssertConfiguration(); + await DiagnosePrequery(async); + string connectionString = ConnectionString(); + string processName = CredentialName(); + TokenStatistics processStatistics = Statistics(); + Login process = await ReadLogin(connectionString, async); + using SafeAccessTokenHandle first = CreateToken(false); + using SafeAccessTokenHandle repeated = CreateToken(false); + using SafeAccessTokenHandle peer = CreateToken(true); + string firstId = WindowsIdentity.RunImpersonated(first, () => Statistics().AuthenticationId.ToString()); + string repeatedId = WindowsIdentity.RunImpersonated(repeated, () => Statistics().AuthenticationId.ToString()); + Assert.NotEqual(firstId, repeatedId); + Login firstLogin = await Probe(first, false, connectionString, async, "A/session1"); + Login repeatedLogin = await Probe(repeated, false, connectionString, async, "A/session2"); + Login peerLogin = await Probe(peer, true, connectionString, async, "B"); + Assert.Equal(firstLogin.Sid, repeatedLogin.Sid); + Assert.NotEqual(firstLogin.Sid, peerLogin.Sid); + Assert.NotEqual(process.Sid, firstLogin.Sid); + + using SafeAccessTokenHandle invalid = WindowsImpersonationHelper.LogonNetOnly( + User(false), Domain(false), ""); + await WindowsIdentity.RunImpersonated(invalid, async () => + { + AssertCredentialName(false, CredentialName()); + SqlException failure = await Assert.ThrowsAsync(() => ReadLogin(connectionString, async)); + Assert.NotEqual(-2, failure.Number); + _output.WriteLine($"A/invalid: same SSPI name, SQL rejected credentials; error={failure.Number}."); + }); + await Probe(first, false, connectionString, async, "A/after-invalid"); + Assert.Equal(processName, CredentialName()); + Assert.Equal(processStatistics.AuthenticationId.ToString(), Statistics().AuthenticationId.ToString()); + Login restored = await ReadLogin(connectionString, async); + Assert.Equal(process.Original, restored.Original); + Assert.Equal(process.Sid, restored.Sid); + _output.WriteLine($"Restoration verified; async={async}; managedSNI={DataTestUtility.UseManagedSNIOnWindows}."); + _output.WriteLine("Kerberos/domain aliases are uncovered: these are local Windows accounts."); + } + + /// + /// Probes a SQL-SPN credential override only in a fresh disposable logon session, + /// observing whether a targetless name/cache predicts the actual SQL login. + /// + [ConditionalTheory(nameof(RunExperiment))] + [InlineData(false)] + [InlineData(true)] + public async Task OutboundName_SessionCredentialOverride(bool async) + { + AssertConfiguration(); + string connectionString = ConnectionString(); + var builder = new SqlConnectionStringBuilder(connectionString); + Assert.StartsWith("tcp:localhost,", builder.DataSource, StringComparison.OrdinalIgnoreCase); + string target = "MSSQLSvc/localhost:" + builder.DataSource.Substring("tcp:localhost,".Length); + TokenStatistics process = Statistics(); + Login processLogin = await ReadLogin(connectionString, async); + using SafeAccessTokenHandle token = CreateToken(false); + await WindowsIdentity.RunImpersonated(token, async () => + { + TokenStatistics before = Statistics(); + Assert.NotEqual(process.AuthenticationId.ToString(), before.AuthenticationId.ToString()); + string cachedName = CredentialName(); + AssertCredentialName(false, cachedName); + await ProbeCurrent(false, connectionString, async, "override/before"); + if (!WriteSessionCredential(target)) + { + return; + } + try + { + TokenStatistics after = Statistics(); + string freshName = CredentialName(); + _output.WriteLine($"Override written with CRED_PERSIST_SESSION; target={target}; " + + $"sameAuthenticationId={before.AuthenticationId.ToString() == after.AuthenticationId.ToString()}; " + + $"sameModifiedId={before.ModifiedId.ToString() == after.ModifiedId.ToString()}; " + + $"sameTargetlessName={string.Equals(cachedName, freshName, StringComparison.OrdinalIgnoreCase)}."); + Login actual = null; + try + { + actual = await ReadLogin(connectionString, async); + } + catch (SqlException ex) + { + _output.WriteLine($"Override SQL authentication failed; error={ex.Number}. Override selection is unverified."); + } + if (actual != null) + { + bool selectedPeer = string.Equals(actual.Original, Expected(true), StringComparison.OrdinalIgnoreCase); + Assert.True(selectedPeer || string.Equals(actual.Original, Expected(false), StringComparison.OrdinalIgnoreCase), + "SQL authenticated neither of the disposable accounts."); + AssertLogin(actual, selectedPeer); + Log("override/after", freshName, actual); + _output.WriteLine(selectedPeer + ? $"Override selected B; cachedNameMatchesSqlAccount={CredentialNameMatchesAccount(Domain(true), User(true), cachedName)}." + : "UNCOVERED: SQL retained A; this target override was not selected by this environment."); + } + } + finally + { + if (!CredDelete(target, CredentialTypeDomainPassword, 0)) + { + throw new Win32Exception(Marshal.GetLastWin32Error(), "Session credential cleanup failed."); + } + } + await ProbeCurrent(false, connectionString, async, "override/removed"); + }); + Assert.Equal(process.AuthenticationId.ToString(), Statistics().AuthenticationId.ToString()); + Assert.Equal(processLogin.Sid, (await ReadLogin(connectionString, async)).Sid); + } + + /// Fails designated CI coverage explicitly if either disposable account is absent. + private static void AssertConfiguration() + { + Assert.True(PoolIdentityImpersonationTest.IsConfigured, "Account A and the TCP SQL endpoint must be configured."); + Assert.False(string.IsNullOrWhiteSpace(User(true)), "Account B must be configured."); + Assert.False(string.IsNullOrWhiteSpace(Domain(true)), "Account B's domain must be configured."); + Assert.False(string.IsNullOrEmpty(Environment.GetEnvironmentVariable("SQLCLIENT_NETONLY_PEER_PASSWORD")), + "Account B's secret must be configured."); + Assert.False(string.Equals(Expected(false), Expected(true), StringComparison.OrdinalIgnoreCase), + "The experiment requires two distinct disposable accounts."); + } + + /// Builds a TCP/master connection that cannot reuse any pooled authentication. + private static string ConnectionString() => new SqlConnectionStringBuilder( + Environment.GetEnvironmentVariable("SQLCLIENT_NETONLY_CONNECTION_STRING")) + { + Pooling = false, + MinPoolSize = 0, + ConnectRetryCount = 0, + ConnectTimeout = 15 + }.ConnectionString; + + /// Returns the configured username for one disposable account. + private static string User(bool peer) => Environment.GetEnvironmentVariable( + peer ? "SQLCLIENT_NETONLY_PEER_USER" : "SQLCLIENT_NETONLY_USER"); + + /// Returns the configured local domain for one disposable account. + private static string Domain(bool peer) => Environment.GetEnvironmentVariable( + peer ? "SQLCLIENT_NETONLY_PEER_DOMAIN" : "SQLCLIENT_NETONLY_DOMAIN"); + + /// Returns the expected SQL Windows login for one disposable account. + private static string Expected(bool peer) => Domain(peer) + "\\" + User(peer); + + /// Creates a fresh outbound logon without validating or logging its secret. + private static SafeAccessTokenHandle CreateToken(bool peer) => WindowsImpersonationHelper.LogonNetOnly( + User(peer), Domain(peer), Environment.GetEnvironmentVariable( + peer ? "SQLCLIENT_NETONLY_PEER_PASSWORD" : "SQLCLIENT_NETONLY_PASSWORD")); + + /// Queries SSPI and SQL inside the same impersonation scope, awaiting all SQL work. + private Task Probe(SafeAccessTokenHandle token, bool peer, string connectionString, bool async, string label) => + WindowsIdentity.RunImpersonated(token, () => ProbeCurrent(peer, connectionString, async, label)); + + /// Isolates name-query effects using the passing regression preflight's unpooled open path. + private async Task DiagnosePrequery(bool async) + { + string connectionString = new SqlConnectionStringBuilder(PoolIdentityImpersonationTest.BuildConnectionString()) + { + Pooling = false, + MinPoolSize = 0 + }.ConnectionString; + Log("Control/cacheable-process", "(no SSPI query)", await ReadLogin(connectionString, async, false)); + using SafeAccessTokenHandle control = CreateToken(false); + using SafeAccessTokenHandle queried = CreateToken(false); + string controlLogin = await WindowsIdentity.RunImpersonated(control, async () => + { + string beforeOpen = Statistics().AuthenticationId.ToString(); + string login = await PoolIdentityImpersonationTest.GetLogin(connectionString, async); + _output.WriteLine($"Control/no-SSPI: beforeOpenAuthenticationId={beforeOpen}; " + + $"afterOpenAuthenticationId={Statistics().AuthenticationId}; SQL={login}; async={async}."); + Login cached = await ReadLogin(connectionString, async, false); + Login recompiled = await ReadLogin(connectionString, async); + Log("Control/cacheable-A", "(no SSPI query)", cached); + Log("Control/recompiled-A", "(no SSPI query)", recompiled); + AssertName(Expected(false), cached.Original); + AssertLogin(recompiled, false); + return login; + }); + string queriedLogin = await WindowsIdentity.RunImpersonated(queried, async () => + { + string lookupId = Statistics().AuthenticationId.ToString(); + string name = CredentialName(); + string beforeOpen = Statistics().AuthenticationId.ToString(); + string login = await PoolIdentityImpersonationTest.GetLogin(connectionString, async); + _output.WriteLine($"Control/acquire-query-free: SSPI={name}; lookupAuthenticationId={lookupId}; " + + $"beforeOpenAuthenticationId={beforeOpen}; afterOpenAuthenticationId={Statistics().AuthenticationId}; " + + $"SQL={login}; async={async}."); + Assert.Equal(lookupId, beforeOpen); + AssertCredentialName(false, name); + return login; + }); + AssertName(Expected(false), controlLogin); + AssertName(Expected(false), queriedLogin); + } + + /// Checks the preconnection name and the authenticated SQL name and SID. + private async Task ProbeCurrent(bool peer, string connectionString, bool async, string label) + { + string lookupId = Statistics().AuthenticationId.ToString(); + string name = CredentialName(); + AssertCredentialName(peer, name); + string beforeOpen = Statistics().AuthenticationId.ToString(); + Login login = await ReadLogin(connectionString, async); + Log(label, name, login); + _output.WriteLine($"{label}: lookupAuthenticationId={lookupId}; beforeOpenAuthenticationId={beforeOpen}; " + + $"afterOpenAuthenticationId={Statistics().AuthenticationId}; async={async}; " + + $"managedSNI={DataTestUtility.UseManagedSNIOnWindows}."); + Assert.Equal(lookupId, beforeOpen); + AssertLogin(login, peer); + return login; + } + + /// Checks the two observed SSPI formats against the exact configured local account. + private static void AssertCredentialName(bool peer, string actual) => + Assert.True(CredentialNameMatchesAccount(Domain(peer), User(peer), actual), + $"Expected SSPI name for '{Expected(peer)}', received '{actual}'."); + + /// Recognizes local SAM and user-at-machine forms without resolving domain aliases. + private static bool CredentialNameMatchesAccount(string domain, string user, string actual) => + string.Equals(domain + "\\" + user, actual, StringComparison.OrdinalIgnoreCase) + || string.Equals(user + "@" + domain, actual, StringComparison.OrdinalIgnoreCase); + + /// Preserves exact account matching across the two SSPI formats observed in CI. + [Theory] + [InlineData(@"machine\account", true)] + [InlineData("account@machine", true)] + [InlineData(@"MACHINE\ACCOUNT", true)] + [InlineData("ACCOUNT@MACHINE", true)] + [InlineData(@"machine\other", false)] + [InlineData("other@machine", false)] + [InlineData(@"other\account", false)] + [InlineData("account@other", false)] + public void OutboundName_LocalAccountFormats(string actual, bool expected) => + Assert.Equal(expected, CredentialNameMatchesAccount("machine", "account", actual)); + + /// Checks SQL SAM-form names without treating case differences as distinct accounts. + private static void AssertName(string expected, string actual) => + Assert.True(string.Equals(expected, actual, StringComparison.OrdinalIgnoreCase), + $"Expected '{expected}', received '{actual}'."); + + /// Verifies both SQL name functions, Windows SID, NTLM authentication and TCP. + private static void AssertLogin(Login login, bool peer) + { + AssertName(Expected(peer), login.Original); + AssertName(Expected(peer), login.Current); + Assert.Equal(((SecurityIdentifier)new NTAccount(Domain(peer), User(peer)) + .Translate(typeof(SecurityIdentifier))).Value, login.Sid); + Assert.Equal("NTLM", login.Authentication); + Assert.Equal("TCP", login.Transport); + } + + /// Publishes only identities and protocol observations, never credentials. + private void Log(string label, string name, Login login) => _output.WriteLine( + $"{label}: SSPI={name}; ORIGINAL_LOGIN={login.Original}; SUSER_SNAME={login.Current}; " + + $"SUSER_SID={login.Sid}; auth_scheme={login.Authentication}; transport={login.Transport}."); + + /// Reads current-session properties, normally recompiling context-sensitive scalar expressions. + private static async Task ReadLogin(string connectionString, bool async, bool recompile = true) + { + using var connection = new SqlConnection(connectionString); + if (async) await connection.OpenAsync(); + else connection.Open(); + using var command = new SqlCommand( + "SELECT ORIGINAL_LOGIN(), SUSER_SNAME(), SUSER_SID(), " + + "CONVERT(varchar(30), CONNECTIONPROPERTY('auth_scheme')), " + + "CONVERT(varchar(30), CONNECTIONPROPERTY('net_transport'))" + + (recompile ? " OPTION (RECOMPILE)" : ""), connection); + using SqlDataReader reader = async ? await command.ExecuteReaderAsync() : command.ExecuteReader(); + Assert.True(async ? await reader.ReadAsync() : reader.Read()); + return new Login + { + Original = reader.GetString(0), + Current = reader.GetString(1), + Sid = new SecurityIdentifier((byte[])reader.GetValue(2), 0).Value, + Authentication = reader.GetString(3), + Transport = reader.GetString(4) + }; + } + + /// Acquires a fresh targetless Negotiate credential under the effective identity. + private static string CredentialName() + { + CheckStatus(AcquireCredentialsHandle(null, "Negotiate", 2, IntPtr.Zero, IntPtr.Zero, + IntPtr.Zero, IntPtr.Zero, out CredentialHandle handle, out _)); + try + { + CheckStatus(QueryCredentialsAttributes(ref handle, 1, out IntPtr name)); + try { return Marshal.PtrToStringUni(name); } + finally { CheckStatus(FreeContextBuffer(name)); } + } + finally { CheckStatus(FreeCredentialsHandle(ref handle)); } + } + + /// Reports native SSPI errors without credential data. + private static void CheckStatus(int status) + { + if (status != 0) throw new Win32Exception(status, $"SSPI status 0x{status:X8}."); + } + + /// Snapshots the effective token's authentication and modification IDs. + private static TokenStatistics Statistics() + { + using WindowsIdentity identity = WindowsIdentity.GetCurrent(); + if (!GetTokenInformation(identity.AccessToken, 10, out TokenStatistics statistics, + Marshal.SizeOf(), out _)) + throw new Win32Exception(Marshal.GetLastWin32Error()); + return statistics; + } + + /// + /// Writes B's credential with session-only persistence; callers must already be in a + /// fresh NEW_CREDENTIALS session and delete the exact target in finally. + /// + private bool WriteSessionCredential(string target) + { + byte[] bytes = Encoding.Unicode.GetBytes(Environment.GetEnvironmentVariable("SQLCLIENT_NETONLY_PEER_PASSWORD")); + IntPtr blob = Marshal.AllocHGlobal(bytes.Length); + try + { + Marshal.Copy(bytes, 0, blob, bytes.Length); + var credential = new Credential + { + Type = CredentialTypeDomainPassword, + TargetName = target, + BlobSize = (uint)bytes.Length, + Blob = blob, + Persist = 1, // CRED_PERSIST_SESSION: never write the agent's ordinary credential store. + UserName = Expected(true) + }; + if (CredWrite(ref credential, 0)) return true; + int error = Marshal.GetLastWin32Error(); + if (error != 5 && error != 50 && error != 1312) + throw new Win32Exception(error, "Session credential write failed."); + _output.WriteLine($"UNCOVERED: session Credential Manager override unavailable; Win32={error}."); + return false; + } + finally + { + Array.Clear(bytes, 0, bytes.Length); + Marshal.Copy(bytes, 0, blob, bytes.Length); + Marshal.FreeHGlobal(blob); + } + } + + private sealed class Login + { + internal string Original, Current, Sid, Authentication, Transport; + } + + [StructLayout(LayoutKind.Sequential)] + private struct Luid + { + internal uint Low; + internal int High; + public override string ToString() => $"{High:X8}{Low:X8}"; + } + + [StructLayout(LayoutKind.Sequential)] + private struct TokenStatistics + { + internal Luid TokenId, AuthenticationId; + internal long Expiration; + internal int Type, Level; + internal uint Charged, Available, Groups, Privileges; + internal Luid ModifiedId; + } + + [StructLayout(LayoutKind.Sequential)] + private struct CredentialHandle { internal IntPtr Lower, Upper; } + + private const uint CredentialTypeDomainPassword = 2; + + [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)] + private struct Credential + { + internal uint Flags, Type; + internal string TargetName, Comment; + internal long LastWritten; + internal uint BlobSize; + internal IntPtr Blob; + internal uint Persist, AttributeCount; + internal IntPtr Attributes; + internal string TargetAlias, UserName; + } + + [DllImport("secur32.dll", EntryPoint = "AcquireCredentialsHandleW", CharSet = CharSet.Unicode, ExactSpelling = true)] + private static extern int AcquireCredentialsHandle(string principal, string package, uint use, + IntPtr logonId, IntPtr authData, IntPtr getKey, IntPtr getKeyArgument, + out CredentialHandle handle, out long expiry); + + [DllImport("secur32.dll", EntryPoint = "QueryCredentialsAttributesW", ExactSpelling = true)] + private static extern int QueryCredentialsAttributes(ref CredentialHandle handle, uint attribute, out IntPtr name); + + [DllImport("secur32.dll", ExactSpelling = true)] + private static extern int FreeCredentialsHandle(ref CredentialHandle handle); + + [DllImport("secur32.dll", ExactSpelling = true)] + private static extern int FreeContextBuffer(IntPtr buffer); + + [DllImport("advapi32.dll", ExactSpelling = true, SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool GetTokenInformation(SafeAccessTokenHandle token, int kind, + out TokenStatistics statistics, int size, out int needed); + + [DllImport("advapi32.dll", EntryPoint = "CredWriteW", CharSet = CharSet.Unicode, ExactSpelling = true, SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool CredWrite(ref Credential credential, uint flags); + + [DllImport("advapi32.dll", EntryPoint = "CredDeleteW", CharSet = CharSet.Unicode, ExactSpelling = true, SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool CredDelete(string target, uint type, uint flags); + } +} diff --git a/src/Microsoft.Data.SqlClient/tests/ManualTests/SQL/IntegratedAuthenticationTest/PoolIdentityImpersonationTest.cs b/src/Microsoft.Data.SqlClient/tests/ManualTests/SQL/IntegratedAuthenticationTest/PoolIdentityImpersonationTest.cs new file mode 100644 index 0000000000..502ce5adc4 --- /dev/null +++ b/src/Microsoft.Data.SqlClient/tests/ManualTests/SQL/IntegratedAuthenticationTest/PoolIdentityImpersonationTest.cs @@ -0,0 +1,345 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using System.Collections.Generic; +using System.Runtime.InteropServices; +using System.Security.Principal; +using System.Threading.Tasks; +using Microsoft.Data.SqlClient.Tests.Common; +using Microsoft.Win32.SafeHandles; +using Xunit; + +namespace Microsoft.Data.SqlClient.ManualTesting.Tests +{ + /// + /// Serializes tests that switch pool implementations and exercise real Windows credentials. + /// + [CollectionDefinition("PoolIdentityImpersonation", DisableParallelization = true)] + public sealed class PoolIdentityImpersonationCollection + { + } + + /// + /// Regression coverage for #4717 against a real SQL Server. Invalid-credential probes need + /// only integrated security; alternate-account tests read credentials from environment + /// variables. + /// + [Collection("PoolIdentityImpersonation")] + [Trait("Set", "3")] + public class PoolIdentityImpersonationTest + { + public static bool IsConfigured => + RuntimeInformation.IsOSPlatform(OSPlatform.Windows) + && !string.IsNullOrWhiteSpace(Environment.GetEnvironmentVariable("SQLCLIENT_NETONLY_USER")) + && !string.IsNullOrWhiteSpace(Environment.GetEnvironmentVariable("SQLCLIENT_NETONLY_DOMAIN")) + && !string.IsNullOrEmpty(Environment.GetEnvironmentVariable("SQLCLIENT_NETONLY_PASSWORD")) + && (!string.IsNullOrWhiteSpace(Environment.GetEnvironmentVariable("SQLCLIENT_NETONLY_CONNECTION_STRING")) + || DataTestUtility.AreConnStringsSetup()); + + /// Identifies CI jobs where alternate-account coverage must execute. + public static bool RequiresNetOnlyTests => + string.Equals(Environment.GetEnvironmentVariable("SQLCLIENT_NETONLY_REQUIRED"), "true", StringComparison.OrdinalIgnoreCase); + + /// Runs required CI cases even when missing configuration must be reported. + public static bool RunConfiguredTests => RequiresNetOnlyTests || IsConfigured; + + /// + /// Needs only an integrated-security connection string: invalid network-only credentials + /// cannot open a new physical connection, so any successful open proves pool sharing. + /// + public static bool SupportsInvalidCredentialProbe => + RuntimeInformation.IsOSPlatform(OSPlatform.Windows) + && DataTestUtility.AreConnStringsSetup() + && DataTestUtility.IsIntegratedSecuritySetup(); + + /// + /// A network-only caller with unusable credentials must not receive idle or replenished + /// connections authenticated as the process account. + /// + [ConditionalTheory(nameof(SupportsInvalidCredentialProbe))] + [InlineData(false, false, 0)] + [InlineData(false, true, 0)] + [InlineData(true, false, 0)] + [InlineData(true, true, 0)] + [InlineData(false, false, 3)] + [InlineData(false, true, 3)] + [InlineData(true, false, 3)] + [InlineData(true, true, 3)] + public async Task InvalidNetOnlyCredentials_DoNotReuseProcessConnections(bool usePoolV2, bool async, int minPoolSize) + { + using var poolVersion = new ConnectionPoolVersionScope(usePoolV2); + using SafeAccessTokenHandle token = CreateInvalidToken(); + string connectionString = BuildConnectionString(minPoolSize: minPoolSize); + await SkipUnlessInvalidCredentialsRejected(token, connectionString, async); + try + { + string processLogin = await GetLogin(connectionString, async); + if (minPoolSize > 0) + { + // Give background replenishment time to fill the process pool. + await Task.Delay(TimeSpan.FromSeconds(2)); + } + + await Assert.ThrowsAsync( + () => WindowsIdentity.RunImpersonated(token, () => GetLogin(connectionString, async))); + Assert.Equal(processLogin, await GetLogin(connectionString, async)); + } + finally + { + using var poolConnection = new SqlConnection(connectionString); + SqlConnection.ClearPool(poolConnection); + } + } + + /// + /// A failed network-only open must not place the process account's pool in its error + /// blocking period. + /// + [ConditionalTheory(nameof(SupportsInvalidCredentialProbe))] + [InlineData(false, false)] + [InlineData(false, true)] + [InlineData(true, false)] + [InlineData(true, true)] + public async Task InvalidNetOnlyCredentials_FailureDoesNotBlockProcess(bool usePoolV2, bool async) + { + using var poolVersion = new ConnectionPoolVersionScope(usePoolV2); + using SafeAccessTokenHandle token = CreateInvalidToken(); + string connectionString = BuildConnectionString(); + await SkipUnlessInvalidCredentialsRejected(token, connectionString, async); + try + { + await Assert.ThrowsAsync( + () => WindowsIdentity.RunImpersonated(token, () => GetLogin(connectionString, async))); + Assert.False(string.IsNullOrEmpty(await GetLogin(connectionString, async))); + } + finally + { + using var poolConnection = new SqlConnection(connectionString); + SqlConnection.ClearPool(poolConnection); + } + } + + /// + /// Connections returned under alternate outbound credentials must not be reused by the + /// process account after impersonation ends, for either pool and either Open API. + /// + [ConditionalTheory(nameof(RunConfiguredTests))] + [InlineData(false, false)] + [InlineData(false, true)] + [InlineData(true, false)] + [InlineData(true, true)] + public async Task NetOnlyImpersonation_RevertsLogin(bool usePoolV2, bool async) + { + AssertConfiguration(); + using var poolVersion = new ConnectionPoolVersionScope(usePoolV2); + using SafeAccessTokenHandle token = CreateToken(); + string connectionString = BuildConnectionString(); + string expected = await VerifyUnpooledLogins(token, connectionString, async); + try + { + string original = await GetLogin(connectionString, async); + string impersonated = await WindowsIdentity.RunImpersonated(token, () => GetLogin(connectionString, async)); + Assert.Equal(expected, impersonated); + Assert.NotEqual(original, impersonated); + Assert.Equal(original, await GetLogin(connectionString, async)); + } + finally + { + using var poolConnection = new SqlConnection(connectionString); + SqlConnection.ClearPool(poolConnection); + } + } + + /// + /// Returning an expired connection outside impersonation must not replenish the owning + /// pool with process-account connections or cache a process-account login failure. + /// + [ConditionalTheory(nameof(RunConfiguredTests))] + [InlineData(false, false)] + [InlineData(false, true)] + [InlineData(true, false)] + [InlineData(true, true)] + public async Task NetOnlyImpersonation_Replenishment_PreservesLogin(bool usePoolV2, bool async) + { + AssertConfiguration(); + using var poolVersion = new ConnectionPoolVersionScope(usePoolV2); + using SafeAccessTokenHandle token = CreateToken(); + string connectionString = BuildConnectionString(loadBalanceTimeout: 1, minPoolSize: 3); + string expected = await VerifyUnpooledLogins(token, connectionString, async); + var opened = new List(); + try + { + SqlConnection expiring = await WindowsIdentity.RunImpersonated(token, () => Open(connectionString, async)); + opened.Add(expiring); + for (int i = 0; i < 2; i++) + { + opened.Add(await WindowsIdentity.RunImpersonated(token, () => Open(connectionString, async))); + } + + // Exceed the one-second physical connection lifetime, then trigger replenishment + // under the process context. Both pools now have a minimum to replenish. + await Task.Delay(TimeSpan.FromMilliseconds(1500)); + expiring.Dispose(); + + // Hold every checkout so a correctly authenticated idle connection cannot hide a + // wrongly replenished one. MaxPoolSize bounds this probe at eight new checkouts. + for (int i = 0; i < 8; i++) + { + SqlConnection connection = await WindowsIdentity.RunImpersonated(token, () => Open(connectionString, async)); + opened.Add(connection); + Assert.Equal(expected, await SelectLogin(connection, async)); + await Task.Delay(TimeSpan.FromMilliseconds(100)); + } + } + finally + { + using var poolConnection = new SqlConnection(connectionString); + SqlConnection.ClearPool(poolConnection); + foreach (SqlConnection connection in opened) + { + connection.Dispose(); + } + } + } + + /// Fails required CI coverage instead of skipping when provisioning is incomplete. + private static void AssertConfiguration() + { + Assert.True(IsConfigured, "Network-only tests require Windows, configured credentials, and a SQL Server connection."); + if (RequiresNetOnlyTests) + { + string configured = Environment.GetEnvironmentVariable("SQLCLIENT_NETONLY_CONNECTION_STRING"); + Assert.False(string.IsNullOrWhiteSpace(configured), + "Required network-only tests need an explicit TCP connection string."); + Assert.StartsWith("tcp:", new SqlConnectionStringBuilder(configured).DataSource, StringComparison.OrdinalIgnoreCase); + } + } + + /// Proves outbound authentication uses the configured account before testing pooling. + /// The alternate outbound logon token. + /// The connection string to probe without pooling. + /// Whether to use asynchronous SQL APIs. + /// The verified alternate SQL login. + private static async Task VerifyUnpooledLogins( + SafeAccessTokenHandle token, string connectionString, bool async) + { + var unpooled = new SqlConnectionStringBuilder(connectionString) + { + Pooling = false, + MinPoolSize = 0 + }; + string alternate = await WindowsIdentity.RunImpersonated(token, () => GetLogin(unpooled.ConnectionString, async)); + string expected = $"{Environment.GetEnvironmentVariable("SQLCLIENT_NETONLY_DOMAIN")}\\{Environment.GetEnvironmentVariable("SQLCLIENT_NETONLY_USER")}"; + Assert.True(string.Equals(expected, alternate, StringComparison.OrdinalIgnoreCase), + $"Expected outbound login '{expected}', but SQL Server authenticated '{alternate}'."); + if (RequiresNetOnlyTests) + { + string original = await GetLogin(unpooled.ConnectionString, async); + Assert.False(string.Equals(original, alternate, StringComparison.OrdinalIgnoreCase), + "The process and network-only accounts must authenticate as different SQL logins."); + } + return alternate; + } + + /// Creates the configured network-only token without persisting credentials. + private static SafeAccessTokenHandle CreateToken() => + WindowsImpersonationHelper.LogonNetOnly( + Environment.GetEnvironmentVariable("SQLCLIENT_NETONLY_USER"), + Environment.GetEnvironmentVariable("SQLCLIENT_NETONLY_DOMAIN"), + Environment.GetEnvironmentVariable("SQLCLIENT_NETONLY_PASSWORD")); + + /// + /// Creates a network-only token with placeholder credentials; NEW_CREDENTIALS logons do + /// not validate them until a new outbound authentication is attempted. + /// + private static SafeAccessTokenHandle CreateInvalidToken() => + WindowsImpersonationHelper.LogonNetOnly("", "EXAMPLE", ""); + + /// + /// The invalid-credential probes rely on the server rejecting the placeholder outbound + /// credentials. Some environments (for example, loopback authentication that falls back + /// to the local logon session) accept them, so a successful open would not prove pool + /// sharing. Verify the precondition with an unpooled connection and skip otherwise. + /// + private static async Task SkipUnlessInvalidCredentialsRejected( + SafeAccessTokenHandle token, string connectionString, bool async) + { + var unpooled = new SqlConnectionStringBuilder(connectionString) + { + Pooling = false, + MinPoolSize = 0 + }; + try + { + await WindowsIdentity.RunImpersonated(token, () => GetLogin(unpooled.ConnectionString, async)); + } + catch (SqlException) + { + return; + } + + throw new Microsoft.DotNet.XUnitExtensions.SkipTestException( + "The server accepted placeholder network-only credentials, so pool sharing cannot be detected."); + } + + /// Creates a unique pool group using the configured server and integrated security. + internal static string BuildConnectionString(int loadBalanceTimeout = 0, int minPoolSize = 0) + { + string configured = Environment.GetEnvironmentVariable("SQLCLIENT_NETONLY_CONNECTION_STRING"); + var builder = new SqlConnectionStringBuilder( + string.IsNullOrWhiteSpace(configured) ? DataTestUtility.TCPConnectionString : configured) + { + IntegratedSecurity = true, + Pooling = true, + ApplicationName = $"PoolIdentityImpersonation-{Guid.NewGuid():N}", + LoadBalanceTimeout = loadBalanceTimeout, + MinPoolSize = minPoolSize, + MaxPoolSize = 10, + ConnectTimeout = 15 + }; + builder.Remove("User ID"); + builder.Remove("Password"); + builder.Remove("Authentication"); + return builder.ConnectionString; + } + + /// Opens and disposes a connection after reading its authenticated SQL login. + internal static async Task GetLogin(string connectionString, bool async) + { + using SqlConnection connection = await Open(connectionString, async); + return await SelectLogin(connection, async); + } + + /// Opens a connection with the selected API, disposing it on failure. + private static async Task Open(string connectionString, bool async) + { + var connection = new SqlConnection(connectionString); + try + { + if (async) + { + await connection.OpenAsync(); + } + else + { + connection.Open(); + } + return connection; + } + catch + { + connection.Dispose(); + throw; + } + } + + /// Reads the login that authenticated the physical connection. + private static async Task SelectLogin(SqlConnection connection, bool async) + { + using var command = new SqlCommand("SELECT ORIGINAL_LOGIN()", connection); + return (string)(async ? await command.ExecuteScalarAsync() : command.ExecuteScalar()); + } + } +} diff --git a/src/Microsoft.Data.SqlClient/tests/UnitTests/ConnectionPool/DbConnectionPoolIdentityTest.cs b/src/Microsoft.Data.SqlClient/tests/UnitTests/ConnectionPool/DbConnectionPoolIdentityTest.cs new file mode 100644 index 0000000000..aa5c87270c --- /dev/null +++ b/src/Microsoft.Data.SqlClient/tests/UnitTests/ConnectionPool/DbConnectionPoolIdentityTest.cs @@ -0,0 +1,312 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using System.Collections.Generic; +using System.Data.Common; +using System.Reflection; +using System.Runtime.InteropServices; +using System.Security.Principal; +using System.Threading.Tasks; +using Interop.Windows.Advapi32; +using Microsoft.Data.Common.ConnectionString; +using Microsoft.Data.ProviderBase; +using Microsoft.Data.SqlClient.ConnectionPool; +using Microsoft.Data.SqlClient.Tests.Common; +using Microsoft.Extensions.Time.Testing; +using Microsoft.Win32.SafeHandles; +using Xunit; + +namespace Microsoft.Data.SqlClient.UnitTests.ConnectionPool +{ + /// + /// Guards against pooling and replenishing under the wrong network-only logon session. + /// No network authentication is performed; connections are supplied by a stub factory. + /// + [Collection(AppContextSwitchTestCollection.Name)] + public class DbConnectionPoolIdentityTest + { + public static bool IsWindows => RuntimeInformation.IsOSPlatform(OSPlatform.Windows); + + /// + /// Network-only logons keep the local SID but must have distinct pool identities, while + /// repeated reads and duplicated tokens from the same logon session remain equivalent. + /// + [ConditionalTheory(nameof(IsWindows))] + [InlineData(false)] + #if NET + [InlineData(true)] + #endif + public void GetCurrent_NetOnlyLogons_AreDistinct(bool managedNetworking) + { + using var switches = new LocalAppContextSwitchesHelper(); + SetNetworking(switches, managedNetworking); + using SafeAccessTokenHandle first = CreateToken(); + using SafeAccessTokenHandle second = CreateToken(); + using WindowsIdentity original = WindowsIdentity.GetCurrent(); + + DbConnectionPoolIdentity processIdentity = DbConnectionPoolIdentity.GetCurrent(); + DbConnectionPoolIdentity firstIdentity = WindowsIdentity.RunImpersonated(first, () => + { + using WindowsIdentity current = WindowsIdentity.GetCurrent(); + Assert.Equal(original.User, current.User); + return DbConnectionPoolIdentity.GetCurrent(); + }); + DbConnectionPoolIdentity secondIdentity = + WindowsIdentity.RunImpersonated(second, DbConnectionPoolIdentity.GetCurrent); + + Assert.NotEqual(processIdentity, firstIdentity); + Assert.NotEqual(firstIdentity, secondIdentity); + Assert.Equal(processIdentity, DbConnectionPoolIdentity.GetCurrent()); + + WindowsIdentity.RunImpersonated(first, () => + { + DbConnectionPoolIdentity repeated = DbConnectionPoolIdentity.GetCurrent(); + Assert.Equal(firstIdentity, repeated); + Assert.Equal(firstIdentity.GetHashCode(), repeated.GetHashCode()); + using WindowsIdentity duplicate = WindowsIdentity.GetCurrent(); + WindowsIdentity.RunImpersonated(duplicate.AccessToken, () => + Assert.Equal(firstIdentity, DbConnectionPoolIdentity.GetCurrent())); + }); + + var identities = new HashSet + { + processIdentity, firstIdentity, secondIdentity, + WindowsIdentity.RunImpersonated(first, DbConnectionPoolIdentity.GetCurrent) + }; + Assert.Equal(3, identities.Count); + } + + /// + /// Both pool implementations select separate pools for identical local SIDs belonging + /// to different logon sessions, and restore the original pool after impersonation ends. + /// + [ConditionalTheory(nameof(IsWindows))] + [InlineData(false, false)] + [InlineData(true, false)] + #if NET + [InlineData(false, true)] + [InlineData(true, true)] + #endif + public void GetPool_NetOnlyLogons_SelectSeparatePools(bool usePoolV2, bool managedNetworking) + { + using var switches = new LocalAppContextSwitchesHelper(); + switches.UseConnectionPoolV2 = usePoolV2; + SetNetworking(switches, managedNetworking); + using SafeAccessTokenHandle first = CreateToken(); + using SafeAccessTokenHandle second = CreateToken(); + var group = CreateGroup(minPoolSize: 0); + var factory = new ChannelDbConnectionPoolTest.SuccessfulSqlConnectionFactory(); + IDbConnectionPool processPool = group.GetConnectionPool(factory); + IDbConnectionPool? firstPool = null; + IDbConnectionPool? secondPool = null; + try + { + firstPool = WindowsIdentity.RunImpersonated(first, () => group.GetConnectionPool(factory)); + secondPool = WindowsIdentity.RunImpersonated(second, () => group.GetConnectionPool(factory)); + Assert.NotSame(processPool, firstPool); + Assert.NotSame(firstPool, secondPool); + Assert.Same(processPool, group.GetConnectionPool(factory)); + Assert.Same(firstPool, WindowsIdentity.RunImpersonated(first, () => group.GetConnectionPool(factory))); + if (usePoolV2) + { + Assert.IsType(firstPool); + } + else + { + Assert.IsType(firstPool); + } + } + finally + { + processPool.Shutdown(); + firstPool?.Shutdown(); + secondPool?.Shutdown(); + } + } + + /// + /// Replenishment with the wrong logon session must neither create connections nor poison + /// the pool; a later pass with the owning session must still reach the minimum. + /// + [ConditionalTheory(nameof(IsWindows))] + [InlineData(false, false, false)] + [InlineData(false, false, true)] + [InlineData(true, false, false)] + [InlineData(true, false, true)] + #if NET + [InlineData(false, true, false)] + [InlineData(false, true, true)] + [InlineData(true, true, false)] + [InlineData(true, true, true)] + #endif + public async Task Replenishment_WrongLogonSession_DoesNotCreateConnections( + bool usePoolV2, bool managedNetworking, bool impersonatedPool) + { + using var switches = new LocalAppContextSwitchesHelper(); + SetNetworking(switches, managedNetworking); + using SafeAccessTokenHandle token = CreateToken(); + DbConnectionPoolIdentity identity = impersonatedPool + ? WindowsIdentity.RunImpersonated(token, DbConnectionPoolIdentity.GetCurrent) + : DbConnectionPoolIdentity.GetCurrent(); + var factory = new ChannelDbConnectionPoolTest.CountingSuccessfulConnectionFactory(); + var group = CreateGroup(minPoolSize: 1); + IDbConnectionPool pool = usePoolV2 + ? new ChannelDbConnectionPool(factory, group, identity, new DbConnectionPoolProviderInfo(), timeProvider: new FakeTimeProvider()) + : new WaitHandleDbConnectionPool(factory, group, identity, new DbConnectionPoolProviderInfo(), timeProvider: new FakeTimeProvider()); + try + { + await (impersonatedPool + ? Replenish(pool) + : WindowsIdentity.RunImpersonated(token, () => Replenish(pool))); + Assert.Equal(0, factory.CreateCount); + Assert.Equal(0, pool.Count); + Assert.False(pool.ErrorOccurred); + + await (impersonatedPool + ? WindowsIdentity.RunImpersonated(token, () => Replenish(pool)) + : Replenish(pool)); + Assert.Equal(1, factory.CreateCount); + Assert.Equal(1, pool.IdleCount); + Assert.False(pool.ErrorOccurred); + } + finally + { + pool.Shutdown(); + } + } + + /// + /// Synchronous and asynchronous checkouts establish connections in the requesting + /// logon session for either pool implementation. + /// + [ConditionalTheory(nameof(IsWindows))] + [InlineData(false, false)] + [InlineData(false, true)] + [InlineData(true, false)] + [InlineData(true, true)] + public async Task GetConnection_NetOnlyLogon_PreservesIdentity(bool usePoolV2, bool async) + { +#if NETFRAMEWORK + // Load EnterpriseServices before placeholder outbound credentials affect assembly resolution. + _ = System.Transactions.Transaction.Current; +#endif + using SafeAccessTokenHandle token = CreateToken(); + DbConnectionPoolIdentity identity = WindowsIdentity.RunImpersonated(token, DbConnectionPoolIdentity.GetCurrent); + var factory = new IdentityRecordingFactory(); + var group = CreateGroup(minPoolSize: 0); + IDbConnectionPool pool = usePoolV2 + ? new ChannelDbConnectionPool(factory, group, identity, new DbConnectionPoolProviderInfo(), timeProvider: new FakeTimeProvider()) + : new WaitHandleDbConnectionPool(factory, group, identity, new DbConnectionPoolProviderInfo(), timeProvider: new FakeTimeProvider()); + using var owner = new SqlConnection(); + DbConnectionInternal? connection = null; + try + { + connection = await WindowsIdentity.RunImpersonated(token, async () => + { + TaskCompletionSource? completion = async + ? new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously) + : null; + bool completed = pool.TryGetConnection(owner, completion, + TimeoutTimer.StartNew(TimeSpan.FromSeconds(15)), out DbConnectionInternal? result); + if (completed) + { + return result!; + } + + Assert.Same(completion!.Task, + await Task.WhenAny(completion.Task, Task.Delay(TimeSpan.FromSeconds(30)))); + return await completion.Task; + }); + Assert.Equal(identity, factory.CreatedIdentity); + } + finally + { + if (connection != null) + { + pool.ReturnInternalConnection(connection, owner); + } + pool.Shutdown(); + } + } + + /// + /// Token-query failures propagate instead of collapsing identities to a default value. + /// + [ConditionalFact(nameof(IsWindows))] + public void TokenQueries_InvalidHandle_Throw() + { + using var token = new SafeAccessTokenHandle(IntPtr.Zero); + Assert.Throws(() => Advapi32.GetAuthenticationId(token)); + Assert.Throws(() => Advapi32.GetIsTokenRestricted(token)); + } + + /// Records the identity under which a stub physical connection is created. + private sealed class IdentityRecordingFactory : ChannelDbConnectionPoolTest.SuccessfulSqlConnectionFactory + { + internal DbConnectionPoolIdentity? CreatedIdentity { get; private set; } + + /// Captures the effective pool identity before creating a stub connection. + protected override DbConnectionInternal CreateConnection( + SqlConnectionOptions options, ConnectionPoolKey poolKey, + DbConnectionPoolGroupProviderInfo poolGroupProviderInfo, IDbConnectionPool pool, + DbConnection owningConnection, TimeoutTimer timeout) + { + CreatedIdentity = DbConnectionPoolIdentity.GetCurrent(); + return base.CreateConnection(options, poolKey, poolGroupProviderInfo, pool, owningConnection, timeout); + } + } + + /// Creates a network-only token without usable network credentials. + /// A token belonging to a new logon session, owned by the caller. + private static SafeAccessTokenHandle CreateToken() => + WindowsImpersonationHelper.LogonNetOnly("", "EXAMPLE", ""); + + /// Chooses the networking mode where that switch exists. + /// The scope restoring the cached switch after the test. + /// Whether to use managed networking on Windows. + private static void SetNetworking(LocalAppContextSwitchesHelper switches, bool managedNetworking) + { + #if NET + switches.UseManagedNetworking = managedNetworking; + #endif + } + + /// Constructs a group that pools integrated-authentication connections by identity. + /// The minimum number of connections to replenish. + /// An isolated pool group for the test. + private static DbConnectionPoolGroup CreateGroup(int minPoolSize) => + new DbConnectionPoolGroup( + new SqlConnectionOptions("Data Source=localhost;Integrated Security=true;"), + new ConnectionPoolKey("IdentityTest", null, null, null, null), + new DbConnectionPoolGroupOptions( + poolByIdentity: true, minPoolSize: minPoolSize, maxPoolSize: 5, + creationTimeout: 15000, loadBalanceTimeout: 0, hasTransactionAffinity: false, idleTimeout: 0)); + + /// + /// Executes an actual background replenishment pass and awaits its completion rather + /// than using sleeps to infer that no connection was created. + /// + /// The pool whose background callback is exercised. + /// A task completing when the replenishment pass finishes. + private static async Task Replenish(IDbConnectionPool pool) + { + Task work; + if (pool is ChannelDbConnectionPool channel) + { + channel.RequestWarmup(); + work = channel.WarmupLoopTask!; + } + else + { + MethodInfo callback = typeof(WaitHandleDbConnectionPool).GetMethod( + "PoolCreateRequest", BindingFlags.Instance | BindingFlags.NonPublic)!; + work = Task.Run(() => callback.Invoke(pool, new object?[] { null })); + } + + Assert.Same(work, await Task.WhenAny(work, Task.Delay(TimeSpan.FromSeconds(30)))); + await work; + } + } +}