Updated 2016-02-09: This also applies when granting database-level permissions, not just roles. Added an example at the end. Also fixed up the final search procedure for servers with case-sensitive collations.
Sometimes after provisioning a login and user it cannot access a database even though everything looks fine, and it's inevitably caused by a missing connect permission on that database. I encountered the issue this week and after having a read of a post by Derek Hammer I wanted to test it for myself also.
As a quick recap of the rules surrounding logins and users.
-
After you've created a login:
- You can immediate connect and use the master database.
- As there's no user for this login yet a guest user may be used instead. The guest user is enabled in master by default and it has the connect permission explicitly granted by default.
- If there was a connect permission granted to the public role in each database this would also allow access, but this isn't configured by default.
- You may then add the login to some server roles or grant some direct permissions.
-
After you've created a user:
- The user is granted the connect permission to the current database by default.
- You may then add the user to some database roles or grant direct permissions.
Here's what a normal commission process would look like.
Create Database TestDb
Go
Use TestDb
Go
-- Create the login. This gives you access to maser.
Create Login [Test_Account] With Password = 'AKLAN234jn1kj3241235!$#@'
-- Create the user. This gives you access to TestDb.
Create User [Test_Account]
-- Check the permissions in TestDb.
Select sp.name As login_name,
dp.name As user_name,
dpe.state_desc,
dpe.permission_name,
dpe.class_desc
From sys.server_principals sp
Join sys.database_principals dp
On sp.sid = dp.sid
Join sys.database_permissions dpe
On dp.principal_id = dpe.grantee_principal_id
Where dp.name = 'Test_Account'
-- Grant further permissions in TestDb.
Exec sys.sp_addrolemember 'db_owner', 'Test_Account'
And this is the permission which was granted automatically.
The above login would be able to log in with no problems.
So in attempting to reproduce a missing connect permission I tried mixing up the steps, such as dropping the user and adding the role member, or dropping the login then adding a role member and re-associating them, but I could not make it fail.
In desperation I inspected the definition for sys.sp_addrolemember and found a block of code which looks like it may be particularly relevant:
Select Object_Definition(Object_Id('sys.sp_addrolemember'))
/*
-- ATTEMPT ADDING IMPLICIT ROW FOR NT NAME --
if @@error <> 0 -- not found
begin
EXEC @ret = sys.sp_MSadduser_implicit_ntlogin @membername
*/
It seems that if you are adding a user to a role and there's a windows login but no user, the user gets created (possibly without a connect permission). So I created a test windows account [VM-WIN81\TestAccount] and ran my test again.
If Exists (Select Top 1 0 From sys.schemas s Where s.name = 'VM-WIN81\Test_Account')
Drop Schema [VM-WIN81\Test_Account]
If Exists (Select Top 1 0 From sys.server_principals sp Where sp.name = 'VM-WIN81\Test_Account')
Drop Login [VM-WIN81\Test_Account]
If Exists (Select Top 1 0 From sys.database_principals dp Where dp.name = 'VM-WIN81\Test_Account')
Drop User [VM-WIN81\Test_Account]
Create Login [VM-WIN81\Test_Account] From Windows
Exec sys.sp_addrolemember 'db_owner', 'VM-WIN81\Test_Account'
Select dp.name,
dp.type_desc
From sys.database_principals dp
Where dp.name = 'VM-WIN81\Test_Account'
Select sp.name As login_name,
dp.name As user_name,
dpe.state_desc,
dpe.permission_name,
dpe.class_desc
From sys.server_principals sp
Join sys.database_principals dp
On sp.sid = dp.sid
Join sys.database_permissions dpe
On dp.principal_id = dpe.grantee_principal_id
Where dp.name = 'VM-WIN81\Test_Account'
As you can see it created the user but did not grant a connect permission.
Weirdly this isn't just restricted to roles and that procedure. When you grant any direct permission in a database to a Windows account the same thing happens!
If Exists (Select Top 1 0 From sys.schemas s Where s.name = 'VM-WIN81\Test_Account')
Drop Schema [VM-WIN81\Test_Account]
If Exists (Select Top 1 0 From sys.server_principals sp Where sp.name = 'VM-WIN81\Test_Account')
Drop Login [VM-WIN81\Test_Account]
If Exists (Select Top 1 0 From sys.database_principals dp Where dp.name = 'VM-WIN81\Test_Account')
Drop User [VM-WIN81\Test_Account]
Create Login [VM-WIN81\Test_Account] From Windows
Grant Select On sys.tables To [VM-WIN81\Test_Account]
Select dp.name,
dp.type_desc
From sys.database_principals dp
Where dp.name = 'VM-WIN81\Test_Account'
Select sp.name As login_name,
dp.name As user_name,
dpe.state_desc,
dpe.permission_name,
dpe.class_desc
From sys.server_principals sp
Join sys.database_principals dp
On sp.sid = dp.sid
Join sys.database_permissions dpe
On dp.principal_id = dpe.grantee_principal_id
Where dp.name = 'VM-WIN81\Test_Account'
Of course it's easily granted once you've found that this is the cause of your issue.
Grant Connect On Database::TestDb To Test_Account
Researching this entire process was a bit of a wild goose chase that I would not want to go through again, so I created a script to identify accounts like this which I can add to a policy and run on all of my servers each day as a health check. Should an account accidentally end up in this state again I'll be able to do something about it quickly.
Set Nocount On
Declare @v_DatabaseName Sysname
Declare @v_Sql Nvarchar(4000)
Declare @v_Results Table (
DatabaseName Sysname,
LoginName Sysname,
UserName Sysname
)
Declare CTE_Databases Cursor Forward_Only Local Read_Only Static For
Select name
From sys.databases d
Where d.state_desc = 'ONLINE'
Order By 1
Open CTE_Databases
Fetch Next From CTE_Databases Into @v_DatabaseName
While @@Fetch_Status = 0
Begin
Set @v_Sql = 'Use ' + Quotename(@v_DatabaseName) + ';
Select Db_Name(),
sp.name,
dp.name
From sys.server_principals sp
Join sys.database_principals dp
On sp.sid = dp.sid
Where Not Exists (
Select Top 1 0
From sys.database_permissions dpe
Where dp.principal_id = dpe.grantee_principal_id
And dpe.state_desc = ''GRANT''
And dpe.permission_name = ''CONNECT''
And dpe.class_desc = ''DATABASE''
);
'
Insert @v_Results
Exec sp_executesql @v_Sql
Fetch Next From CTE_Databases Into @v_DatabaseName
End
Close CTE_Databases
Deallocate CTE_Databases
If Exists (Select Top 1 0 From @v_Results)
Begin
Select *
From @v_Results
Order By 1, 2
End
Here's the output:
I should note that Derek says this is part of Microsoft security; but personally I think it's more of a bug and not one that will ever be fixed. It would have been nice if sys.sp_addrolemember printed something to let you know it had created the user for you though.