Fix | Reenable SqlBulkCopy in least-privilege environments#4306
Fix | Reenable SqlBulkCopy in least-privilege environments#4306edwardneal wants to merge 9 commits into
Conversation
* Add an UnescapedName property to DatabaseObject and use it in XEventsTracingTest. * Create ServerLogin primitive. * Create DatabaseUser primitive.
This makes DatabaseUser simpler to work with. It can store a reference to the database the user should be created in, and the class can handle switching to/from that database.
Fix bug by checking HAS_PERMS_BY_NAME over the sys.all_columns view. This is safe on all supported platforms (including Synapse Analytics dedicated and shared pools - documentation is inconsistent.) Regression tests create a low-privileged login and user in on-premise environments, then use those credentials to perform a SqlBulkCopy.
9e71953 to
c12875c
Compare
|
/azp run |
|
Azure Pipelines successfully started running 2 pipeline(s). |
mdaigle
left a comment
There was a problem hiding this comment.
Still reviewing test changes.
| DECLARE @Column_Name_Query_SORT NVARCHAR(MAX); | ||
| DECLARE @Column_Name_Query NVARCHAR(MAX); | ||
| DECLARE @Column_Names NVARCHAR(MAX) = NULL; | ||
| DECLARE @Has_Permissions INT = HAS_PERMS_BY_NAME('{catalogNameStringLiteral}.[sys].[all_columns]', 'OBJECT', 'SELECT'); |
There was a problem hiding this comment.
✏️
| DECLARE @Has_Permissions INT = HAS_PERMS_BY_NAME('{catalogNameStringLiteral}.[sys].[all_columns]', 'OBJECT', 'SELECT'); | |
| DECLARE @Has_Sys_All_Columns_Permissions INT = HAS_PERMS_BY_NAME('{catalogNameStringLiteral}.[sys].[all_columns]', 'OBJECT', 'SELECT'); |
There was a problem hiding this comment.
No objection here, but I'll change this once the rest of the PR review is complete.
edwardneal
left a comment
There was a problem hiding this comment.
Early responses and some additional commentary on the tests.
| DECLARE @Column_Name_Query_SORT NVARCHAR(MAX); | ||
| DECLARE @Column_Name_Query NVARCHAR(MAX); | ||
| DECLARE @Column_Names NVARCHAR(MAX) = NULL; | ||
| DECLARE @Has_Permissions INT = HAS_PERMS_BY_NAME('{catalogNameStringLiteral}.[sys].[all_columns]', 'OBJECT', 'SELECT'); |
There was a problem hiding this comment.
No objection here, but I'll change this once the rest of the PR review is complete.
| /// The type of the internal state accessible to derived types at the point of object creation | ||
| /// via the <see cref="State"/> property. | ||
| /// </typeparam> | ||
| public abstract class DatabaseObject<TState> : IDisposable |
There was a problem hiding this comment.
This is designed to solve a specific problem: generically passing state to a database object, if that state is required in order to create it. A database user is one example, but another example may be passing column encryption keys for creating Always Encrypted tables, or certificates for creating column encryption keys.
We can't just a property to do this - the base class' constructor will run first and try to create the object before the property is set.
| SqlConnectionStringBuilder tcpConnectionBuilder = new(DataTestUtility.TCPConnectionString) | ||
| { | ||
| IntegratedSecurity = false, | ||
| UserID = _unprivilegedLogin.UnescapedName, |
There was a problem hiding this comment.
This requirement for the unescaped name uses identical logic to XEventsTracingTest (and a few other places which are yet to have the RAII object retrofitted) so I added it to the DatabaseObject<TState> base class.
There was a problem hiding this comment.
Pull request overview
Note
Copilot was unable to run its full agentic suite in this review.
This PR improves SqlBulkCopy behavior when the caller lacks permissions to read sys.all_columns, adding permission-gated alias/hidden-column support and new manual tests/fixtures for unprivileged-login scenarios.
Changes:
- Gate
sys.all_columnsaccess inSqlBulkCopy.CreateInitialQuery()viaHAS_PERMS_BY_NAME, falling back to*when metadata access is denied. - Add manual tests that create an unprivileged SQL login/user and validate bulk copy behavior with/without column aliases.
- Add test infrastructure: generic
DatabaseObject<TState>, transientServerLogin/DatabaseUser, and server-role/auth-mode helpers.
Reviewed changes
Copilot reviewed 9 out of 9 changed files in this pull request and generated 11 comments.
Show a summary per file
| File | Description |
|---|---|
| src/Microsoft.Data.SqlClient/tests/ManualTests/TracingTests/XEventsTracingTest.cs | Switches to UnescapedName helper for SP name normalization in tracing verification. |
| src/Microsoft.Data.SqlClient/tests/ManualTests/Extensions/CodeAnalysis.netfx.cs | Adds a NETFRAMEWORK-only MemberNotNullAttribute shim for nullability annotations. |
| src/Microsoft.Data.SqlClient/tests/ManualTests/DataCommon/DataTestUtility.cs | Adds cached role/auth-mode checks and helpers used to gate new manual tests. |
| src/Microsoft.Data.SqlClient/tests/ManualTests/BulkCopy/UnprivilegedLogin.cs | Introduces manual tests that run bulk copy under an unprivileged SQL login. |
| src/Microsoft.Data.SqlClient/tests/ManualTests/BulkCopy/CopyAllFromReader.cs | Updates expected tracing counter values. |
| src/Microsoft.Data.SqlClient/tests/Common/Fixtures/DatabaseObjects/ServerLogin.cs | Adds transient server-login fixture (create/drop) for tests. |
| src/Microsoft.Data.SqlClient/tests/Common/Fixtures/DatabaseObjects/DatabaseUser.cs | Adds transient database-user fixture (create/drop) for tests. |
| src/Microsoft.Data.SqlClient/tests/Common/Fixtures/DatabaseObjects/DatabaseObject.cs | Refactors fixture base class to DatabaseObject<TState> and adds UnescapedName. |
| src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/SqlBulkCopy.cs | Adds metadata-permission gating and alias behavior changes in initial query generation. |
| CatalogName = SqlServerEscapeHelper.EscapeIdentifier(CatalogName); | ||
| } | ||
|
|
||
| string objectName = ADP.BuildMultiPartName(parts); | ||
| string escapedObjectName = SqlServerEscapeHelper.EscapeStringAsLiteral(objectName); | ||
| string catalogNameStringLiteral = CatalogName is null ? null : SqlServerEscapeHelper.EscapeStringAsLiteral(CatalogName); |
There was a problem hiding this comment.
This is incorrect for a few reasons:
EscapeStringAsLiteraldoesn't wrap the text in quotes, it just escapes them. Wrapping it in quotes inHAS_PERMS_BY_NAMEis thus valid.- It's used as an identifier in
{catalogNameStringLiteral}.[sys]...because that query is dynamically built as a string. .[sys].[all_columns]is a completely valid three-part name. Not all of the parts need to be specified, and so there's no need for null handling - the conversion to an empty string will be absolutely fine.
The same apply for the other four identical comments.
| DECLARE @Column_Name_Query_SORT NVARCHAR(MAX); | ||
| DECLARE @Column_Name_Query NVARCHAR(MAX); | ||
| DECLARE @Column_Names NVARCHAR(MAX) = NULL; | ||
| DECLARE @Has_Permissions INT = HAS_PERMS_BY_NAME('{catalogNameStringLiteral}.[sys].[all_columns]', 'OBJECT', 'SELECT'); |
| IF @Has_Permissions = 1 | ||
| BEGIN | ||
| SET @Column_Name_Query_FILTER = N'WHERE [object_id] = @Object_ID'; | ||
| IF EXISTS (SELECT TOP 1 * FROM {CatalogName}.[sys].[all_columns] WHERE [object_id] = OBJECT_ID('{catalogNameStringLiteral}.[sys].[all_columns]') AND [name] = 'graph_type') |
|
|
||
| EXEC sp_executesql N' | ||
| INSERT INTO #Column_Aliases ([Canonical_Column_Name], [Canonical_Column_Id], [Aliased_Column_Name]) | ||
| SELECT [name], [column_id], ''$to_id'' FROM {catalogNameStringLiteral}.[sys].[all_columns] WHERE [object_id] = @Object_ID AND COALESCE([graph_type], 0) = 8 |
| BEGIN | ||
| SET @Column_Name_Query_FILTER = N'WHERE [object_id] = @Object_ID'; | ||
| END | ||
| SET @Column_Name_Query = @Column_Name_Query_SELECT + ' FROM {catalogNameStringLiteral}.[sys].[all_columns] ' + @Column_Name_Query_FILTER + ' ' + @Column_Name_Query_SORT + ';' |
| using SqlCommand command = new("SELECT IS_SRVROLEMEMBER(@role)", connection); | ||
|
|
||
| connection.Open(); | ||
| command.Parameters.AddWithValue("@role", roleName); |
There was a problem hiding this comment.
That's fine. @role is used as the first parameter to IS_SRVROLEMEMBER, which is defined as sysname - an nvarchar(128).
| public string Name { get; } | ||
|
|
||
| protected DatabaseObject(SqlConnection connection, string name, string definition, bool shouldCreate, bool shouldDrop) | ||
| public string UnescapedName => Name.Substring(1, Name.Length - 2).Replace("]]", "]"); |
There was a problem hiding this comment.
Name is always bracket-escaped.
| [AttributeUsage(AttributeTargets.Method | AttributeTargets.Property, Inherited = false, AllowMultiple = true)] | ||
| internal sealed class MemberNotNullAttribute : Attribute | ||
| { | ||
| public MemberNotNullAttribute(string member) => Members = [member]; |
There was a problem hiding this comment.
LangVersion is appropriately set.
| Random rnd = new(); | ||
|
|
||
| for(int i = 0; i < 5; i++) | ||
| { | ||
| passwordDigits[i] = (char)(UpperCaseStart + rnd.Next(26)); | ||
| } |
There was a problem hiding this comment.
That's fair. I'll change this at the same time as I rename the @Has_Permissions variable.
| for (int i = 10; i < PasswordLength; i++) | ||
| { | ||
| passwordDigits[i] = (char)(DigitsStart + rnd.Next(10)); | ||
| } |
|
/azp run |
|
Azure Pipelines successfully started running 2 pipeline(s). |
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## main #4306 +/- ##
==========================================
- Coverage 65.39% 63.47% -1.93%
==========================================
Files 285 280 -5
Lines 43331 66201 +22870
==========================================
+ Hits 28337 42021 +13684
- Misses 14994 24180 +9186
Flags with carried forward coverage won't be shown. Click here to find out more. ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
Head branch was pushed to by a user without write access
Description
SqlBulkCopy has traditionally only normally required SELECT and INSERT permissions over the table being copied to (src). However, recent additions to the class also required SELECT permissions over the
[sys].[all_columns]metadata view. These permissions are present by default but are sometimes removed as part of SQL Server hardening.This change to the permissions contract was unintentional, the failure of SqlBulkCopy in least-privilege environments which only grant SELECT & INSERT permissions was a bug. This PR resolves the bug by guarding all access to the metadata view behind a simple
HAS_PERMS_BY_NAMEcheck.As a result of this change, least-privilege environments will be unable to use the newer features introduced in #3677 and #3590. I think this is unavoidable - there's no way to implement the features safely without access to the metadata view.
HAS_PERMS_BY_NAMEis documented here as being supported in SQL Server, Azure SQL, managed instances and Fabric. It doesn't document support for two platforms: Azure Synapse Analytics, and APS/PDW. However, other documentation confirms that Azure Synapse Serverless pools support this function, and testing against a dedicated pool confirms that this does too. Furthermore, the APS/PDW 2016 release notes explicitly document the introduction of this function.I've chosen to use this approach because wrapping metadata access in a
TRY/CATCHblock won't work - whenXACT_ABORTis enabled, even a caught exception will result in the wrapping transaction being doomed, and Synapse doesn't allow this option to be temporarily turned off.Issues
Fixes #4293.
Testing
Existing SqlBulkCopy tests continue to pass. I've added two new tests which build a low-privileged environment, then run a bulk copy inside it. They verify that the copy succeeds, but also that the newer alias-based functionality is unavailable.
As part of this, I've added
ServerLoginandDatabaseUserprimitives. The latter primitive highlighted the need for some form of state (the database in which we create the user) to be made available at the point of creation, so I've added this.This widened test infrastructure and more complex testing accounts for about 3/4 of the changes in this PR.
Could someone start a CI run please?