Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
3fd41b9
added ability for user_role to take a list for roles
harCamConsulting Aug 5, 2025
8386b2a
updated changelog
harCamConsulting Aug 5, 2025
40e634e
updated documentation for new option
harCamConsulting Aug 5, 2025
4c41f7b
change output of module so that unit testing can test on more things …
harCamConsulting Aug 6, 2025
c8f0821
typo in unit tests
harCamConsulting Aug 6, 2025
d9ffdad
Merge branch 'main' into add-list-to-dbroles
lowlydba Aug 16, 2025
b3693bd
Merge branch 'main' into add-list-to-dbroles
lowlydba Aug 17, 2025
62db4fa
refactor to implement add/remove/set functionality
harCamConsulting Aug 26, 2025
3028fe7
Merge branch 'add-list-to-dbroles' of github.com:harCamConsulting/low…
harCamConsulting Aug 26, 2025
3a47ebc
Merge branch 'lowlydba:main' into add-list-to-dbroles
harCamConsulting Aug 26, 2025
97b68c7
removing random symlink
harCamConsulting Aug 26, 2025
d66a924
remove unused param, plus linting
harCamConsulting Aug 26, 2025
5c2ab58
fix typo in docs
harCamConsulting Aug 26, 2025
90783b1
fix typo in tests
harCamConsulting Aug 26, 2025
ceb87e6
updating for more linting / style
harCamConsulting Aug 26, 2025
fca53e7
roleMembership typo
harCamConsulting Aug 26, 2025
dd8946d
typos
harCamConsulting Aug 26, 2025
832774c
defaults in doc to match code
harCamConsulting Aug 26, 2025
d85b0a4
change to other type of foreach
harCamConsulting Aug 26, 2025
16ae9fa
alter results so it returns empty arrays instead of null
harCamConsulting Aug 26, 2025
88f5578
Merge branch 'main' into add-list-to-dbroles
lowlydba Sep 5, 2025
e33a412
use proper array casts
harCamConsulting Sep 9, 2025
4b0dea4
Merge branch 'add-list-to-dbroles' of github.com:harCamConsulting/low…
harCamConsulting Sep 9, 2025
0fcedae
catch null and convert to empty array on output rolemembership
harCamConsulting Sep 9, 2025
504b80e
Merge branch 'main' into add-list-to-dbroles
lowlydba Jan 8, 2026
0fc0192
Apply suggestions from code review
lowlydba Jan 8, 2026
d9f3485
Apply suggestions from code review
lowlydba Jan 8, 2026
8eee960
Apply suggestions from code review
lowlydba Jan 8, 2026
720455d
Apply suggestions from code review
lowlydba Jan 8, 2026
29bccba
Apply suggestions from code review
lowlydba Jan 8, 2026
28a3a2c
Apply suggestions from code review
lowlydba Jan 8, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions changelogs/changelog.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -583,3 +583,9 @@ releases:
fragments:
- 329-add-agent-outputfile.yml
release_date: '2025-08-16'
2.8.0:
changes:
minor_changes:
- Added ability for user_role module to take a list of roles. Optionally can also remove unlisted roles.
release_summary: Added support for managing multiple roles per user with the
user_role module.
2 changes: 1 addition & 1 deletion galaxy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

namespace: lowlydba
name: sqlserver
version: 2.7.0
version: 2.8.0
readme: README.md
authors:
- John McCall (github.com/lowlydba)
Expand Down
230 changes: 164 additions & 66 deletions plugins/modules/user_role.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -13,102 +13,200 @@ $ErrorActionPreference = "Stop"
$spec = @{
supports_check_mode = $true
options = @{
database = @{type = 'str'; required = $true }
username = @{type = 'str'; required = $true }
role = @{type = 'str'; required = $true }
state = @{type = 'str'; required = $false; default = 'present'; choices = @('present', 'absent') }
database = @{ type = 'str'; required = $true }
username = @{ type = 'str'; required = $true }
state = @{ type = 'str'; required = $false; default = 'present'; choices = @('present', 'absent') }
role = @{ type = 'str'; required = $false }
roles = @{
default = @{}
type = 'dict'
options = @{
add = @{
default = @()
type = 'list'
elements = 'str'
}
remove = @{
default = @()
type = 'list'
elements = 'str'
}
set = @{
# Intentionally use $null (not @()) so later checks (e.g. $null -ne $roles["set"])
# can distinguish "not provided" (null) from "provided as empty array" ([]),
# allowing roles.set: [] to remove all roles.
default = $null
type = 'list'
elements = 'str'
}
}
}
}
mutually_exclusive = @(
, @("role", "roles")
)
}

$module = [Ansible.Basic.AnsibleModule]::Create($args, $spec, @(Get-LowlyDbaSqlServerAuthSpec))
$sqlInstance, $sqlCredential = Get-SqlCredential -Module $module
$username = $module.Params.username
$database = $module.Params.database
$role = $module.Params.role
$roles = $module.Params.roles
$state = $module.Params.state
$checkMode = $module.CheckMode
$compatibilityMode = $false

$module.Result.changed = $false

$getUserSplat = @{
SqlInstance = $sqlInstance
SqlCredential = $sqlCredential
Database = $database
User = $username
EnableException = $true
# Map the "old" style way of using state and role on to the add/remove/set methods
if ($role -and $state -eq 'present') {
$roles.add = @(, $role)
$compatibilityMode = $true
}
$getRoleSplat = @{
SqlInstance = $sqlInstance
SqlCredential = $sqlCredential
Database = $database
Role = $role
EnableException = $true
if ($role -and $state -eq 'absent') {
$roles.remove = @(, $role)
$compatibilityMode = $true
}
$getRoleMemberSplat = @{

$commonParamSplat = @{
SqlInstance = $sqlInstance
SqlCredential = $sqlCredential
Database = $database
Role = $role
IncludeSystemUser = $true
EnableException = $true
}

# Verify user and role exist, DBATools currently fails silently
$existingUser = Get-DbaDbUser @getUserSplat
$outputProps = @{}

# Verify user and role(s) exist, DBATools currently fails silently
$existingUser = Get-DbaDbUser @commonParamSplat -user $username
if ($null -eq $existingUser) {
$module.FailJson("User [$username] does not exist in database [$database].")
}
$existingRole = Get-DbaDbRole @getRoleSplat
if ($null -eq $existingRole) {
$module.FailJson("Role [$role] does not exist in database [$database].")
}

# Get role members
$existingRoleMembers = Get-DbaDbRoleMember @getRoleMemberSplat

if ($state -eq "absent") {
if ($existingRoleMembers.username -contains $username) {
try {
$removeRoleMemberSplat = @{
SqlInstance = $sqlInstance
SqlCredential = $sqlCredential
User = $username
Database = $database
Role = $role
EnableException = $true
WhatIf = $checkMode
Confirm = $false
}
$output = Remove-DbaDbRoleMember @removeRoleMemberSplat
$module.Result.changed = $true
}
catch {
$module.FailJson("Removing user [$username] from database role [$role] failed: $($_.Exception.Message)", $_)
}

# Ensure that when using the 'roles' parameter directly, at least one operation is specified.
if (-not $compatibilityMode) {
$hasSet = ($null -ne $roles['set'] -and @($roles['set']).Count -gt 0)
$hasAdd = ($null -ne $roles['add'] -and @($roles['add']).Count -gt 0)
$hasRemove = ($null -ne $roles['remove'] -and @($roles['remove']).Count -gt 0)

if (-not ($hasSet -or $hasAdd -or $hasRemove)) {
$module.FailJson("When using the 'roles' parameter, you must specify at least one of: roles.set, roles.add, or roles.remove.")
}
Comment on lines +87 to 95
Copy link

Copilot AI Jan 8, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The validation logic has multiple issues:

  1. Line 89 checks @($roles['set']).Count -gt 0, which prevents using roles.set: [] to remove all roles (a documented feature in the tests and documentation).

  2. The validation doesn't account for "query mode" where neither 'role' nor 'roles' operations are specified (as tested at lines 198 and 224 of the integration tests). In this case, all three $hasSet, $hasAdd, and $hasRemove would be false, causing the module to fail incorrectly.

The fix should:

  • Change line 89 to $hasSet = $null -ne $roles['set'] (without the Count check) to allow empty arrays
  • Add logic to detect and allow query mode, where the module simply returns current role membership without modification

Copilot uses AI. Check for mistakes.
}
Comment on lines 87 to 96
Copy link

Copilot AI Jan 8, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The validation check at lines 84-93 will execute even when neither role nor roles parameters are provided (the "get current role membership" mode documented in the RETURN section). When a user wants to simply query the current role membership without providing either parameter, this check will incorrectly fail. The check should only apply when the roles parameter is explicitly provided by the user.

Copilot uses AI. Check for mistakes.
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@harCamConsulting Can you add a check in line 88 to ensure $roles is defined in addition to the compat mode check?

elseif ($state -eq "present") {
# Add user to role
if ($existingRoleMembers.username -notcontains $username) {
try {
$addRoleMemberSplat = @{
SqlInstance = $sqlInstance
SqlCredential = $sqlCredential
User = $username
Database = $database
Role = $role
EnableException = $true
WhatIf = $checkMode
Confirm = $false
}
$output = Add-DbaDbRoleMember @addRoleMemberSplat
$module.Result.changed = $true

$rolesSet = if ($null -ne $roles['set']) { @($roles['set']) } else { @() }
$rolesAdd = if ($null -ne $roles['add']) { @($roles['add']) } else { @() }
$rolesRemove = if ($null -ne $roles['remove']) { @($roles['remove']) } else { @() }

$combinedRoles = ($rolesSet + $rolesAdd + $rolesRemove) | Select-Object -Unique
$combinedRoles | ForEach-Object {
$thisRole = $_
$existingRole = Get-DbaDbRole @commonParamSplat -role $thisRole
if ($null -eq $existingRole) {
$module.FailJson("Role [$thisRole] does not exist in database [$database].")
}
}

# Sanity check on the add/remove clause not having the same role.
$sameRoles = ( Compare-Object $roles['add'] $roles['remove'] -IncludeEqual | Where-Object { $_.SideIndicator -eq '==' } ).InputObject
if ($sameRoles.count -ge 1) {
$module.FailJson("Role [$($sameRoles -join ', ')] exists in both the add and remove lists.")
}

# Get current role membership of all roles for the user to compare against
$membershipObjects = Get-DbaDbRoleMember @commonParamSplat -IncludeSystemUser $true | Where-Object { $_.UserName -eq $username }
$existingRoleMembership = [array]($membershipObjects.role | Sort-Object)

if ($null -eq $existingRoleMembership) { $existingRoleMembership = @() }

if ($null -ne $roles['set']) {
$comparison = Compare-Object $existingRoleMembership ([array]$roles['set'])
if ($null -eq $comparison) {
$rolesToAdd = @()
$rolesToRemove = @()
}
else {
$rolesToAdd = ( $comparison | Where-Object { $_.SideIndicator -eq '=>' } ).InputObject
$rolesToRemove = ( $comparison | Where-Object { $_.SideIndicator -eq '<=' } ).InputObject
}
}
else {
$comparisonAdd = Compare-Object $existingRoleMembership ([array]$roles['add'])
if ($null -eq $comparisonAdd) {
$rolesToAdd = @()
}
else {
$rolesToAdd = ( $comparisonAdd | Where-Object { $_.SideIndicator -eq '=>' } ).InputObject
}

$comparisonRemove = Compare-Object $existingRoleMembership ([array]$roles['remove']) -IncludeEqual
if ($null -eq $comparisonRemove) {
$rolesToRemove = @()
}
else {
$rolesToRemove = ( $comparisonRemove | Where-Object { $_.SideIndicator -eq '==' } ).InputObject
}
}
Comment on lines +123 to +150
Copy link

Copilot AI Jan 8, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The module allows specifying roles.set simultaneously with roles.add or roles.remove, but the logic at line 123 gives precedence to roles.set, effectively ignoring any add or remove specifications. This could lead to confusing behavior where users expect add/remove to be applied in addition to set.

Consider either:

  1. Making set mutually exclusive with add/remove in the parameter spec
  2. Documenting that set takes precedence and add/remove are ignored when set is provided
  3. Applying add/remove operations after the set operation

Copilot uses AI. Check for mistakes.
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@harCamConsulting This makes sense to me, any of the options given seem good


# Add user to new roles
foreach ($thisRole in $rolesToAdd) {
try {
$addRoleMemberSplat = @{
User = $username
Role = $thisRole
WhatIf = $checkMode
Confirm = $false
}
catch {
$module.FailJson("Adding user [$username] to database role [$role] failed: $($_.Exception.Message)", $_)
$commandResult = Add-DbaDbRoleMember @commonParamSplat @addRoleMemberSplat
$module.Result.changed = $true
}
catch {
$module.FailJson("Adding user [$username] to database role [$thisRole] failed: $($_.Exception.Message)", $_)
}
}

# remove user from unneeded roles
foreach ($thisRole in $rolesToRemove) {
try {
$removeRoleMemberSplat = @{
User = $username
Role = $thisRole
WhatIf = $checkMode
Confirm = $false
}
$commandResult = Remove-DbaDbRoleMember @commonParamSplat @removeRoleMemberSplat
$module.Result.changed = $true
}
catch {
$module.FailJson("Removing user [$username] from database role [$thisRole] failed: $($_.Exception.Message)", $_)
}
}

# if we're still using old mode (using $state and $role) save command result as results,
# otherwise send back full list of old and new roles.
if ($compatibilityMode) {
$output = $commandResult
Comment on lines +188 to +189
Copy link

Copilot AI Jan 8, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In compatibility mode, the variable $commandResult may never be set if both $rolesToAdd and $rolesToRemove are empty (which happens when the user is already in the correct role). This will cause $output to be undefined at line 155. The code should handle the case where no changes are needed and $commandResult is never assigned.

Copilot uses AI. Check for mistakes.
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@harCamConsulting I think this makes sense, can you add some handling?

}
else {
try {
# after changing any roles above, see what our new membership is and report it back
$membershipObjects = Get-DbaDbRoleMember @commonParamSplat -IncludeSystemUser $true | Where-Object { $_.UserName -eq $username }
$newRoleMembership = [array]($membershipObjects.role | Sort-Object)
if ($null -eq $newRoleMembership) { $newRoleMembership = @() }
}
catch {
$module.FailJson("Failure getting new role membership: $($_.Exception.Message)", $_)
}
$outputProps.roleMembership = $newRoleMembership
if ($module.Result.changed) {
$outputProps.diff = @{}
$outputProps.diff.after = $newRoleMembership
$outputProps.diff.before = $existingRoleMembership
}
$output = New-Object -TypeName PSCustomObject -Property $outputProps
}

try {
Copy link

Copilot AI Jan 8, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In non-compatibility mode when no changes occur, the code creates an output with just 'roleMembership' but no 'diff' (lines 198-204). However, when changes do occur, it includes both 'roleMembership' and 'diff'. This is good idempotent behavior. However, in the query-only mode (no role or roles specified), the code should also return the current role membership. The current logic doesn't handle the query-only case where no roles are specified to add/remove/set - it would skip to line 207 with $output potentially undefined if not in compatibility mode.

Suggested change
try {
try {
# In non-compatibility (roles dict) mode, if no output was created earlier (e.g. query-only),
# return the current role membership without a diff.
if (-not $compatibilityMode -and $null -eq $output) {
try {
$membershipObjects = Get-DbaDbRoleMember @commonParamSplat -IncludeSystemUser $true | Where-Object { $_.UserName -eq $username }
$currentRoleMembership = [array]($membershipObjects.role | Sort-Object)
if ($null -eq $currentRoleMembership) { $currentRoleMembership = @() }
}
catch {
$module.FailJson("Failure getting current role membership: $($_.Exception.Message)", $_)
}
if ($null -eq $outputProps) { $outputProps = @{} }
$outputProps.roleMembership = $currentRoleMembership
$output = New-Object -TypeName PSCustomObject -Property $outputProps
}

Copilot uses AI. Check for mistakes.
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@harCamConsulting I think this comment makes sense, can you add this query-mode logic in?

if ($null -ne $output) {
$resultData = ConvertTo-SerializableObject -InputObject $output
Expand Down
74 changes: 69 additions & 5 deletions plugins/modules/user_role.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,8 +25,39 @@
role:
description:
- The database role for the user to be modified.
- When used with State set to present, will add the user to this role.
- When used with State set to absent, will remove the user from this role.
- Mutually exclusive with roles
type: str
required: true
roles:
description:
- The database roles for the user to be added, removed or set.
- Mutually exclusive with role
type: dict
default: {}
suboptions:
add:
description:
- Adds the user to the specified roles, keeping the
existing role membership if they are not specified.
type: list
elements: str
default: []
remove:
description:
- Removes the user from the specified roles, keeping the
existing role membership if they are not specified.
type: list
elements: str
default: []
set:
description:
- Adds the user to the specified roles.
- User will be removed from any other roles not specified.
- Set this to an empty list to remove all role memberships from the user.
type: list
elements: str
version_added: 2.8.0
author: "John McCall (@lowlydba)"
requirements:
- L(dbatools,https://www.powershellgallery.com/packages/dbatools/) PowerShell module
Expand All @@ -45,26 +76,59 @@
database: InternProject1
role: db_owner

- name: Add a user to a list of db roles
lowlydba.sqlserver.user_role:
sql_instance: sql-01.myco.io
username: TheIntern
database: InternProject1
roles:
add:
- db_datareader
- db_datawriter

- name: Remove a user from a fixed db role
lowlydba.sqlserver.login:
lowlydba.sqlserver.user_role:
sql_instance: sql-01.myco.io
username: TheIntern
database: InternProject1
role: db_owner
state: absent

- name: Add a user to a custom db role
lowlydba.sqlserver.login:
lowlydba.sqlserver.user_role:
sql_instance: sql-01.myco.io
username: TheIntern
database: InternProject1
role: db_intern
state: absent
state: present

- name: Specify a list of roles that user should be in and remove all others
lowlydba.sqlserver.user_role:
sql_instance: sql-01.myco.io
username: TheIntern
database: InternProject1
roles:
set:
- db_datareader
- db_datawriter
state: present

- name: Remove user from all roles on this database
lowlydba.sqlserver.user_role:
sql_instance: sql-01.myco.io
username: TheIntern
database: InternProject1
roles:
set: []
state: present
'''

RETURN = r'''
data:
description: Output from the C(Remove-DbaDbRoleMember), (Get-DbaDbRoleMember), or C(Add-DbaDbRoleMember) functions.
description:
- If called with role, then data is output from the C(Remove-DbaDbRoleMember), (Get-DbaDbRoleMember), or C(Add-DbaDbRoleMember) functions.
- If called with roles, then data returned is roleMembership, which is an array of roles that the user is now a member of.
- If called without either role or roles, then the data returned is roleMembership which is the user's current list of roles.
Copy link

Copilot AI Jan 8, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The documentation states that when called without either role or roles, the data returned is roleMembership with the user's current list of roles. However, the code at lines 88-95 will fail with an error message when neither parameter is provided (in non-compatibility mode). This is inconsistent with the documented behavior shown in the test cases at lines 198 and 224.

The documentation should either be updated to remove this statement, or the code should be fixed to support query-only mode.

Suggested change
- If called without either role or roles, then the data returned is roleMembership which is the user's current list of roles.

Copilot uses AI. Check for mistakes.
returned: success, but not in check_mode.
type: dict
'''
Loading
Loading