Does SQL Server ownership chaining not work for ALTER? - sql-server

I have a case where SQL Server ownership chaining doesn't seem to work - or am I missing something?
I have two schemas: Schema1 and Schema2.
In Schema1, I only have SELECT permissions, while in Schema2, I only have EXEC permissions.
I call a stored procedure in Schema2 which inserts a record into a table in Schema1.
This works fine (even though I don't have INSERT permissions in Schema1) due to ownership chaining.
Now, when I call another stored procedure which turns off the identity column in the table before inserting I get an error:
Msg 1088, Level 16, State 11, Procedure Schema2.AddRecordWithSpecificId, Line 7 [Batch Start Line 60]
Cannot find the object "Schema1.MyTable" because it does not exist or you do not have permissions.
If I grant ALTER permissions to Schema1, it works fine - but why is that necessary? Why doesn't schema chaining work in this case?
Script that reproduces the problem:
CREATE DATABASE OwnershipChainingTest
GO
USE OwnershipChainingTest
GO
CREATE SCHEMA Schema1 AUTHORIZATION [dbo]
GO
CREATE SCHEMA Schema2 AUTHORIZATION [dbo]
GO
CREATE TABLE Schema1.MyTable
(
Id int IDENTITY(1,1) NOT NULL,
Title varchar(50) NOT NULL
)
GO
CREATE PROCEDURE Schema2.AddRecord
#title nvarchar(100)
AS
BEGIN
INSERT INTO Schema1.MyTable (Title)
VALUES (#title)
END
GO
CREATE PROCEDURE Schema2.AddRecordWithSpecificId
#id int,
#title nvarchar(100)
AS
BEGIN
SET IDENTITY_INSERT Schema1.MyTable ON
INSERT INTO Schema1.MyTable (Id, Title)
VALUES (#id, #title)
SET IDENTITY_INSERT Schema1.MyTable OFF
END
GO
CREATE USER MyUser WITHOUT LOGIN
GO
CREATE ROLE MyRole AUTHORIZATION [dbo]
GO
EXEC sp_addrolemember MyRole, MyUser
GO
-- With this it works: GRANT SELECT, ALTER ON Schema::Schema1 TO MyRole
GRANT SELECT ON Schema::Schema1 TO MyRole
GO
GRANT EXEC ON Schema::Schema2 TO MyRole
GO
EXEC AS user = 'MyUser'
EXEC Schema2.AddRecord 'hello1'
GO
-- This causes an error
EXEC Schema2.AddRecordWithSpecificId 42, 'hello2'
GO
REVERT;
--SELECT CURRENT_USER
SELECT * FROM Schema1.MyTable
USE MASTER
DROP DATABASE OwnershipChainingTest
GO

Ownership chaining applies only to DML. SET IDENTITY_INSERT is essentially a DDL operation, which is why it requires at least ALTER permissions on the table.
A good way to allow minimally privileged users with only execute permissions to run the proc is by signing the proc with a certificate based on a user with ALTER permissions:
--create certificate and sign proc
CREATE CERTIFICATE AddRecordWithSpecificIdCert
ENCRYPTION BY PASSWORD = 'temporary password'
WITH SUBJECT = 'Allow ALTER on Schema1';
ADD SIGNATURE TO Schema2.AddRecordWithSpecificId BY CERTIFICATE AddRecordWithSpecificIdCert WITH PASSWORD = 'temporary password';
--remove ephemeral private key
ALTER CERTIFICATE AddRecordWithSpecificIdCert REMOVE PRIVATE KEY;
--create a user from certificate with the needed permissions
CREATE USER AddRecordWithSpecificIdCertUser FROM CERTIFICATE AddRecordWithSpecificIdCert;
GRANT ALTER ON SCHEMA::Schema1 TO AddRecordWithSpecificIdCertUser;
GO
--this test now works
EXEC AS user = 'MyUser';
GO
EXEC Schema2.AddRecordWithSpecificId 42, 'hello2'
GO
REVERT;
GO

Related

SQL Server Permission Chaining

I have the following issue:
I have two different databases, db1 and db2. I have an application that loads data into db2 or db3. db1 has a few tables that the application uses to determine behavior, including which db the application should load data into.
Users need to have write access to db1 to operate the application (there is a console application that writes to tables in db1, operating with windows authentication).
Users should not have DML privileges to db2 and db3, with the exception of a few predetermined operations. We grant AD groups database roles to control access from and organization perspective. Specifically, I'm trying to build a stored procedure in db1 that operators can use to reverse data loaded to db2 or db3 with appropriate logging.
I'm attempting to use create proc ... execute as owner to accomplish this, but it does not seem to be working when I try to hit tables in db2/db3 (I'm thinking that "execute as owner" operates on db level users an not server level logins?). The following causes a permission error stating that the owner (myself) does not have permissions to db2/db3.
use db1
go
create proc dbo.wrapper #recordid int
as begin
/*
capturing user
*/
declare #usr varchar(255) = SUSER_SNAME()
exec dbo.inner #usr , #recordid
end
use db1
go
create proc dbo.inner #usr varchar(255), #recordid int
with execute as owner
as begin
/*
logic to determine whether to update db2 or db3 goes here
*/
insert db2.rolled_back
select * , #usr from db2.transactions where id = #recordid
delete from db2.transactions where id = #recordid
insert db3.rolled_back
select * , #usr from db3.transactions where id = #recordid
delete from db3.transactions where id = #recordid
end
Is there a way to get this to work? I've heard that certificate signing could do this, does anyone have any experience using certificate users. Our DBA's would rather not have to maintain certificates, so if there is a way to get this to work without certificates that would be best.
Any advice would be helpful.
Thank You!
I'm going to cover the cross database chaining side of thing here. note that there are certainly security considerations when using this method. For example someone with permissions to create objects in one database can give themselves access to data in another database with the owner, when they themselves have no access to the other database. The security concerns, however, are out of scope of this answer.
Firstly, let's create a couple of test databases.
USE master;
GO
CREATE DATABASE Chain1;
CREATE DATABASE Chain2;
Now I'm going to CREATE a LOGIN, which is disable and make that the owner of these 2 databases. The databases having the same owner is important, as otherwise the chaining won't work.
CREATE LOGIN ChainerOwner WITH PASSWORD = N'SomeSecurePassword123';
ALTER LOGIN ChainerOwner DISABLE;
GO
ALTER AUTHORIZATION ON DATABASE::Chain1 TO ChainerOwner;
ALTER AUTHORIZATION ON DATABASE::Chain2 TO ChainerOwner;
I'm also going to create a LOGIN which we're going to use to test on:
CREATE LOGIN SomeUser WITH PASSWORD = N'SomeSecurePassword123';
Great, now I can create a few objects; a table in Chain1, a PROCEDURE in Chain2 that accesses the TABLE, and a USER in both databases for SomeUser. In Chain1 the USER will be given no permissions, and in Chain2 the user will be given the permsision to EXECUTE the PROCEDURE:
USE Chain1;
GO
CREATE TABLE dbo.SomeTable (I int IDENTITY,
S varchar(10));
INSERT INTO dbo.SomeTable (S)
VALUES ('abc'),
('xyz');
GO
CREATE USER SomeUser FOR LOGIN SomeUser;
GO
USE Chain2;
GO
CREATE PROC dbo.CrossDBProc #I int AS
BEGIN
SELECT I,
S
FROM Chain1.dbo.SomeTable
WHERE I = #I;
END;
GO
CREATE USER SomeUser FOR LOGIN SomeUser;
GO
GRANT EXECUTE ON dbo.CrossDBProc TO SomeUser;
GO
Great, all the objects are created, now let's try to EXECUTE that PROCEDURE:
EXECUTE AS LOGIN = 'SomeUser';
GO
EXEC dbo.CrossDBProc 1; --This fails
GO
REVERT;
GO
This fails, with a permission error:
The SELECT permission was denied on the object 'SomeTable', database 'Chain1', schema 'dbo'.
This is expected, as there is no ownership chaining. let's, therefore enable that now.
USE master;
GO
ALTER DATABASE Chain1 SET DB_CHAINING ON;
ALTER DATABASE Chain2 SET DB_CHAINING ON;
Now if I try the same again, the same SQL works:
USE Chain2;
GO
EXECUTE AS LOGIN = 'SomeUser';
GO
EXEC dbo.CrossDBProc 1; --This now works
GO
REVERT;
GO
This successfully returns the result set
I
S
1
abc
So, yes you can chain cross database, but it requires some set up, and (again) there are security considerations you need think about.
Clean up:
USE master;
GO
DROP DATABASE Chain1;
DROP DATABASE Chain2;
GO
DROP LOGIN ChainerOwner;
DROP LOGIN SomeUser;

SQL Server 2019 cross-database function call permission

I have come across some interesting behaviour in SQL Server 2019 - it does not seem to occur in earlier versions.
If, in database1, I call a function in the same database, which calls a function in database2, which SELECTS a table in database2, I get "The SELECT permission was denied on the object '{TableName}', database '{DbName}', schema 'dbo'."
If, instead, I call the function in database2 directly (without using a function in database1), the query executes successfully.
My question is: what is the logic behind this? I don't understand why I am allowed to read a table in another database, without the SELECT permission, through a function, but not when I call that function using a function in my current database! Is it due to the function preventing the passing on of permissions? I am assuming at the moment that this is an intended change - but I don't understand the logic behind it.
Below is some code demonstrating the behaviour in a simple way.
/*******************************************
SET UP
*******************************************/
CREATE DATABASE TestDb1
GO
CREATE DATABASE TestDb2
GO
CREATE LOGIN [TestLogin] WITH PASSWORD = '123456a.'
GO
--Create users in each database and add to roles.
USE TestDb1
CREATE USER [TestUser] FOR LOGIN [TestLogin]
CREATE ROLE Db1Role
ALTER ROLE Db1Role ADD MEMBER [TestUser]
USE TestDb2
CREATE USER [TestUser] FOR LOGIN [TestLogin]
CREATE ROLE Db2Role
ALTER ROLE Db2Role ADD MEMBER [TestUser]
--Create table in db1, but do no GRANTs on it.
USE TestDb1
CREATE TABLE dbo._testDb1Table (Col1 INT)
GO
--Create a function in db1, and GRANT EXECUTE.
CREATE FUNCTION dbo._TestDb1Function()
RETURNS INT
AS
BEGIN
DECLARE #Result INT = (SELECT TOP (1) Col1 FROM dbo._testDb1Table)
RETURN #Result
END
GO
GRANT EXECUTE ON dbo._TestDb1Function TO Db1Role
GO
--Create a function in db2, and GRANT EXECUTE.
USE TestDb2
GO
CREATE FUNCTION dbo._TestDb2Function()
RETURNS INT
AS
BEGIN
DECLARE #Result INT = (SELECT TestDb1.dbo._TestDb1Function())
RETURN #Result
END
GO
GRANT EXECUTE ON dbo._TestDb2Function TO Db2Role
GO
/*******************************************
TESTS
*******************************************/
USE TestDb2
--Querying TestDb1 by calling the TestDb2 function directly works.
EXECUTE AS LOGIN = 'TestLogin'
SELECT TestDb1.dbo._TestDb1Function()
REVERT
GO
--Querying TestDb2 through a scalar function in db2 doesn't work.
--The SELECT permission was denied on the object '_testDb1Table', database 'TestDb1', schema 'dbo'.
EXECUTE AS LOGIN = 'TestLogin'
SELECT dbo._TestDb2Function()
REVERT
GO
/*******************************************
TIDY UP
*******************************************/
USE [master]
DROP LOGIN [TestLogin]
DROP DATABASE TestDb1
DROP DATABASE TestDb2
This was a bug in SQL Server 2019, caused by scalar UDF inlining.
It was fixed in SQL Server 2019 CU9 (published in February 2021).
For more details see KB4538581.
As per helpful comments by GSerg and Larnu, this behaviour appears to be caused by the scalar UDF inlining feature, added in SQL Server 2019.
It can be fixed by disabling scalar UDF inlining at the database level, in the function definition, or using a query hint.
Edit: as per the answer by Razvan Socol, this has been fixed in SQL Sever 2019 CU9.
Here is the same code as given in the original question, but with these 3 possible solutions inserted into the appropriate places (commented out). Uncommenting any of these 3 solutions allows the script to run without error in SQL Server 2019.
/*******************************************
SET UP
*******************************************/
CREATE DATABASE TestDb1
CREATE DATABASE TestDb2
GO
--SOLUTION 1: Turn off scalar UDF inlining at the database level.
--USE TestDb2
--ALTER DATABASE SCOPED CONFIGURATION SET TSQL_SCALAR_UDF_INLINING = OFF;
GO
CREATE LOGIN [TestLogin] WITH PASSWORD = '123456a.'
GO
--Create users in each database and add to roles.
USE TestDb1
CREATE USER [TestUser] FOR LOGIN [TestLogin]
CREATE ROLE Db1Role
ALTER ROLE Db1Role ADD MEMBER [TestUser]
USE TestDb2
CREATE USER [TestUser] FOR LOGIN [TestLogin]
CREATE ROLE Db2Role
ALTER ROLE Db2Role ADD MEMBER [TestUser]
--Create table in db1, but do no GRANTs on it.
USE TestDb1
CREATE TABLE dbo._testDb1Table (Col1 INT)
GO
--Create a function in db1, and GRANT EXECUTE.
CREATE FUNCTION dbo._TestDb1Function()
RETURNS INT
AS
BEGIN
DECLARE #Result INT = (SELECT TOP (1) Col1 FROM dbo._testDb1Table)
RETURN #Result
END
GO
GRANT EXECUTE ON dbo._TestDb1Function TO Db1Role
GO
--Create a function in db2, and GRANT EXECUTE.
USE TestDb2
GO
CREATE FUNCTION dbo._TestDb2Function()
RETURNS INT
--SOLUTION 2: Turn off scalar UDF inlining for the function.
--WITH INLINE = OFF
AS
BEGIN
DECLARE #Result INT = (SELECT TestDb1.dbo._TestDb1Function())
RETURN #Result
END
GO
GRANT EXECUTE ON dbo._TestDb2Function TO Db2Role
GO
/*******************************************
TESTS
*******************************************/
USE TestDb2
--Querying TestDb1 by calling the TestDb2 function directly works.
EXECUTE AS LOGIN = 'TestLogin'
SELECT TestDb1.dbo._TestDb1Function()
REVERT
GO
--Querying TestDb2 through a scalar function in db2 doesn't work.
--The SELECT permission was denied on the object '_testDb1Table', database 'TestDb1', schema 'dbo'.
EXECUTE AS LOGIN = 'TestLogin'
SELECT dbo._TestDb2Function()
--SOLUTION 3: Turn off scalar UDF inlining for the query which calls the function.
--OPTION (USE HINT('DISABLE_TSQL_SCALAR_UDF_INLINING')); --Added line
REVERT
GO
/*******************************************
TIDY UP
*******************************************/
USE [master]
DROP LOGIN [TestLogin]
DROP DATABASE TestDb1
DROP DATABASE TestDb2

How to create an Azure SQL Database user who can CREATE/ALTER/DROP views but not tables, can read data but not INSERT/UPDATE/DELETE/TRUNCATE data?

Is it possible to create an Azure SQL Database user who can:
SELECT against all tables and views
CREATE/ALTER/DROP views
But the user should NOT have the below permissions:
INSERT/UPDATE/DELETE/TRUNCATE TABLE against any table or view
CREATE/ALTER/DROP any table
What is the proper statements to achieve the above requirements, or it is not possible in Azure SQL?
I tried the below statements, without the last statement, the user cannot create views, but adding the last statement the user can drop tables.
CREATE USER [TestUser] WITH PASSWORD=N'NAvCO_h2eMuX', DEFAULT_SCHEMA=[dbo];
CREATE ROLE [TestRole];
ALTER ROLE [TestRole] ADD MEMBER [TestUser];
ALTER ROLE [db_datareader] ADD MEMBER [TestRole];
GRANT CREATE VIEW TO [TestRole];
GRANT SELECT ON SCHEMA :: dbo TO [TestRole];
GRANT ALTER ON SCHEMA :: dbo TO [TestRole];
When you run the ALTER ROLE [db_datareader] ADD MEMBER [TestRole] or GRANT SELECT ON SCHEMA :: dbo TO [TestRole], the user [TestUser] will be the readonly role db_datareader of the database. You can not do any other opreations like "INSERT/UPDATE/DELETE against any table or view".
But you add the ALTER permission to [TestUser], the user both will have SELECT and ALTER permission, the user will be the role like db_owner. Off course it has the permission to create view or drop tables.
It's impossible to create an Azure SQL Database user which both have the readonly permission and CREATE VIEW permission.
Reference: Database-Level Roles
Hope this helps.
As a workaround, you can create a Database DDL Trigger that does not allow that user to create, drop or alter any table or specific tables.
ALTER TRIGGER [TR_ProtectTables]
ON DATABASE
FOR DROP_TABLE, ALTER_TABLE, CREATE_TABLE
AS
DECLARE #eventData XML,
#uname NVARCHAR(50),
#sname NVARCHAR(100),
#oname NVARCHAR(100),
#otext VARCHAR(MAX),
#etype NVARCHAR(100),
#edate DATETIME
SET #eventData = eventdata()
SELECT
#edate=GETDATE(),
#uname=#eventData.value('data(/EVENT_INSTANCE/UserName)[1]', 'SYSNAME'),
#sname=#eventData.value('data(/EVENT_INSTANCE/SchemaName)[1]', 'SYSNAME'),
#oname=#eventData.value('data(/EVENT_INSTANCE/ObjectName)[1]', 'SYSNAME'),
#otext=#eventData.value('data(/EVENT_INSTANCE/TSQLCommand/CommandText)[1]', 'VARCHAR(MAX)'),
#etype=#eventData.value('data(/EVENT_INSTANCE/EventType)[1]', 'nvarchar(100)')
IF #oname IN ('tblBananas','tblApples','tblOranges') and #uname = 'UserName'
BEGIN
DECLARE #err varchar(100)
SET #err = 'Table ' + #sname + '.' + #oname + ' is super duper protected and cannot be dropped.'
RAISERROR (#err, 16, 1) ;
ROLLBACK;
END
GO
ENABLE TRIGGER [TR_ProtectTables] ON DATABASE
GO
To avoid the user can INSERT/UPDATE/DELETE any data you can create a Role for that and add the user to that role.
CREATE ROLE [DenyWriteOnly]
EXEC sp_addrolemember N'db_datareader', N'DenyWriteOnly'
--explicitly DENY access to writing
EXEC sp_addrolemember N'DB_DenyDataWriter', N'DenyWriteOnly'
--now add the user to the role
EXEC sp_addrolemember N'DenyWriteOnly', N'MyDomain\YourUser'
I see you have edited your original question and you want to prevent the truncate table also. The easiest is to prevent a user from truncating any table is to enable Change Data Capture, but that feature is available for Azure Managed Instance.
If you don't have Azure Managed Instance then solution will be more elaborated since the minimum permission required for a TRUNCATE is ALTER. The other options are making tables to participate on transactional replication, making tables part of indexed views, or creating an empty table simply for the purpose of creating a dummy foreign key on each table that reference the empty table.

SQL Server. Elevate signed procedure permissions to read client IP from server state

Yet another request to capture client's IP from trigger... e.g. when client updates table. In our case the database is not subject to TRUTHWORTHY option. Client soft is old and huge and ugly and there is no way to send IP from all its db calls. I've tryed with no success some compilation of copy-pastes... as follows:
-- Create a test login and test database
CREATE LOGIN testuser WITH PASSWORD = 'CeRT=0=TeST'
CREATE DATABASE certtest
go
-- Move to the test database.
USE certtest
go
-- Create the test user.
CREATE USER testuser
go
CREATE PROCEDURE example_sp AS
SELECT client_net_address as client_ip FROM sys.dm_exec_connections WHERE session_id = ##SPID
go
GRANT EXECUTE ON example_sp TO public
go
-- Create the certificate.
CREATE CERTIFICATE examplecert
ENCRYPTION BY PASSWORD = 'All you need is love'
WITH SUBJECT = 'Certificate for example_sp',
START_DATE = '20020101', EXPIRY_DATE = '20200101'
go
-- transfer certificate to master
BACKUP CERTIFICATE [examplecert] TO FILE = 'C:\TEMP\examplecert.CER';
GO
USE [master]
GO
CREATE CERTIFICATE [examplecert2] FROM FILE = 'C:\TEMP\examplecert.CER';
--EXECUTE master.dbo.xp_delete_file 'del C:\TEMP\examplecert.CER';
-- create user able to read sys.dm_exec_connections
CREATE USER examplecertuser FROM CERTIFICATE [examplecert2]
GRANT VIEW SERVER STATE TO examplecertuser
GRANT AUTHENTICATE TO examplecertuser --?
go
USE certtest
--GRANT SELECT ON testtbl TO examplecertuser
--go
-- Sign the procedure.
ADD SIGNATURE TO example_sp BY CERTIFICATE examplecert
WITH PASSWORD = 'All you need is love'
go
-- Run as the test user, to actually see that this works.
EXECUTE AS USER = 'testuser'
go
-- run the signed procedure.
EXEC example_sp
go
-- Become ourselves again.
REVERT
go
-- Clean up
USE master
DROP DATABASE certtest
DROP LOGIN testuser
DROP USER examplecertuser
DROP CERTIFICATE examplecert2
Still no rights for testuser error.
Fix suggestions or working sample (if possible) would be appreciated )).
Thanks in advance!
This seems working ...
-- Create a test login and test database
CREATE DATABASE certtest
go
-- Move to the test database.
use certtest
go
CREATE LOGIN testuser WITH PASSWORD = 'CeRT=0=TeST'
CREATE USER testuser FROM LOGIN testuser -- Create the test user.
go
CREATE PROCEDURE example_sp
#ip VARCHAR(48) OUTPUT
AS
BEGIN
SELECT #ip = client_net_address FROM sys.dm_exec_connections WHERE session_id = ##SPID
END
go
GRANT EXECUTE ON example_sp TO public
go
-- Create the certificate.
go
CREATE CERTIFICATE examplecert
ENCRYPTION BY PASSWORD = 'All you need is love'
WITH SUBJECT = 'Certificate for example_sp',
START_DATE = '20020101', EXPIRY_DATE = '20200101'
go
-- transfer certificate to master
BACKUP CERTIFICATE [examplecert] TO FILE = 'C:\TEMP\exampleCert.CER';
GO
USE [master]
GO
CREATE CERTIFICATE [examplecert] FROM FILE = 'C:\TEMP\examplecert.CER'
IF EXISTS (SELECT * FROM sys.database_principals WHERE name = N'examplecertuserz')
BEGIN
DROP USER [examplecertuserz]
DROP LOGIN [examplecertuserz]
END
go
CREATE LOGIN [examplecertuserz]
FROM CERTIFICATE examplecert
GO
CREATE USER [examplecertuserz]
REVOKE CONNECT SQL FROM [examplecertuserz]
GO
GRANT AUTHENTICATE SERVER TO [examplecertuserz]
GO
GRANT VIEW SERVER STATE TO [examplecertuserz]
go
USE certtest
go
ADD SIGNATURE TO example_sp BY CERTIFICATE examplecert WITH PASSWORD = 'All you need is love'
go
EXECUTE AS LOGIN = 'testuser'
go
-- Then run the signed procedure.
declare #ip as varchar(48)
exec example_sp #ip OUTPUT
select #ip
go
-- Become ourselves again.
REVERT
go
-- Clean up
USE master
DROP DATABASE certtest
DROP LOGIN testuser
DROP LOGIN examplecertuserz
DROP USER examplecertuserz
DROP CERTIFICATE examplecert

What are the ramifications of granting a DB User with limited access Execute permission?

If I have a user that only has limited permissions - just db_datareader and db_datawriter, which should only permit the user to query data and insert/edit/delete data, without allowing the user to add/modify/delete tables in the database.
There may be a need for the user to be able to execute stored procedures. If the user is given execute permissions (via the following sql: "GRANT EXECUTE TO UserName"), will the previous limitations (datareader and datawriter) still be enforced on what the user tries to execute through stored procedures? Or do Execute privileges really open up a pandora's box of other security holes (and if so, what)?
If the owner of the stored procedure has the rights to select, insert, update or delete against a table then select, insert, update and delete statements inside the stored procedure will execute as long as the caller has execute rights on the stored procedure, even if the caller does not have rights to directly perform select, insert, update or delete against the table.
However a stored procedure can not perform DDL unless the caller has rights to perform DDL even if the owner of the stored procedure has DDL rights. Note this also applies to truncate table.
Answer: In your case granting db_datareader and db_datawriter to a user already gives the user full DML on all tables. Granting execute on any stored procedure will not give any additional rights.
Stored procedures can be used to increase data integrity by providing a gate through which all external programs must go. Do not grant insert, delete or update, but create SPs that do the work and enforce the appropriate rules about the data. (Above and beyond what can be done with constraints.) And as Joe Kuemerle points out, stored procedures can be used to increase security.
I have observed this behavior while developing an application on SQL Server 2000 and this even re-tested on SQL Server 2008 and found the same behavior. I have not been able to find documentation on this behavior.
Logged in as DBO and SA create a table:
create table dbo.SO (PK int identity constraint SO_PK primary key
, SomeData varchar(1000)
)
Then create some stored procedures for basic DML:
create procedure dbo.InsertSO (#SomeData varchar(1000)) as
begin
insert into dbo.SO (SomeData) values (#SomeData)
return SCOPE_IDENTITY()
end
go
create procedure dbo.SelectSO (#PK int=null) as
begin
if #PK is not null
select PK, SomeData from dbo.SO where PK = #PK
else
select PK, SomeData from dbo.SO
end
go
create procedure dbo.CountSO as
begin
select COUNT(*) as CountSO from SO
end
go
create procedure dbo.DeleteSO (#PK int=null ) as
begin
if #PK is not null
delete dbo.SO where PK = #PK
else
delete dbo.SO
end
go
create procedure dbo.UpdateSO (#PK int, #NewSomeData varchar(1000)) as
begin`
update dbo.SO
set SomeData = #NewSomeData
where PK = #PK
end
go
create procedure dbo.TruncateSO as
begin
truncate table dbo.SO
end
go
As dbo, we can run the following SQL statements:
declare #PK_to_update int
insert into dbo.SO (SomeData) values ('Hello world!')
set #PK_to_update = SCOPE_IDENTITY()
declare #PK_to_delete int
insert into dbo.SO (SomeData) values ('Goodbye cruel world!')
set #PK_to_delete = SCOPE_IDENTITY()
insert into dbo.SO (SomeData) values ('Four score and seven years ago...')
select PK, SomeData
from dbo.SO
delete dbo.so
where PK = #PK_to_delete
update dbo.SO
set SomeData = 'Hello Milky Way!'
where PK = #PK_to_update
select PK, SomeData
from dbo.SO
truncate table dbo.SO
select COUNT(*) as CountSO from dbo.SO
Or do the equivalent via the stored procedures
go
declare #PK_to_update int
exec #PK_to_update = dbo.InsertSO 'Hello world!'
declare #PK_to_delete int
exec #PK_to_delete = dbo.InsertSO 'Goodbye cruel world!'
exec dbo.InsertSO 'Four score and seven years ago...'
exec dbo.SelectSO
exec dbo.DeleteSO #PK_to_delete
exec dbo.UpdateSO #PK_to_update, 'Hello Milky Way!'
exec dbo.SelectSO
exec dbo.TruncateSO
exec dbo.CountSO
Now, create a DDL stored procedure and test:
create procedure dbo.DropSO as
begin
drop table dbo.SO
end
go
begin transaction
select TABLE_NAME from INFORMATION_SCHEMA.TABLES
where TABLE_NAME = 'SO'
exec dbo.DropSO
select TABLE_NAME from INFORMATION_SCHEMA.TABLES
where TABLE_NAME = 'SO'
rollback transaction
And now create another user and grant execute rights to all the stored procedure. Do not grant any other rights. (Assumes public does not have extra rights and mixed mode authentication. Mixed mode authentication is not recommended, but makes testing how rights are handled easier.)
exec sp_addlogin #loginame = 'SoLogin' , #passwd = 'notsecure', #defdb = 'Scratch'
exec sp_adduser #loginame = 'SoLogin', #name_in_db = 'SoUser'
go
grant execute on dbo.InsertSo to SoUser
grant execute on dbo.InsertSO to SoUser
grant execute on dbo.SelectSO to SoUser
grant execute on dbo.CountSO to SoUser
grant execute on dbo.DeleteSO to SoUser
grant execute on dbo.UpdateSO to SoUser
grant execute on dbo.TruncateSO to SoUser
grant execute on dbo.DropSO to SoUser
Login in as SoLogin. Try the DML:
declare #PK_to_update int
insert into dbo.SO (SomeData) values ('Hello world!')
set #PK_to_update = SCOPE_IDENTITY()
declare #PK_to_delete int
insert into dbo.SO (SomeData) values ('Goodbye cruel world!')
set #PK_to_delete = SCOPE_IDENTITY()
insert into dbo.SO (SomeData) values ('Four score and seven years ago...')
select PK, SomeData
from dbo.SO
delete dbo.so
where PK = #PK_to_delete
update dbo.SO
set SomeData = 'Hello Milky Way!'
where PK = #PK_to_update
select PK, SomeData
from dbo.SO
truncate table dbo.SO
go
select COUNT(*) as CountSO from dbo.SO
go
drop table dbo.so
Nothing but errors:
Msg 229, Level 14, State 5, Line 2
The INSERT permission was denied on the object 'SO', database 'Scratch', schema 'dbo'.
Msg 229, Level 14, State 5, Line 6
The INSERT permission was denied on the object 'SO', database 'Scratch', schema 'dbo'.
Msg 229, Level 14, State 5, Line 9
The INSERT permission was denied on the object 'SO', database 'Scratch', schema 'dbo'.
Msg 229, Level 14, State 5, Line 11
The SELECT permission was denied on the object 'SO', database 'Scratch', schema 'dbo'.
Msg 229, Level 14, State 5, Line 14
The SELECT permission was denied on the object 'SO', database 'Scratch', schema 'dbo'.
Msg 229, Level 14, State 5, Line 14
The DELETE permission was denied on the object 'SO', database 'Scratch', schema 'dbo'.
Msg 229, Level 14, State 5, Line 17
The SELECT permission was denied on the object 'SO', database 'Scratch', schema 'dbo'.
Msg 229, Level 14, State 5, Line 17
The UPDATE permission was denied on the object 'SO', database 'Scratch', schema 'dbo'.
Msg 229, Level 14, State 5, Line 21
The SELECT permission was denied on the object 'SO', database 'Scratch', schema 'dbo'.
Msg 1088, Level 16, State 7, Line 24
Cannot find the object "SO" because it does not exist or you do not have permissions.
Msg 229, Level 14, State 5, Line 1
The SELECT permission was denied on the object 'SO', database 'Scratch', schema 'dbo'.
Msg 3701, Level 14, State 20, Line 2
Cannot drop the table 'SO', because it does not exist or you do not have permission.
Try the basic DML stored procedures:
declare #PK_to_update int
exec #PK_to_update = dbo.InsertSO 'Hello world!'
declare #PK_to_delete int
exec #PK_to_delete = dbo.InsertSO 'Goodbye cruel world!'
exec dbo.InsertSO 'Four score and seven years ago...'
exec dbo.SelectSO
exec dbo.DeleteSO #PK_to_delete
exec dbo.UpdateSO #PK_to_update, 'Hello Milky Way!'
exec dbo.SelectSO
They work, because the owner of the SPs have the right rights, even though SoUser does not.
Try the truncate or drop stored procedure:
exec dbo.TruncateSO
go
exec dbo.DropSO
Errors again:
Msg 1088, Level 16, State 7, Procedure TruncateSO, Line 4
Cannot find the object "SO" because it does not exist or you do not have permissions.
Msg 3701, Level 14, State 20, Procedure DropSO, Line 4
Cannot drop the table 'SO', because it does not exist or you do not have permission.
Execute permissions do not open up any extra security holes. In my opinion a larger hole is the fact that users have direct read/write access to the tables.
Since SQL Server implements ownership chaining you can provide controllable, auditable access to data by revoking datareader/datawriter permissions and providing all data access through stored procedures where users only have execute permissions. This will ensure that someone cannot arbitrarily insert/update/delete from tables. It will also provide another layer in a defense in depth strategy as in the event that an application that uses the database is vulnerable to a SQL Injection attack the attacker cannot read from/write to any table they want to.
The only caveat with doing this is if you are using an ORM it may take some additional development effort to use sprocs rather than letting the ORM dynamically generate the SQL.
The concept you want is "ownership chaining"
Basically, permissions are not checked on objects in the same schema (say dbo) used by stored procedures. Except: deny is always checked.
So if stored proc dbo.uspDoStuff uses table dbo.Parent and dbo.Child, no permissions are needed on the tables and it just works. Unless you have run "DENY SELECT ON dbo.Parent to MyUser".
Note: You'd normally do "CREATE ROLE MyRole", add the user to the role, and grant permissions on the role. db_datareader is just a special, reserved role for example.
Granting execute permissions will allow that person to do anything which that stored procedure does in the context of that stored procedure (so if the sproc drops a table, the user will be able to execute the sproc to drop the table).
Edit, I just checked and I was wrong. Deny access does not revoke the ability to execute an action in a stored procedure.
Here is the article on MSDN which specifies that denying access does not affect a stored procedure.
http://msdn.microsoft.com/en-us/library/bb669058.aspx
UPDATE:
What you might be able to do is execute a drop table command through sp_executeSQL in the stored procedure and deny the user the ability drop tables. That should prevent the stored procedure from being able to successfully execute the command (unless the user has the permissions to do so), since to use sp_executesql the user needs the permissions to perform the sql action and not just access to the stored procedure.

Resources