diff --git a/CLAUDE.md b/CLAUDE.md index e54e07cd..644d8916 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -127,10 +127,10 @@ The library targets **both** `net472` (Windows PowerShell 5.1) and `net8.0` (Pow | Package | Ceiling | Why | |---------|---------|-----| -| Microsoft.Data.SqlClient | 6.x only | DacFx/SMO compiled against 6.x; 7.x causes type-load failures | -| Microsoft.PowerShell.SDK | 7.4.x only | 7.5+ requires net9.0 target change | -| MSTest.* | 3.x only | 4.x drops `Assert.ThrowsException()` on net472 | -| Microsoft.NET.Test.Sdk | 17.x only | 18.x aligns with MSTest 4.x ecosystem | +| Microsoft.Data.SqlClient | 7.x | 7.0.1 is validated with the pinned SMO and DacFx packages; revalidate their loaders before upgrading | +| Microsoft.PowerShell.SDK | 7.4.x | 7.5+ requires a net9.0 target change | +| MSTest.* | 3.x | 4.x drops `Assert.ThrowsException()` on net472 | +| Microsoft.NET.Test.Sdk | 17.x | 18.x aligns with the MSTest 4.x ecosystem | For full details and current versions, see the [dependency constraints memory](file://memory/dependency_constraints.md). diff --git a/dbatools.library.psd1 b/dbatools.library.psd1 index faf09630..d88066f9 100644 --- a/dbatools.library.psd1 +++ b/dbatools.library.psd1 @@ -7,7 +7,7 @@ # @{ # Version number of this module. - ModuleVersion = '2026.5.3' + ModuleVersion = '2026.9.14' # ID used to uniquely identify this module GUID = '00b61a37-6c36-40d8-8865-ac0180288c84' diff --git a/project/dbatools.Tests/Connection/SqlClientCompatibilityTest.cs b/project/dbatools.Tests/Connection/SqlClientCompatibilityTest.cs new file mode 100644 index 00000000..ac6d025e --- /dev/null +++ b/project/dbatools.Tests/Connection/SqlClientCompatibilityTest.cs @@ -0,0 +1,76 @@ +using Microsoft.Data.SqlClient; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using System; +using System.Net; + +namespace Dataplat.Dbatools.Connection +{ + [TestClass] + public class SqlClientCompatibilityTest + { + [TestMethod] + public void SqlConnectionExposesPluggableSspiProvider() + { + Assert.IsNotNull(typeof(SqlConnection).GetProperty("SspiContextProvider")); + } + + [TestMethod] + public void ActiveDirectoryAuthenticationProviderIsRegistered() + { + Assert.IsNotNull(SqlAuthenticationProvider.GetProvider(SqlAuthenticationMethod.ActiveDirectoryIntegrated)); + } + +#if NET8_0_OR_GREATER + [TestMethod] + public void NetworkCredentialSspiProviderCanBeAssignedToSqlConnection() + { + using (NetworkCredentialSspiContextProvider provider = new NetworkCredentialSspiContextProvider( + new NetworkCredential("user", "password", "domain"))) + using (SqlConnection connection = new SqlConnection()) + { + connection.SspiContextProvider = provider; + + Assert.AreSame(provider, connection.SspiContextProvider); + } + } + + [TestMethod] + public void NetworkCredentialSspiProviderRejectsNullCredential() + { + Assert.ThrowsExactly(() => new NetworkCredentialSspiContextProvider(null)); + } + + [TestMethod] + public void NetworkCredentialSspiProviderExposesPasswordFreePrincipal() + { + using (NetworkCredentialSspiContextProvider domainQualified = new NetworkCredentialSspiContextProvider( + new NetworkCredential("user", "password", "domain"))) + using (NetworkCredentialSspiContextProvider domainless = new NetworkCredentialSspiContextProvider( + new NetworkCredential("user", "password", string.Empty))) + { + Assert.AreEqual("domain\\user", domainQualified.Principal); + Assert.AreEqual("user", domainless.Principal); + } + } + + [TestMethod] + public void NetworkCredentialSspiProviderHasStablePoolIdentityPerCredential() + { + using (NetworkCredentialSspiContextProvider first = new NetworkCredentialSspiContextProvider( + new NetworkCredential("user", "password", "domain"))) + using (NetworkCredentialSspiContextProvider second = new NetworkCredentialSspiContextProvider( + new NetworkCredential("USER", "password", "DOMAIN"))) + using (NetworkCredentialSspiContextProvider different = new NetworkCredentialSspiContextProvider( + new NetworkCredential("other-user", "password", "domain"))) + using (NetworkCredentialSspiContextProvider differentPassword = new NetworkCredentialSspiContextProvider( + new NetworkCredential("user", "different-password", "domain"))) + { + Assert.AreEqual(first, second); + Assert.AreEqual(first.GetHashCode(), second.GetHashCode()); + Assert.AreNotEqual(first, different); + Assert.AreNotEqual(first, differentPassword); + } + } +#endif + } +} diff --git a/project/dbatools/Connection/NetworkCredentialSspiContextProvider.cs b/project/dbatools/Connection/NetworkCredentialSspiContextProvider.cs new file mode 100644 index 00000000..7b9fd058 --- /dev/null +++ b/project/dbatools/Connection/NetworkCredentialSspiContextProvider.cs @@ -0,0 +1,151 @@ +#if NET8_0_OR_GREATER +using Microsoft.Data.SqlClient; +using System; +using System.Buffers; +using System.Net; +using System.Net.Security; +using System.Security.Cryptography; +using System.Text; + +namespace Dataplat.Dbatools.Connection +{ + /// + /// Generates SQL Server integrated-authentication tokens from an explicit Windows credential. + /// + public sealed class NetworkCredentialSspiContextProvider : SspiContextProvider, IDisposable + { + private readonly NetworkCredential credential; + private readonly byte[] credentialIdentity; + private readonly string principal; + private NegotiateAuthentication authentication; + private string resource; + private bool disposed; + + /// + /// The case-insensitive Windows principal ("domain\user" or "user") this provider + /// authenticates as. Password-free, safe to use as part of a registry/cache key. + /// + public string Principal + { + get { return principal; } + } + + /// + /// Creates a provider that authenticates with the supplied Windows credential. + /// + /// The Windows credential used for Negotiate authentication. + public NetworkCredentialSspiContextProvider(NetworkCredential credential) + { + if (credential == null) + throw new ArgumentNullException(nameof(credential)); + + this.credential = new NetworkCredential(credential.UserName, credential.Password, credential.Domain); + principal = String.IsNullOrEmpty(credential.Domain) + ? credential.UserName + : credential.Domain + "\\" + credential.UserName; + using (SHA256 sha256 = SHA256.Create()) + { + credentialIdentity = sha256.ComputeHash(Encoding.UTF8.GetBytes( + principal.ToUpperInvariant() + "\0" + credential.Password)); + } + } + + /// + /// Compares providers by Windows principal and credential identity for the SqlClient pool key. + /// + public override bool Equals(object obj) + { + NetworkCredentialSspiContextProvider other = obj as NetworkCredentialSspiContextProvider; + return other != null && String.Equals(principal, other.principal, StringComparison.OrdinalIgnoreCase) && + CryptographicOperations.FixedTimeEquals(credentialIdentity, other.credentialIdentity); + } + + /// + /// Returns the case-insensitive Windows principal hash used by the SqlClient pool key. + /// Credential identity is intentionally excluded to avoid disclosing a password-derived hash value. + /// + public override int GetHashCode() + { + return StringComparer.OrdinalIgnoreCase.GetHashCode(principal); + } + + /// + protected override bool GenerateContext( + ReadOnlySpan incomingBlob, + IBufferWriter outgoingBlobWriter, + SspiAuthenticationParameters authParams) + { + if (disposed) + throw new ObjectDisposedException(nameof(NetworkCredentialSspiContextProvider)); + if (outgoingBlobWriter == null) + throw new ArgumentNullException(nameof(outgoingBlobWriter)); + if (authParams == null) + throw new ArgumentNullException(nameof(authParams)); + + if (authentication == null || authentication.IsAuthenticated || + !String.Equals(resource, authParams.Resource, StringComparison.Ordinal)) + { + if (authentication != null) + authentication.Dispose(); + + resource = authParams.Resource; + authentication = new NegotiateAuthentication(new NegotiateAuthenticationClientOptions + { + Package = "Negotiate", + TargetName = resource, + Credential = credential + }); + } + + try + { + NegotiateAuthenticationStatusCode statusCode; + byte[] outgoingBlob = authentication.GetOutgoingBlob(incomingBlob, out statusCode); + if (statusCode != NegotiateAuthenticationStatusCode.Completed && + statusCode != NegotiateAuthenticationStatusCode.ContinueNeeded) + { + ResetAuthentication(); + return false; + } + + if (outgoingBlob != null) + { + Span destination = outgoingBlobWriter.GetSpan(outgoingBlob.Length); + outgoingBlob.AsSpan().CopyTo(destination); + outgoingBlobWriter.Advance(outgoingBlob.Length); + } + + if (statusCode == NegotiateAuthenticationStatusCode.Completed) + ResetAuthentication(); + + return true; + } + catch + { + ResetAuthentication(); + throw; + } + } + + private void ResetAuthentication() + { + if (authentication != null) + authentication.Dispose(); + authentication = null; + resource = null; + } + + /// + /// Releases the active Negotiate authentication context. + /// + public void Dispose() + { + if (disposed) + return; + + disposed = true; + ResetAuthentication(); + } + } +} +#endif diff --git a/project/dbatools/dbatools.csproj b/project/dbatools/dbatools.csproj index 05f7c011..cdff1628 100644 --- a/project/dbatools/dbatools.csproj +++ b/project/dbatools/dbatools.csproj @@ -55,7 +55,11 @@ - + + + + +