Using Dynamic SQL inside a Stored Procedure
Preventing direct access to database tables for users is a widely considered “best practice” for many DBAs. The pattern typically looks like User -> Stored Procedure -> Tables. Since the stored procedure owner has access to the referenced tables, the user can be granted EXECUTE access to the stored procedure without requiring them to have SELECT access to the tables. However, if you use dynamic T-SQL inside a stored procedure, that security model breaks. T-SQL executed via EXEC ('some T-SQL')
or EXEC sys.sp_executesql @cmd
will execute under the security context of the caller, not the principal who created the stored procedure. Luckily, SQL Server offers a simple workaround for this problem, defining the stored procedure using the WITH EXECUTE AS OWNER
clause.
The Setup
Before we get into the WITH EXECUTE AS OWNER
clause, lets look at the problem with some code. I find code, with output, is often easier to understand than a description of the problem.
Lets create a database we can use for testing:
1 2 3 4 5 6 7 8 |
USE master; IF DB_ID(N'owner_testing') IS NOT NULL BEGIN ALTER DATABASE owner_testing SET SINGLE_USER WITH ROLLBACK IMMEDIATE; DROP DATABASE owner_testing; END CREATE DATABASE owner_testing; GO |
Next, we’ll create a table, and add some data to it.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
USE owner_testing; CREATE TABLE dbo.somedata ( somedata_key int NOT NULL CONSTRAINT somedata_pk PRIMARY KEY CLUSTERED IDENTITY(1,1) , somechardata varchar(100) NOT NULL , somedate datetime NOT NULL CONSTRAINT somedata_somedate_df DEFAULT ((GETDATE())) ); INSERT INTO dbo.somedata (somechardata) VALUES (REPLICATE('a', 100)); |
Next we’ll create a stored procedure that returns results from the table.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
CREATE PROCEDURE dbo.somedata_query ( @StartDate datetime , @EndDate datetime ) AS BEGIN SET NOCOUNT ON; SET XACT_ABORT ON; SELECT sd.somedata_key , sd.somechardata , sd.somedate FROM dbo.somedata sd WHERE sd.somedate >= @StartDate AND sd.somedate <= @EndDate; END GO< |
We need a login and user to complete the setup in a manner similar to what you’re likely to find in production. The last statement grants EXECUTE on the stored procedure to the newly created user.
1 2 3 4 5 6 7 8 9 10 11 |
CREATE LOGIN owner_testing_login WITH PASSWORD = N'#TheftNewtonCaputJensen6' , DEFAULT_DATABASE = owner_testing , DEFAULT_LANGUAGE = us_english; CREATE USER owner_testing_user FOR LOGIN owner_testing_login WITH DEFAULT_SCHEMA = dbo; GRANT EXECUTE ON dbo.somedata_query TO owner_testing_user; GO |
The Initial Test
In this next bit of code, I’m using the EXECUTE AS USER = N'owner_testing_user';
to simulate logging into the SQL Server as that user. If you like, you can simply open a new SSMS query window logged in as owner_testing_login, and run the EXEC and SELECT statements there.
1 2 3 4 5 6 |
EXECUTE AS USER = N'owner_testing_user'; EXEC dbo.somedata_query @StartDate = N'2019-07-01 00:00:00', @EndDate = N'2019-07-31 23:59:59.997'; SELECT * FROM dbo.somedata; REVERT GO |
The stored procedure executes without error, and returns results as expected. However, the SELECT
statement results in an error:
Msg 229, Level 14, State 5, Line 57 The SELECT permission was denied on the object 'somedata', database 'owner_testing', schema 'dbo'.
That’s by design; we want the user to be able to retrieve data out of the table only via the stored procedure. If we let them select rows directly from the table, all hell might break loose™.
Uh-oh! Dynamic T-SQL!
Now, what if we want to make the @StartDate
and @EndDate
parameters optional? Perhaps the user wants to select every row that exists prior to January 1st, 2017 for example. Or sometimes, they want to get only today’s rows, via @StartDate >= '2019-07-30 00:00:00'
. A good way to do that is with Dynamic T-SQL since it prevents what is known as kitchen-sink-coding where the T-SQL query contains every possible parameter, even if they are unused. Let’s reconstruct the stored procedure to accomplish that:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 |
DROP PROCEDURE IF EXISTS dbo.somedata_query; GO CREATE PROCEDURE dbo.somedata_query ( @StartDate datetime = NULL , @EndDate datetime = NULL ) AS BEGIN SET NOCOUNT ON; SET XACT_ABORT ON; DECLARE @cmd nvarchar(max); DECLARE @connector nvarchar(max); DECLARE @params nvarchar(max); SET @params = N'@StartDate datetime, @EndDate datetime'; SET @cmd = N' SELECT sd.somedata_key , sd.somechardata , sd.somedate FROM dbo.somedata sd '; SET @connector = 'WHERE '; IF @StartDate IS NOT NULL BEGIN SET @cmd = @cmd + @connector + N'sd.somedate >= @StartDate'; SET @connector = ' AND '; END IF @EndDate IS NOT NULL BEGIN SET @cmd = @cmd + @connector + N'sd.somedate <= @EndDate'; SET @connector = ' AND '; END SET @cmd = @cmd + ';'; EXEC sys.sp_executesql @cmd, @params, @StartDate = @StartDate, @EndDate = @EndDate; END GO GRANT EXECUTE ON dbo.somedata_query TO owner_testing_user; GO |
Now, let’s re-run the stored procedure:
1 2 3 4 |
EXECUTE AS USER = 'owner_testing_user'; EXEC dbo.somedata_query @StartDate = N'2019-07-01 00:00:00', @EndDate = N'2019-07-31 23:59:59.997'; REVERT GO |
We get that same error about SELECT permission denied
:
Msg 229, Level 14, State 5, Line 102 The SELECT permission was denied on the object 'somedata', database 'owner_testing', schema 'dbo'.
That’s because the dynamic T-SQL is executed under the users security context, not that of the stored procedure.1
The Solution™
We can get around the problem by redefining the stored procedure to make use of the EXECUTE AS OWNER
clause:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 |
DROP PROCEDURE IF EXISTS dbo.somedata_query; GO CREATE PROCEDURE dbo.somedata_query ( @StartDate datetime = NULL , @EndDate datetime = NULL ) WITH EXECUTE AS OWNER AS BEGIN SET NOCOUNT ON; SET XACT_ABORT ON; DECLARE @cmd nvarchar(max); DECLARE @connector nvarchar(max); DECLARE @params nvarchar(max); SET @params = N'@StartDate datetime, @EndDate datetime'; SET @cmd = N' SELECT sd.somedata_key , sd.somechardata , sd.somedate FROM dbo.somedata sd '; SET @connector = 'WHERE '; IF @StartDate IS NOT NULL BEGIN SET @cmd = @cmd + @connector + N'sd.somedate >= @StartDate'; SET @connector = ' AND '; END IF @EndDate IS NOT NULL BEGIN SET @cmd = @cmd + @connector + N'sd.somedate <= @EndDate'; SET @connector = ' AND '; END SET @cmd = @cmd + ';'; EXEC sys.sp_executesql @cmd, @params, @StartDate = @StartDate, @EndDate = @EndDate; END GO GRANT EXECUTE ON dbo.somedata_query TO owner_testing_user; GO |
Note in the above code I am using parameters for everything that is passed in by the user, instead of simply appending the values directly to the @cmd
string. That’s important because it prevents SQL Injection vulnerabilities. It’s doubly-important that you don’t create a SQL Injection vulnerability in a stored procedure that uses EXECUTE AS OWNER
since the resulting dynamic T-SQL code will be executed in security context of the login that created the stored procedure, typically a sysadmin.2 But enough about that. Let’s run the proc now:
1 2 3 4 |
EXECUTE AS USER = 'owner_testing_user'; EXEC dbo.somedata_query @StartDate = N'2019-07-01 00:00:00', @EndDate = N'2019-07-31 23:59:59.997'; REVERT GO |
Now, we get the results we expect:
╔══════════════╦════════════════════════╦═════════════════════════╗ ║ somedata_key ║ somechardata ║ somedate ║ ╠══════════════╬════════════════════════╬═════════════════════════╣ ║ 1 ║ aaaaaaaaaaaaaaaaaaa... ║ 2019-07-25 12:58:18.233 ║ ╚══════════════╩════════════════════════╩═════════════════════════╝
Success! This allows us to run Dynamic T-SQL from inside a stored procedure, without granting the caller rights to the underlying table.
Thanks for reading, and I hope you enjoyed this part of our series on T-SQL.
If you found this post helpful, would you consider sharing it on social media? That would really help me out!
1 – See Solomon’s comment below for a more technically accurate picture of what’s actually happening here.
2 – Code is not executed in the security context of the login that created the procedure, it is actually executed with the rights of the owner of the procedure, typically the schema, which in the case of the above example is dbo
. See Solomon’s comment below for important details.
Hi Hannah. Nice post. There are a few inaccuracies towards the end:
The statement above is incorrect. Dynamic SQL executes in the same security context as the rest of the procedure (assuming the “AS” clause was not used). Stored procedures, by default, execute in the security context of the caller. The reason for the permission error is that Dynamic SQL breaks ownership chaining. Ownership chaining is not a different security context; it merely skips permission checks (for DML and EXECUTE permissions) on objects within modules that are owned by the owner of the module. When ownership chaining is broken, permissions on objects are checked.
There are two things to fix here:
Meaning, if User [a] creates a stored procedure [b].[proc] using “WITH EXECUTE AS OWNER”, and Schema [b] is owned by “dbo”, then if User [c] executes [b].[proc], the execution context will be as follows:
Yes, using “WITH EXECUTE AS OWNER” (or any value for “EXECUTE AS”) is quick and easy, but it is also problematic for a few reasons:
For these reasons, I would recommend against using the “WITH EXECUTE AS” clause as the ease-of-use does not seem to outweigh the security risks. Instead, I highly recommend Module Signing – see details at ModuleSigning.info. It takes a couple of extra steps (2 one-time setup steps, 1 per-module step, and 1 step that might cover all modules but might not), but they are not complicated and the benefits are well worth it:
Applying this to your example, you would only need to do the following to get the Dynamic SQL working:
That’s it. All you needed was the SELECT permission, and that is all that has been granted (and only to that one Stored Procedure, not to any User that can actually log in / connect). If “dbo.somedata_query” were to “EXEC dbo.LogRequest @User, @StartDate, @EndDate;” (or anything), then the additional SELECT permission would not be available to “dbo.LogRequest” unless that Stored Procedure were all signed with that Certificate.
I have a more thorough explanation / walk-through of all of this in my post PLEASE, Please, please Stop Using Impersonation, TRUSTWORTHY, and Cross-DB Ownership Chaining. And there is even a separate post that is a step-by-step walk-through of doing what I described above (link is towards the bottom of that post, in the “Examples” section, titled “Safely and Easily Use High-Level Permissions Without Granting Them to Anyone: Database-level”.
Take care,
Solomon
Hi Solomon – Thanks for such a detailed comment. This post is designed to provide a quick, easy-to-understand introduction to using the
WITH EXECUTE AS
clause in a stored procedure for the simple case of executing parameterized dynamic SQL inside a stored procedure. While you have accurately denoted the security implications, I think for most cases where the requirement is well understood, and the code is parameterized,WITH EXECUTE AS OWNER
is not a security risk, and offers an easy and succinct method of making the code work. Of course, if you don’t parameterize your dynamic T-SQL, then you’ve got a problem, as noted in my post.