diff --git a/changelogs/changelog.yaml b/changelogs/changelog.yaml index d427baa0..3c9391a1 100644 --- a/changelogs/changelog.yaml +++ b/changelogs/changelog.yaml @@ -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. diff --git a/galaxy.yml b/galaxy.yml index 8ef227ce..106179bd 100644 --- a/galaxy.yml +++ b/galaxy.yml @@ -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) diff --git a/plugins/modules/user_role.ps1 b/plugins/modules/user_role.ps1 index 8a9f7fe4..b3f8cfbf 100644 --- a/plugins/modules/user_role.ps1 +++ b/plugins/modules/user_role.ps1 @@ -13,11 +13,38 @@ $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)) @@ -25,90 +52,161 @@ $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.") } } -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 + } +} + +# 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 +} +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 { if ($null -ne $output) { $resultData = ConvertTo-SerializableObject -InputObject $output diff --git a/plugins/modules/user_role.py b/plugins/modules/user_role.py index f120ba74..1ac34b17 100644 --- a/plugins/modules/user_role.py +++ b/plugins/modules/user_role.py @@ -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 @@ -45,8 +76,18 @@ 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 @@ -54,17 +95,40 @@ 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. returned: success, but not in check_mode. type: dict ''' diff --git a/tests/integration/targets/user_role/tasks/main.yml b/tests/integration/targets/user_role/tasks/main.yml index ec5936b7..a944dcb3 100644 --- a/tests/integration/targets/user_role/tasks/main.yml +++ b/tests/integration/targets/user_role/tasks/main.yml @@ -41,7 +41,6 @@ sql_password: "{{ sqlserver_password }}" database: "{{ database }}" username: "{{ username }}" - role: "{{ role }}" state: present tags: ["sqlserver.user"] block: @@ -68,14 +67,20 @@ - name: Add user to database role lowlydba.sqlserver.user_role: + roles: + add: + - db_owner register: result - assert: that: - result is changed + - result.data.roleMembership == ["db_owner"] - name: Add user to non-existent database role lowlydba.sqlserver.user_role: - role: db_IMadeThisOneUp + roles: + add: + - db_IMadeThisOneUp register: error_result failed_when: error_result.failed ignore_errors: true @@ -87,6 +92,9 @@ - name: Add non-existent user to database role lowlydba.sqlserver.user_role: username: NewUserWhoThis + roles: + add: + - db_owner register: error_result failed_when: error_result.failed ignore_errors: true @@ -97,19 +105,129 @@ - name: Add user again to database role lowlydba.sqlserver.user_role: + roles: + add: + - db_owner register: result - assert: that: - result is not changed - - name: Remove user from database role + - name: Add user to list of database roles + lowlydba.sqlserver.user_role: + roles: + add: + - db_datareader + - db_datawriter + register: result + - assert: + that: + - result is changed + - result.data.roleMembership == ["db_datareader", "db_datawriter", "db_owner"] + + - name: Add user to list of database roles again + lowlydba.sqlserver.user_role: + roles: + add: + - db_datareader + - db_datawriter + register: result + - assert: + that: + - result is not changed + + - name: Add user to list of database roles using set, to remove unlisted roles + lowlydba.sqlserver.user_role: + roles: + set: + - db_datareader + - db_owner + register: result + - assert: + that: + - result is changed + - result.data.roleMembership == ["db_datareader", "db_owner"] + + - name: Add user to list of database roles using set again + lowlydba.sqlserver.user_role: + roles: + set: + - db_datareader + - db_owner + register: result + - assert: + that: + - result is not changed + + - name: Remove user from all roles using set with blank array + lowlydba.sqlserver.user_role: + roles: + set: [] + register: result + - assert: + that: + - result is changed + - result.data.roleMembership == [] + + - name: Remove user from all roles using set with blank array again + lowlydba.sqlserver.user_role: + roles: + set: [] + register: result + - assert: + that: + - result is not changed + + - name: Add user to database role (old method) + lowlydba.sqlserver.user_role: + role: "db_datareader" + register: result + - assert: + that: + - result is changed + + - name: Add user to database role (old method) again + lowlydba.sqlserver.user_role: + role: "db_datareader" + register: result + - assert: + that: + - result is not changed + + - name: Get user rolelist to check if old method task worked (old method doesn't return membershipList) + lowlydba.sqlserver.user_role: + register: result + - assert: + that: + - result is not changed + - result.data.roleMembership == ["db_datareader"] + + - name: Remove user from database role (old method) lowlydba.sqlserver.user_role: state: "absent" + role: "db_datareader" register: result - assert: that: - result is changed + - name: Remove user from database role (old method) again + lowlydba.sqlserver.user_role: + state: "absent" + role: "db_datareader" + register: result + - assert: + that: + - result is not changed + + - name: Get user rolelist to check if old method task worked (old method doesn't return membershipList) + lowlydba.sqlserver.user_role: + register: result + - assert: + that: + - result is not changed + - result.data.roleMembership == [] + always: - name: Drop user lowlydba.sqlserver.user: