From 52281286c0fcdf17ce1d0e7a1d781cf14ee11972 Mon Sep 17 00:00:00 2001 From: Malcolm Daigle Date: Fri, 9 Oct 2026 11:05:56 -0700 Subject: [PATCH 01/10] Fix Windows netonly connection pool identity and replenishment Include the token logon session in Windows pool identities for both networking modes and prevent channel pool warmup under a mismatched identity. Add regression coverage for both pools, sync and async checkout, and real-server invalid-credential isolation, with documentation and an impersonation sample. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- doc/connection-pooling-impersonation.md | 64 ++++ doc/samples/ConnectionPool_Impersonation.cs | 32 ++ .../src/Interop/Windows/Advapi32/Advapi32.cs | 74 +++++ .../src/Interop/Windows/Kernel32/Kernel32.cs | 3 + .../ConnectionPool/ChannelDbConnectionPool.cs | 10 + .../DbConnectionPoolIdentity.cs | 29 +- .../Common/WindowsImpersonationHelper.cs | 45 +++ .../PoolIdentityImpersonationTest.cs | 260 +++++++++++++++ .../DbConnectionPoolIdentityTest.cs | 308 ++++++++++++++++++ 9 files changed, 812 insertions(+), 13 deletions(-) create mode 100644 doc/connection-pooling-impersonation.md create mode 100644 doc/samples/ConnectionPool_Impersonation.cs create mode 100644 src/Microsoft.Data.SqlClient/src/Interop/Windows/Advapi32/Advapi32.cs create mode 100644 src/Microsoft.Data.SqlClient/tests/Common/WindowsImpersonationHelper.cs create mode 100644 src/Microsoft.Data.SqlClient/tests/ManualTests/SQL/IntegratedAuthenticationTest/PoolIdentityImpersonationTest.cs create mode 100644 src/Microsoft.Data.SqlClient/tests/UnitTests/ConnectionPool/DbConnectionPoolIdentityTest.cs diff --git a/doc/connection-pooling-impersonation.md b/doc/connection-pooling-impersonation.md new file mode 100644 index 0000000000..77ae269f73 --- /dev/null +++ b/doc/connection-pooling-impersonation.md @@ -0,0 +1,64 @@ +# 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. +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: loopback TCP authentication uses network-only credentials, but +local named-pipe and shared-memory connections cannot be opened under a +network-only impersonation token. Managed networking is selected through the +existing `Switch.Microsoft.Data.SqlClient.UseManagedNetworkingOnWindows` setting. 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/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..7831331534 --- /dev/null +++ b/src/Microsoft.Data.SqlClient/src/Interop/Windows/Advapi32/Advapi32.cs @@ -0,0 +1,74 @@ +// 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, 10 /* 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, + int 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); + + [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..6645180c3c 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 @@ -1977,6 +1977,16 @@ private async Task RunWarmupLoopAsync() && !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 (Identity != null && Identity != DbConnectionPoolIdentity.NoIdentity + && !Identity.Equals(DbConnectionPoolIdentity.GetCurrent())) + { + 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..1539216aac 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,16 @@ 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()); } // @TODO: Make auto-property @@ -41,7 +44,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 +58,7 @@ public override int GetHashCode() internal static DbConnectionPoolIdentity GetCurrent() { - return LocalAppContextSwitches.UseManagedNetworking - ? GetCurrentManaged() - : GetCurrentNative(); + return OsConstants.IsWindows ? GetCurrentWindows() : GetCurrentManaged(); } #if NETFRAMEWORK @@ -84,30 +86,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 +119,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..47e3210b58 --- /dev/null +++ b/src/Microsoft.Data.SqlClient/tests/Common/WindowsImpersonationHelper.cs @@ -0,0 +1,45 @@ +// 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 +{ + /// + /// 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, 9, 3, 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/PoolIdentityImpersonationTest.cs b/src/Microsoft.Data.SqlClient/tests/ManualTests/SQL/IntegratedAuthenticationTest/PoolIdentityImpersonationTest.cs new file mode 100644 index 0000000000..2033fc4ea7 --- /dev/null +++ b/src/Microsoft.Data.SqlClient/tests/ManualTests/SQL/IntegratedAuthenticationTest/PoolIdentityImpersonationTest.cs @@ -0,0 +1,260 @@ +// 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")) + && DataTestUtility.AreConnStringsSetup(); + + /// + /// 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); + 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(); + 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(IsConfigured))] + [InlineData(false, false)] + [InlineData(false, true)] + [InlineData(true, false)] + [InlineData(true, true)] + public async Task NetOnlyImpersonation_RevertsLogin(bool usePoolV2, bool async) + { + using var poolVersion = new ConnectionPoolVersionScope(usePoolV2); + using SafeAccessTokenHandle token = CreateToken(); + string connectionString = BuildConnectionString(); + string original = await GetLogin(connectionString, async); + string impersonated = await WindowsIdentity.RunImpersonated(token, () => GetLogin(connectionString, async)); + Assert.NotEqual(original, impersonated); + Assert.Equal(original, await GetLogin(connectionString, async)); + } + + /// + /// 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(IsConfigured))] + [InlineData(false, false)] + [InlineData(false, true)] + [InlineData(true, false)] + [InlineData(true, true)] + public async Task NetOnlyImpersonation_Replenishment_PreservesLogin(bool usePoolV2, bool async) + { + using var poolVersion = new ConnectionPoolVersionScope(usePoolV2); + using SafeAccessTokenHandle token = CreateToken(); + string connectionString = BuildConnectionString(loadBalanceTimeout: 1, minPoolSize: 3); + var unpooled = new SqlConnectionStringBuilder(connectionString) + { + Pooling = false, + MinPoolSize = 0 + }; + string expected = await WindowsIdentity.RunImpersonated(token, () => GetLogin(unpooled.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(); + } + } + } + + /// 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", ""); + + /// Creates a unique pool group using the configured server and integrated security. + private static string BuildConnectionString(int loadBalanceTimeout = 0, int minPoolSize = 0) + { + var builder = new SqlConnectionStringBuilder(DataTestUtility.TCPConnectionString) + { + 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. + private 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..e854fb3146 --- /dev/null +++ b/src/Microsoft.Data.SqlClient/tests/UnitTests/ConnectionPool/DbConnectionPoolIdentityTest.cs @@ -0,0 +1,308 @@ +// 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) + { + 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; + } + } +} From 2f5626073e73a03407837c1f753842cff82a258c Mon Sep 17 00:00:00 2001 From: Malcolm Daigle Date: Fri, 9 Oct 2026 11:31:30 -0700 Subject: [PATCH 02/10] Address Copilot review feedback on pool identity fix Guard failed netonly token creation, name the native token/logon constants, include all identity fields in the hash, and query the warmup identity once per pass. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../src/Interop/Windows/Advapi32/Advapi32.cs | 9 +++++++-- .../SqlClient/ConnectionPool/ChannelDbConnectionPool.cs | 8 ++++++-- .../SqlClient/ConnectionPool/DbConnectionPoolIdentity.cs | 3 ++- .../tests/Common/WindowsImpersonationHelper.cs | 7 +++++-- 4 files changed, 20 insertions(+), 7 deletions(-) diff --git a/src/Microsoft.Data.SqlClient/src/Interop/Windows/Advapi32/Advapi32.cs b/src/Microsoft.Data.SqlClient/src/Interop/Windows/Advapi32/Advapi32.cs index 7831331534..090dbccc74 100644 --- a/src/Microsoft.Data.SqlClient/src/Interop/Windows/Advapi32/Advapi32.cs +++ b/src/Microsoft.Data.SqlClient/src/Interop/Windows/Advapi32/Advapi32.cs @@ -12,7 +12,7 @@ internal static class Advapi32 { internal static long GetAuthenticationId(SafeAccessTokenHandle token) { - if (!GetTokenInformation(token, 10 /* TokenStatistics */, out TokenStatistics statistics, + if (!GetTokenInformation(token, TokenInformationClass.TokenStatistics, out TokenStatistics statistics, Marshal.SizeOf(), out _)) { throw new Win32Exception(Marshal.GetLastWin32Error()); @@ -40,7 +40,7 @@ internal static bool GetIsTokenRestricted(SafeAccessTokenHandle token) [return: MarshalAs(UnmanagedType.Bool)] private static extern bool GetTokenInformation( SafeAccessTokenHandle token, - int informationClass, + TokenInformationClass informationClass, out TokenStatistics statistics, int bufferLength, out int returnLength); @@ -49,6 +49,11 @@ private static extern bool GetTokenInformation( [return: MarshalAs(UnmanagedType.Bool)] private static extern bool IsTokenRestricted(SafeAccessTokenHandle token); + private enum TokenInformationClass + { + TokenStatistics = 10 + } + [StructLayout(LayoutKind.Sequential)] private struct Luid { 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 6645180c3c..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,6 +1972,11 @@ 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 @@ -1979,8 +1984,7 @@ private async Task RunWarmupLoopAsync() { // 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 (Identity != null && Identity != DbConnectionPoolIdentity.NoIdentity - && !Identity.Equals(DbConnectionPoolIdentity.GetCurrent())) + if (poolIdentity != null && currentIdentity != null && !poolIdentity.Equals(currentIdentity)) { SqlClientEventSource.Log.TryPoolerTraceEvent( "ChannelDbConnectionPool.RunWarmupLoopAsync | INFO | {0}, Skipping warmup for a different identity.", Id); 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 1539216aac..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 @@ -27,7 +27,8 @@ private DbConnectionPoolIdentity(string sidString, bool isRestricted, bool isNet _isRestricted = isRestricted; _isNetwork = isNetwork; _authenticationId = authenticationId; - _hashCode = unchecked(((sidString == null ? 0 : sidString.GetHashCode()) * 397) ^ authenticationId.GetHashCode()); + _hashCode = unchecked(((((sidString == null ? 0 : sidString.GetHashCode()) * 397) ^ authenticationId.GetHashCode()) * 397 + ^ (isRestricted ? 1 : 0)) * 397 ^ (isNetwork ? 1 : 0)); } // @TODO: Make auto-property diff --git a/src/Microsoft.Data.SqlClient/tests/Common/WindowsImpersonationHelper.cs b/src/Microsoft.Data.SqlClient/tests/Common/WindowsImpersonationHelper.cs index 47e3210b58..42d74bffde 100644 --- a/src/Microsoft.Data.SqlClient/tests/Common/WindowsImpersonationHelper.cs +++ b/src/Microsoft.Data.SqlClient/tests/Common/WindowsImpersonationHelper.cs @@ -13,6 +13,9 @@ namespace Microsoft.Data.SqlClient.Tests.Common; /// 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. @@ -23,10 +26,10 @@ public static class WindowsImpersonationHelper /// 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, 9, 3, out SafeAccessTokenHandle token)) + if (!LogonUser(userName, domain, password, Logon32LogonNewCredentials, Logon32ProviderWinnt50, out SafeAccessTokenHandle token)) { int error = Marshal.GetLastWin32Error(); - token.Dispose(); + token?.Dispose(); throw new Win32Exception(error); } From ecb72154d6e0f1fad6dcd44b59ea03a1ded69adc Mon Sep 17 00:00:00 2001 From: Malcolm Daigle Date: Fri, 9 Oct 2026 15:54:10 -0700 Subject: [PATCH 03/10] Skip invalid netonly credential probes when the server accepts them CI agents accept placeholder network-only credentials over loopback, so a successful open cannot prove pool sharing there. Verify the precondition with an unpooled open and skip when it does not hold. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- doc/connection-pooling-impersonation.md | 2 ++ .../PoolIdentityImpersonationTest.cs | 29 +++++++++++++++++++ 2 files changed, 31 insertions(+) diff --git a/doc/connection-pooling-impersonation.md b/doc/connection-pooling-impersonation.md index 77ae269f73..d15836458c 100644 --- a/doc/connection-pooling-impersonation.md +++ b/doc/connection-pooling-impersonation.md @@ -49,6 +49,8 @@ The `InvalidNetOnlyCredentials_*` tests need only an integrated-security 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. diff --git a/src/Microsoft.Data.SqlClient/tests/ManualTests/SQL/IntegratedAuthenticationTest/PoolIdentityImpersonationTest.cs b/src/Microsoft.Data.SqlClient/tests/ManualTests/SQL/IntegratedAuthenticationTest/PoolIdentityImpersonationTest.cs index 2033fc4ea7..a6fae66396 100644 --- a/src/Microsoft.Data.SqlClient/tests/ManualTests/SQL/IntegratedAuthenticationTest/PoolIdentityImpersonationTest.cs +++ b/src/Microsoft.Data.SqlClient/tests/ManualTests/SQL/IntegratedAuthenticationTest/PoolIdentityImpersonationTest.cs @@ -64,6 +64,7 @@ public async Task InvalidNetOnlyCredentials_DoNotReuseProcessConnections(bool us 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); @@ -98,6 +99,7 @@ public async Task InvalidNetOnlyCredentials_FailureDoesNotBlockProcess(bool useP using var poolVersion = new ConnectionPoolVersionScope(usePoolV2); using SafeAccessTokenHandle token = CreateInvalidToken(); string connectionString = BuildConnectionString(); + await SkipUnlessInvalidCredentialsRejected(token, connectionString, async); try { await Assert.ThrowsAsync( @@ -201,6 +203,33 @@ private static SafeAccessTokenHandle CreateToken() => 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. private static string BuildConnectionString(int loadBalanceTimeout = 0, int minPoolSize = 0) { From 45a6ef930045d8fdec052e179e3ac849bae1f90c Mon Sep 17 00:00:00 2001 From: Malcolm Daigle Date: Mon, 12 Oct 2026 10:20:00 -0700 Subject: [PATCH 04/10] Run alternate-account impersonation coverage in Windows SQL CI Provision temporary non-admin local accounts and SQL Windows logins for Set 3 jobs, require verified alternate TCP authentication, and always clean up. Initialize Framework transaction dependencies before placeholder impersonation in standalone unit tests. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- doc/connection-pooling-impersonation.md | 19 +- .../templates/jobs/ci-run-tests-job.yml | 12 + .../steps/configure-netonly-account-step.yml | 47 ++++ .../templates/steps/run-all-tests-step.yml | 11 + .../pr/jobs/test-sqlclientmanual-job.yml | 17 ++ eng/scripts/Configure-NetOnlyAccount.ps1 | 218 ++++++++++++++++++ .../PoolIdentityImpersonationTest.cs | 84 +++++-- .../DbConnectionPoolIdentityTest.cs | 4 + 8 files changed, 395 insertions(+), 17 deletions(-) create mode 100644 eng/pipelines/common/templates/steps/configure-netonly-account-step.yml create mode 100644 eng/scripts/Configure-NetOnlyAccount.ps1 diff --git a/doc/connection-pooling-impersonation.md b/doc/connection-pooling-impersonation.md index d15836458c..07d0ad06ea 100644 --- a/doc/connection-pooling-impersonation.md +++ b/doc/connection-pooling-impersonation.md @@ -60,7 +60,20 @@ The remaining tests require an alternate account, supplied through the 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: loopback TCP authentication uses network-only credentials, but -local named-pipe and shared-memory connections cannot be opened under a -network-only impersonation token. Managed networking is selected through the +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. 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..fc86cf9225 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,11 @@ 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 + # 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 +364,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 +379,12 @@ 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/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..c71c34f6d7 --- /dev/null +++ b/eng/pipelines/common/templates/steps/configure-netonly-account-step.yml @@ -0,0 +1,47 @@ +################################################################################# +# 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' + +steps: + - task: PowerShell@2 + displayName: '${{ parameters.mode }} temporary net-only 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 + env: + NETONLY_MODE: ${{ parameters.mode }} + NETONLY_INSTANCE: ${{ parameters.instanceName }} + NETONLY_PORT: ${{ parameters.port }} + # Undefined ADO macros are treated as absent by cleanup. + NETONLY_ACCOUNT: $(netOnlyUser) + NETONLY_SID: $(netOnlySid) 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..4612b73ffc 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,13 @@ 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) 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..3794a33c70 100644 --- a/eng/pipelines/pr/jobs/test-sqlclientmanual-job.yml +++ b/eng/pipelines/pr/jobs/test-sqlclientmanual-job.yml @@ -208,9 +208,21 @@ 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.testSet, '3')) }}: + - template: /eng/pipelines/common/templates/steps/configure-netonly-account-step.yml@self + parameters: + mode: setup + # Execute TestSqlClientManual target from build.proj - task: DotNetCoreCLI@2 displayName: 'Test: SqlClientManual ${{ parameters.testSet }}' + ${{ if and(eq(parameters.platformOperatingSystem, 'Windows'), parameters.isLocalServer, eq(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) inputs: command: build projects: build.proj @@ -224,6 +236,11 @@ jobs: -p:TestSet=${{ parameters.testSet }} -p:TestResultsFolderPath="${{ variables.testResultsPath }}" + - ${{ if and(eq(parameters.platformOperatingSystem, 'Windows'), parameters.isLocalServer, eq(parameters.testSet, '3')) }}: + - template: /eng/pipelines/common/templates/steps/configure-netonly-account-step.yml@self + parameters: + mode: cleanup + # 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..d88029c474 --- /dev/null +++ b/eng/scripts/Configure-NetOnlyAccount.ps1 @@ -0,0 +1,218 @@ +# 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. +#> +[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, + + [AllowEmptyString()] + [string] $AccountName = '', + + [AllowEmptyString()] + [string] $AccountSid = '' +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +function Set-PipelineVariable { + param([string] $Name, [string] $Value, [switch] $Secret) + $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.DataSource = 'lpc:.' + if ($InstanceName -ne 'MSSQLSERVER') { + $builder.DataSource = "lpc:.\$InstanceName" + } + $builder.InitialCatalog = 'master' + $builder.IntegratedSecurity = $true + $builder.ConnectTimeout = 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 '$(netOnlyUser)')) { + 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 temporary net-only account: $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 '$(netOnlySid)') { + $AccountSid = '' + } + if ($account) { + if ($account.Description -ne "SqlClient temporary net-only account: $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/tests/ManualTests/SQL/IntegratedAuthenticationTest/PoolIdentityImpersonationTest.cs b/src/Microsoft.Data.SqlClient/tests/ManualTests/SQL/IntegratedAuthenticationTest/PoolIdentityImpersonationTest.cs index a6fae66396..319bf9e24d 100644 --- a/src/Microsoft.Data.SqlClient/tests/ManualTests/SQL/IntegratedAuthenticationTest/PoolIdentityImpersonationTest.cs +++ b/src/Microsoft.Data.SqlClient/tests/ManualTests/SQL/IntegratedAuthenticationTest/PoolIdentityImpersonationTest.cs @@ -35,7 +35,15 @@ public class PoolIdentityImpersonationTest && !string.IsNullOrWhiteSpace(Environment.GetEnvironmentVariable("SQLCLIENT_NETONLY_USER")) && !string.IsNullOrWhiteSpace(Environment.GetEnvironmentVariable("SQLCLIENT_NETONLY_DOMAIN")) && !string.IsNullOrEmpty(Environment.GetEnvironmentVariable("SQLCLIENT_NETONLY_PASSWORD")) - && DataTestUtility.AreConnStringsSetup(); + && (!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 @@ -117,42 +125,49 @@ await Assert.ThrowsAsync( /// 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(IsConfigured))] + [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 original = await GetLogin(connectionString, async); - string impersonated = await WindowsIdentity.RunImpersonated(token, () => GetLogin(connectionString, async)); - Assert.NotEqual(original, impersonated); - Assert.Equal(original, await GetLogin(connectionString, async)); + 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(IsConfigured))] + [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); - var unpooled = new SqlConnectionStringBuilder(connectionString) - { - Pooling = false, - MinPoolSize = 0 - }; - string expected = await WindowsIdentity.RunImpersonated(token, () => GetLogin(unpooled.ConnectionString, async)); + string expected = await VerifyUnpooledLogins(token, connectionString, async); var opened = new List(); try { @@ -189,6 +204,45 @@ public async Task NetOnlyImpersonation_Replenishment_PreservesLogin(bool usePool } } + /// 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( @@ -233,7 +287,9 @@ private static async Task SkipUnlessInvalidCredentialsRejected( /// Creates a unique pool group using the configured server and integrated security. private static string BuildConnectionString(int loadBalanceTimeout = 0, int minPoolSize = 0) { - var builder = new SqlConnectionStringBuilder(DataTestUtility.TCPConnectionString) + string configured = Environment.GetEnvironmentVariable("SQLCLIENT_NETONLY_CONNECTION_STRING"); + var builder = new SqlConnectionStringBuilder( + string.IsNullOrWhiteSpace(configured) ? DataTestUtility.TCPConnectionString : configured) { IntegratedSecurity = true, Pooling = true, diff --git a/src/Microsoft.Data.SqlClient/tests/UnitTests/ConnectionPool/DbConnectionPoolIdentityTest.cs b/src/Microsoft.Data.SqlClient/tests/UnitTests/ConnectionPool/DbConnectionPoolIdentityTest.cs index e854fb3146..aa5c87270c 100644 --- a/src/Microsoft.Data.SqlClient/tests/UnitTests/ConnectionPool/DbConnectionPoolIdentityTest.cs +++ b/src/Microsoft.Data.SqlClient/tests/UnitTests/ConnectionPool/DbConnectionPoolIdentityTest.cs @@ -188,6 +188,10 @@ public async Task Replenishment_WrongLogonSession_DoesNotCreateConnections( [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(); From 3011203616aa9ef547bf789c11e33603ec181be2 Mon Sep 17 00:00:00 2001 From: Malcolm Daigle Date: Mon, 12 Oct 2026 10:32:33 -0700 Subject: [PATCH 05/10] Enable net-only provisioning for combined PR test sets Match Set 3 membership for the 123 selector while restricting credential provisioning to the Windows localhost configuration, excluding LocalDB and Azure. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- eng/pipelines/pr/jobs/test-sqlclientmanual-job.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/eng/pipelines/pr/jobs/test-sqlclientmanual-job.yml b/eng/pipelines/pr/jobs/test-sqlclientmanual-job.yml index 3794a33c70..f24e78edb7 100644 --- a/eng/pipelines/pr/jobs/test-sqlclientmanual-job.yml +++ b/eng/pipelines/pr/jobs/test-sqlclientmanual-job.yml @@ -208,7 +208,7 @@ 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.testSet, '3')) }}: + - ${{ 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 @@ -216,7 +216,7 @@ jobs: # Execute TestSqlClientManual target from build.proj - task: DotNetCoreCLI@2 displayName: 'Test: SqlClientManual ${{ parameters.testSet }}' - ${{ if and(eq(parameters.platformOperatingSystem, 'Windows'), parameters.isLocalServer, eq(parameters.testSet, '3')) }}: + ${{ 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) @@ -236,7 +236,7 @@ jobs: -p:TestSet=${{ parameters.testSet }} -p:TestResultsFolderPath="${{ variables.testResultsPath }}" - - ${{ if and(eq(parameters.platformOperatingSystem, 'Windows'), parameters.isLocalServer, eq(parameters.testSet, '3')) }}: + - ${{ 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 From bfd11947b5bd668686a2c251db988e92861338eb Mon Sep 17 00:00:00 2001 From: Malcolm Daigle Date: Mon, 12 Oct 2026 10:55:51 -0700 Subject: [PATCH 06/10] Fix Windows net-only account provisioning script Keep the ownership description within the Windows 48-character limit and use canonical SQL connection-string keys to avoid PowerShell dictionary adapter property assignment errors. Validated script AST, builder statements for default/named instances, and matching setup/cleanup markers against New-LocalUser parameter metadata in Windows PowerShell 5.1. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- eng/scripts/Configure-NetOnlyAccount.ps1 | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/eng/scripts/Configure-NetOnlyAccount.ps1 b/eng/scripts/Configure-NetOnlyAccount.ps1 index d88029c474..b402aabf2a 100644 --- a/eng/scripts/Configure-NetOnlyAccount.ps1 +++ b/eng/scripts/Configure-NetOnlyAccount.ps1 @@ -49,13 +49,13 @@ 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.DataSource = 'lpc:.' + $builder['Data Source'] = 'lpc:.' if ($InstanceName -ne 'MSSQLSERVER') { - $builder.DataSource = "lpc:.\$InstanceName" + $builder['Data Source'] = "lpc:.\$InstanceName" } - $builder.InitialCatalog = 'master' - $builder.IntegratedSecurity = $true - $builder.ConnectTimeout = 15 + $builder['Initial Catalog'] = 'master' + $builder['Integrated Security'] = $true + $builder['Connect Timeout'] = 15 $connection = New-Object System.Data.SqlClient.SqlConnection $builder.ConnectionString try { $connection.Open() @@ -127,7 +127,7 @@ try { $password = $null try { $account = New-LocalUser -Name $AccountName -Password $securePassword ` - -Description "SqlClient temporary net-only account: $AccountName" -ErrorAction Stop + -Description "SqlClient net-only: $AccountName" -ErrorAction Stop } finally { $securePassword.Dispose() @@ -158,7 +158,7 @@ CREATE LOGIN $quotedLogin FROM WINDOWS WITH DEFAULT_DATABASE = [master]; $AccountSid = '' } if ($account) { - if ($account.Description -ne "SqlClient temporary net-only account: $AccountName") { + 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) { From 7701ac80e45193b97dd6b617eafb298e2e418840 Mon Sep 17 00:00:00 2001 From: Malcolm Daigle Date: Tue, 13 Oct 2026 14:14:26 -0700 Subject: [PATCH 07/10] Investigate outbound SSPI names against SQL authentication in CI Provision a second disposable CI account, compare targetless SSPI names with unpooled SQL identities, and probe session-only credential overrides without changing pooling. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- doc/connection-pooling-impersonation.md | 23 ++ .../templates/jobs/ci-run-tests-job.yml | 8 + .../steps/configure-netonly-account-step.yml | 17 +- .../templates/steps/run-all-tests-step.yml | 3 + .../pr/jobs/test-sqlclientmanual-job.yml | 11 + eng/scripts/Configure-NetOnlyAccount.ps1 | 9 +- .../OutboundCredentialNameTest.cs | 380 ++++++++++++++++++ 7 files changed, 445 insertions(+), 6 deletions(-) create mode 100644 src/Microsoft.Data.SqlClient/tests/ManualTests/SQL/IntegratedAuthenticationTest/OutboundCredentialNameTest.cs diff --git a/doc/connection-pooling-impersonation.md b/doc/connection-pooling-impersonation.md index 07d0ad06ea..f884b8361c 100644 --- a/doc/connection-pooling-impersonation.md +++ b/doc/connection-pooling-impersonation.md @@ -77,3 +77,26 @@ Both successful-login tests first verify that an unpooled connection authenticat 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. +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/eng/pipelines/common/templates/jobs/ci-run-tests-job.yml b/eng/pipelines/common/templates/jobs/ci-run-tests-job.yml index fc86cf9225..d6e3537df9 100644 --- a/eng/pipelines/common/templates/jobs/ci-run-tests-job.yml +++ b/eng/pipelines/common/templates/jobs/ci-run-tests-job.yml @@ -345,6 +345,10 @@ jobs: - 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 @@ -385,6 +389,10 @@ jobs: - 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 index c71c34f6d7..a413da4bc6 100644 --- a/eng/pipelines/common/templates/steps/configure-netonly-account-step.yml +++ b/eng/pipelines/common/templates/steps/configure-netonly-account-step.yml @@ -26,9 +26,16 @@ parameters: type: string default: '1433' + - name: variablePrefix + type: string + default: netOnly + values: + - netOnly + - netOnlyPeer + steps: - task: PowerShell@2 - displayName: '${{ parameters.mode }} temporary net-only account [Win]' + displayName: '${{ parameters.mode }} temporary ${{ parameters.variablePrefix }} account [Win]' ${{ if eq(parameters.mode, 'cleanup') }}: condition: always() inputs: @@ -37,11 +44,13 @@ steps: 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 + -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: $(netOnlyUser) - NETONLY_SID: $(netOnlySid) + 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 4612b73ffc..3bf9a9cd7c 100644 --- a/eng/pipelines/common/templates/steps/run-all-tests-step.yml +++ b/eng/pipelines/common/templates/steps/run-all-tests-step.yml @@ -203,6 +203,9 @@ steps: 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 f24e78edb7..6ae22e7824 100644 --- a/eng/pipelines/pr/jobs/test-sqlclientmanual-job.yml +++ b/eng/pipelines/pr/jobs/test-sqlclientmanual-job.yml @@ -212,6 +212,10 @@ jobs: - 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 @@ -223,6 +227,9 @@ jobs: 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 @@ -240,6 +247,10 @@ jobs: - 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 diff --git a/eng/scripts/Configure-NetOnlyAccount.ps1 b/eng/scripts/Configure-NetOnlyAccount.ps1 index b402aabf2a..679354ae38 100644 --- a/eng/scripts/Configure-NetOnlyAccount.ps1 +++ b/eng/scripts/Configure-NetOnlyAccount.ps1 @@ -15,6 +15,7 @@ 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( @@ -28,6 +29,9 @@ param( [ValidateRange(1, 65535)] [int] $Port = 1433, + [ValidateSet('netOnly', 'netOnlyPeer')] + [string] $VariablePrefix = 'netOnly', + [AllowEmptyString()] [string] $AccountName = '', @@ -40,6 +44,7 @@ $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" @@ -86,7 +91,7 @@ function Get-AccountSidHex { } if ($Mode -eq 'cleanup' -and - ([string]::IsNullOrWhiteSpace($AccountName) -or $AccountName -eq '$(netOnlyUser)')) { + ([string]::IsNullOrWhiteSpace($AccountName) -or $AccountName -eq ('$(' + $VariablePrefix + 'User)'))) { Write-Host 'Net-only setup did not publish an account; cleanup has nothing to remove.' return } @@ -154,7 +159,7 @@ CREATE LOGIN $quotedLogin FROM WINDOWS WITH DEFAULT_DATABASE = [master]; throw "Refusing cleanup of unexpected account name '$AccountName'." } $account = Get-LocalUser | Where-Object { $_.Name -eq $AccountName } - if ($AccountSid -eq '$(netOnlySid)') { + if ($AccountSid -eq ('$(' + $VariablePrefix + 'Sid)')) { $AccountSid = '' } if ($account) { 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..a8f50f8c14 --- /dev/null +++ b/src/Microsoft.Data.SqlClient/tests/ManualTests/SQL/IntegratedAuthenticationTest/OutboundCredentialNameTest.cs @@ -0,0 +1,380 @@ +// 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(); + 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 () => + { + AssertName(Expected(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(); + AssertName(Expected(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; cachedNameMatchesSql={string.Equals(cachedName, actual.Original, StringComparison.OrdinalIgnoreCase)}." + : "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)); + + /// Checks the preconnection name and the authenticated SQL name and SID. + private async Task ProbeCurrent(bool peer, string connectionString, bool async, string label) + { + string name = CredentialName(); + AssertName(Expected(peer), name); + Login login = await ReadLogin(connectionString, async); + AssertLogin(login, peer); + Log(label, name, login); + return login; + } + + /// Checks 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 authentication properties of the current session without server-level permissions. + private static async Task ReadLogin(string connectionString, bool async) + { + 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'))", 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); + } +} From 97d3b9a7b24065437ad6881720d8d5c422258517 Mon Sep 17 00:00:00 2001 From: Malcolm Daigle Date: Tue, 13 Oct 2026 15:02:09 -0700 Subject: [PATCH 08/10] Accept observed local SSPI account formats in CI experiment Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- doc/connection-pooling-impersonation.md | 4 +++ .../OutboundCredentialNameTest.cs | 33 ++++++++++++++++--- 2 files changed, 32 insertions(+), 5 deletions(-) diff --git a/doc/connection-pooling-impersonation.md b/doc/connection-pooling-impersonation.md index f884b8361c..9f6ec6cc87 100644 --- a/doc/connection-pooling-impersonation.md +++ b/doc/connection-pooling-impersonation.md @@ -88,6 +88,10 @@ credential name acquired inside impersonation with `ORIGINAL_LOGIN()`, 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. 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. diff --git a/src/Microsoft.Data.SqlClient/tests/ManualTests/SQL/IntegratedAuthenticationTest/OutboundCredentialNameTest.cs b/src/Microsoft.Data.SqlClient/tests/ManualTests/SQL/IntegratedAuthenticationTest/OutboundCredentialNameTest.cs index a8f50f8c14..237be90740 100644 --- a/src/Microsoft.Data.SqlClient/tests/ManualTests/SQL/IntegratedAuthenticationTest/OutboundCredentialNameTest.cs +++ b/src/Microsoft.Data.SqlClient/tests/ManualTests/SQL/IntegratedAuthenticationTest/OutboundCredentialNameTest.cs @@ -63,7 +63,7 @@ public async Task OutboundName_UnpooledSqlAuthentication(bool async) User(false), Domain(false), ""); await WindowsIdentity.RunImpersonated(invalid, async () => { - AssertName(Expected(false), CredentialName()); + 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}."); @@ -100,7 +100,7 @@ await WindowsIdentity.RunImpersonated(token, async () => TokenStatistics before = Statistics(); Assert.NotEqual(process.AuthenticationId.ToString(), before.AuthenticationId.ToString()); string cachedName = CredentialName(); - AssertName(Expected(false), cachedName); + AssertCredentialName(false, cachedName); await ProbeCurrent(false, connectionString, async, "override/before"); if (!WriteSessionCredential(target)) { @@ -131,7 +131,7 @@ await WindowsIdentity.RunImpersonated(token, async () => AssertLogin(actual, selectedPeer); Log("override/after", freshName, actual); _output.WriteLine(selectedPeer - ? $"Override selected B; cachedNameMatchesSql={string.Equals(cachedName, actual.Original, StringComparison.OrdinalIgnoreCase)}." + ? $"Override selected B; cachedNameMatchesSqlAccount={CredentialNameMatchesAccount(Domain(true), User(true), cachedName)}." : "UNCOVERED: SQL retained A; this target override was not selected by this environment."); } } @@ -194,14 +194,37 @@ private Task Probe(SafeAccessTokenHandle token, bool peer, string connect private async Task ProbeCurrent(bool peer, string connectionString, bool async, string label) { string name = CredentialName(); - AssertName(Expected(peer), name); + AssertCredentialName(peer, name); Login login = await ReadLogin(connectionString, async); AssertLogin(login, peer); Log(label, name, login); return login; } - /// Checks SAM-form names without treating case differences as distinct accounts. + /// 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}'."); From 54a39375b6b5dc2c38498ce887005b5c3c792059 Mon Sep 17 00:00:00 2001 From: Malcolm Daigle Date: Tue, 13 Oct 2026 15:53:46 -0700 Subject: [PATCH 09/10] Isolate outbound SSPI prequery from passing SQL login control Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../OutboundCredentialNameTest.cs | 44 ++++++++++++++++++- .../PoolIdentityImpersonationTest.cs | 4 +- 2 files changed, 45 insertions(+), 3 deletions(-) diff --git a/src/Microsoft.Data.SqlClient/tests/ManualTests/SQL/IntegratedAuthenticationTest/OutboundCredentialNameTest.cs b/src/Microsoft.Data.SqlClient/tests/ManualTests/SQL/IntegratedAuthenticationTest/OutboundCredentialNameTest.cs index 237be90740..55d555ea86 100644 --- a/src/Microsoft.Data.SqlClient/tests/ManualTests/SQL/IntegratedAuthenticationTest/OutboundCredentialNameTest.cs +++ b/src/Microsoft.Data.SqlClient/tests/ManualTests/SQL/IntegratedAuthenticationTest/OutboundCredentialNameTest.cs @@ -42,6 +42,7 @@ public class OutboundCredentialNameTest public async Task OutboundName_UnpooledSqlAuthentication(bool async) { AssertConfiguration(); + await DiagnosePrequery(async); string connectionString = ConnectionString(); string processName = CredentialName(); TokenStatistics processStatistics = Statistics(); @@ -190,14 +191,55 @@ private static SafeAccessTokenHandle CreateToken(bool peer) => WindowsImpersonat 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; + 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}."); + 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); - AssertLogin(login, peer); 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; } diff --git a/src/Microsoft.Data.SqlClient/tests/ManualTests/SQL/IntegratedAuthenticationTest/PoolIdentityImpersonationTest.cs b/src/Microsoft.Data.SqlClient/tests/ManualTests/SQL/IntegratedAuthenticationTest/PoolIdentityImpersonationTest.cs index 319bf9e24d..502ce5adc4 100644 --- a/src/Microsoft.Data.SqlClient/tests/ManualTests/SQL/IntegratedAuthenticationTest/PoolIdentityImpersonationTest.cs +++ b/src/Microsoft.Data.SqlClient/tests/ManualTests/SQL/IntegratedAuthenticationTest/PoolIdentityImpersonationTest.cs @@ -285,7 +285,7 @@ private static async Task SkipUnlessInvalidCredentialsRejected( } /// Creates a unique pool group using the configured server and integrated security. - private static string BuildConnectionString(int loadBalanceTimeout = 0, int minPoolSize = 0) + internal static string BuildConnectionString(int loadBalanceTimeout = 0, int minPoolSize = 0) { string configured = Environment.GetEnvironmentVariable("SQLCLIENT_NETONLY_CONNECTION_STRING"); var builder = new SqlConnectionStringBuilder( @@ -306,7 +306,7 @@ private static string BuildConnectionString(int loadBalanceTimeout = 0, int minP } /// Opens and disposes a connection after reading its authenticated SQL login. - private static async Task GetLogin(string connectionString, bool async) + internal static async Task GetLogin(string connectionString, bool async) { using SqlConnection connection = await Open(connectionString, async); return await SelectLogin(connection, async); From 2d7e4562cc307325fa83c248273ceaf746d72622 Mon Sep 17 00:00:00 2001 From: Malcolm Daigle Date: Tue, 13 Oct 2026 16:40:52 -0700 Subject: [PATCH 10/10] Distinguish cached SQL identity scalars from authenticated login Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- doc/connection-pooling-impersonation.md | 5 +++++ .../OutboundCredentialNameTest.cs | 14 +++++++++++--- 2 files changed, 16 insertions(+), 3 deletions(-) diff --git a/doc/connection-pooling-impersonation.md b/doc/connection-pooling-impersonation.md index 9f6ec6cc87..c87f5757b9 100644 --- a/doc/connection-pooling-impersonation.md +++ b/doc/connection-pooling-impersonation.md @@ -92,6 +92,11 @@ 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. diff --git a/src/Microsoft.Data.SqlClient/tests/ManualTests/SQL/IntegratedAuthenticationTest/OutboundCredentialNameTest.cs b/src/Microsoft.Data.SqlClient/tests/ManualTests/SQL/IntegratedAuthenticationTest/OutboundCredentialNameTest.cs index 55d555ea86..cd38153125 100644 --- a/src/Microsoft.Data.SqlClient/tests/ManualTests/SQL/IntegratedAuthenticationTest/OutboundCredentialNameTest.cs +++ b/src/Microsoft.Data.SqlClient/tests/ManualTests/SQL/IntegratedAuthenticationTest/OutboundCredentialNameTest.cs @@ -199,6 +199,7 @@ private async Task DiagnosePrequery(bool async) 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 () => @@ -207,6 +208,12 @@ private async Task DiagnosePrequery(bool async) 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 () => @@ -287,8 +294,8 @@ 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 authentication properties of the current session without server-level permissions. - private static async Task ReadLogin(string connectionString, bool async) + /// 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(); @@ -296,7 +303,8 @@ private static async Task ReadLogin(string connectionString, bool async) using var command = new SqlCommand( "SELECT ORIGINAL_LOGIN(), SUSER_SNAME(), SUSER_SID(), " + "CONVERT(varchar(30), CONNECTIONPROPERTY('auth_scheme')), " - + "CONVERT(varchar(30), CONNECTIONPROPERTY('net_transport'))", connection); + + "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