Showing Comma-Separated Role Members for Every Database Role

Comma-separated role members give you one readable line per role. Preserve empty roles and identify implicit public membership. Keep the underlying pairs for a separate effective-access review.

Cream and slate twine balls occupy two wicker baskets, a third stays empty, and a shared tray holds both colors

Start with direct membership pairs

Run the report in the database you are reviewing. sys.database_role_members connects role identifiers to member identifiers. Join sys.database_principals twice to obtain both names.

SELECT r.name AS RoleName, m.name AS MemberName, m.type_desc
FROM sys.database_role_members AS rm
JOIN sys.database_principals AS r
  ON r.principal_id = rm.role_principal_id
JOIN sys.database_principals AS m
  ON m.principal_id = rm.member_principal_id
ORDER BY r.name, m.name;

A member can be a user, a group or another role. Metadata visibility also limits the result. Record the instance, database, collecting account and collection time with the export.

Order the names inside each aggregate

STRING_AGG requires SQL Server 2017 or later. Its WITHIN GROUP ordering requires database compatibility level 110 or later. An outer ORDER BY controls the role rows, not names inside each list.

SELECT r.name AS RoleName,
       STRING_AGG(CONVERT(nvarchar(max),m.name),N', ')
       WITHIN GROUP (ORDER BY m.name) AS Members
FROM sys.database_role_members AS rm
JOIN sys.database_principals AS r
  ON r.principal_id = rm.role_principal_id
JOIN sys.database_principals AS m
  ON m.principal_id = rm.member_principal_id
GROUP BY r.name
ORDER BY r.name;

The max conversion avoids the aggregate’s bounded string result. Keep the pair-based report too: a principal name can itself contain commas. Never parse the displayed list back into executable identifiers.

Retain empty roles and label public correctly

Start from role principals and use LEFT JOIN. STRING_AGG ignores NULL inputs, so COALESCE supplies the display value for empty direct membership. Public is different because every database user belongs implicitly.

SELECT r.name AS RoleName,
       CASE WHEN r.name = N'public'
            THEN N'(implicit: all database users)'
            ELSE COALESCE(STRING_AGG(CONVERT(nvarchar(max),m.name),N', ')
              WITHIN GROUP (ORDER BY m.name),N'(none)') END AS Members
FROM sys.database_principals AS r
LEFT JOIN sys.database_role_members AS rm
  ON rm.role_principal_id = r.principal_id
LEFT JOIN sys.database_principals AS m
  ON m.principal_id = rm.member_principal_id
WHERE r.type = 'R'
GROUP BY r.name
ORDER BY r.name;
Actual SSMS role report with dbo in db_owner, empty other direct memberships and implicit public membership
Actual report from the master database on the test instance. dbo appears in db_owner; the other listed direct memberships are empty. Public membership is implicit. This is one database result, not a complete effective-access inventory. Open the image for a larger view.

Do not filter member names in WHERE when preserving empty roles. That can discard the NULL-extended rows. An empty role still needs a purpose review before removal.

Direct role membership, from catalog pairs to an ordered list. Role principal: sys.database_principals, type R. Explicit membership: sys.database_role_members, role and member ID pairs. Member principal: sys.database_principals, user, group or another role. Ordered display: STRING_AGG with WITHIN GROUP (ORDER BY m.name). Start from all roles with LEFT JOIN. No direct assignments: (none). public: implicit membership. Direct assignments do not expand nested roles or directory groups.

The diagram describes explicit membership. Public’s membership remains implicit, as the query labels. An empty-looking catalog bridge must not turn public into a role with no users.

Keep the instance-level report separate

Server roles use sys.server_principals and sys.server_role_members. Every login belongs implicitly to the server public role. The database and instance reports answer different membership questions.

SELECT r.name AS ServerRole,
       CASE WHEN r.name = N'public'
            THEN N'(implicit: all logins)'
            ELSE COALESCE(STRING_AGG(CONVERT(nvarchar(max),m.name),N', ')
              WITHIN GROUP (ORDER BY m.name),N'(none)') END AS Members
FROM sys.server_principals AS r
LEFT JOIN sys.server_role_members AS rm
  ON rm.role_principal_id = r.principal_id
LEFT JOIN sys.server_principals AS m
  ON m.principal_id = rm.member_principal_id
WHERE r.type = 'R'
GROUP BY r.name
ORDER BY r.name;

Know what the report leaves unresolved

These queries report direct assignments. They do not expand nested roles or directory groups. A single group principal can represent many people outside the database catalog.

An unchanged membership list also does not prove unchanged permissions. Someone can change a role’s grants while leaving its members alone. Check the relevant effective permission in the intended user context when that is the review question.

I would keep the formatted report for scanning, but reject it as an executable security script. Use principal identifiers for further processing. Any generated statement needs deliberately quoted identifiers and a separate change review.

I would run this on a test database first and see who turns up in which role.

A readable member list is not an access review, it is the starting evidence for one.

Published by Pinal Dave on SQLAuthority. More of my work at pinaldave.com.


Discover more from SQL Authority with Pinal Dave

Subscribe to get the latest posts sent to your email.

SQL Function, SQL Scripts, SQL Server, SQL Server Security
Previous Post
Splitting Text on Several Delimiters With REGEXP_SPLIT_TO_TABLE
Next Post
TRANSLATE: Map Characters Simultaneously Instead of Cascading Replacements

Related Posts

Leave a Reply

Your email address will not be published. Required fields are marked *

Fill out this field
Fill out this field
Please enter a valid email address.